glazed 0.1.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.
Files changed (85) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.yardopts +5 -0
  4. data/CHANGELOG.md +18 -0
  5. data/LICENSE.txt +202 -0
  6. data/NOTICE +16 -0
  7. data/README.md +3 -0
  8. data/Rakefile +12 -0
  9. data/exe/glazed +8 -0
  10. data/lib/glazed/cli_session.rb +178 -0
  11. data/lib/glazed/collector.rb +113 -0
  12. data/lib/glazed/command/base.rb +146 -0
  13. data/lib/glazed/command/orchestrate.rb +78 -0
  14. data/lib/glazed/command/run.rb +51 -0
  15. data/lib/glazed/command/send_signal.rb +69 -0
  16. data/lib/glazed/command/status.rb +179 -0
  17. data/lib/glazed/command/version.rb +23 -0
  18. data/lib/glazed/command.rb +39 -0
  19. data/lib/glazed/command_result.rb +26 -0
  20. data/lib/glazed/dialect/actions.rb +95 -0
  21. data/lib/glazed/dialect/communication_config.rb +63 -0
  22. data/lib/glazed/dialect/interaction.rb +32 -0
  23. data/lib/glazed/dialect/run.rb +84 -0
  24. data/lib/glazed/dialect/runbook.rb +97 -0
  25. data/lib/glazed/dialect/sensitive_pattern.rb +31 -0
  26. data/lib/glazed/dialect.rb +12 -0
  27. data/lib/glazed/dialects/awplus.rb +84 -0
  28. data/lib/glazed/dialects/base.rb +246 -0
  29. data/lib/glazed/dialects/ios.rb +173 -0
  30. data/lib/glazed/dialects/junos.rb +78 -0
  31. data/lib/glazed/dialects/nec_ix.rb +71 -0
  32. data/lib/glazed/dialects/wlc.rb +56 -0
  33. data/lib/glazed/dialects.rb +65 -0
  34. data/lib/glazed/finder.rb +62 -0
  35. data/lib/glazed/must.rb +54 -0
  36. data/lib/glazed/orchestrator/policy.rb +184 -0
  37. data/lib/glazed/orchestrator.rb +288 -0
  38. data/lib/glazed/plugin_registry.rb +70 -0
  39. data/lib/glazed/proto.rb +20 -0
  40. data/lib/glazed/proto_json.rb +34 -0
  41. data/lib/glazed/redactor.rb +158 -0
  42. data/lib/glazed/runner.rb +176 -0
  43. data/lib/glazed/secret.rb +74 -0
  44. data/lib/glazed/secrets.rb +59 -0
  45. data/lib/glazed/shell_command.rb +90 -0
  46. data/lib/glazed/sources/base.rb +79 -0
  47. data/lib/glazed/sources/command.rb +67 -0
  48. data/lib/glazed/sources/inline.rb +56 -0
  49. data/lib/glazed/sources/json_files.rb +87 -0
  50. data/lib/glazed/sources.rb +105 -0
  51. data/lib/glazed/state_backends/base.rb +72 -0
  52. data/lib/glazed/state_backends/sqlite.rb +299 -0
  53. data/lib/glazed/state_backends.rb +75 -0
  54. data/lib/glazed/step_context.rb +65 -0
  55. data/lib/glazed/steps/base.rb +89 -0
  56. data/lib/glazed/steps/batch_shell.rb +57 -0
  57. data/lib/glazed/steps/fetch.rb +67 -0
  58. data/lib/glazed/steps/publish.rb +94 -0
  59. data/lib/glazed/steps/redact.rb +77 -0
  60. data/lib/glazed/steps/shell.rb +107 -0
  61. data/lib/glazed/steps.rb +83 -0
  62. data/lib/glazed/storages/base.rb +167 -0
  63. data/lib/glazed/storages/filesystem.rb +144 -0
  64. data/lib/glazed/storages/git.rb +181 -0
  65. data/lib/glazed/storages/github.rb +269 -0
  66. data/lib/glazed/storages/memory.rb +125 -0
  67. data/lib/glazed/storages.rb +88 -0
  68. data/lib/glazed/transports/base.rb +103 -0
  69. data/lib/glazed/transports/ssh.rb +177 -0
  70. data/lib/glazed/transports/telnet.rb +100 -0
  71. data/lib/glazed/transports.rb +28 -0
  72. data/lib/glazed/v1/config_pb.rb +25 -0
  73. data/lib/glazed/v1/inventory_pb.rb +27 -0
  74. data/lib/glazed/v1/orchestrator_pb.rb +27 -0
  75. data/lib/glazed/v1/redaction_pb.rb +23 -0
  76. data/lib/glazed/v1/secret_pb.rb +22 -0
  77. data/lib/glazed/v1/state_pb.rb +25 -0
  78. data/lib/glazed/v1/steps_pb.rb +25 -0
  79. data/lib/glazed/v1/storage_pb.rb +34 -0
  80. data/lib/glazed/v1/workflow_pb.rb +29 -0
  81. data/lib/glazed/validation.rb +31 -0
  82. data/lib/glazed/version.rb +9 -0
  83. data/lib/glazed/workflow.rb +70 -0
  84. data/lib/glazed.rb +18 -0
  85. metadata +245 -0
