glazed 0.1.0 → 0.2.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -0
  3. data/lib/glazed/collector.rb +10 -6
  4. data/lib/glazed/command/base.rb +3 -18
  5. data/lib/glazed/command/orchestrate.rb +1 -1
  6. data/lib/glazed/command/run.rb +1 -1
  7. data/lib/glazed/command/send_signal.rb +21 -13
  8. data/lib/glazed/config.rb +50 -0
  9. data/lib/glazed/coordinator/task_runner.rb +189 -0
  10. data/lib/glazed/coordinator.rb +252 -0
  11. data/lib/glazed/dialect/run.rb +9 -3
  12. data/lib/glazed/dialect/sensitive_pattern.rb +7 -2
  13. data/lib/glazed/orchestrator/policy.rb +28 -0
  14. data/lib/glazed/orchestrator.rb +15 -65
  15. data/lib/glazed/proto.rb +1 -0
  16. data/lib/glazed/redactor.rb +65 -26
  17. data/lib/glazed/secret.rb +12 -37
  18. data/lib/glazed/secrets/command.rb +66 -0
  19. data/lib/glazed/secrets.rb +27 -5
  20. data/lib/glazed/secrets_resolver.rb +85 -0
  21. data/lib/glazed/shell_command.rb +41 -6
  22. data/lib/glazed/sources/command.rb +11 -8
  23. data/lib/glazed/state_backends/base.rb +11 -1
  24. data/lib/glazed/state_backends/sqlite.rb +38 -1
  25. data/lib/glazed/step_context.rb +26 -8
  26. data/lib/glazed/steps/{batch_shell.rb → batch_command.rb} +15 -15
  27. data/lib/glazed/steps/{shell.rb → command.rb} +21 -18
  28. data/lib/glazed/steps/fetch.rb +1 -1
  29. data/lib/glazed/steps/redact.rb +3 -1
  30. data/lib/glazed/storage_context.rb +22 -0
  31. data/lib/glazed/storages/base.rb +3 -2
  32. data/lib/glazed/storages/filesystem.rb +3 -2
  33. data/lib/glazed/storages/git.rb +25 -8
  34. data/lib/glazed/storages/github.rb +22 -8
  35. data/lib/glazed/storages/memory.rb +3 -2
  36. data/lib/glazed/storages.rb +6 -4
  37. data/lib/glazed/transports/base.rb +14 -2
  38. data/lib/glazed/transports/ssh.rb +55 -2
  39. data/lib/glazed/transports/telnet.rb +4 -2
  40. data/lib/glazed/v1/config_pb.rb +2 -1
  41. data/lib/glazed/v1/coordinator_pb.rb +34 -0
  42. data/lib/glazed/v1/inventory_pb.rb +5 -1
  43. data/lib/glazed/v1/orchestrator_pb.rb +1 -1
  44. data/lib/glazed/v1/redaction_pb.rb +2 -1
  45. data/lib/glazed/v1/secret_pb.rb +3 -1
  46. data/lib/glazed/v1/state_pb.rb +2 -1
  47. data/lib/glazed/v1/storage_pb.rb +1 -1
  48. data/lib/glazed/v1/workflow_pb.rb +3 -3
  49. data/lib/glazed/version.rb +1 -1
  50. metadata +10 -3
@@ -15,7 +15,8 @@ module Glazed
15
15
  # Only the secret within each match is replaced, by +[~REDACTED~]+ in :remove
16
16
  # mode or +[~REDACTED:dgst=...~]+ in :hmac mode. The digest is a truncated
17
17
  # HMAC-SHA256 of the secret in unpadded base64url, so a changed secret shows
18
- # up in diffs without being revealed.
18
+ # up in diffs without being revealed. A pattern with a note adds +:note=...+
19
+ # before the closing +~]+.
19
20
  #
20
21
  # @example
21
22
  # Glazed::Redactor.new(patterns: Glazed::Dialects::Ios.sensitive_patterns, mode: :remove).call(config)
@@ -47,27 +48,59 @@ module Glazed
47
48
 
48
49
  # Builds a redactor from settings.
49
50
  #
51
+ # Secrets of +redaction+ and +global+ both apply. +redaction+ takes precedence
52
+ # for the mode and the HMAC key when it sets them.
53
+ #
50
54
  # @param redaction [Proto::V1::Redaction] redaction settings
