ace-assign 0.63.1 → 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.
Files changed (147) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
  3. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
  4. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  5. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
  6. data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
  7. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
  8. data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
  9. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
  10. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
  11. data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
  12. data/CHANGELOG.md +62 -0
  13. data/docs/demo/canonical-skill-source.tape.yml +4 -4
  14. data/docs/protected-authority.md +282 -0
  15. data/docs/usage.md +205 -3
  16. data/exe/ace-scope-ready +55 -0
  17. data/handbook/workflow-instructions/assign/drive.wf.md +28 -3
  18. data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
  19. data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
  20. data/lib/ace/assign/atoms/delivery_parameters.rb +19 -0
  21. data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
  22. data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
  23. data/lib/ace/assign/authority/campaign_execution.rb +119 -0
  24. data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
  25. data/lib/ace/assign/authority/candidate_transfer.rb +327 -0
  26. data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
  27. data/lib/ace/assign/authority/client.rb +257 -0
  28. data/lib/ace/assign/authority/deployment.rb +627 -0
  29. data/lib/ace/assign/authority/deployment_history.rb +146 -0
  30. data/lib/ace/assign/authority/endcap.rb +455 -0
  31. data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
  32. data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
  33. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
  34. data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
  35. data/lib/ace/assign/authority/endcap_inboxes.rb +37 -0
  36. data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
  37. data/lib/ace/assign/authority/endcap_publication.rb +207 -0
  38. data/lib/ace/assign/authority/endcap_results.rb +398 -0
  39. data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
  40. data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
  41. data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
  42. data/lib/ace/assign/authority/endcap_services.rb +598 -0
  43. data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
  44. data/lib/ace/assign/authority/execution_scope_observation.rb +903 -0
  45. data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
  46. data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
  47. data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
  48. data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
  49. data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
  50. data/lib/ace/assign/authority/launch_driver.rb +393 -0
  51. data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
  52. data/lib/ace/assign/authority/launch_lifecycle.rb +997 -0
  53. data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
  54. data/lib/ace/assign/authority/launch_review.rb +91 -0
  55. data/lib/ace/assign/authority/launch_scope_abort.rb +81 -0
  56. data/lib/ace/assign/authority/launch_scope_admission.rb +215 -0
  57. data/lib/ace/assign/authority/launch_scope_close.rb +146 -0
  58. data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
  59. data/lib/ace/assign/authority/launch_scope_release.rb +859 -0
  60. data/lib/ace/assign/authority/launch_steering.rb +353 -0
  61. data/lib/ace/assign/authority/launch_stop.rb +254 -0
  62. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
  63. data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
  64. data/lib/ace/assign/authority/posix_acl.rb +59 -0
  65. data/lib/ace/assign/authority/prepared_input.rb +130 -0
  66. data/lib/ace/assign/authority/prepared_queue.rb +185 -0
  67. data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
  68. data/lib/ace/assign/authority/prepared_work.rb +304 -0
  69. data/lib/ace/assign/authority/prepared_worker.rb +69 -0
  70. data/lib/ace/assign/authority/private_directory.rb +33 -0
  71. data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
  72. data/lib/ace/assign/authority/readiness_hook.rb +120 -0
  73. data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
  74. data/lib/ace/assign/authority/router.rb +76 -0
  75. data/lib/ace/assign/authority/server.rb +300 -0
  76. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
  77. data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
  78. data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
  79. data/lib/ace/assign/authority/service_evidence.rb +286 -0
  80. data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
  81. data/lib/ace/assign/authority/task_context_entry.rb +32 -0
  82. data/lib/ace/assign/authority/transfer_codec.rb +187 -0
  83. data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
  84. data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
  85. data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
  86. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
  87. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
  88. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
  89. data/lib/ace/assign/cli/commands/authority/launch.rb +97 -0
  90. data/lib/ace/assign/cli/commands/authority/serve.rb +31 -0
  91. data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
  92. data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
  93. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
  94. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
  95. data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
  96. data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
  97. data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
  98. data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
  99. data/lib/ace/assign/cli/commands/create.rb +11 -1
  100. data/lib/ace/assign/cli/commands/delivery.rb +107 -0
  101. data/lib/ace/assign/cli/commands/fail.rb +6 -0
  102. data/lib/ace/assign/cli/commands/finish.rb +23 -2
  103. data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
  104. data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
  105. data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
  106. data/lib/ace/assign/cli/commands/resume.rb +38 -0
  107. data/lib/ace/assign/cli/commands/select.rb +2 -0
  108. data/lib/ace/assign/cli/commands/start.rb +2 -0
  109. data/lib/ace/assign/cli/commands/status.rb +4 -2
  110. data/lib/ace/assign/cli/commands/step.rb +3 -1
  111. data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
  112. data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
  113. data/lib/ace/assign/cli.rb +56 -4
  114. data/lib/ace/assign/errors.rb +139 -0
  115. data/lib/ace/assign/models/assignment.rb +26 -4
  116. data/lib/ace/assign/models/evidence_event.rb +4 -2
  117. data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
  118. data/lib/ace/assign/molecules/attempt_reconciler.rb +51 -12
  119. data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
  120. data/lib/ace/assign/molecules/canonical_evidence.rb +153 -0
  121. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
  122. data/lib/ace/assign/molecules/evidence_journal.rb +922 -91
  123. data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
  124. data/lib/ace/assign/molecules/execution_scope_lineage.rb +345 -0
  125. data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
  126. data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
  127. data/lib/ace/assign/molecules/journal_mutation.rb +271 -0
  128. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
  129. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +182 -26
  130. data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
  131. data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
  132. data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
  133. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
  134. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
  135. data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
  136. data/lib/ace/assign/molecules/receipt_verifier.rb +68 -32
  137. data/lib/ace/assign/molecules/step_writer.rb +27 -5
  138. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
  139. data/lib/ace/assign/organisms/assignment_executor.rb +10 -4
  140. data/lib/ace/assign/organisms/attempt_coordinator.rb +302 -13
  141. data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
  142. data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
  143. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
  144. data/lib/ace/assign/organisms/task_assignment_creator.rb +11 -4
  145. data/lib/ace/assign/version.rb +1 -1
  146. data/lib/ace/assign.rb +4 -102
  147. metadata +186 -8
