ace-lab 0.14.1 → 0.15.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 +6 -0
- data/docs/usage.md +78 -16
- data/lib/ace/lab/atoms/install_schema.rb +71 -5
- data/lib/ace/lab/auth/credential_handoff.rb +34 -10
- data/lib/ace/lab/auth/error.rb +13 -0
- data/lib/ace/lab/auth/lease_store.rb +91 -29
- data/lib/ace/lab/auth/local_command.rb +62 -0
- data/lib/ace/lab/auth/plugins/forgejo_provider.rb +123 -55
- data/lib/ace/lab/auth/profile_schema.rb +6 -1
- data/lib/ace/lab/auth/providers.rb +8 -0
- data/lib/ace/lab/auth/service.rb +350 -112
- data/lib/ace/lab/auth/session_configuration.rb +44 -0
- data/lib/ace/lab/cli/commands/prepare.rb +2 -1
- data/lib/ace/lab/cli/commands/verify.rb +2 -1
- data/lib/ace/lab/molecules/extension_projection.rb +60 -1
- data/lib/ace/lab/molecules/extension_provisioner.rb +21 -4
- data/lib/ace/lab/molecules/gem_payload_permissions.rb +12 -5
- data/lib/ace/lab/molecules/gemset_provisioner.rb +6 -1
- data/lib/ace/lab/organisms/verify.rb +56 -13
- data/lib/ace/lab/version.rb +1 -1
- data/libexec/forgejo-askpass +10 -0
- metadata +12 -2
data/lib/ace/lab/auth/service.rb
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "etc"
|
|
4
|
+
require "date"
|
|
4
5
|
require "json"
|
|
5
6
|
require "open3"
|
|
7
|
+
require "securerandom"
|
|
8
|
+
require "time"
|
|
9
|
+
require "yaml"
|
|
10
|
+
require_relative "error"
|
|
11
|
+
require_relative "local_command"
|
|
6
12
|
|
|
7
13
|
module Ace
|
|
8
14
|
module Lab
|
|
9
15
|
module Auth
|
|
10
|
-
# Explicit error base for the auth surface (the CLI rescues it).
|
|
11
|
-
class Error < ::Ace::Lab::Error; end
|
|
12
|
-
|
|
13
16
|
# The auth service core (task 8xs.t.pu0.7): request / status /
|
|
14
17
|
# exec / revoke / reconcile over the trusted host configuration
|
|
15
18
|
# and the plugin registry. It owns the lease lifecycle and the
|
|
@@ -26,34 +29,20 @@ module Ace
|
|
|
26
29
|
# credential reference — the issuer's whole environment (and
|
|
27
30
|
# any ambient credential override like GH_TOKEN in it) is never
|
|
28
31
|
# inherited past the lease's own scope.
|
|
29
|
-
CHILD_ENV_ALLOWLIST = %w[PATH HOME TMPDIR LANG LC_ALL TZ USER LOGNAME SHELL
|
|
32
|
+
CHILD_ENV_ALLOWLIST = %w[PATH HOME TMPDIR LANG LC_ALL TZ USER LOGNAME SHELL
|
|
33
|
+
ACE_TOOLCHAIN GEM_HOME GEM_PATH].freeze
|
|
30
34
|
|
|
31
35
|
attr_reader :config, :store
|
|
32
36
|
|
|
33
37
|
# @param authorization_path [String] trusted document location
|
|
34
38
|
# @param runner [Proc] argv runner for the exec'd child
|
|
39
|
+
# @param principal [Proc, nil] trusted callable returning the account name
|
|
35
40
|
def initialize(authorization_path: nil, runner: nil, principal: nil)
|
|
41
|
+
raise ArgumentError, "principal must be callable" unless principal.nil? || principal.respond_to?(:call)
|
|
36
42
|
@authorization_path = authorization_path || Ace::Lab.authorization_path
|
|
37
|
-
|
|
38
|
-
# allowlisted child environment is EXACTLY what the child
|
|
39
|
-
# sees — Ruby's system(env, ...) MERGES the hash into the
|
|
40
|
-
# inherited environment, which would leak every ambient
|
|
41
|
-
# credential override past the profile's scope. The real exit
|
|
42
|
-
# status is preserved (nil-status spawn failure degrades to
|
|
43
|
-
# 127, signals to 128+signum).
|
|
44
|
-
@runner = runner || lambda { |argv, env|
|
|
45
|
-
spawned = ["env", "-i", *env.map { |key, value| "#{key}=#{value}" }, *argv]
|
|
46
|
-
system(*spawned)
|
|
47
|
-
status = $?
|
|
48
|
-
if status.nil?
|
|
49
|
-
127
|
|
50
|
-
elsif status.success?
|
|
51
|
-
0
|
|
52
|
-
else
|
|
53
|
-
status.exitstatus || 128 + status.termsig.to_i
|
|
54
|
-
end
|
|
55
|
-
}
|
|
43
|
+
@injected_runner = !runner.nil?
|
|
56
44
|
@principal = principal || -> { Etc.getpwuid(Process.uid).name }
|
|
45
|
+
@runner = runner
|
|
57
46
|
@config = load_config
|
|
58
47
|
@store = LeaseStore.new(@config["leases_dir"] || DEFAULT_LEASES_DIR)
|
|
59
48
|
@credentials_dir = @config["credentials_dir"] || DEFAULT_CREDENTIALS_DIR
|
|
@@ -66,11 +55,18 @@ module Ace
|
|
|
66
55
|
# scope/principal must match; a mismatched retry (e.g. a
|
|
67
56
|
# narrower repo list) is refused, never silently answered with
|
|
68
57
|
# the wider existing grant. The granted scope is the
|
|
69
|
-
#
|
|
70
|
-
# repo
|
|
58
|
+
# intersection of the requested repos and the host profile, whose
|
|
59
|
+
# empty repo list imposes no further restriction. A request may
|
|
60
|
+
# narrow, never widen; editing the task or
|
|
71
61
|
# project config grants nothing.
|
|
72
62
|
def request(profile_name:, role:, task:, assignment:, repos: [],
|
|
73
63
|
ttl_minutes: nil)
|
|
64
|
+
unless [profile_name, role, task, assignment].all? { |value| value.is_a?(String) && value.valid_encoding? && !value.strip.empty? }
|
|
65
|
+
raise Refusal.new("profile, role, task and assignment must be non-empty strings")
|
|
66
|
+
end
|
|
67
|
+
if Process.euid.zero? && !@injected_runner
|
|
68
|
+
raise Refusal.new("run credential issuance as the configured executor account")
|
|
69
|
+
end
|
|
74
70
|
profile = profile_for(profile_name)
|
|
75
71
|
unless profile["roles"].include?(role)
|
|
76
72
|
raise Refusal.new("role '#{role}' is not allowed by profile '#{profile_name}'")
|
|
@@ -92,8 +88,8 @@ module Ace
|
|
|
92
88
|
|
|
93
89
|
# The granted scope is computed BEFORE the live-lease lookup
|
|
94
90
|
# so a retry is judged against what THIS request asked for.
|
|
95
|
-
requested = Array(repos).map(&:to_s).sort
|
|
96
|
-
repos = (profile["repos"].empty? ? requested : profile["repos"] & requested)
|
|
91
|
+
requested = Array(repos).map(&:to_s).uniq.sort
|
|
92
|
+
repos = (profile["repos"].empty? ? requested : profile["repos"] & requested).sort
|
|
97
93
|
if repos.empty?
|
|
98
94
|
raise Refusal.new("the granted repository scope would be empty (profile " \
|
|
99
95
|
"'#{profile_name}' repos: [#{profile["repos"].join(", ")}]; requested: " \
|
|
@@ -104,15 +100,22 @@ module Ace
|
|
|
104
100
|
# The provider is resolved and capability-verified BEFORE any
|
|
105
101
|
# record exists: a failed resolve or an expiry the provider
|
|
106
102
|
# cannot back must not orphan a lease record.
|
|
107
|
-
|
|
103
|
+
begin
|
|
104
|
+
issue = Providers.resolve(profile["provider"])
|
|
105
|
+
rescue Providers::UnsupportedError => e
|
|
106
|
+
raise Refusal.new("provider unavailable: #{e.message}")
|
|
107
|
+
end
|
|
108
108
|
# A provider unable to back the profile's NATIVE expiry must
|
|
109
109
|
# be visible BEFORE handoff, never hidden behind use_until.
|
|
110
|
+
capabilities = issue.capabilities if profile["expiry_mode"] == "provider"
|
|
110
111
|
if profile["expiry_mode"] == "provider" &&
|
|
111
|
-
|
|
112
|
+
(!capabilities.is_a?(Hash) || capabilities["expiry_mode"] != "provider")
|
|
112
113
|
raise Refusal.new("provider '#{profile["provider"]}' cannot guarantee the " \
|
|
113
114
|
"profile's native expiry")
|
|
114
115
|
end
|
|
115
116
|
|
|
117
|
+
handoff = CredentialHandoff.new(@credentials_dir, recipient: principal)
|
|
118
|
+
|
|
116
119
|
# One request flow at a time per correlation: find_live +
|
|
117
120
|
# create + issue are atomic against a concurrent identical
|
|
118
121
|
# request — the second waits, then returns the first's LIVE
|
|
@@ -122,6 +125,9 @@ module Ace
|
|
|
122
125
|
existing = @store.find_live(task: task, assignment: assignment,
|
|
123
126
|
profile: profile_name, role: role)
|
|
124
127
|
if existing && @store.live?(existing)
|
|
128
|
+
if existing.key?("execution")
|
|
129
|
+
raise Refusal.new("a previous command's execution is unresolved; no lease can be reused")
|
|
130
|
+
end
|
|
125
131
|
# Idempotency holds only for the IDENTICAL request: a live
|
|
126
132
|
# lease with a different granted scope or principal is a
|
|
127
133
|
# mismatched retry — refusing it never silently reuses a
|
|
@@ -132,6 +138,9 @@ module Ace
|
|
|
132
138
|
"[#{existing["repos"].join(", ")}] for principal '#{existing["principal"]}'; " \
|
|
133
139
|
"revoke it first or repeat the identical request")
|
|
134
140
|
end
|
|
141
|
+
unless File.file?(handoff_for(existing).path(existing.fetch("id")))
|
|
142
|
+
raise Refusal.new("live lease '#{existing["id"]}' has no credential handoff; revoke it before requesting a replacement")
|
|
143
|
+
end
|
|
135
144
|
next existing.merge("state_detail" => "live")
|
|
136
145
|
end
|
|
137
146
|
|
|
@@ -143,7 +152,7 @@ module Ace
|
|
|
143
152
|
# cannot be confirmed refuses the retry: a local pending
|
|
144
153
|
# record is never proof the remote token is gone.
|
|
145
154
|
settle_unresolved(task: task, assignment: assignment,
|
|
146
|
-
profile: profile_name, role: role
|
|
155
|
+
profile: profile_name, role: role)
|
|
147
156
|
|
|
148
157
|
lease_id = "lease-#{SecureRandom.hex(8)}"
|
|
149
158
|
# Duck-typed capability: bundled providers always implement
|
|
@@ -161,17 +170,32 @@ module Ace
|
|
|
161
170
|
begin
|
|
162
171
|
granted = issue.issue(profile: profile, lease: lease)
|
|
163
172
|
rescue Providers::RefusedError => e
|
|
164
|
-
@store.update(lease["id"])
|
|
173
|
+
@store.update(lease["id"]) do |record|
|
|
174
|
+
record["state"] = "revoked"
|
|
175
|
+
record["cleanup"] = {"result" => "not-issued", "at" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")}
|
|
176
|
+
end
|
|
165
177
|
raise Refusal.new("provider refused: #{e.message}")
|
|
166
178
|
rescue Providers::TransientError, Providers::OutcomeUnknownError => e
|
|
167
179
|
@store.update(lease["id"]) { |record| record["state"] = "revoke-pending" }
|
|
168
180
|
raise Error.new("issuance failed with unknown outcome: #{e.message}; " \
|
|
169
|
-
"reconcile
|
|
181
|
+
"reconcile retries cleanup; the outcome may remain unresolved")
|
|
170
182
|
end
|
|
171
183
|
|
|
184
|
+
expiry = granted["provider_expiry"]&.to_s
|
|
185
|
+
expiry_valid = begin
|
|
186
|
+
if expiry
|
|
187
|
+
parsed_expiry = Time.parse(expiry)
|
|
188
|
+
expiry = parsed_expiry.utc.iso8601
|
|
189
|
+
parsed_expiry > Time.now.utc
|
|
190
|
+
else
|
|
191
|
+
profile["expiry_mode"] != "provider"
|
|
192
|
+
end
|
|
193
|
+
rescue ArgumentError, TypeError
|
|
194
|
+
false
|
|
195
|
+
end
|
|
172
196
|
@store.update(lease["id"]) do |record|
|
|
173
197
|
record["token_ref"] = granted["token_ref"] || record["token_ref"]
|
|
174
|
-
record["provider_expiry"] =
|
|
198
|
+
record["provider_expiry"] = expiry_valid ? expiry : nil
|
|
175
199
|
record["via"] = granted["via"]
|
|
176
200
|
end
|
|
177
201
|
# The secret goes ONLY to the ephemeral handoff; the returned
|
|
@@ -180,13 +204,18 @@ module Ace
|
|
|
180
204
|
# provider-side, so the record becomes revoke-pending for
|
|
181
205
|
# reconcile to finish — an interrupted handoff must not be
|
|
182
206
|
# reusable by a retry.
|
|
183
|
-
handoff = CredentialHandoff.new(@credentials_dir, recipient: principal)
|
|
184
207
|
begin
|
|
208
|
+
|
|
185
209
|
handoff.deliver(lease["id"], granted["token"])
|
|
210
|
+
|
|
186
211
|
rescue StandardError => e
|
|
187
212
|
@store.update(lease["id"]) { |record| record["state"] = "revoke-pending" }
|
|
188
213
|
raise Error.new("credential handoff failed: #{e.message}; " \
|
|
189
|
-
"reconcile
|
|
214
|
+
"reconcile retries cleanup; the outcome may remain unresolved")
|
|
215
|
+
end
|
|
216
|
+
unless expiry_valid
|
|
217
|
+
@store.update(lease["id"]) { |record| record["state"] = "revoke-pending" }
|
|
218
|
+
raise Error, "provider returned a missing, invalid or expired expiry; reconcile must revoke the grant"
|
|
190
219
|
end
|
|
191
220
|
# ISSUED means a COMPLETE, usable grant: credential
|
|
192
221
|
# delivered, deadlines recorded, cleanup due. Until this
|
|
@@ -208,9 +237,13 @@ module Ace
|
|
|
208
237
|
# --- status --------------------------------------------------------
|
|
209
238
|
|
|
210
239
|
def status(lease_id: nil)
|
|
211
|
-
|
|
240
|
+
if lease_id
|
|
241
|
+
lease = @store.load(lease_id)
|
|
242
|
+
return public_view(lease)
|
|
243
|
+
end
|
|
212
244
|
|
|
213
|
-
@store.all
|
|
245
|
+
records = @store.all
|
|
246
|
+
records.map { |lease| public_view(lease) }
|
|
214
247
|
end
|
|
215
248
|
|
|
216
249
|
# --- exec ----------------------------------------------------------
|
|
@@ -220,21 +253,40 @@ module Ace
|
|
|
220
253
|
# env names), never a value in argv. The process exit code is
|
|
221
254
|
# preserved; on process success a cleanup failure is itself an
|
|
222
255
|
# error; the report carries both outcomes. One exec at a time
|
|
223
|
-
# per lease
|
|
256
|
+
# per lease; a completed attempt triggers cleanup, making the lease
|
|
257
|
+
# single-use once revocation is confirmed.
|
|
224
258
|
def exec(lease_id:, argv:, env: {})
|
|
259
|
+
unless argv.is_a?(Array) && !argv.empty? && argv.all? { |arg| arg.is_a?(String) && !arg.include?("\0") } && !argv.first.empty?
|
|
260
|
+
raise Refusal.new("execution requires a valid command")
|
|
261
|
+
end
|
|
262
|
+
unless env.is_a?(Hash) && env.all? { |key, value|
|
|
263
|
+
CHILD_ENV_ALLOWLIST.include?(key) && value.is_a?(String) && !value.include?("\0")
|
|
264
|
+
}
|
|
265
|
+
raise Refusal.new("execution environment contains invalid or disallowed variables")
|
|
266
|
+
end
|
|
225
267
|
lease = @store.load(lease_id)
|
|
268
|
+
unless lease["principal"] == @principal.call
|
|
269
|
+
raise Refusal.new("lease '#{lease_id}' belongs to a different principal")
|
|
270
|
+
end
|
|
271
|
+
if Process.euid.zero? && !@injected_runner
|
|
272
|
+
raise Refusal.new("run credential execution as the configured executor account")
|
|
273
|
+
end
|
|
226
274
|
raise Refusal.new("lease '#{lease_id}' is not live") unless @store.live?(lease)
|
|
227
275
|
|
|
228
276
|
@store.with_exec_lock(lease_id) do
|
|
277
|
+
lease = @store.load(lease_id)
|
|
278
|
+
unless lease["principal"] == @principal.call && @store.live?(lease) && !lease.key?("execution")
|
|
279
|
+
raise Refusal.new("lease '#{lease_id}' is no longer live for this principal")
|
|
280
|
+
end
|
|
229
281
|
profile = profile_for(lease["profile"])
|
|
230
|
-
handoff =
|
|
282
|
+
handoff = handoff_for(lease)
|
|
231
283
|
credential = handoff.path(lease_id)
|
|
232
284
|
# A live lease without its delivered credential is not
|
|
233
285
|
# executable — degrade with a clear reason instead of an
|
|
234
286
|
# unrescued ENOENT from the child env merge.
|
|
235
287
|
unless File.file?(credential)
|
|
236
288
|
raise Refusal.new("lease '#{lease_id}' has no delivered credential at " \
|
|
237
|
-
"'#{credential}';
|
|
289
|
+
"'#{credential}'; revoke the lease before requesting a replacement")
|
|
238
290
|
end
|
|
239
291
|
|
|
240
292
|
# ALLOWLISTED child environment: required non-secret
|
|
@@ -250,16 +302,78 @@ module Ace
|
|
|
250
302
|
"ACE_LAB_CREDENTIAL_FILE" => credential)
|
|
251
303
|
if profile["provider"] == "env"
|
|
252
304
|
begin
|
|
253
|
-
|
|
254
|
-
|
|
305
|
+
values = JSON.parse(File.read(credential))
|
|
306
|
+
unless values.is_a?(Hash) && values.keys.sort == profile.fetch("env_names").sort &&
|
|
307
|
+
values.values.all? { |value| value.is_a?(String) && !value.empty? && !value.include?("\0") }
|
|
308
|
+
raise TypeError, "invalid env handoff"
|
|
309
|
+
end
|
|
310
|
+
child_env.merge!(values)
|
|
311
|
+
rescue JSON::ParserError, SystemCallError, IOError, TypeError => e
|
|
255
312
|
raise Refusal.new("lease '#{lease_id}' credential could not be read as " \
|
|
256
313
|
"an env handoff (#{e.class.name}); reconcile or request a fresh lease")
|
|
257
314
|
end
|
|
258
315
|
end
|
|
259
316
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
317
|
+
# A process lock disappears on SIGKILL. Persist the in-flight
|
|
318
|
+
# execution before starting a child so recovery cannot mistake
|
|
319
|
+
# a dead issuer for proof that its descendants have stopped.
|
|
320
|
+
@store.update(lease["id"]) do |record|
|
|
321
|
+
unless record["principal"] == @principal.call && @store.live?(record) && !record.key?("execution")
|
|
322
|
+
raise Refusal.new("lease '#{lease_id}' is no longer live for this principal")
|
|
323
|
+
end
|
|
324
|
+
record["execution"] = "in-progress"
|
|
325
|
+
end
|
|
326
|
+
uncontained = false
|
|
327
|
+
begin
|
|
328
|
+
provider = Providers.resolve(profile["provider"])
|
|
329
|
+
exit_code = if provider.respond_to?(:with_exec_environment)
|
|
330
|
+
provider.with_exec_environment(profile: profile, credential: credential) do |provider_env|
|
|
331
|
+
run_leased_command(argv, preserve_installed_runtime!(child_env.merge(provider_env)), lease["id"])
|
|
332
|
+
end
|
|
333
|
+
else
|
|
334
|
+
run_leased_command(argv, preserve_installed_runtime!(child_env), lease["id"])
|
|
335
|
+
end
|
|
336
|
+
unless exit_code.is_a?(Integer) && exit_code.between?(0, 255)
|
|
337
|
+
raise Error, "execution returned an invalid exit status"
|
|
338
|
+
end
|
|
339
|
+
rescue ExecutionCleanupError
|
|
340
|
+
uncontained = true
|
|
341
|
+
raise
|
|
342
|
+
rescue Providers::RefusedError, Providers::UnsupportedError => e
|
|
343
|
+
raise Refusal.new("provider execution refused: #{e.message}")
|
|
344
|
+
rescue SystemCallError, IOError, ArgumentError => e
|
|
345
|
+
raise Error, "execution could not start (#{e.class})"
|
|
346
|
+
ensure
|
|
347
|
+
# A failed spawn, provider error or interrupted caller must not
|
|
348
|
+
# leave an issued credential live merely because no exit code
|
|
349
|
+
# was returned. Reconciliation retains unresolved revocations.
|
|
350
|
+
primary_error = $!
|
|
351
|
+
begin
|
|
352
|
+
if uncontained
|
|
353
|
+
@store.update(lease["id"]) do |record|
|
|
354
|
+
record["state"] = "revoke-pending"
|
|
355
|
+
record["containment"] = "unconfirmed"
|
|
356
|
+
record["execution"] = "unconfirmed"
|
|
357
|
+
end
|
|
358
|
+
# Revoking the remote credential limits exposure, but does
|
|
359
|
+
# not establish that the command's processes have stopped.
|
|
360
|
+
cleanup = cleanup(lease, profile, handoff, allow_executing: true)
|
|
361
|
+
else
|
|
362
|
+
@store.update(lease["id"]) do |record|
|
|
363
|
+
record.delete("execution")
|
|
364
|
+
record["state"] = "revoke-pending" unless record["state"] == "revoked"
|
|
365
|
+
end
|
|
366
|
+
cleanup = cleanup(lease, profile, handoff)
|
|
367
|
+
end
|
|
368
|
+
rescue Error, SystemCallError, IOError => error
|
|
369
|
+
if primary_error.is_a?(ExecutionCleanupError)
|
|
370
|
+
raise ExecutionCleanupError.new(primary_error.message), cause: error
|
|
371
|
+
end
|
|
372
|
+
raise primary_error, cause: nil if primary_error
|
|
373
|
+
raise Error, "credential cleanup failed (#{error.class})"
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
if exit_code.zero? && !%i[revoked already_invalid cleared not-issued].include?(cleanup)
|
|
263
377
|
raise Error.new("process succeeded (exit 0) but cleanup failed: #{cleanup}")
|
|
264
378
|
end
|
|
265
379
|
|
|
@@ -280,13 +394,38 @@ module Ace
|
|
|
280
394
|
# recorded — never a claimed revocation.
|
|
281
395
|
def revoke(lease_id:, force: false)
|
|
282
396
|
lease = @store.load(lease_id)
|
|
283
|
-
|
|
397
|
+
unless %w[task assignment profile role].all? { |field| lease[field].is_a?(String) && !lease[field].empty? }
|
|
398
|
+
raise Error, "lease correlation is incomplete; host recovery is required"
|
|
399
|
+
end
|
|
400
|
+
@store.with_correlation_lock(task: lease.fetch("task"), assignment: lease.fetch("assignment"),
|
|
401
|
+
profile: lease.fetch("profile"), role: lease.fetch("role")) do
|
|
402
|
+
revoke_locked(lease_id: lease_id, force: force)
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def revoke_locked(lease_id:, force:)
|
|
407
|
+
lease = @store.load(lease_id)
|
|
408
|
+
principal = @principal.call
|
|
409
|
+
operator = Process.uid.zero? && principal == "root"
|
|
410
|
+
unless (lease["principal"] == principal) || operator
|
|
411
|
+
raise Refusal.new("lease '#{lease_id}' belongs to a different principal")
|
|
412
|
+
end
|
|
413
|
+
if force && !operator
|
|
414
|
+
raise Refusal.new("forced terminal revocation requires the root operator")
|
|
415
|
+
end
|
|
416
|
+
handoff = handoff_for(lease)
|
|
417
|
+
|
|
418
|
+
if force && lease.key?("execution")
|
|
419
|
+
raise Refusal.new("cannot force-close a lease while command execution is unresolved")
|
|
420
|
+
end
|
|
284
421
|
|
|
285
422
|
# An already-revoked lease never re-dials the provider: the
|
|
286
423
|
# idempotent result stands and only the handoff is re-cleared.
|
|
287
424
|
if lease["state"] == "revoked"
|
|
288
425
|
handoff.clear(lease_id)
|
|
289
|
-
return :
|
|
426
|
+
return :forced_terminal if lease.dig("cleanup", "result") == "forced-terminal"
|
|
427
|
+
return :revoked if confirmed_revocation?(lease)
|
|
428
|
+
raise Error, "lease '#{lease_id}' has an unconfirmed terminal cleanup outcome"
|
|
290
429
|
end
|
|
291
430
|
|
|
292
431
|
if force && lease["state"] == "revoke-pending" &&
|
|
@@ -306,12 +445,17 @@ module Ace
|
|
|
306
445
|
begin
|
|
307
446
|
profile = profile_for(lease["profile"])
|
|
308
447
|
rescue Refusal
|
|
448
|
+
# Keep the handoff while remote revocation is unresolved:
|
|
449
|
+
# token-value providers need it if the profile is restored.
|
|
309
450
|
@store.update(lease_id) { |record| record["state"] = "revoke-pending" }
|
|
310
451
|
return :"revoke-pending (profile '#{lease["profile"]}' removed from the authorization document)"
|
|
311
452
|
end
|
|
312
453
|
|
|
313
|
-
|
|
454
|
+
# Explicit revocation is allowed during execution to cut remote
|
|
455
|
+
# access immediately; the execution marker still prevents reuse.
|
|
456
|
+
cleanup(lease, profile, handoff, allow_executing: true)
|
|
314
457
|
end
|
|
458
|
+
private :revoke_locked
|
|
315
459
|
|
|
316
460
|
# --- reconcile -----------------------------------------------------
|
|
317
461
|
|
|
@@ -325,43 +469,75 @@ module Ace
|
|
|
325
469
|
def reconcile
|
|
326
470
|
results = []
|
|
327
471
|
@store.all.each do |lease|
|
|
328
|
-
|
|
329
|
-
outcome =
|
|
472
|
+
begin
|
|
330
473
|
if lease["corrupt"]
|
|
331
|
-
# An unparseable record is a visible recovery problem —
|
|
332
|
-
# the remote token may still be live; it must never
|
|
333
|
-
# silently vanish from the inventory.
|
|
334
474
|
record = File.basename(lease["_path"].to_s)
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
handoff.clear(lease["id"])
|
|
338
|
-
:already_revoked
|
|
475
|
+
results << {"lease" => lease["id"], "state" => lease["state"], "reconciled" =>
|
|
476
|
+
"corrupt (unreadable lease record #{record}; manual recovery required — the remote token may still be live)"}
|
|
339
477
|
else
|
|
340
|
-
|
|
341
|
-
profile
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
# the lease a visible revoke-pending open item (the
|
|
345
|
-
# operator closes it with `revoke --force` when the
|
|
346
|
-
# credential is gone) instead of crashing reconcile.
|
|
347
|
-
@store.update(lease["id"]) { |record| record["state"] = "revoke-pending" }
|
|
348
|
-
:"revoke-pending (profile '#{lease["profile"]}' removed)"
|
|
349
|
-
else
|
|
350
|
-
if lease["state"] == "preparing" || lease["state"] == "revoke-pending" ||
|
|
351
|
-
!@store.live?(lease)
|
|
352
|
-
cleanup(lease, profile, handoff)
|
|
353
|
-
else
|
|
354
|
-
:live
|
|
355
|
-
end
|
|
478
|
+
@store.with_correlation_lock(task: lease.fetch("task"), assignment: lease.fetch("assignment"),
|
|
479
|
+
profile: lease.fetch("profile"), role: lease.fetch("role")) do
|
|
480
|
+
lease = @store.load(lease.fetch("id"))
|
|
481
|
+
results << reconcile_locked(lease)
|
|
356
482
|
end
|
|
357
483
|
end
|
|
358
|
-
|
|
484
|
+
rescue Error, SystemCallError, IOError, KeyError, ArgumentError => e
|
|
485
|
+
results << {"lease" => lease["id"], "state" => lease["state"],
|
|
486
|
+
"reconciled" => "reconcile-failed (#{e.class}; manual recovery required)"}
|
|
487
|
+
end
|
|
359
488
|
end
|
|
360
489
|
results
|
|
361
490
|
end
|
|
362
491
|
|
|
363
492
|
private
|
|
364
493
|
|
|
494
|
+
def run_leased_command(argv, environment, lease_id)
|
|
495
|
+
return @runner.call(argv, environment) if @runner
|
|
496
|
+
|
|
497
|
+
lease = @store.load(lease_id)
|
|
498
|
+
deadline = [lease.fetch("use_until"), lease["provider_expiry"]].compact.map { |value| Time.iso8601(value) }.min
|
|
499
|
+
LocalCommand.new.call(argv, environment, deadline: deadline)
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def reconcile_locked(lease)
|
|
503
|
+
if lease.key?("execution")
|
|
504
|
+
expired = [lease["use_until"], lease["provider_expiry"]].compact.any? { |deadline| Time.parse(deadline) <= Time.now }
|
|
505
|
+
if lease["state"] != "revoked" && (lease["state"] == "revoke-pending" || expired)
|
|
506
|
+
profile = profile_for(lease["profile"])
|
|
507
|
+
cleanup(lease, profile, handoff_for(lease), allow_executing: true)
|
|
508
|
+
end
|
|
509
|
+
return {"lease" => lease["id"], "state" => lease["state"],
|
|
510
|
+
"reconciled" => "revoke-pending (command execution unresolved)"}
|
|
511
|
+
end
|
|
512
|
+
handoff = handoff_for(lease)
|
|
513
|
+
outcome = if lease["state"] == "revoked"
|
|
514
|
+
handoff.clear(lease["id"])
|
|
515
|
+
if lease.dig("cleanup", "result") == "forced-terminal"
|
|
516
|
+
:forced_terminal
|
|
517
|
+
elsif confirmed_revocation?(lease)
|
|
518
|
+
:already_revoked
|
|
519
|
+
else
|
|
520
|
+
raise Error, "terminal cleanup outcome is unconfirmed"
|
|
521
|
+
end
|
|
522
|
+
else
|
|
523
|
+
begin
|
|
524
|
+
profile = profile_for(lease["profile"])
|
|
525
|
+
rescue Refusal
|
|
526
|
+
@store.update(lease["id"]) { |record| record["state"] = "revoke-pending" }
|
|
527
|
+
:"revoke-pending (profile '#{lease["profile"]}' removed)"
|
|
528
|
+
else
|
|
529
|
+
if lease["state"] == "preparing" || lease["state"] == "revoke-pending" || !@store.live?(lease)
|
|
530
|
+
cleanup(lease, profile, handoff)
|
|
531
|
+
elsif !File.file?(handoff.path(lease.fetch("id")))
|
|
532
|
+
:"live (credential handoff missing; revoke required)"
|
|
533
|
+
else
|
|
534
|
+
:live
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
{"lease" => lease["id"], "state" => lease["state"], "reconciled" => outcome.to_s}
|
|
539
|
+
end
|
|
540
|
+
|
|
365
541
|
# R5: a previous issue with an UNRESOLVED remote outcome (lost
|
|
366
542
|
# response after possible issuance, or a hard death while
|
|
367
543
|
# PREPARING) is settled before any re-mint. Settlement runs the
|
|
@@ -372,38 +548,51 @@ module Ace
|
|
|
372
548
|
# pending/unknown settlement REFUSES the retry: never a blind
|
|
373
549
|
# second mint, never treating a local pending record as a
|
|
374
550
|
# remote revocation.
|
|
375
|
-
def settle_unresolved(task:, assignment:, profile:, role
|
|
376
|
-
|
|
551
|
+
def settle_unresolved(task:, assignment:, profile:, role:)
|
|
552
|
+
records = @store.all
|
|
553
|
+
if records.any? { |record| record["corrupt"] ||
|
|
554
|
+
%w[task assignment profile role].any? { |field| !record[field].is_a?(String) || record[field].empty? } ||
|
|
555
|
+
!%w[preparing issued revoke-pending revoked].include?(record["state"]) }
|
|
556
|
+
raise Refusal.new("a lease record is corrupt or incomplete and may represent an unresolved grant; " \
|
|
557
|
+
"no new lease can be issued until the host owner resolves it")
|
|
558
|
+
end
|
|
559
|
+
matching = records.select do |record|
|
|
377
560
|
record["task"] == task && record["assignment"] == assignment &&
|
|
378
|
-
record["profile"] == profile && record["role"] == role
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
561
|
+
record["profile"] == profile && record["role"] == role
|
|
562
|
+
end
|
|
563
|
+
matching.each do |record|
|
|
564
|
+
begin
|
|
565
|
+
@store.with_exec_lock(record.fetch("id")) do
|
|
566
|
+
current = @store.load(record.fetch("id"))
|
|
567
|
+
if current.key?("execution") || current["containment"] == "unconfirmed"
|
|
568
|
+
raise Refusal.new("a previous command's execution or containment is unconfirmed; no new lease can be issued")
|
|
569
|
+
end
|
|
570
|
+
expired_issue = current["state"] == "issued" && !@store.live?(current)
|
|
571
|
+
next unless expired_issue || %w[preparing revoke-pending].include?(current["state"])
|
|
572
|
+
|
|
573
|
+
# Keep the execution lock through settlement. A record may
|
|
574
|
+
# have changed since the inventory snapshot was read.
|
|
575
|
+
if current["state"] == "revoke-pending" && current["token_ref"].to_s.empty?
|
|
576
|
+
raise Refusal.new("a previous issue for task '#{task}' assignment '#{assignment}' " \
|
|
577
|
+
"profile '#{profile}' has an unresolved remote outcome (lease '#{current["id"]}', " \
|
|
578
|
+
"no deterministic remote identity); it was NOT re-minted — run reconcile, wait " \
|
|
579
|
+
"for the provider's native expiry, or close it explicitly before requesting again")
|
|
580
|
+
end
|
|
581
|
+
record_profile = profile_for(profile)
|
|
582
|
+
handoff = handoff_for(current)
|
|
583
|
+
outcome = cleanup(current, record_profile, handoff)
|
|
584
|
+
next if %i[revoked already_invalid cleared].include?(outcome)
|
|
585
|
+
|
|
586
|
+
raise Refusal.new("a previous issue for task '#{task}' assignment '#{assignment}' " \
|
|
587
|
+
"profile '#{profile}' has an unresolved remote outcome (lease '#{current["id"]}', " \
|
|
588
|
+
"state #{current["state"]}); it was NOT re-minted — run reconcile, wait for the " \
|
|
589
|
+
"provider's native expiry, or close it explicitly before requesting again")
|
|
590
|
+
end
|
|
591
|
+
rescue Refusal
|
|
592
|
+
raise
|
|
593
|
+
rescue Error
|
|
594
|
+
raise Refusal.new("a previous lease could not be settled; inspect its execution, store and provider state")
|
|
397
595
|
end
|
|
398
|
-
|
|
399
|
-
handoff = CredentialHandoff.new(@credentials_dir, recipient: record["principal"] || principal)
|
|
400
|
-
outcome = cleanup(record, record_profile, handoff)
|
|
401
|
-
next if %i[revoked already_invalid cleared].include?(outcome)
|
|
402
|
-
|
|
403
|
-
raise Refusal.new("a previous issue for task '#{task}' assignment '#{assignment}' " \
|
|
404
|
-
"profile '#{profile}' has an unresolved remote outcome (lease '#{record["id"]}', " \
|
|
405
|
-
"state #{record["state"]}); it was NOT re-minted — run reconcile, wait for the " \
|
|
406
|
-
"provider's native expiry, or close it explicitly before requesting again")
|
|
407
596
|
end
|
|
408
597
|
end
|
|
409
598
|
|
|
@@ -412,16 +601,39 @@ module Ace
|
|
|
412
601
|
# installation-token endpoint revokes the token that
|
|
413
602
|
# authenticates the request, so the token VALUE is required
|
|
414
603
|
# there; id-revoking providers ignore it.
|
|
415
|
-
def
|
|
416
|
-
|
|
604
|
+
def handoff_for(lease)
|
|
605
|
+
CredentialHandoff.new(@credentials_dir, recipient: lease.fetch("principal"))
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def cleanup(lease, profile, handoff, allow_executing: false)
|
|
609
|
+
current = @store.load(lease["id"])
|
|
610
|
+
if current["containment"] == "unconfirmed" && !allow_executing
|
|
611
|
+
return :"revoke-pending (command containment unconfirmed)"
|
|
612
|
+
end
|
|
613
|
+
return :"revoke-pending (command execution unresolved)" if current.key?("execution") && !allow_executing
|
|
614
|
+
# A concurrent revoke may have already confirmed cleanup while an
|
|
615
|
+
# exec was running. Never retry or downgrade that settled record.
|
|
616
|
+
return :revoked if confirmed_revocation?(current)
|
|
617
|
+
|
|
417
618
|
credential = handoff.path(lease["id"])
|
|
418
|
-
token = File.file?(credential) ? File.read(credential) : nil
|
|
419
619
|
begin
|
|
620
|
+
token = File.file?(credential) ? File.read(credential) : nil
|
|
621
|
+
rescue SystemCallError, IOError => e
|
|
622
|
+
return mark_cleanup_pending(lease["id"], e.class.to_s)
|
|
623
|
+
end
|
|
624
|
+
begin
|
|
625
|
+
provider = Providers.resolve(profile["provider"])
|
|
420
626
|
outcome = provider.revoke(profile: profile, token_ref: lease["token_ref"],
|
|
421
627
|
token: token)
|
|
422
|
-
rescue Providers::TransientError, Providers::OutcomeUnknownError => e
|
|
423
|
-
|
|
424
|
-
|
|
628
|
+
rescue Providers::TransientError, Providers::OutcomeUnknownError, Providers::RefusedError, Providers::UnsupportedError => e
|
|
629
|
+
return mark_cleanup_pending(lease["id"], e.class.to_s)
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
# An interrupted issue may still be executing at the forge after
|
|
633
|
+
# our HTTP client timed out or died. A 404 before that request
|
|
634
|
+
# commits is not proof that its token can never appear later.
|
|
635
|
+
if outcome == :already_invalid && token.nil? && %w[preparing revoke-pending].include?(current["state"])
|
|
636
|
+
return mark_cleanup_pending(lease["id"], "issue outcome remains unknown")
|
|
425
637
|
end
|
|
426
638
|
|
|
427
639
|
handoff.clear(lease["id"])
|
|
@@ -432,6 +644,21 @@ module Ace
|
|
|
432
644
|
outcome
|
|
433
645
|
end
|
|
434
646
|
|
|
647
|
+
def confirmed_revocation?(record)
|
|
648
|
+
record["state"] == "revoked" && %w[revoked already_invalid cleared not-issued].include?(record.dig("cleanup", "result"))
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
def mark_cleanup_pending(id, reason)
|
|
652
|
+
confirmed = false
|
|
653
|
+
@store.update(id) do |record|
|
|
654
|
+
confirmed = confirmed_revocation?(record)
|
|
655
|
+
# A forced terminal result is an operator decision, not proof of
|
|
656
|
+
# remote revocation; preserve it but never report it confirmed.
|
|
657
|
+
record["state"] = "revoke-pending" unless record["state"] == "revoked"
|
|
658
|
+
end
|
|
659
|
+
confirmed ? :revoked : :"revoke-pending (#{reason})"
|
|
660
|
+
end
|
|
661
|
+
|
|
435
662
|
def profile_for(name)
|
|
436
663
|
profile = @config.dig("profiles", name)
|
|
437
664
|
raise Refusal.new("unknown auth profile '#{name}'") if profile.nil?
|
|
@@ -439,8 +666,20 @@ module Ace
|
|
|
439
666
|
profile
|
|
440
667
|
end
|
|
441
668
|
|
|
669
|
+
# No caller or provider projection may silently replace the selected
|
|
670
|
+
# installed runtime. An absent path stays absent.
|
|
671
|
+
def preserve_installed_runtime!(environment)
|
|
672
|
+
selected = ENV
|
|
673
|
+
if selected["ACE_TOOLCHAIN"] == "installed"
|
|
674
|
+
%w[ACE_TOOLCHAIN PATH GEM_HOME GEM_PATH].each do |key|
|
|
675
|
+
selected.key?(key) ? environment[key] = selected[key] : environment.delete(key)
|
|
676
|
+
end
|
|
677
|
+
end
|
|
678
|
+
environment
|
|
679
|
+
end
|
|
680
|
+
|
|
442
681
|
def public_view(lease)
|
|
443
|
-
lease.reject { |key, _| key == "token_ref" }
|
|
682
|
+
lease.reject { |key, _| key == "token_ref" || key.start_with?("_") }
|
|
444
683
|
.merge("live" => @store.live?(lease))
|
|
445
684
|
end
|
|
446
685
|
|
|
@@ -451,7 +690,6 @@ module Ace
|
|
|
451
690
|
end
|
|
452
691
|
end
|
|
453
692
|
|
|
454
|
-
class Refusal < Error; end
|
|
455
693
|
end
|
|
456
694
|
end
|
|
457
695
|
end
|