51
55
  # @param patterns [Array<Dialect::SensitivePattern>] where secrets appear
56
+ # @param global [Proto::V1::Redaction, nil] settings shared by every redactor
57
+ # @param secrets [SecretsResolver] reads the HMAC key and the secrets to hide
52
58
  # @return [Redactor] the redactor
53
- # @raise [InvalidConfiguration] when the mode is unknown, or the HMAC key is missing or not base64
54
- # @raise [Secret::Unavailable] when the HMAC key cannot be read
55
- #: (Proto::V1::Redaction redaction, patterns: Array[Dialect::SensitivePattern]) -> Redactor
56
- def self.from_proto(redaction, patterns:)
57
- case redaction.mode
59
+ # @raise [InvalidConfiguration] when the mode is unknown, the HMAC key is missing or not base64, or a regexp is invalid
60
+ # @raise [Secret::Unavailable] when the HMAC key or a secret cannot be read
61
+ #: (Proto::V1::Redaction redaction, patterns: Array[Dialect::SensitivePattern], secrets: SecretsResolver, ?global: Proto::V1::Redaction?) -> Redactor
62
+ def self.from_proto(redaction, patterns:, secrets:, global: nil)
63
+ patterns += ((global&.secrets&.to_a || []) + redaction.secrets.to_a).filter_map { extra_pattern(it, secrets) }
64
+ mode = redaction.mode == :MODE_UNSPECIFIED && global ? global.mode : redaction.mode
65
+ case mode
58
66
  when :MODE_UNSPECIFIED, :MODE_REMOVE then new(patterns:, mode: :remove)
59
- when :MODE_HMAC then new(patterns:, mode: :hmac, key: hmac_key(redaction))
60
- else raise InvalidConfiguration, "unknown redaction mode #{redaction.mode.inspect}"
67
+ when :MODE_HMAC then new(patterns:, mode: :hmac, key: hmac_key(redaction.hmac_key || global&.hmac_key, secrets))
68
+ else raise InvalidConfiguration, "unknown redaction mode #{mode.inspect}"
61
69
  end
62
70
  end
63
71
 
64
- # @param redaction [Proto::V1::Redaction] redaction settings
72
+ # @param extra [Proto::V1::ExtraSecret] secret to hide
73
+ # @param secrets [SecretsResolver] reads the secret
74
+ # @return [Dialect::SensitivePattern, nil] pattern locating it, or nil when it has no value
75
+ # @raise [InvalidConfiguration] when a regexp is invalid
76
+ # @raise [Secret::Unavailable] when the referenced value cannot be read
77
+ #: (Proto::V1::ExtraSecret extra, SecretsResolver secrets) -> Dialect::SensitivePattern?
78
+ private_class_method def self.extra_pattern(extra, secrets)
79
+ whole_match = ->(match) { match.begin(0)...match.end(0) } #: Dialect::SensitivePattern::determiner
80
+ note = extra.note.empty? ? nil : extra.note
81
+ ref = extra.ref or raise InvalidConfiguration, 'redaction secret has no ref'
82
+ value = secrets.secret(ref).value
83
+ values = (extra.delimiter.empty? ? [value] : value.split(extra.delimiter)).reject(&:empty?)
84
+ return nil if values.empty?
85
+
86
+ pattern = if extra.regexp
87
+ Regexp.union(values.map { Regexp.new(it) })
88
+ else
89
+ Regexp.union(values.sort_by { -it.size })
90
+ end
91
+ Dialect::SensitivePattern.new(pattern:, determiner: whole_match, note:)
92
+ rescue RegexpError => e
93
+ raise InvalidConfiguration, "invalid regexp in redaction secrets: #{e.message}"
94
+ end
95
+
96
+ # @param ref [Proto::V1::SecretRef, nil] reference to the base64 key
97
+ # @param secrets [SecretsResolver] reads the key
65
98
  # @return [String, nil] the HMAC key, decoded from base64
66
99
  # @raise [InvalidConfiguration] when the key is not base64
67
100
  # @raise [Secret::Unavailable] when the key cannot be read
68
- #: (Proto::V1::Redaction redaction) -> String?
69
- private_class_method def self.hmac_key(redaction)
70
- encoded = Secret.maybe(redaction.hmac_key)&.value or return nil
101
+ #: (Proto::V1::SecretRef? ref, SecretsResolver secrets) -> String?
102
+ private_class_method def self.hmac_key(ref, secrets)
103
+ encoded = secrets.maybe(ref)&.value or return nil
71
104
  encoded.unpack1('m0')
