ace-lab 0.9.0 → 0.13.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 +363 -104
- data/README.md +66 -100
- data/docs/usage.md +529 -592
- data/lib/ace/lab/atoms/install_schema.rb +373 -0
- data/lib/ace/lab/cli/commands/install.rb +161 -0
- data/lib/ace/lab/cli/commands/prepare.rb +118 -0
- data/lib/ace/lab/cli/commands/project_add.rb +101 -0
- data/lib/ace/lab/cli/commands/status.rb +109 -0
- data/lib/ace/lab/cli/commands/verify.rb +115 -0
- data/lib/ace/lab/cli.rb +21 -23
- data/lib/ace/lab/install_manifest_path.rb +23 -0
- data/lib/ace/lab/models/status_report.rb +38 -0
- data/lib/ace/lab/molecules/account_provisioner.rb +196 -0
- data/lib/ace/lab/molecules/config_state_classifier.rb +145 -0
- data/lib/ace/lab/molecules/directory_provisioner.rb +208 -0
- data/lib/ace/lab/molecules/grant_provisioner.rb +250 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +34 -23
- data/lib/ace/lab/molecules/hitl_routes.rb +263 -0
- data/lib/ace/lab/molecules/install_manifest_loader.rb +64 -0
- data/lib/ace/lab/molecules/key_provisioner.rb +736 -0
- data/lib/ace/lab/molecules/lab_state_scanner.rb +55 -0
- data/lib/ace/lab/molecules/overseer_provisioner.rb +188 -0
- data/lib/ace/lab/molecules/project_registry.rb +251 -0
- data/lib/ace/lab/molecules/repository_provisioner.rb +141 -0
- data/lib/ace/lab/molecules/role_verifier.rb +223 -0
- data/lib/ace/lab/organisms/install.rb +213 -0
- data/lib/ace/lab/organisms/prepare.rb +156 -0
- data/lib/ace/lab/organisms/project_add.rb +170 -0
- data/lib/ace/lab/organisms/status_service.rb +70 -0
- data/lib/ace/lab/organisms/verify.rb +768 -0
- data/lib/ace/lab/version.rb +1 -1
- data/lib/ace/lab.rb +34 -6
- metadata +34 -108
- data/lib/ace/lab/atoms/protected_publication_input.rb +0 -50
- data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +0 -114
- data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +0 -86
- data/lib/ace/lab/atoms/service_input.rb +0 -92
- data/lib/ace/lab/cli/commands/assignment.rb +0 -92
- data/lib/ace/lab/cli/commands/command_execute.rb +0 -28
- data/lib/ace/lab/cli/commands/run.rb +0 -65
- data/lib/ace/lab/cli/commands/service.rb +0 -240
- data/lib/ace/lab/models/service_receipt.rb +0 -18
- data/lib/ace/lab/molecules/command_owner_admission.rb +0 -183
- data/lib/ace/lab/molecules/command_policy.rb +0 -158
- data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
- data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
- data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
- data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
- data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
- data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -152
- data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
- data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
- data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
- data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
- data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
- data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
- data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
- data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
- data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
- data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
- data/lib/ace/lab/molecules/service_executor.rb +0 -99
- data/lib/ace/lab/molecules/service_policy.rb +0 -289
- data/lib/ace/lab/organisms/authority_composition.rb +0 -87
- data/lib/ace/lab/organisms/command_executor.rb +0 -220
- data/lib/ace/lab/organisms/command_run_client.rb +0 -131
- data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -120
- data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -153
- data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -679
- data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
- data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
- data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
- data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
- data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
- data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
- data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
- data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
- data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
- data/lib/ace/lab/organisms/service_request_service.rb +0 -249
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "../molecules/install_manifest_loader"
|
|
5
|
+
require_relative "../molecules/key_provisioner"
|
|
6
|
+
require_relative "../molecules/role_verifier"
|
|
7
|
+
require_relative "../molecules/hitl_routes"
|
|
8
|
+
require_relative "../molecules/caller_authorizer"
|
|
9
|
+
require_relative "../atoms/topology_schema"
|
|
10
|
+
require "open3"
|
|
11
|
+
require "yaml"
|
|
12
|
+
require "date"
|
|
13
|
+
|
|
14
|
+
module Ace
|
|
15
|
+
module Lab
|
|
16
|
+
module Organisms
|
|
17
|
+
# Read-only end-of-install readiness verification (L8.5, task
|
|
18
|
+
# 8xg.t.sbz.7.4): checks that the installed lab is ready UNDER
|
|
19
|
+
# THE TARGET ACCOUNTS — not merely for the installer's own
|
|
20
|
+
# account. For every account, directory, key, role, project and
|
|
21
|
+
# grant declared in the trusted install manifest it recomputes
|
|
22
|
+
# the facts from the SAME artifacts install/resume derive truth
|
|
23
|
+
# from (the manifest, the filesystem's permission bits and
|
|
24
|
+
# ownership, the repositories, the per-project registry and the
|
|
25
|
+
# trusted authorization document): no journal, no second state
|
|
26
|
+
# owner, nothing written anywhere.
|
|
27
|
+
#
|
|
28
|
+
# Every check reports pass / fail / unknown with a concrete
|
|
29
|
+
# reason; a failure beside healthy data never hides the healthy
|
|
30
|
+
# data and never prevents the remaining checks from running. The
|
|
31
|
+
# overall verdict (`ready`) is false when ANY check failed — a
|
|
32
|
+
# missing file, wrong permission, unreadable role instructions,
|
|
33
|
+
# an unreachable origin, a principal without grants or a
|
|
34
|
+
# committable `_overseer` folder each block lab readiness.
|
|
35
|
+
# `unknown` (a network remote the read-only verifier does not
|
|
36
|
+
# dial, an unset PATH, a missing resolver) is reported and
|
|
37
|
+
# counted but does not block: it is not a failure, and pretending
|
|
38
|
+
# it were one would make `verify` unusable offline. An unknown is
|
|
39
|
+
# never a verified pass either — the check reports `unknown`, not
|
|
40
|
+
# `pass`, so unverified availability never counts as proven.
|
|
41
|
+
#
|
|
42
|
+
# Because verification reads only converged artifacts, a fresh
|
|
43
|
+
# install and a resumed one verify identically — there is no
|
|
44
|
+
# hidden gap after resume.
|
|
45
|
+
class Verify
|
|
46
|
+
PASS = "pass"
|
|
47
|
+
FAIL = "fail"
|
|
48
|
+
UNKNOWN = "unknown"
|
|
49
|
+
|
|
50
|
+
attr_reader :manifest_path, :platform
|
|
51
|
+
|
|
52
|
+
# @param manifest_path [String, nil] trusted manifest location
|
|
53
|
+
# @param runner [Proc, nil] argv runner seam (git / ssh-keygen)
|
|
54
|
+
# @param account_lookup [Proc, nil] passwd lookup seam
|
|
55
|
+
# @param group_source [Proc, nil] supplementary-group seam
|
|
56
|
+
# (RoleVerifier)
|
|
57
|
+
# @param trusted_read [Proc, nil] trusted-document read seam
|
|
58
|
+
# (defaults to GrantResolver.read_verified)
|
|
59
|
+
# @param authorization_path [String, nil] trusted grants
|
|
60
|
+
# location (defaults to the fixed deployment path)
|
|
61
|
+
# @param nav_runner [Proc, nil] (argv, cwd) runner seam for
|
|
62
|
+
# resolving protocol role instructions through ace-nav
|
|
63
|
+
# @param platform [String, nil] "darwin" / "linux"
|
|
64
|
+
def initialize(manifest_path: nil, runner: nil, account_lookup: nil,
|
|
65
|
+
group_source: nil, trusted_read: nil, authorization_path: nil, nav_runner: nil,
|
|
66
|
+
platform: nil)
|
|
67
|
+
@manifest_path = manifest_path || Ace::Lab.install_manifest_path
|
|
68
|
+
@runner = runner || method(:run_argv)
|
|
69
|
+
@account_lookup = account_lookup || method(:lookup_passwd)
|
|
70
|
+
@group_source = group_source
|
|
71
|
+
@trusted_read = trusted_read || ->(path) { Molecules::GrantResolver.read_verified(path) }
|
|
72
|
+
@authorization_path = authorization_path || Ace::Lab.authorization_path
|
|
73
|
+
@nav_runner = nav_runner || method(:run_nav)
|
|
74
|
+
@platform = platform
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return [Hash] {ready:, machine:, manifest:,
|
|
78
|
+
# authorization_path:, checks: {accounts:, directories:,
|
|
79
|
+
# keys:, roles:, repositories:, grants:, registry:,
|
|
80
|
+
# overseer:, adapters:, tools:}, counts: {pass:, fail:,
|
|
81
|
+
# unknown:}}
|
|
82
|
+
# @raise [Ace::Lab::InvalidConfigurationError] missing,
|
|
83
|
+
# untrusted or schema-invalid manifest — same classification
|
|
84
|
+
# as install/prepare
|
|
85
|
+
def call
|
|
86
|
+
manifest = Molecules::InstallManifestLoader.load(path: manifest_path, platform: platform)
|
|
87
|
+
entries = manifest["accounts"].to_h { |a| [a["username"], @account_lookup.call(a["username"])] }
|
|
88
|
+
executors = target_executors(manifest, entries)
|
|
89
|
+
|
|
90
|
+
checks = {
|
|
91
|
+
"accounts" => check_accounts(manifest, entries),
|
|
92
|
+
"directories" => check_directories(manifest, entries),
|
|
93
|
+
"keys" => check_keys(manifest, entries),
|
|
94
|
+
"roles" => check_roles(manifest),
|
|
95
|
+
"repositories" => check_repositories(manifest, executors),
|
|
96
|
+
"grants" => check_grants(manifest),
|
|
97
|
+
"registry" => check_registry(manifest),
|
|
98
|
+
"overseer" => check_overseer(manifest),
|
|
99
|
+
"adapters" => check_adapters(manifest, executors),
|
|
100
|
+
"tools" => check_tools(manifest, executors)
|
|
101
|
+
}
|
|
102
|
+
flat = checks.values.flatten(1)
|
|
103
|
+
{
|
|
104
|
+
"ready" => flat.none? { |check| check["status"] == FAIL },
|
|
105
|
+
"machine" => manifest["machine"],
|
|
106
|
+
"manifest" => manifest_path,
|
|
107
|
+
"authorization_path" => @authorization_path,
|
|
108
|
+
"checks" => checks,
|
|
109
|
+
"counts" => {
|
|
110
|
+
PASS => flat.count { |check| check["status"] == PASS },
|
|
111
|
+
FAIL => flat.count { |check| check["status"] == FAIL },
|
|
112
|
+
UNKNOWN => flat.count { |check| check["status"] == UNKNOWN }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def check_accounts(manifest, entries)
|
|
120
|
+
manifest["accounts"].map do |account|
|
|
121
|
+
username = account["username"]
|
|
122
|
+
entry = entries[username]
|
|
123
|
+
if entry.nil?
|
|
124
|
+
next {"account" => username, "status" => FAIL,
|
|
125
|
+
"reason" => "account '#{username}' does not exist on this machine; " \
|
|
126
|
+
"fix the account phase (ace-lab prepare) first",
|
|
127
|
+
"uid" => nil, "shell" => nil}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
reason = if entry.dir != account["home"]
|
|
131
|
+
"account '#{username}' home is '#{entry.dir}' but the manifest declares " \
|
|
132
|
+
"'#{account["home"]}' (the manifest home is authoritative for key paths)"
|
|
133
|
+
elsif non_login_shell?(entry)
|
|
134
|
+
"account '#{username}' has non-login shell '#{shell_of(entry)}'; " \
|
|
135
|
+
"the target executor cannot log in"
|
|
136
|
+
end
|
|
137
|
+
{"account" => username, "status" => reason ? FAIL : PASS, "reason" => reason,
|
|
138
|
+
"uid" => entry.uid, "shell" => shell_of(entry)}
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def check_directories(manifest, entries)
|
|
143
|
+
manifest["directories"].map do |directory|
|
|
144
|
+
path = directory["path"]
|
|
145
|
+
stat = lstat(path)
|
|
146
|
+
reason = if stat.nil?
|
|
147
|
+
"directory '#{path}' is missing"
|
|
148
|
+
elsif stat.symlink?
|
|
149
|
+
"directory '#{path}' is a symbolic link"
|
|
150
|
+
elsif !stat.directory?
|
|
151
|
+
"'#{path}' exists but is not a directory"
|
|
152
|
+
elsif (stat.mode & 0o7777) != directory["mode"].to_i(8)
|
|
153
|
+
"directory '#{path}' mode is #{format('%04o', stat.mode & 0o7777)} " \
|
|
154
|
+
"but the manifest declares #{directory["mode"]}"
|
|
155
|
+
else
|
|
156
|
+
owner_reason(directory, stat, entries)
|
|
157
|
+
end
|
|
158
|
+
{"directory" => path, "status" => reason ? FAIL : PASS, "reason" => reason}
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def check_keys(manifest, entries)
|
|
163
|
+
provisioner = Molecules::KeyProvisioner.new(runner: @runner)
|
|
164
|
+
by_username = manifest["accounts"].to_h { |a| [a["username"], a] }
|
|
165
|
+
manifest["keys"].map do |key|
|
|
166
|
+
username = key["account"]
|
|
167
|
+
path = key["path"]
|
|
168
|
+
entry = entries[username]
|
|
169
|
+
reason = if entry.nil?
|
|
170
|
+
"account '#{username}' does not exist; fix the account phase (ace-lab prepare) first"
|
|
171
|
+
else
|
|
172
|
+
key_pair_reason(path, entry)
|
|
173
|
+
end
|
|
174
|
+
fingerprint = if reason
|
|
175
|
+
nil
|
|
176
|
+
else
|
|
177
|
+
provisioner.pair_fingerprint(path, "#{path}.pub")
|
|
178
|
+
end
|
|
179
|
+
if reason.nil? && fingerprint.nil?
|
|
180
|
+
reason = "key pair '#{path}' is not usable (unreadable or mismatched key files); " \
|
|
181
|
+
"resolve manually and rerun ace-lab prepare"
|
|
182
|
+
end
|
|
183
|
+
{"account" => username, "path" => path,
|
|
184
|
+
"status" => reason ? FAIL : PASS, "reason" => reason, "fingerprint" => fingerprint}
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Final verification resolves role instructions through the
|
|
189
|
+
# ACTUAL resolver for the execution context (LAB-4): prepare's
|
|
190
|
+
# protocol check is deliberately syntax-only, so reusing it as
|
|
191
|
+
# readiness would promote a syntactically valid but
|
|
192
|
+
# nonexistent role (`wfi://does-not-exist/not-a-role`) to
|
|
193
|
+
# verified readiness. Protocol instructions are resolved with
|
|
194
|
+
# ace-nav from every declared project root (the execution
|
|
195
|
+
# contexts the executor works in); a role that fails resolution
|
|
196
|
+
# is a failed check, an environment without the resolver
|
|
197
|
+
# reports unknown (offline verifier posture, like network
|
|
198
|
+
# remotes) and does not block.
|
|
199
|
+
def check_roles(manifest)
|
|
200
|
+
verifier = Molecules::RoleVerifier.new(
|
|
201
|
+
account_lookup: @account_lookup, group_source: @group_source
|
|
202
|
+
)
|
|
203
|
+
manifest["roles"].map do |role|
|
|
204
|
+
report = verifier.call(role: role)
|
|
205
|
+
if report["status"] == "verified"
|
|
206
|
+
status, reason = protocol_resolution(role["instructions"], manifest)
|
|
207
|
+
else
|
|
208
|
+
status, reason = FAIL, report["message"]
|
|
209
|
+
end
|
|
210
|
+
{"id" => role["id"], "account" => role["account"],
|
|
211
|
+
"instructions" => role["instructions"],
|
|
212
|
+
"status" => status, "reason" => reason}
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# @return [Array<(String, String, nil)>] pass/fail/unknown with
|
|
217
|
+
# reason for protocol instructions; file-path instructions
|
|
218
|
+
# were already resolved (readability) by the RoleVerifier.
|
|
219
|
+
def protocol_resolution(instructions, manifest)
|
|
220
|
+
unless Molecules::RoleVerifier::PROTOCOL_PREFIXES.any? { |p| instructions.start_with?(p) }
|
|
221
|
+
return [PASS, nil]
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
contexts = manifest["projects"].map { |project| project["path"] }
|
|
225
|
+
if contexts.empty?
|
|
226
|
+
return [PASS, "no project declared as an execution context; syntax verified only"]
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
failures = []
|
|
230
|
+
unresolved_resolver = false
|
|
231
|
+
contexts.each do |root|
|
|
232
|
+
result = @nav_runner.call([nav_binary(root), "resolve", instructions], root)
|
|
233
|
+
if result["status"] == "ok" && !result["out"].to_s.lines.first.to_s.strip.empty?
|
|
234
|
+
next
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
if %w[enoent exec_failed].include?(result["status"])
|
|
238
|
+
unresolved_resolver = true
|
|
239
|
+
else
|
|
240
|
+
failures << "project '#{File.basename(root)}': #{resolution_error(result)}"
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
return [FAIL, "role instructions '#{instructions}' do not resolve through ace-nav " \
|
|
244
|
+
"for the execution context (#{failures.join('; ')})"] unless failures.empty?
|
|
245
|
+
return [UNKNOWN, "ace-nav is not available to resolve role instructions " \
|
|
246
|
+
"'#{instructions}'; not probed by the read-only verifier"] if unresolved_resolver
|
|
247
|
+
|
|
248
|
+
[PASS, nil]
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def resolution_error(result)
|
|
252
|
+
detail = result["err"].to_s.lines.map(&:strip).reject(&:empty?).first
|
|
253
|
+
detail ? detail : "ace-nav exited with status #{result['status']}"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Same binary choice the executor's resolver makes
|
|
257
|
+
# (Ace::Coworker::Molecules::InstructionResolver): the
|
|
258
|
+
# project's binstub when present, otherwise the PATH lookup.
|
|
259
|
+
# A privileged installer never executes repository-controlled
|
|
260
|
+
# binstubs (same trust posture as the LAB-1 symlink refusals):
|
|
261
|
+
# under root only the PATH-installed resolver is used.
|
|
262
|
+
def nav_binary(project_root)
|
|
263
|
+
binstub = File.join(project_root, "bin", "ace-nav")
|
|
264
|
+
use_binstub = !Process.uid.zero? && File.executable?(binstub)
|
|
265
|
+
use_binstub ? binstub : "ace-nav"
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def run_nav(argv, cwd)
|
|
269
|
+
out, err, status = Open3.capture3(*argv, chdir: cwd)
|
|
270
|
+
{"status" => status.success? ? "ok" : (status.exitstatus || "signal #{status.termsig}"),
|
|
271
|
+
"out" => out, "err" => err}
|
|
272
|
+
rescue Errno::ENOENT
|
|
273
|
+
{"status" => "enoent", "out" => "", "err" => ""}
|
|
274
|
+
rescue SystemCallError, IOError => e
|
|
275
|
+
{"status" => "exec_failed", "out" => "", "err" => e.class.name}
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# The declared target executors: the distinct accounts the
|
|
279
|
+
# manifest binds roles to (nil entries — accounts that failed
|
|
280
|
+
# lookup — are dropped; the accounts check already fails those).
|
|
281
|
+
def target_executors(manifest, entries)
|
|
282
|
+
manifest["roles"].filter_map { |role| entries[role["account"]] }
|
|
283
|
+
.uniq { |entry| entry.uid }
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def check_repositories(manifest, executors)
|
|
287
|
+
verifier = Molecules::RoleVerifier.new(
|
|
288
|
+
account_lookup: @account_lookup, group_source: @group_source
|
|
289
|
+
)
|
|
290
|
+
manifest["projects"].map do |project|
|
|
291
|
+
path = project["path"]
|
|
292
|
+
remote = project["remote"]
|
|
293
|
+
origin = origin_url(path)
|
|
294
|
+
|
|
295
|
+
reason = repo_structure_reason(path)
|
|
296
|
+
if reason.nil? && remote && origin != remote
|
|
297
|
+
reason = if origin.nil?
|
|
298
|
+
"'#{path}' has no origin remote configured but the manifest declares '#{remote}'"
|
|
299
|
+
else
|
|
300
|
+
"'#{path}' origin is '#{origin}' but the manifest declares '#{remote}'"
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
# Readiness is judged for the target EXECUTOR, not the
|
|
304
|
+
# invoking installer (LAB-2): a repository behind an
|
|
305
|
+
# installer-owned 0700 ancestor with a different target UID
|
|
306
|
+
# is NOT ready even though the installer's git succeeded.
|
|
307
|
+
if reason.nil?
|
|
308
|
+
reason = executors.filter_map do |entry|
|
|
309
|
+
access = verifier.directory_access_reason(path, entry)
|
|
310
|
+
access ? "executor '#{entry.name}': #{access}" : nil
|
|
311
|
+
end.join("; ")
|
|
312
|
+
reason = nil if reason.empty?
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
status, reach = if reason
|
|
316
|
+
[FAIL, nil]
|
|
317
|
+
elsif remote.nil?
|
|
318
|
+
[PASS, "no remote declared (offline repository)"]
|
|
319
|
+
elsif (local = local_remote_path(remote))
|
|
320
|
+
ls_remote(local) ? [PASS, "origin reachable at '#{remote}'"] : [FAIL,
|
|
321
|
+
"origin '#{remote}' is not reachable (git ls-remote failed)"]
|
|
322
|
+
else
|
|
323
|
+
[UNKNOWN, "origin '#{remote}' is not a local-path remote; not probed by the " \
|
|
324
|
+
"read-only verifier"]
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
{"project" => project["id"], "path" => path, "remote" => remote,
|
|
328
|
+
"origin" => origin, "status" => status,
|
|
329
|
+
"reason" => reason || (status == PASS ? nil : reach)}
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Project visibility through grants: read the SAME trusted
|
|
334
|
+
# document every grant reader verifies, and evaluate it with
|
|
335
|
+
# the real read-side mechanics (CallerAuthorizer) as the
|
|
336
|
+
# manifest principals' identities.
|
|
337
|
+
def check_grants(manifest)
|
|
338
|
+
document_check = installed_principles
|
|
339
|
+
if document_check["missing"] && manifest["grants"].empty?
|
|
340
|
+
# No grants declared: the trusted document is not part of
|
|
341
|
+
# this lab's contract, and none was written — requiring one
|
|
342
|
+
# would make a grants-less manifest permanently unready.
|
|
343
|
+
return [{"principal" => "(document)", "status" => PASS,
|
|
344
|
+
"reason" => "no grants declared; no trusted authorization document required",
|
|
345
|
+
"projects" => nil}]
|
|
346
|
+
end
|
|
347
|
+
document_check.delete("missing") # internal only — keep JSON schema uniform
|
|
348
|
+
return [document_check] if document_check["status"] == FAIL
|
|
349
|
+
|
|
350
|
+
principals = document_check.delete("principals") # internal only — keep JSON schema uniform
|
|
351
|
+
[document_check] + manifest["grants"].map do |principal, projects|
|
|
352
|
+
granted = Molecules::CallerAuthorizer.new(
|
|
353
|
+
principals: principals, identity: [principal]
|
|
354
|
+
).authorized_project_ids
|
|
355
|
+
missing = projects - granted
|
|
356
|
+
if missing.empty?
|
|
357
|
+
{"principal" => principal, "status" => PASS,
|
|
358
|
+
"reason" => nil, "projects" => projects}
|
|
359
|
+
else
|
|
360
|
+
{"principal" => principal, "status" => FAIL,
|
|
361
|
+
"reason" => "principal '#{principal}' is granted #{granted.sort.inspect} but the " \
|
|
362
|
+
"manifest declares #{projects.sort.inspect} (missing: #{missing.sort.join(', ')}); " \
|
|
363
|
+
"rerun ace-lab install",
|
|
364
|
+
"projects" => granted.sort}
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# On failure returns a synthetic failed check carrying the
|
|
370
|
+
# classified reason (document-level failure). An UNTRUSTED but
|
|
371
|
+
# present document fails the same synthetic check (a failed
|
|
372
|
+
# check never hides the healthy data around it) instead of
|
|
373
|
+
# aborting the whole report.
|
|
374
|
+
def installed_principles
|
|
375
|
+
path = @authorization_path
|
|
376
|
+
begin
|
|
377
|
+
content = @trusted_read.call(path)
|
|
378
|
+
rescue Ace::Lab::InvalidConfigurationError => e
|
|
379
|
+
return {"principal" => "(document)", "status" => FAIL,
|
|
380
|
+
"reason" => "trusted authorization file #{path} failed verification: #{e.message}",
|
|
381
|
+
"projects" => nil}
|
|
382
|
+
end
|
|
383
|
+
if content.nil?
|
|
384
|
+
return {"principal" => "(document)", "status" => FAIL, "missing" => true,
|
|
385
|
+
"reason" => "trusted authorization file #{path} is missing; no principal can see " \
|
|
386
|
+
"the installed projects — rerun ace-lab install", "projects" => nil}
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
begin
|
|
390
|
+
document = YAML.safe_load(content, permitted_classes: [Date], aliases: true)
|
|
391
|
+
unless document.is_a?(Hash)
|
|
392
|
+
raise Ace::Lab::InvalidConfigurationError, "#{path} must contain a YAML mapping"
|
|
393
|
+
end
|
|
394
|
+
principals = Molecules::GrantResolver.validate_principals(document["principals"] || {}, path)
|
|
395
|
+
{"principal" => "(document)", "status" => PASS, "reason" => nil,
|
|
396
|
+
"principals" => principals, "projects" => nil}
|
|
397
|
+
rescue Psych::Exception
|
|
398
|
+
{"principal" => "(document)", "status" => FAIL,
|
|
399
|
+
"reason" => "#{path} could not be parsed as YAML; failing closed", "projects" => nil}
|
|
400
|
+
rescue Ace::Lab::InvalidConfigurationError => e
|
|
401
|
+
{"principal" => "(document)", "status" => FAIL, "reason" => e.message, "projects" => nil}
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def check_registry(manifest)
|
|
406
|
+
manifest["projects"].map do |project|
|
|
407
|
+
path = File.join(project["path"], ".ace", "lab", "config.yml")
|
|
408
|
+
reason = registry_reason(path, project)
|
|
409
|
+
{"project" => project["id"], "registry" => path,
|
|
410
|
+
"status" => reason ? FAIL : PASS, "reason" => reason}
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def registry_reason(path, project)
|
|
415
|
+
return "'#{path}' is missing; the project is not visible through the configuration " \
|
|
416
|
+
"cascade (ace-lab projects / ace-overseer projects)" unless File.file?(path)
|
|
417
|
+
|
|
418
|
+
begin
|
|
419
|
+
document = YAML.safe_load_file(path, permitted_classes: [Date], aliases: true)
|
|
420
|
+
rescue Psych::Exception
|
|
421
|
+
return "'#{path}' could not be parsed as YAML"
|
|
422
|
+
end
|
|
423
|
+
return "'#{path}' must contain a YAML mapping" unless document.is_a?(Hash)
|
|
424
|
+
|
|
425
|
+
# Shape validation through the same schema every registry
|
|
426
|
+
# consumer applies (LAB-6): a syntactically valid but
|
|
427
|
+
# structurally malformed document (`topology: invalid`) must
|
|
428
|
+
# become a classified per-project failure, never a TypeError
|
|
429
|
+
# that kills the whole readiness report.
|
|
430
|
+
begin
|
|
431
|
+
Atoms::TopologySchema.normalize!(document)
|
|
432
|
+
rescue Ace::Lab::InvalidConfigurationError => e
|
|
433
|
+
return "'#{path}' is not a valid lab configuration: #{e.message}"
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
projects = document.dig("topology", "projects")
|
|
437
|
+
entry = projects.is_a?(Array) ? projects.find { |c| c.is_a?(Hash) && c["id"] == project["id"] } : nil
|
|
438
|
+
return "'#{path}' does not register project '#{project["id"]}'" if entry.nil?
|
|
439
|
+
if entry["path"] && entry["path"] != project["path"]
|
|
440
|
+
return "project '#{project["id"]}' is registered at '#{entry["path"]}' but the " \
|
|
441
|
+
"manifest declares '#{project["path"]}'"
|
|
442
|
+
end
|
|
443
|
+
nil
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def check_overseer(manifest)
|
|
447
|
+
manifest["projects"].map do |project|
|
|
448
|
+
dir = File.join(project["path"], ".ace-tasks", "_overseer")
|
|
449
|
+
reason = if !File.directory?(dir)
|
|
450
|
+
"private folder '#{dir}' is missing"
|
|
451
|
+
elsif (ignore_reason = gitignore_reason(project["path"], ".ace-tasks/_overseer"))
|
|
452
|
+
ignore_reason
|
|
453
|
+
end
|
|
454
|
+
{"project" => project["id"], "dir" => dir,
|
|
455
|
+
"status" => reason ? FAIL : PASS, "reason" => reason}
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# --- shared probes ------------------------------------------------
|
|
460
|
+
|
|
461
|
+
# Declared communication/HITL adapters (task 8xg.t.sbz.7.2/.7.4,
|
|
462
|
+
# LAB-3): configuration validity is re-derived from the trusted
|
|
463
|
+
# manifest (the single state owner for the declaration), an
|
|
464
|
+
# adapter that names a local command is probed for availability
|
|
465
|
+
# FOR THE TARGET EXECUTORS (FINAL-LAB3), and a `kind: hitl`
|
|
466
|
+
# adapter must be projected into the runtime route configuration
|
|
467
|
+
# (`hitl.adapters.routes` in every declared project's
|
|
468
|
+
# `.ace/hitl/config.yml`, FINAL-LAB2) — an adapter that is
|
|
469
|
+
# schema-checked but not usable by the runtime blocks readiness
|
|
470
|
+
# with a classified failure, never a crash. Network reachability
|
|
471
|
+
# stays out of scope (L9).
|
|
472
|
+
def check_adapters(manifest, executors = [])
|
|
473
|
+
manifest["adapters"].map do |adapter|
|
|
474
|
+
config_reason = ("adapter '#{adapter['id']}' configuration is not a mapping" \
|
|
475
|
+
unless adapter["config"].is_a?(Hash))
|
|
476
|
+
probe = if config_reason.nil? && adapter["command"]
|
|
477
|
+
availability_probe(adapter["command"], executors).first
|
|
478
|
+
else
|
|
479
|
+
:pass
|
|
480
|
+
end
|
|
481
|
+
route_reason = if config_reason.nil? && adapter["kind"] == "hitl"
|
|
482
|
+
hitl_route_reason(adapter, manifest)
|
|
483
|
+
end
|
|
484
|
+
# Route failures are decidable regardless of PATH — they
|
|
485
|
+
# take precedence over the PATH-unset probe message so a
|
|
486
|
+
# missing/invalid HITL route is never masked as UNKNOWN.
|
|
487
|
+
reason = route_reason ||
|
|
488
|
+
config_reason ||
|
|
489
|
+
(probe != :pass ? adapter_command_reason(adapter, executors) : nil)
|
|
490
|
+
note = if reason.nil? && adapter["command"].nil?
|
|
491
|
+
if adapter["kind"] == "hitl"
|
|
492
|
+
"configured (route installed in every project; no local command declared; " \
|
|
493
|
+
"network reachability is out of scope)"
|
|
494
|
+
else
|
|
495
|
+
"configured (no local command declared; network reachability is out of scope)"
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
# A config-shape or route failure is decidable regardless
|
|
499
|
+
# of PATH: it stays FAIL; only the command probe degrades
|
|
500
|
+
# to unknown under an unset PATH.
|
|
501
|
+
status = if config_reason || route_reason
|
|
502
|
+
FAIL
|
|
503
|
+
elsif probe == :unknown
|
|
504
|
+
UNKNOWN
|
|
505
|
+
elsif reason
|
|
506
|
+
FAIL
|
|
507
|
+
else
|
|
508
|
+
PASS
|
|
509
|
+
end
|
|
510
|
+
{"adapter" => adapter["id"], "kind" => adapter["kind"], "command" => adapter["command"],
|
|
511
|
+
"status" => status, "reason" => reason || note}
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# A `kind: hitl` adapter's route must be installed in EVERY
|
|
516
|
+
# declared execution context and the resulting route list must
|
|
517
|
+
# satisfy the runtime loader's contract (HitlRoutes validates
|
|
518
|
+
# the same rules the ace-hitl route loader enforces).
|
|
519
|
+
def hitl_route_reason(adapter, manifest)
|
|
520
|
+
return nil if manifest["projects"].empty?
|
|
521
|
+
|
|
522
|
+
expected = Molecules::HitlRoutes.expected_routes([adapter]).first
|
|
523
|
+
failures = manifest["projects"].filter_map do |project|
|
|
524
|
+
reason = Molecules::HitlRoutes.config_reason(root: project["path"], expected: expected)
|
|
525
|
+
reason ? "project '#{project['id']}': #{reason}" : nil
|
|
526
|
+
end
|
|
527
|
+
failures.empty? ? nil : failures.join("; ")
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# Required tools (task 8xg.t.sbz.7.4, LAB-3, FINAL-LAB3): the
|
|
531
|
+
# tool must resolve to an executable the TARGET EXECUTORS can
|
|
532
|
+
# run — a missing tool, or one only the installer can execute,
|
|
533
|
+
# blocks readiness. No execution, no network.
|
|
534
|
+
def check_tools(manifest, executors = [])
|
|
535
|
+
manifest["tools"].map do |tool|
|
|
536
|
+
status, detail = availability_probe(tool, executors)
|
|
537
|
+
case status
|
|
538
|
+
when :pass
|
|
539
|
+
{"tool" => tool, "status" => PASS, "reason" => nil}
|
|
540
|
+
when :fail
|
|
541
|
+
reason = if detail
|
|
542
|
+
"required tool '#{tool}' is not available for the target executor (#{detail})"
|
|
543
|
+
else
|
|
544
|
+
"required tool '#{tool}' is not available (not found on PATH)"
|
|
545
|
+
end
|
|
546
|
+
{"tool" => tool, "status" => FAIL, "reason" => reason}
|
|
547
|
+
else
|
|
548
|
+
{"tool" => tool, "status" => UNKNOWN,
|
|
549
|
+
"reason" => "PATH is unset; cannot probe tool '#{tool}' availability"}
|
|
550
|
+
end
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
# Resolve an executable without running it and judge it for the
|
|
555
|
+
# TARGET EXECUTORS (FINAL-LAB3): a name containing a separator
|
|
556
|
+
# must be a file executable at that path; a bare name must
|
|
557
|
+
# resolve through PATH (the installer's PATH is the best
|
|
558
|
+
# available locator — candidate files are then permission-checked
|
|
559
|
+
# per target executor through the same governing-class logic as
|
|
560
|
+
# the repository access checks). Without declared executors the
|
|
561
|
+
# installer's own executable bit decides (presence check only).
|
|
562
|
+
# An unset or empty PATH leaves the probe undecidable and yields
|
|
563
|
+
# unknown (never a false failure) — reported as UNKNOWN, never
|
|
564
|
+
# counted as a verified pass, and like every other unknown it
|
|
565
|
+
# does not block readiness by itself.
|
|
566
|
+
# @return [Array<(Symbol, String, nil)>] status (:pass/:fail/:
|
|
567
|
+
# unknown) and the executor-denial detail for failures
|
|
568
|
+
def availability_probe(name, executors)
|
|
569
|
+
if name.include?(File::SEPARATOR)
|
|
570
|
+
candidates = File.file?(name) ? [name] : []
|
|
571
|
+
else
|
|
572
|
+
directories = (ENV["PATH"].to_s.split(File::PATH_SEPARATOR) - [""])
|
|
573
|
+
return [:unknown, nil] if directories.empty?
|
|
574
|
+
|
|
575
|
+
candidates = directories.filter_map do |directory|
|
|
576
|
+
candidate = File.join(directory, name)
|
|
577
|
+
File.file?(candidate) ? candidate : nil
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
return [:fail, nil] if candidates.empty?
|
|
581
|
+
if executors.empty?
|
|
582
|
+
return [candidates.any? { |candidate| File.executable?(candidate) } ? :pass : :fail, nil]
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
verifier = Molecules::RoleVerifier.new(
|
|
586
|
+
account_lookup: @account_lookup, group_source: @group_source
|
|
587
|
+
)
|
|
588
|
+
denials = executors.filter_map do |entry|
|
|
589
|
+
reasons = candidates.map { |candidate| verifier.executable_access_reason(candidate, entry) }
|
|
590
|
+
reasons.compact.empty? ? nil : "executor '#{entry.name}': #{reasons.compact.first}"
|
|
591
|
+
end
|
|
592
|
+
denials.empty? ? [:pass, nil] : [:fail, denials.join("; ")]
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def adapter_command_reason(adapter, executors = [])
|
|
596
|
+
status, detail = availability_probe(adapter["command"], executors)
|
|
597
|
+
case status
|
|
598
|
+
when :fail
|
|
599
|
+
if detail
|
|
600
|
+
"adapter '#{adapter['id']}' command '#{adapter['command']}' is not available " \
|
|
601
|
+
"for the target executor (#{detail})"
|
|
602
|
+
else
|
|
603
|
+
"adapter '#{adapter['id']}' command '#{adapter['command']}' is not available " \
|
|
604
|
+
"(not found on PATH); fix the adapter installation or the manifest declaration"
|
|
605
|
+
end
|
|
606
|
+
when :unknown
|
|
607
|
+
"PATH is unset; cannot probe adapter '#{adapter['id']}' command availability"
|
|
608
|
+
end
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
def key_pair_reason(path, entry)
|
|
612
|
+
stat = lstat(path)
|
|
613
|
+
public_path = "#{path}.pub"
|
|
614
|
+
pub_stat = lstat(public_path)
|
|
615
|
+
return "private key '#{path}' is missing" if stat.nil?
|
|
616
|
+
return "private key '#{path}' is not a regular file" unless stat.file?
|
|
617
|
+
return "public key '#{public_path}' is missing" if pub_stat.nil?
|
|
618
|
+
return "public key '#{public_path}' is not a regular file" unless pub_stat.file?
|
|
619
|
+
|
|
620
|
+
if (stat.mode & 0o7777) != Molecules::KeyProvisioner::PRIVATE_MODE
|
|
621
|
+
return "private key '#{path}' mode is #{format('%04o', stat.mode & 0o7777)} " \
|
|
622
|
+
"but #{format('%04o', Molecules::KeyProvisioner::PRIVATE_MODE)} is required"
|
|
623
|
+
end
|
|
624
|
+
if (pub_stat.mode & 0o7777) != Molecules::KeyProvisioner::PUBLIC_MODE
|
|
625
|
+
return "public key '#{public_path}' mode is #{format('%04o', pub_stat.mode & 0o7777)} " \
|
|
626
|
+
"but #{format('%04o', Molecules::KeyProvisioner::PUBLIC_MODE)} is required"
|
|
627
|
+
end
|
|
628
|
+
if stat.uid != entry.uid
|
|
629
|
+
return "private key '#{path}' is owned by uid #{stat.uid} but the account " \
|
|
630
|
+
"'#{entry.name}' is uid #{entry.uid}"
|
|
631
|
+
end
|
|
632
|
+
if pub_stat.uid != entry.uid
|
|
633
|
+
return "public key '#{public_path}' is owned by uid #{pub_stat.uid} but the " \
|
|
634
|
+
"account '#{entry.name}' is uid #{entry.uid}"
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
ssh_dir = File.dirname(path)
|
|
638
|
+
ssh_stat = lstat(ssh_dir)
|
|
639
|
+
if ssh_stat.nil?
|
|
640
|
+
"key directory '#{ssh_dir}' is missing"
|
|
641
|
+
elsif !ssh_stat.directory? || ssh_stat.symlink?
|
|
642
|
+
"key directory '#{ssh_dir}' is not a directory"
|
|
643
|
+
elsif (ssh_stat.mode & 0o7777) != Molecules::KeyProvisioner::SSH_DIR_MODE
|
|
644
|
+
"key directory '#{ssh_dir}' mode is #{format('%04o', ssh_stat.mode & 0o7777)} " \
|
|
645
|
+
"but #{format('%04o', Molecules::KeyProvisioner::SSH_DIR_MODE)} is required"
|
|
646
|
+
elsif ssh_stat.uid != entry.uid
|
|
647
|
+
"key directory '#{ssh_dir}' is owned by uid #{ssh_stat.uid} but the account " \
|
|
648
|
+
"'#{entry.name}' is uid #{entry.uid}"
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
def repo_structure_reason(path)
|
|
653
|
+
stat = lstat(path)
|
|
654
|
+
return "'#{path}' is missing" if stat.nil?
|
|
655
|
+
return "'#{path}' is not a directory" unless stat.directory?
|
|
656
|
+
|
|
657
|
+
result = @runner.call(["git", "-C", path, "rev-parse", "--show-toplevel"])
|
|
658
|
+
if dubious_ownership?(result)
|
|
659
|
+
return "git refuses to operate on '#{path}' because of dubious ownership — run verify " \
|
|
660
|
+
"as the account that owns the repository, or configure git safe.directory"
|
|
661
|
+
end
|
|
662
|
+
return "'#{path}' is not a Git repository" unless result["status"] == "ok"
|
|
663
|
+
|
|
664
|
+
reported = result["out"].to_s.lines.first.to_s.strip
|
|
665
|
+
return "'#{path}' is a subdirectory of the repository at '#{reported}'" unless same_location?(reported, path)
|
|
666
|
+
|
|
667
|
+
nil
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
# Raw stored URL: `git remote get-url` applies url.<base>.insteadOf
|
|
671
|
+
# rewrites from the invoking user's git config, which would
|
|
672
|
+
# false-fail an origin that matches the manifest canonically.
|
|
673
|
+
def origin_url(path)
|
|
674
|
+
result = @runner.call(["git", "-C", path, "config", "--get", "remote.origin.url"])
|
|
675
|
+
return nil unless result["status"] == "ok"
|
|
676
|
+
|
|
677
|
+
url = result["out"].to_s.lines.first.to_s.strip
|
|
678
|
+
url.empty? ? nil : url
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
# A remote the verifier may dial without leaving the machine:
|
|
682
|
+
# an existing local path (bare or not), optionally via file://.
|
|
683
|
+
def local_remote_path(remote)
|
|
684
|
+
candidate = remote.start_with?("file://") ? remote.delete_prefix("file://") : remote
|
|
685
|
+
candidate if candidate.start_with?("/") && File.directory?(candidate)
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
def ls_remote(remote)
|
|
689
|
+
@runner.call(["git", "ls-remote", remote])["status"] == "ok"
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
# nil when Git ignores the path; otherwise the concrete reason
|
|
693
|
+
# (including the dubious-ownership refusal, which must not be
|
|
694
|
+
# misdiagnosed as "not ignored").
|
|
695
|
+
def gitignore_reason(root, relative)
|
|
696
|
+
result = @runner.call(["git", "-C", root, "check-ignore", "--", relative])
|
|
697
|
+
if dubious_ownership?(result)
|
|
698
|
+
return "git refuses to operate on '#{root}' because of dubious ownership — run " \
|
|
699
|
+
"verify as the owning account, or configure git safe.directory"
|
|
700
|
+
end
|
|
701
|
+
return nil if result["status"] == "ok"
|
|
702
|
+
|
|
703
|
+
"'#{File.join(root, relative)}' is not ignored by Git; private overseer tasks " \
|
|
704
|
+
"would be committed (add '.ace-tasks/_overseer/' to the project .gitignore " \
|
|
705
|
+
"and rerun ace-lab install)"
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
# Git's ownership-safety refusal (verify often runs as a different
|
|
709
|
+
# account than the one that converged the repository — exactly the
|
|
710
|
+
# under-target-accounts posture). Detected so the reported reason
|
|
711
|
+
# is actionable instead of "not a Git repository".
|
|
712
|
+
def dubious_ownership?(result)
|
|
713
|
+
result["status"] != "ok" && result["err"].to_s.include?("dubious ownership")
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
def same_location?(reported, path)
|
|
717
|
+
File.realpath(reported) == File.realpath(path)
|
|
718
|
+
rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES
|
|
719
|
+
File.expand_path(reported) == File.expand_path(path)
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
def owner_reason(directory, stat, entries)
|
|
723
|
+
owner = directory["owner"]
|
|
724
|
+
return nil if owner.nil?
|
|
725
|
+
|
|
726
|
+
entry = entries[owner]
|
|
727
|
+
return nil if entry.nil?
|
|
728
|
+
|
|
729
|
+
if stat.uid != entry.uid
|
|
730
|
+
"directory '#{directory["path"]}' is owned by uid #{stat.uid} but the manifest " \
|
|
731
|
+
"declares owner '#{owner}' (uid #{entry.uid})"
|
|
732
|
+
end
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def non_login_shell?(entry)
|
|
736
|
+
shell = shell_of(entry).to_s
|
|
737
|
+
shell.end_with?("/nologin") || shell == "/bin/false" || shell == "/usr/bin/false"
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
def shell_of(entry)
|
|
741
|
+
entry.respond_to?(:shell) ? entry.shell : nil
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
def lstat(path)
|
|
745
|
+
File.lstat(path)
|
|
746
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
747
|
+
nil
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
def run_argv(argv)
|
|
751
|
+
out, err, status = Open3.capture3(*argv)
|
|
752
|
+
{"status" => status.success? ? "ok" : (status.exitstatus || "signal #{status.termsig}"),
|
|
753
|
+
"out" => out, "err" => err}
|
|
754
|
+
rescue Errno::ENOENT
|
|
755
|
+
{"status" => "enoent", "out" => "", "err" => ""}
|
|
756
|
+
rescue SystemCallError => e
|
|
757
|
+
{"status" => "exec_failed", "out" => "", "err" => e.class.name}
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
def lookup_passwd(username)
|
|
761
|
+
Etc.getpwnam(username)
|
|
762
|
+
rescue ArgumentError
|
|
763
|
+
nil
|
|
764
|
+
end
|
|
765
|
+
end
|
|
766
|
+
end
|
|
767
|
+
end
|
|
768
|
+
end
|