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 +4 -4
- data/CHANGELOG.md +38 -0
- data/docs/usage.md +38 -4
- data/lib/ace/lab/atoms/install_schema.rb +73 -2
- data/lib/ace/lab/cli/commands/prepare.rb +4 -2
- data/lib/ace/lab/cli/commands/verify.rb +2 -2
- data/lib/ace/lab/molecules/gemset_provisioner.rb +222 -0
- data/lib/ace/lab/organisms/install.rb +13 -8
- data/lib/ace/lab/organisms/prepare.rb +18 -4
- data/lib/ace/lab/organisms/verify.rb +70 -2
- data/lib/ace/lab/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 347fa4748ebbcf945253d67f0e9f2f7de3cfd88a11abf134cf2aaf40128c9054
|
|
4
|
+
data.tar.gz: 934f7b130e93180ab259a0ec4efa2598980af7ce3935628bf9e295e8b4689451
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
34
|
-
# manifest (validated at load, reported
|
|
35
|
-
# `tools`), and the converged
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
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
|
|
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
|
data/lib/ace/lab/version.rb
CHANGED
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.
|
|
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-
|
|
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
|