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,373 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Lab
|
|
5
|
+
module Atoms
|
|
6
|
+
# Pure validation and normalization of the trusted install manifest
|
|
7
|
+
# (v1). The manifest is a deployment document read from a fixed
|
|
8
|
+
# root-owned path — never from the caller-writable cascade — so this
|
|
9
|
+
# atom only checks structure; it performs no IO and raises value-free
|
|
10
|
+
# messages naming the offending section (the caller cannot use schema
|
|
11
|
+
# errors to echo untrusted content back into diagnostics).
|
|
12
|
+
class InstallSchema
|
|
13
|
+
SCHEMA_VERSION = 1
|
|
14
|
+
TOP_LEVEL_KEYS =
|
|
15
|
+
%w[schema_version machine accounts directories keys roles projects grants
|
|
16
|
+
adapters tools].freeze
|
|
17
|
+
ENTRY_KEYS = {
|
|
18
|
+
"accounts" => %w[username role create home],
|
|
19
|
+
"directories" => %w[path owner mode],
|
|
20
|
+
"keys" => %w[account type path],
|
|
21
|
+
"roles" => %w[id account instructions],
|
|
22
|
+
"projects" => %w[id path remote label],
|
|
23
|
+
"adapters" => %w[id kind command config]
|
|
24
|
+
}.freeze
|
|
25
|
+
USERNAME_PATTERN = /\A[a-z_][a-z0-9_-]{0,31}\z/
|
|
26
|
+
ROLE_ID_PATTERN = /\A[a-z][a-z0-9_-]{0,31}\z/
|
|
27
|
+
INSTRUCTION_PROTOCOLS = %w[wfi:// prompt:// tmpl://].freeze
|
|
28
|
+
ADAPTER_KINDS = %w[communication hitl].freeze
|
|
29
|
+
DEFAULT_MODE = "0755"
|
|
30
|
+
DEFAULT_KEY_TYPE = "ed25519"
|
|
31
|
+
KEY_TYPES = [DEFAULT_KEY_TYPE].freeze
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
# @param document [Object] parsed YAML document
|
|
35
|
+
# @param platform [String, nil] "darwin" / "linux" (home defaults);
|
|
36
|
+
# nil resolves through the Ace::Lab.host_platform seam
|
|
37
|
+
# @return [Hash] normalized manifest with defaults applied
|
|
38
|
+
# @raise [Ace::Lab::InvalidConfigurationError]
|
|
39
|
+
def normalize(document, platform: nil)
|
|
40
|
+
unless document.is_a?(Hash)
|
|
41
|
+
raise_invalid "manifest must be a mapping"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
version = document["schema_version"]
|
|
45
|
+
unless version == SCHEMA_VERSION
|
|
46
|
+
raise_invalid "schema_version must be #{SCHEMA_VERSION}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
unknown = document.keys - TOP_LEVEL_KEYS
|
|
50
|
+
unless unknown.empty?
|
|
51
|
+
raise_invalid "unknown top-level key '#{unknown.sort.first}'"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
machine = document["machine"]
|
|
55
|
+
unless machine.is_a?(String) && !machine.empty? && machine !~ /\s/
|
|
56
|
+
raise_invalid "machine must be a non-empty string without whitespace"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
os = platform || Ace::Lab.host_platform
|
|
60
|
+
accounts = normalize_accounts(document["accounts"], os)
|
|
61
|
+
usernames = accounts.map { |account| account["username"] }
|
|
62
|
+
projects = normalize_projects(document["projects"])
|
|
63
|
+
|
|
64
|
+
{
|
|
65
|
+
"schema_version" => SCHEMA_VERSION,
|
|
66
|
+
"machine" => machine,
|
|
67
|
+
"accounts" => accounts,
|
|
68
|
+
"directories" => normalize_directories(document["directories"], usernames),
|
|
69
|
+
"keys" => normalize_keys(document["keys"], accounts),
|
|
70
|
+
"roles" => normalize_roles(document["roles"], usernames),
|
|
71
|
+
"projects" => projects,
|
|
72
|
+
"grants" => normalize_grants(document["grants"], projects.map { |p| p["id"] }),
|
|
73
|
+
"adapters" => normalize_adapters(document["adapters"]),
|
|
74
|
+
"tools" => normalize_tools(document["tools"])
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def raise_invalid(reason)
|
|
81
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
82
|
+
"invalid lab install manifest: #{reason}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def raise_invalid_entry(section, reason)
|
|
86
|
+
raise_invalid "#{section} entry #{reason}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def section(raw, name)
|
|
90
|
+
value = raw.nil? ? [] : raw
|
|
91
|
+
unless value.is_a?(Array)
|
|
92
|
+
raise_invalid "#{name} must be a list"
|
|
93
|
+
end
|
|
94
|
+
value.each_with_index do |entry, index|
|
|
95
|
+
unless entry.is_a?(Hash)
|
|
96
|
+
raise_invalid_entry(name, "##{index} must be a mapping")
|
|
97
|
+
end
|
|
98
|
+
next if ENTRY_KEYS[name].nil?
|
|
99
|
+
|
|
100
|
+
unknown = entry.keys - ENTRY_KEYS[name]
|
|
101
|
+
unless unknown.empty?
|
|
102
|
+
raise_invalid_entry(name, "##{index} has unknown key '#{unknown.sort.first}'")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
value
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def normalize_accounts(raw, platform)
|
|
109
|
+
section(raw, "accounts").map do |entry|
|
|
110
|
+
username = required_string(entry, "accounts", "username")
|
|
111
|
+
unless username =~ USERNAME_PATTERN
|
|
112
|
+
raise_invalid_entry("accounts", "username format is invalid")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
role = required_string(entry, "accounts", "role")
|
|
116
|
+
|
|
117
|
+
create = entry.key?("create") ? entry["create"] : false
|
|
118
|
+
unless [true, false].include?(create)
|
|
119
|
+
raise_invalid_entry("accounts", "create must be a boolean")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
home = entry["home"]
|
|
123
|
+
if home.nil?
|
|
124
|
+
home = default_home(username, platform)
|
|
125
|
+
elsif !home.is_a?(String) || !absolute_path?(home)
|
|
126
|
+
raise_invalid_entry("accounts", "home must be an absolute path")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
{"username" => username, "role" => role, "create" => create, "home" => home}
|
|
130
|
+
end.tap do |accounts|
|
|
131
|
+
duplicates_of(accounts.map { |a| a["username"] }) do |_username|
|
|
132
|
+
raise_invalid_entry("accounts", "username is declared more than once")
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def normalize_directories(raw, usernames)
|
|
138
|
+
section(raw, "directories").map do |entry|
|
|
139
|
+
path = required_string(entry, "directories", "path")
|
|
140
|
+
unless absolute_path?(path)
|
|
141
|
+
raise_invalid_entry("directories", "path must be an absolute path")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
owner = entry["owner"]
|
|
145
|
+
if !owner.nil?
|
|
146
|
+
unless owner.is_a?(String) && usernames.include?(owner)
|
|
147
|
+
raise_invalid_entry("directories", "owner must reference a declared account")
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
{"path" => path, "owner" => owner, "mode" => normalize_mode(entry["mode"])}
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def normalize_keys(raw, accounts)
|
|
156
|
+
homes = accounts.to_h { |account| [account["username"], account["home"]] }
|
|
157
|
+
section(raw, "keys").map do |entry|
|
|
158
|
+
account = required_string(entry, "keys", "account")
|
|
159
|
+
home = homes[account]
|
|
160
|
+
unless home
|
|
161
|
+
raise_invalid_entry("keys", "account must reference a declared account")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
type = entry.key?("type") ? entry["type"] : DEFAULT_KEY_TYPE
|
|
165
|
+
unless KEY_TYPES.include?(type)
|
|
166
|
+
raise_invalid_entry("keys", "type must be one of: #{KEY_TYPES.join(', ')}")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
path = entry["path"]
|
|
170
|
+
if path.nil?
|
|
171
|
+
path = File.join(home, ".ssh", "id_#{type}")
|
|
172
|
+
elsif !path.is_a?(String) || !absolute_path?(path)
|
|
173
|
+
raise_invalid_entry("keys", "path must be an absolute path")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
{"account" => account, "type" => type, "path" => path}
|
|
177
|
+
end.tap do |keys|
|
|
178
|
+
duplicates_of(keys.map { |key| key["path"] }) do |_path|
|
|
179
|
+
raise_invalid_entry("keys", "path is declared more than once")
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def normalize_roles(raw, usernames)
|
|
185
|
+
section(raw, "roles").map do |entry|
|
|
186
|
+
id = required_string(entry, "roles", "id")
|
|
187
|
+
unless id =~ ROLE_ID_PATTERN
|
|
188
|
+
raise_invalid_entry("roles", "id format is invalid")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
account = required_string(entry, "roles", "account")
|
|
192
|
+
unless usernames.include?(account)
|
|
193
|
+
raise_invalid_entry("roles", "account must reference a declared account")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
instructions = required_string(entry, "roles", "instructions")
|
|
197
|
+
unless instruction_source?(instructions)
|
|
198
|
+
raise_invalid_entry("roles",
|
|
199
|
+
"instructions must use a wfi://, prompt:// or tmpl:// prefix or be an absolute file path")
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
{"id" => id, "account" => account, "instructions" => instructions}
|
|
203
|
+
end.tap do |roles|
|
|
204
|
+
duplicates_of(roles.map { |role| role["id"] }) do |_id|
|
|
205
|
+
raise_invalid_entry("roles", "id is declared more than once")
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def normalize_projects(raw)
|
|
211
|
+
section(raw, "projects").map do |entry|
|
|
212
|
+
id = required_string(entry, "projects", "id")
|
|
213
|
+
unless id =~ ROLE_ID_PATTERN
|
|
214
|
+
raise_invalid_entry("projects", "id format is invalid")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
path = required_string(entry, "projects", "path")
|
|
218
|
+
unless absolute_path?(path)
|
|
219
|
+
raise_invalid_entry("projects", "path must be an absolute path")
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
remote = optional_string(entry, "projects", "remote")
|
|
223
|
+
label = optional_string(entry, "projects", "label")
|
|
224
|
+
|
|
225
|
+
{"id" => id, "path" => path, "remote" => remote, "label" => label}
|
|
226
|
+
end.tap do |projects|
|
|
227
|
+
duplicates_of(projects.map { |project| project["id"] }) do |_id|
|
|
228
|
+
raise_invalid_entry("projects", "id is declared more than once")
|
|
229
|
+
end
|
|
230
|
+
duplicates_of(projects.map { |project| project["path"] }) do |_path|
|
|
231
|
+
raise_invalid_entry("projects", "path is declared more than once")
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def normalize_grants(raw, project_ids)
|
|
237
|
+
grants = raw.nil? ? {} : raw
|
|
238
|
+
unless grants.is_a?(Hash)
|
|
239
|
+
raise_invalid "grants must be a mapping of principal to project ids"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Reject duplicates after strip, like the other duplicate
|
|
243
|
+
# checks (review r1): "admin" and " admin " would otherwise
|
|
244
|
+
# silently last-wins.
|
|
245
|
+
duplicates_of(grants.keys.filter_map { |key| key.strip if key.is_a?(String) }) do |_principal|
|
|
246
|
+
raise_invalid "grants principal name is declared more than once"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
grants.to_h do |principal, projects|
|
|
250
|
+
unless principal.is_a?(String) && !principal.strip.empty?
|
|
251
|
+
raise_invalid "grants principal name must be a non-empty string"
|
|
252
|
+
end
|
|
253
|
+
unless projects.is_a?(Array) && !projects.empty? &&
|
|
254
|
+
projects.all? { |project| project.is_a?(String) && !project.strip.empty? }
|
|
255
|
+
raise_invalid "grants for principal '#{principal.strip}' must be a non-empty " \
|
|
256
|
+
"list of project ids"
|
|
257
|
+
end
|
|
258
|
+
duplicates_of(projects.map(&:strip)) do |_project|
|
|
259
|
+
raise_invalid "grants for principal '#{principal.strip}' list a project more than once"
|
|
260
|
+
end
|
|
261
|
+
unknown = projects.map(&:strip) - project_ids
|
|
262
|
+
unless unknown.empty?
|
|
263
|
+
raise_invalid "grants for principal '#{principal.strip}' reference project " \
|
|
264
|
+
"'#{unknown.sort.first}' which the manifest does not declare"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
[principal.strip, projects.map(&:strip).uniq]
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Selected communication/HITL adapters (task 8xg.t.sbz.7.2):
|
|
272
|
+
# each declares an id, a kind, an optional local command that
|
|
273
|
+
# provides the adapter (probed for availability by verify) and
|
|
274
|
+
# an optional free-form configuration mapping. Real network
|
|
275
|
+
# traffic stays out of scope (L9) — this is the declaration
|
|
276
|
+
# surface install validates and verify probes.
|
|
277
|
+
def normalize_adapters(raw)
|
|
278
|
+
section(raw, "adapters").map do |entry|
|
|
279
|
+
id = required_string(entry, "adapters", "id")
|
|
280
|
+
unless id =~ ROLE_ID_PATTERN
|
|
281
|
+
raise_invalid_entry("adapters", "id format is invalid")
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
kind = required_string(entry, "adapters", "kind")
|
|
285
|
+
unless ADAPTER_KINDS.include?(kind)
|
|
286
|
+
raise_invalid_entry("adapters",
|
|
287
|
+
"kind must be one of: #{ADAPTER_KINDS.join(', ')}")
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
command = optional_string(entry, "adapters", "command")
|
|
291
|
+
|
|
292
|
+
config = entry["config"].nil? ? {} : entry["config"]
|
|
293
|
+
unless config.is_a?(Hash)
|
|
294
|
+
raise_invalid_entry("adapters", "config must be a mapping when present")
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
{"id" => id, "kind" => kind, "command" => command, "config" => config}
|
|
298
|
+
end.tap do |adapters|
|
|
299
|
+
duplicates_of(adapters.map { |adapter| adapter["id"] }) do |_id|
|
|
300
|
+
raise_invalid_entry("adapters", "id is declared more than once")
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Required tools (task 8xg.t.sbz.7.4): names the lab needs on
|
|
306
|
+
# PATH for the target accounts; verify probes each one's
|
|
307
|
+
# presence (no execution, no network).
|
|
308
|
+
def normalize_tools(raw)
|
|
309
|
+
tools = raw.nil? ? [] : raw
|
|
310
|
+
unless tools.is_a?(Array) && tools.all? { |tool| tool.is_a?(String) && !tool.strip.empty? }
|
|
311
|
+
raise_invalid "tools must be a list of non-empty strings"
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
duplicates_of(tools.map(&:strip)) do |_tool|
|
|
315
|
+
raise_invalid "tools list a tool more than once"
|
|
316
|
+
end
|
|
317
|
+
tools.map(&:strip).uniq
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def optional_string(entry, section_name, field)
|
|
321
|
+
value = entry[field]
|
|
322
|
+
if !value.nil? && (!value.is_a?(String) || value.strip.empty?)
|
|
323
|
+
raise_invalid_entry(section_name, "#{field} must be a non-empty string when present")
|
|
324
|
+
end
|
|
325
|
+
value.nil? ? nil : value.strip
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def required_string(entry, section_name, field)
|
|
329
|
+
value = entry[field]
|
|
330
|
+
unless value.is_a?(String) && !value.strip.empty?
|
|
331
|
+
raise_invalid_entry(section_name, "#{field} is required and must be a non-empty string")
|
|
332
|
+
end
|
|
333
|
+
value
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def normalize_mode(raw)
|
|
337
|
+
mode = raw.nil? ? DEFAULT_MODE : raw
|
|
338
|
+
unless mode.is_a?(String) && mode.match?(/\A[0-7]{3,4}\z/)
|
|
339
|
+
raise_invalid_entry("directories", "mode must be an octal string like \"0750\"")
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
format("%04o", mode.to_i(8))
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def absolute_path?(path)
|
|
346
|
+
path.start_with?("/")
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def instruction_source?(instructions)
|
|
350
|
+
INSTRUCTION_PROTOCOLS.any? { |protocol| instructions.start_with?(protocol) } ||
|
|
351
|
+
absolute_path?(instructions)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def default_home(username, platform)
|
|
355
|
+
prefix = platform == "darwin" ? "/Users" : "/home"
|
|
356
|
+
File.join(prefix, username)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def duplicates_of(values)
|
|
360
|
+
seen = {}
|
|
361
|
+
values.each do |value|
|
|
362
|
+
if seen[value]
|
|
363
|
+
yield value
|
|
364
|
+
else
|
|
365
|
+
seen[value] = true
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "ace/support/cli"
|
|
5
|
+
require "ace/core"
|
|
6
|
+
require_relative "../../organisms/install"
|
|
7
|
+
require_relative "verify"
|
|
8
|
+
|
|
9
|
+
module Ace
|
|
10
|
+
module Lab
|
|
11
|
+
module CLI
|
|
12
|
+
module Commands
|
|
13
|
+
class Install < Ace::Support::Cli::Command
|
|
14
|
+
include Ace::Support::Cli::Base
|
|
15
|
+
|
|
16
|
+
# Step names inside a per-project report (ProjectAdd shape).
|
|
17
|
+
PROJECT_STEPS = %w[repository overseer registry owner].freeze
|
|
18
|
+
|
|
19
|
+
desc "Perform the full new-lab installation from the trusted install manifest " \
|
|
20
|
+
"(prepare, per-project project add, trusted-tier grants)"
|
|
21
|
+
|
|
22
|
+
example [
|
|
23
|
+
"--dry-run # Predict every phase without mutating",
|
|
24
|
+
"--format json # Machine-readable report"
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
option :dry_run, type: :boolean, desc: "Predict changes without applying them"
|
|
28
|
+
option :format, type: :string, desc: "Output format (table, json; default: table)"
|
|
29
|
+
option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress output"
|
|
30
|
+
|
|
31
|
+
def initialize(install: nil)
|
|
32
|
+
super()
|
|
33
|
+
@install = install
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call(**options)
|
|
37
|
+
format = options[:format] || "table"
|
|
38
|
+
unless %w[table json].include?(format)
|
|
39
|
+
raise Ace::Support::Cli::Error,
|
|
40
|
+
"--format \"#{format}\" is not supported; table or json"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
organism = @install || Organisms::Install.new
|
|
44
|
+
report = organism.call(dry_run: options[:dry_run] || false)
|
|
45
|
+
render(report, format: format, quiet: options[:quiet])
|
|
46
|
+
|
|
47
|
+
# Same contract as prepare/project add: a wet run with
|
|
48
|
+
# failed steps never exits 0 — the report renders first,
|
|
49
|
+
# then the failure list names each step explicitly. Dry-run
|
|
50
|
+
# conflicts are predicted failures, not execution failures
|
|
51
|
+
# — exit 0 so callers can script a preview.
|
|
52
|
+
unless report["ok"] || report["dry_run"]
|
|
53
|
+
raise Ace::Support::Cli::Error, failure_summary(report)
|
|
54
|
+
end
|
|
55
|
+
rescue Ace::Lab::InvalidConfigurationError => e
|
|
56
|
+
raise Ace::Support::Cli::Error, e.message
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def render(report, format:, quiet:)
|
|
62
|
+
return if quiet
|
|
63
|
+
|
|
64
|
+
puts format == "json" ? JSON.generate(report) : render_table(report)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def render_table(report)
|
|
68
|
+
lines = ["Machine: #{report['machine']} manifest: #{report['manifest']}" \
|
|
69
|
+
"#{" (dry run)" if report['dry_run']}"]
|
|
70
|
+
|
|
71
|
+
lines << "Prepare: #{report['prepare']['ok'] ? 'ok' : 'FAILED'}"
|
|
72
|
+
prepare_failures(report['prepare']).each do |line|
|
|
73
|
+
lines << " #{line}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
lines << "Projects:"
|
|
77
|
+
if report["projects"].empty?
|
|
78
|
+
lines << " (none declared)"
|
|
79
|
+
else
|
|
80
|
+
report["projects"].each do |project|
|
|
81
|
+
steps = PROJECT_STEPS.map { |step| "#{step}: #{project[step]['status']}" }.join(", ")
|
|
82
|
+
lines << " #{project['project'].to_s.ljust(34)} " \
|
|
83
|
+
"#{project['ok'] ? 'ok' : 'FAILED'} (#{steps})"
|
|
84
|
+
PROJECT_STEPS.each do |step|
|
|
85
|
+
lines << " #{step}: #{project[step]['message']}" if project[step]["message"]
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
grants = report["grants"]
|
|
91
|
+
lines << "Grants (#{grants['path']}): #{grants['status']}"
|
|
92
|
+
grants["principals"].each do |principal, status|
|
|
93
|
+
lines << " #{principal.to_s.ljust(34)} #{status}"
|
|
94
|
+
end
|
|
95
|
+
lines << " #{grants['message']}" if grants["message"]
|
|
96
|
+
|
|
97
|
+
lines.concat(render_verify(report["verify"]))
|
|
98
|
+
|
|
99
|
+
lines << "Result: #{report['ok'] ? 'ok' : 'FAILED'}"
|
|
100
|
+
lines.join("\n")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Final install verification stage: the readiness verdict with
|
|
104
|
+
# every failed check named (adapters and tools included).
|
|
105
|
+
def render_verify(verify_report)
|
|
106
|
+
return ["Verify: skipped (#{verify_report['message']})"] if verify_report["status"] == "skipped"
|
|
107
|
+
|
|
108
|
+
counts = verify_report["counts"] || {}
|
|
109
|
+
verdict = verify_report["ready"] ? "ready" : "NOT READY"
|
|
110
|
+
lines = ["Verify: #{verdict} (#{counts.fetch('pass', 0)} pass, " \
|
|
111
|
+
"#{counts.fetch('fail', 0)} failed, #{counts.fetch('unknown', 0)} unknown)"]
|
|
112
|
+
Ace::Lab::CLI::Commands::Verify::SECTION_LABELS.each do |section, label_key|
|
|
113
|
+
Array(verify_report.dig("checks", section)).each do |check|
|
|
114
|
+
next unless check["status"] == "fail"
|
|
115
|
+
|
|
116
|
+
name = check[label_key] || check["path"] || section
|
|
117
|
+
lines << " #{section}/#{name}: #{check['reason']}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
lines
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def prepare_failures(prepare_report)
|
|
124
|
+
Ace::Lab::CLI::Commands::Prepare::SECTION_NAMERS.flat_map do |section, namer|
|
|
125
|
+
prepare_report[section]
|
|
126
|
+
.select { |item| item["status"] == "failed" }
|
|
127
|
+
.map { |item| "#{section}/#{namer.call(item)}: #{item['message']}" }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def failure_summary(report)
|
|
132
|
+
failures = prepare_failures(report["prepare"])
|
|
133
|
+
report["projects"].each do |project|
|
|
134
|
+
PROJECT_STEPS.each do |step|
|
|
135
|
+
item = project[step]
|
|
136
|
+
failures << "projects/#{project['project']}/#{step}: #{item['message']}" \
|
|
137
|
+
if item["status"] == "failed"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
if report["grants"]["status"] == "failed"
|
|
141
|
+
failures << "grants: #{report['grants']['message']}"
|
|
142
|
+
end
|
|
143
|
+
verify_report = report["verify"]
|
|
144
|
+
if verify_report.is_a?(Hash) && verify_report["ready"] == false
|
|
145
|
+
Ace::Lab::CLI::Commands::Verify::SECTION_LABELS.each do |section, label_key|
|
|
146
|
+
Array(verify_report.dig("checks", section)).each do |check|
|
|
147
|
+
next unless check["status"] == "fail"
|
|
148
|
+
|
|
149
|
+
name = check[label_key] || check["path"] || section
|
|
150
|
+
failures << "verify/#{section}/#{name}: #{check['reason']}"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
failures << "verify: #{verify_report['message']}" if verify_report["message"]
|
|
154
|
+
end
|
|
155
|
+
"install failed (#{failures.size} step(s)): #{failures.join('; ')}"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "ace/support/cli"
|
|
5
|
+
require "ace/core"
|
|
6
|
+
require_relative "../../organisms/prepare"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Lab
|
|
10
|
+
module CLI
|
|
11
|
+
module Commands
|
|
12
|
+
class Prepare < Ace::Support::Cli::Command
|
|
13
|
+
include Ace::Support::Cli::Base
|
|
14
|
+
|
|
15
|
+
# One shared section-namer map: the table renderer and the
|
|
16
|
+
# failure summary must name items identically.
|
|
17
|
+
SECTION_NAMERS = {
|
|
18
|
+
"accounts" => ->(item) { item["username"] },
|
|
19
|
+
"directories" => ->(item) { item["path"] },
|
|
20
|
+
"keys" => ->(item) { "#{item['account']}:#{item['path']}" },
|
|
21
|
+
"roles" => ->(item) { item["id"] }
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
# "SHA256:" plus the first 20 base64 characters is enough to
|
|
25
|
+
# eyeball-match a key without ballooning the table row.
|
|
26
|
+
FINGERPRINT_WIDTH = 27
|
|
27
|
+
|
|
28
|
+
desc "Prepare accounts, directories, permissions, keys and roles from the trusted install manifest"
|
|
29
|
+
|
|
30
|
+
example [
|
|
31
|
+
"--dry-run # Predict all sections without mutating",
|
|
32
|
+
"--format json # Machine-readable report"
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
option :dry_run, type: :boolean, desc: "Predict changes without applying them"
|
|
36
|
+
option :format, type: :string, desc: "Output format (table, json; default: table)"
|
|
37
|
+
option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress output"
|
|
38
|
+
|
|
39
|
+
def initialize(prepare: nil)
|
|
40
|
+
super()
|
|
41
|
+
@prepare = prepare
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def call(**options)
|
|
45
|
+
format = options[:format] || "table"
|
|
46
|
+
unless %w[table json].include?(format)
|
|
47
|
+
raise Ace::Support::Cli::Error,
|
|
48
|
+
"--format \"#{format}\" is not supported; table or json"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
organism = @prepare || Organisms::Prepare.new
|
|
52
|
+
report = organism.call(dry_run: options[:dry_run] || false)
|
|
53
|
+
render(report, format: format, quiet: options[:quiet])
|
|
54
|
+
|
|
55
|
+
# A wet run with failed items never exits 0: the report renders
|
|
56
|
+
# first, then the failure list names each item explicitly.
|
|
57
|
+
# Dry-run conflicts are predicted failures, not execution
|
|
58
|
+
# failures — exit 0 so callers can script a preview.
|
|
59
|
+
unless report["ok"] || report["dry_run"]
|
|
60
|
+
raise Ace::Support::Cli::Error, failure_summary(report)
|
|
61
|
+
end
|
|
62
|
+
rescue Ace::Lab::InvalidConfigurationError => e
|
|
63
|
+
raise Ace::Support::Cli::Error, e.message
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def render(report, format:, quiet:)
|
|
69
|
+
return if quiet
|
|
70
|
+
|
|
71
|
+
puts format == "json" ? JSON.generate(report) : render_table(report)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def render_table(report)
|
|
75
|
+
lines = ["Machine: #{report['machine']} manifest: #{report['manifest']}" \
|
|
76
|
+
"#{" (dry run)" if report['dry_run']}"]
|
|
77
|
+
SECTION_NAMERS.each do |section, namer|
|
|
78
|
+
lines << "#{section.capitalize}:"
|
|
79
|
+
items = report[section]
|
|
80
|
+
if items.empty?
|
|
81
|
+
lines << " (none declared)"
|
|
82
|
+
else
|
|
83
|
+
items.each do |item|
|
|
84
|
+
detail = detail_for(section, item)
|
|
85
|
+
lines << " #{namer.call(item).to_s.ljust(34)} #{item['status']}#{detail}"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
lines << "Result: #{report['ok'] ? 'ok' : 'FAILED'}"
|
|
90
|
+
lines.join("\n")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Key rows render a truncated public fingerprint (never key
|
|
94
|
+
# material); everything else appends the message when present.
|
|
95
|
+
def detail_for(section, item)
|
|
96
|
+
parts = []
|
|
97
|
+
if section == "keys" && item["fingerprint"]
|
|
98
|
+
fingerprint = item["fingerprint"]
|
|
99
|
+
parts << (fingerprint.length <= FINGERPRINT_WIDTH ? fingerprint :
|
|
100
|
+
"#{fingerprint[0, FINGERPRINT_WIDTH]}…")
|
|
101
|
+
end
|
|
102
|
+
parts << item["message"] if item["message"]
|
|
103
|
+
parts.empty? ? "" : " #{parts.join(' ')}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def failure_summary(report)
|
|
107
|
+
failures = SECTION_NAMERS.flat_map do |section, namer|
|
|
108
|
+
report[section]
|
|
109
|
+
.select { |item| item["status"] == "failed" }
|
|
110
|
+
.map { |item| "#{section}/#{namer.call(item)}: #{item['message']}" }
|
|
111
|
+
end
|
|
112
|
+
"prepare failed (#{failures.size} item(s)): #{failures.join('; ')}"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|