72
105
  rescue ArgumentError
73
106
  raise InvalidConfiguration, 'hmac_key is not base64'
@@ -84,31 +117,33 @@ module Glazed
84
117
  #: (String text) -> String
85
118
  def call(text)
86
119
  result = text.dup
87
- secret_ranges(text).reverse_each do |start, finish|
88
- result[start...finish] = replacement(text[start...finish] || '')
120
+ secret_ranges(text).reverse_each do |start, finish, note|
121
+ result[start...finish] = replacement(text[start...finish] || '', note)
89
122
  end
90
123
  result
91
124
  end
92
125
 
93
126
  # @param text [String] configuration text
94
- # @return [Array<Array(Integer, Integer)>] non-overlapping secret start and end offsets, in order
95
- #: (String text) -> Array[[Integer, Integer]]
127
+ # @return [Array<Array(Integer, Integer, String?)>] non-overlapping secret start and end offsets, each with the
128
+ # note of the pattern that located it, in order
129
+ #: (String text) -> Array[[Integer, Integer, String?]]
96
130
  private def secret_ranges(text)
97
131
  ranges = @patterns.flat_map do |sensitive|
98
132
  matches(sensitive.pattern, text).filter_map do |match|
99
- case (secret = sensitive.determiner.call(match))
133
+ range = case (secret = sensitive.determiner.call(match))
100
134
  when Range
101
135
  start = secret.begin
102
136
  finish = secret.exclude_end? ? secret.end : secret.end + 1
103
137
  [start, finish] if finish > start
104
138
  when String then locate(match, secret) unless secret.empty?
105
139
  end
140
+ [range[0], range[1], sensitive.note] if range
106
141
  end
107
142
  end
108
- kept = [] #: Array[[Integer, Integer]]
109
- ranges.sort_by { |start, finish| [start, -finish] }.each do |start, finish|
143
+ kept = [] #: Array[[Integer, Integer, String?]]
144
+ ranges.sort_by { |start, finish,| [start, -finish] }.each do |range|
110
145
  last = kept.last
111
- kept << [start, finish] if last.nil? || start >= last[1]
146
+ kept << range if last.nil? || range[0] >= last[1]
112
147
  end
113
148
  kept
114
149
  end
@@ -146,13 +181,17 @@ module Glazed
146
181
  end
147
182
 
148
183
  # @param secret [String] secret to hide
184
+ # @param note [String, nil] note of the pattern that located the secret
149
185
  # @return [String] its replacement
150
- #: (String secret) -> String
151
- private def replacement(secret)
152
- return REMOVED unless @mode == :hmac
153
-
154
- digest = OpenSSL::HMAC.digest('SHA256', @key.to_s, secret).byteslice(0, HMAC_BYTES)
155
- "[~REDACTED:dgst=#{[digest].pack('m0').tr('+/', '-_').delete('=')}~]"
186
+ #: (String secret, String? note) -> String
187
+ private def replacement(secret, note)
188
+ fields = [] #: Array[String]
189
+ if @mode == :hmac
190
+ digest = OpenSSL::HMAC.digest('SHA256', @key.to_s, secret).byteslice(0, HMAC_BYTES)
191
+ fields << "dgst=#{[digest].pack('m0').tr('+/', '-_').delete('=')}"
192
+ end
193
+ fields << "note=#{note}" if note
194
+ fields.empty? ? REMOVED : "[~REDACTED:#{fields.join(':')}~]"
156
195
  end
157
196
  end
158
197
  end
data/lib/glazed/secret.rb CHANGED
@@ -5,28 +5,20 @@
5
5
 
6
6
  require 'glazed'
7
7
  require 'glazed/proto'
8
- require 'glazed/secrets'
9
8
 
10
9
  module Glazed
11
- # Secret resolved from a {Proto::V1::SecretRef} on each access. Its value
12
- # never appears in #inspect or #to_s.
10
+ # Secret read from a {Proto::V1::SecretRef} through a {SecretsResolver} on
11
+ # each access. Its value never appears in #inspect or #to_s.
13
12
  class Secret
14
13
  # Raised when the referenced value cannot be read.
15
14
  class Unavailable < Glazed::Error; end
16
15
 
