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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +132 -1
- data/docs/usage.md +168 -4
- data/lib/ace/lab/atoms/install_schema.rb +135 -3
- data/lib/ace/lab/auth/credential_handoff.rb +47 -0
- data/lib/ace/lab/auth/lease_store.rb +212 -0
- data/lib/ace/lab/auth/plugins/env_provider.rb +60 -0
- data/lib/ace/lab/auth/plugins/forgejo_provider.rb +189 -0
- data/lib/ace/lab/auth/plugins/github_provider.rb +207 -0
- data/lib/ace/lab/auth/profile_schema.rb +152 -0
- data/lib/ace/lab/auth/providers.rb +66 -0
- data/lib/ace/lab/auth/service.rb +457 -0
- data/lib/ace/lab/auth.rb +25 -0
- data/lib/ace/lab/cli/commands/auth.rb +99 -0
- data/lib/ace/lab/cli/commands/prepare.rb +4 -2
- data/lib/ace/lab/cli/commands/verify.rb +2 -2
- data/lib/ace/lab/cli.rb +4 -1
- data/lib/ace/lab/molecules/extension_projection.rb +91 -0
- data/lib/ace/lab/molecules/extension_provisioner.rb +226 -0
- data/lib/ace/lab/molecules/gemset_provisioner.rb +441 -0
- data/lib/ace/lab/molecules/project_locations.rb +62 -0
- data/lib/ace/lab/organisms/install.rb +13 -8
- data/lib/ace/lab/organisms/prepare.rb +44 -4
- data/lib/ace/lab/organisms/verify.rb +200 -2
- data/lib/ace/lab/version.rb +1 -1
- data/lib/ace/lab.rb +1 -0
- metadata +16 -2
|
@@ -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
|
|
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,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
|
|
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
|
|
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"] }
|