ace-assign 0.64.0 → 0.66.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.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
- data/CHANGELOG.md +19 -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 +88 -9
- data/lib/ace/assign/cli/commands/inbox_reconcile.rb +0 -36
|
@@ -5,6 +5,8 @@ require "fileutils"
|
|
|
5
5
|
require "json"
|
|
6
6
|
require "open3"
|
|
7
7
|
require "time"
|
|
8
|
+
require "securerandom"
|
|
9
|
+
require_relative "../atoms/evidence_digest"
|
|
8
10
|
|
|
9
11
|
module Ace
|
|
10
12
|
module Assign
|
|
@@ -25,6 +27,95 @@ module Ace
|
|
|
25
27
|
# present. Legitimate fresh provisioning (a new worktree for a task)
|
|
26
28
|
# clears the marker explicitly via `reset_removed`.
|
|
27
29
|
class LifecycleExclusion
|
|
30
|
+
autoload :ControlExclusion, File.expand_path("protected_control_exclusion", __dir__)
|
|
31
|
+
|
|
32
|
+
def self.control_exclusion(authority:, project_id:, descriptor_sha256:, root_identity: nil, **boundaries)
|
|
33
|
+
ControlExclusion.new(authority: authority, project_id: project_id,
|
|
34
|
+
descriptor_sha256: descriptor_sha256, root_identity: root_identity, **boundaries)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
autoload :WorkspaceNativeReader, File.expand_path("protected_workspace_exclusion", __dir__)
|
|
38
|
+
autoload :WorkspaceReader, File.expand_path("protected_workspace_exclusion", __dir__)
|
|
39
|
+
|
|
40
|
+
autoload :WorkspaceWriter, File.expand_path("protected_workspace_exclusion", __dir__)
|
|
41
|
+
|
|
42
|
+
autoload :WorkspaceFenceReader, File.expand_path("protected_workspace_exclusion", __dir__)
|
|
43
|
+
|
|
44
|
+
def self.workspace_fence_reader(projection:, protection: nil, files: File)
|
|
45
|
+
WorkspaceFenceReader.new(projection: projection, protection: protection, files: files)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.workspace_writer(projection:, protection: nil, files: File)
|
|
49
|
+
WorkspaceWriter.new(projection: projection, protection: protection, files: files)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
autoload :WorkspaceProvisioner, File.expand_path("protected_workspace_provisioner", __dir__)
|
|
53
|
+
|
|
54
|
+
def self.provision_workspace!(mapping_id:, project_id:, authority:, cwd_resource:, **boundaries)
|
|
55
|
+
WorkspaceProvisioner.new(mapping_id: mapping_id, project_id: project_id,
|
|
56
|
+
authority: authority, cwd_resource: cwd_resource, **boundaries).provision!
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.workspace_reader(projection:, protection: nil, files: File)
|
|
60
|
+
WorkspaceReader.new(projection: projection, protection: protection, files: files)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.workspace_selection(mapping_id:, project_id:, authority:, cwd_resource:)
|
|
64
|
+
fields = %w[device filesystem_type gid host_path inode mount_id uid view_path]
|
|
65
|
+
unless authority.is_a?(Hash) && cwd_resource.is_a?(Hash) && cwd_resource.keys.sort == fields &&
|
|
66
|
+
%w[device gid inode uid].all? { |field| cwd_resource[field].is_a?(Integer) && cwd_resource[field] >= 0 } &&
|
|
67
|
+
cwd_resource["mount_id"].is_a?(Integer) && cwd_resource["mount_id"].positive? &&
|
|
68
|
+
%w[host_path view_path].all? { |field| cwd_resource[field].is_a?(String) &&
|
|
69
|
+
cwd_resource[field].valid_encoding? && cwd_resource[field].bytesize.between?(1, 4096) &&
|
|
70
|
+
!cwd_resource[field].include?("\0") && cwd_resource[field].start_with?("/") &&
|
|
71
|
+
File.expand_path(cwd_resource[field]) == cwd_resource[field] } &&
|
|
72
|
+
%w[ext4 xfs btrfs tmpfs].include?(cwd_resource["filesystem_type"]) &&
|
|
73
|
+
[mapping_id, project_id].all? { |value| value.is_a?(String) && value.match?(/\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/) } &&
|
|
74
|
+
%w[uid gid].all? { |field| authority[field].is_a?(Integer) && authority[field].positive? } &&
|
|
75
|
+
authority["state_root"].is_a?(String) && authority["state_root"].valid_encoding? &&
|
|
76
|
+
authority["state_root"].bytesize.between?(1, 4096) && !authority["state_root"].include?("\0") &&
|
|
77
|
+
authority["state_root"].start_with?("/") &&
|
|
78
|
+
File.expand_path(authority["state_root"]) == authority["state_root"]
|
|
79
|
+
raise AttemptErrors::EvidenceUnavailable, "Original lifecycle workspace selection is malformed"
|
|
80
|
+
end
|
|
81
|
+
digest = Atoms::EvidenceDigest.digest(cwd_resource)
|
|
82
|
+
selection = {"key" => "workspace:#{mapping_id}:#{digest}",
|
|
83
|
+
"host_path" => File.join(authority.fetch("state_root"), "lifecycle-exclusion", project_id,
|
|
84
|
+
"workspaces", mapping_id, digest), "view_path" => "/run/ace/lifecycle-exclusion/#{mapping_id}"}
|
|
85
|
+
unless selection.fetch("host_path").bytesize <= 4096
|
|
86
|
+
raise AttemptErrors::EvidenceUnavailable, "Original lifecycle workspace path is oversized"
|
|
87
|
+
end
|
|
88
|
+
selection.each_value(&:freeze)
|
|
89
|
+
selection.freeze
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.workspace_initial_marker(key:)
|
|
93
|
+
unless key.is_a?(String) && key.match?(/\Aworkspace:[A-Za-z0-9][A-Za-z0-9._-]{0,127}:[0-9a-f]{64}\z/)
|
|
94
|
+
raise AttemptErrors::EvidenceUnavailable, "Original lifecycle workspace key is malformed"
|
|
95
|
+
end
|
|
96
|
+
Atoms::EvidenceDigest.canonical_json({"key" => key, "removed" => false, "removed_at" => nil}).freeze
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.workspace_marker!(bytes:, key:)
|
|
100
|
+
unless bytes.is_a?(String) && bytes.bytesize.between?(1, 8192)
|
|
101
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle marker bytes differ"
|
|
102
|
+
end
|
|
103
|
+
bytes = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
104
|
+
unless bytes.valid_encoding?
|
|
105
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle marker is not bounded UTF8"
|
|
106
|
+
end
|
|
107
|
+
state = JSON.parse(bytes, create_additions: false, max_nesting: 4, allow_duplicate_key: false)
|
|
108
|
+
unless state.is_a?(Hash) && state.keys.sort == %w[key removed removed_at] && state.fetch("key") == key &&
|
|
109
|
+
(state.values_at("removed", "removed_at") == [false, nil] || state["removed"] == true &&
|
|
110
|
+
state["removed_at"].is_a?(String) && Time.iso8601(state["removed_at"]).utc.iso8601 == state["removed_at"])
|
|
111
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle marker schema differs"
|
|
112
|
+
end
|
|
113
|
+
state.each_value { |value| value.freeze }
|
|
114
|
+
state.freeze
|
|
115
|
+
rescue JSON::ParserError, ArgumentError, KeyError
|
|
116
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle marker schema differs"
|
|
117
|
+
end
|
|
118
|
+
|
|
28
119
|
# @param repo_root [String, nil] Repository used to resolve the
|
|
29
120
|
# shared Git common dir (default: project root)
|
|
30
121
|
# @param root [String, nil] Explicit exclusion root (tests)
|
|
@@ -33,21 +124,17 @@ module Ace
|
|
|
33
124
|
end
|
|
34
125
|
|
|
35
126
|
# The exclusion root shared by every writer and prune for this repo.
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
cache_base = ENV["CACHE_BASE"]
|
|
41
|
-
if cache_base && !cache_base.empty?
|
|
42
|
-
return File.join(File.expand_path(cache_base), ".exclusion")
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
repo_root = Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
|
|
127
|
+
# Assignment cache configuration does not select this repository
|
|
128
|
+
# identity. The Git common dir survives linked worktree removal.
|
|
129
|
+
def self.default_root(repo_root = nil)
|
|
130
|
+
repo_root ||= Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
|
|
46
131
|
common_dir, _stderr, status = Open3.capture3(
|
|
47
132
|
"git", "-C", repo_root, "rev-parse", "--path-format=absolute", "--git-common-dir"
|
|
48
133
|
)
|
|
49
|
-
|
|
50
|
-
|
|
134
|
+
unless status.success? && !common_dir.to_s.strip.empty? && File.absolute_path(common_dir.strip) == common_dir.strip
|
|
135
|
+
raise AttemptErrors::Conflict, "Repository lifecycle root is unavailable"
|
|
136
|
+
end
|
|
137
|
+
File.join(common_dir.strip, "ace", "lifecycle-exclusion")
|
|
51
138
|
end
|
|
52
139
|
|
|
53
140
|
def assignment_key(assignment_id)
|
|
@@ -79,12 +166,17 @@ module Ace
|
|
|
79
166
|
#
|
|
80
167
|
# @param key [String] Identity key
|
|
81
168
|
# @yield Required block executed under exclusive exclusion
|
|
82
|
-
def with_exclusive(key)
|
|
169
|
+
def with_exclusive(key, deadline: nil)
|
|
83
170
|
raise ArgumentError, "exclusion key is required" if key.to_s.empty?
|
|
84
171
|
|
|
85
|
-
|
|
86
|
-
lock.
|
|
172
|
+
maintenance_deadline!(deadline) unless deadline.nil?
|
|
173
|
+
lock = open_lock(key, nonblock: !deadline.nil?)
|
|
87
174
|
begin
|
|
175
|
+
mode = File::LOCK_EX | (deadline ? File::LOCK_NB : 0)
|
|
176
|
+
unless lock.flock(mode)
|
|
177
|
+
raise AttemptErrors::MaintenanceBusy, "maintenance exclusion is busy"
|
|
178
|
+
end
|
|
179
|
+
maintenance_deadline!(deadline) unless deadline.nil?
|
|
88
180
|
yield
|
|
89
181
|
ensure
|
|
90
182
|
lock.flock(File::LOCK_UN)
|
|
@@ -126,8 +218,9 @@ module Ace
|
|
|
126
218
|
ordered = Array(keys).compact.reject { |key| key.to_s.empty? }.uniq
|
|
127
219
|
raise ArgumentError, "exclusion keys are required" if ordered.empty?
|
|
128
220
|
|
|
129
|
-
locks =
|
|
221
|
+
locks = []
|
|
130
222
|
begin
|
|
223
|
+
ordered.each { |key| locks << open_lock(key) }
|
|
131
224
|
locks.each { |lock| lock.flock(File::LOCK_SH) }
|
|
132
225
|
ordered.each do |key|
|
|
133
226
|
if removed?(key)
|
|
@@ -157,25 +250,67 @@ module Ace
|
|
|
157
250
|
end
|
|
158
251
|
|
|
159
252
|
def removed?(key)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
253
|
+
path = state_path(key)
|
|
254
|
+
opened = false
|
|
255
|
+
File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
|
|
256
|
+
opened = true
|
|
257
|
+
before = file.stat
|
|
258
|
+
unless before.file? && before.nlink == 1 && before.size.between?(1, 8192) && (before.mode & 0022).zero?
|
|
259
|
+
raise AttemptErrors::Conflict, "Lifecycle marker is unsafe"
|
|
260
|
+
end
|
|
261
|
+
content = file.read(8193).force_encoding(Encoding::UTF_8)
|
|
262
|
+
unless content.valid_encoding? && content.bytesize == before.size && same_file?(before, file.stat) && same_file?(before, File.lstat(path))
|
|
263
|
+
raise AttemptErrors::Conflict, "Lifecycle marker changed"
|
|
264
|
+
end
|
|
265
|
+
state = JSON.parse(content, create_additions: false, max_nesting: 4, allow_duplicate_key: false)
|
|
266
|
+
unless state.is_a?(Hash) && state.keys.sort == %w[key removed removed_at] &&
|
|
267
|
+
state["key"] == key && state["removed"] == true && state["removed_at"].is_a?(String)
|
|
268
|
+
raise AttemptErrors::Conflict, "Lifecycle marker is malformed"
|
|
269
|
+
end
|
|
270
|
+
Time.iso8601(state.fetch("removed_at"))
|
|
271
|
+
true
|
|
272
|
+
end
|
|
273
|
+
rescue Errno::ENOENT
|
|
274
|
+
raise AttemptErrors::Conflict, "Lifecycle marker disappeared during read" if opened
|
|
163
275
|
false
|
|
276
|
+
rescue JSON::ParserError, ArgumentError, TypeError, SystemCallError, IOError
|
|
277
|
+
raise AttemptErrors::Conflict, "Lifecycle marker is unavailable or malformed"
|
|
164
278
|
end
|
|
165
279
|
|
|
166
280
|
def clear_removed!(key)
|
|
281
|
+
return unless removed?(key)
|
|
167
282
|
File.delete(state_path(key))
|
|
168
|
-
|
|
169
|
-
nil
|
|
283
|
+
sync_directory(root)
|
|
170
284
|
end
|
|
171
285
|
|
|
172
286
|
private
|
|
173
287
|
|
|
174
288
|
attr_reader :root
|
|
175
289
|
|
|
176
|
-
def open_lock(key)
|
|
290
|
+
def open_lock(key, nonblock: false)
|
|
291
|
+
retained = false
|
|
177
292
|
FileUtils.mkdir_p(root)
|
|
178
|
-
|
|
293
|
+
flags = File::RDWR | File::CREAT | File::NONBLOCK | File::NOFOLLOW
|
|
294
|
+
file = File.open(lock_path(key), flags, 0600)
|
|
295
|
+
unless file.stat.file? && file.stat.nlink == 1 && (file.stat.mode & 0022).zero? &&
|
|
296
|
+
same_file?(file.stat, File.lstat(lock_path(key)))
|
|
297
|
+
raise AttemptErrors::MaintenanceBusy, "maintenance exclusion is not a regular file"
|
|
298
|
+
end
|
|
299
|
+
retained = true
|
|
300
|
+
file
|
|
301
|
+
rescue SystemCallError
|
|
302
|
+
raise AttemptErrors::Conflict, "Lifecycle lock is unavailable"
|
|
303
|
+
ensure
|
|
304
|
+
file&.close unless retained
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def maintenance_deadline!(deadline)
|
|
308
|
+
unless (deadline.is_a?(Integer) || deadline.is_a?(Float)) && deadline.finite?
|
|
309
|
+
raise ArgumentError, "maintenance deadline must be finite"
|
|
310
|
+
end
|
|
311
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
312
|
+
raise AttemptErrors::MaintenanceBusy, "maintenance admission deadline expired"
|
|
313
|
+
end
|
|
179
314
|
end
|
|
180
315
|
|
|
181
316
|
def lock_path(key)
|
|
@@ -198,15 +333,32 @@ module Ace
|
|
|
198
333
|
end
|
|
199
334
|
end
|
|
200
335
|
|
|
336
|
+
def same_file?(left, right)
|
|
337
|
+
[left.dev, left.ino, left.size, left.mtime, left.ctime] == [right.dev, right.ino, right.size, right.mtime, right.ctime]
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def sync_directory(path)
|
|
341
|
+
File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) { |directory| directory.fsync }
|
|
342
|
+
end
|
|
343
|
+
|
|
201
344
|
def atomic_write(path, content)
|
|
202
345
|
dir = File.dirname(path)
|
|
203
|
-
FileUtils.mkdir_p(dir)
|
|
204
|
-
temp = File.join(dir, ".tmp-#{
|
|
205
|
-
File.
|
|
346
|
+
FileUtils.mkdir_p(dir, mode: 0700)
|
|
347
|
+
temp = File.join(dir, ".tmp-#{SecureRandom.hex(16)}")
|
|
348
|
+
File.open(temp, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW | File::NONBLOCK, 0600) do |file|
|
|
349
|
+
file.write(content)
|
|
350
|
+
file.flush
|
|
351
|
+
file.fsync
|
|
352
|
+
end
|
|
353
|
+
if File.exist?(path) || File.symlink?(path)
|
|
354
|
+
removed?(JSON.parse(content).fetch("key"))
|
|
355
|
+
end
|
|
206
356
|
File.rename(temp, path)
|
|
357
|
+
sync_directory(dir)
|
|
207
358
|
ensure
|
|
208
359
|
File.delete(temp) if temp && File.exist?(temp)
|
|
209
360
|
end
|
|
361
|
+
|
|
210
362
|
end
|
|
211
363
|
end
|
|
212
364
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/herdr/molecules/guarded_native_origin"
|
|
4
|
+
require_relative "../models/evidence_event"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module Molecules
|
|
9
|
+
# Exact accepted launch record; this reader grants no native effect.
|
|
10
|
+
module OriginalLaunchBinding
|
|
11
|
+
def self.verify!(events:, state:, params:)
|
|
12
|
+
unless state.is_a?(Hash) && state["guarded_origin"].is_a?(Hash) && state["process_binding"].is_a?(Hash)
|
|
13
|
+
raise AttemptErrors::EvidenceUnavailable, "Original guarded record is unavailable"
|
|
14
|
+
end
|
|
15
|
+
unless Models::EvidenceEvent.chain_valid?(events)
|
|
16
|
+
raise AttemptErrors::EvidenceUnavailable, "Original guarded record chain is corrupt"
|
|
17
|
+
end
|
|
18
|
+
records = events.select { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "record_launch" }
|
|
19
|
+
raise AttemptErrors::EvidenceUnavailable, "Original guarded record is ambiguous or absent" unless records.size == 1
|
|
20
|
+
record = records.first
|
|
21
|
+
data = record.fetch("payload").fetch("data")
|
|
22
|
+
unless data.is_a?(Hash) && record["attempt_id"] == params.fetch("attempt_id") &&
|
|
23
|
+
%w[mapping_id assignment_id attempt_id].all? { |key| data[key] == params.fetch(key) } &&
|
|
24
|
+
data["process_binding"] == state.fetch("process_binding") && data["guarded_origin"] == state.fetch("guarded_origin")
|
|
25
|
+
raise AttemptErrors::EvidenceUnavailable, "Original guarded record differs from accepted launch"
|
|
26
|
+
end
|
|
27
|
+
binding = data.fetch("process_binding")
|
|
28
|
+
origin = Ace::Herdr::Molecules::GuardedNativeOrigin.verify!(data.fetch("guarded_origin"),
|
|
29
|
+
terminal_id: binding.fetch("terminal_id"), child: binding.fetch("process_identity"))
|
|
30
|
+
{"binding_digest" => record.fetch("digest").dup.freeze, "origin" => origin}.freeze
|
|
31
|
+
rescue KeyError, TypeError, Ace::Runtime::RuntimeUnavailableError
|
|
32
|
+
raise AttemptErrors::EvidenceUnavailable, "Original guarded record is malformed"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "protected_workspace_exclusion"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Assign
|
|
7
|
+
module Molecules
|
|
8
|
+
class LifecycleExclusion
|
|
9
|
+
# Authority-side canonical guards. This owner never selects a Git/cache
|
|
10
|
+
# root, resets a fence, or grants the lifetime workspace writer lease.
|
|
11
|
+
class ControlExclusion
|
|
12
|
+
class Protection < Ace::Runtime::Molecules::ProtectedArtifactSet::Protection
|
|
13
|
+
def initialize(authority_uid:, acl: Authority::PosixAcl.new, **options)
|
|
14
|
+
super(**options)
|
|
15
|
+
@authority_uid, @acl = authority_uid, acl
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def verify!(path, handle, directory:)
|
|
19
|
+
super
|
|
20
|
+
unless (handle.stat.mode & 0o7000).zero? && @acl.entries(path).nil? &&
|
|
21
|
+
(!directory || @acl.entries(path, attribute: "system.posix_acl_default").nil?)
|
|
22
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle control protection differs"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
def trusted_owner?(stat) = [0, @authority_uid].include?(stat.uid)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(authority:, project_id:, descriptor_sha256:, root_identity: nil, files: File, protection: nil)
|
|
31
|
+
unless authority.is_a?(Hash) && project_id.is_a?(String) && token?(project_id) &&
|
|
32
|
+
descriptor_sha256.is_a?(String) && descriptor_sha256.match?(/\A[0-9a-f]{64}\z/) &&
|
|
33
|
+
%w[uid gid].all? { |key| authority[key].is_a?(Integer) && authority[key].positive? } &&
|
|
34
|
+
authority["state_root"].is_a?(String) && authority["state_root"].valid_encoding? &&
|
|
35
|
+
authority["state_root"].bytesize.between?(1, 4096) && !authority["state_root"].include?("\0") &&
|
|
36
|
+
authority["state_root"].start_with?("/") && File.expand_path(authority["state_root"]) == authority["state_root"]
|
|
37
|
+
unavailable!("Lifecycle control selection is malformed")
|
|
38
|
+
end
|
|
39
|
+
@root = File.join(authority.fetch("state_root"), "lifecycle-exclusion", project_id, "control")
|
|
40
|
+
unavailable!("Lifecycle control path is oversized") if @root.bytesize > 4096
|
|
41
|
+
@uid, @gid, @descriptor = authority.values_at("uid", "gid") + [descriptor_sha256.dup.freeze]
|
|
42
|
+
if root_identity
|
|
43
|
+
unless root_identity.is_a?(Hash) && root_identity.keys.sort == %w[device gid inode uid] &&
|
|
44
|
+
root_identity.values.all? { |value| value.is_a?(Integer) && value >= 0 } &&
|
|
45
|
+
root_identity.values_at("uid", "gid") == [@uid, @gid]
|
|
46
|
+
unavailable!("Lifecycle control original identity differs")
|
|
47
|
+
end
|
|
48
|
+
@identity = root_identity.dup.freeze
|
|
49
|
+
end
|
|
50
|
+
@files = files
|
|
51
|
+
@protection = protection || Protection.new(authority_uid: @uid)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def task_key(id) = key!("task", id)
|
|
55
|
+
def assignment_key(id) = key!("assignment", id)
|
|
56
|
+
|
|
57
|
+
def selection!
|
|
58
|
+
with_root do |handles, identities|
|
|
59
|
+
stat = handles.fetch(@root).stat
|
|
60
|
+
@identity ||= {"device" => stat.dev, "inode" => stat.ino, "uid" => stat.uid, "gid" => stat.gid}.freeze
|
|
61
|
+
{"descriptor_sha256" => @descriptor, "root_identity" => @identity}.freeze
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Invoked only after the existing complete registration admission.
|
|
66
|
+
# A partial creation is unavailable, not a missing-fence reset grant.
|
|
67
|
+
def provision_keys!(keys:)
|
|
68
|
+
ordered = keys!(keys)
|
|
69
|
+
with_root do |handles, identities|
|
|
70
|
+
root = handles.fetch(@root)
|
|
71
|
+
ordered.each do |key|
|
|
72
|
+
lock_path, marker_path = paths(key)
|
|
73
|
+
lock, created = create_or_open!(lock_path, "", root)
|
|
74
|
+
begin
|
|
75
|
+
validate_file!(lock_path, lock, empty: true)
|
|
76
|
+
marker = if created
|
|
77
|
+
create_or_open!(marker_path, Atoms::EvidenceDigest.canonical_json(
|
|
78
|
+
{"key" => key, "removed" => false, "removed_at" => nil}), root).first
|
|
79
|
+
else
|
|
80
|
+
@files.open(marker_path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
|
|
81
|
+
end
|
|
82
|
+
begin
|
|
83
|
+
live_marker!(key, marker_path, marker)
|
|
84
|
+
ensure
|
|
85
|
+
marker&.close
|
|
86
|
+
end
|
|
87
|
+
ensure
|
|
88
|
+
lock&.close
|
|
89
|
+
end
|
|
90
|
+
unchanged!(handles, identities)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
self
|
|
94
|
+
rescue SystemCallError, IOError, Ace::Runtime::RuntimeUnavailableError
|
|
95
|
+
unavailable!("Lifecycle control provisioning is unavailable")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def with_shared_multi(keys, &block) = with_locks(keys, File::LOCK_SH, &block)
|
|
99
|
+
def with_shared(key, &block) = with_shared_multi([key], &block)
|
|
100
|
+
def with_exclusive(key, deadline: nil, &block) = with_locks([key], File::LOCK_EX, deadline: deadline, &block)
|
|
101
|
+
|
|
102
|
+
# The canonical writer calls this again immediately before CAS. It
|
|
103
|
+
# authenticates the same held keys, not freshly reopened substitutes.
|
|
104
|
+
def verify_unchanged!
|
|
105
|
+
checks = Thread.current[:ace_assign_control_checks]&.fetch(object_id, nil)
|
|
106
|
+
unavailable!("Lifecycle control is not held") unless checks && !checks.empty?
|
|
107
|
+
checks.each(&:call)
|
|
108
|
+
true
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def with_locks(keys, mode, deadline: nil)
|
|
114
|
+
callback_error = nil
|
|
115
|
+
ordered = keys!(keys)
|
|
116
|
+
deadline!(deadline) if deadline
|
|
117
|
+
with_root do |handles, identities|
|
|
118
|
+
locks, markers, marker_facts, lock_facts = [], [], [], []
|
|
119
|
+
begin
|
|
120
|
+
ordered.each do |key|
|
|
121
|
+
path, marker_path = paths(key)
|
|
122
|
+
lock = @files.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
|
|
123
|
+
locks << lock
|
|
124
|
+
validate_file!(path, lock, empty: true)
|
|
125
|
+
lock_facts << file_metadata(lock.stat)
|
|
126
|
+
unless lock.flock(mode | File::LOCK_NB)
|
|
127
|
+
raise AttemptErrors::MaintenanceBusy, "Lifecycle control is under maintenance"
|
|
128
|
+
end
|
|
129
|
+
marker = @files.open(marker_path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
|
|
130
|
+
markers << marker
|
|
131
|
+
live_marker!(key, marker_path, marker)
|
|
132
|
+
marker_facts << file_metadata(marker.stat)
|
|
133
|
+
end
|
|
134
|
+
deadline!(deadline) if deadline
|
|
135
|
+
unchanged!(handles, identities)
|
|
136
|
+
check = proc do
|
|
137
|
+
ordered.each_with_index do |key, index|
|
|
138
|
+
path, marker_path = paths(key)
|
|
139
|
+
lock, marker = locks.fetch(index), markers.fetch(index)
|
|
140
|
+
validate_file!(path, lock, empty: true)
|
|
141
|
+
unless file_metadata(lock.stat) == lock_facts.fetch(index) &&
|
|
142
|
+
file_metadata(marker.stat) == marker_facts.fetch(index)
|
|
143
|
+
unavailable!("Lifecycle control key changed")
|
|
144
|
+
end
|
|
145
|
+
marker.rewind
|
|
146
|
+
live_marker!(key, marker_path, marker)
|
|
147
|
+
end
|
|
148
|
+
unchanged!(handles, identities)
|
|
149
|
+
end
|
|
150
|
+
registry = Thread.current[:ace_assign_control_checks] ||= {}
|
|
151
|
+
checks = registry[object_id] ||= []
|
|
152
|
+
checks << check
|
|
153
|
+
begin
|
|
154
|
+
begin
|
|
155
|
+
result = yield
|
|
156
|
+
rescue Exception => error
|
|
157
|
+
callback_error = error
|
|
158
|
+
raise
|
|
159
|
+
end
|
|
160
|
+
verify_unchanged!
|
|
161
|
+
ensure
|
|
162
|
+
checks.pop
|
|
163
|
+
registry.delete(object_id) if checks.empty?
|
|
164
|
+
end
|
|
165
|
+
deadline!(deadline) if deadline
|
|
166
|
+
result
|
|
167
|
+
ensure
|
|
168
|
+
close_all!(locks + markers)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
rescue SystemCallError, IOError, Ace::Runtime::RuntimeUnavailableError
|
|
172
|
+
raise if callback_error
|
|
173
|
+
unavailable!("Lifecycle control admission is unavailable")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def with_root
|
|
177
|
+
callback_error = nil
|
|
178
|
+
handles, identities = {}, {}
|
|
179
|
+
begin
|
|
180
|
+
parts = @root.split("/").reject(&:empty?)
|
|
181
|
+
(["/"] + parts.each_index.map { |index| "/" + parts.take(index + 1).join("/") }).each do |path|
|
|
182
|
+
handle = @files.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
|
|
183
|
+
handles[path] = handle
|
|
184
|
+
unless handle.stat.directory? && file_identity(handle.stat) == file_identity(@files.lstat(path))
|
|
185
|
+
unavailable!("Lifecycle control directory changed")
|
|
186
|
+
end
|
|
187
|
+
@protection.verify!(path, handle, directory: true)
|
|
188
|
+
identities[path] = file_identity(handle.stat)
|
|
189
|
+
end
|
|
190
|
+
root = handles.fetch(@root).stat
|
|
191
|
+
unless [root.uid, root.gid, root.mode & 0o7777] == [@uid, @gid, 0o700] &&
|
|
192
|
+
(!@identity || [root.dev, root.ino, root.uid, root.gid] == @identity.values_at("device", "inode", "uid", "gid"))
|
|
193
|
+
unavailable!("Lifecycle control original root differs")
|
|
194
|
+
end
|
|
195
|
+
begin
|
|
196
|
+
result = yield handles, identities
|
|
197
|
+
rescue Exception => error
|
|
198
|
+
callback_error = error
|
|
199
|
+
raise
|
|
200
|
+
end
|
|
201
|
+
unchanged!(handles, identities)
|
|
202
|
+
result
|
|
203
|
+
ensure
|
|
204
|
+
close_all!(handles.values)
|
|
205
|
+
end
|
|
206
|
+
rescue SystemCallError, IOError, Ace::Runtime::RuntimeUnavailableError
|
|
207
|
+
raise if callback_error
|
|
208
|
+
unavailable!("Lifecycle control root is unavailable")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def unchanged!(handles, identities)
|
|
212
|
+
handles.each do |path, handle|
|
|
213
|
+
unless file_identity(handle.stat) == identities.fetch(path) &&
|
|
214
|
+
identities.fetch(path) == file_identity(@files.lstat(path))
|
|
215
|
+
unavailable!("Lifecycle control directory changed")
|
|
216
|
+
end
|
|
217
|
+
@protection.verify!(path, handle, directory: true)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def create_or_open!(path, bytes, root)
|
|
222
|
+
created = true
|
|
223
|
+
file = begin
|
|
224
|
+
@files.open(path, File::RDWR | File::CREAT | File::EXCL | File::NOFOLLOW | File::NONBLOCK, 0o600)
|
|
225
|
+
rescue Errno::EEXIST
|
|
226
|
+
created = false
|
|
227
|
+
@files.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
|
|
228
|
+
end
|
|
229
|
+
if created
|
|
230
|
+
file.write(bytes)
|
|
231
|
+
file.flush
|
|
232
|
+
file.fsync
|
|
233
|
+
root.fsync
|
|
234
|
+
end
|
|
235
|
+
file.rewind
|
|
236
|
+
[file, created]
|
|
237
|
+
rescue Exception
|
|
238
|
+
file&.close
|
|
239
|
+
raise
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def validate_file!(path, file, empty: false)
|
|
243
|
+
stat = file.stat
|
|
244
|
+
unless stat.file? && stat.nlink == 1 && [stat.uid, stat.gid, stat.mode & 0o7777] == [@uid, @gid, 0o600] &&
|
|
245
|
+
file_identity(stat) == file_identity(@files.lstat(path)) && (empty ? stat.size.zero? : stat.size.between?(1, 8192))
|
|
246
|
+
unavailable!("Lifecycle control file differs")
|
|
247
|
+
end
|
|
248
|
+
@protection.verify!(path, file, directory: false)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def live_marker!(key, path, file)
|
|
252
|
+
validate_file!(path, file)
|
|
253
|
+
before = file.stat
|
|
254
|
+
bytes = file.read(8193)
|
|
255
|
+
state = LifecycleExclusion.workspace_marker!(bytes: bytes, key: key)
|
|
256
|
+
unless bytes.bytesize == before.size && file_metadata(before) == file_metadata(file.stat) &&
|
|
257
|
+
file_metadata(before) == file_metadata(@files.lstat(path))
|
|
258
|
+
unavailable!("Lifecycle control marker changed")
|
|
259
|
+
end
|
|
260
|
+
raise AttemptErrors::Conflict, "Lifecycle control identity is fenced" if state.fetch("removed")
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def keys!(keys)
|
|
264
|
+
values = Array(keys).uniq
|
|
265
|
+
unless values.any? && values.all? { |key| key.is_a?(String) && key.match?(/\A(?:task|assignment):[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/) }
|
|
266
|
+
unavailable!("Lifecycle control keys differ")
|
|
267
|
+
end
|
|
268
|
+
values.sort_by { |key| [key.start_with?("task:") ? 0 : 1, key] }
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def key!(kind, id)
|
|
272
|
+
unavailable!("Lifecycle control key differs") unless id.is_a?(String) && token?(id)
|
|
273
|
+
"#{kind}:#{id}"
|
|
274
|
+
end
|
|
275
|
+
def token?(value) = value.match?(/\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/)
|
|
276
|
+
def paths(key)
|
|
277
|
+
stem = File.join(@root, Digest::SHA256.hexdigest(key))
|
|
278
|
+
[stem + ".lock", stem + ".state.json"]
|
|
279
|
+
end
|
|
280
|
+
def file_identity(stat) = [stat.dev, stat.ino, stat.uid, stat.gid, stat.mode]
|
|
281
|
+
def file_metadata(stat) = file_identity(stat) + [stat.size, stat.mtime, stat.ctime]
|
|
282
|
+
def unavailable!(message) = raise(AttemptErrors::EvidenceUnavailable, message)
|
|
283
|
+
def deadline!(deadline)
|
|
284
|
+
unless deadline.is_a?(Numeric) && deadline.finite? && deadline > Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
285
|
+
raise AttemptErrors::MaintenanceBusy, "Lifecycle control admission deadline elapsed"
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
def close_all!(files)
|
|
289
|
+
error = nil
|
|
290
|
+
files.reverse_each do |file|
|
|
291
|
+
begin
|
|
292
|
+
file.close unless file.closed?
|
|
293
|
+
rescue SystemCallError, IOError => caught
|
|
294
|
+
error ||= caught
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
raise error if error
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|