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
@@ -34,7 +34,7 @@ module Glazed
34
34
  class SchemaMismatch < Glazed::Error; end
35
35
 
36
36
  # Version of the database layout.
37
- SCHEMA_VERSION = 1 #: Integer
37
+ SCHEMA_VERSION = 2 #: Integer
38
38
  # Attempts at a change that keeps conflicting with other processes.
39
39
  MAX_ATTEMPTS = 5 #: Integer
40
40
  # Milliseconds to wait for another process holding the database lock.
@@ -113,6 +113,32 @@ module Glazed
113
113
  end
114
114
  end
115
115
 
116
+ # @yieldparam record [Proto::V1::Wakeup, nil] the current record, or nil when there is none
117
+ # @yieldreturn [Proto::V1::Wakeup, nil] the new record, or nil to delete it
118
+ # @return [Proto::V1::Wakeup, nil] the record held afterwards
119
+ # @raise [InvalidMessage] when the block returns an invalid record
120
+ # @raise [Storages::Base::Conflict] when other processes keep changing the database
121
+ #: () { (Proto::V1::Wakeup? record) -> Proto::V1::Wakeup? } -> Proto::V1::Wakeup?
122
+ def update_wakeup(&block)
123
+ write do |db|
124
+ stored = db.get_first_value("select record from wakeups where name = 'default'") #: String?
125
+ updated = block.call(stored && Proto::V1::Wakeup.decode(stored))
126
+ if updated
127
+ Validation.validate!(updated)
128
+ encoded = Proto::V1::Wakeup.encode(updated)
129
+ unless encoded == stored
130
+ db.execute(<<~SQL, [SQLite3::Blob.new(encoded)])
131
+ insert into wakeups (name, record) values ('default', ?)
132
+ on conflict (name) do update set record = excluded.record
133
+ SQL
134
+ end
135
+ elsif stored
136
+ db.execute("delete from wakeups where name = 'default'")
137
+ end
138
+ updated
139
+ end
140
+ end
141
+
116
142
  # @param workflow [String] workflow name
117
143
  # @param now [Time] the time; a lease ending at or before it has expired
118
144
  # @param limit [Integer, nil] records returned at most; all when nil
@@ -288,12 +314,23 @@ module Glazed
288
314
  ) strict
289
315
  SQL
290
316
  db.execute('create index schedules_due on schedules (workflow, due_at_ms)')
317
+ create_wakeups(db)
291
318
  db.execute('insert into glazed_schema (version) values (?)', [SCHEMA_VERSION])
319
+ when 1
320
+ create_wakeups(db)
321
+ db.execute('update glazed_schema set version = ?', [SCHEMA_VERSION])
292
322
  else
293
323
  raise SchemaMismatch, "#{@path} has state schema version #{version}, while this glazed uses #{SCHEMA_VERSION}"
294
324
  end
295
325
  end
296
326
  end
327
+
328
+ # @param db [SQLite3::Database] the database, in a transaction
329
+ # @return [void]
330
+ #: (SQLite3::Database db) -> void
331
+ private def create_wakeups(db)
332
+ db.execute('create table wakeups (name text primary key, record blob not null) strict')
333
+ end
297
334
  end
298
335
  end
299
336
  end
@@ -6,34 +6,52 @@
6
6
  require 'google/protobuf'
7
7
  require 'glazed'
8
8
  require 'glazed/proto'
9
+ require 'glazed/secrets_resolver'
10
+ require 'glazed/storage_context'
9
11
  require 'glazed/storages'
10
12
 
11
13
  module Glazed
12
- # Named storages shared by the steps of a workflow, and where steps report progress.
14
+ # Named storages shared by the steps of a workflow, the resolver of the
15
+ # secrets they refer to, and where steps report progress.
13
16
  class StepContext
14
17
  # Raised when a step names a storage that is not configured.
15
18
  class UnknownStorage < Glazed::Error; end
16
19
 
20
+ # @return [Proto::V1::Redaction, nil] redaction settings shared by redact steps
21
+ #: Proto::V1::Redaction?
22
+ attr_reader :redaction
23
+
17
24
  # @param storages [Hash{String => Storages::Base}] storages by name
25
+ # @param redaction [Proto::V1::Redaction, nil] redaction settings shared by redact steps
18
26
  # @param listener [Proc] receives progress events from steps, possibly from several threads at once
