ace-lab 0.13.1 → 0.14.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 +104 -0
- data/docs/usage.md +130 -0
- data/lib/ace/lab/atoms/install_schema.rb +72 -11
- 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.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/gem_payload_permissions.rb +174 -0
- data/lib/ace/lab/molecules/gemset_provisioner.rb +229 -4
- data/lib/ace/lab/molecules/project_locations.rb +62 -0
- data/lib/ace/lab/organisms/prepare.rb +28 -2
- data/lib/ace/lab/organisms/verify.rb +139 -9
- data/lib/ace/lab/version.rb +1 -1
- data/lib/ace/lab.rb +1 -0
- metadata +16 -2
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "../errors"
|
|
4
4
|
require "open3"
|
|
5
5
|
require "fileutils"
|
|
6
|
+
require_relative "gem_payload_permissions"
|
|
6
7
|
|
|
7
8
|
module Ace
|
|
8
9
|
module Lab
|
|
@@ -49,7 +50,12 @@ module Ace
|
|
|
49
50
|
if runtime_failure
|
|
50
51
|
base(gem, gem_home).merge("status" => "failed", "message" => runtime_failure)
|
|
51
52
|
else
|
|
52
|
-
converge(base(gem, gem_home), gem, gem_home)
|
|
53
|
+
report = converge(base(gem, gem_home), gem, gem_home)
|
|
54
|
+
if %w[exists installed updated].include?(report["status"])
|
|
55
|
+
converge_post_presence(report, gem, gem_home)
|
|
56
|
+
else
|
|
57
|
+
report
|
|
58
|
+
end
|
|
53
59
|
end
|
|
54
60
|
end
|
|
55
61
|
end
|
|
@@ -78,6 +84,55 @@ module Ace
|
|
|
78
84
|
[:ok, versions]
|
|
79
85
|
end
|
|
80
86
|
|
|
87
|
+
# Public READ-ONLY probes (verify, task 8xs.t.pu0.0): the same
|
|
88
|
+
# primitives convergence runs, exposed so readiness reuses the
|
|
89
|
+
# identical logic instead of reimplementing it.
|
|
90
|
+
#
|
|
91
|
+
# Unsatisfied runtime dependencies of the declared gem — the
|
|
92
|
+
# concrete missing-dependency list readiness reports.
|
|
93
|
+
# @return [Array(Symbol, Object)] [:ok, reasons] (empty reasons
|
|
94
|
+
# = closed) or the failing probe's [:unknown/:failed,
|
|
95
|
+
# message] — the same shape newer_versions returns.
|
|
96
|
+
def closure_failures(name:, version:, gem_home:)
|
|
97
|
+
queue = [[name, version]]
|
|
98
|
+
seen = {}
|
|
99
|
+
reasons = []
|
|
100
|
+
until queue.empty?
|
|
101
|
+
current_name, current_version = queue.shift
|
|
102
|
+
next if seen[[current_name, current_version]]
|
|
103
|
+
|
|
104
|
+
seen[[current_name, current_version]] = true
|
|
105
|
+
deps = runtime_dependencies(name: current_name, version: current_version, gem_home: gem_home)
|
|
106
|
+
return [:failed, deps] if deps.is_a?(String)
|
|
107
|
+
|
|
108
|
+
deps.each do |dep|
|
|
109
|
+
kind, versions_or_message = runtime_versions(name: dep["name"], gem_home: gem_home)
|
|
110
|
+
return [kind, versions_or_message] if kind != :ok
|
|
111
|
+
|
|
112
|
+
satisfying = satisfying_versions(dep, versions_or_message)
|
|
113
|
+
if satisfying.empty?
|
|
114
|
+
reasons << "dependency '#{dep["name"]} (#{dep["requirement"]})' required by gem " \
|
|
115
|
+
"'#{current_name}' is not satisfied in runtime '#{gem_home}'"
|
|
116
|
+
else
|
|
117
|
+
queue << [dep["name"], satisfying.max_by { |v| Gem::Version.new(v) }]
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
[:ok, reasons]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Installed versions of `name` NEWER than the declared one —
|
|
125
|
+
# the versions that would win activation and take over the CLI.
|
|
126
|
+
# @return [Array(String, Object)] [:ok, versions] or the probe's
|
|
127
|
+
# [:unknown/:failed, message]
|
|
128
|
+
def newer_versions(name:, declared_version:, gem_home:)
|
|
129
|
+
kind, versions_or_message = runtime_versions(name: name, gem_home: gem_home)
|
|
130
|
+
return [kind, versions_or_message] if kind != :ok
|
|
131
|
+
|
|
132
|
+
declared = Gem::Version.new(declared_version)
|
|
133
|
+
[:ok, versions_or_message.select { |version| Gem::Version.new(version) > declared }]
|
|
134
|
+
end
|
|
135
|
+
|
|
81
136
|
private
|
|
82
137
|
|
|
83
138
|
def base(gem, gem_home)
|
|
@@ -132,6 +187,158 @@ module Ace
|
|
|
132
187
|
end
|
|
133
188
|
end
|
|
134
189
|
|
|
190
|
+
# Post-presence convergence (task 8xs.t.pu0.0): the declared
|
|
191
|
+
# version being present is necessary but not sufficient.
|
|
192
|
+
# 1. NEWER versions of the declared gem sitting beside the
|
|
193
|
+
# declared one would win RubyGems activation and take over
|
|
194
|
+
# the runtime's CLI — the manifest is the single owner of
|
|
195
|
+
# the declared set, so convergence removes them from the
|
|
196
|
+
# target runtime.
|
|
197
|
+
# 2. The dependency closure must actually resolve in the
|
|
198
|
+
# runtime: a gem whose version already figures in
|
|
199
|
+
# `gem list` can still miss a transitive runtime
|
|
200
|
+
# dependency (the manually-repaired `json` class of gap);
|
|
201
|
+
# missing dependencies are converged by installing them
|
|
202
|
+
# at the parent's requirement, and an unsatisfiable
|
|
203
|
+
# dependency is a classified failure — never readiness.
|
|
204
|
+
# A dry run predicts both without writing (would_uninstall /
|
|
205
|
+
# would_install) and stops recursion at the prediction.
|
|
206
|
+
def converge_post_presence(report, gem, gem_home)
|
|
207
|
+
removal = converge_newer_versions(gem, gem_home)
|
|
208
|
+
return report.merge("status" => "failed", "message" => removal) if removal.is_a?(String)
|
|
209
|
+
report["removed_newer_versions"] = removal
|
|
210
|
+
|
|
211
|
+
closure = converge_closure(gem, gem_home)
|
|
212
|
+
return report.merge("status" => "failed", "message" => closure) if closure.is_a?(String)
|
|
213
|
+
report["closure"] = closure
|
|
214
|
+
permissions = GemPayloadPermissions.new(gem_home: gem_home, dry_run: @dry_run,
|
|
215
|
+
privileged: @privileged.call).call(name: gem["name"], version: gem["version"])
|
|
216
|
+
report["permissions"] = permissions
|
|
217
|
+
report
|
|
218
|
+
rescue GemPayloadPermissions::Error => e
|
|
219
|
+
report.merge("status" => "failed", "message" => e.message)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# @return [Array<Hash>, String] per-version removal reports or a
|
|
223
|
+
# classified failure message
|
|
224
|
+
def converge_newer_versions(gem, gem_home)
|
|
225
|
+
kind, versions_or_message = runtime_versions(name: gem["name"], gem_home: gem_home)
|
|
226
|
+
return versions_or_message if kind != :ok
|
|
227
|
+
|
|
228
|
+
declared = Gem::Version.new(gem["version"])
|
|
229
|
+
newer = versions_or_message.select { |version| Gem::Version.new(version) > declared }
|
|
230
|
+
return [] if newer.empty?
|
|
231
|
+
|
|
232
|
+
return newer.map { |version| {"version" => version, "status" => "would_uninstall"} } if @dry_run
|
|
233
|
+
|
|
234
|
+
newer.map do |version|
|
|
235
|
+
result = @runner.call(runtime_argv(gem_home,
|
|
236
|
+
["gem", "uninstall", gem["name"], "--version", version, "-x", "--force"]))
|
|
237
|
+
if result["status"] != "ok"
|
|
238
|
+
break command_failure("gem uninstall #{gem["name"]} #{version}", result)
|
|
239
|
+
end
|
|
240
|
+
{"version" => version, "status" => "uninstalled"}
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Breadth-first walk of the gem's RUNTIME dependency closure in
|
|
245
|
+
# the target runtime. `gem dependency` output is plain text (no
|
|
246
|
+
# marshaled objects), development dependencies are ignored.
|
|
247
|
+
# @return [Array<Hash>, String] per-dependency convergence
|
|
248
|
+
# actions or a classified failure message
|
|
249
|
+
def converge_closure(gem, gem_home)
|
|
250
|
+
queue = [[gem["name"], gem["version"]]]
|
|
251
|
+
seen = {}
|
|
252
|
+
actions = []
|
|
253
|
+
until queue.empty?
|
|
254
|
+
name, version = queue.shift
|
|
255
|
+
next if seen[[name, version]]
|
|
256
|
+
|
|
257
|
+
seen[[name, version]] = true
|
|
258
|
+
deps = runtime_dependencies(name: name, version: version, gem_home: gem_home)
|
|
259
|
+
return deps if deps.is_a?(String)
|
|
260
|
+
|
|
261
|
+
deps.each do |dep|
|
|
262
|
+
kind, versions_or_message = runtime_versions(name: dep["name"], gem_home: gem_home)
|
|
263
|
+
return versions_or_message if kind != :ok
|
|
264
|
+
|
|
265
|
+
satisfying = satisfying_versions(dep, versions_or_message)
|
|
266
|
+
if satisfying.empty?
|
|
267
|
+
if @dry_run
|
|
268
|
+
actions << {"name" => dep["name"], "requirement" => dep["requirement"],
|
|
269
|
+
"status" => "would_install"}
|
|
270
|
+
next # recursion stops at the prediction
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
failure = install_dependency(dep, gem_home)
|
|
274
|
+
return failure if failure
|
|
275
|
+
|
|
276
|
+
kind, versions_or_message = runtime_versions(name: dep["name"], gem_home: gem_home)
|
|
277
|
+
return versions_or_message if kind != :ok
|
|
278
|
+
satisfying = satisfying_versions(dep, versions_or_message)
|
|
279
|
+
if satisfying.empty?
|
|
280
|
+
return "dependency '#{dep["name"]} (#{dep["requirement"]})' required by gem " \
|
|
281
|
+
"'#{name}' is not satisfiable in runtime '#{gem_home}' after install; " \
|
|
282
|
+
"check the gem source or the manifest declaration"
|
|
283
|
+
end
|
|
284
|
+
actions << {"name" => dep["name"], "requirement" => dep["requirement"],
|
|
285
|
+
"status" => "installed"}
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
chosen = satisfying.max_by { |v| Gem::Version.new(v) }
|
|
289
|
+
queue << [dep["name"], chosen]
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
actions
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Installs a missing dependency at the parent requirement: the
|
|
296
|
+
# requirement string the runtime's own resolver would accept
|
|
297
|
+
# (`gem install name -v "~> 2.7"`).
|
|
298
|
+
# @return [String, nil] classified failure, nil on success
|
|
299
|
+
def install_dependency(dep, gem_home)
|
|
300
|
+
result = @runner.call(runtime_argv(gem_home,
|
|
301
|
+
["gem", "install", dep["name"], "--version", dep["requirement"], "--no-document"]))
|
|
302
|
+
return nil if result["status"] == "ok"
|
|
303
|
+
|
|
304
|
+
command_failure("gem install #{dep["name"]}", result)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# The runtime (non-development) dependencies of an installed
|
|
308
|
+
# gem, from `gem dependency <name> --version <v>` plain-text
|
|
309
|
+
# output: "dep (~> 2.0, runtime)" lines under the gem header.
|
|
310
|
+
# @return [Array<Hash{"name","requirement"}>, String] or a
|
|
311
|
+
# classified failure message
|
|
312
|
+
def runtime_dependencies(name:, version:, gem_home:)
|
|
313
|
+
result = @runner.call(runtime_argv(gem_home,
|
|
314
|
+
["gem", "dependency", name, "--version", version]))
|
|
315
|
+
if result["status"] == "enoent"
|
|
316
|
+
return "gem not found in PATH; install RubyGems and rerun ace-lab prepare"
|
|
317
|
+
end
|
|
318
|
+
return "gem dependency failed (#{exit_detail(result["status"])}); rerun ace-lab prepare" \
|
|
319
|
+
unless result["status"] == "ok"
|
|
320
|
+
|
|
321
|
+
result["out"].lines.filter_map do |line|
|
|
322
|
+
match = line.match(/\A\s+([A-Za-z0-9._-]+) \(([^)]*)\)\s*\z/)
|
|
323
|
+
next if match.nil?
|
|
324
|
+
|
|
325
|
+
parts = match[2].split(",").map(&:strip)
|
|
326
|
+
type = %w[runtime development].include?(parts.last) ? parts.pop : "runtime"
|
|
327
|
+
next if type == "development"
|
|
328
|
+
|
|
329
|
+
requirement = parts.join(", ")
|
|
330
|
+
requirement = ">= 0" if requirement.empty?
|
|
331
|
+
{"name" => match[1], "requirement" => requirement}
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def satisfying_versions(dep, versions)
|
|
336
|
+
requirement = dep["requirement"].split(",").map(&:strip)
|
|
337
|
+
versions.select do |version|
|
|
338
|
+
Gem::Dependency.new(dep["name"], *requirement).match?(dep["name"], version)
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
135
342
|
# Installs the EXACT declared version into the runtime. Sources
|
|
136
343
|
# are the gem command's own defaults — fetching from the
|
|
137
344
|
# configured gem source is provisioning network access, the same
|
|
@@ -169,8 +376,16 @@ module Ace
|
|
|
169
376
|
|
|
170
377
|
# `gem list <name> --exact --local` prints one `name (v1, v2)`
|
|
171
378
|
# line when the name is present in the runtime and nothing when
|
|
172
|
-
# it is absent. A
|
|
173
|
-
#
|
|
379
|
+
# it is absent. A DEFAULT gem's bundled version appears as a
|
|
380
|
+
# `default: V` part (`abbrev (default: 0.1.2)`,
|
|
381
|
+
# `json (2.9.4, default: 2.9.1)`): the marked V is the default
|
|
382
|
+
# gem's own version, every other part is an additionally
|
|
383
|
+
# installed version — both are present in the runtime, so the
|
|
384
|
+
# normalized list carries the marked version in place of the
|
|
385
|
+
# marker. Every candidate is validated by Gem::Version itself —
|
|
386
|
+
# platform-suffixed or otherwise malformed strings fail the
|
|
387
|
+
# probe (nil, fail closed) instead of crashing a later
|
|
388
|
+
# Gem::Version.new caller; never a guessed empty list.
|
|
174
389
|
def parse_versions(out, name)
|
|
175
390
|
line = out.lines.find { |candidate| candidate.start_with?("#{name} ") }
|
|
176
391
|
return [] if line.nil?
|
|
@@ -178,7 +393,17 @@ module Ace
|
|
|
178
393
|
match = line.chomp.match(/\A#{Regexp.escape(name)} \((.*)\)\z/)
|
|
179
394
|
return nil if match.nil?
|
|
180
395
|
|
|
181
|
-
match[1].split(",").map(&:strip)
|
|
396
|
+
versions = match[1].split(",").map(&:strip).map do |part|
|
|
397
|
+
default = part.match(/\Adefault: (\S+)\z/)
|
|
398
|
+
default ? default[1] : part
|
|
399
|
+
end
|
|
400
|
+
begin
|
|
401
|
+
versions.each { |version| Gem::Version.new(version) }
|
|
402
|
+
rescue ArgumentError
|
|
403
|
+
return nil
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
versions
|
|
182
407
|
end
|
|
183
408
|
|
|
184
409
|
# Classified one-line command failure: status plus the first
|
|
@@ -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
|
|
@@ -7,6 +7,7 @@ require_relative "../molecules/directory_provisioner"
|
|
|
7
7
|
require_relative "../molecules/key_provisioner"
|
|
8
8
|
require_relative "../molecules/role_verifier"
|
|
9
9
|
require_relative "../molecules/gemset_provisioner"
|
|
10
|
+
require_relative "../molecules/extension_provisioner"
|
|
10
11
|
|
|
11
12
|
module Ace
|
|
12
13
|
module Lab
|
|
@@ -51,9 +52,11 @@ module Ace
|
|
|
51
52
|
unresolved, effective_dry_run)
|
|
52
53
|
roles = prepare_roles(manifest["roles"], available, unresolved, effective_dry_run)
|
|
53
54
|
gems = prepare_gems(manifest["gemset"], effective_dry_run)
|
|
55
|
+
extensions = prepare_extensions(manifest["gemset"], effective_dry_run, gems)
|
|
54
56
|
|
|
55
57
|
{
|
|
56
|
-
"ok" => [accounts, directories, keys, roles, gems].flatten
|
|
58
|
+
"ok" => [accounts, directories, keys, roles, gems, extensions].flatten
|
|
59
|
+
.none? { |item| item["status"] == "failed" },
|
|
57
60
|
"dry_run" => effective_dry_run,
|
|
58
61
|
"machine" => manifest["machine"],
|
|
59
62
|
"manifest" => manifest_path,
|
|
@@ -61,7 +64,8 @@ module Ace
|
|
|
61
64
|
"directories" => directories,
|
|
62
65
|
"keys" => keys,
|
|
63
66
|
"roles" => roles,
|
|
64
|
-
"gems" => gems
|
|
67
|
+
"gems" => gems,
|
|
68
|
+
"extensions" => extensions
|
|
65
69
|
}
|
|
66
70
|
end
|
|
67
71
|
|
|
@@ -152,6 +156,28 @@ module Ace
|
|
|
152
156
|
provisioner.call(runtime: runtime)
|
|
153
157
|
end
|
|
154
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
|
+
|
|
155
181
|
def available_accounts(account_reports)
|
|
156
182
|
account_reports.reject { |report| report["status"] == "failed" }
|
|
157
183
|
.map { |report| report["username"] }
|
|
@@ -5,11 +5,14 @@ require_relative "../molecules/install_manifest_loader"
|
|
|
5
5
|
require_relative "../molecules/key_provisioner"
|
|
6
6
|
require_relative "../molecules/role_verifier"
|
|
7
7
|
require_relative "../molecules/gemset_provisioner"
|
|
8
|
+
require_relative "../molecules/extension_provisioner"
|
|
8
9
|
require_relative "../molecules/hitl_routes"
|
|
9
10
|
require_relative "../molecules/caller_authorizer"
|
|
10
11
|
require_relative "../atoms/topology_schema"
|
|
11
12
|
require "open3"
|
|
12
13
|
require "yaml"
|
|
14
|
+
require "json"
|
|
15
|
+
require "fileutils"
|
|
13
16
|
require "date"
|
|
14
17
|
|
|
15
18
|
module Ace
|
|
@@ -99,7 +102,8 @@ module Ace
|
|
|
99
102
|
"overseer" => check_overseer(manifest),
|
|
100
103
|
"adapters" => check_adapters(manifest, executors),
|
|
101
104
|
"tools" => check_tools(manifest, executors),
|
|
102
|
-
"gems" => check_gems(manifest)
|
|
105
|
+
"gems" => check_gems(manifest),
|
|
106
|
+
"extensions" => check_extensions(manifest, entries)
|
|
103
107
|
}
|
|
104
108
|
flat = checks.values.flatten(1)
|
|
105
109
|
{
|
|
@@ -583,20 +587,146 @@ module Ace
|
|
|
583
587
|
kind, versions_or_message = provisioner.runtime_versions(
|
|
584
588
|
name: gem["name"], gem_home: gem_home
|
|
585
589
|
)
|
|
586
|
-
reason =
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
status = case kind
|
|
591
|
-
when :ok then reason ? FAIL : PASS
|
|
592
|
-
when :unknown then UNKNOWN
|
|
593
|
-
else FAIL
|
|
590
|
+
status, reason = if kind != :ok
|
|
591
|
+
[kind == :unknown ? UNKNOWN : FAIL, versions_or_message]
|
|
592
|
+
else
|
|
593
|
+
verify_present_gem(provisioner, gem, versions_or_message, gem_home)
|
|
594
594
|
end
|
|
595
595
|
{"gem" => gem["name"], "version" => gem["version"], "gem_home" => gem_home,
|
|
596
596
|
"status" => status, "reason" => reason}
|
|
597
597
|
end
|
|
598
598
|
end
|
|
599
599
|
|
|
600
|
+
# A gem whose exact declared version is present must ALSO prove
|
|
601
|
+
# (1) its runtime dependency closure resolves in the target
|
|
602
|
+
# runtime — a gem listed in `gem list` with a missing
|
|
603
|
+
# transitive dependency is NOT ready (task 8xs.t.pu0.0) — and
|
|
604
|
+
# (2) no NEWER version sits beside the declared one: RubyGems
|
|
605
|
+
# activates the highest installed version, so a newer sibling
|
|
606
|
+
# would silently take over the runtime's CLI.
|
|
607
|
+
def verify_present_gem(provisioner, gem, versions, gem_home)
|
|
608
|
+
reason = gem_version_reason(gem, versions, gem_home)
|
|
609
|
+
return [FAIL, reason] if reason
|
|
610
|
+
|
|
611
|
+
newer_kind, newer_or_message = provisioner.newer_versions(
|
|
612
|
+
name: gem["name"], declared_version: gem["version"], gem_home: gem_home
|
|
613
|
+
)
|
|
614
|
+
if newer_kind != :ok
|
|
615
|
+
return [newer_kind == :unknown ? UNKNOWN : FAIL, newer_or_message]
|
|
616
|
+
end
|
|
617
|
+
unless newer_or_message.empty?
|
|
618
|
+
return [FAIL,
|
|
619
|
+
"gem '#{gem["name"]}' has newer version(s) #{newer_or_message.join(", ")} beside " \
|
|
620
|
+
"the declared #{gem["version"]} in '#{gem_home}'; the newer version would take " \
|
|
621
|
+
"over CLI activation; rerun ace-lab install"]
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
closure_kind, closure_or_message = provisioner.closure_failures(
|
|
625
|
+
name: gem["name"], version: gem["version"], gem_home: gem_home
|
|
626
|
+
)
|
|
627
|
+
return [closure_kind == :unknown ? UNKNOWN : FAIL, closure_or_message] if closure_kind != :ok
|
|
628
|
+
return [FAIL, closure_or_message.join("; ")] unless closure_or_message.empty?
|
|
629
|
+
|
|
630
|
+
[PASS, nil]
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
# Declared harness extension projections (task 8xs.t.pu0.0):
|
|
634
|
+
# re-derived from the same receipts and installed gem trees the
|
|
635
|
+
# provisioner converges (shared ExtensionProjection helpers) —
|
|
636
|
+
# the receipt must name the declared gem at its exact declared
|
|
637
|
+
# version, every projected file must match the installed gem's
|
|
638
|
+
# handbook/extensions content, the destination must not escape
|
|
639
|
+
# the extensions directory through a symlinked component, and
|
|
640
|
+
# the projected files must grant the declared account read.
|
|
641
|
+
def check_extensions(manifest, entries)
|
|
642
|
+
gemset = manifest["gemset"]
|
|
643
|
+
gemset["extensions"].map do |entry|
|
|
644
|
+
declared = gemset["gems"].find { |gem| gem["name"] == entry["gem"] }
|
|
645
|
+
reason = if declared.nil?
|
|
646
|
+
"extension source gem '#{entry["gem"]}' is not declared in the gemset"
|
|
647
|
+
else
|
|
648
|
+
extension_reason(entry, declared, gemset["gem_home"], entries[entry["account"]])
|
|
649
|
+
end
|
|
650
|
+
{"extension" => entry["gem"], "gem" => entry["gem"], "path" => entry["path"],
|
|
651
|
+
"account" => entry["account"], "status" => reason ? FAIL : PASS, "reason" => reason}
|
|
652
|
+
end
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
def extension_reason(entry, declared, gem_home, account_entry)
|
|
656
|
+
source_root = Molecules::ExtensionProjection.gem_source_root(
|
|
657
|
+
gem_home, entry["gem"], declared["version"]
|
|
658
|
+
)
|
|
659
|
+
return "gem '#{entry["gem"]}' #{declared["version"]} is not installed with a " \
|
|
660
|
+
"handbook/extensions tree in '#{gem_home}'; rerun ace-lab install" unless File.directory?(source_root)
|
|
661
|
+
|
|
662
|
+
receipt_path = File.join(entry["path"], Molecules::ExtensionProvisioner::RECEIPT_NAME)
|
|
663
|
+
unless File.file?(receipt_path)
|
|
664
|
+
return "extension projection receipt '#{receipt_path}' is missing; rerun ace-lab install"
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
begin
|
|
668
|
+
receipt = JSON.parse(File.read(receipt_path))
|
|
669
|
+
rescue JSON::ParserError
|
|
670
|
+
return "extension projection receipt '#{receipt_path}' is not valid JSON; rerun ace-lab install"
|
|
671
|
+
rescue Errno::EACCES, Errno::ENOENT
|
|
672
|
+
return "extension projection receipt '#{receipt_path}' is not readable; " \
|
|
673
|
+
"rerun ace-lab install"
|
|
674
|
+
end
|
|
675
|
+
if receipt["gem"] != entry["gem"] || receipt["version"] != declared["version"]
|
|
676
|
+
return "extension projection at '#{entry["path"]}' was projected from " \
|
|
677
|
+
"'#{receipt["gem"]} #{receipt["version"]}' but the manifest declares " \
|
|
678
|
+
"'#{entry["gem"]} #{declared["version"]}'; rerun ace-lab install"
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
expected = Molecules::ExtensionProjection.source_files(source_root)
|
|
682
|
+
listed = receipt.fetch("files", [])
|
|
683
|
+
if listed.sort != expected.keys.sort
|
|
684
|
+
return "extension projection at '#{entry["path"]}' lists different files than " \
|
|
685
|
+
"gem '#{entry["gem"]}' #{declared["version"]} carries; rerun ace-lab install"
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
# A destination that escapes the extensions directory through
|
|
689
|
+
# a symlinked component is refused before any comparison (the
|
|
690
|
+
# same validate_destination posture convergence applies).
|
|
691
|
+
begin
|
|
692
|
+
expected.each_key do |relative|
|
|
693
|
+
Molecules::ExtensionProjection.validate_destination!(entry["path"], relative)
|
|
694
|
+
end
|
|
695
|
+
rescue Ace::Lab::Error => e
|
|
696
|
+
return "#{e.message}; rerun ace-lab install"
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
mismatch = expected.find do |relative, source|
|
|
700
|
+
target = File.join(entry["path"], relative)
|
|
701
|
+
!File.file?(target) || !FileUtils.compare_file(source, target)
|
|
702
|
+
end
|
|
703
|
+
return "extension projection file '#{mismatch.first}' differs from gem " \
|
|
704
|
+
"'#{entry["gem"]}' #{declared["version"]}; rerun ace-lab install" if mismatch
|
|
705
|
+
|
|
706
|
+
# Declared-account readability (stat-based — the verifier
|
|
707
|
+
# does not run as the declared account): a projected file is
|
|
708
|
+
# readable when it is world-readable, or owner-readable AND
|
|
709
|
+
# owned by the declared account's uid.
|
|
710
|
+
unreadable = expected.keys.find do |relative|
|
|
711
|
+
unreadable_reason(File.join(entry["path"], relative), account_entry)
|
|
712
|
+
end
|
|
713
|
+
return "extension projection file '#{unreadable}' is not readable by the declared " \
|
|
714
|
+
"account '#{entry["account"]}'; rerun ace-lab install" if unreadable
|
|
715
|
+
|
|
716
|
+
nil
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
def unreadable_reason(path, account_entry)
|
|
720
|
+
stat = lstat(path)
|
|
721
|
+
return true if stat.nil? || !stat.file? || stat.symlink?
|
|
722
|
+
|
|
723
|
+
mode = stat.mode & 0o777
|
|
724
|
+
return false if (mode & 0o004) != 0 # world-readable
|
|
725
|
+
return false if (mode & 0o400) != 0 && account_entry && stat.uid == account_entry.uid
|
|
726
|
+
|
|
727
|
+
true
|
|
728
|
+
end
|
|
729
|
+
|
|
600
730
|
def gem_version_reason(gem, versions, gem_home)
|
|
601
731
|
return nil if versions.include?(gem["version"])
|
|
602
732
|
if versions.empty?
|
data/lib/ace/lab/version.rb
CHANGED
data/lib/ace/lab.rb
CHANGED
|
@@ -18,6 +18,7 @@ require_relative "lab/models/topology_entry"
|
|
|
18
18
|
require_relative "lab/models/query_result"
|
|
19
19
|
require_relative "lab/models/status_report"
|
|
20
20
|
require_relative "lab/molecules/topology_loader"
|
|
21
|
+
require_relative "lab/molecules/project_locations"
|
|
21
22
|
require_relative "lab/molecules/config_state_classifier"
|
|
22
23
|
require_relative "lab/molecules/lab_state_scanner"
|
|
23
24
|
require_relative "lab/molecules/caller_authorizer"
|
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.
|
|
4
|
+
version: 0.14.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-11-01 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ace-support-cli
|
|
@@ -173,9 +173,19 @@ files:
|
|
|
173
173
|
- lib/ace/lab/atoms/install_schema.rb
|
|
174
174
|
- lib/ace/lab/atoms/public_projection.rb
|
|
175
175
|
- lib/ace/lab/atoms/topology_schema.rb
|
|
176
|
+
- lib/ace/lab/auth.rb
|
|
177
|
+
- lib/ace/lab/auth/credential_handoff.rb
|
|
178
|
+
- lib/ace/lab/auth/lease_store.rb
|
|
179
|
+
- lib/ace/lab/auth/plugins/env_provider.rb
|
|
180
|
+
- lib/ace/lab/auth/plugins/forgejo_provider.rb
|
|
181
|
+
- lib/ace/lab/auth/plugins/github_provider.rb
|
|
182
|
+
- lib/ace/lab/auth/profile_schema.rb
|
|
183
|
+
- lib/ace/lab/auth/providers.rb
|
|
184
|
+
- lib/ace/lab/auth/service.rb
|
|
176
185
|
- lib/ace/lab/authorization_path.rb
|
|
177
186
|
- lib/ace/lab/cli.rb
|
|
178
187
|
- lib/ace/lab/cli/commands/agents.rb
|
|
188
|
+
- lib/ace/lab/cli/commands/auth.rb
|
|
179
189
|
- lib/ace/lab/cli/commands/install.rb
|
|
180
190
|
- lib/ace/lab/cli/commands/prepare.rb
|
|
181
191
|
- lib/ace/lab/cli/commands/project_add.rb
|
|
@@ -198,8 +208,11 @@ files:
|
|
|
198
208
|
- lib/ace/lab/molecules/config_state_classifier.rb
|
|
199
209
|
- lib/ace/lab/molecules/directory_provisioner.rb
|
|
200
210
|
- lib/ace/lab/molecules/exact_resolver.rb
|
|
211
|
+
- lib/ace/lab/molecules/extension_projection.rb
|
|
212
|
+
- lib/ace/lab/molecules/extension_provisioner.rb
|
|
201
213
|
- lib/ace/lab/molecules/forge_merge_config.rb
|
|
202
214
|
- lib/ace/lab/molecules/forgejo_merge_adapter.rb
|
|
215
|
+
- lib/ace/lab/molecules/gem_payload_permissions.rb
|
|
203
216
|
- lib/ace/lab/molecules/gemset_provisioner.rb
|
|
204
217
|
- lib/ace/lab/molecules/grant_provisioner.rb
|
|
205
218
|
- lib/ace/lab/molecules/grant_resolver.rb
|
|
@@ -209,6 +222,7 @@ files:
|
|
|
209
222
|
- lib/ace/lab/molecules/key_provisioner.rb
|
|
210
223
|
- lib/ace/lab/molecules/lab_state_scanner.rb
|
|
211
224
|
- lib/ace/lab/molecules/overseer_provisioner.rb
|
|
225
|
+
- lib/ace/lab/molecules/project_locations.rb
|
|
212
226
|
- lib/ace/lab/molecules/project_registry.rb
|
|
213
227
|
- lib/ace/lab/molecules/repository_provisioner.rb
|
|
214
228
|
- lib/ace/lab/molecules/role_verifier.rb
|