ace-lab 0.13.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff20c0d594202a649d5597257411b611d6faf6b66128fd8ff4ad0702989b6e16
4
- data.tar.gz: 1d6575916e526eec678ee39c9be5025f53653fcdfac27c8075692c936d3351bd
3
+ metadata.gz: 347fa4748ebbcf945253d67f0e9f2f7de3cfd88a11abf134cf2aaf40128c9054
4
+ data.tar.gz: 934f7b130e93180ab259a0ec4efa2598980af7ce3935628bf9e295e8b4689451
5
5
  SHA512:
6
- metadata.gz: 32b6ee73258e1534a9ff91f2bc83d30ff4265b124aefcee056652dd0544ef770aba4036e055e282fc35ea1c2a993cfefa67f46480e82ecf4f294fbda5561deeb
7
- data.tar.gz: 786a3ca1af5fad657f722d3ea2c0ba525988c5461ed18a94023650e17f5b7870c15a754d71560ee442e78bd6527a42823d25dc297911666156bf57287072e9c7
6
+ metadata.gz: 1def278779e6c0593732ebb7872e0ee05aeaed5bacff5c6d623d13a15a1edea755cfe7bb6506edd11d2b22f8c71d8d64c3870bf9b35207395622f2f9c8ccd781
7
+ data.tar.gz: 0b459c559a716459349d6b7b96ff3dfb521413c526593549e06a18d48a1b682d0d1e006e783ffa80eea9409dabf639341e8556dcf17dcd40fc2baec3c7da9259
data/CHANGELOG.md CHANGED
@@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.13.1] - 2026-10-26
11
+
12
+ ### Added
13
+
14
+ - Gem-set installation into the target lab runtime (gad.2 acceptance
15
+ gap #1, owner spec 8xg.t.sbz.7, L8 install group): the trusted
16
+ install manifest (still schema v1) gains an optional `gemset` section
17
+ — a sibling of `tools` — declaring the target runtime (`gem_home`,
18
+ an absolute path converged as GEM_HOME and GEM_PATH) and the required
19
+ gem set (`name` + exact `version`; unique names, versions validated
20
+ as real Gem versions, so ranges and "latest" are rejected at load).
21
+ A manifest without the section behaves exactly as before.
22
+ `ace-lab prepare` (and therefore `ace-lab install`) converges the gem
23
+ set into the declared runtime idempotently through a new
24
+ `GemsetProvisioner` molecule: exact version present = no-op
25
+ (`exists`), missing = installed, different version present = updated
26
+ (the declared version installed; RubyGems keeps versions side by
27
+ side and verify judges the declared set). The gem subprocesses run
28
+ with `GEM_HOME`/`GEM_PATH` pinned to the runtime through the same
29
+ argv runner seam as every other prepare step — with the invoking
30
+ process's bundler context stripped (`RUBYOPT`, `RUBYLIB`,
31
+ `BUNDLE_*`/`BUNDLER_*`), so `bundle exec ace-lab prepare` converges
32
+ the LAB runtime instead of its own bundle. The runtime directory is
33
+ created only with administrator privileges (the AccountProvisioner
34
+ seam); a symlink or non-directory runtime is refused (the
35
+ DirectoryProvisioner takeover posture); runtime failures fail every
36
+ gem item as a dependency and gate the install's projects and grants
37
+ like any other machine-level failure. `--dry-run` predicts
38
+ `would_install`/`would_update`/`exists` read-only and predicts the
39
+ wet privilege failure. The install report echoes the declared gemset
40
+ under `gemset`. `ace-lab verify` gained a matching read-only `gems`
41
+ check — every declared gem must be installed at its exact version in
42
+ the declared runtime, judged through the SAME probe the provisioner
43
+ converges from (a missing `gem` executable reports `unknown`, the
44
+ offline posture). `project add` stays project-scoped: gem
45
+ installation is machine-level provisioning and is deliberately not
46
+ added there. (PR #77)
47
+
10
48
 
11
49
  ## [0.13.0] - 2026-10-20
12
50
 
data/docs/usage.md CHANGED
@@ -170,14 +170,23 @@ projects: # optional (consumed by ace-lab install)
170
170
  grants: # optional: trusted-tier principal grants
171
171
  ace-overseer: # principal → declared project ids
172
172
  - atlas
173
+ gemset: # optional (gad.2): target runtime + required gem set
174
+ gem_home: /opt/lab/gems # absolute; converged as GEM_HOME and GEM_PATH
175
+ gems: # exact versions, unique names
176
+ - name: ace-lab
177
+ version: 0.13.0 # must be an exact gem version (no ranges)
173
178
  ```
174
179
 
175
180
  Statuses: accounts `created|exists|would_create|failed`; directories
176
181
  `created|exists|mode_fixed|would_create|would_fix_mode|failed`; keys
177
182
  `created|exists|would_create|would_repair_public|would_fix_mode|failed`
178
- (with the public `fingerprint`); roles `verified|failed`. `ok` in the
183
+ (with the public `fingerprint`); roles `verified|failed`; gems
184
+ `installed|updated|exists|would_install|would_update|failed` (one item
185
+ per declared gem). `ok` in the
179
186
  JSON report means zero failed items. Key/role items whose account
180
- failed are reported as dependency failures instead of being attempted.
187
+ failed are reported as dependency failures instead of being attempted;
188
+ gem items fail as dependencies when the declared runtime did not
189
+ prepare.
181
190
 
182
191
  `--dry-run` is a faithful prediction of the wet run (`would_*`
183
192
  statuses, zero mutations): keys and roles of an account the account
@@ -299,6 +308,26 @@ Behavior and guarantees:
299
308
  group, or other. A readable file behind an unsearchable directory
300
309
  (e.g. 0644 inside a 0700 directory owned by someone else) is
301
310
  `failed`, not `verified`. ACLs remain out of scope.
311
+ - **Gem-set convergence (gad.2).** When the manifest declares a
312
+ `gemset`, prepare converges the declared gem set into the declared
313
+ target runtime (`gem_home`): a gem whose exact declared version is
314
+ already present is a no-op (`exists`), a missing gem is installed
315
+ (`installed`), a gem present at a different version is updated (the
316
+ declared version is installed; RubyGems keeps versions side by side
317
+ and verify judges the declared set). The `gem` subprocesses run with
318
+ `GEM_HOME`/`GEM_PATH` pinned to the runtime (through the POSIX `env`
319
+ command, argv arrays only) and with the invoking process's bundler
320
+ context stripped (`RUBYOPT`, `RUBYLIB`, `BUNDLE_*`/`BUNDLER_*`), so
321
+ a `bundle exec ace-lab prepare` converges the LAB runtime, never its
322
+ own bundle. The runtime directory is created only with
323
+ administrator privileges; a symlink or non-directory at `gem_home`
324
+ is refused (takeover posture). Installs fetch from the `gem`
325
+ command's configured sources (provisioning network access, like
326
+ cloning a declared remote) with `--no-document`. `--dry-run` is a
327
+ faithful prediction: the read-only probe runs, statuses are
328
+ `would_install`/`would_update`/`exists`, nothing is created or
329
+ installed, and a runtime that would need privileges predicts the wet
330
+ failure.
302
331
  - **Exit codes.** A wet run with failed items prints the report, then
303
332
  exits non-zero enumerating each `<section>/<item>: <message>`;
304
333
  `--dry-run` exits 0 whenever a report renders (conflicts appear as
@@ -408,8 +437,9 @@ existing molecules in a fixed order:
408
437
  present) must not grant projects the manifest does not declare: a
409
438
  ready or foreign lab is refused with a classified error and zero
410
439
  writes, identically in wet runs and `--dry-run` (prediction parity).
411
- 2. **prepare (L8.1)** — accounts → directories → keys → roles, exactly
412
- as `ace-lab prepare` (same organism, same per-item report).
440
+ 2. **prepare (L8.1)** — accounts → directories → keys → roles → gems,
441
+ exactly as `ace-lab prepare` (same organism, same per-item report;
442
+ the declared gem set converges into the declared target runtime).
413
443
  3. **project add (L8.2) per declared project** — repository, private
414
444
  `_overseer`, project registry, exactly as `ace-lab project add`.
415
445
  Projects are isolated: one failing project does not block the
@@ -486,6 +516,7 @@ never taken over, never cleaned:
486
516
  | --- | --- | --- |
487
517
  | declared directory | symlink, non-directory, or foreign ownership | step fails with a resolve-manually message; projects and grants are gated as dependency failures |
488
518
  | key pair | foreign-owned key files or `.ssh`, non-regular file | step fails with a resolve-manually message |
519
+ | gem set | symlinked or non-directory runtime, missing privileges, or a failed probe/install | every gem item fails with a classified message; projects and grants are gated as dependency failures |
489
520
  | project repository | non-empty non-Git directory, or a Git repository with a different origin | step fails: "refusing to take over a foreign location" / "refusing to overwrite an existing registration"; the other projects still run (isolation) |
490
521
  | grants | trusted document granting projects outside the manifest, or a document that fails verification/parsing | the whole install refuses before any mutation ("refusing to install over a foreign lab"), wet and in `--dry-run` alike |
491
522
 
@@ -533,6 +564,9 @@ a concrete reason (never a bare boolean):
533
564
  | grants | the trusted document is present and verifiable, and every manifest principal's grants resolve through the real read-side mechanics (`CallerAuthorizer`) to the declared projects |
534
565
  | registry | each project is registered in its `.ace/lab/config.yml` (visibility through the configuration cascade) |
535
566
  | overseer | `.ace-tasks/_overseer/` exists and is ignored by Git (`git check-ignore`) |
567
+ | adapters | configuration is a mapping; a declared local command resolves (probed for the target executors); `kind: hitl` routes are installed in every project's `.ace/hitl/config.yml` and satisfy the runtime loader's rules |
568
+ | tools | each required tool resolves to an executable the target executors can run (presence + permission probe, no execution) |
569
+ | gems | each declared gem is installed at its EXACT declared version in the declared target runtime — judged through the same read-only probe prepare converges from; a missing runtime, a missing gem or a version mismatch blocks readiness (a missing `gem` executable reports `unknown`, the offline posture) |
536
570
 
537
571
  The overall verdict is `ready` only when no check failed. A failure
538
572
  never hides healthy data: the full report renders first (table or
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rubygems/version"
4
+
3
5
  module Ace
4
6
  module Lab
5
7
  module Atoms
@@ -13,7 +15,7 @@ module Ace
13
15
  SCHEMA_VERSION = 1
14
16
  TOP_LEVEL_KEYS =
15
17
  %w[schema_version machine accounts directories keys roles projects grants
16
- adapters tools].freeze
18
+ adapters tools gemset].freeze
17
19
  ENTRY_KEYS = {
18
20
  "accounts" => %w[username role create home],
19
21
  "directories" => %w[path owner mode],
@@ -29,6 +31,9 @@ module Ace
29
31
  DEFAULT_MODE = "0755"
30
32
  DEFAULT_KEY_TYPE = "ed25519"
31
33
  KEY_TYPES = [DEFAULT_KEY_TYPE].freeze
34
+ GEMSET_KEYS = %w[gem_home gems].freeze
35
+ GEM_KEYS = %w[name version].freeze
36
+ GEM_NAME_PATTERN = /\A[A-Za-z0-9._-]+\z/
32
37
 
33
38
  class << self
34
39
  # @param document [Object] parsed YAML document
@@ -71,7 +76,8 @@ module Ace
71
76
  "projects" => projects,
72
77
  "grants" => normalize_grants(document["grants"], projects.map { |p| p["id"] }),
73
78
  "adapters" => normalize_adapters(document["adapters"]),
74
- "tools" => normalize_tools(document["tools"])
79
+ "tools" => normalize_tools(document["tools"]),
80
+ "gemset" => normalize_gemset(document["gemset"])
75
81
  }
76
82
  end
77
83
 
@@ -317,6 +323,71 @@ module Ace
317
323
  tools.map(&:strip).uniq
318
324
  end
319
325
 
326
+ # Target lab runtime and required gem set (gad.2 gap #1, owner
327
+ # spec 8xg.t.sbz.7): a sibling of `tools` declaring WHERE the
328
+ # lab's gems must live (`gem_home`, converged as GEM_HOME/
329
+ # GEM_PATH) and WHICH gems at WHICH exact versions. Absent =
330
+ # no runtime declared, behavior unchanged. Names must be real
331
+ # gem names; versions must parse as exact Gem versions (the
332
+ # same parser the runtime uses) — ranges and bare names are
333
+ # rejected so convergence is always exact-version.
334
+ def normalize_gemset(raw)
335
+ return {"gem_home" => nil, "gems" => []} if raw.nil?
336
+ unless raw.is_a?(Hash)
337
+ raise_invalid "gemset must be a mapping"
338
+ end
339
+
340
+ unknown = raw.keys - GEMSET_KEYS
341
+ unless unknown.empty?
342
+ raise_invalid "gemset has unknown key '#{unknown.sort.first}'"
343
+ end
344
+
345
+ gem_home = raw["gem_home"]
346
+ unless gem_home.is_a?(String) && !gem_home.strip.empty? && absolute_path?(gem_home.strip)
347
+ raise_invalid "gemset gem_home is required and must be an absolute path"
348
+ end
349
+
350
+ {"gem_home" => gem_home.strip, "gems" => normalize_gems(raw["gems"])}
351
+ end
352
+
353
+ def normalize_gems(raw)
354
+ gems = raw.nil? ? [] : raw
355
+ unless gems.is_a?(Array)
356
+ raise_invalid "gemset gems must be a list"
357
+ end
358
+ gems.each_with_index do |entry, index|
359
+ unless entry.is_a?(Hash)
360
+ raise_invalid "gemset gems entry ##{index} must be a mapping"
361
+ end
362
+ unknown = entry.keys - GEM_KEYS
363
+ unless unknown.empty?
364
+ raise_invalid "gemset gems entry ##{index} has unknown key '#{unknown.sort.first}'"
365
+ end
366
+ end
367
+
368
+ gems.map do |entry|
369
+ name = entry["name"]
370
+ unless name.is_a?(String) && name.strip =~ GEM_NAME_PATTERN
371
+ raise_invalid_entry("gemset gems", "name is required and must be a gem name")
372
+ end
373
+ version = entry["version"]
374
+ unless version.is_a?(String) && !version.strip.empty?
375
+ raise_invalid_entry("gemset gems", "version is required and must be an exact version")
376
+ end
377
+ begin
378
+ Gem::Version.new(version.strip)
379
+ rescue ArgumentError
380
+ raise_invalid_entry("gemset gems", "version must be an exact gem version")
381
+ end
382
+
383
+ {"name" => name.strip, "version" => version.strip}
384
+ end.tap do |gems|
385
+ duplicates_of(gems.map { |gem| gem["name"] }) do |_name|
386
+ raise_invalid_entry("gemset gems", "name is declared more than once")
387
+ end
388
+ end
389
+ end
390
+
320
391
  def optional_string(entry, section_name, field)
321
392
  value = entry[field]
322
393
  if !value.nil? && (!value.is_a?(String) || value.strip.empty?)
@@ -18,14 +18,16 @@ module Ace
18
18
  "accounts" => ->(item) { item["username"] },
19
19
  "directories" => ->(item) { item["path"] },
20
20
  "keys" => ->(item) { "#{item['account']}:#{item['path']}" },
21
- "roles" => ->(item) { item["id"] }
21
+ "roles" => ->(item) { item["id"] },
22
+ "gems" => ->(item) { "#{item['name']} #{item['version']}" }
22
23
  }.freeze
23
24
 
24
25
  # "SHA256:" plus the first 20 base64 characters is enough to
25
26
  # eyeball-match a key without ballooning the table row.
26
27
  FINGERPRINT_WIDTH = 27
27
28
 
28
- desc "Prepare accounts, directories, permissions, keys and roles from the trusted install manifest"
29
+ desc "Prepare accounts, directories, permissions, keys, roles and the declared " \
30
+ "gem set from the trusted install manifest"
29
31
 
30
32
  example [
31
33
  "--dry-run # Predict all sections without mutating",
@@ -16,13 +16,13 @@ module Ace
16
16
  "accounts" => "account", "directories" => "directory", "keys" => "account",
17
17
  "roles" => "id", "repositories" => "project", "grants" => "principal",
18
18
  "registry" => "project", "overseer" => "project",
19
- "adapters" => "adapter", "tools" => "tool"
19
+ "adapters" => "adapter", "tools" => "tool", "gems" => "gem"
20
20
  }.freeze
21
21
 
22
22
  desc "Verify the installed lab's readiness under the target accounts (read-only): " \
23
23
  "accounts, directories, key pairs, role instructions, repository origins and " \
24
24
  "target-executor access, grant-based project visibility, private _overseer " \
25
- "folders, configured communication/HITL adapters, required tools"
25
+ "folders, configured communication/HITL adapters, required tools, declared gem set"
26
26
 
27
27
  example [
28
28
  "--format json # Machine-readable report for the overseer / next tool",
@@ -0,0 +1,222 @@
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
+ converge(base(gem, gem_home), gem, gem_home)
53
+ end
54
+ end
55
+ end
56
+
57
+ # Public READ-ONLY probe (L8.5): the local versions of `name`
58
+ # inside the target runtime — the exact probe convergence runs,
59
+ # exposed so the readiness verifier reuses the same logic
60
+ # instead of reimplementing it (the two can never drift).
61
+ # @param name [String] gem name
62
+ # @param gem_home [String] target runtime (GEM_HOME)
63
+ # @return [Array(Symbol, Object)] [:ok, versions],
64
+ # [:unknown, message] (no `gem` executable — offline posture)
65
+ # or [:failed, message]
66
+ def runtime_versions(name:, gem_home:)
67
+ result = @runner.call(runtime_argv(gem_home,
68
+ ["gem", "list", name, "--exact", "--local"]))
69
+ return [:unknown, "gem not found in PATH; install RubyGems and rerun ace-lab prepare"] if result["status"] == "enoent"
70
+ return [:failed, command_failure("gem list", result)] unless result["status"] == "ok"
71
+
72
+ versions = parse_versions(result["out"], name)
73
+ if versions.nil?
74
+ return [:failed, "could not determine installed versions of gem '#{name}' from " \
75
+ "'gem list' output; rerun ace-lab prepare"]
76
+ end
77
+
78
+ [:ok, versions]
79
+ end
80
+
81
+ private
82
+
83
+ def base(gem, gem_home)
84
+ {"name" => gem["name"], "version" => gem["version"], "gem_home" => gem_home}
85
+ end
86
+
87
+ # One runtime failure fails every declared gem as a dependency
88
+ # (the keys-need-accounts posture): convergence is never
89
+ # attempted against a runtime that did not prepare.
90
+ def ensure_runtime(gem_home)
91
+ stat = lstat(gem_home)
92
+ return "gem runtime '#{gem_home}' is a symbolic link; refusing" if stat&.symlink?
93
+ return "gem runtime '#{gem_home}' exists but is not a directory; refusing" if stat && !stat.directory?
94
+ return nil if stat
95
+
96
+ unless @privileged.call
97
+ return "creating gem runtime '#{gem_home}' requires administrator privileges; " \
98
+ "rerun ace-lab prepare with sudo or create the directory manually"
99
+ end
100
+ return nil if @dry_run # prediction: creation would succeed
101
+
102
+ FileUtils.mkdir_p(gem_home)
103
+ nil
104
+ rescue Errno::EPERM, Errno::EACCES
105
+ "insufficient permissions while preparing gem runtime '#{gem_home}'; " \
106
+ "rerun ace-lab prepare with sudo or adjust ownership manually"
107
+ rescue SystemCallError => e
108
+ "gem runtime '#{gem_home}' could not be created (#{e.class.name}); rerun ace-lab prepare"
109
+ end
110
+
111
+ def converge(base, gem, gem_home)
112
+ kind, versions_or_message = runtime_versions(name: gem["name"], gem_home: gem_home)
113
+ # Prepare's fail-closed posture: an unprobeable runtime (no
114
+ # `gem` executable — the :unknown probe kind) fails the item in
115
+ # wet AND dry runs, exactly as the wet run would fail; verify
116
+ # maps the same probe kind to its offline `unknown` posture.
117
+ return base.merge("status" => "failed", "message" => versions_or_message) if kind != :ok
118
+
119
+ versions = versions_or_message
120
+ return base.merge("status" => "exists", "message" => nil) if versions.include?(gem["version"])
121
+
122
+ status = versions.empty? ? "install" : "update"
123
+ return base.merge("status" => "would_#{status}", "message" => nil) if @dry_run
124
+
125
+ failure = install(gem, gem_home)
126
+ return base.merge("status" => "failed", "message" => failure) if failure
127
+
128
+ if status == "install"
129
+ base.merge("status" => "installed", "message" => nil)
130
+ else
131
+ base.merge("status" => "updated", "message" => nil)
132
+ end
133
+ end
134
+
135
+ # Installs the EXACT declared version into the runtime. Sources
136
+ # are the gem command's own defaults — fetching from the
137
+ # configured gem source is provisioning network access, the same
138
+ # envelope as cloning a declared repository remote.
139
+ # @return [String, nil] the classified failure, nil on success
140
+ def install(gem, gem_home)
141
+ result = @runner.call(runtime_argv(gem_home,
142
+ ["gem", "install", gem["name"], "--version", gem["version"], "--no-document"]))
143
+ return nil if result["status"] == "ok"
144
+
145
+ command_failure("gem install", result)
146
+ end
147
+
148
+ # Environment variables that would drag the INVOKING process's
149
+ # bundler context into the gem subprocess: RUBYOPT carries
150
+ # `-rbundler/setup` under `bundle exec ace-lab` (the documented
151
+ # invocation), RubyGems auto-requires ENV["BUNDLER_SETUP"]
152
+ # (rubygems.rb), and the BUNDLE_*/BUNDLER_* vars re-bind bundler
153
+ # when it loads — any of which overrides the pinned paths with
154
+ # the invoking bundle's load paths. The lab runtime must stay
155
+ # isolated from the installer's gems, its bundle included.
156
+ BUNDLER_ENV_KEYS = %w[RUBYOPT RUBYLIB BUNDLER_SETUP BUNDLE_BIN_PATH
157
+ BUNDLE_GEMFILE BUNDLER_VERSION BUNDLE_BUNDLER_VERSION].freeze
158
+
159
+ private_constant :BUNDLER_ENV_KEYS
160
+
161
+ # GEM_HOME/GEM_PATH are pinned through the POSIX `env` command so
162
+ # the argv-array runner seam stays unchanged and the subprocess
163
+ # sees ONLY the target runtime.
164
+ def runtime_argv(gem_home, argv)
165
+ ["env"] +
166
+ BUNDLER_ENV_KEYS.flat_map { |key| ["-u", key] } +
167
+ ["GEM_HOME=#{gem_home}", "GEM_PATH=#{gem_home}"] + argv
168
+ end
169
+
170
+ # `gem list <name> --exact --local` prints one `name (v1, v2)`
171
+ # line when the name is present in the runtime and nothing when
172
+ # it is absent. A present-but-unparseable line is a failed probe
173
+ # (nil, fail closed), never a guessed empty list.
174
+ def parse_versions(out, name)
175
+ line = out.lines.find { |candidate| candidate.start_with?("#{name} ") }
176
+ return [] if line.nil?
177
+
178
+ match = line.chomp.match(/\A#{Regexp.escape(name)} \((.*)\)\z/)
179
+ return nil if match.nil?
180
+
181
+ match[1].split(",").map(&:strip)
182
+ end
183
+
184
+ # Classified one-line command failure: status plus the first
185
+ # stderr line only — never command output bodies.
186
+ def command_failure(command, result)
187
+ case result["status"]
188
+ when "enoent"
189
+ return "#{command} failed: gem not found in PATH; install RubyGems and rerun ace-lab prepare"
190
+ when "exec_failed"
191
+ return "#{command} failed: could not be executed (#{result["err"]}); rerun ace-lab prepare"
192
+ end
193
+
194
+ message = "#{command} failed (#{exit_detail(result["status"])})"
195
+ line = result["err"].to_s.lines.map(&:strip).reject(&:empty?).first
196
+ message = "#{message}: #{line}" if line
197
+ "#{message}; rerun ace-lab prepare (with sudo when the runtime is not writable)"
198
+ end
199
+
200
+ def exit_detail(status)
201
+ status.is_a?(Integer) ? "exit #{status}" : status.to_s
202
+ end
203
+
204
+ def lstat(path)
205
+ File.lstat(path)
206
+ rescue Errno::ENOENT, Errno::ENOTDIR
207
+ nil
208
+ end
209
+
210
+ def run_argv(argv)
211
+ out, err, status = Open3.capture3(*argv)
212
+ {"status" => status.success? ? "ok" : (status.exitstatus || "signal #{status.termsig}"),
213
+ "out" => out, "err" => err}
214
+ rescue Errno::ENOENT
215
+ {"status" => "enoent", "out" => "", "err" => ""}
216
+ rescue SystemCallError => e
217
+ {"status" => "exec_failed", "out" => "", "err" => e.class.name}
218
+ end
219
+ end
220
+ end
221
+ end
222
+ 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,19 @@ 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"
9
10
 
10
11
  module Ace
11
12
  module Lab
12
13
  module Organisms
13
14
  # Orchestrates the preparation phase (L8.1) from the trusted install
14
- # manifest: accounts → directories → keys → roles, in that fixed
15
+ # manifest: accounts → directories → keys → roles → gems, in that fixed
15
16
  # order, each through its idempotent provisioner molecule. The
16
17
  # aggregated report is the contract consumed by `ace-lab project add`
17
18
  # (7.1) and `ace-lab install` (7.2); `ok` means zero failed items.
18
19
  # Key and role items whose account failed are reported as dependency
19
- # failures instead of being attempted. No state is written beyond
20
+ # failures instead of being attempted; gem items fail as dependencies
21
+ # when the declared runtime did not prepare. No state is written beyond
20
22
  # what the manifest declares — this organism owns no journal.
21
23
  class Prepare
22
24
  attr_reader :manifest_path, :dry_run, :platform
@@ -48,16 +50,18 @@ module Ace
48
50
  keys = prepare_keys(manifest["keys"], manifest["machine"], manifest["accounts"], available,
49
51
  unresolved, effective_dry_run)
50
52
  roles = prepare_roles(manifest["roles"], available, unresolved, effective_dry_run)
53
+ gems = prepare_gems(manifest["gemset"], effective_dry_run)
51
54
 
52
55
  {
53
- "ok" => [accounts, directories, keys, roles].flatten.none? { |item| item["status"] == "failed" },
56
+ "ok" => [accounts, directories, keys, roles, gems].flatten.none? { |item| item["status"] == "failed" },
54
57
  "dry_run" => effective_dry_run,
55
58
  "machine" => manifest["machine"],
56
59
  "manifest" => manifest_path,
57
60
  "accounts" => accounts,
58
61
  "directories" => directories,
59
62
  "keys" => keys,
60
- "roles" => roles
63
+ "roles" => roles,
64
+ "gems" => gems
61
65
  }
62
66
  end
63
67
 
@@ -138,6 +142,16 @@ module Ace
138
142
  end
139
143
  end
140
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
+
141
155
  def available_accounts(account_reports)
142
156
  account_reports.reject { |report| report["status"] == "failed" }
143
157
  .map { |report| report["username"] }
@@ -4,6 +4,7 @@ require_relative "../errors"
4
4
  require_relative "../molecules/install_manifest_loader"
5
5
  require_relative "../molecules/key_provisioner"
6
6
  require_relative "../molecules/role_verifier"
7
+ require_relative "../molecules/gemset_provisioner"
7
8
  require_relative "../molecules/hitl_routes"
8
9
  require_relative "../molecules/caller_authorizer"
9
10
  require_relative "../atoms/topology_schema"
@@ -77,7 +78,7 @@ module Ace
77
78
  # @return [Hash] {ready:, machine:, manifest:,
78
79
  # authorization_path:, checks: {accounts:, directories:,
79
80
  # keys:, roles:, repositories:, grants:, registry:,
80
- # overseer:, adapters:, tools:}, counts: {pass:, fail:,
81
+ # overseer:, adapters:, tools:, gems:}, counts: {pass:, fail:,
81
82
  # unknown:}}
82
83
  # @raise [Ace::Lab::InvalidConfigurationError] missing,
83
84
  # untrusted or schema-invalid manifest — same classification
@@ -97,7 +98,8 @@ module Ace
97
98
  "registry" => check_registry(manifest),
98
99
  "overseer" => check_overseer(manifest),
99
100
  "adapters" => check_adapters(manifest, executors),
100
- "tools" => check_tools(manifest, executors)
101
+ "tools" => check_tools(manifest, executors),
102
+ "gems" => check_gems(manifest)
101
103
  }
102
104
  flat = checks.values.flatten(1)
103
105
  {
@@ -551,6 +553,72 @@ module Ace
551
553
  end
552
554
  end
553
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
+
554
622
  # Resolve an executable without running it and judge it for the
555
623
  # TARGET EXECUTORS (FINAL-LAB3): a name containing a separator
556
624
  # must be a file executable at that path; a bare name must
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Lab
5
- VERSION = "0.13.0"
5
+ VERSION = "0.13.1"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-lab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.0
4
+ version: 0.13.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-20 00:00:00.000000000 Z
10
+ date: 2026-10-26 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-cli
@@ -200,6 +200,7 @@ files:
200
200
  - lib/ace/lab/molecules/exact_resolver.rb
201
201
  - lib/ace/lab/molecules/forge_merge_config.rb
202
202
  - lib/ace/lab/molecules/forgejo_merge_adapter.rb
203
+ - lib/ace/lab/molecules/gemset_provisioner.rb
203
204
  - lib/ace/lab/molecules/grant_provisioner.rb
204
205
  - lib/ace/lab/molecules/grant_resolver.rb
205
206
  - lib/ace/lab/molecules/hitl_routes.rb