19
- #: (storages: Hash[String, Storages::Base], ?listener: Runner::listener) -> void
20
- def initialize(storages:, listener: ->(_event) {})
27
+ # @param secrets [SecretsResolver] reads secrets of nodes and settings
28
+ #: (storages: Hash[String, Storages::Base], ?redaction: Proto::V1::Redaction?, ?listener: Runner::listener, ?secrets: SecretsResolver) -> void
29
+ def initialize(storages:, redaction: nil, listener: ->(_event) {}, secrets: SecretsResolver.new)
21
30
  @storages = storages #: Hash[String, Storages::Base]
31
+ @redaction = redaction #: Proto::V1::Redaction?
22
32
  @listener = listener #: Runner::listener
33
+ @secrets = secrets #: SecretsResolver
23
34
  end
24
35
 
25
- # Builds every configured storage.
36
+ # @return [SecretsResolver] reads secrets of nodes and settings
37
+ #: SecretsResolver
38
+ attr_reader :secrets
39
+
40
+ # Builds every configured storage, with one secrets resolver for the storages and the steps.
26
41
  #
27
42
  # @param storages [Google::Protobuf::Map] storage configs by name, from {Proto::V1::Config#storages}
43
+ # @param redaction [Proto::V1::Redaction, nil] redaction settings shared by redact steps, from {Proto::V1::Config#redaction}
28
44
  # @param listener [Proc] receives progress events from steps, possibly from several threads at once
29
45
  # @return [StepContext] the context
30
46
  # @raise [Storages::MissingBackend] when a storage config sets no backend
31
- #: (Google::Protobuf::Map[String, Proto::V1::StorageConfig] storages, ?listener: Runner::listener) -> StepContext
32
- def self.from_proto(storages, listener: ->(_event) {})
47
+ #: (Google::Protobuf::Map[String, Proto::V1::StorageConfig] storages, ?redaction: Proto::V1::Redaction?, ?listener: Runner::listener) -> StepContext
48
+ def self.from_proto(storages, redaction: nil, listener: ->(_event) {})
49
+ secrets = SecretsResolver.new
50
+ storage_context = StorageContext.new(secrets:)
33
51
  # Map#to_h would turn the messages into hashes.
34
52
  built = {} #: Hash[String, Storages::Base]
35
- storages.each { |name, config| built[name] = Storages.build(config) }
36
- new(storages: built, listener:)
53
+ storages.each { |name, config| built[name] = Storages.build(config, context: storage_context) }
54
+ new(storages: built, redaction:, listener:, secrets:)
37
55
  end
38
56
 
39
57
  # Reports progress of a step.
@@ -11,41 +11,41 @@ require 'glazed/steps'
11
11
 
12
12
  module Glazed
13
13
  module Steps
14
- # Batch: runs a command with /bin/sh -c once, with the
15
- # {Proto::V1::BatchState} of every node that got this far as ProtoJSON on
16
- # stdin, for hooks such as notifications.
14
+ # Batch: runs a command line with /bin/sh -c, or a program with its
15
+ # arguments, once with the {Proto::V1::BatchState} of every node that got
16
+ # this far as ProtoJSON on stdin, for hooks such as notifications.
17
17
  #
18
18
  # @example Step definition
19
- # {"name": "notify", "batch_shell": {"run": "bin/notify-changes"}}
20
- class BatchShell < BatchStep
19
+ # {"name": "notify", "batch_command": {"run": "bin/notify-changes"}}
20
+ class BatchCommand < BatchStep
21
21
  # Seconds a command may run when the definition sets none.
22
22
  DEFAULT_TIMEOUT = 300 #: Integer
23
23
 
24
24
  # @param name [String] step name
25
- # @param run [String] command line
25
+ # @param run [String, Array<String>] command line for /bin/sh -c, or program and its arguments
26
26
  # @param timeout [Numeric] seconds before the command is killed
27
- #: (name: String, run: String, ?timeout: Numeric) -> void
27
+ #: (name: String, run: String | Array[String], ?timeout: Numeric) -> void
28
28
  def initialize(name:, run:, timeout: DEFAULT_TIMEOUT)
29
29
  super(name:)
30
- @run = run #: String
30
+ @run = run #: String | Array[String]
31
31
  @timeout = timeout #: Numeric
32
32
  end
33
33
 
34
- # Builds the step from {Proto::V1::BatchShellStep}.
34
+ # Builds the step from {Proto::V1::BatchCommandStep}.
35
35
  #
36
36
  # @param name [String] step name
