ace-lab 0.13.0 → 0.14.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.
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "auth/profile_schema"
4
+ require_relative "auth/lease_store"
5
+ require_relative "auth/providers"
6
+ require_relative "auth/credential_handoff"
7
+ require_relative "auth/plugins/env_provider"
8
+ require_relative "auth/plugins/forgejo_provider"
9
+ require_relative "auth/plugins/github_provider"
10
+ require_relative "auth/service"
11
+
12
+ module Ace
13
+ module Lab
14
+ # ace-lab auth (task 8xs.t.pu0.7): short-lived, role-scoped access
15
+ # for delegated work. The public contract and lease lifecycle live
16
+ # here; provider protocols live in plugins. Explicit registration
17
+ # of the bundled plugins — the same precedented pattern as the
18
+ # ace-git provider registry.
19
+ module Auth
20
+ Providers.register("env", Plugins::EnvProvider.new)
21
+ Providers.register("forgejo", Plugins::ForgejoProvider.new)
22
+ Providers.register("github", Plugins::GithubProvider.new)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Lab
7
+ module CLI
8
+ module Commands
9
+ # `ace-lab auth request|status|exec|revoke|reconcile` (task
10
+ # 8xs.t.pu0.7): the public short-lived-access interface. A
11
+ # single structured JSON report; refusals and errors are
12
+ # non-zero with a reason and the escalation addressee; NO
13
+ # command ever prints a token or exports one to shell history.
14
+ # status is the read-only readiness view (alongside ace-lab
15
+ # verify).
16
+ class Auth < Ace::Support::Cli::Command
17
+ include Ace::Support::Cli::Base
18
+
19
+ desc "Short-lived role access: request, status, exec, revoke, reconcile"
20
+
21
+ argument :action, required: true, desc: "request | status | exec | revoke | reconcile"
22
+ argument :target, required: false, desc: "Lease id (status/exec/revoke)"
23
+
24
+ option :profile, type: :string, desc: "Auth profile name (request)"
25
+ option :role, type: :string, desc: "Delegation role (request)"
26
+ option :task, type: :string, desc: "Task reference (request)"
27
+ option :assignment, type: :string, desc: "Assignment reference (request)"
28
+ option :repo, type: :string, repeat: true, desc: "Repository (request, repeatable)"
29
+ option :ttl, type: :integer, desc: "Requested TTL minutes (request)"
30
+ option :cmd, type: :string, repeat: true,
31
+ desc: "Exec command, one argument per repeat, in order (exec)"
32
+ option :force, type: :boolean, default: false,
33
+ desc: "Close a revoke-pending lease whose credential is gone (operator-forced terminal; revoke)"
34
+ option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
35
+
36
+ def call(action:, target: nil, **options)
37
+ require_relative "../../auth"
38
+ service = ::Ace::Lab::Auth::Service.new
39
+
40
+ result = case action
41
+ when "request"
42
+ require_options!(options, :profile, :role, :task, :assignment)
43
+ service.request(profile_name: options[:profile], role: options[:role],
44
+ task: options[:task], assignment: options[:assignment],
45
+ repos: Array(options[:repo]), ttl_minutes: options[:ttl])
46
+ when "status"
47
+ service.status(lease_id: target)
48
+ when "exec"
49
+ raise Ace::Support::Cli::Error.new("exec requires a lease id and a command") if target.nil?
50
+
51
+ command = Array(options[:cmd])
52
+ raise Ace::Support::Cli::Error.new("exec requires a command (--cmd, one argument per repeat)") if command.empty?
53
+
54
+ service.exec(lease_id: target, argv: command)
55
+ when "revoke"
56
+ raise Ace::Support::Cli::Error, "revoke requires a lease id" if target.nil?
57
+
58
+ {"lease" => target, "cleanup" => service.revoke(lease_id: target,
59
+ force: options[:force]).to_s}
60
+ when "reconcile"
61
+ {"reconciled" => service.reconcile}
62
+ else
63
+ raise Ace::Support::Cli::Error,
64
+ "unknown auth action '#{action}' (request, status, exec, revoke, reconcile)"
65
+ end
66
+
67
+ render(result)
68
+ # The child's REAL exit code is the exec exit code — raised
69
+ # AFTER the single report render, so both outcomes stay
70
+ # visible without duplicating the report.
71
+ if action == "exec" && result["exit"] && !result["exit"].zero?
72
+ raise Ace::Support::Cli::Error.new("exec exited #{result["exit"]}",
73
+ exit_code: result["exit"])
74
+ end
75
+ rescue ::Ace::Lab::Auth::Refusal => e
76
+ raise Ace::Support::Cli::Error.new("refused: #{e.message}")
77
+ rescue ::Ace::Lab::Auth::Error => e
78
+ raise Ace::Support::Cli::Error.new(e.message)
79
+ end
80
+
81
+ private
82
+
83
+ def require_options!(options, *keys)
84
+ missing = keys.reject { |key| options[key] && !options[key].to_s.strip.empty? }
85
+ unless missing.empty?
86
+ raise Ace::Support::Cli::Error.new("missing required options: #{missing.map { |k| "--#{k}" }.join(' ')}")
87
+ end
88
+ end
89
+
90
+ def render(result)
91
+ # A single structured JSON report: the data is the interface
92
+ # agents consume (no table form exists for auth reports).
93
+ puts JSON.pretty_generate(result)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -18,14 +18,16 @@ module Ace
18
18
  "accounts" => ->(item) { item["username"] },
19
19
  "directories" => ->(item) { item["path"] },
20
20
  "keys" => ->(item) { "#{item['account']}:#{item['path']}" },
21
- "roles" => ->(item) { item["id"] }
21
+ "roles" => ->(item) { item["id"] },
22
+ "gems" => ->(item) { "#{item['name']} #{item['version']}" }
22
23
  }.freeze
