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,146 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
require 'json'
|
|
7
|
+
require 'dry/cli'
|
|
8
|
+
require 'glazed'
|
|
9
|
+
require 'glazed/must'
|
|
10
|
+
require 'glazed/proto'
|
|
11
|
+
require 'glazed/proto_json'
|
|
12
|
+
require 'glazed/runner'
|
|
13
|
+
require 'glazed/state_backends'
|
|
14
|
+
require 'glazed/step_context'
|
|
15
|
+
require 'glazed/validation'
|
|
16
|
+
|
|
17
|
+
module Glazed
|
|
18
|
+
module Command
|
|
19
|
+
# Base of glazed's commands, giving them the output streams dry-cli
|
|
20
|
+
# injects (or the process's own when run directly).
|
|
21
|
+
class Base < Dry::CLI::Command
|
|
22
|
+
# Nodes processed in parallel when the config sets none.
|
|
23
|
+
DEFAULT_JOBS = 4 #: Integer
|
|
24
|
+
# Environment variable naming the config file when --config is omitted.
|
|
25
|
+
CONFIG_FILE_ENV = 'GLAZED_CONFIG_FILE' #: String
|
|
26
|
+
|
|
27
|
+
# @return [IO] standard output
|
|
28
|
+
#: () -> IO
|
|
29
|
+
private def out = instance_variable_get(:@out) || $stdout
|
|
30
|
+
|
|
31
|
+
# @return [IO] standard error
|
|
32
|
+
#: () -> IO
|
|
33
|
+
private def err = instance_variable_get(:@err) || $stderr
|
|
34
|
+
|
|
35
|
+
# Exits 1 with a message when neither --config nor {CONFIG_FILE_ENV} names the config file.
|
|
36
|
+
#
|
|
37
|
+
# @param config [String, nil] the --config option
|
|
38
|
+
# @param command [String] command name, for the message
|
|
39
|
+
# @return [String] path of the config file
|
|
40
|
+
#: (String? config, String command) -> String
|
|
41
|
+
private def config_path(config, command)
|
|
42
|
+
path = config || ENV.fetch(CONFIG_FILE_ENV, '')
|
|
43
|
+
return path unless path.empty?
|
|
44
|
+
|
|
45
|
+
err.puts "glazed #{command}: --config or #{CONFIG_FILE_ENV} is required"
|
|
46
|
+
exit 1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Proc] listener writing each event to standard error as a JSON line
|
|
50
|
+
#: () -> Runner::listener
|
|
51
|
+
private def json_listener
|
|
52
|
+
lock = Mutex.new
|
|
53
|
+
->(event) { lock.synchronize { err.puts(JSON.generate(event)) } }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @param config [Proto::V1::Config] the config
|
|
57
|
+
# @param path [String] config file, for the error message
|
|
58
|
+
# @return [Proto::V1::Orchestrator] the orchestrator settings
|
|
59
|
+
# @raise [Glazed::Error] when the config has none
|
|
60
|
+
#: (Proto::V1::Config config, String path) -> Proto::V1::Orchestrator
|
|
61
|
+
private def orchestrator_settings(config, path)
|
|
62
|
+
config.orchestrator or raise Glazed::Error, "#{path}: orchestrator is not configured"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @param orchestration [Proto::V1::Orchestrator] the orchestrator settings
|
|
66
|
+
# @param names [Array<String>, nil] requested workflows; every orchestrated one when nil
|
|
67
|
+
# @return [Array<String>] the workflows, without duplicates
|
|
68
|
+
# @raise [Glazed::Error] when a requested workflow is not orchestrated
|
|
69
|
+
#: (Proto::V1::Orchestrator orchestration, Array[String]? names) -> Array[String]
|
|
70
|
+
private def select_orchestrated(orchestration, names)
|
|
71
|
+
orchestrated = orchestration.workflows.keys.sort
|
|
72
|
+
return orchestrated unless names
|
|
73
|
+
|
|
74
|
+
unknown = names - orchestrated
|
|
75
|
+
raise Glazed::Error, "not orchestrated workflows: #{unknown.join(', ')}" unless unknown.empty?
|
|
76
|
+
|
|
77
|
+
names.uniq
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @param orchestration [Proto::V1::Orchestrator] the orchestrator settings
|
|
81
|
+
# @param context [StepContext] configured storages
|
|
82
|
+
# @return [StateBackends::Base] the state backend
|
|
83
|
+
# @raise [Glazed::Error] when the backend cannot be built
|
|
84
|
+
#: (Proto::V1::Orchestrator orchestration, StepContext context) -> StateBackends::Base
|
|
85
|
+
private def state_backend(orchestration, context)
|
|
86
|
+
StateBackends.build(Must.because(orchestration.state) { 'validation requires orchestrator.state' }, context:)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @param config [Proto::V1::Config] the config
|
|
90
|
+
# @return [Integer] nodes processed in parallel
|
|
91
|
+
#: (Proto::V1::Config config) -> Integer
|
|
92
|
+
private def default_jobs(config) = config.jobs.zero? ? DEFAULT_JOBS : config.jobs
|
|
93
|
+
|
|
94
|
+
# Reads the config, first requiring the features its `requires` lists so
|
|
95
|
+
# plugin settings types are known when it is decoded.
|
|
96
|
+
#
|
|
97
|
+
# @param path [String] config file
|
|
98
|
+
# @return [Proto::V1::Config] the config
|
|
99
|
+
# @raise [Glazed::Error] when `requires` is malformed, or the config does not decode
|
|
100
|
+
# @raise [InvalidMessage] when the config breaks the rules of its schema
|
|
101
|
+
# @raise [LoadError] when a required feature is missing
|
|
102
|
+
#: (String path) -> Proto::V1::Config
|
|
103
|
+
private def load_config(path)
|
|
104
|
+
json = ::File.read(path)
|
|
105
|
+
document = JSON.parse(json) #: untyped
|
|
106
|
+
requires = document.is_a?(Hash) ? document.fetch('requires', []) : []
|
|
107
|
+
unless requires.is_a?(Array) && requires.all?(String)
|
|
108
|
+
raise Glazed::Error, "#{path}: requires must be a list of strings"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
requires.each { require it }
|
|
112
|
+
ProtoJson.load(Proto::V1::Config, json).tap { Validation.validate!(it, origin: path) }
|
|
113
|
+
rescue Google::Protobuf::ParseError, JSON::ParserError => e
|
|
114
|
+
hint = e.message.include?('Type was not found') ? '; list the gem providing that plugin type in requires' : ''
|
|
115
|
+
raise Glazed::Error, "#{path}: #{e.message}#{hint}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @param config [Proto::V1::Config] the config
|
|
119
|
+
# @param name [String, nil] requested workflow
|
|
120
|
+
# @return [Proto::V1::Workflow] the selected workflow
|
|
121
|
+
# @raise [Glazed::Error] when the name is unknown, or none is given and the config has not exactly one
|
|
122
|
+
#: (Proto::V1::Config config, String? name) -> Proto::V1::Workflow
|
|
123
|
+
private def select_workflow(config, name)
|
|
124
|
+
names = config.workflows.keys.sort
|
|
125
|
+
name ||= names.first if names.size == 1
|
|
126
|
+
raise Glazed::Error, "choose a workflow with --workflow: #{names.join(', ')}" unless name
|
|
127
|
+
|
|
128
|
+
config.workflows[name] or raise Glazed::Error, "no workflow named #{name.inspect}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @param nodes [Array<Proto::V1::Node>] every node in the inventory
|
|
132
|
+
# @param only [Array<String>] node names to keep; all when empty
|
|
133
|
+
# @return [Array<Proto::V1::Node>] the selected nodes
|
|
134
|
+
# @raise [Glazed::Error] when only names an unknown node
|
|
135
|
+
#: (Array[Proto::V1::Node] nodes, Array[String] only) -> Array[Proto::V1::Node]
|
|
136
|
+
private def select_nodes(nodes, only)
|
|
137
|
+
return nodes if only.empty?
|
|
138
|
+
|
|
139
|
+
unknown = only - nodes.map(&:name)
|
|
140
|
+
raise Glazed::Error, "unknown nodes: #{unknown.join(', ')}" unless unknown.empty?
|
|
141
|
+
|
|
142
|
+
nodes.select { only.include?(it.name) }
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
require 'glazed/command/base'
|
|
7
|
+
require 'glazed/must'
|
|
8
|
+
require 'glazed/orchestrator'
|
|
9
|
+
require 'glazed/proto'
|
|
10
|
+
require 'glazed/sources'
|
|
11
|
+
require 'glazed/state_backends'
|
|
12
|
+
require 'glazed/step_context'
|
|
13
|
+
require 'glazed/workflow'
|
|
14
|
+
|
|
15
|
+
module Glazed
|
|
16
|
+
module Command
|
|
17
|
+
# `glazed orchestrate`: runs the orchestrator in the foreground until
|
|
18
|
+
# terminated, logging JSON lines to standard error. TERM or INT stops it
|
|
19
|
+
# after the current batch, and a second one interrupts the batch. HUP
|
|
20
|
+
# reloads the inventory.
|
|
21
|
+
class Orchestrate < Base
|
|
22
|
+
desc 'Run the orchestrated workflows for each node as it becomes due, until terminated'
|
|
23
|
+
option :config, aliases: ['-c'], desc: 'Config file, ProtoJSON of glazed.v1.Config; $GLAZED_CONFIG_FILE when omitted'
|
|
24
|
+
option :jobs, aliases: ['-j'], type: :integer, desc: 'Nodes processed in parallel'
|
|
25
|
+
|
|
26
|
+
# Runs the orchestrator until a signal stops it.
|
|
27
|
+
#
|
|
28
|
+
# @param config [String, nil] path of the config file; {Base::CONFIG_FILE_ENV} when nil
|
|
29
|
+
# @param jobs [Integer, nil] parallelism overriding the config
|
|
30
|
+
# @param _options [Hash] other options dry-cli passes, unused
|
|
31
|
+
# @return [void]
|
|
32
|
+
# @raise [Glazed::Error, Google::Protobuf::ParseError] when the config or inventory is invalid, or the state backend fails
|
|
33
|
+
# @raise [Interrupt] when a second TERM or INT interrupts a batch
|
|
34
|
+
#: (?config: String?, ?jobs: Integer?, **untyped _options) -> void
|
|
35
|
+
def call(config: nil, jobs: nil, **_options)
|
|
36
|
+
config = config_path(config, 'orchestrate')
|
|
37
|
+
|
|
38
|
+
settings = load_config(config)
|
|
39
|
+
orchestration = orchestrator_settings(settings, config)
|
|
40
|
+
listener = json_listener
|
|
41
|
+
context = StepContext.from_proto(settings.storages, listener:)
|
|
42
|
+
workflows = orchestration.workflows.keys.to_h { [it, Workflow.from_proto(select_workflow(settings, it), context:)] }
|
|
43
|
+
backend = state_backend(orchestration, context)
|
|
44
|
+
orchestrator = Orchestrator.new(
|
|
45
|
+
settings: orchestration, workflows:, backend:, inventory: -> { Sources.nodes(settings.inventory.to_a) },
|
|
46
|
+
jobs: jobs || default_jobs(settings), listener:,
|
|
47
|
+
)
|
|
48
|
+
with_traps(orchestrator) { orchestrator.run }
|
|
49
|
+
ensure
|
|
50
|
+
backend&.close
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Installs the signal handlers while the block runs.
|
|
54
|
+
#
|
|
55
|
+
# @param orchestrator [Orchestrator] the orchestrator to control
|
|
56
|
+
# @yieldreturn [void]
|
|
57
|
+
# @return [void]
|
|
58
|
+
#: (Orchestrator orchestrator) { () -> void } -> void
|
|
59
|
+
private def with_traps(orchestrator, &)
|
|
60
|
+
stopping = false #: bool
|
|
61
|
+
stop = proc do |_signo|
|
|
62
|
+
raise Interrupt if stopping
|
|
63
|
+
|
|
64
|
+
stopping = true
|
|
65
|
+
orchestrator.stop
|
|
66
|
+
end
|
|
67
|
+
previous = {
|
|
68
|
+
'TERM' => trap('TERM', &stop),
|
|
69
|
+
'INT' => trap('INT', &stop),
|
|
70
|
+
'HUP' => trap('HUP') { orchestrator.reload_inventory },
|
|
71
|
+
} #: Hash[String, untyped]
|
|
72
|
+
yield
|
|
73
|
+
ensure
|
|
74
|
+
previous&.each { |signal, handler| trap(signal, handler || 'DEFAULT') }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
require 'glazed/command/base'
|
|
7
|
+
require 'glazed/proto'
|
|
8
|
+
require 'glazed/runner'
|
|
9
|
+
require 'glazed/sources'
|
|
10
|
+
require 'glazed/step_context'
|
|
11
|
+
require 'glazed/workflow'
|
|
12
|
+
|
|
13
|
+
module Glazed
|
|
14
|
+
module Command
|
|
15
|
+
# `glazed run`: runs one workflow of the config against the inventory in
|
|
16
|
+
# this process. Nodes that fail a step are logged and skipped, and make the
|
|
17
|
+
# exit status 1.
|
|
18
|
+
class Run < Base
|
|
19
|
+
desc 'Run a workflow of the config against the inventory'
|
|
20
|
+
option :config, aliases: ['-c'], desc: 'Config file, ProtoJSON of glazed.v1.Config; $GLAZED_CONFIG_FILE when omitted'
|
|
21
|
+
option :workflow, aliases: ['-w'], desc: 'Workflow to run; optional when the config defines one'
|
|
22
|
+
option :only, type: :array, desc: 'Run only for these nodes, comma-separated'
|
|
23
|
+
option :jobs, aliases: ['-j'], type: :integer, desc: 'Nodes processed in parallel'
|
|
24
|
+
|
|
25
|
+
# Runs the workflow, exiting 1 when a node failed or --config is missing.
|
|
26
|
+
#
|
|
27
|
+
# @param config [String, nil] path of the config file; {Base::CONFIG_FILE_ENV} when nil
|
|
28
|
+
# @param workflow [String, nil] workflow name; the only one when nil
|
|
29
|
+
# @param only [Array<String>, nil] node names to run for; all when nil
|
|
30
|
+
# @param jobs [Integer, nil] parallelism overriding the config
|
|
31
|
+
# @param _options [Hash] other options dry-cli passes, unused
|
|
32
|
+
# @return [void]
|
|
33
|
+
# @raise [Glazed::Error, Google::Protobuf::ParseError] when the config, inventory or a batch step fails
|
|
34
|
+
#: (?config: String?, ?workflow: String?, ?only: Array[String]?, ?jobs: Integer?, **untyped _options) -> void
|
|
35
|
+
def call(config: nil, workflow: nil, only: nil, jobs: nil, **_options)
|
|
36
|
+
config = config_path(config, 'run')
|
|
37
|
+
|
|
38
|
+
settings = load_config(config)
|
|
39
|
+
listener = json_listener
|
|
40
|
+
context = StepContext.from_proto(settings.storages, listener:)
|
|
41
|
+
steps = Workflow.from_proto(select_workflow(settings, workflow), context:)
|
|
42
|
+
nodes = select_nodes(Sources.nodes(settings.inventory.to_a), only || [])
|
|
43
|
+
|
|
44
|
+
jobs ||= default_jobs(settings)
|
|
45
|
+
result = Runner.new(workflow: steps, jobs:, listener:).call(nodes)
|
|
46
|
+
out.puts "completed #{result.states.size} of #{nodes.size} nodes, #{result.changed.size} changed, #{result.failures.size} failed"
|
|
47
|
+
exit 1 unless result.failures.empty?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
require 'time'
|
|
7
|
+
require 'google/protobuf'
|
|
8
|
+
require 'glazed/command/base'
|
|
9
|
+
require 'glazed/must'
|
|
10
|
+
require 'glazed/orchestrator'
|
|
11
|
+
require 'glazed/proto'
|
|
12
|
+
require 'glazed/sources'
|
|
13
|
+
require 'glazed/state_backends'
|
|
14
|
+
require 'glazed/step_context'
|
|
15
|
+
require 'glazed/validation'
|
|
16
|
+
|
|
17
|
+
module Glazed
|
|
18
|
+
module Command
|
|
19
|
+
# `glazed signal`: asks the orchestrator to run nodes soon, such as after
|
|
20
|
+
# their configuration changed. The signal is recorded in the state backend
|
|
21
|
+
# for every orchestrated workflow, or those chosen, with each workflow's
|
|
22
|
+
# debounce, so `glazed orchestrate` need not be reachable. It prints when
|
|
23
|
+
# each node is due.
|
|
24
|
+
class SendSignal < Base
|
|
25
|
+
desc 'Make nodes due soon for glazed orchestrate'
|
|
26
|
+
argument :nodes, type: :array, required: true, desc: 'Names of the nodes'
|
|
27
|
+
option :config, aliases: ['-c'], desc: 'Config file, ProtoJSON of glazed.v1.Config; $GLAZED_CONFIG_FILE when omitted'
|
|
28
|
+
option :workflow, aliases: ['-w'], type: :array, desc: 'Signal only these orchestrated workflows, comma-separated'
|
|
29
|
+
option :source, default: 'manual', desc: 'What sent the signal, recorded as the reason the node is due'
|
|
30
|
+
option :now, type: :boolean, default: false, desc: 'Make the nodes due now, without the debounce delay'
|
|
31
|
+
|
|
32
|
+
# Records the signals.
|
|
33
|
+
#
|
|
34
|
+
# @param nodes [Array<String>] node names
|
|
35
|
+
# @param config [String, nil] path of the config file; {Base::CONFIG_FILE_ENV} when nil
|
|
36
|
+
# @param workflow [Array<String>, nil] workflows to signal; every orchestrated one when nil
|
|
37
|
+
# @param source [String] what sent the signal
|
|
38
|
+
# @param now [Boolean] whether to skip the debounce delay
|
|
39
|
+
# @param _options [Hash] other options dry-cli passes, unused
|
|
40
|
+
# @return [void]
|
|
41
|
+
# @raise [Glazed::Error, Google::Protobuf::ParseError] when the config is invalid, a node is unknown, or the state backend fails
|
|
42
|
+
#: (nodes: Array[String], ?config: String?, ?workflow: Array[String]?, ?source: String, ?now: bool, **untyped _options) -> void
|
|
43
|
+
def call(nodes:, config: nil, workflow: nil, source: 'manual', now: false, **_options)
|
|
44
|
+
config = config_path(config, 'signal')
|
|
45
|
+
|
|
46
|
+
settings = load_config(config)
|
|
47
|
+
orchestration = orchestrator_settings(settings, config)
|
|
48
|
+
workflows = select_orchestrated(orchestration, workflow)
|
|
49
|
+
select_nodes(Sources.nodes(settings.inventory.to_a), nodes)
|
|
50
|
+
context = StepContext.from_proto(settings.storages, listener: json_listener)
|
|
51
|
+
backend = state_backend(orchestration, context)
|
|
52
|
+
at = Time.now
|
|
53
|
+
signals = nodes.map do |node|
|
|
54
|
+
Proto::V1::Signal.new(node:, source:, received_at: Google::Protobuf::Timestamp.from_time(at), immediate: now).tap { Validation.validate!(it) }
|
|
55
|
+
end
|
|
56
|
+
workflows.each do |name|
|
|
57
|
+
debounce = orchestration.workflows[name]&.debounce
|
|
58
|
+
records = Orchestrator.signal(backend:, workflow: name, signals:, debounce:, now: at)
|
|
59
|
+
nodes.uniq.each do |node|
|
|
60
|
+
due_at = Must.because(records.fetch(node).due_at) { 'validation requires due_at' }
|
|
61
|
+
out.puts "#{node} #{name} due at #{due_at.to_time.utc.iso8601}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
ensure
|
|
65
|
+
backend&.close
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
require 'glazed/command/base'
|
|
7
|
+
require 'glazed/orchestrator'
|
|
8
|
+
require 'glazed/proto'
|
|
9
|
+
require 'glazed/proto_json'
|
|
10
|
+
require 'glazed/state_backends/base'
|
|
11
|
+
require 'glazed/step_context'
|
|
12
|
+
|
|
13
|
+
module Glazed
|
|
14
|
+
module Command
|
|
15
|
+
# `glazed status`: prints the orchestration state of each workflow and
|
|
16
|
+
# node, as a table or as ProtoJSON lines. The exit status is 1 when a
|
|
17
|
+
# listed node's last run failed or it is overdue, so it can serve as a
|
|
18
|
+
# monitoring check.
|
|
19
|
+
class Status < Base
|
|
20
|
+
#: type row = Array[String]
|
|
21
|
+
|
|
22
|
+
# Headings of the table columns.
|
|
23
|
+
HEADINGS = ['WORKFLOW', 'NODE', 'STATUS', 'DUE', 'REASON', 'LAST RUN', 'RESULT'].freeze #: Array[String]
|
|
24
|
+
|
|
25
|
+
desc 'Print when each node runs next and how its last run went'
|
|
26
|
+
option :config, aliases: ['-c'], desc: 'Config file, ProtoJSON of glazed.v1.Config; $GLAZED_CONFIG_FILE when omitted'
|
|
27
|
+
option :workflow, aliases: ['-w'], type: :array, desc: 'Show only these orchestrated workflows, comma-separated'
|
|
28
|
+
option :only, type: :array, desc: 'Show only these nodes, comma-separated'
|
|
29
|
+
option :failing, type: :boolean, default: false, desc: 'Show only nodes whose last run failed'
|
|
30
|
+
option :json, type: :boolean, default: false, desc: 'Print each record as a line of ProtoJSON of glazed.v1.NodeSchedule'
|
|
31
|
+
|
|
32
|
+
# Prints the records, exiting 1 when one of them is failing or overdue.
|
|
33
|
+
#
|
|
34
|
+
# @param config [String, nil] path of the config file; {Base::CONFIG_FILE_ENV} when nil
|
|
35
|
+
# @param workflow [Array<String>, nil] workflows to show; every orchestrated one when nil
|
|
36
|
+
# @param only [Array<String>, nil] nodes to show; all when nil
|
|
37
|
+
# @param failing [Boolean] whether to show only nodes whose last run failed
|
|
38
|
+
# @param json [Boolean] whether to print ProtoJSON lines instead of a table
|
|
39
|
+
# @param _options [Hash] other options dry-cli passes, unused
|
|
40
|
+
# @return [void]
|
|
41
|
+
# @raise [Glazed::Error, Google::Protobuf::ParseError] when the config is invalid or the state backend fails
|
|
42
|
+
#: (?config: String?, ?workflow: Array[String]?, ?only: Array[String]?, ?failing: bool, ?json: bool, **untyped _options) -> void
|
|
43
|
+
def call(config: nil, workflow: nil, only: nil, failing: false, json: false, **_options)
|
|
44
|
+
config = config_path(config, 'status')
|
|
45
|
+
settings = load_config(config)
|
|
46
|
+
orchestration = orchestrator_settings(settings, config)
|
|
47
|
+
workflows = select_orchestrated(orchestration, workflow)
|
|
48
|
+
backend = state_backend(orchestration, StepContext.from_proto(settings.storages, listener: json_listener))
|
|
49
|
+
records = workflows.flat_map { backend.list(it) }
|
|
50
|
+
records.select! { only.include?(it.node) } if only
|
|
51
|
+
records.select!(&:last_failed) if failing
|
|
52
|
+
|
|
53
|
+
now = Time.now
|
|
54
|
+
poll_seconds = orchestration.poll_seconds.zero? ? Orchestrator::DEFAULT_POLL : orchestration.poll_seconds
|
|
55
|
+
if json
|
|
56
|
+
records.each { out.puts ProtoJson.dump(it) }
|
|
57
|
+
else
|
|
58
|
+
print_table(records.map { row(it, now:, poll_seconds:) })
|
|
59
|
+
end
|
|
60
|
+
exit 1 if records.any? { it.last_failed || status(it, now:, poll_seconds:) == 'overdue' }
|
|
61
|
+
ensure
|
|
62
|
+
backend&.close
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @param rows [Array<Array<String>>] cells of each row, the last being free-form notes
|
|
66
|
+
# @return [void]
|
|
67
|
+
#: (Array[Status::row] rows) -> void
|
|
68
|
+
private def print_table(rows)
|
|
69
|
+
widths = HEADINGS.each_index.map { |index| [HEADINGS.fetch(index), *rows.map { it.fetch(index) }].map(&:length).max || 0 }
|
|
70
|
+
[HEADINGS, *rows].each do |cells|
|
|
71
|
+
columns = HEADINGS.each_index.map { |index| cells.fetch(index).ljust(widths.fetch(index)) }
|
|
72
|
+
out.puts [*columns, *cells.drop(HEADINGS.size)].join(' ').rstrip
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @param record [Proto::V1::NodeSchedule] the record
|
|
77
|
+
# @param now [Time] current time
|
|
78
|
+
# @param poll_seconds [Integer] seconds between the orchestrator's checks for due nodes
|
|
79
|
+
# @return [Array<String>] the cells, then the notes
|
|
80
|
+
#: (Proto::V1::NodeSchedule record, now: Time, poll_seconds: Integer) -> Status::row
|
|
81
|
+
private def row(record, now:, poll_seconds:)
|
|
82
|
+
status = status(record, now:, poll_seconds:)
|
|
83
|
+
due = time(record.due_at)
|
|
84
|
+
due_cell = due ? clock(due, now) : '-'
|
|
85
|
+
due_cell = "#{due_cell} (#{relative(due, now)})" if due && status != 'running'
|
|
86
|
+
[record.workflow, record.node, status, due_cell, record.due_reason, last_run(record, now:), result(record), notes(record, now:).join('; ')]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @param record [Proto::V1::NodeSchedule] the record
|
|
90
|
+
# @param now [Time] current time
|
|
91
|
+
# @param poll_seconds [Integer] seconds between the orchestrator's checks for due nodes
|
|
92
|
+
# @return [String] "running", "overdue" when due for more than a poll interval, "due", or "waiting"
|
|
93
|
+
#: (Proto::V1::NodeSchedule record, now: Time, poll_seconds: Integer) -> String
|
|
94
|
+
private def status(record, now:, poll_seconds:)
|
|
95
|
+
return 'running' if Orchestrator::Policy.running?(record, now)
|
|
96
|
+
|
|
97
|
+
due = time(record.due_at)
|
|
98
|
+
return 'waiting' if due.nil? || due > now
|
|
99
|
+
|
|
100
|
+
now - due > poll_seconds ? 'overdue' : 'due'
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @param record [Proto::V1::NodeSchedule] the record
|
|
104
|
+
# @param now [Time] current time
|
|
105
|
+
# @return [String] start of the latest run with its duration, or "-" when it never ran
|
|
106
|
+
#: (Proto::V1::NodeSchedule record, now: Time) -> String
|
|
107
|
+
private def last_run(record, now:)
|
|
108
|
+
started = time(record.last_started_at)
|
|
109
|
+
return '-' unless started
|
|
110
|
+
|
|
111
|
+
finished = time(record.last_finished_at)
|
|
112
|
+
duration = finished && finished >= started ? format('%.1fs', finished - started) : 'running'
|
|
113
|
+
"#{clock(started, now)} (#{duration})"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @param record [Proto::V1::NodeSchedule] the record
|
|
117
|
+
# @return [String] "ok", "failed ×N", or "-" when it never finished a run
|
|
118
|
+
#: (Proto::V1::NodeSchedule record) -> String
|
|
119
|
+
private def result(record)
|
|
120
|
+
return "failed ×#{record.consecutive_failures}" if record.last_failed
|
|
121
|
+
return '-' unless record.last_finished_at
|
|
122
|
+
|
|
123
|
+
'ok'
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @param record [Proto::V1::NodeSchedule] the record
|
|
127
|
+
# @param now [Time] current time
|
|
128
|
+
# @return [Array<String>] the last error, a pending signal and the lease, where present
|
|
129
|
+
#: (Proto::V1::NodeSchedule record, now: Time) -> Array[String]
|
|
130
|
+
private def notes(record, now:)
|
|
131
|
+
notes = [] #: Array[String]
|
|
132
|
+
notes << record.last_error if record.last_failed && !record.last_error.empty?
|
|
133
|
+
window = time(record.signal_window_started_at)
|
|
134
|
+
started = time(record.last_started_at)
|
|
135
|
+
notes << "signal pending since #{clock(window, now)}" if window && (started.nil? || window > started)
|
|
136
|
+
lease_until = time(record.lease_until)
|
|
137
|
+
notes << "lease #{record.lease_owner} until #{clock(lease_until, now)}" if !record.lease_owner.empty? && lease_until
|
|
138
|
+
notes
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# @param timestamp [Google::Protobuf::Timestamp, nil] the timestamp
|
|
142
|
+
# @return [Time, nil] the time, or nil when unset
|
|
143
|
+
#: (Google::Protobuf::Timestamp? timestamp) -> Time?
|
|
144
|
+
private def time(timestamp) = Orchestrator::Policy.time(timestamp)
|
|
145
|
+
|
|
146
|
+
# @param time [Time] the time
|
|
147
|
+
# @param now [Time] current time
|
|
148
|
+
# @return [String] the UTC time of day, with the date when it is not today
|
|
149
|
+
#: (Time time, Time now) -> String
|
|
150
|
+
private def clock(time, now)
|
|
151
|
+
utc = time.getutc
|
|
152
|
+
utc.strftime('%F') == now.getutc.strftime('%F') ? utc.strftime('%TZ') : utc.strftime('%FT%TZ')
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# @param time [Time] the time
|
|
156
|
+
# @param now [Time] current time
|
|
157
|
+
# @return [String] such as "in 8m52s" or "4m16s ago"
|
|
158
|
+
#: (Time time, Time now) -> String
|
|
159
|
+
private def relative(time, now)
|
|
160
|
+
seconds = (time - now).round
|
|
161
|
+
seconds.negative? ? "#{span(-seconds)} ago" : "in #{span(seconds)}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# @param seconds [Integer] non-negative seconds
|
|
165
|
+
# @return [String] the span in its two largest units, such as "2d3h", "4m16s" or "9s"
|
|
166
|
+
#: (Integer seconds) -> String
|
|
167
|
+
private def span(seconds)
|
|
168
|
+
minutes, secs = seconds.divmod(60)
|
|
169
|
+
hours, mins = minutes.divmod(60)
|
|
170
|
+
days, hrs = hours.divmod(24)
|
|
171
|
+
return "#{days}d#{hrs}h" if days.positive?
|
|
172
|
+
return "#{hours}h#{mins}m" if hours.positive?
|
|
173
|
+
return "#{minutes}m#{secs}s" if minutes.positive?
|
|
174
|
+
|
|
175
|
+
"#{secs}s"
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
require 'glazed/command/base'
|
|
7
|
+
require 'glazed/version'
|
|
8
|
+
|
|
9
|
+
module Glazed
|
|
10
|
+
module Command
|
|
11
|
+
# `glazed version`: prints the version.
|
|
12
|
+
class Version < Base
|
|
13
|
+
desc 'Print the version'
|
|
14
|
+
|
|
15
|
+
# @param _options [Hash] options dry-cli passes, unused
|
|
16
|
+
# @return [void]
|
|
17
|
+
#: (**untyped _options) -> void
|
|
18
|
+
def call(**_options)
|
|
19
|
+
out.puts Glazed::VERSION
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
require 'dry/cli'
|
|
7
|
+
require 'glazed'
|
|
8
|
+
require 'glazed/command/orchestrate'
|
|
9
|
+
require 'glazed/command/run'
|
|
10
|
+
require 'glazed/command/send_signal'
|
|
11
|
+
require 'glazed/command/status'
|
|
12
|
+
require 'glazed/command/version'
|
|
13
|
+
|
|
14
|
+
module Glazed
|
|
15
|
+
# Commands of the glazed executable.
|
|
16
|
+
module Command
|
|
17
|
+
extend Dry::CLI::Registry
|
|
18
|
+
|
|
19
|
+
register 'orchestrate', Orchestrate
|
|
20
|
+
register 'run', Run
|
|
21
|
+
register 'signal', SendSignal
|
|
22
|
+
register 'status', Status
|
|
23
|
+
register 'version', Version, aliases: ['--version']
|
|
24
|
+
|
|
25
|
+
# Runs a command line.
|
|
26
|
+
#
|
|
27
|
+
# @param argv [Array<String>] command line arguments
|
|
28
|
+
# @param out [IO] standard output
|
|
29
|
+
# @param err [IO] standard error, which receives JSON log lines
|
|
30
|
+
# @return [Integer] exit status
|
|
31
|
+
#: (Array[String] argv, ?out: IO, ?err: IO) -> Integer
|
|
32
|
+
def self.start(argv, out: $stdout, err: $stderr)
|
|
33
|
+
Dry::CLI.new(self).call(arguments: argv, out:, err:)
|
|
34
|
+
0
|
|
35
|
+
rescue SystemExit => e
|
|
36
|
+
e.status
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
require 'glazed'
|
|
7
|
+
|
|
8
|
+
module Glazed
|
|
9
|
+
# Output of one command run on a device.
|
|
10
|
+
class CommandResult
|
|
11
|
+
# @param command [String] command line that was run
|
|
12
|
+
# @param output [String] output without the echoed command or trailing prompt
|
|
13
|
+
#: (command: String, output: String) -> void
|
|
14
|
+
def initialize(command:, output:)
|
|
15
|
+
@command = command #: String
|
|
16
|
+
@output = output #: String
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [String] command line that was run
|
|
20
|
+
#: String
|
|
21
|
+
attr_reader :command
|
|
22
|
+
# @return [String] output without the echoed command or trailing prompt
|
|
23
|
+
#: String
|
|
24
|
+
attr_reader :output
|
|
25
|
+
end
|
|
26
|
+
end
|