ace-lab 0.9.0 → 0.13.1

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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +399 -102
  3. data/README.md +66 -100
  4. data/docs/usage.md +563 -592
  5. data/lib/ace/lab/atoms/install_schema.rb +444 -0
  6. data/lib/ace/lab/cli/commands/install.rb +161 -0
  7. data/lib/ace/lab/cli/commands/prepare.rb +120 -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/gemset_provisioner.rb +222 -0
  18. data/lib/ace/lab/molecules/grant_provisioner.rb +250 -0
  19. data/lib/ace/lab/molecules/grant_resolver.rb +34 -23
  20. data/lib/ace/lab/molecules/hitl_routes.rb +263 -0
  21. data/lib/ace/lab/molecules/install_manifest_loader.rb +64 -0
  22. data/lib/ace/lab/molecules/key_provisioner.rb +736 -0
  23. data/lib/ace/lab/molecules/lab_state_scanner.rb +55 -0
  24. data/lib/ace/lab/molecules/overseer_provisioner.rb +188 -0
  25. data/lib/ace/lab/molecules/project_registry.rb +251 -0
  26. data/lib/ace/lab/molecules/repository_provisioner.rb +141 -0
  27. data/lib/ace/lab/molecules/role_verifier.rb +223 -0
  28. data/lib/ace/lab/organisms/install.rb +218 -0
  29. data/lib/ace/lab/organisms/prepare.rb +170 -0
  30. data/lib/ace/lab/organisms/project_add.rb +170 -0
  31. data/lib/ace/lab/organisms/status_service.rb +70 -0
  32. data/lib/ace/lab/organisms/verify.rb +836 -0
  33. data/lib/ace/lab/version.rb +1 -1
  34. data/lib/ace/lab.rb +34 -6
  35. metadata +35 -108
  36. data/lib/ace/lab/atoms/protected_publication_input.rb +0 -50
  37. data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +0 -114
  38. data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +0 -86
  39. data/lib/ace/lab/atoms/service_input.rb +0 -92
  40. data/lib/ace/lab/cli/commands/assignment.rb +0 -92
  41. data/lib/ace/lab/cli/commands/command_execute.rb +0 -28
  42. data/lib/ace/lab/cli/commands/run.rb +0 -65
  43. data/lib/ace/lab/cli/commands/service.rb +0 -240
  44. data/lib/ace/lab/models/service_receipt.rb +0 -18
  45. data/lib/ace/lab/molecules/command_owner_admission.rb +0 -183
  46. data/lib/ace/lab/molecules/command_policy.rb +0 -158
  47. data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
  48. data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
  49. data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
  50. data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
  51. data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
  52. data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -152
  53. data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
  54. data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
  55. data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
  56. data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
  57. data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
  58. data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
  59. data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
  60. data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
  61. data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
  62. data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
  63. data/lib/ace/lab/molecules/service_executor.rb +0 -99
  64. data/lib/ace/lab/molecules/service_policy.rb +0 -289
  65. data/lib/ace/lab/organisms/authority_composition.rb +0 -87
  66. data/lib/ace/lab/organisms/command_executor.rb +0 -220
  67. data/lib/ace/lab/organisms/command_run_client.rb +0 -131
  68. data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -120
  69. data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -153
  70. data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -679
  71. data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
  72. data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
  73. data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
  74. data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
  75. data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
  76. data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
  77. data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
  78. data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
  79. data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
  80. data/lib/ace/lab/organisms/service_request_service.rb +0 -249
