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,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "etc"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Lab
|
|
10
|
+
module Molecules
|
|
11
|
+
# Idempotently ensures a lab account exists, per the trusted install
|
|
12
|
+
# manifest. Existing accounts are reported with their uid; missing
|
|
13
|
+
# accounts either fail with an actionable message (create: false, or
|
|
14
|
+
# creation without administrator privileges) or are created through
|
|
15
|
+
# the platform's own account tooling (darwin dscl / linux useradd),
|
|
16
|
+
# always with argv arrays (ADR-031) — never a shell string. Every
|
|
17
|
+
# failure message names the account and the remedy; command failures
|
|
18
|
+
# surface only the exit status and the first stderr line, never file
|
|
19
|
+
# content.
|
|
20
|
+
class AccountProvisioner
|
|
21
|
+
DEFAULT_SHELL = "/bin/zsh"
|
|
22
|
+
DARWIN_PRIMARY_GROUP = 20
|
|
23
|
+
|
|
24
|
+
attr_reader :runner, :account_lookup, :privileged, :platform, :dry_run
|
|
25
|
+
|
|
26
|
+
def initialize(runner: nil, account_lookup: nil, privileged: nil, platform: nil, dry_run: false)
|
|
27
|
+
@runner = runner || method(:run_argv)
|
|
28
|
+
@account_lookup = account_lookup || method(:lookup_passwd)
|
|
29
|
+
@privileged = privileged || -> { Process.euid.zero? }
|
|
30
|
+
@platform = platform || Ace::Lab.host_platform
|
|
31
|
+
@dry_run = dry_run
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param account [Hash] normalized manifest account entry
|
|
35
|
+
# @return [Hash] {username:, status:, uid:, message:}
|
|
36
|
+
def call(account:)
|
|
37
|
+
username = account["username"]
|
|
38
|
+
existing = account_lookup.call(username)
|
|
39
|
+
if existing
|
|
40
|
+
return {"username" => username, "status" => "exists", "uid" => existing.uid,
|
|
41
|
+
"message" => nil} if existing.dir == account["home"]
|
|
42
|
+
|
|
43
|
+
# Drift is reported, not repaired: the manifest home stays
|
|
44
|
+
# authoritative (key default paths derive from it).
|
|
45
|
+
{"username" => username, "status" => "exists", "uid" => existing.uid,
|
|
46
|
+
"message" => "account '#{username}' home is '#{existing.dir}' but the manifest " \
|
|
47
|
+
"declares '#{account["home"]}'; the manifest home is authoritative for key paths"}
|
|
48
|
+
else
|
|
49
|
+
|
|
50
|
+
unless account["create"]
|
|
51
|
+
return failure(username,
|
|
52
|
+
"account '#{username}' does not exist; create it manually (administrator action) " \
|
|
53
|
+
"or set create: true in the install manifest")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
unless privileged.call
|
|
57
|
+
return failure(username,
|
|
58
|
+
"creating account '#{username}' requires administrator privileges; rerun ace-lab prepare " \
|
|
59
|
+
"with sudo or create the account manually")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Platform support is classified BEFORE the dry-run
|
|
63
|
+
# prediction: --dry-run must predict the wet failure on an
|
|
64
|
+
# unsupported platform, not would_create.
|
|
65
|
+
creation = case platform
|
|
66
|
+
when "darwin" then :create_darwin
|
|
67
|
+
when "linux" then :create_linux
|
|
68
|
+
end
|
|
69
|
+
if creation.nil?
|
|
70
|
+
return failure(username,
|
|
71
|
+
"unsupported platform '#{platform}' for account creation; create account '#{username}' " \
|
|
72
|
+
"manually and set create: false in the install manifest")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
if dry_run
|
|
76
|
+
return {"username" => username, "status" => "would_create", "uid" => nil, "message" => nil}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
send(creation, account)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def failure(username, message)
|
|
86
|
+
{"username" => username, "status" => "failed", "uid" => nil, "message" => message}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def created(username, uid)
|
|
90
|
+
{"username" => username, "status" => "created", "uid" => uid, "message" => nil}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def create_darwin(account)
|
|
94
|
+
username = account["username"]
|
|
95
|
+
listing = invoke(["dscl", ".", "-list", "/Users", "UniqueID"])
|
|
96
|
+
return failure(username, listing["message"]) unless listing["ok"]
|
|
97
|
+
|
|
98
|
+
uid = next_darwin_uid(listing["out"])
|
|
99
|
+
steps = [
|
|
100
|
+
["dscl", ".", "-create", "/Users/#{username}", "RealName", username],
|
|
101
|
+
["dscl", ".", "-create", "/Users/#{username}", "UniqueID", uid.to_s],
|
|
102
|
+
["dscl", ".", "-create", "/Users/#{username}", "PrimaryGroupID", DARWIN_PRIMARY_GROUP.to_s],
|
|
103
|
+
["dscl", ".", "-create", "/Users/#{username}", "UserShell", DEFAULT_SHELL],
|
|
104
|
+
["dscl", ".", "-create", "/Users/#{username}", "NFSHomeDirectory", account["home"]],
|
|
105
|
+
["dscl", ".", "-create", "/Users/#{username}", "GeneratedUID", SecureRandom.uuid],
|
|
106
|
+
["dscl", ".", "-create", "/Users/#{username}", "Password", "*"],
|
|
107
|
+
["createhomedir", "-c", "-u", username]
|
|
108
|
+
]
|
|
109
|
+
steps.each do |argv|
|
|
110
|
+
result = invoke(argv)
|
|
111
|
+
return failure(username, result["message"]) unless result["ok"]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
created(username, uid)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Allocate the next free UniqueID above the current maximum,
|
|
118
|
+
# ignoring the reserved high sentinel values (darwin's `nobody`
|
|
119
|
+
# reports 4294967294). Parse only the trailing UniqueID column
|
|
120
|
+
# of each `dscl -list` line — digits inside account names such
|
|
121
|
+
# as `deploy-2026` must never be taken for a UID. Deliberately
|
|
122
|
+
# simple: dscl owns the directory; concurrent creation is
|
|
123
|
+
# reconciled by the idempotent re-run.
|
|
124
|
+
def next_darwin_uid(out)
|
|
125
|
+
ids = out.lines.filter_map { |line| line[/\s(\d+)\s*\z/, 1] }
|
|
126
|
+
.map(&:to_i).reject { |id| id >= 2**31 }
|
|
127
|
+
return 501 if ids.empty?
|
|
128
|
+
|
|
129
|
+
[ids.max + 1, 501].max
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def create_linux(account)
|
|
133
|
+
username = account["username"]
|
|
134
|
+
result = invoke([
|
|
135
|
+
"useradd", "-m", "-s", DEFAULT_SHELL, "-d", account["home"], username
|
|
136
|
+
])
|
|
137
|
+
return failure(username, result["message"]) unless result["ok"]
|
|
138
|
+
|
|
139
|
+
entry = account_lookup.call(username)
|
|
140
|
+
if entry
|
|
141
|
+
created(username, entry.uid)
|
|
142
|
+
else
|
|
143
|
+
{"username" => username, "status" => "created", "uid" => nil,
|
|
144
|
+
"message" => "account '#{username}' was created but its uid could not be " \
|
|
145
|
+
"resolved yet; rerun ace-lab prepare to report it"}
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @return [Hash] {ok:, out:, message:} — failure message carries
|
|
150
|
+
# the exit status and the first stderr line only, never command
|
|
151
|
+
# output bodies or file content.
|
|
152
|
+
def invoke(argv)
|
|
153
|
+
result = runner.call(argv)
|
|
154
|
+
if result["status"] == "ok"
|
|
155
|
+
{"ok" => true, "out" => result["out"], "message" => nil}
|
|
156
|
+
elsif result["status"] == "enoent"
|
|
157
|
+
{"ok" => false, "out" => result["out"],
|
|
158
|
+
"message" => "#{argv.first} not found in PATH; install it and rerun ace-lab prepare"}
|
|
159
|
+
elsif result["status"] == "exec_failed"
|
|
160
|
+
# Exec-level SystemCallError, not a subprocess exit status —
|
|
161
|
+
# render the errno class so the message never says "exit failed".
|
|
162
|
+
{"ok" => false, "out" => result["out"],
|
|
163
|
+
"message" => "command could not be executed (#{argv.first}, #{result['err']})"}
|
|
164
|
+
else
|
|
165
|
+
first_line = result["err"].to_s.lines.map(&:strip).reject(&:empty?).first
|
|
166
|
+
# Round 5 (C2): a signal-killed child has no exitstatus —
|
|
167
|
+
# the runner renders "signal <termsig>" instead of nil.
|
|
168
|
+
message = "command failed (#{argv.first}, #{exit_detail(result['status'])})"
|
|
169
|
+
message += ": #{first_line}" if first_line
|
|
170
|
+
{"ok" => false, "out" => result["out"], "message" => message}
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def exit_detail(status)
|
|
175
|
+
status.is_a?(Integer) ? "exit #{status}" : status.to_s
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def run_argv(argv)
|
|
179
|
+
out, err, status = Open3.capture3(*argv)
|
|
180
|
+
{"status" => status.success? ? "ok" : (status.exitstatus || "signal #{status.termsig}"),
|
|
181
|
+
"out" => out, "err" => err}
|
|
182
|
+
rescue Errno::ENOENT
|
|
183
|
+
{"status" => "enoent", "out" => "", "err" => ""}
|
|
184
|
+
rescue SystemCallError => e
|
|
185
|
+
{"status" => "exec_failed", "out" => "", "err" => e.class.name}
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def lookup_passwd(username)
|
|
189
|
+
Etc.getpwnam(username)
|
|
190
|
+
rescue ArgumentError
|
|
191
|
+
nil
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -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
|