refactor: add provider backend registry

This commit is contained in:
Peter Steinberger 2026-05-06 05:23:07 +01:00
parent 7c1cabf5f3
commit 494f3a4d77
No known key found for this signature in database
39 changed files with 4318 additions and 1136 deletions

View File

@ -11,6 +11,7 @@
- Added per-lease portal detail pages with bridge status, pasteable commands, recent run links, and a stop action.
- Added `.crabboxignore` for repo-local sync-only exclude patterns shared by `run` and `sync-plan`.
- Documented the prebaked runner image boundary: provider-owned AMIs/snapshots hold machine capabilities while repo/runtime caches stay in QA workflows or warm leases.
- Added a provider backend registry and authoring guide so delegated and SSH-backed providers can live in provider-owned packages while core keeps command parsing, rendering, and capability validation.
### Fixed
@ -33,6 +34,7 @@
- Fixed remote git seeding so an unfetchable local commit cannot leave an empty `.git` worktree that makes sync sanity report every tracked file as deleted.
- Skipped remote git seeding for local commits that are not present in any remote-tracking ref, avoiding slow doomed clone/fetch attempts before rsync.
- Fixed Windows archive sync from macOS so Apple extended attributes do not spam remote tar warnings.
- Fixed provider-owned flags and target/capability validation to run through registered provider specs while preserving script-facing list JSON compatibility for coordinator and Blacksmith backends.
## 0.5.0 - 2026-05-04

View File

