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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +363 -104
  3. data/README.md +66 -100
  4. data/docs/usage.md +529 -592
  5. data/lib/ace/lab/atoms/install_schema.rb +373 -0
  6. data/lib/ace/lab/cli/commands/install.rb +161 -0
  7. data/lib/ace/lab/cli/commands/prepare.rb +118 -0
  8. data/lib/ace/lab/cli/commands/project_add.rb +101 -0
  9. data/lib/ace/lab/cli/commands/status.rb +109 -0
  10. data/lib/ace/lab/cli/commands/verify.rb +115 -0
  11. data/lib/ace/lab/cli.rb +21 -23
  12. data/lib/ace/lab/install_manifest_path.rb +23 -0
  13. data/lib/ace/lab/models/status_report.rb +38 -0
  14. data/lib/ace/lab/molecules/account_provisioner.rb +196 -0
  15. data/lib/ace/lab/molecules/config_state_classifier.rb +145 -0
  16. data/lib/ace/lab/molecules/directory_provisioner.rb +208 -0
  17. data/lib/ace/lab/molecules/grant_provisioner.rb +250 -0
  18. data/lib/ace/lab/molecules/grant_resolver.rb +34 -23
  19. data/lib/ace/lab/molecules/hitl_routes.rb +263 -0
  20. data/lib/ace/lab/molecules/install_manifest_loader.rb +64 -0
  21. data/lib/ace/lab/molecules/key_provisioner.rb +736 -0
  22. data/lib/ace/lab/molecules/lab_state_scanner.rb +55 -0
  23. data/lib/ace/lab/molecules/overseer_provisioner.rb +188 -0
  24. data/lib/ace/lab/molecules/project_registry.rb +251 -0
  25. data/lib/ace/lab/molecules/repository_provisioner.rb +141 -0
  26. data/lib/ace/lab/molecules/role_verifier.rb +223 -0
  27. data/lib/ace/lab/organisms/install.rb +213 -0
  28. data/lib/ace/lab/organisms/prepare.rb +156 -0
  29. data/lib/ace/lab/organisms/project_add.rb +170 -0
  30. data/lib/ace/lab/organisms/status_service.rb +70 -0
  31. data/lib/ace/lab/organisms/verify.rb +768 -0
  32. data/lib/ace/lab/version.rb +1 -1
  33. data/lib/ace/lab.rb +34 -6
  34. metadata +34 -108
  35. data/lib/ace/lab/atoms/protected_publication_input.rb +0 -50
  36. data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +0 -114
  37. data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +0 -86
  38. data/lib/ace/lab/atoms/service_input.rb +0 -92
  39. data/lib/ace/lab/cli/commands/assignment.rb +0 -92
  40. data/lib/ace/lab/cli/commands/command_execute.rb +0 -28
  41. data/lib/ace/lab/cli/commands/run.rb +0 -65
  42. data/lib/ace/lab/cli/commands/service.rb +0 -240
  43. data/lib/ace/lab/models/service_receipt.rb +0 -18
  44. data/lib/ace/lab/molecules/command_owner_admission.rb +0 -183
  45. data/lib/ace/lab/molecules/command_policy.rb +0 -158
  46. data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
  47. data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
  48. data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
  49. data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
  50. data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
  51. data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -152
  52. data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
  53. data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
  54. data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
  55. data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
  56. data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
  57. data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
  58. data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
  59. data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
  60. data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
  61. data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
  62. data/lib/ace/lab/molecules/service_executor.rb +0 -99
  63. data/lib/ace/lab/molecules/service_policy.rb +0 -289
  64. data/lib/ace/lab/organisms/authority_composition.rb +0 -87
  65. data/lib/ace/lab/organisms/command_executor.rb +0 -220
  66. data/lib/ace/lab/organisms/command_run_client.rb +0 -131
  67. data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -120
  68. data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -153
  69. data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -679
  70. data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
  71. data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
  72. data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
  73. data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
  74. data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
  75. data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
  76. data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
  77. data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
  78. data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
  79. 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