Skip to content

Commit dcbea5c

Browse files
authored
feat(local-container): fixed idempotent lease IDs (#1493)
* feat(local-container): fixed idempotent lease IDs The local-container provider now advertises SupportsRequestedLeaseID and routes requested lease IDs through the durable AcquireFixedLease helper: existing containers are adopted only after verifying lease, slug, container name, runtime, image, daemon identity, and intent fingerprint; recovery mirrors the aws fixed-lease fail-closed semantics (live containers resume, unresolved attempts, missing or dead containers, and released tombstones return lease_id_conflict). The intent fingerprint covers runtime and daemon scope, image and checkpoint identity, resources, mounts, network and socket access, desktop settings, architecture, slug, retention, lifecycle timing, and the SSH public key, so a materially different configuration conflicts instead of silently reusing a lease. * docs(local-container): document fixed idempotent lease IDs * fix(local-container): preserve downgrade-safe fixed lease claims * fix(local-container): exclude released fixed claims from slug routing * fix(local-container): preserve fixed lease duration precision * test(local-container): cover fixed checkpoint fork acquisition * fix(local-container): bind fixed lease intent to normalized pond * fix(local-container): bind stopped fixed lease recovery before conflict
1 parent 0396365 commit dcbea5c

12 files changed

Lines changed: 1441 additions & 58 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## 0.46.1 - Unreleased
44

5+
### Added
6+
7+
- Added fixed idempotent `--lease-id` replay to local-container warmups, with exact container-intent matching and single-use released IDs.
8+
59
### Fixed
610

711
- Redacted coordinator URL credentials, query parameters, and fragments from run-context portal and logs links.

docs/commands/warmup.md

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ crabbox warmup --browser
1515
crabbox warmup --tailscale
1616
crabbox warmup --slug update-flow-smoke
1717
crabbox warmup --provider aws --lease-id cbx_abcdef123456 --slug update-flow-smoke
18+
crabbox warmup --provider local-container --lease-id cbx_abcdef123456 --slug update-flow-smoke
1819
crabbox warmup --pond alpha --slug db
1920
crabbox warmup --provider aws --target windows --desktop
2021
crabbox warmup --provider azure --target windows
@@ -50,13 +51,14 @@ Warmup requires an explicit provider selection from `--provider`,
5051
recorded lease route. With no selection it exits before provider initialization
5152
and points to `crabbox providers recommend`.
5253

53-
For `local-container`, the default `--keep=true` also covers SSH readiness
54-
failure: once Docker has returned an exact container identity, Crabbox persists
55-
a scoped `provisioning` claim before inspecting the container or waiting for
56-
SSH. Cancellation or timeout retains that pending lease and prints exact
57-
inspect, reclaim, and cleanup commands.
58-
`warmup --keep=false` rolls the container and all per-lease local state back
59-
instead.
54+
For ordinary `local-container` warmups without `--lease-id`, the default
55+
`--keep=true` also covers SSH readiness failure: once Docker has returned an
56+
exact container identity, Crabbox persists a scoped `provisioning` claim before
57+
inspecting the container or waiting for SSH. Cancellation or timeout retains
58+
that pending lease and prints exact inspect, reclaim, and cleanup commands.
59+
`warmup --keep=false` rolls an ordinary container and all per-lease local state
60+
back instead. Fixed-ID local-container warmups retain their durable create
61+
attempt so an interrupted operation can be safely replayed.
6062

6163
## Lifetime: TTL and idle timeout
6264

@@ -70,28 +72,29 @@ instead.
7072
it and may append a short suffix if an active lease already uses that slug.
7173

7274
`--lease-id cbx_<12 lowercase hex>` is the automation idempotency contract for
73-
providers that explicitly support fixed identities. Direct AWS and managed
74-
coordinator leases accept it. Replaying the same normalized create intent
75+
providers that explicitly support fixed identities. Direct AWS, Machine0, and
76+
local-container leases, managed coordinator leases, and explicitly capable
77+
external providers accept it. Replaying the same normalized create intent
7578
returns or joins the same lease, including after the creating process loses its
7679
response. Reusing the ID with a different provider, slug request, SSH key,
77-
machine shape, capabilities, lifetime, or other immutable create input fails
78-
with `lease_id_conflict` before another provider create. Slugs remain display
79-
aliases and are never used as the idempotency key.
80+
machine or container shape, capabilities, lifetime, or other immutable create
81+
input fails with `lease_id_conflict` before another provider create. Slugs
82+
remain display aliases and are never used as the idempotency key.
8083

8184
Coordinator-backed fixed-ID creation uses a versioned `PUT /v1/leases/<id>`
8285
route. An older coordinator therefore rejects the request before provisioning;
8386
the CLI never falls back to slug lookup or legacy create behavior. After an
8487
ambiguous fixed create response, the CLI repeats that exact PUT to atomically
8588
confirm the same intent before it may poll lease status with GET.
8689

87-
A fixed lease ID is single-use. If direct AWS acquisition completed and its
88-
bound instance later disappears, replay fails closed instead of relying on EC2
89-
client-token retention. Successful stop and missing-resource cleanup replace
90-
the live local claim with a compact terminal tombstone, so the ID remains
91-
rejected after release. Use a new operation ID for every later lease.
92-
If an AWS launch attempt was durably recorded but its instance is not yet
93-
visible, replay fails closed without resubmitting it; retry later to adopt the
94-
resource after provider inventory converges.
90+
A fixed lease ID is single-use. Direct AWS, Machine0, and local-container
91+
acquisitions fail closed if their bound resource later disappears. Successful
92+
stop and missing-resource cleanup replace the live local claim with a compact
93+
terminal tombstone, so the ID remains rejected after release. Use a new
94+
operation ID for every later lease. If an AWS launch or local-container create
95+
attempt was durably recorded but its resource is not yet visible, replay fails
96+
closed without resubmitting it; retry later to adopt the resource after
97+
provider inventory converges.
9598

9699
`--pond <name>` tags a new lease into a named pond (stored as a reserved
97100
provider label); `crabbox list --pond <name>` filters by it. When combined with

docs/features/identifiers.md

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -57,20 +57,30 @@ private token and generation never appear in public lease records. Fixed-ID
5757
to own replay, and caller cancellation never releases them.
5858

5959
Automation may instead supply the canonical ID with `warmup --lease-id`. For
60-
direct AWS, direct Machine0, and managed coordinator leases, that ID is an
61-
immutable create identity: an identical semantic replay returns the same lease,
62-
while intent drift returns `lease_id_conflict`. The coordinator durably stores
63-
a versioned normalized request hash. Direct AWS durably stores the intent and
64-
current resolved EC2 attempt in the normal lease claim before `RunInstances`,
65-
then uses a deterministic regional/zonal client token. Neither path uses the
66-
slug to decide replay ownership.
60+
direct AWS, direct Machine0, direct local-container, and managed coordinator
61+
leases, that ID is an immutable create identity: an identical semantic replay
62+
returns the same lease, while intent drift returns `lease_id_conflict`. External
63+
providers also accept requested IDs when their protocol explicitly advertises
64+
idempotent lease identity support. The coordinator durably stores a versioned
65+
normalized request hash. Direct AWS durably stores the intent and current
66+
resolved EC2 attempt in the normal lease claim before `RunInstances`, then uses
67+
a deterministic regional/zonal client token. No path uses the slug to decide
68+
replay ownership.
6769

6870
Direct Machine0 binds the intent to its deterministic VM name before creation;
6971
the durable attempt binds the first visible match to its Machine0 resource ID,
7072
and every later adoption requires that exact recorded ID. Its fixed claims use
7173
the downgrade-safe `machine0-fixed-v1` marker alongside AWS's `aws-fixed-v1`
7274
marker.
7375

76+
Direct local-container binds the intent to its runtime and daemon scope,
77+
normalized container configuration, and deterministic container name before
78+
creation. Replay adopts only the exact recorded container when its lease, name,
79+
runtime identity, and intent fingerprint still match; an unresolved attempt or
80+
missing acquired container fails closed instead of starting another container.
81+
Its fixed claims use the downgrade-safe `local-container-fixed-v1` marker, so
82+
older clients cannot mistake them for ordinary local-container claims.
83+
7484
After the direct AWS launch attempt is durable, Crabbox never submits that
7585
attempt again. An ambiguous replay with no visible tagged instance fails closed;
7686
a later replay can adopt the one instance after inventory converges only when
@@ -79,11 +89,11 @@ match the persisted attempt exactly. Fixed AWS
7989
claims use the downgrade-safe local discriminator `aws-fixed-v1`; current
8090
clients map it to runtime AWS, while older clients skip/refuse it.
8191

82-
Fixed IDs are single-use operation identities. Direct AWS and Machine0 keep a
83-
compact terminal claim tombstone after successful destroy release or exact
84-
missing-resource cleanup. Tombstones contain only the ID, slug, provider scope,
85-
versioned intent hash, timestamps, and terminal state; automatic provider
86-
cleanup never prunes them.
92+
Fixed IDs are single-use operation identities. Direct AWS, Machine0, and
93+
local-container keep a compact terminal claim tombstone after successful
94+
destroy release or exact missing-resource cleanup. Tombstones contain only the
95+
ID, slug, provider scope, versioned intent hash, timestamps, and terminal
96+
state; automatic provider cleanup never prunes them.
8797
There is no time-based reuse window. Explicitly deleting local Crabbox claim
8898
state forfeits this replay protection, so automation must instead mint a new
8999
operation ID.

docs/provider-backends.md

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,22 @@ remote commands itself, use `DelegatedRunBackend`.
138138

139139
Add optional capabilities as small interfaces instead of widening every backend.
140140

141+
Requested fixed lease IDs are optional:
142+
143+
```go
144+
type IdempotentLeaseIDBackend interface {
145+
SupportsRequestedLeaseID() bool
146+
}
147+
```
148+
149+
Direct AWS, Machine0, and local-container backends implement this capability;
150+
coordinator-backed leases support it through the coordinator wrapper. External
151+
backends support it only when their configured protocol explicitly advertises
152+
idempotent lease IDs. `crabbox warmup --lease-id` rejects other backends before
153+
provisioning. Built-in direct adapters reuse `core.AcquireFixedLease` for
154+
durable intent and replay mechanics while keeping resource creation,
155+
reconciliation, and identity validation provider-owned.
156+
141157
Cleanup is optional:
142158

143159
```go
@@ -386,8 +402,9 @@ Acquisition already shares the mechanics that have provider-neutral contracts:
386402
- `core.AcquireFixedLease`, `core.FixedAcquireOptions`,
387403
`core.FixedLeaseBinding`, and `core.FixedLeaseKind` already share durable
388404
fixed-ID intent locking, replay validation, acquired-state commit, and
389-
terminal tombstones for AWS and Machine0. Their adapters still own exact
390-
create attempts, provider reconciliation, and immutable resource identity.
405+
terminal tombstones for AWS, Machine0, and local-container. Their adapters
406+
still own exact create attempts, provider reconciliation, and immutable
407+
resource identity.
391408

392409
The transaction boundary deliberately remains inside each adapter:
393410

@@ -415,9 +432,9 @@ The transaction boundary deliberately remains inside each adapter:
415432
back failed creation even with `Keep`, so retention cannot be a global rule.
416433
- **`OnAcquired` placement:** Lume acknowledges an early provisional identity,
417434
Vast acknowledges after readiness but before its final claim, and fixed-ID
418-
Machine0 acknowledges only after durable commit and lock release. Moving the
419-
callback changes when controller ownership transfers and which transaction
420-
must clean up if acknowledgment fails.
435+
Machine0 and local-container acknowledge only after durable commit and lock
436+
release. Moving the callback changes when controller ownership transfers and
437+
which transaction must clean up if acknowledgment fails.
421438
- **Security-critical bootstrap ordering:** Hyper-V locks down guest SSH before
422439
attaching networking; Lume pins authenticated guest identity before accepting
423440
SSH; Vast probes initial access, installs required tools, and only then proves

docs/providers/local-container.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,25 @@ Docker Desktop-specific APIs. Crabbox detects an installed `docker` or `podman`
8181
CLI and uses that runtime. Set `localContainer.runtime` when you need a specific
8282
CLI.
8383

84+
### Fixed-ID replay
85+
86+
```sh
87+
crabbox warmup --provider local-container \
88+
--lease-id cbx_abcdef123456 --slug my-app-operation
89+
```
90+
91+
A fixed lease ID makes warmup replay-safe across process restarts. Crabbox
92+
persists the runtime identity and normalized container-create intent before
93+
starting the container, then reuses an existing container only when its lease,
94+
slug, runtime scope, and intent fingerprint match. Changing the image or other
95+
container-shaping configuration returns `lease_id_conflict` instead of silently
96+
reusing the lease. An unresolved create attempt or a missing previously
97+
acquired container also fails closed without starting a second container.
98+
99+
Stopping a fixed lease preserves a terminal local tombstone, so the same
100+
operation ID cannot create another container after release. Use a new fixed
101+
lease ID for each later operation.
102+
84103
## Configuration
85104

86105
```yaml

internal/cli/claim.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ type FixedCreateIntent struct {
7676

7777
const FixedAWSClaimProvider = "aws-fixed-v1"
7878
const FixedMachine0ClaimProvider = "machine0-fixed-v1"
79+
const FixedLocalContainerClaimProvider = "local-container-fixed-v1"
7980

8081
const maxLocalClaimInventoryFileBytes int64 = 1 * 1024 * 1024
8182

@@ -1303,6 +1304,8 @@ func canonicalClaimProvider(provider string) string {
13031304
return "aws"
13041305
case FixedMachine0ClaimProvider:
13051306
return "machine0"
1307+
case FixedLocalContainerClaimProvider:
1308+
return "local-container"
13061309
case "exec-provider":
13071310
return "external"
13081311
}

internal/cli/claim_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,44 @@ func TestFixedMachine0ClaimProviderCanonicalizes(t *testing.T) {
5656
}
5757
}
5858

59+
func TestFixedLocalContainerClaimProviderCanonicalizesWithoutOverwritingMarker(t *testing.T) {
60+
t.Setenv("XDG_STATE_HOME", t.TempDir())
61+
const leaseID = "cbx_abcdef123465"
62+
err := withDurableLeaseClaimLock(leaseID, func(claim *leaseClaim, _ bool, persist func() error) error {
63+
claim.LeaseID = leaseID
64+
claim.Slug = "fixed-local-container"
65+
claim.Provider = FixedLocalContainerClaimProvider
66+
claim.ProviderScope = "runtime:docker/context:default"
67+
claim.RepoRoot = "/repo"
68+
claim.FixedCreateIntent = &FixedCreateIntent{
69+
Version: 1, Fingerprint: strings.Repeat("a", 64), ProviderScope: claim.ProviderScope,
70+
Slug: claim.Slug, CreatedAt: time.Now().UTC().Format(time.RFC3339Nano), State: "acquired",
71+
}
72+
return persist()
73+
})
74+
if err != nil {
75+
t.Fatal(err)
76+
}
77+
if got := canonicalClaimProvider(FixedLocalContainerClaimProvider); got != "local-container" {
78+
t.Fatalf("fixed local-container marker canonicalized to %q", got)
79+
}
80+
resolved, ok, exact, err := resolveLeaseClaimForProviderWithExact(leaseID, "local-container")
81+
if err != nil || !ok || !exact || resolved.Provider != FixedLocalContainerClaimProvider {
82+
t.Fatalf("resolved=%#v ok=%t exact=%t err=%v", resolved, ok, exact, err)
83+
}
84+
if err := claimLeaseForRepoProvider(leaseID, "fixed-local-container", "local-container", "/repo", time.Minute, false); err != nil {
85+
t.Fatal(err)
86+
}
87+
after, exists, err := readLeaseClaimWithPresence(leaseID)
88+
if err != nil || !exists || after.Provider != FixedLocalContainerClaimProvider {
89+
t.Fatalf("runtime local-container claim update overwrote marker: claim=%#v exists=%t err=%v", after, exists, err)
90+
}
91+
peer := bridgePeerFromClaim(after, TransportNone)
92+
if peer.Provider != "local-container" {
93+
t.Fatalf("fixed marker displayed as provider %q", peer.Provider)
94+
}
95+
}
96+
5997
func TestClaimEndpointReservationDeadlineStartsAfterClaimLockAcquired(t *testing.T) {
6098
t.Setenv("XDG_STATE_HOME", t.TempDir())
6199
const leaseID = "cbx_reservation_lock"

internal/cli/pond_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ func TestNormalizePondName(t *testing.T) {
2929
if got := normalizePondName(tc.in); got != tc.want {
3030
t.Fatalf("normalizePondName(%q)=%q want %q", tc.in, got, tc.want)
3131
}
32+
if got := NormalizePondName(tc.in); got != tc.want {
33+
t.Fatalf("NormalizePondName(%q)=%q want %q", tc.in, got, tc.want)
34+
}
3235
}
3336
}
3437

internal/cli/provider_exports.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,10 @@ func NormalizeLeaseSlug(value string) string {
580580
return normalizeLeaseSlug(value)
581581
}
582582

583+
func NormalizePondName(value string) string {
584+
return normalizePondName(value)
585+
}
586+
583587
func RenderTailscaleHostname(template, leaseID, slug, provider string) string {
584588
return renderTailscaleHostname(template, leaseID, slug, provider)
585589
}

0 commit comments

Comments
 (0)