ace-assign 0.64.0 → 0.66.1
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.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
- data/CHANGELOG.md +25 -0
- data/docs/protected-authority.md +23 -7
- data/docs/usage.md +117 -4
- data/exe/ace-scope-ready +55 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +26 -1
- data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
- data/lib/ace/assign/atoms/delivery_parameters.rb +4 -39
- data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
- data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
- data/lib/ace/assign/authority/campaign_execution.rb +119 -0
- data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +169 -2
- data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
- data/lib/ace/assign/authority/client.rb +156 -6
- data/lib/ace/assign/authority/deployment.rb +201 -39
- data/lib/ace/assign/authority/deployment_history.rb +146 -0
- data/lib/ace/assign/authority/endcap.rb +131 -18
- data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
- data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
- data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
- data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
- data/lib/ace/assign/authority/endcap_inboxes.rb +3 -280
- data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
- data/lib/ace/assign/authority/endcap_publication.rb +207 -0
- data/lib/ace/assign/authority/endcap_results.rb +36 -18
- data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
- data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
- data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
- data/lib/ace/assign/authority/endcap_services.rb +221 -18
- data/lib/ace/assign/authority/execution_scope_observation.rb +468 -41
- data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
- data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
- data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
- data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
- data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
- data/lib/ace/assign/authority/launch_driver.rb +298 -19
- data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
- data/lib/ace/assign/authority/launch_lifecycle.rb +275 -100
- data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
- data/lib/ace/assign/authority/launch_review.rb +91 -0
- data/lib/ace/assign/authority/launch_scope_abort.rb +0 -7
- data/lib/ace/assign/authority/launch_scope_admission.rb +136 -5
- data/lib/ace/assign/authority/launch_scope_close.rb +68 -37
- data/lib/ace/assign/authority/launch_scope_release.rb +696 -17
- data/lib/ace/assign/authority/launch_steering.rb +353 -0
- data/lib/ace/assign/authority/launch_stop.rb +254 -0
- data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
- data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
- data/lib/ace/assign/authority/posix_acl.rb +5 -2
- data/lib/ace/assign/authority/prepared_input.rb +130 -0
- data/lib/ace/assign/authority/prepared_queue.rb +185 -0
- data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
- data/lib/ace/assign/authority/prepared_work.rb +304 -0
- data/lib/ace/assign/authority/prepared_worker.rb +69 -0
- data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
- data/lib/ace/assign/authority/readiness_hook.rb +120 -0
- data/lib/ace/assign/authority/router.rb +24 -2
- data/lib/ace/assign/authority/server.rb +103 -33
- data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
- data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
- data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
- data/lib/ace/assign/authority/service_evidence.rb +243 -12
- data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
- data/lib/ace/assign/authority/task_context_entry.rb +32 -0
- data/lib/ace/assign/authority/transfer_codec.rb +44 -12
- data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
- data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
- data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
- data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
- data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
- data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
- data/lib/ace/assign/cli/commands/authority/launch.rb +58 -6
- data/lib/ace/assign/cli/commands/authority/serve.rb +2 -1
- data/lib/ace/assign/cli/commands/authority/status.rb +1 -1
- data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
- data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
- data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
- data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
- data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
- data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
- data/lib/ace/assign/cli/commands/create.rb +2 -0
- data/lib/ace/assign/cli/commands/delivery.rb +54 -4
- data/lib/ace/assign/cli/commands/fail.rb +6 -0
- data/lib/ace/assign/cli/commands/finish.rb +23 -2
- data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
- data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
- data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
- data/lib/ace/assign/cli/commands/resume.rb +12 -0
- data/lib/ace/assign/cli/commands/select.rb +2 -0
- data/lib/ace/assign/cli/commands/start.rb +2 -0
- data/lib/ace/assign/cli/commands/status.rb +4 -2
- data/lib/ace/assign/cli/commands/step.rb +3 -1
- data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
- data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
- data/lib/ace/assign/cli.rb +36 -5
- data/lib/ace/assign/errors.rb +139 -0
- data/lib/ace/assign/models/assignment.rb +26 -4
- data/lib/ace/assign/models/evidence_event.rb +3 -3
- data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
- data/lib/ace/assign/molecules/attempt_reconciler.rb +20 -0
- data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
- data/lib/ace/assign/molecules/canonical_evidence.rb +5 -2
- data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +720 -44
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +27 -9
- data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
- data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +82 -13
- data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +178 -26
- data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
- data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
- data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
- data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
- data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
- data/lib/ace/assign/molecules/receipt_verifier.rb +21 -15
- data/lib/ace/assign/molecules/step_writer.rb +27 -5
- data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +4 -4
- data/lib/ace/assign/organisms/attempt_coordinator.rb +66 -76
- data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
- data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
- data/lib/ace/assign/organisms/task_assignment_creator.rb +4 -2
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +1 -112
- metadata +90 -11
- data/lib/ace/assign/cli/commands/inbox_reconcile.rb +0 -36
|
@@ -6,9 +6,14 @@ require "rbconfig"
|
|
|
6
6
|
require "openssl"
|
|
7
7
|
require_relative "private_directory"
|
|
8
8
|
require_relative "posix_acl"
|
|
9
|
+
require_relative "task_context_entry"
|
|
10
|
+
require_relative "campaign_consumer_policy"
|
|
11
|
+
require "ace/runtime/molecules/protected_worker_entry"
|
|
9
12
|
require "ace/runtime/molecules/protected_socket"
|
|
13
|
+
require "ace/runtime/molecules/protected_artifact_set"
|
|
10
14
|
require "ace/runtime/molecules/protected_linux"
|
|
11
15
|
require "ace/runtime/molecules/execution_unit_installation"
|
|
16
|
+
require "ace/runtime/molecules/readiness_configuration"
|
|
12
17
|
|
|
13
18
|
module Ace
|
|
14
19
|
module Assign
|
|
@@ -16,15 +21,48 @@ module Ace
|
|
|
16
21
|
class Deployment
|
|
17
22
|
PATH = "/etc/ace/assignment-authorities.json"
|
|
18
23
|
TOKEN = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
|
|
19
|
-
attr_reader :data
|
|
24
|
+
attr_reader :data, :artifact_reference
|
|
20
25
|
|
|
21
26
|
def self.load
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
Ace::Runtime::Molecules::ProtectedArtifactSet.new.with do |artifacts|
|
|
28
|
+
bytes, reference = artifacts.read_path!(PATH, limit: 65_536)
|
|
29
|
+
deployment = from_verified_bytes(bytes, reference)
|
|
30
|
+
artifacts.verify_unchanged!
|
|
31
|
+
deployment
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Trusted installer source call only; this is not a transport selector.
|
|
36
|
+
# Authentication establishes fixed descriptor bytes, not installed readiness.
|
|
37
|
+
def self.load_artifact(reference)
|
|
38
|
+
unless reference.is_a?(Hash) && reference.keys.sort == %w[bytes path sha256] &&
|
|
39
|
+
reference["path"].is_a?(String) && reference["path"].start_with?("/") &&
|
|
40
|
+
!reference["path"].include?("\0") && File.expand_path(reference["path"]) == reference["path"] &&
|
|
41
|
+
reference["sha256"].is_a?(String) && reference["sha256"].match?(/\A[0-9a-f]{64}\z/) &&
|
|
42
|
+
reference["bytes"].is_a?(Integer) && reference["bytes"].between?(1, 65_536)
|
|
43
|
+
raise ArgumentError, "invalid protected deployment artifact reference"
|
|
44
|
+
end
|
|
45
|
+
selected = reference.transform_values { |value| value.is_a?(String) ? value.dup.freeze : value }.freeze
|
|
46
|
+
Ace::Runtime::Molecules::ProtectedArtifactSet.new.with do |artifacts|
|
|
47
|
+
deployment = from_verified_bytes(artifacts.read!(selected), selected)
|
|
48
|
+
artifacts.verify_unchanged!
|
|
49
|
+
deployment
|
|
50
|
+
end
|
|
26
51
|
end
|
|
27
52
|
|
|
53
|
+
def self.from_verified_bytes(raw, reference)
|
|
54
|
+
bytes = raw.dup.force_encoding(Encoding::UTF_8)
|
|
55
|
+
raise ArgumentError, "deployment artifact is not UTF-8" unless bytes.valid_encoding?
|
|
56
|
+
raise ArgumentError, "deployment artifact exceeds bounds" unless bytes.bytesize.between?(1, 65_536)
|
|
57
|
+
value = JSON.parse(bytes, create_additions: false, max_nesting: 32,
|
|
58
|
+
allow_duplicate_key: false, allow_comments: false)
|
|
59
|
+
deployment = new(value)
|
|
60
|
+
deployment.send(:freeze_data!, deployment.data)
|
|
61
|
+
deployment.instance_variable_set(:@artifact_reference, reference)
|
|
62
|
+
deployment.freeze
|
|
63
|
+
end
|
|
64
|
+
private_class_method :from_verified_bytes
|
|
65
|
+
|
|
28
66
|
def initialize(data)
|
|
29
67
|
unless data.is_a?(Hash) && data["schema"] == "ace.assign.authorities/v2" &&
|
|
30
68
|
data.keys.sort == %w[authorities launch_mappings projects schema]
|
|
@@ -46,16 +84,18 @@ module Ace
|
|
|
46
84
|
end
|
|
47
85
|
@data["projects"].each_value do |project|
|
|
48
86
|
raise ArgumentError, "invalid project mapping" unless project.is_a?(Hash)
|
|
49
|
-
keys = %w[journal_repository evidence_git_ref evidence_checkout_root assignment_root candidate_root
|
|
87
|
+
keys = %w[journal_repository evidence_git_ref evidence_checkout_root assignment_root candidate_root campaign_repository campaign_store_root campaign_policy
|
|
50
88
|
launcher_uids reviewer_uids worker_uids service_executor_uids supervisor_uids peer_credentials]
|
|
51
89
|
keys << "service_receivers" if project.key?("service_receivers")
|
|
52
90
|
keys << "inbox_contexts" if project.key?("inbox_contexts")
|
|
53
91
|
strict!(project, keys)
|
|
54
|
-
|
|
92
|
+
CampaignConsumerPolicy.reference!(project.fetch("campaign_policy"))
|
|
93
|
+
%w[journal_repository evidence_checkout_root assignment_root candidate_root campaign_repository campaign_store_root].each { |key| path!(project.fetch(key)) }
|
|
55
94
|
unless project["evidence_git_ref"] == "refs/ace/execution"
|
|
56
95
|
raise ArgumentError, "protected authority uses the canonical execution ref"
|
|
57
96
|
end
|
|
58
|
-
|
|
97
|
+
role_keys = %w[launcher_uids reviewer_uids worker_uids service_executor_uids supervisor_uids]
|
|
98
|
+
role_uids = role_keys.flat_map do |key|
|
|
59
99
|
values = project.fetch(key)
|
|
60
100
|
unless values.is_a?(Array) && values.all? { |id| id.is_a?(Integer) && id.positive? } && values == values.sort.uniq
|
|
61
101
|
raise ArgumentError, "invalid project principal allowlist"
|
|
@@ -105,10 +145,19 @@ module Ace
|
|
|
105
145
|
raise ArgumentError, "receiver endpoint must be separate from private staging"
|
|
106
146
|
end
|
|
107
147
|
protected_roots = @data.fetch("authorities").values.map { |service| service.fetch("state_root") } +
|
|
108
|
-
@data.fetch("projects").values.flat_map { |project| %w[journal_repository evidence_checkout_root assignment_root candidate_root].map { |key| project.fetch(key) } }
|
|
148
|
+
@data.fetch("projects").values.flat_map { |project| %w[journal_repository evidence_checkout_root assignment_root candidate_root campaign_repository campaign_store_root].map { |key| project.fetch(key) } }
|
|
109
149
|
if staging.any? { |root| protected_roots.any? { |protected| paths_overlap?(root, protected) } }
|
|
110
150
|
raise ArgumentError, "receiver staging overlaps authority state"
|
|
111
151
|
end
|
|
152
|
+
campaign_roots = @data.fetch("projects").values.flat_map { |project| project.values_at("campaign_repository", "campaign_store_root") }
|
|
153
|
+
other_roots = @data.fetch("authorities").values.map { |service| service.fetch("state_root") } +
|
|
154
|
+
@data.fetch("projects").values.flat_map { |project|
|
|
155
|
+
project.values_at("journal_repository", "evidence_checkout_root", "assignment_root", "candidate_root") +
|
|
156
|
+
project.fetch("peer_credentials").values.map { |peer| peer.fetch("scratch_root") } }
|
|
157
|
+
if campaign_roots.combination(2).any? { |left, right| paths_overlap?(left, right) } ||
|
|
158
|
+
campaign_roots.any? { |root| other_roots.any? { |other| paths_overlap?(root, other) } }
|
|
159
|
+
raise ArgumentError, "campaign roots overlap another protected or worker root"
|
|
160
|
+
end
|
|
112
161
|
repositories = @data.fetch("projects").values.map { |project| project.fetch("journal_repository") }
|
|
113
162
|
raise ArgumentError, "canonical project repository has duplicate mappings" unless repositories.uniq.size == repositories.size
|
|
114
163
|
@data.fetch("projects").each_key do |project_id|
|
|
@@ -141,24 +190,13 @@ module Ace
|
|
|
141
190
|
unless Process.uid == service.fetch("uid")
|
|
142
191
|
raise Ace::Runtime::RuntimeUnavailableError, "inbox resolver must run as its installed authority"
|
|
143
192
|
end
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
unless File.file?(path) && !File.symlink?(path) && File.readable?(path)
|
|
152
|
-
raise Ace::Runtime::RuntimeUnavailableError, "installed inbox artifact is unavailable"
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
unless File.executable?(client_path) && Digest::SHA256.file(client_path).hexdigest == context.fetch("pi_queue_client_sha256")
|
|
156
|
-
raise Ace::Runtime::RuntimeUnavailableError, "installed inbox identity client differs"
|
|
157
|
-
end
|
|
158
|
-
raise Ace::Runtime::RuntimeUnavailableError, "installed inbox public key is oversized" if File.size(key_path) > 16_384
|
|
159
|
-
key = OpenSSL::PKey.read(File.binread(key_path))
|
|
160
|
-
unless key.is_a?(OpenSSL::PKey::RSA) && !key.private?
|
|
161
|
-
raise Ace::Runtime::RuntimeUnavailableError, "installed inbox key must be public-only RSA"
|
|
193
|
+
credentials = context.fetch("owner_credentials")
|
|
194
|
+
path = context.fetch("control_socket_path")
|
|
195
|
+
wire = Ace::Runtime::Molecules::ProtectedSocket
|
|
196
|
+
wire.root_path!(File.dirname(path), directory: true, owner: credentials.fetch("uid"))
|
|
197
|
+
identity = wire.socket_identity(path)
|
|
198
|
+
unless identity.last == credentials.fetch("uid")
|
|
199
|
+
raise Ace::Runtime::RuntimeUnavailableError, "installed context owner endpoint differs"
|
|
162
200
|
end
|
|
163
201
|
context
|
|
164
202
|
rescue AttemptErrors::ReceiptRejected, OpenSSL::PKey::PKeyError, SystemCallError
|
|
@@ -196,14 +234,22 @@ module Ace
|
|
|
196
234
|
|
|
197
235
|
def validate_inbox_contexts!
|
|
198
236
|
roots = []
|
|
237
|
+
service_scopes = data.fetch("launch_mappings").values.map { |map| map.fetch("execution_scope") }
|
|
238
|
+
service_owner_uids = []
|
|
199
239
|
data.fetch("projects").each do |project_id, fixed_project|
|
|
200
240
|
contexts = fixed_project.fetch("inbox_contexts", {})
|
|
201
241
|
unless contexts.is_a?(Hash) && contexts.keys.all? { |id| id.is_a?(String) && TOKEN.match?(id) }
|
|
202
242
|
raise ArgumentError, "invalid installed inbox context map"
|
|
203
243
|
end
|
|
204
244
|
contexts.each_value do |context|
|
|
205
|
-
|
|
206
|
-
|
|
245
|
+
fields = %w[control_socket_path deliveries_dir owner_credentials native_mapping_id pi_queue_client pi_queue_client_sha256 supervisor_uids]
|
|
246
|
+
fields << "service" if context.key?("service")
|
|
247
|
+
strict!(context, fields)
|
|
248
|
+
credentials = context.fetch("owner_credentials")
|
|
249
|
+
strict!(credentials, %w[gid groups uid])
|
|
250
|
+
principal!(credentials, "uid", "gid", "groups")
|
|
251
|
+
raise ArgumentError, "context owner group count exceeds bounds" if credentials.fetch("groups").size > 64
|
|
252
|
+
%w[control_socket_path deliveries_dir pi_queue_client].each { |key| path!(context.fetch(key)) }
|
|
207
253
|
unless context["pi_queue_client_sha256"].is_a?(String) && context["pi_queue_client_sha256"].match?(/\A[0-9a-f]{64}\z/)
|
|
208
254
|
raise ArgumentError, "invalid installed Pi identity client digest"
|
|
209
255
|
end
|
|
@@ -220,19 +266,48 @@ module Ace
|
|
|
220
266
|
unless native.fetch("project_id") == project_id && authority(native.fetch("authority_id")).fetch("composition") == "services"
|
|
221
267
|
raise ArgumentError, "inbox context requires its services project mapping"
|
|
222
268
|
end
|
|
269
|
+
if context.key?("service")
|
|
270
|
+
Ace::Runtime::Molecules::ExecutionUnitInstallation.validate_inbox_context_service!(context.fetch("service"))
|
|
271
|
+
scope = context.fetch("service").fetch("execution_scope")
|
|
272
|
+
if service_scopes.any? { |other|
|
|
273
|
+
%w[slot_id slice_unit service_unit].any? { |field| scope.fetch(field) == other.fetch(field) } ||
|
|
274
|
+
scope.values_at("root_directory", "runtime_directory").any? { |path|
|
|
275
|
+
other.values_at("root_directory", "runtime_directory").any? { |existing| paths_overlap?(path, existing) }
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
raise ArgumentError, "context service shares a protected execution slot or backing root"
|
|
279
|
+
end
|
|
280
|
+
prohibited_uids = data.fetch("authorities").values.map { |service| service.fetch("uid") } +
|
|
281
|
+
data.fetch("launch_mappings").values.flat_map { |map| map.values_at("launcher_uid", "worker_uid") } +
|
|
282
|
+
data.fetch("projects").values.flat_map { |project|
|
|
283
|
+
%w[launcher_uids reviewer_uids worker_uids service_executor_uids supervisor_uids].flat_map { |field| project.fetch(field) } +
|
|
284
|
+
project.fetch("service_receivers", {}).values.map { |receiver| receiver.fetch("executor_uid") }
|
|
285
|
+
}
|
|
286
|
+
prohibited_uids.concat(data.fetch("projects").values.flat_map { |project|
|
|
287
|
+
project.fetch("inbox_contexts", {}).values.reject { |other| other.equal?(context) }.map { |other| other.fetch("owner_credentials").fetch("uid") }
|
|
288
|
+
})
|
|
289
|
+
uid = credentials.fetch("uid")
|
|
290
|
+
if prohibited_uids.include?(uid) || service_owner_uids.include?(uid)
|
|
291
|
+
raise ArgumentError, "context service owner shares another protected principal"
|
|
292
|
+
end
|
|
293
|
+
service_owner_uids << uid
|
|
294
|
+
service_scopes << scope
|
|
295
|
+
end
|
|
223
296
|
roots << context.fetch("deliveries_dir")
|
|
224
297
|
end
|
|
225
298
|
end
|
|
226
299
|
forbidden = data.fetch("projects").values.flat_map do |fixed_project|
|
|
227
300
|
fixed_project.fetch("peer_credentials").values.map { |peer| peer.fetch("scratch_root") } +
|
|
228
301
|
fixed_project.fetch("service_receivers", {}).values.map { |receiver| receiver.fetch("staging_root") } +
|
|
229
|
-
%w[journal_repository evidence_checkout_root assignment_root candidate_root].map { |key| fixed_project.fetch(key) }
|
|
302
|
+
%w[journal_repository evidence_checkout_root assignment_root candidate_root campaign_repository campaign_store_root].map { |key| fixed_project.fetch(key) }
|
|
230
303
|
end
|
|
231
304
|
forbidden.concat(data.fetch("authorities").values.map { |service| service.fetch("state_root") })
|
|
232
305
|
if roots.combination(2).any? { |left, right| paths_overlap?(left, right) } ||
|
|
233
306
|
roots.any? { |root| forbidden.any? { |other| paths_overlap?(root, other) } }
|
|
234
307
|
raise ArgumentError, "installed inbox private roots overlap protected state"
|
|
235
308
|
end
|
|
309
|
+
rescue Ace::Runtime::RuntimeUnavailableError
|
|
310
|
+
raise ArgumentError, "invalid context service installation selection"
|
|
236
311
|
end
|
|
237
312
|
private :validate_inbox_contexts!
|
|
238
313
|
|
|
@@ -318,12 +393,43 @@ module Ace
|
|
|
318
393
|
data.fetch("launch_mappings").fetch(id)
|
|
319
394
|
end
|
|
320
395
|
|
|
396
|
+
# Canonical mapping identity shared by scope evidence and the trusted
|
|
397
|
+
# provisioning owner. Resolves only this descriptor's exact mapping ID.
|
|
398
|
+
def mapping_digest(id)
|
|
399
|
+
Digest::SHA256.hexdigest(JSON.generate(canonical_mapping_value(mapping(id)))).freeze
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def canonical_mapping_value(value)
|
|
403
|
+
case value
|
|
404
|
+
when Hash then value.sort.to_h.transform_values { |item| canonical_mapping_value(item) }
|
|
405
|
+
when Array then value.map { |item| canonical_mapping_value(item) }
|
|
406
|
+
else value
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
private :canonical_mapping_value
|
|
410
|
+
|
|
321
411
|
def validate_mapping!(id, mapping)
|
|
322
412
|
required = %w[project_id authority_id launcher_uid launcher_gid launcher_groups worker_uid worker_gid
|
|
323
|
-
worker_groups worker_actor worker_cwd
|
|
324
|
-
|
|
413
|
+
worker_groups worker_actor worker_cwd worker_entry worker_env bootstrap bootstrap_sha256 native execution_scope task_context_entry]
|
|
414
|
+
workspace = %w[workspace_repository_id workspace_cleanup_config]
|
|
415
|
+
unless mapping.is_a?(Hash) && [required.sort, (required + workspace).sort].include?(mapping.keys.sort)
|
|
325
416
|
raise ArgumentError, "launch mapping fields differ: #{id}"
|
|
326
417
|
end
|
|
418
|
+
if mapping.key?("workspace_repository_id")
|
|
419
|
+
repository = mapping.fetch("workspace_repository_id")
|
|
420
|
+
unless repository.is_a?(String) && TOKEN.match?(repository)
|
|
421
|
+
raise ArgumentError, "workspace repository identity is invalid"
|
|
422
|
+
end
|
|
423
|
+
reference = mapping.fetch("workspace_cleanup_config")
|
|
424
|
+
strict!(reference, %w[path sha256 bytes])
|
|
425
|
+
path!(reference.fetch("path"))
|
|
426
|
+
unless reference.fetch("path").bytesize <= 4096 && reference.fetch("path").encoding == Encoding::UTF_8 && reference.fetch("path").valid_encoding? &&
|
|
427
|
+
digest?(reference.fetch("sha256")) && reference.fetch("bytes").is_a?(Integer) && reference.fetch("bytes").between?(1, 65_536)
|
|
428
|
+
raise ArgumentError, "workspace cleanup configuration reference is invalid"
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
TaskContextEntry.validate!(mapping.fetch("task_context_entry"))
|
|
432
|
+
Ace::Runtime::Molecules::ProtectedWorkerEntry.validate!(mapping.fetch("worker_entry"))
|
|
327
433
|
service = authority(mapping.fetch("authority_id"))
|
|
328
434
|
fixed_project = project(mapping.fetch("project_id"))
|
|
329
435
|
%w[launcher worker].each do |role|
|
|
@@ -367,12 +473,10 @@ module Ace
|
|
|
367
473
|
!paths_overlap?(scope.fetch("root_directory"), scope.fetch("runtime_directory"))
|
|
368
474
|
raise ArgumentError, "execution scope backing roots overlap or runtime root is invalid"
|
|
369
475
|
end
|
|
370
|
-
argv = mapping.fetch("worker_argv")
|
|
371
476
|
env = mapping.fetch("worker_env")
|
|
372
|
-
unless
|
|
373
|
-
argv.first.start_with?("/") && env.is_a?(Hash) && env.all? { |k, v| k.match?(/\A[A-Z_][A-Z0-9_]*\z/) && v.is_a?(String) && !v.include?("\0") } &&
|
|
477
|
+
unless env.is_a?(Hash) && env.all? { |k, v| k.match?(/\A[A-Z_][A-Z0-9_]*\z/) && v.is_a?(String) && !v.include?("\0") } &&
|
|
374
478
|
env.keys.none? { |key| key.match?(/\A(?:LD_|DYLD_|RUBY|BUNDLE|PYTHON)/) }
|
|
375
|
-
raise ArgumentError, "invalid fixed worker
|
|
479
|
+
raise ArgumentError, "invalid fixed worker environment"
|
|
376
480
|
end
|
|
377
481
|
unless mapping["worker_actor"].is_a?(String) && !mapping["worker_actor"].empty? &&
|
|
378
482
|
mapping["bootstrap_sha256"].match?(/\A[0-9a-f]{64}\z/)
|
|
@@ -402,6 +506,51 @@ module Ace
|
|
|
402
506
|
end
|
|
403
507
|
end
|
|
404
508
|
|
|
509
|
+
# Original ownership cannot be recycled or hidden by a staged descriptor.
|
|
510
|
+
def maintenance_inventory(candidate)
|
|
511
|
+
unless candidate.is_a?(Deployment) && artifact_reference && candidate.artifact_reference && frozen? && candidate.frozen?
|
|
512
|
+
raise AttemptErrors::EvidenceUnavailable, "maintenance requires authenticated immutable original and candidate descriptors"
|
|
513
|
+
end
|
|
514
|
+
originals = data.fetch("launch_mappings")
|
|
515
|
+
proposed = candidate.data.fetch("launch_mappings")
|
|
516
|
+
originals.each do |id, map|
|
|
517
|
+
next unless proposed.key?(id)
|
|
518
|
+
unless maintenance_association(map) == candidate.maintenance_association(proposed.fetch(id))
|
|
519
|
+
raise ArgumentError, "existing maintenance mapping was reassociated: #{id}"
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
entries = originals.map { |id, map| [id, self, map] } +
|
|
523
|
+
proposed.reject { |id, _| originals.key?(id) }.map { |id, map| [id, candidate, map] }
|
|
524
|
+
all = originals.map { |id, map| [id, self, map] } + proposed.map { |id, map| [id, candidate, map] }
|
|
525
|
+
all.combination(2).each do |left, right|
|
|
526
|
+
l_id, l_owner, l_map = left
|
|
527
|
+
r_id, r_owner, r_map = right
|
|
528
|
+
l_scope, r_scope = l_map.fetch("execution_scope"), r_map.fetch("execution_scope")
|
|
529
|
+
shared = %w[slot_id slice_unit service_unit network_namespace_path].any? { |key| l_scope.fetch(key) == r_scope.fetch(key) }
|
|
530
|
+
shared ||= l_scope.values_at("root_directory", "runtime_directory").product(
|
|
531
|
+
r_scope.values_at("root_directory", "runtime_directory")).any? { |a, b| paths_overlap?(a, b) }
|
|
532
|
+
if shared && (l_id != r_id || l_owner.maintenance_association(l_map) != r_owner.maintenance_association(r_map))
|
|
533
|
+
raise ArgumentError, "physical execution slot has conflicting maintenance ownership"
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
entries.sort_by(&:first).map { |entry| entry.freeze }.freeze
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def maintenance_association(map)
|
|
540
|
+
fixed = project(map.fetch("project_id"))
|
|
541
|
+
[map.fetch("project_id"), fixed.values_at("journal_repository", "evidence_git_ref", "evidence_checkout_root"),
|
|
542
|
+
authority(map.fetch("authority_id")).fetch("state_root"),
|
|
543
|
+
map.fetch("execution_scope").values_at("slot_id", "slice_unit", "service_unit", "network_namespace_path")]
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
def freeze_data!(value)
|
|
547
|
+
case value
|
|
548
|
+
when Hash then value.each { |key, item| key.freeze; freeze_data!(item) }
|
|
549
|
+
when Array then value.each { |item| freeze_data!(item) }
|
|
550
|
+
end
|
|
551
|
+
value.freeze
|
|
552
|
+
end
|
|
553
|
+
|
|
405
554
|
def strict!(value, keys)
|
|
406
555
|
raise ArgumentError, "deployment fields differ" unless value.is_a?(Hash) && value.keys.sort == keys.sort
|
|
407
556
|
end
|
|
@@ -438,11 +587,24 @@ module Ace
|
|
|
438
587
|
manager ||= Ace::Runtime::Molecules::SystemdScopeManager.new(
|
|
439
588
|
slice_unit: scope.fetch("slice_unit"), service_unit: scope.fetch("service_unit"))
|
|
440
589
|
manifest = Ace::Runtime::Molecules::ExecutionUnitInstallation.new(scope: scope, native: map.fetch("native"),
|
|
441
|
-
bootstrap: map.fetch("bootstrap"),
|
|
590
|
+
bootstrap: map.fetch("bootstrap"), worker_entry: map.fetch("worker_entry"),
|
|
442
591
|
worker_uid: map.fetch("worker_uid"), worker_gid: map.fetch("worker_gid")).verify!(manager: manager)
|
|
443
592
|
artifacts = manifest.fetch("artifacts")
|
|
593
|
+
configuration = artifacts.find { |artifact| artifact.fetch("role") == "readiness_configuration" }
|
|
594
|
+
Ace::Runtime::Molecules::ProtectedArtifactSet.new.with do |reader|
|
|
595
|
+
bytes, = reader.read_path!(configuration.fetch("host_path"), limit: 65_536)
|
|
596
|
+
unless Digest::SHA256.hexdigest(bytes) == configuration.fetch("sha256")
|
|
597
|
+
raise Ace::Runtime::RuntimeUnavailableError, "readiness configuration content differs"
|
|
598
|
+
end
|
|
599
|
+
Ace::Runtime::Molecules::ReadinessConfiguration.decode(bytes, slot: scope.fetch("slot_id"))
|
|
600
|
+
.verify_selection!(mapping_id: id, mapping: map, authority: service, installation: manifest)
|
|
601
|
+
reader.verify_unchanged!
|
|
602
|
+
end
|
|
444
603
|
bootstrap = artifacts.find { |artifact| artifact.fetch("role") == "bootstrap" }
|
|
445
|
-
executables = artifacts.select
|
|
604
|
+
executables = artifacts.select do |artifact|
|
|
605
|
+
artifact.fetch("role") == "bootstrap" || (artifact.fetch("role") == "runtime_dependency" &&
|
|
606
|
+
artifact.fetch("view_path") == map.fetch("worker_entry").fetch("interpreter").fetch("path"))
|
|
607
|
+
end
|
|
446
608
|
unless bootstrap.fetch("sha256") == map.fetch("bootstrap_sha256") && elf_architecture?(bootstrap.fetch("host_path")) &&
|
|
447
609
|
executables.all? { |artifact| File.executable?(artifact.fetch("host_path")) }
|
|
448
610
|
raise Ace::Runtime::RuntimeUnavailableError, "installed bootstrap or executable is unsafe"
|
|
@@ -450,7 +612,7 @@ module Ace
|
|
|
450
612
|
wire.root_path!(File.dirname(service.fetch("socket_path")), directory: true, owner: service.fetch("uid"))
|
|
451
613
|
if authority_state
|
|
452
614
|
fixed = project(map.fetch("project_id"))
|
|
453
|
-
([service.fetch("state_root")] + %w[journal_repository evidence_checkout_root assignment_root candidate_root].map { |key| fixed.fetch(key) }).each do |path|
|
|
615
|
+
([service.fetch("state_root")] + %w[journal_repository evidence_checkout_root assignment_root candidate_root campaign_repository campaign_store_root].map { |key| fixed.fetch(key) }).each do |path|
|
|
454
616
|
wire.root_path!(path, directory: true, owner: service.fetch("uid"))
|
|
455
617
|
unless (File.stat(path).mode & 0o077).zero?
|
|
456
618
|
raise Ace::Runtime::RuntimeUnavailableError, "authority state root must remain private"
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "deployment"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Assign
|
|
7
|
+
module Authority
|
|
8
|
+
# Immutable installer configuration, indexed by canonical content identity.
|
|
9
|
+
# This object is never a transport selector or an execution ledger.
|
|
10
|
+
class DeploymentHistory
|
|
11
|
+
PATH = "/etc/ace/assignment-deployment-history.json"
|
|
12
|
+
attr_reader :original, :candidate, :artifact_reference
|
|
13
|
+
|
|
14
|
+
def self.load = load_protected(nil)
|
|
15
|
+
|
|
16
|
+
def self.load_artifact(reference)
|
|
17
|
+
validate_reference!(reference)
|
|
18
|
+
load_protected(reference)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.load_protected(reference)
|
|
22
|
+
Ace::Runtime::Molecules::ProtectedArtifactSet.new.with do |artifacts|
|
|
23
|
+
bytes = if reference
|
|
24
|
+
artifacts.read!(reference)
|
|
25
|
+
else
|
|
26
|
+
held, selected = artifacts.read_path!(PATH, limit: 65_536)
|
|
27
|
+
reference = selected
|
|
28
|
+
held
|
|
29
|
+
end
|
|
30
|
+
raw = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
31
|
+
raise ArgumentError, "history manifest is not UTF-8" unless raw.valid_encoding?
|
|
32
|
+
value = JSON.parse(raw, create_additions: false, max_nesting: 32,
|
|
33
|
+
allow_duplicate_key: false, allow_comments: false)
|
|
34
|
+
unless value.is_a?(Hash) && value.keys.sort ==
|
|
35
|
+
%w[candidate_descriptor descriptors original_descriptor public_keys schema] &&
|
|
36
|
+
value["schema"] == "ace.assign.deployment-history/v1"
|
|
37
|
+
raise ArgumentError, "invalid deployment history manifest"
|
|
38
|
+
end
|
|
39
|
+
descriptors = value.fetch("descriptors")
|
|
40
|
+
keys = value.fetch("public_keys")
|
|
41
|
+
unless descriptors.is_a?(Array) && descriptors.size.between?(1, 128) &&
|
|
42
|
+
keys.is_a?(Array) && keys.size <= 128 && descriptors.size + keys.size <= 255
|
|
43
|
+
raise ArgumentError, "deployment history inventory exceeds bounds"
|
|
44
|
+
end
|
|
45
|
+
index = {}
|
|
46
|
+
descriptors.each do |selected|
|
|
47
|
+
validate_reference!(selected)
|
|
48
|
+
digest = selected.fetch("sha256")
|
|
49
|
+
raise ArgumentError, "duplicate historical descriptor selector" if index.key?(digest)
|
|
50
|
+
index[digest] = Deployment.send(:from_verified_bytes, artifacts.read!(selected), immutable(selected))
|
|
51
|
+
end
|
|
52
|
+
selections = %w[original_descriptor candidate_descriptor].map do |name|
|
|
53
|
+
selected = value.fetch(name)
|
|
54
|
+
validate_reference!(selected)
|
|
55
|
+
descriptor = index.fetch(selected.fetch("sha256")) do
|
|
56
|
+
raise ArgumentError, "transaction descriptor is not retained"
|
|
57
|
+
end
|
|
58
|
+
unless descriptor.artifact_reference == selected
|
|
59
|
+
raise ArgumentError, "transaction descriptor reference conflicts"
|
|
60
|
+
end
|
|
61
|
+
descriptor
|
|
62
|
+
end
|
|
63
|
+
public_keys = {}
|
|
64
|
+
keys.each do |entry|
|
|
65
|
+
unless entry.is_a?(Hash) && entry.keys.sort == %w[public_key_sha256 ref] &&
|
|
66
|
+
digest?(entry["public_key_sha256"])
|
|
67
|
+
raise ArgumentError, "invalid historical public key entry"
|
|
68
|
+
end
|
|
69
|
+
validate_reference!(entry.fetch("ref"))
|
|
70
|
+
digest = entry.fetch("public_key_sha256")
|
|
71
|
+
raise ArgumentError, "duplicate historical public key selector" if public_keys.key?(digest)
|
|
72
|
+
key = OpenSSL::PKey.read(artifacts.read!(entry.fetch("ref")))
|
|
73
|
+
unless key.is_a?(OpenSSL::PKey::RSA) && !key.private? &&
|
|
74
|
+
Digest::SHA256.hexdigest(key.public_key.to_der) == digest
|
|
75
|
+
raise ArgumentError, "historical public key identity differs"
|
|
76
|
+
end
|
|
77
|
+
public_keys[digest] = key.freeze
|
|
78
|
+
end
|
|
79
|
+
selections.first.maintenance_inventory(selections.last)
|
|
80
|
+
history = new(selections, index, public_keys, immutable(reference))
|
|
81
|
+
artifacts.verify_unchanged!
|
|
82
|
+
history.freeze
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def initialize(selections, descriptors, keys, reference)
|
|
87
|
+
@original, @candidate = selections
|
|
88
|
+
@descriptors, @keys = descriptors.freeze, keys.freeze
|
|
89
|
+
@artifact_reference = reference
|
|
90
|
+
end
|
|
91
|
+
private_class_method :new
|
|
92
|
+
|
|
93
|
+
def descriptor!(sha256:)
|
|
94
|
+
@descriptors.fetch(sha256) do
|
|
95
|
+
raise AttemptErrors::EvidenceUnavailable, "original protected descriptor is not retained"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def public_key!(sha256:)
|
|
100
|
+
@keys.fetch(sha256) do
|
|
101
|
+
raise AttemptErrors::EvidenceUnavailable, "original protected public key is not retained"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def descriptors = @descriptors.values.freeze
|
|
106
|
+
|
|
107
|
+
# Publication moves verified bytes to the fixed current descriptor path.
|
|
108
|
+
# Retention paths stay immutable; canonical content, not pathname, joins
|
|
109
|
+
# the installed selection to that exact independently verified artifact.
|
|
110
|
+
def selects?(deployment, selection: nil)
|
|
111
|
+
return false unless deployment.is_a?(Deployment) && deployment.frozen? && deployment.artifact_reference
|
|
112
|
+
choices = case selection
|
|
113
|
+
when :original then [original]
|
|
114
|
+
when :candidate then [candidate]
|
|
115
|
+
when nil then [original, candidate]
|
|
116
|
+
else raise ArgumentError, "invalid history transaction selection"
|
|
117
|
+
end
|
|
118
|
+
choices.any? do |selected|
|
|
119
|
+
selected.artifact_reference.values_at("sha256", "bytes") == deployment.artifact_reference.values_at("sha256", "bytes")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def self.digest?(value) = value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
|
|
124
|
+
|
|
125
|
+
def self.validate_reference!(value)
|
|
126
|
+
unless value.is_a?(Hash) && value.keys.sort == %w[bytes path sha256] &&
|
|
127
|
+
value["path"].is_a?(String) && value["path"].start_with?("/") &&
|
|
128
|
+
!value["path"].include?("\0") && File.expand_path(value["path"]) == value["path"] &&
|
|
129
|
+
digest?(value["sha256"]) && value["bytes"].is_a?(Integer) && value["bytes"].between?(1, 65_536)
|
|
130
|
+
raise ArgumentError, "invalid protected history artifact reference"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def self.immutable(value)
|
|
135
|
+
case value
|
|
136
|
+
when Hash then value.to_h { |key, item| [key.dup.freeze, immutable(item)] }.freeze
|
|
137
|
+
when Array then value.map { |item| immutable(item) }.freeze
|
|
138
|
+
when String then value.dup.freeze
|
|
139
|
+
else value.freeze
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
private_class_method :digest?, :validate_reference!, :immutable, :load_protected
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|