ace-lab 0.8.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 -92
- data/README.md +66 -100
- data/docs/usage.md +529 -404
- 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 -13
- 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 +49 -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 -102
- 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 -45
- 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/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 -139
- 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/ordinary_assignment_client.rb +0 -149
- data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -123
- data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -688
- 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,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require "etc"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Lab
|
|
8
|
+
module Molecules
|
|
9
|
+
# Verifies role bindings from the trusted install manifest: the role's
|
|
10
|
+
# account must exist after the account phase, and its instruction
|
|
11
|
+
# source must be either a supported protocol reference (syntactic
|
|
12
|
+
# validation only — context resolution belongs to ace-coworker /
|
|
13
|
+
# ace-nav, see L3.1) or an absolute file path that exists and whose
|
|
14
|
+
# static permission bits allow the account to read it. ACLs are out
|
|
15
|
+
# of scope.
|
|
16
|
+
class RoleVerifier
|
|
17
|
+
PROTOCOL_PREFIXES = %w[wfi:// prompt:// tmpl://].freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :account_lookup, :group_source, :dry_run
|
|
20
|
+
|
|
21
|
+
def initialize(account_lookup: nil, group_source: nil, dry_run: false)
|
|
22
|
+
@account_lookup = account_lookup || method(:lookup_passwd)
|
|
23
|
+
# Injectable group-membership seam: a callable returning the
|
|
24
|
+
# gids the named account holds beyond its primary gid
|
|
25
|
+
# (Etc.group scan in production). Supplementary groups are
|
|
26
|
+
# ordinary static permission bits — distinct from the ACLs
|
|
27
|
+
# that are out of scope.
|
|
28
|
+
@group_source = group_source || method(:supplementary_gids)
|
|
29
|
+
@dry_run = dry_run
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param role [Hash] normalized manifest role entry
|
|
33
|
+
# @return [Hash] {id:, account:, instructions:, status:, message:}
|
|
34
|
+
def call(role:)
|
|
35
|
+
base = {"id" => role["id"], "account" => role["account"],
|
|
36
|
+
"instructions" => role["instructions"]}
|
|
37
|
+
|
|
38
|
+
entry = account_lookup.call(role["account"])
|
|
39
|
+
if entry.nil? && !dry_run
|
|
40
|
+
return base.merge("status" => "failed",
|
|
41
|
+
"message" => "role '#{role['id']}' account '#{role['account']}' is not available; " \
|
|
42
|
+
"fix the account phase first")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
instructions = role["instructions"]
|
|
46
|
+
return base.merge("status" => "verified", "message" => nil) if protocol?(instructions)
|
|
47
|
+
|
|
48
|
+
verify_file(base, instructions, entry)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Readiness probe for a project/worktree directory used by a
|
|
52
|
+
# target executor (task 8xg.t.sbz.7.4, LAB-2): every ancestor of
|
|
53
|
+
# the RESOLVED path must grant the account search (x) permission
|
|
54
|
+
# and the directory itself must grant read+write+search (the
|
|
55
|
+
# executor clones and provisions worktrees inside it — a
|
|
56
|
+
# directory without x on itself is unusable however rw it is).
|
|
57
|
+
# Returns nil when the account can work in the directory, or a
|
|
58
|
+
# concrete reason. Same governing-class permission logic as the
|
|
59
|
+
# instruction checks so the two can never drift apart.
|
|
60
|
+
def directory_access_reason(path, entry)
|
|
61
|
+
resolved = File.realpath(path)
|
|
62
|
+
current = File.dirname(resolved)
|
|
63
|
+
loop do
|
|
64
|
+
stat = File.stat(current)
|
|
65
|
+
return search_denied(current, entry) if (permission_bits(stat, entry) & 0o1).zero?
|
|
66
|
+
break if current == "/"
|
|
67
|
+
|
|
68
|
+
current = File.dirname(current)
|
|
69
|
+
end
|
|
70
|
+
stat = File.stat(resolved)
|
|
71
|
+
return work_denied(resolved, entry) if (permission_bits(stat, entry) & 0o7) != 0o7
|
|
72
|
+
|
|
73
|
+
nil
|
|
74
|
+
rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES
|
|
75
|
+
"'#{path}' does not exist or cannot be inspected for account '#{entry.name}'"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Readiness probe for an executable the target executor must be
|
|
79
|
+
# able to RUN (PR #393 final audit, FINAL-LAB3): every ancestor
|
|
80
|
+
# of the RESOLVED path must grant search (x) and the file itself
|
|
81
|
+
# must be a regular file granting the account execute through
|
|
82
|
+
# its governing permission class — an installer-owned 0700
|
|
83
|
+
# executable is NOT available to a different-uid executor.
|
|
84
|
+
# Returns nil when the account can execute it, or a concrete
|
|
85
|
+
# reason (same governing-class logic as the other probes).
|
|
86
|
+
def executable_access_reason(path, entry)
|
|
87
|
+
resolved = File.realpath(path)
|
|
88
|
+
current = File.dirname(resolved)
|
|
89
|
+
loop do
|
|
90
|
+
stat = File.stat(current)
|
|
91
|
+
return search_denied(current, entry) if (permission_bits(stat, entry) & 0o1).zero?
|
|
92
|
+
break if current == "/"
|
|
93
|
+
|
|
94
|
+
current = File.dirname(current)
|
|
95
|
+
end
|
|
96
|
+
stat = File.stat(resolved)
|
|
97
|
+
return "'#{resolved}' is not a regular file" unless stat.file?
|
|
98
|
+
return execute_denied(resolved, entry) if (permission_bits(stat, entry) & 0o1).zero?
|
|
99
|
+
|
|
100
|
+
nil
|
|
101
|
+
rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES
|
|
102
|
+
"'#{path}' does not exist or cannot be inspected for account '#{entry.name}'"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def search_denied(directory, entry)
|
|
108
|
+
"'#{directory}' denies search permission to account '#{entry.name}'; the target " \
|
|
109
|
+
"executor cannot reach the project through this ancestor"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def work_denied(directory, entry)
|
|
113
|
+
"'#{directory}' denies read/write/search permission to account '#{entry.name}'; the " \
|
|
114
|
+
"target executor cannot work inside the project"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def execute_denied(file, entry)
|
|
118
|
+
"'#{file}' denies execute permission to account '#{entry.name}'; the target " \
|
|
119
|
+
"executor cannot run it"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def protocol?(instructions)
|
|
123
|
+
PROTOCOL_PREFIXES.any? { |prefix| instructions.start_with?(prefix) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# entry is nil only in dry-run for an account the account phase
|
|
127
|
+
# would create: readability depends on a uid that does not exist
|
|
128
|
+
# yet, so the binding is predicted verified once the file itself
|
|
129
|
+
# checks out.
|
|
130
|
+
def verify_file(base, path, entry)
|
|
131
|
+
stat = File.stat(path)
|
|
132
|
+
unless stat.file?
|
|
133
|
+
return base.merge("status" => "failed",
|
|
134
|
+
"message" => "instructions '#{path}' is not a regular file")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
if entry && !readable_by_account?(stat, entry)
|
|
138
|
+
return base.merge("status" => "failed",
|
|
139
|
+
"message" => "instructions '#{path}' is not readable by account '#{entry.name}' " \
|
|
140
|
+
"(static permission bits; ACLs are out of scope)")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Read bits on the file alone are not enough: POSIX requires
|
|
144
|
+
# the search (x) bit on EVERY ancestor directory of the
|
|
145
|
+
# resolved path, or the account cannot reach the file at all
|
|
146
|
+
# (a 0644 file behind a 0700 directory is unreachable).
|
|
147
|
+
if entry && !reachable_by_account?(path, entry)
|
|
148
|
+
return base.merge("status" => "failed",
|
|
149
|
+
"message" => "instructions '#{path}' is not reachable by account '#{entry.name}' " \
|
|
150
|
+
"(an ancestor directory denies search permission; static permission bits; " \
|
|
151
|
+
"ACLs are out of scope)")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
base.merge("status" => "verified", "message" => nil)
|
|
155
|
+
rescue Errno::ENOENT
|
|
156
|
+
base.merge("status" => "failed",
|
|
157
|
+
"message" => "instructions file '#{path}' does not exist")
|
|
158
|
+
rescue SystemCallError => e
|
|
159
|
+
base.merge("status" => "failed",
|
|
160
|
+
"message" => "instructions file '#{path}' could not be verified (#{e.class.name})")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Reachability: every ancestor of the RESOLVED path must grant
|
|
164
|
+
# the account search permission through its governing class.
|
|
165
|
+
# The final component is resolved too (File.realpath(path)):
|
|
166
|
+
# realpath follows both ancestor symlinks AND a symlink at the
|
|
167
|
+
# instruction file itself, mirroring what open(2) traverses —
|
|
168
|
+
# judging only the symlink's own ancestors would miss an
|
|
169
|
+
# unsearchable directory on the target's path. An unresolvable
|
|
170
|
+
# or unstattable ancestor is conservatively unreachable.
|
|
171
|
+
def reachable_by_account?(path, entry)
|
|
172
|
+
current = File.dirname(File.realpath(path))
|
|
173
|
+
loop do
|
|
174
|
+
stat = File.stat(current)
|
|
175
|
+
return false if (permission_bits(stat, entry) & 0o1).zero?
|
|
176
|
+
break if current == "/"
|
|
177
|
+
|
|
178
|
+
current = File.dirname(current)
|
|
179
|
+
end
|
|
180
|
+
true
|
|
181
|
+
rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES
|
|
182
|
+
false
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# POSIX access checks do not fall through permission classes: a
|
|
186
|
+
# file owned by the account is governed by the owner bits alone —
|
|
187
|
+
# group/other bits never apply. Short-circuit per class so an
|
|
188
|
+
# owner-permission-bit-clear file can never verify through a
|
|
189
|
+
# gid match. The group class matches ANY group the account is a
|
|
190
|
+
# member of — primary gid or supplementary (round 5): the
|
|
191
|
+
# runtime's group credentials include them, so a
|
|
192
|
+
# supplementary-group-granted permission genuinely applies.
|
|
193
|
+
# Returns the governing class's rwx bits normalized to the low
|
|
194
|
+
# octal (r=4, w=2, x=1), shared by the file-read check and the
|
|
195
|
+
# ancestor search check so the two can never drift apart.
|
|
196
|
+
def permission_bits(stat, entry)
|
|
197
|
+
return (stat.mode >> 6) & 0o7 if stat.uid == entry.uid
|
|
198
|
+
if stat.gid == entry.gid || group_source.call(entry.name).include?(stat.gid)
|
|
199
|
+
return (stat.mode >> 3) & 0o7
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
stat.mode & 0o7
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def readable_by_account?(stat, entry)
|
|
206
|
+
(permission_bits(stat, entry) & 0o4).nonzero?
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def supplementary_gids(username)
|
|
210
|
+
gids = []
|
|
211
|
+
Etc.group { |group| gids << group.gid if group.mem.include?(username) }
|
|
212
|
+
gids
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def lookup_passwd(username)
|
|
216
|
+
Etc.getpwnam(username)
|
|
217
|
+
rescue ArgumentError
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "../molecules/install_manifest_loader"
|
|
5
|
+
require_relative "../molecules/grant_provisioner"
|
|
6
|
+
require_relative "prepare"
|
|
7
|
+
require_relative "project_add"
|
|
8
|
+
require_relative "verify"
|
|
9
|
+
|
|
10
|
+
module Ace
|
|
11
|
+
module Lab
|
|
12
|
+
module Organisms
|
|
13
|
+
# Orchestrates the full new-lab installation (L8.3, task
|
|
14
|
+
# 8xg.t.sbz.7.2) from the trusted install manifest by composing
|
|
15
|
+
# the existing molecules in a fixed order — nothing here
|
|
16
|
+
# duplicates them:
|
|
17
|
+
#
|
|
18
|
+
# 1. foreign-lab guard (BEFORE any mutation): the trusted
|
|
19
|
+
# authorization document must not grant projects this
|
|
20
|
+
# manifest does not declare — a ready/foreign lab is refused
|
|
21
|
+
# with a classified error and zero writes;
|
|
22
|
+
# 2. `Prepare` (L8.1): accounts → directories → keys → roles;
|
|
23
|
+
# 3. `ProjectAdd` (L8.2) per declared project: repository,
|
|
24
|
+
# private `_overseer`, project registry, runtime HITL route
|
|
25
|
+
# configuration (the manifest's `kind: hitl` adapters
|
|
26
|
+
# projected into `.ace/hitl/config.yml`, FINAL-LAB2) and
|
|
27
|
+
# executor ownership propagation — each project is isolated;
|
|
28
|
+
# one failing project does not block the others;
|
|
29
|
+
# 4. `GrantProvisioner`: trusted-tier principal grants that
|
|
30
|
+
# make the installed projects visible to `ace-lab projects`
|
|
31
|
+
# and `ace-overseer projects`;
|
|
32
|
+
# 5. `Verify` (final stage, task 8xg.t.sbz.7.4): the declared
|
|
33
|
+
# adapters and required tools are part of the SAME trusted
|
|
34
|
+
# manifest (validated at load, reported under `adapters` /
|
|
35
|
+
# `tools`), and the converged lab must prove ready UNDER THE
|
|
36
|
+
# TARGET ACCOUNTS — tool availability, adapter commands and
|
|
37
|
+
# every other readiness check — or install reports not ok.
|
|
38
|
+
# Dry runs skip this stage (nothing has converged to verify).
|
|
39
|
+
#
|
|
40
|
+
# Every step is idempotent, so an interrupted re-run converges
|
|
41
|
+
# where steps already completed (the resume contract .7.3 builds
|
|
42
|
+
# on: no journal — the report is a per-step record in prepare's
|
|
43
|
+
# established shape). The organism owns no new state: it writes
|
|
44
|
+
# only through the provisioners above; the trusted manifest
|
|
45
|
+
# remains the single owner of the adapter/tool declarations.
|
|
46
|
+
class Install
|
|
47
|
+
attr_reader :manifest_path, :dry_run, :platform
|
|
48
|
+
|
|
49
|
+
def initialize(manifest_path: nil, runner: nil, account_lookup: nil, privileged: nil,
|
|
50
|
+
platform: nil, dry_run: false, prepare: nil, project_add: nil, grants: nil, verify: nil)
|
|
51
|
+
@manifest_path = manifest_path || Ace::Lab.install_manifest_path
|
|
52
|
+
@runner = runner
|
|
53
|
+
@account_lookup = account_lookup
|
|
54
|
+
@privileged = privileged
|
|
55
|
+
@platform = platform
|
|
56
|
+
@dry_run = dry_run
|
|
57
|
+
@prepare = prepare
|
|
58
|
+
@project_add = project_add
|
|
59
|
+
@grants = grants
|
|
60
|
+
@verify = verify
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param dry_run [Boolean, nil] per-call override of the
|
|
64
|
+
# constructor default (the CLI passes its --dry-run here)
|
|
65
|
+
# @return [Hash] aggregated report (ok / dry_run / machine /
|
|
66
|
+
# manifest / prepare / projects / grants / adapters / tools /
|
|
67
|
+
# verify)
|
|
68
|
+
# @raise [Ace::Lab::InvalidConfigurationError] missing, untrusted
|
|
69
|
+
# or invalid manifest, or a foreign lab refusing overwrite
|
|
70
|
+
# before any mutation
|
|
71
|
+
def call(dry_run: nil)
|
|
72
|
+
effective_dry_run = dry_run.nil? ? self.dry_run : dry_run
|
|
73
|
+
manifest = Molecules::InstallManifestLoader.load(path: manifest_path, platform: platform)
|
|
74
|
+
declared_projects = manifest["projects"].map { |project| project["id"] }
|
|
75
|
+
|
|
76
|
+
conflict = grants_molecule.foreign_grants?(declared_projects)
|
|
77
|
+
if conflict
|
|
78
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
79
|
+
"refusing to install over a foreign lab: #{conflict}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
prepare_report = prepare_organism.call(dry_run: effective_dry_run)
|
|
83
|
+
prepare_ok = prepare_report["ok"]
|
|
84
|
+
|
|
85
|
+
projects = manifest["projects"].map do |project|
|
|
86
|
+
if prepare_ok
|
|
87
|
+
project_add_organism.call(
|
|
88
|
+
id: project["id"], path: project["path"],
|
|
89
|
+
remote: project["remote"], label: project["label"],
|
|
90
|
+
owner: project_owner(manifest, project["path"]),
|
|
91
|
+
hitl_adapters: manifest["adapters"],
|
|
92
|
+
dry_run: effective_dry_run
|
|
93
|
+
)
|
|
94
|
+
else
|
|
95
|
+
dependency_failed_project(project,
|
|
96
|
+
"prepare did not converge; fix the prepare phase first", effective_dry_run)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
projects_ok = projects.all? { |report| report["ok"] }
|
|
100
|
+
|
|
101
|
+
grants = if prepare_ok && projects_ok
|
|
102
|
+
grants_molecule.call(
|
|
103
|
+
desired: manifest["grants"], declared_projects: declared_projects,
|
|
104
|
+
dry_run: effective_dry_run
|
|
105
|
+
)
|
|
106
|
+
else
|
|
107
|
+
{"path" => Ace::Lab.authorization_path, "status" => "failed", "principals" => {},
|
|
108
|
+
"kept" => [], "dry_run" => effective_dry_run,
|
|
109
|
+
"message" => "install steps did not converge; fix the failing steps first " \
|
|
110
|
+
"(trusted grants are configured only for a converged install)"}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
verify_report = verify_stage(manifest, effective_dry_run)
|
|
114
|
+
|
|
115
|
+
{
|
|
116
|
+
"ok" => prepare_ok && projects_ok && grants["status"] != "failed" &&
|
|
117
|
+
verify_stage_ok?(verify_report),
|
|
118
|
+
"dry_run" => effective_dry_run,
|
|
119
|
+
"machine" => manifest["machine"],
|
|
120
|
+
"manifest" => manifest_path,
|
|
121
|
+
"prepare" => prepare_report,
|
|
122
|
+
"projects" => projects,
|
|
123
|
+
"grants" => grants,
|
|
124
|
+
"adapters" => manifest["adapters"],
|
|
125
|
+
"tools" => manifest["tools"],
|
|
126
|
+
"verify" => verify_report
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
# Final install verification (LAB-3): read-only, over the
|
|
133
|
+
# converged artifacts. A wet run's readiness gates `ok`; a dry
|
|
134
|
+
# run has nothing converged to verify and reports the stage
|
|
135
|
+
# skipped instead of a guaranteed failure.
|
|
136
|
+
def verify_stage(manifest, dry_run)
|
|
137
|
+
return {"ready" => nil, "status" => "skipped",
|
|
138
|
+
"message" => "verification runs on the converged lab; dry-run predicts the " \
|
|
139
|
+
"steps above only"} if dry_run
|
|
140
|
+
|
|
141
|
+
verify_organism.call
|
|
142
|
+
rescue Ace::Lab::InvalidConfigurationError => e
|
|
143
|
+
{"ready" => false, "status" => "failed",
|
|
144
|
+
"message" => "final verification could not run: #{e.message}"}
|
|
145
|
+
rescue StandardError => e
|
|
146
|
+
{"ready" => false, "status" => "failed",
|
|
147
|
+
"message" => "final verification crashed: #{e.class}: #{e.message}"}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def verify_stage_ok?(report)
|
|
151
|
+
report["ready"] != false
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def verify_organism
|
|
155
|
+
@verify || Verify.new(
|
|
156
|
+
manifest_path: manifest_path, runner: @runner, account_lookup: @account_lookup,
|
|
157
|
+
authorization_path: grants_molecule.authorization_path, platform: platform
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# The project's target executor account: the owner of the
|
|
162
|
+
# declared directory the project lives in (longest prefix
|
|
163
|
+
# match; a directory declared AS the exact project path matches
|
|
164
|
+
# its own project too); projects outside any declared directory
|
|
165
|
+
# are left ownership-neutral.
|
|
166
|
+
def project_owner(manifest, path)
|
|
167
|
+
candidates = manifest["directories"].filter_map do |directory|
|
|
168
|
+
owner = directory["owner"]
|
|
169
|
+
prefix = directory["path"].to_s
|
|
170
|
+
matches = path.start_with?("#{prefix}/") || path == prefix
|
|
171
|
+
owner && matches ? [prefix.length, owner] : nil
|
|
172
|
+
end
|
|
173
|
+
candidates.max_by(&:first)&.last
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def prepare_organism
|
|
177
|
+
@prepare || Prepare.new(
|
|
178
|
+
manifest_path: manifest_path, runner: @runner, account_lookup: @account_lookup,
|
|
179
|
+
privileged: @privileged, platform: platform
|
|
180
|
+
)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def project_add_organism
|
|
184
|
+
@project_add || ProjectAdd.new(runner: @runner, account_lookup: @account_lookup)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def grants_molecule
|
|
188
|
+
@grants || Molecules::GrantProvisioner.new
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Same shape as a ProjectAdd report, with every step failed as a
|
|
192
|
+
# dependency failure — mirroring prepare's dependency pattern.
|
|
193
|
+
# `dry_run` is the effective per-call value, not the constructor
|
|
194
|
+
# default (review r1).
|
|
195
|
+
def dependency_failed_project(project, message, dry_run)
|
|
196
|
+
failed = ->(location) { {"location" => location, "status" => "failed", "message" => message} }
|
|
197
|
+
{
|
|
198
|
+
"ok" => false,
|
|
199
|
+
"dry_run" => dry_run,
|
|
200
|
+
"project" => project["id"],
|
|
201
|
+
"path" => project["path"],
|
|
202
|
+
"remote" => project["remote"],
|
|
203
|
+
"repository" => failed.call(project["path"]),
|
|
204
|
+
"overseer" => failed.call(File.join(project["path"], ".ace-tasks", "_overseer")),
|
|
205
|
+
"registry" => failed.call(File.join(project["path"], ".ace", "lab", "config.yml")),
|
|
206
|
+
"hitl" => failed.call(File.join(project["path"], ".ace", "hitl", "config.yml")),
|
|
207
|
+
"owner" => failed.call(project["path"])
|
|
208
|
+
}
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "../molecules/install_manifest_loader"
|
|
5
|
+
require_relative "../molecules/account_provisioner"
|
|
6
|
+
require_relative "../molecules/directory_provisioner"
|
|
7
|
+
require_relative "../molecules/key_provisioner"
|
|
8
|
+
require_relative "../molecules/role_verifier"
|
|
9
|
+
|
|
10
|
+
module Ace
|
|
11
|
+
module Lab
|
|
12
|
+
module Organisms
|
|
13
|
+
# Orchestrates the preparation phase (L8.1) from the trusted install
|
|
14
|
+
# manifest: accounts → directories → keys → roles, in that fixed
|
|
15
|
+
# order, each through its idempotent provisioner molecule. The
|
|
16
|
+
# aggregated report is the contract consumed by `ace-lab project add`
|
|
17
|
+
# (7.1) and `ace-lab install` (7.2); `ok` means zero failed items.
|
|
18
|
+
# Key and role items whose account failed are reported as dependency
|
|
19
|
+
# failures instead of being attempted. No state is written beyond
|
|
20
|
+
# what the manifest declares — this organism owns no journal.
|
|
21
|
+
class Prepare
|
|
22
|
+
attr_reader :manifest_path, :dry_run, :platform
|
|
23
|
+
|
|
24
|
+
def initialize(manifest_path: nil, runner: nil, account_lookup: nil, privileged: nil,
|
|
25
|
+
platform: nil, dry_run: false)
|
|
26
|
+
@manifest_path = manifest_path || Ace::Lab.install_manifest_path
|
|
27
|
+
@runner = runner
|
|
28
|
+
@account_lookup = account_lookup
|
|
29
|
+
@privileged = privileged
|
|
30
|
+
@platform = platform
|
|
31
|
+
@dry_run = dry_run
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param dry_run [Boolean, nil] per-call override of the
|
|
35
|
+
# constructor default (the CLI passes its --dry-run here)
|
|
36
|
+
# @return [Hash] aggregated report (ok / dry_run / machine /
|
|
37
|
+
# manifest / accounts / directories / keys / roles)
|
|
38
|
+
# @raise [Ace::Lab::InvalidConfigurationError] missing, untrusted,
|
|
39
|
+
# unparseable or schema-invalid manifest
|
|
40
|
+
def call(dry_run: nil)
|
|
41
|
+
effective_dry_run = dry_run.nil? ? self.dry_run : dry_run
|
|
42
|
+
manifest = Molecules::InstallManifestLoader.load(path: manifest_path, platform: platform)
|
|
43
|
+
accounts = prepare_accounts(manifest["accounts"], effective_dry_run)
|
|
44
|
+
available = available_accounts(accounts)
|
|
45
|
+
unresolved = unresolved_accounts(accounts)
|
|
46
|
+
directories = prepare_directories(manifest["directories"], effective_dry_run, available,
|
|
47
|
+
unresolved)
|
|
48
|
+
keys = prepare_keys(manifest["keys"], manifest["machine"], manifest["accounts"], available,
|
|
49
|
+
unresolved, effective_dry_run)
|
|
50
|
+
roles = prepare_roles(manifest["roles"], available, unresolved, effective_dry_run)
|
|
51
|
+
|
|
52
|
+
{
|
|
53
|
+
"ok" => [accounts, directories, keys, roles].flatten.none? { |item| item["status"] == "failed" },
|
|
54
|
+
"dry_run" => effective_dry_run,
|
|
55
|
+
"machine" => manifest["machine"],
|
|
56
|
+
"manifest" => manifest_path,
|
|
57
|
+
"accounts" => accounts,
|
|
58
|
+
"directories" => directories,
|
|
59
|
+
"keys" => keys,
|
|
60
|
+
"roles" => roles
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def prepare_accounts(entries, dry_run)
|
|
67
|
+
provisioner = Molecules::AccountProvisioner.new(
|
|
68
|
+
runner: @runner, account_lookup: @account_lookup, privileged: @privileged,
|
|
69
|
+
platform: @platform, dry_run: dry_run
|
|
70
|
+
)
|
|
71
|
+
entries.map { |account| provisioner.call(account: account) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def prepare_directories(entries, dry_run, available, unresolved)
|
|
75
|
+
provisioner = Molecules::DirectoryProvisioner.new(
|
|
76
|
+
account_lookup: @account_lookup, dry_run: dry_run, available_accounts: available
|
|
77
|
+
)
|
|
78
|
+
entries.map do |directory|
|
|
79
|
+
if unresolved.include?(directory["owner"])
|
|
80
|
+
# linux uid-lag edge, mirroring prepare_keys (round 5):
|
|
81
|
+
# the account phase created the owner but its uid is not
|
|
82
|
+
# yet resolvable — the remedy is a convergence rerun,
|
|
83
|
+
# not "fix the account phase" (that phase did not fail).
|
|
84
|
+
{"path" => directory["path"], "owner" => directory["owner"],
|
|
85
|
+
"mode" => directory["mode"], "status" => "failed",
|
|
86
|
+
"message" => "owner account '#{directory["owner"]}' was created but its uid is " \
|
|
87
|
+
"not yet resolvable; rerun ace-lab prepare"}
|
|
88
|
+
else
|
|
89
|
+
provisioner.call(directory: directory)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def prepare_keys(entries, machine, manifest_accounts, available, unresolved, dry_run)
|
|
95
|
+
provisioner = Molecules::KeyProvisioner.new(
|
|
96
|
+
runner: @runner, account_lookup: @account_lookup, dry_run: dry_run
|
|
97
|
+
)
|
|
98
|
+
by_username = manifest_accounts.to_h { |account| [account["username"], account] }
|
|
99
|
+
entries.map do |key|
|
|
100
|
+
account = by_username.fetch(key["account"])
|
|
101
|
+
username = account["username"]
|
|
102
|
+
if unresolved.include?(username)
|
|
103
|
+
# linux uid-lag edge: the account phase reported created
|
|
104
|
+
# with a nil uid (post-useradd passwd lookup lag) — the
|
|
105
|
+
# remedy is a convergence rerun, not "fix the account
|
|
106
|
+
# phase" (that phase did not fail).
|
|
107
|
+
{"account" => username, "path" => key["path"], "status" => "failed",
|
|
108
|
+
"fingerprint" => nil,
|
|
109
|
+
"message" => "account '#{username}' was created but its uid is not yet " \
|
|
110
|
+
"resolvable; rerun ace-lab prepare"}
|
|
111
|
+
elsif available.include?(username)
|
|
112
|
+
provisioner.call(key: key, account: account, machine: machine)
|
|
113
|
+
else
|
|
114
|
+
{"account" => username, "path" => key["path"], "status" => "failed",
|
|
115
|
+
"fingerprint" => nil,
|
|
116
|
+
"message" => "account '#{username}' is not available; fix the account phase first"}
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def prepare_roles(entries, available, unresolved, dry_run)
|
|
122
|
+
provisioner = Molecules::RoleVerifier.new(account_lookup: @account_lookup, dry_run: dry_run)
|
|
123
|
+
entries.map do |role|
|
|
124
|
+
if unresolved.include?(role["account"])
|
|
125
|
+
# linux uid-lag edge, mirroring prepare_keys (round 5):
|
|
126
|
+
# same rerun remedy, not "fix the account phase".
|
|
127
|
+
{"id" => role["id"], "account" => role["account"], "instructions" => role["instructions"],
|
|
128
|
+
"status" => "failed",
|
|
129
|
+
"message" => "account '#{role["account"]}' was created but its uid is not yet " \
|
|
130
|
+
"resolvable; rerun ace-lab prepare"}
|
|
131
|
+
elsif available.include?(role["account"])
|
|
132
|
+
provisioner.call(role: role)
|
|
133
|
+
else
|
|
134
|
+
{"id" => role["id"], "account" => role["account"], "instructions" => role["instructions"],
|
|
135
|
+
"status" => "failed",
|
|
136
|
+
"message" => "account '#{role['account']}' is not available; fix the account phase first"}
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def available_accounts(account_reports)
|
|
142
|
+
account_reports.reject { |report| report["status"] == "failed" }
|
|
143
|
+
.map { |report| report["username"] }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Accounts whose creation succeeded but whose uid is not yet
|
|
147
|
+
# resolvable (linux post-useradd passwd lag): the phase did not
|
|
148
|
+
# fail, but dependent key provisioning cannot chown to them.
|
|
149
|
+
def unresolved_accounts(account_reports)
|
|
150
|
+
account_reports.select { |report| report["status"] == "created" && report["uid"].nil? }
|
|
151
|
+
.map { |report| report["username"] }
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|