ace-lab 0.9.0 → 0.13.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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +399 -102
  3. data/README.md +66 -100
  4. data/docs/usage.md +563 -592
  5. data/lib/ace/lab/atoms/install_schema.rb +444 -0
  6. data/lib/ace/lab/cli/commands/install.rb +161 -0
  7. data/lib/ace/lab/cli/commands/prepare.rb +120 -0
  8. data/lib/ace/lab/cli/commands/project_add.rb +101 -0
  9. data/lib/ace/lab/cli/commands/status.rb +109 -0
  10. data/lib/ace/lab/cli/commands/verify.rb +115 -0
  11. data/lib/ace/lab/cli.rb +21 -23
  12. data/lib/ace/lab/install_manifest_path.rb +23 -0
  13. data/lib/ace/lab/models/status_report.rb +38 -0
  14. data/lib/ace/lab/molecules/account_provisioner.rb +196 -0
  15. data/lib/ace/lab/molecules/config_state_classifier.rb +145 -0
  16. data/lib/ace/lab/molecules/directory_provisioner.rb +208 -0
  17. data/lib/ace/lab/molecules/gemset_provisioner.rb +222 -0
  18. data/lib/ace/lab/molecules/grant_provisioner.rb +250 -0
  19. data/lib/ace/lab/molecules/grant_resolver.rb +34 -23
  20. data/lib/ace/lab/molecules/hitl_routes.rb +263 -0
  21. data/lib/ace/lab/molecules/install_manifest_loader.rb +64 -0
  22. data/lib/ace/lab/molecules/key_provisioner.rb +736 -0
  23. data/lib/ace/lab/molecules/lab_state_scanner.rb +55 -0
  24. data/lib/ace/lab/molecules/overseer_provisioner.rb +188 -0
  25. data/lib/ace/lab/molecules/project_registry.rb +251 -0
  26. data/lib/ace/lab/molecules/repository_provisioner.rb +141 -0
  27. data/lib/ace/lab/molecules/role_verifier.rb +223 -0
  28. data/lib/ace/lab/organisms/install.rb +218 -0
  29. data/lib/ace/lab/organisms/prepare.rb +170 -0
  30. data/lib/ace/lab/organisms/project_add.rb +170 -0
  31. data/lib/ace/lab/organisms/status_service.rb +70 -0
  32. data/lib/ace/lab/organisms/verify.rb +836 -0
  33. data/lib/ace/lab/version.rb +1 -1
  34. data/lib/ace/lab.rb +34 -6
  35. metadata +35 -108
  36. data/lib/ace/lab/atoms/protected_publication_input.rb +0 -50
  37. data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +0 -114
  38. data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +0 -86
  39. data/lib/ace/lab/atoms/service_input.rb +0 -92
  40. data/lib/ace/lab/cli/commands/assignment.rb +0 -92
  41. data/lib/ace/lab/cli/commands/command_execute.rb +0 -28
  42. data/lib/ace/lab/cli/commands/run.rb +0 -65
  43. data/lib/ace/lab/cli/commands/service.rb +0 -240
  44. data/lib/ace/lab/models/service_receipt.rb +0 -18
  45. data/lib/ace/lab/molecules/command_owner_admission.rb +0 -183
  46. data/lib/ace/lab/molecules/command_policy.rb +0 -158
  47. data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
  48. data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
  49. data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
  50. data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
  51. data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
  52. data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -152
  53. data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
  54. data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
  55. data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
  56. data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
  57. data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
  58. data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
  59. data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
  60. data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
  61. data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
  62. data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
  63. data/lib/ace/lab/molecules/service_executor.rb +0 -99
  64. data/lib/ace/lab/molecules/service_policy.rb +0 -289
  65. data/lib/ace/lab/organisms/authority_composition.rb +0 -87
  66. data/lib/ace/lab/organisms/command_executor.rb +0 -220
  67. data/lib/ace/lab/organisms/command_run_client.rb +0 -131
  68. data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -120
  69. data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -153
  70. data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -679
  71. data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
  72. data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
  73. data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
  74. data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
  75. data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
  76. data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
  77. data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
  78. data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
  79. data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
  80. data/lib/ace/lab/organisms/service_request_service.rb +0 -249
