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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +399 -102
- data/README.md +66 -100
- data/docs/usage.md +563 -592
- data/lib/ace/lab/atoms/install_schema.rb +444 -0
- data/lib/ace/lab/cli/commands/install.rb +161 -0
- data/lib/ace/lab/cli/commands/prepare.rb +120 -0
- data/lib/ace/lab/cli/commands/project_add.rb +101 -0
- data/lib/ace/lab/cli/commands/status.rb +109 -0
- data/lib/ace/lab/cli/commands/verify.rb +115 -0
- data/lib/ace/lab/cli.rb +21 -23
- data/lib/ace/lab/install_manifest_path.rb +23 -0
- data/lib/ace/lab/models/status_report.rb +38 -0
- data/lib/ace/lab/molecules/account_provisioner.rb +196 -0
- data/lib/ace/lab/molecules/config_state_classifier.rb +145 -0
- data/lib/ace/lab/molecules/directory_provisioner.rb +208 -0
- data/lib/ace/lab/molecules/gemset_provisioner.rb +222 -0
- data/lib/ace/lab/molecules/grant_provisioner.rb +250 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +34 -23
- data/lib/ace/lab/molecules/hitl_routes.rb +263 -0
- data/lib/ace/lab/molecules/install_manifest_loader.rb +64 -0
- data/lib/ace/lab/molecules/key_provisioner.rb +736 -0
- data/lib/ace/lab/molecules/lab_state_scanner.rb +55 -0
- data/lib/ace/lab/molecules/overseer_provisioner.rb +188 -0
- data/lib/ace/lab/molecules/project_registry.rb +251 -0
- data/lib/ace/lab/molecules/repository_provisioner.rb +141 -0
- data/lib/ace/lab/molecules/role_verifier.rb +223 -0
- data/lib/ace/lab/organisms/install.rb +218 -0
- data/lib/ace/lab/organisms/prepare.rb +170 -0
- data/lib/ace/lab/organisms/project_add.rb +170 -0
- data/lib/ace/lab/organisms/status_service.rb +70 -0
- data/lib/ace/lab/organisms/verify.rb +836 -0
- data/lib/ace/lab/version.rb +1 -1
- data/lib/ace/lab.rb +34 -6
- metadata +35 -108
- data/lib/ace/lab/atoms/protected_publication_input.rb +0 -50
- data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +0 -114
- data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +0 -86
- data/lib/ace/lab/atoms/service_input.rb +0 -92
- data/lib/ace/lab/cli/commands/assignment.rb +0 -92
- data/lib/ace/lab/cli/commands/command_execute.rb +0 -28
- data/lib/ace/lab/cli/commands/run.rb +0 -65
- data/lib/ace/lab/cli/commands/service.rb +0 -240
- data/lib/ace/lab/models/service_receipt.rb +0 -18
- data/lib/ace/lab/molecules/command_owner_admission.rb +0 -183
- data/lib/ace/lab/molecules/command_policy.rb +0 -158
- data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
- data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
- data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
- data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
- data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
- data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -152
- data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
- data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
- data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
- data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
- data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
- data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
- data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
- data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
- data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
- data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
- data/lib/ace/lab/molecules/service_executor.rb +0 -99
- data/lib/ace/lab/molecules/service_policy.rb +0 -289
- data/lib/ace/lab/organisms/authority_composition.rb +0 -87
- data/lib/ace/lab/organisms/command_executor.rb +0 -220
- data/lib/ace/lab/organisms/command_run_client.rb +0 -131
- data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -120
- data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -153
- data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -679
- data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
- data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
- data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
- data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
- data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
- data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
- data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
- data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
- data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
- data/lib/ace/lab/organisms/service_request_service.rb +0 -249
|
@@ -0,0 +1,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
|
|
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"
|