37
- # @param settings [Google::Protobuf::MessageExts] a {Proto::V1::BatchShellStep}
37
+ # @param settings [Google::Protobuf::MessageExts] a {Proto::V1::BatchCommandStep}
38
38
  # @param context [StepContext] named storages, unused
39
- # @return [BatchShell] the step
40
- #: (String name, Google::Protobuf::MessageExts settings, context: StepContext) -> BatchShell
39
+ # @return [BatchCommand] the step
40
+ #: (String name, Google::Protobuf::MessageExts settings, context: StepContext) -> BatchCommand
41
41
  def self.from_proto(name, settings, context:)
42
- shell = Must.be_a(settings, Proto::V1::BatchShellStep)
43
- new(name:, run: shell.run, timeout: shell.timeout_seconds.zero? ? DEFAULT_TIMEOUT : shell.timeout_seconds)
42
+ command = Must.be_a(settings, Proto::V1::BatchCommandStep)
43
+ new(name:, run: ShellCommand.run_of(command.run, command.exec), timeout: command.timeout_seconds.zero? ? DEFAULT_TIMEOUT : command.timeout_seconds)
44
44
  end
45
45
 
46
46
  # @param batch [Proto::V1::BatchState] node states
47
47
  # @return [Proto::V1::BatchState] the same states
48
- # @raise [ShellCommand::Failed] when the command exits non-zero
48
+ # @raise [ShellCommand::Failed] when the command cannot start or exits non-zero
49
49
  # @raise [TimeoutError] when the command does not finish in time
50
50
  #: (Proto::V1::BatchState batch) -> Proto::V1::BatchState
51
51
  def call(batch)
@@ -12,28 +12,31 @@ require 'glazed/storages'
12
12
 
13
13
  module Glazed
14
14
  module Steps
15
- # Per node: runs a command with /bin/sh -c. The input artifact's content is
16
- # on stdin and stdout becomes the output artifact; both are optional.
15
+ # Per node: runs a command line with /bin/sh -c, or a program with its
16
+ # arguments. The input artifact's content is on stdin and stdout becomes
17
+ # the output artifact; both are optional.
17
18
  #
18
19
  # @example Step definition
19
- # {"name": "normalize", "shell": {"run": "sed -e '/^ntp clock-period/d'",
20
+ # {"name": "normalize", "command": {"run": "sed -e '/^ntp clock-period/d'",
20
21
  # "input": "raw", "output": "normalized", "storage": "staging"}}
21
- class Shell < NodeStep
22
+ # @example Step definition without a shell
23
+ # {"name": "lint", "command": {"exec": ["bin/lint-config", "--strict"], "input": "raw"}}
24
+ class Command < NodeStep
22
25
  # Seconds a command may run when the definition sets none.
23
26
  DEFAULT_TIMEOUT = 300 #: Integer
24
27
 
25
28
  # @param name [String] step name
26
- # @param run [String] command line
29
+ # @param run [String, Array<String>] command line for /bin/sh -c, or program and its arguments
27
30
  # @param input [String, nil] artifact written to stdin
28
31
  # @param output [String, nil] artifact produced from stdout
29
32
  # @param storage_name [String, nil] storage for the output, by name
30
33
  # @param context [StepContext] named storages
31
34
  # @param timeout [Numeric] seconds before the command is killed
32
35
  # @raise [StepContext::UnknownStorage] when the storage is not configured
33
- #: (name: String, run: String, input: String?, output: String?, storage_name: String?, context: StepContext, ?timeout: Numeric) -> void
36
+ #: (name: String, run: String | Array[String], input: String?, output: String?, storage_name: String?, context: StepContext, ?timeout: Numeric) -> void
34
37
  def initialize(name:, run:, input:, output:, storage_name:, context:, timeout: DEFAULT_TIMEOUT)
35
38
  super(name:)
36
- @run = run #: String
39
+ @run = run #: String | Array[String]
37
40
  @input = input #: String?
38
41
  @output = output #: String?
39
42
  @storage_name = storage_name #: String?
@@ -42,21 +45,21 @@ module Glazed
42
45
  @timeout = timeout #: Numeric
43
46
  end
44
47
 
45
- # Builds the step from {Proto::V1::ShellStep}.
48
+ # Builds the step from {Proto::V1::CommandStep}.
46
49
  #
47
50
  # @param name [String] step name
48
- # @param settings [Google::Protobuf::MessageExts] a {Proto::V1::ShellStep}
51
+ # @param settings [Google::Protobuf::MessageExts] a {Proto::V1::CommandStep}
49
52
  # @param context [StepContext] named storages
