glazed 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f09d16d28a4ecb996fa05ba53cc4af10d5cbe45b65c09f3830f88326a6e200c5
4
- data.tar.gz: 7c40270ad31553fac8a681ba5369c1596cfaed4a8c09cf66036115def90bd4d9
3
+ metadata.gz: e764c4f949d82239b29b22b0d38b300f260e31818bf41adc5fb47ad6c091468a
4
+ data.tar.gz: 5788a04e0b8da61d1c46e436520ad9e3f78641310b4e0f66c210c524d0041e64
5
5
  SHA512:
6
- metadata.gz: d12cde4376d07ae82221202ebab2ec22a473b85dcdae0e2a5352d2f82235ed8e059a7a66fc7266d1d6d5e14eac5d68b2fdc05a34b22991aac18111d1620bd21e
7
- data.tar.gz: b0e2f351f6ff580fd4b639162088ad4bfd3d947d8591805453129703ed227569d94c605124d43d760824d650c318450b6ab93766886c5e1fc2cd859b4eec1373
6
+ metadata.gz: f456a6b2116655636c1c19e3c8253131aecc266e2d72201ecc6d531faa7ee2e8d64d4ca92147bed91f2153a7e6810c5db8268ca31c257d2cbb08db1160731a02
7
+ data.tar.gz: 9d4d631c639884e667c90b718476d1c99a2d37c49085bdf9c48eb7c9a4f2d96626b2681cd5cd12a62abc20e1169b5371ce313d29d6ddffcfea430e8f65cd2776
data/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ## [0.3.0] - 2026-10-17
2
+
3
+ ### Breaking changes
4
+
5
+ - The `wlc` dialect is renamed `aireos` (`Glazed::Dialects::Aireos`), as Catalyst 9800 controllers run IOS XE
6
+
7
+ ### Enhancements
8
+
9
+ - `aruba_instant` dialect for Aruba Instant (IAP and virtual controller), ported from oxidized's arubainstant model, with secret redaction for management users, SNMP, RADIUS/TACACS keys and passphrases
10
+ - `eos` dialect for Arista EOS, ported from oxidized, redacting hashed and typed secrets, SNMP, AAA, routing, key chain and TerminAttr keys
11
+ - `routeros` dialect for MikroTik RouterOS (SSH exec channel or Telnet), ported from Oxidized, redacting passwords, pre-shared keys, private keys, RADIUS and IPsec secrets, and SNMP community names. Param `routeros_hide_sensitive` runs `/export hide-sensitive`
12
+ - `Glazed::Coordinator` takes `waiter_alive:`, telling whether the execution holding the waiter record still runs, so `arm` takes over from one that ended without giving its record up
13
+ - `aireos` redacts the encrypted secrets AireOS prints in its configuration, along with mgmtuser, netuser, AP mgmtuser and SNMP passwords, and SNMP community names wherever they appear
14
+ - `Glazed::NodeSession` connects to a node, logs in and runs the dialect's post-login and pre-logout hooks, for callers that keep an interactive session open. `exec: false` keeps commands on the shell where the dialect would use exec channels, and `interactions:` adds responders after the dialect's own. `Collector` is built on it
15
+
1
16
  ## [0.2.0] - 2026-10-15
2
17
 
3
18
  ### Breaking changes
@@ -8,6 +23,8 @@
8
23
 
9
24
  ### Enhancements
10
25
 
26
+ - New `routeros` dialect for MikroTik RouterOS (SSH exec channel or Telnet), ported from Oxidized, redacting passwords, pre-shared keys, private keys, RADIUS and IPsec secrets, and SNMP community names. Param `routeros_hide_sensitive` runs `/export hide-sensitive`
27
+
11
28
  - Redaction hides additional secrets given as secret references, as literal text or regular expressions and optionally split by a delimiter, in a `redact` step or in the top-level `redaction` shared by all of them
12
29
  - Storages: `subdirectory` for `git` and `github` places files in a directory of the repository instead of its root
13
30
  - `glazed signal --all`, and `all_nodes` and `workflows` on `glazed.v1.Signal`, signal every node of chosen workflows, for scheduled runs
@@ -23,6 +40,8 @@ Initial release.
23
40
 
24
41
  ### Enhancements
25
42
 
43
+ - New `routeros` dialect for MikroTik RouterOS (SSH exec channel or Telnet), ported from Oxidized, redacting passwords, pre-shared keys, private keys, RADIUS and IPsec secrets, and SNMP community names. Param `routeros_hide_sensitive` runs `/export hide-sensitive`
44
+
26
45
  - Collect configuration over SSH or Telnet, with dialects for Cisco IOS/IOS XE, Cisco WLC (AireOS), Juniper Junos, NEC UNIVERGE IX and Allied Telesis AlliedWare Plus, ported from Oxidized
27
46
  - Class-based dialect DSL with inheritance ([docs/dialect-api.md](../docs/dialect-api.md))
28
47
  - Redaction of the secrets a dialect locates: replaced with `[~REDACTED~]`, or with `[~REDACTED:dgst=…~]` carrying a keyed HMAC so changed secrets still show in diffs
data/NOTICE CHANGED
@@ -11,6 +11,6 @@ Version 2.0:
11
11
 
12
12
  Derived from Oxidized as of commit d7f51c2, and modified for glazed:
13
13
 
14
- - lib/glazed/dialects/{awplus,ios,junos,nec_ix,wlc}.rb
14
+ - lib/glazed/dialects/{aireos,aruba_instant,awplus,eos,ios,junos,nec_ix,routeros}.rb
15
15
  Prompts, command sequences, output cleanup and secret patterns ported
16
- from lib/oxidized/model/{awplus,ios,junos,necix,aireos}.rb.
16
+ from lib/oxidized/model/{awplus,ios,junos,necix,aireos,eos,arubainstant,routeros}.rb.
@@ -22,7 +22,7 @@ module Glazed
22
22
  # session = Glazed::CliSession.new(transport:, prompt: /^\S+#\s?$/)
23
23
  # session.command('show version').output
24
24
  class CliSession
25
- # Default seconds to wait for expected output.
25
+ # Default seconds the device may stay silent while expected output is awaited.
26
26
  DEFAULT_TIMEOUT = 60 #: Integer
27
27
  # Longest single wait on the transport, so a deadline is checked regularly.
28
28
  READ_SLICE = 1.0 #: Float
@@ -32,7 +32,7 @@ module Glazed
32
32
  # @param data_filters [Array<Proc>] filters applied to each received fragment, in order
33
33
  # @param interactions [Array<Dialect::Interaction>] responders to patterns in the buffer
34
34
  # @param exec [Boolean] run {#command} through {Transports::Base#exec} when supported
35
- # @param timeout [Numeric] default seconds to wait for expected output
35
+ # @param timeout [Numeric] default seconds the device may stay silent while output is awaited
36
36
  # @param progress [Proc] receives a message whenever the session starts waiting for the device
37
37
  #: (transport: Transports::Base, prompt: Regexp, ?data_filters: Array[Dialect::data_filter], ?interactions: Array[Dialect::Interaction], ?exec: bool, ?timeout: Numeric, ?progress: ^(String) -> void) -> void
38
38
  def initialize(transport:, prompt:, data_filters: [], interactions: [], exec: false, timeout: DEFAULT_TIMEOUT, progress: ->(_message) {})
@@ -72,9 +72,9 @@ module Glazed
72
72
  # the end of the match.
73
73
  #
74
74
  # @param patterns [Array<Regexp>] patterns tried in order
75
- # @param timeout [Numeric] seconds to wait
75
+ # @param timeout [Numeric] seconds the device may stay silent
76
76
  # @return [MatchData] the first match
77
- # @raise [TimeoutError] when nothing matches in time
77
+ # @raise [TimeoutError] when the device stays silent for timeout seconds before a match
78
78
  # @raise [ConnectionClosed] when the device closes the connection first
79
79
  #: (*Regexp patterns, ?timeout: Numeric) -> MatchData