17
- # @param ref [Proto::V1::SecretRef] reference to resolve
18
- #: (Proto::V1::SecretRef ref) -> void
19
- def initialize(ref)
16
+ # @param ref [Proto::V1::SecretRef] reference to read
17
+ # @param resolver [SecretsResolver] reads the value
18
+ #: (Proto::V1::SecretRef ref, resolver: SecretsResolver) -> void
19
+ def initialize(ref, resolver:)
20
20
  @ref = ref #: Proto::V1::SecretRef
21
- end
22
-
23
- # Wraps a reference that may be absent.
24
- #
25
- # @param ref [Proto::V1::SecretRef, nil] reference to wrap
26
- # @return [Secret, nil] the secret, or nil when ref is nil
27
- #: (Proto::V1::SecretRef? ref) -> Secret?
28
- def self.maybe(ref)
29
- ref && new(ref)
21
+ @resolver = resolver #: SecretsResolver
30
22
  end
31
23
 
32
24
  # @return [Proto::V1::SecretRef] the wrapped reference
@@ -36,39 +28,22 @@ module Glazed
36
28
  # Reads the secret value.
37
29
  #
38
30
  # @return [String] the value
39
- # @raise [Unavailable] when the environment variable or file is missing, or no source is set
31
+ # @raise [Unavailable] when the value cannot be read, or no source is set
40
32
  #: () -> String
41
- def value
42
- case ref.source
43
- when :value then ref.value
44
- when :env then ENV.fetch(ref.env) { raise Unavailable, "environment variable #{ref.env} is not set" }
45
- when :file then read_file(ref.file)
46
- when :plugin then Secrets.build(ref.plugin || raise(Unavailable, 'secret plugin has no settings')).value
47
- else raise Unavailable, 'secret has no source'
48
- end
49
- end
33
+ def value = @resolver.value(ref)
50
34
 
51
35
  # @return [String] description naming the source but not the value
52
36
  #: () -> String
53
37
  def inspect
54
- case ref.source
38
+ case (source = ref.source)
39
+ when :value, nil then "#<#{self.class} value>"
55
40
  when :env then "#<#{self.class} env:#{ref.env}>"
56
41
  when :file then "#<#{self.class} file:#{ref.file}>"
57
42
  when :plugin then "#<#{self.class} plugin:#{ref.plugin&.type_name}>"
58
- else "#<#{self.class} value>"
43
+ else "#<#{self.class} #{source}>"
59
44
  end
60
45
  end
61
46
 
62
47
  alias to_s inspect
63
-
64
- # @param path [String] file to read
65
- # @return [String] file contents without one trailing newline
66
- # @raise [Unavailable] when the file cannot be read
67
- #: (String path) -> String
68
- private def read_file(path)
69
- File.read(path).delete_suffix("\n")
70
- rescue SystemCallError => e
71
- raise Unavailable, "cannot read secret file #{path}: #{e.message}"
72
- end
73
48
  end
74
49
  end