50
- # @return [Shell] the step
51
- #: (String name, Google::Protobuf::MessageExts settings, context: StepContext) -> Shell
53
+ # @return [Command] the step
54
+ #: (String name, Google::Protobuf::MessageExts settings, context: StepContext) -> Command
52
55
  def self.from_proto(name, settings, context:)
53
- shell = Must.be_a(settings, Proto::V1::ShellStep)
56
+ command = Must.be_a(settings, Proto::V1::CommandStep)
54
57
  new(
55
- name:, run: shell.run, context:,
56
- input: shell.input.empty? ? nil : shell.input,
57
- output: shell.output.empty? ? nil : shell.output,
58
- storage_name: shell.storage.empty? ? nil : shell.storage,
59
- timeout: shell.timeout_seconds.zero? ? DEFAULT_TIMEOUT : shell.timeout_seconds,
58
+ name:, run: ShellCommand.run_of(command.run, command.exec), context:,
59
+ input: command.input.empty? ? nil : command.input,
60
+ output: command.output.empty? ? nil : command.output,
61
+ storage_name: command.storage.empty? ? nil : command.storage,
62
+ timeout: command.timeout_seconds.zero? ? DEFAULT_TIMEOUT : command.timeout_seconds,
60
63
  )
61
64
  end
62
65
 
@@ -70,7 +73,7 @@ module Glazed
70
73
 
71
74
  # @param state [Proto::V1::NodeState] the node and its artifacts
72
75
  # @return [Proto::V1::NodeState] the state, with the output artifact when configured
73
- # @raise [ShellCommand::Failed] when the command exits non-zero
76
+ # @raise [ShellCommand::Failed] when the command cannot start or exits non-zero
74
77
  # @raise [TimeoutError] when the command does not finish in time
75
78
  # @raise [MissingArtifact] when the input artifact is absent
76
79
  #: (Proto::V1::NodeState state) -> Proto::V1::NodeState
@@ -58,7 +58,7 @@ module Glazed
58
58
  def call(state)
59
59
  node = state.node or raise ArgumentError, "#{name}: state has no node"
60
60
  progress = ->(message) { @context.notify(level: 'info', step: name, node: node.name, message:) } #: ^(String) -> void
61
- config = Collector.new(node:, timeout: @timeout, progress:).call
61
+ config = Collector.new(node:, timeout: @timeout, progress:, secrets: @context.secrets).call
62
62
  result = @storage.write(key: Storages.key_for(node), content: config, message: "#{node.name}: #{name}")
63
63
  with_artifacts(state, @output => stored(@storage_name, result))
64
64
  end
@@ -67,7 +67,9 @@ module Glazed
67
67
  #: (Proto::V1::NodeState state) -> Proto::V1::NodeState
68
68
  def call(state)
69
69
  node = state.node or raise ArgumentError, "#{name}: state has no node"
70
- redactor = Redactor.from_proto(@redaction, patterns: Dialects.find(node.dialect).sensitive_patterns)
70
+ redactor = Redactor.from_proto(
71
+ @redaction, patterns: Dialects.find(node.dialect).sensitive_patterns, global: @context.redaction, secrets: @context.secrets,
72
+ )
71
73
  content = redactor.call(@context.read(artifact(state, @input)))
72
74
  result = @storage.write(key: Storages.key_for(node), content:, message: "#{node.name}: #{name}")
73
75
  with_artifacts(state, @output => stored(@storage_name, result))
@@ -0,0 +1,22 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ require 'glazed'
7
+ require 'glazed/secrets_resolver'
8
+
9
+ module Glazed
10
+ # What storages are built with, besides their own settings.
11
+ class StorageContext
12
+ # @param secrets [SecretsResolver] reads secrets the storages refer to
13
+ #: (?secrets: SecretsResolver) -> void
14
+ def initialize(secrets: SecretsResolver.new)
15
+ @secrets = secrets #: SecretsResolver
16
+ end
17
+
18
+ # @return [SecretsResolver] reads secrets the storages refer to
19
+ #: SecretsResolver
20
+ attr_reader :secrets
21
+ end
22
+ end
@@ -64,9 +64,10 @@ module Glazed
64
64
  # Builds a storage from its settings message.
65
65
  #
66
66
  # @param settings [Google::Protobuf::MessageExts] backend settings from {Proto::V1::StorageConfig}
67
+ # @param context [StorageContext] secrets the settings refer to
67
68
  # @return [Base] the storage
