ace-lab 0.4.0 → 0.5.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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/docs/usage.md +10 -0
  4. data/lib/ace/lab/atoms/protected_publication_input.rb +50 -0
  5. data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +114 -0
  6. data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +86 -0
  7. data/lib/ace/lab/atoms/service_input.rb +20 -8
  8. data/lib/ace/lab/authorization_path.rb +20 -0
  9. data/lib/ace/lab/cli/commands/service.rb +45 -0
  10. data/lib/ace/lab/errors.rb +11 -0
  11. data/lib/ace/lab/molecules/grant_resolver.rb +2 -0
  12. data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +163 -0
  13. data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +165 -0
  14. data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +239 -0
  15. data/lib/ace/lab/molecules/protected_publication_publisher.rb +110 -0
  16. data/lib/ace/lab/molecules/protected_service_handler.rb +9 -40
  17. data/lib/ace/lab/molecules/protected_service_policy.rb +49 -5
  18. data/lib/ace/lab/molecules/service_policy.rb +26 -0
  19. data/lib/ace/lab/organisms/authority_composition.rb +25 -4
  20. data/lib/ace/lab/organisms/protected_cleanup_owner.rb +361 -0
  21. data/lib/ace/lab/organisms/protected_service_client.rb +129 -0
  22. data/lib/ace/lab/organisms/protected_service_listener.rb +181 -0
  23. data/lib/ace/lab/organisms/protected_service_publication.rb +268 -0
  24. data/lib/ace/lab/organisms/protected_service_receiver.rb +101 -11
  25. data/lib/ace/lab/organisms/protected_service_recovery.rb +185 -0
  26. data/lib/ace/lab/organisms/protected_service_request.rb +128 -0
  27. data/lib/ace/lab/organisms/protected_service_worker.rb +89 -0
  28. data/lib/ace/lab/version.rb +1 -1
  29. data/lib/ace/lab.rb +2 -22
  30. metadata +34 -4
@@ -8,6 +8,8 @@ require "ace/assign/authority/candidate_transfer"
8
8
  require "ace/assign/authority/evidence_transfer"
9
9
  require_relative "../molecules/protected_service_handler"
10
10
  require_relative "../molecules/protected_service_policy"
11
+ require_relative "../atoms/protected_workspace_prune_preview"
12
+ require_relative "protected_service_publication"
11
13
 
12
14
  module Ace
13
15
  module Lab
@@ -21,8 +23,11 @@ module Ace
21
23
 
22
24
  def initialize(mapping_id:, service_id:, deployment: Ace::Assign::Authority::Deployment.load,
23
25
  kernel: Ace::Runtime::Molecules::ProtectedLinux.new, client: nil,
24
- handler: Molecules::ProtectedServiceHandler.new)
26
+ handler: Molecules::ProtectedServiceHandler.new, cleanup_owner: nil, publication_publisher: nil, publication_hitl: nil)
25
27
  @mapping_id, @service_id, @deployment, @kernel, @handler = mapping_id, service_id, deployment, kernel, handler
28
+ @cleanup_owner = cleanup_owner
29
+ @publication_publisher = publication_publisher || Molecules::ProtectedPublicationPublisher.new
30
+ @publication_hitl = publication_hitl
26
31
  @map = deployment.mapping(mapping_id)
27
32
  deployment.verify_composition!(@map.fetch("authority_id"), composition: "services")
28
33
  @project = deployment.project(@map.fetch("project_id"))
@@ -31,7 +36,30 @@ module Ace
31
36
  @inputs = Molecules::ProtectedServicePolicy.new(proposal_resolver: ->(*) { raise SecurityError, "proposal decisions belong to authority" })
32
37
  end
33
38
 
