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,65 @@
|
|
|
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/dialects/base'
|
|
10
|
+
|
|
11
|
+
module Glazed
|
|
12
|
+
# Namespace of dialects, looked up by name.
|
|
13
|
+
module Dialects
|
|
14
|
+
#: type resolver = ^(String name) -> singleton(Base)?
|
|
15
|
+
|
|
16
|
+
@resolvers = [] #: Array[Dialects::resolver]
|
|
17
|
+
@resolvers_lock = Mutex.new #: Thread::Mutex
|
|
18
|
+
|
|
19
|
+
# Finds a dialect class by snake_case name, then asks each resolver in
|
|
20
|
+
# registration order.
|
|
21
|
+
#
|
|
22
|
+
# @param name [String, Symbol] dialect name, such as "ios"
|
|
23
|
+
# @return [Class<Base>] the dialect class
|
|
24
|
+
# @raise [Finder::NotFound] when there is none
|
|
25
|
+
# @raise [Must::Violation] when the name resolves to something other than a dialect
|
|
26
|
+
#: (String | Symbol name) -> singleton(Base)
|
|
27
|
+
def self.find(name)
|
|
28
|
+
found = Finder.maybe_find(self, 'glazed/dialects', name) || resolve(name.to_s)
|
|
29
|
+
raise Finder::NotFound, "Couldn't find #{name.inspect} for #{self}" unless found
|
|
30
|
+
return found if found.is_a?(Class) && found < Base
|
|
31
|
+
|
|
32
|
+
raise Must::Violation, "#{found} is not a dialect"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Registers a resolver for names that no dialect class or file answers to,
|
|
36
|
+
# such as dialects generated from another format.
|
|
37
|
+
#
|
|
38
|
+
# @yieldparam name [String] dialect name
|
|
39
|
+
# @yieldreturn [Class<Base>, nil] the dialect, or nil when the resolver has none by that name
|
|
40
|
+
# @return [void]
|
|
41
|
+
# @example
|
|
42
|
+
# Glazed::Dialects.add_resolver { |name| GENERATED[name] }
|
|
43
|
+
#: () { (String name) -> singleton(Base)? } -> void
|
|
44
|
+
def self.add_resolver(&resolver)
|
|
45
|
+
@resolvers_lock.synchronize { @resolvers << resolver }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Removes a resolver registered with {add_resolver}.
|
|
49
|
+
#
|
|
50
|
+
# @param resolver [Proc] the resolver
|
|
51
|
+
# @return [void]
|
|
52
|
+
#: (Dialects::resolver resolver) -> void
|
|
53
|
+
def self.remove_resolver(resolver)
|
|
54
|
+
@resolvers_lock.synchronize { @resolvers.delete(resolver) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @param name [String] dialect name
|
|
58
|
+
# @return [Class<Base>, nil] the first dialect a resolver returns
|
|
59
|
+
#: (String name) -> singleton(Base)?
|
|
60
|
+
private_class_method def self.resolve(name)
|
|
61
|
+
resolvers = @resolvers_lock.synchronize { @resolvers.dup }
|
|
62
|
+
resolvers.lazy.filter_map { it.call(name) }.first
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
module Glazed
|
|
7
|
+
# Resolves plugin classes by name, requiring `<prefix>/<name>` on demand so
|
|
8
|
+
# other gems can provide implementations.
|
|
9
|
+
module Finder
|
|
10
|
+
# Raised when no implementation is found for a name.
|
|
11
|
+
class NotFound < Glazed::Error; end
|
|
12
|
+
|
|
13
|
+
# Looks up the constant for a snake_case name under a namespace.
|
|
14
|
+
#
|
|
15
|
+
# @param namespace [Module] namespace holding implementations
|
|
16
|
+
# @param prefix [String] require path prefix for the namespace
|
|
17
|
+
# @param name [String, Symbol] snake_case implementation name
|
|
18
|
+
# @return [Module] the implementation
|
|
19
|
+
# @raise [NotFound] when the constant cannot be found even after requiring
|
|
20
|
+
# @example
|
|
21
|
+
# Glazed::Finder.find(Glazed::Storages, 'glazed/storages', 's3')
|
|
22
|
+
#: (Module[untyped] namespace, String prefix, String | Symbol name) -> Module[untyped]
|
|
23
|
+
def self.find(namespace, prefix, name)
|
|
24
|
+
maybe_find(namespace, prefix, name) or raise NotFound, "Couldn't find #{name.inspect} for #{namespace}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Looks up the constant for a snake_case name, returning nil when absent.
|
|
28
|
+
#
|
|
29
|
+
# @param namespace [Module] namespace holding implementations
|
|
30
|
+
# @param prefix [String] require path prefix for the namespace
|
|
31
|
+
# @param name [String, Symbol] snake_case implementation name
|
|
32
|
+
# @return [Module, nil] the implementation
|
|
33
|
+
# @raise [LoadError] when the implementation exists but fails to load its dependencies
|
|
34
|
+
#: (Module[untyped] namespace, String prefix, String | Symbol name) -> Module[untyped]?
|
|
35
|
+
def self.maybe_find(namespace, prefix, name)
|
|
36
|
+
constant_name = name.to_s.split('_').map(&:capitalize).join
|
|
37
|
+
return lookup(namespace, constant_name) if namespace.const_defined?(constant_name, false)
|
|
38
|
+
|
|
39
|
+
path = "#{prefix}/#{name}"
|
|
40
|
+
begin
|
|
41
|
+
require path
|
|
42
|
+
rescue LoadError => e
|
|
43
|
+
raise unless e.path == path
|
|
44
|
+
|
|
45
|
+
return nil
|
|
46
|
+
end
|
|
47
|
+
lookup(namespace, constant_name) if namespace.const_defined?(constant_name, false)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param namespace [Module] namespace holding implementations
|
|
51
|
+
# @param constant_name [String] constant to read
|
|
52
|
+
# @return [Module] the constant, which must be a module or class
|
|
53
|
+
# @raise [NotFound] when the constant is not a module
|
|
54
|
+
#: (Module[untyped] namespace, String constant_name) -> Module[untyped]
|
|
55
|
+
private_class_method def self.lookup(namespace, constant_name)
|
|
56
|
+
value = namespace.const_get(constant_name, false)
|
|
57
|
+
raise NotFound, "#{namespace}::#{constant_name} is not a module" unless value.is_a?(Module)
|
|
58
|
+
|
|
59
|
+
value
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/lib/glazed/must.rb
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
module Glazed
|
|
7
|
+
# Runtime counterpart of Sorbet's T.must for a value the type checker cannot
|
|
8
|
+
# prove present. The reason names the invariant that makes it so.
|
|
9
|
+
module Must
|
|
10
|
+
# Raised when an asserted invariant does not hold.
|
|
11
|
+
class Violation < StandardError; end
|
|
12
|
+
|
|
13
|
+
# Returns the value, or raises with the reason when it is nil.
|
|
14
|
+
#
|
|
15
|
+
# @param value [Object, nil] value expected to be present
|
|
16
|
+
# @yieldreturn [String] the invariant that makes the value present
|
|
17
|
+
# @return [Object] the value
|
|
18
|
+
# @raise [Violation] when the value is nil
|
|
19
|
+
#: [T] (T? value) { () -> String } -> T
|
|
20
|
+
def self.because(value, &_block)
|
|
21
|
+
case value
|
|
22
|
+
when nil then raise Violation, yield
|
|
23
|
+
else value
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Returns the value, or raises when it is nil.
|
|
28
|
+
#
|
|
29
|
+
# @param value [Object, nil] value expected to be present
|
|
30
|
+
# @return [Object] the value
|
|
31
|
+
# @raise [Violation] when the value is nil
|
|
32
|
+
#: [T] (T? value) -> T
|
|
33
|
+
def self.expect(value)
|
|
34
|
+
case value
|
|
35
|
+
when nil then raise Violation, 'expected a value, got nil'
|
|
36
|
+
else value
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Returns the value narrowed to the class or module, or raises.
|
|
41
|
+
#
|
|
42
|
+
# @param value [Object] value to narrow
|
|
43
|
+
# @param klass [Module] expected class or module
|
|
44
|
+
# @return [Object] the value
|
|
45
|
+
# @raise [Violation] when the value is not a klass
|
|
46
|
+
#: [T] (untyped value, Module[T] klass) -> T
|
|
47
|
+
def self.be_a(value, klass)
|
|
48
|
+
case value
|
|
49
|
+
when klass then value
|
|
50
|
+
else raise Violation, "expected #{klass}, got #{value.class}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
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
|
+
|
|
10
|
+
module Glazed
|
|
11
|
+
class Orchestrator
|
|
12
|
+
# How node schedules change on inventory changes, signals, claims and
|
|
13
|
+
# completed runs. Each function returns a new record and leaves the given
|
|
14
|
+
# one untouched, so state backends may call them more than once.
|
|
15
|
+
module Policy
|
|
16
|
+
# Seconds between runs when the schedule sets none.
|
|
17
|
+
DEFAULT_INTERVAL = 3600 #: Integer
|
|
18
|
+
# Seconds a signal waits for further signals when the debounce sets none.
|
|
19
|
+
DEFAULT_DELAY = 60 #: Integer
|
|
20
|
+
# Seconds after the first signal by which a node runs when the debounce sets none.
|
|
21
|
+
DEFAULT_MAX_WAIT = 600 #: Integer
|
|
22
|
+
|
|
23
|
+
# Record of a node new to the inventory, due within the schedule's jitter.
|
|
24
|
+
#
|
|
25
|
+
# @param workflow [String] workflow name
|
|
26
|
+
# @param node [String] node name
|
|
27
|
+
# @param now [Time] current time
|
|
28
|
+
# @param schedule [Proto::V1::Schedule, nil] the schedule
|
|
29
|
+
# @param random [Random] source of jitter
|
|
30
|
+
# @return [Proto::V1::NodeSchedule] the record
|
|
31
|
+
#: (String workflow, String node, now: Time, schedule: Proto::V1::Schedule?, random: Random) -> Proto::V1::NodeSchedule
|
|
32
|
+
def self.discovered(workflow, node, now:, schedule:, random:)
|
|
33
|
+
due = timestamp(now + jitter(schedule, random))
|
|
34
|
+
Proto::V1::NodeSchedule.new(workflow:, node:, due_at: due, scheduled_at: due, due_reason: 'inventory')
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Applies a signal with trailing debounce: the node becomes due the
|
|
38
|
+
# debounce delay after the latest signal, but no later than the maximum
|
|
39
|
+
# wait after the first signal not yet followed by a run. An earlier
|
|
40
|
+
# scheduled run is kept, unless the node is running, as that run started
|
|
41
|
+
# before the signal.
|
|
42
|
+
#
|
|
43
|
+
# @param record [Proto::V1::NodeSchedule, nil] the node's record; nil when it has none yet
|
|
44
|
+
# @param signal [Proto::V1::Signal] the signal
|
|
45
|
+
# @param workflow [String] workflow name
|
|
46
|
+
# @param now [Time] current time, used when the signal has no reception time
|
|
47
|
+
# @param debounce [Proto::V1::Debounce, nil] the debounce settings
|
|
48
|
+
# @return [Proto::V1::NodeSchedule] the record
|
|
49
|
+
#: (Proto::V1::NodeSchedule? record, Proto::V1::Signal signal, workflow: String, now: Time, debounce: Proto::V1::Debounce?) -> Proto::V1::NodeSchedule
|
|
50
|
+
def self.signal(record, signal, workflow:, now:, debounce:)
|
|
51
|
+
at = time(signal.received_at) || now
|
|
52
|
+
updated = record&.dup || Proto::V1::NodeSchedule.new(workflow:, node: signal.node)
|
|
53
|
+
running = running?(updated, now)
|
|
54
|
+
window = time(updated.signal_window_started_at)
|
|
55
|
+
started = time(updated.last_started_at)
|
|
56
|
+
window = at if window.nil? || (running && started && window <= started)
|
|
57
|
+
|
|
58
|
+
due = if signal.immediate
|
|
59
|
+
at
|
|
60
|
+
else
|
|
61
|
+
[at + nonzero(debounce&.delay_seconds, DEFAULT_DELAY), window + nonzero(debounce&.max_wait_seconds, DEFAULT_MAX_WAIT)].min
|
|
62
|
+
end
|
|
63
|
+
scheduled = time(updated.scheduled_at)
|
|
64
|
+
if !running && scheduled && scheduled <= due
|
|
65
|
+
updated.due_at = timestamp(scheduled)
|
|
66
|
+
else
|
|
67
|
+
updated.due_at = timestamp(due)
|
|
68
|
+
updated.due_reason = signal.source
|
|
69
|
+
end
|
|
70
|
+
updated.signal_window_started_at = timestamp(window)
|
|
71
|
+
updated
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Leases a due node to an orchestrator.
|
|
75
|
+
#
|
|
76
|
+
# @param record [Proto::V1::NodeSchedule, nil] the node's record
|
|
77
|
+
# @param owner [String] identifier of the claiming orchestrator and batch
|
|
78
|
+
# @param now [Time] current time
|
|
79
|
+
# @param lease_seconds [Integer] seconds the lease lasts
|
|
80
|
+
# @return [Proto::V1::NodeSchedule, nil] the leased record; the record unchanged when it is not due or leased to another
|
|
81
|
+
#: (Proto::V1::NodeSchedule? record, owner: String, now: Time, lease_seconds: Integer) -> Proto::V1::NodeSchedule?
|
|
82
|
+
def self.claim(record, owner:, now:, lease_seconds:)
|
|
83
|
+
return record unless record
|
|
84
|
+
return record if running?(record, now)
|
|
85
|
+
|
|
86
|
+
due = time(record.due_at)
|
|
87
|
+
return record unless due && due <= now
|
|
88
|
+
|
|
89
|
+
record.dup.tap do |claimed|
|
|
90
|
+
claimed.lease_owner = owner
|
|
91
|
+
claimed.lease_until = timestamp(now + lease_seconds)
|
|
92
|
+
claimed.last_started_at = timestamp(now)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Records the outcome of a run and schedules the next one. A signal that
|
|
97
|
+
# arrived during the run keeps its due date when that is earlier.
|
|
98
|
+
#
|
|
99
|
+
# @param record [Proto::V1::NodeSchedule, nil] the node's record
|
|
100
|
+
# @param owner [String] identifier the run claimed the node with
|
|
101
|
+
# @param finished_at [Time] when the run finished
|
|
102
|
+
# @param error [String, nil] why the run failed; nil when it succeeded
|
|
103
|
+
# @param schedule [Proto::V1::Schedule, nil] the schedule
|
|
104
|
+
# @param random [Random] source of jitter
|
|
105
|
+
# @return [Proto::V1::NodeSchedule, nil] the record; unchanged when another orchestrator holds it
|
|
106
|
+
#: (Proto::V1::NodeSchedule? record, owner: String, finished_at: Time, error: String?, schedule: Proto::V1::Schedule?, random: Random) -> Proto::V1::NodeSchedule?
|
|
107
|
+
def self.completed(record, owner:, finished_at:, error:, schedule:, random:)
|
|
108
|
+
return record unless record && record.lease_owner == owner
|
|
109
|
+
|
|
110
|
+
interval = nonzero(schedule&.interval_seconds, DEFAULT_INTERVAL)
|
|
111
|
+
interval = nonzero(schedule&.retry_interval_seconds, interval) if error
|
|
112
|
+
due = finished_at + interval + jitter(schedule, random)
|
|
113
|
+
window = time(record.signal_window_started_at)
|
|
114
|
+
started = time(record.last_started_at)
|
|
115
|
+
pending = window && started && window > started
|
|
116
|
+
|
|
117
|
+
record.dup.tap do |updated|
|
|
118
|
+
updated.lease_owner = ''
|
|
119
|
+
updated.lease_until = nil
|
|
120
|
+
updated.last_finished_at = timestamp(finished_at)
|
|
121
|
+
updated.last_failed = !error.nil?
|
|
122
|
+
updated.last_error = error || ''
|
|
123
|
+
updated.consecutive_failures = error ? record.consecutive_failures + 1 : 0
|
|
124
|
+
updated.scheduled_at = timestamp(due)
|
|
125
|
+
current = time(record.due_at)
|
|
126
|
+
unless pending && current && current <= due
|
|
127
|
+
updated.due_at = timestamp(due)
|
|
128
|
+
updated.due_reason = error ? 'retry' : 'schedule'
|
|
129
|
+
end
|
|
130
|
+
updated.signal_window_started_at = nil unless pending
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Gives up a lease without recording a run, such as when the orchestrator is interrupted.
|
|
135
|
+
#
|
|
136
|
+
# @param record [Proto::V1::NodeSchedule, nil] the node's record
|
|
137
|
+
# @param owner [String] identifier the node was claimed with
|
|
138
|
+
# @return [Proto::V1::NodeSchedule, nil] the record without the lease; unchanged when another orchestrator holds it
|
|
139
|
+
#: (Proto::V1::NodeSchedule? record, owner: String) -> Proto::V1::NodeSchedule?
|
|
140
|
+
def self.released(record, owner:)
|
|
141
|
+
return record unless record && record.lease_owner == owner
|
|
142
|
+
|
|
143
|
+
record.dup.tap do |updated|
|
|
144
|
+
updated.lease_owner = ''
|
|
145
|
+
updated.lease_until = nil
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @param record [Proto::V1::NodeSchedule] the node's record
|
|
150
|
+
# @param now [Time] current time
|
|
151
|
+
# @return [Boolean] whether an orchestrator holds an unexpired lease on the node
|
|
152
|
+
#: (Proto::V1::NodeSchedule record, Time now) -> bool
|
|
153
|
+
def self.running?(record, now)
|
|
154
|
+
lease_until = time(record.lease_until)
|
|
155
|
+
!record.lease_owner.empty? && !lease_until.nil? && lease_until > now
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# @param timestamp [Google::Protobuf::Timestamp, nil] the timestamp
|
|
159
|
+
# @return [Time, nil] the time, or nil when unset
|
|
160
|
+
#: (Google::Protobuf::Timestamp? timestamp) -> Time?
|
|
161
|
+
def self.time(timestamp) = timestamp&.to_time
|
|
162
|
+
|
|
163
|
+
# @param time [Time] the time
|
|
164
|
+
# @return [Google::Protobuf::Timestamp] the timestamp
|
|
165
|
+
#: (Time time) -> Google::Protobuf::Timestamp
|
|
166
|
+
def self.timestamp(time) = Google::Protobuf::Timestamp.from_time(time)
|
|
167
|
+
|
|
168
|
+
# @param schedule [Proto::V1::Schedule, nil] the schedule
|
|
169
|
+
# @param random [Random] source of jitter
|
|
170
|
+
# @return [Integer] seconds of jitter
|
|
171
|
+
#: (Proto::V1::Schedule? schedule, Random random) -> Integer
|
|
172
|
+
private_class_method def self.jitter(schedule, random)
|
|
173
|
+
max = schedule&.jitter_seconds || 0
|
|
174
|
+
max.zero? ? 0 : random.rand(max + 1)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# @param value [Integer, nil] configured value
|
|
178
|
+
# @param default [Integer] value used when unset or 0
|
|
179
|
+
# @return [Integer] the value
|
|
180
|
+
#: (Integer? value, Integer default) -> Integer
|
|
181
|
+
private_class_method def self.nonzero(value, default) = value.nil? || value.zero? ? default : value
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
require 'securerandom'
|
|
7
|
+
require 'socket'
|
|
8
|
+
require 'glazed'
|
|
9
|
+
require 'glazed/must'
|
|
10
|
+
require 'glazed/orchestrator/policy'
|
|
11
|
+
require 'glazed/proto'
|
|
12
|
+
require 'glazed/runner'
|
|
13
|
+
require 'glazed/state_backends/base'
|
|
14
|
+
require 'glazed/workflow'
|
|
15
|
+
|
|
16
|
+
module Glazed
|
|
17
|
+
# Runs workflows for nodes as they become due, until stopped. Due dates
|
|
18
|
+
# live in a state backend, per workflow and node: nodes new to the inventory
|
|
19
|
+
# are due right away, signals move a node's due date per
|
|
20
|
+
# {Proto::V1::Debounce}, and each run schedules the next per
|
|
21
|
+
# {Proto::V1::Schedule}. Due nodes of a workflow are leased and run together
|
|
22
|
+
# as one batch, so batch steps such as publish see all of them. Workflows
|
|
23
|
+
# take turns, running one batch at a time.
|
|
24
|
+
#
|
|
25
|
+
# @example
|
|
26
|
+
# orchestrator = Glazed::Orchestrator.new(settings:, workflows: {'backup' => workflow}, backend:, inventory: -> { nodes }, jobs: 4)
|
|
27
|
+
# trap('TERM') { orchestrator.stop }
|
|
28
|
+
# orchestrator.run
|
|
29
|
+
class Orchestrator
|
|
30
|
+
# Seconds between checks for due nodes when the settings set none.
|
|
31
|
+
DEFAULT_POLL = 30 #: Integer
|
|
32
|
+
# Seconds between inventory reloads when the settings set none.
|
|
33
|
+
DEFAULT_INVENTORY_REFRESH = 300 #: Integer
|
|
34
|
+
# Seconds a lease lasts when the settings set none.
|
|
35
|
+
DEFAULT_LEASE = 3600 #: Integer
|
|
36
|
+
|
|
37
|
+
# Byte written to the wakeup pipe by {#stop}.
|
|
38
|
+
STOP = 's' #: String
|
|
39
|
+
# Byte written to the wakeup pipe by {#reload_inventory}.
|
|
40
|
+
RELOAD_INVENTORY = 'i' #: String
|
|
41
|
+
|
|
42
|
+
#: type inventory = ^() -> Array[Proto::V1::Node]
|
|
43
|
+
|
|
44
|
+
# Applies signals to the nodes' schedules in one change.
|
|
45
|
+
#
|
|
46
|
+
# @param backend [StateBackends::Base] where schedules are kept
|
|
47
|
+
# @param workflow [String] workflow name
|
|
48
|
+
# @param signals [Array<Proto::V1::Signal>] the signals
|
|
49
|
+
# @param debounce [Proto::V1::Debounce, nil] the debounce settings
|
|
50
|
+
# @param now [Time] current time, used for signals without a reception time
|
|
51
|
+
# @return [Hash{String => Proto::V1::NodeSchedule}] the resulting records, by node name
|
|
52
|
+
#: (backend: StateBackends::Base, workflow: String, signals: Array[Proto::V1::Signal], debounce: Proto::V1::Debounce?, ?now: Time) -> Hash[String, Proto::V1::NodeSchedule]
|
|
53
|
+
def self.signal(backend:, workflow:, signals:, debounce:, now: Time.now)
|
|
54
|
+
by_node = signals.group_by(&:node)
|
|
55
|
+
backend.update_all(workflow, by_node.keys) do |node, record|
|
|
56
|
+
by_node.fetch(node).inject(record) { |current, signal| Policy.signal(current, signal, workflow:, now:, debounce:) }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @param settings [Proto::V1::Orchestrator] orchestrator settings
|
|
61
|
+
# @param workflows [Hash{String => Workflow}] the workflows the settings orchestrate, by name
|
|
62
|
+
# @param backend [StateBackends::Base] where schedules are kept
|
|
63
|
+
# @param inventory [Proc] loads the current nodes
|
|
64
|
+
# @param jobs [Integer] nodes processed in parallel
|
|
65
|
+
# @param listener [Proc] receives events of the orchestrator and the runs
|
|
66
|
+
# @param clock [Proc] returns the current time
|
|
67
|
+
# @param random [Random] source of jitter
|
|
68
|
+
# @raise [ArgumentError] when the workflows differ from those the settings orchestrate
|
|
69
|
+
#: (settings: Proto::V1::Orchestrator, workflows: Hash[String, Workflow], backend: StateBackends::Base, inventory: Orchestrator::inventory, jobs: Integer, ?listener: Runner::listener, ?clock: ^() -> Time, ?random: Random) -> void
|
|
70
|
+
def initialize(settings:, workflows:, backend:, inventory:, jobs:, listener: ->(_event) {}, clock: -> { Time.now }, random: Random.new)
|
|
71
|
+
unless workflows.keys.sort == settings.workflows.keys.sort
|
|
72
|
+
raise ArgumentError, "workflows #{workflows.keys.sort} differ from the orchestrated #{settings.workflows.keys.sort}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
@settings = settings #: Proto::V1::Orchestrator
|
|
76
|
+
@workflows = workflows #: Hash[String, Workflow]
|
|
77
|
+
@backend = backend #: StateBackends::Base
|
|
78
|
+
@inventory = inventory #: Orchestrator::inventory
|
|
79
|
+
@jobs = jobs #: Integer
|
|
80
|
+
@listener = listener #: Runner::listener
|
|
81
|
+
@clock = clock #: ^() -> Time
|
|
82
|
+
@random = random #: Random
|
|
83
|
+
@owner = "#{Socket.gethostname}:#{Process.pid}:#{SecureRandom.hex(4)}" #: String
|
|
84
|
+
@nodes = {} #: Hash[String, Proto::V1::Node]
|
|
85
|
+
@inventory_loaded_at = nil #: Time?
|
|
86
|
+
# A pipe, as a thread blocked in Queue#pop misses a push from a signal handler on that thread.
|
|
87
|
+
wakeup_reader, wakeup_writer = IO.pipe
|
|
88
|
+
@wakeup_reader = wakeup_reader #: IO
|
|
89
|
+
@wakeup_writer = wakeup_writer #: IO
|
|
90
|
+
@stopping = false #: bool
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Runs batches of due nodes until {#stop} is called. Loading the inventory
|
|
94
|
+
# the first time must succeed; later reload failures are reported and the
|
|
95
|
+
# previous inventory is kept.
|
|
96
|
+
#
|
|
97
|
+
# @return [void]
|
|
98
|
+
# @raise [Glazed::Error] when the first inventory load or the state backend fails
|
|
99
|
+
#: () -> void
|
|
100
|
+
def run
|
|
101
|
+
notify(level: 'info', message: 'orchestrator started', owner: @owner)
|
|
102
|
+
until @stopping
|
|
103
|
+
wait = tick
|
|
104
|
+
wait_for_wakeup(wait)
|
|
105
|
+
end
|
|
106
|
+
notify(level: 'info', message: 'orchestrator stopped')
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Makes {#run} return after the current batch. Safe to call from a signal handler.
|
|
110
|
+
#
|
|
111
|
+
# @return [void]
|
|
112
|
+
#: () -> void
|
|
113
|
+
def stop = wake(STOP)
|
|
114
|
+
|
|
115
|
+
# Makes {#run} reload the inventory before the next batch. Safe to call from a signal handler.
|
|
116
|
+
#
|
|
117
|
+
# @return [void]
|
|
118
|
+
#: () -> void
|
|
119
|
+
def reload_inventory = wake(RELOAD_INVENTORY)
|
|
120
|
+
|
|
121
|
+
# Reloads the inventory when it is time, then runs one batch of due nodes
|
|
122
|
+
# for each workflow in turn.
|
|
123
|
+
#
|
|
124
|
+
# @return [Float] seconds until the next check
|
|
125
|
+
# @raise [Glazed::Error] when the first inventory load or the state backend fails
|
|
126
|
+
#: () -> Float
|
|
127
|
+
def tick
|
|
128
|
+
now = @clock.call
|
|
129
|
+
loaded_at = @inventory_loaded_at
|
|
130
|
+
refresh_inventory(now) if loaded_at.nil? || now - loaded_at >= inventory_refresh_seconds
|
|
131
|
+
@workflows.each_key { |workflow| run_batch(workflow, claim(workflow, @clock.call)) }
|
|
132
|
+
next_wait
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @param workflow [String] workflow name
|
|
136
|
+
# @return [Proto::V1::Orchestration] how the workflow is orchestrated
|
|
137
|
+
#: (String workflow) -> Proto::V1::Orchestration
|
|
138
|
+
private def orchestration(workflow)
|
|
139
|
+
Must.because(@settings.workflows[workflow]) { 'initialize checks the workflows against the settings' }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @return [Integer] seconds between inventory reloads
|
|
143
|
+
#: () -> Integer
|
|
144
|
+
private def inventory_refresh_seconds = @settings.inventory_refresh_seconds.zero? ? DEFAULT_INVENTORY_REFRESH : @settings.inventory_refresh_seconds
|
|
145
|
+
|
|
146
|
+
# @return [Integer] seconds between checks for due nodes
|
|
147
|
+
#: () -> Integer
|
|
148
|
+
private def poll_seconds = @settings.poll_seconds.zero? ? DEFAULT_POLL : @settings.poll_seconds
|
|
149
|
+
|
|
150
|
+
# @param request [String] one byte naming the request
|
|
151
|
+
# @return [void]
|
|
152
|
+
#: (String request) -> void
|
|
153
|
+
private def wake(request)
|
|
154
|
+
@wakeup_writer.write_nonblock(request, exception: false)
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Waits for a request from {#stop} or {#reload_inventory}, or until the timeout.
|
|
159
|
+
#
|
|
160
|
+
# @param timeout [Float] seconds to wait at most
|
|
161
|
+
# @return [void]
|
|
162
|
+
#: (Float timeout) -> void
|
|
163
|
+
private def wait_for_wakeup(timeout)
|
|
164
|
+
return unless @wakeup_reader.wait_readable(timeout)
|
|
165
|
+
|
|
166
|
+
requests = @wakeup_reader.readpartial(4096)
|
|
167
|
+
@stopping = true if requests.include?(STOP)
|
|
168
|
+
@inventory_loaded_at = nil if requests.include?(RELOAD_INVENTORY)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Loads the inventory, and for each workflow adds records for new nodes and deletes those of nodes gone.
|
|
172
|
+
#
|
|
173
|
+
# @param now [Time] current time
|
|
174
|
+
# @return [void]
|
|
175
|
+
# @raise [StandardError] when loading the inventory fails the first time
|
|
176
|
+
#: (Time now) -> void
|
|
177
|
+
private def refresh_inventory(now)
|
|
178
|
+
begin
|
|
179
|
+
nodes = @inventory.call
|
|
180
|
+
rescue StandardError => e
|
|
181
|
+
raise if @inventory_loaded_at.nil?
|
|
182
|
+
|
|
183
|
+
# A transient failure of an inventory source must not stop scheduled runs.
|
|
184
|
+
notify(level: 'error', error: e.class.name, message: "inventory reload failed, keeping the previous one: #{e.message}")
|
|
185
|
+
@inventory_loaded_at = now
|
|
186
|
+
return
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
@nodes = nodes.to_h { [it.name, it] }
|
|
190
|
+
@inventory_loaded_at = now
|
|
191
|
+
notify(level: 'info', message: 'inventory loaded', nodes: @nodes.size)
|
|
192
|
+
@workflows.each_key do |workflow|
|
|
193
|
+
known = @backend.list(workflow).map(&:node)
|
|
194
|
+
added = @nodes.keys - known
|
|
195
|
+
removed = known - @nodes.keys
|
|
196
|
+
next if added.empty? && removed.empty?
|
|
197
|
+
|
|
198
|
+
schedule = orchestration(workflow).schedule
|
|
199
|
+
@backend.update_all(workflow, added + removed) do |node, record|
|
|
200
|
+
next nil unless @nodes.key?(node)
|
|
201
|
+
|
|
202
|
+
record || Policy.discovered(workflow, node, now:, schedule:, random: @random)
|
|
203
|
+
end
|
|
204
|
+
notify(level: 'info', workflow:, message: 'inventory changed', nodes: @nodes.size, added:, removed:)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Leases due nodes of a workflow.
|
|
209
|
+
#
|
|
210
|
+
# @param workflow [String] workflow name
|
|
211
|
+
# @param now [Time] current time
|
|
212
|
+
# @return [Array(String, Array<String>)] identifier of the batch, and names of the leased nodes
|
|
213
|
+
#: (String workflow, Time now) -> [String, Array[String]]
|
|
214
|
+
private def claim(workflow, now)
|
|
215
|
+
owner = "#{@owner}:#{SecureRandom.hex(4)}"
|
|
216
|
+
settings = orchestration(workflow)
|
|
217
|
+
limit = settings.batch_size.zero? ? nil : settings.batch_size
|
|
218
|
+
due = @backend.list_due(workflow, now:, limit:)
|
|
219
|
+
return [owner, []] if due.empty?
|
|
220
|
+
|
|
221
|
+
lease_seconds = settings.lease_seconds.zero? ? DEFAULT_LEASE : settings.lease_seconds
|
|
222
|
+
records = @backend.update_all(workflow, due.map(&:node)) do |_node, record|
|
|
223
|
+
Policy.claim(record, owner:, now:, lease_seconds:)
|
|
224
|
+
end
|
|
225
|
+
[owner, records.values.select { it.lease_owner == owner }.map(&:node)]
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Runs the workflow for leased nodes and records the outcomes. When
|
|
229
|
+
# interrupted, the leases are released so the nodes run again soon.
|
|
230
|
+
#
|
|
231
|
+
# @param workflow [String] workflow name
|
|
232
|
+
# @param claimed [Array(String, Array<String>)] identifier of the batch, and names of the leased nodes
|
|
233
|
+
# @return [void]
|
|
234
|
+
# @raise [Interrupt] when the run is interrupted
|
|
235
|
+
#: (String workflow, [String, Array[String]] claimed) -> void
|
|
236
|
+
private def run_batch(workflow, claimed)
|
|
237
|
+
owner, names = claimed
|
|
238
|
+
return if names.empty?
|
|
239
|
+
|
|
240
|
+
nodes = names.filter_map { @nodes[it] }
|
|
241
|
+
notify(level: 'info', workflow:, message: 'batch started', nodes: nodes.map(&:name))
|
|
242
|
+
begin
|
|
243
|
+
errors = run_workflow(workflow, nodes)
|
|
244
|
+
rescue Interrupt
|
|
245
|
+
@backend.update_all(workflow, names) { |_node, record| Policy.released(record, owner:) }
|
|
246
|
+
raise
|
|
247
|
+
end
|
|
248
|
+
finished_at = @clock.call
|
|
249
|
+
schedule = orchestration(workflow).schedule
|
|
250
|
+
@backend.update_all(workflow, names) do |node, record|
|
|
251
|
+
next nil unless @nodes.key?(node)
|
|
252
|
+
|
|
253
|
+
Policy.completed(record, owner:, finished_at:, error: errors[node], schedule:, random: @random)
|
|
254
|
+
end
|
|
255
|
+
notify(level: 'info', workflow:, message: 'batch finished', nodes: nodes.size, failed: errors.size)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Runs a workflow. A batch step failure fails every node of the batch.
|
|
259
|
+
#
|
|
260
|
+
# @param workflow [String] workflow name
|
|
261
|
+
# @param nodes [Array<Proto::V1::Node>] the nodes
|
|
262
|
+
# @return [Hash{String => String}] errors of the failed nodes, by node name
|
|
263
|
+
#: (String workflow, Array[Proto::V1::Node] nodes) -> Hash[String, String]
|
|
264
|
+
private def run_workflow(workflow, nodes)
|
|
265
|
+
result = Runner.new(workflow: @workflows.fetch(workflow), jobs: @jobs, listener: @listener).call(nodes)
|
|
266
|
+
result.failures.to_h { [it.node.name, "#{it.step}: #{it.error.message}"] }
|
|
267
|
+
rescue StandardError => e
|
|
268
|
+
# The nodes are scheduled for retry instead of the orchestrator stopping.
|
|
269
|
+
notify(level: 'error', workflow:, error: e.class.name, message: "batch failed: #{e.message}", backtrace: e.backtrace || [])
|
|
270
|
+
nodes.to_h { [it.name, "#{e.class}: #{e.message}"] }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# @return [Float] seconds until the earliest known due date of any workflow, at most the poll interval
|
|
274
|
+
#: () -> Float
|
|
275
|
+
private def next_wait
|
|
276
|
+
now = @clock.call
|
|
277
|
+
upcoming = @workflows.keys.filter_map { @backend.list_due(it, now: now + poll_seconds, limit: 1).first&.due_at&.to_time }.min
|
|
278
|
+
return poll_seconds.to_f unless upcoming
|
|
279
|
+
|
|
280
|
+
(upcoming - now).clamp(0.0, poll_seconds.to_f).to_f
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# @param event [Hash{Symbol => String, Integer, Boolean, Array<String>, nil}] event fields
|
|
284
|
+
# @return [void]
|
|
285
|
+
#: (**Runner::event_value event) -> void
|
|
286
|
+
private def notify(**event) = @listener.call(event)
|
|
287
|
+
end
|
|
288
|
+
end
|