68
- #: (Google::Protobuf::MessageExts settings) -> Base
69
- def self.from_proto(settings) = raise NotImplementedError
69
+ #: (Google::Protobuf::MessageExts settings, context: StorageContext) -> Base
70
+ def self.from_proto(settings, context:) = raise NotImplementedError
70
71
 
71
72
  # Writes one file.
72
73
  #
@@ -33,10 +33,11 @@ module Glazed
33
33
  # Builds a storage from {Proto::V1::FilesystemStorage}.
34
34
  #
35
35
  # @param settings [Google::Protobuf::MessageExts] a {Proto::V1::FilesystemStorage}
36
+ # @param context [StorageContext] secrets the settings refer to
36
37
  # @return [Filesystem] the storage
37
38
  # @raise [Must::Violation] when settings is another message
38
- #: (Google::Protobuf::MessageExts settings) -> Filesystem
39
- def self.from_proto(settings)
39
+ #: (Google::Protobuf::MessageExts settings, context: StorageContext) -> Filesystem
40
+ def self.from_proto(settings, context:)
40
41
  filesystem = Must.be_a(settings, Proto::V1::FilesystemStorage)
41
42
  new(path: filesystem.path)
42
43
  end
@@ -34,24 +34,29 @@ module Glazed
34
34
  # @param remote [String, nil] remote name or URL to pull from and push to
35
35
  # @param author_name [String] commit author and committer name
36
36
  # @param author_email [String] commit author and committer email
37
- #: (path: String, ?branch: String, ?remote: String?, ?author_name: String, ?author_email: String) -> void
38
- def initialize(path:, branch: DEFAULT_BRANCH, remote: nil, author_name: DEFAULT_AUTHOR_NAME, author_email: DEFAULT_AUTHOR_EMAIL)
37
+ # @param subdirectory [String, nil] directory within the repository holding the files; the root when nil
38
+ # @raise [InvalidKey] for an unsafe subdirectory
39
+ #: (path: String, ?branch: String, ?remote: String?, ?author_name: String, ?author_email: String, ?subdirectory: String?) -> void
40
+ def initialize(path:, branch: DEFAULT_BRANCH, remote: nil, author_name: DEFAULT_AUTHOR_NAME, author_email: DEFAULT_AUTHOR_EMAIL, subdirectory: nil)
39
41
  super()
40
42
  @path = path #: String
41
43
  @branch = branch #: String
42
44
  @remote = remote #: String?
43
45
  @author_name = author_name #: String
44
46
  @author_email = author_email #: String
47
+ @subdirectory = subdirectory&.then { validate_key(it.chomp('/')) } #: String?
45
48
  @lock = Mutex.new #: Thread::Mutex
46
49
  end
47
50
 
48
51
  # Builds a storage from {Proto::V1::GitStorage}.
49
52
  #
50
53
  # @param settings [Google::Protobuf::MessageExts] a {Proto::V1::GitStorage}
54
+ # @param context [StorageContext] secrets the settings refer to
51
55
  # @return [Git] the storage
52
56
  # @raise [Must::Violation] when settings is another message
53
- #: (Google::Protobuf::MessageExts settings) -> Git
54
- def self.from_proto(settings)
57
+ # @raise [InvalidKey] for an unsafe subdirectory
58
+ #: (Google::Protobuf::MessageExts settings, context: StorageContext) -> Git
59
+ def self.from_proto(settings, context:)
55
60
  git = Must.be_a(settings, Proto::V1::GitStorage)
56
61
  new(
57
62
  path: git.path,
@@ -59,6 +64,7 @@ module Glazed
59
64
  remote: git.remote.empty? ? nil : git.remote,
60
65
  author_name: git.author_name.empty? ? DEFAULT_AUTHOR_NAME : git.author_name,
61
66
  author_email: git.author_email.empty? ? DEFAULT_AUTHOR_EMAIL : git.author_email,
67
+ subdirectory: git.subdirectory.empty? ? nil : git.subdirectory,
62
68
  )
63
69
  end
64
70
 
@@ -71,6 +77,9 @@ module Glazed
71
77
  # @return [String, nil] remote name or URL
72
78
  #: String?
73
79
  attr_reader :remote
80
+ # @return [String, nil] directory within the repository holding the files
81
+ #: String?
82
+ attr_reader :subdirectory
74
83
 
75
84
  # Writes files in one commit. Nothing is committed when no content
76
85
  # changed, and the pointers then name the existing HEAD.
