ace-lab 0.13.0 → 0.14.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.
@@ -0,0 +1,441 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require "open3"
5
+ require "fileutils"
6
+
7
+ module Ace
8
+ module Lab
9
+ module Molecules
10
+ # Idempotently converges the install manifest's declared gem set
11
+ # into the declared target runtime (GEM_HOME; gad.2 gap #1, owner
12
+ # spec 8xg.t.sbz.7): a gem whose exact declared version is already
13
+ # present is a no-op (`exists`), a missing gem is installed
14
+ # (`installed`), a gem present at a different version is updated
15
+ # (the declared version installed alongside — RubyGems keeps
16
+ # versions side by side; verify judges the declared set). Probes
17
+ # and installs run the `gem` executable with GEM_HOME/GEM_PATH
18
+ # pinned to the target runtime through the SAME argv runner seam
19
+ # the other prepare molecules use (POSIX `env`, never a shell
20
+ # string, ADR-031) — so the lab runtime stays isolated from the
21
+ # installer's own gems and the runner stays injectable for
22
+ # dry-run and tests. The runtime directory itself is created only
23
+ # with administrator privileges (the same seam
24
+ # AccountProvisioner gates creation with) and a symlink or
25
+ # non-directory at the declared path is refused (the
26
+ # DirectoryProvisioner takeover posture). Every failure is a
27
+ # classified per-gem item with an actionable message; command
28
+ # failures surface only the exit status and the first stderr line.
29
+ class GemsetProvisioner
30
+ attr_reader :runner, :privileged, :dry_run
31
+
32
+ def initialize(runner: nil, privileged: nil, dry_run: false)
33
+ @runner = runner || method(:run_argv)
34
+ @privileged = privileged || -> { Process.euid.zero? }
35
+ @dry_run = dry_run
36
+ end
37
+
38
+ # @param runtime [Hash] normalized manifest gemset section
39
+ # ({"gem_home" => path, "gems" => [{"name" =>, "version" =>}]})
40
+ # @return [Array<Hash>] one report per declared gem:
41
+ # {name:, version:, gem_home:, status:, message:}
42
+ def call(runtime:)
43
+ gems = runtime["gems"]
44
+ return [] if gems.empty?
45
+
46
+ gem_home = runtime["gem_home"]
47
+ runtime_failure = ensure_runtime(gem_home)
48
+ gems.map do |gem|
49
+ if runtime_failure
50
+ base(gem, gem_home).merge("status" => "failed", "message" => runtime_failure)
51
+ else
52
+ report = converge(base(gem, gem_home), gem, gem_home)
53
+ if %w[exists installed updated].include?(report["status"])
54
+ converge_post_presence(report, gem, gem_home)
55
+ else
56
+ report
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ # Public READ-ONLY probe (L8.5): the local versions of `name`
63
+ # inside the target runtime — the exact probe convergence runs,
64
+ # exposed so the readiness verifier reuses the same logic
65
+ # instead of reimplementing it (the two can never drift).
66
+ # @param name [String] gem name
67
+ # @param gem_home [String] target runtime (GEM_HOME)
68
+ # @return [Array(Symbol, Object)] [:ok, versions],
69
+ # [:unknown, message] (no `gem` executable — offline posture)
70
+ # or [:failed, message]
71
+ def runtime_versions(name:, gem_home:)
72
+ result = @runner.call(runtime_argv(gem_home,
73
+ ["gem", "list", name, "--exact", "--local"]))
74
+ return [:unknown, "gem not found in PATH; install RubyGems and rerun ace-lab prepare"] if result["status"] == "enoent"
75
+ return [:failed, command_failure("gem list", result)] unless result["status"] == "ok"
76
+
77
+ versions = parse_versions(result["out"], name)
78
+ if versions.nil?
79
+ return [:failed, "could not determine installed versions of gem '#{name}' from " \
80
+ "'gem list' output; rerun ace-lab prepare"]
81
+ end
82
+
83
+ [:ok, versions]
84
+ end
85
+
86
+ # Public READ-ONLY probes (verify, task 8xs.t.pu0.0): the same
87
+ # primitives convergence runs, exposed so readiness reuses the
88
+ # identical logic instead of reimplementing it.
89
+ #
90
+ # Unsatisfied runtime dependencies of the declared gem — the
91
+ # concrete missing-dependency list readiness reports.
92
+ # @return [Array(Symbol, Object)] [:ok, reasons] (empty reasons
93
+ # = closed) or the failing probe's [:unknown/:failed,
94
+ # message] — the same shape newer_versions returns.
95
+ def closure_failures(name:, version:, gem_home:)
96
+ queue = [[name, version]]
97
+ seen = {}
98
+ reasons = []
99
+ until queue.empty?
100
+ current_name, current_version = queue.shift
101
+ next if seen[[current_name, current_version]]
102
+
103
+ seen[[current_name, current_version]] = true
104
+ deps = runtime_dependencies(name: current_name, version: current_version, gem_home: gem_home)
105
+ return [:failed, deps] if deps.is_a?(String)
106
+
107
+ deps.each do |dep|
108
+ kind, versions_or_message = runtime_versions(name: dep["name"], gem_home: gem_home)
109
+ return [kind, versions_or_message] if kind != :ok
110
+
111
+ satisfying = satisfying_versions(dep, versions_or_message)
112
+ if satisfying.empty?
113
+ reasons << "dependency '#{dep["name"]} (#{dep["requirement"]})' required by gem " \
114
+ "'#{current_name}' is not satisfied in runtime '#{gem_home}'"
115
+ else
116
+ queue << [dep["name"], satisfying.max_by { |v| Gem::Version.new(v) }]
117
+ end
118
+ end
119
+ end
120
+ [:ok, reasons]
121
+ end
122
+
123
+ # Installed versions of `name` NEWER than the declared one —
124
+ # the versions that would win activation and take over the CLI.
125
+ # @return [Array(String, Object)] [:ok, versions] or the probe's
126
+ # [:unknown/:failed, message]
127
+ def newer_versions(name:, declared_version:, gem_home:)
128
+ kind, versions_or_message = runtime_versions(name: name, gem_home: gem_home)
129
+ return [kind, versions_or_message] if kind != :ok
130
+
131
+ declared = Gem::Version.new(declared_version)
132
+ [:ok, versions_or_message.select { |version| Gem::Version.new(version) > declared }]
133
+ end
134
+
135
+ private
136
+
137
+ def base(gem, gem_home)
138
+ {"name" => gem["name"], "version" => gem["version"], "gem_home" => gem_home}
139
+ end
140
+
141
+ # One runtime failure fails every declared gem as a dependency
142
+ # (the keys-need-accounts posture): convergence is never
143
+ # attempted against a runtime that did not prepare.
144
+ def ensure_runtime(gem_home)
145
+ stat = lstat(gem_home)
146
+ return "gem runtime '#{gem_home}' is a symbolic link; refusing" if stat&.symlink?
147
+ return "gem runtime '#{gem_home}' exists but is not a directory; refusing" if stat && !stat.directory?
148
+ return nil if stat
149
+
150
+ unless @privileged.call
151
+ return "creating gem runtime '#{gem_home}' requires administrator privileges; " \
152
+ "rerun ace-lab prepare with sudo or create the directory manually"
153
+ end
154
+ return nil if @dry_run # prediction: creation would succeed
155
+
156
+ FileUtils.mkdir_p(gem_home)
157
+ nil
158
+ rescue Errno::EPERM, Errno::EACCES
159
+ "insufficient permissions while preparing gem runtime '#{gem_home}'; " \
160
+ "rerun ace-lab prepare with sudo or adjust ownership manually"
161
+ rescue SystemCallError => e
162
+ "gem runtime '#{gem_home}' could not be created (#{e.class.name}); rerun ace-lab prepare"
163
+ end
164
+
165
+ def converge(base, gem, gem_home)
166
+ kind, versions_or_message = runtime_versions(name: gem["name"], gem_home: gem_home)
167
+ # Prepare's fail-closed posture: an unprobeable runtime (no
168
+ # `gem` executable — the :unknown probe kind) fails the item in
169
+ # wet AND dry runs, exactly as the wet run would fail; verify
170
+ # maps the same probe kind to its offline `unknown` posture.
171
+ return base.merge("status" => "failed", "message" => versions_or_message) if kind != :ok
172
+
173
+ versions = versions_or_message
174
+ return base.merge("status" => "exists", "message" => nil) if versions.include?(gem["version"])
175
+
176
+ status = versions.empty? ? "install" : "update"
177
+ return base.merge("status" => "would_#{status}", "message" => nil) if @dry_run
178
+
179
+ failure = install(gem, gem_home)
180
+ return base.merge("status" => "failed", "message" => failure) if failure
181
+
182
+ if status == "install"
183
+ base.merge("status" => "installed", "message" => nil)
184
+ else
185
+ base.merge("status" => "updated", "message" => nil)
186
+ end
187
+ end
188
+
189
+ # Post-presence convergence (task 8xs.t.pu0.0): the declared
190
+ # version being present is necessary but not sufficient.
191
+ # 1. NEWER versions of the declared gem sitting beside the
192
+ # declared one would win RubyGems activation and take over
193
+ # the runtime's CLI — the manifest is the single owner of
194
+ # the declared set, so convergence removes them from the
195
+ # target runtime.
196
+ # 2. The dependency closure must actually resolve in the
197
+ # runtime: a gem whose version already figures in
198
+ # `gem list` can still miss a transitive runtime
199
+ # dependency (the manually-repaired `json` class of gap);
200
+ # missing dependencies are converged by installing them
201
+ # at the parent's requirement, and an unsatisfiable
202
+ # dependency is a classified failure — never readiness.
203
+ # A dry run predicts both without writing (would_uninstall /
204
+ # would_install) and stops recursion at the prediction.
205
+ def converge_post_presence(report, gem, gem_home)
206
+ removal = converge_newer_versions(gem, gem_home)
207
+ return report.merge("status" => "failed", "message" => removal) if removal.is_a?(String)
208
+ report["removed_newer_versions"] = removal
209
+
210
+ closure = converge_closure(gem, gem_home)
211
+ return report.merge("status" => "failed", "message" => closure) if closure.is_a?(String)
212
+ report["closure"] = closure
213
+ report
214
+ end
215
+
216
+ # @return [Array<Hash>, String] per-version removal reports or a
217
+ # classified failure message
218
+ def converge_newer_versions(gem, gem_home)
219
+ kind, versions_or_message = runtime_versions(name: gem["name"], gem_home: gem_home)
220
+ return versions_or_message if kind != :ok
221
+
222
+ declared = Gem::Version.new(gem["version"])
223
+ newer = versions_or_message.select { |version| Gem::Version.new(version) > declared }
224
+ return [] if newer.empty?
225
+
226
+ return newer.map { |version| {"version" => version, "status" => "would_uninstall"} } if @dry_run
227
+
228
+ newer.map do |version|
229
+ result = @runner.call(runtime_argv(gem_home,
230
+ ["gem", "uninstall", gem["name"], "--version", version, "-x", "--force"]))
231
+ if result["status"] != "ok"
232
+ break command_failure("gem uninstall #{gem["name"]} #{version}", result)
233
+ end
234
+ {"version" => version, "status" => "uninstalled"}
235
+ end
236
+ end
237
+
238
+ # Breadth-first walk of the gem's RUNTIME dependency closure in
239
+ # the target runtime. `gem dependency` output is plain text (no
240
+ # marshaled objects), development dependencies are ignored.
241
+ # @return [Array<Hash>, String] per-dependency convergence
242
+ # actions or a classified failure message
243
+ def converge_closure(gem, gem_home)
244
+ queue = [[gem["name"], gem["version"]]]
245
+ seen = {}
246
+ actions = []
247
+ until queue.empty?
248
+ name, version = queue.shift
249
+ next if seen[[name, version]]
250
+
251
+ seen[[name, version]] = true
252
+ deps = runtime_dependencies(name: name, version: version, gem_home: gem_home)
253
+ return deps if deps.is_a?(String)
254
+
255
+ deps.each do |dep|
256
+ kind, versions_or_message = runtime_versions(name: dep["name"], gem_home: gem_home)
257
+ return versions_or_message if kind != :ok
258
+
259
+ satisfying = satisfying_versions(dep, versions_or_message)
260
+ if satisfying.empty?
261
+ if @dry_run
262
+ actions << {"name" => dep["name"], "requirement" => dep["requirement"],
263
+ "status" => "would_install"}
264
+ next # recursion stops at the prediction
265
+ end
266
+
267
+ failure = install_dependency(dep, gem_home)
268
+ return failure if failure
269
+
270
+ kind, versions_or_message = runtime_versions(name: dep["name"], gem_home: gem_home)
271
+ return versions_or_message if kind != :ok
272
+ satisfying = satisfying_versions(dep, versions_or_message)
273
+ if satisfying.empty?
274
+ return "dependency '#{dep["name"]} (#{dep["requirement"]})' required by gem " \
275
+ "'#{name}' is not satisfiable in runtime '#{gem_home}' after install; " \
276
+ "check the gem source or the manifest declaration"
277
+ end
278
+ actions << {"name" => dep["name"], "requirement" => dep["requirement"],
279
+ "status" => "installed"}
280
+ end
281
+
282
+ chosen = satisfying.max_by { |v| Gem::Version.new(v) }
283
+ queue << [dep["name"], chosen]
284
+ end
285
+ end
286
+ actions
287
+ end
288
+
289
+ # Installs a missing dependency at the parent requirement: the
290
+ # requirement string the runtime's own resolver would accept
291
+ # (`gem install name -v "~> 2.7"`).
292
+ # @return [String, nil] classified failure, nil on success
293
+ def install_dependency(dep, gem_home)
294
+ result = @runner.call(runtime_argv(gem_home,
295
+ ["gem", "install", dep["name"], "--version", dep["requirement"], "--no-document"]))
296
+ return nil if result["status"] == "ok"
297
+
298
+ command_failure("gem install #{dep["name"]}", result)
299
+ end
300
+
301
+ # The runtime (non-development) dependencies of an installed
302
+ # gem, from `gem dependency <name> --version <v>` plain-text
303
+ # output: "dep (~> 2.0, runtime)" lines under the gem header.
304
+ # @return [Array<Hash{"name","requirement"}>, String] or a
305
+ # classified failure message
306
+ def runtime_dependencies(name:, version:, gem_home:)
307
+ result = @runner.call(runtime_argv(gem_home,
308
+ ["gem", "dependency", name, "--version", version]))
309
+ if result["status"] == "enoent"
310
+ return "gem not found in PATH; install RubyGems and rerun ace-lab prepare"
311
+ end
312
+ return "gem dependency failed (#{exit_detail(result["status"])}); rerun ace-lab prepare" \
313
+ unless result["status"] == "ok"
314
+
315
+ result["out"].lines.filter_map do |line|
316
+ match = line.match(/\A\s+([A-Za-z0-9._-]+) \(([^)]*)\)\s*\z/)
317
+ next if match.nil?
318
+
319
+ parts = match[2].split(",").map(&:strip)
320
+ type = %w[runtime development].include?(parts.last) ? parts.pop : "runtime"
321
+ next if type == "development"
322
+
323
+ requirement = parts.join(", ")
324
+ requirement = ">= 0" if requirement.empty?
325
+ {"name" => match[1], "requirement" => requirement}
326
+ end
327
+ end
328
+
329
+ def satisfying_versions(dep, versions)
330
+ requirement = dep["requirement"].split(",").map(&:strip)
331
+ versions.select do |version|
332
+ Gem::Dependency.new(dep["name"], *requirement).match?(dep["name"], version)
333
+ end
334
+ end
335
+
336
+ # Installs the EXACT declared version into the runtime. Sources
337
+ # are the gem command's own defaults — fetching from the
338
+ # configured gem source is provisioning network access, the same
339
+ # envelope as cloning a declared repository remote.
340
+ # @return [String, nil] the classified failure, nil on success
341
+ def install(gem, gem_home)
342
+ result = @runner.call(runtime_argv(gem_home,
343
+ ["gem", "install", gem["name"], "--version", gem["version"], "--no-document"]))
344
+ return nil if result["status"] == "ok"
345
+
346
+ command_failure("gem install", result)
347
+ end
348
+
349
+ # Environment variables that would drag the INVOKING process's
350
+ # bundler context into the gem subprocess: RUBYOPT carries
351
+ # `-rbundler/setup` under `bundle exec ace-lab` (the documented
352
+ # invocation), RubyGems auto-requires ENV["BUNDLER_SETUP"]
353
+ # (rubygems.rb), and the BUNDLE_*/BUNDLER_* vars re-bind bundler
354
+ # when it loads — any of which overrides the pinned paths with
355
+ # the invoking bundle's load paths. The lab runtime must stay
356
+ # isolated from the installer's gems, its bundle included.
357
+ BUNDLER_ENV_KEYS = %w[RUBYOPT RUBYLIB BUNDLER_SETUP BUNDLE_BIN_PATH
358
+ BUNDLE_GEMFILE BUNDLER_VERSION BUNDLE_BUNDLER_VERSION].freeze
359
+
360
+ private_constant :BUNDLER_ENV_KEYS
361
+
362
+ # GEM_HOME/GEM_PATH are pinned through the POSIX `env` command so
363
+ # the argv-array runner seam stays unchanged and the subprocess
364
+ # sees ONLY the target runtime.
365
+ def runtime_argv(gem_home, argv)
366
+ ["env"] +
367
+ BUNDLER_ENV_KEYS.flat_map { |key| ["-u", key] } +
368
+ ["GEM_HOME=#{gem_home}", "GEM_PATH=#{gem_home}"] + argv
369
+ end
370
+
371
+ # `gem list <name> --exact --local` prints one `name (v1, v2)`
372
+ # line when the name is present in the runtime and nothing when
373
+ # it is absent. A DEFAULT gem's bundled version appears as a
374
+ # `default: V` part (`abbrev (default: 0.1.2)`,
375
+ # `json (2.9.4, default: 2.9.1)`): the marked V is the default
376
+ # gem's own version, every other part is an additionally
377
+ # installed version — both are present in the runtime, so the
378
+ # normalized list carries the marked version in place of the
379
+ # marker. Every candidate is validated by Gem::Version itself —
380
+ # platform-suffixed or otherwise malformed strings fail the
381
+ # probe (nil, fail closed) instead of crashing a later
382
+ # Gem::Version.new caller; never a guessed empty list.
383
+ def parse_versions(out, name)
384
+ line = out.lines.find { |candidate| candidate.start_with?("#{name} ") }
385
+ return [] if line.nil?
386
+
387
+ match = line.chomp.match(/\A#{Regexp.escape(name)} \((.*)\)\z/)
388
+ return nil if match.nil?
389
+
390
+ versions = match[1].split(",").map(&:strip).map do |part|
391
+ default = part.match(/\Adefault: (\S+)\z/)
392
+ default ? default[1] : part
393
+ end
394
+ begin
395
+ versions.each { |version| Gem::Version.new(version) }
396
+ rescue ArgumentError
397
+ return nil
398
+ end
399
+
400
+ versions
401
+ end
402
+
403
+ # Classified one-line command failure: status plus the first
404
+ # stderr line only — never command output bodies.
405
+ def command_failure(command, result)
406
+ case result["status"]
407
+ when "enoent"
408
+ return "#{command} failed: gem not found in PATH; install RubyGems and rerun ace-lab prepare"
409
+ when "exec_failed"
410
+ return "#{command} failed: could not be executed (#{result["err"]}); rerun ace-lab prepare"
411
+ end
412
+
413
+ message = "#{command} failed (#{exit_detail(result["status"])})"
414
+ line = result["err"].to_s.lines.map(&:strip).reject(&:empty?).first
415
+ message = "#{message}: #{line}" if line
416
+ "#{message}; rerun ace-lab prepare (with sudo when the runtime is not writable)"
417
+ end
418
+
419
+ def exit_detail(status)
420
+ status.is_a?(Integer) ? "exit #{status}" : status.to_s
421
+ end
422
+
423
+ def lstat(path)
424
+ File.lstat(path)
425
+ rescue Errno::ENOENT, Errno::ENOTDIR
426
+ nil
427
+ end
428
+
429
+ def run_argv(argv)
430
+ out, err, status = Open3.capture3(*argv)
431
+ {"status" => status.success? ? "ok" : (status.exitstatus || "signal #{status.termsig}"),
432
+ "out" => out, "err" => err}
433
+ rescue Errno::ENOENT
434
+ {"status" => "enoent", "out" => "", "err" => ""}
435
+ rescue SystemCallError => e
436
+ {"status" => "exec_failed", "out" => "", "err" => e.class.name}
437
+ end
438
+ end
439
+ end
440
+ end
441
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Ace
6
+ module Lab
7
+ module Molecules
8
+ # INTERNAL project-location read for the lab-level overseer start
9
+ # (task 8xs.t.pu0.a): the project list and their CANONICAL
10
+ # locations come from the raw topology configuration
11
+ # (`topology.projects`: id/path) read at the lab root — the schema
12
+ # normalization and the public projection deliberately strip paths,
13
+ # so this reader goes to the raw cascade document and takes only
14
+ # what the start needs. Locations are never derived from a public
15
+ # id/label or a repo name. The public CLI keeps its allowlisted
16
+ # projection; only the start operation consumes locations,
17
+ # in-process.
18
+ class ProjectLocations
19
+ # @param lab_root [String] lab coordination directory whose
20
+ # cascade lists the configured projects
21
+ # @return [Array<Hash>] {"id" =>, "path" =>} sorted by id; a
22
+ # project entry without a usable path is returned with a nil
23
+ # path so its OWN start reports the concrete error
24
+ # @raise [Ace::Lab::InvalidConfigurationError] the topology
25
+ # configuration could not be read or has no project list
26
+ def self.read(lab_root)
27
+ config_path = File.join(lab_root, ".ace/lab/config.yml")
28
+ document = YAML.safe_load_file(config_path,
29
+ permitted_classes: [Date], aliases: true)
30
+ unless document.is_a?(Hash)
31
+ raise Ace::Lab::InvalidConfigurationError, ".ace/lab/config.yml must be a mapping"
32
+ end
33
+
34
+ raw = document.dig("topology", "projects")
35
+ unless raw.is_a?(Array)
36
+ raise Ace::Lab::InvalidConfigurationError,
37
+ "topology.projects must be a list in .ace/lab/config.yml"
38
+ end
39
+
40
+ raw.map do |entry|
41
+ next {"id" => nil, "path" => nil} unless entry.is_a?(Hash)
42
+
43
+ # A nil/blank path encoding is returned as a nil path (the
44
+ # project's OWN start reports the concrete error) — never a
45
+ # bogus empty-string path, never an exception. The config is
46
+ # read by explicit path: no global cwd mutation.
47
+ path = entry["path"]
48
+ path = nil unless path.is_a?(String) && !path.strip.empty?
49
+ {"id" => entry["id"].to_s, "path" => path}
50
+ end.sort_by { |project| project["id"].to_s }
51
+ rescue Errno::ENOENT
52
+ raise Ace::Lab::InvalidConfigurationError,
53
+ "no lab configuration at #{lab_root}/.ace/lab/config.yml; run " \
54
+ "ace-lab project add or fix the lab root"
55
+ rescue Psych::SyntaxError => e
56
+ raise Ace::Lab::InvalidConfigurationError,
57
+ "lab configuration could not be parsed: #{e.message}"
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -19,7 +19,9 @@ module Ace
19
19
  # authorization document must not grant projects this
20
20
  # manifest does not declare — a ready/foreign lab is refused
21
21
  # with a classified error and zero writes;
22
- # 2. `Prepare` (L8.1): accounts → directories → keys → roles;
22
+ # 2. `Prepare` (L8.1): accounts → directories → keys → roles →
23
+ # gems (the declared gem set converged into the declared
24
+ # target runtime, gad.2);
23
25
  # 3. `ProjectAdd` (L8.2) per declared project: repository,
24
26
  # private `_overseer`, project registry, runtime HITL route
25
27
  # configuration (the manifest's `kind: hitl` adapters
@@ -30,12 +32,14 @@ module Ace
30
32
  # make the installed projects visible to `ace-lab projects`
31
33
  # and `ace-overseer projects`;
32
34
  # 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).
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).
39
43
  #