@@ -0,0 +1,66 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ require 'glazed'
7
+ require 'glazed/must'
8
+ require 'glazed/proto'
9
+ require 'glazed/secret'
10
+ require 'glazed/secrets'
11
+ require 'glazed/shell_command'
12
+
13
+ module Glazed
14
+ module Secrets
15
+ # Secret printed by a command line run with /bin/sh -c, or by a program
16
+ # run with its arguments, such as a password manager's CLI. It runs each
17
+ # time the value is read.
18
+ #
19
+ # @example
20
+ # Glazed::Secrets::Command.new(run: 'pass show network/enable').value
21
+ # Glazed::Secrets::Command.new(run: %w[op read op://network/enable/password]).value
22
+ class Command < Base
23
+ # Seconds before the command is killed when the settings set none.
24
+ DEFAULT_TIMEOUT = 30 #: Integer
25
+
26
+ # @param run [String, Array<String>] command line for /bin/sh -c, or program and its arguments
27
+ # @param timeout [Numeric] seconds before the command is killed
28
+ #: (run: String | Array[String], ?timeout: Numeric) -> void
29
+ def initialize(run:, timeout: DEFAULT_TIMEOUT)
30
+ super()
31
+ @run = run #: String | Array[String]
32
+ @timeout = timeout #: Numeric
33
+ end
34
+
35
+ # Builds a source from {Proto::V1::CommandSecret}.
36
+ #
37
+ # @param settings [Google::Protobuf::MessageExts] a {Proto::V1::CommandSecret}
38
+ # @return [Command] the source
39
+ # @raise [Must::Violation] when settings is another message
40
+ #: (Google::Protobuf::MessageExts settings) -> Command
41
+ def self.from_proto(settings)
42
+ command = Must.be_a(settings, Proto::V1::CommandSecret)
43
+ new(run: ShellCommand.run_of(command.run, command.exec), timeout: command.timeout_seconds.zero? ? DEFAULT_TIMEOUT : command.timeout_seconds)
44
+ end
45
+
46
+ # @return [String, Array<String>] command line, or program and its arguments
47
+ #: String | Array[String]
48
+ attr_reader :run
49
+
50
+ # @return [Numeric] seconds before the command is killed
51
+ #: Numeric
52
+ attr_reader :timeout
53
+
54
+ # Runs the command and reads the value it prints.
55
+ #
56
+ # @return [String] stdout without one trailing newline
57
+ # @raise [Secret::Unavailable] when the command cannot start, exits non-zero or does not finish in time
58
+ #: () -> String
59
+ def value
60
+ ShellCommand.new(run:, env: {}, stdin: '', timeout:).call.delete_suffix("\n")
61
+ rescue ShellCommand::Failed, TimeoutError => e
62
+ raise Secret::Unavailable, "secret command failed: #{e.message}"
63
+ end
64
+ end
65
+ end
66
+ end
@@ -5,11 +5,14 @@
5
5
 
6
6
  require 'google/protobuf'
7
7
  require 'glazed'
8
+ require 'glazed/finder'
8
9
  require 'glazed/must'
9
10
  require 'glazed/plugin_registry'
10
11
 
11
12
  module Glazed
12
- # Secret sources implemented by plugins, used by `plugin` secret references.
13
+ # Secret sources other than a value, an environment variable or a file:
14
+ # those built for a message field of {Proto::V1::SecretRef}, such as
15
+ # `command` or glazed-aws's `ssm`, and plugins used by `plugin` references.
13
16
  module Secrets
14
17
  # A plugin source of one secret value, built from its settings message.
15
18
  class Base
@@ -20,13 +23,24 @@ module Glazed
20
23
  #: (Google::Protobuf::MessageExts settings) -> Base
21
24
  def self.from_proto(settings) = raise NotImplementedError
22
25
 
23
- # Reads the secret, each time it is used.
26
+ # Reads the secret, each time it is used. A source that fetches the
27
+ # value remotely may keep it for a while; the {SecretsResolver} holding
28
+ # the source bounds how long.
24
29
  #
25
30
  # @return [String] the secret value
26
31
  #: () -> String
27
32
  def value = raise NotImplementedError
28
33
  end
29
34
 
35
+ # Finds a secret source class by snake_case name.
36
+ #
37
+ # @param name [String, Symbol] source name, such as "ssm"
38
+ # @return [Class<Base>] the source class
39
+ # @raise [Finder::NotFound] when there is none, for example when the gem providing it is not installed
40
+ # @raise [Must::Violation] when the name resolves to something other than a secret source
41
+ #: (String | Symbol name) -> singleton(Base)
42
+ def self.find(name) = source_class(Finder.find(self, 'glazed/secrets', name))
43
+
30
44
  # Secret sources implemented by plugins, by settings message type.
31
45
  PLUGINS = PluginRegistry.new('secret source') #: PluginRegistry
32
46
 
@@ -37,7 +51,7 @@ module Glazed
37
51
  # @param implementation [Class<Base>] source built with {Base.from_proto}
38
52
  # @return [void]
39
53
  # @example
40
- # Glazed::Secrets.register(GlazedAws::V1::SsmParameter, GlazedAws::SsmParameterSecret)
54
+ # Glazed::Secrets.register(GlazedVault::V1::VaultSecret, GlazedVault::Secret)
41
55
  #: (singleton(Google::Protobuf::AbstractMessage) settings_class, singleton(Base) implementation) -> void
42
56
  def self.register(settings_class, implementation)
43
57
  PLUGINS.register(settings_class, implementation)
@@ -51,9 +65,17 @@ module Glazed
51
65
  #: (Google::Protobuf::Any packed) -> Base
52
66
  def self.build(packed)
53
67
  implementation, settings = PLUGINS.resolve(packed)