80
80
  def expect(*patterns, timeout: self.timeout)
@@ -87,9 +87,9 @@ module Glazed
87
87
  # Runs a command and waits for the prompt.
88
88
  #
89
89
  # @param line [String] command line, without newline
90
- # @param timeout [Numeric] seconds to wait for the prompt
90
+ # @param timeout [Numeric] seconds the device may stay silent before the prompt
91
91
  # @return [CommandResult] output without the echoed command and the prompt
92
- # @raise [TimeoutError] when the prompt does not appear in time
92
+ # @raise [TimeoutError] when the device stays silent for timeout seconds before the prompt
93
93
  # @raise [ConnectionClosed] when the device closes the connection first
94
94
  #: (String line, ?timeout: Numeric) -> CommandResult
95
95
  def command(line, timeout: self.timeout)
@@ -122,11 +122,14 @@ module Glazed
122
122
  discarded
123
123
  end
124
124
 
125
+ # Waits for a match while the device keeps sending. The timeout restarts
126
+ # whenever data arrives, so slow but steady output does not time out.
127
+ #
125
128
  # @param description [String] what is awaited, for error messages
126
- # @param timeout [Numeric] seconds to wait
129
+ # @param timeout [Numeric] seconds the device may stay silent
127
130
  # @yieldreturn [MatchData, nil] match against the current buffer, or nil to keep waiting
128
131
  # @return [MatchData] the first non-nil match
129
- # @raise [TimeoutError] when nothing matches in time
132
+ # @raise [TimeoutError] when the device stays silent for timeout seconds before a match
130
133
  # @raise [ConnectionClosed] when the device closes the connection first
131
134
  #: (String description, timeout: Numeric) { () -> MatchData? } -> MatchData
132
135
  private def wait_until(description, timeout:, &matcher)
@@ -137,12 +140,14 @@ module Glazed
137
140
  return match if match
138
141
 
139
142
  remaining = deadline - now
140
- raise TimeoutError, "timed out after #{timeout}s waiting for #{description}; buffer tail: #{(buffer[-200..] || buffer).dump}" unless remaining.positive?
143
+ raise TimeoutError, "no output for #{timeout}s while waiting for #{description}; buffer tail: #{(buffer[-200..] || buffer).dump}" unless remaining.positive?
141
144
 
142
145
  data = transport.read(timeout: [remaining, READ_SLICE].min)
143
146
  raise ConnectionClosed, "connection closed while waiting for #{description}" if data.nil?
147
+ next if data.empty?
144
148
 
145
- feed(data) unless data.empty?
149
+ deadline = now + timeout.to_f
150
+ feed(data)
146
151
  end
147
152
  end
148
153
 
@@ -5,8 +5,7 @@
5
5
 
6
6
  require 'glazed'
7
7
  require 'glazed/cli_session'
8
- require 'glazed/dialect/run'
9
- require 'glazed/dialects'
8
+ require 'glazed/node_session'
10
9
  require 'glazed/proto'
11
10
  require 'glazed/secrets_resolver'
12
11
  require 'glazed/transports'
@@ -18,9 +17,6 @@ module Glazed
18
17
  # @example
19
18
  # config = Glazed::Collector.new(node:).call
20
19
  class Collector
21
- # Transport used when a node names none.
22
- DEFAULT_TRANSPORT = 'ssh' #: String
23
-
24
20
  # @param node [Proto::V1::Node] node to collect from
25
21
  # @param transport [Transports::Base, nil] transport to use instead of building one from the node
26
22
  # @param timeout [Numeric] seconds to wait for each expected output
@@ -28,22 +24,16 @@ module Glazed
28
24
  # @param secrets [SecretsResolver] reads the node's password and secrets
29
25
  #: (node: Proto::V1::Node, ?transport: Transports::Base?, ?timeout: Numeric, ?progress: ^(String) -> void, ?secrets: SecretsResolver) -> void
30
26
  def initialize(node:, transport: nil, timeout: CliSession::DEFAULT_TIMEOUT, progress: ->(_message) {}, secrets: SecretsResolver.new)
31
- @secrets = secrets #: SecretsResolver
32
- @node = node #: Proto::V1::Node
33
- @dialect = Dialects.find(node.dialect) #: singleton(Dialects::Base)
34
- @kind = (node.transport.empty? ? DEFAULT_TRANSPORT : node.transport) #: String
35
- @transport = transport || build_transport #: Transports::Base
36
- @config = @dialect.communication_config(@kind.to_sym) #: Dialect::CommunicationConfig
37
- @timeout = timeout #: Numeric
38
- @progress = progress #: ^(String) -> void
27
+ @session = NodeSession.new(node:, transport:, timeout:, progress:, secrets:) #: NodeSession
39
28
  end
40
29
 
41
30
  # @return [Proto::V1::Node] node to collect from
42
- #: Proto::V1::Node
43
- attr_reader :node
31
+ #: () -> Proto::V1::Node
32
+ def node = @session.node
33
+
44
34
  # @return [Class<Dialects::Base>] dialect of the node
45
- #: singleton(Dialects::Base)
46
- attr_reader :dialect
35
+ #: () -> singleton(Dialects::Base)
36
+ def dialect = @session.dialect
47
37
 
48
38
  # Collects the configuration. The connection is closed even on failure.
49
39
  #
@@ -53,65 +43,16 @@ module Glazed
53
43
  # @raise [InvalidEncoding] when the configuration is not valid UTF-8
54
44
  #: () -> String
55
45
  def call
56
- session = CliSession.new(
57
- transport: @transport,
58
- prompt: dialect.prompt_pattern,
59
- data_filters: dialect.data_filters,
60
- interactions: dialect.interactions,
61
- exec: @config.exec,
62
- timeout: @timeout,
63
- progress: @progress,
64
- )
65
- run = Dialect::Run.new(cli: session, node:, dialect:, secrets: @secrets)
66
- @transport.pty_options = @config.pty_options
67
- @progress.call("connecting to #{@transport.host} over #{@kind}")
68
- @transport.open
46
+ @session.open
69
47
  begin
70
- login(session) unless exec_only?
71
- @config.post_login_hooks.each { it.call(run) }
72
- output = dialect.runbook_for(:get).execute(run).dup.force_encoding(Encoding::UTF_8)
48
+ output = dialect.runbook_for(:get).execute(@session.run).dup.force_encoding(Encoding::UTF_8)
73
49
  raise InvalidEncoding, "#{node.name}: collected configuration is not valid UTF-8" unless output.valid_encoding?
74
50
 
75
- @config.pre_logout_hooks.each { it.call(run) }
51
+ @session.logout
76
52
  output
77
53
  ensure
78
- @transport.close
79
- end
80
- end
81
-
82
- # @return [Boolean] whether every command runs on exec channels, so the shell is never used
83
- #: () -> bool
84
- private def exec_only? = @config.exec && @transport.exec?
85
-
86
- # Answers login prompts the dialect declares, then waits for the first prompt.
87
- #
88
- # @param session [CliSession] session to log in through
89
- # @return [void]
90
- #: (CliSession session) -> void
91
- private def login(session)
92
- if (username_prompt = @config.username_prompt)
93
- session.expect(username_prompt)
94
- session.write("#{node.username}\n")
95
- end
96
- if (password_prompt = @config.password_prompt)
97
- session.expect(password_prompt)
98
- session.write("#{@secrets.maybe(node.password)&.value}\n")
54
+ @session.close
99
55
  end
100
- session.expect(session.prompt)
101
- end
102
-
103
- # @return [Transports::Base] transport built from the node
104
- # @raise [Finder::NotFound] when the transport is unknown
105
- #: () -> Transports::Base
106
- private def build_transport
107
- Transports.find(@kind).new(
108
- host: node.host.empty? ? node.name : node.host,
109
- port: node.port.zero? ? nil : node.port,
110
- username: node.username,
111
- password: @secrets.maybe(node.password),
112
- transport_options: node.transport_options,
113
- secrets: @secrets,
114
- )
115
56
  end