40
44
  # Every step is idempotent, so an interrupted re-run converges
41
45
  # where steps already completed (the resume contract .7.3 builds
@@ -64,7 +68,7 @@ module Ace
64
68
  # constructor default (the CLI passes its --dry-run here)
65
69
  # @return [Hash] aggregated report (ok / dry_run / machine /
66
70
  # manifest / prepare / projects / grants / adapters / tools /
67
- # verify)
71
+ # gemset / verify)
68
72
  # @raise [Ace::Lab::InvalidConfigurationError] missing, untrusted
69
73
  # or invalid manifest, or a foreign lab refusing overwrite
70
74
  # before any mutation
@@ -123,6 +127,7 @@ module Ace
123
127
  "grants" => grants,
124
128
  "adapters" => manifest["adapters"],
125
129
  "tools" => manifest["tools"],
130
+ "gemset" => manifest["gemset"],
126
131
  "verify" => verify_report
127
132
  }
128
133
  end
@@ -6,17 +6,20 @@ require_relative "../molecules/account_provisioner"
6
6
  require_relative "../molecules/directory_provisioner"
7
7
  require_relative "../molecules/key_provisioner"
8
8
  require_relative "../molecules/role_verifier"
9
+ require_relative "../molecules/gemset_provisioner"
10
+ require_relative "../molecules/extension_provisioner"
9
11
 
