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,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require "etc"
5
+
6
+ module Ace
7
+ module Lab
8
+ module Molecules
9
+ # Verifies role bindings from the trusted install manifest: the role's
10
+ # account must exist after the account phase, and its instruction
11
+ # source must be either a supported protocol reference (syntactic
12
+ # validation only — context resolution belongs to ace-coworker /
13
+ # ace-nav, see L3.1) or an absolute file path that exists and whose
14
+ # static permission bits allow the account to read it. ACLs are out
15
+ # of scope.
16
+ class RoleVerifier
17
+ PROTOCOL_PREFIXES = %w[wfi:// prompt:// tmpl://].freeze
18
+
19
+ attr_reader :account_lookup, :group_source, :dry_run
20
+
21
+ def initialize(account_lookup: nil, group_source: nil, dry_run: false)
22
+ @account_lookup = account_lookup || method(:lookup_passwd)
23
+ # Injectable group-membership seam: a callable returning the
24
+ # gids the named account holds beyond its primary gid
25
+ # (Etc.group scan in production). Supplementary groups are
26
+ # ordinary static permission bits — distinct from the ACLs
27
+ # that are out of scope.
28
+ @group_source = group_source || method(:supplementary_gids)
29
+ @dry_run = dry_run
30
+ end
31
+
32
+ # @param role [Hash] normalized manifest role entry
33
+ # @return [Hash] {id:, account:, instructions:, status:, message:}
34
+ def call(role:)
35
+ base = {"id" => role["id"], "account" => role["account"],
36
+ "instructions" => role["instructions"]}
37
+
38
+ entry = account_lookup.call(role["account"])
39
+ if entry.nil? && !dry_run
40
+ return base.merge("status" => "failed",
41
+ "message" => "role '#{role['id']}' account '#{role['account']}' is not available; " \
42
+ "fix the account phase first")
43
+ end
44
+
45
+ instructions = role["instructions"]
46
+ return base.merge("status" => "verified", "message" => nil) if protocol?(instructions)
47
+
48
+ verify_file(base, instructions, entry)
49
+ end
50
+
51
+ # Readiness probe for a project/worktree directory used by a
52
+ # target executor (task 8xg.t.sbz.7.4, LAB-2): every ancestor of
53
+ # the RESOLVED path must grant the account search (x) permission
54
+ # and the directory itself must grant read+write+search (the
55
+ # executor clones and provisions worktrees inside it — a
56
+ # directory without x on itself is unusable however rw it is).
57
+ # Returns nil when the account can work in the directory, or a
58
+ # concrete reason. Same governing-class permission logic as the
59
+ # instruction checks so the two can never drift apart.
60
+ def directory_access_reason(path, entry)
61
+ resolved = File.realpath(path)
62
+ current = File.dirname(resolved)
63
+ loop do
64
+ stat = File.stat(current)
65
+ return search_denied(current, entry) if (permission_bits(stat, entry) & 0o1).zero?
66
+ break if current == "/"
67
+
68
+ current = File.dirname(current)
69
+ end
70
+ stat = File.stat(resolved)
71
+ return work_denied(resolved, entry) if (permission_bits(stat, entry) & 0o7) != 0o7
72
+
73
+ nil
74
+ rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES
75
+ "'#{path}' does not exist or cannot be inspected for account '#{entry.name}'"
76
+ end
77
+
78
+ # Readiness probe for an executable the target executor must be
79
+ # able to RUN (PR #393 final audit, FINAL-LAB3): every ancestor
80
+ # of the RESOLVED path must grant search (x) and the file itself
81
+ # must be a regular file granting the account execute through
82
+ # its governing permission class — an installer-owned 0700
83
+ # executable is NOT available to a different-uid executor.
84
+ # Returns nil when the account can execute it, or a concrete
85
+ # reason (same governing-class logic as the other probes).
86
+ def executable_access_reason(path, entry)
87
+ resolved = File.realpath(path)
88
+ current = File.dirname(resolved)
89
+ loop do
90
+ stat = File.stat(current)
91
+ return search_denied(current, entry) if (permission_bits(stat, entry) & 0o1).zero?
92
+ break if current == "/"
93
+
94
+ current = File.dirname(current)
95
+ end
96
+ stat = File.stat(resolved)
97
+ return "'#{resolved}' is not a regular file" unless stat.file?
98
+ return execute_denied(resolved, entry) if (permission_bits(stat, entry) & 0o1).zero?
99
+
100
+ nil
101
+ rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES
102
+ "'#{path}' does not exist or cannot be inspected for account '#{entry.name}'"
103
+ end
104
+
105
+ private
106
+
107
+ def search_denied(directory, entry)
108
+ "'#{directory}' denies search permission to account '#{entry.name}'; the target " \
109
+ "executor cannot reach the project through this ancestor"
110
+ end
111
+
112
+ def work_denied(directory, entry)
113
+ "'#{directory}' denies read/write/search permission to account '#{entry.name}'; the " \
114
+ "target executor cannot work inside the project"
115
+ end
116
+
117
+ def execute_denied(file, entry)
118
+ "'#{file}' denies execute permission to account '#{entry.name}'; the target " \
119
+ "executor cannot run it"
120
+ end
121
+
122
+ def protocol?(instructions)
123
+ PROTOCOL_PREFIXES.any? { |prefix| instructions.start_with?(prefix) }
124
+ end
125
+
126
+ # entry is nil only in dry-run for an account the account phase
127
+ # would create: readability depends on a uid that does not exist
128
+ # yet, so the binding is predicted verified once the file itself
129
+ # checks out.
130
+ def verify_file(base, path, entry)
131
+ stat = File.stat(path)
132
+ unless stat.file?
133
+ return base.merge("status" => "failed",
134
+ "message" => "instructions '#{path}' is not a regular file")
135
+ end
136
+
137
+ if entry && !readable_by_account?(stat, entry)
138
+ return base.merge("status" => "failed",
139
+ "message" => "instructions '#{path}' is not readable by account '#{entry.name}' " \
140
+ "(static permission bits; ACLs are out of scope)")
141
+ end
142
+
143
+ # Read bits on the file alone are not enough: POSIX requires
144
+ # the search (x) bit on EVERY ancestor directory of the
145
+ # resolved path, or the account cannot reach the file at all
146
+ # (a 0644 file behind a 0700 directory is unreachable).
147
+ if entry && !reachable_by_account?(path, entry)
148
+ return base.merge("status" => "failed",
149
+ "message" => "instructions '#{path}' is not reachable by account '#{entry.name}' " \
150
+ "(an ancestor directory denies search permission; static permission bits; " \
151
+ "ACLs are out of scope)")
152
+ end
153
+
154
+ base.merge("status" => "verified", "message" => nil)
155
+ rescue Errno::ENOENT
156
+ base.merge("status" => "failed",
157
+ "message" => "instructions file '#{path}' does not exist")
158
+ rescue SystemCallError => e
159
+ base.merge("status" => "failed",
160
+ "message" => "instructions file '#{path}' could not be verified (#{e.class.name})")
161
+ end
162
+
163
+ # Reachability: every ancestor of the RESOLVED path must grant
164
+ # the account search permission through its governing class.
165
+ # The final component is resolved too (File.realpath(path)):
166
+ # realpath follows both ancestor symlinks AND a symlink at the
167
+ # instruction file itself, mirroring what open(2) traverses —
168
+ # judging only the symlink's own ancestors would miss an
169
+ # unsearchable directory on the target's path. An unresolvable
170
+ # or unstattable ancestor is conservatively unreachable.
171
+ def reachable_by_account?(path, entry)
172
+ current = File.dirname(File.realpath(path))
173
+ loop do
174
+ stat = File.stat(current)
175
+ return false if (permission_bits(stat, entry) & 0o1).zero?
176
+ break if current == "/"
177
+
178
+ current = File.dirname(current)
179
+ end
180
+ true
181
+ rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES
182
+ false
183
+ end
184
+
185
+ # POSIX access checks do not fall through permission classes: a
186
+ # file owned by the account is governed by the owner bits alone —
187
+ # group/other bits never apply. Short-circuit per class so an
188
+ # owner-permission-bit-clear file can never verify through a
189
+ # gid match. The group class matches ANY group the account is a
190
+ # member of — primary gid or supplementary (round 5): the
191
+ # runtime's group credentials include them, so a
192
+ # supplementary-group-granted permission genuinely applies.
193
+ # Returns the governing class's rwx bits normalized to the low
194
+ # octal (r=4, w=2, x=1), shared by the file-read check and the
195
+ # ancestor search check so the two can never drift apart.
196
+ def permission_bits(stat, entry)
197
+ return (stat.mode >> 6) & 0o7 if stat.uid == entry.uid
198
+ if stat.gid == entry.gid || group_source.call(entry.name).include?(stat.gid)
199
+ return (stat.mode >> 3) & 0o7
200
+ end
201
+
202
+ stat.mode & 0o7
203
+ end
204
+
205
+ def readable_by_account?(stat, entry)
206
+ (permission_bits(stat, entry) & 0o4).nonzero?
207
+ end
208
+
209
+ def supplementary_gids(username)
210
+ gids = []
211
+ Etc.group { |group| gids << group.gid if group.mem.include?(username) }
212
+ gids
213
+ end
214
+
215
+ def lookup_passwd(username)
216
+ Etc.getpwnam(username)
217
+ rescue ArgumentError
218
+ nil
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "../molecules/install_manifest_loader"
5
+ require_relative "../molecules/grant_provisioner"
6
+ require_relative "prepare"
7
+ require_relative "project_add"
8
+ require_relative "verify"
9
+
10
+ module Ace
11
+ module Lab
12
+ module Organisms
13
+ # Orchestrates the full new-lab installation (L8.3, task
14
+ # 8xg.t.sbz.7.2) from the trusted install manifest by composing
15
+ # the existing molecules in a fixed order — nothing here
16
+ # duplicates them:
17
+ #
18
+ # 1. foreign-lab guard (BEFORE any mutation): the trusted
19
+ # authorization document must not grant projects this
20
+ # manifest does not declare — a ready/foreign lab is refused
21
+ # with a classified error and zero writes;
22
+ # 2. `Prepare` (L8.1): accounts → directories → keys → roles →
23
+ # gems (the declared gem set converged into the declared
24
+ # target runtime, gad.2);
25
+ # 3. `ProjectAdd` (L8.2) per declared project: repository,
26
+ # private `_overseer`, project registry, runtime HITL route
27
+ # configuration (the manifest's `kind: hitl` adapters
28
+ # projected into `.ace/hitl/config.yml`, FINAL-LAB2) and
29
+ # executor ownership propagation — each project is isolated;
30
+ # one failing project does not block the others;
31
+ # 4. `GrantProvisioner`: trusted-tier principal grants that
32
+ # make the installed projects visible to `ace-lab projects`
33
+ # and `ace-overseer projects`;
34
+ # 5. `Verify` (final stage, task 8xg.t.sbz.7.4): the declared
35
+ # adapters, required tools and the declared gem set are part
36
+ # of the SAME trusted manifest (validated at load, reported
37
+ # under `adapters` / `tools` / `gemset`), and the converged
38
+ # lab must prove ready UNDER THE
39
+ # TARGET ACCOUNTS — tool availability, adapter commands,
40
+ # gem-set versions and every other readiness check — or
41
+ # install reports not ok. Dry runs skip this stage (nothing
42
+ # has converged to verify).
43
+ #
44
+ # Every step is idempotent, so an interrupted re-run converges
45
+ # where steps already completed (the resume contract .7.3 builds
46
+ # on: no journal — the report is a per-step record in prepare's
47
+ # established shape). The organism owns no new state: it writes
48
+ # only through the provisioners above; the trusted manifest
49
+ # remains the single owner of the adapter/tool declarations.
50
+ class Install
51
+ attr_reader :manifest_path, :dry_run, :platform
52
+
53
+ def initialize(manifest_path: nil, runner: nil, account_lookup: nil, privileged: nil,
54
+ platform: nil, dry_run: false, prepare: nil, project_add: nil, grants: nil, verify: nil)
55
+ @manifest_path = manifest_path || Ace::Lab.install_manifest_path
56
+ @runner = runner
57
+ @account_lookup = account_lookup
58
+ @privileged = privileged
59
+ @platform = platform
60
+ @dry_run = dry_run
61
+ @prepare = prepare
62
+ @project_add = project_add
63
+ @grants = grants
64
+ @verify = verify
65
+ end
66
+
67
+ # @param dry_run [Boolean, nil] per-call override of the
68
+ # constructor default (the CLI passes its --dry-run here)
69
+ # @return [Hash] aggregated report (ok / dry_run / machine /
70
+ # manifest / prepare / projects / grants / adapters / tools /
71
+ # gemset / verify)
72
+ # @raise [Ace::Lab::InvalidConfigurationError] missing, untrusted
73
+ # or invalid manifest, or a foreign lab refusing overwrite
74
+ # before any mutation
75
+ def call(dry_run: nil)
76
+ effective_dry_run = dry_run.nil? ? self.dry_run : dry_run
77
+ manifest = Molecules::InstallManifestLoader.load(path: manifest_path, platform: platform)
78
+ declared_projects = manifest["projects"].map { |project| project["id"] }
79
+
80
+ conflict = grants_molecule.foreign_grants?(declared_projects)
81
+ if conflict
82
+ raise Ace::Lab::InvalidConfigurationError,
83
+ "refusing to install over a foreign lab: #{conflict}"
84
+ end
85
+
86
+ prepare_report = prepare_organism.call(dry_run: effective_dry_run)
87
+ prepare_ok = prepare_report["ok"]
88
+
89
+ projects = manifest["projects"].map do |project|
90
+ if prepare_ok
91
+ project_add_organism.call(
92
+ id: project["id"], path: project["path"],
93
+ remote: project["remote"], label: project["label"],
94
+ owner: project_owner(manifest, project["path"]),
95
+ hitl_adapters: manifest["adapters"],
96
+ dry_run: effective_dry_run
97
+ )
98
+ else
99
+ dependency_failed_project(project,
100
+ "prepare did not converge; fix the prepare phase first", effective_dry_run)
101
+ end
102
+ end
103
+ projects_ok = projects.all? { |report| report["ok"] }
104
+
105
+ grants = if prepare_ok && projects_ok
106
+ grants_molecule.call(
107
+ desired: manifest["grants"], declared_projects: declared_projects,
108
+ dry_run: effective_dry_run
109
+ )
110
+ else
111
+ {"path" => Ace::Lab.authorization_path, "status" => "failed", "principals" => {},
112
+ "kept" => [], "dry_run" => effective_dry_run,
113
+ "message" => "install steps did not converge; fix the failing steps first " \
114
+ "(trusted grants are configured only for a converged install)"}
115
+ end
116
+
117
+ verify_report = verify_stage(manifest, effective_dry_run)
118
+
119
+ {
120
+ "ok" => prepare_ok && projects_ok && grants["status"] != "failed" &&
121
+ verify_stage_ok?(verify_report),
122
+ "dry_run" => effective_dry_run,
123
+ "machine" => manifest["machine"],
124
+ "manifest" => manifest_path,
125
+ "prepare" => prepare_report,
126
+ "projects" => projects,
127
+ "grants" => grants,
128
+ "adapters" => manifest["adapters"],
129
+ "tools" => manifest["tools"],
130
+ "gemset" => manifest["gemset"],
131
+ "verify" => verify_report
132
+ }
133
+ end
134
+
135
+ private
136
+
137
+ # Final install verification (LAB-3): read-only, over the
138
+ # converged artifacts. A wet run's readiness gates `ok`; a dry
139
+ # run has nothing converged to verify and reports the stage
140
+ # skipped instead of a guaranteed failure.
141
+ def verify_stage(manifest, dry_run)
142
+ return {"ready" => nil, "status" => "skipped",
143
+ "message" => "verification runs on the converged lab; dry-run predicts the " \
144
+ "steps above only"} if dry_run
145
+
146
+ verify_organism.call
147
+ rescue Ace::Lab::InvalidConfigurationError => e
148
+ {"ready" => false, "status" => "failed",
149
+ "message" => "final verification could not run: #{e.message}"}
150
+ rescue StandardError => e
151
+ {"ready" => false, "status" => "failed",
152
+ "message" => "final verification crashed: #{e.class}: #{e.message}"}
153
+ end
154
+
155
+ def verify_stage_ok?(report)
156
+ report["ready"] != false
157
+ end
158
+
159
+ def verify_organism
160
+ @verify || Verify.new(
161
+ manifest_path: manifest_path, runner: @runner, account_lookup: @account_lookup,
162
+ authorization_path: grants_molecule.authorization_path, platform: platform
163
+ )
164
+ end
165
+
166
+ # The project's target executor account: the owner of the
167
+ # declared directory the project lives in (longest prefix
168
+ # match; a directory declared AS the exact project path matches
169
+ # its own project too); projects outside any declared directory
170
+ # are left ownership-neutral.
171
+ def project_owner(manifest, path)
172
+ candidates = manifest["directories"].filter_map do |directory|
173
+ owner = directory["owner"]
174
+ prefix = directory["path"].to_s
175
+ matches = path.start_with?("#{prefix}/") || path == prefix
176
+ owner && matches ? [prefix.length, owner] : nil
177
+ end
178
+ candidates.max_by(&:first)&.last
179
+ end
180
+
181
+ def prepare_organism
182
+ @prepare || Prepare.new(
183
+ manifest_path: manifest_path, runner: @runner, account_lookup: @account_lookup,
184
+ privileged: @privileged, platform: platform
185
+ )
186
+ end
187
+
188
+ def project_add_organism
189
+ @project_add || ProjectAdd.new(runner: @runner, account_lookup: @account_lookup)
190
+ end
191
+
192
+ def grants_molecule
193
+ @grants || Molecules::GrantProvisioner.new
194
+ end
195
+
196
+ # Same shape as a ProjectAdd report, with every step failed as a
197
+ # dependency failure — mirroring prepare's dependency pattern.
198
+ # `dry_run` is the effective per-call value, not the constructor
199
+ # default (review r1).
200
+ def dependency_failed_project(project, message, dry_run)
201
+ failed = ->(location) { {"location" => location, "status" => "failed", "message" => message} }
202
+ {
203
+ "ok" => false,
204
+ "dry_run" => dry_run,
205
+ "project" => project["id"],
206
+ "path" => project["path"],
207
+ "remote" => project["remote"],
208
+ "repository" => failed.call(project["path"]),
209
+ "overseer" => failed.call(File.join(project["path"], ".ace-tasks", "_overseer")),
210
+ "registry" => failed.call(File.join(project["path"], ".ace", "lab", "config.yml")),
211
+ "hitl" => failed.call(File.join(project["path"], ".ace", "hitl", "config.yml")),
212
+ "owner" => failed.call(project["path"])
213
+ }
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "../molecules/install_manifest_loader"
5
+ require_relative "../molecules/account_provisioner"
6
+ require_relative "../molecules/directory_provisioner"
7
+ require_relative "../molecules/key_provisioner"
8
+ require_relative "../molecules/role_verifier"
9
+ require_relative "../molecules/gemset_provisioner"
10
+
11
+ module Ace
12
+ module Lab
13
+ module Organisms
14
+ # Orchestrates the preparation phase (L8.1) from the trusted install
15
+ # manifest: accounts → directories → keys → roles → gems, in that fixed
16
+ # order, each through its idempotent provisioner molecule. The
17
+ # aggregated report is the contract consumed by `ace-lab project add`
18
+ # (7.1) and `ace-lab install` (7.2); `ok` means zero failed items.
19
+ # Key and role items whose account failed are reported as dependency
20
+ # failures instead of being attempted; gem items fail as dependencies
21
+ # when the declared runtime did not prepare. No state is written beyond
22
+ # what the manifest declares — this organism owns no journal.
23
+ class Prepare
24
+ attr_reader :manifest_path, :dry_run, :platform
25
+
26
+ def initialize(manifest_path: nil, runner: nil, account_lookup: nil, privileged: nil,
27
+ platform: nil, dry_run: false)
28
+ @manifest_path = manifest_path || Ace::Lab.install_manifest_path
29
+ @runner = runner
30
+ @account_lookup = account_lookup
31
+ @privileged = privileged
32
+ @platform = platform
33
+ @dry_run = dry_run
34
+ end
35
+
36
+ # @param dry_run [Boolean, nil] per-call override of the
37
+ # constructor default (the CLI passes its --dry-run here)
38
+ # @return [Hash] aggregated report (ok / dry_run / machine /
39
+ # manifest / accounts / directories / keys / roles)
40
+ # @raise [Ace::Lab::InvalidConfigurationError] missing, untrusted,
41
+ # unparseable or schema-invalid manifest
42
+ def call(dry_run: nil)
43
+ effective_dry_run = dry_run.nil? ? self.dry_run : dry_run
44
+ manifest = Molecules::InstallManifestLoader.load(path: manifest_path, platform: platform)
45
+ accounts = prepare_accounts(manifest["accounts"], effective_dry_run)
46
+ available = available_accounts(accounts)
47
+ unresolved = unresolved_accounts(accounts)
48
+ directories = prepare_directories(manifest["directories"], effective_dry_run, available,
49
+ unresolved)
50
+ keys = prepare_keys(manifest["keys"], manifest["machine"], manifest["accounts"], available,
51
+ unresolved, effective_dry_run)
52
+ roles = prepare_roles(manifest["roles"], available, unresolved, effective_dry_run)
53
+ gems = prepare_gems(manifest["gemset"], effective_dry_run)
54
+
55
+ {
56
+ "ok" => [accounts, directories, keys, roles, gems].flatten.none? { |item| item["status"] == "failed" },
57
+ "dry_run" => effective_dry_run,
58
+ "machine" => manifest["machine"],
59
+ "manifest" => manifest_path,
60
+ "accounts" => accounts,
61
+ "directories" => directories,
62
+ "keys" => keys,
63
+ "roles" => roles,
64
+ "gems" => gems
65
+ }
66
+ end
67
+
68
+ private
69
+
70
+ def prepare_accounts(entries, dry_run)
71
+ provisioner = Molecules::AccountProvisioner.new(
72
+ runner: @runner, account_lookup: @account_lookup, privileged: @privileged,
73
+ platform: @platform, dry_run: dry_run
74
+ )
75
+ entries.map { |account| provisioner.call(account: account) }
76
+ end
77
+
78
+ def prepare_directories(entries, dry_run, available, unresolved)
79
+ provisioner = Molecules::DirectoryProvisioner.new(
80
+ account_lookup: @account_lookup, dry_run: dry_run, available_accounts: available
81
+ )
82
+ entries.map do |directory|
83
+ if unresolved.include?(directory["owner"])
84
+ # linux uid-lag edge, mirroring prepare_keys (round 5):
85
+ # the account phase created the owner but its uid is not
86
+ # yet resolvable — the remedy is a convergence rerun,
87
+ # not "fix the account phase" (that phase did not fail).
88
+ {"path" => directory["path"], "owner" => directory["owner"],
89
+ "mode" => directory["mode"], "status" => "failed",
90
+ "message" => "owner account '#{directory["owner"]}' was created but its uid is " \
91
+ "not yet resolvable; rerun ace-lab prepare"}
92
+ else
93
+ provisioner.call(directory: directory)
94
+ end
95
+ end
96
+ end
97
+
98
+ def prepare_keys(entries, machine, manifest_accounts, available, unresolved, dry_run)
99
+ provisioner = Molecules::KeyProvisioner.new(
100
+ runner: @runner, account_lookup: @account_lookup, dry_run: dry_run
101
+ )
102
+ by_username = manifest_accounts.to_h { |account| [account["username"], account] }
103
+ entries.map do |key|
104
+ account = by_username.fetch(key["account"])
105
+ username = account["username"]
106
+ if unresolved.include?(username)
107
+ # linux uid-lag edge: the account phase reported created
108
+ # with a nil uid (post-useradd passwd lookup lag) — the
109
+ # remedy is a convergence rerun, not "fix the account
110
+ # phase" (that phase did not fail).
111
+ {"account" => username, "path" => key["path"], "status" => "failed",
112
+ "fingerprint" => nil,
113
+ "message" => "account '#{username}' was created but its uid is not yet " \
114
+ "resolvable; rerun ace-lab prepare"}
115
+ elsif available.include?(username)
116
+ provisioner.call(key: key, account: account, machine: machine)
117
+ else
118
+ {"account" => username, "path" => key["path"], "status" => "failed",
119
+ "fingerprint" => nil,
120
+ "message" => "account '#{username}' is not available; fix the account phase first"}
121
+ end
122
+ end
123
+ end
124
+
125
+ def prepare_roles(entries, available, unresolved, dry_run)
126
+ provisioner = Molecules::RoleVerifier.new(account_lookup: @account_lookup, dry_run: dry_run)
127
+ entries.map do |role|
128
+ if unresolved.include?(role["account"])
129
+ # linux uid-lag edge, mirroring prepare_keys (round 5):
130
+ # same rerun remedy, not "fix the account phase".
131
+ {"id" => role["id"], "account" => role["account"], "instructions" => role["instructions"],
132
+ "status" => "failed",
133
+ "message" => "account '#{role["account"]}' was created but its uid is not yet " \
134
+ "resolvable; rerun ace-lab prepare"}
135
+ elsif available.include?(role["account"])
136
+ provisioner.call(role: role)
137
+ else
138
+ {"id" => role["id"], "account" => role["account"], "instructions" => role["instructions"],
139
+ "status" => "failed",
140
+ "message" => "account '#{role['account']}' is not available; fix the account phase first"}
141
+ end
142
+ end
143
+ end
144
+
145
+ # The declared gem set converged into the declared runtime
146
+ # (gad.2): machine-level like every other section — independent
147
+ # of the account phases, gated by its own runtime preparation.
148
+ def prepare_gems(runtime, dry_run)
149
+ provisioner = Molecules::GemsetProvisioner.new(
150
+ runner: @runner, privileged: @privileged, dry_run: dry_run
151
+ )
152
+ provisioner.call(runtime: runtime)
153
+ end
154
+
155
+ def available_accounts(account_reports)
156
+ account_reports.reject { |report| report["status"] == "failed" }
157
+ .map { |report| report["username"] }
158
+ end
159
+
160
+ # Accounts whose creation succeeded but whose uid is not yet
161
+ # resolvable (linux post-useradd passwd lag): the phase did not
162
+ # fail, but dependent key provisioning cannot chown to them.
163
+ def unresolved_accounts(account_reports)
164
+ account_reports.select { |report| report["status"] == "created" && report["uid"].nil? }
165
+ .map { |report| report["username"] }
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end