@@ -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
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lifecycle_exclusion"
4
+ require_relative "../authority/posix_acl"
5
+ require "ace/runtime/molecules/protected_artifact_set"
6
+
7
+ module Ace
8
+ module Assign
9
+ module Molecules
10
+ class LifecycleExclusion
11
+ # A retained reader lease only. It cannot provision, reset or publish.
12
+ # Source-owned original PreparedInput selects its closed projection.
13
+ class WorkspaceReader
14
+ attr_reader :projection
15
+ class Protection < Ace::Runtime::Molecules::ProtectedArtifactSet::Protection
16
+ def initialize(projection:, acl: Authority::PosixAcl.new, **options)
17
+ super(**options)
18
+ @projection, @acl = projection, acl
19
+ end
20
+
21
+ def verify!(path, handle, directory:)
22
+ super
23
+ unless (handle.stat.mode & 0o7000).zero? && @acl.entries(path).nil? &&
24
+ (!directory || @acl.entries(path, attribute: "system.posix_acl_default").nil?)
25
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle reader protection differs"
26
+ end
27
+ root = @projection.fetch("root_resource")
28
+ if path == root_path(root)
29
+ actual = handle.stat
30
+ unless directory && [actual.dev, actual.ino, actual.uid, actual.gid, actual.mode & 0o7777] ==
31
+ root.values_at("device", "inode", "uid", "gid") + [0o755]
32
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle reader root identity differs"
33
+ end
34
+ verify_root_mount!(path, handle, root)
35
+ elsif !directory
36
+ unless handle.stat.nlink == 1 && (handle.stat.mode & 0o7777) == 0o644
37
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle reader file protection differs"
38
+ end
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def root_path(root) = root.fetch("view_path")
45
+
46
+ def verify_root_mount!(path, handle, root)
47
+ mount = @mounts.mount_identity(handle)
48
+ table = Ace::Runtime::Molecules::LinuxMountInfo.new(
49
+ @mounts.read("/proc/self/mountinfo", limit: Ace::Runtime::Molecules::LinuxMountInfo::LIMIT))
50
+ selected = table.by_id(mount.fetch("mount_id"))
51
+ unless mount == selected.slice(*mount.keys) && selected.fetch("mountpoint") == path &&
52
+ selected.fetch("filesystem_type") == root.fetch("filesystem_type") &&
53
+ selected.fetch("options").include?("ro") && !selected.fetch("options").include?("rw")
54
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle reader view is not the selected readonly mount"
55
+ end
56
+ end
57
+
58
+ def trusted_owner?(stat)
59
+ [0, @projection.fetch("authority_uid")].include?(stat.uid)
60
+ end
61
+ end
62
+
63
+ def initialize(projection:, protection: nil, files: File)
64
+ @projection = JSON.parse(JSON.generate(projection))
65
+ validate_projection!
66
+ pending = [@projection]
67
+ until pending.empty?
68
+ value = pending.pop
69
+ pending.concat(value.values) if value.is_a?(Hash)
70
+ pending.concat(value) if value.is_a?(Array)
71
+ value.freeze
72
+ end
73
+ @root = @projection.fetch("root_resource").fetch("view_path")
74
+ @protection = protection || Protection.new(projection: @projection)
75
+ @files = files
76
+ @handles, @lock, @active = {}, nil, false
77
+ end
78
+
79
+ def acquire!
80
+ raise AttemptErrors::Conflict, "Lifecycle reader already acquired" if @active || !@handles.empty?
81
+ parts = @root.split("/").reject(&:empty?)
82
+ (["/"] + parts.each_index.map { |index| "/" + parts.take(index + 1).join("/") }).each do |path|
83
+ pin!(path, directory: true)
84
+ end
85
+ @lock = pin!(path_for("lock"), directory: false)
86
+ stat = @lock.stat
87
+ unless stat.size.zero? && [stat.uid, stat.gid] == @projection.values_at("authority_uid", "authority_gid")
88
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle reader immutable lock differs"
89
+ end
90
+ unless @lock.flock(lock_mode | File::LOCK_NB)
91
+ raise AttemptErrors::MaintenanceBusy, "Lifecycle workspace is under maintenance"
92
+ end
93
+ @active = true
94
+ marker = pin!(path_for("state.json"), directory: false)
95
+ state = read_marker!(marker)
96
+ owner = [marker.stat.uid, marker.stat.gid]
97
+ unless owner == @projection.values_at("authority_uid", "authority_gid") || state.fetch("removed") && owner == [0, 0]
98
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle marker selected owner differs"
99
+ end
100
+ require_live_marker!(state)
101
+ verify_unchanged!
102
+ self
103
+ rescue Ace::Runtime::RuntimeUnavailableError, SystemCallError, IOError, JSON::ParserError, KeyError, TypeError, ArgumentError
104
+ close!
105
+ raise AttemptErrors::EvidenceUnavailable, "Original lifecycle reader admission is unavailable"
106
+ rescue Exception
107
+ close!
108
+ raise
109
+ end
110
+
111
+ def verify_unchanged!
112
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle reader is not held" unless @active
113
+ @handles.each do |path, entry|
114
+ unless identity(entry.fetch(:handle).stat) == entry.fetch(:identity) &&
115
+ identity(@files.lstat(path)) == entry.fetch(:identity)
116
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle reader retained object changed"
117
+ end
118
+ @protection.verify!(path, entry.fetch(:handle), directory: entry.fetch(:directory))
119
+ end
120
+ true
121
+ end
122
+
123
+ # The maintained creator calls this only after confirmed completion.
124
+ # Unknown child ownership retains the lease until its owner exits.
125
+ def close!
126
+ begin
127
+ @lock.flock(File::LOCK_UN) if @active && @lock && !@lock.closed?
128
+ ensure
129
+ close_error = nil
130
+ @handles.values.reverse_each do |entry|
131
+ begin
132
+ entry.fetch(:handle).close unless entry.fetch(:handle).closed?
133
+ rescue IOError, SystemCallError => error
134
+ close_error ||= error
135
+ end
136
+ end
137
+ @handles.clear
138
+ @active = false
139
+ raise close_error if close_error
140
+ end
141
+ end
142
+
143
+ private
144
+
145
+ def require_live_marker!(state)
146
+ raise AttemptErrors::Conflict, "Original lifecycle workspace was fenced for removal" if state.fetch("removed")
147
+ end
148
+
149
+ def lock_mode = File::LOCK_SH
150
+
151
+ def validate_projection!
152
+ fields = %w[authority_gid authority_uid key root_resource worker_cwd_resource]
153
+ resource_fields = %w[device filesystem_type gid host_path inode mount_id uid view_path]
154
+ unless @projection.is_a?(Hash)
155
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle original reader projection differs"
156
+ end
157
+ root, cwd = @projection.values_at("root_resource", "worker_cwd_resource")
158
+ unless @projection.keys.sort == fields && [root, cwd].all? { |value| value.is_a?(Hash) && value.keys.sort == resource_fields } &&
159
+ %w[authority_uid authority_gid].all? { |key| @projection[key].is_a?(Integer) && @projection[key].positive? }
160
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle original reader projection differs"
161
+ end
162
+ unless [root, cwd].all? { |value|
163
+ %w[host_path view_path].all? { |key| value[key].is_a?(String) && value[key].valid_encoding? &&
164
+ value[key].bytesize.between?(1, 4096) && !value[key].include?("\0") && value[key].start_with?("/") &&
165
+ File.expand_path(value[key]) == value[key] } &&
166
+ %w[device inode uid gid].all? { |key| value[key].is_a?(Integer) && value[key] >= 0 } &&
167
+ value["mount_id"].is_a?(Integer) && value["mount_id"].positive? &&
168
+ %w[ext4 xfs btrfs tmpfs].include?(value["filesystem_type"]) }
169
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle original reader resource differs"
170
+ end
171
+ mapping = root.fetch("view_path").delete_prefix("/run/ace/lifecycle-exclusion/")
172
+ digest = Atoms::EvidenceDigest.digest(cwd)
173
+ unless mapping.match?(/\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/) &&
174
+ @projection.fetch("key") == "workspace:#{mapping}:#{digest}" &&
175
+ root.fetch("host_path").end_with?("/workspaces/#{mapping}/#{digest}") &&
176
+ root.values_at("uid", "gid") == @projection.values_at("authority_uid", "authority_gid")
177
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle original reader association differs"
178
+ end
179
+ end
180
+
181
+ def pin!(path, directory:)
182
+ file = @files.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
183
+ @protection.verify!(path, file, directory: directory)
184
+ stat = file.stat
185
+ unless (directory ? stat.directory? : stat.file?) && identity(stat) == identity(@files.lstat(path))
186
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle reader object is substituted"
187
+ end
188
+ @handles[path] = {handle: file, identity: identity(stat), directory: directory}
189
+ file
190
+ ensure
191
+ file&.close unless @handles.key?(path)
192
+ end
193
+
194
+ def identity(stat)
195
+ core = [stat.dev, stat.ino, stat.uid, stat.gid, stat.mode]
196
+ stat.directory? ? core : core + [stat.nlink, stat.size, stat.mtime, stat.ctime]
197
+ end
198
+
199
+ def path_for(suffix)
200
+ File.join(@root, "#{Digest::SHA256.hexdigest(@projection.fetch('key'))}.#{suffix}")
201
+ end
202
+
203
+ def read_marker!(file)
204
+ size = file.stat.size
205
+ unless size.between?(1, 8192)
206
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle marker bytes differ"
207
+ end
208
+ bytes = file.read(8193).force_encoding(Encoding::UTF_8)
209
+ unless bytes.valid_encoding? && bytes.bytesize == size
210
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle marker is not bounded UTF8"
211
+ end
212
+ state = LifecycleExclusion.workspace_marker!(bytes: bytes, key: @projection.fetch("key"))
213
+ @marker_bytes = bytes.freeze
214
+ state
215
+ end
216
+ end
217
+ # Native activation owns this host lease for the original scope lifetime.
218
+ # An admission deadline must not become a provider lifetime ceiling.
219
+ class WorkspaceNativeReader < WorkspaceReader
220
+ def initialize(projection:, protection: nil, files: File)
221
+ super(projection: projection, protection: protection, files: files)
222
+ @root = @projection.fetch("root_resource").fetch("host_path")
223
+ @protection = protection || WorkspaceHostReader::Protection.new(projection: @projection)
224
+ end
225
+ end
226
+
227
+ class WorkspaceHostReader < WorkspaceReader
228
+ class Protection < WorkspaceReader::Protection
229
+ private
230
+ def root_path(root) = root.fetch("host_path")
231
+ def verify_root_mount!(_path, handle, root)
232
+ unless @mounts.mount_identity(handle).fetch("filesystem_type") == root.fetch("filesystem_type")
233
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle writer filesystem differs"
234
+ end
235
+ end
236
+ end
237
+
238
+ def initialize(projection:, protection: nil, files: File)
239
+ super(projection: projection, protection: protection, files: files)
240
+ @root = @projection.fetch("root_resource").fetch("host_path")
241
+ @protection = protection || Protection.new(projection: @projection)
242
+ end
243
+
244
+ def acquire!(deadline:)
245
+ @deadline = deadline
246
+ deadline!(deadline)
247
+ super()
248
+ deadline!(deadline)
249
+ self
250
+ rescue Exception
251
+ close!
252
+ raise
253
+ end
254
+
255
+ def verify_unchanged!
256
+ deadline!(@deadline) unless @deadline.nil?
257
+ super
258
+ end
259
+
260
+ private
261
+ def deadline!(value)
262
+ unless value.is_a?(Numeric) && value.finite? && value > Process.clock_gettime(Process::CLOCK_MONOTONIC)
263
+ raise AttemptErrors::MaintenanceBusy, "Lifecycle maintenance deadline elapsed"
264
+ end
265
+ end
266
+ end
267
+
268
+ # Inspection evidence only: never permits creation or publication.
269
+ class WorkspaceFenceReader < WorkspaceHostReader
270
+ def fence_evidence!
271
+ verify_unchanged!
272
+ bytes = @marker_bytes
273
+ reference = {"path" => path_for("state.json").freeze,
274
+ "sha256" => Digest::SHA256.hexdigest(bytes).freeze, "bytes" => bytes.bytesize}.freeze
275
+ {"reference" => reference, "bytes" => bytes}.freeze
276
+ end
277
+
278
+ private
279
+ def require_live_marker!(_state); end
280
+ end
281
+
282
+ class WorkspaceWriter < WorkspaceHostReader
283
+ # Refusal fence only; canonical physical proof is owned separately.
284
+ def publish_removed!(removed_at:)
285
+ verify_unchanged!
286
+ unless removed_at.is_a?(String) && Time.iso8601(removed_at).utc.iso8601 == removed_at
287
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle fence timestamp differs"
288
+ end
289
+ bytes = Atoms::EvidenceDigest.canonical_json({"key" => @projection.fetch("key"),
290
+ "removed" => true, "removed_at" => removed_at}).freeze
291
+ target = path_for("state.json")
292
+ temporary = File.join(@root, ".#{SecureRandom.hex(16)}.fence")
293
+ begin
294
+ @files.open(temporary, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW | File::NONBLOCK, 0o644) do |file|
295
+ file.chmod(0o644)
296
+ file.write(bytes)
297
+ file.flush
298
+ unless file.stat.file? && file.stat.nlink == 1 && file.stat.size == bytes.bytesize &&
299
+ [file.stat.uid, file.stat.gid, file.stat.mode & 0o7777] == [0, 0, 0o644]
300
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle temporary fence protection differs"
301
+ end
302
+ file.fsync
303
+ end
304
+ verify_unchanged!
305
+ @files.rename(temporary, target)
306
+ @handles.fetch(@root).fetch(:handle).fsync
307
+ previous = @handles.delete(target)
308
+ previous.fetch(:handle).close
309
+ marker = pin!(target, directory: false)
310
+ unless [marker.stat.uid, marker.stat.gid] == [0, 0] && read_marker!(marker).fetch("removed")
311
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle published fence owner differs"
312
+ end
313
+ verify_unchanged!
314
+ reference = {"path" => target.freeze, "sha256" => Digest::SHA256.hexdigest(bytes).freeze,
315
+ "bytes" => bytes.bytesize}.freeze
316
+ {"reference" => reference, "bytes" => bytes}.freeze
317
+ ensure
318
+ @files.unlink(temporary) if @files.exist?(temporary)
319
+ end
320
+ rescue SystemCallError, IOError, ArgumentError
321
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle removal fence publication is unavailable"
322
+ end
323
+
324
+ private
325
+ def lock_mode = File::LOCK_EX
326
+ end
327
+ end
328
+ end
329
+ end
330
+ end