10
12
  module Ace
11
13
  module Lab
12
14
  module Organisms
13
15
  # Orchestrates the preparation phase (L8.1) from the trusted install
14
- # manifest: accounts → directories → keys → roles, in that fixed
16
+ # manifest: accounts → directories → keys → roles → gems, in that fixed
15
17
  # order, each through its idempotent provisioner molecule. The
16
18
  # aggregated report is the contract consumed by `ace-lab project add`
17
19
  # (7.1) and `ace-lab install` (7.2); `ok` means zero failed items.
18
20
  # Key and role items whose account failed are reported as dependency
19
- # failures instead of being attempted. No state is written beyond
21
+ # failures instead of being attempted; gem items fail as dependencies
22
+ # when the declared runtime did not prepare. No state is written beyond
20
23
  # what the manifest declares — this organism owns no journal.
21
24
  class Prepare
22
25
  attr_reader :manifest_path, :dry_run, :platform
@@ -48,16 +51,21 @@ module Ace
48
51
  keys = prepare_keys(manifest["keys"], manifest["machine"], manifest["accounts"], available,
49
52
  unresolved, effective_dry_run)
50
53
  roles = prepare_roles(manifest["roles"], available, unresolved, effective_dry_run)
54
+ gems = prepare_gems(manifest["gemset"], effective_dry_run)
55
+ extensions = prepare_extensions(manifest["gemset"], effective_dry_run, gems)
51
56
 
