ace-lab 0.9.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +363 -104
- data/README.md +66 -100
- data/docs/usage.md +529 -592
- data/lib/ace/lab/atoms/install_schema.rb +373 -0
- data/lib/ace/lab/cli/commands/install.rb +161 -0
- data/lib/ace/lab/cli/commands/prepare.rb +118 -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/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 +213 -0
- data/lib/ace/lab/organisms/prepare.rb +156 -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 +768 -0
- data/lib/ace/lab/version.rb +1 -1
- data/lib/ace/lab.rb +34 -6
- metadata +34 -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,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
|
|
@@ -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"
|