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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +363 -104
  3. data/README.md +66 -100
  4. data/docs/usage.md +529 -592
  5. data/lib/ace/lab/atoms/install_schema.rb +373 -0
  6. data/lib/ace/lab/cli/commands/install.rb +161 -0
  7. data/lib/ace/lab/cli/commands/prepare.rb +118 -0
  8. data/lib/ace/lab/cli/commands/project_add.rb +101 -0
  9. data/lib/ace/lab/cli/commands/status.rb +109 -0
  10. data/lib/ace/lab/cli/commands/verify.rb +115 -0
  11. data/lib/ace/lab/cli.rb +21 -23
  12. data/lib/ace/lab/install_manifest_path.rb +23 -0
  13. data/lib/ace/lab/models/status_report.rb +38 -0
  14. data/lib/ace/lab/molecules/account_provisioner.rb +196 -0
  15. data/lib/ace/lab/molecules/config_state_classifier.rb +145 -0
  16. data/lib/ace/lab/molecules/directory_provisioner.rb +208 -0
  17. data/lib/ace/lab/molecules/grant_provisioner.rb +250 -0
  18. data/lib/ace/lab/molecules/grant_resolver.rb +34 -23
  19. data/lib/ace/lab/molecules/hitl_routes.rb +263 -0
  20. data/lib/ace/lab/molecules/install_manifest_loader.rb +64 -0
  21. data/lib/ace/lab/molecules/key_provisioner.rb +736 -0
  22. data/lib/ace/lab/molecules/lab_state_scanner.rb +55 -0
  23. data/lib/ace/lab/molecules/overseer_provisioner.rb +188 -0
  24. data/lib/ace/lab/molecules/project_registry.rb +251 -0
  25. data/lib/ace/lab/molecules/repository_provisioner.rb +141 -0
  26. data/lib/ace/lab/molecules/role_verifier.rb +223 -0
  27. data/lib/ace/lab/organisms/install.rb +213 -0
  28. data/lib/ace/lab/organisms/prepare.rb +156 -0
  29. data/lib/ace/lab/organisms/project_add.rb +170 -0
  30. data/lib/ace/lab/organisms/status_service.rb +70 -0
  31. data/lib/ace/lab/organisms/verify.rb +768 -0
  32. data/lib/ace/lab/version.rb +1 -1
  33. data/lib/ace/lab.rb +34 -6
  34. metadata +34 -108
  35. data/lib/ace/lab/atoms/protected_publication_input.rb +0 -50
  36. data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +0 -114
  37. data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +0 -86
  38. data/lib/ace/lab/atoms/service_input.rb +0 -92
  39. data/lib/ace/lab/cli/commands/assignment.rb +0 -92
  40. data/lib/ace/lab/cli/commands/command_execute.rb +0 -28
  41. data/lib/ace/lab/cli/commands/run.rb +0 -65
  42. data/lib/ace/lab/cli/commands/service.rb +0 -240
  43. data/lib/ace/lab/models/service_receipt.rb +0 -18
  44. data/lib/ace/lab/molecules/command_owner_admission.rb +0 -183
  45. data/lib/ace/lab/molecules/command_policy.rb +0 -158
  46. data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
  47. data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
  48. data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
  49. data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
  50. data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
  51. data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -152
  52. data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
  53. data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
  54. data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
  55. data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
  56. data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
  57. data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
  58. data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
  59. data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
  60. data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
  61. data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
  62. data/lib/ace/lab/molecules/service_executor.rb +0 -99
  63. data/lib/ace/lab/molecules/service_policy.rb +0 -289
  64. data/lib/ace/lab/organisms/authority_composition.rb +0 -87
  65. data/lib/ace/lab/organisms/command_executor.rb +0 -220
  66. data/lib/ace/lab/organisms/command_run_client.rb +0 -131
  67. data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -120
  68. data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -153
  69. data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -679
  70. data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
  71. data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
  72. data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
  73. data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
  74. data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
  75. data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
  76. data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
  77. data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
  78. data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
  79. 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