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