@@ -0,0 +1,444 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubygems/version"
4
+
5
+ module Ace
6
+ module Lab
7
+ module Atoms
8
+ # Pure validation and normalization of the trusted install manifest
9
+ # (v1). The manifest is a deployment document read from a fixed
10
+ # root-owned path — never from the caller-writable cascade — so this
11
+ # atom only checks structure; it performs no IO and raises value-free
12
+ # messages naming the offending section (the caller cannot use schema
13
+ # errors to echo untrusted content back into diagnostics).
14
+ class InstallSchema
15
+ SCHEMA_VERSION = 1
16
+ TOP_LEVEL_KEYS =
17
+ %w[schema_version machine accounts directories keys roles projects grants
18
+ adapters tools gemset].freeze
19
+ ENTRY_KEYS = {
20
+ "accounts" => %w[username role create home],
21
+ "directories" => %w[path owner mode],
22
+ "keys" => %w[account type path],
23
+ "roles" => %w[id account instructions],
24
+ "projects" => %w[id path remote label],
25
+ "adapters" => %w[id kind command config]
26
+ }.freeze
27
+ USERNAME_PATTERN = /\A[a-z_][a-z0-9_-]{0,31}\z/
28
+ ROLE_ID_PATTERN = /\A[a-z][a-z0-9_-]{0,31}\z/
29
+ INSTRUCTION_PROTOCOLS = %w[wfi:// prompt:// tmpl://].freeze
30
+ ADAPTER_KINDS = %w[communication hitl].freeze
31
+ DEFAULT_MODE = "0755"
32
+ DEFAULT_KEY_TYPE = "ed25519"
33
+ KEY_TYPES = [DEFAULT_KEY_TYPE].freeze
34
+ GEMSET_KEYS = %w[gem_home gems].freeze
35
+ GEM_KEYS = %w[name version].freeze
36
+ GEM_NAME_PATTERN = /\A[A-Za-z0-9._-]+\z/
37
+
38
+ class << self
39
+ # @param document [Object] parsed YAML document
40
+ # @param platform [String, nil] "darwin" / "linux" (home defaults);
41
+ # nil resolves through the Ace::Lab.host_platform seam
42
+ # @return [Hash] normalized manifest with defaults applied
43
+ # @raise [Ace::Lab::InvalidConfigurationError]
44
+ def normalize(document, platform: nil)
45
+ unless document.is_a?(Hash)
46
+ raise_invalid "manifest must be a mapping"
47
+ end
48
+
49
+ version = document["schema_version"]
50
+ unless version == SCHEMA_VERSION
51
+ raise_invalid "schema_version must be #{SCHEMA_VERSION}"
52
+ end
53
+
54
+ unknown = document.keys - TOP_LEVEL_KEYS
55
+ unless unknown.empty?
56
+ raise_invalid "unknown top-level key '#{unknown.sort.first}'"
57
+ end
58
+
59
+ machine = document["machine"]
60
+ unless machine.is_a?(String) && !machine.empty? && machine !~ /\s/
61
+ raise_invalid "machine must be a non-empty string without whitespace"
62
+ end
63
+
64
+ os = platform || Ace::Lab.host_platform
65
+ accounts = normalize_accounts(document["accounts"], os)
66
+ usernames = accounts.map { |account| account["username"] }
67
+ projects = normalize_projects(document["projects"])
68
+
69
+ {
70
+ "schema_version" => SCHEMA_VERSION,
71
+ "machine" => machine,
72
+ "accounts" => accounts,
73
+ "directories" => normalize_directories(document["directories"], usernames),
74
+ "keys" => normalize_keys(document["keys"], accounts),
75
+ "roles" => normalize_roles(document["roles"], usernames),
76
+ "projects" => projects,
77
+ "grants" => normalize_grants(document["grants"], projects.map { |p| p["id"] }),
78
+ "adapters" => normalize_adapters(document["adapters"]),
79
+ "tools" => normalize_tools(document["tools"]),
80
+ "gemset" => normalize_gemset(document["gemset"])
81
+ }
82
+ end
83
+
84
+ private
85
+
86
+ def raise_invalid(reason)
87
+ raise Ace::Lab::InvalidConfigurationError,
88
+ "invalid lab install manifest: #{reason}"
89
+ end
90
+
91
+ def raise_invalid_entry(section, reason)
92
+ raise_invalid "#{section} entry #{reason}"
93
+ end
94
+
95
+ def section(raw, name)
96
+ value = raw.nil? ? [] : raw
97
+ unless value.is_a?(Array)
98
+ raise_invalid "#{name} must be a list"
99
+ end
100
+ value.each_with_index do |entry, index|
101
+ unless entry.is_a?(Hash)
102
+ raise_invalid_entry(name, "##{index} must be a mapping")
103
+ end
104
+ next if ENTRY_KEYS[name].nil?
105
+
106
+ unknown = entry.keys - ENTRY_KEYS[name]
107
+ unless unknown.empty?
108
+ raise_invalid_entry(name, "##{index} has unknown key '#{unknown.sort.first}'")
109
+ end
110
+ end
111
+ value
112
+ end
113
+
114
+ def normalize_accounts(raw, platform)
115
+ section(raw, "accounts").map do |entry|
116
+ username = required_string(entry, "accounts", "username")
117
+ unless username =~ USERNAME_PATTERN
118
+ raise_invalid_entry("accounts", "username format is invalid")
119
+ end
120
+
121
+ role = required_string(entry, "accounts", "role")
122
+
123
+ create = entry.key?("create") ? entry["create"] : false
124
+ unless [true, false].include?(create)
125
+ raise_invalid_entry("accounts", "create must be a boolean")
126
+ end
127
+
128
+ home = entry["home"]
129
+ if home.nil?
130
+ home = default_home(username, platform)
131
+ elsif !home.is_a?(String) || !absolute_path?(home)
132
+ raise_invalid_entry("accounts", "home must be an absolute path")
133
+ end
134
+
135
+ {"username" => username, "role" => role, "create" => create, "home" => home}
136
+ end.tap do |accounts|
137
+ duplicates_of(accounts.map { |a| a["username"] }) do |_username|
138
+ raise_invalid_entry("accounts", "username is declared more than once")
139
+ end
140
+ end
141
+ end
142
+
143
+ def normalize_directories(raw, usernames)
144
+ section(raw, "directories").map do |entry|
145
+ path = required_string(entry, "directories", "path")
146
+ unless absolute_path?(path)
147
+ raise_invalid_entry("directories", "path must be an absolute path")
148
+ end
149
+
150
+ owner = entry["owner"]
151
+ if !owner.nil?
152
+ unless owner.is_a?(String) && usernames.include?(owner)
153
+ raise_invalid_entry("directories", "owner must reference a declared account")
154
+ end
155
+ end
156
+
157
+ {"path" => path, "owner" => owner, "mode" => normalize_mode(entry["mode"])}
158
+ end
159
+ end
160
+
161
+ def normalize_keys(raw, accounts)
162
+ homes = accounts.to_h { |account| [account["username"], account["home"]] }
163
+ section(raw, "keys").map do |entry|
164
+ account = required_string(entry, "keys", "account")
165
+ home = homes[account]
166
+ unless home
167
+ raise_invalid_entry("keys", "account must reference a declared account")
168
+ end
169
+
170
+ type = entry.key?("type") ? entry["type"] : DEFAULT_KEY_TYPE
171
+ unless KEY_TYPES.include?(type)
172
+ raise_invalid_entry("keys", "type must be one of: #{KEY_TYPES.join(', ')}")
173
+ end
174
+
175
+ path = entry["path"]
176
+ if path.nil?
177
+ path = File.join(home, ".ssh", "id_#{type}")
178
+ elsif !path.is_a?(String) || !absolute_path?(path)
179
+ raise_invalid_entry("keys", "path must be an absolute path")
180
+ end
181
+
182
+ {"account" => account, "type" => type, "path" => path}
183
+ end.tap do |keys|
184
+ duplicates_of(keys.map { |key| key["path"] }) do |_path|
185
+ raise_invalid_entry("keys", "path is declared more than once")
186
+ end
187
+ end
188
+ end
189
+
190
+ def normalize_roles(raw, usernames)
191
+ section(raw, "roles").map do |entry|
192
+ id = required_string(entry, "roles", "id")
193
+ unless id =~ ROLE_ID_PATTERN
194
+ raise_invalid_entry("roles", "id format is invalid")
195
+ end
196
+
197
+ account = required_string(entry, "roles", "account")
198
+ unless usernames.include?(account)
199
+ raise_invalid_entry("roles", "account must reference a declared account")
200
+ end
201
+
202
+ instructions = required_string(entry, "roles", "instructions")
203
+ unless instruction_source?(instructions)
204
+ raise_invalid_entry("roles",
205
+ "instructions must use a wfi://, prompt:// or tmpl:// prefix or be an absolute file path")
206
+ end
207
+
208
+ {"id" => id, "account" => account, "instructions" => instructions}
209
+ end.tap do |roles|
210
+ duplicates_of(roles.map { |role| role["id"] }) do |_id|
211
+ raise_invalid_entry("roles", "id is declared more than once")
212
+ end
213
+ end
214
+ end
215
+
216
+ def normalize_projects(raw)
217
+ section(raw, "projects").map do |entry|
218
+ id = required_string(entry, "projects", "id")
219
+ unless id =~ ROLE_ID_PATTERN
220
+ raise_invalid_entry("projects", "id format is invalid")
221
+ end
222
+
223
+ path = required_string(entry, "projects", "path")
224
+ unless absolute_path?(path)
225
+ raise_invalid_entry("projects", "path must be an absolute path")
226
+ end
227
+
228
+ remote = optional_string(entry, "projects", "remote")
229
+ label = optional_string(entry, "projects", "label")
230
+
231
+ {"id" => id, "path" => path, "remote" => remote, "label" => label}
232
+ end.tap do |projects|
233
+ duplicates_of(projects.map { |project| project["id"] }) do |_id|
234
+ raise_invalid_entry("projects", "id is declared more than once")
235
+ end
236
+ duplicates_of(projects.map { |project| project["path"] }) do |_path|
237
+ raise_invalid_entry("projects", "path is declared more than once")
238
+ end
239
+ end
240
+ end
241
+
242
+ def normalize_grants(raw, project_ids)
243
+ grants = raw.nil? ? {} : raw
244
+ unless grants.is_a?(Hash)
245
+ raise_invalid "grants must be a mapping of principal to project ids"
246
+ end
247
+
248
+ # Reject duplicates after strip, like the other duplicate
249
+ # checks (review r1): "admin" and " admin " would otherwise
250
+ # silently last-wins.
251
+ duplicates_of(grants.keys.filter_map { |key| key.strip if key.is_a?(String) }) do |_principal|
252
+ raise_invalid "grants principal name is declared more than once"
253
+ end
254
+
255
+ grants.to_h do |principal, projects|
256
+ unless principal.is_a?(String) && !principal.strip.empty?
257
+ raise_invalid "grants principal name must be a non-empty string"
258
+ end
259
+ unless projects.is_a?(Array) && !projects.empty? &&
260
+ projects.all? { |project| project.is_a?(String) && !project.strip.empty? }
261
+ raise_invalid "grants for principal '#{principal.strip}' must be a non-empty " \
262
+ "list of project ids"
263
+ end
264
+ duplicates_of(projects.map(&:strip)) do |_project|
265
+ raise_invalid "grants for principal '#{principal.strip}' list a project more than once"
266
+ end
267
+ unknown = projects.map(&:strip) - project_ids
268
+ unless unknown.empty?
269
+ raise_invalid "grants for principal '#{principal.strip}' reference project " \
270
+ "'#{unknown.sort.first}' which the manifest does not declare"
271
+ end
272
+
273
+ [principal.strip, projects.map(&:strip).uniq]
274
+ end
275
+ end
276
+
277
+ # Selected communication/HITL adapters (task 8xg.t.sbz.7.2):
278
+ # each declares an id, a kind, an optional local command that
279
+ # provides the adapter (probed for availability by verify) and
280
+ # an optional free-form configuration mapping. Real network
281
+ # traffic stays out of scope (L9) — this is the declaration
282
+ # surface install validates and verify probes.
283
+ def normalize_adapters(raw)
284
+ section(raw, "adapters").map do |entry|
285
+ id = required_string(entry, "adapters", "id")
286
+ unless id =~ ROLE_ID_PATTERN
287
+ raise_invalid_entry("adapters", "id format is invalid")
288
+ end
289
+
290
+ kind = required_string(entry, "adapters", "kind")
291
+ unless ADAPTER_KINDS.include?(kind)
292
+ raise_invalid_entry("adapters",
293
+ "kind must be one of: #{ADAPTER_KINDS.join(', ')}")
294
+ end
295
+
296
+ command = optional_string(entry, "adapters", "command")
297
+
298
+ config = entry["config"].nil? ? {} : entry["config"]
299
+ unless config.is_a?(Hash)
300
+ raise_invalid_entry("adapters", "config must be a mapping when present")
301
+ end
302
+
303
+ {"id" => id, "kind" => kind, "command" => command, "config" => config}
304
+ end.tap do |adapters|
305
+ duplicates_of(adapters.map { |adapter| adapter["id"] }) do |_id|
306
+ raise_invalid_entry("adapters", "id is declared more than once")
307
+ end
308
+ end
309
+ end
310
+
311
+ # Required tools (task 8xg.t.sbz.7.4): names the lab needs on
312
+ # PATH for the target accounts; verify probes each one's
313
+ # presence (no execution, no network).
314
+ def normalize_tools(raw)
315
+ tools = raw.nil? ? [] : raw
316
+ unless tools.is_a?(Array) && tools.all? { |tool| tool.is_a?(String) && !tool.strip.empty? }
317
+ raise_invalid "tools must be a list of non-empty strings"
318
+ end
319
+
320
+ duplicates_of(tools.map(&:strip)) do |_tool|
321
+ raise_invalid "tools list a tool more than once"
322
+ end
323
+ tools.map(&:strip).uniq
324
+ end
325
+
326
+ # Target lab runtime and required gem set (gad.2 gap #1, owner
327
+ # spec 8xg.t.sbz.7): a sibling of `tools` declaring WHERE the
328
+ # lab's gems must live (`gem_home`, converged as GEM_HOME/
329
+ # GEM_PATH) and WHICH gems at WHICH exact versions. Absent =
330
+ # no runtime declared, behavior unchanged. Names must be real
331
+ # gem names; versions must parse as exact Gem versions (the
332
+ # same parser the runtime uses) — ranges and bare names are
333
+ # rejected so convergence is always exact-version.
334
+ def normalize_gemset(raw)
335
+ return {"gem_home" => nil, "gems" => []} if raw.nil?
336
+ unless raw.is_a?(Hash)
337
+ raise_invalid "gemset must be a mapping"
338
+ end
339
+
340
+ unknown = raw.keys - GEMSET_KEYS
341
+ unless unknown.empty?
342
+ raise_invalid "gemset has unknown key '#{unknown.sort.first}'"
343
+ end
344
+
345
+ gem_home = raw["gem_home"]
346
+ unless gem_home.is_a?(String) && !gem_home.strip.empty? && absolute_path?(gem_home.strip)
347
+ raise_invalid "gemset gem_home is required and must be an absolute path"
348
+ end
349
+
350
+ {"gem_home" => gem_home.strip, "gems" => normalize_gems(raw["gems"])}
351
+ end
352
+
353
+ def normalize_gems(raw)
354
+ gems = raw.nil? ? [] : raw
355
+ unless gems.is_a?(Array)
356
+ raise_invalid "gemset gems must be a list"
357
+ end
358
+ gems.each_with_index do |entry, index|
359
+ unless entry.is_a?(Hash)
360
+ raise_invalid "gemset gems entry ##{index} must be a mapping"
361
+ end
362
+ unknown = entry.keys - GEM_KEYS
363
+ unless unknown.empty?
364
+ raise_invalid "gemset gems entry ##{index} has unknown key '#{unknown.sort.first}'"
365
+ end
366
+ end
367
+
368
+ gems.map do |entry|
369
+ name = entry["name"]
370
+ unless name.is_a?(String) && name.strip =~ GEM_NAME_PATTERN
371
+ raise_invalid_entry("gemset gems", "name is required and must be a gem name")
372
+ end
373
+ version = entry["version"]
374
+ unless version.is_a?(String) && !version.strip.empty?
375
+ raise_invalid_entry("gemset gems", "version is required and must be an exact version")
376
+ end
377
+ begin
378
+ Gem::Version.new(version.strip)
379
+ rescue ArgumentError
380
+ raise_invalid_entry("gemset gems", "version must be an exact gem version")
381
+ end
382
+
383
+ {"name" => name.strip, "version" => version.strip}
384
+ end.tap do |gems|
385
+ duplicates_of(gems.map { |gem| gem["name"] }) do |_name|
386
+ raise_invalid_entry("gemset gems", "name is declared more than once")
387
+ end
388
+ end
389
+ end
390
+
391
+ def optional_string(entry, section_name, field)
392
+ value = entry[field]
393
+ if !value.nil? && (!value.is_a?(String) || value.strip.empty?)
394
+ raise_invalid_entry(section_name, "#{field} must be a non-empty string when present")
395
+ end
396
+ value.nil? ? nil : value.strip
397
+ end
398
+
399
+ def required_string(entry, section_name, field)
400
+ value = entry[field]
401
+ unless value.is_a?(String) && !value.strip.empty?
402
+ raise_invalid_entry(section_name, "#{field} is required and must be a non-empty string")
403
+ end
404
+ value
405
+ end
406
+
407
+ def normalize_mode(raw)
408
+ mode = raw.nil? ? DEFAULT_MODE : raw
409
+ unless mode.is_a?(String) && mode.match?(/\A[0-7]{3,4}\z/)
410
+ raise_invalid_entry("directories", "mode must be an octal string like \"0750\"")
411
+ end
412
+
413
+ format("%04o", mode.to_i(8))
414
+ end
415
+
416
+ def absolute_path?(path)
417
+ path.start_with?("/")
418
+ end
419
+
420
+ def instruction_source?(instructions)
421
+ INSTRUCTION_PROTOCOLS.any? { |protocol| instructions.start_with?(protocol) } ||
422
+ absolute_path?(instructions)
423
+ end
424
+
425
+ def default_home(username, platform)
426
+ prefix = platform == "darwin" ? "/Users" : "/home"
427
+ File.join(prefix, username)
428
+ end
429
+
430
+ def duplicates_of(values)
431
+ seen = {}
432
+ values.each do |value|
433
+ if seen[value]
434
+ yield value
435
+ else
436
+ seen[value] = true
437
+ end
438
+ end
439
+ end
440
+ end
441
+ end
442
+ end
443
+ end
444
+ 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