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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.yardopts +5 -0
- data/CHANGELOG.md +18 -0
- data/LICENSE.txt +202 -0
- data/NOTICE +16 -0
- data/README.md +3 -0
- data/Rakefile +12 -0
- data/exe/glazed +8 -0
- data/lib/glazed/cli_session.rb +178 -0
- data/lib/glazed/collector.rb +113 -0
- data/lib/glazed/command/base.rb +146 -0
- data/lib/glazed/command/orchestrate.rb +78 -0
- data/lib/glazed/command/run.rb +51 -0
- data/lib/glazed/command/send_signal.rb +69 -0
- data/lib/glazed/command/status.rb +179 -0
- data/lib/glazed/command/version.rb +23 -0
- data/lib/glazed/command.rb +39 -0
- data/lib/glazed/command_result.rb +26 -0
- data/lib/glazed/dialect/actions.rb +95 -0
- data/lib/glazed/dialect/communication_config.rb +63 -0
- data/lib/glazed/dialect/interaction.rb +32 -0
- data/lib/glazed/dialect/run.rb +84 -0
- data/lib/glazed/dialect/runbook.rb +97 -0
- data/lib/glazed/dialect/sensitive_pattern.rb +31 -0
- data/lib/glazed/dialect.rb +12 -0
- data/lib/glazed/dialects/awplus.rb +84 -0
- data/lib/glazed/dialects/base.rb +246 -0
- data/lib/glazed/dialects/ios.rb +173 -0
- data/lib/glazed/dialects/junos.rb +78 -0
- data/lib/glazed/dialects/nec_ix.rb +71 -0
- data/lib/glazed/dialects/wlc.rb +56 -0
- data/lib/glazed/dialects.rb +65 -0
- data/lib/glazed/finder.rb +62 -0
- data/lib/glazed/must.rb +54 -0
- data/lib/glazed/orchestrator/policy.rb +184 -0
- data/lib/glazed/orchestrator.rb +288 -0
- data/lib/glazed/plugin_registry.rb +70 -0
- data/lib/glazed/proto.rb +20 -0
- data/lib/glazed/proto_json.rb +34 -0
- data/lib/glazed/redactor.rb +158 -0
- data/lib/glazed/runner.rb +176 -0
- data/lib/glazed/secret.rb +74 -0
- data/lib/glazed/secrets.rb +59 -0
- data/lib/glazed/shell_command.rb +90 -0
- data/lib/glazed/sources/base.rb +79 -0
- data/lib/glazed/sources/command.rb +67 -0
- data/lib/glazed/sources/inline.rb +56 -0
- data/lib/glazed/sources/json_files.rb +87 -0
- data/lib/glazed/sources.rb +105 -0
- data/lib/glazed/state_backends/base.rb +72 -0
- data/lib/glazed/state_backends/sqlite.rb +299 -0
- data/lib/glazed/state_backends.rb +75 -0
- data/lib/glazed/step_context.rb +65 -0
- data/lib/glazed/steps/base.rb +89 -0
- data/lib/glazed/steps/batch_shell.rb +57 -0
- data/lib/glazed/steps/fetch.rb +67 -0
- data/lib/glazed/steps/publish.rb +94 -0
- data/lib/glazed/steps/redact.rb +77 -0
- data/lib/glazed/steps/shell.rb +107 -0
- data/lib/glazed/steps.rb +83 -0
- data/lib/glazed/storages/base.rb +167 -0
- data/lib/glazed/storages/filesystem.rb +144 -0
- data/lib/glazed/storages/git.rb +181 -0
- data/lib/glazed/storages/github.rb +269 -0
- data/lib/glazed/storages/memory.rb +125 -0
- data/lib/glazed/storages.rb +88 -0
- data/lib/glazed/transports/base.rb +103 -0
- data/lib/glazed/transports/ssh.rb +177 -0
- data/lib/glazed/transports/telnet.rb +100 -0
- data/lib/glazed/transports.rb +28 -0
- data/lib/glazed/v1/config_pb.rb +25 -0
- data/lib/glazed/v1/inventory_pb.rb +27 -0
- data/lib/glazed/v1/orchestrator_pb.rb +27 -0
- data/lib/glazed/v1/redaction_pb.rb +23 -0
- data/lib/glazed/v1/secret_pb.rb +22 -0
- data/lib/glazed/v1/state_pb.rb +25 -0
- data/lib/glazed/v1/steps_pb.rb +25 -0
- data/lib/glazed/v1/storage_pb.rb +34 -0
- data/lib/glazed/v1/workflow_pb.rb +29 -0
- data/lib/glazed/validation.rb +31 -0
- data/lib/glazed/version.rb +9 -0
- data/lib/glazed/workflow.rb +70 -0
- data/lib/glazed.rb +18 -0
- 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
|