@ -6,6 +6,7 @@ import (
"os"
"github.com/openclaw/crabbox/internal/cli"
_ "github.com/openclaw/crabbox/internal/providers/all"
)
func main() {

View File

@ -78,7 +78,7 @@ Pick whichever matches your intent:
- **Get the mental model:** [How Crabbox Works](how-it-works.md), [Architecture](architecture.md), [Orchestrator](orchestrator.md).
- **Use the CLI:** [CLI](cli.md), [Commands](commands/README.md), [Features](features/README.md), [Actions hydration](features/actions-hydration.md).
- **Pick a target:** [Providers](features/providers.md), [AWS](features/aws.md), [Hetzner](features/hetzner.md), [Blacksmith Testbox](features/blacksmith-testbox.md), [Interactive desktop and VNC](features/interactive-desktop-vnc.md).
- **Pick or add a target:** [Providers](features/providers.md), [Provider backends](provider-backends.md), [AWS](features/aws.md), [Hetzner](features/hetzner.md), [Blacksmith Testbox](features/blacksmith-testbox.md), [Interactive desktop and VNC](features/interactive-desktop-vnc.md).
- **Operate it:** [Operations](operations.md), [Observability](observability.md), [Troubleshooting](troubleshooting.md), [Performance](performance.md).
- **Set it up or audit it:** [Infrastructure](infrastructure.md), [Security](security.md), [Source Map](source-map.md), [MVP Plan](mvp-plan.md).

View File

@ -14,7 +14,10 @@ crabbox list --json
In `provider=ssh` mode this prints the configured static target.
In `blacksmith-testbox` mode this forwards to `blacksmith testbox list`. Human output preserves the Blacksmith table; `--json` emits Crabbox-parsed rows with id, status, repo, workflow, job, ref, and created time when the upstream table exposes those columns.
In `blacksmith-testbox` mode this reads `blacksmith testbox list` and renders the
same Crabbox list shape as other providers. `--json` keeps the compatibility
shape parsed from the Blacksmith table: id, status, repo, workflow, job, ref,
and created time when the upstream table exposes those columns.
Flags:

View File

@ -10,7 +10,12 @@ crabbox status --id blue-lobster --json
crabbox status --provider ssh --target macos --static-host mac-studio.local
```
`--id` accepts the canonical `cbx_...` ID or active slug. In `blacksmith-testbox` mode it accepts a `tbx_...` ID or local slug and forwards to `blacksmith testbox status`. In `provider=ssh` mode `--id` is optional and resolves the configured static target or local claim. Plain status is read-only; `--wait` touches the lease while waiting for Crabbox brokered leases.
`--id` accepts the canonical `cbx_...` ID or active slug. In
`blacksmith-testbox` mode it accepts a `tbx_...` ID or local slug and derives a
normalized Crabbox status view from `blacksmith testbox list --all`. In
`provider=ssh` mode `--id` is optional and resolves the configured static target
or local claim. Plain status is read-only; `--wait` touches the lease while
waiting for Crabbox brokered leases.
Flags:

View File

@ -13,6 +13,7 @@ Core features:
- [Coordinator](coordinator.md): brokered leases through Cloudflare Workers and Durable Objects.
- [Broker auth and routing](broker-auth-routing.md): GitHub login, shared bearer tokens, optional Cloudflare Access, and Worker routes.
- [Providers](providers.md): provider overview, target matrix, classes, and fallback.
- [Provider backends](../provider-backends.md): implementation guide for adding a new provider/backend/plugin.
- [AWS](aws.md): EC2 Linux, Windows, WSL2, EC2 Mac, capacity, AMIs, and security groups.
- [Hetzner](hetzner.md): Linux-only managed Hetzner behavior, classes, and cleanup.
- [Blacksmith Testbox](blacksmith-testbox.md): delegated Testbox backend behavior.

View File

@ -75,20 +75,23 @@ For repos that already use Crabbox Actions hydration, `blacksmith.workflow`, `bl
## Forwarded Commands
Crabbox forwards machine operations to the Blacksmith CLI:
Crabbox forwards lifecycle and run operations to the Blacksmith CLI:
```sh
blacksmith testbox warmup <workflow> --job <job> --ref <ref> --ssh-public-key <key> --idle-timeout <minutes>
blacksmith testbox run --id <tbx_id> --ssh-private-key <key> <command>
blacksmith testbox status --id <tbx_id>
blacksmith testbox list
blacksmith testbox list --all
blacksmith testbox stop --id <tbx_id>
```
The wrapper is deliberately thin. If Blacksmith adds behavior to those commands, Crabbox should prefer forwarding rather than reimplementing it.
The wrapper is deliberately thin for warmup, run, and stop. `crabbox list` and
`crabbox status` normalize Blacksmith data into Crabbox's common list/status
views so rendering stays core-owned across providers. Status currently reads
`blacksmith testbox list --all` to build that view.
`crabbox list --provider blacksmith-testbox --json` parses the Blacksmith table
output into JSON rows with the fields Crabbox can see. That parser is a
output into compatibility JSON rows with the fields Crabbox can see. That parser is a
compatibility layer, not a Blacksmith API contract. If the Blacksmith CLI adds
native JSON output, Crabbox should switch to that and drop table parsing.

View File

@ -26,6 +26,7 @@ ssh Existing SSH host selected by static.host
- [AWS](aws.md): EC2 Linux, Windows, WSL2, EC2 Mac, capacity, AMIs, and security groups.
- [Hetzner](hetzner.md): Linux-only managed provider behavior, classes, and cleanup.
- [Blacksmith Testbox](blacksmith-testbox.md): delegated Testbox backend behavior.
- [Provider backends](../provider-backends.md): implementation guide for adding a new provider/backend/plugin.
## Hetzner Summary

591
docs/provider-backends.md Normal file
View File

@ -0,0 +1,591 @@
# Provider Backends
Read when:
- adding a new Crabbox provider;
- deciding between an SSH lease backend and a delegated run backend;
- adding provider-specific flags or config;
- reviewing a provider PR for the right ownership boundary;
- designing a future external provider plugin protocol.
Crabbox providers are built around one rule:
Providers configure backends. Core commands own workflows.
That keeps `crabbox run`, `warmup`, `list`, `status`, `stop`, `cleanup`,
Actions hydration, sync, result collection, rendering, and timing consistent
across providers. A provider should describe what it can do and return a backend
object. It should not fork the command surface.
## Choose The Backend Shape
Start by choosing the execution model.
### SSH Lease Backend
Use `SSHLeaseBackend` when the provider can hand Crabbox an SSH target.
Examples:
- Hetzner Cloud
- AWS EC2
- static SSH hosts
- a future Daytona sandbox if it exposes stable SSH access
Crabbox core owns the normal workflow after acquisition:
- claim and slug handling;
- SSH readiness checks;
- network target resolution;
- sync and sync guardrails;
- command wrapping and streaming;
- JUnit/result collection;
- Actions runner hydration over SSH;
- heartbeat/touch;
- release.
The backend owns only provider lifecycle:
```go
type SSHLeaseBackend interface {
Backend
Acquire(ctx context.Context, req AcquireRequest) (LeaseTarget, error)
Resolve(ctx context.Context, req ResolveRequest) (LeaseTarget, error)
List(ctx context.Context, req ListRequest) ([]LeaseView, error)
ReleaseLease(ctx context.Context, req ReleaseLeaseRequest) error
Touch(ctx context.Context, req TouchRequest) (Server, error)
}
```
Implement this when `LeaseTarget.SSH` can be populated with host, port, user,
key, work root, target OS, and Windows mode.
### Delegated Run Backend
Use `DelegatedRunBackend` when the provider owns execution instead of exposing
Crabbox-managed SSH.
Examples:
- Blacksmith Testbox
- a future Islo backend if it owns workspace setup and command streaming
- a future external runner service that accepts a command and streams output
The delegated backend owns warmup, command execution, output streaming, and
stop. Crabbox core still owns provider selection, config loading, local claims,
friendly slugs, timing summaries, and normalized list/status rendering.
```go
type DelegatedRunBackend interface {
Backend
Warmup(ctx context.Context, req WarmupRequest) error
Run(ctx context.Context, req RunRequest) (RunResult, error)
List(ctx context.Context, req ListRequest) ([]LeaseView, error)
Status(ctx context.Context, req StatusRequest) (statusView, error)
Stop(ctx context.Context, req StopRequest) error
}
```
The current implementation still returns the unexported `statusView`. That means
a delegated backend implementation cannot live entirely outside `internal/cli`
yet. Keep delegated backend implementations in `internal/cli`, or expose a
narrow constructor from `internal/cli` and let the provider package own only
registration/spec/flags/configure. Exporting `StatusView` is the next cleanup
before delegated backends can move fully into `internal/providers/<name>`.
A delegated backend must reject sync-only options that Crabbox cannot honor:
```go
if err := rejectDelegatedSyncOptions(providerName, req); err != nil {
return RunResult{}, err
}
```
`rejectDelegatedSyncOptions` is currently an `internal/cli` helper. Delegated
backends outside `internal/cli` need an exported equivalent before they can use
this directly.
Do not pretend a delegated provider is SSH-like unless the provider has a stable
SSH contract. If Crabbox cannot run rsync and remote commands itself, use
`DelegatedRunBackend`.
### Optional Interfaces
Add optional capabilities as small interfaces instead of widening every backend.
Cleanup is already optional:
```go
type CleanupBackend interface {
Backend
Cleanup(ctx context.Context, req CleanupRequest) error
}
```
List JSON compatibility is optional:
```go
type JSONListBackend interface {
Backend
ListJSON(ctx context.Context, req ListRequest) (any, error)
}
```
`JSONListBackend` is a compatibility escape hatch for script-facing JSON shapes.
Use it only when an existing provider already exposed a different JSON schema
than the normalized `[]LeaseView` shape.
Future provider-specific capability areas should follow the same pattern, for
example pricing or image management.
## Package Layout
Built-in providers live under `internal/providers/<name>`:
```text
internal/providers/all
internal/providers/hetzner
internal/providers/aws
internal/providers/ssh
internal/providers/blacksmith
```
Each provider package owns registration, provider name, aliases, spec,
provider-specific flags, and backend configuration. `cmd/crabbox` imports
`internal/providers/all` for side-effect registration:
```go
import (
"github.com/openclaw/crabbox/internal/cli"
_ "github.com/openclaw/crabbox/internal/providers/all"
)
```
The core provider contract and current backend implementations live in
`internal/cli`:
```text
internal/cli/provider_backend.go # interfaces, registry, request/result types
internal/cli/providers_common.go # shared direct SSH backend helpers
internal/cli/provider_aws.go # AWS SSH lease backend implementation
internal/cli/provider_hetzner.go # Hetzner SSH lease backend implementation
internal/cli/provider_static.go # static SSH lease backend implementation
internal/cli/provider_coordinator.go # brokered coordinator lease backend
internal/cli/provider_blacksmith.go # existing delegated Blacksmith backend
```
This split is intentional. Existing built-ins still use a broad set of
unexported lifecycle helpers for SSH keys, labels, slugs, claims, coordinator
heartbeats, sync, timing, and release. Provider packages should depend only on
the exported contract. Move backend implementation code into
`internal/providers/<name>` only when the required helper surface is small and
intentionally exported.
New providers should start in their own provider folder. If an SSH backend can
be implemented against the exported contract, keep it there. If it needs
temporary core helpers, expose a narrow constructor or helper from
`internal/cli` rather than exporting a large grab bag. Delegated backends cannot
move fully out of `internal/cli` until `statusView` and delegated sync-option
validation are exported.
## Provider Registration
A provider implements `cli.Provider`:
```go
type Provider interface {
Name() string
Aliases() []string
Spec() ProviderSpec
RegisterFlags(fs *flag.FlagSet, defaults Config) any
ApplyFlags(cfg *Config, fs *flag.FlagSet, values any) error
Configure(cfg Config, rt Runtime) (Backend, error)
}
```
Minimal SSH provider package:
```go
package example
import (
"flag"
"github.com/openclaw/crabbox/internal/cli"
)
func init() {
cli.RegisterProvider(Provider{})
}
type Provider struct{}
func (Provider) Name() string { return "example" }
func (Provider) Aliases() []string { return nil }
func (Provider) Spec() cli.ProviderSpec {
return cli.ProviderSpec{
Name: "example",
Kind: cli.ProviderKindSSHLease,
Targets: []cli.TargetSpec{
{OS: "linux"},
},
Features: cli.FeatureSet{
cli.FeatureSSH,
cli.FeatureCrabboxSync,
},
Coordinator: cli.CoordinatorNever,
}
}
func (Provider) RegisterFlags(*flag.FlagSet, cli.Config) any {
return cli.NoProviderFlags()
}
func (Provider) ApplyFlags(*cli.Config, *flag.FlagSet, any) error {
return nil
}
func (p Provider) Configure(cfg cli.Config, rt cli.Runtime) (cli.Backend, error) {
return cli.NewExampleLeaseBackend(p.Spec(), cfg, rt), nil
}
```
`NewExampleLeaseBackend` stands in for the backend constructor you add for the
provider. Existing providers use constructors such as `NewAWSLeaseBackend` and
`NewBlacksmithBackend`.
Then add the provider to `internal/providers/all/all.go`:
```go
import _ "github.com/openclaw/crabbox/internal/providers/example"
```
Tests in `internal/cli` do not import `internal/providers/all`, because that
would create an import cycle. Register test providers from a same-package test
file when testing core dispatch.
## Provider Spec
`ProviderSpec` is command-facing metadata:
```go
type ProviderSpec struct {
Name string
Kind ProviderKind
Targets []TargetSpec
Features FeatureSet
Coordinator CoordinatorMode
}
```
Use canonical provider names in docs and config. Aliases are for compatibility.
Pick `Kind` carefully:
- `ProviderKindSSHLease`: provider returns SSH targets and Crabbox owns sync/run.
- `ProviderKindDelegatedRun`: provider owns execution and output streaming.
Targets should describe what the provider can actually satisfy. Do not list
`windows`, `macos`, `desktop`, `browser`, or `code` unless the backend supports
that path end to end.
Feature flags should be concrete:
```go
cli.FeatureSSH
cli.FeatureCrabboxSync
cli.FeatureCleanup
cli.FeatureDesktop
cli.FeatureBrowser
cli.FeatureCode
cli.FeatureTailscale
```
Actions runner hydration is intentionally not a provider feature. It is a core
SSH-over-Linux workflow. It requires:
- an SSH lease backend;
- `target=linux`;
- no delegated execution.
Only set `CoordinatorSupported` when the Crabbox coordinator can provision that
provider. A direct-only SSH provider should use `CoordinatorNever`.
## Flags And Config
Provider flags are registered before parsing because Go's `flag` package rejects
unknown flags. `RegisterFlags` must be cheap and side-effect free. It returns an
opaque values struct that is passed back into `ApplyFlags` only after config and
common flags select the provider.
Pattern, when the provider has an exported flag helper or lives in `internal/cli`:
```go
type exampleFlagValues struct {
Region *string
}
func (Provider) RegisterFlags(fs *flag.FlagSet, defaults cli.Config) any {
return exampleFlagValues{
Region: fs.String("example-region", defaults.Example.Region, "Example region"),
}
}
func (Provider) ApplyFlags(cfg *cli.Config, fs *flag.FlagSet, values any) error {
v, ok := values.(exampleFlagValues)
if !ok {
return nil
}
if cli.FlagWasSet(fs, "example-region") {
cfg.Example.Region = *v.Region
}
return nil
}
```
`Config` does not yet have a generic provider config bag. New provider packages
should either:
- add typed config fields and use `cli.FlagWasSet` from the provider package; or
- expose a small provider-specific flag helper from `internal/cli`, as
Blacksmith does, when the config type is not ready to export cleanly.
If a provider needs durable config, add typed config fields in `Config` and env
overrides in `config.go`. Keep compatibility shims for existing top-level
provider config, but prefer `providers.<name>` for new provider families once
that config bag lands.
Never pass provider secrets as command-line arguments. Use environment variables,
local SDK config, the coordinator, or a credential store outside repo config.
## Runtime
Backends receive a narrow runtime:
```go
type Runtime struct {
Stdout io.Writer
Stderr io.Writer
Clock Clock
HTTP *http.Client
Exec CommandRunner
}
```
Use it instead of `App`, global clocks, or package-level command hooks.
Delegated CLI integrations must use `Runtime.Exec`:
```go
result, err := rt.Exec.Run(ctx, cli.LocalCommandRequest{
Name: "provider-cli",
Args: args,
Stdout: rt.Stdout,
Stderr: rt.Stderr,
})
```
This gives tests a fake command runner and avoids package-level
`exec.CommandContext` seams.
Use `Runtime.Clock` for timing in backend code. Use `Runtime.Stdout` and
`Runtime.Stderr` for streaming and warnings.
## Implementing An SSH Lease Backend
An SSH lease backend should return a complete `LeaseTarget`:
```go
type LeaseTarget struct {
Server Server
SSH SSHTarget
LeaseID string
Coordinator *CoordinatorClient
}
```
`Acquire` should:
1. validate direct-provider prerequisites;
2. mint or accept the lease id handled by the request path;
3. ensure or install the SSH key;
4. provision the machine or sandbox;
5. wait until an address exists;
6. populate `SSHTarget`;
7. wait for SSH readiness when the provider owns boot;
8. mark provider labels/tags as ready;
9. return `LeaseTarget`.
`Resolve` should accept canonical lease IDs, provider IDs, names, and slugs
where the provider can support them. It should return the stored per-lease SSH
key when available.
`List` returns normalized `LeaseView` values. Do not print from `List`; command
rendering belongs to core.
`Touch` should update provider labels/tags with idle and state metadata when the
provider supports it. Static providers can update only the in-memory view.
`ReleaseLease` should be idempotent where practical. Remove local claims after
the provider release succeeds or is known to be unnecessary.
If cleanup is meaningful, implement `CleanupBackend`. Cleanup should honor
`DryRun`, log skip/delete decisions to stderr, and use provider labels to avoid
deleting unrelated machines.
## Implementing A Delegated Run Backend
A delegated backend should preserve Crabbox ergonomics while letting the provider
own the remote workflow.
`Warmup` should:
1. validate provider-specific workflow config;
2. create or warm the provider resource;
3. claim the resource locally with provider name and slug;
4. print the standard warmup summary;
5. write timing JSON when requested.
`Run` should:
1. reject unsupported Crabbox sync options;
2. acquire a resource or resolve an existing id/slug;
3. claim/reclaim the resource for the repo;
4. stream provider output through `Runtime.Stdout` and `Runtime.Stderr`;
5. return `RunResult`;
6. stop temporary resources when `Keep` is false.
`List` and `Status` should return normalized views. If the provider only offers
a table or lossy native status shape, keep that parsing inside the backend.
`Stop` should stop the provider resource, remove local claims, and remove local
per-resource keys if the backend created them.
Do not make delegated providers support `crabbox ssh`, `vnc`, `webvnc`,
`screenshot`, `code`, or Actions runner hydration unless the provider exposes a
stable connection contract that preserves Crabbox's security boundary.
## Rendering
Backends return values. Core renders output.
`ListRequest` and `StatusRequest` intentionally do not carry JSON flags. The
command handler decides whether to render human output or JSON.
`JSONListBackend` is the exception for compatibility with older script-facing
JSON schemas. It should not be used for new providers.
That rule keeps:
- `crabbox list --json`;
- `crabbox status --json`;
- human tables;
- future UI/plugin consumers;
consistent across backend kinds.
## External Provider Plugins
External process plugins are not implemented yet. Do not add a provider that
depends on an undocumented stdio protocol.
The intended direction is:
- a built-in Go provider package discovers/configures the external process;
- the process speaks JSON over stdio;
- the Go side adapts it to `SSHLeaseBackend` or `DelegatedRunBackend`;
- core commands still render list/status and own SSH workflows where applicable.
Expected rough command shape:
```text
provider-plugin capabilities
provider-plugin acquire
provider-plugin resolve
provider-plugin list
provider-plugin release
provider-plugin touch
provider-plugin run
provider-plugin status
provider-plugin stop
```
The external protocol should not bypass the backend interfaces. It is an
implementation detail behind a normal registered provider.
## Tests
Add tests at the lowest level that proves the contract.
For provider registration:
- canonical name resolves through `ProviderFor`;
- aliases resolve where promised;
- `Spec` has the expected kind, targets, features, and coordinator mode;
- provider-specific flags apply only after selection.
For SSH lease backends:
- acquire success returns a `LeaseTarget` with host, user, port, key, lease id;
- acquire failure releases partial resources when possible;
- resolve supports lease id and supported aliases;
- list returns normalized views without printing;
- touch updates labels/tags and honors state/idle timeout;
- release removes claims and provider resources;
- cleanup honors dry-run.
For delegated run backends:
- sync-only/checksum/force-large options are rejected;
- new run acquires, claims, streams, and stops when `Keep=false`;
- existing id/slug resolves and claims correctly;
- list/status parse provider output into normalized views;
- stop removes claims and local keys;
- all subprocess calls go through `Runtime.Exec`.
Use fake `CommandRunner`, fake clocks, fake HTTP clients, and provider test
clients. Avoid live provider calls in unit tests.
Run at least:
```sh
go test -count=1 ./internal/cli ./internal/providers/...
go test -count=1 ./...
go vet ./...
npm run docs:check
```
For high-risk provider changes, also run:
```sh
go test -race -count=1 ./internal/cli
go build -trimpath -o bin/crabbox ./cmd/crabbox
```
Add live smoke only when credentials and cost boundaries are explicit.
## Review Checklist
Before landing a new backend:
- The provider has a folder under `internal/providers/<name>`.
- The provider is imported by `internal/providers/all`.
- `Name` is canonical and docs use that name.
- Compatibility aliases are intentional and tested.
- `ProviderSpec.Kind` matches the real execution model.
- Targets and features describe implemented behavior only.
- Coordinator mode is `CoordinatorNever` unless the coordinator can provision it.
- Provider flags are registered before parse and applied only after selection.
- Secrets are not stored in repo config or passed in argv.
- `list` and `status` return normalized values instead of printing.
- Delegated providers reject unsupported sync options.
- SSH providers do not own core sync/run/rendering.
- Tests cover command dispatch and backend behavior without live credentials.
- Docs and source map are updated.

1422
docs/refactor/provider.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -36,11 +36,22 @@ This page maps user-facing behavior back to implementation files. Keep docs desc
- Direct Hetzner provider: `internal/cli/hcloud.go`
- Direct AWS provider: `internal/cli/aws.go`
- Static SSH macOS/Windows provider: `internal/cli/static.go`
- Blacksmith Testbox CLI wrapper: `internal/cli/blacksmith.go`
- Blacksmith Testbox argument/parsing helpers: `internal/cli/blacksmith.go`
- Provider backend interfaces, registry, and request/result types:
`internal/cli/provider_backend.go`
- Built-in provider registration packages:
`internal/providers/hetzner`, `internal/providers/aws`,
`internal/providers/ssh`, `internal/providers/blacksmith`,
`internal/providers/all`
- Built-in provider backend implementations:
`internal/cli/providers_common.go`, `internal/cli/provider_aws.go`,
`internal/cli/provider_hetzner.go`, `internal/cli/provider_static.go`,
`internal/cli/provider_coordinator.go`, `internal/cli/provider_blacksmith.go`
- Worker Hetzner provider: `worker/src/hetzner.ts`
- Worker AWS EC2 provider: `worker/src/aws.ts`
- Worker AWS AMI create/read/promote routes: `worker/src/fleet.ts`, `worker/src/aws.ts`
- Provider feature docs: `docs/features/aws.md`, `docs/features/hetzner.md`, `docs/features/blacksmith-testbox.md`
- Provider/backend authoring guide: `docs/provider-backends.md`
- CLI cloud-init bootstrap: `internal/cli/bootstrap.go`
- Worker cloud-init bootstrap: `worker/src/bootstrap.ts`
- Tailscale feature contract: `docs/features/tailscale.md`

View File

@ -78,13 +78,18 @@ func (a App) actionsHydrate(ctx context.Context, args []string) error {
if err := claimLeaseForRepoConfig(leaseID, slug, cfg, repo.Root, cfg.IdleTimeout, *reclaim); err != nil {
return err
}
if coord, ok, err := newTargetCoordinatorClient(cfg); err != nil {
backend, err := loadBackend(cfg, runtimeForApp(a))
if err != nil {
return err
} else if ok {
}
if coord := backendCoordinator(backend); coord != nil {
stopHeartbeat := startCoordinatorHeartbeat(ctx, coord, leaseID, cfg.IdleTimeout, nil, a.Stderr)
defer stopHeartbeat()
} else {
a.touchActiveLeaseBestEffort(ctx, cfg, server, leaseID)
} else if sshBackend, ok := backend.(SSHLeaseBackend); ok {
_, err := sshBackend.Touch(ctx, TouchRequest{Lease: LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, State: blank(server.Labels["state"], "ready"), IdleTimeout: cfg.IdleTimeout})
if err != nil {
fmt.Fprintf(a.Stderr, "warning: touch failed for %s: %v\n", leaseID, err)
}
}
label := githubActionsLeaseLabel(leaseID)
if err := a.registerGitHubActionsRunner(ctx, cfg, target, leaseID, slug, ghRepo, "", nil); err != nil {
@ -186,29 +191,15 @@ func (a App) actionsRegister(ctx context.Context, args []string) error {
if err != nil {
return err
}
if coord, ok, err := newTargetCoordinatorClient(cfg); err != nil {
return err
} else if ok {
lease, err := coord.GetLease(ctx, *leaseIDFlag)
if err != nil {
return err
}
_, target, leaseID := leaseToServerTarget(lease, cfg)
if err := claimLeaseForRepoConfig(leaseID, lease.Slug, cfg, repo.Root, cfg.IdleTimeout, *reclaim); err != nil {
return err
}
a.touchCoordinatorLeaseBestEffort(ctx, cfg, leaseID)
return a.registerGitHubActionsRunner(ctx, cfg, target, leaseID, lease.Slug, ghRepo, *nameFlag, extraLabels)
}
server, target, leaseID, err := a.findLease(ctx, cfg, *leaseIDFlag)
server, target, leaseID, slug, err := a.resolveLeaseTargetForActions(ctx, cfg, *leaseIDFlag)
if err != nil {
return err
}
if err := claimLeaseForRepoConfig(leaseID, serverSlug(server), cfg, repo.Root, cfg.IdleTimeout, *reclaim); err != nil {
if err := claimLeaseForRepoConfig(leaseID, slug, cfg, repo.Root, cfg.IdleTimeout, *reclaim); err != nil {
return err
}
a.touchActiveLeaseBestEffort(ctx, cfg, server, leaseID)
return a.registerGitHubActionsRunner(ctx, cfg, target, leaseID, serverSlug(server), ghRepo, *nameFlag, extraLabels)
a.touchLeaseTargetBestEffort(ctx, cfg, LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, "")
return a.registerGitHubActionsRunner(ctx, cfg, target, leaseID, slug, ghRepo, *nameFlag, extraLabels)
}
func (a App) actionsDispatch(ctx context.Context, args []string) error {
@ -277,17 +268,7 @@ func (a App) registerGitHubActionsRunner(ctx context.Context, cfg Config, target
}
func (a App) resolveLeaseTargetForActions(ctx context.Context, cfg Config, id string) (Server, SSHTarget, string, string, error) {
if coord, ok, err := newTargetCoordinatorClient(cfg); err != nil {
return Server{}, SSHTarget{}, "", "", err
} else if ok {
lease, err := coord.GetLease(ctx, id)
if err != nil {
return Server{}, SSHTarget{}, "", "", err
}
server, target, leaseID := leaseToServerTarget(lease, cfg)
return server, target, leaseID, lease.Slug, nil
}
server, target, leaseID, err := a.findLease(ctx, cfg, id)
server, target, leaseID, err := a.resolveLeaseTarget(ctx, cfg, id)
return server, target, leaseID, serverSlug(server), err
}

View File

@ -1,13 +1,8 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"os/exec"
"regexp"
"strings"
"time"
@ -16,25 +11,12 @@ import (
const blacksmithTestboxProvider = "blacksmith-testbox"
var (
blacksmithCommandContext = exec.CommandContext
blacksmithIDPattern = regexp.MustCompile(`\btbx_[A-Za-z0-9_-]+\b`)
blacksmithCleanupAttempts = 36
blacksmithCleanupDelay = 5 * time.Second
blacksmithCleanupQuiet = 12
)
type blacksmithRunOptions struct {
ID string
Keep bool
Reclaim bool
SyncOnly bool
Debug bool
ShellMode bool
Command []string
IdleTimeout time.Duration
TimingJSON bool
}
type blacksmithFlagValues struct {
Org *string
Workflow *string
@ -80,204 +62,6 @@ func applyBlacksmithFlagOverrides(cfg *Config, fs *flag.FlagSet, values blacksmi
}
}
func (a App) blacksmithWarmup(ctx context.Context, cfg Config, repo Repo, keep, reclaim, timingJSON bool) error {
started := time.Now()
leaseID, slug, err := a.blacksmithWarmupLease(ctx, cfg, repo, reclaim)
if err != nil {
return err
}
fmt.Fprintf(a.Stdout, "leased %s slug=%s provider=%s idle_timeout=%s\n", leaseID, slug, blacksmithTestboxProvider, blacksmithIdleTimeout(cfg))
if !keep {
fmt.Fprintf(a.Stderr, "warning: blacksmith warmup keeps the testbox until idle timeout or explicit stop\n")
}
fmt.Fprintf(a.Stdout, "warmup complete total=%s\n", time.Since(started).Round(time.Millisecond))
if timingJSON {
total := time.Since(started)
if err := writeTimingJSON(a.Stderr, timingReport{
Provider: blacksmithTestboxProvider,
LeaseID: leaseID,
Slug: slug,
TotalMs: total.Milliseconds(),
ExitCode: 0,
}); err != nil {
return err
}
}
return nil
}
func (a App) blacksmithRun(ctx context.Context, cfg Config, repo Repo, opts blacksmithRunOptions) error {
if opts.SyncOnly {
return exit(2, "blacksmith-testbox delegates sync to Blacksmith; --sync-only is not supported")
}
started := time.Now()
leaseID := opts.ID
acquired := false
var err error
if leaseID == "" {
leaseID, _, err = a.blacksmithWarmupLease(ctx, cfg, repo, opts.Reclaim)
if err != nil {
return err
}
acquired = true
} else {
leaseID, err = resolveBlacksmithLeaseID(leaseID, repo.Root, opts.Reclaim)
if err != nil {
return err
}
slug, err := blacksmithClaimSlug(opts.ID, leaseID)
if err != nil {
return err
}
if err := claimLeaseForRepoProvider(leaseID, slug, blacksmithTestboxProvider, repo.Root, opts.IdleTimeout, opts.Reclaim); err != nil {
return err
}
}
if acquired && !opts.Keep {
defer func() {
if err := a.blacksmithStopLease(context.Background(), cfg, leaseID); err != nil {
fmt.Fprintf(a.Stderr, "warning: blacksmith stop failed for %s: %v\n", leaseID, err)
return
}
removeLeaseClaim(leaseID)
removeStoredTestboxKey(leaseID)
}()
}
fmt.Fprintf(a.Stderr, "provider=blacksmith-testbox id=%s sync=delegated auth=blacksmith\n", leaseID)
commandStart := time.Now()
code := a.runBlacksmithTestbox(ctx, cfg, leaseID, opts.Command, opts.Debug, opts.ShellMode)
commandDuration := time.Since(commandStart)
total := time.Since(started)
fmt.Fprintf(a.Stderr, "blacksmith run summary sync=delegated command=%s total=%s exit=%d\n", commandDuration.Round(time.Millisecond), total.Round(time.Millisecond), code)
if opts.TimingJSON {
if err := writeTimingJSON(a.Stderr, timingReport{
Provider: blacksmithTestboxProvider,
LeaseID: leaseID,
SyncPhases: []timingPhase{{Name: "delegated", Skipped: true, Reason: "blacksmith-testbox owns sync"}},
SyncDelegated: true,
CommandMs: commandDuration.Milliseconds(),
TotalMs: total.Milliseconds(),
ExitCode: code,
}); err != nil {
return err
}
}
if code != 0 {
return ExitError{Code: code, Message: fmt.Sprintf("blacksmith testbox run exited %d", code)}
}
return nil
}
func (a App) blacksmithList(ctx context.Context, cfg Config, jsonOut bool) error {
args := blacksmithListArgs(cfg)
if !jsonOut {
return a.streamBlacksmith(ctx, args)
}
cmd := blacksmithCommandContext(ctx, "blacksmith", args...)
out, err := cmd.CombinedOutput()
if err != nil {
return ExitError{Code: exitCode(err), Message: fmt.Sprintf("blacksmith failed: %v: %s", err, strings.TrimSpace(string(out)))}
}
return json.NewEncoder(a.Stdout).Encode(parseBlacksmithList(string(out)))
}
func (a App) blacksmithStatus(ctx context.Context, cfg Config, id string, wait bool, waitTimeout time.Duration, jsonOut bool) error {
if jsonOut {
return exit(2, "blacksmith-testbox status does not support --json")
}
leaseID, err := resolveBlacksmithLeaseID(id, "", false)
if err != nil {
return err
}
return a.streamBlacksmith(ctx, blacksmithStatusArgs(cfg, leaseID, wait, waitTimeout))
}
func (a App) blacksmithStop(ctx context.Context, cfg Config, id string) error {
leaseID, err := resolveBlacksmithLeaseID(id, "", false)
if err != nil {
return err
}
if err := a.blacksmithStopLease(ctx, cfg, leaseID); err != nil {
return err
}
removeLeaseClaim(leaseID)
removeStoredTestboxKey(leaseID)
return nil
}
func (a App) blacksmithWarmupLease(ctx context.Context, cfg Config, repo Repo, reclaim bool) (string, string, error) {
pendingID := "tbx_pending_" + strings.TrimPrefix(newLeaseID(), "cbx_")
cleanupKeyID := pendingID
defer func() {
if cleanupKeyID != "" {
removeStoredTestboxKey(cleanupKeyID)
}
}()
_, publicKey, err := ensureTestboxKey(pendingID)
if err != nil {
return "", "", err
}
args, err := blacksmithWarmupArgs(cfg, publicKey)
if err != nil {
return "", "", err
}
beforeWarmup := a.blacksmithListIDsBestEffort(ctx, cfg)
var output bytes.Buffer
cmd := blacksmithCommandContext(ctx, "blacksmith", args...)
cmd.Stdout = io.MultiWriter(a.Stdout, &output)
cmd.Stderr = io.MultiWriter(a.Stderr, &output)
if err := cmd.Run(); err != nil {
a.cleanupFailedBlacksmithWarmup(ctx, cfg, beforeWarmup, output.String())
return "", "", exit(exitCode(err), "blacksmith testbox warmup failed: %v", err)
}
leaseID := parseBlacksmithID(output.String())
if leaseID == "" {
return "", "", exit(5, "blacksmith testbox warmup did not print a tbx_ id")
}
if err := moveStoredTestboxKey(pendingID, leaseID); err != nil {
_ = a.blacksmithStopLease(ctx, cfg, leaseID)
return "", "", exit(2, "store blacksmith key for %s: %v", leaseID, err)
}
cleanupKeyID = leaseID
slug := newLeaseSlug(leaseID)
if err := claimLeaseForRepoProvider(leaseID, slug, blacksmithTestboxProvider, repo.Root, blacksmithIdleTimeout(cfg), reclaim); err != nil {
_ = a.blacksmithStopLease(ctx, cfg, leaseID)
return "", "", err
}
cleanupKeyID = ""
return leaseID, slug, nil
}
func (a App) runBlacksmithTestbox(ctx context.Context, cfg Config, leaseID string, command []string, debug, shellMode bool) int {
keyPath, err := testboxKeyPath(leaseID)
if err != nil {
fmt.Fprintf(a.Stderr, "blacksmith key path failed: %v\n", err)
return 2
}
args := blacksmithRunArgs(cfg, leaseID, keyPath, command, debug || cfg.Blacksmith.Debug, shellMode)
cmd := blacksmithCommandContext(ctx, "blacksmith", args...)
cmd.Stdout = a.Stdout
cmd.Stderr = a.Stderr
if err := cmd.Run(); err != nil {
return exitCode(err)
}
return 0
}
func (a App) blacksmithStopLease(ctx context.Context, cfg Config, leaseID string) error {
return a.streamBlacksmith(ctx, blacksmithStopArgs(cfg, leaseID))
}
func (a App) streamBlacksmith(ctx context.Context, args []string) error {
cmd := blacksmithCommandContext(ctx, "blacksmith", args...)
cmd.Stdout = a.Stdout
cmd.Stderr = a.Stderr
if err := cmd.Run(); err != nil {
return ExitError{Code: exitCode(err), Message: fmt.Sprintf("blacksmith failed: %v", err)}
}
return nil
}
func blacksmithWarmupArgs(cfg Config, publicKey string) ([]string, error) {
workflow := blacksmithWorkflow(cfg)
if workflow == "" {
@ -334,61 +118,6 @@ func blacksmithListAllArgs(cfg Config) []string {
return append(blacksmithListArgs(cfg), "--all")
}
func (a App) blacksmithListIDsBestEffort(ctx context.Context, cfg Config) map[string]bool {
out, err := blacksmithCommandOutput(ctx, cfg, blacksmithListAllArgs(cfg))
if err != nil {
return map[string]bool{}
}
ids := map[string]bool{}
for _, item := range parseBlacksmithList(out) {
ids[item.ID] = true
}
return ids
}
func (a App) cleanupFailedBlacksmithWarmup(ctx context.Context, cfg Config, before map[string]bool, output string) {
if leaseID := parseBlacksmithID(output); leaseID != "" {
if err := a.blacksmithStopLease(ctx, cfg, leaseID); err == nil {
before[leaseID] = true
}
}
stoppedAny := false
quietAttempts := 0
for attempt := 0; attempt < blacksmithCleanupAttempts; attempt++ {
if attempt > 0 {
select {
case <-ctx.Done():
return
case <-time.After(blacksmithCleanupDelay):
}
}
list, err := blacksmithCommandOutput(ctx, cfg, blacksmithListAllArgs(cfg))
if err != nil {
return
}
stopped := false
for _, item := range parseBlacksmithList(list) {
if before[item.ID] || !blacksmithMatchesConfig(item, cfg) {
continue
}
_ = a.blacksmithStopLease(ctx, cfg, item.ID)
before[item.ID] = true
stopped = true
}
if stopped {
stoppedAny = true
quietAttempts = 0
continue
}
if stoppedAny {
quietAttempts++
if quietAttempts >= blacksmithCleanupQuiet {
return
}
}
}
}
func blacksmithMatchesConfig(item blacksmithListItem, cfg Config) bool {
if workflow := blacksmithWorkflow(cfg); workflow != "" && item.Workflow != workflow {
return false
@ -402,15 +131,6 @@ func blacksmithMatchesConfig(item blacksmithListItem, cfg Config) bool {
return true
}
func blacksmithCommandOutput(ctx context.Context, cfg Config, args []string) (string, error) {
cmd := blacksmithCommandContext(ctx, "blacksmith", args...)
out, err := cmd.CombinedOutput()
if err != nil {
return "", err
}
return string(out), nil
}
func parseBlacksmithList(output string) []blacksmithListItem {
items := []blacksmithListItem{}
for _, line := range strings.Split(output, "\n") {

View File

@ -2,9 +2,9 @@ package cli
import (
"context"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
@ -12,6 +12,27 @@ import (
"time"
)
type blacksmithFuncRunner struct {
calls [][]string
fn func(LocalCommandRequest) (LocalCommandResult, error)
}
func (r *blacksmithFuncRunner) Run(_ context.Context, req LocalCommandRequest) (LocalCommandResult, error) {
r.calls = append(r.calls, append([]string(nil), req.Args...))
if r.fn != nil {
return r.fn(req)
}
return LocalCommandResult{}, nil
}
func newTestBlacksmithBackend(cfg Config, runner CommandRunner) *blacksmithBackend {
return &blacksmithBackend{
spec: testBlacksmithProvider{}.Spec(),
cfg: cfg,
rt: Runtime{Stdout: io.Discard, Stderr: io.Discard, Clock: realClock{}, Exec: runner},
}
}
func TestBlacksmithWarmupArgs(t *testing.T) {
cfg := baseConfig()
cfg.Blacksmith = BlacksmithConfig{
@ -66,18 +87,14 @@ func TestBlacksmithWarmupFailureRemovesPendingKey(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
original := blacksmithCommandContext
blacksmithCommandContext = func(context.Context, string, ...string) *exec.Cmd {
return exec.Command("sh", "-c", "exit 1")
}
t.Cleanup(func() {
blacksmithCommandContext = original
})
runner := &blacksmithFuncRunner{fn: func(LocalCommandRequest) (LocalCommandResult, error) {
return LocalCommandResult{ExitCode: 1}, errors.New("exit status 1")
}}
cfg := baseConfig()
cfg.Blacksmith.Workflow = ".github/workflows/testbox.yml"
app := App{Stdout: io.Discard, Stderr: io.Discard}
_, _, err := app.blacksmithWarmupLease(context.Background(), cfg, Repo{Root: "/repo"}, false)
backend := newTestBlacksmithBackend(cfg, runner)
_, _, err := backend.warmupLease(context.Background(), Repo{Root: "/repo"}, false)
if err == nil {
t.Fatal("expected warmup failure")
}
@ -98,27 +115,23 @@ func TestBlacksmithWarmupFailureStopsPrintedTestbox(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
original := blacksmithCommandContext
var stopped string
blacksmithCommandContext = func(_ context.Context, _ string, args ...string) *exec.Cmd {
if len(args) >= 3 && args[0] == "testbox" && args[1] == "stop" {
for i, arg := range args {
if arg == "--id" && i+1 < len(args) {
stopped = args[i+1]
runner := &blacksmithFuncRunner{fn: func(req LocalCommandRequest) (LocalCommandResult, error) {
if len(req.Args) >= 3 && req.Args[0] == "testbox" && req.Args[1] == "stop" {
for i, arg := range req.Args {
if arg == "--id" && i+1 < len(req.Args) {
stopped = req.Args[i+1]
}
}
return exec.Command("sh", "-c", "exit 0")
return LocalCommandResult{}, nil
}
return exec.Command("sh", "-c", "printf 'queued tbx_leaked123\\n'; exit 1")
}
t.Cleanup(func() {
blacksmithCommandContext = original
})
return LocalCommandResult{ExitCode: 1, Stdout: "queued tbx_leaked123\n"}, errors.New("exit status 1")
}}
cfg := baseConfig()
cfg.Blacksmith.Workflow = ".github/workflows/testbox.yml"
app := App{Stdout: io.Discard, Stderr: io.Discard}
_, _, err := app.blacksmithWarmupLease(context.Background(), cfg, Repo{Root: "/repo"}, false)
backend := newTestBlacksmithBackend(cfg, runner)
_, _, err := backend.warmupLease(context.Background(), Repo{Root: "/repo"}, false)
if err == nil {
t.Fatal("expected warmup failure")
}
@ -131,7 +144,6 @@ func TestBlacksmithWarmupFailureStopsNewListedTestbox(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
original := blacksmithCommandContext
originalDelay := blacksmithCleanupDelay
originalAttempts := blacksmithCleanupAttempts
originalQuiet := blacksmithCleanupQuiet
@ -140,26 +152,25 @@ func TestBlacksmithWarmupFailureStopsNewListedTestbox(t *testing.T) {
blacksmithCleanupQuiet = 1
var stopped string
listCalls := 0
blacksmithCommandContext = func(_ context.Context, _ string, args ...string) *exec.Cmd {
if len(args) >= 3 && args[0] == "testbox" && args[1] == "list" {
runner := &blacksmithFuncRunner{fn: func(req LocalCommandRequest) (LocalCommandResult, error) {
if len(req.Args) >= 3 && req.Args[0] == "testbox" && req.Args[1] == "list" {
listCalls++
if listCalls < 3 {
return exec.Command("sh", "-c", "printf 'ID STATUS REPO WORKFLOW JOB REF CREATED\\n'")
return LocalCommandResult{Stdout: "ID STATUS REPO WORKFLOW JOB REF CREATED\n"}, nil
}
return exec.Command("sh", "-c", "printf 'tbx_async123 queued openclaw .github/workflows/testbox.yml check main 2026-05-04T21:23:47.000000Z\\n'")
return LocalCommandResult{Stdout: "tbx_async123 queued openclaw .github/workflows/testbox.yml check main 2026-05-04T21:23:47.000000Z\n"}, nil
}
if len(args) >= 3 && args[0] == "testbox" && args[1] == "stop" {
for i, arg := range args {
if arg == "--id" && i+1 < len(args) {
stopped = args[i+1]
if len(req.Args) >= 3 && req.Args[0] == "testbox" && req.Args[1] == "stop" {
for i, arg := range req.Args {
if arg == "--id" && i+1 < len(req.Args) {
stopped = req.Args[i+1]
}
}
return exec.Command("sh", "-c", "exit 0")
return LocalCommandResult{}, nil
}
return exec.Command("sh", "-c", "printf 'workflow missing\\n'; exit 1")
}
return LocalCommandResult{ExitCode: 1, Stdout: "workflow missing\n"}, errors.New("exit status 1")
}}
t.Cleanup(func() {
blacksmithCommandContext = original
blacksmithCleanupDelay = originalDelay
blacksmithCleanupAttempts = originalAttempts
blacksmithCleanupQuiet = originalQuiet
@ -169,8 +180,8 @@ func TestBlacksmithWarmupFailureStopsNewListedTestbox(t *testing.T) {
cfg.Blacksmith.Workflow = ".github/workflows/testbox.yml"
cfg.Blacksmith.Job = "check"
cfg.Blacksmith.Ref = "main"
app := App{Stdout: io.Discard, Stderr: io.Discard}
_, _, err := app.blacksmithWarmupLease(context.Background(), cfg, Repo{Root: "/repo"}, false)
backend := newTestBlacksmithBackend(cfg, runner)
_, _, err := backend.warmupLease(context.Background(), Repo{Root: "/repo"}, false)
if err == nil {
t.Fatal("expected warmup failure")
}
@ -183,7 +194,6 @@ func TestBlacksmithWarmupFailureContinuesAfterFirstDelayedStop(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
original := blacksmithCommandContext
originalDelay := blacksmithCleanupDelay
originalAttempts := blacksmithCleanupAttempts
originalQuiet := blacksmithCleanupQuiet
@ -192,30 +202,29 @@ func TestBlacksmithWarmupFailureContinuesAfterFirstDelayedStop(t *testing.T) {
blacksmithCleanupQuiet = 1
stopped := []string{}
listCalls := 0
blacksmithCommandContext = func(_ context.Context, _ string, args ...string) *exec.Cmd {
if len(args) >= 3 && args[0] == "testbox" && args[1] == "list" {
runner := &blacksmithFuncRunner{fn: func(req LocalCommandRequest) (LocalCommandResult, error) {
if len(req.Args) >= 3 && req.Args[0] == "testbox" && req.Args[1] == "list" {
listCalls++
switch listCalls {
case 2:
return exec.Command("sh", "-c", "printf 'tbx_delayed1 queued openclaw .github/workflows/testbox.yml check main 2026-05-04T21:23:47.000000Z\\n'")
return LocalCommandResult{Stdout: "tbx_delayed1 queued openclaw .github/workflows/testbox.yml check main 2026-05-04T21:23:47.000000Z\n"}, nil
case 3:
return exec.Command("sh", "-c", "printf 'tbx_delayed2 queued openclaw .github/workflows/testbox.yml check main 2026-05-04T21:23:48.000000Z\\n'")
return LocalCommandResult{Stdout: "tbx_delayed2 queued openclaw .github/workflows/testbox.yml check main 2026-05-04T21:23:48.000000Z\n"}, nil
default:
return exec.Command("sh", "-c", "printf 'ID STATUS REPO WORKFLOW JOB REF CREATED\\n'")
return LocalCommandResult{Stdout: "ID STATUS REPO WORKFLOW JOB REF CREATED\n"}, nil
}
}
if len(args) >= 3 && args[0] == "testbox" && args[1] == "stop" {
for i, arg := range args {
if arg == "--id" && i+1 < len(args) {
stopped = append(stopped, args[i+1])
if len(req.Args) >= 3 && req.Args[0] == "testbox" && req.Args[1] == "stop" {
for i, arg := range req.Args {
if arg == "--id" && i+1 < len(req.Args) {
stopped = append(stopped, req.Args[i+1])
}
}
return exec.Command("sh", "-c", "exit 0")
return LocalCommandResult{}, nil
}
return exec.Command("sh", "-c", "printf 'workflow missing\\n'; exit 1")
}
return LocalCommandResult{ExitCode: 1, Stdout: "workflow missing\n"}, errors.New("exit status 1")
}}
t.Cleanup(func() {
blacksmithCommandContext = original
blacksmithCleanupDelay = originalDelay
blacksmithCleanupAttempts = originalAttempts
blacksmithCleanupQuiet = originalQuiet
@ -225,8 +234,8 @@ func TestBlacksmithWarmupFailureContinuesAfterFirstDelayedStop(t *testing.T) {
cfg.Blacksmith.Workflow = ".github/workflows/testbox.yml"
cfg.Blacksmith.Job = "check"
cfg.Blacksmith.Ref = "main"
app := App{Stdout: io.Discard, Stderr: io.Discard}
_, _, err := app.blacksmithWarmupLease(context.Background(), cfg, Repo{Root: "/repo"}, false)
backend := newTestBlacksmithBackend(cfg, runner)
_, _, err := backend.warmupLease(context.Background(), Repo{Root: "/repo"}, false)
if err == nil {
t.Fatal("expected warmup failure")
}
@ -240,30 +249,25 @@ func TestBlacksmithOneShotRunRemovesClaimAfterStop(t *testing.T) {
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
original := blacksmithCommandContext
calls := 0
blacksmithCommandContext = func(_ context.Context, _ string, args ...string) *exec.Cmd {
calls++
if len(args) >= 3 && args[0] == "testbox" && args[1] == "warmup" {
return exec.Command("sh", "-c", "printf 'ready tbx_abc123\\n'")
runner := &blacksmithFuncRunner{fn: func(req LocalCommandRequest) (LocalCommandResult, error) {
if len(req.Args) >= 3 && req.Args[0] == "testbox" && req.Args[1] == "warmup" {
return LocalCommandResult{Stdout: "ready tbx_abc123\n"}, nil
}
return exec.Command("sh", "-c", "exit 0")
}
t.Cleanup(func() {
blacksmithCommandContext = original
})
return LocalCommandResult{}, nil
}}
cfg := baseConfig()
cfg.Blacksmith.Workflow = ".github/workflows/testbox.yml"
app := App{Stdout: io.Discard, Stderr: io.Discard}
err := app.blacksmithRun(context.Background(), cfg, Repo{Root: "/repo"}, blacksmithRunOptions{
backend := newTestBlacksmithBackend(cfg, runner)
_, err := backend.Run(context.Background(), RunRequest{
Repo: Repo{Root: "/repo"},
Command: []string{"true"},
})
if err != nil {
t.Fatal(err)
}
if calls != 4 {
t.Fatalf("blacksmith calls=%d, want list/warmup/run/stop", calls)
if len(runner.calls) != 4 {
t.Fatalf("blacksmith calls=%d, want list/warmup/run/stop", len(runner.calls))
}
if claim, err := readLeaseClaim("tbx_abc123"); err != nil {
t.Fatal(err)

View File

@ -147,7 +147,7 @@ func (a App) cacheTarget(ctx context.Context, id string, reclaim bool) (SSHTarge
if claimErr := claimLeaseForRepoConfig(leaseID, serverSlug(server), cfg, repo.Root, cfg.IdleTimeout, reclaim); claimErr != nil {
return SSHTarget{}, Config{}, "", claimErr
}
a.touchActiveLeaseBestEffort(ctx, cfg, server, leaseID)
a.touchLeaseTargetBestEffort(ctx, cfg, LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, "")
}
return target, cfg, leaseID, err
}

View File

@ -33,14 +33,19 @@ func applyCapabilityFlags(cfg *Config, desktop, browser, code bool) {
}
func validateRequestedCapabilities(cfg Config) error {
if cfg.Desktop && isBlacksmithProvider(cfg.Provider) {
return exit(2, "desktop/VNC is not supported for provider=%s; Blacksmith owns machine connectivity", cfg.Provider)
provider, err := ProviderFor(cfg.Provider)
if err != nil {
return err
}
if cfg.Browser && isBlacksmithProvider(cfg.Provider) {
return exit(2, "browser provisioning is not supported for provider=%s; use Blacksmith workflow setup for headless browser automation", cfg.Provider)
spec := provider.Spec()
if cfg.Desktop && !featureSetHas(spec.Features, FeatureDesktop) {
return exit(2, "desktop/VNC is not supported for provider=%s", provider.Name())
}
if cfg.Code && isBlacksmithProvider(cfg.Provider) {
return exit(2, "web code is not supported for provider=%s; Blacksmith owns machine connectivity", cfg.Provider)
if cfg.Browser && !featureSetHas(spec.Features, FeatureBrowser) {
return exit(2, "browser provisioning is not supported for provider=%s", provider.Name())
}
if cfg.Code && !featureSetHas(spec.Features, FeatureCode) {
return exit(2, "web code is not supported for provider=%s", provider.Name())
}
if cfg.Code && cfg.TargetOS != targetLinux {
return exit(2, "web code currently supports managed Linux leases only")

View File

@ -113,7 +113,7 @@ func (a App) webCode(ctx context.Context, args []string) error {
if err := claimLeaseForRepoConfig(leaseID, serverSlug(server), cfg, repo.Root, cfg.IdleTimeout, *reclaim); err != nil {
return err
}
a.touchActiveLeaseBestEffort(ctx, cfg, server, leaseID)
a.touchLeaseTargetBestEffort(ctx, cfg, LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, "")
workspace, folder, hydratedByActions := codeWorkspace(ctx, target, cfg, leaseID, repo)
if hydratedByActions {
fmt.Fprintf(a.Stderr, "using GitHub Actions workspace %s\n", workspace)

View File

@ -63,7 +63,7 @@ func (a App) desktopLaunch(ctx context.Context, args []string) error {
if err := claimLeaseForRepoConfig(leaseID, serverSlug(server), cfg, repo.Root, cfg.IdleTimeout, *reclaim); err != nil {
return err
}
a.touchActiveLeaseBestEffort(ctx, cfg, server, leaseID)
a.touchLeaseTargetBestEffort(ctx, cfg, LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, "")
if err := waitForLoopbackVNC(ctx, &target); err != nil {
return err
}

View File

@ -12,6 +12,10 @@ func flagWasSet(fs *flag.FlagSet, name string) bool {
return seen
}
func FlagWasSet(fs *flag.FlagSet, name string) bool {
return flagWasSet(fs, name)
}
func extractBoolFlag(args []string, name string) ([]string, bool) {
want := "--" + name
out := make([]string, 0, len(args))

View File

@ -8,36 +8,36 @@ import (
)
type leaseCreateFlagValues struct {
Provider *string
Profile *string
Class *string
ServerType *string
Market *string
TTL *time.Duration
Idle *time.Duration
Desktop *bool
Browser *bool
Code *bool
Blacksmith blacksmithFlagValues
Target targetFlagValues
Network networkFlagValues
Provider *string
Profile *string
Class *string
ServerType *string
Market *string
TTL *time.Duration
Idle *time.Duration
Desktop *bool
Browser *bool
Code *bool
ProviderFlags providerFlagValues
Target targetFlagValues
Network networkFlagValues
}
func registerLeaseCreateFlags(fs *flag.FlagSet, defaults Config) leaseCreateFlagValues {
return leaseCreateFlagValues{
Provider: fs.String("provider", defaults.Provider, "provider: hetzner, aws, ssh, or blacksmith-testbox"),
Profile: fs.String("profile", defaults.Profile, "profile"),
Class: fs.String("class", defaults.Class, "machine class"),
ServerType: fs.String("type", getenv("CRABBOX_SERVER_TYPE", ""), "provider server/instance type"),
Market: fs.String("market", defaults.Capacity.Market, "capacity market: spot or on-demand"),
TTL: fs.Duration("ttl", defaults.TTL, "maximum lease lifetime"),
Idle: fs.Duration("idle-timeout", defaults.IdleTimeout, "idle timeout"),
Desktop: fs.Bool("desktop", defaults.Desktop, "provision or require a visible desktop/VNC session"),
Browser: fs.Bool("browser", defaults.Browser, "provision or require a browser binary"),
Code: fs.Bool("code", defaults.Code, "provision or require web code-server capability"),
Blacksmith: registerBlacksmithFlags(fs, defaults),
Target: registerTargetFlags(fs, defaults),
Network: registerNetworkFlags(fs, defaults),
Provider: fs.String("provider", defaults.Provider, "provider: hetzner, aws, ssh, or blacksmith-testbox"),
Profile: fs.String("profile", defaults.Profile, "profile"),
Class: fs.String("class", defaults.Class, "machine class"),
ServerType: fs.String("type", getenv("CRABBOX_SERVER_TYPE", ""), "provider server/instance type"),
Market: fs.String("market", defaults.Capacity.Market, "capacity market: spot or on-demand"),
TTL: fs.Duration("ttl", defaults.TTL, "maximum lease lifetime"),
Idle: fs.Duration("idle-timeout", defaults.IdleTimeout, "idle timeout"),
Desktop: fs.Bool("desktop", defaults.Desktop, "provision or require a visible desktop/VNC session"),
Browser: fs.Bool("browser", defaults.Browser, "provision or require a browser binary"),
Code: fs.Bool("code", defaults.Code, "provision or require web code-server capability"),
ProviderFlags: registerProviderFlags(fs, defaults),
Target: registerTargetFlags(fs, defaults),
Network: registerNetworkFlags(fs, defaults),
}
}
@ -62,7 +62,9 @@ func applyLeaseCreateFlags(cfg *Config, fs *flag.FlagSet, values leaseCreateFlag
if flagWasSet(fs, "idle-timeout") {
cfg.IdleTimeout = *values.Idle
}
applyBlacksmithFlagOverrides(cfg, fs, values.Blacksmith)
if err := applyProviderFlags(cfg, fs, values.ProviderFlags); err != nil {
return err
}
if err := validateProviderTarget(*cfg); err != nil {
return err
}
@ -141,6 +143,6 @@ func (a App) claimAndTouchLeaseTarget(ctx context.Context, cfg Config, server Se
if err := claimLeaseForRepoConfig(leaseID, serverSlug(server), cfg, repo.Root, cfg.IdleTimeout, reclaim); err != nil {
return err
}
a.touchActiveLeaseBestEffort(ctx, cfg, server, leaseID)
a.touchLeaseTargetBestEffort(ctx, cfg, LeaseTarget{Server: server, LeaseID: leaseID}, "")
return nil
}

View File

@ -3,16 +3,17 @@ package cli
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
)
func (a App) list(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("list", a.Stderr)
provider := fs.String("provider", defaultConfig().Provider, "provider: hetzner, aws, ssh, or blacksmith-testbox")
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, ssh, or blacksmith-testbox")
jsonOut := fs.Bool("json", false, "print JSON")
targetFlags := registerTargetFlags(fs, defaultConfig())
providerFlags := registerProviderFlags(fs, defaults)
targetFlags := registerTargetFlags(fs, defaults)
if err := parseFlags(fs, args); err != nil {
return err
}
@ -21,93 +22,39 @@ func (a App) list(ctx context.Context, args []string) error {
return err
}
cfg.Provider = *provider
if err := applyProviderFlags(&cfg, fs, providerFlags); err != nil {
return err
}
if err := applyTargetFlagOverrides(&cfg, fs, targetFlags); err != nil {
return err
}
if isBlacksmithProvider(cfg.Provider) {
return a.blacksmithList(ctx, cfg, *jsonOut)
}
if isStaticProvider(cfg.Provider) {
server, _, _, err := staticLease(cfg)
if err != nil {
return err
}
servers := []Server{server}
if *jsonOut {
return json.NewEncoder(a.Stdout).Encode(servers)
}
for _, s := range servers {
fmt.Fprintf(a.Stdout, "%-20s %-28s %-12s %-14s %-15s lease=%s slug=%s keep=%s target=%s\n",
s.DisplayID(), s.Name, s.Status, s.ServerType.Name, s.PublicNet.IPv4.IP, s.Labels["lease"], blank(serverSlug(s), "-"), s.Labels["keep"], s.Labels["target"])
}
return nil
}
if _, ok, err := newCoordinatorClient(cfg); err != nil {
return err
} else if ok {
if cfg.CoordAdminToken == "" {
return exit(2, "pool list requires broker.adminToken or CRABBOX_COORDINATOR_ADMIN_TOKEN when a coordinator is configured")
}
cfg.CoordToken = cfg.CoordAdminToken
coord, _, err := newCoordinatorClient(cfg)
if err != nil {
return err
}
machines, err := coord.Pool(ctx, cfg)
if err != nil {
return err
}
activeLeases, err := coord.AdminLeases(ctx, "active", "", "", 1000)
if err != nil {
fmt.Fprintf(a.Stderr, "warning: active lease lookup failed; orphan status unavailable: %v\n", err)
}
activeLeaseIDs := activeCoordinatorLeaseIDs(activeLeases)
if *jsonOut {
return json.NewEncoder(a.Stdout).Encode(machines)
}
for _, s := range machines {
extra := ""
if err == nil {
extra = coordinatorMachineOrphanField(s.Labels, activeLeaseIDs)
}
fmt.Fprintf(a.Stdout, "%-20s %-28s %-12s %-14s %-15s lease=%s slug=%s keep=%s%s\n",
s.ID, s.Name, s.Status, s.ServerType, s.Host, s.Labels["lease"], blank(s.Labels["slug"], "-"), s.Labels["keep"], extra)
}
return nil
}
if cfg.Provider == "aws" {
client, err := newAWSClient(ctx, cfg)
if err != nil {
return err
}
servers, err := client.ListCrabboxServers(ctx)
if err != nil {
return err
}
if *jsonOut {
return json.NewEncoder(a.Stdout).Encode(servers)
}
for _, s := range servers {
fmt.Fprintf(a.Stdout, "%-20s %-28s %-12s %-14s %-15s lease=%s slug=%s keep=%s\n",
s.DisplayID(), s.Name, s.Status, s.ServerType.Name, s.PublicNet.IPv4.IP, s.Labels["lease"], blank(serverSlug(s), "-"), s.Labels["keep"])
}
return nil
}
client, err := newHetznerClient()
backend, err := loadBackend(cfg, runtimeForApp(a))
if err != nil {
return err
}
servers, err := client.ListCrabboxServers(ctx)
var servers []Server
switch b := backend.(type) {
case SSHLeaseBackend:
servers, err = b.List(ctx, ListRequest{Options: leaseOptionsFromConfig(cfg)})
case DelegatedRunBackend:
servers, err = b.List(ctx, ListRequest{Options: leaseOptionsFromConfig(cfg)})
default:
return exit(2, "provider=%s does not support list", backend.Spec().Name)
}
if err != nil {
return err
}
if *jsonOut {
if jsonBackend, ok := backend.(JSONListBackend); ok {
view, err := jsonBackend.ListJSON(ctx, ListRequest{Options: leaseOptionsFromConfig(cfg)})
if err != nil {
return err
}
return json.NewEncoder(a.Stdout).Encode(view)
}
return json.NewEncoder(a.Stdout).Encode(servers)
}
for _, s := range servers {
fmt.Fprintf(a.Stdout, "%-20s %-28s %-12s %-14s %-15s lease=%s slug=%s keep=%s\n",
s.DisplayID(), s.Name, s.Status, s.ServerType.Name, s.PublicNet.IPv4.IP, s.Labels["lease"], blank(serverSlug(s), "-"), s.Labels["keep"])
}
renderServerList(a.Stdout, servers)
return nil
}
@ -133,10 +80,12 @@ func coordinatorMachineOrphanField(labels map[string]string, activeLeaseIDs map[
}
func (a App) cleanup(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("machine cleanup", a.Stderr)
provider := fs.String("provider", defaultConfig().Provider, "provider: hetzner or aws")
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
dryRun := fs.Bool("dry-run", false, "only print")
targetFlags := registerTargetFlags(fs, defaultConfig())
providerFlags := registerProviderFlags(fs, defaults)
targetFlags := registerTargetFlags(fs, defaults)
if err := parseFlags(fs, args); err != nil {
return err
}
@ -145,63 +94,24 @@ func (a App) cleanup(ctx context.Context, args []string) error {
return err
}
cfg.Provider = *provider
if err := applyProviderFlags(&cfg, fs, providerFlags); err != nil {
return err
}
if err := applyTargetFlagOverrides(&cfg, fs, targetFlags); err != nil {
return err
}
if isStaticProvider(cfg.Provider) {
return exit(2, "machine cleanup is not supported for provider=%s", cfg.Provider)
}
if _, ok, err := newCoordinatorClient(cfg); err != nil {
backend, err := loadBackend(cfg, runtimeForApp(a))
if err != nil {
return err
} else if ok {
}
if backendCoordinator(backend) != nil {
return exit(2, "machine cleanup is disabled when a coordinator is configured; coordinator TTL alarms own brokered cleanup")
}
if cfg.Provider == "aws" {
awsClient, err := newAWSClient(ctx, cfg)
if err != nil {
return err
}
servers, err := awsClient.ListCrabboxServers(ctx)
if err != nil {
return err
}
for _, s := range servers {
shouldDelete, reason := shouldCleanupServer(s, time.Now().UTC())
if !shouldDelete {
fmt.Fprintf(a.Stderr, "skip server id=%s name=%s reason=%s\n", s.DisplayID(), s.Name, reason)
continue
}
fmt.Fprintf(a.Stderr, "delete server id=%s name=%s\n", s.DisplayID(), s.Name)
if !*dryRun {
if err := deleteServer(ctx, cfg, s); err != nil {
return err
}
}
}
return nil
cleaner, ok := backend.(CleanupBackend)
if !ok {
return exit(2, "machine cleanup is not supported for provider=%s", cfg.Provider)
}
client, err := newHetznerClient()
if err != nil {
return err
}
servers, err := client.ListCrabboxServers(ctx)
if err != nil {
return err
}
for _, s := range servers {
shouldDelete, reason := shouldCleanupServer(s, time.Now().UTC())
if !shouldDelete {
fmt.Fprintf(a.Stderr, "skip server id=%s name=%s reason=%s\n", s.DisplayID(), s.Name, reason)
continue
}
fmt.Fprintf(a.Stderr, "delete server id=%s name=%s\n", s.DisplayID(), s.Name)
if !*dryRun {
if err := deleteServer(ctx, cfg, s); err != nil {
return err
}
}
}
return nil
return cleaner.Cleanup(ctx, CleanupRequest{Options: leaseOptionsFromConfig(cfg), DryRun: *dryRun})
}
func shouldCleanupServer(server Server, now time.Time) (bool, string) {

View File

@ -0,0 +1,119 @@
package cli
import (
"context"
"fmt"
"strings"
)
type awsLeaseBackend struct{ directSSHBackend }
func (b *awsLeaseBackend) Acquire(ctx context.Context, req AcquireRequest) (LeaseTarget, error) {
return acquireAttemptsRetry(b.rt, req.Keep, func() (LeaseTarget, error) {
return b.acquireOnce(ctx, req.Keep)
})
}
func (b *awsLeaseBackend) acquireOnce(ctx context.Context, keep bool) (LeaseTarget, error) {
if b.cfg.Tailscale.Enabled && b.cfg.Tailscale.AuthKey == "" {
return LeaseTarget{}, exit(2, "direct --tailscale requires %s to contain a Tailscale auth key; brokered mode uses coordinator OAuth secrets", b.cfg.Tailscale.AuthKeyEnv)
}
cfg := chooseAWSRegion(ctx, b.cfg, b.rt.Stderr)
client, err := newAWSClient(ctx, cfg)
if err != nil {
return LeaseTarget{}, err
}
leaseID := newLeaseID()
servers, err := client.ListCrabboxServers(ctx)
if err != nil {
return LeaseTarget{}, err
}
slug := allocateDirectLeaseSlug(leaseID, servers)
keyPath, publicKey, err := ensureTestboxKeyForConfig(cfg, leaseID)
if err != nil {
return LeaseTarget{}, err
}
cfg.SSHKey = keyPath
cfg.ProviderKey = providerKeyForLease(leaseID)
ensureAWSSSHCIDRs(ctx, &cfg)
fmt.Fprintf(b.rt.Stderr, "provisioning provider=aws lease=%s slug=%s class=%s preferred_type=%s region=%s keep=%v market=%s strategy=%s\n", leaseID, slug, cfg.Class, cfg.ServerType, cfg.AWSRegion, keep, cfg.Capacity.Market, cfg.Capacity.Strategy)
server, cfg, err := client.CreateServerWithFallback(ctx, cfg, publicKey, leaseID, slug, keep, func(format string, args ...any) {
fmt.Fprintf(b.rt.Stderr, format, args...)
})
if err != nil {
return LeaseTarget{}, err
}
fmt.Fprintf(b.rt.Stderr, "provisioned lease=%s server=%s type=%s\n", leaseID, server.DisplayID(), cfg.ServerType)
server, err = client.waitForServerIP(ctx, server.CloudID)
if err != nil {
return LeaseTarget{}, err
}
target := sshTargetFromConfig(cfg, server.PublicNet.IPv4.IP)
if err := bootstrapAWSWindowsDesktop(ctx, cfg, &target, publicKey, b.rt.Stderr); err != nil {
_ = client.DeleteServer(context.Background(), server.CloudID)
return LeaseTarget{}, err
}
server.Labels["state"] = "ready"
if err := client.SetTags(ctx, server.CloudID, server.Labels); err != nil {
fmt.Fprintf(b.rt.Stderr, "warning: set tags: %v\n", err)
}
return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil
}
func (b *awsLeaseBackend) Resolve(ctx context.Context, req ResolveRequest) (LeaseTarget, error) {
client, err := newAWSClient(ctx, b.cfg)
if err != nil {
return LeaseTarget{}, err
}
if strings.HasPrefix(req.ID, "i-") {
server, err := client.GetServer(ctx, req.ID)
if err != nil {
return LeaseTarget{}, err
}
leaseID := blank(server.Labels["lease"], req.ID)
target := sshTargetFromConfig(b.cfg, server.PublicNet.IPv4.IP)
useStoredTestboxKey(&target, leaseID)
return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil
}
servers, err := client.ListCrabboxServers(ctx)
if err != nil {
return LeaseTarget{}, err
}
if server, leaseID, err := findServerByAlias(servers, req.ID); err != nil {
return LeaseTarget{}, err
} else if leaseID != "" {
target := sshTargetFromConfig(b.cfg, server.PublicNet.IPv4.IP)
useStoredTestboxKey(&target, leaseID)
return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil
}
return LeaseTarget{}, exit(4, "lease/server not found: %s", req.ID)
}
func (b *awsLeaseBackend) List(ctx context.Context, req ListRequest) ([]LeaseView, error) {
_ = req
client, err := newAWSClient(ctx, b.cfg)
if err != nil {
return nil, err
}
return client.ListCrabboxServers(ctx)
}
func (b *awsLeaseBackend) ReleaseLease(ctx context.Context, req ReleaseLeaseRequest) error {
if err := deleteServer(ctx, b.cfg, req.Lease.Server); err != nil {
return err
}
removeLeaseClaim(req.Lease.LeaseID)
return nil
}
func (b *awsLeaseBackend) Touch(ctx context.Context, req TouchRequest) (Server, error) {
return b.touch(ctx, req.Lease.Server, req.State), nil
}
func (b *awsLeaseBackend) Cleanup(ctx context.Context, req CleanupRequest) error {
servers, err := b.List(ctx, ListRequest{Options: req.Options})
if err != nil {
return err
}
return b.cleanupServers(ctx, req, servers)
}

View File

@ -0,0 +1,461 @@
package cli
import (
"bytes"
"context"
"flag"
"fmt"
"io"
"net/http"
"os/exec"
"sort"
"strings"
"time"
)
type Provider interface {
Name() string
Aliases() []string
Spec() ProviderSpec
RegisterFlags(fs *flag.FlagSet, defaults Config) any
ApplyFlags(cfg *Config, fs *flag.FlagSet, values any) error
Configure(cfg Config, rt Runtime) (Backend, error)
}
type Backend interface {
Spec() ProviderSpec
}
type SSHLeaseBackend interface {
Backend
Acquire(ctx context.Context, req AcquireRequest) (LeaseTarget, error)
Resolve(ctx context.Context, req ResolveRequest) (LeaseTarget, error)
List(ctx context.Context, req ListRequest) ([]LeaseView, error)
ReleaseLease(ctx context.Context, req ReleaseLeaseRequest) error
Touch(ctx context.Context, req TouchRequest) (Server, error)
}
type DelegatedRunBackend interface {
Backend
Warmup(ctx context.Context, req WarmupRequest) error
Run(ctx context.Context, req RunRequest) (RunResult, error)
List(ctx context.Context, req ListRequest) ([]LeaseView, error)
Status(ctx context.Context, req StatusRequest) (statusView, error)
Stop(ctx context.Context, req StopRequest) error
}
type CleanupBackend interface {
Backend
Cleanup(ctx context.Context, req CleanupRequest) error
}
type JSONListBackend interface {
Backend
ListJSON(ctx context.Context, req ListRequest) (any, error)
}
type ProviderSpec struct {
Name string
Kind ProviderKind
Targets []TargetSpec
Features FeatureSet
Coordinator CoordinatorMode
}
type ProviderKind string
const (
ProviderKindSSHLease ProviderKind = "ssh-lease"
ProviderKindDelegatedRun ProviderKind = "delegated-run"
)
type CoordinatorMode string
const (
CoordinatorNever CoordinatorMode = "never"
CoordinatorSupported CoordinatorMode = "supported"
)
type TargetSpec struct {
OS string
WindowsMode string
}
type Feature string
const (
FeatureSSH Feature = "ssh"
FeatureCrabboxSync Feature = "crabbox-sync"
FeatureCleanup Feature = "cleanup"
FeatureDesktop Feature = "desktop"
FeatureBrowser Feature = "browser"
FeatureCode Feature = "code"
FeatureTailscale Feature = "tailscale"
)
type FeatureSet []Feature
type Runtime struct {
Stdout io.Writer
Stderr io.Writer
Clock Clock
HTTP *http.Client
Exec CommandRunner
}
type Clock interface {
Now() time.Time
}
type realClock struct{}
func (realClock) Now() time.Time { return time.Now() }
type CommandRunner interface {
Run(ctx context.Context, req LocalCommandRequest) (LocalCommandResult, error)
}
type LocalCommandRequest struct {
Name string
Args []string
Env []string
Dir string
Stdout io.Writer
Stderr io.Writer
}
type LocalCommandResult struct {
ExitCode int
Stdout string
Stderr string
}
type execCommandRunner struct{}
func (execCommandRunner) Run(ctx context.Context, req LocalCommandRequest) (LocalCommandResult, error) {
cmd := exec.CommandContext(ctx, req.Name, req.Args...)
cmd.Env = req.Env
cmd.Dir = req.Dir
var stdout bytes.Buffer
var stderr bytes.Buffer
if req.Stdout != nil {
cmd.Stdout = io.MultiWriter(req.Stdout, &stdout)
} else {
cmd.Stdout = &stdout
}
if req.Stderr != nil {
cmd.Stderr = io.MultiWriter(req.Stderr, &stderr)
} else {
cmd.Stderr = &stderr
}
err := cmd.Run()
result := LocalCommandResult{ExitCode: exitCode(err), Stdout: stdout.String(), Stderr: stderr.String()}
if err == nil {
result.ExitCode = 0
}
return result, err
}
type LeaseOptions struct {
TargetOS string
WindowsMode string
Class string
ServerType string
IdleTimeout time.Duration
TTL time.Duration
Desktop bool
Browser bool
Code bool
Tailscale TailscaleConfig
WorkRoot string
SSHUser string
SSHPort string
SSHKey string
Sync SyncConfig
Results ResultsConfig
EnvAllow []string
ActionsRunner bool
}
type AcquireRequest struct {
Repo Repo
Options LeaseOptions
Keep bool
Reclaim bool
}
type ResolveRequest struct {
Repo Repo
Options LeaseOptions
ID string
Reclaim bool
}
type ReleaseLeaseRequest struct {
Lease LeaseTarget
Force bool
}
type TouchRequest struct {
Lease LeaseTarget
State string
IdleTimeout time.Duration
}
type ListRequest struct {
Options LeaseOptions
}
type RunRequest struct {
Repo Repo
ID string
Options LeaseOptions
Keep bool
Reclaim bool
NoSync bool
SyncOnly bool
DebugSync bool
ShellMode bool
ChecksumSync bool
ForceSyncLarge bool
Command []string
TimingJSON bool
}
type WarmupRequest struct {
Repo Repo
Options LeaseOptions
Keep bool
Reclaim bool
ActionsRunner bool
TimingJSON bool
}
type StatusRequest struct {
Options LeaseOptions
ID string
Wait bool
WaitTimeout time.Duration
}
type StopRequest struct {
Options LeaseOptions
ID string
}
type CleanupRequest struct {
Options LeaseOptions
DryRun bool
}
type RunResult struct {
ExitCode int
Command time.Duration
Total time.Duration
SyncDelegated bool
}
type LeaseTarget struct {
Server Server
SSH SSHTarget
LeaseID string
Coordinator *CoordinatorClient
}
type LeaseView = Server
var providerRegistry = map[string]Provider{}
func RegisterProvider(provider Provider) {
names := append([]string{provider.Name()}, provider.Aliases()...)
for _, name := range names {
key := normalizeProviderName(name)
if key == "" {
panic("provider name is empty")
}
if providerRegistry[key] != nil {
panic("provider already registered: " + key)
}
providerRegistry[key] = provider
}
}
func ProviderFor(name string) (Provider, error) {
provider := providerRegistry[normalizeProviderName(name)]
if provider == nil {
return nil, exit(2, "unknown provider %q", name)
}
return provider, nil
}
func registeredProviders() []Provider {
seen := map[string]struct{}{}
providers := make([]Provider, 0, len(providerRegistry))
for _, provider := range providerRegistry {
name := normalizeProviderName(provider.Name())
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
providers = append(providers, provider)
}
sort.Slice(providers, func(i, j int) bool {
return providers[i].Name() < providers[j].Name()
})
return providers
}
func normalizeProviderName(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
type providerFlagValues map[string]any
func registerProviderFlags(fs *flag.FlagSet, defaults Config) providerFlagValues {
values := providerFlagValues{}
for _, provider := range registeredProviders() {
values[provider.Name()] = provider.RegisterFlags(fs, defaults)
}
return values
}
func applyProviderFlags(cfg *Config, fs *flag.FlagSet, values providerFlagValues) error {
provider, err := ProviderFor(cfg.Provider)
if err != nil {
return err
}
return provider.ApplyFlags(cfg, fs, values[provider.Name()])
}
func runtimeForApp(a App) Runtime {
return Runtime{Stdout: a.Stdout, Stderr: a.Stderr, Clock: realClock{}, Exec: execCommandRunner{}}
}
func loadBackend(cfg Config, rt Runtime) (Backend, error) {
if rt.Stdout == nil {
rt.Stdout = io.Discard
}
if rt.Stderr == nil {
rt.Stderr = io.Discard
}
if rt.Clock == nil {
rt.Clock = realClock{}
}
if rt.Exec == nil {
rt.Exec = execCommandRunner{}
}
provider, err := ProviderFor(cfg.Provider)
if err != nil {
return nil, err
}
backend, err := provider.Configure(cfg, rt)
if err != nil {
return nil, err
}
if ssh, ok := backend.(SSHLeaseBackend); ok && shouldUseCoordinator(cfg, provider.Spec()) {
coord, _, err := newCoordinatorClient(cfg)
if err != nil {
return nil, err
}
return &coordinatorLeaseBackend{spec: provider.Spec(), cfg: cfg, direct: ssh, coord: coord, rt: rt}, nil
}
return backend, nil
}
func shouldUseCoordinator(cfg Config, spec ProviderSpec) bool {
return spec.Coordinator == CoordinatorSupported && strings.TrimSpace(cfg.Coordinator) != ""
}
func backendCoordinator(backend Backend) *CoordinatorClient {
if b, ok := backend.(*coordinatorLeaseBackend); ok {
return b.coord
}
return nil
}
func leaseOptionsFromConfig(cfg Config) LeaseOptions {
return LeaseOptions{
TargetOS: cfg.TargetOS,
WindowsMode: cfg.WindowsMode,
Class: cfg.Class,
ServerType: cfg.ServerType,
IdleTimeout: cfg.IdleTimeout,
TTL: cfg.TTL,
Desktop: cfg.Desktop,
Browser: cfg.Browser,
Code: cfg.Code,
Tailscale: cfg.Tailscale,
WorkRoot: cfg.WorkRoot,
SSHUser: cfg.SSHUser,
SSHPort: cfg.SSHPort,
SSHKey: cfg.SSHKey,
Sync: cfg.Sync,
Results: cfg.Results,
EnvAllow: cfg.EnvAllow,
}
}
func validateActionsRunnerCapability(backend Backend, cfg Config) error {
if _, ok := backend.(SSHLeaseBackend); !ok {
return exit(2, "--actions-runner requires an SSH lease provider")
}
if cfg.TargetOS != targetLinux {
return exit(2, "--actions-runner requires target=linux")
}
return nil
}
func featureSetHas(features FeatureSet, feature Feature) bool {
for _, candidate := range features {
if candidate == feature {
return true
}
}
return false
}
func rejectDelegatedSyncOptions(provider string, req RunRequest) error {
if req.SyncOnly {
return exit(2, "%s delegates sync; --sync-only is not supported", provider)
}
if req.ChecksumSync {
return exit(2, "%s delegates sync; --checksum is not supported", provider)
}
if req.ForceSyncLarge {
return exit(2, "%s delegates sync; --force-sync-large is not supported", provider)
}
return nil
}
func renderServerList(stdout io.Writer, servers []Server) {
for _, s := range servers {
extra := ""
if orphan := strings.TrimSpace(s.Labels["orphan"]); orphan != "" {
extra = " " + orphan
}
fmt.Fprintf(stdout, "%-20s %-28s %-12s %-14s %-15s lease=%s slug=%s keep=%s target=%s%s\n",
s.DisplayID(), s.Name, s.Status, s.ServerType.Name, s.PublicNet.IPv4.IP, s.Labels["lease"], blank(serverSlug(s), "-"), s.Labels["keep"], s.Labels["target"], extra)
}
}
func (a App) touchLeaseTargetBestEffort(ctx context.Context, cfg Config, lease LeaseTarget, state string) Server {
backend, err := loadBackend(cfg, runtimeForApp(a))
if err != nil {
fmt.Fprintf(a.Stderr, "warning: touch failed for %s: %v\n", lease.LeaseID, err)
return lease.Server
}
sshBackend, ok := backend.(SSHLeaseBackend)
if !ok {
fmt.Fprintf(a.Stderr, "warning: provider=%s does not support lease touch\n", backend.Spec().Name)
return lease.Server
}
if state == "" {
state = blank(lease.Server.Labels["state"], "ready")
}
server, err := sshBackend.Touch(ctx, TouchRequest{Lease: lease, State: state, IdleTimeout: cfg.IdleTimeout})
if err != nil {
fmt.Fprintf(a.Stderr, "warning: touch failed for %s: %v\n", lease.LeaseID, err)
return lease.Server
}
return server
}

View File

@ -0,0 +1,171 @@
package cli
import (
"context"
"io"
"testing"
)
type recordingCommandRunner struct {
calls []LocalCommandRequest
result LocalCommandResult
err error
}
func (r *recordingCommandRunner) Run(_ context.Context, req LocalCommandRequest) (LocalCommandResult, error) {
r.calls = append(r.calls, req)
return r.result, r.err
}
func testRuntimeWithRunner(r CommandRunner) Runtime {
return Runtime{Stdout: io.Discard, Stderr: io.Discard, Clock: realClock{}, Exec: r}
}
func TestProviderRegistryCanonicalAndAliases(t *testing.T) {
for _, name := range []string{"hetzner", "aws", "ssh", "static", "static-ssh", "blacksmith", "blacksmith-testbox"} {
if _, err := ProviderFor(name); err != nil {
t.Fatalf("ProviderFor(%q): %v", name, err)
}
}
if _, err := ProviderFor("missing"); err == nil {
t.Fatal("expected missing provider to fail")
}
}
func TestLoadBackendWrapsCoordinatorOnlyForSupportedSSHProviders(t *testing.T) {
cfg := baseConfig()
cfg.Provider = "aws"
cfg.Coordinator = "https://coordinator.example"
backend, err := loadBackend(cfg, testRuntimeWithRunner(&recordingCommandRunner{}))
if err != nil {
t.Fatalf("load aws coordinator backend: %v", err)
}
if _, ok := backend.(*coordinatorLeaseBackend); !ok {
t.Fatalf("backend=%T, want coordinatorLeaseBackend", backend)
}
cfg.Provider = "ssh"
backend, err = loadBackend(cfg, testRuntimeWithRunner(&recordingCommandRunner{}))
if err != nil {
t.Fatalf("load static ssh backend: %v", err)
}
if _, ok := backend.(*coordinatorLeaseBackend); ok {
t.Fatalf("static ssh unexpectedly used coordinator wrapper")
}
cfg.Provider = "blacksmith-testbox"
backend, err = loadBackend(cfg, testRuntimeWithRunner(&recordingCommandRunner{}))
if err != nil {
t.Fatalf("load blacksmith backend: %v", err)
}
if _, ok := backend.(DelegatedRunBackend); !ok {
t.Fatalf("backend=%T, want delegated run backend", backend)
}
}
func TestLeaseCreateFlagsApplySelectedProviderFlags(t *testing.T) {
defaults := baseConfig()
fs := newFlagSet("test", io.Discard)
values := registerLeaseCreateFlags(fs, defaults)
if err := parseFlags(fs, []string{
"--provider", "blacksmith-testbox",
"--blacksmith-org", "openclaw",
"--blacksmith-workflow", ".github/workflows/testbox.yml",
"--blacksmith-job", "test",
"--blacksmith-ref", "feature",
}); err != nil {
t.Fatal(err)
}
cfg := baseConfig()
if err := applyLeaseCreateFlags(&cfg, fs, values); err != nil {
t.Fatal(err)
}
if cfg.Blacksmith.Org != "openclaw" || cfg.Blacksmith.Workflow != ".github/workflows/testbox.yml" || cfg.Blacksmith.Job != "test" || cfg.Blacksmith.Ref != "feature" {
t.Fatalf("blacksmith flags not applied through provider registry: %#v", cfg.Blacksmith)
}
}
func TestValidateRequestedCapabilitiesUsesProviderSpec(t *testing.T) {
cfg := baseConfig()
cfg.Provider = "blacksmith-testbox"
cfg.Desktop = true
if err := validateRequestedCapabilities(cfg); err == nil {
t.Fatal("expected blacksmith desktop capability rejection")
}
cfg = baseConfig()
cfg.Provider = "hetzner"
cfg.Desktop = true
if err := validateRequestedCapabilities(cfg); err != nil {
t.Fatalf("hetzner desktop capability rejected: %v", err)
}
}
func TestBlacksmithBackendUsesInjectedCommandRunnerForListAndStatus(t *testing.T) {
runner := &recordingCommandRunner{
result: LocalCommandResult{
Stdout: "tbx_123 ready openclaw .github/workflows/testbox.yml test main 2026-05-06T00:00:00Z\n",
},
}
cfg := baseConfig()
cfg.Provider = "blacksmith-testbox"
cfg.Blacksmith.Workflow = ".github/workflows/testbox.yml"
cfg.Blacksmith.Job = "test"
cfg.Blacksmith.Ref = "main"
backend, err := loadBackend(cfg, testRuntimeWithRunner(runner))
if err != nil {
t.Fatalf("load blacksmith backend: %v", err)
}
delegated := backend.(DelegatedRunBackend)
servers, err := delegated.List(context.Background(), ListRequest{Options: leaseOptionsFromConfig(cfg)})
if err != nil {
t.Fatalf("list: %v", err)
}
if len(servers) != 1 || servers[0].CloudID != "tbx_123" {
t.Fatalf("servers=%#v", servers)
}
state, err := delegated.Status(context.Background(), StatusRequest{Options: leaseOptionsFromConfig(cfg), ID: "tbx_123"})
if err != nil {
t.Fatalf("status: %v", err)
}
if !state.Ready || state.ID != "tbx_123" {
t.Fatalf("state=%#v", state)
}
if len(runner.calls) != 2 {
t.Fatalf("runner calls=%d, want 2", len(runner.calls))
}
for _, call := range runner.calls {
if call.Name != "blacksmith" {
t.Fatalf("command name=%q", call.Name)
}
}
}
func TestBlacksmithBackendListJSONKeepsParsedTableShape(t *testing.T) {
runner := &recordingCommandRunner{
result: LocalCommandResult{
Stdout: "tbx_123 ready openclaw .github/workflows/testbox.yml test main 2026-05-06T00:00:00Z\n",
},
}
cfg := baseConfig()
cfg.Provider = "blacksmith-testbox"
backend, err := loadBackend(cfg, testRuntimeWithRunner(runner))
if err != nil {
t.Fatalf("load blacksmith backend: %v", err)
}
jsonBackend, ok := backend.(JSONListBackend)
if !ok {
t.Fatalf("backend=%T, want JSONListBackend", backend)
}
view, err := jsonBackend.ListJSON(context.Background(), ListRequest{Options: leaseOptionsFromConfig(cfg)})
if err != nil {
t.Fatalf("list json: %v", err)
}
items, ok := view.([]blacksmithListItem)
if !ok {
t.Fatalf("view=%T, want []blacksmithListItem", view)
}
if len(items) != 1 || items[0].ID != "tbx_123" || items[0].Repo != "openclaw" {
t.Fatalf("items=%#v", items)
}
}

View File

@ -0,0 +1,357 @@
package cli
import (
"context"
"flag"
"fmt"
"io"
"strings"
"time"
)
func RegisterBlacksmithProviderFlags(fs *flag.FlagSet, defaults Config) any {
return registerBlacksmithFlags(fs, defaults)
}
func ApplyBlacksmithProviderFlags(cfg *Config, fs *flag.FlagSet, values any) error {
if v, ok := values.(blacksmithFlagValues); ok {
applyBlacksmithFlagOverrides(cfg, fs, v)
}
return nil
}
func NewBlacksmithBackend(spec ProviderSpec, cfg Config, rt Runtime) Backend {
cfg.Provider = blacksmithTestboxProvider
return &blacksmithBackend{spec: spec, cfg: cfg, rt: rt}
}
type blacksmithBackend struct {
spec ProviderSpec
cfg Config
rt Runtime
}
func (b *blacksmithBackend) Spec() ProviderSpec { return b.spec }
func (b *blacksmithBackend) Warmup(ctx context.Context, req WarmupRequest) error {
if req.ActionsRunner {
return exit(2, "--actions-runner is not supported for provider=%s; Blacksmith owns runner hydration", b.cfg.Provider)
}
started := b.rt.Clock.Now()
leaseID, slug, err := b.warmupLease(ctx, req.Repo, req.Reclaim)
if err != nil {
return err
}
fmt.Fprintf(b.rt.Stdout, "leased %s slug=%s provider=%s idle_timeout=%s\n", leaseID, slug, blacksmithTestboxProvider, blacksmithIdleTimeout(b.cfg))
if !req.Keep {
fmt.Fprintf(b.rt.Stderr, "warning: blacksmith warmup keeps the testbox until idle timeout or explicit stop\n")
}
fmt.Fprintf(b.rt.Stdout, "warmup complete total=%s\n", b.rt.Clock.Now().Sub(started).Round(time.Millisecond))
if req.TimingJSON {
total := b.rt.Clock.Now().Sub(started)
if err := writeTimingJSON(b.rt.Stderr, timingReport{
Provider: blacksmithTestboxProvider,
LeaseID: leaseID,
Slug: slug,
TotalMs: total.Milliseconds(),
ExitCode: 0,
}); err != nil {
return err
}
}
return nil
}
func (b *blacksmithBackend) Run(ctx context.Context, req RunRequest) (RunResult, error) {
if err := rejectDelegatedSyncOptions(blacksmithTestboxProvider, req); err != nil {
return RunResult{}, err
}
started := b.rt.Clock.Now()
leaseID := req.ID
acquired := false
var err error
if leaseID == "" {
leaseID, _, err = b.warmupLease(ctx, req.Repo, req.Reclaim)
if err != nil {
return RunResult{}, err
}
acquired = true
} else {
leaseID, err = resolveBlacksmithLeaseID(leaseID, req.Repo.Root, req.Reclaim)
if err != nil {
return RunResult{}, err
}
slug, err := blacksmithClaimSlug(req.ID, leaseID)
if err != nil {
return RunResult{}, err
}
if err := claimLeaseForRepoProvider(leaseID, slug, blacksmithTestboxProvider, req.Repo.Root, blacksmithIdleTimeout(b.cfg), req.Reclaim); err != nil {
return RunResult{}, err
}
}
if acquired && !req.Keep {
defer func() {
if err := b.Stop(context.Background(), StopRequest{ID: leaseID}); err != nil {
fmt.Fprintf(b.rt.Stderr, "warning: blacksmith stop failed for %s: %v\n", leaseID, err)
return
}
removeLeaseClaim(leaseID)
removeStoredTestboxKey(leaseID)
}()
}
fmt.Fprintf(b.rt.Stderr, "provider=blacksmith-testbox id=%s sync=delegated auth=blacksmith\n", leaseID)
commandStart := b.rt.Clock.Now()
code := b.runTestbox(ctx, leaseID, req.Command, req.DebugSync, req.ShellMode)
commandDuration := b.rt.Clock.Now().Sub(commandStart)
total := b.rt.Clock.Now().Sub(started)
fmt.Fprintf(b.rt.Stderr, "blacksmith run summary sync=delegated command=%s total=%s exit=%d\n", commandDuration.Round(time.Millisecond), total.Round(time.Millisecond), code)
if req.TimingJSON {
if err := writeTimingJSON(b.rt.Stderr, timingReport{
Provider: blacksmithTestboxProvider,
LeaseID: leaseID,
SyncPhases: []timingPhase{{Name: "delegated", Skipped: true, Reason: "blacksmith-testbox owns sync"}},
SyncDelegated: true,
CommandMs: commandDuration.Milliseconds(),
TotalMs: total.Milliseconds(),
ExitCode: code,
}); err != nil {
return RunResult{}, err
}
}
result := RunResult{ExitCode: code, Command: commandDuration, Total: total, SyncDelegated: true}
if code != 0 {
return result, ExitError{Code: code, Message: fmt.Sprintf("blacksmith testbox run exited %d", code)}
}
return result, nil
}
func (b *blacksmithBackend) List(ctx context.Context, req ListRequest) ([]Server, error) {
_ = req
out, err := b.commandOutput(ctx, blacksmithListArgs(b.cfg))
if err != nil {
return nil, err
}
items := parseBlacksmithList(out)
servers := make([]Server, 0, len(items))
for _, item := range items {
servers = append(servers, blacksmithItemToServer(item))
}
return servers, nil
}
func (b *blacksmithBackend) ListJSON(ctx context.Context, req ListRequest) (any, error) {
_ = req
out, err := b.commandOutput(ctx, blacksmithListArgs(b.cfg))
if err != nil {
return nil, err
}
return parseBlacksmithList(out), nil
}
func (b *blacksmithBackend) Status(ctx context.Context, req StatusRequest) (statusView, error) {
leaseID, err := resolveBlacksmithLeaseID(req.ID, "", false)
if err != nil {
return statusView{}, err
}
deadline := b.rt.Clock.Now().Add(req.WaitTimeout)
for {
state, err := b.blacksmithStatusView(ctx, leaseID)
if err != nil {
return statusView{}, err
}
if !req.Wait || state.Ready {
return state, nil
}
if b.rt.Clock.Now().After(deadline) {
return statusView{}, exit(5, "timed out waiting for %s to become ready", req.ID)
}
time.Sleep(5 * time.Second)
}
}
func (b *blacksmithBackend) Stop(ctx context.Context, req StopRequest) error {
leaseID, err := resolveBlacksmithLeaseID(req.ID, "", false)
if err != nil {
return err
}
if _, err := b.runCommand(ctx, blacksmithStopArgs(b.cfg, leaseID), b.rt.Stdout, b.rt.Stderr); err != nil {
return err
}
removeLeaseClaim(leaseID)
removeStoredTestboxKey(leaseID)
return nil
}
func (b *blacksmithBackend) warmupLease(ctx context.Context, repo Repo, reclaim bool) (string, string, error) {
pendingID := "tbx_pending_" + strings.TrimPrefix(newLeaseID(), "cbx_")
cleanupKeyID := pendingID
defer func() {
if cleanupKeyID != "" {
removeStoredTestboxKey(cleanupKeyID)
}
}()
_, publicKey, err := ensureTestboxKey(pendingID)
if err != nil {
return "", "", err
}
args, err := blacksmithWarmupArgs(b.cfg, publicKey)
if err != nil {
return "", "", err
}
beforeWarmup := b.listIDsBestEffort(ctx)
result, err := b.runCommand(ctx, args, b.rt.Stdout, b.rt.Stderr)
output := result.Stdout + result.Stderr
if err != nil {
b.cleanupFailedWarmup(ctx, beforeWarmup, output)
return "", "", exit(result.ExitCode, "blacksmith testbox warmup failed: %v", err)
}
leaseID := parseBlacksmithID(output)
if leaseID == "" {
return "", "", exit(5, "blacksmith testbox warmup did not print a tbx_ id")
}
if err := moveStoredTestboxKey(pendingID, leaseID); err != nil {
_ = b.Stop(ctx, StopRequest{ID: leaseID})
return "", "", exit(2, "store blacksmith key for %s: %v", leaseID, err)
}
cleanupKeyID = leaseID
slug := newLeaseSlug(leaseID)
if err := claimLeaseForRepoProvider(leaseID, slug, blacksmithTestboxProvider, repo.Root, blacksmithIdleTimeout(b.cfg), reclaim); err != nil {
_ = b.Stop(ctx, StopRequest{ID: leaseID})
return "", "", err
}
cleanupKeyID = ""
return leaseID, slug, nil
}
func (b *blacksmithBackend) runTestbox(ctx context.Context, leaseID string, command []string, debug, shellMode bool) int {
keyPath, err := testboxKeyPath(leaseID)
if err != nil {
fmt.Fprintf(b.rt.Stderr, "blacksmith key path failed: %v\n", err)
return 2
}
args := blacksmithRunArgs(b.cfg, leaseID, keyPath, command, debug || b.cfg.Blacksmith.Debug, shellMode)
result, err := b.runCommand(ctx, args, b.rt.Stdout, b.rt.Stderr)
if err != nil {
return result.ExitCode
}
return 0
}
func (b *blacksmithBackend) commandOutput(ctx context.Context, args []string) (string, error) {
result, err := b.runCommand(ctx, args, nil, nil)
if err != nil {
return "", ExitError{Code: result.ExitCode, Message: fmt.Sprintf("blacksmith failed: %v: %s", err, strings.TrimSpace(result.Stdout+result.Stderr))}
}
return result.Stdout + result.Stderr, nil
}
func (b *blacksmithBackend) runCommand(ctx context.Context, args []string, stdout, stderr io.Writer) (LocalCommandResult, error) {
result, err := b.rt.Exec.Run(ctx, LocalCommandRequest{Name: "blacksmith", Args: args, Stdout: stdout, Stderr: stderr})
if err != nil {
return result, ExitError{Code: result.ExitCode, Message: fmt.Sprintf("blacksmith failed: %v", err)}
}
return result, nil
}
func (b *blacksmithBackend) listIDsBestEffort(ctx context.Context) map[string]bool {
out, err := b.commandOutput(ctx, blacksmithListAllArgs(b.cfg))
if err != nil {
return map[string]bool{}
}
ids := map[string]bool{}
for _, item := range parseBlacksmithList(out) {
ids[item.ID] = true
}
return ids
}
func (b *blacksmithBackend) cleanupFailedWarmup(ctx context.Context, before map[string]bool, output string) {
if leaseID := parseBlacksmithID(output); leaseID != "" {
if err := b.Stop(ctx, StopRequest{ID: leaseID}); err == nil {
before[leaseID] = true
}
}
stoppedAny := false
quietAttempts := 0
for attempt := 0; attempt < blacksmithCleanupAttempts; attempt++ {
if attempt > 0 {
select {
case <-ctx.Done():
return
case <-time.After(blacksmithCleanupDelay):
}
}
list, err := b.commandOutput(ctx, blacksmithListAllArgs(b.cfg))
if err != nil {
return
}
stopped := false
for _, item := range parseBlacksmithList(list) {
if before[item.ID] || !blacksmithMatchesConfig(item, b.cfg) {
continue
}
_ = b.Stop(ctx, StopRequest{ID: item.ID})
before[item.ID] = true
stopped = true
}
if stopped {
stoppedAny = true
quietAttempts = 0
continue
}
if stoppedAny {
quietAttempts++
if quietAttempts >= blacksmithCleanupQuiet {
return
}
}
}
}
func (b *blacksmithBackend) blacksmithStatusView(ctx context.Context, leaseID string) (statusView, error) {
out, err := b.commandOutput(ctx, blacksmithListAllArgs(b.cfg))
if err != nil {
return statusView{}, err
}
for _, item := range parseBlacksmithList(out) {
if item.ID != leaseID {
continue
}
server := blacksmithItemToServer(item)
return statusView{
ID: item.ID,
Provider: blacksmithTestboxProvider,
TargetOS: targetLinux,
State: item.Status,
ServerID: item.ID,
ServerType: "testbox",
Labels: server.Labels,
HasHost: false,
Ready: strings.EqualFold(item.Status, "ready") || strings.EqualFold(item.Status, "running"),
IdleTimeout: blacksmithIdleTimeout(b.cfg).String(),
}, nil
}
return statusView{}, exit(4, "blacksmith testbox not found: %s", leaseID)
}
func blacksmithItemToServer(item blacksmithListItem) Server {
labels := map[string]string{
"lease": item.ID,
"provider": blacksmithTestboxProvider,
"state": item.Status,
"repo": item.Repo,
"workflow": item.Workflow,
"job": item.Job,
"ref": item.Ref,
"created": item.Created,
}
server := Server{
CloudID: item.ID,
Provider: blacksmithTestboxProvider,
Name: item.ID,
Status: item.Status,
Labels: labels,
}
server.ServerType.Name = "testbox"
return server
}

View File

@ -0,0 +1,165 @@
package cli
import (
"context"
"fmt"
"strings"
)
type coordinatorLeaseBackend struct {
spec ProviderSpec
cfg Config
direct SSHLeaseBackend
coord *CoordinatorClient
rt Runtime
}
func (b *coordinatorLeaseBackend) Spec() ProviderSpec { return b.spec }
func (b *coordinatorLeaseBackend) Acquire(ctx context.Context, req AcquireRequest) (LeaseTarget, error) {
return acquireAttemptsRetry(b.rt, req.Keep, func() (LeaseTarget, error) {
return b.acquireOnce(ctx, req.Keep)
})
}
func (b *coordinatorLeaseBackend) acquireOnce(ctx context.Context, keep bool) (LeaseTarget, error) {
leaseID := newLeaseID()
slug := newLeaseSlug(leaseID)
keyPath, publicKey, err := ensureTestboxKeyForConfig(b.cfg, leaseID)
if err != nil {
return LeaseTarget{}, err
}
cfg := b.cfg
cfg.SSHKey = keyPath
cfg.ProviderKey = providerKeyForLease(leaseID)
if cfg.Tailscale.Enabled && cfg.Tailscale.Hostname == "" {
cfg.Tailscale.Hostname = renderTailscaleHostname(cfg.Tailscale.HostnameTemplate, leaseID, slug, cfg.Provider)
}
ensureAWSSSHCIDRs(ctx, &cfg)
fmt.Fprintf(b.rt.Stderr, "coordinator lease class=%s preferred_type=%s keep=%v slug=%s idle_timeout=%s ttl=%s\n", cfg.Class, cfg.ServerType, keep, slug, cfg.IdleTimeout, cfg.TTL)
lease, err := b.coord.CreateLease(ctx, cfg, publicKey, keep, leaseID, slug)
if err != nil {
return LeaseTarget{}, err
}
if lease.ID != "" && lease.ID != leaseID {
if err := moveStoredTestboxKey(leaseID, lease.ID); err != nil {
fmt.Fprintf(b.rt.Stderr, "warning: could not move local key from %s to %s: %v\n", leaseID, lease.ID, err)
}
}
if err := validateCoordinatorLeaseCapabilities(cfg, lease); err != nil {
if releaseErr := releaseCoordinatorLease(context.Background(), b.coord, blank(lease.ID, leaseID)); releaseErr != nil {
fmt.Fprintf(b.rt.Stderr, "warning: release failed after capability mismatch for %s: %v\n", blank(lease.ID, leaseID), releaseErr)
}
return LeaseTarget{}, err
}
server, target, leaseID := leaseToServerTarget(lease, cfg)
fmt.Fprintf(b.rt.Stderr, "leased %s slug=%s server=%d type=%s ip=%s via coordinator\n", leaseID, blank(lease.Slug, "-"), server.ID, server.ServerType.Name, target.Host)
if summary := coordinatorFallbackSummary(lease); summary != "" {
fmt.Fprintf(b.rt.Stderr, "fallback resolved %s\n", summary)
}
waitCtx, cancelWait := context.WithCancelCause(ctx)
defer cancelWait(nil)
stopHeartbeat := startCoordinatorHeartbeat(waitCtx, b.coord, leaseID, cfg.IdleTimeout, nil, b.rt.Stderr)
defer stopHeartbeat()
stopLeaseWatch := startCoordinatorLeaseWatch(waitCtx, b.coord, leaseID, cancelWait, b.rt.Stderr)
defer stopLeaseWatch()
if err := bootstrapAWSWindowsDesktop(waitCtx, cfg, &target, publicKey, b.rt.Stderr); err != nil {
if releaseErr := releaseCoordinatorLease(context.Background(), b.coord, leaseID); releaseErr != nil {
fmt.Fprintf(b.rt.Stderr, "warning: release failed after bootstrap error for %s: %v\n", leaseID, releaseErr)
}
return LeaseTarget{}, err
}
return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID, Coordinator: b.coord}, nil
}
func (b *coordinatorLeaseBackend) Resolve(ctx context.Context, req ResolveRequest) (LeaseTarget, error) {
lease, err := b.coord.GetLease(ctx, req.ID)
if err != nil {
return LeaseTarget{}, err
}
server, target, leaseID := leaseToServerTarget(lease, b.cfg)
return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID, Coordinator: b.coord}, nil
}
func (b *coordinatorLeaseBackend) List(ctx context.Context, req ListRequest) ([]Server, error) {
machines, activeLeaseIDs, err := b.listMachines(ctx)
if err != nil {
return nil, err
}
return coordinatorMachinesToServers(machines, activeLeaseIDs), nil
}
func (b *coordinatorLeaseBackend) ListJSON(ctx context.Context, req ListRequest) (any, error) {
_ = req
machines, _, err := b.listMachines(ctx)
if err != nil {
return nil, err
}
return machines, nil
}
func (b *coordinatorLeaseBackend) listMachines(ctx context.Context) ([]CoordinatorMachine, map[string]struct{}, error) {
if b.cfg.CoordAdminToken == "" {
return nil, nil, exit(2, "pool list requires broker.adminToken or CRABBOX_COORDINATOR_ADMIN_TOKEN when a coordinator is configured")
}
cfg := b.cfg
cfg.CoordToken = cfg.CoordAdminToken
coord, _, err := newCoordinatorClient(cfg)
if err != nil {
return nil, nil, err
}
machines, err := coord.Pool(ctx, cfg)
if err != nil {
return nil, nil, err
}
activeLeases, err := coord.AdminLeases(ctx, "active", "", "", 1000)
if err != nil {
fmt.Fprintf(b.rt.Stderr, "warning: active lease lookup failed; orphan status unavailable: %v\n", err)
return machines, nil, nil
}
return machines, activeCoordinatorLeaseIDs(activeLeases), nil
}
func (b *coordinatorLeaseBackend) ReleaseLease(ctx context.Context, req ReleaseLeaseRequest) error {
if req.Lease.LeaseID == "" {
return exit(2, "missing coordinator lease id")
}
if err := releaseCoordinatorLease(ctx, b.coord, req.Lease.LeaseID); err != nil {
return err
}
removeLeaseClaim(req.Lease.LeaseID)
return nil
}
func (b *coordinatorLeaseBackend) Touch(ctx context.Context, req TouchRequest) (Server, error) {
lease, err := b.coord.TouchLease(ctx, req.Lease.LeaseID)
if err != nil {
return req.Lease.Server, err
}
server, _, _ := leaseToServerTarget(lease, b.cfg)
return server, nil
}
func coordinatorMachinesToServers(machines []CoordinatorMachine, activeLeaseIDs map[string]struct{}) []Server {
servers := make([]Server, 0, len(machines))
for _, machine := range machines {
labels := map[string]string{}
for k, v := range machine.Labels {
labels[k] = v
}
if activeLeaseIDs != nil {
labels["orphan"] = strings.TrimSpace(coordinatorMachineOrphanField(labels, activeLeaseIDs))
}
server := Server{
CloudID: string(machine.ID),
Provider: machine.Provider,
Name: machine.Name,
Status: machine.Status,
Labels: labels,
}
server.ServerType.Name = machine.ServerType
server.PublicNet.IPv4.IP = machine.Host
servers = append(servers, server)
}
return servers
}

View File

@ -0,0 +1,124 @@
package cli
import (
"context"
"fmt"
)
type hetznerLeaseBackend struct{ directSSHBackend }
func (b *hetznerLeaseBackend) Acquire(ctx context.Context, req AcquireRequest) (LeaseTarget, error) {
return acquireAttemptsRetry(b.rt, req.Keep, func() (LeaseTarget, error) {
return b.acquireOnce(ctx, req.Keep)
})
}
func (b *hetznerLeaseBackend) acquireOnce(ctx context.Context, keep bool) (LeaseTarget, error) {
if b.cfg.Tailscale.Enabled && b.cfg.Tailscale.AuthKey == "" {
return LeaseTarget{}, exit(2, "direct --tailscale requires %s to contain a Tailscale auth key; brokered mode uses coordinator OAuth secrets", b.cfg.Tailscale.AuthKeyEnv)
}
client, err := newHetznerClient()
if err != nil {
return LeaseTarget{}, err
}
leaseID := newLeaseID()
servers, err := client.ListCrabboxServers(ctx)
if err != nil {
return LeaseTarget{}, err
}
slug := allocateDirectLeaseSlug(leaseID, servers)
cfg := b.cfg
keyPath, publicKey, err := ensureTestboxKeyForConfig(cfg, leaseID)
if err != nil {
return LeaseTarget{}, err
}
cfg.SSHKey = keyPath
cfg.ProviderKey = providerKeyForLease(leaseID)
if cfg.ProviderKey != "" {
providerKey, err := client.EnsureSSHKey(ctx, cfg.ProviderKey, publicKey)
if err != nil {
return LeaseTarget{}, err
}
cfg.ProviderKey = providerKey.Name
}
fmt.Fprintf(b.rt.Stderr, "provisioning provider=hetzner lease=%s slug=%s class=%s preferred_type=%s location=%s keep=%v\n", leaseID, slug, cfg.Class, cfg.ServerType, cfg.Location, keep)
server, cfg, err := client.CreateServerWithFallback(ctx, cfg, publicKey, leaseID, slug, keep, func(format string, args ...any) {
fmt.Fprintf(b.rt.Stderr, format, args...)
})
if err != nil {
return LeaseTarget{}, err
}
fmt.Fprintf(b.rt.Stderr, "provisioned lease=%s server=%d type=%s\n", leaseID, server.ID, cfg.ServerType)
server, err = waitForServerIP(ctx, client, server.ID)
if err != nil {
return LeaseTarget{}, err
}
target := sshTargetFromConfig(cfg, server.PublicNet.IPv4.IP)
if err := waitForSSHReady(ctx, &target, b.rt.Stderr, "bootstrap", bootstrapWaitTimeout(cfg)); err != nil {
_ = deleteServer(context.Background(), cfg, server)
return LeaseTarget{}, err
}
server.Labels["state"] = "ready"
if err := client.SetLabels(ctx, server.ID, server.Labels); err != nil {
fmt.Fprintf(b.rt.Stderr, "warning: set labels: %v\n", err)
}
return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil
}
func (b *hetznerLeaseBackend) Resolve(ctx context.Context, req ResolveRequest) (LeaseTarget, error) {
client, err := newHetznerClient()
if err != nil {
return LeaseTarget{}, err
}
if serverID, ok := parseServerID(req.ID); ok {
server, err := client.GetServer(ctx, serverID)
if err != nil {
return LeaseTarget{}, err
}
leaseID := blank(server.Labels["lease"], req.ID)
target := sshTargetFromConfig(b.cfg, server.PublicNet.IPv4.IP)
useStoredTestboxKey(&target, leaseID)
return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil
}
servers, err := client.ListCrabboxServers(ctx)
if err != nil {
return LeaseTarget{}, err
}
if server, leaseID, err := findServerByAlias(servers, req.ID); err != nil {
return LeaseTarget{}, err
} else if leaseID != "" {
target := sshTargetFromConfig(b.cfg, server.PublicNet.IPv4.IP)
useStoredTestboxKey(&target, leaseID)
return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil
}
return LeaseTarget{}, exit(4, "lease/server not found: %s", req.ID)
}
func (b *hetznerLeaseBackend) List(ctx context.Context, req ListRequest) ([]LeaseView, error) {
_ = req
client, err := newHetznerClient()
if err != nil {
return nil, err
}
return client.ListCrabboxServers(ctx)
}
func (b *hetznerLeaseBackend) ReleaseLease(ctx context.Context, req ReleaseLeaseRequest) error {
if err := deleteServer(ctx, b.cfg, req.Lease.Server); err != nil {
return err
}
removeLeaseClaim(req.Lease.LeaseID)
return nil
}
func (b *hetznerLeaseBackend) Touch(ctx context.Context, req TouchRequest) (Server, error) {
return b.touch(ctx, req.Lease.Server, req.State), nil
}
func (b *hetznerLeaseBackend) Cleanup(ctx context.Context, req CleanupRequest) error {
servers, err := b.List(ctx, ListRequest{Options: req.Options})
if err != nil {
return err
}
return b.cleanupServers(ctx, req, servers)
}

View File

@ -0,0 +1,60 @@
package cli
import (
"context"
"fmt"
"time"
)
type staticLeaseBackend struct{ directSSHBackend }
func (b *staticLeaseBackend) Acquire(ctx context.Context, req AcquireRequest) (LeaseTarget, error) {
server, target, leaseID, err := staticLease(b.cfg)
if err != nil {
return LeaseTarget{}, err
}
fmt.Fprintf(b.rt.Stderr, "using static target lease=%s slug=%s target=%s windows_mode=%s host=%s keep=%v\n", leaseID, serverSlug(server), b.cfg.TargetOS, b.cfg.WindowsMode, target.Host, req.Keep)
if err := waitForSSH(ctx, &target, b.rt.Stderr); err != nil {
return LeaseTarget{}, err
}
server.Labels["state"] = "ready"
return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil
}
func (b *staticLeaseBackend) Resolve(_ context.Context, req ResolveRequest) (LeaseTarget, error) {
server, target, leaseID, err := staticLease(b.cfg)
if err != nil {
return LeaseTarget{}, err
}
if req.ID == "" || req.ID == leaseID || req.ID == server.Name || req.ID == serverSlug(server) || req.ID == b.cfg.Static.Host {
return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil
}
return LeaseTarget{}, exit(4, "static lease not found: %s", req.ID)
}
func (b *staticLeaseBackend) List(_ context.Context, req ListRequest) ([]LeaseView, error) {
_ = req
server, _, _, err := staticLease(b.cfg)
if err != nil {
return nil, err
}
return []LeaseView{server}, nil
}
func (b *staticLeaseBackend) ReleaseLease(_ context.Context, req ReleaseLeaseRequest) error {
removeLeaseClaim(req.Lease.LeaseID)
return nil
}
func (b *staticLeaseBackend) Touch(_ context.Context, req TouchRequest) (Server, error) {
server := req.Lease.Server
if server.Labels == nil {
server.Labels = map[string]string{}
}
server.Labels = touchDirectLeaseLabels(server.Labels, b.cfg, req.State, time.Now().UTC())
return server, nil
}
func (b *staticLeaseBackend) Cleanup(context.Context, CleanupRequest) error {
return exit(2, "machine cleanup is not supported for provider=%s", b.cfg.Provider)
}

View File

@ -0,0 +1,110 @@
package cli
import "flag"
func init() {
RegisterProvider(testHetznerProvider{})
RegisterProvider(testAWSProvider{})
RegisterProvider(testStaticSSHProvider{})
RegisterProvider(testBlacksmithProvider{})
}
type testHetznerProvider struct{}
func (testHetznerProvider) Name() string { return "hetzner" }
func (testHetznerProvider) Aliases() []string { return nil }
func (testHetznerProvider) Spec() ProviderSpec {
return ProviderSpec{
Name: "hetzner",
Kind: ProviderKindSSHLease,
Targets: []TargetSpec{{OS: targetLinux}},
Features: FeatureSet{FeatureSSH, FeatureCrabboxSync, FeatureCleanup, FeatureDesktop, FeatureBrowser, FeatureCode, FeatureTailscale},
Coordinator: CoordinatorSupported,
}
}
func (testHetznerProvider) RegisterFlags(*flag.FlagSet, Config) any { return noProviderFlags{} }
func (testHetznerProvider) ApplyFlags(*Config, *flag.FlagSet, any) error {
return nil
}
func (p testHetznerProvider) Configure(cfg Config, rt Runtime) (Backend, error) {
return NewHetznerLeaseBackend(p.Spec(), cfg, rt), nil
}
type testAWSProvider struct{}
func (testAWSProvider) Name() string { return "aws" }
func (testAWSProvider) Aliases() []string { return nil }
func (testAWSProvider) Spec() ProviderSpec {
return ProviderSpec{
Name: "aws",
Kind: ProviderKindSSHLease,
Targets: []TargetSpec{
{OS: targetLinux},
{OS: targetWindows, WindowsMode: windowsModeNormal},
{OS: targetWindows, WindowsMode: windowsModeWSL2},
{OS: targetMacOS},
},
Features: FeatureSet{FeatureSSH, FeatureCrabboxSync, FeatureCleanup, FeatureDesktop, FeatureBrowser, FeatureCode},
Coordinator: CoordinatorSupported,
}
}
func (testAWSProvider) RegisterFlags(*flag.FlagSet, Config) any { return noProviderFlags{} }
func (testAWSProvider) ApplyFlags(*Config, *flag.FlagSet, any) error {
return nil
}
func (p testAWSProvider) Configure(cfg Config, rt Runtime) (Backend, error) {
return NewAWSLeaseBackend(p.Spec(), cfg, rt), nil
}
type testStaticSSHProvider struct{}
func (testStaticSSHProvider) Name() string { return staticProvider }
func (testStaticSSHProvider) Aliases() []string {
return []string{"static", "static-ssh"}
}
func (testStaticSSHProvider) Spec() ProviderSpec {
return ProviderSpec{
Name: staticProvider,
Kind: ProviderKindSSHLease,
Targets: []TargetSpec{
{OS: targetLinux},
{OS: targetWindows, WindowsMode: windowsModeNormal},
{OS: targetWindows, WindowsMode: windowsModeWSL2},
{OS: targetMacOS},
},
Features: FeatureSet{FeatureSSH, FeatureCrabboxSync, FeatureDesktop, FeatureBrowser, FeatureCode},
Coordinator: CoordinatorNever,
}
}
func (testStaticSSHProvider) RegisterFlags(*flag.FlagSet, Config) any { return noProviderFlags{} }
func (testStaticSSHProvider) ApplyFlags(*Config, *flag.FlagSet, any) error {
return nil
}
func (p testStaticSSHProvider) Configure(cfg Config, rt Runtime) (Backend, error) {
return NewStaticSSHLeaseBackend(p.Spec(), cfg, rt), nil
}
type testBlacksmithProvider struct{}
func (testBlacksmithProvider) Name() string { return blacksmithTestboxProvider }
func (testBlacksmithProvider) Aliases() []string {
return []string{"blacksmith"}
}
func (testBlacksmithProvider) Spec() ProviderSpec {
return ProviderSpec{
Name: blacksmithTestboxProvider,
Kind: ProviderKindDelegatedRun,
Targets: []TargetSpec{{OS: targetLinux}},
Features: nil,
Coordinator: CoordinatorNever,
}
}
func (testBlacksmithProvider) RegisterFlags(fs *flag.FlagSet, defaults Config) any {
return RegisterBlacksmithProviderFlags(fs, defaults)
}
func (testBlacksmithProvider) ApplyFlags(cfg *Config, fs *flag.FlagSet, values any) error {
return ApplyBlacksmithProviderFlags(cfg, fs, values)
}
func (p testBlacksmithProvider) Configure(cfg Config, rt Runtime) (Backend, error) {
return NewBlacksmithBackend(p.Spec(), cfg, rt), nil
}

View File

@ -0,0 +1,135 @@
package cli
import (
"context"
"fmt"
"io"
"strings"
"time"
)
type noProviderFlags struct{}
func NoProviderFlags() any { return noProviderFlags{} }
func NewHetznerLeaseBackend(spec ProviderSpec, cfg Config, rt Runtime) Backend {
cfg.Provider = "hetzner"
return &hetznerLeaseBackend{directSSHBackend: directSSHBackend{spec: spec, cfg: cfg, rt: rt}}
}
func NewAWSLeaseBackend(spec ProviderSpec, cfg Config, rt Runtime) Backend {
cfg.Provider = "aws"
return &awsLeaseBackend{directSSHBackend: directSSHBackend{spec: spec, cfg: cfg, rt: rt}}
}
func NewStaticSSHLeaseBackend(spec ProviderSpec, cfg Config, rt Runtime) Backend {
cfg.Provider = staticProvider
return &staticLeaseBackend{directSSHBackend: directSSHBackend{spec: spec, cfg: cfg, rt: rt}}
}
type directSSHBackend struct {
spec ProviderSpec
cfg Config
rt Runtime
}
func (b *directSSHBackend) Spec() ProviderSpec { return b.spec }
func (b *directSSHBackend) cleanupServers(ctx context.Context, req CleanupRequest, servers []Server) error {
_ = ctx
_ = req
for _, s := range servers {
shouldDelete, reason := shouldCleanupServer(s, time.Now().UTC())
if !shouldDelete {
fmt.Fprintf(b.rt.Stderr, "skip server id=%s name=%s reason=%s\n", s.DisplayID(), s.Name, reason)
continue
}
fmt.Fprintf(b.rt.Stderr, "delete server id=%s name=%s\n", s.DisplayID(), s.Name)
if !req.DryRun {
if err := deleteServer(ctx, b.cfg, s); err != nil {
return err
}
}
}
return nil
}
func (b *directSSHBackend) touch(ctx context.Context, server Server, state string) Server {
return touchDirectLeaseBestEffort(ctx, b.cfg, server, state, b.rt.Stderr)
}
func touchDirectLeaseBestEffort(ctx context.Context, cfg Config, server Server, state string, stderr io.Writer) Server {
if server.Labels == nil {
server.Labels = map[string]string{}
}
server.Labels = touchDirectLeaseLabels(server.Labels, cfg, state, time.Now().UTC())
if isStaticProvider(cfg.Provider) || server.Provider == staticProvider {
return server
}
if cfg.Provider == "aws" || server.Provider == "aws" || strings.HasPrefix(server.CloudID, "i-") {
client, err := newAWSClient(ctx, cfg)
if err != nil {
fmt.Fprintf(stderr, "warning: direct touch state=%s: %v\n", state, err)
return server
}
if err := client.SetTags(ctx, server.CloudID, server.Labels); err != nil {
fmt.Fprintf(stderr, "warning: direct touch state=%s: %v\n", state, err)
}
return server
}
client, err := newHetznerClient()
if err != nil {
fmt.Fprintf(stderr, "warning: direct touch state=%s: %v\n", state, err)
return server
}
if err := client.SetLabels(ctx, server.ID, server.Labels); err != nil {
fmt.Fprintf(stderr, "warning: direct touch state=%s: %v\n", state, err)
}
return server
}
func chooseAWSRegion(ctx context.Context, cfg Config, stderr io.Writer) Config {
if cfg.Provider != "aws" || cfg.Capacity.Market != "spot" || len(cfg.Capacity.Regions) < 2 {
return cfg
}
client, err := newAWSClient(ctx, cfg)
if err != nil {
fmt.Fprintf(stderr, "warning: spot placement score unavailable: %v\n", err)
return cfg
}
scores, err := client.SpotPlacementScores(ctx, cfg)
if err != nil {
fmt.Fprintf(stderr, "warning: spot placement score unavailable: %v\n", err)
return cfg
}
if len(scores) == 0 {
return cfg
}
best := awsString(scores[0].Region)
score := int32(0)
if scores[0].Score != nil {
score = *scores[0].Score
}
if best != "" && best != cfg.AWSRegion {
fmt.Fprintf(stderr, "selected aws region=%s spot_score=%d previous=%s\n", best, score, cfg.AWSRegion)
cfg.AWSRegion = best
}
return cfg
}
func acquireAttemptsRetry(rt Runtime, keep bool, acquire func() (LeaseTarget, error)) (LeaseTarget, error) {
var lastErr error
attempts := acquireAttempts(keep)
for attempt := 1; attempt <= attempts; attempt++ {
lease, err := acquire()
if err == nil {
return lease, nil
}
lastErr = err
if attempt == attempts || !isBootstrapWaitError(err) {
return LeaseTarget{}, err
}
fmt.Fprintf(rt.Stderr, "warning: bootstrap failed; retrying with fresh lease: %v\n", err)
}
return LeaseTarget{}, lastErr
}

View File

@ -60,42 +60,42 @@ func (a App) warmup(ctx context.Context, args []string) error {
if err != nil {
return err
}
if isBlacksmithProvider(cfg.Provider) {
if *actionsRunner {
return exit(2, "--actions-runner is not supported for provider=%s; Blacksmith owns runner hydration", cfg.Provider)
backend, err := loadBackend(cfg, runtimeForApp(a))
if err != nil {
return err
}
options := leaseOptionsFromConfig(cfg)
if delegated, ok := backend.(DelegatedRunBackend); ok {
return delegated.Warmup(ctx, WarmupRequest{Repo: repo, Options: options, Keep: *keep, Reclaim: *reclaim, ActionsRunner: *actionsRunner, TimingJSON: *timingJSON})
}
sshBackend, ok := backend.(SSHLeaseBackend)
if !ok {
return exit(2, "provider=%s does not support warmup", backend.Spec().Name)
}
if *actionsRunner {
if err := validateActionsRunnerCapability(backend, cfg); err != nil {
return err
}
return a.blacksmithWarmup(ctx, cfg, repo, *keep, *reclaim, *timingJSON)
}
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
if err != nil {
return err
}
var server Server
var target SSHTarget
var leaseID string
if useCoordinator {
server, target, leaseID, err = a.acquireCoordinatorWithRetry(ctx, cfg, coord, *keep)
} else {
server, target, leaseID, err = a.acquireWithRetry(ctx, cfg, *keep)
}
lease, err := sshBackend.Acquire(ctx, AcquireRequest{Repo: repo, Options: options, Keep: *keep, Reclaim: *reclaim})
if err != nil {
return err
}
server, target, leaseID := lease.Server, lease.SSH, lease.LeaseID
applyResolvedServerConfig(&cfg, server)
if err := claimLeaseForRepoConfig(leaseID, serverSlug(server), cfg, repo.Root, cfg.IdleTimeout, *reclaim); err != nil {
a.releaseAcquiredLeaseBestEffort(ctx, cfg, coord, useCoordinator, server, target, leaseID)
a.releaseBackendLeaseBestEffort(ctx, sshBackend, LeaseTarget{Server: server, SSH: target, LeaseID: leaseID, Coordinator: lease.Coordinator})
return err
}
if serverTailscaleMetadata(server).Enabled {
if err := waitForSSHReady(ctx, &target, a.Stderr, "tailscale metadata", 2*time.Minute); err == nil {
a.refreshTailscaleMetadata(ctx, cfg, coord, useCoordinator, &server, target, leaseID)
a.refreshTailscaleMetadata(ctx, cfg, lease.Coordinator, lease.Coordinator != nil, &server, target, leaseID)
} else {
fmt.Fprintf(a.Stderr, "warning: tailscale metadata wait failed: %v\n", err)
}
}
if resolved, err := resolveNetworkTarget(ctx, cfg, server, target); err != nil {
a.releaseAcquiredLeaseBestEffort(ctx, cfg, coord, useCoordinator, server, target, leaseID)
a.releaseBackendLeaseBestEffort(ctx, sshBackend, LeaseTarget{Server: server, SSH: target, LeaseID: leaseID, Coordinator: lease.Coordinator})
return err
} else {
target = resolved.Target
@ -182,28 +182,40 @@ func (a App) runCommand(ctx context.Context, args []string) (err error) {
if err != nil {
return err
}
if isBlacksmithProvider(cfg.Provider) {
return a.blacksmithRun(ctx, cfg, repo, blacksmithRunOptions{
ID: *leaseIDFlag,
Keep: *keep,
Reclaim: *reclaim,
SyncOnly: *syncOnly,
Debug: *debugSync,
ShellMode: *shellMode,
Command: command,
IdleTimeout: cfg.IdleTimeout,
TimingJSON: *timingJSON,
backend, err := loadBackend(cfg, runtimeForApp(a))
if err != nil {
return err
}
options := leaseOptionsFromConfig(cfg)
if delegated, ok := backend.(DelegatedRunBackend); ok {
_, err := delegated.Run(ctx, RunRequest{
Repo: repo,
ID: *leaseIDFlag,
Options: options,
Keep: *keep,
Reclaim: *reclaim,
NoSync: *noSync,
SyncOnly: *syncOnly,
DebugSync: *debugSync,
ShellMode: *shellMode,
ChecksumSync: *checksumSync,
ForceSyncLarge: *forceSyncLarge,
Command: command,
TimingJSON: *timingJSON,
})
return err
}
sshBackend, ok := backend.(SSHLeaseBackend)
if !ok {
return exit(2, "provider=%s does not support run", backend.Spec().Name)
}
var server Server
var target SSHTarget
var leaseID string
acquired := false
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
if err != nil {
return err
}
coord := backendCoordinator(backend)
useCoordinator := coord != nil
recorder := &runRecorder{}
var runFailure error
recordFailure := func(failure error) error {
@ -217,48 +229,35 @@ func (a App) runCommand(ctx context.Context, args []string) (err error) {
recorder.Event("leasing.started", "leasing", "")
}
if *leaseIDFlag != "" {
if useCoordinator {
var lease CoordinatorLease
lease, err = coord.GetLease(ctx, *leaseIDFlag)
if err == nil {
server, target, leaseID = leaseToServerTarget(lease, cfg)
if resolved, resolveErr := resolveNetworkTarget(ctx, cfg, server, target); resolveErr != nil {
err = resolveErr
} else {
target = resolved.Target
if resolved.FallbackReason != "" {
fmt.Fprintf(a.Stderr, "network fallback %s\n", resolved.FallbackReason)
}
}
if !flagWasSet(fs, "idle-timeout") && lease.IdleTimeoutSeconds > 0 {
cfg.IdleTimeout = time.Duration(lease.IdleTimeoutSeconds) * time.Second
}
}
} else {
server, target, leaseID, err = a.findLease(ctx, cfg, *leaseIDFlag)
if err == nil {
if resolved, resolveErr := resolveNetworkTarget(ctx, cfg, server, target); resolveErr != nil {
err = resolveErr
} else {
target = resolved.Target
if resolved.FallbackReason != "" {
fmt.Fprintf(a.Stderr, "network fallback %s\n", resolved.FallbackReason)
}
}
}
if err == nil && !flagWasSet(fs, "idle-timeout") {
if duration, ok := parseDurationSecondsLabel(server.Labels["idle_timeout_secs"]); ok {
cfg.IdleTimeout = duration
} else if duration, ok := parseDurationSecondsLabel(server.Labels["idle_timeout"]); ok {
cfg.IdleTimeout = duration
var lease LeaseTarget
lease, err = sshBackend.Resolve(ctx, ResolveRequest{Repo: repo, Options: options, ID: *leaseIDFlag, Reclaim: *reclaim})
if err == nil {
server, target, leaseID = lease.Server, lease.SSH, lease.LeaseID
if resolved, resolveErr := resolveNetworkTarget(ctx, cfg, server, target); resolveErr != nil {
err = resolveErr
} else {
target = resolved.Target
if resolved.FallbackReason != "" {
fmt.Fprintf(a.Stderr, "network fallback %s\n", resolved.FallbackReason)
}
}
}
if err == nil && !flagWasSet(fs, "idle-timeout") {
if useCoordinator {
if duration, ok := parseDurationSecondsLabel(server.Labels["idle_timeout_secs"]); ok {
cfg.IdleTimeout = duration
}
} else if duration, ok := parseDurationSecondsLabel(server.Labels["idle_timeout_secs"]); ok {
cfg.IdleTimeout = duration
} else if duration, ok := parseDurationSecondsLabel(server.Labels["idle_timeout"]); ok {
cfg.IdleTimeout = duration
}
}
} else {
if useCoordinator {
server, target, leaseID, err = a.acquireCoordinatorWithRetry(ctx, cfg, coord, *keep)
} else {
server, target, leaseID, err = a.acquireWithRetry(ctx, cfg, *keep)
var lease LeaseTarget
lease, err = sshBackend.Acquire(ctx, AcquireRequest{Repo: repo, Options: options, Keep: *keep, Reclaim: *reclaim})
if err == nil {
server, target, leaseID = lease.Server, lease.SSH, lease.LeaseID
}
acquired = true
}
@ -274,17 +273,21 @@ func (a App) runCommand(ctx context.Context, args []string) (err error) {
}
if err := claimLeaseForRepoConfig(leaseID, serverSlug(server), cfg, repo.Root, cfg.IdleTimeout, *reclaim); err != nil {
if acquired && !*keep {
a.releaseAcquiredLeaseBestEffort(ctx, cfg, coord, useCoordinator, server, target, leaseID)
a.releaseBackendLeaseBestEffort(ctx, sshBackend, LeaseTarget{Server: server, SSH: target, LeaseID: leaseID, Coordinator: coord})
}
return recordFailure(err)
}
if !useCoordinator && leaseID != "" {
server = a.touchDirectLeaseBestEffort(ctx, cfg, server, blank(server.Labels["state"], "ready"))
if touched, touchErr := sshBackend.Touch(ctx, TouchRequest{Lease: LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, State: blank(server.Labels["state"], "ready"), IdleTimeout: cfg.IdleTimeout}); touchErr == nil {
server = touched
} else {
fmt.Fprintf(a.Stderr, "warning: direct touch failed for %s: %v\n", leaseID, touchErr)
}
}
if acquired {
defer func() {
if !*keep {
a.releaseAcquiredLeaseBestEffort(context.Background(), cfg, coord, useCoordinator, server, target, leaseID)
a.releaseBackendLeaseBestEffort(context.Background(), sshBackend, LeaseTarget{Server: server, SSH: target, LeaseID: leaseID, Coordinator: coord})
recorder.Event("lease.released", "released", "")
}
}()
@ -493,9 +496,17 @@ afterSync:
}
}
if !useCoordinator {
server = a.touchDirectLeaseBestEffort(context.Background(), cfg, server, "running")
if touched, touchErr := sshBackend.Touch(context.Background(), TouchRequest{Lease: LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, State: "running", IdleTimeout: cfg.IdleTimeout}); touchErr == nil {
server = touched
} else {
fmt.Fprintf(a.Stderr, "warning: direct touch state=running: %v\n", touchErr)
}
defer func() {
server = a.touchDirectLeaseBestEffort(context.Background(), cfg, server, "ready")
if touched, touchErr := sshBackend.Touch(context.Background(), TouchRequest{Lease: LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, State: "ready", IdleTimeout: cfg.IdleTimeout}); touchErr == nil {
server = touched
} else {
fmt.Fprintf(a.Stderr, "warning: direct touch state=ready: %v\n", touchErr)
}
}()
}
fmt.Fprintf(a.Stderr, "running on %s %s\n", target.Host, strings.Join(command, " "))
@ -674,55 +685,6 @@ func shouldUseShell(command []string) bool {
return false
}
func (a App) acquireCoordinator(ctx context.Context, cfg Config, coord *CoordinatorClient, keep bool) (Server, SSHTarget, string, error) {
leaseID := newLeaseID()
slug := newLeaseSlug(leaseID)
keyPath, publicKey, err := ensureTestboxKeyForConfig(cfg, leaseID)
if err != nil {
return Server{}, SSHTarget{}, "", err
}
cfg.SSHKey = keyPath
cfg.ProviderKey = providerKeyForLease(leaseID)
if cfg.Tailscale.Enabled && cfg.Tailscale.Hostname == "" {
cfg.Tailscale.Hostname = renderTailscaleHostname(cfg.Tailscale.HostnameTemplate, leaseID, slug, cfg.Provider)
}
ensureAWSSSHCIDRs(ctx, &cfg)
fmt.Fprintf(a.Stderr, "coordinator lease class=%s preferred_type=%s keep=%v slug=%s idle_timeout=%s ttl=%s\n", cfg.Class, cfg.ServerType, keep, slug, cfg.IdleTimeout, cfg.TTL)
lease, err := coord.CreateLease(ctx, cfg, publicKey, keep, leaseID, slug)
if err != nil {
return Server{}, SSHTarget{}, "", err
}
if lease.ID != "" && lease.ID != leaseID {
if err := moveStoredTestboxKey(leaseID, lease.ID); err != nil {
fmt.Fprintf(a.Stderr, "warning: could not move local key from %s to %s: %v\n", leaseID, lease.ID, err)
}
}
if err := validateCoordinatorLeaseCapabilities(cfg, lease); err != nil {
if releaseErr := releaseCoordinatorLease(context.Background(), coord, blank(lease.ID, leaseID)); releaseErr != nil {
fmt.Fprintf(a.Stderr, "warning: release failed after capability mismatch for %s: %v\n", blank(lease.ID, leaseID), releaseErr)
}
return Server{}, SSHTarget{}, "", err
}
server, target, leaseID := leaseToServerTarget(lease, cfg)
fmt.Fprintf(a.Stderr, "leased %s slug=%s server=%d type=%s ip=%s via coordinator\n", leaseID, blank(lease.Slug, "-"), server.ID, server.ServerType.Name, target.Host)
if summary := coordinatorFallbackSummary(lease); summary != "" {
fmt.Fprintf(a.Stderr, "fallback resolved %s\n", summary)
}
waitCtx, cancelWait := context.WithCancelCause(ctx)
defer cancelWait(nil)
stopHeartbeat := startCoordinatorHeartbeat(waitCtx, coord, leaseID, cfg.IdleTimeout, nil, a.Stderr)
defer stopHeartbeat()
stopLeaseWatch := startCoordinatorLeaseWatch(waitCtx, coord, leaseID, cancelWait, a.Stderr)
defer stopLeaseWatch()
if err := bootstrapAWSWindowsDesktop(waitCtx, cfg, &target, publicKey, a.Stderr); err != nil {
if releaseErr := releaseCoordinatorLease(context.Background(), coord, leaseID); releaseErr != nil {
fmt.Fprintf(a.Stderr, "warning: release failed after bootstrap error for %s: %v\n", leaseID, releaseErr)
}
return Server{}, SSHTarget{}, "", err
}
return server, target, leaseID, nil
}
func validateCoordinatorLeaseCapabilities(cfg Config, lease CoordinatorLease) error {
if cfg.Desktop && !lease.Desktop {
return exit(5, "coordinator did not provision desktop=true for lease %s; deploy the coordinator with desktop/VNC support", blank(lease.ID, "-"))
@ -769,40 +731,6 @@ func coordinatorFallbackSummary(lease CoordinatorLease) string {
return fmt.Sprintf("requested_type=%s actual_type=%s attempts=%s", lease.RequestedServerType, lease.ServerType, blank(strings.Join(attempts, ","), "-"))
}
func (a App) acquireCoordinatorWithRetry(ctx context.Context, cfg Config, coord *CoordinatorClient, keep bool) (Server, SSHTarget, string, error) {
var lastErr error
attempts := acquireAttempts(keep)
for attempt := 1; attempt <= attempts; attempt++ {
server, target, leaseID, err := a.acquireCoordinator(ctx, cfg, coord, keep)
if err == nil {
return server, target, leaseID, nil
}
lastErr = err
if attempt == attempts || !isBootstrapWaitError(err) {
return Server{}, SSHTarget{}, "", err
}
fmt.Fprintf(a.Stderr, "warning: bootstrap failed; retrying with fresh lease: %v\n", err)
}
return Server{}, SSHTarget{}, "", lastErr
}
func (a App) acquireWithRetry(ctx context.Context, cfg Config, keep bool) (Server, SSHTarget, string, error) {
var lastErr error
attempts := acquireAttempts(keep)
for attempt := 1; attempt <= attempts; attempt++ {
server, target, leaseID, err := a.acquire(ctx, cfg, keep)
if err == nil {
return server, target, leaseID, nil
}
lastErr = err
if attempt == attempts || !isBootstrapWaitError(err) {
return Server{}, SSHTarget{}, "", err
}
fmt.Fprintf(a.Stderr, "warning: bootstrap failed; retrying with fresh lease: %v\n", err)
}
return Server{}, SSHTarget{}, "", lastErr
}
func acquireAttempts(bool) int {
return 2
}
@ -832,17 +760,12 @@ func releaseCoordinatorLease(ctx context.Context, coord *CoordinatorClient, leas
return lastErr
}
func (a App) releaseAcquiredLeaseBestEffort(ctx context.Context, cfg Config, coord *CoordinatorClient, useCoordinator bool, server Server, target SSHTarget, leaseID string) {
a.writeActionsHydrationStopBestEffort(ctx, target, leaseID)
fmt.Fprintf(a.Stderr, "releasing %s server=%s\n", leaseID, server.DisplayID())
if useCoordinator {
if err := releaseCoordinatorLease(ctx, coord, leaseID); err != nil {
fmt.Fprintf(a.Stderr, "warning: release failed for %s: %v\n", leaseID, err)
}
} else if err := deleteServer(ctx, cfg, server); err != nil {
fmt.Fprintf(a.Stderr, "warning: delete failed for %s: %v\n", leaseID, err)
func (a App) releaseBackendLeaseBestEffort(ctx context.Context, backend SSHLeaseBackend, lease LeaseTarget) {
a.writeActionsHydrationStopBestEffort(ctx, lease.SSH, lease.LeaseID)
fmt.Fprintf(a.Stderr, "releasing %s server=%s\n", lease.LeaseID, lease.Server.DisplayID())
if err := backend.ReleaseLease(ctx, ReleaseLeaseRequest{Lease: lease, Force: true}); err != nil {
fmt.Fprintf(a.Stderr, "warning: release failed for %s: %v\n", lease.LeaseID, err)
}
removeLeaseClaim(leaseID)
}
func startCoordinatorHeartbeat(ctx context.Context, coord *CoordinatorClient, leaseID string, idleTimeout time.Duration, updateIdleTimeout *time.Duration, stderr io.Writer) func() {
@ -947,189 +870,6 @@ func heartbeatInterval(ttl time.Duration) time.Duration {
return interval
}
func (a App) touchLeaseBestEffort(ctx context.Context, cfg Config, identifier, leaseID string) {
if _, ok, err := newTargetCoordinatorClient(cfg); err == nil && ok {
if leaseID == "" {
leaseID = identifier
}
a.touchCoordinatorLeaseBestEffort(ctx, cfg, leaseID)
return
}
server, _, _, err := a.findLease(ctx, cfg, identifier)
if err != nil {
fmt.Fprintf(a.Stderr, "warning: direct touch failed for %s: %v\n", identifier, err)
return
}
a.touchDirectLeaseBestEffort(ctx, cfg, server, blank(server.Labels["state"], "ready"))
}
func (a App) touchActiveLeaseBestEffort(ctx context.Context, cfg Config, server Server, leaseID string) Server {
if _, ok, err := newTargetCoordinatorClient(cfg); err == nil && ok {
a.touchCoordinatorLeaseBestEffort(ctx, cfg, leaseID)
return server
}
return a.touchDirectLeaseBestEffort(ctx, cfg, server, blank(server.Labels["state"], "ready"))
}
func (a App) touchDirectLeaseBestEffort(ctx context.Context, cfg Config, server Server, state string) Server {
if server.Labels == nil {
server.Labels = map[string]string{}
}
server.Labels = touchDirectLeaseLabels(server.Labels, cfg, state, time.Now().UTC())
if isStaticProvider(cfg.Provider) || server.Provider == staticProvider {
return server
}
if cfg.Provider == "aws" || server.Provider == "aws" || strings.HasPrefix(server.CloudID, "i-") {
client, err := newAWSClient(ctx, cfg)
if err != nil {
fmt.Fprintf(a.Stderr, "warning: direct touch state=%s: %v\n", state, err)
return server
}
if err := client.SetTags(ctx, server.CloudID, server.Labels); err != nil {
fmt.Fprintf(a.Stderr, "warning: direct touch state=%s: %v\n", state, err)
}
return server
}
client, err := newHetznerClient()
if err != nil {
fmt.Fprintf(a.Stderr, "warning: direct touch state=%s: %v\n", state, err)
return server
}
if err := client.SetLabels(ctx, server.ID, server.Labels); err != nil {
fmt.Fprintf(a.Stderr, "warning: direct touch state=%s: %v\n", state, err)
}
return server
}
func (a App) acquire(ctx context.Context, cfg Config, keep bool) (Server, SSHTarget, string, error) {
if isStaticProvider(cfg.Provider) {
return a.acquireStatic(ctx, cfg, keep)
}
if cfg.Tailscale.Enabled && cfg.Tailscale.AuthKey == "" {
return Server{}, SSHTarget{}, "", exit(2, "direct --tailscale requires %s to contain a Tailscale auth key; brokered mode uses coordinator OAuth secrets", cfg.Tailscale.AuthKeyEnv)
}
if cfg.Provider == "aws" {
return a.acquireAWS(ctx, cfg, keep)
}
client, err := newHetznerClient()
if err != nil {
return Server{}, SSHTarget{}, "", err
}
leaseID := newLeaseID()
servers, err := client.ListCrabboxServers(ctx)
if err != nil {
return Server{}, SSHTarget{}, "", err
}
slug := allocateDirectLeaseSlug(leaseID, servers)
keyPath, publicKey, err := ensureTestboxKeyForConfig(cfg, leaseID)
if err != nil {
return Server{}, SSHTarget{}, "", err
}
cfg.SSHKey = keyPath
cfg.ProviderKey = providerKeyForLease(leaseID)
if cfg.ProviderKey != "" {
providerKey, err := client.EnsureSSHKey(ctx, cfg.ProviderKey, publicKey)
if err != nil {
return Server{}, SSHTarget{}, "", err
}
cfg.ProviderKey = providerKey.Name
}
fmt.Fprintf(a.Stderr, "provisioning provider=hetzner lease=%s slug=%s class=%s preferred_type=%s location=%s keep=%v\n", leaseID, slug, cfg.Class, cfg.ServerType, cfg.Location, keep)
server, cfg, err := client.CreateServerWithFallback(ctx, cfg, publicKey, leaseID, slug, keep, func(format string, args ...any) {
fmt.Fprintf(a.Stderr, format, args...)
})
if err != nil {
return Server{}, SSHTarget{}, "", err
}
fmt.Fprintf(a.Stderr, "provisioned lease=%s server=%d type=%s\n", leaseID, server.ID, cfg.ServerType)
server, err = waitForServerIP(ctx, client, server.ID)
if err != nil {
return Server{}, SSHTarget{}, "", err
}
target := sshTargetFromConfig(cfg, server.PublicNet.IPv4.IP)
if err := waitForSSHReady(ctx, &target, a.Stderr, "bootstrap", bootstrapWaitTimeout(cfg)); err != nil {
_ = deleteServer(context.Background(), cfg, server)
return Server{}, SSHTarget{}, "", err
}
server.Labels["state"] = "ready"
if err := client.SetLabels(ctx, server.ID, server.Labels); err != nil {
fmt.Fprintf(a.Stderr, "warning: set labels: %v\n", err)
}
return server, target, leaseID, nil
}
func (a App) acquireAWS(ctx context.Context, cfg Config, keep bool) (Server, SSHTarget, string, error) {
cfg = a.chooseAWSRegion(ctx, cfg)
client, err := newAWSClient(ctx, cfg)
if err != nil {
return Server{}, SSHTarget{}, "", err
}
leaseID := newLeaseID()
servers, err := client.ListCrabboxServers(ctx)
if err != nil {
return Server{}, SSHTarget{}, "", err
}
slug := allocateDirectLeaseSlug(leaseID, servers)
keyPath, publicKey, err := ensureTestboxKeyForConfig(cfg, leaseID)
if err != nil {
return Server{}, SSHTarget{}, "", err
}
cfg.SSHKey = keyPath
cfg.ProviderKey = providerKeyForLease(leaseID)
ensureAWSSSHCIDRs(ctx, &cfg)
fmt.Fprintf(a.Stderr, "provisioning provider=aws lease=%s slug=%s class=%s preferred_type=%s region=%s keep=%v market=%s strategy=%s\n", leaseID, slug, cfg.Class, cfg.ServerType, cfg.AWSRegion, keep, cfg.Capacity.Market, cfg.Capacity.Strategy)
server, cfg, err := client.CreateServerWithFallback(ctx, cfg, publicKey, leaseID, slug, keep, func(format string, args ...any) {
fmt.Fprintf(a.Stderr, format, args...)
})
if err != nil {
return Server{}, SSHTarget{}, "", err
}
fmt.Fprintf(a.Stderr, "provisioned lease=%s server=%s type=%s\n", leaseID, server.DisplayID(), cfg.ServerType)
server, err = client.waitForServerIP(ctx, server.CloudID)
if err != nil {
return Server{}, SSHTarget{}, "", err
}
target := sshTargetFromConfig(cfg, server.PublicNet.IPv4.IP)
if err := bootstrapAWSWindowsDesktop(ctx, cfg, &target, publicKey, a.Stderr); err != nil {
_ = client.DeleteServer(context.Background(), server.CloudID)
return Server{}, SSHTarget{}, "", err
}
server.Labels["state"] = "ready"
if err := client.SetTags(ctx, server.CloudID, server.Labels); err != nil {
fmt.Fprintf(a.Stderr, "warning: set tags: %v\n", err)
}
return server, target, leaseID, nil
}
func (a App) chooseAWSRegion(ctx context.Context, cfg Config) Config {
if cfg.Provider != "aws" || cfg.Capacity.Market != "spot" || len(cfg.Capacity.Regions) < 2 {
return cfg
}
client, err := newAWSClient(ctx, cfg)
if err != nil {
fmt.Fprintf(a.Stderr, "warning: spot placement score unavailable: %v\n", err)
return cfg
}
scores, err := client.SpotPlacementScores(ctx, cfg)
if err != nil {
fmt.Fprintf(a.Stderr, "warning: spot placement score unavailable: %v\n", err)
return cfg
}
if len(scores) == 0 {
return cfg
}
best := awsString(scores[0].Region)
score := int32(0)
if scores[0].Score != nil {
score = *scores[0].Score
}
if best != "" && best != cfg.AWSRegion {
fmt.Fprintf(a.Stderr, "selected aws region=%s spot_score=%d previous=%s\n", best, score, cfg.AWSRegion)
cfg.AWSRegion = best
}
return cfg
}
func waitForServerIP(ctx context.Context, client *HetznerClient, id int64) (Server, error) {
deadline := time.Now().Add(5 * time.Minute)
for {
@ -1147,76 +887,6 @@ func waitForServerIP(ctx context.Context, client *HetznerClient, id int64) (Serv
}
}
func (a App) findLease(ctx context.Context, cfg Config, id string) (Server, SSHTarget, string, error) {
if isStaticProvider(cfg.Provider) {
return a.findStaticLease(ctx, cfg, id)
}
if cfg.Provider == "aws" {
return a.findAWSLease(ctx, cfg, id)
}
client, err := newHetznerClient()
if err != nil {
return Server{}, SSHTarget{}, "", err
}
if serverID, ok := parseServerID(id); ok {
server, err := client.GetServer(ctx, serverID)
if err != nil {
return Server{}, SSHTarget{}, "", err
}
leaseID := server.Labels["lease"]
if leaseID == "" {
leaseID = id
}
target := sshTargetFromConfig(cfg, server.PublicNet.IPv4.IP)
useStoredTestboxKey(&target, leaseID)
return server, target, leaseID, nil
}
servers, err := client.ListCrabboxServers(ctx)
if err != nil {
return Server{}, SSHTarget{}, "", err
}
if server, leaseID, err := findServerByAlias(servers, id); err != nil {
return Server{}, SSHTarget{}, "", err
} else if leaseID != "" {
target := sshTargetFromConfig(cfg, server.PublicNet.IPv4.IP)
useStoredTestboxKey(&target, leaseID)
return server, target, leaseID, nil
}
return Server{}, SSHTarget{}, "", exit(4, "lease/server not found: %s", id)
}
func (a App) findAWSLease(ctx context.Context, cfg Config, id string) (Server, SSHTarget, string, error) {
client, err := newAWSClient(ctx, cfg)
if err != nil {
return Server{}, SSHTarget{}, "", err
}
if strings.HasPrefix(id, "i-") {
server, err := client.GetServer(ctx, id)
if err != nil {
return Server{}, SSHTarget{}, "", err
}
leaseID := server.Labels["lease"]
if leaseID == "" {
leaseID = id
}
target := sshTargetFromConfig(cfg, server.PublicNet.IPv4.IP)
useStoredTestboxKey(&target, leaseID)
return server, target, leaseID, nil
}
servers, err := client.ListCrabboxServers(ctx)
if err != nil {
return Server{}, SSHTarget{}, "", err
}
if server, leaseID, err := findServerByAlias(servers, id); err != nil {
return Server{}, SSHTarget{}, "", err
} else if leaseID != "" {
target := sshTargetFromConfig(cfg, server.PublicNet.IPv4.IP)
useStoredTestboxKey(&target, leaseID)
return server, target, leaseID, nil
}
return Server{}, SSHTarget{}, "", exit(4, "lease/server not found: %s", id)
}
func findServerByAlias(servers []Server, id string) (Server, string, error) {
if isCanonicalLeaseID(id) {
for _, server := range servers {
@ -1252,9 +922,11 @@ func findServerByAlias(servers []Server, id string) (Server, string, error) {
}
func (a App) stop(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("stop", a.Stderr)
provider := fs.String("provider", defaultConfig().Provider, "provider: hetzner, aws, ssh, or blacksmith-testbox")
targetFlags := registerTargetFlags(fs, defaultConfig())
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, ssh, or blacksmith-testbox")
providerFlags := registerProviderFlags(fs, defaults)
targetFlags := registerTargetFlags(fs, defaults)
if err := parseFlags(fs, args); err != nil {
return err
}
@ -1266,39 +938,47 @@ func (a App) stop(ctx context.Context, args []string) error {
return err
}
cfg.Provider = *provider
if err := applyProviderFlags(&cfg, fs, providerFlags); err != nil {
return err
}
if err := applyTargetFlagOverrides(&cfg, fs, targetFlags); err != nil {
return err
}
if isBlacksmithProvider(cfg.Provider) {
return a.blacksmithStop(ctx, cfg, fs.Arg(0))
}
if coord, ok, err := newTargetCoordinatorClient(cfg); err != nil {
return err
} else if ok {
if lease, err := coord.GetLease(ctx, fs.Arg(0)); err == nil {
_, target, leaseID := leaseToServerTarget(lease, cfg)
a.writeActionsHydrationStopBestEffort(ctx, target, leaseID)
} else {
fmt.Fprintf(a.Stderr, "warning: could not inspect lease before release: %v\n", err)
}
released, err := coord.ReleaseLease(ctx, fs.Arg(0), true)
if err != nil {
return err
}
removeLeaseClaim(released.ID)
fmt.Fprintf(a.Stderr, "released lease=%s server=%s\n", released.ID, leaseDisplayID(released))
return nil
}
server, target, leaseID, err := a.findLease(ctx, cfg, fs.Arg(0))
backend, err := loadBackend(cfg, runtimeForApp(a))
if err != nil {
return err
}
a.writeActionsHydrationStopBestEffort(ctx, target, leaseID)
fmt.Fprintf(a.Stderr, "deleting lease=%s server=%s name=%s\n", leaseID, server.DisplayID(), server.Name)
if err := deleteServer(ctx, cfg, server); err != nil {
if delegated, ok := backend.(DelegatedRunBackend); ok {
return delegated.Stop(ctx, StopRequest{Options: leaseOptionsFromConfig(cfg), ID: fs.Arg(0)})
}
sshBackend, ok := backend.(SSHLeaseBackend)
if !ok {
return exit(2, "provider=%s does not support stop", backend.Spec().Name)
}
lease, err := sshBackend.Resolve(ctx, ResolveRequest{Options: leaseOptionsFromConfig(cfg), ID: fs.Arg(0)})
if err != nil {
if backendCoordinator(backend) != nil {
fmt.Fprintf(a.Stderr, "warning: could not inspect lease before release: %v\n", err)
lease = LeaseTarget{LeaseID: fs.Arg(0)}
} else {
return err
}
}
if lease.SSH.Host != "" {
a.writeActionsHydrationStopBestEffort(ctx, lease.SSH, lease.LeaseID)
}
if err := sshBackend.ReleaseLease(ctx, ReleaseLeaseRequest{Lease: lease, Force: true}); err != nil {
return err
}
removeLeaseClaim(leaseID)
if backendCoordinator(backend) != nil {
fmt.Fprintf(a.Stderr, "released lease=%s server=%s\n", lease.LeaseID, lease.Server.DisplayID())
return nil
}
if isStaticProvider(cfg.Provider) || lease.Server.Provider == staticProvider {
fmt.Fprintf(a.Stderr, "released static lease=%s host=%s\n", lease.LeaseID, lease.SSH.Host)
return nil
}
fmt.Fprintf(a.Stderr, "deleted lease=%s server=%s name=%s\n", lease.LeaseID, lease.Server.DisplayID(), lease.Server.Name)
return nil
}

View File

@ -1,9 +1,7 @@
package cli
import (
"context"
"flag"
"fmt"
"strings"
"time"
)
@ -53,30 +51,6 @@ func applyTargetFlagOverrides(cfg *Config, fs *flag.FlagSet, values targetFlagVa
return validateTargetConfig(*cfg)
}
func (a App) acquireStatic(ctx context.Context, cfg Config, keep bool) (Server, SSHTarget, string, error) {
server, target, leaseID, err := staticLease(cfg)
if err != nil {
return Server{}, SSHTarget{}, "", err
}
fmt.Fprintf(a.Stderr, "using static target lease=%s slug=%s target=%s windows_mode=%s host=%s keep=%v\n", leaseID, serverSlug(server), cfg.TargetOS, cfg.WindowsMode, target.Host, keep)
if err := waitForSSH(ctx, &target, a.Stderr); err != nil {
return Server{}, SSHTarget{}, "", err
}
server.Labels["state"] = "ready"
return server, target, leaseID, nil
}
func (a App) findStaticLease(_ context.Context, cfg Config, id string) (Server, SSHTarget, string, error) {
server, target, leaseID, err := staticLease(cfg)
if err != nil {
return Server{}, SSHTarget{}, "", err
}
if id == "" || id == leaseID || id == server.Name || id == serverSlug(server) || id == cfg.Static.Host {
return server, target, leaseID, nil
}
return Server{}, SSHTarget{}, "", exit(4, "static lease not found: %s", id)
}
func staticLease(cfg Config) (Server, SSHTarget, string, error) {
if cfg.Static.Host == "" {
return Server{}, SSHTarget{}, "", exit(2, "provider=%s requires static.host or CRABBOX_STATIC_HOST", cfg.Provider)

View File

@ -15,6 +15,7 @@ func (a App) status(ctx context.Context, args []string) error {
wait := fs.Bool("wait", false, "wait until ready")
waitTimeout := fs.Duration("wait-timeout", 5*time.Minute, "maximum wait duration")
jsonOut := fs.Bool("json", false, "print JSON")
providerFlags := registerProviderFlags(fs, defaults)
targetFlags := registerTargetFlags(fs, defaults)
networkFlags := registerNetworkModeFlag(fs, defaults)
if err := parseFlags(fs, args); err != nil {
@ -25,21 +26,42 @@ func (a App) status(ctx context.Context, args []string) error {
if err != nil {
return err
}
if err := applyProviderFlags(&cfg, fs, providerFlags); err != nil {
return err
}
if err := requireLeaseID(*id, "crabbox status --id <lease-id-or-slug>", cfg); err != nil {
return err
}
if isBlacksmithProvider(cfg.Provider) {
return a.blacksmithStatus(ctx, cfg, *id, *wait, *waitTimeout, *jsonOut)
backend, err := loadBackend(cfg, runtimeForApp(a))
if err != nil {
return err
}
delegated, isDelegated := backend.(DelegatedRunBackend)
sshBackend, isSSH := backend.(SSHLeaseBackend)
deadline := time.Now().Add(*waitTimeout)
for {
state, err := a.leaseStatus(ctx, cfg, *id)
var state statusView
var err error
if isDelegated {
state, err = delegated.Status(ctx, StatusRequest{Options: leaseOptionsFromConfig(cfg), ID: *id, Wait: *wait, WaitTimeout: *waitTimeout})
} else if isSSH {
var lease LeaseTarget
lease, err = sshBackend.Resolve(ctx, ResolveRequest{Options: leaseOptionsFromConfig(cfg), ID: *id})
if err == nil {
state, err = statusViewFromLeaseTarget(ctx, cfg, lease)
if err == nil && *wait {
_, touchErr := sshBackend.Touch(ctx, TouchRequest{Lease: lease, State: state.State, IdleTimeout: cfg.IdleTimeout})
if touchErr != nil {
fmt.Fprintf(a.Stderr, "warning: touch failed for %s: %v\n", lease.LeaseID, touchErr)
}
}
}
} else {
state, err = a.leaseStatus(ctx, cfg, *id)
}
if err != nil {
return err
}
if *wait {
a.touchLeaseBestEffort(ctx, cfg, *id, state.ID)
}
if *jsonOut {
if !*wait || state.Ready {
return json.NewEncoder(a.Stdout).Encode(state)
@ -61,6 +83,48 @@ func (a App) status(ctx context.Context, args []string) error {
}
}
func statusViewFromLeaseTarget(ctx context.Context, cfg Config, lease LeaseTarget) (statusView, error) {
server := lease.Server
target := lease.SSH
hasHost := server.PublicNet.IPv4.IP != ""
resolved, err := resolveNetworkTarget(ctx, cfg, server, target)
if err != nil {
return statusView{}, err
}
target = resolved.Target
ready := hasHost && blank(server.Labels["state"], server.Status) != "provisioning" && probeSSHReady(ctx, &target, 4*time.Second)
meta := serverTailscaleMetadata(server)
var tailscale *TailscaleMetadata
if meta.Enabled {
tailscale = &meta
}
return statusView{
ID: lease.LeaseID,
Slug: serverSlug(server),
Provider: blank(server.Provider, cfg.Provider),
TargetOS: blank(server.Labels["target"], cfg.TargetOS),
WindowsMode: blank(server.Labels["windows_mode"], cfg.WindowsMode),
State: blank(server.Labels["state"], server.Status),
ServerID: server.DisplayID(),
ServerType: server.ServerType.Name,
Host: server.PublicNet.IPv4.IP,
Network: resolved.Network,
Tailscale: tailscale,
SSHHost: target.Host,
SSHUser: target.User,
SSHPort: target.Port,
SSHFallbackPorts: target.FallbackPorts,
SSHKey: target.Key,
LastTouchedAt: blank(leaseLabelTimeDisplay(server.Labels["last_touched_at"]), server.Labels["last_touched_at"]),
IdleFor: idleForString(server.Labels["last_touched_at"], time.Now()),
IdleTimeout: leaseLabelDurationDisplay(server.Labels["idle_timeout_secs"], server.Labels["idle_timeout"]),
ExpiresAt: blank(leaseLabelTimeDisplay(server.Labels["expires_at"]), server.Labels["expires_at"]),
Labels: server.Labels,
HasHost: hasHost,
Ready: ready,
}, nil
}
type statusView struct {
ID string `json:"id"`
Slug string `json:"slug,omitempty"`
@ -88,102 +152,38 @@ type statusView struct {
}
func (a App) leaseStatus(ctx context.Context, cfg Config, id string) (statusView, error) {
if coord, ok, err := newTargetCoordinatorClient(cfg); err != nil {
return statusView{}, err
} else if ok {
lease, err := coord.GetLease(ctx, id)
if err != nil {
return statusView{}, err
}
server, target, _ := leaseToServerTarget(lease, cfg)
resolved, err := resolveNetworkTarget(ctx, cfg, server, target)
if err != nil {
return statusView{}, err
}
target = resolved.Target
hasHost := lease.Host != ""
ready := lease.State == "active" && hasHost && probeSSHReady(ctx, &target, 4*time.Second)
return statusView{
ID: lease.ID,
Slug: lease.Slug,
Provider: blank(lease.Provider, cfg.Provider),
TargetOS: blank(target.TargetOS, cfg.TargetOS),
WindowsMode: blank(target.WindowsMode, cfg.WindowsMode),
State: lease.State,
ServerID: leaseDisplayID(lease),
ServerType: lease.ServerType,
Host: lease.Host,
Network: resolved.Network,
Tailscale: lease.Tailscale,
SSHHost: target.Host,
SSHUser: target.User,
SSHPort: target.Port,
SSHFallbackPorts: target.FallbackPorts,
SSHKey: target.Key,
LastTouchedAt: lease.LastTouchedAt,
IdleFor: idleForString(lease.LastTouchedAt, time.Now()),
IdleTimeout: formatSecondsDuration(lease.IdleTimeoutSeconds),
ExpiresAt: lease.ExpiresAt,
Labels: map[string]string{"keep": fmt.Sprint(lease.Keep)},
HasHost: hasHost,
Ready: ready,
}, nil
}
server, target, leaseID, err := a.findLease(ctx, cfg, id)
backend, err := loadBackend(cfg, runtimeForApp(a))
if err != nil {
return statusView{}, err
}
hasHost := server.PublicNet.IPv4.IP != ""
resolved, err := resolveNetworkTarget(ctx, cfg, server, target)
if delegated, ok := backend.(DelegatedRunBackend); ok {
return delegated.Status(ctx, StatusRequest{Options: leaseOptionsFromConfig(cfg), ID: id})
}
sshBackend, ok := backend.(SSHLeaseBackend)
if !ok {
return statusView{}, exit(2, "provider=%s does not support status", backend.Spec().Name)
}
lease, err := sshBackend.Resolve(ctx, ResolveRequest{Options: leaseOptionsFromConfig(cfg), ID: id})
if err != nil {
return statusView{}, err
}
target = resolved.Target
ready := hasHost && server.Labels["state"] != "provisioning" && probeSSHReady(ctx, &target, 4*time.Second)
meta := serverTailscaleMetadata(server)
var tailscale *TailscaleMetadata
if meta.Enabled {
tailscale = &meta
}
return statusView{
ID: leaseID,
Slug: serverSlug(server),
Provider: blank(server.Provider, cfg.Provider),
TargetOS: blank(server.Labels["target"], cfg.TargetOS),
WindowsMode: blank(server.Labels["windows_mode"], cfg.WindowsMode),
State: blank(server.Labels["state"], server.Status),
ServerID: server.DisplayID(),
ServerType: server.ServerType.Name,
Host: server.PublicNet.IPv4.IP,
Network: resolved.Network,
Tailscale: tailscale,
SSHHost: target.Host,
SSHUser: target.User,
SSHPort: target.Port,
SSHFallbackPorts: target.FallbackPorts,
SSHKey: target.Key,
LastTouchedAt: blank(leaseLabelTimeDisplay(server.Labels["last_touched_at"]), server.Labels["last_touched_at"]),
IdleFor: idleForString(server.Labels["last_touched_at"], time.Now()),
IdleTimeout: leaseLabelDurationDisplay(server.Labels["idle_timeout_secs"], server.Labels["idle_timeout"]),
ExpiresAt: blank(leaseLabelTimeDisplay(server.Labels["expires_at"]), server.Labels["expires_at"]),
Labels: server.Labels,
HasHost: hasHost,
Ready: ready,
}, nil
return statusViewFromLeaseTarget(ctx, cfg, lease)
}
func (a App) resolveLeaseTarget(ctx context.Context, cfg Config, id string) (Server, SSHTarget, string, error) {
if coord, ok, err := newTargetCoordinatorClient(cfg); err != nil {
backend, err := loadBackend(cfg, runtimeForApp(a))
if err != nil {
return Server{}, SSHTarget{}, "", err
} else if ok {
lease, err := coord.GetLease(ctx, id)
if err != nil {
return Server{}, SSHTarget{}, "", err
}
server, target, leaseID := leaseToServerTarget(lease, cfg)
return server, target, leaseID, nil
}
return a.findLease(ctx, cfg, id)
sshBackend, ok := backend.(SSHLeaseBackend)
if !ok {
return Server{}, SSHTarget{}, "", exit(2, "provider=%s does not expose an SSH target", backend.Spec().Name)
}
lease, err := sshBackend.Resolve(ctx, ResolveRequest{Options: leaseOptionsFromConfig(cfg), ID: id})
if err != nil {
return Server{}, SSHTarget{}, "", err
}
return lease.Server, lease.SSH, lease.LeaseID, nil
}
func idleForString(value string, now time.Time) string {

View File

@ -100,14 +100,12 @@ func validateTargetConfig(cfg Config) error {
}
func validateProviderTarget(cfg Config) error {
if isStaticProvider(cfg.Provider) || isBlacksmithProvider(cfg.Provider) {
return nil
provider, err := ProviderFor(cfg.Provider)
if err != nil {
return err
}
if cfg.Provider == "aws" && cfg.TargetOS == targetWindows && cfg.WindowsMode == windowsModeNormal {
return nil
}
if cfg.Provider == "aws" && cfg.TargetOS == targetWindows && cfg.WindowsMode == windowsModeWSL2 {
return nil
if !providerSpecSupportsTarget(provider.Spec(), cfg.TargetOS, cfg.WindowsMode) {
return exit(2, "%s", unsupportedManagedTargetMessage(provider.Name(), cfg.TargetOS))
}
if cfg.Provider == "aws" && cfg.TargetOS == targetMacOS {
if cfg.AWSMacHostID == "" && cfg.Coordinator == "" {
@ -118,12 +116,22 @@ func validateProviderTarget(cfg Config) error {
}
return nil
}
if cfg.TargetOS != targetLinux {
return exit(2, "%s", unsupportedManagedTargetMessage(cfg.Provider, cfg.TargetOS))
}
return nil
}
func providerSpecSupportsTarget(spec ProviderSpec, targetOS, windowsMode string) bool {
for _, target := range spec.Targets {
if target.OS != targetOS {
continue
}
if targetOS == targetWindows && target.WindowsMode != "" && target.WindowsMode != windowsMode {
continue
}
return true
}
return false
}
func unsupportedManagedTargetMessage(provider, target string) string {
switch target {
case targetWindows:

View File

@ -0,0 +1,8 @@
package all
import (
_ "github.com/openclaw/crabbox/internal/providers/aws"
_ "github.com/openclaw/crabbox/internal/providers/blacksmith"
_ "github.com/openclaw/crabbox/internal/providers/hetzner"
_ "github.com/openclaw/crabbox/internal/providers/ssh"
)

View File

@ -0,0 +1,37 @@
package aws
import (
"flag"
"github.com/openclaw/crabbox/internal/cli"
)
func init() {
cli.RegisterProvider(Provider{})
}
type Provider struct{}
func (Provider) Name() string { return "aws" }
func (Provider) Aliases() []string { return nil }
func (Provider) Spec() cli.ProviderSpec {
return cli.ProviderSpec{
Name: "aws",
Kind: cli.ProviderKindSSHLease,
Targets: []cli.TargetSpec{
{OS: "linux"},
{OS: "windows", WindowsMode: "normal"},
{OS: "windows", WindowsMode: "wsl2"},
{OS: "macos"},
},
Features: cli.FeatureSet{cli.FeatureSSH, cli.FeatureCrabboxSync, cli.FeatureCleanup, cli.FeatureDesktop, cli.FeatureBrowser, cli.FeatureCode},
Coordinator: cli.CoordinatorSupported,
}
}
func (Provider) RegisterFlags(*flag.FlagSet, cli.Config) any { return cli.NoProviderFlags() }
func (Provider) ApplyFlags(*cli.Config, *flag.FlagSet, any) error {
return nil
}
func (p Provider) Configure(cfg cli.Config, rt cli.Runtime) (cli.Backend, error) {
return cli.NewAWSLeaseBackend(p.Spec(), cfg, rt), nil
}

View File

@ -0,0 +1,36 @@
package blacksmith
import (
"flag"
"github.com/openclaw/crabbox/internal/cli"
)
func init() {
cli.RegisterProvider(Provider{})
}
type Provider struct{}
func (Provider) Name() string { return "blacksmith-testbox" }
func (Provider) Aliases() []string {
return []string{"blacksmith"}
}
func (Provider) Spec() cli.ProviderSpec {
return cli.ProviderSpec{
Name: "blacksmith-testbox",
Kind: cli.ProviderKindDelegatedRun,
Targets: []cli.TargetSpec{{OS: "linux"}},
Features: nil,
Coordinator: cli.CoordinatorNever,
}
}
func (Provider) RegisterFlags(fs *flag.FlagSet, defaults cli.Config) any {
return cli.RegisterBlacksmithProviderFlags(fs, defaults)
}
func (Provider) ApplyFlags(cfg *cli.Config, fs *flag.FlagSet, values any) error {
return cli.ApplyBlacksmithProviderFlags(cfg, fs, values)
}
func (p Provider) Configure(cfg cli.Config, rt cli.Runtime) (cli.Backend, error) {
return cli.NewBlacksmithBackend(p.Spec(), cfg, rt), nil
}

View File

@ -0,0 +1,32 @@
package hetzner
import (
"flag"
"github.com/openclaw/crabbox/internal/cli"
)
func init() {
cli.RegisterProvider(Provider{})
}
type Provider struct{}
func (Provider) Name() string { return "hetzner" }
func (Provider) Aliases() []string { return nil }
func (Provider) Spec() cli.ProviderSpec {
return cli.ProviderSpec{
Name: "hetzner",
Kind: cli.ProviderKindSSHLease,
Targets: []cli.TargetSpec{{OS: "linux"}},
Features: cli.FeatureSet{cli.FeatureSSH, cli.FeatureCrabboxSync, cli.FeatureCleanup, cli.FeatureDesktop, cli.FeatureBrowser, cli.FeatureCode, cli.FeatureTailscale},
Coordinator: cli.CoordinatorSupported,
}
}
func (Provider) RegisterFlags(*flag.FlagSet, cli.Config) any { return cli.NoProviderFlags() }
func (Provider) ApplyFlags(*cli.Config, *flag.FlagSet, any) error {
return nil
}
func (p Provider) Configure(cfg cli.Config, rt cli.Runtime) (cli.Backend, error) {
return cli.NewHetznerLeaseBackend(p.Spec(), cfg, rt), nil
}

View File

@ -0,0 +1,39 @@
package ssh
import (
"flag"
"github.com/openclaw/crabbox/internal/cli"
)
func init() {
cli.RegisterProvider(Provider{})
}
type Provider struct{}
func (Provider) Name() string { return "ssh" }
func (Provider) Aliases() []string {
return []string{"static", "static-ssh"}
}
func (Provider) Spec() cli.ProviderSpec {
return cli.ProviderSpec{
Name: "ssh",
Kind: cli.ProviderKindSSHLease,
Targets: []cli.TargetSpec{
{OS: "linux"},
{OS: "windows", WindowsMode: "normal"},
{OS: "windows", WindowsMode: "wsl2"},
{OS: "macos"},
},
Features: cli.FeatureSet{cli.FeatureSSH, cli.FeatureCrabboxSync, cli.FeatureDesktop, cli.FeatureBrowser, cli.FeatureCode},
Coordinator: cli.CoordinatorNever,
}
}
func (Provider) RegisterFlags(*flag.FlagSet, cli.Config) any { return cli.NoProviderFlags() }
func (Provider) ApplyFlags(*cli.Config, *flag.FlagSet, any) error {
return nil
}
func (p Provider) Configure(cfg cli.Config, rt cli.Runtime) (cli.Backend, error) {
return cli.NewStaticSSHLeaseBackend(p.Spec(), cfg, rt), nil
}