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
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Lab
|
|
5
|
+
module Molecules
|
|
6
|
+
# Read-only scan of the file-based lab state-owner locations
|
|
7
|
+
# (ADR-034). Every location reports presence and — unless it is
|
|
8
|
+
# private or reserved — an entry count; a per-location read failure
|
|
9
|
+
# becomes that location's :error and never masks its siblings
|
|
10
|
+
# (task 8xg.t.sbz.6.0: a source failure is shown alongside
|
|
11
|
+
# available results).
|
|
12
|
+
class LabStateScanner
|
|
13
|
+
# presence_only locations are never enumerated: _overseer is the
|
|
14
|
+
# private task list, coworker is reserved L3 configuration.
|
|
15
|
+
LOCATIONS = [
|
|
16
|
+
{key: "tasks", path: ".ace-tasks", note: nil},
|
|
17
|
+
{key: "overseer", path: ".ace-tasks/_overseer", note: "private; not listed"},
|
|
18
|
+
{key: "assignments", path: ".ace-local/assign", note: nil},
|
|
19
|
+
{key: "hitl", path: ".ace-local/hitl", note: nil},
|
|
20
|
+
{key: "worktrees", path: ".ace-wt", note: "enriched view: ace-git-worktree list"},
|
|
21
|
+
{key: "coworker", path: ".ace-local/coworker", note: "reserved L3; informational"}
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
PRESENCE_ONLY = %w[overseer coworker].freeze
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# @param root [String] project root to scan
|
|
28
|
+
# @return [Array<Hash>] {key, path, state, entries, note} per
|
|
29
|
+
# location; state is :present, :absent, or :error
|
|
30
|
+
def scan(root: Dir.pwd)
|
|
31
|
+
LOCATIONS.map do |location|
|
|
32
|
+
absolute = File.join(root, location[:path])
|
|
33
|
+
entry = {key: location[:key], path: location[:path], note: location[:note]}
|
|
34
|
+
begin
|
|
35
|
+
if File.directory?(absolute)
|
|
36
|
+
entry[:state] = :present
|
|
37
|
+
entry[:entries] = PRESENCE_ONLY.include?(location[:key]) ? nil : Dir.children(absolute).length
|
|
38
|
+
entry[:state] = :prepared if PRESENCE_ONLY.include?(location[:key])
|
|
39
|
+
else
|
|
40
|
+
entry[:state] = :absent
|
|
41
|
+
entry[:entries] = nil
|
|
42
|
+
end
|
|
43
|
+
rescue SystemCallError => e
|
|
44
|
+
entry[:state] = :error
|
|
45
|
+
entry[:entries] = nil
|
|
46
|
+
entry[:note] = e.message
|
|
47
|
+
end
|
|
48
|
+
entry
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Lab
|
|
8
|
+
module Molecules
|
|
9
|
+
# Idempotently prepares the project's private overseer folder
|
|
10
|
+
# (ADR-034): the ordinary, gitignored `.ace-tasks/_overseer/`
|
|
11
|
+
# directory plus its `.gitignore` entry. Existing content is never
|
|
12
|
+
# inspected or modified — the folder is private to the overseer that
|
|
13
|
+
# owns it; this provisioner only ensures the folder and the ignore
|
|
14
|
+
# rule exist. Re-running reports ok without rewriting anything.
|
|
15
|
+
#
|
|
16
|
+
# Whether the folder is ACTUALLY ignored is decided by real Git
|
|
17
|
+
# evaluation (`git check-ignore`), never by scanning `.gitignore`
|
|
18
|
+
# lines: a later negation (`!.ace-tasks/_overseer/`) un-ignores an
|
|
19
|
+
# earlier positive line, so line matching would report a private
|
|
20
|
+
# folder as ignored while Git would commit it (LAB-5). When Git
|
|
21
|
+
# reports the folder as not effectively ignored, the effective rule
|
|
22
|
+
# is appended (append-only — the project's own content survives)
|
|
23
|
+
# and the decision is re-evaluated to confirm.
|
|
24
|
+
#
|
|
25
|
+
# The provisioner runs with installer privileges on a freshly
|
|
26
|
+
# cloned repository whose contents are NOT root-trusted: every
|
|
27
|
+
# write is preceded by lstat checks refusing symbolic links under
|
|
28
|
+
# the project root (a committed `.gitignore` or `.ace-tasks`
|
|
29
|
+
# symlink must never redirect a privileged write outside the
|
|
30
|
+
# project — LAB-1).
|
|
31
|
+
class OverseerProvisioner
|
|
32
|
+
OVERSEER_PATH = File.join(".ace-tasks", "_overseer")
|
|
33
|
+
OVERSEER_IGNORE_PATH = ".ace-tasks/_overseer"
|
|
34
|
+
|
|
35
|
+
attr_reader :dry_run
|
|
36
|
+
|
|
37
|
+
# @param runner [Proc, nil] argv runner seam (git check-ignore)
|
|
38
|
+
def initialize(runner: nil, dry_run: false)
|
|
39
|
+
@runner = runner || method(:run_argv)
|
|
40
|
+
@dry_run = dry_run
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param root [String] absolute project root
|
|
44
|
+
# @return [Hash] {dir:, gitignore:, status:, message:} where
|
|
45
|
+
# status is created / exists / would_create / failed
|
|
46
|
+
def call(root:)
|
|
47
|
+
directory = File.join(root, OVERSEER_PATH)
|
|
48
|
+
gitignore = File.join(root, ".gitignore")
|
|
49
|
+
|
|
50
|
+
unsafe = unsafe_symlink_reason(root)
|
|
51
|
+
return failed(unsafe) if unsafe
|
|
52
|
+
|
|
53
|
+
# A repo-controlled symlink at the ignore path is refused up
|
|
54
|
+
# front — no privileged read-through either (git check-ignore
|
|
55
|
+
# would follow the link): same trust posture as the LAB-1
|
|
56
|
+
# write refusals.
|
|
57
|
+
gitignore_stat = begin
|
|
58
|
+
File.lstat(gitignore)
|
|
59
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
if gitignore_stat&.symlink?
|
|
63
|
+
return failed("'#{gitignore}' is a symbolic link; refusing to follow it with " \
|
|
64
|
+
"installer privileges (remove the link and rerun)")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Dry-run prediction for a repository the clone step would
|
|
68
|
+
# create: no work tree exists to evaluate yet — predict the
|
|
69
|
+
# rule the wet run appends.
|
|
70
|
+
ignore = if dry_run && !File.directory?(root)
|
|
71
|
+
{"status" => "ok", "ignored" => false, "message" => nil}
|
|
72
|
+
else
|
|
73
|
+
ignore_decision(root)
|
|
74
|
+
end
|
|
75
|
+
return failed(ignore["message"]) if ignore["status"] == "failed"
|
|
76
|
+
|
|
77
|
+
dir_present = File.directory?(directory)
|
|
78
|
+
if dir_present && ignore["ignored"]
|
|
79
|
+
return {"dir" => OVERSEER_PATH, "gitignore" => ".gitignore",
|
|
80
|
+
"status" => "exists", "message" => nil}
|
|
81
|
+
end
|
|
82
|
+
return {"dir" => OVERSEER_PATH, "gitignore" => ".gitignore",
|
|
83
|
+
"status" => "would_create", "message" => nil} if dry_run
|
|
84
|
+
|
|
85
|
+
begin
|
|
86
|
+
require "fileutils"
|
|
87
|
+
FileUtils.mkdir_p(directory) unless dir_present
|
|
88
|
+
append_ignore_entry(gitignore) unless ignore["ignored"]
|
|
89
|
+
rescue SystemCallError => e
|
|
90
|
+
return failed("preparing _overseer failed: #{e.message}")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
unless ignore["ignored"]
|
|
94
|
+
confirmed = ignore_decision(root)
|
|
95
|
+
if confirmed["status"] == "failed"
|
|
96
|
+
return failed(confirmed["message"])
|
|
97
|
+
end
|
|
98
|
+
unless confirmed["ignored"]
|
|
99
|
+
return failed("'#{OVERSEER_IGNORE_PATH}' is still not ignored by Git after " \
|
|
100
|
+
"appending the rule to '#{gitignore}'; fix the project's ignore rules manually")
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
{"dir" => OVERSEER_PATH, "gitignore" => ".gitignore",
|
|
105
|
+
"status" => "created", "message" => nil}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
# A repository-controlled symlink under the project root must
|
|
111
|
+
# never be followed by a privileged read or write: refuse before
|
|
112
|
+
# any mutation. Applies to the `.ace-tasks` component of the
|
|
113
|
+
# private folder path (a symlinked `.gitignore` is refused below
|
|
114
|
+
# before it is read or appended).
|
|
115
|
+
def unsafe_symlink_reason(root)
|
|
116
|
+
%w[.ace-tasks .ace-tasks/_overseer].each do |component|
|
|
117
|
+
path = File.join(root, component)
|
|
118
|
+
stat = File.lstat(path)
|
|
119
|
+
if stat.symlink?
|
|
120
|
+
return "'#{path}' is a symbolic link; refusing to follow it with installer " \
|
|
121
|
+
"privileges (remove the link and rerun)"
|
|
122
|
+
end
|
|
123
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Real Git ignore evaluation: ok means ignored, exit 1 means not
|
|
130
|
+
# ignored, anything else (dubious ownership, missing git, not a
|
|
131
|
+
# repository) is a concrete failure — a decision this molecule
|
|
132
|
+
# cannot make must never be guessed.
|
|
133
|
+
def ignore_decision(root)
|
|
134
|
+
result = @runner.call(["git", "-C", root, "check-ignore", "--", OVERSEER_IGNORE_PATH])
|
|
135
|
+
case result["status"]
|
|
136
|
+
when "ok"
|
|
137
|
+
{"status" => "ok", "ignored" => true, "message" => nil}
|
|
138
|
+
when 1, "1" # exit 1: evaluated and NOT ignored
|
|
139
|
+
{"status" => "ok", "ignored" => false, "message" => nil}
|
|
140
|
+
else
|
|
141
|
+
detail = result["err"].to_s.lines.map(&:strip).reject(&:empty?).first
|
|
142
|
+
{"status" => "failed", "ignored" => nil,
|
|
143
|
+
"message" => "cannot evaluate Git ignore rules for '#{root}' " \
|
|
144
|
+
"(git check-ignore exited with #{result['status']}#{": #{detail}" if detail})"}
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Append only — an existing .gitignore is the project's own
|
|
149
|
+
# content and must survive a re-add untouched. A symlinked
|
|
150
|
+
# .gitignore (checked again here, immediately before the open)
|
|
151
|
+
# is refused rather than followed.
|
|
152
|
+
def append_ignore_entry(gitignore)
|
|
153
|
+
stat = begin
|
|
154
|
+
File.lstat(gitignore)
|
|
155
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
if stat&.symlink?
|
|
159
|
+
raise SystemCallError.new(
|
|
160
|
+
"'#{gitignore}' is a symbolic link; refusing to follow it with installer privileges", 0)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
entry = "#{OVERSEER_IGNORE_PATH}/"
|
|
164
|
+
if stat && !File.read(gitignore).end_with?("\n")
|
|
165
|
+
File.open(gitignore, "a") { |file| file.write("\n#{entry}\n") }
|
|
166
|
+
else
|
|
167
|
+
File.open(gitignore, "a") { |file| file.write("#{entry}\n") }
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def failed(message)
|
|
172
|
+
{"dir" => OVERSEER_PATH, "gitignore" => ".gitignore",
|
|
173
|
+
"status" => "failed", "message" => message}
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def run_argv(argv)
|
|
177
|
+
out, err, status = Open3.capture3(*argv)
|
|
178
|
+
{"status" => status.success? ? "ok" : (status.exitstatus || "signal #{status.termsig}"),
|
|
179
|
+
"out" => out, "err" => err}
|
|
180
|
+
rescue Errno::ENOENT
|
|
181
|
+
{"status" => "enoent", "out" => "", "err" => ""}
|
|
182
|
+
rescue SystemCallError => e
|
|
183
|
+
{"status" => "exec_failed", "out" => "", "err" => e.class.name}
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "../atoms/topology_schema"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Lab
|
|
8
|
+
module Molecules
|
|
9
|
+
# Reads and updates the project registry: the project-tier lab
|
|
10
|
+
# configuration at `<project>/.ace/lab/config.yml` (task
|
|
11
|
+
# 8xg.t.sbz.7.1). This is the same ADR-022 cascade document every
|
|
12
|
+
# consumer already reads (`ace-lab status`, `ace-lab projects`,
|
|
13
|
+
# `ace-overseer projects`), so registering a project here makes it
|
|
14
|
+
# visible to the whole chain — no second registry exists.
|
|
15
|
+
#
|
|
16
|
+
# Project entries carry the deployment facts the installer owns:
|
|
17
|
+
# id, label, absolute path and origin remote. `TopologySchema`
|
|
18
|
+
# ignores unknown keys, so the query surface (PublicProjection)
|
|
19
|
+
# is unchanged. The molecule is idempotent and refuses before any
|
|
20
|
+
# overwrite: a contradictory registration (same id, different
|
|
21
|
+
# path/remote), a location conflict (same path, different id), an
|
|
22
|
+
# unparseable or foreign document — all fail without writing.
|
|
23
|
+
class ProjectRegistry
|
|
24
|
+
REGISTRY_PATH = File.join(".ace", "lab", "config.yml")
|
|
25
|
+
SCHEMA_VERSION = 1
|
|
26
|
+
|
|
27
|
+
# @param root [String] absolute project root
|
|
28
|
+
# @return [Hash, nil] parsed registry document, nil when absent
|
|
29
|
+
# @raise [Ace::Lab::InvalidConfigurationError] unparseable or
|
|
30
|
+
# non-mapping document
|
|
31
|
+
def load(root:)
|
|
32
|
+
path = registry_path(root)
|
|
33
|
+
return nil unless File.file?(path)
|
|
34
|
+
|
|
35
|
+
require "yaml"
|
|
36
|
+
document = YAML.safe_load_file(path, permitted_classes: [Date], aliases: true)
|
|
37
|
+
unless document.nil? || document.is_a?(Hash)
|
|
38
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
39
|
+
"#{path} must contain a YAML mapping"
|
|
40
|
+
end
|
|
41
|
+
document || {}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param root [String] absolute project root
|
|
45
|
+
# @return [String] absolute registry path
|
|
46
|
+
def registry_path(root)
|
|
47
|
+
File.join(root, REGISTRY_PATH)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Idempotently register a project.
|
|
51
|
+
#
|
|
52
|
+
# @param root [String] absolute project root
|
|
53
|
+
# @param project [Hash] {"id" =>, "label" =>, "path" =>,
|
|
54
|
+
# "remote" =>} — path is the absolute project path, remote the
|
|
55
|
+
# origin URL (nil allowed for an offline repository)
|
|
56
|
+
# @param dry_run [Boolean]
|
|
57
|
+
# @return [Hash] {path:, status:, message:} where status is
|
|
58
|
+
# created / exists / would_create / failed
|
|
59
|
+
def register(root:, project:, dry_run: false)
|
|
60
|
+
path = registry_path(root)
|
|
61
|
+
document = begin
|
|
62
|
+
load(root: root)
|
|
63
|
+
rescue StandardError
|
|
64
|
+
return failure(path, "#{path} could not be read as the lab registry; refusing to overwrite")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
if document.nil?
|
|
68
|
+
document = {"schema_version" => SCHEMA_VERSION,
|
|
69
|
+
"topology" => {"projects" => [], "agents" => [], "services" => []}}
|
|
70
|
+
return write(path, document, project, dry_run)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
if document.empty?
|
|
74
|
+
return failure(path, "#{path} exists but is empty; " \
|
|
75
|
+
"it is not this lab's registry — refusing to overwrite")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
version = document["schema_version"]
|
|
79
|
+
unless version == SCHEMA_VERSION
|
|
80
|
+
return failure(path, "#{path} has schema_version #{version.inspect}; " \
|
|
81
|
+
"it is not this lab's registry — refusing to overwrite")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Pre-validate the loaded document (review 8xikzhvi): a
|
|
85
|
+
# malformed entry (e.g. a non-mapping topology.projects item)
|
|
86
|
+
# is a foreign document and must fail classification here,
|
|
87
|
+
# never crash with a TypeError mid-iteration.
|
|
88
|
+
begin
|
|
89
|
+
Atoms::TopologySchema.normalize!(deep_copy(document))
|
|
90
|
+
rescue Ace::Lab::InvalidConfigurationError
|
|
91
|
+
return failure(path, "#{path} is not this lab's registry " \
|
|
92
|
+
"(invalid lab configuration); refusing to overwrite")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
conflict = conflicting_registration(document, project)
|
|
96
|
+
return failure(path, conflict) if conflict
|
|
97
|
+
|
|
98
|
+
existing = projects_in(document).find { |entry| entry["id"] == project["id"] }
|
|
99
|
+
if existing
|
|
100
|
+
# `label` is display data, not identity: a changed label on
|
|
101
|
+
# re-add updates it in place (review 8xikzhvl). An absent
|
|
102
|
+
# label leaves the recorded one untouched.
|
|
103
|
+
return {"path" => path, "status" => "exists", "message" => nil} if !project["label"] || project["label"] == existing["label"]
|
|
104
|
+
|
|
105
|
+
return relabel(path, document, project, dry_run)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
write(path, document, project, dry_run)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def write(path, document, project, dry_run)
|
|
114
|
+
updated = deep_copy(document)
|
|
115
|
+
updated["topology"] ||= {}
|
|
116
|
+
updated["topology"]["projects"] ||= []
|
|
117
|
+
entry = {"id" => project["id"]}
|
|
118
|
+
entry["label"] = project["label"] if project["label"]
|
|
119
|
+
entry["path"] = project["path"]
|
|
120
|
+
entry["remote"] = project["remote"] if project["remote"]
|
|
121
|
+
updated["topology"]["projects"] << entry
|
|
122
|
+
persist(path, updated, project, dry_run, "would_create", "created")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def relabel(path, document, project, dry_run)
|
|
126
|
+
updated = deep_copy(document)
|
|
127
|
+
entry = projects_in(updated).find { |candidate| candidate["id"] == project["id"] }
|
|
128
|
+
entry["label"] = project["label"]
|
|
129
|
+
persist(path, updated, project, dry_run, "would_update", "updated")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Validate the updated document BEFORE the dry-run branch
|
|
133
|
+
# (review 8xikzhvj): a dry run predicts the wet run's
|
|
134
|
+
# validation failures instead of a success the wet run cannot
|
|
135
|
+
# deliver.
|
|
136
|
+
def persist(path, updated, project, dry_run, dry_status, wet_status)
|
|
137
|
+
begin
|
|
138
|
+
Atoms::TopologySchema.normalize!(updated)
|
|
139
|
+
rescue Ace::Lab::InvalidConfigurationError => e
|
|
140
|
+
return failure(path,
|
|
141
|
+
"registering project '#{project['id']}' would produce an invalid lab configuration: #{e.message}")
|
|
142
|
+
end
|
|
143
|
+
return {"path" => path, "status" => dry_status, "message" => nil} if dry_run
|
|
144
|
+
|
|
145
|
+
begin
|
|
146
|
+
require "fileutils"
|
|
147
|
+
dir = File.dirname(path)
|
|
148
|
+
unsafe = unsafe_symlink_reason(path)
|
|
149
|
+
return failure(path, unsafe) if unsafe
|
|
150
|
+
|
|
151
|
+
FileUtils.mkdir_p(dir)
|
|
152
|
+
# Atomic write (review 8xikzhvk): a crash mid-write must
|
|
153
|
+
# never leave a truncated cascade document behind. The
|
|
154
|
+
# temporary file is created EXCLUSIVELY (O_CREAT|O_EXCL)
|
|
155
|
+
# under an unguessable name: a pre-planted symlink at a
|
|
156
|
+
# predictable `.tmp.<PID>` path must never redirect the
|
|
157
|
+
# privileged write outside the project (LAB-1/F3) —
|
|
158
|
+
# O_EXCL refuses to follow an existing symlink, and a
|
|
159
|
+
# unique random name leaves nothing predictable to plant.
|
|
160
|
+
tmp = write_exclusive(dir, YAML.dump(updated))
|
|
161
|
+
File.rename(tmp, path)
|
|
162
|
+
rescue SystemCallError, IOError => e
|
|
163
|
+
File.delete(tmp) if defined?(tmp) && tmp && File.exist?(tmp)
|
|
164
|
+
return failure(path, "writing #{path} failed: #{e.message}")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
{"path" => path, "status" => wet_status, "message" => nil}
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# A repository-controlled symlink among the registry's ancestor
|
|
171
|
+
# directories (`.ace`, `.ace/lab`) must never be followed by a
|
|
172
|
+
# privileged mkdir/write: refuse before any mutation.
|
|
173
|
+
def unsafe_symlink_reason(registry_path)
|
|
174
|
+
[File.dirname(registry_path), File.dirname(File.dirname(registry_path))].each do |ancestor|
|
|
175
|
+
stat = File.lstat(ancestor)
|
|
176
|
+
if stat.symlink?
|
|
177
|
+
return "'#{ancestor}' is a symbolic link; refusing to follow it with installer " \
|
|
178
|
+
"privileges (remove the link and rerun)"
|
|
179
|
+
end
|
|
180
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Create the temp file exclusively (never following a symlink),
|
|
187
|
+
# write the payload, close, and return the path. Raises
|
|
188
|
+
# SystemCallError/IOError on failure so the caller classifies.
|
|
189
|
+
def write_exclusive(dir, payload)
|
|
190
|
+
require "securerandom"
|
|
191
|
+
100.times do
|
|
192
|
+
candidate = File.join(dir, ".config.yml.tmp.#{Process.pid}.#{SecureRandom.hex(8)}")
|
|
193
|
+
next if File.exist?(candidate) # includes symlinks (lstat)
|
|
194
|
+
|
|
195
|
+
begin
|
|
196
|
+
file = File.open(candidate, File::RDWR | File::CREAT | File::EXCL, 0o600)
|
|
197
|
+
rescue Errno::EEXIST
|
|
198
|
+
next
|
|
199
|
+
end
|
|
200
|
+
begin
|
|
201
|
+
file.write(payload)
|
|
202
|
+
ensure
|
|
203
|
+
file.close
|
|
204
|
+
end
|
|
205
|
+
return candidate
|
|
206
|
+
end
|
|
207
|
+
raise SystemCallError.new("could not create an exclusive temporary file in '#{dir}'", 0)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Contradiction and location-conflict detection, checked before
|
|
211
|
+
# any write: same id with a different path or remote, or the same
|
|
212
|
+
# absolute path registered under a different id.
|
|
213
|
+
def conflicting_registration(document, project)
|
|
214
|
+
projects_in(document).each do |entry|
|
|
215
|
+
next unless entry.is_a?(Hash)
|
|
216
|
+
|
|
217
|
+
if entry["id"] == project["id"]
|
|
218
|
+
if entry["path"] && entry["path"] != project["path"]
|
|
219
|
+
return "project '#{project['id']}' is registered at '#{entry['path']}' " \
|
|
220
|
+
"but the request declares '#{project['path']}'"
|
|
221
|
+
end
|
|
222
|
+
if entry["remote"] && project["remote"] && entry["remote"] != project["remote"]
|
|
223
|
+
return "project '#{project['id']}' is registered with remote '#{entry['remote']}' " \
|
|
224
|
+
"but the request declares '#{project['remote']}'"
|
|
225
|
+
end
|
|
226
|
+
elsif entry["path"] && entry["path"] == project["path"]
|
|
227
|
+
return "path '#{project['path']}' is already registered as project '#{entry['id']}'"
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
nil
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def projects_in(document)
|
|
234
|
+
topology = document["topology"]
|
|
235
|
+
return [] unless topology.is_a?(Hash) && topology["projects"].is_a?(Array)
|
|
236
|
+
|
|
237
|
+
topology["projects"]
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def deep_copy(document)
|
|
241
|
+
require "yaml"
|
|
242
|
+
YAML.safe_load(YAML.dump(document), permitted_classes: [Date], aliases: true)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def failure(path, message)
|
|
246
|
+
{"path" => path, "status" => "failed", "message" => message}
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Lab
|
|
8
|
+
module Molecules
|
|
9
|
+
# Idempotently ensures a Git repository exists at the project path
|
|
10
|
+
# with the declared origin remote (task 8xg.t.sbz.7.1). A missing or
|
|
11
|
+
# empty target is cloned from the remote; an existing repository is
|
|
12
|
+
# verified against the declared remote — a different origin is a
|
|
13
|
+
# contradictory registration that is refused, never overwritten.
|
|
14
|
+
# All Git access goes through argv arrays (ADR-031); failures
|
|
15
|
+
# surface the exit status and the first stderr line only.
|
|
16
|
+
class RepositoryProvisioner
|
|
17
|
+
attr_reader :runner, :dry_run
|
|
18
|
+
|
|
19
|
+
def initialize(runner: nil, dry_run: false)
|
|
20
|
+
@runner = runner || method(:run_argv)
|
|
21
|
+
@dry_run = dry_run
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param path [String] absolute target directory
|
|
25
|
+
# @param remote [String, nil] declared origin remote URL
|
|
26
|
+
# @return [Hash] {path:, remote:, status:, message:} where status
|
|
27
|
+
# is created / exists / would_create / failed
|
|
28
|
+
def call(path:, remote:)
|
|
29
|
+
if repository?(path)
|
|
30
|
+
converge_existing(path, remote)
|
|
31
|
+
elsif foreign_directory?(path)
|
|
32
|
+
failure(path, remote,
|
|
33
|
+
"'#{path}' exists and is not an empty directory or a Git repository; " \
|
|
34
|
+
"refusing to take over a foreign location")
|
|
35
|
+
elsif remote.nil?
|
|
36
|
+
failure(path, remote,
|
|
37
|
+
"'#{path}' has no repository and no --remote was given; " \
|
|
38
|
+
"nothing to prepare the project from")
|
|
39
|
+
elsif dry_run
|
|
40
|
+
{"path" => path, "remote" => remote, "status" => "would_create", "message" => nil}
|
|
41
|
+
else
|
|
42
|
+
clone(path, remote)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def converge_existing(path, remote)
|
|
49
|
+
origin = origin_url(path)
|
|
50
|
+
return {"path" => path, "remote" => origin || remote, "status" => "exists", "message" => nil} if remote.nil?
|
|
51
|
+
return {"path" => path, "remote" => origin, "status" => "exists", "message" => nil} if origin == remote
|
|
52
|
+
|
|
53
|
+
if origin
|
|
54
|
+
failure(path, remote,
|
|
55
|
+
"'#{path}' is a Git repository with origin '#{origin}' but the request " \
|
|
56
|
+
"declares '#{remote}'; refusing to overwrite an existing registration")
|
|
57
|
+
elsif dry_run
|
|
58
|
+
{"path" => path, "remote" => remote, "status" => "would_create",
|
|
59
|
+
"message" => "would set origin to '#{remote}'"}
|
|
60
|
+
else
|
|
61
|
+
result = runner.call(["git", "-C", path, "remote", "add", "origin", remote])
|
|
62
|
+
return failure(path, remote, "setting origin on '#{path}' failed: #{git_error(result)}") unless success?(result)
|
|
63
|
+
|
|
64
|
+
{"path" => path, "remote" => remote, "status" => "created", "message" => nil}
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def clone(path, remote)
|
|
69
|
+
result = runner.call(["git", "clone", remote, path])
|
|
70
|
+
return failure(path, remote, "cloning '#{remote}' into '#{path}' failed: #{git_error(result)}") unless success?(result)
|
|
71
|
+
|
|
72
|
+
{"path" => path, "remote" => remote, "status" => "created", "message" => nil}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Read-only probes: safe in dry-run (the prediction contract from
|
|
76
|
+
# ace-lab prepare applies — predictions use read-only filesystem
|
|
77
|
+
# and Git queries, effects are suppressed).
|
|
78
|
+
# The path must BE the repository toplevel, not merely sit inside
|
|
79
|
+
# one: a subdirectory of a parent work tree also answers
|
|
80
|
+
# `--is-inside-work-tree` with true, which would let a foreign
|
|
81
|
+
# directory bypass the takeover refusal and mutate the parent
|
|
82
|
+
# repository's origin (review 8xikzhvh).
|
|
83
|
+
def repository?(path)
|
|
84
|
+
return false unless File.directory?(path)
|
|
85
|
+
|
|
86
|
+
result = runner.call(["git", "-C", path, "rev-parse", "--show-toplevel"])
|
|
87
|
+
success?(result) && same_location?(result["out"].to_s.strip, path)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Git prints the physical toplevel (symlinks resolved), so the
|
|
91
|
+
# comparison resolves both sides; a path that cannot be
|
|
92
|
+
# resolved falls back to the lexical spelling.
|
|
93
|
+
def same_location?(reported, path)
|
|
94
|
+
File.realpath(reported) == File.realpath(path)
|
|
95
|
+
rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES
|
|
96
|
+
File.expand_path(reported) == File.expand_path(path)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def foreign_directory?(path)
|
|
100
|
+
File.exist?(path) && !(File.directory?(path) && Dir.children(path).empty?)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Raw stored remote URL: `git remote get-url` applies
|
|
104
|
+
# url.<base>.insteadOf rewrites from the invoking user's git
|
|
105
|
+
# config, so an identical project would false-fail the
|
|
106
|
+
# idempotent reuse check under a common SSH rewrite (LAB-7).
|
|
107
|
+
# Connection rewriting is Git transport policy, not a changed
|
|
108
|
+
# project identity — compare the stored URL the same way the
|
|
109
|
+
# final verifier does.
|
|
110
|
+
def origin_url(path)
|
|
111
|
+
result = runner.call(["git", "-C", path, "config", "--get", "remote.origin.url"])
|
|
112
|
+
success?(result) ? result["out"].to_s.strip : nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def success?(result)
|
|
116
|
+
result["status"] == "ok"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def git_error(result)
|
|
120
|
+
first_line = result["err"].to_s.lines.map(&:strip).reject(&:empty?).first
|
|
121
|
+
detail = first_line ? " #{first_line}" : ""
|
|
122
|
+
"git exited with status #{result['status']}#{detail}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def failure(path, remote, message)
|
|
126
|
+
{"path" => path, "remote" => remote, "status" => "failed", "message" => message}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def run_argv(argv)
|
|
130
|
+
out, err, status = Open3.capture3(*argv)
|
|
131
|
+
{"status" => status.success? ? "ok" : (status.exitstatus || "signal #{status.termsig}"),
|
|
132
|
+
"out" => out, "err" => err}
|
|
133
|
+
rescue Errno::ENOENT
|
|
134
|
+
{"status" => "enoent", "out" => "", "err" => ""}
|
|
135
|
+
rescue SystemCallError => e
|
|
136
|
+
{"status" => "exec_failed", "out" => "", "err" => e.class.name}
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|