34
- def execute(submission:, peer:, input_bytes:, mutation_id:)
39
+ def preview_workspace_prune(intent:, peer:, deadline:)
40
+ raise SecurityError, "fixed cleanup preview composition unavailable" unless @cleanup_owner
41
+ intent = Atoms::ProtectedWorkspacePrunePreview.intent!(JSON.parse(JSON.generate(intent)))
42
+ maintenance = intent.fetch("maintenance")
43
+ unless maintenance.fetch("mapping_id") == @mapping_id && maintenance.fetch("project_id") == @map.fetch("project_id") &&
44
+ peer.values_at("uid", "gid", "groups") == @map.values_at("worker_uid", "worker_gid", "worker_groups")
45
+ raise SecurityError, "cleanup preview worker or installed scope differs"
46
+ end
47
+ @kernel.live!(peer)
48
+ me = @kernel.capture(Process.pid)
49
+ credentials = @project.fetch("peer_credentials").fetch(@receiver.fetch("executor_uid").to_s)
50
+ unless me.values_at("uid", "gid", "groups") == [@receiver.fetch("executor_uid"), credentials.fetch("gid"), credentials.fetch("groups")]
51
+ raise SecurityError, "cleanup preview receiver principal differs"
52
+ end
53
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
54
+ raise Ace::Runtime::RuntimeUnavailableError, "cleanup preview deadline expired" unless remaining.positive?
55
+ selected = @client.call("workspace_prune_preview_context", maintenance.except("project_id", "mapping_id").merge(
56
+ "service_id" => @service_id, "worker_process_binding" => peer), timeout: remaining).data
57
+ selected = Atoms::ProtectedWorkspacePrunePreview.context!(selected)
58
+ result = @cleanup_owner.preview!(intent: intent, maintenance_context: selected, deadline: deadline)
59
+ Atoms::ProtectedWorkspacePrunePreview.result!(result, intent: intent, context: selected)
60
+ end
61
+
62
+ def execute(submission:, peer:, input_bytes:, mutation_id:, on_claim: nil, claim_timeout: nil)
35
63
  contacted = false
36
64
  request_id = nil
37
65
  unless submission.is_a?(Hash) && submission.keys.sort == SUBMISSION.sort &&
@@ -55,13 +83,25 @@ module Ace
55
83
  unless input_bytes.is_a?(String) && mutation_id.is_a?(String) && mutation_id.match?(Ace::Assign::Molecules::JournalMutation::ID)
56
84
  raise ArgumentError, "receiver input or mutation identity is invalid"
57
85
  end
86
+ if claim_timeout && (!claim_timeout.is_a?(Numeric) || !claim_timeout.finite? ||
87
+ !claim_timeout.positive? || claim_timeout > 5)
88
+ raise ArgumentError, "receiver claim deadline is invalid"
89
+ end
58
90
  bytes = input_bytes.dup.freeze
59
- input = @inputs.input_binding(bytes, expected_digest: params.fetch("input_digest"), expected_target: params.fetch("target"))
91
+ mutation_id = mutation_id.dup.freeze
92
+ input = @inputs.input_binding(bytes, expected_digest: params.fetch("input_digest"),
93
+ expected_target: params.fetch("target"), operation: params.fetch("operation"))
94
+ cleanup = params.fetch("operation") == "prune-preserved-workspace"
95
+ raise SecurityError, "fixed cleanup owner composition is unavailable" if cleanup && !@cleanup_owner
60
96
  contacted = true
61
- claim = @client.call("request_service", params, mutation_id: mutation_id, upload_parts: [bytes], purpose: :service_input)
97
+ claim_options = {mutation_id: mutation_id, upload_parts: [bytes], purpose: :service_input}
98
+ claim_options[:timeout] = claim_timeout if claim_timeout
99
+ claim = @client.call("request_service", params, **claim_options)
100
+ notify_claim(on_claim, claim, request_id) if on_claim
62
101
  unless !claim.replayed && claim.data["claim"] == "created"
63
102
  return projection(claim.data)
64
103
  end
104
+ original_cleanup_owner = @cleanup_owner.identity! if cleanup
65
105
  binding = params.slice("assignment_id", "attempt_id", "candidate_generation", "head", "request_id")
66
106
  exported = @client.call("export_candidate", binding.except("request_id").merge("purpose_id" => params.fetch("request_id")),
67
107
  download: true, purpose: :candidate, timeout: 30)
@@ -75,6 +115,10 @@ module Ace
75
115
  begin_params = binding.merge("claim_binding" => claim.data.fetch("claim_binding"), "expected_generation" => claim.data.fetch("generation"))
76
116
  started = @client.call("begin_dispatch", begin_params, mutation_id: Digest::SHA256.hexdigest("begin:#{mutation_id}"), upload_parts: [bytes], purpose: :service_input)
77
117
  return projection(started.data) unless !started.replayed && started.data["invocation"] == "permitted"