52
57
  {
53
- "ok" => [accounts, directories, keys, roles].flatten.none? { |item| item["status"] == "failed" },
58
+ "ok" => [accounts, directories, keys, roles, gems, extensions].flatten
59
+ .none? { |item| item["status"] == "failed" },
54
60
  "dry_run" => effective_dry_run,
55
61
  "machine" => manifest["machine"],
56
62
  "manifest" => manifest_path,
57
63
  "accounts" => accounts,
58
64
  "directories" => directories,
59
65
  "keys" => keys,
60
- "roles" => roles
66
+ "roles" => roles,
67
+ "gems" => gems,
68
+ "extensions" => extensions
61
69
  }
62
70
  end
63
71
 
@@ -138,6 +146,38 @@ module Ace
138
146
  end
139
147
  end
140
148
 
149
+ # The declared gem set converged into the declared runtime
150
+ # (gad.2): machine-level like every other section — independent
151
+ # of the account phases, gated by its own runtime preparation.
152
+ def prepare_gems(runtime, dry_run)
153
+ provisioner = Molecules::GemsetProvisioner.new(
154
+ runner: @runner, privileged: @privileged, dry_run: dry_run
155
+ )
156
+ provisioner.call(runtime: runtime)
157
+ end
158
+
159
+ # Harness extension projections (task 8xs.t.pu0.0): converged
160
+ # from the installed gem's exact declared version after the
161
+ # gemset phase; a gem phase failure fails every extension as a
162
+ # dependency (the projection source would not exist).
163
+ def prepare_extensions(runtime, dry_run, gems)
164
+ entries = runtime["extensions"]
165
+ return [] if entries.empty?
166
+
167
+ if gems.any? { |gem| gem["status"] == "failed" }
168
+ return entries.map do |entry|
169
+ {"extension" => entry["gem"], "gem" => entry["gem"], "version" => nil,
170
+ "path" => entry["path"], "account" => entry["account"], "status" => "failed",
171
+ "loaded_version" => nil, "restart_required" => false, "files" => [],
172
+ "message" => "gemset convergence failed; fix the gem phase first"}
173
+ end
174
+ end
175
+
176
+ Molecules::ExtensionProvisioner.new(
177
+ gem_home: runtime["gem_home"], dry_run: dry_run, account_lookup: @account_lookup
178
+ ).call(extensions: entries, gems: runtime["gems"])
179
+ end
180
+
141
181
  def available_accounts(account_reports)
142
182
  account_reports.reject { |report| report["status"] == "failed" }
143
183
  .map { |report| report["username"] }