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.
Files changed (129) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
  3. data/CHANGELOG.md +25 -0
  4. data/docs/protected-authority.md +23 -7
  5. data/docs/usage.md +117 -4
  6. data/exe/ace-scope-ready +55 -0
  7. data/handbook/workflow-instructions/assign/drive.wf.md +26 -1
  8. data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
  9. data/lib/ace/assign/atoms/delivery_parameters.rb +4 -39
  10. data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
  11. data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
  12. data/lib/ace/assign/authority/campaign_execution.rb +119 -0
  13. data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
  14. data/lib/ace/assign/authority/candidate_transfer.rb +169 -2
  15. data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
  16. data/lib/ace/assign/authority/client.rb +156 -6
  17. data/lib/ace/assign/authority/deployment.rb +201 -39
  18. data/lib/ace/assign/authority/deployment_history.rb +146 -0
  19. data/lib/ace/assign/authority/endcap.rb +131 -18
  20. data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
  21. data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
  22. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
  23. data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
  24. data/lib/ace/assign/authority/endcap_inboxes.rb +3 -280
  25. data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
  26. data/lib/ace/assign/authority/endcap_publication.rb +207 -0
  27. data/lib/ace/assign/authority/endcap_results.rb +36 -18
  28. data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
  29. data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
  30. data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
  31. data/lib/ace/assign/authority/endcap_services.rb +221 -18
  32. data/lib/ace/assign/authority/execution_scope_observation.rb +468 -41
  33. data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
  34. data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
  35. data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
  36. data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
  37. data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
  38. data/lib/ace/assign/authority/launch_driver.rb +298 -19
  39. data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
  40. data/lib/ace/assign/authority/launch_lifecycle.rb +275 -100
  41. data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
  42. data/lib/ace/assign/authority/launch_review.rb +91 -0
  43. data/lib/ace/assign/authority/launch_scope_abort.rb +0 -7
  44. data/lib/ace/assign/authority/launch_scope_admission.rb +136 -5
  45. data/lib/ace/assign/authority/launch_scope_close.rb +68 -37
  46. data/lib/ace/assign/authority/launch_scope_release.rb +696 -17
  47. data/lib/ace/assign/authority/launch_steering.rb +353 -0
  48. data/lib/ace/assign/authority/launch_stop.rb +254 -0
  49. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
  50. data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
  51. data/lib/ace/assign/authority/posix_acl.rb +5 -2
  52. data/lib/ace/assign/authority/prepared_input.rb +130 -0
  53. data/lib/ace/assign/authority/prepared_queue.rb +185 -0
  54. data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
  55. data/lib/ace/assign/authority/prepared_work.rb +304 -0
  56. data/lib/ace/assign/authority/prepared_worker.rb +69 -0
  57. data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
  58. data/lib/ace/assign/authority/readiness_hook.rb +120 -0
  59. data/lib/ace/assign/authority/router.rb +24 -2
  60. data/lib/ace/assign/authority/server.rb +103 -33
  61. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
  62. data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
  63. data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
  64. data/lib/ace/assign/authority/service_evidence.rb +243 -12
  65. data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
  66. data/lib/ace/assign/authority/task_context_entry.rb +32 -0
  67. data/lib/ace/assign/authority/transfer_codec.rb +44 -12
  68. data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
  69. data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
  70. data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
  71. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
  72. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
  73. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
  74. data/lib/ace/assign/cli/commands/authority/launch.rb +58 -6
  75. data/lib/ace/assign/cli/commands/authority/serve.rb +2 -1
  76. data/lib/ace/assign/cli/commands/authority/status.rb +1 -1
  77. data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
  78. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
  79. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
  80. data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
  81. data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
  82. data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
  83. data/lib/ace/assign/cli/commands/create.rb +2 -0
  84. data/lib/ace/assign/cli/commands/delivery.rb +54 -4
  85. data/lib/ace/assign/cli/commands/fail.rb +6 -0
  86. data/lib/ace/assign/cli/commands/finish.rb +23 -2
  87. data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
  88. data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
  89. data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
  90. data/lib/ace/assign/cli/commands/resume.rb +12 -0
  91. data/lib/ace/assign/cli/commands/select.rb +2 -0
  92. data/lib/ace/assign/cli/commands/start.rb +2 -0
  93. data/lib/ace/assign/cli/commands/status.rb +4 -2
  94. data/lib/ace/assign/cli/commands/step.rb +3 -1
  95. data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
  96. data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
  97. data/lib/ace/assign/cli.rb +36 -5
  98. data/lib/ace/assign/errors.rb +139 -0
  99. data/lib/ace/assign/models/assignment.rb +26 -4
  100. data/lib/ace/assign/models/evidence_event.rb +3 -3
  101. data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
  102. data/lib/ace/assign/molecules/attempt_reconciler.rb +20 -0
  103. data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
  104. data/lib/ace/assign/molecules/canonical_evidence.rb +5 -2
  105. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
  106. data/lib/ace/assign/molecules/evidence_journal.rb +720 -44
  107. data/lib/ace/assign/molecules/execution_scope_lineage.rb +27 -9
  108. data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
  109. data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
  110. data/lib/ace/assign/molecules/journal_mutation.rb +82 -13
  111. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
  112. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +178 -26
  113. data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
  114. data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
  115. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
  116. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
  117. data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
  118. data/lib/ace/assign/molecules/receipt_verifier.rb +21 -15
  119. data/lib/ace/assign/molecules/step_writer.rb +27 -5
  120. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
  121. data/lib/ace/assign/organisms/assignment_executor.rb +4 -4
  122. data/lib/ace/assign/organisms/attempt_coordinator.rb +66 -76
  123. data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
  124. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
  125. data/lib/ace/assign/organisms/task_assignment_creator.rb +4 -2
  126. data/lib/ace/assign/version.rb +1 -1
  127. data/lib/ace/assign.rb +1 -112
  128. metadata +90 -11
  129. 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
- # Sandboxed environments (CACHE_BASE) scope it under the cache base,
37
- # which itself is never a deletion target; otherwise it lives under
38
- # the shared Git common dir, which survives worktree removal.
39
- def self.default_root(_repo_root = nil)
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
- base = status.success? && !common_dir.to_s.strip.empty? ? common_dir.strip : File.join(repo_root, ".git")
50
- File.join(base, "ace", "lifecycle-exclusion")
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
- lock = open_lock(key)
86
- lock.flock(File::LOCK_EX)
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 = ordered.map { |key| open_lock(key) }
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
- state = JSON.parse(File.read(state_path(key)))
161
- state["removed"] == true
162
- rescue Errno::ENOENT, JSON::ParserError, TypeError
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
- rescue Errno::ENOENT
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
- File.open(lock_path(key), File::RDWR | File::CREAT)
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-#{Process.pid}-#{rand(1_000_000)}")
205
- File.write(temp, content)
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