116
57
  end
117
58
  end
@@ -85,10 +85,20 @@ module Glazed
85
85
  StateBackends.build(Must.because(orchestration.state) { 'validation requires orchestrator.state' }, context:)
86
86
  end
87
87
 
88
+ # dry-cli passes integer options as strings.
89
+ #
90
+ # @param option [String, nil] the --jobs option
88
91
  # @param config [Proto::V1::Config] the config
89
92
  # @return [Integer] nodes processed in parallel
90
- #: (Proto::V1::Config config) -> Integer
91
- private def default_jobs(config) = config.jobs.zero? ? DEFAULT_JOBS : config.jobs
93
+ # @raise [Glazed::Error] when --jobs is not a positive integer
94
+ #: (String? option, Proto::V1::Config config) -> Integer
95
+ private def resolve_jobs(option, config)
96
+ return config.jobs.zero? ? DEFAULT_JOBS : config.jobs unless option
97
+
98
+ raise Glazed::Error, "--jobs must be a positive integer, got #{option.inspect}" unless option.match?(/\A[1-9]\d*\z/)
99
+
100
+ option.to_i
101
+ end
92
102
 
93
103
  # Reads the config with {Glazed::Config.load}.
94
104
  #
@@ -26,12 +26,12 @@ module Glazed
26
26
  # Runs the orchestrator until a signal stops it.
27
27
  #
28
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
29
+ # @param jobs [String, nil] parallelism overriding the config
30
30
  # @param _options [Hash] other options dry-cli passes, unused
31
31
  # @return [void]
32
32
  # @raise [Glazed::Error, Google::Protobuf::ParseError] when the config or inventory is invalid, or the state backend fails
33
33
  # @raise [Interrupt] when a second TERM or INT interrupts a batch
34
- #: (?config: String?, ?jobs: Integer?, **untyped _options) -> void
34
+ #: (?config: String?, ?jobs: String?, **untyped _options) -> void
35
35
  def call(config: nil, jobs: nil, **_options)
36
36
  config = config_path(config, 'orchestrate')
37
37
 
@@ -43,7 +43,7 @@ module Glazed
43
43
  backend = state_backend(orchestration, context)
44
44
  orchestrator = Orchestrator.new(
45
45
  settings: orchestration, workflows:, backend:, inventory: -> { Sources.nodes(settings.inventory.to_a) },
46
- jobs: jobs || default_jobs(settings), listener:,
46
+ jobs: resolve_jobs(jobs, settings), listener:,
47
47
  )
48
48
  with_traps(orchestrator) { orchestrator.run }
49
49
  ensure
@@ -27,11 +27,11 @@ module Glazed
27
27
  # @param config [String, nil] path of the config file; {Base::CONFIG_FILE_ENV} when nil
28
28
  # @param workflow [String, nil] workflow name; the only one when nil
29
29
  # @param only [Array<String>, nil] node names to run for; all when nil
30
- # @param jobs [Integer, nil] parallelism overriding the config
30
+ # @param jobs [String, nil] parallelism overriding the config
31
31
  # @param _options [Hash] other options dry-cli passes, unused
32
32
  # @return [void]
33
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
34
+ #: (?config: String?, ?workflow: String?, ?only: Array[String]?, ?jobs: String?, **untyped _options) -> void
35
35
  def call(config: nil, workflow: nil, only: nil, jobs: nil, **_options)
36
36
  config = config_path(config, 'run')
37
37
 
@@ -41,8 +41,7 @@ module Glazed
41
41
  steps = Workflow.from_proto(select_workflow(settings, workflow), context:)
42
42
  nodes = select_nodes(Sources.nodes(settings.inventory.to_a), only || [])
43
43
 
44
- jobs ||= default_jobs(settings)
45
- result = Runner.new(workflow: steps, jobs:, listener:).call(nodes)
44
+ result = Runner.new(workflow: steps, jobs: resolve_jobs(jobs, settings), listener:).call(nodes)
46
45
  out.puts "completed #{result.states.size} of #{nodes.size} nodes, #{result.changed.size} changed, #{result.failures.size} failed"
47
46
  exit 1 unless result.failures.empty?
48
47
  end
@@ -36,15 +36,17 @@ module Glazed
36
36
  # @param clock [Proc] returns the current time
37
37
  # @param random [Random] source of jitter
38
38
  # @param owner [String] identifies this process in leases
39
- #: (settings: Proto::V1::Orchestrator, backend: StateBackends::Base, ?listener: Runner::listener, ?clock: ^() -> Time, ?random: Random, ?owner: String) -> void
39
+ # @param waiter_alive [Proc] tells whether the execution of a waiter's token still runs; see {#arm}
40
+ #: (settings: Proto::V1::Orchestrator, backend: StateBackends::Base, ?listener: Runner::listener, ?clock: ^() -> Time, ?random: Random, ?owner: String, ?waiter_alive: ^(String) -> bool) -> void
40
41
  def initialize(settings:, backend:, listener: ->(_event) {}, clock: -> { Time.now }, random: Random.new,
41
- owner: "#{Socket.gethostname}:#{Process.pid}:#{SecureRandom.hex(4)}")
42
+ owner: "#{Socket.gethostname}:#{Process.pid}:#{SecureRandom.hex(4)}", waiter_alive: ->(_token) { true })
42
43
  @settings = settings #: Proto::V1::Orchestrator
43
44
  @backend = backend #: StateBackends::Base
44
45
  @listener = listener #: Runner::listener
45
46
  @clock = clock #: ^() -> Time
46
47
  @random = random #: Random
47
48
  @owner = owner #: String
49
+ @waiter_alive = waiter_alive #: ^(String) -> bool
48
50
  end
49
51
 
50
52
  # @return [Proto::V1::Orchestrator] orchestrator settings
@@ -202,7 +204,8 @@ module Glazed
202
204
 
203
205
  # Decides whether a coordinator execution waits for the next due node,
204
206
  # recording it as the waiter, or ends because another execution already
205
- # waits until then. See {Orchestrator::Policy.arm}.
207
+ # waits until then. A waiter whose execution waiter_alive reports ended
208
+ # is taken over before its record expires. See {Orchestrator::Policy.arm}.
206
209
  #
207
210
  # @param token [String] identifies the execution
208
211
  # @param cycle [Integer] times the execution has run due nodes already
@@ -215,7 +218,9 @@ module Glazed
215
218
  now = @clock.call
216
219
  wake = nil #: Time?
217
220
  @backend.update_wakeup do |record|
218
- kept, wake = Orchestrator::Policy.arm(record, token:, next_due:, now:, gap: poll_seconds, grace: grace || longest_lease, cycle:, max_cycles:)
221
+ kept, wake = Orchestrator::Policy.arm(
222
+ record, token:, next_due:, now:, gap: poll_seconds, grace: grace || longest_lease, cycle:, max_cycles:, alive: @waiter_alive,
223
+ )
219
224
  kept
220
225
  end
221
226
  if wake
@@ -13,15 +13,31 @@ module Glazed
13
13
  module Dialects
14
14
  # Cisco Wireless LAN Controller on AireOS. Ported from oxidized's aireos model.
15
15
  #
16
- # AireOS asks for the user name and password inside the session, after SSH
17
- # authentication as well.
18
- class Wlc < Base
16
+ # Depending on version and settings, AireOS asks for the user name and
17
+ # password again inside the session after SSH authentication.
18
+ class Aireos < Base
19
+ # Encrypted secret in the running configuration: key index, IV, HMAC, optional length and ciphertext.
20
+ ENCRYPTED = /1 \h{32} \h{40}(?: \d+)? \h+/ #: Regexp
21
+
19
22
  prompt(/^\([^)]+\)\s>/)
20
23
  comment_prefix '# '
21
24
 