23
24
 
24
25
  # "SHA256:" plus the first 20 base64 characters is enough to
25
26
  # eyeball-match a key without ballooning the table row.
26
27
  FINGERPRINT_WIDTH = 27
27
28
 
28
- desc "Prepare accounts, directories, permissions, keys and roles from the trusted install manifest"
29
+ desc "Prepare accounts, directories, permissions, keys, roles and the declared " \
30
+ "gem set from the trusted install manifest"
29
31
 
30
32
  example [
31
33
  "--dry-run # Predict all sections without mutating",
@@ -16,13 +16,13 @@ module Ace
16
16
  "accounts" => "account", "directories" => "directory", "keys" => "account",
17
17
  "roles" => "id", "repositories" => "project", "grants" => "principal",
18
18
  "registry" => "project", "overseer" => "project",
19
- "adapters" => "adapter", "tools" => "tool"
19
+ "adapters" => "adapter", "tools" => "tool", "gems" => "gem"
20
20
  }.freeze
21
21
 
22
22
  desc "Verify the installed lab's readiness under the target accounts (read-only): " \
23
23
  "accounts, directories, key pairs, role instructions, repository origins and " \
24
24
  "target-executor access, grant-based project visibility, private _overseer " \
25
- "folders, configured communication/HITL adapters, required tools"
25
+ "folders, configured communication/HITL adapters, required tools, declared gem set"
26
26
 
27
27
  example [
28
28
  "--format json # Machine-readable report for the overseer / next tool",
data/lib/ace/lab/cli.rb CHANGED
@@ -13,6 +13,7 @@ require_relative "cli/commands/prepare"
13
13
  require_relative "cli/commands/project_add"
14
14
  require_relative "cli/commands/install"
15
15
  require_relative "cli/commands/verify"
16
+ require_relative "cli/commands/auth"
16
17
 
17
18
  module Ace
18
19
  module Lab
@@ -33,7 +34,8 @@ module Ace
33
34
  ["prepare", "Prepare accounts, directories, permissions, keys and roles from the trusted install manifest"],
34
35
  ["project add", "Register a project and make it ready for team work (repository, _overseer, lab config)"],
35
36
  ["install", "Perform the full new-lab installation from the trusted install manifest"],
36
- ["verify", "Verify the installed lab's readiness under the target accounts (read-only)"]
37
+ ["verify", "Verify the installed lab's readiness under the target accounts (read-only)"],
38
+ ["auth", "Short-lived role access: request, status, exec, revoke, reconcile"]
37
39
  ].freeze
38
40
 