@@ -0,0 +1,299 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ require 'fileutils'
7
+ require 'securerandom'
8
+ require 'tmpdir'
9
+ require 'sqlite3'
10
+ require 'glazed'
11
+ require 'glazed/must'
12
+ require 'glazed/proto'
13
+ require 'glazed/runner'
14
+ require 'glazed/state_backends/base'
15
+ require 'glazed/step_context'
16
+ require 'glazed/storages/base'
17
+ require 'glazed/validation'
18
+
19
+ module Glazed
20
+ module StateBackends
21
+ # SQLite database of node schedules. With a path, the file is the state
22
+ # and processes on the host share it through SQLite locking. With a
23
+ # storage, each process works on a private copy: it downloads the copy
24
+ # again whenever the storage holds a newer version, and uploads it after
25
+ # each change with a conditional write, redoing the change on the newer
26
+ # copy when another process uploaded first.
27
+ #
28
+ # @example Local file
29
+ # Glazed::StateBackends::Sqlite.new(path: '/var/lib/glazed/state.sqlite3')
30
+ # @example Kept in S3
31
+ # Glazed::StateBackends::Sqlite.new(storage: Glazed::Storages::S3.new(bucket: 'netconf'), key: 'glazed/state.sqlite3')
32
+ class Sqlite < Base
33
+ # Raised when the database was written by an incompatible glazed version.
34
+ class SchemaMismatch < Glazed::Error; end
35
+
36
+ # Version of the database layout.
37
+ SCHEMA_VERSION = 1 #: Integer
38
+ # Attempts at a change that keeps conflicting with other processes.
39
+ MAX_ATTEMPTS = 5 #: Integer
40
+ # Milliseconds to wait for another process holding the database lock.
41
+ BUSY_TIMEOUT_MS = 10_000 #: Integer
42
+
43
+ # @param path [String, nil] local database file, created when missing
44
+ # @param storage [Storages::Base, nil] storage holding the database, supporting conditional writes
45
+ # @param key [String, nil] key of the database in the storage
46
+ # @param listener [Proc] receives an event whenever a change is redone after a conflict
47
+ # @raise [ArgumentError] unless either path, or storage and key are given, or when the storage is not conditional
48
+ #: (?path: String?, ?storage: Storages::Base?, ?key: String?, ?listener: Runner::listener) -> void
49
+ def initialize(path: nil, storage: nil, key: nil, listener: ->(_event) {})
50
+ unless path.nil? ? storage && key : storage.nil? && key.nil?
51
+ raise ArgumentError, 'set either path, or storage and key'
52
+ end
53
+ raise ArgumentError, "#{storage.class.name} does not support conditional writes" if storage && !storage.conditional?
54
+
55
+ @storage = storage #: Storages::Base?
56
+ @key = key #: String?
57
+ @listener = listener #: Runner::listener
58
+ @tmpdir = (Dir.mktmpdir('glazed-state') unless path) #: String?
59
+ @path = path || ::File.join(Must.expect(@tmpdir), 'state.sqlite3') #: String
60
+ @db = nil #: SQLite3::Database?
61
+ @version = nil #: String?
62
+ @lock = Mutex.new #: Thread::Mutex
63
+ FileUtils.mkdir_p(::File.dirname(@path))
64
+ end
65
+
66
+ # Builds a state backend from {Proto::V1::SqliteStateBackend}.
67
+ #
68
+ # @param settings [Google::Protobuf::MessageExts] a {Proto::V1::SqliteStateBackend}
69
+ # @param context [StepContext] configured storages, and where retries are reported
70
+ # @return [Sqlite] the state backend
71
+ # @raise [Must::Violation] when settings is another message
72
+ # @raise [StepContext::UnknownStorage] when the storage is not configured
73
+ # @raise [ArgumentError] when the storage does not support conditional writes
74
+ #: (Google::Protobuf::MessageExts settings, context: StepContext) -> Sqlite
75
+ def self.from_proto(settings, context:)
76
+ sqlite = Must.be_a(settings, Proto::V1::SqliteStateBackend)
77
+ return new(path: sqlite.path) unless sqlite.path.empty?
78
+
79
+ new(storage: context.storage(sqlite.storage), key: sqlite.key, listener: ->(event) { context.notify(**event) })
80
+ end
81
+
82
+ # @param workflow [String] workflow name
83
+ # @param nodes [Array<String>] node names
84
+ # @yieldparam node [String] node name
85
+ # @yieldparam record [Proto::V1::NodeSchedule, nil] the current record, or nil when there is none
86
+ # @yieldreturn [Proto::V1::NodeSchedule, nil] the new record, or nil to delete it
87
+ # @return [Hash{String => Proto::V1::NodeSchedule}] records held afterwards, by node name
88
+ # @raise [ArgumentError] when the block returns a record of another workflow or node
89
+ # @raise [InvalidMessage] when the block returns an invalid record
90
+ # @raise [Storages::Base::Conflict] when other processes keep changing the database
91
+ #: (String workflow, Array[String] nodes) { (String node, Proto::V1::NodeSchedule? record) -> Proto::V1::NodeSchedule? } -> Hash[String, Proto::V1::NodeSchedule]
92
+ def update_all(workflow, nodes, &block)
93
+ write do |db|
94
+ result = {} #: Hash[String, Proto::V1::NodeSchedule]
95
+ nodes.uniq.each do |node|
96
+ stored = db.get_first_value('select record from schedules where workflow = ? and node = ?', workflow, node) #: String?
97
+ updated = block.call(node, stored && Proto::V1::NodeSchedule.decode(stored))
98
+ unless updated
99
+ db.execute('delete from schedules where workflow = ? and node = ?', [workflow, node]) if stored
100
+ next
101
+ end
102
+
103
+ unless updated.workflow == workflow && updated.node == node
104
+ raise ArgumentError, "record of #{updated.workflow}/#{updated.node} returned for #{workflow}/#{node}"
105
+ end
106
+
107
+ Validation.validate!(updated)
108
+ encoded = Proto::V1::NodeSchedule.encode(updated)
109
+ save(db, updated, encoded) unless encoded == stored
110
+ result[node] = updated
111
+ end
112
+ result
113
+ end
114
+ end
115
+
116
+ # @param workflow [String] workflow name
117
+ # @param now [Time] the time; a lease ending at or before it has expired
118
+ # @param limit [Integer, nil] records returned at most; all when nil
119
+ # @return [Array<Proto::V1::NodeSchedule>] the records, earliest due first
120
+ #: (String workflow, now: Time, ?limit: Integer?) -> Array[Proto::V1::NodeSchedule]
121
+ def list_due(workflow, now:, limit: nil)
122
+ at = (now.to_r * 1000).floor
123
+ query(
124
+ 'select record from schedules where workflow = ? and due_at_ms <= ? and lease_until_ms <= ? order by due_at_ms, node limit ?',
125
+ [workflow, at, at, limit || -1],
126
+ )
127
+ end
128
+
129
+ # @param workflow [String] workflow name
130
+ # @return [Array<Proto::V1::NodeSchedule>] every record of the workflow, by node name
131
+ #: (String workflow) -> Array[Proto::V1::NodeSchedule]
132
+ def list(workflow) = query('select record from schedules where workflow = ? order by node', [workflow])
133
+
134
+ # Closes the database and removes the private copy, if any.
135
+ #
136
+ # @return [void]
137
+ #: () -> void
138
+ def close
139
+ @lock.synchronize do
140
+ disconnect
141
+ tmpdir = @tmpdir
142
+ FileUtils.rm_rf(tmpdir) if tmpdir
143
+ end
144
+ end
145
+
146
+ # @param sql [String] select statement returning records
147
+ # @param binds [Array<String, Integer>] bound values
148
+ # @return [Array<Proto::V1::NodeSchedule>] the records
149
+ #: (String sql, Array[String | Integer] binds) -> Array[Proto::V1::NodeSchedule]
150
+ private def query(sql, binds)
151
+ @lock.synchronize do
152
+ rows = connection.execute(sql, binds) #: Array[[String]]
153
+ rows.map { |(record)| Proto::V1::NodeSchedule.decode(record) }
154
+ end
155
+ end
156
+
157
+ # Runs a change in a transaction, then uploads the database when it
158
+ # changed and is kept in a storage. A conflicting upload redoes the change.
159
+ #
160
+ # @yieldparam db [SQLite3::Database] the database, in a transaction
161
+ # @return [Object] what the block returns
162
+ # @raise [Storages::Base::Conflict] when the upload keeps conflicting
163
+ #: [T] () { (SQLite3::Database db) -> T } -> T
164
+ private def write(&block)
165
+ @lock.synchronize do
166
+ attempts = 0
167
+ begin
168
+ attempts += 1
169
+ db = connection
170
+ changes = db.total_changes
171
+ result = db.transaction(:immediate) { block.call(db) } #: T
172
+ upload if @storage && db.total_changes != changes
173
+ result
174
+ rescue Storages::Base::Conflict => e
175
+ raise if attempts >= MAX_ATTEMPTS
176
+
177
+ @listener.call({level: 'info', message: "state changed by another process, redoing the change: #{e.message}"})
178
+ sleep(rand * 0.05 * (2**attempts))
179
+ retry
180
+ end
181
+ end
182
+ end
183
+
184
+ # @param db [SQLite3::Database] the database, in a transaction
185
+ # @param record [Proto::V1::NodeSchedule] record to save
186
+ # @param encoded [String] the record encoded
187
+ # @return [void]
188
+ #: (SQLite3::Database db, Proto::V1::NodeSchedule record, String encoded) -> void
189
+ private def save(db, record, encoded)
190
+ db.execute(<<~SQL, [record.workflow, record.node, milliseconds(record.due_at), milliseconds(record.lease_until), SQLite3::Blob.new(encoded)])
191
+ insert into schedules (workflow, node, due_at_ms, lease_until_ms, record) values (?, ?, ?, ?, ?)
192
+ on conflict (workflow, node) do update set
193
+ due_at_ms = excluded.due_at_ms, lease_until_ms = excluded.lease_until_ms, record = excluded.record
194
+ SQL
195
+ end
196
+
197
+ # @param timestamp [Google::Protobuf::Timestamp, nil] the time
198
+ # @return [Integer] milliseconds since the epoch; 0 when unset
199
+ #: (Google::Protobuf::Timestamp? timestamp) -> Integer
200
+ private def milliseconds(timestamp)
201
+ timestamp ? (timestamp.seconds * 1000) + (timestamp.nanos / 1_000_000) : 0
202
+ end
203
+
204
+ # Opens the database, first replacing the private copy when the storage
205
+ # holds a version this process has not seen.
206
+ #
207
+ # @return [SQLite3::Database] the database
208
+ # @raise [SchemaMismatch] when the database has an incompatible layout
209
+ #: () -> SQLite3::Database
210
+ private def connection
211
+ storage = @storage
212
+ key = @key
213
+ db = @db
214
+ return db || connect unless storage && key
215
+ return db if db && storage.version_of(key) == @version
216
+
217
+ disconnect
218
+ latest = storage.read_versioned(key)
219
+ if latest
220
+ temporary = "#{@path}.#{SecureRandom.hex(8)}.tmp"
221
+ ::File.binwrite(temporary, latest.content)
222
+ ::File.rename(temporary, @path)
223
+ end
224
+ @version = latest&.version
225
+ connect
226
+ end
227
+
228
+ # Uploads the private copy, expecting the storage to still hold the version it was made from.
229
+ #
230
+ # @return [void]
231
+ # @raise [Storages::Base::Conflict] when the storage holds another version
232
+ #: () -> void
233
+ private def upload
234
+ succeeded = false
235
+ @version = Must.expect(@storage).write_if(key: Must.expect(@key), content: ::File.binread(@path), expected_version: @version)
236
+ succeeded = true
237
+ ensure
238
+ # The private copy holds a change the storage lacks, so it is fetched again.
239
+ disconnect unless succeeded
240
+ end
241
+
242
+ # @return [SQLite3::Database] the database, with its schema ensured
243
+ # @raise [SchemaMismatch] when the database has an incompatible layout
244
+ #: () -> SQLite3::Database
245
+ private def connect
246
+ db = SQLite3::Database.new(@path, strict: true)
247
+ db.busy_timeout = BUSY_TIMEOUT_MS
248
+ ensure_schema(db)
249
+ @db = db
250
+ rescue SchemaMismatch
251
+ db&.close
252
+ raise
253
+ end
254
+
255
+ # Closes the database, removing the private copy so it is fetched again.
256
+ #
257
+ # @return [void]
258
+ #: () -> void
259
+ private def disconnect
260
+ @db&.close
261
+ @db = nil
262
+ return unless @storage
263
+
264
+ ::File.unlink(@path) if ::File.exist?(@path)
265
+ @version = nil
266
+ end
267
+
268
+ # @param db [SQLite3::Database] the database
269
+ # @return [void]
270
+ # @raise [SchemaMismatch] when the database has an incompatible layout
271
+ #: (SQLite3::Database db) -> void
272
+ private def ensure_schema(db)
273
+ db.transaction(:immediate) do
274
+ db.execute('create table if not exists glazed_schema (version integer not null) strict')
275
+ version = db.get_first_value('select version from glazed_schema') #: Integer?
276
+ case version
277
+ when SCHEMA_VERSION
278
+ nil
279
+ when nil
280
+ db.execute(<<~SQL)
281
+ create table schedules (
282
+ workflow text not null,
283
+ node text not null,
284
+ due_at_ms integer not null,
285
+ lease_until_ms integer not null,
286
+ record blob not null,
287
+ primary key (workflow, node)
288
+ ) strict
289
+ SQL
290
+ db.execute('create index schedules_due on schedules (workflow, due_at_ms)')
291
+ db.execute('insert into glazed_schema (version) values (?)', [SCHEMA_VERSION])
292
+ else
293
+ raise SchemaMismatch, "#{@path} has state schema version #{version}, while this glazed uses #{SCHEMA_VERSION}"
294
+ end
295
+ end
296
+ end
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,75 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ require 'glazed'
7
+ require 'glazed/finder'
8
+ require 'glazed/must'
9
+ require 'glazed/plugin_registry'
10
+ require 'glazed/proto'
11
+ require 'glazed/state_backends/base'
12
+ require 'glazed/step_context'
13
+ require 'glazed/validation'
14
+
15
+ module Glazed
16
+ # Namespace of state backend implementations, looked up by name.
17
+ module StateBackends
18
+ # Raised when a state backend config names no backend.
19
+ class MissingBackend < Glazed::Error; end
20
+
21
+ # Finds a state backend class by snake_case name.
22
+ #
23
+ # @param name [String, Symbol] state backend name, such as "sqlite"
24
+ # @return [Class<Base>] the state backend class
25
+ # @raise [Finder::NotFound] when there is none, for example when the gem providing it is not installed
26
+ # @raise [Must::Violation] when the name resolves to something other than a state backend
27
+ #: (String | Symbol name) -> singleton(Base)
28
+ def self.find(name) = state_backend_class(Finder.find(self, 'glazed/state_backends', name))
29
+
30
+ # State backends implemented by plugins, by settings message type.
31
+ PLUGINS = PluginRegistry.new('state backend') #: PluginRegistry
32
+
33
+ # Registers a plugin state backend, selected by `plugin` configs whose
34
+ # settings are of the given type.
35
+ #
36
+ # @param settings_class [Class] generated message class of the state backend settings
37
+ # @param implementation [Class<Base>] state backend built with {Base.from_proto}
38
+ # @return [void]
39
+ #: (singleton(Google::Protobuf::AbstractMessage) settings_class, singleton(Base) implementation) -> void
40
+ def self.register(settings_class, implementation)
41
+ PLUGINS.register(settings_class, implementation)
42
+ end
43
+
44
+ # Builds the state backend a config selects.
45
+ #
46
+ # @param config [Proto::V1::StateBackendConfig] state backend settings
47
+ # @param context [StepContext] configured storages, and where progress is reported
48
+ # @return [Base] the state backend
49
+ # @raise [InvalidMessage] when the config breaks the rules of its schema
50
+ # @raise [Finder::NotFound] when the backend is not available
51
+ # @raise [PluginRegistry::UnknownPlugin] when no plugin is registered for the settings type
52
+ #: (Proto::V1::StateBackendConfig config, context: StepContext) -> Base
53
+ def self.build(config, context:)
54
+ Validation.validate!(config)
55
+ backend = config.backend or raise MissingBackend, 'state backend config sets no backend'
56
+ if (packed = config.plugin)
57
+ implementation, settings = PLUGINS.resolve(packed)
58
+ return state_backend_class(implementation).from_proto(settings, context:)
59
+ end
60
+
61
+ settings = Must.be_a(config[backend.to_s], Google::Protobuf::MessageExts)
62
+ find(backend).from_proto(settings, context:)
63
+ end
64
+
65
+ # @param implementation [Module] registered or found implementation
66
+ # @return [Class<Base>] the implementation as a state backend class
67
+ # @raise [Must::Violation] when it is not a state backend
68
+ #: (Module[untyped] implementation) -> singleton(Base)
69
+ private_class_method def self.state_backend_class(implementation)
70
+ return implementation if implementation.is_a?(Class) && implementation < Base
71
+
72
+ raise Must::Violation, "#{implementation} is not a state backend"
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,65 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ require 'google/protobuf'
7
+ require 'glazed'
8
+ require 'glazed/proto'
9
+ require 'glazed/storages'
10
+
11
+ module Glazed
12
+ # Named storages shared by the steps of a workflow, and where steps report progress.
13
+ class StepContext
14
+ # Raised when a step names a storage that is not configured.
15
+ class UnknownStorage < Glazed::Error; end
16
+
17
+ # @param storages [Hash{String => Storages::Base}] storages by name
18
+ # @param listener [Proc] receives progress events from steps, possibly from several threads at once
19
+ #: (storages: Hash[String, Storages::Base], ?listener: Runner::listener) -> void
20
+ def initialize(storages:, listener: ->(_event) {})
21
+ @storages = storages #: Hash[String, Storages::Base]
22
+ @listener = listener #: Runner::listener
23
+ end
24
+
25
+ # Builds every configured storage.
26
+ #
27
+ # @param storages [Google::Protobuf::Map] storage configs by name, from {Proto::V1::Config#storages}
28
+ # @param listener [Proc] receives progress events from steps, possibly from several threads at once
29
+ # @return [StepContext] the context
30
+ # @raise [Storages::MissingBackend] when a storage config sets no backend
31
+ #: (Google::Protobuf::Map[String, Proto::V1::StorageConfig] storages, ?listener: Runner::listener) -> StepContext
32
+ def self.from_proto(storages, listener: ->(_event) {})
33
+ # Map#to_h would turn the messages into hashes.
34
+ built = {} #: Hash[String, Storages::Base]
35
+ storages.each { |name, config| built[name] = Storages.build(config) }
36
+ new(storages: built, listener:)
37
+ end
38
+
39
+ # Reports progress of a step.
40
+ #
41
+ # @param event [Hash{Symbol => String, Integer, Boolean, nil}] event fields
42
+ # @return [void]
43
+ #: (**Runner::event_value event) -> void
44
+ def notify(**event) = @listener.call(event)
45
+
46
+ # @param name [String] storage name
47
+ # @return [Storages::Base] the storage
48
+ # @raise [UnknownStorage] when no storage has that name
49
+ #: (String name) -> Storages::Base
50
+ def storage(name)
51
+ @storages.fetch(name) { raise UnknownStorage, "no storage named #{name.inspect}" }
52
+ end
53
+
54
+ # Reads an artifact's content from its storage.
55
+ #
56
+ # @param artifact [Proto::V1::Artifact] the artifact
57
+ # @return [String] its content
58
+ # @raise [UnknownStorage] when the artifact's storage is not configured
59
+ #: (Proto::V1::Artifact artifact) -> String
60
+ def read(artifact)
61
+ pointer = artifact.pointer or raise ArgumentError, "artifact in #{artifact.storage} has no pointer"
62
+ storage(artifact.storage).read(pointer)
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,89 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ require 'google/protobuf'
7
+ require 'glazed'
8
+ require 'glazed/proto'
9
+ require 'glazed/storages/base'
10
+
11
+ module Glazed
12
+ module Steps
13
+ # Base of workflow steps. Subclass {NodeStep} or {BatchStep}.
14
+ class Base
15
+ # Raised when a state lacks an artifact the step reads.
16
+ class MissingArtifact < Glazed::Error; end
17
+
18
+ # Builds a step from its settings message.
19
+ #
20
+ # @param name [String] step name, unique within the workflow
21
+ # @param settings [Google::Protobuf::MessageExts] action settings from {Proto::V1::Step}
22
+ # @param context [StepContext] named storages
23
+ # @return [Base] the step
24
+ #: (String name, Google::Protobuf::MessageExts settings, context: StepContext) -> Base
25
+ def self.from_proto(name, settings, context:) = raise NotImplementedError
26
+
27
+ # @param name [String] step name, unique within the workflow
28
+ #: (name: String) -> void
29
+ def initialize(name:)
30
+ @name = name #: String
31
+ end
32
+
33
+ # @return [String] step name
34
+ #: String
35
+ attr_reader :name
36
+
37
+ # @return [Array<String>] names of the artifacts the step reads
38
+ #: () -> Array[String]
39
+ def inputs = []
40
+
41
+ # @return [Array<String>] names of the artifacts the step produces
42
+ #: () -> Array[String]
43
+ def outputs = []
44
+
45
+ # @param state [Proto::V1::NodeState] state to read from
46
+ # @param artifact [String] artifact name
47
+ # @return [Proto::V1::Artifact] the artifact
48
+ # @raise [MissingArtifact] when the state lacks it
49
+ #: (Proto::V1::NodeState state, String artifact) -> Proto::V1::Artifact
50
+ private def artifact(state, artifact)
51
+ state.artifacts[artifact] or raise MissingArtifact, "#{name}: #{state.node&.name} has no artifact #{artifact.inspect}"
52
+ end
53
+
54
+ # @param storage_name [String] name of the storage written to
55
+ # @param result [Storages::Base::WriteResult] outcome of the write
56
+ # @return [Proto::V1::Artifact] artifact naming the stored content and whether it changed
57
+ #: (String storage_name, Storages::Base::WriteResult result) -> Proto::V1::Artifact
58
+ private def stored(storage_name, result)
59
+ Proto::V1::Artifact.new(storage: storage_name, pointer: result.pointer, changed: result.changed?)
60
+ end
61
+
62
+ # @param state [Proto::V1::NodeState] state to extend
63
+ # @param artifacts [Hash{String => Proto::V1::Artifact}] artifacts to add
64
+ # @return [Proto::V1::NodeState] a copy of the state with the artifacts
65
+ #: (Proto::V1::NodeState state, Hash[String, Proto::V1::Artifact] artifacts) -> Proto::V1::NodeState
66
+ private def with_artifacts(state, artifacts)
67
+ copy = Proto::V1::NodeState.decode(Proto::V1::NodeState.encode(state))
68
+ artifacts.each { |key, value| copy.artifacts[key] = value }
69
+ copy
70
+ end
71
+ end
72
+
73
+ # Step run for each node independently.
74
+ class NodeStep < Base
75
+ # @param state [Proto::V1::NodeState] the node and its artifacts
76
+ # @return [Proto::V1::NodeState] the state with the step's outputs added
77
+ #: (Proto::V1::NodeState state) -> Proto::V1::NodeState
78
+ def call(state) = raise NotImplementedError
79
+ end
80
+
81
+ # Step run once with every node that completed the steps before it.
82
+ class BatchStep < Base
83
+ # @param batch [Proto::V1::BatchState] node states
84
+ # @return [Proto::V1::BatchState] the states with the step's outputs added
85
+ #: (Proto::V1::BatchState batch) -> Proto::V1::BatchState
86
+ def call(batch) = raise NotImplementedError
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,57 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ require 'glazed/must'
7
+ require 'glazed/proto'
8
+ require 'glazed/proto_json'
9
+ require 'glazed/shell_command'
10
+ require 'glazed/steps'
11
+
12
+ module Glazed
13
+ module Steps
14
+ # Batch: runs a command with /bin/sh -c once, with the
15
+ # {Proto::V1::BatchState} of every node that got this far as ProtoJSON on
16
+ # stdin, for hooks such as notifications.
17
+ #
18
+ # @example Step definition
19
+ # {"name": "notify", "batch_shell": {"run": "bin/notify-changes"}}
20
+ class BatchShell < BatchStep
21
+ # Seconds a command may run when the definition sets none.
22
+ DEFAULT_TIMEOUT = 300 #: Integer
23
+
24
+ # @param name [String] step name
25
+ # @param run [String] command line
26
+ # @param timeout [Numeric] seconds before the command is killed
27
+ #: (name: String, run: String, ?timeout: Numeric) -> void
28
+ def initialize(name:, run:, timeout: DEFAULT_TIMEOUT)
29
+ super(name:)
30
+ @run = run #: String
31
+ @timeout = timeout #: Numeric
32
+ end
33
+
34
+ # Builds the step from {Proto::V1::BatchShellStep}.
35
+ #
36
+ # @param name [String] step name
37
+ # @param settings [Google::Protobuf::MessageExts] a {Proto::V1::BatchShellStep}
38
+ # @param context [StepContext] named storages, unused
39
+ # @return [BatchShell] the step
40
+ #: (String name, Google::Protobuf::MessageExts settings, context: StepContext) -> BatchShell
41
+ def self.from_proto(name, settings, context:)
42
+ shell = Must.be_a(settings, Proto::V1::BatchShellStep)
43
+ new(name:, run: shell.run, timeout: shell.timeout_seconds.zero? ? DEFAULT_TIMEOUT : shell.timeout_seconds)
44
+ end
45
+
46
+ # @param batch [Proto::V1::BatchState] node states
47
+ # @return [Proto::V1::BatchState] the same states
48
+ # @raise [ShellCommand::Failed] when the command exits non-zero
49
+ # @raise [TimeoutError] when the command does not finish in time
50
+ #: (Proto::V1::BatchState batch) -> Proto::V1::BatchState
51
+ def call(batch)
52
+ ShellCommand.new(run: @run, env: {'GLAZED_STEP' => name}, stdin: ProtoJson.dump(batch), timeout: @timeout).call
53
+ batch
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,67 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ require 'glazed/cli_session'
7
+ require 'glazed/collector'
8
+ require 'glazed/must'
9
+ require 'glazed/proto'
10
+ require 'glazed/steps'
11
+ require 'glazed/storages'
12
+
13
+ module Glazed
14
+ module Steps
15
+ # Per node: collects the node's configuration and stores it as an artifact.
16
+ #
17
+ # @example Step definition
18
+ # {"name": "fetch", "fetch": {"output": "raw", "storage": "vault"}}
19
+ class Fetch < NodeStep
20
+ # @param name [String] step name
21
+ # @param output [String] artifact to produce
22
+ # @param storage_name [String] storage to write to, by name
23
+ # @param context [StepContext] named storages, and where progress is reported
24
+ # @param timeout [Numeric] seconds to wait for each expected device output
25
+ # @raise [StepContext::UnknownStorage] when the storage is not configured
26
+ #: (name: String, output: String, storage_name: String, context: StepContext, ?timeout: Numeric) -> void
27
+ def initialize(name:, output:, storage_name:, context:, timeout: CliSession::DEFAULT_TIMEOUT)
28
+ super(name:)
29
+ @output = output #: String
30
+ @storage_name = storage_name #: String
31
+ @context = context #: StepContext
32
+ @storage = context.storage(storage_name) #: Storages::Base
33
+ @timeout = timeout #: Numeric
34
+ end
35
+
36
+ # Builds the step from {Proto::V1::FetchStep}.
37
+ #
38
+ # @param name [String] step name
39
+ # @param settings [Google::Protobuf::MessageExts] a {Proto::V1::FetchStep}
40
+ # @param context [StepContext] named storages
41
+ # @return [Fetch] the step
42
+ #: (String name, Google::Protobuf::MessageExts settings, context: StepContext) -> Fetch
43
+ def self.from_proto(name, settings, context:)
44
+ fetch = Must.be_a(settings, Proto::V1::FetchStep)
45
+ timeout = fetch.timeout_seconds.zero? ? CliSession::DEFAULT_TIMEOUT : fetch.timeout_seconds
46
+ new(name:, output: fetch.output, storage_name: fetch.storage, context:, timeout:)
47
+ end
48
+
49
+ # @return [Array<String>] the produced artifact
50
+ #: () -> Array[String]
51
+ def outputs = [@output]
52
+
53
+ # @param state [Proto::V1::NodeState] the node
54
+ # @return [Proto::V1::NodeState] the state with the collected configuration
55
+ # @raise [ArgumentError] when the state has no node
56
+ # @raise [Glazed::Error] when collecting or storing fails
57
+ #: (Proto::V1::NodeState state) -> Proto::V1::NodeState
58
+ def call(state)
59
+ node = state.node or raise ArgumentError, "#{name}: state has no node"
60
+ progress = ->(message) { @context.notify(level: 'info', step: name, node: node.name, message:) } #: ^(String) -> void
61
+ config = Collector.new(node:, timeout: @timeout, progress:).call
62
+ result = @storage.write(key: Storages.key_for(node), content: config, message: "#{node.name}: #{name}")
63
+ with_artifacts(state, @output => stored(@storage_name, result))
64
+ end
65
+ end
66
+ end
67
+ end