ace-lab 0.13.1 → 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 +94 -1
- 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/gemset_provisioner.rb +223 -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 +15 -2
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "pathname"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Lab
|
|
9
|
+
module Molecules
|
|
10
|
+
# Shared extension-projection primitives (task 8xs.t.pu0.0): the
|
|
11
|
+
# ONE place that knows how the installed gem's handbook/extensions
|
|
12
|
+
# tree is located, enumerated, and projected safely — used by both
|
|
13
|
+
# ExtensionProvisioner (convergence) and Verify (readiness), so
|
|
14
|
+
# the two can never drift.
|
|
15
|
+
module ExtensionProjection
|
|
16
|
+
RECEIPT_NAME = ".ace-lab-extension-projection.json"
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# The installed gem's own tree at the EXACT declared version.
|
|
21
|
+
# @return [String, nil] the handbook/extensions directory or nil
|
|
22
|
+
# when the gem (or its extensions tree) is not installed
|
|
23
|
+
def gem_source_root(gem_home, name, version)
|
|
24
|
+
candidate = File.join(gem_home, "gems", "#{name}-#{version}")
|
|
25
|
+
extensions = File.join(candidate, "handbook", "extensions")
|
|
26
|
+
File.directory?(extensions) ? extensions : nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Relative path → absolute source file for every file under the
|
|
30
|
+
# gem's handbook/extensions tree (e.g. "ace-wake/index.js").
|
|
31
|
+
# @return [Hash<String, String>]
|
|
32
|
+
def source_files(source_root)
|
|
33
|
+
Dir.glob(File.join(source_root, "**", "*")).select { |path| File.file?(path) }
|
|
34
|
+
.to_h { |path| [path.delete_prefix("#{source_root}/"), path] }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Resolve and fully validate a projection destination before
|
|
38
|
+
# anything is written or verified (the provider_syncer
|
|
39
|
+
# validate_projection_destination posture): every path
|
|
40
|
+
# component under the projection directory must be a directory
|
|
41
|
+
# or creatable — a symlinked component could redirect the
|
|
42
|
+
# write outside the extensions directory, so it is refused.
|
|
43
|
+
# @param output_dir [String] manifest-declared extension dir
|
|
44
|
+
# @param relative_path [String] receipt-relative file path
|
|
45
|
+
# @return [String] the validated absolute destination
|
|
46
|
+
# @raise [Ace::Lab::Error] invalid or escaping destination
|
|
47
|
+
def validate_destination!(output_dir, relative_path)
|
|
48
|
+
segments = relative_path.to_s.split(File::SEPARATOR)
|
|
49
|
+
if segments.empty? || segments.any? { |segment| segment.empty? || segment == "." || segment == ".." }
|
|
50
|
+
raise Ace::Lab::Error, "cannot project #{relative_path.inspect}: invalid destination path"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
begin
|
|
54
|
+
current = Pathname.new(File.realpath(output_dir))
|
|
55
|
+
rescue Errno::ENOENT
|
|
56
|
+
# the projection directory itself does not exist yet — no
|
|
57
|
+
# component below it can escape
|
|
58
|
+
return File.join(output_dir, *segments)
|
|
59
|
+
end
|
|
60
|
+
segments.each_with_index do |segment, index|
|
|
61
|
+
current = current.join(segment)
|
|
62
|
+
if File.symlink?(current.to_s)
|
|
63
|
+
raise Ace::Lab::Error,
|
|
64
|
+
"cannot project #{relative_path} into #{output_dir}: a symlinked path component " \
|
|
65
|
+
"would escape the projection directory"
|
|
66
|
+
end
|
|
67
|
+
# The FINAL segment is the file itself — only intermediate
|
|
68
|
+
# components must be directories (compare by position, not
|
|
69
|
+
# value: "a/a/index.js" repeats "a" legitimately).
|
|
70
|
+
next if index == segments.size - 1
|
|
71
|
+
|
|
72
|
+
if !current.directory? && File.exist?(current.to_s)
|
|
73
|
+
raise Ace::Lab::Error,
|
|
74
|
+
"cannot project #{relative_path} into #{output_dir}: #{current} exists and is not a directory"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
if File.directory?(current.to_s)
|
|
78
|
+
# Receipt ownership of a path never authorizes writing
|
|
79
|
+
# through a directory planted at that path — cp would
|
|
80
|
+
# reinterpret the destination as a container.
|
|
81
|
+
raise Ace::Lab::Error,
|
|
82
|
+
"cannot project #{relative_path} into #{output_dir}: #{current} exists and is a directory"
|
|
83
|
+
end
|
|
84
|
+
current.to_s
|
|
85
|
+
rescue Errno::ENOENT
|
|
86
|
+
raise Ace::Lab::Error, "cannot project #{relative_path} into #{output_dir}: projection directory vanished"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "extension_projection"
|
|
5
|
+
require "etc"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "json"
|
|
8
|
+
|
|
9
|
+
module Ace
|
|
10
|
+
module Lab
|
|
11
|
+
module Molecules
|
|
12
|
+
# Projects harness extension assets from an INSTALLED gem's exact
|
|
13
|
+
# declared version into the manifest-declared extension directory
|
|
14
|
+
# (task 8xs.t.pu0.0): the manual host-lane copy of the ace-wake
|
|
15
|
+
# extension is replaced by a converged, repeatable projection.
|
|
16
|
+
#
|
|
17
|
+
# Source of truth is the installed gem tree itself —
|
|
18
|
+
# `<gem_home>/gems/<name>-<version>/handbook/extensions` — so the
|
|
19
|
+
# projection always matches the exact version the manifest
|
|
20
|
+
# declared (never the checkout, never a newer gem beside it).
|
|
21
|
+
# Each target directory carries a receipt
|
|
22
|
+
# (`.ace-lab-extension-projection.json`) recording the producing
|
|
23
|
+
# gem, version and file list; ONLY receipt-listed files are ever
|
|
24
|
+
# removed, so user-authored extensions in the same directory are
|
|
25
|
+
# never touched. The report names the version that WAS loaded
|
|
26
|
+
# before this run (`loaded_version`) and whether the projection
|
|
27
|
+
# changed (`restart_required`) — a changed projection means the
|
|
28
|
+
# running harness processes still hold the OLD extension; the
|
|
29
|
+
# installer REPORTS the restart need, it never restarts an
|
|
30
|
+
# executor on its own.
|
|
31
|
+
class ExtensionProvisioner
|
|
32
|
+
RECEIPT_NAME = ExtensionProjection::RECEIPT_NAME
|
|
33
|
+
FILE_MODE = 0o644
|
|
34
|
+
DIR_MODE = 0o755
|
|
35
|
+
|
|
36
|
+
# @param gem_home [String] target runtime the gems were
|
|
37
|
+
# converged into
|
|
38
|
+
# @param dry_run [Boolean] predict without writing
|
|
39
|
+
# @param account_lookup [Proc, nil] passwd lookup seam for the
|
|
40
|
+
# declared accounts (projection ownership convergence)
|
|
41
|
+
def initialize(gem_home:, dry_run: false, account_lookup: nil)
|
|
42
|
+
@gem_home = gem_home
|
|
43
|
+
@dry_run = dry_run
|
|
44
|
+
@account_lookup = account_lookup || method(:lookup_passwd)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @param extensions [Array<Hash>] normalized manifest gemset
|
|
48
|
+
# extensions entries ({"gem" =>, "path" =>, "account" =>})
|
|
49
|
+
# @param gems [Array<Hash>] declared gem set (name/version)
|
|
50
|
+
# @return [Array<Hash>] one report per entry:
|
|
51
|
+
# {extension:, gem:, version:, path:, account:, status:,
|
|
52
|
+
# loaded_version:, restart_required:, files:, message:}
|
|
53
|
+
def call(extensions:, gems:)
|
|
54
|
+
extensions.map do |entry|
|
|
55
|
+
converge(entry, gems)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def converge(entry, gems)
|
|
62
|
+
declared = gems.find { |gem| gem["name"] == entry["gem"] }
|
|
63
|
+
version = declared && declared["version"]
|
|
64
|
+
source = version && ExtensionProjection.gem_source_root(@gem_home, entry["gem"], version)
|
|
65
|
+
|
|
66
|
+
if source.nil?
|
|
67
|
+
# Fresh-lab dry run (the gem phase predicts would_install
|
|
68
|
+
# for the same missing gem): predict the projection the
|
|
69
|
+
# converged gem would feed instead of reporting a failure
|
|
70
|
+
# the wet run would not produce.
|
|
71
|
+
if @dry_run
|
|
72
|
+
return entry_report(entry, version, "would_project", nil, true, [])
|
|
73
|
+
end
|
|
74
|
+
return failed(entry, version,
|
|
75
|
+
"gem '#{entry["gem"]}' is not installed in runtime '#{@gem_home}' at declared " \
|
|
76
|
+
"version '#{version}'; converge the gemset first (ace-lab install)")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
expected = ExtensionProjection.source_files(source)
|
|
80
|
+
if expected.empty?
|
|
81
|
+
return failed(entry, version,
|
|
82
|
+
"gem '#{entry["gem"]}' #{version} carries no handbook/extensions tree")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
output_dir = entry["path"]
|
|
86
|
+
escape = projection_escape_reason(output_dir, expected.keys)
|
|
87
|
+
return failed(entry, version, escape) if escape
|
|
88
|
+
|
|
89
|
+
receipt = read_receipt(output_dir)
|
|
90
|
+
loaded_version = receipt && receipt["gem"] == entry["gem"] ? receipt["version"] : nil
|
|
91
|
+
|
|
92
|
+
changed = projection_changed?(output_dir, expected, receipt, version)
|
|
93
|
+
if !changed
|
|
94
|
+
converge_modes(output_dir, expected, entry)
|
|
95
|
+
return entry_report(entry, version, "unchanged", loaded_version, false,
|
|
96
|
+
receipt ? receipt.fetch("files") : expected.keys)
|
|
97
|
+
end
|
|
98
|
+
# dry-run predicts the projection (restart needed) without
|
|
99
|
+
# writing anything
|
|
100
|
+
return entry_report(entry, version, "projected", loaded_version, true, expected.keys) if @dry_run
|
|
101
|
+
|
|
102
|
+
project(output_dir, expected, entry, version)
|
|
103
|
+
entry_report(entry, version, "projected", loaded_version, true, expected.keys)
|
|
104
|
+
rescue SystemCallError => e
|
|
105
|
+
failed(entry, version,
|
|
106
|
+
"extension projection into '#{output_dir}' failed (#{e.class.name}); " \
|
|
107
|
+
"rerun ace-lab prepare with sudo or adjust ownership manually")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def failed(entry, version, message)
|
|
111
|
+
entry_report(entry, version, "failed", nil, false, [])
|
|
112
|
+
.merge("message" => message)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def entry_report(entry, version, status, loaded_version, restart_required, files)
|
|
116
|
+
{"extension" => entry["gem"], "gem" => entry["gem"], "version" => version,
|
|
117
|
+
"path" => entry["path"], "account" => entry["account"], "status" => status,
|
|
118
|
+
"loaded_version" => loaded_version, "restart_required" => restart_required,
|
|
119
|
+
"files" => files, "message" => nil}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Refuse a destination that escapes the projection directory
|
|
123
|
+
# through a symlinked component BEFORE anything is written (the
|
|
124
|
+
# provider_syncer validate_projection_destination posture).
|
|
125
|
+
def projection_escape_reason(output_dir, relative_paths)
|
|
126
|
+
relative_paths.each { |relative| ExtensionProjection.validate_destination!(output_dir, relative) }
|
|
127
|
+
nil
|
|
128
|
+
rescue Ace::Lab::Error => e
|
|
129
|
+
e.message
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# A projection changed when the receipt is absent, names a
|
|
133
|
+
# different version, lists a different file set, or any
|
|
134
|
+
# expected file's content differs on disk.
|
|
135
|
+
def projection_changed?(output_dir, expected, receipt, version)
|
|
136
|
+
return true if receipt.nil?
|
|
137
|
+
return true if receipt["version"] != version
|
|
138
|
+
return true unless receipt.fetch("files", []).sort == expected.keys.sort
|
|
139
|
+
|
|
140
|
+
expected.any? do |relative, source|
|
|
141
|
+
target = File.join(output_dir, relative)
|
|
142
|
+
!File.file?(target) || !FileUtils.compare_file(source, target)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def project(output_dir, expected, entry, version)
|
|
147
|
+
FileUtils.mkdir_p(output_dir)
|
|
148
|
+
expected.each do |relative, source|
|
|
149
|
+
target = File.join(output_dir, relative)
|
|
150
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
151
|
+
FileUtils.cp(source, target)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Only receipt-owned files no longer in the projection are
|
|
155
|
+
# removed; a removal that fails keeps its receipt entry so a
|
|
156
|
+
# later run retries it (user-authored extensions are never
|
|
157
|
+
# touched).
|
|
158
|
+
previous = read_receipt(output_dir)&.fetch("files", []) || []
|
|
159
|
+
retained = previous.filter_map do |relative|
|
|
160
|
+
target = File.join(output_dir, relative)
|
|
161
|
+
next relative if expected.key?(relative)
|
|
162
|
+
|
|
163
|
+
FileUtils.rm_f(target)
|
|
164
|
+
File.exist?(target) ? relative : nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
write_receipt(output_dir, entry, version, (expected.keys + retained).sort)
|
|
168
|
+
converge_modes(output_dir, expected, entry)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Mode/ownership convergence (the key-provisioner posture):
|
|
172
|
+
# projected files carry owner read/write plus world read
|
|
173
|
+
# (0644), projection directories 0755, and both are owned by
|
|
174
|
+
# the declared account whenever the passwd lookup resolves it —
|
|
175
|
+
# so the target executor can always read its extensions.
|
|
176
|
+
def converge_modes(output_dir, expected, entry)
|
|
177
|
+
account = @account_lookup.call(entry["account"])
|
|
178
|
+
targets = expected.keys.map { |relative| File.join(output_dir, relative) }
|
|
179
|
+
targets << File.join(output_dir, RECEIPT_NAME)
|
|
180
|
+
targets.each do |target|
|
|
181
|
+
next unless File.file?(target)
|
|
182
|
+
|
|
183
|
+
FileUtils.chmod(FILE_MODE, target)
|
|
184
|
+
FileUtils.chown(account.uid, account.gid, target) if account
|
|
185
|
+
end
|
|
186
|
+
root = File.realpath(output_dir)
|
|
187
|
+
dirs = expected.keys.filter_map do |relative|
|
|
188
|
+
File.dirname(File.join(root, relative)) if relative.include?(File::SEPARATOR)
|
|
189
|
+
end.uniq
|
|
190
|
+
until dirs.empty?
|
|
191
|
+
dir = dirs.pop
|
|
192
|
+
FileUtils.chmod(DIR_MODE, dir)
|
|
193
|
+
FileUtils.chown(account.uid, account.gid, dir) if account
|
|
194
|
+
parent = File.dirname(dir)
|
|
195
|
+
dirs << parent if dir != root && parent.start_with?("#{root}/") && !dirs.include?(parent) && parent != root
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def write_receipt(output_dir, entry, version, files)
|
|
200
|
+
document = {
|
|
201
|
+
"gem" => entry["gem"], "version" => version,
|
|
202
|
+
"projected_at" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
203
|
+
"files" => files.sort
|
|
204
|
+
}
|
|
205
|
+
File.write(File.join(output_dir, RECEIPT_NAME), JSON.pretty_generate(document))
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def read_receipt(output_dir)
|
|
209
|
+
path = File.join(output_dir, RECEIPT_NAME)
|
|
210
|
+
return nil unless File.file?(path)
|
|
211
|
+
|
|
212
|
+
document = JSON.parse(File.read(path))
|
|
213
|
+
document.is_a?(Hash) ? document : nil
|
|
214
|
+
rescue JSON::ParserError, Errno::EACCES
|
|
215
|
+
nil
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def lookup_passwd(username)
|
|
219
|
+
Etc.getpwnam(username)
|
|
220
|
+
rescue ArgumentError
|
|
221
|
+
nil
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -49,7 +49,12 @@ module Ace
|
|
|
49
49
|
if runtime_failure
|
|
50
50
|
base(gem, gem_home).merge("status" => "failed", "message" => runtime_failure)
|
|
51
51
|
else
|
|
52
|
-
converge(base(gem, gem_home), gem, gem_home)
|
|
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
|
|
53
58
|
end
|
|
54
59
|
end
|
|
55
60
|
end
|
|
@@ -78,6 +83,55 @@ module Ace
|
|
|
78
83
|
[:ok, versions]
|
|
79
84
|
end
|
|
80
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
|
+
|
|
81
135
|
private
|
|
82
136
|
|
|
83
137
|
def base(gem, gem_home)
|
|
@@ -132,6 +186,153 @@ module Ace
|
|
|
132
186
|
end
|
|
133
187
|
end
|
|
134
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
|
+
|
|
135
336
|
# Installs the EXACT declared version into the runtime. Sources
|
|
136
337
|
# are the gem command's own defaults — fetching from the
|
|
137
338
|
# configured gem source is provisioning network access, the same
|
|
@@ -169,8 +370,16 @@ module Ace
|
|
|
169
370
|
|
|
170
371
|
# `gem list <name> --exact --local` prints one `name (v1, v2)`
|
|
171
372
|
# line when the name is present in the runtime and nothing when
|
|
172
|
-
# it is absent. A
|
|
173
|
-
#
|
|
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.
|
|
174
383
|
def parse_versions(out, name)
|
|
175
384
|
line = out.lines.find { |candidate| candidate.start_with?("#{name} ") }
|
|
176
385
|
return [] if line.nil?
|
|
@@ -178,7 +387,17 @@ module Ace
|
|
|
178
387
|
match = line.chomp.match(/\A#{Regexp.escape(name)} \((.*)\)\z/)
|
|
179
388
|
return nil if match.nil?
|
|
180
389
|
|
|
181
|
-
match[1].split(",").map(&:strip)
|
|
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
|
|
182
401
|
end
|
|
183
402
|
|
|
184
403
|
# 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
|