glazed-aws 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +3 -1
- data/exe/glazed-aws +8 -0
- data/lib/glazed/aws/command/state_machine.rb +57 -0
- data/lib/glazed/aws/command.rb +33 -0
- data/lib/glazed/aws/lambda.rb +196 -0
- data/lib/glazed/aws/signal_events.rb +106 -0
- data/lib/glazed/aws/state_machine.rb +261 -0
- data/lib/glazed/aws/version.rb +1 -1
- data/lib/glazed/secrets/ssm.rb +97 -0
- data/lib/glazed/storages/s3.rb +3 -2
- metadata +39 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: da36b22c067ea1b077af0937adac343cfe582c5e096d59ab92ccc24d9137c8f3
|
|
4
|
+
data.tar.gz: de668481d4de70c3f1757d0c510a6c41d0917c70bbd4fb94a8c6652c9c418755
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4505bc0c45940d21ead467725e6f8ed54c9fd1790b3a00f41d20c25b22d275eb583f1b95e172ba5e0cae5e95bbd8e3106c1077b7aa048715402e565d1b0078cb
|
|
7
|
+
data.tar.gz: ce0a8e0c3b9f29919dc9689fae1629fad2252a4c16a36e1870d8b1ae8616a3b203162fd62d6d39446262d2043624797dbfe8847207d4a0a62551a9777f52e380
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
## [0.2.0] - 2026-10-15
|
|
2
|
+
|
|
3
|
+
### Enhancements
|
|
4
|
+
|
|
5
|
+
- The Lambda function returns the state machine definition for the event `{"state_machine": {}}`, so Terraform can generate it with `aws_lambda_invocation`
|
|
6
|
+
- `ssm` secret source: `{"ssm": {"name": ...}}` reads an AWS Systems Manager Parameter Store parameter, decrypted, and reuses it for `cache_seconds`
|
|
7
|
+
- The Lambda function reads its config from S3 with `GLAZED_CONFIG_S3_BUCKET`, `GLAZED_CONFIG_S3_KEY` and `GLAZED_CONFIG_S3_DIGEST`, checking the object's SHA-256
|
|
8
|
+
- Run the orchestrated workflows on AWS Step Functions: a Lambda function performing each task (`Glazed::Aws::Lambda`), and `glazed-aws state-machine` printing the state machine definition. Signals come from EventBridge rules, Pipes and Scheduler, and debounce waits in the state machine ([docs/aws-step-functions.md](../docs/aws-step-functions.md))
|
|
9
|
+
|
|
1
10
|
## [0.1.0] - 2026-10-14
|
|
2
11
|
|
|
3
12
|
Initial release.
|
data/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
# glazed-aws
|
|
2
2
|
|
|
3
|
-
AWS integrations for [glazed](https://github.com/sorah/glazed): the `s3` storage (`Glazed::Storages::S3`). Installing this gem makes `{"s3": {...}}` storage configs available.
|
|
3
|
+
AWS integrations for [glazed](https://github.com/sorah/glazed): the `s3` storage (`Glazed::Storages::S3`) and the `ssm` secret source (`Glazed::Secrets::Ssm`). Installing this gem makes `{"s3": {...}}` storage configs and `{"ssm": {...}}` secret references available.
|
|
4
|
+
|
|
5
|
+
It also runs the orchestrated workflows of a config on AWS Step Functions with a Lambda function (`Glazed::Aws::Lambda`), and the `glazed-aws state-machine` command prints the state machine definition. See [docs/aws-step-functions.md](../docs/aws-step-functions.md).
|
data/exe/glazed-aws
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
require 'json'
|
|
7
|
+
require 'glazed/aws'
|
|
8
|
+
require 'glazed/aws/state_machine'
|
|
9
|
+
require 'glazed/command/base'
|
|
10
|
+
|
|
11
|
+
module Glazed
|
|
12
|
+
module Aws
|
|
13
|
+
module Command
|
|
14
|
+
# `glazed-aws state-machine`: prints the Amazon States Language
|
|
15
|
+
# definition running the orchestrated workflows of a config with the
|
|
16
|
+
# glazed-aws Lambda function. Storages that need care under Step
|
|
17
|
+
# Functions are warned about on standard error.
|
|
18
|
+
class StateMachine < Glazed::Command::Base
|
|
19
|
+
desc 'Print the Step Functions state machine definition for the orchestrated workflows'
|
|
20
|
+
option :config, aliases: ['-c'], desc: 'Config file, ProtoJSON of glazed.v1.Config; $GLAZED_CONFIG_FILE when omitted'
|
|
21
|
+
option :function_arn, desc: 'ARN of the Lambda function with the glazed/aws/lambda handler'
|
|
22
|
+
option :max_concurrency, type: :integer, desc: 'Nodes processed in parallel; jobs in the config by default'
|
|
23
|
+
option :grace_seconds, type: :integer, desc: 'Seconds after waking until another execution may take over; the longest lease by default'
|
|
24
|
+
option :max_cycles, type: :integer, desc: 'Cycles after which an execution ends, leaving later ones to the next; 20 by default'
|
|
25
|
+
|
|
26
|
+
# Prints the definition.
|
|
27
|
+
#
|
|
28
|
+
# @param function_arn [String, nil] ARN of the Lambda function; required
|
|
29
|
+
# @param config [String, nil] path of the config file; {Glazed::Command::Base::CONFIG_FILE_ENV} when nil
|
|
30
|
+
# @param max_concurrency [String, nil] nodes processed in parallel
|
|
31
|
+
# @param grace_seconds [String, nil] seconds after waking until another execution may take over
|
|
32
|
+
# @param max_cycles [String, nil] cycles after which an execution ends
|
|
33
|
+
# @param _options [Hash] other options dry-cli passes, unused
|
|
34
|
+
# @return [void]
|
|
35
|
+
# @raise [Glazed::Error] when the config is invalid, or a workflow cannot run under Step Functions
|
|
36
|
+
#: (?function_arn: String?, ?config: String?, ?max_concurrency: String?, ?grace_seconds: String?, ?max_cycles: String?, **untyped _options) -> void
|
|
37
|
+
def call(function_arn: nil, config: nil, max_concurrency: nil, grace_seconds: nil, max_cycles: nil, **_options)
|
|
38
|
+
path = config_path(config, 'state-machine')
|
|
39
|
+
unless function_arn
|
|
40
|
+
err.puts 'glazed-aws state-machine: --function-arn is required'
|
|
41
|
+
exit 1
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
settings = load_config(path)
|
|
45
|
+
orchestrator_settings(settings, path)
|
|
46
|
+
# dry-cli passes integer options as strings.
|
|
47
|
+
definition = Aws::StateMachine.new(
|
|
48
|
+
config: settings, function_arn:,
|
|
49
|
+
max_concurrency: max_concurrency&.then { Integer(it) }, grace_seconds: grace_seconds&.then { Integer(it) }, max_cycles: max_cycles&.then { Integer(it) },
|
|
50
|
+
)
|
|
51
|
+
out.puts JSON.pretty_generate(definition.to_h)
|
|
52
|
+
definition.warnings.each { err.puts "glazed-aws state-machine: #{it}" }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
require 'dry/cli'
|
|
7
|
+
require 'glazed/aws'
|
|
8
|
+
require 'glazed/aws/command/state_machine'
|
|
9
|
+
|
|
10
|
+
module Glazed
|
|
11
|
+
module Aws
|
|
12
|
+
# Commands of the glazed-aws executable.
|
|
13
|
+
module Command
|
|
14
|
+
extend Dry::CLI::Registry
|
|
15
|
+
|
|
16
|
+
register 'state-machine', StateMachine
|
|
17
|
+
|
|
18
|
+
# Runs a command line.
|
|
19
|
+
#
|
|
20
|
+
# @param argv [Array<String>] command line arguments
|
|
21
|
+
# @param out [IO] standard output
|
|
22
|
+
# @param err [IO] standard error
|
|
23
|
+
# @return [Integer] exit status
|
|
24
|
+
#: (Array[String] argv, ?out: IO, ?err: IO) -> Integer
|
|
25
|
+
def self.start(argv, out: $stdout, err: $stderr)
|
|
26
|
+
Dry::CLI.new(self).call(arguments: argv, out:, err:)
|
|
27
|
+
0
|
|
28
|
+
rescue SystemExit => e
|
|
29
|
+
e.status
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
require 'digest'
|
|
7
|
+
require 'json'
|
|
8
|
+
require 'aws-sdk-s3'
|
|
9
|
+
require 'glazed'
|
|
10
|
+
require 'glazed/aws'
|
|
11
|
+
require 'glazed/aws/signal_events'
|
|
12
|
+
require 'glazed/aws/state_machine'
|
|
13
|
+
require 'glazed/config'
|
|
14
|
+
require 'glazed/coordinator'
|
|
15
|
+
require 'glazed/coordinator/task_runner'
|
|
16
|
+
require 'glazed/must'
|
|
17
|
+
require 'glazed/proto'
|
|
18
|
+
require 'glazed/proto_json'
|
|
19
|
+
require 'glazed/runner'
|
|
20
|
+
require 'glazed/sources'
|
|
21
|
+
require 'glazed/state_backends'
|
|
22
|
+
require 'glazed/step_context'
|
|
23
|
+
require 'glazed/workflow'
|
|
24
|
+
|
|
25
|
+
module Glazed
|
|
26
|
+
module Aws
|
|
27
|
+
# AWS Lambda function performing the tasks of the state machine
|
|
28
|
+
# {StateMachine} generates. Set the handler to
|
|
29
|
+
# `glazed/aws/lambda.Glazed::Aws::Lambda.handler`. The config is read from
|
|
30
|
+
# the S3 object GLAZED_CONFIG_S3_BUCKET and GLAZED_CONFIG_S3_KEY name,
|
|
31
|
+
# which must have the SHA-256 in GLAZED_CONFIG_S3_DIGEST, or else from the
|
|
32
|
+
# file GLAZED_CONFIG_FILE names. Events are `{"task":
|
|
33
|
+
# <glazed.v1.CoordinatorTask>, "execution_input": <execution input>}`;
|
|
34
|
+
# signals in the execution input are added to a check task. The result is
|
|
35
|
+
# the task's result as ProtoJSON. The event `{"state_machine": {}}` returns
|
|
36
|
+
# the definition of the state machine invoking this function instead.
|
|
37
|
+
module Lambda
|
|
38
|
+
# Environment variable naming the config file.
|
|
39
|
+
CONFIG_FILE_ENV = 'GLAZED_CONFIG_FILE' #: String
|
|
40
|
+
# Environment variable naming the bucket of the config object.
|
|
41
|
+
CONFIG_S3_BUCKET_ENV = 'GLAZED_CONFIG_S3_BUCKET' #: String
|
|
42
|
+
# Environment variable naming the key of the config object.
|
|
43
|
+
CONFIG_S3_KEY_ENV = 'GLAZED_CONFIG_S3_KEY' #: String
|
|
44
|
+
# Environment variable holding the SHA-256 of the config object, in base64.
|
|
45
|
+
CONFIG_S3_DIGEST_ENV = 'GLAZED_CONFIG_S3_DIGEST' #: String
|
|
46
|
+
# Environment variable naming the region of the config bucket; the SDK default when unset.
|
|
47
|
+
CONFIG_S3_REGION_ENV = 'GLAZED_CONFIG_S3_REGION' #: String
|
|
48
|
+
|
|
49
|
+
# What a warm function keeps between invocations.
|
|
50
|
+
class Function
|
|
51
|
+
# Builds the task runner of a config.
|
|
52
|
+
#
|
|
53
|
+
# @param config [Proto::V1::Config] the config
|
|
54
|
+
# @param source [String] where the config comes from
|
|
55
|
+
# @param listener [Proc] receives events
|
|
56
|
+
# @return [Function] the function
|
|
57
|
+
# @raise [Glazed::Error] when the config lacks orchestrator settings
|
|
58
|
+
#: (Proto::V1::Config config, source: String, listener: Runner::listener) -> Function
|
|
59
|
+
def self.build(config, source:, listener:)
|
|
60
|
+
settings = config.orchestrator or raise Glazed::Error, "#{source}: orchestrator is not configured"
|
|
61
|
+
context = StepContext.from_proto(config.storages, listener:)
|
|
62
|
+
workflows = settings.workflows.keys.to_h do |name|
|
|
63
|
+
definition = config.workflows[name] or raise Glazed::Error, "#{source}: no workflow named #{name.inspect}"
|
|
64
|
+
[name, Workflow.from_proto(definition, context:)]
|
|
65
|
+
end
|
|
66
|
+
backend = StateBackends.build(Must.because(settings.state) { 'validation requires orchestrator.state' }, context:)
|
|
67
|
+
coordinator = Coordinator.new(settings:, backend:, listener:)
|
|
68
|
+
runner = Coordinator::TaskRunner.new(coordinator:, workflows:, inventory: -> { Sources.nodes(config.inventory.to_a) }, listener:)
|
|
69
|
+
new(config:, source:, runner:)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @param config [Proto::V1::Config] the config
|
|
73
|
+
# @param source [String] where the config comes from
|
|
74
|
+
# @param runner [Coordinator::TaskRunner] performs the tasks
|
|
75
|
+
#: (config: Proto::V1::Config, source: String, runner: Coordinator::TaskRunner) -> void
|
|
76
|
+
def initialize(config:, source:, runner:)
|
|
77
|
+
@config = config #: Proto::V1::Config
|
|
78
|
+
@source = source #: String
|
|
79
|
+
@runner = runner #: Coordinator::TaskRunner
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [Proto::V1::Config] the config
|
|
83
|
+
#: Proto::V1::Config
|
|
84
|
+
attr_reader :config
|
|
85
|
+
# @return [String] where the config comes from
|
|
86
|
+
#: String
|
|
87
|
+
attr_reader :source
|
|
88
|
+
# @return [Coordinator::TaskRunner] performs the tasks
|
|
89
|
+
#: Coordinator::TaskRunner
|
|
90
|
+
attr_reader :runner
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
@function = nil #: Function?
|
|
94
|
+
|
|
95
|
+
# Entry point of the Lambda runtime.
|
|
96
|
+
#
|
|
97
|
+
# @param event [Hash] the event
|
|
98
|
+
# @param context [Object] the Lambda context
|
|
99
|
+
# @return [Hash] the task's result as ProtoJSON, or the state machine definition
|
|
100
|
+
#: (event: Hash[String, untyped], context: untyped) -> Hash[String, untyped]
|
|
101
|
+
def self.handler(event:, context:)
|
|
102
|
+
handle(event, environment: ENV.to_h, invoked_function_arn: context.invoked_function_arn)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Performs the task of an event. The config is loaded once per source,
|
|
106
|
+
# so a changed object in S3 is read only when its digest changes.
|
|
107
|
+
#
|
|
108
|
+
# @param event [Hash] the event
|
|
109
|
+
# @param environment [Hash{String => String}] environment variables
|
|
110
|
+
# @param invoked_function_arn [String, nil] ARN the function was invoked with, including any qualifier
|
|
111
|
+
# @param listener [Proc] receives events; JSON lines on standard error by default
|
|
112
|
+
# @param s3 [Aws::S3::Client, nil] client reading the config object; built from the environment when nil
|
|
113
|
+
# @return [Hash] the task's result as ProtoJSON, or the state machine definition
|
|
114
|
+
# @raise [Glazed::Error] when the config is missing, invalid or not of the expected digest, or the task fails
|
|
115
|
+
#: (Hash[String, untyped] event, environment: Hash[String, String], ?invoked_function_arn: String?, ?listener: Runner::listener, ?s3: ::Aws::S3::Client?) -> Hash[String, untyped]
|
|
116
|
+
def self.handle(event, environment:, invoked_function_arn: nil, listener: ->(event) { $stderr.puts(JSON.generate(event)) }, s3: nil)
|
|
117
|
+
source = config_source(environment)
|
|
118
|
+
function = @function
|
|
119
|
+
if function.nil? || function.source != source
|
|
120
|
+
function = @function = Function.build(load_config(environment, s3:), source:, listener:)
|
|
121
|
+
end
|
|
122
|
+
return state_machine(function.config, event.fetch('state_machine'), invoked_function_arn:) if event.key?('state_machine')
|
|
123
|
+
|
|
124
|
+
task = ProtoJson.load(Proto::V1::CoordinatorTask, JSON.generate(event.fetch('task')))
|
|
125
|
+
if (check = task.check) && event.key?('execution_input')
|
|
126
|
+
SignalEvents.extract(event['execution_input'], listener:).each { check.signals << it }
|
|
127
|
+
end
|
|
128
|
+
JSON.parse(ProtoJson.dump(function.runner.perform(task)))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Generates the state machine definition, as `glazed-aws state-machine` does.
|
|
132
|
+
#
|
|
133
|
+
# @param config [Proto::V1::Config] the config
|
|
134
|
+
# @param request [Hash, nil] options: `function_arn`, the invoked ARN by default, and
|
|
135
|
+
# `max_concurrency`, `grace_seconds` and `max_cycles`
|
|
136
|
+
# @param invoked_function_arn [String, nil] ARN the function was invoked with
|
|
137
|
+
# @return [Hash] `definition`, the definition as a JSON string, and `warnings`
|
|
138
|
+
# @raise [Glazed::Error] when no function ARN is known, or a workflow cannot run under Step Functions
|
|
139
|
+
#: (Proto::V1::Config config, Hash[String, untyped]? request, invoked_function_arn: String?) -> Hash[String, untyped]
|
|
140
|
+
private_class_method def self.state_machine(config, request, invoked_function_arn:)
|
|
141
|
+
request ||= {}
|
|
142
|
+
function_arn = request.fetch('function_arn', invoked_function_arn) or raise Glazed::Error, 'state_machine.function_arn is required'
|
|
143
|
+
definition = StateMachine.new(
|
|
144
|
+
config:, function_arn:,
|
|
145
|
+
max_concurrency: request['max_concurrency']&.then { Integer(it) },
|
|
146
|
+
grace_seconds: request['grace_seconds']&.then { Integer(it) },
|
|
147
|
+
max_cycles: request['max_cycles']&.then { Integer(it) },
|
|
148
|
+
)
|
|
149
|
+
{'definition' => JSON.generate(definition.to_h), 'warnings' => definition.warnings}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @param environment [Hash{String => String}] environment variables
|
|
153
|
+
# @return [String] where the config comes from: the S3 object and its digest, or the file
|
|
154
|
+
# @raise [Glazed::Error] when neither is set, or the S3 object is set partly
|
|
155
|
+
#: (Hash[String, String] environment) -> String
|
|
156
|
+
private_class_method def self.config_source(environment)
|
|
157
|
+
bucket = environment[CONFIG_S3_BUCKET_ENV]
|
|
158
|
+
return environment.fetch(CONFIG_FILE_ENV) { raise Glazed::Error, "#{CONFIG_S3_BUCKET_ENV} or #{CONFIG_FILE_ENV} is required" } unless bucket
|
|
159
|
+
|
|
160
|
+
key = environment.fetch(CONFIG_S3_KEY_ENV) { raise Glazed::Error, "#{CONFIG_S3_KEY_ENV} is required with #{CONFIG_S3_BUCKET_ENV}" }
|
|
161
|
+
digest = environment.fetch(CONFIG_S3_DIGEST_ENV) { raise Glazed::Error, "#{CONFIG_S3_DIGEST_ENV} is required with #{CONFIG_S3_BUCKET_ENV}" }
|
|
162
|
+
"s3://#{bucket}/#{key}##{digest}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# @param environment [Hash{String => String}] environment variables
|
|
166
|
+
# @param s3 [Aws::S3::Client, nil] client reading the config object; built from the environment when nil
|
|
167
|
+
# @return [Proto::V1::Config] the config
|
|
168
|
+
# @raise [Glazed::Error] when the config is invalid, or the S3 object is not of the expected digest
|
|
169
|
+
#: (Hash[String, String] environment, s3: ::Aws::S3::Client?) -> Proto::V1::Config
|
|
170
|
+
private_class_method def self.load_config(environment, s3:)
|
|
171
|
+
bucket = environment[CONFIG_S3_BUCKET_ENV]
|
|
172
|
+
return Glazed::Config.load(environment.fetch(CONFIG_FILE_ENV)) unless bucket
|
|
173
|
+
|
|
174
|
+
key = environment.fetch(CONFIG_S3_KEY_ENV)
|
|
175
|
+
origin = "s3://#{bucket}/#{key}"
|
|
176
|
+
region = environment[CONFIG_S3_REGION_ENV]
|
|
177
|
+
s3 ||= region ? ::Aws::S3::Client.new(region:) : ::Aws::S3::Client.new
|
|
178
|
+
json = s3.get_object(bucket:, key:).body.read.force_encoding(Encoding::UTF_8)
|
|
179
|
+
# The digest pins the object the function was deployed with.
|
|
180
|
+
actual = [Digest::SHA256.digest(json)].pack('m0')
|
|
181
|
+
expected = environment.fetch(CONFIG_S3_DIGEST_ENV)
|
|
182
|
+
raise Glazed::Error, "#{origin} has SHA-256 #{actual}, not #{expected} in #{CONFIG_S3_DIGEST_ENV}" unless actual == expected
|
|
183
|
+
|
|
184
|
+
Glazed::Config.parse(json, origin:)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Forgets the loaded config, so the next event loads it again.
|
|
188
|
+
#
|
|
189
|
+
# @return [void]
|
|
190
|
+
#: () -> void
|
|
191
|
+
def self.reset
|
|
192
|
+
@function = nil
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
require 'json'
|
|
7
|
+
require 'time'
|
|
8
|
+
require 'google/protobuf'
|
|
9
|
+
require 'glazed'
|
|
10
|
+
require 'glazed/proto'
|
|
11
|
+
require 'glazed/proto_json'
|
|
12
|
+
require 'glazed/runner'
|
|
13
|
+
require 'glazed/validation'
|
|
14
|
+
|
|
15
|
+
module Glazed
|
|
16
|
+
module Aws
|
|
17
|
+
# Reads {Proto::V1::Signal}s from the input of a Step Functions execution,
|
|
18
|
+
# in the shapes its triggers deliver:
|
|
19
|
+
#
|
|
20
|
+
# - an EventBridge event, from a rule targeting the state machine, whose `detail` is a Signal
|
|
21
|
+
# - an array of SQS messages, from an EventBridge Pipe, whose bodies are Signals or EventBridge events
|
|
22
|
+
# - `{"signals": [...]}`, from EventBridge Scheduler or a manual StartExecution
|
|
23
|
+
#
|
|
24
|
+
# A Signal lacking `source` or `received_at` takes the event's source and
|
|
25
|
+
# time. Entries that do not decode or validate are reported and skipped,
|
|
26
|
+
# so one bad message does not hold back the others.
|
|
27
|
+
module SignalEvents
|
|
28
|
+
# @param input [Object] the execution input, decoded from JSON
|
|
29
|
+
# @param listener [Proc] receives an event for each skipped entry
|
|
30
|
+
# @return [Array<Proto::V1::Signal>] the signals
|
|
31
|
+
#: (untyped input, ?listener: Runner::listener) -> Array[Proto::V1::Signal]
|
|
32
|
+
def self.extract(input, listener: ->(_event) {})
|
|
33
|
+
case input
|
|
34
|
+
when Array then input.flat_map { from_sqs_message(it, listener:) }
|
|
35
|
+
when Hash
|
|
36
|
+
if input.key?('detail-type') then from_event(input, listener:)
|
|
37
|
+
elsif input.key?('signals') then Array(input['signals']).filter_map { decode(it, listener:) }
|
|
38
|
+
else []
|
|
39
|
+
end
|
|
40
|
+
else []
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param message [Object] an SQS message as an EventBridge Pipe delivers it
|
|
45
|
+
# @param listener [Proc] receives an event when the message is skipped
|
|
46
|
+
# @return [Array<Proto::V1::Signal>] the signals in its body
|
|
47
|
+
#: (untyped message, listener: Runner::listener) -> Array[Proto::V1::Signal]
|
|
48
|
+
private_class_method def self.from_sqs_message(message, listener:)
|
|
49
|
+
body = message.is_a?(Hash) ? message['body'] : nil
|
|
50
|
+
unless body.is_a?(String)
|
|
51
|
+
skip(listener, 'SQS message without a body')
|
|
52
|
+
return []
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
begin
|
|
56
|
+
document = JSON.parse(body) #: untyped
|
|
57
|
+
rescue JSON::ParserError => e
|
|
58
|
+
skip(listener, "SQS message #{message['messageId']}: #{e.message}")
|
|
59
|
+
return []
|
|
60
|
+
end
|
|
61
|
+
return from_event(document, listener:) if document.is_a?(Hash) && document.key?('detail-type')
|
|
62
|
+
|
|
63
|
+
sent = message.dig('attributes', 'SentTimestamp')
|
|
64
|
+
received_at = (Time.at(Rational(sent.to_i, 1000)).utc.iso8601(3) if sent) #: String?
|
|
65
|
+
[decode(document, listener:, received_at:)].compact
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @param event [Hash] an EventBridge event
|
|
69
|
+
# @param listener [Proc] receives an event when the event is skipped
|
|
70
|
+
# @return [Array<Proto::V1::Signal>] the signal in its detail
|
|
71
|
+
#: (Hash[String, untyped] event, listener: Runner::listener) -> Array[Proto::V1::Signal]
|
|
72
|
+
private_class_method def self.from_event(event, listener:)
|
|
73
|
+
[decode(event['detail'], listener:, source: event['source'], received_at: event['time'])].compact
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @param document [Object] a Signal as ProtoJSON, decoded from JSON
|
|
77
|
+
# @param listener [Proc] receives an event when the document is skipped
|
|
78
|
+
# @param source [String, nil] source when the document has none
|
|
79
|
+
# @param received_at [String, nil] RFC 3339 reception time when the document has none
|
|
80
|
+
# @return [Proto::V1::Signal, nil] the signal; nil when it is invalid
|
|
81
|
+
#: (untyped document, listener: Runner::listener, ?source: String?, ?received_at: String?) -> Proto::V1::Signal?
|
|
82
|
+
private_class_method def self.decode(document, listener:, source: nil, received_at: nil)
|
|
83
|
+
unless document.is_a?(Hash)
|
|
84
|
+
skip(listener, "signal is not an object: #{JSON.generate(document)}")
|
|
85
|
+
return
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
defaults = {'source' => source, 'received_at' => received_at}.compact
|
|
89
|
+
signal = ProtoJson.load(Proto::V1::Signal, JSON.generate(defaults.merge(document)))
|
|
90
|
+
Validation.validate!(signal)
|
|
91
|
+
signal
|
|
92
|
+
rescue Google::Protobuf::ParseError, InvalidMessage => e
|
|
93
|
+
skip(listener, "invalid signal #{JSON.generate(document)}: #{e.message}")
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @param listener [Proc] receives the event
|
|
98
|
+
# @param message [String] why an entry is skipped
|
|
99
|
+
# @return [void]
|
|
100
|
+
#: (Runner::listener listener, String message) -> void
|
|
101
|
+
private_class_method def self.skip(listener, message)
|
|
102
|
+
listener.call({level: 'error', message: "signal skipped: #{message}"})
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
require 'json'
|
|
7
|
+
require 'google/protobuf'
|
|
8
|
+
require 'glazed'
|
|
9
|
+
require 'glazed/aws'
|
|
10
|
+
require 'glazed/must'
|
|
11
|
+
require 'glazed/proto'
|
|
12
|
+
require 'glazed/steps'
|
|
13
|
+
|
|
14
|
+
module Glazed
|
|
15
|
+
module Aws
|
|
16
|
+
# Amazon States Language definition of a Standard state machine running
|
|
17
|
+
# the orchestrated workflows of a config with the {Lambda} function. Each
|
|
18
|
+
# execution checks when nodes are due, waits, runs every workflow's
|
|
19
|
+
# batch of due nodes, and checks again, until another execution waits
|
|
20
|
+
# for the next due node instead. Each step is one state: consecutive
|
|
21
|
+
# per-node steps run in a Map over the batch, batch steps run once.
|
|
22
|
+
#
|
|
23
|
+
# @example
|
|
24
|
+
# definition = Glazed::Aws::StateMachine.new(config:, function_arn: 'arn:aws:lambda:...:function:glazed').to_h
|
|
25
|
+
class StateMachine
|
|
26
|
+
# Raised when a workflow cannot run under Step Functions.
|
|
27
|
+
class Unsupported < Glazed::Error; end
|
|
28
|
+
|
|
29
|
+
# Nodes processed in parallel when neither the caller nor the config sets it.
|
|
30
|
+
DEFAULT_MAX_CONCURRENCY = 4 #: Integer
|
|
31
|
+
# Errors of the Lambda service rather than of the function, worth retrying.
|
|
32
|
+
LAMBDA_ERRORS = [
|
|
33
|
+
'Lambda.ServiceException', 'Lambda.AWSLambdaException', 'Lambda.SdkClientException', 'Lambda.TooManyRequestsException',
|
|
34
|
+
].freeze #: Array[String]
|
|
35
|
+
# Longest state name Step Functions accepts.
|
|
36
|
+
MAX_STATE_NAME = 80 #: Integer
|
|
37
|
+
|
|
38
|
+
#: type state = Hash[String, untyped]
|
|
39
|
+
|
|
40
|
+
# @param config [Proto::V1::Config] the config, with orchestrator settings
|
|
41
|
+
# @param function_arn [String] ARN of the {Lambda} function
|
|
42
|
+
# @param max_concurrency [Integer, nil] nodes processed in parallel; the config's jobs when nil
|
|
43
|
+
# @param grace_seconds [Integer, nil] seconds after waking until another execution may take over;
|
|
44
|
+
# the longest lease when nil
|
|
45
|
+
# @param max_cycles [Integer, nil] cycles after which an execution ends; 20 when nil
|
|
46
|
+
# @raise [Glazed::Error] when the config has no orchestrator settings
|
|
47
|
+
#: (config: Proto::V1::Config, function_arn: String, ?max_concurrency: Integer?, ?grace_seconds: Integer?, ?max_cycles: Integer?) -> void
|
|
48
|
+
def initialize(config:, function_arn:, max_concurrency: nil, grace_seconds: nil, max_cycles: nil)
|
|
49
|
+
@config = config #: Proto::V1::Config
|
|
50
|
+
settings = config.orchestrator or raise Glazed::Error, 'orchestrator is not configured'
|
|
51
|
+
@settings = settings #: Proto::V1::Orchestrator
|
|
52
|
+
@function_arn = function_arn #: String
|
|
53
|
+
jobs = config.jobs.zero? ? DEFAULT_MAX_CONCURRENCY : config.jobs
|
|
54
|
+
@max_concurrency = max_concurrency || jobs #: Integer
|
|
55
|
+
@grace_seconds = grace_seconds #: Integer?
|
|
56
|
+
@max_cycles = max_cycles #: Integer?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Storages that work under Step Functions only with care, with why.
|
|
60
|
+
#
|
|
61
|
+
# @return [Array<String>] the warnings
|
|
62
|
+
#: () -> Array[String]
|
|
63
|
+
def warnings
|
|
64
|
+
storages_written.filter_map do |name|
|
|
65
|
+
case Must.because(@config.storages[name]) { 'steps are checked against the storages' }.backend
|
|
66
|
+
when :filesystem then "storage #{name} is a local directory; every Lambda instance has its own"
|
|
67
|
+
when :git
|
|
68
|
+
git = Must.because(@config.storages[name]&.git) { 'backend is git' }
|
|
69
|
+
"storage #{name} is a local git repository without remote; every Lambda instance has its own" if git.remote.empty?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Hash] the definition
|
|
75
|
+
# @raise [Unsupported] when a workflow writes to memory storage, which does not outlive one task
|
|
76
|
+
# @raise [Finder::NotFound, PluginRegistry::UnknownPlugin] when a step's implementation is not available
|
|
77
|
+
#: () -> Hash[String, untyped]
|
|
78
|
+
def to_h
|
|
79
|
+
memory = storages_written.select { @config.storages[it]&.backend == :memory }
|
|
80
|
+
raise Unsupported, "memory storages do not outlive a Lambda invocation: #{memory.join(', ')}" unless memory.empty?
|
|
81
|
+
|
|
82
|
+
{
|
|
83
|
+
'Comment' => 'glazed coordinator, generated by glazed-aws state-machine',
|
|
84
|
+
'QueryLanguage' => 'JSONata',
|
|
85
|
+
'StartAt' => 'Start',
|
|
86
|
+
'States' => {
|
|
87
|
+
'Start' => {'Type' => 'Pass', 'Assign' => {'cycle' => 0}, 'Next' => 'Check'},
|
|
88
|
+
'Check' => invoke(
|
|
89
|
+
{'check' => {
|
|
90
|
+
'token' => '{% $states.context.Execution.Name %}', 'cycle' => '{% $cycle %}',
|
|
91
|
+
'grace_seconds' => @grace_seconds || 0, 'max_cycles' => @max_cycles || 0,
|
|
92
|
+
}},
|
|
93
|
+
execution_input: '{% $cycle = 0 ? $states.context.Execution.Input : null %}',
|
|
94
|
+
).merge('Retry' => [retry_on(LAMBDA_ERRORS + ['States.TaskFailed'])], 'Next' => 'Armed?'),
|
|
95
|
+
'Armed?' => {
|
|
96
|
+
'Type' => 'Choice',
|
|
97
|
+
'Choices' => [{'Condition' => '{% $exists($states.input.wait_until) %}', 'Next' => 'Sleep'}],
|
|
98
|
+
'Default' => 'Done',
|
|
99
|
+
},
|
|
100
|
+
'Sleep' => {'Type' => 'Wait', 'Timestamp' => '{% $states.input.wait_until %}', 'Next' => 'Prepare'},
|
|
101
|
+
'Prepare' => invoke({'prepare' => {}}).merge('Retry' => [retry_on(LAMBDA_ERRORS + ['States.TaskFailed'])], 'Next' => 'Workflows'),
|
|
102
|
+
'Workflows' => {
|
|
103
|
+
'Type' => 'Parallel',
|
|
104
|
+
'Branches' => @settings.workflows.keys.sort.map { branch(it) },
|
|
105
|
+
'Catch' => [{'ErrorEquals' => ['States.ALL'], 'Next' => 'Next cycle'}],
|
|
106
|
+
'Next' => 'Next cycle',
|
|
107
|
+
},
|
|
108
|
+
'Next cycle' => {'Type' => 'Pass', 'Assign' => {'cycle' => '{% $cycle + 1 %}'}, 'Next' => 'Check'},
|
|
109
|
+
'Done' => {'Type' => 'Succeed'},
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @param workflow [String] workflow name
|
|
115
|
+
# @return [Hash] the Parallel branch running the workflow's batch
|
|
116
|
+
#: (String workflow) -> StateMachine::state
|
|
117
|
+
private def branch(workflow)
|
|
118
|
+
steps = Must.because(@config.workflows[workflow]) { 'validation checks orchestrated workflows exist' }.steps.to_a
|
|
119
|
+
chunks = steps.chunk_while { |a, b| node_step?(a) && node_step?(b) }.to_a
|
|
120
|
+
complete = name(workflow, 'complete')
|
|
121
|
+
states = {} #: Hash[String, StateMachine::state]
|
|
122
|
+
maps = 0
|
|
123
|
+
names = chunks.map { |chunk| node_step?(chunk.fetch(0)) ? name(workflow, "each node ##{maps += 1}") : name(workflow, chunk.fetch(0).name) }
|
|
124
|
+
names << complete
|
|
125
|
+
|
|
126
|
+
start = name(workflow, 'batch')
|
|
127
|
+
states[start] = {'Type' => 'Pass', 'Output' => "{% $lookup($states.input.batches, #{JSON.generate(workflow)}) %}", 'Next' => name(workflow, 'any due?')}
|
|
128
|
+
states[name(workflow, 'any due?')] = {
|
|
129
|
+
'Type' => 'Choice',
|
|
130
|
+
'Choices' => [{'Condition' => '{% $count($states.input.nodes) > 0 %}', 'Next' => names.fetch(0)}],
|
|
131
|
+
'Default' => name(workflow, 'none due'),
|
|
132
|
+
}
|
|
133
|
+
states[name(workflow, 'none due')] = {'Type' => 'Succeed'}
|
|
134
|
+
chunks.each_with_index do |chunk, index|
|
|
135
|
+
following = names.fetch(index + 1)
|
|
136
|
+
if node_step?(chunk.fetch(0))
|
|
137
|
+
states[names.fetch(index)] = map(workflow, chunk, names.fetch(index), following)
|
|
138
|
+
else
|
|
139
|
+
step = chunk.fetch(0)
|
|
140
|
+
failed = "{% $merge([$states.input, {\"error\": #{JSON.generate("#{step.name}: ")} & $states.errorOutput.Error & \": \" & $states.errorOutput.Cause}]) %}"
|
|
141
|
+
# A failed batch skips the remaining steps.
|
|
142
|
+
after = following == complete ? complete : name(workflow, "#{step.name} failed?")
|
|
143
|
+
states[names.fetch(index)] = invoke({'batch_step' => {'step' => step.name, 'batch' => '{% $states.input %}'}}).merge(
|
|
144
|
+
'Retry' => [retry_on(LAMBDA_ERRORS)],
|
|
145
|
+
'Catch' => [{'ErrorEquals' => ['States.ALL'], 'Output' => failed, 'Next' => complete}],
|
|
146
|
+
'Next' => after,
|
|
147
|
+
)
|
|
148
|
+
if after != complete
|
|
149
|
+
states[after] = {
|
|
150
|
+
'Type' => 'Choice',
|
|
151
|
+
'Choices' => [{'Condition' => '{% $exists($states.input.error) %}', 'Next' => complete}],
|
|
152
|
+
'Default' => following,
|
|
153
|
+
}
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
states[complete] = invoke({'complete' => {'batch' => '{% $states.input %}'}}).merge(
|
|
158
|
+
'Retry' => [retry_on(LAMBDA_ERRORS + ['States.TaskFailed'])],
|
|
159
|
+
'Output' => '{% {"workflow": $states.result.Payload.workflow, "nodes": $count($states.result.Payload.nodes)} %}',
|
|
160
|
+
'End' => true,
|
|
161
|
+
)
|
|
162
|
+
{'StartAt' => start, 'States' => states}
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# @param workflow [String] workflow name
|
|
166
|
+
# @param chunk [Array<Proto::V1::Step>] consecutive per-node steps
|
|
167
|
+
# @param map_name [String] name of the Map state
|
|
168
|
+
# @param following [String] state after the Map
|
|
169
|
+
# @return [Hash] the Map state running the steps for each node of the batch
|
|
170
|
+
#: (String workflow, Array[Proto::V1::Step] chunk, String map_name, String following) -> StateMachine::state
|
|
171
|
+
private def map(workflow, chunk, map_name, following)
|
|
172
|
+
done = name(workflow, "#{map_name.delete_prefix("#{workflow}: ")} done")
|
|
173
|
+
states = {} #: Hash[String, StateMachine::state]
|
|
174
|
+
chunk.each_with_index do |step, index|
|
|
175
|
+
after = chunk[index + 1]&.then { name(workflow, it.name) } || done
|
|
176
|
+
failure = '{% {"state": $states.input.state, "failure": ' \
|
|
177
|
+
"{\"step\": #{JSON.generate(step.name)}, \"error\": $states.errorOutput.Error & \": \" & $states.errorOutput.Cause}} %}"
|
|
178
|
+
states[name(workflow, step.name)] = invoke(
|
|
179
|
+
{'node_step' => {'workflow' => workflow, 'step' => step.name, 'outcome' => '{% $states.input %}'}},
|
|
180
|
+
).merge(
|
|
181
|
+
'Retry' => [retry_on(LAMBDA_ERRORS)],
|
|
182
|
+
'Catch' => [{'ErrorEquals' => ['States.ALL'], 'Output' => failure, 'Next' => after}],
|
|
183
|
+
'Next' => after,
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
states[done] = {'Type' => 'Pass', 'End' => true}
|
|
187
|
+
{
|
|
188
|
+
'Type' => 'Map',
|
|
189
|
+
'Items' => '{% $states.input.nodes %}',
|
|
190
|
+
'MaxConcurrency' => @max_concurrency,
|
|
191
|
+
'ItemProcessor' => {'ProcessorConfig' => {'Mode' => 'INLINE'}, 'StartAt' => name(workflow, chunk.fetch(0).name), 'States' => states},
|
|
192
|
+
'Output' => '{% $merge([$states.input, {"nodes": $states.result}]) %}',
|
|
193
|
+
'Next' => following,
|
|
194
|
+
}
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# @param task [Hash] the glazed.v1.CoordinatorTask, with JSONata expressions
|
|
198
|
+
# @param execution_input [String, nil] JSONata expression of the execution input to pass
|
|
199
|
+
# @return [Hash] a Task state invoking the function
|
|
200
|
+
#: (Hash[String, untyped] task, ?execution_input: String?) -> StateMachine::state
|
|
201
|
+
private def invoke(task, execution_input: nil)
|
|
202
|
+
payload = {'task' => task} #: Hash[String, untyped]
|
|
203
|
+
payload['execution_input'] = execution_input if execution_input
|
|
204
|
+
{
|
|
205
|
+
'Type' => 'Task',
|
|
206
|
+
'Resource' => 'arn:aws:states:::lambda:invoke',
|
|
207
|
+
'Arguments' => {'FunctionName' => @function_arn, 'Payload' => payload},
|
|
208
|
+
'Output' => '{% $states.result.Payload %}',
|
|
209
|
+
}
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# @param errors [Array<String>] errors to retry
|
|
213
|
+
# @return [Hash] a retrier with backoff
|
|
214
|
+
#: (Array[String] errors) -> Hash[String, untyped]
|
|
215
|
+
private def retry_on(errors)
|
|
216
|
+
{'ErrorEquals' => errors, 'IntervalSeconds' => 2, 'MaxAttempts' => 3, 'BackoffRate' => 2, 'JitterStrategy' => 'FULL'}
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# @param workflow [String] workflow name
|
|
220
|
+
# @param label [String] what the state does
|
|
221
|
+
# @return [String] a state name unique across the state machine
|
|
222
|
+
# @raise [Unsupported] when the name exceeds what Step Functions accepts
|
|
223
|
+
#: (String workflow, String label) -> String
|
|
224
|
+
private def name(workflow, label)
|
|
225
|
+
name = "#{workflow}: #{label}"
|
|
226
|
+
raise Unsupported, "state name #{name.inspect} is longer than #{MAX_STATE_NAME} characters" if name.length > MAX_STATE_NAME
|
|
227
|
+
|
|
228
|
+
name
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# @param step [Proto::V1::Step] step definition
|
|
232
|
+
# @return [Boolean] whether the step runs for each node
|
|
233
|
+
#: (Proto::V1::Step step) -> bool
|
|
234
|
+
private def node_step?(step)
|
|
235
|
+
implementation = if (packed = step.plugin)
|
|
236
|
+
Steps::PLUGINS.resolve(packed).first
|
|
237
|
+
else
|
|
238
|
+
Steps.find(Must.because(step.action) { 'validation requires an action' })
|
|
239
|
+
end
|
|
240
|
+
implementation.is_a?(Class) && implementation.ancestors.include?(Steps::NodeStep)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# @return [Array<String>] names of the storages steps of the orchestrated workflows write to
|
|
244
|
+
#: () -> Array[String]
|
|
245
|
+
private def storages_written
|
|
246
|
+
@settings.workflows.keys.flat_map do |workflow|
|
|
247
|
+
(@config.workflows[workflow]&.steps || []).filter_map do |step|
|
|
248
|
+
action = step.action
|
|
249
|
+
next unless action && action != :plugin
|
|
250
|
+
|
|
251
|
+
field = Proto::V1::Step.descriptor.lookup(action.to_s)&.subtype&.lookup('storage')
|
|
252
|
+
next unless field
|
|
253
|
+
|
|
254
|
+
storage = field.get(step[action.to_s]) #: untyped
|
|
255
|
+
storage.is_a?(String) && !storage.empty? ? storage : nil
|
|
256
|
+
end
|
|
257
|
+
end.uniq
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
data/lib/glazed/aws/version.rb
CHANGED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
require 'aws-sdk-ssm'
|
|
7
|
+
require 'glazed/must'
|
|
8
|
+
require 'glazed/proto'
|
|
9
|
+
require 'glazed/secret'
|
|
10
|
+
require 'glazed/secrets'
|
|
11
|
+
|
|
12
|
+
module Glazed
|
|
13
|
+
# Namespace of secret sources; glazed-aws adds {Ssm}.
|
|
14
|
+
module Secrets
|
|
15
|
+
# AWS Systems Manager Parameter Store parameter, decrypted when it is a
|
|
16
|
+
# SecureString. A fetched value is reused for cache_seconds, so nodes of a
|
|
17
|
+
# batch sharing a password fetch it once per resolver.
|
|
18
|
+
#
|
|
19
|
+
# @example
|
|
20
|
+
# Glazed::Secrets::Ssm.new(name: '/glazed/backup/password').value
|
|
21
|
+
class Ssm < Base
|
|
22
|
+
# Seconds a fetched value is reused when the settings set none.
|
|
23
|
+
DEFAULT_CACHE_SECONDS = 300 #: Integer
|
|
24
|
+
|
|
25
|
+
# @param name [String] name or ARN of the parameter
|
|
26
|
+
# @param region [String, nil] region of the parameter; the SDK default when nil
|
|
27
|
+
# @param cache_seconds [Integer] seconds a fetched value is reused
|
|
28
|
+
# @param client [Aws::SSM::Client, nil] client to use instead of building one
|
|
29
|
+
# @param clock [Proc] returns monotonic seconds
|
|
30
|
+
#: (name: String, ?region: String?, ?cache_seconds: Integer, ?client: ::Aws::SSM::Client?, ?clock: ^() -> Float) -> void
|
|
31
|
+
def initialize(name:, region: nil, cache_seconds: DEFAULT_CACHE_SECONDS, client: nil, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC).to_f })
|
|
32
|
+
super()
|
|
33
|
+
@name = name #: String
|
|
34
|
+
@region = region #: String?
|
|
35
|
+
@cache_seconds = cache_seconds #: Integer
|
|
36
|
+
@client = client #: ::Aws::SSM::Client?
|
|
37
|
+
@clock = clock #: ^() -> Float
|
|
38
|
+
@value = nil #: String?
|
|
39
|
+
@fetched_at = nil #: Float?
|
|
40
|
+
@lock = Mutex.new #: Thread::Mutex
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Builds a source from {Proto::V1::SsmParameter}.
|
|
44
|
+
#
|
|
45
|
+
# @param settings [Google::Protobuf::MessageExts] a {Proto::V1::SsmParameter}
|
|
46
|
+
# @return [Ssm] the source
|
|
47
|
+
# @raise [Must::Violation] when settings is another message
|
|
48
|
+
#: (Google::Protobuf::MessageExts settings) -> Ssm
|
|
49
|
+
def self.from_proto(settings)
|
|
50
|
+
ssm = Must.be_a(settings, Proto::V1::SsmParameter)
|
|
51
|
+
new(
|
|
52
|
+
name: ssm.name,
|
|
53
|
+
region: ssm.region.empty? ? nil : ssm.region,
|
|
54
|
+
cache_seconds: ssm.cache_seconds.zero? ? DEFAULT_CACHE_SECONDS : ssm.cache_seconds,
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [String] name or ARN of the parameter
|
|
59
|
+
#: String
|
|
60
|
+
attr_reader :name
|
|
61
|
+
|
|
62
|
+
# @return [String] the parameter value, fetched again once cache_seconds have passed
|
|
63
|
+
# @raise [Secret::Unavailable] when the parameter or its version does not exist
|
|
64
|
+
# @raise [Aws::SSM::Errors::ServiceError] when the request fails otherwise, such as for lack of permission
|
|
65
|
+
#: () -> String
|
|
66
|
+
def value
|
|
67
|
+
@lock.synchronize do
|
|
68
|
+
now = @clock.call
|
|
69
|
+
cached = @value
|
|
70
|
+
fetched_at = @fetched_at
|
|
71
|
+
return cached if cached && fetched_at && now - fetched_at < @cache_seconds
|
|
72
|
+
|
|
73
|
+
fetch.tap do |fetched|
|
|
74
|
+
@value = fetched
|
|
75
|
+
@fetched_at = now
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [String] the current value
|
|
81
|
+
# @raise [Secret::Unavailable] when the parameter or its version does not exist
|
|
82
|
+
#: () -> String
|
|
83
|
+
private def fetch
|
|
84
|
+
parameter = client.get_parameter(name:, with_decryption: true).parameter
|
|
85
|
+
parameter&.value or raise Secret::Unavailable, "SSM parameter #{name} has no value"
|
|
86
|
+
rescue ::Aws::SSM::Errors::ParameterNotFound, ::Aws::SSM::Errors::ParameterVersionNotFound => e
|
|
87
|
+
raise Secret::Unavailable, "SSM parameter #{name}: #{e.message}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @return [Aws::SSM::Client] the client
|
|
91
|
+
#: () -> ::Aws::SSM::Client
|
|
92
|
+
private def client
|
|
93
|
+
@client ||= @region ? ::Aws::SSM::Client.new(region: @region) : ::Aws::SSM::Client.new
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
data/lib/glazed/storages/s3.rb
CHANGED
|
@@ -41,10 +41,11 @@ module Glazed
|
|
|
41
41
|
# Builds a storage from {Proto::V1::S3Storage}.
|
|
42
42
|
#
|
|
43
43
|
# @param settings [Google::Protobuf::MessageExts] a {Proto::V1::S3Storage}
|
|
44
|
+
# @param context [StorageContext] secrets the settings refer to
|
|
44
45
|
# @return [S3] the storage
|
|
45
46
|
# @raise [Must::Violation] when settings is another message
|
|
46
|
-
#: (Google::Protobuf::MessageExts settings) -> S3
|
|
47
|
-
def self.from_proto(settings)
|
|
47
|
+
#: (Google::Protobuf::MessageExts settings, context: StorageContext) -> S3
|
|
48
|
+
def self.from_proto(settings, context:)
|
|
48
49
|
s3 = Must.be_a(settings, Proto::V1::S3Storage)
|
|
49
50
|
new(bucket: s3.bucket, prefix: s3.prefix, region: s3.region.empty? ? nil : s3.region)
|
|
50
51
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: glazed-aws
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sorah Fukumori
|
|
@@ -11,6 +11,20 @@ date: 1980-01-02 00:00:00.000000000 Z
|
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: glazed
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.2.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.2.0
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: aws-sdk-s3
|
|
14
28
|
requirement: !ruby/object:Gem::Requirement
|
|
15
29
|
requirements:
|
|
16
30
|
- - ">="
|
|
@@ -24,7 +38,21 @@ dependencies:
|
|
|
24
38
|
- !ruby/object:Gem::Version
|
|
25
39
|
version: '0'
|
|
26
40
|
- !ruby/object:Gem::Dependency
|
|
27
|
-
name: aws-sdk-
|
|
41
|
+
name: aws-sdk-ssm
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: dry-cli
|
|
28
56
|
requirement: !ruby/object:Gem::Requirement
|
|
29
57
|
requirements:
|
|
30
58
|
- - ">="
|
|
@@ -39,7 +67,8 @@ dependencies:
|
|
|
39
67
|
version: '0'
|
|
40
68
|
email:
|
|
41
69
|
- her@sorah.jp
|
|
42
|
-
executables:
|
|
70
|
+
executables:
|
|
71
|
+
- glazed-aws
|
|
43
72
|
extensions: []
|
|
44
73
|
extra_rdoc_files: []
|
|
45
74
|
files:
|
|
@@ -49,8 +78,15 @@ files:
|
|
|
49
78
|
- LICENSE.txt
|
|
50
79
|
- README.md
|
|
51
80
|
- Rakefile
|
|
81
|
+
- exe/glazed-aws
|
|
52
82
|
- lib/glazed/aws.rb
|
|
83
|
+
- lib/glazed/aws/command.rb
|
|
84
|
+
- lib/glazed/aws/command/state_machine.rb
|
|
85
|
+
- lib/glazed/aws/lambda.rb
|
|
86
|
+
- lib/glazed/aws/signal_events.rb
|
|
87
|
+
- lib/glazed/aws/state_machine.rb
|
|
53
88
|
- lib/glazed/aws/version.rb
|
|
89
|
+
- lib/glazed/secrets/ssm.rb
|
|
54
90
|
- lib/glazed/storages/s3.rb
|
|
55
91
|
homepage: https://github.com/sorah/glazed
|
|
56
92
|
licenses:
|