@@ -83,22 +92,25 @@ module Glazed
83
92
  #: (Array[Base::file] files, message: Base::message_builder) -> Array[WriteResult]
84
93
  def write_all(files, message:)
85
94
  keys = files.map { |key, _| validate_key(key) }
95
+ paths = keys.map { repository_path(it) }
86
96
  @lock.synchronize do
87
97
  prepare
88
98
  files.each do |key, content|
89
- file = File.join(path, key)
99
+ file = File.join(path, repository_path(key))
90
100
  FileUtils.mkdir_p(File.dirname(file))
91
101
  File.write(file, content)
92
102
  end
93
- git(['add', '--', *keys])
94
- changed = git(['diff', '--cached', '--name-only', '-z', '--', *keys]).split("\0")
103
+ git(['add', '--', *paths])
104
+ changed_paths = git(['diff', '--cached', '--name-only', '-z', '--', *paths]).split("\0")
105
+ changed = keys.select { changed_paths.include?(repository_path(it)) }
95
106
  unless changed.empty?
96
107
  git(['commit', '--quiet', '--no-verify', '-m', message.call(changed)])
97
108
  git(['push', '--quiet', Must.expect(remote), "HEAD:refs/heads/#{branch}"]) if remote
98
109
  end
99
110
  commit = git(%w[rev-parse HEAD]).strip
100
111
  keys.map do |key|
101
- WriteResult.new(pointer: pointer_to(Proto::V1::GitLocation.new(path: key, commit:)), changed: changed.include?(key))
112
+ location = Proto::V1::GitLocation.new(path: repository_path(key), commit:)
113
+ WriteResult.new(pointer: pointer_to(location), changed: changed.include?(key))
102
114
  end
103
115
  end
104
116
  end
@@ -115,6 +127,11 @@ module Glazed
115
127
  git(['show', "#{location.commit}:#{validate_key(location.path)}"])
116
128
  end
117
129
 
130
+ # @param key [String] validated file key
131
+ # @return [String] path of the file within the repository
132
+ #: (String key) -> String
133
+ private def repository_path(key) = subdirectory ? "#{subdirectory}/#{key}" : key
134
+
118
135
  # Initialises the repository when missing, switches to the branch and
119
136
  # pulls it from the remote when it exists there.
120
137
  #
@@ -51,8 +51,10 @@ module Glazed
51
51
  # @param api_url [String] API endpoint
52
52
  # @param author_name [String, nil] commit author name; the token owner when nil
53
53
  # @param author_email [String, nil] commit author email; the token owner when nil
54
- #: (repository: String, branch: String, token: Secret, ?base_branch: String?, ?api_url: String, ?author_name: String?, ?author_email: String?) -> void
55
- def initialize(repository:, branch:, token:, base_branch: nil, api_url: DEFAULT_API_URL, author_name: nil, author_email: nil)
54
+ # @param subdirectory [String, nil] directory within the repository holding the files; the root when nil
55
+ # @raise [InvalidKey] for an unsafe subdirectory
56
+ #: (repository: String, branch: String, token: Secret, ?base_branch: String?, ?api_url: String, ?author_name: String?, ?author_email: String?, ?subdirectory: String?) -> void
57
+ def initialize(repository:, branch:, token:, base_branch: nil, api_url: DEFAULT_API_URL, author_name: nil, author_email: nil, subdirectory: nil)
56
58
  super()
57
59
  @repository = repository #: String
58
60
  @branch = branch #: String
@@ -60,26 +62,30 @@ module Glazed
60
62
  @base_branch = base_branch #: String?
61
63
  @api_url = api_url.chomp('/') #: String
62
64
  @author = (author_name && author_email ? {name: author_name, email: author_email} : nil) #: Hash[Symbol, String]?
65
+ @subdirectory = subdirectory&.then { validate_key(it.chomp('/')) } #: String?
63
66
  @http = nil #: HTTPX::Session?
64
67
  end
65
68
 
66
69
  # Builds a storage from {Proto::V1::GithubStorage}.
67
70
  #
68
71
  # @param settings [Google::Protobuf::MessageExts] a {Proto::V1::GithubStorage}
72
+ # @param context [StorageContext] secrets the settings refer to
69
73
  # @return [Github] the storage
70
74
  # @raise [Must::Violation] when settings is another message
71
75
  # @raise [ArgumentError] when the token is missing
