ace-lab 0.8.0 → 0.13.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +363 -92
  3. data/README.md +66 -100
  4. data/docs/usage.md +529 -404
  5. data/lib/ace/lab/atoms/install_schema.rb +373 -0
  6. data/lib/ace/lab/cli/commands/install.rb +161 -0
  7. data/lib/ace/lab/cli/commands/prepare.rb +118 -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 -13
  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/grant_provisioner.rb +250 -0
  18. data/lib/ace/lab/molecules/grant_resolver.rb +49 -23
  19. data/lib/ace/lab/molecules/hitl_routes.rb +263 -0
  20. data/lib/ace/lab/molecules/install_manifest_loader.rb +64 -0
  21. data/lib/ace/lab/molecules/key_provisioner.rb +736 -0
  22. data/lib/ace/lab/molecules/lab_state_scanner.rb +55 -0
  23. data/lib/ace/lab/molecules/overseer_provisioner.rb +188 -0
  24. data/lib/ace/lab/molecules/project_registry.rb +251 -0
  25. data/lib/ace/lab/molecules/repository_provisioner.rb +141 -0
  26. data/lib/ace/lab/molecules/role_verifier.rb +223 -0
  27. data/lib/ace/lab/organisms/install.rb +213 -0
  28. data/lib/ace/lab/organisms/prepare.rb +156 -0
  29. data/lib/ace/lab/organisms/project_add.rb +170 -0
  30. data/lib/ace/lab/organisms/status_service.rb +70 -0
  31. data/lib/ace/lab/organisms/verify.rb +768 -0
  32. data/lib/ace/lab/version.rb +1 -1
  33. data/lib/ace/lab.rb +34 -6
  34. metadata +34 -102
  35. data/lib/ace/lab/atoms/protected_publication_input.rb +0 -50
  36. data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +0 -114
  37. data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +0 -86
  38. data/lib/ace/lab/atoms/service_input.rb +0 -92
  39. data/lib/ace/lab/cli/commands/assignment.rb +0 -45
  40. data/lib/ace/lab/cli/commands/service.rb +0 -240
  41. data/lib/ace/lab/models/service_receipt.rb +0 -18
  42. data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
  43. data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
  44. data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
  45. data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
  46. data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
  47. data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -139
  48. data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
  49. data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
  50. data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
  51. data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
  52. data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
  53. data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
  54. data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
  55. data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
  56. data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
  57. data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
  58. data/lib/ace/lab/molecules/service_executor.rb +0 -99
  59. data/lib/ace/lab/molecules/service_policy.rb +0 -289
  60. data/lib/ace/lab/organisms/authority_composition.rb +0 -87
  61. data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -149
  62. data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -123
  63. data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -688
  64. data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
  65. data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
  66. data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
  67. data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
  68. data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
  69. data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
  70. data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
  71. data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
  72. data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
  73. 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,213 @@
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
+ # 3. `ProjectAdd` (L8.2) per declared project: repository,
24
+ # private `_overseer`, project registry, runtime HITL route
25
+ # configuration (the manifest's `kind: hitl` adapters
26
+ # projected into `.ace/hitl/config.yml`, FINAL-LAB2) and
27
+ # executor ownership propagation — each project is isolated;
28
+ # one failing project does not block the others;
29
+ # 4. `GrantProvisioner`: trusted-tier principal grants that
30
+ # make the installed projects visible to `ace-lab projects`
31
+ # and `ace-overseer projects`;
32
+ # 5. `Verify` (final stage, task 8xg.t.sbz.7.4): the declared
33
+ # adapters and required tools are part of the SAME trusted
34
+ # manifest (validated at load, reported under `adapters` /
35
+ # `tools`), and the converged lab must prove ready UNDER THE
36
+ # TARGET ACCOUNTS — tool availability, adapter commands and
37
+ # every other readiness check — or install reports not ok.
38
+ # Dry runs skip this stage (nothing has converged to verify).
39
+ #
40
+ # Every step is idempotent, so an interrupted re-run converges
41
+ # where steps already completed (the resume contract .7.3 builds
42
+ # on: no journal — the report is a per-step record in prepare's
43
+ # established shape). The organism owns no new state: it writes
44
+ # only through the provisioners above; the trusted manifest
45
+ # remains the single owner of the adapter/tool declarations.
46
+ class Install
47
+ attr_reader :manifest_path, :dry_run, :platform
48
+
49
+ def initialize(manifest_path: nil, runner: nil, account_lookup: nil, privileged: nil,
50
+ platform: nil, dry_run: false, prepare: nil, project_add: nil, grants: nil, verify: nil)
51
+ @manifest_path = manifest_path || Ace::Lab.install_manifest_path
52
+ @runner = runner
53
+ @account_lookup = account_lookup
54
+ @privileged = privileged
55
+ @platform = platform
56
+ @dry_run = dry_run
57
+ @prepare = prepare
58
+ @project_add = project_add
59
+ @grants = grants
60
+ @verify = verify
61
+ end
62
+
63
+ # @param dry_run [Boolean, nil] per-call override of the
64
+ # constructor default (the CLI passes its --dry-run here)
65
+ # @return [Hash] aggregated report (ok / dry_run / machine /
66
+ # manifest / prepare / projects / grants / adapters / tools /
67
+ # verify)
68
+ # @raise [Ace::Lab::InvalidConfigurationError] missing, untrusted
69
+ # or invalid manifest, or a foreign lab refusing overwrite
70
+ # before any mutation
71
+ def call(dry_run: nil)
72
+ effective_dry_run = dry_run.nil? ? self.dry_run : dry_run
73
+ manifest = Molecules::InstallManifestLoader.load(path: manifest_path, platform: platform)
74
+ declared_projects = manifest["projects"].map { |project| project["id"] }
75
+
76
+ conflict = grants_molecule.foreign_grants?(declared_projects)
77
+ if conflict
78
+ raise Ace::Lab::InvalidConfigurationError,
79
+ "refusing to install over a foreign lab: #{conflict}"
80
+ end
81
+
82
+ prepare_report = prepare_organism.call(dry_run: effective_dry_run)
83
+ prepare_ok = prepare_report["ok"]
84
+
85
+ projects = manifest["projects"].map do |project|
86
+ if prepare_ok
87
+ project_add_organism.call(
88
+ id: project["id"], path: project["path"],
89
+ remote: project["remote"], label: project["label"],
90
+ owner: project_owner(manifest, project["path"]),
91
+ hitl_adapters: manifest["adapters"],
92
+ dry_run: effective_dry_run
93
+ )
94
+ else
95
+ dependency_failed_project(project,
96
+ "prepare did not converge; fix the prepare phase first", effective_dry_run)
97
+ end
98
+ end
99
+ projects_ok = projects.all? { |report| report["ok"] }
100
+
101
+ grants = if prepare_ok && projects_ok
102
+ grants_molecule.call(
103
+ desired: manifest["grants"], declared_projects: declared_projects,
104
+ dry_run: effective_dry_run
105
+ )
106
+ else
107
+ {"path" => Ace::Lab.authorization_path, "status" => "failed", "principals" => {},
108
+ "kept" => [], "dry_run" => effective_dry_run,
109
+ "message" => "install steps did not converge; fix the failing steps first " \
110
+ "(trusted grants are configured only for a converged install)"}
111
+ end
112
+
113
+ verify_report = verify_stage(manifest, effective_dry_run)
114
+
115
+ {
116
+ "ok" => prepare_ok && projects_ok && grants["status"] != "failed" &&
117
+ verify_stage_ok?(verify_report),
118
+ "dry_run" => effective_dry_run,
119
+ "machine" => manifest["machine"],
120
+ "manifest" => manifest_path,
121
+ "prepare" => prepare_report,
122
+ "projects" => projects,
123
+ "grants" => grants,
124
+ "adapters" => manifest["adapters"],
125
+ "tools" => manifest["tools"],
126
+ "verify" => verify_report
127
+ }
128
+ end
129
+
130
+ private
131
+
132
+ # Final install verification (LAB-3): read-only, over the
133
+ # converged artifacts. A wet run's readiness gates `ok`; a dry
134
+ # run has nothing converged to verify and reports the stage
135
+ # skipped instead of a guaranteed failure.
136
+ def verify_stage(manifest, dry_run)
137
+ return {"ready" => nil, "status" => "skipped",
138
+ "message" => "verification runs on the converged lab; dry-run predicts the " \
139
+ "steps above only"} if dry_run
140
+
141
+ verify_organism.call
142
+ rescue Ace::Lab::InvalidConfigurationError => e
143
+ {"ready" => false, "status" => "failed",
144
+ "message" => "final verification could not run: #{e.message}"}
145
+ rescue StandardError => e
146
+ {"ready" => false, "status" => "failed",
147
+ "message" => "final verification crashed: #{e.class}: #{e.message}"}
148
+ end
149
+
150
+ def verify_stage_ok?(report)
151
+ report["ready"] != false
152
+ end
153
+
154
+ def verify_organism
155
+ @verify || Verify.new(
156
+ manifest_path: manifest_path, runner: @runner, account_lookup: @account_lookup,
157
+ authorization_path: grants_molecule.authorization_path, platform: platform
158
+ )
159
+ end
160
+
161
+ # The project's target executor account: the owner of the
162
+ # declared directory the project lives in (longest prefix
163
+ # match; a directory declared AS the exact project path matches
164
+ # its own project too); projects outside any declared directory
165
+ # are left ownership-neutral.
166
+ def project_owner(manifest, path)
167
+ candidates = manifest["directories"].filter_map do |directory|
168
+ owner = directory["owner"]
169
+ prefix = directory["path"].to_s
170
+ matches = path.start_with?("#{prefix}/") || path == prefix
171
+ owner && matches ? [prefix.length, owner] : nil
172
+ end
173
+ candidates.max_by(&:first)&.last
174
+ end
175
+
176
+ def prepare_organism
177
+ @prepare || Prepare.new(
178
+ manifest_path: manifest_path, runner: @runner, account_lookup: @account_lookup,
179
+ privileged: @privileged, platform: platform
180
+ )
181
+ end
182
+
183
+ def project_add_organism
184
+ @project_add || ProjectAdd.new(runner: @runner, account_lookup: @account_lookup)
185
+ end
186
+
187
+ def grants_molecule
188
+ @grants || Molecules::GrantProvisioner.new
189
+ end
190
+
191
+ # Same shape as a ProjectAdd report, with every step failed as a
192
+ # dependency failure — mirroring prepare's dependency pattern.
193
+ # `dry_run` is the effective per-call value, not the constructor
194
+ # default (review r1).
195
+ def dependency_failed_project(project, message, dry_run)
196
+ failed = ->(location) { {"location" => location, "status" => "failed", "message" => message} }
197
+ {
198
+ "ok" => false,
199
+ "dry_run" => dry_run,
200
+ "project" => project["id"],
201
+ "path" => project["path"],
202
+ "remote" => project["remote"],
203
+ "repository" => failed.call(project["path"]),
204
+ "overseer" => failed.call(File.join(project["path"], ".ace-tasks", "_overseer")),
205
+ "registry" => failed.call(File.join(project["path"], ".ace", "lab", "config.yml")),
206
+ "hitl" => failed.call(File.join(project["path"], ".ace", "hitl", "config.yml")),
207
+ "owner" => failed.call(project["path"])
208
+ }
209
+ end
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,156 @@
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
+
10
+ module Ace
11
+ module Lab
12
+ module Organisms
13
+ # Orchestrates the preparation phase (L8.1) from the trusted install
14
+ # manifest: accounts → directories → keys → roles, in that fixed
15
+ # order, each through its idempotent provisioner molecule. The
16
+ # aggregated report is the contract consumed by `ace-lab project add`
17
+ # (7.1) and `ace-lab install` (7.2); `ok` means zero failed items.
18
+ # Key and role items whose account failed are reported as dependency
19
+ # failures instead of being attempted. No state is written beyond
20
+ # what the manifest declares — this organism owns no journal.
21
+ class Prepare
22
+ attr_reader :manifest_path, :dry_run, :platform
23
+
24
+ def initialize(manifest_path: nil, runner: nil, account_lookup: nil, privileged: nil,
25
+ platform: nil, dry_run: false)
26
+ @manifest_path = manifest_path || Ace::Lab.install_manifest_path
27
+ @runner = runner
28
+ @account_lookup = account_lookup
29
+ @privileged = privileged
30
+ @platform = platform
31
+ @dry_run = dry_run
32
+ end
33
+
34
+ # @param dry_run [Boolean, nil] per-call override of the
35
+ # constructor default (the CLI passes its --dry-run here)
36
+ # @return [Hash] aggregated report (ok / dry_run / machine /
37
+ # manifest / accounts / directories / keys / roles)
38
+ # @raise [Ace::Lab::InvalidConfigurationError] missing, untrusted,
39
+ # unparseable or schema-invalid manifest
40
+ def call(dry_run: nil)
41
+ effective_dry_run = dry_run.nil? ? self.dry_run : dry_run
42
+ manifest = Molecules::InstallManifestLoader.load(path: manifest_path, platform: platform)
43
+ accounts = prepare_accounts(manifest["accounts"], effective_dry_run)
44
+ available = available_accounts(accounts)
45
+ unresolved = unresolved_accounts(accounts)
46
+ directories = prepare_directories(manifest["directories"], effective_dry_run, available,
47
+ unresolved)
48
+ keys = prepare_keys(manifest["keys"], manifest["machine"], manifest["accounts"], available,
49
+ unresolved, effective_dry_run)
50
+ roles = prepare_roles(manifest["roles"], available, unresolved, effective_dry_run)
51
+
52
+ {
53
+ "ok" => [accounts, directories, keys, roles].flatten.none? { |item| item["status"] == "failed" },
54
+ "dry_run" => effective_dry_run,
55
+ "machine" => manifest["machine"],
56
+ "manifest" => manifest_path,
57
+ "accounts" => accounts,
58
+ "directories" => directories,
59
+ "keys" => keys,
60
+ "roles" => roles
61
+ }
62
+ end
63
+
64
+ private
65
+
66
+ def prepare_accounts(entries, dry_run)
67
+ provisioner = Molecules::AccountProvisioner.new(
68
+ runner: @runner, account_lookup: @account_lookup, privileged: @privileged,
69
+ platform: @platform, dry_run: dry_run
70
+ )
71
+ entries.map { |account| provisioner.call(account: account) }
72
+ end
73
+
74
+ def prepare_directories(entries, dry_run, available, unresolved)
75
+ provisioner = Molecules::DirectoryProvisioner.new(
76
+ account_lookup: @account_lookup, dry_run: dry_run, available_accounts: available
77
+ )
78
+ entries.map do |directory|
79
+ if unresolved.include?(directory["owner"])
80
+ # linux uid-lag edge, mirroring prepare_keys (round 5):
81
+ # the account phase created the owner but its uid is not
82
+ # yet resolvable — the remedy is a convergence rerun,
83
+ # not "fix the account phase" (that phase did not fail).
84
+ {"path" => directory["path"], "owner" => directory["owner"],
85
+ "mode" => directory["mode"], "status" => "failed",
86
+ "message" => "owner account '#{directory["owner"]}' was created but its uid is " \
87
+ "not yet resolvable; rerun ace-lab prepare"}
88
+ else
89
+ provisioner.call(directory: directory)
90
+ end
91
+ end
92
+ end
93
+
94
+ def prepare_keys(entries, machine, manifest_accounts, available, unresolved, dry_run)
95
+ provisioner = Molecules::KeyProvisioner.new(
96
+ runner: @runner, account_lookup: @account_lookup, dry_run: dry_run
97
+ )
98
+ by_username = manifest_accounts.to_h { |account| [account["username"], account] }
99
+ entries.map do |key|
100
+ account = by_username.fetch(key["account"])
101
+ username = account["username"]
102
+ if unresolved.include?(username)
103
+ # linux uid-lag edge: the account phase reported created
104
+ # with a nil uid (post-useradd passwd lookup lag) — the
105
+ # remedy is a convergence rerun, not "fix the account
106
+ # phase" (that phase did not fail).
107
+ {"account" => username, "path" => key["path"], "status" => "failed",
108
+ "fingerprint" => nil,
109
+ "message" => "account '#{username}' was created but its uid is not yet " \
110
+ "resolvable; rerun ace-lab prepare"}
111
+ elsif available.include?(username)
112
+ provisioner.call(key: key, account: account, machine: machine)
113
+ else
114
+ {"account" => username, "path" => key["path"], "status" => "failed",
115
+ "fingerprint" => nil,
116
+ "message" => "account '#{username}' is not available; fix the account phase first"}
117
+ end
118
+ end
119
+ end
120
+
121
+ def prepare_roles(entries, available, unresolved, dry_run)
122
+ provisioner = Molecules::RoleVerifier.new(account_lookup: @account_lookup, dry_run: dry_run)
123
+ entries.map do |role|
124
+ if unresolved.include?(role["account"])
125
+ # linux uid-lag edge, mirroring prepare_keys (round 5):
126
+ # same rerun remedy, not "fix the account phase".
127
+ {"id" => role["id"], "account" => role["account"], "instructions" => role["instructions"],
128
+ "status" => "failed",
129
+ "message" => "account '#{role["account"]}' was created but its uid is not yet " \
130
+ "resolvable; rerun ace-lab prepare"}
131
+ elsif available.include?(role["account"])
132
+ provisioner.call(role: role)
133
+ else
134
+ {"id" => role["id"], "account" => role["account"], "instructions" => role["instructions"],
135
+ "status" => "failed",
136
+ "message" => "account '#{role['account']}' is not available; fix the account phase first"}
137
+ end
138
+ end
139
+ end
140
+
141
+ def available_accounts(account_reports)
142
+ account_reports.reject { |report| report["status"] == "failed" }
143
+ .map { |report| report["username"] }
144
+ end
145
+
146
+ # Accounts whose creation succeeded but whose uid is not yet
147
+ # resolvable (linux post-useradd passwd lag): the phase did not
148
+ # fail, but dependent key provisioning cannot chown to them.
149
+ def unresolved_accounts(account_reports)
150
+ account_reports.select { |report| report["status"] == "created" && report["uid"].nil? }
151
+ .map { |report| report["username"] }
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end