22
- sensitive_pattern(/^config snmp community create (\S+)/) { it[1] }
23
- sensitive_pattern(/^config wlan security wpa akm psk set-key (?:ascii|hex) (\S+)/) { it[1] }
24
- sensitive_pattern(/^config (?:radius|tacacs) (?:auth|acct|athr) add \d+ \S+ \d+ (?:ascii|hex) (\S+)/) { it[1] }
25
+ # Parts of `show run-config commands` page even after `config paging disable`.
26
+ interact(/--More-- or \(q\)uit/) do |cli, _buffer, match|
27
+ cli.write(' ')
28
+ Actions::Replace.new(content: match.pre_match + match.post_match)
29
+ end
30
+
31
+ sensitive_pattern(/(?<= )#{ENCRYPTED}/o) { it[0] }
32
+ # Some releases print run-config commands without the leading `config`.
33
+ sensitive_pattern(/^(?:config )?wlan security wpa akm \S+ set-key (?:ascii|hex) (?!encrypt )(\S+)/) { it[1] }
34
+ sensitive_pattern(/^(?:config )?(?:radius|tacacs) (?:auth|acct|athr) add \d+ \S+ \d+ (?:ascii|hex) (\S+)/) { it[1] }
35
+ sensitive_pattern(/^(?:config )?mgmtuser (?:add|password) (?!encrypt)\S+ (\S+)/) { it[1] }
36
+ sensitive_pattern(/^(?:config )?netuser (?:add|password) (?!encrypt)\S+ (\S+)/) { it[1] }
37
+ # Covers the password, its crypt hash and the enable secret.
38
+ sensitive_pattern(/^(?:config )?ap mgmtuser add (?:encrypt )?username \S+ password (.+) \S+$/) { it[1] }
39
+ sensitive_pattern(/^(?:config )?snmp community (?:create|mode \S+|accessmode \S+|ipaddr \S+ \S+|ipv6addr \S+ \S+) (\S+)/) { it[1] }
40
+ sensitive_pattern(/^(?:config )?snmp v3user create \S+ (?:ro|rw) \S+ \S+ (\S.*)$/) { it[1] }
25
41
 
26
42
  runbook :get do |book|
27
43
  book.command('show udi') { comment("#{clean(it.output)}\n") }
@@ -0,0 +1,90 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ # Derived from Oxidized (https://github.com/ytti/oxidized) lib/oxidized/model/arubainstant.rb,
7
+ # Copyright 2013-2015 Saku Ytti, Samer Abdel-Hafez and Oxidized contributors,
8
+ # licensed under the Apache License, Version 2.0. Modified for the glazed dialect API.
9
+
10
+ require 'glazed/dialects/base'
11
+
12
+ module Glazed
13
+ module Dialects
14
+ # Aruba Instant (IAP and Instant virtual controller). Ported from oxidized's arubainstant model.
15
+ #
16
+ # Node secrets: "enable" is sent as the enable password; without it, param
17
+ # "enable" set to "true" runs `enable` without one.
18
+ class ArubaInstant < Base
19
+ # Access levels of `mgmt-user` on Instant virtual controllers and Mobility Access Switches.
20
+ MGMT_ROLES = 'root|guest-provisioning|network-operations|read-only|location-api-mgmt' #: String
21
+
22
+ # The prompt gains a leading space while a core file is present.
23
+ prompt(/^ ?[\w:.@-]+[#>] $/)
24
+ comment_prefix '# '
25
+
26
+ sensitive_pattern(/ipsec (\S+)$/) { it[1] }
27
+ sensitive_pattern(/community (\S+)$/) { it[1] }
28
+ sensitive_pattern(/^\s*snmp-server host [\d.]+ version 2c (\S+)/) { it[1] }
29
+ sensitive_pattern(/^\s*snmpv3-user .*?auth-pass (\S+)/) { it[1] }
30
+ sensitive_pattern(/^\s*snmpv3-user .*?priv-pass (\S+)/) { it[1] }
31
+ sensitive_pattern(/^\s*mgmt-user \S+ (?:#{MGMT_ROLES}) (\S+)$/) { it[1] }
32
+ sensitive_pattern(/^\s*mgmt-user \S+ (?!(?:#{MGMT_ROLES}) )(\S+)(?: (?:read-only|guest-mgmt))?$/) { it[1] }
33
+ sensitive_pattern(/key (\S+)$/) { it[1] }
34
+ sensitive_pattern(/^\s*(?:\S+-)?(?:passwd|passphrase|psk|secret|password|passwords) (\S+)$/) { it[1] }
35
+ sensitive_pattern(/^\s*user \S+ (\S+) \S+$/) { it[1] }
36
+ sensitive_pattern(/^\s*hash-mgmt-user \S+ password \S+ (\S+)(?: usertype .*)?$/) { it[1] }
37
+ sensitive_pattern(/^\s*hash-mgmt-user \S+ password (\S+)(?: usertype .*)?$/) { it[1] }
38
+
39
+ runbook :get do |book|
40
+ book.command('show version') do |result|
41
+ comment(result.output.each_line.grep_v(/^(?:Switch|AP) uptime is |^Reboot Time and Cause/).join)
42
+ end
43
+ book.command('show activate status') do |result|
44
+ comment("#{result.output.each_line.grep_v(/^(?:Activate |Provision interval|Cloud Activation Key)/).join}\n")
45
+ end
46
+ book.command('show aps') do |result|
47
+ comment("#{result.output.each_line.map { aps_line(it) }.join}\n")
48
+ end
49
+ book.command('show running-config no-encrypt')
50
+ end
51
+
52
+ communication :telnet do |config|
53
+ config.username_prompt = /^User:\s*/
54
+ config.password_prompt = /^Password:\s*/
55
+ end
56
+
57
+ communication :ssh, :telnet do |config|
58
+ config.post_login do |run|
59
+ if (enable = run.secret('enable'))
60
+ run.cli.write("enable\n")
61
+ run.cli.expect(/^[pP]assword:/)
62
+ run.cli.write("#{enable.value}\n")
63
+ run.cli.expect(run.cli.prompt)
64
+ elsif run.param('enable') == 'true'
65
+ run.cli.command('enable')
66
+ end
67
+ end
68
+ config.pre_logout do |run|
69
+ run.cli.write("exit\n") if run.secret('enable') || run.param('enable') == 'true'
70
+ run.cli.write("exit\n")
71
+ end
72
+ end
73
+
74
+ class << self
75
+ # Drops the per-radio columns of `show aps`, which change on their own.
76
+ #
77
+ # @param line [String] a line of `show aps`
78
+ # @return [String] the line with the volatile columns removed
79
+ #: (String line) -> String
80
+ def aps_line(line)
81
+ if line.match?(/^Name/)
82
+ line.sub(/^(Name +IP Address +).*(Type +IPv6 Address +).*(Serial #).*$/, '\1\2\3')
83
+ else
84
+ line.sub(/^(\S+ +\S+ +)(?:\S+ +){3}(\S+ +\S+ +)(?:\S+ +){2}(\S+) +.*$/, '\1\2\3')
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,66 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ # Derived from Oxidized (https://github.com/ytti/oxidized) lib/oxidized/model/eos.rb,
7
+ # Copyright 2013-2015 Saku Ytti, Samer Abdel-Hafez and Oxidized contributors,
8
+ # licensed under the Apache License, Version 2.0. Modified for the glazed dialect API.
9
+
10
+ require 'glazed/dialects/base'
11
+
12
+ module Glazed
13
+ module Dialects
14
+ # Arista EOS. Ported from oxidized's eos model.
15
+ #
16
+ # Node secrets: "enable" is sent as the enable password; without it, param
17
+ # "enable" set to "true" runs `enable` without one.
18
+ class Eos < Base
19
+ # Optional encoding prefix of a secret (0 plaintext, 5 and sha512 hashes, 7 and 8a reversible).
20
+ SECRET_TYPE = '(?:(?:0|5|7|8a|sha512) )?'
21
+
22
+ prompt(/^.+[#>]$/)
23
+ comment_prefix '! '
24
+
25
+ sensitive_pattern(/^snmp-server community (?:(?:0|7|8a) (?=\S+ (?:ro|rw)\b))?(\S+)/) { it[1] }
26
+ # SNMPv3 hosts carry a user name, not a community.
27
+ sensitive_pattern(/^snmp-server host (?!.* version 3 )\S+(?> vrf \S+)?(?> informs?| traps)?(?> version (?:1|2c))? +(\S+)/) { it[1] }
28
+ sensitive_pattern(/^snmp-server user .*? auth (?:md5|sha\d*) (?:(?:0|7|8a) )?(\S+)/) { it[1] }
29
+ sensitive_pattern(/^snmp-server user .*? priv \w+ (?:(?:0|7|8a) )?(\S+)/) { it[1] }
30
+ sensitive_pattern(/^enable (?:secret|password)(?: level \d+)? #{SECRET_TYPE}(\S+)/) { it[1] }
31
+ sensitive_pattern(/^username \S+ .*?(?:secret|password) #{SECRET_TYPE}(\S+)/) { it[1] }
32
+ sensitive_pattern(/^\s+(?:secret|password) #{SECRET_TYPE}(\S+)/) { it[1] }
33
+ sensitive_pattern(/^(?:tacacs|radius)-server (?:.+ )?key (?:(?:0|7|8a) )?(\S+)/) { it[1] }
34
+ sensitive_pattern(/^\s+server \S+(?: .+)? key (?:(?:0|7|8a) )?(\S+)/) { it[1] }
35
+ sensitive_pattern(/^\s*key (?:\h+ )?(?:0|7|8a) (\S+)/) { it[1] }
36
+ sensitive_pattern(/^\s*(?:encryption-key|key-string|pre-shared-key) (?:(?:0|7|8a) )?(\S+)/) { it[1] }
37
+ sensitive_pattern(/^\s*(?:isis |ip ospf )?authentication(?:-key| key) (?:(?:0|7|8a) )?(\S+)/) { it[1] }
38
+ sensitive_pattern(/^\s*ip ospf message-digest-key \d+ \S+ (?:(?:0|7|8a) )?(\S+)/) { it[1] }
39
+ sensitive_pattern(/^\s*neighbor \S+ password (?:(?:0|7|8a) )?(\S+)/) { it[1] }
40
+ sensitive_pattern(/^\s*vrrp \d+ authentication text (\S+)/) { it[1] }
41
+ sensitive_pattern(/^ntp authentication-key \d+ \S+ (?:(?:0|7|8a) )?(\S+)/) { it[1] }
42
+ sensitive_pattern(/^service unsupported-transceiver \S+ (\S+)/) { it[1] }
43
+ sensitive_pattern(/-(?:cv|ingest)auth=key,(\S+)/) { it[1] }
44
+
45
+ runbook :get do |book|
46
+ book.command('show inventory | no-more') { comment(it.output) }
47
+ book.command('show running-config | no-more | exclude ! Time:')
48
+ end
49
+
50
+ communication :ssh, :telnet do |config|
51
+ config.post_login do |run|
52
+ if (enable = run.secret('enable'))
53
+ run.cli.write("enable\n")
54
+ run.cli.expect(/[pP]assword:\s?$/)
55
+ run.cli.write("#{enable.value}\n")
56
+ run.cli.expect(run.cli.prompt)
57
+ elsif run.param('enable') == 'true'
58
+ run.cli.command('enable')
59
+ end
60
+ run.cli.command('terminal length 0')
61
+ end
62
+ config.pre_logout { |run| run.cli.write("exit\n") }
63
+ end
64
+ end
65
+ end
66
+ end
@@ -26,7 +26,8 @@ module Glazed
26
26
  sensitive_pattern(/^ikev2 authentication psk id .+ key \S+ (\S+).*/) { it[1] }
27
27
  sensitive_pattern(/^ike policy ike-policy .+ key \S+ (\S+).*/) { it[1] }
28
28
  sensitive_pattern(/^radius host .+ key \S+ (\S+).*/) { it[1] }
29
- sensitive_pattern(/^\s*authentication (?:secret-)?password \S+ (\S+)/) { it[1] }
29
+ sensitive_pattern(/^\s*authentication password \S+ (\S+)/) { it[1] }
30
+ sensitive_pattern(/^\s*authentication secret-password \d+ (.+?)\s*$/) { it[1] }
30
31
  sensitive_pattern(/^\s*ngn radius-auth password \S+ (\S+)/) { it[1] }
31
32
  sensitive_pattern(/^\s*usbmem authentication .+ password-file \S+ \S+ (\S+)/) { it[1] }
32
33
  sensitive_pattern(/^web-auth username \S+ (?:secret-)?password (\S+)/) { it[1] }
@@ -0,0 +1,92 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ # Derived from Oxidized (https://github.com/ytti/oxidized) lib/oxidized/model/routeros.rb,
7
+ # Copyright 2013-2015 Saku Ytti, Samer Abdel-Hafez and Oxidized contributors,
8
+ # licensed under the Apache License, Version 2.0. Modified for the glazed dialect API.
9
+
10
+ require 'glazed/dialects/base'
11
+
12
+ module Glazed
13
+ module Dialects
14
+ # MikroTik RouterOS. Ported from oxidized's routeros model.
15
+ #
16
+ # Node params: "routeros_hide_sensitive" set to "true" collects `/export hide-sensitive`.
17
+ # Otherwise RouterOS 7 and later run `/export show-sensitive`, and older versions `/export`.
18
+ class Routeros < Base
19
+ # A key=value value, either quoted or bare.
20
+ VALUE = /(?:"((?:[^"\\]|\\.)*)"|([^\s"]\S*))/ #: Regexp
21
+
22
+ prompt(/\[\w+@\S+(\s+\S+)*\]\s?>\s?$/)
23
+ comment_prefix '# '
24
+
25
+ # Cursor movements also appear, such as before the prompt.
26
+ filter_output { it.gsub(/\x1B\[[0-9;?]*[A-Za-z]/, '') }
27
+ filter_output { it.gsub(/^\r+(.+)/, '\1').gsub(/([^\r]*)\r+$/, '\1') }
28
+
29
+ sensitive_pattern(
30
+ /(?<![\w.-])[\w.-]*(?:password|passphrase|secret|pre-shared-key|preshared-key|private-key|psk|md5-key|auth-key|authentication-key|protection-key|static-key-\d|trap-community)=#{VALUE}/,
31
+ ) { it[1] || it[2] }
32
+ # Community names follow `name=`, one section at a time. \G continues from the previous match.
33
+ sensitive_pattern(
34
+ /(?:^\/snmp community\n|\G(?<=\S)[^\n]*\n)(?:(?:add|set)[^\n]*\n)*?(?:add|set) [^\n]*?(?<=\s)name=#{VALUE}/,
35
+ ) { it[1] || it[2] }
36
+
37
+ runbook :get do |book|
38
+ book.command('/system resource print') do |result|
39
+ comment(result.output.each_line.grep(/(version|factory-software|total-memory|cpu|cpu-count|total-hdd-space|architecture-name|board-name|platform):/).join)
40
+ end
41
+ book.raw('/system package update print') do |run|
42
+ line = run.command('/system package update print').output.each_line.grep(/installed-version:\s|current-version:\s/).first
43
+ next unless line
44
+
45
+ run.vars[:ros_version] = line[/:\s*(\d+)/, 1]&.to_i
46
+ run.emit(comment(line))
47
+ end
48
+ book.command('/system history print without-paging') { comment(it.output) }
49
+ book.raw('/export') do |run|
50
+ line = if run.param('routeros_hide_sensitive') == 'true'
51
+ '/export hide-sensitive'
52
+ elsif (version = run.vars[:ros_version]).is_a?(Integer) && version >= 7
53
+ '/export show-sensitive'
54
+ else
55
+ '/export'
56
+ end
57
+ run.emit(export(run.command(line).output))
58
+ end
59
+ end
60
+
61
+ communication :telnet do |config|
62
+ config.username_prompt = /^Login:/
63
+ config.password_prompt = /^Password:/
64
+ config.pre_logout { |run| run.cli.write("quit\n") }
65
+ end
66
+
67
+ communication :ssh do |config|
68
+ config.exec = true
69
+ end
70
+
71
+ class << self
72
+ # Drops the lines of `/export` that change without configuration changes
73
+ # and joins the lines RouterOS wraps with a trailing backslash.
74
+ #
75
+ # @param output [String] output of `/export`
76
+ # @return [String] the configuration, ending with a newline
77
+ #: (String output) -> String
78
+ def export(output)
79
+ config = output
80
+ .gsub(/\\\r?\n\s+/, '')
81
+ .gsub("# inactive time\n", '')
82
+ .gsub(/# received packet from \S+ bad format\n/, '')
83
+ .gsub("# poe-out status: short_circuit\n", '')
84
+ .gsub("# Firmware upgraded successfully, please reboot for changes to take effect!\n", '')
85
+ .gsub(/# \S+ not ready\n/, '')
86
+ lines = config.split("\n").reject { it.match?(%r{^#\s\w{3}/\d{2}/\d{4}.*$}) || it.match?(/^#\s\d{4}-\d{2}-\d{2}.*$/) }
87
+ "#{lines.join("\n")}\n"
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,152 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ require 'glazed'
7
+ require 'glazed/cli_session'
8
+ require 'glazed/dialect/run'
9
+ require 'glazed/dialects'
10
+ require 'glazed/proto'
11
+ require 'glazed/secrets_resolver'
12
+ require 'glazed/transports'
13
+
14
+ module Glazed
15
+ # Logged-in session to one node: connects, answers the dialect's login
16
+ # prompts, runs its post-login hooks, and later its pre-logout hooks.
17
+ #
18
+ # @example
19
+ # session = Glazed::NodeSession.new(node:)
20
+ # session.open
21
+ # begin
22
+ # session.run.command('show version')
23
+ # session.logout
24
+ # ensure
25
+ # session.close
26
+ # end
27
+ class NodeSession
28
+ # Transport used when a node names none.
29
+ DEFAULT_TRANSPORT = 'ssh' #: String
30
+
31
+ # @param node [Proto::V1::Node] node to connect to
32
+ # @param transport [Transports::Base, nil] transport to use instead of building one from the node
33
+ # @param timeout [Numeric] seconds to wait for each expected output
34
+ # @param exec [Boolean, nil] run commands on exec channels when the transport supports it; the dialect decides when nil
35
+ # @param interactions [Array<Dialect::Interaction>] responders tried after the dialect's own
36
+ # @param progress [Proc] receives a message when connecting and whenever waiting for the device
37
+ # @param secrets [SecretsResolver] reads the node's password and secrets
38
+ #: (node: Proto::V1::Node, ?transport: Transports::Base?, ?timeout: Numeric, ?exec: bool?, ?interactions: Array[Dialect::Interaction], ?progress: ^(String) -> void, ?secrets: SecretsResolver) -> void
39
+ def initialize(node:, transport: nil, timeout: CliSession::DEFAULT_TIMEOUT, exec: nil, interactions: [], progress: ->(_message) {}, secrets: SecretsResolver.new)
40
+ @node = node #: Proto::V1::Node
41
+ @secrets = secrets #: SecretsResolver
42
+ @progress = progress #: ^(String) -> void
43
+ @dialect = Dialects.find(node.dialect) #: singleton(Dialects::Base)
44
+ @kind = (node.transport.empty? ? DEFAULT_TRANSPORT : node.transport) #: String
45
+ @transport = transport || build_transport #: Transports::Base
46
+ @config = @dialect.communication_config(@kind.to_sym) #: Dialect::CommunicationConfig
47
+ @exec = (exec.nil? ? @config.exec : exec) #: bool
48
+ @cli = CliSession.new(
49
+ transport: @transport,
50
+ prompt: @dialect.prompt_pattern,
51
+ data_filters: @dialect.data_filters,
52
+ interactions: @dialect.interactions + interactions,
53
+ exec: @exec,
54
+ timeout:,
55
+ progress:,
56
+ ) #: CliSession
57
+ @run = Dialect::Run.new(cli: @cli, node:, dialect: @dialect, secrets:) #: Dialect::Run
58
+ end
59
+
60
+ #: Proto::V1::Node
61
+ attr_reader :node
62
+ #: singleton(Dialects::Base)
63
+ attr_reader :dialect
64
+ # @return [Transports::Base] underlying transport
65
+ #: Transports::Base
66
+ attr_reader :transport
67
+ # @return [CliSession] session to the node
68
+ #: CliSession
69
+ attr_reader :cli
70
+ # @return [Dialect::Run] run state passed to runbooks and hooks
71
+ #: Dialect::Run
72
+ attr_reader :run
73
+
74
+ # Connects, logs in and runs the post-login hooks. The connection is
75
+ # closed when any of them fails.
76
+ #
77
+ # @return [void]
78
+ # @raise [TimeoutError] when the device does not answer as the dialect expects
79
+ # @raise [ConnectionClosed] when the device drops the connection
80
+ #: () -> void
81
+ def open
82
+ @transport.pty_options = @config.pty_options
83
+ @progress.call("connecting to #{@transport.host} over #{@kind}")
84
+ @transport.open
85
+ begin
86
+ login unless exec_only?
87
+ @config.post_login_hooks.each { it.call(run) }
88
+ rescue StandardError
89
+ close
90
+ raise
91
+ end
92
+ end
93
+
94
+ # Runs the pre-logout hooks.
95
+ #
96
+ # @return [void]
97
+ #: () -> void
98
+ def logout
99
+ @config.pre_logout_hooks.each { it.call(run) }
100
+ end
101
+
102
+ # Closes the connection without logging out. Safe to call more than once.
103
+ #
104
+ # @return [void]
105
+ #: () -> void
106
+ def close
107
+ @transport.close
108
+ end
109
+
110
+ # @return [Boolean] whether every command runs on exec channels, so the shell is never used
111
+ #: () -> bool
112
+ private def exec_only? = @exec && @transport.exec?
113
+
114
+ # Answers login prompts the dialect declares until the first prompt appears.
115
+ # Each login prompt is answered at most once, and none has to appear, as
116
+ # some devices ask for credentials only under certain settings.
117
+ #
118
+ # @return [void]
119
+ #: () -> void
120
+ private def login
121
+ answers = {} #: Hash[Regexp, ^() -> String?]
122
+ if (username_prompt = @config.username_prompt)
123
+ answers[username_prompt] = -> { node.username }
124
+ end
125
+ if (password_prompt = @config.password_prompt)
126
+ answers[password_prompt] = -> { @secrets.maybe(node.password)&.value }
127
+ end
128
+ # Sorbet cannot check a splat against expect's keyword options (7019).
129
+ cli = @cli #: as untyped
130
+ loop do
131
+ answer = answers.delete(cli.expect(*answers.keys, @cli.prompt).regexp)
132
+ return unless answer
133
+
134
+ @cli.write("#{answer.call}\n")
135
+ end
136
+ end
137
+
138
+ # @return [Transports::Base] transport built from the node
139
+ # @raise [Finder::NotFound] when the transport is unknown
140
+ #: () -> Transports::Base
141
+ private def build_transport
142
+ Transports.find(@kind).new(
143
+ host: node.host.empty? ? node.name : node.host,
144
+ port: node.port.zero? ? nil : node.port,
145
+ username: node.username,
146
+ password: @secrets.maybe(node.password),
147
+ transport_options: node.transport_options,
148
+ secrets: @secrets,
149
+ )
150
+ end
151
+ end
152
+ end
@@ -149,7 +149,8 @@ module Glazed
149
149
  # Decides whether a coordinator execution waits for the next due node.
150
150
  # It does unless another live execution already waits until then, or
151
151
  # it has looped max_cycles times; a waiter that stops waiting gives up
152
- # its record.
152
+ # its record. A waiter that ended without giving it up, such as by
153
+ # failing, is live until its record expires unless alive tells otherwise.
153
154
  #
154
155
  # @param record [Proto::V1::Wakeup, nil] the current waiter
155
156
  # @param token [String] identifies the deciding execution
@@ -159,17 +160,20 @@ module Glazed
159
160
  # @param grace [Integer] seconds after waking until another execution may take over
160
161
  # @param cycle [Integer] times the execution has run due nodes already
161
162
  # @param max_cycles [Integer] cycles after which the execution ends
163
+ # @param alive [Proc] tells whether the execution of a waiter's token still runs
162
164
  # @return [Array(Proto::V1::Wakeup, Time), Array(Proto::V1::Wakeup, nil), Array(nil, nil)] the waiter
163
165
  # record to keep, and when to wake; nil when the execution ends
164
- #: (Proto::V1::Wakeup? record, token: String, next_due: Time?, now: Time, gap: Integer, grace: Integer, cycle: Integer, max_cycles: Integer) -> [Proto::V1::Wakeup?, Time?]
165
- def self.arm(record, token:, next_due:, now:, gap:, grace:, cycle:, max_cycles:)
166
+ #: (Proto::V1::Wakeup? record, token: String, next_due: Time?, now: Time, gap: Integer, grace: Integer, cycle: Integer, max_cycles: Integer, ?alive: ^(String) -> bool) -> [Proto::V1::Wakeup?, Time?]
167
+ def self.arm(record, token:, next_due:, now:, gap:, grace:, cycle:, max_cycles:, alive: ->(_token) { true })
166
168
  own = record&.owner == token
167
169
  return [own ? nil : record, nil] if next_due.nil? || cycle >= max_cycles
168
170
 
169
171
  wake = [next_due, cycle.zero? ? now : now + gap].max
170
172
  expires_at = time(record&.expires_at)
171
173
  wake_at = time(record&.wake_at)
172
- return [record, nil] if !own && expires_at && expires_at > now && wake_at && wake_at <= wake
174
+ if record && !own && expires_at && expires_at > now && wake_at && wake_at <= wake && alive.call(record.owner)
175
+ return [record, nil]
176
+ end
173
177
 
174
178
  [Proto::V1::Wakeup.new(owner: token, wake_at: timestamp(wake), expires_at: timestamp(wake + grace)), wake]
175
179
  end
@@ -10,15 +10,12 @@ require 'glazed/storages'
10
10
 
11
11
  module Glazed
12
12
  module Steps
13
- # Batch: writes an artifact of every node into a storage as one change,
14
- # such as one commit naming the nodes.
13
+ # Batch: writes an artifact of every node into a storage, as one change
14
+ # per changed node, such as a commit titled "glazed: rt1".
15
15
  #
16
16
  # @example Step definition
17
17
  # {"name": "publish", "publish": {"input": "redacted", "storage": "team"}}
18
18
  class Publish < BatchStep
19
- # Node names listed in a commit message before it is shortened.
20
- MESSAGE_NAMES = 5 #: Integer
21
-
22
19
  # @param name [String] step name
23
20
  # @param input [String] artifact to read from each node
24
21
  # @param storage_name [String] storage to write to, by name
@@ -71,7 +68,7 @@ module Glazed
71
68
  names[key] = node.name
72
69
  [key, @context.read(artifact(state, @input))] #: Storages::Base::file
73
70
  end
74
- results = @storage.write_all(files, message: ->(changed_keys) { message(changed_keys.map { names.fetch(it, it) }) })
71
+ results = @storage.write_all(files, message: ->(key) { "glazed: #{names.fetch(key, key)}" })
75
72
  output = @output
76
73
  return batch unless output
77
74
 
@@ -80,15 +77,6 @@ module Glazed
80
77
  end
81
78
  Proto::V1::BatchState.new(nodes:)
82
79
  end
83
-
84
- # @param names [Array<String>] names of the nodes whose content changed
85
- # @return [String] commit message naming the nodes
86
- #: (Array[String] names) -> String
87
- private def message(names)
88
- listed = names.first(MESSAGE_NAMES).join(', ')
89
- rest = names.size - MESSAGE_NAMES
90
- rest.positive? ? "#{name}: update #{listed} and #{rest} more" : "#{name}: update #{listed}"
91
- end
92
80
  end
93
81
  end
94
82
  end
@@ -21,7 +21,7 @@ module Glazed
21
21
  class Conflict < Glazed::Error; end
22
22
 
23
23
  #: type file = [String, String]
24
- #: type message_builder = ^(Array[String] changed_keys) -> String
24
+ #: type message_builder = ^(String changed_key) -> String
25
25
 
26
26
  # Outcome of writing one file.
27
27
  class WriteResult
@@ -78,15 +78,16 @@ module Glazed
78
78
  # @raise [InvalidKey] for an unsafe key
79
79
  #: (key: String, content: String, message: String) -> WriteResult
80
80
  def write(key:, content:, message:)
81
- write_all([[key, content]], message: ->(_changed_keys) { message }).fetch(0)
81
+ write_all([[key, content]], message: ->(_changed_key) { message }).fetch(0)
82
82
  end
83
83
 
84
- # Writes several files as one change where the storage supports it. Files
85
- # whose content equals what their key holds are not written again.
84
+ # Writes several files, as one change per changed file where the storage
85
+ # records changes, such as one commit each. Files whose content equals
86
+ # what their key holds are not written again.
86
87
  #
87
88
  # @param files [Array<Array(String, String)>] keys and contents
88
- # @param message [Proc] builds the description of the change, such as a
89
- # commit message, from the keys whose content changed; not called when none did
89
+ # @param message [Proc] builds the description of the change to one key,
90
+ # such as a commit message; called only for keys whose content changed
90
91
  # @return [Array<WriteResult>] results, in the order of files
91
92
  # @raise [InvalidKey] for an unsafe key
92
93
  #: (Array[Base::file] files, message: Base::message_builder) -> Array[WriteResult]
@@ -12,8 +12,8 @@ require 'glazed/storages/base'
12
12
 
13
13
  module Glazed
14
14
  module Storages
15
- # Local git repository driven by the git command. Each write is one commit
16
- # on the configured branch, optionally pulled before and pushed after.
15
+ # Local git repository driven by the git command. Each changed file is one
16
+ # commit on the configured branch, optionally pulled before and pushed after.
17
17
  #
18
18
  # @example
19
19
  # storage = Glazed::Storages::Git.new(path: '/var/lib/glazed/raw')
@@ -81,12 +81,13 @@ module Glazed
81
81
  #: String?
82
82
  attr_reader :subdirectory
83
83
 
84
- # Writes files in one commit. Nothing is committed when no content
85
- # changed, and the pointers then name the existing HEAD.
84
+ # Writes each changed file in its own commit and pushes once. Nothing is
85
+ # committed when no content changed, and the pointers then name the
86
+ # existing HEAD.
86
87
  #
87
88
  # @param files [Array<Array(String, String)>] keys and contents
88
- # @param message [Proc] builds the commit message from the changed keys
89
- # @return [Array<WriteResult>] the commit holding each file, and whether the file changed
89
+ # @param message [Proc] builds the commit message for a changed key
90
+ # @return [Array<WriteResult>] the last commit, which holds every file, and whether each file changed
90
91
  # @raise [InvalidKey] for an unsafe key
91
92
  # @raise [CommandFailed] when a git command fails
92
93
  #: (Array[Base::file] files, message: Base::message_builder) -> Array[WriteResult]
@@ -104,7 +105,7 @@ module Glazed
104
105
  changed_paths = git(['diff', '--cached', '--name-only', '-z', '--', *paths]).split("\0")
105
106
  changed = keys.select { changed_paths.include?(repository_path(it)) }
106
107
  unless changed.empty?
107
- git(['commit', '--quiet', '--no-verify', '-m', message.call(changed)])
108
+ changed.each { git(['commit', '--quiet', '--no-verify', '-m', message.call(it), '--', repository_path(it)]) }
108
109
  git(['push', '--quiet', Must.expect(remote), "HEAD:refs/heads/#{branch}"]) if remote
109
110
  end
110
111
  commit = git(%w[rev-parse HEAD]).strip
@@ -15,8 +15,8 @@ require 'glazed/version'
15
15
 
16
16
  module Glazed
17
17
  module Storages
18
- # GitHub repository written through the Git Data API. Each write is one
19
- # commit on the branch, which is created from the base branch when missing.
18
+ # GitHub repository written through the Git Data API. Each changed file is
19
+ # one commit on the branch, which is created from the base branch when missing.
20
20
  #
21
21
  # @example
22
22
  # storage = Glazed::Storages::Github.new(repository: 'org/netconf', branch: 'glazed/auto', token:)
@@ -99,12 +99,13 @@ module Glazed
99
99
  #: String?
100
100
  attr_reader :subdirectory
101
101
 
102
- # Writes the changed files in one commit. Nothing is committed when no
103
- # content changed, and the pointers then name the existing head.
102
+ # Writes each changed file in its own commit and moves the branch once.
103
+ # Nothing is committed when no content changed, and the pointers then
104
+ # name the existing head.
104
105
  #
105
106
  # @param files [Array<Array(String, String)>] keys and contents
106
- # @param message [Proc] builds the commit message from the changed keys
107
- # @return [Array<WriteResult>] the commit holding each file, and whether the file changed
107
+ # @param message [Proc] builds the commit message for a changed key
108
+ # @return [Array<WriteResult>] the last commit, which holds every file, and whether each file changed
108
109
  # @raise [InvalidKey] for an unsafe key
109
110
  # @raise [ApiError] when the API fails, or the branch keeps moving under concurrent writers
110
111
  #: (Array[Base::file] files, message: Base::message_builder) -> Array[WriteResult]
@@ -147,8 +148,8 @@ module Glazed
147
148
  end
148
149
 
149
150
  # @param files [Array<Array(String, String)>] keys and contents
150
- # @param message [Proc] builds the commit message from the changed keys
151
- # @return [Array(String, Array<String>)] SHA of the commit holding the files, and the changed keys
151
+ # @param message [Proc] builds the commit message for a changed key
152
+ # @return [Array(String, Array<String>)] SHA of the last commit, and the changed keys
152
153
  # @raise [ApiError] when the API fails
153
154
  #: (Array[Base::file] files, Base::message_builder message) -> [String, Array[String]]
154
155
  private def commit_files(files, message)
@@ -158,16 +159,18 @@ module Glazed
158
159
  existing = blob_shas(base_tree)
159
160
  candidates = existing ? files.reject { |key, content| existing[repository_path(key)] == blob_sha(content) } : files
160
161
 
161
- commit = parent
162
+ commit = parent #: String
163
+ tree = base_tree
162
164
  changed = [] #: Array[String]
163
- unless candidates.empty?
164
- entries = candidates.map { |key, content| {path: repository_path(key), mode: '100644', type: 'blob', content:} }
165
- tree = json(post("/repos/#{repository}/git/trees", {base_tree:, tree: entries})).fetch('sha')
166
- unless tree == base_tree
167
- changed = candidates.map(&:first)
168
- body = {message: message.call(changed), tree:, parents: [parent], author: @author}.compact
169
- commit = json(post("/repos/#{repository}/git/commits", body)).fetch('sha')
170
- end
165
+ candidates.each do |key, content|
166
+ entry = {path: repository_path(key), mode: '100644', type: 'blob', content:}
167
+ new_tree = json(post("/repos/#{repository}/git/trees", {base_tree: tree, tree: [entry]})).fetch('sha')
168
+ next if new_tree == tree
169
+
170
+ body = {message: message.call(key), tree: new_tree, parents: [commit], author: @author}.compact
171
+ commit = json(post("/repos/#{repository}/git/commits", body)).fetch('sha')
172
+ tree = new_tree
173
+ changed << key
171
174
  end
172
175
 
173
176
  if head.nil?
@@ -101,7 +101,7 @@ module Glazed
101
101
  # Runs one command outside the interactive session.
102
102
  #
103
103
  # @param command [String] command line
104
- # @param timeout [Numeric] seconds to wait for the command to finish
104
+ # @param timeout [Numeric] seconds the command may stay silent before it finishes
105
105
  # @return [String] command output
106
106
  # @raise [NotImplementedError] when the transport has no exec
107
107
  #: (String command, timeout: Numeric) -> String
@@ -58,6 +58,9 @@ module Glazed
58
58
  password: password&.value,
59
59
  timeout: connect_timeout,
60
60
  non_interactive: true,
61
+ # Some devices leave keepalives unanswered while busy, and the idle
62
+ # timeout of CliSession and #exec already detects a dead connection.
63
+ keepalive_maxcount: 0,
61
64
  )
62
65
  end
63
66
 
@@ -140,25 +143,29 @@ module Glazed
140
143
  # Runs a command on its own exec channel.
141
144
  #
142
145
  # @param command [String] command line
143
- # @param timeout [Numeric] seconds to wait for the channel to close
146
+ # @param timeout [Numeric] seconds the channel may stay silent before it closes
144
147
  # @return [String] combined stdout and stderr
145
148
  # @raise [Glazed::Error] when the device refuses the exec request
146
- # @raise [Glazed::TimeoutError] when the command does not finish in time
149
+ # @raise [Glazed::TimeoutError] when the channel stays silent for timeout seconds
147
150
  #: (String command, timeout: Numeric) -> String
148
151
  def exec(command, timeout:)
149
152
  output = +''
150
153
  closed = false #: bool
154
+ deadline = now + timeout.to_f
155
+ receive = lambda do |data|
156
+ output << data
157
+ deadline = now + timeout.to_f
158
+ end
151
159
  session.open_channel do |channel|
152
160
  channel.exec(command) do |_, success|
153
161
  raise Glazed::Error, "#{host}: exec request refused for #{command.inspect}" unless success
154
162
  end
155
- channel.on_data { |_, data| output << data }
156
- channel.on_extended_data { |_, _, data| output << data }
163
+ channel.on_data { |_, data| receive.call(data) }
164
+ channel.on_extended_data { |_, _, data| receive.call(data) }
157
165
  channel.on_close { closed = true }
158
166
  end
159
- deadline = now + timeout.to_f
160
167
  session.loop(0.1) do
161
- raise Glazed::TimeoutError, "#{host}: #{command.inspect} did not finish in #{timeout}s" if !closed && now > deadline
168
+ raise Glazed::TimeoutError, "#{host}: #{command.inspect} sent no output for #{timeout}s" if !closed && now > deadline
162
169
 
163
170
  !closed
164
171
  end
@@ -5,5 +5,5 @@
5
5
 
6
6
  module Glazed
7
7
  # Gem version.
8
- VERSION = '0.2.0' #: String
8
+ VERSION = '0.3.0' #: String
9
9
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: glazed
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sorah Fukumori
@@ -164,14 +164,18 @@ files:
164
164
  - lib/glazed/dialect/runbook.rb
165
165
  - lib/glazed/dialect/sensitive_pattern.rb
166
166
  - lib/glazed/dialects.rb
167
+ - lib/glazed/dialects/aireos.rb
168
+ - lib/glazed/dialects/aruba_instant.rb
167
169
  - lib/glazed/dialects/awplus.rb
168
170
  - lib/glazed/dialects/base.rb
171
+ - lib/glazed/dialects/eos.rb
169
172
  - lib/glazed/dialects/ios.rb
170
173
  - lib/glazed/dialects/junos.rb
171
174
  - lib/glazed/dialects/nec_ix.rb
172
- - lib/glazed/dialects/wlc.rb
175
+ - lib/glazed/dialects/routeros.rb
173
176
  - lib/glazed/finder.rb
174
177
  - lib/glazed/must.rb
178
+ - lib/glazed/node_session.rb
175
179
  - lib/glazed/orchestrator.rb
176
180
  - lib/glazed/orchestrator/policy.rb
177
181
  - lib/glazed/plugin_registry.rb