54
- raise Must::Violation, "#{implementation} is not a secret source" unless implementation.is_a?(Class) && implementation < Base
68
+ source_class(implementation).from_proto(settings)
69
+ end
70
+
71
+ # @param implementation [Module] registered or found implementation
72
+ # @return [Class<Base>] the implementation as a secret source class
73
+ # @raise [Must::Violation] when it is not a secret source
74
+ #: (Module[untyped] implementation) -> singleton(Base)
75
+ private_class_method def self.source_class(implementation)
76
+ return implementation if implementation.is_a?(Class) && implementation < Base
55
77
 
56
- implementation.from_proto(settings)
78
+ raise Must::Violation, "#{implementation} is not a secret source"
57
79
  end
58
80
  end
59
81
  end
@@ -0,0 +1,85 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ require 'google/protobuf'
7
+ require 'glazed'
8
+ require 'glazed/must'
9
+ require 'glazed/proto'
10
+ require 'glazed/secret'
11
+ require 'glazed/secrets'
12
+
13
+ module Glazed
14
+ # Reads the values of {Proto::V1::SecretRef}s. Sources other than a value,
15
+ # an environment variable or a file are built once per reference and kept
16
+ # here, so a source that caches what it fetches, such as glazed-aws's
17
+ # `ssm`, does so for as long as this resolver lives.
18
+ #
19
+ # @example
20
+ # secrets = Glazed::SecretsResolver.new
21
+ # secrets.secret(node.password).value
22
+ class SecretsResolver
23
+ #: () -> void
24
+ def initialize
25
+ @sources = {} #: Hash[String, Secrets::Base]
26
+ @lock = Mutex.new #: Thread::Mutex
27
+ end
28
+
29
+ # @param ref [Proto::V1::SecretRef] reference to wrap
30
+ # @return [Secret] the secret, read through this resolver
31
+ #: (Proto::V1::SecretRef ref) -> Secret
32
+ def secret(ref) = Secret.new(ref, resolver: self)
33
+
34
+ # @param ref [Proto::V1::SecretRef, nil] reference to wrap
35
+ # @return [Secret, nil] the secret, or nil when ref is nil
36
+ #: (Proto::V1::SecretRef? ref) -> Secret?
37
+ def maybe(ref) = ref && secret(ref)
38
+
39
+ # Reads a secret value.
40
+ #
41
+ # @param ref [Proto::V1::SecretRef] reference to read
42
+ # @return [String] the value
43
+ # @raise [Secret::Unavailable] when the value cannot be read, or no source is set
44
+ # @raise [Finder::NotFound] when the source is provided by a gem that is not installed
45
+ # @raise [PluginRegistry::UnknownPlugin] when no plugin is registered for the settings type
46
+ #: (Proto::V1::SecretRef ref) -> String
47
+ def value(ref)
48
+ case ref.source
49
+ when :value then ref.value
50
+ when :env then ENV.fetch(ref.env) { raise Secret::Unavailable, "environment variable #{ref.env} is not set" }
51
+ when :file then read_file(ref.file)
52
+ when nil then raise Secret::Unavailable, 'secret has no source'
53
+ else source(ref).value
54
+ end
55
+ end
56
+
57
+ # @param ref [Proto::V1::SecretRef] reference with a plugin or gem-provided source
58
+ # @return [Secrets::Base] the source, built on first use
59
+ #: (Proto::V1::SecretRef ref) -> Secrets::Base
60
+ private def source(ref)
61
+ key = Proto::V1::SecretRef.encode(ref)
62
+ @lock.synchronize { @sources[key] ||= build(ref) }
63
+ end
64
+
65
+ # @param ref [Proto::V1::SecretRef] reference with a plugin or gem-provided source
66
+ # @return [Secrets::Base] the source
67
+ #: (Proto::V1::SecretRef ref) -> Secrets::Base
68
+ private def build(ref)
69
+ kind = Must.because(ref.source) { 'value reads only references with a source' }
70
+ return Secrets.build(Must.because(ref.plugin) { 'plugin is the selected source' }) if kind == :plugin
71
+
72
+ Secrets.find(kind).from_proto(Must.be_a(ref[kind.to_s], Google::Protobuf::MessageExts))
73
+ end
74
+
75
+ # @param path [String] file to read
76
+ # @return [String] file contents without one trailing newline
77
+ # @raise [Secret::Unavailable] when the file cannot be read
78
+ #: (String path) -> String
79
+ private def read_file(path)
80
+ File.read(path).delete_suffix("\n")
81
+ rescue SystemCallError => e
82
+ raise Secret::Unavailable, "cannot read secret file #{path}: #{e.message}"
83
+ end
84
+ end
85
+ end
@@ -7,10 +7,12 @@ require 'open3'
7
7
  require 'glazed'
