ace-lab 0.8.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +363 -92
- data/README.md +66 -100
- data/docs/usage.md +529 -404
- 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 -13
- 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 +49 -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 -102
- 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 -45
- 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/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 -139
- 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/ordinary_assignment_client.rb +0 -149
- data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -123
- data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -688
- 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
|
@@ -43,6 +43,21 @@ module Ace
|
|
|
43
43
|
"invalid lab configuration: trusted authorization file could not be parsed"
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
# Validate an installed directory through the same original-path
|
|
47
|
+
# traversal as files, including root-owned system symlink aliases.
|
|
48
|
+
def trusted_directory!(path)
|
|
49
|
+
candidate = verified_resolve(path)
|
|
50
|
+
raise Ace::Lab::InvalidConfigurationError, "trusted directory is unavailable" unless candidate
|
|
51
|
+
File.open(candidate, File::RDONLY | File::NOFOLLOW) do |handle|
|
|
52
|
+
unless handle.stat.directory? && secure_file_stat?(handle.stat)
|
|
53
|
+
raise Ace::Lab::InvalidConfigurationError, "trusted directory ownership or mode differs"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
true
|
|
57
|
+
rescue SystemCallError
|
|
58
|
+
raise Ace::Lab::InvalidConfigurationError, "trusted directory is unavailable"
|
|
59
|
+
end
|
|
60
|
+
|
|
46
61
|
# @param documents [Array<Hash>] {path:, document:, defaults:}
|
|
47
62
|
# from Ace::Lab.cascade_documents
|
|
48
63
|
# @param topology [Hash] normalized topology (project IDs)
|
|
@@ -98,6 +113,39 @@ module Ace
|
|
|
98
113
|
end
|
|
99
114
|
end
|
|
100
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
|
+
|
|
101
149
|
private
|
|
102
150
|
|
|
103
151
|
def verify_error(path)
|
|
@@ -168,29 +216,7 @@ module Ace
|
|
|
168
216
|
"invalid lab configuration: trusted authorization file #{path} must contain a YAML mapping"
|
|
169
217
|
end
|
|
170
218
|
|
|
171
|
-
principals
|
|
172
|
-
unless principals.is_a?(Hash)
|
|
173
|
-
raise Ace::Lab::InvalidConfigurationError,
|
|
174
|
-
"invalid lab configuration: trusted authorization file #{path} principals must be a mapping"
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
validated = principals.map do |identity, policy|
|
|
178
|
-
unless identity.is_a?(String) && !identity.strip.empty?
|
|
179
|
-
raise Ace::Lab::InvalidConfigurationError,
|
|
180
|
-
"invalid lab configuration: trusted authorization file #{path} principal name " \
|
|
181
|
-
"must be a non-empty string"
|
|
182
|
-
end
|
|
183
|
-
unless policy.is_a?(Hash) && policy["projects"].is_a?(Array) &&
|
|
184
|
-
policy["projects"].all? { |project| project.is_a?(String) && !project.strip.empty? }
|
|
185
|
-
raise Ace::Lab::InvalidConfigurationError,
|
|
186
|
-
"invalid lab configuration: trusted authorization file #{path} principal projects " \
|
|
187
|
-
"must be an array of non-empty strings"
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
[identity.strip, {"projects" => policy["projects"].map(&:strip).uniq}]
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
{"principals" => validated.to_h}
|
|
219
|
+
{"principals" => validate_principals(document["principals"] || {}, path)}
|
|
194
220
|
rescue Psych::Exception
|
|
195
221
|
raise Ace::Lab::InvalidConfigurationError,
|
|
196
222
|
"invalid lab configuration: trusted authorization file #{path} could not be parsed as YAML"
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "date"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Lab
|
|
10
|
+
module Molecules
|
|
11
|
+
# Installs the manifest's HITL adapter declarations as the runtime
|
|
12
|
+
# route configuration the ace-hitl cascade actually reads
|
|
13
|
+
# (PR #393 final audit, FINAL-LAB2): `hitl.adapters.routes` in the
|
|
14
|
+
# project's `.ace/hitl/config.yml`. The trusted install manifest
|
|
15
|
+
# stays the single declaration owner — this molecule only
|
|
16
|
+
# PROJECTS that declaration into the existing HITL configuration
|
|
17
|
+
# contract; ace-hitl remains its owner and reader. A `kind: hitl`
|
|
18
|
+
# adapter becomes one route entry named after the adapter id
|
|
19
|
+
# (config entries are carried verbatim; a `route` key renames the
|
|
20
|
+
# route), so the runtime route loader finds a non-empty, valid
|
|
21
|
+
# route set instead of failing with NoRoutesError.
|
|
22
|
+
#
|
|
23
|
+
# Validation mirrors Ace::Hitl::Molecules::HitlRouteLoader
|
|
24
|
+
# (non-empty list of mappings, non-empty `adapter`, single-segment
|
|
25
|
+
# route names — delivery retention persists route names as literal
|
|
26
|
+
# dot-keys — and no duplicate names); the committed suite
|
|
27
|
+
# cross-checks the written document against the real loader.
|
|
28
|
+
#
|
|
29
|
+
# Convergence rules (review 8xir47, findings 5-6): routes are
|
|
30
|
+
# converged BY ROUTE NAME. On re-install the manifest-declared
|
|
31
|
+
# keys win over the existing same-name route, but operator
|
|
32
|
+
# extensions SURVIVE: extra keys the operator added to a
|
|
33
|
+
# same-name route (e.g. `pane: p5` on `console`) are kept and
|
|
34
|
+
# merged around the manifest keys, and operator-added routes
|
|
35
|
+
# survive untouched. Verify applies the same rule — a route is
|
|
36
|
+
# converged when a same-name entry carries every
|
|
37
|
+
# manifest-declared key/value; operator extra keys never fail
|
|
38
|
+
# verification. An unchanged route list is never rewritten, so
|
|
39
|
+
# an idempotent no-op install leaves the file (including any
|
|
40
|
+
# operator comments) byte-identical. When a write DOES happen
|
|
41
|
+
# it goes through a YAML round-trip (atomic: exclusive temp
|
|
42
|
+
# file + rename, the ProjectRegistry pattern), which normalizes
|
|
43
|
+
# formatting and DROPS comments in the file — comments are not
|
|
44
|
+
# preserved across a converging write.
|
|
45
|
+
class HitlRoutes
|
|
46
|
+
RELATIVE_CONFIG_PATH = %w[.ace hitl config.yml].freeze
|
|
47
|
+
ROUTE_NAME_PATTERN = /\A[a-z0-9][a-z0-9_-]*\z/i.freeze
|
|
48
|
+
|
|
49
|
+
# @param adapters [Array<Hash>] normalized manifest adapters
|
|
50
|
+
# @return [Array<Hash>] route entries for the `kind: hitl`
|
|
51
|
+
# declarations (string keys, `adapter` => adapter id merged
|
|
52
|
+
# with the declaration's config)
|
|
53
|
+
def self.expected_routes(adapters)
|
|
54
|
+
adapters.filter_map do |adapter|
|
|
55
|
+
next unless adapter["kind"] == "hitl"
|
|
56
|
+
|
|
57
|
+
{"adapter" => adapter["id"]}.merge(adapter["config"] || {})
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param route [Hash] route entry
|
|
62
|
+
# @return [String] the entry's route name (defaults to the
|
|
63
|
+
# adapter name, exactly like the runtime loader)
|
|
64
|
+
def self.route_name(route)
|
|
65
|
+
name = route["route"].to_s.strip
|
|
66
|
+
name.empty? ? route["adapter"].to_s : name
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The runtime loader's validation contract; nil when the route
|
|
70
|
+
# list is loadable, otherwise the concrete reason.
|
|
71
|
+
# @param routes [Object] the routes value from the config
|
|
72
|
+
# @return [String, nil]
|
|
73
|
+
def self.validation_reason(routes)
|
|
74
|
+
unless routes.is_a?(Array) && !routes.empty?
|
|
75
|
+
return "hitl.adapters.routes must be a non-empty list"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
names = []
|
|
79
|
+
routes.each do |route|
|
|
80
|
+
return "route entry must be a mapping" unless route.is_a?(Hash)
|
|
81
|
+
|
|
82
|
+
if route["adapter"].to_s.strip.empty?
|
|
83
|
+
return "route entry missing required 'adapter'"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
name = route_name(route)
|
|
87
|
+
unless name.match?(ROUTE_NAME_PATTERN)
|
|
88
|
+
return "route name '#{name}' is invalid (must match #{ROUTE_NAME_PATTERN.source})"
|
|
89
|
+
end
|
|
90
|
+
names << name
|
|
91
|
+
end
|
|
92
|
+
duplicates = names.tally.select { |_, count| count > 1 }.keys
|
|
93
|
+
return "duplicate route name(s) #{duplicates.sort.join(', ')}" unless duplicates.empty?
|
|
94
|
+
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Readiness probe (verify): the project's installed HITL
|
|
99
|
+
# configuration must exist, parse, satisfy the loader contract,
|
|
100
|
+
# and contain the expected route BY NAME with every
|
|
101
|
+
# manifest-declared key matching (operator extra keys on the
|
|
102
|
+
# same-name route are allowed — an extended route is converged,
|
|
103
|
+
# not drift). nil when ready, otherwise the concrete reason.
|
|
104
|
+
# @param root [String] project root
|
|
105
|
+
# @param expected [Hash] expected route entry
|
|
106
|
+
# @return [String, nil]
|
|
107
|
+
def self.config_reason(root:, expected:)
|
|
108
|
+
path = File.join(root, *RELATIVE_CONFIG_PATH)
|
|
109
|
+
unless File.file?(path)
|
|
110
|
+
return "no HITL adapter routes are configured: '#{path}' is missing " \
|
|
111
|
+
"(the runtime cannot deliver HITL; rerun ace-lab install)"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
begin
|
|
115
|
+
document = YAML.safe_load_file(path, permitted_classes: [Date], aliases: true)
|
|
116
|
+
rescue Psych::Exception
|
|
117
|
+
return "'#{path}' could not be parsed as YAML"
|
|
118
|
+
end
|
|
119
|
+
return "'#{path}' must contain a YAML mapping" unless document.is_a?(Hash)
|
|
120
|
+
|
|
121
|
+
routes = document.dig("hitl", "adapters", "routes")
|
|
122
|
+
reason = validation_reason(routes)
|
|
123
|
+
return "hitl.adapters.routes in '#{path}' is invalid: #{reason}" if reason
|
|
124
|
+
|
|
125
|
+
name = route_name(expected)
|
|
126
|
+
route = routes.find { |candidate| route_name(candidate) == name }
|
|
127
|
+
unless route && expected.all? { |key, value| route[key] == value }
|
|
128
|
+
return "route '#{name}' is not configured in '#{path}' with the declared " \
|
|
129
|
+
"settings (rerun ace-lab install)"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @param root [String] project root
|
|
136
|
+
# @param adapters [Array<Hash>] normalized manifest adapters
|
|
137
|
+
# @param dry_run [Boolean]
|
|
138
|
+
# @return [Hash] {location:, status:, message:} — status is
|
|
139
|
+
# created / exists / would_create / skipped / failed
|
|
140
|
+
def install(root:, adapters:, dry_run: false)
|
|
141
|
+
path = File.join(root, *RELATIVE_CONFIG_PATH)
|
|
142
|
+
base = {"location" => path}
|
|
143
|
+
expected = self.class.expected_routes(adapters)
|
|
144
|
+
return base.merge("status" => "skipped", "message" => nil) if expected.empty?
|
|
145
|
+
|
|
146
|
+
document, reason = load_document(path)
|
|
147
|
+
return base.merge("status" => "failed", "message" => reason) if reason
|
|
148
|
+
|
|
149
|
+
routes = document.dig("hitl", "adapters", "routes")
|
|
150
|
+
routes = routes.is_a?(Array) ? routes : []
|
|
151
|
+
merged = merge_routes(routes, expected)
|
|
152
|
+
if (invalid = self.class.validation_reason(merged))
|
|
153
|
+
return base.merge("status" => "failed",
|
|
154
|
+
"message" => "resulting hitl.adapters.routes is invalid: #{invalid}")
|
|
155
|
+
end
|
|
156
|
+
return base.merge("status" => "exists", "message" => nil) if routes == merged
|
|
157
|
+
return base.merge("status" => "would_create", "message" => nil) if dry_run
|
|
158
|
+
|
|
159
|
+
document["hitl"] ||= {}
|
|
160
|
+
document["hitl"]["adapters"] ||= {}
|
|
161
|
+
document["hitl"]["adapters"]["routes"] = merged
|
|
162
|
+
begin
|
|
163
|
+
dir = File.dirname(path)
|
|
164
|
+
FileUtils.mkdir_p(dir)
|
|
165
|
+
# Atomic write (review 8xir47, finding 5, the ProjectRegistry
|
|
166
|
+
# pattern): a crash mid-write must never truncate the runtime
|
|
167
|
+
# route configuration. The temp file is created EXCLUSIVELY
|
|
168
|
+
# (O_CREAT|O_EXCL) under an unguessable name so a planted
|
|
169
|
+
# symlink can never redirect the write.
|
|
170
|
+
tmp = write_exclusive(dir, YAML.dump(document))
|
|
171
|
+
File.rename(tmp, path)
|
|
172
|
+
rescue SystemCallError, IOError => e
|
|
173
|
+
File.delete(tmp) if defined?(tmp) && tmp && File.exist?(tmp)
|
|
174
|
+
return base.merge("status" => "failed",
|
|
175
|
+
"message" => "writing '#{path}' failed: #{e.class}: #{e.message}")
|
|
176
|
+
end
|
|
177
|
+
base.merge("status" => "created", "message" => nil)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
# An existing configuration document is preserved (root_dir and
|
|
183
|
+
# operator-owned settings survive); only the lab-declared routes
|
|
184
|
+
# are converged. Unparseable or foreign-shaped content is
|
|
185
|
+
# refused instead of overwritten.
|
|
186
|
+
def load_document(path)
|
|
187
|
+
return [{}, nil] unless File.exist?(path)
|
|
188
|
+
|
|
189
|
+
begin
|
|
190
|
+
document = YAML.safe_load_file(path, permitted_classes: [Date], aliases: true)
|
|
191
|
+
rescue Psych::Exception
|
|
192
|
+
return [nil, "'#{path}' exists but could not be parsed as YAML; resolve it " \
|
|
193
|
+
"manually and rerun ace-lab install"]
|
|
194
|
+
end
|
|
195
|
+
return [nil, "'#{path}' must contain a YAML mapping"] unless document.is_a?(Hash)
|
|
196
|
+
|
|
197
|
+
[document, nil]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Converge the expected routes into the existing list by route
|
|
201
|
+
# name: a declared route's keys are MERGED into the entry
|
|
202
|
+
# carrying the same name (manifest-declared keys win, operator
|
|
203
|
+
# extra keys like `pane: p5` survive), a route with no
|
|
204
|
+
# same-name counterpart is appended; operator-added routes and
|
|
205
|
+
# their order are untouched.
|
|
206
|
+
def merge_routes(routes, expected)
|
|
207
|
+
merged = routes.map(&:dup)
|
|
208
|
+
expected.each do |route|
|
|
209
|
+
name = self.class.route_name(route)
|
|
210
|
+
index = merged.index { |existing| self.class.route_name(existing) == name }
|
|
211
|
+
if index
|
|
212
|
+
merged[index] = merged[index].merge(route)
|
|
213
|
+
else
|
|
214
|
+
merged << route.dup
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
merged
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# NOTE: merge_routes uses a shallow Hash#merge — manifest keys
|
|
221
|
+
# win per-key, but a nested manifest value would replace an
|
|
222
|
+
# operator's nested value wholesale. Current route shapes are
|
|
223
|
+
# flat (adapter/pane/runtime/route), so shallow is correct.
|
|
224
|
+
|
|
225
|
+
# Create the temp file exclusively (never following a symlink),
|
|
226
|
+
# write the payload, close, and return the path — the
|
|
227
|
+
# ProjectRegistry atomic-write discipline. Raises
|
|
228
|
+
# SystemCallError/IOError on failure so the caller classifies.
|
|
229
|
+
def write_exclusive(dir, payload)
|
|
230
|
+
require "securerandom"
|
|
231
|
+
100.times do
|
|
232
|
+
candidate = File.join(dir, ".config.yml.tmp.#{Process.pid}.#{SecureRandom.hex(8)}")
|
|
233
|
+
next if File.exist?(candidate) # includes symlinks (lstat)
|
|
234
|
+
|
|
235
|
+
begin
|
|
236
|
+
file = File.open(candidate, File::RDWR | File::CREAT | File::EXCL, 0o600)
|
|
237
|
+
rescue Errno::EEXIST
|
|
238
|
+
next
|
|
239
|
+
end
|
|
240
|
+
begin
|
|
241
|
+
file.write(payload)
|
|
242
|
+
rescue StandardError
|
|
243
|
+
# A failed write must not leak the exclusive temp file:
|
|
244
|
+
# close, remove, and re-raise verbatim so the caller
|
|
245
|
+
# still classifies (encoding failures included).
|
|
246
|
+
file.close unless file.closed?
|
|
247
|
+
begin
|
|
248
|
+
File.delete(candidate)
|
|
249
|
+
rescue SystemCallError
|
|
250
|
+
nil
|
|
251
|
+
end
|
|
252
|
+
raise
|
|
253
|
+
ensure
|
|
254
|
+
file.close unless file.closed?
|
|
255
|
+
end
|
|
256
|
+
return candidate
|
|
257
|
+
end
|
|
258
|
+
raise SystemCallError.new("could not create an exclusive temporary file in '#{dir}'", 0)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "../atoms/install_schema"
|
|
5
|
+
require_relative "grant_resolver"
|
|
6
|
+
require "yaml"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Lab
|
|
10
|
+
module Molecules
|
|
11
|
+
# Loads the trusted install manifest through the same fail-closed
|
|
12
|
+
# deployment-ownership verification as the authorization document
|
|
13
|
+
# (GrantResolver.read_verified): every traversed element root-owned
|
|
14
|
+
# and not group/world-writable, O_NOFOLLOW on the final file. The
|
|
15
|
+
# loader adds nothing to trust — it only classifies absence and parse
|
|
16
|
+
# failures with value-free, path-bearing messages.
|
|
17
|
+
class InstallManifestLoader
|
|
18
|
+
class << self
|
|
19
|
+
# @param path [String] trusted manifest location (default: the
|
|
20
|
+
# fixed deployment path)
|
|
21
|
+
# @param platform [String, nil] platform for home-path defaults
|
|
22
|
+
# @return [Hash] normalized manifest (Atoms::InstallSchema)
|
|
23
|
+
# @raise [Ace::Lab::InvalidConfigurationError] missing, untrusted,
|
|
24
|
+
# unparseable or schema-invalid manifest
|
|
25
|
+
def load(path: Ace::Lab.install_manifest_path, platform: nil)
|
|
26
|
+
# The nested GrantResolver message speaks of the
|
|
27
|
+
# "authorization file" — a wrong remedy lead inside an
|
|
28
|
+
# install-manifest error. The wrapper already states the
|
|
29
|
+
# requirement, so the nested message is not embedded.
|
|
30
|
+
content = begin
|
|
31
|
+
GrantResolver.read_verified(path)
|
|
32
|
+
rescue Ace::Lab::InvalidConfigurationError
|
|
33
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
34
|
+
"invalid lab install manifest: #{path} failed trusted deployment verification " \
|
|
35
|
+
"or could not be read; failing closed"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if content.nil?
|
|
39
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
40
|
+
"invalid lab install manifest: #{path} not found; create the root-owned " \
|
|
41
|
+
"deployment manifest at this path (administrator action) " \
|
|
42
|
+
"and keep it outside the caller-writable configuration cascade"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
document = begin
|
|
46
|
+
YAML.safe_load(content, permitted_classes: [], aliases: false)
|
|
47
|
+
rescue Psych::DisallowedClass
|
|
48
|
+
# It parsed; it just carries types outside the permitted
|
|
49
|
+
# set — a different remedy than fixing YAML syntax.
|
|
50
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
51
|
+
"invalid lab install manifest: #{path} contains YAML types outside the " \
|
|
52
|
+
"permitted set; use plain scalars, sequences and mappings only"
|
|
53
|
+
rescue Psych::Exception
|
|
54
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
55
|
+
"invalid lab install manifest: #{path} could not be parsed as YAML"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
Atoms::InstallSchema.normalize(document, platform: platform)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|