@@ -0,0 +1,836 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "../molecules/install_manifest_loader"
5
+ require_relative "../molecules/key_provisioner"
6
+ require_relative "../molecules/role_verifier"
7
+ require_relative "../molecules/gemset_provisioner"
8
+ require_relative "../molecules/hitl_routes"
9
+ require_relative "../molecules/caller_authorizer"
10
+ require_relative "../atoms/topology_schema"
11
+ require "open3"
12
+ require "yaml"
13
+ require "date"
14
+
15
+ module Ace
16
+ module Lab
17
+ module Organisms
18
+ # Read-only end-of-install readiness verification (L8.5, task
19
+ # 8xg.t.sbz.7.4): checks that the installed lab is ready UNDER
20
+ # THE TARGET ACCOUNTS — not merely for the installer's own
21
+ # account. For every account, directory, key, role, project and
22
+ # grant declared in the trusted install manifest it recomputes
23
+ # the facts from the SAME artifacts install/resume derive truth
24
+ # from (the manifest, the filesystem's permission bits and
25
+ # ownership, the repositories, the per-project registry and the
26
+ # trusted authorization document): no journal, no second state
27
+ # owner, nothing written anywhere.
28
+ #
29
+ # Every check reports pass / fail / unknown with a concrete
30
+ # reason; a failure beside healthy data never hides the healthy
31
+ # data and never prevents the remaining checks from running. The
32
+ # overall verdict (`ready`) is false when ANY check failed — a
33
+ # missing file, wrong permission, unreadable role instructions,
34
+ # an unreachable origin, a principal without grants or a
35
+ # committable `_overseer` folder each block lab readiness.
36
+ # `unknown` (a network remote the read-only verifier does not
37
+ # dial, an unset PATH, a missing resolver) is reported and
38
+ # counted but does not block: it is not a failure, and pretending
39
+ # it were one would make `verify` unusable offline. An unknown is
40
+ # never a verified pass either — the check reports `unknown`, not
41
+ # `pass`, so unverified availability never counts as proven.
42
+ #
43
+ # Because verification reads only converged artifacts, a fresh
44
+ # install and a resumed one verify identically — there is no
45
+ # hidden gap after resume.
46
+ class Verify
47
+ PASS = "pass"
48
+ FAIL = "fail"
49
+ UNKNOWN = "unknown"
50
+
51
+ attr_reader :manifest_path, :platform
52
+
53
+ # @param manifest_path [String, nil] trusted manifest location
54
+ # @param runner [Proc, nil] argv runner seam (git / ssh-keygen)
55
+ # @param account_lookup [Proc, nil] passwd lookup seam
56
+ # @param group_source [Proc, nil] supplementary-group seam
57
+ # (RoleVerifier)
58
+ # @param trusted_read [Proc, nil] trusted-document read seam
59
+ # (defaults to GrantResolver.read_verified)
60
+ # @param authorization_path [String, nil] trusted grants
61
+ # location (defaults to the fixed deployment path)
62
+ # @param nav_runner [Proc, nil] (argv, cwd) runner seam for
63
+ # resolving protocol role instructions through ace-nav
64
+ # @param platform [String, nil] "darwin" / "linux"
65
+ def initialize(manifest_path: nil, runner: nil, account_lookup: nil,
66
+ group_source: nil, trusted_read: nil, authorization_path: nil, nav_runner: nil,
67
+ platform: nil)
68
+ @manifest_path = manifest_path || Ace::Lab.install_manifest_path
69
+ @runner = runner || method(:run_argv)
70
+ @account_lookup = account_lookup || method(:lookup_passwd)
71
+ @group_source = group_source
72
+ @trusted_read = trusted_read || ->(path) { Molecules::GrantResolver.read_verified(path) }
73
+ @authorization_path = authorization_path || Ace::Lab.authorization_path
74
+ @nav_runner = nav_runner || method(:run_nav)
75
+ @platform = platform
76
+ end
77
+
78
+ # @return [Hash] {ready:, machine:, manifest:,
79
+ # authorization_path:, checks: {accounts:, directories:,
80
+ # keys:, roles:, repositories:, grants:, registry:,
81
+ # overseer:, adapters:, tools:, gems:}, counts: {pass:, fail:,
82
+ # unknown:}}
83
+ # @raise [Ace::Lab::InvalidConfigurationError] missing,
84
+ # untrusted or schema-invalid manifest — same classification
85
+ # as install/prepare
86
+ def call
87
+ manifest = Molecules::InstallManifestLoader.load(path: manifest_path, platform: platform)
88
+ entries = manifest["accounts"].to_h { |a| [a["username"], @account_lookup.call(a["username"])] }
89
+ executors = target_executors(manifest, entries)
90
+
91
+ checks = {
92
+ "accounts" => check_accounts(manifest, entries),
93
+ "directories" => check_directories(manifest, entries),
94
+ "keys" => check_keys(manifest, entries),
95
+ "roles" => check_roles(manifest),
96
+ "repositories" => check_repositories(manifest, executors),
97
+ "grants" => check_grants(manifest),
98
+ "registry" => check_registry(manifest),
99
+ "overseer" => check_overseer(manifest),
100
+ "adapters" => check_adapters(manifest, executors),
101
+ "tools" => check_tools(manifest, executors),
102
+ "gems" => check_gems(manifest)
103
+ }
104
+ flat = checks.values.flatten(1)
105
+ {
106
+ "ready" => flat.none? { |check| check["status"] == FAIL },
107
+ "machine" => manifest["machine"],
108
+ "manifest" => manifest_path,
109
+ "authorization_path" => @authorization_path,
110
+ "checks" => checks,
111
+ "counts" => {
112
+ PASS => flat.count { |check| check["status"] == PASS },
113
+ FAIL => flat.count { |check| check["status"] == FAIL },
114
+ UNKNOWN => flat.count { |check| check["status"] == UNKNOWN }
115
+ }
116
+ }
117
+ end
118
+
119
+ private
120
+
121
+ def check_accounts(manifest, entries)
122
+ manifest["accounts"].map do |account|
123
+ username = account["username"]
124
+ entry = entries[username]
125
+ if entry.nil?
126
+ next {"account" => username, "status" => FAIL,
127
+ "reason" => "account '#{username}' does not exist on this machine; " \
128
+ "fix the account phase (ace-lab prepare) first",
129
+ "uid" => nil, "shell" => nil}
130
+ end
131
+
132
+ reason = if entry.dir != account["home"]
133
+ "account '#{username}' home is '#{entry.dir}' but the manifest declares " \
134
+ "'#{account["home"]}' (the manifest home is authoritative for key paths)"
135
+ elsif non_login_shell?(entry)
136
+ "account '#{username}' has non-login shell '#{shell_of(entry)}'; " \
137
+ "the target executor cannot log in"
138
+ end
139
+ {"account" => username, "status" => reason ? FAIL : PASS, "reason" => reason,
140
+ "uid" => entry.uid, "shell" => shell_of(entry)}
141
+ end
142
+ end
143
+
144
+ def check_directories(manifest, entries)
145
+ manifest["directories"].map do |directory|
146
+ path = directory["path"]
147
+ stat = lstat(path)
148
+ reason = if stat.nil?
149
+ "directory '#{path}' is missing"
150
+ elsif stat.symlink?
151
+ "directory '#{path}' is a symbolic link"
152
+ elsif !stat.directory?
153
+ "'#{path}' exists but is not a directory"
154
+ elsif (stat.mode & 0o7777) != directory["mode"].to_i(8)
155
+ "directory '#{path}' mode is #{format('%04o', stat.mode & 0o7777)} " \
156
+ "but the manifest declares #{directory["mode"]}"
157
+ else
158
+ owner_reason(directory, stat, entries)
159
+ end
160
+ {"directory" => path, "status" => reason ? FAIL : PASS, "reason" => reason}
161
+ end
162
+ end
163
+
164
+ def check_keys(manifest, entries)
165
+ provisioner = Molecules::KeyProvisioner.new(runner: @runner)
166
+ by_username = manifest["accounts"].to_h { |a| [a["username"], a] }
167
+ manifest["keys"].map do |key|
168
+ username = key["account"]
169
+ path = key["path"]
170
+ entry = entries[username]
171
+ reason = if entry.nil?
172
+ "account '#{username}' does not exist; fix the account phase (ace-lab prepare) first"
173
+ else
174
+ key_pair_reason(path, entry)
175
+ end
176
+ fingerprint = if reason
177
+ nil
178
+ else
179
+ provisioner.pair_fingerprint(path, "#{path}.pub")
180
+ end
181
+ if reason.nil? && fingerprint.nil?
182
+ reason = "key pair '#{path}' is not usable (unreadable or mismatched key files); " \
183
+ "resolve manually and rerun ace-lab prepare"
184
+ end
185
+ {"account" => username, "path" => path,
186
+ "status" => reason ? FAIL : PASS, "reason" => reason, "fingerprint" => fingerprint}
187
+ end
188
+ end
189
+
190
+ # Final verification resolves role instructions through the
191
+ # ACTUAL resolver for the execution context (LAB-4): prepare's
192
+ # protocol check is deliberately syntax-only, so reusing it as
193
+ # readiness would promote a syntactically valid but
194
+ # nonexistent role (`wfi://does-not-exist/not-a-role`) to
195
+ # verified readiness. Protocol instructions are resolved with
196
+ # ace-nav from every declared project root (the execution
197
+ # contexts the executor works in); a role that fails resolution
198
+ # is a failed check, an environment without the resolver
199
+ # reports unknown (offline verifier posture, like network
200
+ # remotes) and does not block.
201
+ def check_roles(manifest)
202
+ verifier = Molecules::RoleVerifier.new(
203
+ account_lookup: @account_lookup, group_source: @group_source
204
+ )
205
+ manifest["roles"].map do |role|
206
+ report = verifier.call(role: role)
207
+ if report["status"] == "verified"
208
+ status, reason = protocol_resolution(role["instructions"], manifest)
209
+ else
210
+ status, reason = FAIL, report["message"]
211
+ end
212
+ {"id" => role["id"], "account" => role["account"],
213
+ "instructions" => role["instructions"],
214
+ "status" => status, "reason" => reason}
215
+ end
216
+ end
217
+
218
+ # @return [Array<(String, String, nil)>] pass/fail/unknown with
219
+ # reason for protocol instructions; file-path instructions
220
+ # were already resolved (readability) by the RoleVerifier.
221
+ def protocol_resolution(instructions, manifest)
222
+ unless Molecules::RoleVerifier::PROTOCOL_PREFIXES.any? { |p| instructions.start_with?(p) }
223
+ return [PASS, nil]
224
+ end
225
+
226
+ contexts = manifest["projects"].map { |project| project["path"] }
227
+ if contexts.empty?
228
+ return [PASS, "no project declared as an execution context; syntax verified only"]
229
+ end
230
+
231
+ failures = []
232
+ unresolved_resolver = false
233
+ contexts.each do |root|
234
+ result = @nav_runner.call([nav_binary(root), "resolve", instructions], root)
235
+ if result["status"] == "ok" && !result["out"].to_s.lines.first.to_s.strip.empty?
236
+ next
237
+ end
238
+
239
+ if %w[enoent exec_failed].include?(result["status"])
240
+ unresolved_resolver = true
241
+ else
242
+ failures << "project '#{File.basename(root)}': #{resolution_error(result)}"
243
+ end
244
+ end
245
+ return [FAIL, "role instructions '#{instructions}' do not resolve through ace-nav " \
246
+ "for the execution context (#{failures.join('; ')})"] unless failures.empty?
247
+ return [UNKNOWN, "ace-nav is not available to resolve role instructions " \
248
+ "'#{instructions}'; not probed by the read-only verifier"] if unresolved_resolver
249
+
250
+ [PASS, nil]
251
+ end
252
+
253
+ def resolution_error(result)
254
+ detail = result["err"].to_s.lines.map(&:strip).reject(&:empty?).first
255
+ detail ? detail : "ace-nav exited with status #{result['status']}"
256
+ end
257
+
258
+ # Same binary choice the executor's resolver makes
259
+ # (Ace::Coworker::Molecules::InstructionResolver): the
260
+ # project's binstub when present, otherwise the PATH lookup.
261
+ # A privileged installer never executes repository-controlled
262
+ # binstubs (same trust posture as the LAB-1 symlink refusals):
263
+ # under root only the PATH-installed resolver is used.
264
+ def nav_binary(project_root)
265
+ binstub = File.join(project_root, "bin", "ace-nav")
266
+ use_binstub = !Process.uid.zero? && File.executable?(binstub)
267
+ use_binstub ? binstub : "ace-nav"
268
+ end
269
+
270
+ def run_nav(argv, cwd)
271
+ out, err, status = Open3.capture3(*argv, chdir: cwd)
272
+ {"status" => status.success? ? "ok" : (status.exitstatus || "signal #{status.termsig}"),
273
+ "out" => out, "err" => err}
274
+ rescue Errno::ENOENT
275
+ {"status" => "enoent", "out" => "", "err" => ""}
276
+ rescue SystemCallError, IOError => e
277
+ {"status" => "exec_failed", "out" => "", "err" => e.class.name}
278
+ end
279
+
280
+ # The declared target executors: the distinct accounts the
281
+ # manifest binds roles to (nil entries — accounts that failed
282
+ # lookup — are dropped; the accounts check already fails those).
283
+ def target_executors(manifest, entries)
284
+ manifest["roles"].filter_map { |role| entries[role["account"]] }
285
+ .uniq { |entry| entry.uid }
286
+ end
287
+
288
+ def check_repositories(manifest, executors)
289
+ verifier = Molecules::RoleVerifier.new(
290
+ account_lookup: @account_lookup, group_source: @group_source
291
+ )
292
+ manifest["projects"].map do |project|
293
+ path = project["path"]
294
+ remote = project["remote"]
295
+ origin = origin_url(path)
296
+
297
+ reason = repo_structure_reason(path)
298
+ if reason.nil? && remote && origin != remote
299
+ reason = if origin.nil?
300
+ "'#{path}' has no origin remote configured but the manifest declares '#{remote}'"
301
+ else
302
+ "'#{path}' origin is '#{origin}' but the manifest declares '#{remote}'"
303
+ end
304
+ end
305
+ # Readiness is judged for the target EXECUTOR, not the
306
+ # invoking installer (LAB-2): a repository behind an
307
+ # installer-owned 0700 ancestor with a different target UID
308
+ # is NOT ready even though the installer's git succeeded.
309
+ if reason.nil?
310
+ reason = executors.filter_map do |entry|
311
+ access = verifier.directory_access_reason(path, entry)
312
+ access ? "executor '#{entry.name}': #{access}" : nil
313
+ end.join("; ")
314
+ reason = nil if reason.empty?
315
+ end
316
+
317
+ status, reach = if reason
318
+ [FAIL, nil]
319
+ elsif remote.nil?
320
+ [PASS, "no remote declared (offline repository)"]
321
+ elsif (local = local_remote_path(remote))
322
+ ls_remote(local) ? [PASS, "origin reachable at '#{remote}'"] : [FAIL,
323
+ "origin '#{remote}' is not reachable (git ls-remote failed)"]
324
+ else
325
+ [UNKNOWN, "origin '#{remote}' is not a local-path remote; not probed by the " \
326
+ "read-only verifier"]
327
+ end
328
+
329
+ {"project" => project["id"], "path" => path, "remote" => remote,
330
+ "origin" => origin, "status" => status,
331
+ "reason" => reason || (status == PASS ? nil : reach)}
332
+ end
333
+ end
334
+
335
+ # Project visibility through grants: read the SAME trusted
336
+ # document every grant reader verifies, and evaluate it with
337
+ # the real read-side mechanics (CallerAuthorizer) as the
338
+ # manifest principals' identities.
339
+ def check_grants(manifest)
340
+ document_check = installed_principles
341
+ if document_check["missing"] && manifest["grants"].empty?
342
+ # No grants declared: the trusted document is not part of
343
+ # this lab's contract, and none was written — requiring one
344
+ # would make a grants-less manifest permanently unready.
345
+ return [{"principal" => "(document)", "status" => PASS,
346
+ "reason" => "no grants declared; no trusted authorization document required",
347
+ "projects" => nil}]
348
+ end
349
+ document_check.delete("missing") # internal only — keep JSON schema uniform
350
+ return [document_check] if document_check["status"] == FAIL
351
+
352
+ principals = document_check.delete("principals") # internal only — keep JSON schema uniform
353
+ [document_check] + manifest["grants"].map do |principal, projects|
354
+ granted = Molecules::CallerAuthorizer.new(
355
+ principals: principals, identity: [principal]
356
+ ).authorized_project_ids
357
+ missing = projects - granted
358
+ if missing.empty?
359
+ {"principal" => principal, "status" => PASS,
360
+ "reason" => nil, "projects" => projects}
361
+ else
362
+ {"principal" => principal, "status" => FAIL,
363
+ "reason" => "principal '#{principal}' is granted #{granted.sort.inspect} but the " \
364
+ "manifest declares #{projects.sort.inspect} (missing: #{missing.sort.join(', ')}); " \
365
+ "rerun ace-lab install",
366
+ "projects" => granted.sort}
367
+ end
368
+ end
369
+ end
370
+
371
+ # On failure returns a synthetic failed check carrying the
372
+ # classified reason (document-level failure). An UNTRUSTED but
373
+ # present document fails the same synthetic check (a failed
374
+ # check never hides the healthy data around it) instead of
375
+ # aborting the whole report.
376
+ def installed_principles
377
+ path = @authorization_path
378
+ begin
379
+ content = @trusted_read.call(path)
380
+ rescue Ace::Lab::InvalidConfigurationError => e
381
+ return {"principal" => "(document)", "status" => FAIL,
382
+ "reason" => "trusted authorization file #{path} failed verification: #{e.message}",
383
+ "projects" => nil}
384
+ end
385
+ if content.nil?
386
+ return {"principal" => "(document)", "status" => FAIL, "missing" => true,
387
+ "reason" => "trusted authorization file #{path} is missing; no principal can see " \
388
+ "the installed projects — rerun ace-lab install", "projects" => nil}
389
+ end
390
+
391
+ begin
392
+ document = YAML.safe_load(content, permitted_classes: [Date], aliases: true)
393
+ unless document.is_a?(Hash)
394
+ raise Ace::Lab::InvalidConfigurationError, "#{path} must contain a YAML mapping"
395
+ end
396
+ principals = Molecules::GrantResolver.validate_principals(document["principals"] || {}, path)
397
+ {"principal" => "(document)", "status" => PASS, "reason" => nil,
398
+ "principals" => principals, "projects" => nil}
399
+ rescue Psych::Exception
400
+ {"principal" => "(document)", "status" => FAIL,
401
+ "reason" => "#{path} could not be parsed as YAML; failing closed", "projects" => nil}
402
+ rescue Ace::Lab::InvalidConfigurationError => e
403
+ {"principal" => "(document)", "status" => FAIL, "reason" => e.message, "projects" => nil}
404
+ end
405
+ end
406
+
407
+ def check_registry(manifest)
408
+ manifest["projects"].map do |project|
409
+ path = File.join(project["path"], ".ace", "lab", "config.yml")
410
+ reason = registry_reason(path, project)
411
+ {"project" => project["id"], "registry" => path,
412
+ "status" => reason ? FAIL : PASS, "reason" => reason}
413
+ end
414
+ end
415
+
416
+ def registry_reason(path, project)
417
+ return "'#{path}' is missing; the project is not visible through the configuration " \
418
+ "cascade (ace-lab projects / ace-overseer projects)" unless File.file?(path)
419
+
420
+ begin
421
+ document = YAML.safe_load_file(path, permitted_classes: [Date], aliases: true)
422
+ rescue Psych::Exception
423
+ return "'#{path}' could not be parsed as YAML"
424
+ end
425
+ return "'#{path}' must contain a YAML mapping" unless document.is_a?(Hash)
426
+
427
+ # Shape validation through the same schema every registry
428
+ # consumer applies (LAB-6): a syntactically valid but
429
+ # structurally malformed document (`topology: invalid`) must
430
+ # become a classified per-project failure, never a TypeError
431
+ # that kills the whole readiness report.
432
+ begin
433
+ Atoms::TopologySchema.normalize!(document)
434
+ rescue Ace::Lab::InvalidConfigurationError => e
435
+ return "'#{path}' is not a valid lab configuration: #{e.message}"
436
+ end
437
+
438
+ projects = document.dig("topology", "projects")
439
+ entry = projects.is_a?(Array) ? projects.find { |c| c.is_a?(Hash) && c["id"] == project["id"] } : nil
440
+ return "'#{path}' does not register project '#{project["id"]}'" if entry.nil?
441
+ if entry["path"] && entry["path"] != project["path"]
442
+ return "project '#{project["id"]}' is registered at '#{entry["path"]}' but the " \
443
+ "manifest declares '#{project["path"]}'"
444
+ end
445
+ nil
446
+ end
447
+
448
+ def check_overseer(manifest)
449
+ manifest["projects"].map do |project|
450
+ dir = File.join(project["path"], ".ace-tasks", "_overseer")
451
+ reason = if !File.directory?(dir)
452
+ "private folder '#{dir}' is missing"
453
+ elsif (ignore_reason = gitignore_reason(project["path"], ".ace-tasks/_overseer"))
454
+ ignore_reason
455
+ end
456
+ {"project" => project["id"], "dir" => dir,
457
+ "status" => reason ? FAIL : PASS, "reason" => reason}
458
+ end
459
+ end
460
+
461
+ # --- shared probes ------------------------------------------------
462
+
463
+ # Declared communication/HITL adapters (task 8xg.t.sbz.7.2/.7.4,
464
+ # LAB-3): configuration validity is re-derived from the trusted
465
+ # manifest (the single state owner for the declaration), an
466
+ # adapter that names a local command is probed for availability
467
+ # FOR THE TARGET EXECUTORS (FINAL-LAB3), and a `kind: hitl`
468
+ # adapter must be projected into the runtime route configuration
469
+ # (`hitl.adapters.routes` in every declared project's
470
+ # `.ace/hitl/config.yml`, FINAL-LAB2) — an adapter that is
471
+ # schema-checked but not usable by the runtime blocks readiness
472
+ # with a classified failure, never a crash. Network reachability
473
+ # stays out of scope (L9).
474
+ def check_adapters(manifest, executors = [])
475
+ manifest["adapters"].map do |adapter|
476
+ config_reason = ("adapter '#{adapter['id']}' configuration is not a mapping" \
477
+ unless adapter["config"].is_a?(Hash))
478
+ probe = if config_reason.nil? && adapter["command"]
479
+ availability_probe(adapter["command"], executors).first
480
+ else
481
+ :pass
482
+ end
483
+ route_reason = if config_reason.nil? && adapter["kind"] == "hitl"
484
+ hitl_route_reason(adapter, manifest)
485
+ end
486
+ # Route failures are decidable regardless of PATH — they
487
+ # take precedence over the PATH-unset probe message so a
488
+ # missing/invalid HITL route is never masked as UNKNOWN.
489
+ reason = route_reason ||
490
+ config_reason ||
491
+ (probe != :pass ? adapter_command_reason(adapter, executors) : nil)
492
+ note = if reason.nil? && adapter["command"].nil?
493
+ if adapter["kind"] == "hitl"
494
+ "configured (route installed in every project; no local command declared; " \
495
+ "network reachability is out of scope)"
496
+ else
497
+ "configured (no local command declared; network reachability is out of scope)"
498
+ end
499
+ end
500
+ # A config-shape or route failure is decidable regardless
501
+ # of PATH: it stays FAIL; only the command probe degrades
502
+ # to unknown under an unset PATH.
503
+ status = if config_reason || route_reason
504
+ FAIL
505
+ elsif probe == :unknown
506
+ UNKNOWN
507
+ elsif reason
508
+ FAIL
509
+ else
510
+ PASS
511
+ end
512
+ {"adapter" => adapter["id"], "kind" => adapter["kind"], "command" => adapter["command"],
513
+ "status" => status, "reason" => reason || note}
514
+ end
515
+ end
516
+
517
+ # A `kind: hitl` adapter's route must be installed in EVERY
518
+ # declared execution context and the resulting route list must
519
+ # satisfy the runtime loader's contract (HitlRoutes validates
520
+ # the same rules the ace-hitl route loader enforces).
521
+ def hitl_route_reason(adapter, manifest)
522
+ return nil if manifest["projects"].empty?
523
+
524
+ expected = Molecules::HitlRoutes.expected_routes([adapter]).first
525
+ failures = manifest["projects"].filter_map do |project|
526
+ reason = Molecules::HitlRoutes.config_reason(root: project["path"], expected: expected)
527
+ reason ? "project '#{project['id']}': #{reason}" : nil
528
+ end
529
+ failures.empty? ? nil : failures.join("; ")
530
+ end
531
+
532
+ # Required tools (task 8xg.t.sbz.7.4, LAB-3, FINAL-LAB3): the
533
+ # tool must resolve to an executable the TARGET EXECUTORS can
534
+ # run — a missing tool, or one only the installer can execute,
535
+ # blocks readiness. No execution, no network.
536
+ def check_tools(manifest, executors = [])
537
+ manifest["tools"].map do |tool|
538
+ status, detail = availability_probe(tool, executors)
539
+ case status
540
+ when :pass
541
+ {"tool" => tool, "status" => PASS, "reason" => nil}
542
+ when :fail
543
+ reason = if detail
544
+ "required tool '#{tool}' is not available for the target executor (#{detail})"
545
+ else
546
+ "required tool '#{tool}' is not available (not found on PATH)"
547
+ end
548
+ {"tool" => tool, "status" => FAIL, "reason" => reason}
549
+ else
550
+ {"tool" => tool, "status" => UNKNOWN,
551
+ "reason" => "PATH is unset; cannot probe tool '#{tool}' availability"}
552
+ end
553
+ end
554
+ end
555
+
556
+ # Declared gem set (gad.2 gap #1, owner spec 8xg.t.sbz.7): every
557
+ # declared gem must be installed at its EXACT declared version in
558
+ # the declared target runtime (GEM_HOME) — read-only, through the
559
+ # SAME probe the provisioner converges from
560
+ # (GemsetProvisioner#runtime_versions), so readiness and
561
+ # convergence can never drift. A missing, symlinked or
562
+ # non-directory runtime fails every declared gem as a dependency
563
+ # (nothing could have converged into it); a gem at a wrong or
564
+ # missing version is a classified failure with an
565
+ # `ace-lab install` remedy; an environment without the `gem`
566
+ # executable degrades to unknown (the offline posture) and does
567
+ # not block by itself. Extra undeclared versions are not judged:
568
+ # the manifest is the single owner of the DECLARED set.
569
+ def check_gems(manifest)
570
+ gemset = manifest["gemset"]
571
+ gems = gemset["gems"]
572
+ return [] if gems.empty?
573
+
574
+ gem_home = gemset["gem_home"]
575
+ runtime_reason = gem_runtime_reason(gem_home)
576
+ provisioner = Molecules::GemsetProvisioner.new(runner: @runner)
577
+ gems.map do |gem|
578
+ if runtime_reason
579
+ next {"gem" => gem["name"], "version" => gem["version"], "gem_home" => gem_home,
580
+ "status" => FAIL, "reason" => runtime_reason}
581
+ end
582
+
583
+ kind, versions_or_message = provisioner.runtime_versions(
584
+ name: gem["name"], gem_home: gem_home
585
+ )
586
+ reason = case kind
587
+ when :unknown, :failed then versions_or_message
588
+ when :ok then gem_version_reason(gem, versions_or_message, gem_home)
589
+ end
590
+ status = case kind
591
+ when :ok then reason ? FAIL : PASS
592
+ when :unknown then UNKNOWN
593
+ else FAIL
594
+ end
595
+ {"gem" => gem["name"], "version" => gem["version"], "gem_home" => gem_home,
596
+ "status" => status, "reason" => reason}
597
+ end
598
+ end
599
+
600
+ def gem_version_reason(gem, versions, gem_home)
601
+ return nil if versions.include?(gem["version"])
602
+ if versions.empty?
603
+ "gem '#{gem["name"]}' is not installed in gem runtime '#{gem_home}'; " \
604
+ "rerun ace-lab install"
605
+ else
606
+ "gem '#{gem["name"]}' is installed as #{versions.join(", ")} in " \
607
+ "'#{gem_home}' but the manifest declares #{gem["version"]}; rerun ace-lab install"
608
+ end
609
+ end
610
+
611
+ def gem_runtime_reason(gem_home)
612
+ stat = lstat(gem_home)
613
+ if stat.nil?
614
+ "gem runtime '#{gem_home}' is missing; rerun ace-lab install"
615
+ elsif stat.symlink?
616
+ "gem runtime '#{gem_home}' is a symbolic link"
617
+ elsif !stat.directory?
618
+ "'#{gem_home}' exists but is not a directory"
619
+ end
620
+ end
621
+
622
+ # Resolve an executable without running it and judge it for the
623
+ # TARGET EXECUTORS (FINAL-LAB3): a name containing a separator
624
+ # must be a file executable at that path; a bare name must
625
+ # resolve through PATH (the installer's PATH is the best
626
+ # available locator — candidate files are then permission-checked
627
+ # per target executor through the same governing-class logic as
628
+ # the repository access checks). Without declared executors the
629
+ # installer's own executable bit decides (presence check only).
630
+ # An unset or empty PATH leaves the probe undecidable and yields
631
+ # unknown (never a false failure) — reported as UNKNOWN, never
632
+ # counted as a verified pass, and like every other unknown it
633
+ # does not block readiness by itself.
634
+ # @return [Array<(Symbol, String, nil)>] status (:pass/:fail/:
635
+ # unknown) and the executor-denial detail for failures
636
+ def availability_probe(name, executors)
637
+ if name.include?(File::SEPARATOR)
638
+ candidates = File.file?(name) ? [name] : []
639
+ else
640
+ directories = (ENV["PATH"].to_s.split(File::PATH_SEPARATOR) - [""])
641
+ return [:unknown, nil] if directories.empty?
642
+
643
+ candidates = directories.filter_map do |directory|
644
+ candidate = File.join(directory, name)
645
+ File.file?(candidate) ? candidate : nil
646
+ end
647
+ end
648
+ return [:fail, nil] if candidates.empty?
649
+ if executors.empty?
650
+ return [candidates.any? { |candidate| File.executable?(candidate) } ? :pass : :fail, nil]
651
+ end
652
+
653
+ verifier = Molecules::RoleVerifier.new(
654
+ account_lookup: @account_lookup, group_source: @group_source
655
+ )
656
+ denials = executors.filter_map do |entry|
657
+ reasons = candidates.map { |candidate| verifier.executable_access_reason(candidate, entry) }
658
+ reasons.compact.empty? ? nil : "executor '#{entry.name}': #{reasons.compact.first}"
659
+ end
660
+ denials.empty? ? [:pass, nil] : [:fail, denials.join("; ")]
661
+ end
662
+
663
+ def adapter_command_reason(adapter, executors = [])
664
+ status, detail = availability_probe(adapter["command"], executors)
665
+ case status
666
+ when :fail
667
+ if detail
668
+ "adapter '#{adapter['id']}' command '#{adapter['command']}' is not available " \
669
+ "for the target executor (#{detail})"
670
+ else
671
+ "adapter '#{adapter['id']}' command '#{adapter['command']}' is not available " \
672
+ "(not found on PATH); fix the adapter installation or the manifest declaration"
673
+ end
674
+ when :unknown
675
+ "PATH is unset; cannot probe adapter '#{adapter['id']}' command availability"
676
+ end
677
+ end
678
+
679
+ def key_pair_reason(path, entry)
680
+ stat = lstat(path)
681
+ public_path = "#{path}.pub"
682
+ pub_stat = lstat(public_path)
683
+ return "private key '#{path}' is missing" if stat.nil?
684
+ return "private key '#{path}' is not a regular file" unless stat.file?
685
+ return "public key '#{public_path}' is missing" if pub_stat.nil?
686
+ return "public key '#{public_path}' is not a regular file" unless pub_stat.file?
687
+
688
+ if (stat.mode & 0o7777) != Molecules::KeyProvisioner::PRIVATE_MODE
689
+ return "private key '#{path}' mode is #{format('%04o', stat.mode & 0o7777)} " \
690
+ "but #{format('%04o', Molecules::KeyProvisioner::PRIVATE_MODE)} is required"
691
+ end
692
+ if (pub_stat.mode & 0o7777) != Molecules::KeyProvisioner::PUBLIC_MODE
693
+ return "public key '#{public_path}' mode is #{format('%04o', pub_stat.mode & 0o7777)} " \
694
+ "but #{format('%04o', Molecules::KeyProvisioner::PUBLIC_MODE)} is required"
695
+ end
696
+ if stat.uid != entry.uid
697
+ return "private key '#{path}' is owned by uid #{stat.uid} but the account " \
698
+ "'#{entry.name}' is uid #{entry.uid}"
699
+ end
700
+ if pub_stat.uid != entry.uid
701
+ return "public key '#{public_path}' is owned by uid #{pub_stat.uid} but the " \
702
+ "account '#{entry.name}' is uid #{entry.uid}"
703
+ end
704
+
705
+ ssh_dir = File.dirname(path)
706
+ ssh_stat = lstat(ssh_dir)
707
+ if ssh_stat.nil?
708
+ "key directory '#{ssh_dir}' is missing"
709
+ elsif !ssh_stat.directory? || ssh_stat.symlink?
710
+ "key directory '#{ssh_dir}' is not a directory"
711
+ elsif (ssh_stat.mode & 0o7777) != Molecules::KeyProvisioner::SSH_DIR_MODE
712
+ "key directory '#{ssh_dir}' mode is #{format('%04o', ssh_stat.mode & 0o7777)} " \
713
+ "but #{format('%04o', Molecules::KeyProvisioner::SSH_DIR_MODE)} is required"
714
+ elsif ssh_stat.uid != entry.uid
715
+ "key directory '#{ssh_dir}' is owned by uid #{ssh_stat.uid} but the account " \
716
+ "'#{entry.name}' is uid #{entry.uid}"
717
+ end
718
+ end
719
+
720
+ def repo_structure_reason(path)
721
+ stat = lstat(path)
722
+ return "'#{path}' is missing" if stat.nil?
723
+ return "'#{path}' is not a directory" unless stat.directory?
724
+
725
+ result = @runner.call(["git", "-C", path, "rev-parse", "--show-toplevel"])
726
+ if dubious_ownership?(result)
727
+ return "git refuses to operate on '#{path}' because of dubious ownership — run verify " \
728
+ "as the account that owns the repository, or configure git safe.directory"
729
+ end
730
+ return "'#{path}' is not a Git repository" unless result["status"] == "ok"
731
+
732
+ reported = result["out"].to_s.lines.first.to_s.strip
733
+ return "'#{path}' is a subdirectory of the repository at '#{reported}'" unless same_location?(reported, path)
734
+
735
+ nil
736
+ end
737
+
738
+ # Raw stored URL: `git remote get-url` applies url.<base>.insteadOf
739
+ # rewrites from the invoking user's git config, which would
740
+ # false-fail an origin that matches the manifest canonically.
741
+ def origin_url(path)
742
+ result = @runner.call(["git", "-C", path, "config", "--get", "remote.origin.url"])
743
+ return nil unless result["status"] == "ok"
744
+
745
+ url = result["out"].to_s.lines.first.to_s.strip
746
+ url.empty? ? nil : url
747
+ end
748
+
749
+ # A remote the verifier may dial without leaving the machine:
750
+ # an existing local path (bare or not), optionally via file://.
751
+ def local_remote_path(remote)
752
+ candidate = remote.start_with?("file://") ? remote.delete_prefix("file://") : remote
753
+ candidate if candidate.start_with?("/") && File.directory?(candidate)
754
+ end
755
+
756
+ def ls_remote(remote)
757
+ @runner.call(["git", "ls-remote", remote])["status"] == "ok"
758
+ end
759
+
760
+ # nil when Git ignores the path; otherwise the concrete reason
761
+ # (including the dubious-ownership refusal, which must not be
762
+ # misdiagnosed as "not ignored").
763
+ def gitignore_reason(root, relative)
764
+ result = @runner.call(["git", "-C", root, "check-ignore", "--", relative])
765
+ if dubious_ownership?(result)
766
+ return "git refuses to operate on '#{root}' because of dubious ownership — run " \
767
+ "verify as the owning account, or configure git safe.directory"
768
+ end
769
+ return nil if result["status"] == "ok"
770
+
771
+ "'#{File.join(root, relative)}' is not ignored by Git; private overseer tasks " \
772
+ "would be committed (add '.ace-tasks/_overseer/' to the project .gitignore " \
773
+ "and rerun ace-lab install)"
774
+ end
775
+
776
+ # Git's ownership-safety refusal (verify often runs as a different
777
+ # account than the one that converged the repository — exactly the
778
+ # under-target-accounts posture). Detected so the reported reason
779
+ # is actionable instead of "not a Git repository".
780
+ def dubious_ownership?(result)
781
+ result["status"] != "ok" && result["err"].to_s.include?("dubious ownership")
782
+ end
783
+
784
+ def same_location?(reported, path)
785
+ File.realpath(reported) == File.realpath(path)
786
+ rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES
787
+ File.expand_path(reported) == File.expand_path(path)
788
+ end
789
+
790
+ def owner_reason(directory, stat, entries)
791
+ owner = directory["owner"]
792
+ return nil if owner.nil?
793
+
794
+ entry = entries[owner]
795
+ return nil if entry.nil?
796
+
797
+ if stat.uid != entry.uid
798
+ "directory '#{directory["path"]}' is owned by uid #{stat.uid} but the manifest " \
799
+ "declares owner '#{owner}' (uid #{entry.uid})"
800
+ end
801
+ end
802
+
803
+ def non_login_shell?(entry)
804
+ shell = shell_of(entry).to_s
805
+ shell.end_with?("/nologin") || shell == "/bin/false" || shell == "/usr/bin/false"
806
+ end
807
+
808
+ def shell_of(entry)
809
+ entry.respond_to?(:shell) ? entry.shell : nil
810
+ end
811
+
812
+ def lstat(path)
813
+ File.lstat(path)
814
+ rescue Errno::ENOENT, Errno::ENOTDIR
815
+ nil
816
+ end
817
+
818
+ def run_argv(argv)
819
+ out, err, status = Open3.capture3(*argv)
820
+ {"status" => status.success? ? "ok" : (status.exitstatus || "signal #{status.termsig}"),
821
+ "out" => out, "err" => err}
822
+ rescue Errno::ENOENT
823
+ {"status" => "enoent", "out" => "", "err" => ""}
824
+ rescue SystemCallError => e
825
+ {"status" => "exec_failed", "out" => "", "err" => e.class.name}
826
+ end
827
+
828
+ def lookup_passwd(username)
829
+ Etc.getpwnam(username)
830
+ rescue ArgumentError
831
+ nil
832
+ end
833
+ end
834
+ end
835
+ end
836
+ end