8
8
 
9
9
  module Glazed
10
- # Runs a command line with /bin/sh -c, feeding stdin and capturing stdout.
10
+ # Runs a command line with /bin/sh -c, or a program with its arguments
11
+ # without a shell, feeding stdin and capturing stdout.
11
12
  #
12
13
  # @example
13
14
  # Glazed::ShellCommand.new(run: 'sort', env: {}, stdin: "b\na\n", timeout: 10).call #=> "a\nb\n"
15
+ # Glazed::ShellCommand.new(run: %w[sort -r], env: {}, stdin: "a\nb\n", timeout: 10).call #=> "b\na\n"
14
16
  class ShellCommand
15
17
  # Raised when the command exits non-zero or is killed by a signal.
16
18
  class Failed < Glazed::Error; end
@@ -20,13 +22,22 @@ module Glazed
20
22
  # Seconds between SIGTERM and SIGKILL when the command overruns.
21
23
  KILL_GRACE = 5 #: Integer
22
24
 
23
- # @param run [String] command line
25
+ # Picks what to run from settings holding a `run` command line and an
26
+ # `exec` argument list, of which one is set.
27
+ #
28
+ # @param run [String] command line for /bin/sh -c
29
+ # @param exec [Enumerable<String>] program and its arguments
30
+ # @return [String, Array<String>] exec, when set, or the command line
31
+ #: (String run, Enumerable[String] exec) -> (String | Array[String])
32
+ def self.run_of(run, exec) = exec.none? ? run : exec.to_a
33
+
34
+ # @param run [String, Array<String>] command line for /bin/sh -c, or program and its arguments
24
35
  # @param env [Hash{String => String}] environment added to the inherited one
25
36
  # @param stdin [String] data written to stdin
26
37
  # @param timeout [Numeric] seconds before the command is killed
27
- #: (run: String, env: Hash[String, String], stdin: String, timeout: Numeric) -> void
38
+ #: (run: String | Array[String], env: Hash[String, String], stdin: String, timeout: Numeric) -> void
28
39
  def initialize(run:, env:, stdin:, timeout:)
29
- @run = run #: String
40
+ @run = run #: String | Array[String]
30
41
  @env = env #: Hash[String, String]
31
42
  @stdin = stdin #: String
32
43
  @timeout = timeout #: Numeric
@@ -35,11 +46,11 @@ module Glazed
35
46
  # Runs the command to completion.
36
47
  #
37
48
  # @return [String] stdout
38
- # @raise [Failed] when the command exits non-zero or is killed by a signal
49
+ # @raise [Failed] when the command cannot start, exits non-zero or is killed by a signal
39
50
  # @raise [TimeoutError] when the command does not finish in time; its process group is killed
40
51
  #: () -> String
41
52
  def call
42
- stdin, stdout, stderr, waiter = Open3.popen3(@env, '/bin/sh', '-c', @run, pgroup: true)
53
+ stdin, stdout, stderr, waiter = spawn
43
54
  begin
44
55
  writer = Thread.new do
45
56
  stdin.write(@stdin)
@@ -67,6 +78,30 @@ module Glazed
67
78
  end
68
79
  end
69
80
 
81
+ # @return [Array(IO, IO, IO, Process::Waiter)] stdin, stdout, stderr and the waiter
82
+ # @raise [Failed] when the program cannot be executed
83
+ #: () -> [IO, IO, IO, Process::Waiter]
84
+ private def spawn
85
+ # Sorbet cannot check a splat against popen3's keyword options (7019).
86
+ open3 = Open3 #: untyped
87
+ open3.popen3(@env, *argv, pgroup: true)
88
+ rescue SystemCallError => e
89
+ raise Failed, "#{@run.inspect} could not start: #{e.message}"
90
+ end
91
+
92
+ # @return [Array] arguments for Open3.popen3
93
+ #: () -> Array[String | [String, String]]
94
+ private def argv
95
+ run = @run
96
+ return ['/bin/sh', '-c', run] if run.is_a?(String)
97
+
98
+ program, *args = run
99
+ raise ArgumentError, 'empty command' unless program
100
+
101
+ # A lone string would be run by a shell, so the program goes as [path, argv0].
102
+ [[program, program], *args]
103
+ end
104
+
70
105
  # Stops the command and everything it started, gracefully first.