72
- #: (Google::Protobuf::MessageExts settings) -> Github
73
- def self.from_proto(settings)
76
+ # @raise [InvalidKey] for an unsafe subdirectory
77
+ #: (Google::Protobuf::MessageExts settings, context: StorageContext) -> Github
78
+ def self.from_proto(settings, context:)
74
79
  github = Must.be_a(settings, Proto::V1::GithubStorage)
75
80
  new(
76
81
  repository: github.repository,
77
82
  branch: github.branch,
78
- token: Secret.maybe(github.token) || raise(ArgumentError, 'github storage needs a token'),
83
+ token: context.secrets.maybe(github.token) || raise(ArgumentError, 'github storage needs a token'),
79
84
  base_branch: github.base_branch.empty? ? nil : github.base_branch,
80
85
  api_url: github.api_url.empty? ? DEFAULT_API_URL : github.api_url,
81
86
  author_name: github.author_name.empty? ? nil : github.author_name,
82
87
  author_email: github.author_email.empty? ? nil : github.author_email,
88
+ subdirectory: github.subdirectory.empty? ? nil : github.subdirectory,
83
89
  )
84
90
  end
85
91
 
@@ -89,6 +95,9 @@ module Glazed
89
95
  # @return [String] branch to commit to
90
96
  #: String
91
97
  attr_reader :branch
98
+ # @return [String, nil] directory within the repository holding the files
99
+ #: String?
100
+ attr_reader :subdirectory
92
101
 
93
102
  # Writes the changed files in one commit. Nothing is committed when no
94
103
  # content changed, and the pointers then name the existing head.
@@ -103,7 +112,7 @@ module Glazed
103
112
  keys = files.map { |key, _| validate_key(key) }
104
113
  commit, changed = with_retries { commit_files(files, message) }
105
114
  keys.map do |key|
106
- pointer = pointer_to(Proto::V1::GithubLocation.new(repository:, path: key, commit:))
115
+ pointer = pointer_to(Proto::V1::GithubLocation.new(repository:, path: repository_path(key), commit:))
107
116
  WriteResult.new(pointer:, changed: changed.include?(key))
108
117
  end
109
118
  end
@@ -147,12 +156,12 @@ module Glazed
147
156
  parent = head || ref_sha(base_branch) || raise(ApiError.new(status: 404, message: "#{repository}: base branch #{base_branch} not found"))
148
157
  base_tree = json(get("/repos/#{repository}/git/commits/#{parent}")).dig('tree', 'sha')
149
158
  existing = blob_shas(base_tree)
150
- candidates = existing ? files.reject { |key, content| existing[key] == blob_sha(content) } : files
159
+ candidates = existing ? files.reject { |key, content| existing[repository_path(key)] == blob_sha(content) } : files
151
160
 
152
161
  commit = parent
153
162
  changed = [] #: Array[String]
154
163
  unless candidates.empty?
155
- entries = candidates.map { |key, content| {path: key, mode: '100644', type: 'blob', content:} }
164
+ entries = candidates.map { |key, content| {path: repository_path(key), mode: '100644', type: 'blob', content:} }
156
165
  tree = json(post("/repos/#{repository}/git/trees", {base_tree:, tree: entries})).fetch('sha')
157
166
  unless tree == base_tree
158
167
  changed = candidates.map(&:first)
@@ -181,6 +190,11 @@ module Glazed
181
190
  entries.select { it['type'] == 'blob' }.to_h { [it.fetch('path'), it.fetch('sha')] }
182
191
  end
183
192
 
193
+ # @param key [String] validated file key
194
+ # @return [String] path of the file within the repository
195
+ #: (String key) -> String
196
+ private def repository_path(key) = subdirectory ? "#{subdirectory}/#{key}" : key
197
+
184
198
  # @param content [String] file content
185
199
  # @return [String] git blob SHA-1 of the content
186
200
  #: (String content) -> String
@@ -32,10 +32,11 @@ module Glazed
32
32
  # Builds a storage from {Proto::V1::MemoryStorage}.
33
33
  #
34
34
  # @param settings [Google::Protobuf::MessageExts] a {Proto::V1::MemoryStorage}
35
+ # @param context [StorageContext] secrets the settings refer to
35
36
  # @return [Memory] the storage
36
37
  # @raise [Must::Violation] when settings is another message
37
- #: (Google::Protobuf::MessageExts settings) -> Memory
38
- def self.from_proto(settings)
38
+ #: (Google::Protobuf::MessageExts settings, context: StorageContext) -> Memory
39
+ def self.from_proto(settings, context:)
39
40
  Must.be_a(settings, Proto::V1::MemoryStorage)
40
41
  new
41
42
  end
@@ -8,6 +8,7 @@ require 'glazed/finder'
8
8
  require 'glazed/must'
9
9
  require 'glazed/plugin_registry'
10
10
  require 'glazed/proto'
11
+ require 'glazed/storage_context'
11
12
  require 'glazed/storages/base'
12
13
  require 'glazed/validation'
13
14
 
@@ -49,21 +50,22 @@ module Glazed
49
50
  # Builds the storage a config selects.
50
51
  #
51
52
  # @param config [Proto::V1::StorageConfig] storage settings
53
+ # @param context [StorageContext] secrets the settings refer to
52
54
  # @return [Base] the storage
53
55
  # @raise [InvalidMessage] when the config breaks the rules of its schema
54
56
  # @raise [Finder::NotFound] when the backend is not available
55
57
  # @raise [PluginRegistry::UnknownPlugin] when no plugin is registered for the settings type
56
- #: (Proto::V1::StorageConfig config) -> Base
57
- def self.build(config)
58
+ #: (Proto::V1::StorageConfig config, ?context: StorageContext) -> Base
59
+ def self.build(config, context: StorageContext.new)
58
60
  Validation.validate!(config)
59
61
  backend = config.backend or raise MissingBackend, 'storage config sets no backend'
60
62
  if (packed = config.plugin)
61
63
  implementation, settings = PLUGINS.resolve(packed)
62
- return storage_class(implementation).from_proto(settings)
64
+ return storage_class(implementation).from_proto(settings, context:)
63
65
  end
64
66
 
65
67
  settings = Must.be_a(config[backend.to_s], Google::Protobuf::MessageExts)
66
- find(backend).from_proto(settings)
68
+ find(backend).from_proto(settings, context:)
67
69
  end
68
70
 
69
71
  # @param implementation [Module] registered implementation
@@ -4,7 +4,9 @@
4
4
  # SPDX-License-Identifier: Apache-2.0
5
5
 
6
6
  require 'glazed'
7
+ require 'glazed/proto'
7
8
  require 'glazed/secret'
9
+ require 'glazed/secrets_resolver'
8
10
 
9
11
  module Glazed
10
12
  module Transports
@@ -18,13 +20,17 @@ module Glazed
18
20
  # @param username [String] login user name
19
21
  # @param password [Secret, nil] login password
20
22
  # @param connect_timeout [Numeric] seconds to wait for a connection
21
- #: (host: String, port: Integer?, username: String, password: Secret?, ?connect_timeout: Numeric) -> void
22
- def initialize(host:, port:, username:, password:, connect_timeout: DEFAULT_CONNECT_TIMEOUT)
23
+ # @param transport_options [Proto::V1::TransportOptions, nil] per-transport connection options
24
+ # @param secrets [SecretsResolver] reads secrets in the transport options
25
+ #: (host: String, port: Integer?, username: String, password: Secret?, ?connect_timeout: Numeric, ?transport_options: Proto::V1::TransportOptions?, ?secrets: SecretsResolver) -> void
26
+ def initialize(host:, port:, username:, password:, connect_timeout: DEFAULT_CONNECT_TIMEOUT, transport_options: nil, secrets: SecretsResolver.new)
23
27
  @host = host #: String
24
28
  @port = port #: Integer?
25
29
  @username = username #: String
26
30
  @password = password #: Secret?
27
31
  @connect_timeout = connect_timeout #: Numeric
32
+ @transport_options = transport_options #: Proto::V1::TransportOptions?
33
+ @secrets = secrets #: SecretsResolver
28
34
  @pty_options = {} #: Hash[Symbol, untyped]
29
35
  end
30
36
 
@@ -43,6 +49,12 @@ module Glazed
43
49
  # @return [Numeric] seconds to wait for a connection
44
50
  #: Numeric
45
51
  attr_reader :connect_timeout
52
+ # @return [Proto::V1::TransportOptions, nil] per-transport connection options
53
+ #: Proto::V1::TransportOptions?
54
+ attr_reader :transport_options
55
+ # @return [SecretsResolver] reads secrets in the transport options
56
+ #: SecretsResolver
57
+ attr_reader :secrets
46
58
  # @return [Hash{Symbol => Object}] terminal settings overriding the transport's defaults; ignored by transports without a PTY
47
59
  #: Hash[Symbol, untyped]
48
60
  attr_accessor :pty_options