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,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require "open3"
5
+ require "fileutils"
6
+
7
+ module Ace
8
+ module Lab
9
+ module Molecules
10
+ # Idempotently converges the install manifest's declared gem set
11
+ # into the declared target runtime (GEM_HOME; gad.2 gap #1, owner
12
+ # spec 8xg.t.sbz.7): a gem whose exact declared version is already
13
+ # present is a no-op (`exists`), a missing gem is installed
14
+ # (`installed`), a gem present at a different version is updated
15
+ # (the declared version installed alongside — RubyGems keeps
16
+ # versions side by side; verify judges the declared set). Probes
17
+ # and installs run the `gem` executable with GEM_HOME/GEM_PATH
18
+ # pinned to the target runtime through the SAME argv runner seam
19
+ # the other prepare molecules use (POSIX `env`, never a shell
20
+ # string, ADR-031) — so the lab runtime stays isolated from the
21
+ # installer's own gems and the runner stays injectable for
22
+ # dry-run and tests. The runtime directory itself is created only
23
+ # with administrator privileges (the same seam
24
+ # AccountProvisioner gates creation with) and a symlink or
25
+ # non-directory at the declared path is refused (the
26
+ # DirectoryProvisioner takeover posture). Every failure is a
27
+ # classified per-gem item with an actionable message; command
28
+ # failures surface only the exit status and the first stderr line.
29
+ class GemsetProvisioner
30
+ attr_reader :runner, :privileged, :dry_run
31
+
32
+ def initialize(runner: nil, privileged: nil, dry_run: false)
33
+ @runner = runner || method(:run_argv)
34
+ @privileged = privileged || -> { Process.euid.zero? }
35
+ @dry_run = dry_run
36
+ end
37
+
38
+ # @param runtime [Hash] normalized manifest gemset section
39
+ # ({"gem_home" => path, "gems" => [{"name" =>, "version" =>}]})
40
+ # @return [Array<Hash>] one report per declared gem:
41
+ # {name:, version:, gem_home:, status:, message:}
42
+ def call(runtime:)
43
+ gems = runtime["gems"]
44
+ return [] if gems.empty?
45
+
46
+ gem_home = runtime["gem_home"]
47
+ runtime_failure = ensure_runtime(gem_home)
48
+ gems.map do |gem|
49
+ if runtime_failure
50
+ base(gem, gem_home).merge("status" => "failed", "message" => runtime_failure)
51
+ else
52
+ converge(base(gem, gem_home), gem, gem_home)
53
+ end
54
+ end
55
+ end
56
+
57
+ # Public READ-ONLY probe (L8.5): the local versions of `name`
58
+ # inside the target runtime — the exact probe convergence runs,
59
+ # exposed so the readiness verifier reuses the same logic
60
+ # instead of reimplementing it (the two can never drift).
61
+ # @param name [String] gem name
62
+ # @param gem_home [String] target runtime (GEM_HOME)
63
+ # @return [Array(Symbol, Object)] [:ok, versions],
64
+ # [:unknown, message] (no `gem` executable — offline posture)
65
+ # or [:failed, message]
66
+ def runtime_versions(name:, gem_home:)
67
+ result = @runner.call(runtime_argv(gem_home,
68
+ ["gem", "list", name, "--exact", "--local"]))
69
+ return [:unknown, "gem not found in PATH; install RubyGems and rerun ace-lab prepare"] if result["status"] == "enoent"
70
+ return [:failed, command_failure("gem list", result)] unless result["status"] == "ok"
71
+
72
+ versions = parse_versions(result["out"], name)
73
+ if versions.nil?
74
+ return [:failed, "could not determine installed versions of gem '#{name}' from " \
75
+ "'gem list' output; rerun ace-lab prepare"]
76
+ end
77
+
78
+ [:ok, versions]
79
+ end
80
+
81
+ private
82
+
83
+ def base(gem, gem_home)
84
+ {"name" => gem["name"], "version" => gem["version"], "gem_home" => gem_home}
85
+ end
86
+
87
+ # One runtime failure fails every declared gem as a dependency
88
+ # (the keys-need-accounts posture): convergence is never
89
+ # attempted against a runtime that did not prepare.
90
+ def ensure_runtime(gem_home)
91
+ stat = lstat(gem_home)
92
+ return "gem runtime '#{gem_home}' is a symbolic link; refusing" if stat&.symlink?
93
+ return "gem runtime '#{gem_home}' exists but is not a directory; refusing" if stat && !stat.directory?
94
+ return nil if stat
95
+
96
+ unless @privileged.call
97
+ return "creating gem runtime '#{gem_home}' requires administrator privileges; " \
98
+ "rerun ace-lab prepare with sudo or create the directory manually"
99
+ end
100
+ return nil if @dry_run # prediction: creation would succeed
101
+
102
+ FileUtils.mkdir_p(gem_home)
103
+ nil
104
+ rescue Errno::EPERM, Errno::EACCES
105
+ "insufficient permissions while preparing gem runtime '#{gem_home}'; " \
106
+ "rerun ace-lab prepare with sudo or adjust ownership manually"
107
+ rescue SystemCallError => e
108
+ "gem runtime '#{gem_home}' could not be created (#{e.class.name}); rerun ace-lab prepare"
109
+ end
110
+
111
+ def converge(base, gem, gem_home)
112
+ kind, versions_or_message = runtime_versions(name: gem["name"], gem_home: gem_home)
113
+ # Prepare's fail-closed posture: an unprobeable runtime (no
114
+ # `gem` executable — the :unknown probe kind) fails the item in
115
+ # wet AND dry runs, exactly as the wet run would fail; verify
116
+ # maps the same probe kind to its offline `unknown` posture.
117
+ return base.merge("status" => "failed", "message" => versions_or_message) if kind != :ok
118
+
119
+ versions = versions_or_message
120
+ return base.merge("status" => "exists", "message" => nil) if versions.include?(gem["version"])
121
+
122
+ status = versions.empty? ? "install" : "update"
123
+ return base.merge("status" => "would_#{status}", "message" => nil) if @dry_run
124
+
125
+ failure = install(gem, gem_home)
126
+ return base.merge("status" => "failed", "message" => failure) if failure
127
+
128
+ if status == "install"
129
+ base.merge("status" => "installed", "message" => nil)
130
+ else
131
+ base.merge("status" => "updated", "message" => nil)
132
+ end
133
+ end
134
+
135
+ # Installs the EXACT declared version into the runtime. Sources
136
+ # are the gem command's own defaults — fetching from the
137
+ # configured gem source is provisioning network access, the same
138
+ # envelope as cloning a declared repository remote.
139
+ # @return [String, nil] the classified failure, nil on success
140
+ def install(gem, gem_home)
141
+ result = @runner.call(runtime_argv(gem_home,
142
+ ["gem", "install", gem["name"], "--version", gem["version"], "--no-document"]))
143
+ return nil if result["status"] == "ok"
144
+
145
+ command_failure("gem install", result)
146
+ end
147
+
148
+ # Environment variables that would drag the INVOKING process's
149
+ # bundler context into the gem subprocess: RUBYOPT carries
150
+ # `-rbundler/setup` under `bundle exec ace-lab` (the documented
151
+ # invocation), RubyGems auto-requires ENV["BUNDLER_SETUP"]
152
+ # (rubygems.rb), and the BUNDLE_*/BUNDLER_* vars re-bind bundler
153
+ # when it loads — any of which overrides the pinned paths with
154
+ # the invoking bundle's load paths. The lab runtime must stay
155
+ # isolated from the installer's gems, its bundle included.
156
+ BUNDLER_ENV_KEYS = %w[RUBYOPT RUBYLIB BUNDLER_SETUP BUNDLE_BIN_PATH
157
+ BUNDLE_GEMFILE BUNDLER_VERSION BUNDLE_BUNDLER_VERSION].freeze
158
+
159
+ private_constant :BUNDLER_ENV_KEYS
160
+
161
+ # GEM_HOME/GEM_PATH are pinned through the POSIX `env` command so
162
+ # the argv-array runner seam stays unchanged and the subprocess
163
+ # sees ONLY the target runtime.
164
+ def runtime_argv(gem_home, argv)
165
+ ["env"] +
166
+ BUNDLER_ENV_KEYS.flat_map { |key| ["-u", key] } +
167
+ ["GEM_HOME=#{gem_home}", "GEM_PATH=#{gem_home}"] + argv
168
+ end
169
+
170
+ # `gem list <name> --exact --local` prints one `name (v1, v2)`
171
+ # line when the name is present in the runtime and nothing when
172
+ # it is absent. A present-but-unparseable line is a failed probe
173
+ # (nil, fail closed), never a guessed empty list.
174
+ def parse_versions(out, name)
175
+ line = out.lines.find { |candidate| candidate.start_with?("#{name} ") }
176
+ return [] if line.nil?
177
+
178
+ match = line.chomp.match(/\A#{Regexp.escape(name)} \((.*)\)\z/)
179
+ return nil if match.nil?
180
+
181
+ match[1].split(",").map(&:strip)
182
+ end
183
+
184
+ # Classified one-line command failure: status plus the first
185
+ # stderr line only — never command output bodies.
186
+ def command_failure(command, result)
187
+ case result["status"]
188
+ when "enoent"
189
+ return "#{command} failed: gem not found in PATH; install RubyGems and rerun ace-lab prepare"
190
+ when "exec_failed"
191
+ return "#{command} failed: could not be executed (#{result["err"]}); rerun ace-lab prepare"
192
+ end
193
+
194
+ message = "#{command} failed (#{exit_detail(result["status"])})"
195
+ line = result["err"].to_s.lines.map(&:strip).reject(&:empty?).first
196
+ message = "#{message}: #{line}" if line
197
+ "#{message}; rerun ace-lab prepare (with sudo when the runtime is not writable)"
198
+ end
199
+
200
+ def exit_detail(status)
201
+ status.is_a?(Integer) ? "exit #{status}" : status.to_s
202
+ end
203
+
204
+ def lstat(path)
205
+ File.lstat(path)
206
+ rescue Errno::ENOENT, Errno::ENOTDIR
207
+ nil
208
+ end
209
+
210
+ def run_argv(argv)
211
+ out, err, status = Open3.capture3(*argv)
212
+ {"status" => status.success? ? "ok" : (status.exitstatus || "signal #{status.termsig}"),
213
+ "out" => out, "err" => err}
214
+ rescue Errno::ENOENT
215
+ {"status" => "enoent", "out" => "", "err" => ""}
216
+ rescue SystemCallError => e
217
+ {"status" => "exec_failed", "out" => "", "err" => e.class.name}
218
+ end
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "grant_resolver"
5
+ require "yaml"
6
+ require "date"
7
+ require "fileutils"
8
+
9
+ module Ace
10
+ module Lab
11
+ module Molecules
12
+ # Writes the trusted-tier principal grants (task 8xg.t.sbz.7.2,
13
+ # L8.3): the `principals` section of the deployment-owned
14
+ # authorization document at the fixed AUTHORIZATION_PATH — the only
15
+ # place GrantResolver reads grants from, so `ace-lab projects` /
16
+ # `ace-overseer projects` see the installed projects with no
17
+ # read-side change.
18
+ #
19
+ # The installer owns this configuration, but never overwrites a
20
+ # foreign lab: a document whose principals grant projects outside
21
+ # the manifest's declared set is foreign (`foreign_grants?`, used
22
+ # by the installer as a before-any-mutation guard), and a manifest
23
+ # that would REMOVE an existing grant is refused — grants only
24
+ # grow. All other sections of the document are preserved
25
+ # untouched. Writes are atomic (temp file + rename) with mode
26
+ # 0644, mirroring the registry's write discipline.
27
+ class GrantProvisioner
28
+ STATUS_DRY = {"created" => "would_create", "updated" => "would_update"}.freeze
29
+
30
+ # @param resolver [Object] trusted-read seam (defaults to
31
+ # GrantResolver; injected in tests)
32
+ # @param authorization_path [String] trusted grants location
33
+ def initialize(resolver: GrantResolver, authorization_path: Ace::Lab.authorization_path)
34
+ @resolver = resolver
35
+ @authorization_path = authorization_path
36
+ end
37
+
38
+ # @return [String] the trusted grants location this molecule
39
+ # converges — install threads it into the final Verify stage.
40
+ attr_reader :authorization_path
41
+
42
+ # Foreign-lab detection: an existing trusted document that grants
43
+ # projects outside the manifest's declared set belongs to a
44
+ # different lab and must never be overwritten by this install.
45
+ #
46
+ # @param declared_projects [Array<String>] project ids declared
47
+ # by the install manifest
48
+ # @return [String, nil] conflict message, nil when compatible
49
+ # @raise [Ace::Lab::InvalidConfigurationError] the trusted
50
+ # document exists but fails verification or parsing — a lab
51
+ # the installer cannot reason about is treated as foreign
52
+ def foreign_grants?(declared_projects)
53
+ document = read_document
54
+ return nil if document.nil?
55
+
56
+ (document["principals"] || {}).each do |principal, policy|
57
+ foreign = granted_projects(policy) - declared_projects
58
+ next if foreign.empty?
59
+
60
+ return "trusted authorization file #{@authorization_path} already grants " \
61
+ "project(s) #{foreign.sort.join(', ')} to principal '#{principal}' — this lab's " \
62
+ "manifest does not declare them; refusing to overwrite a foreign lab"
63
+ end
64
+ nil
65
+ end
66
+
67
+ # Idempotently converge the principal grants.
68
+ #
69
+ # @param desired [Hash] principal => project ids (normalized
70
+ # manifest grants)
71
+ # @param declared_projects [Array<String>] manifest project ids
72
+ # @param dry_run [Boolean]
73
+ # @return [Hash] {path:, status:, principals:, message:} where
74
+ # status is created / updated / exists / would_create /
75
+ # would_update / failed and principals carries one entry per
76
+ # desired principal
77
+ def call(desired:, declared_projects:, dry_run: false)
78
+ # Exposed on every report/failure so the grants section of the
79
+ # install JSON has one uniform schema (the top-level report
80
+ # also carries it; consumers may read either).
81
+ @dry_run = dry_run
82
+ document = begin
83
+ read_document
84
+ rescue Ace::Lab::InvalidConfigurationError => e
85
+ return failure(e.message)
86
+ end
87
+ existing = document ? (document["principals"] || {}) : {}
88
+
89
+ removal = desired.filter_map do |principal, projects|
90
+ granted = existing.key?(principal) ? granted_projects(existing[principal]) : []
91
+ next if (granted - projects).empty?
92
+
93
+ "principal '#{principal}' is already granted project(s) " \
94
+ "#{(granted - projects).sort.join(', ')} which this manifest does not grant; " \
95
+ "refusing to remove trusted grants — edit #{@authorization_path} manually"
96
+ end
97
+ return failure(removal.join("; ")) unless removal.empty?
98
+
99
+ merged, statuses = merge_principals(existing, desired)
100
+
101
+ # An empty grants manifest over an absent document is a no-op
102
+ # (review r1): nothing is granted, so no empty authorization
103
+ # document is created.
104
+ if document.nil? && merged.empty?
105
+ return report("skipped", {}, "no grants declared and no trusted document present; " \
106
+ "nothing to write", existing, desired)
107
+ end
108
+
109
+ aggregate = if statuses.values.include?("created") && existing.empty?
110
+ "created"
111
+ elsif statuses.values.any? { |status| %w[created updated].include?(status) }
112
+ "updated"
113
+ else
114
+ "exists"
115
+ end
116
+
117
+ # Dry-run parity: per-principal predictions mirror the wet
118
+ # statuses (an already-converged principal stays `exists`).
119
+ statuses = statuses.transform_values { |status| STATUS_DRY.fetch(status, status) } if dry_run
120
+
121
+ return report(STATUS_DRY.fetch(aggregate, aggregate), statuses, nil, existing,
122
+ desired) if dry_run
123
+
124
+ # Converged: the merged principals deep-equal the parsed
125
+ # existing mapping, so the write is skipped — the trusted
126
+ # document (admin-authored comments included) stays
127
+ # byte-identical instead of being round-tripped through YAML
128
+ # (review r1). An absent document is always created.
129
+ if document && merged == existing
130
+ return report(aggregate, statuses, nil, existing, desired)
131
+ end
132
+
133
+ write_failure = persist(document, merged)
134
+ return write_failure if write_failure
135
+
136
+ report(aggregate, statuses, nil, existing, desired)
137
+ rescue Ace::Lab::InvalidConfigurationError => e
138
+ failure(e.message)
139
+ end
140
+
141
+ private
142
+
143
+ # Per-principal convergence: a principal with no grants yet is
144
+ # `created`, one gaining projects is `updated`, an already
145
+ # fully-granted one is `exists`. Principals absent from the
146
+ # manifest are carried over unchanged (never removed).
147
+ def merge_principals(existing, desired)
148
+ merged = existing.each_with_object({}) do |(principal, policy), acc|
149
+ acc[principal] = {"projects" => granted_projects(policy)}
150
+ end
151
+ statuses = desired.each_with_object({}) do |(principal, projects), acc|
152
+ granted = merged.key?(principal) ? merged[principal]["projects"] : []
153
+ acc[principal] = if granted.empty?
154
+ "created"
155
+ elsif (projects - granted).empty?
156
+ "exists"
157
+ else
158
+ "updated"
159
+ end
160
+ merged[principal] = {"projects" => (granted | projects).sort}
161
+ end
162
+ [merged, statuses]
163
+ end
164
+
165
+ # Atomic write preserving every other section of the document.
166
+ # Returns nil on success, a failed report otherwise.
167
+ def persist(document, merged)
168
+ updated = document ? deep_copy(document) : {}
169
+ updated["principals"] = merged
170
+
171
+ dir = File.dirname(@authorization_path)
172
+ tmp = File.join(dir, ".authorization.yml.tmp.#{Process.pid}")
173
+ begin
174
+ FileUtils.mkdir_p(dir)
175
+ File.write(tmp, YAML.dump(updated))
176
+ File.chmod(0o644, tmp)
177
+ File.rename(tmp, @authorization_path)
178
+ rescue SystemCallError, IOError => e
179
+ File.delete(tmp) if File.exist?(tmp)
180
+ return failure("writing #{@authorization_path} failed: #{e.message}")
181
+ end
182
+ nil
183
+ end
184
+
185
+ def report(status, statuses, message, existing, desired)
186
+ {
187
+ "path" => @authorization_path,
188
+ "status" => status,
189
+ "principals" => statuses,
190
+ "message" => message,
191
+ # Always present (empty when nothing is carried over) so
192
+ # JSON consumers see a stable schema (review r1).
193
+ "kept" => existing.keys - desired.keys,
194
+ # Same key set as the gated grants failure in the install
195
+ # report — one uniform grants-section schema (review L8.4).
196
+ "dry_run" => @dry_run ? true : false
197
+ }
198
+ end
199
+
200
+ def failure(message)
201
+ # `kept` is present (empty) like in every report so JSON
202
+ # consumers see one stable schema — a failure merges
203
+ # nothing, so nothing is carried over (resume/verification
204
+ # readers, tasks 8xg.t.sbz.7.3/.7.4).
205
+ {"path" => @authorization_path, "status" => "failed", "principals" => {},
206
+ "message" => message, "kept" => [], "dry_run" => @dry_run ? true : false}
207
+ end
208
+
209
+ # Read and parse the trusted document through the same
210
+ # fail-closed verification every grant reader uses.
211
+ # @return [Hash, nil] parsed document, nil when absent
212
+ # @raise [Ace::Lab::InvalidConfigurationError]
213
+ def read_document
214
+ content = @resolver.read_verified(@authorization_path)
215
+ return nil if content.nil?
216
+
217
+ document = begin
218
+ YAML.safe_load(content, permitted_classes: [Date], aliases: true)
219
+ rescue Psych::Exception
220
+ raise Ace::Lab::InvalidConfigurationError,
221
+ "#{@authorization_path} could not be parsed as YAML; refusing to overwrite"
222
+ end
223
+ unless document.is_a?(Hash)
224
+ raise Ace::Lab::InvalidConfigurationError,
225
+ "#{@authorization_path} must contain a YAML mapping; refusing to overwrite"
226
+ end
227
+ principals = document["principals"]
228
+ return document if principals.nil?
229
+
230
+ # Per-principal validation identical to the read side
231
+ # (GrantResolver.parse_grants): a malformed policy is refused
232
+ # with the classified error instead of silently rewritten,
233
+ # and principal names are stripped so a whitespace-padded
234
+ # existing principal cannot coexist with its stripped twin
235
+ # after a write (review r1).
236
+ document.merge("principals" =>
237
+ GrantResolver.validate_principals(principals, @authorization_path))
238
+ end
239
+
240
+ def granted_projects(policy)
241
+ policy.is_a?(Hash) && policy["projects"].is_a?(Array) ? policy["projects"] : []
242
+ end
243
+
244
+ def deep_copy(document)
245
+ YAML.safe_load(YAML.dump(document), permitted_classes: [Date], aliases: true)
246
+ end
247
+ end
248
+ end
249
+ end
250
+ end
@@ -113,6 +113,39 @@ module Ace
113
113
  end
114
114
  end
115
115
 
116
+ # Shared per-principal validation so the read side
117
+ # (`parse_grants`) and the installer's writer
118
+ # (`Molecules::GrantProvisioner`) cannot drift (review r1):
119
+ # principal names are stripped and each policy must be a
120
+ # mapping with a `projects` array of non-empty strings.
121
+ # Messages are value-free.
122
+ # @param principals [Object] parsed principals mapping
123
+ # @param path [String] trusted file location for messages
124
+ # @return [Hash] principal => {"projects" => stripped, uniq ids}
125
+ # @raise [Ace::Lab::InvalidConfigurationError]
126
+ def validate_principals(principals, path)
127
+ unless principals.is_a?(Hash)
128
+ raise Ace::Lab::InvalidConfigurationError,
129
+ "invalid lab configuration: trusted authorization file #{path} principals must be a mapping"
130
+ end
131
+
132
+ principals.map do |identity, policy|
133
+ unless identity.is_a?(String) && !identity.strip.empty?
134
+ raise Ace::Lab::InvalidConfigurationError,
135
+ "invalid lab configuration: trusted authorization file #{path} principal name " \
136
+ "must be a non-empty string"
137
+ end
138
+ unless policy.is_a?(Hash) && policy["projects"].is_a?(Array) &&
139
+ policy["projects"].all? { |project| project.is_a?(String) && !project.strip.empty? }
140
+ raise Ace::Lab::InvalidConfigurationError,
141
+ "invalid lab configuration: trusted authorization file #{path} principal projects " \
142
+ "must be an array of non-empty strings"
143
+ end
144
+
145
+ [identity.strip, {"projects" => policy["projects"].map(&:strip).uniq}]
146
+ end.to_h
147
+ end
148
+
116
149
  private
117
150
 
118
151
  def verify_error(path)
@@ -183,29 +216,7 @@ module Ace
183
216
  "invalid lab configuration: trusted authorization file #{path} must contain a YAML mapping"
184
217
  end
185
218
 
186
- principals = document["principals"] || {}
187
- unless principals.is_a?(Hash)
188
- raise Ace::Lab::InvalidConfigurationError,
189
- "invalid lab configuration: trusted authorization file #{path} principals must be a mapping"
190
- end
191
-
192
- validated = principals.map do |identity, policy|
193
- unless identity.is_a?(String) && !identity.strip.empty?
194
- raise Ace::Lab::InvalidConfigurationError,
195
- "invalid lab configuration: trusted authorization file #{path} principal name " \
196
- "must be a non-empty string"
197
- end
198
- unless policy.is_a?(Hash) && policy["projects"].is_a?(Array) &&
199
- policy["projects"].all? { |project| project.is_a?(String) && !project.strip.empty? }
200
- raise Ace::Lab::InvalidConfigurationError,
201
- "invalid lab configuration: trusted authorization file #{path} principal projects " \
202
- "must be an array of non-empty strings"
203
- end
204
-
205
- [identity.strip, {"projects" => policy["projects"].map(&:strip).uniq}]
206
- end
207
-
208
- {"principals" => validated.to_h}
219
+ {"principals" => validate_principals(document["principals"] || {}, path)}
209
220
  rescue Psych::Exception
210
221
  raise Ace::Lab::InvalidConfigurationError,
211
222
  "invalid lab configuration: trusted authorization file #{path} could not be parsed as YAML"