118
+ if cleanup && Ace::Assign::Atoms::EvidenceDigest.digest(original_cleanup_owner) !=
119
+ Ace::Assign::Atoms::EvidenceDigest.digest(started.data.fetch("operation_owner_binding"))
120
+ raise SecurityError, "original cleanup admission owner differs"
121
+ end
78
122
 
79
123
  document = Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path)
80
124
  operation = Molecules::ServicePolicy.new(document).operation!(params.fetch("operation"),
@@ -100,9 +144,22 @@ module Ace
100
144
  "executor_uid" => @receiver.fetch("executor_uid"), "authority_id" => @map.fetch("authority_id"),
101
145
  "claim_binding" => claim.data.fetch("claim_binding"), "candidate_generation" => binding.fetch("candidate_generation"),
102
146
  "head" => binding.fetch("head"), "staging_id" => File.basename(materialized.fetch("directory"))})
103
- response = @handler.execute(operation: immutable(operation), envelope: envelope, candidate_root: materialized.fetch("directory"))
104
- return uncertain(params.fetch("request_id")) unless response
105
- artifacts = Ace::Assign::Authority::EvidenceTransfer.read_staged(root: materialized.fetch("directory"), evidence: response.fetch("evidence"))
147
+ if params.fetch("operation") == "publish"
148
+ return begin_publication!(admitted: {params: params, claim: claim.data, started: started.data,
149
+ dispatch_event_digest: started.data.fetch("dispatch_event_digest"), operation: immutable(operation), envelope: envelope,
150
+ staging_identity: staging_identity, mutation_id: mutation_id}, materialized: materialized,
151
+ deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30)
152
+ end
153
+ if cleanup
154
+ artifacts = cleanup_result_evidence!(params, input.fetch(:input), claim.data, started.data)
155
+ response = {"outcome" => "succeeded", "evidence" => artifacts.each_with_index.map do |artifact, index|
156
+ {"ref" => "cleanup-#{index}", "sha256" => Digest::SHA256.hexdigest(artifact)}
157
+ end}
158
+ else
159
+ response = @handler.execute(operation: immutable(operation), envelope: envelope, candidate_root: materialized.fetch("directory"))
160
+ return uncertain(params.fetch("request_id")) unless response
161
+ artifacts = Ace::Assign::Authority::EvidenceTransfer.read_staged(root: materialized.fetch("directory"), evidence: response.fetch("evidence"))
162
+ end
106
163
  receipt = canonical.slice(*Ace::Assign::Molecules::EvidenceJournal::TERMINAL_BINDING_FIELDS).merge(
107
164
  "outcome" => response.fetch("outcome"), "evidence" => response.fetch("evidence"))
108
165
  receipt_bytes = JSON.generate(receipt)
@@ -115,18 +172,47 @@ module Ace
115
172
  cleanup_staging(materialized.fetch("directory"), staging_identity)
116
173
  projection(completion.data)
117
174
  rescue Ace::Assign::Error, Ace::Runtime::RuntimeUnavailableError, Ace::Lab::InvalidConfigurationError,
118
- SecurityError, ArgumentError, KeyError, SystemCallError
175
+ SecurityError, ArgumentError, KeyError, SystemCallError, Timeout::Error
119
176
  contacted ? uncertain(request_id) : {"request_id" => request_id, "state" => "refused", "code" => "invalid_receiver_admission"}
120
177
  end
121
178
 
122
179
  private
123
180
 
