ace-lab 0.8.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +363 -92
  3. data/README.md +66 -100
  4. data/docs/usage.md +529 -404
  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 -13
  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 +49 -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 -102
  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 -45
  40. data/lib/ace/lab/cli/commands/service.rb +0 -240
  41. data/lib/ace/lab/models/service_receipt.rb +0 -18
  42. data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
  43. data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
  44. data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
  45. data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
  46. data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
  47. data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -139
  48. data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
  49. data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
  50. data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
  51. data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
  52. data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
  53. data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
  54. data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
  55. data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
  56. data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
  57. data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
  58. data/lib/ace/lab/molecules/service_executor.rb +0 -99
  59. data/lib/ace/lab/molecules/service_policy.rb +0 -289
  60. data/lib/ace/lab/organisms/authority_composition.rb +0 -87
  61. data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -149
  62. data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -123
  63. data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -688
  64. data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
  65. data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
  66. data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
  67. data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
  68. data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
  69. data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
  70. data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
  71. data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
  72. data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
  73. data/lib/ace/lab/organisms/service_request_service.rb +0 -249
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Lab
5
+ module Molecules
6
+ # Classifies the lab configuration namespace into exactly three
7
+ # distinguished states (task 8xg.t.sbz.6.0): a valid empty set, missing
8
+ # configuration, and a source error are never conflated.
9
+ #
10
+ # :defaults_only — no overlay exists (gem defaults only) ⇒ missing
11
+ # configuration; informational, exit-eligible success
12
+ # :configured — at least one overlay present and valid; carries
13
+ # topology counts (0/0/0 is a valid empty set)
14
+ # :invalid — any cascade document unparsable/non-mapping or a
15
+ # schema violation; carries the path-bearing message
16
+ #
17
+ # Also echoes runtime *configuration files* (project, user, ACE_RUNTIME
18
+ # env): presence + the configured runtime value. No runtime detection
19
+ # and no ace-runtime dependency — live runtime availability is
20
+ # ace-herdr status's dimension.
21
+ class ConfigStateClassifier
22
+ RUNTIME_PATTERNS = %w[runtime/config.yml runtime/config.yaml].freeze
23
+
24
+ class << self
25
+ # @param documents [Array<Hash>, nil] cascade_documents output;
26
+ # nil means the cascade itself raised before documents existed
27
+ # @param cascade_error [String, nil] path-bearing message from the
28
+ # InvalidConfigurationError raised while reading the cascade
29
+ # @param root [String] project root for runtime config reads
30
+ # @return [Hash] classified configuration section
31
+ def classify(documents:, cascade_error: nil, root: Dir.pwd)
32
+ return invalid_config(cascade_error) if cascade_error
33
+
34
+ documents ||= []
35
+ # No cascade documents at all (not even gem defaults): nothing
36
+ # is broken, the lab is simply unconfigured.
37
+ return missing_config if documents.empty?
38
+
39
+ overlays = documents.reject { |document| document[:defaults] }
40
+ user_overlay_dir = File.join(Dir.home, ".ace")
41
+ project = overlays.find { |document| !document[:path].start_with?(user_overlay_dir) }
42
+ user = overlays.find { |document| document[:path].start_with?(user_overlay_dir) }
43
+
44
+ merged = deep_merge_documents(documents)
45
+ begin
46
+ topology = Atoms::TopologySchema.normalize!(merged).fetch("topology", {})
47
+ {
48
+ state: overlays.empty? ? :defaults_only : :configured,
49
+ overlays: {project: project&.dig(:path), user: user&.dig(:path)},
50
+ source_path: overlays.first&.dig(:path),
51
+ error: nil,
52
+ topology: {
53
+ projects: topology.fetch("projects", []).length,
54
+ agents: topology.fetch("agents", []).length,
55
+ services: topology.fetch("services", []).length
56
+ }
57
+ }
58
+ rescue Ace::Lab::InvalidConfigurationError => e
59
+ invalid_config(e.message)
60
+ end
61
+ end
62
+
63
+ # Runtime configuration echo: files first (project, then user),
64
+ # then the ACE_RUNTIME environment variable. Presence and value
65
+ # only — never liveness.
66
+ #
67
+ # @param root [String] project root
68
+ # @return [Hash] runtime configuration section
69
+ def runtime_config(root: Dir.pwd)
70
+ candidates = [
71
+ {source: "project", paths: RUNTIME_PATTERNS.map { |pattern| File.join(root, ".ace", pattern) }},
72
+ {source: "user", paths: RUNTIME_PATTERNS.map { |pattern| File.join(Dir.home, ".ace", pattern) }}
73
+ ]
74
+ found = candidates.find do |candidate|
75
+ candidate[:paths].any? { |candidate_path| File.exist?(candidate_path) }
76
+ end
77
+ if found
78
+ path = found[:paths].find { |candidate_path| File.exist?(candidate_path) }
79
+ document = begin
80
+ require "yaml"
81
+ YAML.safe_load_file(path, permitted_classes: [Date], aliases: true)
82
+ rescue StandardError
83
+ nil
84
+ end
85
+ if document.is_a?(Hash)
86
+ return {state: :valid, source: found[:source], path: path, runtime: document["runtime"]}
87
+ end
88
+
89
+ return {state: :invalid, source: found[:source], path: path, runtime: nil,
90
+ error: "#{path} could not be parsed as a YAML mapping"}
91
+ end
92
+ return runtime_from_env if ENV["ACE_RUNTIME"] && !ENV["ACE_RUNTIME"].empty?
93
+
94
+ {state: :absent, source: nil, path: nil, runtime: nil}
95
+ end
96
+
97
+ private
98
+
99
+ def runtime_from_env
100
+ {state: :valid, source: "env", path: nil, runtime: ENV["ACE_RUNTIME"]}
101
+ end
102
+
103
+ def invalid_config(message)
104
+ {
105
+ state: :invalid,
106
+ overlays: {},
107
+ source_path: nil,
108
+ error: message,
109
+ topology: {projects: 0, agents: 0, services: 0}
110
+ }
111
+ end
112
+
113
+ def missing_config
114
+ {
115
+ state: :defaults_only,
116
+ overlays: {},
117
+ source_path: nil,
118
+ error: nil,
119
+ topology: {projects: 0, agents: 0, services: 0}
120
+ }
121
+ end
122
+
123
+ # Cascade merge mirroring the canonical ADR-22 resolver:
124
+ # cascade_documents lists documents highest-priority-first, so
125
+ # reduce in reverse (defaults first, nearest overlay last — later
126
+ # wins). Arrays replace (they are topology inventories, not
127
+ # additive sets).
128
+ def deep_merge_documents(documents)
129
+ documents.reverse.reduce({}) { |merged, document| deep_merge(merged, document[:document]) }
130
+ end
131
+
132
+ def deep_merge(base, overlay)
133
+ base.merge(overlay) do |_key, base_value, overlay_value|
134
+ if base_value.is_a?(Hash) && overlay_value.is_a?(Hash)
135
+ deep_merge(base_value, overlay_value)
136
+ else
137
+ overlay_value
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require "etc"
5
+ require "fileutils"
6
+
7
+ module Ace
8
+ module Lab
9
+ module Molecules
10
+ # Idempotently ensures a manifest-declared directory exists with the
11
+ # declared mode and ownership. Pre-existing directories are never
12
+ # silently taken over: a symlink at the path, a non-directory, or a
13
+ # directory owned by a different account than declared is refused
14
+ # with a resolve-manually message; only mode drift on an
15
+ # owner-matching directory is repaired. Permission failures
16
+ # (EPERM/EACCES) classify as actionable missing-permission reports.
17
+ class DirectoryProvisioner
18
+ attr_reader :account_lookup, :dry_run
19
+
20
+ # available_accounts [Enumerable<String>, nil] usernames the
21
+ # account phase will have (exists/created/would_create) — the
22
+ # orchestrator's already-computed set, threaded in so dry-run
23
+ # can predict the wet run's owner_unavailable failure for a
24
+ # directory whose owner account failed permanently (mirroring
25
+ # keys/roles dependency prediction). nil = predict from live
26
+ # passwd only (standalone use).
27
+ def initialize(account_lookup: nil, dry_run: false, available_accounts: nil)
28
+ @account_lookup = account_lookup || method(:lookup_passwd)
29
+ @dry_run = dry_run
30
+ @available_accounts = available_accounts
31
+ end
32
+
33
+ # @param directory [Hash] normalized manifest directory entry
34
+ # @return [Hash] {path:, owner:, mode:, status:, message:}
35
+ def call(directory:)
36
+ path = directory["path"]
37
+ mode = directory["mode"].to_i(8)
38
+ stat = begin
39
+ File.lstat(path)
40
+ rescue Errno::ENOENT
41
+ nil
42
+ end
43
+
44
+ if stat.nil?
45
+ create_missing(path, directory, mode)
46
+ elsif stat.symlink?
47
+ refuse_symlink(path, directory)
48
+ elsif !stat.directory?
49
+ failure(path, "path '#{path}' is occupied by a non-directory; refusing", directory)
50
+ else
51
+ ensure_existing(path, directory, stat, mode)
52
+ end
53
+ rescue Errno::EPERM, Errno::EACCES
54
+ failure(path,
55
+ "insufficient permissions while preparing '#{path}'; rerun ace-lab prepare with sudo " \
56
+ "or adjust ownership manually", directory)
57
+ rescue SystemCallError => e
58
+ failure(path, "path '#{path}' could not be prepared (#{e.class.name})", directory)
59
+ end
60
+
61
+ private
62
+
63
+ def base(path, directory)
64
+ {"path" => path, "owner" => directory["owner"], "mode" => directory["mode"]}
65
+ end
66
+
67
+ # Every failed entry carries the full {path, owner, mode} shape —
68
+ # JSON consumers must see one shape per section, including
69
+ # rescue-path failures.
70
+ def failure(path, message, directory)
71
+ base(path, directory).merge("status" => "failed", "message" => message)
72
+ end
73
+
74
+ def refuse_symlink(path, directory)
75
+ failure(path, "path '#{path}' is a symbolic link; refusing", directory)
76
+ end
77
+
78
+ # Ownership an existing directory must match: the declared owner
79
+ # account's uid, or the installer's euid when no owner is declared.
80
+ # Returns nil when the declared owner account is not resolvable on
81
+ # this machine (account phase not yet green).
82
+ def expected_owner_uid(directory)
83
+ return Process.euid unless directory["owner"]
84
+
85
+ entry = account_lookup.call(directory["owner"])
86
+ entry && entry.uid
87
+ end
88
+
89
+ def create_missing(path, directory, mode)
90
+ if dry_run
91
+ # A declared owner whose account phase failed permanently
92
+ # (not merely would_create) makes the wet run fail with
93
+ # owner_unavailable — the prediction must say so instead of
94
+ # would_create.
95
+ if directory["owner"] && @available_accounts &&
96
+ !@available_accounts.include?(directory["owner"])
97
+ return owner_unavailable(path, directory, "fix the account phase first")
98
+ end
99
+
100
+ return base(path, directory).merge("status" => "would_create", "message" => nil)
101
+ end
102
+
103
+ missing = chown_target(directory)
104
+ return owner_unavailable(path, directory) if directory["owner"] && missing.nil?
105
+
106
+ refusal = create_leaf(path, directory)
107
+ return refusal if refusal
108
+
109
+ FileUtils.chmod(mode, path)
110
+ chown_to_owner(path, directory, missing)
111
+ base(path, directory).merge("status" => "created", "message" => nil)
112
+ end
113
+
114
+ # Close the create-time TOCTOU window as far as stdlib allows:
115
+ # intermediate components are created per the documented policy
116
+ # (mkdir_p follows existing components — the manifest is
117
+ # root-owned and only the leaf carries declared values), but the
118
+ # leaf uses Dir.mkdir, whose EEXIST on a planted symlink never
119
+ # follows it, and the leaf is re-lstat'd before any chmod/chown:
120
+ # a leaf that is not a real directory this process just created
121
+ # is refused instead of mutated. The window between this
122
+ # re-lstat and the chmod/chown that follow is a documented
123
+ # accepted residual (usage.md).
124
+ def create_leaf(path, directory)
125
+ FileUtils.mkdir_p(File.dirname(path))
126
+ Dir.mkdir(path)
127
+ post = lstat(path)
128
+ return nil if post && post.directory? && post.uid == Process.euid
129
+
130
+ failure(path, "path '#{path}' changed while preparing; refusing — rerun ace-lab prepare",
131
+ directory)
132
+ rescue Errno::EEXIST
133
+ failure(path, "path '#{path}' appeared or was replaced while preparing; " \
134
+ "refusing — rerun ace-lab prepare", directory)
135
+ rescue Errno::ENOENT, Errno::ENOTDIR
136
+ failure(path, "path '#{path}' could not be created; rerun ace-lab prepare", directory)
137
+ end
138
+
139
+ def ensure_existing(path, directory, stat, mode)
140
+ expected_uid = expected_owner_uid(directory)
141
+ if directory["owner"] && expected_uid.nil?
142
+ return owner_unavailable(path, directory)
143
+ end
144
+
145
+ if stat.uid != expected_uid
146
+ owner = directory["owner"] || "installer"
147
+ return failure(path,
148
+ "directory '#{path}' exists but is owned by uid #{stat.uid} " \
149
+ "(expected account '#{owner}'); refusing takeover — resolve ownership manually " \
150
+ "or rerun ace-lab prepare with sudo", directory)
151
+ end
152
+
153
+ if (stat.mode & 0o7777) == mode
154
+ return base(path, directory).merge("status" => "exists", "message" => nil)
155
+ end
156
+
157
+ return base(path, directory).merge("status" => "would_fix_mode", "message" => nil) if dry_run
158
+
159
+ # Stale-stat re-check: the owner-matching lstat above may be
160
+ # outdated by the time chmod runs; re-lstat and refuse unless
161
+ # the leaf is still a real, owner-matching directory.
162
+ fresh = lstat(path)
163
+ unless fresh && fresh.directory? && fresh.uid == expected_uid
164
+ return failure(path,
165
+ "directory '#{path}' changed while preparing; refusing — rerun ace-lab prepare", directory)
166
+ end
167
+
168
+ FileUtils.chmod(mode, path)
169
+ base(path, directory).merge("status" => "mode_fixed", "message" => nil)
170
+ end
171
+
172
+ def lstat(path)
173
+ File.lstat(path)
174
+ rescue Errno::ENOENT, Errno::ENOTDIR
175
+ nil
176
+ end
177
+
178
+ def owner_unavailable(path, directory, remedy = nil)
179
+ remedy ||= if dry_run
180
+ "the account phase would create it; ownership of existing '#{path}' cannot be " \
181
+ "predicted before the account exists — rerun prepare --dry-run after the account phase"
182
+ else
183
+ "fix the account phase first"
184
+ end
185
+ failure(path,
186
+ "owner account '#{directory["owner"]}' is not available on this machine; " \
187
+ "#{remedy}", directory)
188
+ end
189
+
190
+ def chown_target(directory)
191
+ return nil unless directory["owner"]
192
+
193
+ account_lookup.call(directory["owner"])
194
+ end
195
+
196
+ def chown_to_owner(path, directory, entry)
197
+ FileUtils.chown(entry.uid, entry.gid, path) if entry
198
+ end
199
+
200
+ def lookup_passwd(username)
201
+ Etc.getpwnam(username)
202
+ rescue ArgumentError
203
+ nil
204
+ end
205
+ end
206
+ end
207
+ end
208
+ 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