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,185 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ace
4
- module Lab
5
- module Organisms
6
- class ProtectedServiceReceiver
7
- RECOVERY_BINDING = %w[assignment_id attempt_id candidate_generation head request_id].freeze
8
- RECOVERY_EXECUTION = %w[mapping_id candidate_generation head claim_binding dispatch_phase].freeze
9
- CLEANUP_RECOVERY_EXECUTION = (RECOVERY_EXECUTION + %w[operation_owner_binding executor_process_binding
10
- request_event_digest dispatch_event_digest]).freeze
11
- RECOVERY_CHALLENGE = %w[request_id input_digest claim_binding no_effect_challenge challenge_generation
12
- failure_event_digest failure_generation challenge_event_digest].freeze
13
-
14
- def recover_no_effect(binding:, input_bytes:, mutation_id:, expected_generation:)
15
- request_id = nil
16
- recovery_binding!(binding, input_bytes, mutation_id, expected_generation)
17
- params = immutable(binding)
18
- input_bytes = input_bytes.dup.freeze
19
- mutation_id = mutation_id.dup.freeze
20
- recovery_binding!(params, input_bytes, mutation_id, expected_generation)
21
- request_id = params.fetch("request_id")
22
- recovery_executor!
23
- status, context, input = recovery_status!(params, input_bytes)
24
- return projection(status) if %w[succeeded failed-settled].include?(status.fetch("state"))
25
- unless context["challenge"]
26
- claim_params = params.merge("expected_generation" => expected_generation)
27
- begin
28
- @client.call("claim_service_settlement", claim_params, mutation_id: mutation_id)
29
- rescue Ace::Runtime::RuntimeUnavailableError
30
- # Recover a possibly accepted challenge through current status,
31
- # never refreshing the original generation or silently claiming.
32
- end
33
- status, context, input = recovery_status!(params, input_bytes)
34
- return projection(status) if %w[succeeded failed-settled].include?(status.fetch("state"))
35
- return uncertain(request_id) unless context["challenge"]
36
- end
37
- operation = recovery_inspector!(context)
38
- root = Ace::Assign::Authority::PrivateDirectory.verify!(@receiver.fetch("staging_root"))
39
- directory = Dir.mktmpdir("recovery-", root)
40
- File.chmod(0o700, directory)
41
- identity = File.lstat(directory)
42
- envelope = immutable("version" => 1, "kind" => "service_no_effect_inspection",
43
- "request" => context.fetch("request"), "input" => input.fetch(:input),
44
- "execution" => context.fetch("execution"), "challenge" => context.fetch("challenge"))
45
- # The selected inspector supplies retained success or positive absence;
46
- # neither follows from a timeout or process/scope cleanup.
47
- if context.dig("request", "operation") == "prune-preserved-workspace"
48
- return uncertain(request_id) unless @cleanup_owner && context.dig("execution", "dispatch_phase") == "dispatch_started"
49
- inspected_kind, artifacts = cleanup_inspection_evidence!(context, input.fetch(:input))
50
- if inspected_kind == "succeeded"
51
- evidence = artifacts.each_with_index.map do |artifact, index|
52
- {"ref" => "cleanup-#{index}", "sha256" => Digest::SHA256.hexdigest(artifact)}
53
- end
54
- receipt = JSON.generate(context.fetch("request").merge("outcome" => "succeeded", "evidence" => evidence))
55
- completion = params.merge("claim_binding" => context.dig("execution", "claim_binding"),
56
- "receipt_sha256" => Digest::SHA256.hexdigest(receipt))
57
- @client.call("complete_service", completion,
58
- mutation_id: Digest::SHA256.hexdigest("cleanup-completed:#{mutation_id}"),
59
- upload_parts: [receipt] + artifacts, purpose: :receipt_artifacts)
60
- completed, = recovery_status!(params, input_bytes)
61
- return uncertain(request_id) unless completed["state"] == "succeeded"
62
- cleanup_staging(directory, identity, prefix: "recovery-")
63
- return projection(completed)
64
- end
65
- response = {"outcome" => "failed", "request_id" => request_id,
66
- "input_digest" => context.dig("request", "input_digest"), "evidence" => artifacts.each_with_index.map do |artifact, index|
67
- {"ref" => index.zero? ? "cleanup-inspection-no-effect" : "cleanup-inspection-selection", "sha256" => Digest::SHA256.hexdigest(artifact)}
68
- end}
69
- else
70
- response = @handler.execute(operation: immutable(operation), envelope: envelope, candidate_root: directory)
71
- end
72
- return uncertain(request_id) unless response && response["outcome"] == "failed" &&
73
- response["request_id"] == request_id && response["input_digest"] == context.dig("request", "input_digest")
74
- artifacts ||= Ace::Assign::Authority::EvidenceTransfer.read_staged(root: directory, evidence: response.fetch("evidence"))
75
- receipt = context.fetch("request").merge("outcome" => "failed", "evidence" => response.fetch("evidence"))
76
- bytes = JSON.generate(receipt)
77
- completion_params = params.merge("claim_binding" => context.dig("execution", "claim_binding"),
78
- "reconciliation_challenge" => context.fetch("challenge").slice("no_effect_challenge", "challenge_generation", "challenge_event_digest"),
79
- "receipt_sha256" => Digest::SHA256.hexdigest(bytes))
80
- @client.call("complete_no_effect", completion_params,
81
- mutation_id: Digest::SHA256.hexdigest("no-effect:#{mutation_id}"),
82
- upload_parts: [bytes] + artifacts, purpose: :receipt_artifacts)
83
- # Protected status invokes the shared canonical receipt/import reader.
84
- # Never accept a terminal state string without that authentication.
85
- settled, = recovery_status!(params, input_bytes)
86
- return uncertain(request_id) unless settled["state"] == "failed-settled"
87
- cleanup_staging(directory, identity, prefix: "recovery-")
88
- projection(settled)
89
- rescue Ace::Assign::Error, Ace::Runtime::RuntimeUnavailableError, Ace::Lab::InvalidConfigurationError,
90
- SecurityError, ArgumentError, KeyError, TypeError, SystemCallError
91
- uncertain(request_id)
92
- end
93
-
94
- private
95
-
96
- def recovery_binding!(binding, bytes, mutation_id, generation)
97
- unless binding.is_a?(Hash) && binding.keys.sort == RECOVERY_BINDING.sort &&
98
- %w[assignment_id attempt_id request_id].all? { |key| binding[key].is_a?(String) && binding[key].match?(Ace::Assign::Molecules::JournalMutation::ID) } &&
99
- binding["head"].is_a?(String) && binding["head"].match?(Ace::Assign::Authority::CandidateTransfer::SHA) &&
100
- binding["candidate_generation"].is_a?(Integer) && binding["candidate_generation"] >= 0 &&
101
- generation.is_a?(Integer) && generation.positive? && bytes.is_a?(String) &&
102
- mutation_id.is_a?(String) && mutation_id.match?(Ace::Assign::Molecules::JournalMutation::ID)
103
- raise ArgumentError, "original recovery binding is invalid"
104
- end
105
- end
106
-
107
- def recovery_executor!
108
- me = @kernel.capture(Process.pid)
109
- credentials = @project.fetch("peer_credentials").fetch(@receiver.fetch("executor_uid").to_s)
110
- unless me["uid"] == @receiver.fetch("executor_uid") && me["gid"] == credentials.fetch("gid") &&
111
- me["groups"] == credentials.fetch("groups")
112
- raise SecurityError, "recovery executor differs"
113
- end
114
- @kernel.live!(me)
115
- end
116
-
117
- def recovery_status!(params, bytes)
118
- reply = @client.call("service_status", params)
119
- data = reply.data
120
- context = data.fetch("settlement_context")
121
- unless context.is_a?(Hash) && context.keys.sort == %w[version request execution challenge].sort &&
122
- context["version"].is_a?(Integer) && context["version"] == 1
123
- raise SecurityError, "canonical recovery context is malformed"
124
- end
125
- request, execution, challenge = context.values_at("request", "execution", "challenge")
126
- execution_fields = request.is_a?(Hash) && request["operation"] == "prune-preserved-workspace" &&
127
- execution.is_a?(Hash) && execution["dispatch_phase"] == "dispatch_started" ? CLEANUP_RECOVERY_EXECUTION : RECOVERY_EXECUTION
128
- unless request.is_a?(Hash) && request.keys.sort == Ace::Assign::Molecules::EvidenceJournal::TERMINAL_BINDING_FIELDS.sort &&
129
- execution.is_a?(Hash) && execution.keys.sort == execution_fields.sort &&
130
- %w[assignment_id attempt_id request_id].all? { |key| request[key] == params.fetch(key) } &&
131
- request["project_id"] == @map.fetch("project_id") && request["executor_uid"] == @receiver.fetch("executor_uid") &&
132
- request["transport"] == "unix" && request["candidate_head"] == params.fetch("head") &&
133
- execution["mapping_id"] == @mapping_id && execution["candidate_generation"] == params.fetch("candidate_generation") &&
134
- execution["head"] == params.fetch("head") && %w[issued dispatch_started].include?(execution["dispatch_phase"]) &&
135
- %w[uncertain failed succeeded failed-settled].include?(data["state"]) &&
136
- data["request_id"] == params.fetch("request_id") && data["service_id"] == @service_id
137
- raise SecurityError, "canonical recovery original binding differs"
138
- end
139
- if !challenge.nil? && (!challenge.is_a?(Hash) || challenge.keys.sort != RECOVERY_CHALLENGE.sort ||
140
- !%w[challenge_generation failure_generation].all? { |key| challenge[key].is_a?(Integer) && challenge[key].positive? } ||
141
- challenge["request_id"] != request["request_id"] || challenge["input_digest"] != request["input_digest"] ||
142
- challenge["claim_binding"] != execution["claim_binding"])
143
- raise SecurityError, "canonical recovery challenge differs"
144
- end
145
- input = @inputs.input_binding(bytes, expected_digest: request.fetch("input_digest"),
146
- expected_target: request.fetch("target"), operation: request.fetch("operation"))
147
- [data, immutable(context), input]
148
- end
149
-
150
- def cleanup_inspection_evidence!(context, input)
151
- request, execution = context.values_at("request", "execution")
152
- result = @cleanup_owner.inspect!(request: request.slice("request_id", "input_digest").merge(
153
- "claim_binding" => execution.fetch("claim_binding"),
154
- "request_event_digest" => execution.fetch("request_event_digest"),
155
- "dispatch_event_digest" => execution.fetch("dispatch_event_digest"), "input" => input,
156
- "challenge_ref" => context.fetch("challenge").slice("challenge_event_digest")),
157
- operation_owner_binding: execution.fetch("operation_owner_binding"))
158
- unless result.is_a?(Hash) && result.keys.sort == %i[bytes kind receipt_ref] &&
159
- %w[succeeded failed-no-effect].include?(result[:kind])
160
- raise SecurityError, "original cleanup inspection kind differs"
161
- end
162
- success = result.fetch(:kind) == "succeeded"
163
- ref, bytes = result.values_at(:receipt_ref, :bytes)
164
- unless ref.is_a?(Hash) && ref.keys.sort == %w[bytes path sha256] && bytes.is_a?(String) &&
165
- bytes.bytesize.between?(1, 65_536) && ref["bytes"].is_a?(Integer) && ref["bytes"] == bytes.bytesize &&
166
- ref["sha256"] == Digest::SHA256.hexdigest(bytes)
167
- raise SecurityError, "original cleanup inspection result differs"
168
- end
169
- selection = {"schema" => success ? Ace::Assign::Authority::ServiceCleanupEvidence::ROOT_SELECTION_SCHEMA : Ace::Assign::Authority::ServiceCleanupEvidence::ROOT_INSPECTION_SELECTION_SCHEMA,
170
- "request_id" => request.fetch("request_id"), "input_digest" => request.fetch("input_digest"),
171
- "operation_owner_binding_digest" => Ace::Assign::Atoms::EvidenceDigest.digest(execution.fetch("operation_owner_binding")),
172
- (success ? "receipt_ref" : "inspection_ref") => ref}
173
- [result.fetch(:kind), [bytes, JSON.generate(selection)].freeze].freeze
174
- end
175
-
176
- def recovery_inspector!(context)
177
- request = context.fetch("request")
178
- policy = Molecules::ServicePolicy.new(Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path))
179
- policy.inspection!(request.fetch("operation"), project: @map.fetch("project_id"),
180
- service_id: @service_id, executor_uid: @receiver.fetch("executor_uid"))
181
- end
182
- end
183
- end
184
- end
185
- end
@@ -1,128 +0,0 @@
1
- # frozen_string_literal: true
2
- require "ace/assign/authority/protected_assignment_context"
3
- require "ace/assign/organisms/protected_delivery_coordinator"
4
- require_relative "protected_service_client"
5
- require "ace/git/atoms/service_pr_input"
6
-
7
- module Ace
8
- module Lab
9
- module Organisms
10
- # Public selection adapter. The original authority and fixed receiver
11
- # own every grant/effect; this adapter has no local journal or executor.
12
- class ProtectedServiceRequest
13
- FLAGS = %i[mapping scope service candidate_head candidate_generation expected_generation input_digest target artifact_digest].freeze
14
- LIMIT = 16_384
15
-
16
- def initialize(context: Ace::Assign::Authority::ProtectedAssignmentContext.load, ingress_factory: nil)
17
- @context = context
18
- @ingress = ingress_factory || ->(**selection) { ProtectedServiceClient.new(**selection) }
19
- end
20
-
21
- def selected?(options)
22
- @context.protected_participant? || @context.mapping_hint? || FLAGS.any? { |key| !options[key].nil? }
23
- end
24
-
25
- def request(project:, assignment:, attempt:, operation:, input_path:, authorization:, request_id:, **options)
26
- refuse_nonworker!
27
- unless (options.keys - %i[mapping scope service candidate_head candidate_generation expected_generation dry_run]).empty?
28
- raise ArgumentError, "protected request rejects incompatible options"
29
- end
30
- raise ArgumentError, "protected service request requires a fixed PR operation and no dry-run" unless Ace::Git::Atoms::ServicePrInput::OPERATIONS.include?(operation) && !options[:dry_run]
31
- validate!(project, assignment, attempt, request_id, options)
32
- unless authorization.is_a?(String) && authorization.match?(Molecules::ServicePolicy::ID) &&
33
- options[:service].is_a?(String) && options[:service].match?(Molecules::ServicePolicy::ID) &&
34
- options[:expected_generation].is_a?(Integer) && options[:expected_generation].positive?
35
- raise ArgumentError, "protected service request requires receiver, authorization and original generation"
36
- end
37
- input = Atoms::ServiceInput.load(input_path)
38
- Ace::Git::Atoms::ServicePrInput.validate(input, operation: operation)
39
- target = Atoms::ServiceInput.target(input)
40
- digest = Atoms::ServiceInput.digest(input)
41
- prepared = resolve!(project, assignment, attempt, options)
42
- selection = {"project_id" => project, "mapping_id" => options.fetch(:mapping), "assignment_id" => assignment,
43
- "attempt_id" => attempt, "scope" => options.fetch(:scope), "service_id" => options.fetch(:service),
44
- "candidate_head" => options.fetch(:candidate_head), "candidate_generation" => options.fetch(:candidate_generation),
45
- "expected_generation" => options.fetch(:expected_generation), "request_id" => request_id,
46
- "input_digest" => digest, "target" => target}
47
- submission = {"assignment_id" => assignment, "attempt_id" => attempt, "expected_generation" => options.fetch(:expected_generation),
48
- "candidate_generation" => options.fetch(:candidate_generation), "head" => options.fetch(:candidate_head),
49
- "request_id" => request_id, "operation" => operation, "input_digest" => digest, "target" => target,
50
- "authorization" => authorization}
51
- mutation = Digest::SHA256.hexdigest("qkb.#{operation}.claim:v1:#{assignment}:#{attempt}:#{request_id}")
52
- claim = @context.with_installed_selection(options: options, input: prepared) do |deployment, kernel, mapping|
53
- @ingress.call(mapping_id: mapping, service_id: options.fetch(:service), deployment: deployment, kernel: kernel)
54
- .submit(submission: submission, input_bytes: JSON.generate(input), mutation_id: mutation)
55
- end
56
- envelope = if claim.fetch("type") == "service_claim_accepted"
57
- {"status" => "ok", "data" => {"selection" => selection, "claim" => claim}}
58
- else
59
- code = claim.fetch("type")
60
- {"status" => "error", "error" => {"code" => code,
61
- "message" => "inspect original canonical service status; no automatic resubmission",
62
- "selection" => selection, "claim" => claim}}
63
- end
64
- bounded!(envelope, selection: selection)
65
- end
66
-
67
- def status(request_id:, **options)
68
- refuse_nonworker!
69
- unless (options.keys - %i[project assignment attempt mapping scope candidate_head candidate_generation input_digest target artifact_digest format]).empty?
70
- raise ArgumentError, "protected status rejects incompatible options"
71
- end
72
- project, assignment, attempt = options.values_at(:project, :assignment, :attempt)
73
- validate!(project, assignment, attempt, request_id, options)
74
- unless options[:input_digest].is_a?(String) && options[:input_digest].match?(Atoms::ServiceInput::SHA256)
75
- raise ArgumentError, "protected status requires original input digest"
76
- end
77
- target = Atoms::ServiceInput.target({"target" => {"resource" => options[:target], "artifact_digest" => options[:artifact_digest]}})
78
- resolve!(project, assignment, attempt, options)
79
- data = Ace::Assign::Organisms::ProtectedDeliveryCoordinator.new(client: @context.client(options: options), project_id: project)
80
- .perform(assignment_id: assignment, attempt_id: attempt, operation: "status", service_request_id: request_id,
81
- candidate_head: options.fetch(:candidate_head), candidate_generation: options.fetch(:candidate_generation),
82
- input_digest: options.fetch(:input_digest), target: target)
83
- bounded!({"status" => "ok", "data" => data})
84
- end
85
-
86
- private
87
-
88
- def refuse_nonworker!
89
- if @context.protected_participant? && !@context.protected_worker?
90
- raise Ace::Assign::AttemptErrors::EvidenceUnavailable, "protected service request/status is worker-only"
91
- end
92
- end
93
-
94
- def validate!(project, assignment, attempt, request_id, options)
95
- unless [project, assignment, attempt, request_id, options[:mapping]].all? { |value| value.is_a?(String) && value.match?(Molecules::ServicePolicy::ID) } &&
96
- options[:scope].is_a?(String) && options[:scope].match?(Ace::Assign::Authority::PreparedWork::SCOPE) &&
97
- options[:candidate_head].is_a?(String) && options[:candidate_head].match?(/\A[0-9a-f]{40}\z/) &&
98
- options[:candidate_generation].is_a?(Integer) && options[:candidate_generation].positive?
99
- raise ArgumentError, "protected service requires exact original scoped selectors"
100
- end
101
- end
102
-
103
- def resolve!(project, assignment, attempt, options)
104
- input = @context.resolve(options: options.merge(attempt: attempt), assignment_id: assignment, scope: options.fetch(:scope))
105
- unless input && input.descriptor.values_at("project_id", "assignment_id", "attempt_id", "scope", "mapping_id") ==
106
- [project, assignment, attempt, options.fetch(:scope), options.fetch(:mapping)]
107
- raise Ace::Assign::AttemptErrors::EvidenceUnavailable, "original prepared service selection differs"
108
- end
109
- input
110
- end
111
-
112
- def bounded!(envelope, selection: nil)
113
- return envelope if JSON.generate(envelope).bytesize <= LIMIT
114
- if selection
115
- # Submission may already be durable. Never classify lost output as
116
- # a pre-effect refusal or retry; retain the original lookup tuple.
117
- envelope = {"status" => "error", "error" => {"code" => "service_claim_unconfirmed",
118
- "message" => "claim output unavailable; inspect original canonical service status without resubmission",
119
- "selection" => selection, "claim" => {"version" => 1, "type" => "service_claim_unconfirmed",
120
- "required_action" => "inspect_canonical_service_status"}}}
121
- return envelope if JSON.generate(envelope).bytesize <= LIMIT
122
- end
123
- raise Ace::Assign::AttemptErrors::EvidenceUnavailable, "protected status output exceeds bound"
124
- end
125
- end
126
- end
127
- end
128
- end
@@ -1,89 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "protected_service_receiver"
4
-
5
- module Ace
6
- module Lab
7
- module Organisms
8
- # One admitted worker for one installed receiver. Capacity is acquired
9
- # before the canonical claim, not after an accepted request. This owns
10
- # execution lifetime only; it never substitutes for canonical status.
11
- class ProtectedServiceWorker
12
- def initialize(receiver:)
13
- @receiver = receiver
14
- @mutex = Mutex.new
15
- @worker = nil
16
- @closing = false
17
- end
18
-
19
- def start(submission:, peer:, input_bytes:, mutation_id:, on_claim:, deadline: nil)
20
- raise ArgumentError, "receiver acceptance callback unavailable" unless on_claim.respond_to?(:call)
21
- now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
22
- deadline ||= now + 5
23
- unless deadline.is_a?(Numeric) && deadline.finite? && deadline > now && deadline <= now + 5
24
- raise ArgumentError, "receiver acceptance deadline is invalid"
25
- end
26
- # Hold independent copies before returning to the socket handler.
27
- submission = copy(submission)
28
- peer = copy(peer)
29
- input_bytes = input_bytes.dup.freeze
30
- mutation_id = mutation_id.dup.freeze
31
- @mutex.synchronize do
32
- raise Ace::Assign::AttemptErrors::Conflict, "receiver capacity unavailable" if @closing || @worker&.alive?
33
-
34
- @worker = Thread.new do
35
- remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
36
- next({"state" => "uncertain"}) unless remaining.positive?
37
- result = @receiver.execute(submission: submission, peer: peer, input_bytes: input_bytes,
38
- mutation_id: mutation_id, claim_timeout: [remaining, 5].min,
39
- on_claim: lambda do |identity|
40
- begin
41
- on_claim.call(identity)
42
- rescue IOError, SystemCallError
43
- # A lost acceptance reply cannot cancel already admitted
44
- # work or cause a second execution. Status owns recovery.
45
- nil
46
- end
47
- end)
48
- if submission.fetch("operation") == "publish"
49
- while @receiver.publication_active? && !@mutex.synchronize { @closing }
50
- result = @receiver.tick_publication!(request_id: submission.fetch("request_id"),
51
- deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30)
52
- sleep 0.25 if @receiver.publication_active?
53
- end
54
- @receiver.release_publication_lifetime!
55
- end
56
- result
57
- end
58
- end
59
- end
60
-
61
- # Stop admission before joining. A timed-out join preserves both the
62
- # worker and closing state; the listener must retain its lifetime lock.
63
- def close(timeout:)
64
- unless timeout.is_a?(Numeric) && timeout.finite? && timeout >= 0
65
- raise ArgumentError, "receiver close deadline is invalid"
66
- end
67
- worker = @mutex.synchronize do
68
- @closing = true
69
- @worker
70
- end
71
- !worker || !worker.join(timeout).nil?
72
- end
73
-
74
- private
75
-
76
- def copy(value)
77
- duplicate = JSON.parse(JSON.generate(value))
78
- freeze_value(duplicate)
79
- end
80
-
81
- def freeze_value(value)
82
- value.each_value { |child| freeze_value(child) } if value.is_a?(Hash)
83
- value.each { |child| freeze_value(child) } if value.is_a?(Array)
84
- value.freeze
85
- end
86
- end
87
- end
88
- end
89
- end
@@ -1,249 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "digest"
4
- require "fileutils"
5
- require "open3"
6
- require "ace/assign"
7
-
8
- module Ace
9
- module Lab
10
- module Organisms
11
- # Coordinates the generic request contract. ace-assign owns the durable
12
- # claim and receipt; this class never writes evidence files directly.
13
- class ServiceRequestService
14
- # Immutable request identity: a replay must match every field; the
15
- # candidate head is deliberately absent because replays return the
16
- # head recorded at claim time.
17
- SERVICE_REPLAY_FIELDS = %w[project_id operation input_digest assignment_id attempt_id
18
- target authorization caller_uid request_id].freeze
19
-
20
- def initialize(topology: nil, policy: nil, coordinator: nil, executor: nil, repo_root: nil)
21
- @topology = topology || TopologyService.from_config
22
- @policy = policy
23
- @repo_root = repo_root || git_toplevel
24
- @coordinator = coordinator ||
25
- Ace::Assign::Organisms::AttemptCoordinator.new(repo_root: @repo_root)
26
- @executor = executor || Molecules::ServiceExecutor.new
27
- end
28
-
29
- def request(project:, assignment:, attempt:, operation:, input_path:, authorization:, request_id:, dry_run: false)
30
- {project: project, assignment: assignment, attempt: attempt, request: request_id}.each do |name, value|
31
- raise ArgumentError, "invalid #{name} ID" unless value.is_a?(String) &&
32
- value.match?(Molecules::ServicePolicy::ID)
33
- end
34
- input = Atoms::ServiceInput.load(input_path)
35
- target = Atoms::ServiceInput.target(input)
36
- # An identical retry returns the stored outcome — with the head
37
- # recorded at claim time — without re-checking attempt, policy, or
38
- # routing: a completed request must survive attempt terminality,
39
- # authorization expiry, routing changes, and candidate-head
40
- # advance. Any changed immutable field under the same request ID is
41
- # a conflict, never a replay.
42
- existing = @coordinator.service_request_status(request_id)
43
- if existing
44
- # Current project visibility gates replays: a revoked grant must
45
- # not expose stored receipts, though the old operation route and
46
- # authorization decision do not need to remain active.
47
- visibility = @topology.services(project: project)
48
- return visibility.envelope unless visibility.ok?
49
- supplied = {"request_id" => request_id, "assignment_id" => assignment, "attempt_id" => attempt,
50
- "project_id" => project, "operation" => operation,
51
- "input_digest" => Atoms::ServiceInput.digest(input), "target" => target,
52
- "caller_uid" => Process.uid, "authorization" => authorization}
53
- changed = SERVICE_REPLAY_FIELDS.find do |field|
54
- existing[field] != supplied.fetch(field)
55
- end
56
- if changed
57
- raise Ace::Assign::AttemptErrors::Conflict,
58
- "Service request #{request_id} has different #{changed}"
59
- end
60
- if dry_run
61
- # A preview of an existing request still validates current
62
- # eligibility and reports itself as a dry run.
63
- @coordinator.request_eligible!(existing)
64
- return {"status" => "ok", "data" => result(existing).fetch("data").merge("dry_run" => true)}
65
- end
66
- return result(existing)
67
- end
68
- route = @topology.route(project: project, capability: operation)
69
- return route.envelope unless route.ok?
70
- service_id = route.data.fetch("entry").fetch("id")
71
- trusted = Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path) unless @policy
72
- policy = @policy || Molecules::ServicePolicy.new(trusted,
73
- ->(reference, exact) { @coordinator.proposal_authorization(reference, exact) })
74
- head = current_head
75
- binding = {"request_id" => request_id, "assignment_id" => assignment, "attempt_id" => attempt,
76
- "project_id" => project, "operation" => operation,
77
- "input_digest" => Atoms::ServiceInput.digest(input), "target" => target,
78
- "candidate_head" => head, "caller_uid" => Process.uid}
79
- binding["authorization"] = authorization
80
- binding["service_id"] = service_id
81
- binding["caller_uid"] = Process.uid
82
- validate_attempt!(binding)
83
- begin
84
- operation_policy = policy.operation!(operation, project: project, service_id: service_id)
85
- policy.authorize!(authorization, binding)
86
- rescue SecurityError, Ace::Lab::InvalidConfigurationError
87
- @coordinator.reject_service_request(binding, reason: "policy_rejected") unless dry_run
88
- raise
89
- end
90
- # The trusted policy owns executor identity and transport; the
91
- # journaled claim binds them so only that executor identity can
92
- # later complete the receipt. A local operation whose configured
93
- # executor is not this process is refused before any claim, so no
94
- # authorization is consumed by a dispatch that cannot happen.
95
- binding["executor_uid"] = operation_policy.fetch("executor_uid")
96
- binding["transport"] = transport = operation_policy.fetch("transport", "local")
97
- if transport == "local" &&
98
- (Process.uid != binding["executor_uid"] || Process.euid != binding["executor_uid"])
99
- # A preview records nothing: the durable rejection is only for
100
- # real submissions, so the request ID stays reusable.
101
- @coordinator.reject_service_request(binding, reason: "executor_identity_unavailable") unless dry_run
102
- raise SecurityError, "current OS identity is not the configured executor"
103
- end
104
-
105
- if dry_run
106
- # The preview applies the same read-only eligibility checks as
107
- # the claim — review evidence for external effects and the
108
- # current candidate head — so it cannot promise acceptance for a
109
- # request that would fail on submission.
110
- @coordinator.request_eligible!(binding)
111
- preview = {"outcome" => "accepted", "dry_run" => true, "request_id" => request_id,
112
- "project" => project, "operation" => operation, "service_id" => service_id,
113
- "target" => target, "candidate_head" => head}
114
- return {"status" => "ok", "data" => preview}
115
- end
116
-
117
- # The claim itself records uncertain (dispatch intent): a crash
118
- # after the claim leaves uncertainty, never a stranded accepted
119
- # state that a retry would return without dispatching.
120
- claimed = @coordinator.claim_service_request(binding)
121
- return result(claimed) unless claimed["state"] == "uncertain" && claimed["journal_commit"]
122
-
123
- # The executor reloads the trusted policy from disk at the effect
124
- # boundary: a revocation after the claim must still stop dispatch.
125
- begin
126
- receipt = @executor.execute(operation: operation_policy, request: binding, input: input,
127
- policy_loader: policy_loader, authorization: authorization,
128
- head_loader: -> { current_head })
129
- rescue SecurityError, Ace::Lab::InvalidConfigurationError => e
130
- # Refusal before invocation: no effect was attempted. When this
131
- # process is the executor identity (local transport), record a
132
- # proven no-effect settlement instead of stranding uncertainty.
133
- settle_pre_invocation_refusal(binding, request_id, e)
134
- raise
135
- end
136
- return result(@coordinator.service_request_status(request_id)) if receipt.nil?
137
-
138
- state = receipt.fetch("outcome")
139
- full_receipt = Models::ServiceReceipt.build(binding, receipt)
140
- result(@coordinator.transition_service_request(request_id, state: state, receipt: full_receipt))
141
- rescue ArgumentError => e
142
- failure("invalid_input", e.message)
143
- rescue SecurityError => e
144
- failure("unauthorized", e.message)
145
- rescue Ace::Lab::InvalidConfigurationError => e
146
- failure("invalid_configuration", e.message)
147
- rescue Ace::Assign::AttemptErrors::Conflict => e
148
- failure("conflict", e.message)
149
- rescue Ace::Assign::AttemptErrors::NotFound, Ace::Assign::AttemptErrors::ReceiptRejected => e
150
- failure("invalid_attempt", e.message)
151
- rescue Ace::Assign::AttemptErrors::InvalidState => e
152
- failure("invalid_attempt", e.message)
153
- rescue Ace::Assign::AttemptErrors::EvidenceUnavailable => e
154
- failure("evidence_unavailable", e.message)
155
- end
156
-
157
- def status(request_id:)
158
- request = @coordinator.service_request_status(request_id)
159
- return failure("missing", "service request not found") unless request
160
- return failure("unauthorized", "caller does not own the request") unless request["caller_uid"] == Process.uid
161
- visibility = @topology.services(project: request["project_id"])
162
- return visibility.envelope unless visibility.ok?
163
- result(request)
164
- rescue ArgumentError => e
165
- failure("invalid_input", e.message)
166
- rescue Ace::Assign::AttemptErrors::EvidenceUnavailable => e
167
- failure("evidence_unavailable", e.message)
168
- end
169
-
170
- private
171
-
172
- # A refusal raised before the handler was invoked proves no effect
173
- # was attempted. When this process runs as the executor identity
174
- # (local transport), record the proven no-effect settlement with an
175
- # executor-owned attestation; otherwise the claim stays uncertain
176
- # for evidence-based reconciliation.
177
- def settle_pre_invocation_refusal(binding, request_id, error)
178
- return unless binding["transport"] == "local" && Process.uid == binding["executor_uid"]
179
- evidence_dir = File.join(@repo_root, "evidence", "service")
180
- FileUtils.mkdir_p(evidence_dir)
181
- attestation = File.join(evidence_dir, "#{request_id}-no-effect")
182
- content = "ace-service-attestation request:#{request_id} " \
183
- "input:#{binding.fetch("input_digest")} outcome:failed no-effect:true\n" \
184
- "refusal: #{error.message}\n"
185
- # Exclusive creation without following symlinks: the caller-controlled
186
- # request ID must never truncate an existing file, and the artifact
187
- # must stay private to pass the executor-owned verification.
188
- File.open(attestation, File::WRONLY | File::CREAT | File::EXCL, 0600) do |file|
189
- file.write(content)
190
- end
191
- receipt = Models::ServiceReceipt.build(binding, {
192
- "outcome" => "failed",
193
- "evidence" => [{"ref" => "evidence/service/#{request_id}-no-effect",
194
- "sha256" => Digest::SHA256.hexdigest(content)}],
195
- "executor_uid" => binding.fetch("executor_uid")
196
- })
197
- @coordinator.reconcile_service_no_effect(request_id, receipt: receipt)
198
- rescue Ace::Assign::AttemptErrors::InvalidState, Ace::Assign::AttemptErrors::ReceiptRejected,
199
- Ace::Assign::AttemptErrors::NotFound, Errno::ENOENT, Errno::EACCES, Errno::EEXIST
200
- nil
201
- end
202
-
203
- # The Git toplevel (not a package directory that happens to hold a
204
- # Rakefile) bounds evidence verification for nested invocations.
205
- def git_toplevel
206
- out, status = Open3.capture2("git", "rev-parse", "--show-toplevel")
207
- (status&.success? ? out.strip : Dir.pwd)
208
- rescue Errno::ENOENT
209
- Dir.pwd
210
- end
211
-
212
- def policy_loader
213
- if @policy
214
- -> { @policy }
215
- else
216
- -> { Molecules::ServicePolicy.new(Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path),
217
- ->(reference, exact) { @coordinator.proposal_authorization(reference, exact) }) }
218
- end
219
- end
220
-
221
- def validate_attempt!(binding)
222
- attempt = @coordinator.service_attempt(binding)
223
- unless attempt.candidate_head.nil? || attempt.candidate_head == binding["candidate_head"]
224
- raise SecurityError, "request is not bound to an active managed attempt"
225
- end
226
- end
227
-
228
- def current_head
229
- out, _stderr, status = Open3.capture3("git", "rev-parse", "HEAD", chdir: @repo_root)
230
- raise SecurityError, "candidate head is unavailable" unless status.success?
231
- out.strip
232
- end
233
-
234
- def result(request)
235
- public = request.slice("request_id", "assignment_id", "attempt_id", "project_id", "operation",
236
- "input_digest", "target", "candidate_head", "service_id", "state", "receipt")
237
- # failed-settled is internal bookkeeping: the documented public
238
- # outcome stays "failed"; the state field distinguishes settlement.
239
- outcome = request.fetch("state") == "failed-settled" ? "failed" : request.fetch("state")
240
- {"status" => "ok", "data" => public.merge("outcome" => outcome)}
241
- end
242
-
243
- def failure(code, message)
244
- {"status" => "error", "error" => {"code" => code, "message" => message}}
245
- end
246
- end
247
- end
248
- end
249
- end