ace-lab 0.8.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +363 -92
  3. data/README.md +66 -100
  4. data/docs/usage.md +529 -404
  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 -13
  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 +49 -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 -102
  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 -45
  40. data/lib/ace/lab/cli/commands/service.rb +0 -240
  41. data/lib/ace/lab/models/service_receipt.rb +0 -18
  42. data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
  43. data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
  44. data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
  45. data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
  46. data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
  47. data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -139
  48. data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
  49. data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
  50. data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
  51. data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
  52. data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
  53. data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
  54. data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
  55. data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
  56. data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
  57. data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
  58. data/lib/ace/lab/molecules/service_executor.rb +0 -99
  59. data/lib/ace/lab/molecules/service_policy.rb +0 -289
  60. data/lib/ace/lab/organisms/authority_composition.rb +0 -87
  61. data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -149
  62. data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -123
  63. data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -688
  64. data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
  65. data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
  66. data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
  67. data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
  68. data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
  69. data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
  70. data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
  71. data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
  72. data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
  73. data/lib/ace/lab/organisms/service_request_service.rb +0 -249
@@ -1,289 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../errors"
4
- require_relative "../atoms/service_input"
5
- require "ace/assign/errors"
6
- require "pathname"
7
- require "time"
8
-
9
- module Ace
10
- module Lab
11
- module Molecules
12
- # Deployment-owned operation and authorization facts. The caller may
13
- # name a reference but cannot define its scope or executor binding.
14
- class ServicePolicy
15
- NAME = /\A[a-z][a-z0-9-]{0,63}\z/
16
- ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
17
-
18
- def initialize(document, proposal_resolver = nil)
19
- @document = document
20
- @operations = document.fetch("operations", {})
21
- @authorizations = document.fetch("authorizations", {})
22
- @proposal_resolver = proposal_resolver
23
- unless @operations.is_a?(Hash) && @authorizations.is_a?(Hash)
24
- raise Ace::Lab::InvalidConfigurationError, "trusted service policy must contain mappings"
25
- end
26
- end
27
-
28
- # The trusted installation document as loaded. Decisions are mutable;
29
- # all other configuration remains bound for the receiver lifetime.
30
- attr_reader :document
31
-
32
- def self.configuration_digest(document)
33
- Atoms::ServiceInput.digest(document.except("authorizations"))
34
- end
35
-
36
- def operation!(name, project:, service_id:)
37
- raise ArgumentError, "invalid operation name" unless name.to_s.match?(NAME)
38
- operation = @operations[name]
39
- raise SecurityError, "operation is not configured" unless operation.is_a?(Hash)
40
- unless operation["project"] == project && operation["service_id"] == service_id
41
- raise SecurityError, "operation does not authorize the selected service in this project"
42
- end
43
- transport = operation.fetch("transport", "local")
44
- unless %w[local unix].include?(transport)
45
- raise Ace::Lab::InvalidConfigurationError, "configured service transport is invalid"
46
- end
47
- if transport == "local"
48
- argv = operation["argv"]
49
- unless argv.is_a?(Array) && !argv.empty? && argv.all? { |v| v.is_a?(String) && !v.empty? } &&
50
- argv.first.start_with?("/") && File.file?(argv.first) && File.executable?(argv.first)
51
- raise Ace::Lab::InvalidConfigurationError, "configured operation needs a fixed executable argv"
52
- end
53
- elsif !operation["socket_path"].is_a?(String) || !operation["socket_path"].start_with?("/")
54
- raise Ace::Lab::InvalidConfigurationError, "unix service needs an absolute socket path"
55
- end
56
- uid = operation["executor_uid"]
57
- raise Ace::Lab::InvalidConfigurationError, "configured executor UID is invalid" unless uid.is_a?(Integer) && uid >= 0
58
- if transport == "local"
59
- # Every path component is validated (symlinks resolved, writable
60
- # ancestors rejected) and the validated resolved file replaces
61
- # the configured argv entry, so execution cannot be redirected
62
- # between validation and dispatch.
63
- resolved = trusted_executable!(operation["argv"].first, uid)
64
- operation = operation.merge("argv" => [resolved] + operation["argv"][1..])
65
- end
66
- expires_at = parse_time(operation["lease_expires_at"])
67
- raise SecurityError, "executor lease has expired" if expires_at <= Time.now.utc
68
- if operation["host_maintenance"] == true
69
- # Maintenance executes a fixed updater outside the replaced
70
- # deployment; a socket-only operation has no executable to place
71
- # and cannot prove the maintenance boundary.
72
- unless transport == "local"
73
- raise Ace::Lab::InvalidConfigurationError,
74
- "host maintenance requires a local executable transport"
75
- end
76
- deployment_root = operation["deployment_root"].to_s
77
- sink_path = operation["evidence_sink"].to_s
78
- unless deployment_root.start_with?("/") && sink_path.start_with?("/")
79
- raise Ace::Lab::InvalidConfigurationError,
80
- "host maintenance needs absolute deployment and evidence sink paths"
81
- end
82
- deployment = resolved_path(deployment_root)
83
- sink = resolved_path(sink_path)
84
- executable = operation.dig("argv", 0).to_s
85
- unless deployment.start_with?("/") && sink.start_with?("/") && executable.start_with?("/") &&
86
- !inside?(executable, deployment) && !inside?(sink, deployment)
87
- raise Ace::Lab::InvalidConfigurationError,
88
- "host maintenance needs executable and evidence sink outside the replaced deployment"
89
- end
90
- end
91
- operation
92
- end
93
-
94
- def authorize!(reference, binding)
95
- raise ArgumentError, "invalid authorization reference" unless reference.to_s.match?(ID)
96
- if reference.start_with?("proposal-")
97
- raise SecurityError, "canonical proposal authority is unavailable" unless @proposal_resolver
98
- begin
99
- return @proposal_resolver.call(reference, binding)
100
- rescue Ace::Assign::Error
101
- raise SecurityError, "canonical proposal does not authorize this exact effect"
102
- end
103
- end
104
- decision = @authorizations[reference]
105
- raise SecurityError, "authorization reference is unresolved" unless decision.is_a?(Hash)
106
- required = %w[operation project_id assignment_id attempt_id input_digest target candidate_head caller_uid]
107
- unless required.all? { |key| decision[key] == binding[key] }
108
- raise SecurityError, "authorization does not match the exact service request"
109
- end
110
- raise SecurityError, "authorization has expired" if parse_time(decision["expires_at"]) <= Time.now.utc
111
- decision
112
- end
113
-
114
- # ---- ordinary fixed merge admission policy (spec 8x3.t.xz9.4) ------
115
- #
116
- # The installed ordinary section is the separately configured
117
- # ordinary receiver mapping and admission policy. It supplies
118
- # identity and placement only: the caller selects no endpoint,
119
- # credential or receiver. An absent project section or a disabled
120
- # admission flag is rollback: new admission is unavailable while
121
- # canonical records and read-only status stay preserved.
122
- ORDINARY_SECTION_KEYS = %w[projects].freeze
123
- ORDINARY_PROJECT_KEYS = %w[receiver_id socket_path receiver_uid principal admission journal_repository journal_ref].freeze
124
- ORDINARY_AUTHORIZATION_KEYS = %w[operation project_id assignment_id attempt_id queue_entry
125
- input_digest forge pr_url head_repository head_ref candidate_head base_repository
126
- base_ref expected_base_head expires_at].freeze
127
- # expires_at bounds the decision itself; it is never exact-matched
128
- # against the request binding, which carries no expiry.
129
- ORDINARY_BINDING_KEYS = ORDINARY_AUTHORIZATION_KEYS - %w[expires_at].freeze
130
-
131
- def ordinary_receiver!(project)
132
- section = @document["ordinary"]
133
- return nil unless section.is_a?(Hash)
134
- validate_ordinary_section!(section)
135
- entry = section.fetch("projects")[project.to_s]
136
- return nil unless entry.is_a?(Hash)
137
- entry
138
- end
139
-
140
- # The ordinary admission decision: exact-match on every closed
141
- # binding key, like authorize!, but over the ordinary request
142
- # identity (forge/PR/repository/ref) instead of execution selectors.
143
- def authorize_ordinary!(reference, binding)
144
- raise ArgumentError, "invalid authorization reference" unless reference.to_s.match?(ID)
145
- decision = @authorizations[reference]
146
- raise SecurityError, "authorization reference is unresolved" unless decision.is_a?(Hash)
147
- unless ORDINARY_AUTHORIZATION_KEYS.all? { |key| decision.key?(key) }
148
- raise SecurityError, "authorization reference is not an ordinary merge decision"
149
- end
150
- unless ORDINARY_BINDING_KEYS.all? { |key| decision[key] == binding[key] }
151
- raise SecurityError, "authorization does not match the exact ordinary merge request"
152
- end
153
- raise SecurityError, "authorization has expired" if parse_time(decision["expires_at"]) <= Time.now.utc
154
- decision
155
- end
156
-
157
- def validate_ordinary_section!(section)
158
- unless (section.keys - ORDINARY_SECTION_KEYS).empty? && section["projects"].is_a?(Hash)
159
- raise Ace::Lab::InvalidConfigurationError, "ordinary section must map projects"
160
- end
161
- section.fetch("projects").each do |project, entry|
162
- unless project.is_a?(String) && project.match?(ID) && entry.is_a?(Hash) &&
163
- (entry.keys - ORDINARY_PROJECT_KEYS).empty?
164
- raise Ace::Lab::InvalidConfigurationError, "ordinary project section is invalid"
165
- end
166
- unless entry["receiver_id"].is_a?(String) && entry["receiver_id"].match?(ID) &&
167
- entry["socket_path"].is_a?(String) && entry["socket_path"].start_with?("/") &&
168
- entry["receiver_uid"].is_a?(Integer) && entry["receiver_uid"].positive? &&
169
- entry["principal"].is_a?(String) && !entry["principal"].empty?
170
- raise Ace::Lab::InvalidConfigurationError, "ordinary receiver mapping is incomplete"
171
- end
172
- admission = entry.fetch("admission", "enabled")
173
- unless %w[enabled disabled].include?(admission)
174
- raise Ace::Lab::InvalidConfigurationError, "ordinary admission flag is invalid"
175
- end
176
- repository, ref = entry.values_at("journal_repository", "journal_ref")
177
- unless repository.is_a?(String) && repository.start_with?("/") && !repository.include?("\0") &&
178
- File.expand_path(repository) == repository &&
179
- ref.is_a?(String) && ref.match?(%r{\Arefs/[a-zA-Z0-9_-]+(?:/[a-zA-Z0-9_-]+)+\z})
180
- raise Ace::Lab::InvalidConfigurationError, "ordinary journal selection is invalid"
181
- end
182
- end
183
- true
184
- end
185
-
186
- # Inspection never renews effect permission or selects effect argv.
187
- def inspection!(name, project:, service_id:, executor_uid:)
188
- raise ArgumentError, "invalid inspection operation" unless name.is_a?(String) && name.match?(NAME)
189
- operation = @operations[name]
190
- unless operation.is_a?(Hash) && operation["project"] == project && operation["service_id"] == service_id &&
191
- operation["executor_uid"].is_a?(Integer) && operation["executor_uid"] == executor_uid &&
192
- operation.fetch("transport", "local") == "local"
193
- raise SecurityError, "inspection does not match the selected original executor"
194
- end
195
- # This source-composed inspector uses the fixed original root SDK;
196
- # configured handler argv cannot select or replace that owner.
197
- if name == "prune-preserved-workspace"
198
- return {"executor_uid" => executor_uid, "operation" => name}
199
- end
200
- argv = operation["no_effect_argv"]
201
- unless argv.is_a?(Array) && argv.length.between?(1, 16) &&
202
- argv.all? { |value| value.is_a?(String) && value.bytesize.between?(1, 1024) && !value.include?("\0") } &&
203
- argv.first.start_with?("/")
204
- raise Ace::Lab::InvalidConfigurationError, "operation has no fixed no-effect inspector"
205
- end
206
- executable = trusted_executable!(argv.first, executor_uid)
207
- {"executor_uid" => executor_uid, "argv" => [executable] + argv.drop(1)}
208
- end
209
-
210
- private
211
-
212
- # Validate the executable and every directory component on its
213
- # resolved path: each must be owned by root or the executor and not
214
- # group/other-writable, so no ancestor or symlink component can
215
- # redirect the fixed argv. Returns the resolved file path.
216
- def trusted_executable!(path, uid)
217
- resolved = resolved_path(path.to_s)
218
- unless resolved.start_with?("/") && File.file?(resolved) && File.executable?(resolved)
219
- raise Ace::Lab::InvalidConfigurationError, "configured executable is unavailable"
220
- end
221
- Pathname.new(resolved).descend do |component|
222
- stat = File.stat(component)
223
- unless [0, uid].include?(stat.uid) && (stat.mode & 0o022).zero?
224
- raise Ace::Lab::InvalidConfigurationError,
225
- "configured executable path has a writable or foreign-owned component: #{component}"
226
- end
227
- end
228
- resolved
229
- rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
230
- raise Ace::Lab::InvalidConfigurationError, "configured executable is unavailable"
231
- end
232
-
233
- def inside?(path, root)
234
- expanded = File.expand_path(path)
235
- base = File.expand_path(root)
236
- expanded == base || expanded.start_with?(base + File::SEPARATOR)
237
- end
238
-
239
- # Resolve a configured path through symlinks so placement checks see
240
- # the real location. Paths may not exist yet (an evidence sink is
241
- # created on first use), so the deepest ancestor — counting symlink
242
- # components themselves via lstat — is resolved instead of failing;
243
- # a dangling symlink resolves through its own target chain rather
244
- # than hiding behind its parent.
245
- MAX_SYMLINK_DEPTH = 16
246
-
247
- def resolved_path(path, depth = 0)
248
- raise Ace::Lab::InvalidConfigurationError, "trusted policy path has a symlink cycle" if depth > MAX_SYMLINK_DEPTH
249
- expanded = File.expand_path(path)
250
- candidate = expanded
251
- candidate = File.dirname(candidate) until candidate == "/" || lstat?(candidate)
252
- resolved = begin
253
- File.realpath(candidate)
254
- rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
255
- dangling_target(candidate, depth) || candidate
256
- end
257
- return resolved unless resolved == candidate && lstat?(candidate) && File.symlink?(candidate)
258
- dangling_target(candidate, depth) || resolved
259
- rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
260
- expanded
261
- end
262
-
263
- # Follow a dangling symlink's own target chain; nil when the link
264
- # cannot be resolved to any concrete location.
265
- def dangling_target(link, depth = 0)
266
- raise Ace::Lab::InvalidConfigurationError, "trusted policy path has a symlink cycle" if depth > MAX_SYMLINK_DEPTH
267
- target = File.readlink(link)
268
- target = File.expand_path(target, File.dirname(link)) unless Pathname.new(target).absolute?
269
- resolved_path(target, depth + 1)
270
- rescue Errno::ENOENT, Errno::EACCES
271
- nil
272
- end
273
-
274
- def lstat?(path)
275
- File.lstat(path)
276
- true
277
- rescue Errno::ENOENT
278
- false
279
- end
280
-
281
- def parse_time(value)
282
- Time.iso8601(value.to_s)
283
- rescue ArgumentError
284
- raise Ace::Lab::InvalidConfigurationError, "trusted policy has invalid expiry"
285
- end
286
- end
287
- end
288
- end
289
- end
@@ -1,87 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "ace/assign/authority/server"
4
- require "ace/assign/authority/router"
5
- require "ace/assign/authority/launch_lifecycle"
6
- require "ace/assign/authority/endcap"
7
- require_relative "../molecules/protected_service_policy"
8
- require "ace/hitl/providers/lab"
9
- require "ace/hitl/providers/lab/protected_assignment_binding"
10
- require "ace/hitl/lifecycle/service_publication_binding"
11
- require "ace/hitl/lifecycle/service"
12
-
13
- module Ace
14
- module Lab
15
- module Organisms
16
- # The only full-service composition. The deployed map chooses identity
17
- # and placement; source selects all handlers, policy and evidence readers.
18
- class AuthorityComposition
19
- REQUIRED_ENDCAP = %w[submit_candidate export_candidate assign_review accept_review request_service
20
- begin_dispatch publication_challenge publication_continue claim_service_settlement complete_service complete_no_effect submit_result finish recover bind_inbox
21
- service_status evidence_fetch].freeze
22
-
23
- def initialize(authority_id:, deployment: Ace::Assign::Authority::Deployment.load,
24
- kernel: Ace::Runtime::Molecules::ProtectedLinux.new)
25
- @authority_id, @deployment, @kernel = authority_id, deployment, kernel
26
- end
27
-
28
- def build
29
- @deployment.verify_composition!(@authority_id, composition: "services")
30
- unless (REQUIRED_ENDCAP - Ace::Assign::Authority::Endcap::OPERATIONS).empty?
31
- raise InvalidConfigurationError, "full-service source composition is incomplete"
32
- end
33
- projects = @deployment.data.fetch("launch_mappings").values
34
- .select { |map| map.fetch("authority_id") == @authority_id }.map { |map| map.fetch("project_id") }.uniq
35
- raise InvalidConfigurationError, "full-service authority has no projects" if projects.empty?
36
- projects.each do |id|
37
- project = @deployment.project(id)
38
- unless project["service_receivers"].is_a?(Hash) && !project["service_receivers"].empty?
39
- raise InvalidConfigurationError, "full-service project has no fixed receiver"
40
- end
41
- end
42
-
43
- journals = {}
44
- endcap = nil
45
- projects.each do |id|
46
- project = @deployment.project(id)
47
- reader = nil
48
- journal = Ace::Assign::Molecules::EvidenceJournal.new(repo_root: project.fetch("journal_repository"),
49
- checkout_root: project.fetch("evidence_checkout_root"), ref: project.fetch("evidence_git_ref"), mode: :protected,
50
- evidence_reader: ->(reference, record, state, pending) { reader.call(reference, record, state, pending) },
51
- service_authorizer: ->(existing, replacement, pending) {
52
- endcap.authorize_service_update!(journal: journals.fetch(id), existing: existing,
53
- replacement: replacement, pending: pending)
54
- })
55
- reader = Ace::Assign::Authority::ServiceEvidence.new(journal: journal)
56
- journals[id] = journal
57
- end
58
- policy = Molecules::ProtectedServicePolicy.new(proposal_resolver: ->(project, reference, binding) {
59
- journals.fetch(project).proposal_authorize!(reference, binding)
60
- })
61
- history = Ace::Assign::Authority::DeploymentHistory.load
62
- launch = Ace::Assign::Authority::LaunchLifecycle.new(deployment: @deployment, deployment_history: history,
63
- kernel: @kernel, journals: journals)
64
- endcap = Ace::Assign::Authority::Endcap.new(deployment: @deployment, launch: launch, kernel: @kernel,
65
- service_policy: policy, deployment_history: history)
66
- router = Ace::Assign::Authority::Router.new(launch: launch, handlers: [endcap])
67
- Ace::Assign::Authority::Server.new(authority_id: @authority_id, deployment: @deployment, kernel: @kernel,
68
- lifecycle: router, composition: "services", services: [publication_hitl(endcap, journals)])
69
- end
70
-
71
- private
72
-
73
- def publication_hitl(endcap, journals)
74
- provider = Ace::Hitl::Providers::Lab
75
- policy = provider.grants_policy(grants_path: provider::DEFAULT_GRANTS_PATH)
76
- unless policy.service_uid == @deployment.authority(@authority_id).fetch("uid")
77
- raise InvalidConfigurationError, "protected HITL service principal differs from installed authority"
78
- end
79
- ordinary = provider::ProtectedAssignmentBinding.new(authority: endcap, kernel: @kernel, journals: journals)
80
- binding = Ace::Hitl::Lifecycle::ServicePublicationBinding.new(ordinary: ordinary, authority: endcap, kernel: @kernel)
81
- Ace::Hitl::Lifecycle::Service.new(root: provider::DEFAULT_STORE_ROOT,
82
- socket_path: provider::DEFAULT_SOCKET_PATH, binding: binding, policy: policy)
83
- end
84
- end
85
- end
86
- end
87
- end
@@ -1,149 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "securerandom"
4
- require "ace/assign/authority/ordinary_client"
5
- require "ace/assign/authority/ordinary_lifecycle"
6
- require "ace/assign/organisms/prepared_work_builder"
7
- require_relative "../molecules/ordinary_owner_config"
8
-
9
- module Ace
10
- module Lab
11
- module Organisms
12
- # File inputs retain the exact mutation and bytes across response loss.
13
- # Only Assign's installed owner registers, advances and accepts execution.
14
- class OrdinaryAssignmentClient
15
- A = Ace::Assign::Authority
16
- REQUEST_KEYS = %w[version project_id operation mutation_id params parts].freeze
17
-
18
- def initialize(project:, config: nil, client_factory: nil)
19
- @project = project
20
- @config = config || Molecules::OrdinaryOwnerConfig.load(project: project)
21
- unless @config.is_a?(Molecules::OrdinaryOwnerConfig) && @config.project == project
22
- raise ArgumentError, "ordinary assignment project selection differs"
23
- end
24
- @client_factory = client_factory || ->(**options) { A::OrdinaryClient.new(**options) }
25
- end
26
-
27
- # Preparation produces input only, so it is also available on the host.
28
- # Reusing an existing output directory never rebuilds or resubmits work.
29
- def self.prepare(project:, task:, pane:, output:, dependency_reports: [])
30
- unless project.is_a?(String) && Molecules::OrdinaryOwnerConfig::ID.match?(project) &&
31
- pane.is_a?(String) && Molecules::OrdinaryOwnerConfig::PANE.match?(pane)
32
- raise ArgumentError, "preparation requires project and native pane selectors"
33
- end
34
- directory = create_output(output)
35
- prepared = Ace::Assign::Organisms::PreparedWorkBuilder.new(export_root: directory)
36
- .call(task_ref: task, project_id: project, dependency_reports: dependency_reports)
37
- definition = JSON.parse(prepared.fetch("definition_bytes"))
38
- reference = definition.fetch("prepared_work")
39
- request = {"version" => 1, "project_id" => project, "operation" => "register_assignment",
40
- "mutation_id" => SecureRandom.hex(16), "params" => {
41
- "assignment_id" => prepared.fetch("assignment_id"), "pane" => pane, "expected_generation" => 0,
42
- "definition_digest" => Digest::SHA256.hexdigest(prepared.fetch("definition_bytes")),
43
- "prepared_head" => prepared.fetch("head"), "prepared_tree" => prepared.fetch("tree"),
44
- "manifest_sha256" => reference.fetch("manifest_sha256")},
45
- "parts" => [{"file" => "prepared.bundle", "bytes" => prepared.fetch("bytes"), "sha256" => prepared.fetch("sha256")}]}
46
- write_file(directory, "definition.json", prepared.fetch("definition_bytes"))
47
- write_file(directory, "prepared.bundle", prepared.fetch("bundle"))
48
- write_file(directory, "request.json", JSON.generate(request) + "\n")
49
- {"project_id" => project, "assignment_id" => prepared.fetch("assignment_id"),
50
- "scope" => prepared.fetch("scope"), "request" => File.join(directory, "request.json"), "prepared" => true}
51
- end
52
-
53
- def request(input:, output: nil)
54
- request = JSON.parse(self.class.read_file(input, 16_384), allow_duplicate_key: false, create_additions: false, max_nesting: 24)
55
- unless request.is_a?(Hash) && request.keys.sort == REQUEST_KEYS.sort && request["version"].is_a?(Integer) && request["version"] == 1 &&
56
- request["project_id"] == @project && A::OrdinaryLifecycle::OPERATIONS.include?(request["operation"]) &&
57
- request["params"].is_a?(Hash) && !request["params"].key?("transfer") && request["parts"].is_a?(Array)
58
- raise ArgumentError, "retained ordinary request selection is invalid"
59
- end
60
- operation = request.fetch("operation")
61
- read = %w[registration_status assignment_snapshot result_status result_snapshot].include?(operation)
62
- mutation = request.fetch("mutation_id")
63
- unless read ? mutation.nil? : mutation.is_a?(String) && Ace::Assign::Molecules::JournalMutation::ID.match?(mutation)
64
- raise ArgumentError, "retained mutation ID must be explicit; read operations require null"
65
- end
66
- transfer = A::OrdinaryLifecycle::TRANSFER_OPERATIONS[operation]
67
- download = transfer && transfer.fetch(:direction) == :download
68
- upload = transfer && transfer.fetch(:direction) == :upload
69
- unless (!!output == !!download) && (request["parts"].empty? || upload)
70
- raise ArgumentError, "download requires a new --output directory; only uploads accept parts"
71
- end
72
- parts = upload && read_parts(request.fetch("parts"), input, transfer.fetch(:purpose))
73
- # No local metadata is interpreted as successful execution. Download
74
- # files are merely a projection of the authenticated canonical reply.
75
- directory = download && self.class.create_output(output)
76
- client = @client_factory.call(project_id: @project, service: @config.service,
77
- verify_selection: @config.method(:verify_owner!), scratch_root: directory || nil)
78
- reply = client.call(operation, request.fetch("params"), mutation_id: request.fetch("mutation_id"),
79
- upload_parts: parts || nil, download: !!download, purpose: transfer && transfer.fetch(:purpose))
80
- envelope = {"status" => "ok", "data" => reply.data, "transport" => {"replayed" => reply.replayed}}
81
- if download
82
- files = reply.parts.each_with_index.map do |bytes, index|
83
- name = format("part-%02d.bytes", index)
84
- self.class.write_file(directory, name, bytes)
85
- {"file" => File.join(directory, name), "bytes" => bytes.bytesize, "sha256" => Digest::SHA256.hexdigest(bytes)}
86
- end
87
- envelope["files"] = files
88
- self.class.write_file(directory, "response.json", JSON.generate(envelope) + "\n")
89
- end
90
- envelope
91
- rescue JSON::ParserError
92
- raise ArgumentError, "retained request is not valid bounded JSON"
93
- end
94
-
95
- def self.read_file(path, limit)
96
- File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
97
- before = file.stat
98
- raise ArgumentError, "retained input is not a bounded regular file" unless before.file? && before.size.between?(0, limit)
99
- bytes = (file.read(limit + 1) || "").b
100
- after, selected = file.stat, File.lstat(path)
101
- identity = ->(stat) { [stat.dev, stat.ino, stat.size, stat.mtime, stat.ctime] }
102
- unless bytes.bytesize == before.size && identity.call(before) == identity.call(after) &&
103
- identity.call(after) == identity.call(selected) && !selected.symlink?
104
- raise ArgumentError, "retained input changed during read"
105
- end
106
- bytes.freeze
107
- end
108
- end
109
-
110
- def self.create_output(path)
111
- raise ArgumentError, "output must be a new directory path" unless path.is_a?(String) && !path.empty?
112
- directory = File.expand_path(path)
113
- Dir.mkdir(directory, 0o700)
114
- A::PrivateDirectory.verify!(directory)
115
- directory
116
- end
117
-
118
- def self.write_file(directory, name, bytes)
119
- File.open(File.join(directory, name), File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
120
- file.write(bytes)
121
- file.flush
122
- file.fsync
123
- end
124
- end
125
-
126
- private
127
-
128
- def read_parts(entries, input, purpose)
129
- total, count, per_part = A::TransferCodec::LIMITS.fetch(purpose)
130
- unless entries.size.between?(1, count) && entries.all? { |entry|
131
- entry.is_a?(Hash) && entry.keys.sort == %w[bytes file sha256] && entry["file"].is_a?(String) &&
132
- entry["file"].bytesize.between?(1, 4096) && !entry["file"].include?("\0") &&
133
- entry["bytes"].is_a?(Integer) && entry["bytes"].between?(0, per_part) &&
134
- entry["sha256"].is_a?(String) && A::TransferCodec::SHA256.match?(entry["sha256"])
135
- } && entries.sum { |entry| entry.fetch("bytes") } <= total
136
- raise ArgumentError, "retained upload inventory is invalid"
137
- end
138
- entries.map do |entry|
139
- bytes = self.class.read_file(File.expand_path(entry.fetch("file"), File.dirname(File.expand_path(input))), per_part)
140
- unless bytes.bytesize == entry.fetch("bytes") && Digest::SHA256.hexdigest(bytes) == entry.fetch("sha256")
141
- raise ArgumentError, "retained upload bytes changed; preserve the original mutation"
142
- end
143
- bytes
144
- end
145
- end
146
- end
147
- end
148
- end
149
- end
@@ -1,123 +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/ordinary_listener"
12
- require_relative "ordinary_merge_receiver"
13
-
14
- module Ace
15
- module Lab
16
- module Organisms
17
- # Ordinary delivery has one installed journal writer and exclusion owner.
18
- # Existing Assign, HITL and merge protocols share that process lifetime;
19
- # no requester cache or daemon working directory chooses accepted state.
20
- class OrdinaryAuthorityComposition
21
- def initialize(receiver_config:, owner_config: nil)
22
- unless receiver_config.is_a?(Molecules::OrdinaryReceiverConfig)
23
- raise ArgumentError, "ordinary composition needs installed receiver selection"
24
- end
25
- @receiver = receiver_config
26
- @config = owner_config || Molecules::OrdinaryOwnerConfig.load(project: @receiver.project)
27
- unless @config.is_a?(Molecules::OrdinaryOwnerConfig)
28
- raise ArgumentError, "ordinary composition needs installed assignment selection"
29
- end
30
- end
31
-
32
- def build
33
- @config.verify_listener!
34
- policy_document = Molecules::GrantResolver.trusted_document(@receiver.policy_path)
35
- verify_join!(policy_document)
36
- admission = Molecules::OrdinaryOwnerAdmission.new(config: @config)
37
- reader = nil
38
- journal = Ace::Assign::Molecules::EvidenceJournal.new(repo_root: @config.journal.fetch("repository"),
39
- ref: @config.journal.fetch("ref"), checkout_root: @config.journal.fetch("checkout_root"),
40
- evidence_reader: ->(reference, record, state, pending) { reader.call(reference, record, state, pending) })
41
- reader = Ace::Assign::Authority::ServiceEvidence.new(journal: journal)
42
- exclusion = Ace::Assign::Molecules::LifecycleExclusion.new(root: @config.lifecycle_root)
43
- coordinator = Ace::Assign::Organisms::AttemptCoordinator.new(cache_base: @config.assignment_root,
44
- repo_root: @config.journal.fetch("repository"), journal: journal, lifecycle_exclusion: exclusion,
45
- recorded_binding_observer: admission.method(:recorded_binding!))
46
- lifecycle = Ace::Assign::Authority::OrdinaryLifecycle.new(project: @config.project,
47
- admission: admission, journal: journal, exclusion: exclusion, input_root: @config.assignment_root)
48
- router = Ace::Assign::Authority::Router.new(handlers: [lifecycle])
49
- merge = OrdinaryMergeReceiver.new(config: @receiver, coordinator: coordinator,
50
- policy_document: policy_document, owner_guard: -> { @config.selected_principal!(@receiver.principal_uid) },
51
- merge_factory: ->(document) {
52
- Molecules::OrdinaryMergeRead.new(repo_path: @receiver.repo_path, base_ref: document.fetch("base_ref"),
53
- root: @receiver.repo_path, common_dir: @config.worktrees.fetch("common_dir"))
54
- })
55
- listener = Molecules::OrdinaryListener.new(socket_path: @receiver.socket_path,
56
- peer_uid: @receiver.principal_uid, socket_gid: @receiver.socket_gid, handler: merge,
57
- logger: ->(message) { warn message })
58
- Ace::Assign::Authority::OrdinaryServer.new(admission: admission, lifecycle: router,
59
- services: hitl_services(coordinator) + [listener])
60
- end
61
-
62
- private
63
-
64
- def verify_join!(document)
65
- configured = Molecules::ServicePolicy.new(document).ordinary_receiver!(@config.project)
66
- expected = {"receiver_id" => @receiver.receiver_id, "socket_path" => @receiver.socket_path,
67
- "receiver_uid" => @config.service.fetch("uid"), "principal" => @receiver.principal,
68
- "journal_repository" => @config.journal.fetch("repository"), "journal_ref" => @config.journal.fetch("ref")}
69
- unless @receiver.project == @config.project && @receiver.receiver_uid == @config.service.fetch("uid") &&
70
- configured && configured.slice(*expected.keys) == expected &&
71
- @config.participants.key?(@receiver.principal_uid.to_s) &&
72
- ![@config.service.fetch("socket_path"), @config.native.fetch("socket_path"), @config.hitl.fetch("socket_path")].include?(@receiver.socket_path)
73
- raise InvalidConfigurationError, "ordinary receiver and assignment owner selections differ"
74
- end
75
- end
76
-
77
- def hitl_services(coordinator)
78
- selected = @config.hitl
79
- policy = Ace::Hitl::Providers::Lab.grants_policy(grants_path: selected.fetch("grants_path"))
80
- unless policy.service_uid == @config.service.fetch("uid")
81
- raise InvalidConfigurationError, "ordinary HITL service principal differs from assignment owner"
82
- end
83
- guard = lambda do
84
- @config.verify_owner!
85
- unless policy.service_uid == @config.service.fetch("uid")
86
- raise Ace::Hitl::Lifecycle::BindingError, "installed HITL service principal changed"
87
- end
88
- rescue Ace::Lab::InvalidConfigurationError, Ace::Assign::Error => error
89
- raise Ace::Hitl::Lifecycle::BindingError, error.message
90
- end
91
- binding = Ace::Hitl::Providers::Lab::AssignmentBinding.new(coordinator: coordinator,
92
- project: @config.project, owner_guard: guard)
93
- # Unix requesters and the existing Hermes mTLS transport share the
94
- # same owner and one in-memory OTP vault. No second daemon opens the
95
- # journal and no synthetic Unix principal represents a certificate.
96
- vault = Ace::Hitl::Lifecycle::OtpVault::MemoryVault.new
97
- services = [Ace::Hitl::Lifecycle::Service.new(root: selected.fetch("root"),
98
- socket_path: selected.fetch("socket_path"), group: selected.fetch("group"),
99
- binding: binding, policy: policy, vault: vault)]
100
- if selected.key?("tls_authority")
101
- authority = Ace::Hitl::Lifecycle::TlsAuthority.load(selected.fetch("tls_authority"))
102
- verify_tls_identity!(authority)
103
- services << Ace::Hitl::Lifecycle::TlsService.new(root: selected.fetch("root"),
104
- binding: binding, project: @config.project, credential: authority.credential,
105
- ssl_context: authority.ssl_context!, listen: authority.listen, port: authority.port,
106
- group: selected.fetch("group"), vault: vault, decision_policy: policy)
107
- end
108
- services
109
- end
110
-
111
- def verify_tls_identity!(authority)
112
- unless authority.project_id == @config.project &&
113
- Etc.getpwnam(authority.service_user).uid == @config.service.fetch("uid") &&
114
- Etc.getgrnam(authority.service_group).gid == @config.service.fetch("gid")
115
- raise InvalidConfigurationError, "ordinary HITL TLS project or service identity differs"
116
- end
117
- rescue ArgumentError
118
- raise InvalidConfigurationError, "ordinary HITL TLS service account is unavailable"
119
- end
120
- end
121
- end
122
- end
123
- end