124
- def cleanup_staging(directory, identity)
181
+ def cleanup_result_evidence!(params, input, claim, started)
182
+ owner = started.fetch("operation_owner_binding")
183
+ result = @cleanup_owner.execute!(request: {
184
+ "request_id" => params.fetch("request_id"), "input_digest" => params.fetch("input_digest"),
185
+ "claim_binding" => claim.fetch("claim_binding"), "request_event_digest" => claim.fetch("request_event_digest"),
186
+ "dispatch_event_digest" => started.fetch("dispatch_event_digest"), "input" => input
187
+ }, operation_owner_binding: owner)
188
+ selection = {"schema" => "ace.protected-workspace-prune-root-selection/v1",
189
+ "request_id" => params.fetch("request_id"), "input_digest" => params.fetch("input_digest"),
190
+ "operation_owner_binding_digest" => Ace::Assign::Atoms::EvidenceDigest.digest(owner),
191
+ "receipt_ref" => result.fetch(:receipt_ref)}
192
+ [result.fetch(:bytes), JSON.generate(selection)]
193
+ end
194
+
195
+ # A source-owned listener callback, never a wire-selected handler. Its
196
+ # reply exposes accepted canonical identity before long work. Transport
197
+ # loss in the callback is handled by that listener; it cannot resend.
198
+ def notify_claim(callback, claim, request_id)
199
+ data = claim.data
200
+ unless data.is_a?(Hash) && data["request_id"] == request_id &&
201
+ data["generation"].is_a?(Integer) && data["generation"].positive? &&
202
+ data["journal_commit"].is_a?(String) && data["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
203
+ [true, false].include?(claim.replayed)
204
+ raise SecurityError, "canonical service acceptance identity is unavailable"
205
+ end
206
+ callback.call(immutable(data.slice("request_id", "generation", "journal_commit", "state", "claim")
207
+ .merge("replayed" => claim.replayed)))
208
+ end
209
+
210
+ def cleanup_staging(directory, identity, prefix: "candidate-")
125
211
  quarantine = nil
126
212
  root = File.expand_path(@receiver.fetch("staging_root"))
127
213
  Ace::Assign::Authority::PrivateDirectory.verify!(root)
128
214
  current = File.lstat(directory)
129
- return unless File.dirname(directory) == root && File.basename(directory).start_with?("candidate-") &&
215
+ return unless File.dirname(directory) == root && File.basename(directory).start_with?(prefix) &&
130
216
  current.directory? && current.uid == Process.uid && current.dev == identity.dev && current.ino == identity.ino
131
217
  # Capture the pathname atomically before checking the object we will
132
218
  # delete. A concurrent replacement at the original name must survive.
@@ -154,7 +240,9 @@ module Ace
154
240
  end
155
241
 
156
242
  def projection(data)
157
- data.slice("request_id", "state", "dispatch_phase", "claim", "generation", "journal_commit")
243
+ data.slice("request_id", "state", "dispatch_phase", "claim", "generation", "journal_commit").tap do |result|
244
+ result.merge!(data.slice("receipt", "completion_digest")) if data["operation"] == "publish"
245
+ end
158
246
  end
159
247
 
160
248
  def uncertain(id)
@@ -175,3 +263,5 @@ module Ace
175
263
  end
176
264
  end
177
265
  end
266
+
267
+ require_relative "protected_service_recovery"
@@ -0,0 +1,185 @@
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
@@ -0,0 +1,128 @@
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
@@ -0,0 +1,89 @@
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Lab
5
- VERSION = "0.4.0"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
data/lib/ace/lab.rb CHANGED
@@ -3,14 +3,8 @@
3
3
  require_relative "lab/version"
4
4
  require "ace/support/config"
5
5
 
6
- module Ace
7
- module Lab
8
- class Error < StandardError; end
9
-
10
- # Raised when topology configuration violates the schema contract
11
- class InvalidConfigurationError < Error; end
12
- end
13
- end
6
+ require_relative "lab/errors"
7
+ require_relative "lab/authorization_path"
14
8
 
15
9
  # Load all ace-lab components
16
10
  require_relative "lab/atoms/topology_schema"
@@ -63,20 +57,6 @@ module Ace
63
57
  # Same discovery the cascade resolver performs for this namespace
64
58
  LAB_FILE_PATTERNS = ["lab/config.yml", "lab/config.yaml"].freeze
65
59
 
66
- # Authorization grants live in a single deployment-controlled file at a
67
- # FIXED path — never in the caller-writable configuration cascade and
68
- # never at a caller-selected location (review rounds 4-5, F3/F1). The
69
- # file must be root-owned and not group/world-writable, including every
70
- # directory on its real path; GrantResolver verifies and fails closed.
71
- AUTHORIZATION_PATH = "/etc/lab/ace-lab/authorization.yml"
72
-
73
- # Path of the trusted authorization grants document. A method (not a
74
- # bare constant reference) so tests can stub the seam without any
75
- # caller-controllable production override.
76
- # @return [String]
77
- def self.authorization_path
78
- AUTHORIZATION_PATH
79
- end
80
60
 
81
61
  # Load configuration using Ace::Support::Config cascade.
82
62
  # Load failures raise InvalidConfigurationError (classified by the query