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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +132 -1
- data/docs/usage.md +168 -4
- data/lib/ace/lab/atoms/install_schema.rb +135 -3
- data/lib/ace/lab/auth/credential_handoff.rb +47 -0
- data/lib/ace/lab/auth/lease_store.rb +212 -0
- data/lib/ace/lab/auth/plugins/env_provider.rb +60 -0
- data/lib/ace/lab/auth/plugins/forgejo_provider.rb +189 -0
- data/lib/ace/lab/auth/plugins/github_provider.rb +207 -0
- data/lib/ace/lab/auth/profile_schema.rb +152 -0
- data/lib/ace/lab/auth/providers.rb +66 -0
- data/lib/ace/lab/auth/service.rb +457 -0
- data/lib/ace/lab/auth.rb +25 -0
- data/lib/ace/lab/cli/commands/auth.rb +99 -0
- data/lib/ace/lab/cli/commands/prepare.rb +4 -2
- data/lib/ace/lab/cli/commands/verify.rb +2 -2
- data/lib/ace/lab/cli.rb +4 -1
- data/lib/ace/lab/molecules/extension_projection.rb +91 -0
- data/lib/ace/lab/molecules/extension_provisioner.rb +226 -0
- data/lib/ace/lab/molecules/gemset_provisioner.rb +441 -0
- data/lib/ace/lab/molecules/project_locations.rb +62 -0
- data/lib/ace/lab/organisms/install.rb +13 -8
- data/lib/ace/lab/organisms/prepare.rb +44 -4
- data/lib/ace/lab/organisms/verify.rb +200 -2
- data/lib/ace/lab/version.rb +1 -1
- data/lib/ace/lab.rb +1 -0
- metadata +16 -2
data/lib/ace/lab/auth.rb
ADDED
|
@@ -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
|
|
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
|