39
41
  HELP_EXAMPLES = [
@@ -67,6 +69,7 @@ module Ace
67
69
  register "project add", CLI::Commands::ProjectAdd.new
68
70
  register "install", CLI::Commands::Install.new
69
71
  register "verify", CLI::Commands::Verify.new
72
+ register "auth", CLI::Commands::Auth.new
70
73
 
71
74
  # Register version command
72
75
  version_cmd = Ace::Support::Cli::VersionCommand.build(
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require "fileutils"
5
+ require "pathname"
6
+
7
+ module Ace
8
+ module Lab
9
+ module Molecules
10
+ # Shared extension-projection primitives (task 8xs.t.pu0.0): the
11
+ # ONE place that knows how the installed gem's handbook/extensions
12
+ # tree is located, enumerated, and projected safely — used by both
13
+ # ExtensionProvisioner (convergence) and Verify (readiness), so
14
+ # the two can never drift.
15
+ module ExtensionProjection
16
+ RECEIPT_NAME = ".ace-lab-extension-projection.json"
17
+
18
+ module_function
19
+
20
+ # The installed gem's own tree at the EXACT declared version.
21
+ # @return [String, nil] the handbook/extensions directory or nil
22
+ # when the gem (or its extensions tree) is not installed
23
+ def gem_source_root(gem_home, name, version)
24
+ candidate = File.join(gem_home, "gems", "#{name}-#{version}")
25
+ extensions = File.join(candidate, "handbook", "extensions")
26
+ File.directory?(extensions) ? extensions : nil
27
+ end
28
+
29
+ # Relative path → absolute source file for every file under the
30
+ # gem's handbook/extensions tree (e.g. "ace-wake/index.js").
31
+ # @return [Hash<String, String>]
32
+ def source_files(source_root)
33
+ Dir.glob(File.join(source_root, "**", "*")).select { |path| File.file?(path) }
34
+ .to_h { |path| [path.delete_prefix("#{source_root}/"), path] }
35
+ end
36
+
37
+ # Resolve and fully validate a projection destination before
38
+ # anything is written or verified (the provider_syncer
39
+ # validate_projection_destination posture): every path
40
+ # component under the projection directory must be a directory
41
+ # or creatable — a symlinked component could redirect the
42
+ # write outside the extensions directory, so it is refused.
43
+ # @param output_dir [String] manifest-declared extension dir
44
+ # @param relative_path [String] receipt-relative file path
45
+ # @return [String] the validated absolute destination
46
+ # @raise [Ace::Lab::Error] invalid or escaping destination
47
+ def validate_destination!(output_dir, relative_path)
48
+ segments = relative_path.to_s.split(File::SEPARATOR)
49
+ if segments.empty? || segments.any? { |segment| segment.empty? || segment == "." || segment == ".." }
50
+ raise Ace::Lab::Error, "cannot project #{relative_path.inspect}: invalid destination path"
51
+ end
52
+
53
+ begin
54
+ current = Pathname.new(File.realpath(output_dir))
55
+ rescue Errno::ENOENT
56
+ # the projection directory itself does not exist yet — no
57
+ # component below it can escape
58
+ return File.join(output_dir, *segments)
59
+ end
60
+ segments.each_with_index do |segment, index|
61
+ current = current.join(segment)
62
+ if File.symlink?(current.to_s)
63
+ raise Ace::Lab::Error,
64
+ "cannot project #{relative_path} into #{output_dir}: a symlinked path component " \
65
+ "would escape the projection directory"
66
+ end
67
+ # The FINAL segment is the file itself — only intermediate
68
+ # components must be directories (compare by position, not
69
+ # value: "a/a/index.js" repeats "a" legitimately).
70
+ next if index == segments.size - 1
71
+
72
+ if !current.directory? && File.exist?(current.to_s)
73
+ raise Ace::Lab::Error,
74
+ "cannot project #{relative_path} into #{output_dir}: #{current} exists and is not a directory"
75
+ end
76
+ end
77
+ if File.directory?(current.to_s)
78
+ # Receipt ownership of a path never authorizes writing
79
+ # through a directory planted at that path — cp would
80
+ # reinterpret the destination as a container.
81
+ raise Ace::Lab::Error,
82
+ "cannot project #{relative_path} into #{output_dir}: #{current} exists and is a directory"
83
+ end
84
+ current.to_s
85
+ rescue Errno::ENOENT
86
+ raise Ace::Lab::Error, "cannot project #{relative_path} into #{output_dir}: projection directory vanished"
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "extension_projection"
5
+ require "etc"
6
+ require "fileutils"
7
+ require "json"
8
+
9
+ module Ace
10
+ module Lab
11
+ module Molecules
12
+ # Projects harness extension assets from an INSTALLED gem's exact
13
+ # declared version into the manifest-declared extension directory
14
+ # (task 8xs.t.pu0.0): the manual host-lane copy of the ace-wake
15
+ # extension is replaced by a converged, repeatable projection.
16
+ #
17
+ # Source of truth is the installed gem tree itself —
18
+ # `<gem_home>/gems/<name>-<version>/handbook/extensions` — so the
19
+ # projection always matches the exact version the manifest
20
+ # declared (never the checkout, never a newer gem beside it).
21
+ # Each target directory carries a receipt
22
+ # (`.ace-lab-extension-projection.json`) recording the producing
23
+ # gem, version and file list; ONLY receipt-listed files are ever
24
+ # removed, so user-authored extensions in the same directory are
25
+ # never touched. The report names the version that WAS loaded
26
+ # before this run (`loaded_version`) and whether the projection
27
+ # changed (`restart_required`) — a changed projection means the
28
+ # running harness processes still hold the OLD extension; the
29
+ # installer REPORTS the restart need, it never restarts an
30
+ # executor on its own.
31
+ class ExtensionProvisioner
32
+ RECEIPT_NAME = ExtensionProjection::RECEIPT_NAME
33
+ FILE_MODE = 0o644
34
+ DIR_MODE = 0o755
35
+
36
+ # @param gem_home [String] target runtime the gems were
37
+ # converged into
38
+ # @param dry_run [Boolean] predict without writing
39
+ # @param account_lookup [Proc, nil] passwd lookup seam for the
40
+ # declared accounts (projection ownership convergence)
41
+ def initialize(gem_home:, dry_run: false, account_lookup: nil)
42
+ @gem_home = gem_home
43
+ @dry_run = dry_run
44
+ @account_lookup = account_lookup || method(:lookup_passwd)
45
+ end
46
+
47
+ # @param extensions [Array<Hash>] normalized manifest gemset
48
+ # extensions entries ({"gem" =>, "path" =>, "account" =>})
49
+ # @param gems [Array<Hash>] declared gem set (name/version)
50
+ # @return [Array<Hash>] one report per entry:
51
+ # {extension:, gem:, version:, path:, account:, status:,
52
+ # loaded_version:, restart_required:, files:, message:}
53
+ def call(extensions:, gems:)
54
+ extensions.map do |entry|
55
+ converge(entry, gems)
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def converge(entry, gems)
62
+ declared = gems.find { |gem| gem["name"] == entry["gem"] }
63
+ version = declared && declared["version"]
64
+ source = version && ExtensionProjection.gem_source_root(@gem_home, entry["gem"], version)
65
+
66
+ if source.nil?
67
+ # Fresh-lab dry run (the gem phase predicts would_install
68
+ # for the same missing gem): predict the projection the
69
+ # converged gem would feed instead of reporting a failure
70
+ # the wet run would not produce.
71
+ if @dry_run
72
+ return entry_report(entry, version, "would_project", nil, true, [])
73
+ end
74
+ return failed(entry, version,
75
+ "gem '#{entry["gem"]}' is not installed in runtime '#{@gem_home}' at declared " \
76
+ "version '#{version}'; converge the gemset first (ace-lab install)")
77
+ end
78
+
79
+ expected = ExtensionProjection.source_files(source)
80
+ if expected.empty?
81
+ return failed(entry, version,
82
+ "gem '#{entry["gem"]}' #{version} carries no handbook/extensions tree")
83
+ end
84
+
85
+ output_dir = entry["path"]
86
+ escape = projection_escape_reason(output_dir, expected.keys)
87
+ return failed(entry, version, escape) if escape
88
+
89
+ receipt = read_receipt(output_dir)
90
+ loaded_version = receipt && receipt["gem"] == entry["gem"] ? receipt["version"] : nil
91
+
92
+ changed = projection_changed?(output_dir, expected, receipt, version)
93
+ if !changed
94
+ converge_modes(output_dir, expected, entry)
95
+ return entry_report(entry, version, "unchanged", loaded_version, false,
96
+ receipt ? receipt.fetch("files") : expected.keys)
97
+ end
98
+ # dry-run predicts the projection (restart needed) without
99
+ # writing anything
100
+ return entry_report(entry, version, "projected", loaded_version, true, expected.keys) if @dry_run
101
+
102
+ project(output_dir, expected, entry, version)
103
+ entry_report(entry, version, "projected", loaded_version, true, expected.keys)
104
+ rescue SystemCallError => e
105
+ failed(entry, version,
106
+ "extension projection into '#{output_dir}' failed (#{e.class.name}); " \
107
+ "rerun ace-lab prepare with sudo or adjust ownership manually")
108
+ end
109
+
110
+ def failed(entry, version, message)
111
+ entry_report(entry, version, "failed", nil, false, [])
112
+ .merge("message" => message)
113
+ end
114
+
115
+ def entry_report(entry, version, status, loaded_version, restart_required, files)
116
+ {"extension" => entry["gem"], "gem" => entry["gem"], "version" => version,
117
+ "path" => entry["path"], "account" => entry["account"], "status" => status,
118
+ "loaded_version" => loaded_version, "restart_required" => restart_required,
119
+ "files" => files, "message" => nil}
120
+ end
121
+
122
+ # Refuse a destination that escapes the projection directory
123
+ # through a symlinked component BEFORE anything is written (the
124
+ # provider_syncer validate_projection_destination posture).
125
+ def projection_escape_reason(output_dir, relative_paths)
126
+ relative_paths.each { |relative| ExtensionProjection.validate_destination!(output_dir, relative) }
127
+ nil
128
+ rescue Ace::Lab::Error => e
129
+ e.message
130
+ end
131
+
132
+ # A projection changed when the receipt is absent, names a
133
+ # different version, lists a different file set, or any
134
+ # expected file's content differs on disk.
135
+ def projection_changed?(output_dir, expected, receipt, version)
136
+ return true if receipt.nil?
137
+ return true if receipt["version"] != version
138
+ return true unless receipt.fetch("files", []).sort == expected.keys.sort
139
+
140
+ expected.any? do |relative, source|
141
+ target = File.join(output_dir, relative)
142
+ !File.file?(target) || !FileUtils.compare_file(source, target)
143
+ end
144
+ end
145
+
146
+ def project(output_dir, expected, entry, version)
147
+ FileUtils.mkdir_p(output_dir)
148
+ expected.each do |relative, source|
149
+ target = File.join(output_dir, relative)
150
+ FileUtils.mkdir_p(File.dirname(target))
151
+ FileUtils.cp(source, target)
152
+ end
153
+
154
+ # Only receipt-owned files no longer in the projection are
155
+ # removed; a removal that fails keeps its receipt entry so a
156
+ # later run retries it (user-authored extensions are never
157
+ # touched).
158
+ previous = read_receipt(output_dir)&.fetch("files", []) || []
159
+ retained = previous.filter_map do |relative|
160
+ target = File.join(output_dir, relative)
161
+ next relative if expected.key?(relative)
162
+
163
+ FileUtils.rm_f(target)
164
+ File.exist?(target) ? relative : nil
165
+ end
166
+
167
+ write_receipt(output_dir, entry, version, (expected.keys + retained).sort)
168
+ converge_modes(output_dir, expected, entry)
169
+ end
170
+
171
+ # Mode/ownership convergence (the key-provisioner posture):
172
+ # projected files carry owner read/write plus world read
173
+ # (0644), projection directories 0755, and both are owned by
174
+ # the declared account whenever the passwd lookup resolves it —
175
+ # so the target executor can always read its extensions.
176
+ def converge_modes(output_dir, expected, entry)
177
+ account = @account_lookup.call(entry["account"])
178
+ targets = expected.keys.map { |relative| File.join(output_dir, relative) }
179
+ targets << File.join(output_dir, RECEIPT_NAME)
180
+ targets.each do |target|
181
+ next unless File.file?(target)
182
+
183
+ FileUtils.chmod(FILE_MODE, target)
184
+ FileUtils.chown(account.uid, account.gid, target) if account
185
+ end
186
+ root = File.realpath(output_dir)
187
+ dirs = expected.keys.filter_map do |relative|
188
+ File.dirname(File.join(root, relative)) if relative.include?(File::SEPARATOR)
189
+ end.uniq
190
+ until dirs.empty?
191
+ dir = dirs.pop
192
+ FileUtils.chmod(DIR_MODE, dir)
193
+ FileUtils.chown(account.uid, account.gid, dir) if account
194
+ parent = File.dirname(dir)
195
+ dirs << parent if dir != root && parent.start_with?("#{root}/") && !dirs.include?(parent) && parent != root
196
+ end
197
+ end
198
+
199
+ def write_receipt(output_dir, entry, version, files)
200
+ document = {
201
+ "gem" => entry["gem"], "version" => version,
202
+ "projected_at" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
203
+ "files" => files.sort
204
+ }
205
+ File.write(File.join(output_dir, RECEIPT_NAME), JSON.pretty_generate(document))
206
+ end
207
+
208
+ def read_receipt(output_dir)
209
+ path = File.join(output_dir, RECEIPT_NAME)
210
+ return nil unless File.file?(path)
211
+
212
+ document = JSON.parse(File.read(path))
213
+ document.is_a?(Hash) ? document : nil
214
+ rescue JSON::ParserError, Errno::EACCES
215
+ nil
216
+ end
217
+
218
+ def lookup_passwd(username)
219
+ Etc.getpwnam(username)
220
+ rescue ArgumentError
221
+ nil
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end