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,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
@@ -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