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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: abc0ee55acf9c806cea84fd269b250283e24bcdf32f03a41009e3ff75d579b66
4
- data.tar.gz: 9d468db2b2b4e4264f9c9c0af4a16617513a87f5c3ace4c8714a47bc13d5236b
3
+ metadata.gz: da36b22c067ea1b077af0937adac343cfe582c5e096d59ab92ccc24d9137c8f3
4
+ data.tar.gz: de668481d4de70c3f1757d0c510a6c41d0917c70bbd4fb94a8c6652c9c418755
5
5
  SHA512:
6
- metadata.gz: ae2aa7b00c8ab8be71f2a01b9ad64b05b2c905f8736391b8c291926909cab07e0ed239d48b953a6d568d4c45974509fe1ebf9c957c316a82b14e4d0b5e1b83e9
7
- data.tar.gz: c4e701032a6baab3fac4a4672be3a4c2d26da14e1d4a1d0153c205184905eb2650b9b7cfb9c7d818ddbeadd99f81039ee6b63947e9300a8eadc2a29e2f422e1e
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,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ require 'glazed/aws/command'
7
+
8
+ exit Glazed::Aws::Command.start(ARGV)
@@ -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
@@ -6,6 +6,6 @@
6
6
  module Glazed
7
7
  module Aws
8
8
  # Gem version.
9
- VERSION = '0.1.0' #: String
9
+ VERSION = '0.2.0' #: String
10
10
  end
11
11
  end
@@ -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
@@ -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.1.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-s3
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: