ace-lab 0.9.0 → 0.13.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +363 -104
  3. data/README.md +66 -100
  4. data/docs/usage.md +529 -592
  5. data/lib/ace/lab/atoms/install_schema.rb +373 -0
  6. data/lib/ace/lab/cli/commands/install.rb +161 -0
  7. data/lib/ace/lab/cli/commands/prepare.rb +118 -0
  8. data/lib/ace/lab/cli/commands/project_add.rb +101 -0
  9. data/lib/ace/lab/cli/commands/status.rb +109 -0
  10. data/lib/ace/lab/cli/commands/verify.rb +115 -0
  11. data/lib/ace/lab/cli.rb +21 -23
  12. data/lib/ace/lab/install_manifest_path.rb +23 -0
  13. data/lib/ace/lab/models/status_report.rb +38 -0
  14. data/lib/ace/lab/molecules/account_provisioner.rb +196 -0
  15. data/lib/ace/lab/molecules/config_state_classifier.rb +145 -0
  16. data/lib/ace/lab/molecules/directory_provisioner.rb +208 -0
  17. data/lib/ace/lab/molecules/grant_provisioner.rb +250 -0
  18. data/lib/ace/lab/molecules/grant_resolver.rb +34 -23
  19. data/lib/ace/lab/molecules/hitl_routes.rb +263 -0
  20. data/lib/ace/lab/molecules/install_manifest_loader.rb +64 -0
  21. data/lib/ace/lab/molecules/key_provisioner.rb +736 -0
  22. data/lib/ace/lab/molecules/lab_state_scanner.rb +55 -0
  23. data/lib/ace/lab/molecules/overseer_provisioner.rb +188 -0
  24. data/lib/ace/lab/molecules/project_registry.rb +251 -0
  25. data/lib/ace/lab/molecules/repository_provisioner.rb +141 -0
  26. data/lib/ace/lab/molecules/role_verifier.rb +223 -0
  27. data/lib/ace/lab/organisms/install.rb +213 -0
  28. data/lib/ace/lab/organisms/prepare.rb +156 -0
  29. data/lib/ace/lab/organisms/project_add.rb +170 -0
  30. data/lib/ace/lab/organisms/status_service.rb +70 -0
  31. data/lib/ace/lab/organisms/verify.rb +768 -0
  32. data/lib/ace/lab/version.rb +1 -1
  33. data/lib/ace/lab.rb +34 -6
  34. metadata +34 -108
  35. data/lib/ace/lab/atoms/protected_publication_input.rb +0 -50
  36. data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +0 -114
  37. data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +0 -86
  38. data/lib/ace/lab/atoms/service_input.rb +0 -92
  39. data/lib/ace/lab/cli/commands/assignment.rb +0 -92
  40. data/lib/ace/lab/cli/commands/command_execute.rb +0 -28
  41. data/lib/ace/lab/cli/commands/run.rb +0 -65
  42. data/lib/ace/lab/cli/commands/service.rb +0 -240
  43. data/lib/ace/lab/models/service_receipt.rb +0 -18
  44. data/lib/ace/lab/molecules/command_owner_admission.rb +0 -183
  45. data/lib/ace/lab/molecules/command_policy.rb +0 -158
  46. data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
  47. data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
  48. data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
  49. data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
  50. data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
  51. data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -152
  52. data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
  53. data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
  54. data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
  55. data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
  56. data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
  57. data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
  58. data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
  59. data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
  60. data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
  61. data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
  62. data/lib/ace/lab/molecules/service_executor.rb +0 -99
  63. data/lib/ace/lab/molecules/service_policy.rb +0 -289
  64. data/lib/ace/lab/organisms/authority_composition.rb +0 -87
  65. data/lib/ace/lab/organisms/command_executor.rb +0 -220
  66. data/lib/ace/lab/organisms/command_run_client.rb +0 -131
  67. data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -120
  68. data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -153
  69. data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -679
  70. data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
  71. data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
  72. data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
  73. data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
  74. data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
  75. data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
  76. data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
  77. data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
  78. data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
  79. data/lib/ace/lab/organisms/service_request_service.rb +0 -249
@@ -1,220 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "pty"
5
- require "etc"
6
- require "fiddle"
7
- require "base64"
8
- require "io/console"
9
- require "ace/runtime/molecules/owned_child_scope"
10
- require_relative "command_run_client"
11
-
12
- module Ace
13
- module Lab
14
- module Organisms
15
- # Short-lived execution child of the installed root labd, not a listener
16
- # or another journal owner. A single Ruby thread forks the gated executor.
17
- class CommandExecutor
18
- def initialize(project:, owner: nil)
19
- @project = project
20
- @owner = owner || CommandRunClient.new(project: project).owner
21
- end
22
-
23
- def run(socket:, request:)
24
- raise SecurityError, "command executor requires Linux root" unless RUBY_PLATFORM.include?("linux") && Process.uid.zero? && Process.euid.zero?
25
- strict!(request, %w[execution_id caller tty rows cols])
26
- id = request.fetch("execution_id")
27
- raise ArgumentError, "invalid command reference" unless id.is_a?(String) && Molecules::CommandPolicy::ID.match?(id)
28
- interactive = request.fetch("tty")
29
- raise ArgumentError, "invalid terminal mode" unless [true, false].include?(interactive)
30
- rows, cols = geometry(request)
31
- params = {"execution_id" => id}
32
- initial = @owner.call("command_status", params).data
33
- unless initial["phase"] == "admitted" && initial.dig("definition", "requester") == request.fetch("caller")
34
- raise SecurityError, "original command already claimed or requester differs"
35
- end
36
- definition = initial.fetch("definition")
37
- unless definition.fetch("secret_references").empty?
38
- raise SecurityError, "secret command execution capability is not installed"
39
- end
40
- claimed = @owner.call("command_claim", params.merge("caller" => request.fetch("caller")), mutation_id: "command-claim.#{id}")
41
- raise SecurityError, "claim replay cannot execute" if claimed.replayed
42
- launch(socket, id, definition, interactive, rows, cols)
43
- end
44
-
45
- private
46
-
47
- def launch(socket, id, definition, interactive, rows, cols)
48
- raise SecurityError, "command fork requires a single-thread helper" unless Thread.list.count(&:alive?) == 1
49
- scope = Ace::Runtime::Molecules::OwnedChildScope.new
50
- executor = definition.fetch("executor")
51
- user = Etc.getpwuid(executor.fetch("uid"))
52
- environment = {"HOME" => executor.fetch("home"), "USER" => user.name, "LOGNAME" => user.name,
53
- "PATH" => executor.fetch("path").join(":")}.merge(executor.fetch("environment"))
54
- if interactive
55
- parent_input, child_output = PTY.open
56
- parent_output, child_input = parent_input, child_output
57
- child_output.winsize = [rows, cols]
58
- else
59
- child_input, parent_input = IO.pipe
60
- parent_output, child_output = IO.pipe
61
- parent_error, child_error = IO.pipe
62
- end
63
- gate_read, gate_write = IO.pipe
64
- ready_read, ready_write = IO.pipe
65
- child = fork do
66
- begin
67
- socket.close
68
- [parent_input, parent_output, parent_error, gate_write, ready_read].compact.uniq.each(&:close)
69
- Process.setsid
70
- child_input.ioctl(0x540E, 0) if interactive # Linux TIOCSCTTY
71
- $stdin.reopen(child_input)
72
- $stdout.reopen(child_output)
73
- $stderr.reopen(child_error || child_output)
74
- [child_input, child_output, child_error].compact.uniq.each(&:close)
75
- Process.groups = executor.fetch("groups")
76
- Process::GID.change_privilege(executor.fetch("gid"))
77
- Process::UID.change_privilege(executor.fetch("uid"))
78
- File.umask(0o077)
79
- prctl = Fiddle::Function.new(Fiddle::Handle::DEFAULT["prctl"],
80
- [Fiddle::TYPE_INT, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG], Fiddle::TYPE_INT)
81
- raise SecurityError, "cannot inhibit privilege gain" unless prctl.call(38, 1, 0, 0, 0).zero?
82
- Dir.chdir(executor.fetch("cwd"))
83
- ready_write.write("R")
84
- ready_write.close
85
- exit! 125 unless IO.select([gate_read], nil, nil, 35) && gate_read.read(1) == "E"
86
- gate_read.close
87
- argv = definition.fetch("argv")
88
- exec(environment, [File.expand_path(argv.first), argv.first], *argv.drop(1), unsetenv_others: true)
89
- rescue Exception
90
- exit! 126
91
- end
92
- end
93
- [child_input, child_output, child_error, gate_read, ready_write].compact.uniq.each(&:close)
94
- unless IO.select([ready_read], nil, nil, 10) && ready_read.read(1) == "R"
95
- raise SecurityError, "child did not reach admission gate"
96
- end
97
- ready_read.close
98
- witness = child_witness(child)
99
- bound = @owner.call("command_bind", {"execution_id" => id, "child" => witness}, mutation_id: "command-bind.#{id}")
100
- raise SecurityError, "binding replay cannot execute" if bound.replayed
101
- gate_write.write("E")
102
- gate_write.close
103
- send_frame(socket, {"type" => "started", "execution_id" => id})
104
- outputs = {parent_output => interactive ? "terminal" : "stdout"}
105
- outputs[parent_error] = "stderr" if parent_error
106
- pump(socket, parent_input, outputs, child, interactive)
107
- scope.cleanup!(leader: child)
108
- _, result = Process.waitpid2(child)
109
- reaped = true
110
- termination = {"exit_status" => result.exitstatus, "signal" => result.termsig}
111
- @owner.call("command_complete", {"execution_id" => id, "result" => termination}, mutation_id: "command-complete.#{id}")
112
- send_frame(socket, termination.merge("type" => "result", "execution_id" => id))
113
- result.exitstatus || 128 + result.termsig
114
- ensure
115
- [gate_write, ready_read, parent_input, parent_output, parent_error].compact.uniq.each { |io| io.close unless io.closed? }
116
- if child && !reaped
117
- scope.cleanup!(leader: child)
118
- deadline = monotonic + 5
119
- loop do
120
- break if Process.waitpid(child, Process::WNOHANG)
121
- break if monotonic >= deadline
122
- IO.select(nil, nil, nil, 0.01)
123
- end
124
- end
125
- end
126
-
127
- def pump(socket, input, outputs, child, interactive)
128
- pending = "".b
129
- pending_input = "".b
130
- input_open = true
131
- input_finished = false
132
- terminal_at = nil
133
- loop do
134
- terminal_at ||= monotonic if zombie?(child)
135
- break if terminal_at && (outputs.empty? || monotonic - terminal_at >= 1)
136
- readers = (pending_input.bytesize < 131_072 ? [socket] : []) + outputs.keys
137
- ready = IO.select(readers, pending_input.empty? || input.closed? ? [] : [input], nil, 0.1)
138
- next unless ready
139
- if ready.first.include?(socket)
140
- bytes = socket.read_nonblock(65_536, exception: false)
141
- next if bytes == :wait_readable
142
- raise IOError, "command client disconnected" unless bytes
143
- pending << bytes
144
- raise ArgumentError, "command frame exceeds bound" if pending.bytesize > 131_072
145
- while (newline = pending.index("\n"))
146
- frame = JSON.parse(pending.slice!(0, newline + 1))
147
- case frame.fetch("type")
148
- when "input"
149
- strict!(frame, %w[type data])
150
- raise ArgumentError, "command input after EOF" unless input_open
151
- pending_input << Base64.strict_decode64(frame.fetch("data"))
152
- raise ArgumentError, "command buffered input exceeds bound" if pending_input.bytesize > 131_072
153
- when "resize"
154
- strict!(frame, %w[type rows cols])
155
- raise ArgumentError, "resize requires a terminal" unless interactive
156
- input.winsize = geometry(frame)
157
- when "eof"
158
- strict!(frame, %w[type])
159
- raise ArgumentError, "duplicate input EOF" unless input_open
160
- pending_input << "\x04" if interactive
161
- input_open = false
162
- else raise ArgumentError, "invalid command input"
163
- end
164
- end
165
- end
166
- if ready[1].include?(input)
167
- written = input.write_nonblock(pending_input, exception: false)
168
- pending_input.slice!(0, written) if written.is_a?(Integer)
169
- end
170
- if !input_open && pending_input.empty? && !input_finished
171
- input.close unless interactive
172
- input_finished = true
173
- end
174
- outputs.keys.each do |output|
175
- next unless ready.first.include?(output)
176
- begin
177
- bytes = output.read_nonblock(65_536, exception: false)
178
- rescue Errno::EIO
179
- bytes = nil
180
- end
181
- next if bytes == :wait_readable
182
- if bytes
183
- send_frame(socket, {"type" => "output", "stream" => outputs.fetch(output), "data" => Base64.strict_encode64(bytes)})
184
- else
185
- outputs.delete(output)
186
- end
187
- end
188
- end
189
- end
190
-
191
- def child_witness(pid)
192
- observer = Ace::Runtime::Molecules::ProcessIdentity.new(executor:
193
- Ace::Herdr::Molecules::BoundedObservation.new(deadline: monotonic + 5))
194
- identity = observer.capture(pid)
195
- raise SecurityError, "child generation unavailable" unless identity
196
- status = File.readlines("/proc/#{pid}/status").filter_map { |line| line.include?(":") && line.split(":", 2) }.to_h
197
- identity.merge("gid" => status.fetch("Gid").split.first.to_i, "groups" => status.fetch("Groups").split.map(&:to_i).sort,
198
- "cwd" => File.readlink("/proc/#{pid}/cwd"), "process_group" => Process.getpgid(pid),
199
- "no_new_privs" => Integer(status.fetch("NoNewPrivs").strip, 10),
200
- "capabilities" => %w[CapInh CapPrm CapEff CapAmb].to_h { |key| [key, status.fetch(key).strip] })
201
- end
202
-
203
- def zombie?(pid) = File.read("/proc/#{pid}/stat").split(")").last.split.first == "Z"
204
- def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
205
- def geometry(value)
206
- rows, cols = value.values_at("rows", "cols")
207
- raise ArgumentError, "invalid command geometry" unless [rows, cols].all? { |v| v.is_a?(Integer) && v.between?(1, 65_535) }
208
- [rows, cols]
209
- end
210
- def strict!(value, keys)
211
- raise ArgumentError, "invalid command fields" unless value.is_a?(Hash) && value.keys.sort == keys.sort
212
- end
213
- def send_frame(socket, frame)
214
- Ace::Runtime::Molecules::ProtectedSocket.write(socket, frame,
215
- deadline: Ace::Runtime::Molecules::ProtectedSocket.deadline(5), limit: 131_072)
216
- end
217
- end
218
- end
219
- end
220
- end
@@ -1,131 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "securerandom"
5
- require "fileutils"
6
- require "base64"
7
- require "io/console"
8
- require "ace/assign/authority/ordinary_client"
9
- require_relative "../molecules/ordinary_owner_config"
10
- require_relative "../molecules/command_policy"
11
-
12
- module Ace
13
- module Lab
14
- module Organisms
15
- class CommandRunClient
16
- def initialize(project:)
17
- @project = project
18
- @config = Molecules::OrdinaryOwnerConfig.load(project: project)
19
- end
20
-
21
- def owner
22
- Ace::Assign::Authority::OrdinaryClient.new(project_id: @project, service: @config.service,
23
- verify_selection: @config.method(:verify_owner!))
24
- end
25
-
26
- def status(reference)
27
- owner.call("command_status", {"execution_id" => reference}).data
28
- end
29
-
30
- def run(argv:, executor:, services:, pane: nil)
31
- id = SecureRandom.hex(16)
32
- selection = {"argv" => argv, "executor" => executor, "services" => services, "pane" => pane}
33
- request = {"project" => @project, "execution_id" => id, "selection" => selection,
34
- "mutation_id" => "command-admit.#{id}"}
35
- parent = File.expand_path(".ace-local/lab-commands")
36
- FileUtils.mkdir_p(parent, mode: 0o700)
37
- directory = File.join(parent, id)
38
- Dir.mkdir(directory, 0o700)
39
- File.open(File.join(directory, "request.json"), File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
40
- file.write(JSON.generate(request) + "\n")
41
- file.flush
42
- file.fsync
43
- end
44
- File.open(directory, File::RDONLY) { |parent_io| parent_io.fsync }
45
- $stderr.puts "Execution #{id}; inspect with ace-lab run-status --project #{@project} --reference #{id}"
46
- reply = owner.call("command_admit", {"execution_id" => id, "selection" => selection}, mutation_id: request.fetch("mutation_id"))
47
- raise Ace::Assign::AttemptErrors::Conflict, "original command admission must be reconciled" if reply.replayed
48
- stream(id)
49
- end
50
-
51
- private
52
-
53
- def stream(id)
54
- wire = Ace::Runtime::Molecules::ProtectedSocket
55
- policy = Molecules::CommandPolicy.load(project: @project)
56
- path = policy.launcher.fetch("socket_path")
57
- wire.root_path!(File.dirname(path), directory: true)
58
- endpoint = wire.socket_identity(path, mode: 0o660)
59
- raise SecurityError, "command launcher endpoint owner differs" unless endpoint.last.zero?
60
- expected = JSON.parse(Molecules::GrantResolver.read_verified(policy.launcher.fetch("identity_path")) || "null")
61
- wire.connect(path, deadline: wire.deadline(5)) do |socket|
62
- peer = Ace::Assign::Authority::OrdinaryPeer.new.capture(socket)
63
- unless expected.is_a?(Hash) && expected["schema"] == "labd-command-launcher/v1" && expected["identity"] == peer
64
- raise SecurityError, "command launcher generation differs"
65
- end
66
- interactive = $stdin.tty? && $stdout.tty?
67
- rows, cols = interactive ? $stdin.winsize : [24, 80]
68
- wire.write(socket, {"op" => "command_run", "project" => @project, "execution_id" => id,
69
- "tty" => interactive, "rows" => rows, "cols" => cols}, deadline: wire.deadline(5), limit: 16_384)
70
- if interactive
71
- $stdin.raw { pump(socket, id, interactive: true) }
72
- else
73
- pump(socket, id, interactive: false)
74
- end
75
- end
76
- end
77
-
78
- def pump(socket, id, interactive:)
79
- wire = Ace::Runtime::Molecules::ProtectedSocket
80
- ingress = wire::Ingress.new(socket)
81
- inputs = [socket]
82
- resize = false
83
- old_winch = Signal.trap("WINCH") { resize = true } if interactive
84
- loop do
85
- if resize
86
- resize = false
87
- rows, cols = $stdin.winsize
88
- wire.write(socket, {"type" => "resize", "rows" => rows, "cols" => cols}, deadline: wire.deadline(5), limit: 131_072)
89
- end
90
- ready = IO.select(inputs, nil, nil, 0.25)
91
- next unless ready
92
- if ready.first.include?(socket)
93
- frame = wire.read(ingress, deadline: wire.deadline(5), limit: 131_072)
94
- case frame["type"]
95
- when "started"
96
- raise SecurityError, "invalid command start frame" unless frame.keys.sort == %w[execution_id type]
97
- raise SecurityError, "command reference differs" unless frame["execution_id"] == id
98
- inputs << $stdin unless inputs.include?($stdin)
99
- when "output"
100
- raise SecurityError, "invalid command output frame" unless frame.keys.sort == %w[data stream type] && %w[stdout stderr terminal].include?(frame["stream"])
101
- destination = frame["stream"] == "stderr" ? $stderr : $stdout
102
- destination.write(Base64.strict_decode64(frame.fetch("data")))
103
- destination.flush
104
- when "result"
105
- raise SecurityError, "invalid command result frame" unless frame.keys.sort == %w[execution_id exit_status signal type]
106
- raise SecurityError, "command reference differs" unless frame["execution_id"] == id
107
- exited, signal = frame.values_at("exit_status", "signal")
108
- if exited.is_a?(Integer) && exited.between?(0, 255) && signal.nil?
109
- return exited
110
- elsif exited.nil? && signal.is_a?(Integer) && signal.between?(1, 127)
111
- return 128 + signal
112
- end
113
- raise SecurityError, "command termination is invalid"
114
- else
115
- raise SecurityError, "command stream is incomplete"
116
- end
117
- end
118
- next unless ready.first.include?($stdin)
119
- bytes = $stdin.read_nonblock(8192, exception: false)
120
- next if bytes == :wait_readable
121
- frame = bytes ? {"type" => "input", "data" => Base64.strict_encode64(bytes)} : {"type" => "eof"}
122
- wire.write(socket, frame, deadline: wire.deadline(5), limit: 131_072)
123
- inputs.delete($stdin) unless bytes
124
- end
125
- ensure
126
- Signal.trap("WINCH", old_winch) if old_winch
127
- end
128
- end
129
- end
130
- end
131
- end
@@ -1,120 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "securerandom"
4
- require "ace/assign/authority/retained_input"
5
- require "ace/assign/authority/ordinary_client"
6
- require "ace/assign/authority/ordinary_lifecycle"
7
- require "ace/assign/organisms/prepared_work_builder"
8
- require_relative "../molecules/ordinary_owner_config"
9
-
10
- module Ace
11
- module Lab
12
- module Organisms
13
- # File inputs retain the exact mutation and bytes across response loss.
14
- # Only Assign's installed owner registers, advances and accepts execution.
15
- class OrdinaryAssignmentClient
16
- A = Ace::Assign::Authority
17
- Files = A::RetainedInput
18
- REQUEST_KEYS = %w[version project_id operation mutation_id params parts].freeze
19
-
20
- def initialize(project:, config: nil, client_factory: nil)
21
- @project = project
22
- @config = config || Molecules::OrdinaryOwnerConfig.load(project: project)
23
- unless @config.is_a?(Molecules::OrdinaryOwnerConfig) && @config.project == project
24
- raise ArgumentError, "ordinary assignment project selection differs"
25
- end
26
- @client_factory = client_factory || ->(**options) { A::OrdinaryClient.new(**options) }
27
- end
28
-
29
- # Preparation produces input only, so it is also available on the host.
30
- # Reusing an existing output directory never rebuilds or resubmits work.
31
- def self.prepare(project:, task:, pane:, output:, dependency_reports: [])
32
- unless project.is_a?(String) && Molecules::OrdinaryOwnerConfig::ID.match?(project) &&
33
- pane.is_a?(String) && Molecules::OrdinaryOwnerConfig::PANE.match?(pane)
34
- raise ArgumentError, "preparation requires project and native pane selectors"
35
- end
36
- directory = Files.create_output(output)
37
- prepared = Ace::Assign::Organisms::PreparedWorkBuilder.new(export_root: directory)
38
- .call(task_ref: task, project_id: project, dependency_reports: dependency_reports)
39
- definition = JSON.parse(prepared.fetch("definition_bytes"))
40
- reference = definition.fetch("prepared_work")
41
- request = {"version" => 1, "project_id" => project, "operation" => "register_assignment",
42
- "mutation_id" => SecureRandom.hex(16), "params" => {
43
- "assignment_id" => prepared.fetch("assignment_id"), "pane" => pane, "expected_generation" => 0,
44
- "definition_digest" => Digest::SHA256.hexdigest(prepared.fetch("definition_bytes")),
45
- "prepared_head" => prepared.fetch("head"), "prepared_tree" => prepared.fetch("tree"),
46
- "manifest_sha256" => reference.fetch("manifest_sha256")},
47
- "parts" => [{"file" => "prepared.bundle", "bytes" => prepared.fetch("bytes"), "sha256" => prepared.fetch("sha256")}]}
48
- Files.write_file(directory, "definition.json", prepared.fetch("definition_bytes"))
49
- Files.write_file(directory, "prepared.bundle", prepared.fetch("bundle"))
50
- Files.write_file(directory, "request.json", JSON.generate(request) + "\n")
51
- {"project_id" => project, "assignment_id" => prepared.fetch("assignment_id"),
52
- "scope" => prepared.fetch("scope"), "request" => File.join(directory, "request.json"), "prepared" => true}
53
- end
54
-
55
- def request(input:, output: nil)
56
- request = JSON.parse(Files.read_file(input, 16_384), allow_duplicate_key: false, create_additions: false, max_nesting: 24)
57
- unless request.is_a?(Hash) && request.keys.sort == REQUEST_KEYS.sort && request["version"].is_a?(Integer) && request["version"] == 1 &&
58
- request["project_id"] == @project && A::OrdinaryLifecycle::OPERATIONS.include?(request["operation"]) &&
59
- request["params"].is_a?(Hash) && !request["params"].key?("transfer") && request["parts"].is_a?(Array)
60
- raise ArgumentError, "retained ordinary request selection is invalid"
61
- end
62
- operation = request.fetch("operation")
63
- read = %w[registration_status assignment_snapshot result_status result_snapshot supervisor_status export_candidate review_status merge_evidence].include?(operation)
64
- mutation = request.fetch("mutation_id")
65
- unless read ? mutation.nil? : mutation.is_a?(String) && Ace::Assign::Molecules::JournalMutation::ID.match?(mutation)
66
- raise ArgumentError, "retained mutation ID must be explicit; read operations require null"
67
- end
68
- transfer = A::OrdinaryLifecycle::TRANSFER_OPERATIONS[operation]
69
- download = transfer && transfer.fetch(:direction) == :download
70
- upload = transfer && transfer.fetch(:direction) == :upload
71
- unless (!!output == !!download) && (request["parts"].empty? || upload)
72
- raise ArgumentError, "download requires a new --output directory; only uploads accept parts"
73
- end
74
- parts = upload && read_parts(request.fetch("parts"), input, transfer.fetch(:purpose))
75
- # No local metadata is interpreted as successful execution. Download
76
- # files are merely a projection of the authenticated canonical reply.
77
- directory = download && Files.create_output(output)
78
- client = @client_factory.call(project_id: @project, service: @config.service,
79
- verify_selection: @config.method(:verify_owner!), scratch_root: directory || nil)
80
- reply = client.call(operation, request.fetch("params"), mutation_id: request.fetch("mutation_id"),
81
- upload_parts: parts || nil, download: !!download, purpose: transfer && transfer.fetch(:purpose))
82
- envelope = {"status" => "ok", "data" => reply.data, "transport" => {"replayed" => reply.replayed}}
83
- if download
84
- files = reply.parts.each_with_index.map do |bytes, index|
85
- name = format("part-%02d.bytes", index)
86
- Files.write_file(directory, name, bytes)
87
- {"file" => File.join(directory, name), "bytes" => bytes.bytesize, "sha256" => Digest::SHA256.hexdigest(bytes)}
88
- end
89
- envelope["files"] = files
90
- Files.write_file(directory, "response.json", JSON.generate(envelope) + "\n")
91
- end
92
- envelope
93
- rescue JSON::ParserError
94
- raise ArgumentError, "retained request is not valid bounded JSON"
95
- end
96
-
97
- private
98
-
99
- def read_parts(entries, input, purpose)
100
- total, count, per_part = A::TransferCodec::LIMITS.fetch(purpose)
101
- unless entries.size.between?(1, count) && entries.all? { |entry|
102
- entry.is_a?(Hash) && entry.keys.sort == %w[bytes file sha256] && entry["file"].is_a?(String) &&
103
- entry["file"].bytesize.between?(1, 4096) && !entry["file"].include?("\0") &&
104
- entry["bytes"].is_a?(Integer) && entry["bytes"].between?(0, per_part) &&
105
- entry["sha256"].is_a?(String) && A::TransferCodec::SHA256.match?(entry["sha256"])
106
- } && entries.sum { |entry| entry.fetch("bytes") } <= total
107
- raise ArgumentError, "retained upload inventory is invalid"
108
- end
109
- entries.map do |entry|
110
- bytes = Files.read_file(File.expand_path(entry.fetch("file"), File.dirname(File.expand_path(input))), per_part)
111
- unless bytes.bytesize == entry.fetch("bytes") && Digest::SHA256.hexdigest(bytes) == entry.fetch("sha256")
112
- raise ArgumentError, "retained upload bytes changed; preserve the original mutation"
113
- end
114
- bytes
115
- end
116
- end
117
- end
118
- end
119
- end
120
- end
@@ -1,153 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "ace/assign/authority/ordinary_lifecycle"
4
- require "ace/assign/authority/ordinary_server"
5
- require "ace/assign/authority/router"
6
- require "ace/hitl/providers/lab"
7
- require "ace/hitl/lifecycle/service"
8
- require "ace/hitl/lifecycle/tls_authority"
9
- require "ace/hitl/lifecycle/tls_service"
10
- require_relative "../molecules/ordinary_owner_admission"
11
- require_relative "../molecules/caller_authorizer"
12
- require_relative "../molecules/command_owner_admission"
13
- require_relative "../molecules/ordinary_listener"
14
- require_relative "ordinary_merge_receiver"
15
-
16
- module Ace
17
- module Lab
18
- module Organisms
19
- # Ordinary delivery has one installed journal writer and exclusion owner.
20
- # Existing Assign, HITL and merge protocols share that process lifetime;
21
- # no requester cache or daemon working directory chooses accepted state.
22
- class OrdinaryAuthorityComposition
23
- def initialize(receiver_config:, owner_config: nil)
24
- unless receiver_config.is_a?(Molecules::OrdinaryReceiverConfig)
25
- raise ArgumentError, "ordinary composition needs installed receiver selection"
26
- end
27
- @receiver = receiver_config
28
- @config = owner_config || Molecules::OrdinaryOwnerConfig.load(project: @receiver.project)
29
- unless @config.is_a?(Molecules::OrdinaryOwnerConfig)
30
- raise ArgumentError, "ordinary composition needs installed assignment selection"
31
- end
32
- end
33
-
34
- def build
35
- @config.verify_listener!
36
- policy_document = Molecules::GrantResolver.trusted_document(@receiver.policy_path)
37
- verify_join!(policy_document)
38
- admission = Molecules::OrdinaryOwnerAdmission.new(config: @config)
39
- reader = nil
40
- journal = Ace::Assign::Molecules::EvidenceJournal.new(repo_root: @config.journal.fetch("repository"),
41
- ref: @config.journal.fetch("ref"), checkout_root: @config.journal.fetch("checkout_root"),
42
- evidence_reader: ->(reference, record, state, pending) { reader.call(reference, record, state, pending) })
43
- reader = Ace::Assign::Authority::ServiceEvidence.new(journal: journal)
44
- exclusion = Ace::Assign::Molecules::LifecycleExclusion.new(root: @config.lifecycle_root)
45
- command_admission = Molecules::CommandOwnerAdmission.new(base: admission, project: @config.project)
46
- command = Ace::Assign::Authority::CommandExecution.new(project: @config.project, journal: journal,
47
- admission: command_admission)
48
- coordinator = nil
49
- lifecycle = Ace::Assign::Authority::OrdinaryLifecycle.new(project: @config.project,
50
- admission: admission, journal: journal, exclusion: exclusion, input_root: @config.assignment_root,
51
- command_evidence: command,
52
- release_authorizer: ->(binding) { authorize_release_artifact!(binding, coordinator) })
53
- coordinator = Ace::Assign::Organisms::AttemptCoordinator.new(cache_base: @config.assignment_root,
54
- repo_root: @config.journal.fetch("repository"), journal: journal, lifecycle_exclusion: exclusion,
55
- recorded_binding_observer: admission.method(:recorded_binding!), ordinary_merge_evidence: lifecycle)
56
- router = Ace::Assign::Authority::Router.new(handlers: [lifecycle, command])
57
- merge = OrdinaryMergeReceiver.new(config: @receiver, coordinator: coordinator,
58
- policy_document: policy_document, owner_guard: -> { @config.selected_principal!(@receiver.principal_uid) },
59
- merge_factory: ->(document) {
60
- Molecules::OrdinaryMergeRead.new(repo_path: @receiver.repo_path, base_ref: document.fetch("base_ref"),
61
- root: @receiver.repo_path, common_dir: @config.worktrees.fetch("common_dir"))
62
- })
63
- listener = Molecules::OrdinaryListener.new(socket_path: @receiver.socket_path,
64
- peer_uid: @receiver.principal_uid, socket_gid: @receiver.socket_gid, handler: merge,
65
- logger: ->(message) { warn message })
66
- Ace::Assign::Authority::OrdinaryServer.new(admission: command_admission, lifecycle: router,
67
- services: hitl_services(coordinator) + [listener])
68
- end
69
-
70
- private
71
-
72
- # Retention reads the existing exact release decision. It neither
73
- # consumes an authorization nor dispatches a publication effect.
74
- def authorize_release_artifact!(binding, coordinator)
75
- @config.verify_owner!
76
- document = Molecules::GrantResolver.trusted_document(@receiver.policy_path)
77
- verify_join!(document)
78
- uid = binding.fetch("caller_uid")
79
- unless uid.is_a?(Integer) && uid.positive? && binding.fetch("project_id") == @config.project
80
- raise SecurityError, "release artifact caller or project differs"
81
- end
82
- username = begin
83
- Etc.getpwuid(uid)&.name
84
- rescue ArgumentError
85
- nil
86
- end
87
- visibility = Molecules::CallerAuthorizer.new(principals: document.fetch("principals", {}),
88
- identity: Molecules::CallerAuthorizer.matchable_identities(username, uid))
89
- raise SecurityError, "release artifact project grant is unavailable" unless visibility.authorized?(@config.project)
90
- Molecules::ServicePolicy.new(document, coordinator.method(:proposal_authorization))
91
- .authorize!(binding.fetch("authorization"), binding)
92
- end
93
-
94
- def verify_join!(document)
95
- configured = Molecules::ServicePolicy.new(document).ordinary_receiver!(@config.project)
96
- expected = {"receiver_id" => @receiver.receiver_id, "socket_path" => @receiver.socket_path,
97
- "receiver_uid" => @config.service.fetch("uid"), "principal" => @receiver.principal,
98
- "journal_repository" => @config.journal.fetch("repository"), "journal_ref" => @config.journal.fetch("ref")}
99
- unless @receiver.project == @config.project && @receiver.receiver_uid == @config.service.fetch("uid") &&
100
- configured && configured.slice(*expected.keys) == expected &&
101
- @config.participants.key?(@receiver.principal_uid.to_s) &&
102
- ![@config.service.fetch("socket_path"), @config.native.fetch("socket_path"), @config.hitl.fetch("socket_path")].include?(@receiver.socket_path)
103
- raise InvalidConfigurationError, "ordinary receiver and assignment owner selections differ"
104
- end
105
- end
106
-
107
- def hitl_services(coordinator)
108
- selected = @config.hitl
109
- policy = Ace::Hitl::Providers::Lab.grants_policy(grants_path: selected.fetch("grants_path"))
110
- unless policy.service_uid == @config.service.fetch("uid")
111
- raise InvalidConfigurationError, "ordinary HITL service principal differs from assignment owner"
112
- end
113
- guard = lambda do
114
- @config.verify_owner!
115
- unless policy.service_uid == @config.service.fetch("uid")
116
- raise Ace::Hitl::Lifecycle::BindingError, "installed HITL service principal changed"
117
- end
118
- rescue Ace::Lab::InvalidConfigurationError, Ace::Assign::Error => error
119
- raise Ace::Hitl::Lifecycle::BindingError, error.message
120
- end
121
- binding = Ace::Hitl::Providers::Lab::AssignmentBinding.new(coordinator: coordinator,
122
- project: @config.project, owner_guard: guard)
123
- # Unix requesters and the existing Hermes mTLS transport share the
124
- # same owner and one in-memory OTP vault. No second daemon opens the
125
- # journal and no synthetic Unix principal represents a certificate.
126
- vault = Ace::Hitl::Lifecycle::OtpVault::MemoryVault.new
127
- services = [Ace::Hitl::Lifecycle::Service.new(root: selected.fetch("root"),
128
- socket_path: selected.fetch("socket_path"), group: selected.fetch("group"),
129
- binding: binding, policy: policy, vault: vault)]
130
- if selected.key?("tls_authority")
131
- authority = Ace::Hitl::Lifecycle::TlsAuthority.load(selected.fetch("tls_authority"))
132
- verify_tls_identity!(authority)
133
- services << Ace::Hitl::Lifecycle::TlsService.new(root: selected.fetch("root"),
134
- binding: binding, project: @config.project, credential: authority.credential,
135
- ssl_context: authority.ssl_context!, listen: authority.listen, port: authority.port,
136
- group: selected.fetch("group"), vault: vault, decision_policy: policy)
137
- end
138
- services
139
- end
140
-
141
- def verify_tls_identity!(authority)
142
- unless authority.project_id == @config.project &&
143
- Etc.getpwnam(authority.service_user).uid == @config.service.fetch("uid") &&
144
- Etc.getgrnam(authority.service_group).gid == @config.service.fetch("gid")
145
- raise InvalidConfigurationError, "ordinary HITL TLS project or service identity differs"
146
- end
147
- rescue ArgumentError
148
- raise InvalidConfigurationError, "ordinary HITL TLS service account is unavailable"
149
- end
150
- end
151
- end
152
- end
153
- end