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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -0
- data/lib/glazed/collector.rb +10 -6
- data/lib/glazed/command/base.rb +3 -18
- data/lib/glazed/command/orchestrate.rb +1 -1
- data/lib/glazed/command/run.rb +1 -1
- data/lib/glazed/command/send_signal.rb +21 -13
- data/lib/glazed/config.rb +50 -0
- data/lib/glazed/coordinator/task_runner.rb +189 -0
- data/lib/glazed/coordinator.rb +252 -0
- data/lib/glazed/dialect/run.rb +9 -3
- data/lib/glazed/dialect/sensitive_pattern.rb +7 -2
- data/lib/glazed/orchestrator/policy.rb +28 -0
- data/lib/glazed/orchestrator.rb +15 -65
- data/lib/glazed/proto.rb +1 -0
- data/lib/glazed/redactor.rb +65 -26
- data/lib/glazed/secret.rb +12 -37
- data/lib/glazed/secrets/command.rb +66 -0
- data/lib/glazed/secrets.rb +27 -5
- data/lib/glazed/secrets_resolver.rb +85 -0
- data/lib/glazed/shell_command.rb +41 -6
- data/lib/glazed/sources/command.rb +11 -8
- data/lib/glazed/state_backends/base.rb +11 -1
- data/lib/glazed/state_backends/sqlite.rb +38 -1
- data/lib/glazed/step_context.rb +26 -8
- data/lib/glazed/steps/{batch_shell.rb → batch_command.rb} +15 -15
- data/lib/glazed/steps/{shell.rb → command.rb} +21 -18
- data/lib/glazed/steps/fetch.rb +1 -1
- data/lib/glazed/steps/redact.rb +3 -1
- data/lib/glazed/storage_context.rb +22 -0
- data/lib/glazed/storages/base.rb +3 -2
- data/lib/glazed/storages/filesystem.rb +3 -2
- data/lib/glazed/storages/git.rb +25 -8
- data/lib/glazed/storages/github.rb +22 -8
- data/lib/glazed/storages/memory.rb +3 -2
- data/lib/glazed/storages.rb +6 -4
- data/lib/glazed/transports/base.rb +14 -2
- data/lib/glazed/transports/ssh.rb +55 -2
- data/lib/glazed/transports/telnet.rb +4 -2
- data/lib/glazed/v1/config_pb.rb +2 -1
- data/lib/glazed/v1/coordinator_pb.rb +34 -0
- data/lib/glazed/v1/inventory_pb.rb +5 -1
- data/lib/glazed/v1/orchestrator_pb.rb +1 -1
- data/lib/glazed/v1/redaction_pb.rb +2 -1
- data/lib/glazed/v1/secret_pb.rb +3 -1
- data/lib/glazed/v1/state_pb.rb +2 -1
- data/lib/glazed/v1/storage_pb.rb +1 -1
- data/lib/glazed/v1/workflow_pb.rb +3 -3
- data/lib/glazed/version.rb +1 -1
- metadata +10 -3
data/lib/glazed/redactor.rb
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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 #{
|
|
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
|
|
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::
|
|
69
|
-
private_class_method def self.hmac_key(
|
|
70
|
-
encoded =
|
|
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,
|
|
95
|
-
|
|
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
|
|
143
|
+
kept = [] #: Array[[Integer, Integer, String?]]
|
|
144
|
+
ranges.sort_by { |start, finish,| [start, -finish] }.each do |range|
|
|
110
145
|
last = kept.last
|
|
111
|
-
kept <<
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
|
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}
|
|
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
|
data/lib/glazed/secrets.rb
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
data/lib/glazed/shell_command.rb
CHANGED
|
@@ -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,
|
|
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
|
-
#
|
|
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 =
|
|
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
|
|
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
|
|
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}
|
|
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]
|