71
106
  #
72
107
  # @param waiter [Process::Waiter] thread waiting on the process group leader
@@ -21,12 +21,12 @@ module Glazed
21
21
  # Seconds before the command is killed when the settings set none.
22
22
  DEFAULT_TIMEOUT = 300 #: Integer
23
23
 
24
- # @param run [String] command line, run by /bin/sh -c
24
+ # @param run [String, Array<String>] command line for /bin/sh -c, or program and its arguments
25
25
  # @param timeout [Numeric] seconds before the command is killed
26
- #: (run: String, ?timeout: Numeric) -> void
26
+ #: (run: String | Array[String], ?timeout: Numeric) -> void
27
27
  def initialize(run:, timeout: DEFAULT_TIMEOUT)
28
28
  super()
29
- @run = run #: String
29
+ @run = run #: String | Array[String]
30
30
  @timeout = timeout #: Numeric
31
31
  end
32
32
 
@@ -38,11 +38,11 @@ module Glazed
38
38
  #: (Google::Protobuf::MessageExts settings) -> Command
39
39
  def self.from_proto(settings)
40
40
  command = Must.be_a(settings, Proto::V1::CommandInventory)
41
- new(run: command.run, timeout: command.timeout_seconds.zero? ? DEFAULT_TIMEOUT : command.timeout_seconds)
41
+ new(run: ShellCommand.run_of(command.run, command.exec), timeout: command.timeout_seconds.zero? ? DEFAULT_TIMEOUT : command.timeout_seconds)
42
42
  end
43
43
 
44
- # @return [String] command line
45
- #: String
44
+ # @return [String, Array<String>] command line, or program and its arguments
45
+ #: String | Array[String]
46
46
  attr_reader :run
47
47
 
48
48
  # @return [Numeric] seconds before the command is killed
@@ -59,9 +59,12 @@ module Glazed
59
59
  #: () -> Array[Proto::V1::Node]
60
60
  def nodes = nodes_from_json(ShellCommand.new(run:, env: {}, stdin: '', timeout:).call, origin: description)
61
61
 
62
- # @return [String] the command line
62
+ # @return [String] the command line, or the program and its arguments
63
63
  #: () -> String
64
- def description = "command #{run}"
64
+ def description
65
+ run = self.run
66
+ "command #{run.is_a?(String) ? run : run.join(' ')}"
67
+ end
65
68
  end
66
69
  end
67
70
  end
@@ -13,7 +13,8 @@ module Glazed
13
13
  # Records change only through read-modify-write blocks, which a backend may
14
14
  # run more than once when another process changed the records meanwhile,
15
15
  # so a block must compute its result from the record it is given alone.
16
- # Subclasses implement {.from_proto}, {#update_all}, {#list_due} and {#list}.
16
+ # Subclasses implement {.from_proto}, {#update_all}, {#list_due}, {#list}
17
+ # and {#update_wakeup}.
17
18
  class Base
18
19
  #: type updater = ^(String node, Proto::V1::NodeSchedule? record) -> Proto::V1::NodeSchedule?
19
20
 
@@ -62,6 +63,15 @@ module Glazed
62
63
  #: (String workflow) -> Array[Proto::V1::NodeSchedule]
63
64
  def list(workflow) = raise NotImplementedError
64
65
 
66
+ # Replaces the record of the waiting coordinator execution with what the
67
+ # block returns, with the same guarantees as {#update_all}.
68
+ #
69
+ # @yieldparam record [Proto::V1::Wakeup, nil] the current record, or nil when there is none
70
+ # @yieldreturn [Proto::V1::Wakeup, nil] the new record, or nil to delete it
71
+ # @return [Proto::V1::Wakeup, nil] the record held afterwards
72
+ #: () { (Proto::V1::Wakeup? record) -> Proto::V1::Wakeup? } -> Proto::V1::Wakeup?
73
+ def update_wakeup(&) = raise NotImplementedError
74
+
65
75
  # Releases resources held by the backend.
66
76
  #
67
77
  # @return [void]