refactor: split provider backends

This commit is contained in:
Peter Steinberger 2026-05-06 09:03:03 +01:00
parent ffb14bf5ba
commit 379b4f4faf
No known key found for this signature in database
57 changed files with 1981 additions and 787 deletions

View File

@ -20,6 +20,10 @@
- Added `provider: daytona` for Daytona sandbox leases using Daytona's SDK/toolbox for sync and command execution, with short-lived SSH access available through `crabbox ssh`.
- Added `provider: islo` for delegated Islo sandbox runs using the Islo Go SDK.
### Changed
- Refactored built-in provider backend implementations into `internal/providers/<name>` packages while keeping command orchestration and rendering core-owned.
### Fixed
- Fixed managed Linux desktop/browser leases to preinstall video capture and native addon build helpers, avoiding per-scenario apt installs in browser QA runs.
@ -41,7 +45,7 @@
- Fixed managed Linux browser setup so Chrome/Chromium launches skip first-run and default-browser prompts.
- Fixed managed Linux browser cloud-init setup so Chrome/Chromium policy and wrapper generation cannot break YAML parsing.
- Fixed Islo delegated runs so shell-mode commands preserve raw shell strings and truncated exec streams fail instead of silently reporting success.
- Fixed Daytona SDK sync so the tar archive uploads from a temp file instead of buffering the whole archive in memory.
- Fixed Daytona SDK sync so tar creation and Daytona toolbox upload stream from disk instead of buffering large archives in memory.
- Fixed WebVNC portal passwords with escaped special characters and kept the bridge alive across viewer resets and transient coordinator EOFs.
- Fixed managed AWS Windows WSL2 bootstrap by using the current Ubuntu WSL rootfs URL, downloading large rootfs files through `curl.exe`, and retrying empty or partial rootfs downloads instead of reusing a poisoned tarball. Thanks @vincentkoc.
- Fixed AWS Windows WSL2 mode overrides so they refresh the default instance type to a nested-virtualization-capable family. Thanks @steipete.

View File

@ -84,30 +84,23 @@ type DelegatedRunBackend interface {
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)
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>`.
Delegated backends return normalized `StatusView` values. Rendering remains
core-owned, so provider packages should not print their own `status` or `list`
tables unless a compatibility interface explicitly asks for native output.
A delegated backend must reject sync-only options that Crabbox cannot honor:
```go
if err := rejectDelegatedSyncOptions(providerName, req); err != nil {
if err := cli.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`.
@ -158,7 +151,8 @@ internal/providers/islo
```
Each provider package owns registration, provider name, aliases, spec,
provider-specific flags, and backend configuration. `cmd/crabbox` imports
provider-specific flags, backend configuration, provider clients, provider
lifecycle code, and provider-specific tests. `cmd/crabbox` imports
`internal/providers/all` for side-effect registration:
```go
@ -168,35 +162,26 @@ import (
)
```
The core provider contract and current backend implementations live in
`internal/cli`:
The core provider contract remains in `internal/cli`; built-in implementations
live in their provider folders:
```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
internal/cli/provider_daytona.go # Daytona SSH access backend implementation
internal/cli/provider_daytona_delegated.go # Daytona SDK/toolbox run backend
internal/cli/provider_islo.go # Islo delegated backend implementation
internal/cli/provider_backend.go # interfaces, registry, request/result types
internal/cli/provider_coordinator.go # brokered coordinator lease wrapper
internal/cli/provider_labels.go # shared direct-provider label helpers
internal/providers/shared # shared direct SSH retry/touch/cleanup helpers
internal/providers/aws # AWS SSH lease backend
internal/providers/hetzner # Hetzner SSH lease backend
internal/providers/ssh # static SSH backend
internal/providers/blacksmith # Blacksmith delegated backend
internal/providers/daytona # Daytona SSH + delegated SDK backend
internal/providers/islo # Islo delegated 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 packages may use small exported core helpers for claims, labels,
sync preflight, timing JSON, and SSH key storage. Keep that helper surface
narrow: if a provider needs broad command orchestration, the behavior probably
belongs in core instead.
## Provider Registration

View File

@ -193,7 +193,7 @@ type DelegatedRunBackend interface {
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)
Status(ctx context.Context, req StatusRequest) (StatusView, error)
Stop(ctx context.Context, req StopRequest) error
}
```
@ -203,10 +203,7 @@ remote command wrapping for these providers. Delegated providers may stream
stdout/stderr during `Run`, but they should not own normal `list` or `status`
rendering when a normalized value can describe the result. If a provider has a
lossy or native-only status shape, keep that loss inside its backend and return
the closest status view instead of printing directly from command code. The
current implementation still uses unexported `statusView`; exporting
`StatusView` is a follow-up before delegated backend implementations can move
fully out of `internal/cli`.
the closest status view instead of printing directly from command code.
### Optional Backend Interfaces
@ -417,30 +414,28 @@ internal/providers/hetzner # Hetzner provider registration/spec
internal/providers/aws # AWS provider registration/spec
internal/providers/ssh # static SSH provider registration/spec
internal/providers/blacksmith # Blacksmith provider registration/spec
internal/providers/daytona # Daytona provider registration/spec
internal/providers/islo # Islo provider registration/spec
internal/cli/provider_backend.go # core interfaces, registry, requests
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 # delegated Blacksmith backend implementation
internal/providers/shared # shared direct SSH backend helpers
internal/providers/aws/backend.go # AWS SSH lease backend implementation
internal/providers/hetzner/backend.go # Hetzner SSH lease backend implementation
internal/providers/ssh/backend.go # static SSH lease backend implementation
internal/providers/blacksmith # delegated Blacksmith backend implementation
internal/providers/daytona # Daytona SSH + delegated SDK backend
internal/providers/islo # Islo delegated backend implementation
internal/cli/hcloud.go # Hetzner API client
internal/cli/aws.go # AWS API client
internal/cli/static.go # static SSH target mapping and flags
internal/cli/blacksmith.go # Blacksmith args/parsing helpers
```
The first split keeps backend implementations in `internal/cli` because the
existing providers still use broad unexported lifecycle helpers for SSH keys,
claims, labels, slugs, coordinator heartbeats, sync, release, and timing. The
exported contract between provider folders and CLI is deliberately small:
`Provider`, `ProviderSpec`, request/result types, `Runtime`, and one backend
constructor per built-in provider.
Move each backend implementation deeper into `internal/providers/<name>` only
as the required helper surface becomes intentionally exported. New providers
such as Daytona and Islo should start in their own provider folder and avoid
depending on CLI internals that are not part of that exported contract.
The backend implementations now live with their provider packages. The exported
contract between provider folders and CLI stays deliberately small: `Provider`,
`ProviderSpec`, request/result/view types, `Runtime`, and narrow helpers for
claims, labels, sync preflight, SSH key storage, and timing output. Keep command
orchestration in `internal/cli`; move provider lifecycle/client code into the
provider folder.
## Flag Parsing
@ -736,8 +731,8 @@ SSH workflow consumes sync, result, and environment options. After the provider
split lands, consider splitting this into `ProvisionOptions` and `RunOptions`.
`LeaseView` and `StatusView` are command-facing view models. They can wrap or
alias the existing `Server` and `statusView` during migration, but they must
carry redaction metadata for secret-bearing auth. Rendering is core-owned for
alias existing core structs during migration, but they must carry redaction
metadata for secret-bearing auth. Rendering is core-owned for
both backend kinds: `ListRequest` and `StatusRequest` do not carry JSON or human
format flags because backends return normalized views and core renders them.
`JSONListBackend` is a narrow compatibility escape hatch for existing

View File

@ -33,12 +33,12 @@ This page maps user-facing behavior back to implementation files. Keep docs desc
## Providers And Runner Bootstrap
- 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 argument/parsing helpers: `internal/cli/blacksmith.go`
- Daytona provider backend and SDK/toolbox wrapper: `internal/cli/provider_daytona.go`, `internal/cli/provider_daytona_delegated.go`, `internal/providers/daytona`
- Islo delegated backend and SDK wrapper: `internal/cli/provider_islo.go`, `internal/providers/islo`
- Direct Hetzner provider: `internal/providers/hetzner`, with API client helpers in `internal/cli/hcloud.go`
- Direct AWS provider: `internal/providers/aws`, with API client helpers in `internal/cli/aws.go`
- Static SSH macOS/Windows provider: `internal/providers/ssh`, with target mapping helpers in `internal/cli/static.go`
- Blacksmith Testbox backend and argument/parsing helpers: `internal/providers/blacksmith`
- Daytona provider backend and SDK/toolbox wrapper: `internal/providers/daytona`
- Islo delegated backend and SDK wrapper: `internal/providers/islo`
- Provider backend interfaces, registry, and request/result types:
`internal/cli/provider_backend.go`
- Built-in provider registration packages:
@ -47,10 +47,10 @@ This page maps user-facing behavior back to implementation files. Keep docs desc
`internal/providers/daytona`, `internal/providers/islo`,
`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`,
`internal/cli/provider_daytona.go`, `internal/cli/provider_islo.go`
`internal/providers/aws`, `internal/providers/hetzner`,
`internal/providers/ssh`, `internal/providers/blacksmith`,
`internal/providers/daytona`, `internal/providers/islo`,
plus shared helpers in `internal/providers/shared`
- 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`

View File

@ -33,6 +33,10 @@ func newAWSClient(ctx context.Context, cfg Config) (*AWSClient, error) {
return &AWSClient{ec2: ec2.NewFromConfig(awsCfg), region: cfg.AWSRegion}, nil
}
func NewAWSClient(ctx context.Context, cfg Config) (*AWSClient, error) {
return newAWSClient(ctx, cfg)
}
func (c *AWSClient) SpotPlacementScores(ctx context.Context, cfg Config) ([]types.SpotPlacementScore, error) {
regions := cfg.Capacity.Regions
if len(regions) == 0 && cfg.AWSRegion != "" {
@ -285,6 +289,10 @@ func (c *AWSClient) waitForServerIP(ctx context.Context, id string) (Server, err
}
}
func (c *AWSClient) WaitForServerIP(ctx context.Context, id string) (Server, error) {
return c.waitForServerIP(ctx, id)
}
func (c *AWSClient) GetServer(ctx context.Context, id string) (Server, error) {
out, err := c.ec2.DescribeInstances(ctx, &ec2.DescribeInstancesInput{
InstanceIds: []string{id},

View File

@ -20,6 +20,10 @@ func ensureAWSSSHCIDRs(ctx context.Context, cfg *Config) {
cfg.AWSSSHCIDRs = []string{cidr}
}
func EnsureAWSSSHCIDRs(ctx context.Context, cfg *Config) {
ensureAWSSSHCIDRs(ctx, cfg)
}
func detectOutboundIPv4CIDR(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()

View File

@ -36,6 +36,10 @@ exit $LASTEXITCODE`)
return waitForSSHReady(ctx, target, stderr, "bootstrap", bootstrapWaitTimeout(cfg))
}
func BootstrapAWSWindowsDesktop(ctx context.Context, cfg Config, target *SSHTarget, publicKey string, stderr io.Writer) error {
return bootstrapAWSWindowsDesktop(ctx, cfg, target, publicKey, stderr)
}
func bootstrapAWSWindowsWSL2(ctx context.Context, cfg Config, target *SSHTarget, bootstrapTarget SSHTarget, publicKey string, stderr io.Writer) error {
for attempt := 1; attempt <= 5; attempt++ {
if err := waitForSSHReady(ctx, &bootstrapTarget, stderr, "windows openssh", 20*time.Minute); err != nil {

View File

@ -19,6 +19,8 @@ type leaseClaim struct {
IdleTimeoutSeconds int `json:"idleTimeoutSeconds,omitempty"`
}
type LeaseClaim = leaseClaim
func claimLeaseForRepo(leaseID, slug, repoRoot string, idleTimeout time.Duration, reclaim bool) error {
return claimLeaseForRepoProvider(leaseID, slug, "", repoRoot, idleTimeout, reclaim)
}
@ -74,6 +76,10 @@ func claimLeaseForRepoProvider(leaseID, slug, provider, repoRoot string, idleTim
return nil
}
func ClaimLeaseForRepoProvider(leaseID, slug, provider, repoRoot string, idleTimeout time.Duration, reclaim bool) error {
return claimLeaseForRepoProvider(leaseID, slug, provider, repoRoot, idleTimeout, reclaim)
}
func resolveLeaseClaim(identifier string) (leaseClaim, bool, error) {
if identifier == "" {
return leaseClaim{}, false, nil
@ -111,6 +117,10 @@ func resolveLeaseClaim(identifier string) (leaseClaim, bool, error) {
return leaseClaim{}, false, nil
}
func ResolveLeaseClaim(identifier string) (LeaseClaim, bool, error) {
return resolveLeaseClaim(identifier)
}
func removeLeaseClaim(leaseID string) {
path, err := leaseClaimPath(leaseID)
if err == nil {
@ -118,6 +128,10 @@ func removeLeaseClaim(leaseID string) {
}
}
func RemoveLeaseClaim(leaseID string) {
removeLeaseClaim(leaseID)
}
func readLeaseClaim(leaseID string) (leaseClaim, error) {
path, err := leaseClaimPath(leaseID)
if err != nil {
@ -137,6 +151,10 @@ func readLeaseClaim(leaseID string) (leaseClaim, error) {
return claim, nil
}
func ReadLeaseClaim(leaseID string) (LeaseClaim, error) {
return readLeaseClaim(leaseID)
}
func leaseClaimPath(leaseID string) (string, error) {
dir, err := crabboxStateDir()
if err != nil {

View File

@ -264,6 +264,10 @@ func baseConfig() Config {
}
}
func BaseConfig() Config {
return baseConfig()
}
type fileConfig struct {
Profile string `yaml:"profile,omitempty"`
Provider string `yaml:"provider,omitempty"`
@ -1104,6 +1108,10 @@ func serverTypeForProviderClass(provider, class string) string {
return serverTypeForClass(class)
}
func ServerTypeForProviderClass(provider, class string) string {
return serverTypeForProviderClass(provider, class)
}
func serverTypeCandidatesForClass(class string) []string {
switch class {
case "standard":

View File

@ -225,3 +225,7 @@ func blank(value, fallback string) string {
}
return value
}
func Blank(value, fallback string) string {
return blank(value, fallback)
}

View File

@ -18,3 +18,7 @@ func AsExitError(err error, target *ExitError) bool {
func exit(code int, format string, args ...any) ExitError {
return ExitError{Code: code, Message: sprintf(format, args...)}
}
func Exit(code int, format string, args ...any) ExitError {
return exit(code, format, args...)
}

View File

@ -61,6 +61,10 @@ func newHetznerClient() (*HetznerClient, error) {
return &HetznerClient{Token: token, Client: &http.Client{Timeout: 60 * time.Second}}, nil
}
func NewHetznerClient() (*HetznerClient, error) {
return newHetznerClient()
}
func (c *HetznerClient) do(ctx context.Context, method, path string, body any, out any) error {
var r io.Reader
if body != nil {
@ -277,3 +281,7 @@ func summarizeJSON(data []byte) string {
}
return s
}
func SummarizeJSON(data []byte) string {
return summarizeJSON(data)
}

View File

@ -19,6 +19,10 @@ func newLeaseID() string {
return "cbx_" + hex.EncodeToString(b[:])
}
func NewLeaseID() string {
return newLeaseID()
}
func publicKeyFor(privatePath string) (string, error) {
pub := privatePath + ".pub"
data, err := os.ReadFile(pub)
@ -40,10 +44,18 @@ func testboxKeyPath(leaseID string) (string, error) {
return filepath.Join(dir, "crabbox", "testboxes", leaseID, "id_ed25519"), nil
}
func TestboxKeyPath(leaseID string) (string, error) {
return testboxKeyPath(leaseID)
}
func ensureTestboxKey(leaseID string) (string, string, error) {
return ensureTestboxKeyWithType(leaseID, "ed25519")
}
func EnsureTestboxKey(leaseID string) (string, string, error) {
return ensureTestboxKey(leaseID)
}
func ensureTestboxKeyForConfig(cfg Config, leaseID string) (string, string, error) {
if cfg.Provider == "aws" && cfg.TargetOS == targetWindows {
return ensureTestboxKeyWithType(leaseID, "rsa")
@ -51,6 +63,10 @@ func ensureTestboxKeyForConfig(cfg Config, leaseID string) (string, string, erro
return ensureTestboxKey(leaseID)
}
func EnsureTestboxKeyForConfig(cfg Config, leaseID string) (string, string, error) {
return ensureTestboxKeyForConfig(cfg, leaseID)
}
func ensureTestboxKeyWithType(leaseID, keyType string) (string, string, error) {
privatePath, err := testboxKeyPath(leaseID)
if err != nil {
@ -114,6 +130,10 @@ func moveStoredTestboxKey(oldLeaseID, newLeaseID string) error {
return os.Rename(oldDir, newDir)
}
func MoveStoredTestboxKey(oldLeaseID, newLeaseID string) error {
return moveStoredTestboxKey(oldLeaseID, newLeaseID)
}
func removeStoredTestboxKey(leaseID string) {
keyPath, err := testboxKeyPath(leaseID)
if err == nil {
@ -121,6 +141,14 @@ func removeStoredTestboxKey(leaseID string) {
}
}
func RemoveStoredTestboxKey(leaseID string) {
removeStoredTestboxKey(leaseID)
}
func providerKeyForLease(leaseID string) string {
return strings.ReplaceAll("crabbox-"+leaseID, "_", "-")
}
func ProviderKeyForLease(leaseID string) string {
return providerKeyForLease(leaseID)
}

View File

@ -150,6 +150,10 @@ func shouldCleanupServer(server Server, now time.Time) (bool, string) {
return true, "expired"
}
func ShouldCleanupServer(server Server, now time.Time) (bool, string) {
return shouldCleanupServer(server, now)
}
func cleanupExpiry(labels map[string]string) (time.Time, bool) {
for _, key := range []string{"expires_at", "ttl"} {
value := strings.TrimSpace(labels[key])

View File

@ -1,119 +0,0 @@
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

@ -40,7 +40,7 @@ type DelegatedRunBackend interface {
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)
Status(ctx context.Context, req StatusRequest) (StatusView, error)
Stop(ctx context.Context, req StopRequest) error
}
@ -317,6 +317,10 @@ func providerHelpSSH() string {
return "provider: hetzner, aws, ssh, or daytona"
}
func isBlacksmithProvider(provider string) bool {
return provider == "blacksmith-testbox" || provider == "blacksmith"
}
type providerFlagValues map[string]any
func registerProviderFlags(fs *flag.FlagSet, defaults Config) providerFlagValues {
@ -436,6 +440,10 @@ func rejectDelegatedSyncOptions(provider string, req RunRequest) error {
return nil
}
func RejectDelegatedSyncOptions(provider string, req RunRequest) error {
return rejectDelegatedSyncOptions(provider, req)
}
func renderServerList(stdout io.Writer, servers []Server) {
for _, s := range servers {
extra := ""

View File

@ -3,7 +3,6 @@ package cli
import (
"context"
"io"
"strings"
"testing"
)
@ -102,46 +101,6 @@ func TestValidateRequestedCapabilitiesUsesProviderSpec(t *testing.T) {
}
}
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 TestProviderFlagsApplyDaytonaAndIsloWithoutCoreEdits(t *testing.T) {
defaults := baseConfig()
fs := newFlagSet("test", io.Discard)
@ -185,108 +144,15 @@ func TestProviderFlagsApplyDaytonaAndIsloWithoutCoreEdits(t *testing.T) {
}
}
func TestDaytonaAuthRequiresOrganizationForJWT(t *testing.T) {
cfg := baseConfig()
cfg.Provider = daytonaProvider
cfg.Daytona.APIKey = ""
cfg.Daytona.JWTToken = "jwt"
cfg.Daytona.OrganizationID = ""
_, err := newDaytonaClient(cfg, Runtime{})
if err == nil || !strings.Contains(err.Error(), "DAYTONA_ORGANIZATION_ID") {
t.Fatalf("err=%v, want organization requirement", err)
}
}
func TestDaytonaSSHTargetUsesReturnedSSHCommand(t *testing.T) {
cfg := baseConfig()
cfg.Daytona.SSHGatewayHost = "fallback.example"
target, err := daytonaSSHTargetFromAccess(cfg, daytonaSSHAccess{
Token: "tok_live_secret",
Command: "ssh -p 2222 tok_live_secret@region-ssh.example.com",
})
if err != nil {
t.Fatal(err)
}
if target.User != "tok_live_secret" || target.Host != "region-ssh.example.com" || target.Port != "2222" {
t.Fatalf("target=%#v", target)
}
if target.Key != "" || !target.AuthSecret || target.NetworkKind != NetworkPublic {
t.Fatalf("target auth/network=%#v", target)
}
}
func TestDaytonaSSHTargetFallsBackWhenCommandMissing(t *testing.T) {
cfg := baseConfig()
cfg.Daytona.SSHGatewayHost = "fallback.example"
target, err := daytonaSSHTargetFromAccess(cfg, daytonaSSHAccess{Token: "tok_live_secret"})
if err != nil {
t.Fatal(err)
}
if target.User != "tok_live_secret" || target.Host != "fallback.example" || target.Port != "22" {
t.Fatalf("target=%#v", target)
}
}
func TestDaytonaBackendIsHybridSDKRunAndSSHAccess(t *testing.T) {
backend := NewDaytonaLeaseBackend(ProviderSpec{Name: daytonaProvider}, baseConfig(), Runtime{})
if _, ok := backend.(DelegatedRunBackend); !ok {
t.Fatal("daytona should use delegated SDK run path")
}
if _, ok := backend.(SSHLeaseBackend); !ok {
t.Fatal("daytona should still expose explicit SSH access")
}
}
func TestDaytonaCommandString(t *testing.T) {
if got := daytonaCommandString([]string{"go", "test", "./..."}, false); got != "'go' 'test' './...'" {
t.Fatalf("command=%q", got)
}
if got := daytonaCommandString([]string{"FOO=bar", "go", "test"}, false); !strings.Contains(got, "FOO=") || !strings.Contains(got, "go") {
t.Fatalf("shell command=%q", got)
}
if got := daytonaCommandString([]string{"echo hello && pwd"}, true); got != "echo hello && pwd" {
t.Fatalf("shell mode=%q", got)
}
}
func TestRedactedSSHUserOnlyForDaytona(t *testing.T) {
target := SSHTarget{User: "tok_live_secret"}
if got := redactedSSHUser(Config{Provider: "hetzner"}, Server{Provider: "hetzner"}, target); got != target.User {
t.Fatalf("redactedSSHUser hetzner=%q", got)
}
if got := redactedSSHUser(Config{Provider: "hetzner"}, Server{Provider: "hetzner"}, SSHTarget{User: "secret", AuthSecret: true}); got != daytonaTokenRedacted {
if got := redactedSSHUser(Config{Provider: "hetzner"}, Server{Provider: "hetzner"}, SSHTarget{User: "secret", AuthSecret: true}); got != "<token>" {
t.Fatalf("redactedSSHUser auth secret=%q", got)
}
if got := redactedSSHUser(Config{Provider: daytonaProvider}, Server{}, target); got != daytonaTokenRedacted {
if got := redactedSSHUser(Config{Provider: "daytona"}, Server{}, target); got != "<token>" {
t.Fatalf("redactedSSHUser daytona=%q", got)
}
}
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

@ -1,52 +0,0 @@
package cli
import (
"archive/tar"
"compress/gzip"
"io"
"os"
"path/filepath"
"testing"
)
func TestCreateDaytonaSyncArchiveWritesTempFile(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "hello.txt"), []byte("hello"), 0o600); err != nil {
t.Fatal(err)
}
archive, err := createDaytonaSyncArchive(t.Context(), Repo{Root: root}, SyncManifest{Files: []string{"hello.txt"}, Bytes: 5}, io.Discard)
if err != nil {
t.Fatal(err)
}
defer os.Remove(archive.Name())
defer archive.Close()
info, err := archive.Stat()
if err != nil {
t.Fatal(err)
}
if info.Size() == 0 {
t.Fatal("archive temp file is empty")
}
if _, err := archive.Seek(0, 0); err != nil {
t.Fatal(err)
}
gz, err := gzip.NewReader(archive)
if err != nil {
t.Fatal(err)
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
if header.Name == "hello.txt" {
return
}
}
t.Fatal("archive missing hello.txt")
}

View File

@ -1,124 +0,0 @@
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

@ -53,6 +53,10 @@ func directLeaseLabels(cfg Config, leaseID, slug, provider, market string, keep
return sanitizeProviderLabels(labels)
}
func DirectLeaseLabels(cfg Config, leaseID, slug, provider, market string, keep bool, now time.Time) map[string]string {
return directLeaseLabels(cfg, leaseID, slug, provider, market, keep, now)
}
func touchDirectLeaseLabels(labels map[string]string, cfg Config, state string, now time.Time) map[string]string {
next := make(map[string]string, len(labels)+4)
for key, value := range labels {
@ -90,6 +94,10 @@ func touchDirectLeaseLabels(labels map[string]string, cfg Config, state string,
return sanitizeProviderLabels(next)
}
func TouchDirectLeaseLabels(labels map[string]string, cfg Config, state string, now time.Time) map[string]string {
return touchDirectLeaseLabels(labels, cfg, state, now)
}
func directLeaseExpiresAtFrom(createdAt, lastTouchedAt time.Time, ttl, idleTimeout time.Duration) time.Time {
expiresAt := lastTouchedAt.Add(idleTimeout)
if ttl > 0 {
@ -105,6 +113,10 @@ func leaseLabelTime(t time.Time) string {
return strconv.FormatInt(t.UTC().Unix(), 10)
}
func LeaseLabelTime(t time.Time) string {
return leaseLabelTime(t)
}
func parseLeaseLabelTime(value string) (time.Time, bool) {
value = strings.TrimSpace(value)
if value == "" {
@ -129,6 +141,10 @@ func leaseLabelTimeDisplay(value string) string {
return t.Format(time.RFC3339)
}
func LeaseLabelTimeDisplay(value string) string {
return leaseLabelTimeDisplay(value)
}
func durationSecondsLabel(duration time.Duration) string {
if duration <= 0 {
return ""
@ -160,6 +176,10 @@ func leaseLabelDurationDisplay(secondsValue, fallbackValue string) string {
return ""
}
func LeaseLabelDurationDisplay(secondsValue, fallbackValue string) string {
return leaseLabelDurationDisplay(secondsValue, fallbackValue)
}
func sanitizeProviderLabels(labels map[string]string) map[string]string {
out := make(map[string]string, len(labels))
for key, value := range labels {

View File

@ -1,60 +0,0 @@
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

@ -1,6 +1,9 @@
package cli
import "flag"
import (
"context"
"flag"
)
func init() {
RegisterProvider(testHetznerProvider{})
@ -29,7 +32,7 @@ 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
return testSSHBackend{spec: p.Spec()}, nil
}
type testAWSProvider struct{}
@ -55,7 +58,7 @@ 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
return testSSHBackend{spec: p.Spec()}, nil
}
type testStaticSSHProvider struct{}
@ -83,32 +86,61 @@ 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
return testSSHBackend{spec: p.Spec()}, nil
}
type testBlacksmithProvider struct{}
func (testBlacksmithProvider) Name() string { return blacksmithTestboxProvider }
func (testBlacksmithProvider) Name() string { return "blacksmith-testbox" }
func (testBlacksmithProvider) Aliases() []string {
return []string{"blacksmith"}
}
func (testBlacksmithProvider) Spec() ProviderSpec {
return ProviderSpec{
Name: blacksmithTestboxProvider,
Name: "blacksmith-testbox",
Kind: ProviderKindDelegatedRun,
Targets: []TargetSpec{{OS: targetLinux}},
Features: nil,
Coordinator: CoordinatorNever,
}
}
type testBlacksmithFlagValues struct {
Org *string
Workflow *string
Job *string
Ref *string
}
func (testBlacksmithProvider) RegisterFlags(fs *flag.FlagSet, defaults Config) any {
return RegisterBlacksmithProviderFlags(fs, defaults)
return testBlacksmithFlagValues{
Org: fs.String("blacksmith-org", defaults.Blacksmith.Org, "Blacksmith organization"),
Workflow: fs.String("blacksmith-workflow", defaults.Blacksmith.Workflow, "Blacksmith Testbox workflow file, name, or id"),
Job: fs.String("blacksmith-job", defaults.Blacksmith.Job, "Blacksmith Testbox workflow job"),
Ref: fs.String("blacksmith-ref", defaults.Blacksmith.Ref, "Blacksmith Testbox git ref"),
}
}
func (testBlacksmithProvider) ApplyFlags(cfg *Config, fs *flag.FlagSet, values any) error {
return ApplyBlacksmithProviderFlags(cfg, fs, values)
v, ok := values.(testBlacksmithFlagValues)
if !ok {
return nil
}
if flagWasSet(fs, "blacksmith-org") {
cfg.Blacksmith.Org = *v.Org
}
if flagWasSet(fs, "blacksmith-workflow") {
cfg.Blacksmith.Workflow = *v.Workflow
}
if flagWasSet(fs, "blacksmith-job") {
cfg.Blacksmith.Job = *v.Job
}
if flagWasSet(fs, "blacksmith-ref") {
cfg.Blacksmith.Ref = *v.Ref
}
return nil
}
func (p testBlacksmithProvider) Configure(cfg Config, rt Runtime) (Backend, error) {
return NewBlacksmithBackend(p.Spec(), cfg, rt), nil
return testDelegatedBackend{spec: p.Spec()}, nil
}
type testDaytonaProvider struct{}
@ -117,42 +149,132 @@ func (testDaytonaProvider) Name() string { return "daytona" }
func (testDaytonaProvider) Aliases() []string { return nil }
func (testDaytonaProvider) Spec() ProviderSpec {
return ProviderSpec{
Name: daytonaProvider,
Name: "daytona",
Kind: ProviderKindSSHLease,
Targets: []TargetSpec{{OS: targetLinux}},
Features: FeatureSet{FeatureSSH, FeatureCrabboxSync},
Coordinator: CoordinatorNever,
}
}
type testDaytonaFlagValues struct {
Snapshot *string
Target *string
WorkRoot *string
}
func (testDaytonaProvider) RegisterFlags(fs *flag.FlagSet, defaults Config) any {
return RegisterDaytonaProviderFlags(fs, defaults)
return testDaytonaFlagValues{
Snapshot: fs.String("daytona-snapshot", defaults.Daytona.Snapshot, "Daytona snapshot name"),
Target: fs.String("daytona-target", defaults.Daytona.Target, "Daytona compute target"),
WorkRoot: fs.String("daytona-work-root", defaults.Daytona.WorkRoot, "Daytona sandbox work root"),
}
}
func (testDaytonaProvider) ApplyFlags(cfg *Config, fs *flag.FlagSet, values any) error {
return ApplyDaytonaProviderFlags(cfg, fs, values)
v, ok := values.(testDaytonaFlagValues)
if !ok {
return nil
}
if flagWasSet(fs, "daytona-snapshot") {
cfg.Daytona.Snapshot = *v.Snapshot
}
if flagWasSet(fs, "daytona-target") {
cfg.Daytona.Target = *v.Target
}
if flagWasSet(fs, "daytona-work-root") {
cfg.Daytona.WorkRoot = *v.WorkRoot
}
return nil
}
func (p testDaytonaProvider) Configure(cfg Config, rt Runtime) (Backend, error) {
return NewDaytonaLeaseBackend(p.Spec(), cfg, rt), nil
return testSSHBackend{spec: p.Spec()}, nil
}
type testIsloProvider struct{}
func (testIsloProvider) Name() string { return isloProvider }
func (testIsloProvider) Name() string { return "islo" }
func (testIsloProvider) Aliases() []string { return nil }
func (testIsloProvider) Spec() ProviderSpec {
return ProviderSpec{
Name: isloProvider,
Name: "islo",
Kind: ProviderKindDelegatedRun,
Targets: []TargetSpec{{OS: targetLinux}},
Features: nil,
Coordinator: CoordinatorNever,
}
}
type testIsloFlagValues struct {
Image *string
VCPUs *int
MemoryMB *int
}
func (testIsloProvider) RegisterFlags(fs *flag.FlagSet, defaults Config) any {
return RegisterIsloProviderFlags(fs, defaults)
return testIsloFlagValues{
Image: fs.String("islo-image", defaults.Islo.Image, "Islo sandbox image"),
VCPUs: fs.Int("islo-vcpus", defaults.Islo.VCPUs, "Islo sandbox vCPUs"),
MemoryMB: fs.Int("islo-memory-mb", defaults.Islo.MemoryMB, "Islo sandbox memory in MB"),
}
}
func (testIsloProvider) ApplyFlags(cfg *Config, fs *flag.FlagSet, values any) error {
return ApplyIsloProviderFlags(cfg, fs, values)
v, ok := values.(testIsloFlagValues)
if !ok {
return nil
}
if flagWasSet(fs, "islo-image") {
cfg.Islo.Image = *v.Image
}
if flagWasSet(fs, "islo-vcpus") {
cfg.Islo.VCPUs = *v.VCPUs
}
if flagWasSet(fs, "islo-memory-mb") {
cfg.Islo.MemoryMB = *v.MemoryMB
}
return nil
}
func (p testIsloProvider) Configure(cfg Config, rt Runtime) (Backend, error) {
return NewIsloBackend(p.Spec(), cfg, rt), nil
return testDelegatedBackend{spec: p.Spec()}, nil
}
type testDelegatedBackend struct {
spec ProviderSpec
}
func (b testDelegatedBackend) Spec() ProviderSpec { return b.spec }
func (b testDelegatedBackend) Warmup(context.Context, WarmupRequest) error {
return nil
}
func (b testDelegatedBackend) Run(context.Context, RunRequest) (RunResult, error) {
return RunResult{}, nil
}
func (b testDelegatedBackend) List(context.Context, ListRequest) ([]LeaseView, error) {
return nil, nil
}
func (b testDelegatedBackend) Status(context.Context, StatusRequest) (StatusView, error) {
return StatusView{}, nil
}
func (b testDelegatedBackend) Stop(context.Context, StopRequest) error {
return nil
}
type testSSHBackend struct {
spec ProviderSpec
}
func (b testSSHBackend) Spec() ProviderSpec { return b.spec }
func (b testSSHBackend) Acquire(context.Context, AcquireRequest) (LeaseTarget, error) {
return LeaseTarget{}, nil
}
func (b testSSHBackend) Resolve(context.Context, ResolveRequest) (LeaseTarget, error) {
return LeaseTarget{}, nil
}
func (b testSSHBackend) List(context.Context, ListRequest) ([]LeaseView, error) {
return nil, nil
}
func (b testSSHBackend) ReleaseLease(context.Context, ReleaseLeaseRequest) error {
return nil
}
func (b testSSHBackend) Touch(context.Context, TouchRequest) (Server, error) {
return Server{}, nil
}

View File

@ -12,52 +12,6 @@ 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{}
@ -88,33 +42,8 @@ func touchDirectLeaseBestEffort(ctx context.Context, cfg Config, server Server,
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 TouchDirectLeaseBestEffort(ctx context.Context, cfg Config, server Server, state string, stderr io.Writer) Server {
return touchDirectLeaseBestEffort(ctx, cfg, server, state, stderr)
}
func acquireAttemptsRetry(rt Runtime, keep bool, acquire func() (LeaseTarget, error)) (LeaseTarget, error) {

View File

@ -79,6 +79,10 @@ func syncExcludes(root string, cfg Config) ([]string, error) {
return appendUniqueStrings(excludes, ignore...), nil
}
func SyncExcludes(root string, cfg Config) ([]string, error) {
return syncExcludes(root, cfg)
}
func readCrabboxIgnore(root string) ([]string, error) {
if root == "" {
return nil, nil
@ -116,6 +120,10 @@ func allowedEnv(allow []string) map[string]string {
return out
}
func AllowedEnv(allow []string) map[string]string {
return allowedEnv(allow)
}
func envAllowed(name string, allow []string) bool {
for _, pattern := range allow {
pattern = strings.TrimSpace(pattern)
@ -256,6 +264,10 @@ func syncManifest(root string, excludes []string) (SyncManifest, error) {
return manifest, nil
}
func BuildSyncManifest(root string, excludes []string) (SyncManifest, error) {
return syncManifest(root, excludes)
}
func (m SyncManifest) NUL() []byte {
var b bytes.Buffer
for _, rel := range m.Files {
@ -349,6 +361,10 @@ func checkSyncPreflight(manifest SyncManifest, cfg Config, force bool, stderr io
return nil
}
func CheckSyncPreflight(manifest SyncManifest, cfg Config, force bool, stderr io.Writer) error {
return checkSyncPreflight(manifest, cfg, force, stderr)
}
func humanBytes(n int64) string {
const unit = 1024
if n < unit {

View File

@ -684,6 +684,18 @@ func shouldUseShell(command []string) bool {
return false
}
func ShouldUseShell(command []string) bool {
return shouldUseShell(command)
}
func leadingEnvAssignment(command []string) bool {
return len(command) > 1 && strings.Contains(command[0], "=") && !strings.HasPrefix(command[0], "-")
}
func LeadingEnvAssignment(command []string) bool {
return leadingEnvAssignment(command)
}
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, "-"))
@ -750,6 +762,10 @@ func acquireAttempts(bool) int {
return 2
}
func AcquireAttempts(keep bool) int {
return acquireAttempts(keep)
}
func isBootstrapWaitError(err error) bool {
var exitErr ExitError
return AsExitError(err, &exitErr) &&
@ -757,6 +773,10 @@ func isBootstrapWaitError(err error) bool {
strings.Contains(exitErr.Message, "timed out waiting for SSH")
}
func IsBootstrapWaitError(err error) bool {
return isBootstrapWaitError(err)
}
func releaseCoordinatorLease(ctx context.Context, coord *CoordinatorClient, leaseID string) error {
var lastErr error
for attempt := 1; attempt <= 5; attempt++ {
@ -903,6 +923,10 @@ func waitForServerIP(ctx context.Context, client *HetznerClient, id int64) (Serv
}
}
func WaitForServerIP(ctx context.Context, client *HetznerClient, id int64) (Server, error) {
return waitForServerIP(ctx, client, id)
}
func findServerByAlias(servers []Server, id string) (Server, string, error) {
if isCanonicalLeaseID(id) {
for _, server := range servers {
@ -937,6 +961,10 @@ func findServerByAlias(servers []Server, id string) (Server, string, error) {
return Server{}, "", nil
}
func FindServerByAlias(servers []Server, id string) (Server, string, error) {
return findServerByAlias(servers, id)
}
func (a App) stop(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("stop", a.Stderr)
@ -1049,6 +1077,10 @@ func deleteServer(ctx context.Context, cfg Config, server Server) error {
return nil
}
func DeleteServer(ctx context.Context, cfg Config, server Server) error {
return deleteServer(ctx, cfg, server)
}
func serverProviderKey(server Server) string {
if server.Labels != nil && server.Labels["provider_key"] != "" {
return server.Labels["provider_key"]

View File

@ -44,6 +44,10 @@ func newLeaseSlug(leaseID string) string {
return adjective + "-" + noun
}
func NewLeaseSlug(leaseID string) string {
return newLeaseSlug(leaseID)
}
func slugWithCollisionSuffix(base, seed string) string {
base = normalizeLeaseSlug(base)
if base == "" {
@ -71,6 +75,10 @@ func normalizeLeaseSlug(value string) string {
return strings.Trim(out.String(), "-")
}
func NormalizeLeaseSlug(value string) string {
return normalizeLeaseSlug(value)
}
func leaseProviderName(leaseID, slug string) string {
if slug = normalizeLeaseSlug(slug); slug != "" {
return fmt.Sprintf("crabbox-%s-%08x", slug, leaseSlugHash(leaseID))
@ -78,6 +86,10 @@ func leaseProviderName(leaseID, slug string) string {
return strings.ReplaceAll("crabbox-"+leaseID, "_", "-")
}
func LeaseProviderName(leaseID, slug string) string {
return leaseProviderName(leaseID, slug)
}
func allocateDirectLeaseSlug(leaseID string, servers []Server) string {
base := newLeaseSlug(leaseID)
slug := base
@ -90,6 +102,10 @@ func allocateDirectLeaseSlug(leaseID string, servers []Server) string {
return slugWithCollisionSuffix(base, leaseID)
}
func AllocateDirectLeaseSlug(leaseID string, servers []Server) string {
return allocateDirectLeaseSlug(leaseID, servers)
}
func serverSlugInUse(slug string, servers []Server) bool {
slug = normalizeLeaseSlug(slug)
for _, server := range servers {
@ -107,10 +123,18 @@ func serverSlug(server Server) string {
return normalizeLeaseSlug(server.Labels["slug"])
}
func ServerSlug(server Server) string {
return serverSlug(server)
}
func isCanonicalLeaseID(value string) bool {
return canonicalLeaseIDPattern.MatchString(value)
}
func IsCanonicalLeaseID(value string) bool {
return isCanonicalLeaseID(value)
}
func leaseSlugHash(value string) uint32 {
h := fnv.New32a()
_, _ = h.Write([]byte(value))

View File

@ -36,6 +36,10 @@ func sshTargetFromConfig(cfg Config, host string) SSHTarget {
return sshTargetForLease(cfg, host, cfg.SSHUser, cfg.SSHPort)
}
func SSHTargetFromConfig(cfg Config, host string) SSHTarget {
return sshTargetFromConfig(cfg, host)
}
func sshTargetForLease(cfg Config, host, user, port string) SSHTarget {
if user == "" {
user = cfg.SSHUser
@ -58,6 +62,10 @@ func waitForSSH(ctx context.Context, target *SSHTarget, stderr io.Writer) error
return waitForSSHReady(ctx, target, stderr, "bootstrap", 20*time.Minute)
}
func WaitForSSH(ctx context.Context, target *SSHTarget, stderr io.Writer) error {
return waitForSSH(ctx, target, stderr)
}
func bootstrapWaitTimeout(cfg Config) time.Duration {
if cfg.Desktop || cfg.Browser {
return 45 * time.Minute
@ -65,6 +73,10 @@ func bootstrapWaitTimeout(cfg Config) time.Duration {
return 20 * time.Minute
}
func BootstrapWaitTimeout(cfg Config) time.Duration {
return bootstrapWaitTimeout(cfg)
}
func waitForSSHReady(ctx context.Context, target *SSHTarget, stderr io.Writer, phase string, timeout time.Duration) error {
start := time.Now()
deadline := time.Now().Add(timeout)
@ -107,6 +119,10 @@ func waitForSSHReady(ctx context.Context, target *SSHTarget, stderr io.Writer, p
}
}
func WaitForSSHReady(ctx context.Context, target *SSHTarget, stderr io.Writer, phase string, timeout time.Duration) error {
return waitForSSHReady(ctx, target, stderr, phase, timeout)
}
func sshWaitProgressMessage(target *SSHTarget, phase, reachablePort, transportPort string, elapsed, remaining time.Duration) string {
if remaining < 0 {
remaining = 0
@ -464,6 +480,10 @@ func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}
func ShellQuote(s string) string {
return shellQuote(s)
}
func psQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
@ -533,6 +553,36 @@ func shellScriptFromArgv(command []string) string {
return strings.Join(parts, " ")
}
func ShellScriptFromArgv(command []string) string {
return shellScriptFromArgv(command)
}
func isShellEnvAssignment(word string) bool {
if word == "" {
return false
}
idx := strings.IndexByte(word, '=')
if idx <= 0 {
return false
}
for i, r := range word[:idx] {
if i == 0 {
if !((r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || r == '_') {
return false
}
continue
}
if !((r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_') {
return false
}
}
return true
}
func IsShellEnvAssignment(word string) bool {
return isShellEnvAssignment(word)
}
func isShellControlOperator(word string) bool {
switch word {
case "&&", "||", ";", "|", ">", ">>", "<", "2>", "2>>":
@ -578,6 +628,10 @@ func shellWords(words []string) []string {
return out
}
func ShellWords(words []string) []string {
return shellWords(words)
}
func remoteMkdir(workdir string) string {
return "mkdir -p " + shellQuote(workdir)
}
@ -813,3 +867,7 @@ func parseServerID(s string) (int64, bool) {
id, err := strconv.ParseInt(s, 10, 64)
return id, err == nil
}
func ParseServerID(s string) (int64, bool) {
return parseServerID(s)
}

View File

@ -43,7 +43,7 @@ func (a App) ssh(ctx context.Context, args []string) error {
func sshCommandLine(target SSHTarget, redactSecret bool) string {
renderTarget := target
if redactSecret {
renderTarget.User = daytonaTokenRedacted
renderTarget.User = "<token>"
}
args := append([]string{"ssh"}, sshBaseArgs(renderTarget)...)
args = append(args, renderTarget.User+"@"+renderTarget.Host)

View File

@ -274,7 +274,7 @@ func TestSSHCommandLineRedactsSecretAuthUser(t *testing.T) {
if strings.Contains(redacted, target.User) {
t.Fatalf("redacted command leaked token: %q", redacted)
}
if !strings.Contains(redacted, daytonaTokenRedacted+"@ssh.app.daytona.io") {
if !strings.Contains(redacted, "<token>@ssh.app.daytona.io") {
t.Fatalf("redacted command missing placeholder user: %q", redacted)
}
full := sshCommandLine(target, false)

View File

@ -90,6 +90,10 @@ func staticLease(cfg Config) (Server, SSHTarget, string, error) {
return server, target, leaseID, nil
}
func StaticLease(cfg Config) (Server, SSHTarget, string, error) {
return staticLease(cfg)
}
func staticReadyCommand(target SSHTarget) string {
if isWindowsNativeTarget(target) {
return windowsRemoteDoctor()

View File

@ -148,7 +148,7 @@ func leaseStatusStateCanBeReady(lease LeaseTarget, state string) bool {
return state != "provisioning"
}
type statusView struct {
type StatusView struct {
ID string `json:"id"`
Slug string `json:"slug,omitempty"`
Provider string `json:"provider"`
@ -176,6 +176,8 @@ type statusView struct {
TelemetryHistory []*LeaseTelemetry `json:"telemetryHistory,omitempty"`
}
type statusView = StatusView
func (a App) leaseStatus(ctx context.Context, cfg Config, id string) (statusView, error) {
backend, err := loadBackend(cfg, runtimeForApp(a))
if err != nil {
@ -227,6 +229,20 @@ func idleForString(value string, now time.Time) string {
return now.Sub(touched).Round(time.Second).String()
}
func IdleForString(value string, now time.Time) string {
return idleForString(value, now)
}
func redactedSSHUser(cfg Config, server Server, target SSHTarget) string {
if target.AuthSecret {
return "<token>"
}
if cfg.Provider == "daytona" || server.Provider == "daytona" {
return "<token>"
}
return target.User
}
func formatSecondsDuration(seconds int) string {
if seconds <= 0 {
return ""

View File

@ -17,6 +17,12 @@ const (
defaultWindowsWorkRoot = `C:\crabbox`
)
const (
TargetLinux = targetLinux
TargetMacOS = targetMacOS
TargetWindows = targetWindows
)
func normalizeTargetConfig(cfg *Config) {
cfg.TargetOS = normalizeTargetOS(cfg.TargetOS)
cfg.WindowsMode = normalizeWindowsMode(cfg.WindowsMode)
@ -183,6 +189,10 @@ func remoteJoin(cfg Config, parts ...string) string {
return path.Join(values...)
}
func RemoteJoin(cfg Config, parts ...string) string {
return remoteJoin(cfg, parts...)
}
func windowsPathJoin(parts ...string) string {
out := ""
for _, part := range parts {

View File

@ -6,12 +6,12 @@ import (
"time"
)
type timingReport struct {
type TimingReport struct {
Provider string `json:"provider"`
LeaseID string `json:"leaseId,omitempty"`
Slug string `json:"slug,omitempty"`
SyncMs int64 `json:"syncMs"`
SyncPhases []timingPhase `json:"syncPhases,omitempty"`
SyncPhases []TimingPhase `json:"syncPhases,omitempty"`
SyncSkipped bool `json:"syncSkipped"`
SyncDelegated bool `json:"syncDelegated,omitempty"`
CommandMs int64 `json:"commandMs"`
@ -20,19 +20,44 @@ type timingReport struct {
ActionsRunURL string `json:"actionsRunUrl,omitempty"`
}
type timingPhase struct {
type TimingPhase struct {
Name string `json:"name"`
Ms int64 `json:"ms,omitempty"`
Skipped bool `json:"skipped,omitempty"`
Reason string `json:"reason,omitempty"`
}
func writeTimingJSON(w io.Writer, report timingReport) error {
type timingReport = TimingReport
type timingPhase = TimingPhase
func writeTimingJSON(w io.Writer, report TimingReport) error {
encoder := json.NewEncoder(w)
encoder.SetEscapeHTML(false)
return encoder.Encode(report)
}
func WriteTimingJSON(w io.Writer, report TimingReport) error {
return writeTimingJSON(w, report)
}
func DurationMinutesCeil(duration time.Duration) int {
if duration <= 0 {
return 1
}
minutes := int(duration / time.Minute)
if duration%time.Minute != 0 {
minutes++
}
if minutes < 1 {
return 1
}
return minutes
}
func durationMinutesCeil(duration time.Duration) int {
return DurationMinutesCeil(duration)
}
func timingReportFromRun(provider, leaseID, slug string, timings runTimings, total time.Duration, exitCode int) timingReport {
return timingReport{
Provider: provider,

View File

@ -0,0 +1,219 @@
package aws
import (
"context"
"fmt"
"io"
"os"
"strings"
core "github.com/openclaw/crabbox/internal/cli"
"github.com/openclaw/crabbox/internal/providers/shared"
)
type Config = core.Config
type Runtime = core.Runtime
type ProviderSpec = core.ProviderSpec
type Backend = core.Backend
type AcquireRequest = core.AcquireRequest
type ResolveRequest = core.ResolveRequest
type ListRequest = core.ListRequest
type LeaseView = core.LeaseView
type ReleaseLeaseRequest = core.ReleaseLeaseRequest
type TouchRequest = core.TouchRequest
type CleanupRequest = core.CleanupRequest
type LeaseTarget = core.LeaseTarget
type Server = core.Server
type SSHTarget = core.SSHTarget
type awsLeaseBackend struct{ shared.DirectSSHBackend }
func NewAWSLeaseBackend(spec ProviderSpec, cfg Config, rt Runtime) Backend {
cfg.Provider = "aws"
return &awsLeaseBackend{DirectSSHBackend: shared.DirectSSHBackend{SpecValue: spec, Cfg: cfg, RT: rt}}
}
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.DirectSSHBackend.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)
}
func acquireAttemptsRetry(rt Runtime, keep bool, acquire func() (LeaseTarget, error)) (LeaseTarget, error) {
return shared.AcquireAttemptsRetry(rt, keep, acquire)
}
func exit(code int, format string, args ...any) core.ExitError {
return core.Exit(code, format, args...)
}
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 := core.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 := ""
if scores[0].Region != nil {
best = *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 newAWSClient(ctx context.Context, cfg Config) (*core.AWSClient, error) {
return core.NewAWSClient(ctx, cfg)
}
func newLeaseID() string { return core.NewLeaseID() }
func allocateDirectLeaseSlug(id string, servers []Server) string {
return core.AllocateDirectLeaseSlug(id, servers)
}
func ensureTestboxKeyForConfig(cfg Config, leaseID string) (string, string, error) {
return core.EnsureTestboxKeyForConfig(cfg, leaseID)
}
func providerKeyForLease(leaseID string) string { return core.ProviderKeyForLease(leaseID) }
func ensureAWSSSHCIDRs(ctx context.Context, cfg *Config) { core.EnsureAWSSSHCIDRs(ctx, cfg) }
func sshTargetFromConfig(cfg Config, host string) SSHTarget {
return core.SSHTargetFromConfig(cfg, host)
}
func bootstrapAWSWindowsDesktop(ctx context.Context, cfg Config, target *SSHTarget, publicKey string, stderr io.Writer) error {
return core.BootstrapAWSWindowsDesktop(ctx, cfg, target, publicKey, stderr)
}
func blank(value, fallback string) string { return core.Blank(value, fallback) }
func useStoredTestboxKey(target *SSHTarget, leaseID string) {
if keyPath, err := core.TestboxKeyPath(leaseID); err == nil {
if _, statErr := os.Stat(keyPath); statErr == nil {
target.Key = keyPath
}
}
}
func findServerByAlias(servers []Server, id string) (Server, string, error) {
return core.FindServerByAlias(servers, id)
}
func deleteServer(ctx context.Context, cfg Config, server Server) error {
return core.DeleteServer(ctx, cfg, server)
}
func removeLeaseClaim(leaseID string) { core.RemoveLeaseClaim(leaseID) }

View File

@ -3,35 +3,35 @@ package aws
import (
"flag"
"github.com/openclaw/crabbox/internal/cli"
core "github.com/openclaw/crabbox/internal/cli"
)
func init() {
cli.RegisterProvider(Provider{})
core.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{
func (Provider) Spec() core.ProviderSpec {
return core.ProviderSpec{
Name: "aws",
Kind: cli.ProviderKindSSHLease,
Targets: []cli.TargetSpec{
{OS: "linux"},
{OS: "windows", WindowsMode: "normal"},
{OS: "windows", WindowsMode: "wsl2"},
{OS: "macos"},
Kind: core.ProviderKindSSHLease,
Targets: []core.TargetSpec{
{OS: core.TargetLinux},
{OS: core.TargetWindows, WindowsMode: "normal"},
{OS: core.TargetWindows, WindowsMode: "wsl2"},
{OS: core.TargetMacOS},
},
Features: cli.FeatureSet{cli.FeatureSSH, cli.FeatureCrabboxSync, cli.FeatureCleanup, cli.FeatureDesktop, cli.FeatureBrowser, cli.FeatureCode},
Coordinator: cli.CoordinatorSupported,
Features: core.FeatureSet{core.FeatureSSH, core.FeatureCrabboxSync, core.FeatureCleanup, core.FeatureDesktop, core.FeatureBrowser, core.FeatureCode},
Coordinator: core.CoordinatorSupported,
}
}
func (Provider) RegisterFlags(*flag.FlagSet, cli.Config) any { return cli.NoProviderFlags() }
func (Provider) ApplyFlags(*cli.Config, *flag.FlagSet, any) error {
func (Provider) RegisterFlags(*flag.FlagSet, core.Config) any { return core.NoProviderFlags() }
func (Provider) ApplyFlags(*core.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
func (p Provider) Configure(cfg core.Config, rt core.Runtime) (core.Backend, error) {
return NewAWSLeaseBackend(p.Spec(), cfg, rt), nil
}

View File

@ -1,4 +1,4 @@
package cli
package blacksmith
import (
"context"
@ -7,8 +7,34 @@ import (
"io"
"strings"
"time"
core "github.com/openclaw/crabbox/internal/cli"
)
type Config = core.Config
type ProviderSpec = core.ProviderSpec
type Runtime = core.Runtime
type Backend = core.Backend
type BlacksmithConfig = core.BlacksmithConfig
type WarmupRequest = core.WarmupRequest
type RunRequest = core.RunRequest
type RunResult = core.RunResult
type ListRequest = core.ListRequest
type LeaseView = core.LeaseView
type StatusRequest = core.StatusRequest
type StatusView = core.StatusView
type StopRequest = core.StopRequest
type Server = core.Server
type Repo = core.Repo
type ExitError = core.ExitError
type LocalCommandRequest = core.LocalCommandRequest
type LocalCommandResult = core.LocalCommandResult
type CommandRunner = core.CommandRunner
type timingReport = core.TimingReport
type timingPhase = core.TimingPhase
const targetLinux = core.TargetLinux
func RegisterBlacksmithProviderFlags(fs *flag.FlagSet, defaults Config) any {
return registerBlacksmithFlags(fs, defaults)
}
@ -355,3 +381,53 @@ func blacksmithItemToServer(item blacksmithListItem) Server {
server.ServerType.Name = "testbox"
return server
}
type statusView = core.StatusView
func rejectDelegatedSyncOptions(provider string, req RunRequest) error {
return core.RejectDelegatedSyncOptions(provider, req)
}
func writeTimingJSON(w io.Writer, report timingReport) error {
return core.WriteTimingJSON(w, report)
}
func newLeaseID() string {
return core.NewLeaseID()
}
func newLeaseSlug(leaseID string) string {
return core.NewLeaseSlug(leaseID)
}
func claimLeaseForRepoProvider(leaseID, slug, provider, repoRoot string, idleTimeout time.Duration, reclaim bool) error {
return core.ClaimLeaseForRepoProvider(leaseID, slug, provider, repoRoot, idleTimeout, reclaim)
}
func removeLeaseClaim(leaseID string) {
core.RemoveLeaseClaim(leaseID)
}
func ensureTestboxKey(leaseID string) (string, string, error) {
return core.EnsureTestboxKey(leaseID)
}
func moveStoredTestboxKey(oldLeaseID, newLeaseID string) error {
return core.MoveStoredTestboxKey(oldLeaseID, newLeaseID)
}
func removeStoredTestboxKey(leaseID string) {
core.RemoveStoredTestboxKey(leaseID)
}
func testboxKeyPath(leaseID string) (string, error) {
return core.TestboxKeyPath(leaseID)
}
func baseConfig() Config {
return core.BaseConfig()
}
func readLeaseClaim(leaseID string) (core.LeaseClaim, error) {
return core.ReadLeaseClaim(leaseID)
}

View File

@ -1,4 +1,4 @@
package cli
package blacksmith
import (
"context"
@ -12,6 +12,10 @@ import (
"time"
)
type testClock struct{}
func (testClock) Now() time.Time { return time.Now() }
type blacksmithFuncRunner struct {
calls [][]string
fn func(LocalCommandRequest) (LocalCommandResult, error)
@ -27,9 +31,9 @@ func (r *blacksmithFuncRunner) Run(_ context.Context, req LocalCommandRequest) (
func newTestBlacksmithBackend(cfg Config, runner CommandRunner) *blacksmithBackend {
return &blacksmithBackend{
spec: testBlacksmithProvider{}.Spec(),
spec: Provider{}.Spec(),
cfg: cfg,
rt: Runtime{Stdout: io.Discard, Stderr: io.Discard, Clock: realClock{}, Exec: runner},
rt: Runtime{Stdout: io.Discard, Stderr: io.Discard, Clock: testClock{}, Exec: runner},
}
}
@ -281,6 +285,56 @@ func TestBlacksmithOneShotRunRemovesClaimAfterStop(t *testing.T) {
}
}
func TestBlacksmithBackendUsesInjectedCommandRunnerForListAndStatus(t *testing.T) {
runner := &blacksmithFuncRunner{fn: func(LocalCommandRequest) (LocalCommandResult, error) {
return LocalCommandResult{
Stdout: "tbx_123 ready openclaw .github/workflows/testbox.yml test main 2026-05-06T00:00:00Z\n",
}, nil
}}
cfg := baseConfig()
cfg.Blacksmith.Workflow = ".github/workflows/testbox.yml"
cfg.Blacksmith.Job = "test"
cfg.Blacksmith.Ref = "main"
backend := newTestBlacksmithBackend(cfg, runner)
servers, err := backend.List(context.Background(), ListRequest{})
if err != nil {
t.Fatalf("list: %v", err)
}
if len(servers) != 1 || servers[0].CloudID != "tbx_123" {
t.Fatalf("servers=%#v", servers)
}
state, err := backend.Status(context.Background(), StatusRequest{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))
}
}
func TestBlacksmithBackendListJSONKeepsParsedTableShape(t *testing.T) {
runner := &blacksmithFuncRunner{fn: func(LocalCommandRequest) (LocalCommandResult, error) {
return LocalCommandResult{
Stdout: "tbx_123 ready openclaw .github/workflows/testbox.yml test main 2026-05-06T00:00:00Z\n",
}, nil
}}
backend := newTestBlacksmithBackend(baseConfig(), runner)
view, err := backend.ListJSON(context.Background(), ListRequest{})
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)
}
}
func TestApplyBlacksmithFlagOverrides(t *testing.T) {
defaults := baseConfig()
defaults.Blacksmith = BlacksmithConfig{
@ -433,12 +487,3 @@ func TestBlacksmithClaimSlugPreservesExistingSlug(t *testing.T) {
t.Fatalf("slug=%q", got)
}
}
func containsString(items []string, want string) bool {
for _, item := range items {
if item == want {
return true
}
}
return false
}

View File

@ -1,4 +1,4 @@
package cli
package blacksmith
import (
"flag"
@ -6,6 +6,8 @@ import (
"regexp"
"strings"
"time"
core "github.com/openclaw/crabbox/internal/cli"
)
const blacksmithTestboxProvider = "blacksmith-testbox"
@ -182,17 +184,7 @@ func blacksmithIdleTimeout(cfg Config) time.Duration {
}
func durationMinutesCeil(duration time.Duration) int {
if duration <= 0 {
return 1
}
minutes := int(duration / time.Minute)
if duration%time.Minute != 0 {
minutes++
}
if minutes < 1 {
return 1
}
return minutes
return core.DurationMinutesCeil(duration)
}
func parseBlacksmithID(output string) string {
@ -280,3 +272,31 @@ func isShellEnvAssignment(word string) bool {
}
return true
}
func flagWasSet(fs *flag.FlagSet, name string) bool {
return core.FlagWasSet(fs, name)
}
func exit(code int, format string, args ...any) core.ExitError {
return core.Exit(code, format, args...)
}
func blank(value, fallback string) string {
return core.Blank(value, fallback)
}
func resolveLeaseClaim(identifier string) (core.LeaseClaim, bool, error) {
return core.ResolveLeaseClaim(identifier)
}
func shouldUseShell(command []string) bool {
return core.ShouldUseShell(command)
}
func shellScriptFromArgv(command []string) string {
return core.ShellScriptFromArgv(command)
}
func shellQuote(s string) string {
return core.ShellQuote(s)
}

View File

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

View File

@ -0,0 +1,25 @@
package blacksmith
import (
"flag"
"io"
)
func containsString(items []string, want string) bool {
for _, item := range items {
if item == want {
return true
}
}
return false
}
func newFlagSet(name string, output io.Writer) *flag.FlagSet {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.SetOutput(output)
return fs
}
func parseFlags(fs *flag.FlagSet, args []string) error {
return fs.Parse(args)
}

View File

@ -1,9 +1,10 @@
package cli
package daytona
import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"path"
@ -330,14 +331,8 @@ func (b *daytonaLeaseBackend) syncDaytonaToolbox(ctx context.Context, sandbox *s
if _, err := archive.Seek(0, 0); err != nil {
return nil, fmt.Errorf("daytona rewind archive: %w", err)
}
if sandbox.ToolboxClient == nil {
return nil, fmt.Errorf("daytona toolbox client is not configured")
}
if _, httpResp, err := sandbox.ToolboxClient.FileSystemAPI.UploadFile(ctx).Path(archivePath).File(archive).Execute(); err != nil {
if httpResp != nil {
return nil, fmt.Errorf("daytona upload archive: %s: %w", httpResp.Status, err)
}
return nil, fmt.Errorf("daytona upload archive: %w", err)
if err := b.uploadDaytonaArchive(ctx, sandbox.ID, archivePath, archive); err != nil {
return nil, err
}
uploadDuration := time.Since(uploadStarted)
extractStarted := time.Now()
@ -373,7 +368,7 @@ func (b *daytonaLeaseBackend) syncDaytonaToolbox(ctx context.Context, sandbox *s
return phases, nil
}
func createDaytonaSyncArchive(ctx context.Context, repo Repo, manifest SyncManifest, stderr anyWriter) (*os.File, error) {
func createDaytonaSyncArchive(ctx context.Context, repo Repo, manifest SyncManifest, stderr io.Writer) (*os.File, error) {
var input bytes.Buffer
input.Write(manifest.NUL())
archive, err := os.CreateTemp("", "crabbox-daytona-sync-*.tgz")

View File

@ -0,0 +1,214 @@
package daytona
import (
"archive/tar"
"compress/gzip"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
apidaytona "github.com/daytonaio/daytona/libs/api-client-go"
)
func TestCreateDaytonaSyncArchiveWritesTempFile(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "hello.txt"), []byte("hello"), 0o600); err != nil {
t.Fatal(err)
}
archive, err := createDaytonaSyncArchive(t.Context(), Repo{Root: root}, SyncManifest{Files: []string{"hello.txt"}, Bytes: 5}, io.Discard)
if err != nil {
t.Fatal(err)
}
defer os.Remove(archive.Name())
defer archive.Close()
info, err := archive.Stat()
if err != nil {
t.Fatal(err)
}
if info.Size() == 0 {
t.Fatal("archive temp file is empty")
}
if _, err := archive.Seek(0, 0); err != nil {
t.Fatal(err)
}
gz, err := gzip.NewReader(archive)
if err != nil {
t.Fatal(err)
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
if header.Name == "hello.txt" {
return
}
}
t.Fatal("archive missing hello.txt")
}
func TestDaytonaToolboxUploadURL(t *testing.T) {
sandbox := &apidaytona.Sandbox{}
sandbox.SetToolboxProxyUrl("https://proxy.example/base/")
got, err := daytonaToolboxUploadURL(sandbox, "sbx-123", "/tmp/crabbox archive.tgz")
if err != nil {
t.Fatal(err)
}
want := "https://proxy.example/base/sbx-123/files/upload?path=%2Ftmp%2Fcrabbox+archive.tgz"
if got != want {
t.Fatalf("url=%q, want %q", got, want)
}
}
func TestUploadDaytonaFileStreamDoesNotPrebuffer(t *testing.T) {
sourceReader, sourceWriter := io.Pipe()
requestStarted := make(chan struct{})
bodyRead := make(chan []byte, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method=%s, want POST", r.Method)
}
if r.URL.Path != "/sbx-123/files/upload" {
t.Errorf("path=%s", r.URL.Path)
}
if r.URL.Query().Get("path") != "/tmp/archive.tgz" {
t.Errorf("query path=%q", r.URL.Query().Get("path"))
}
if r.Header.Get("Authorization") != "Bearer token" {
t.Errorf("authorization=%q", r.Header.Get("Authorization"))
}
close(requestStarted)
reader, err := r.MultipartReader()
if err != nil {
t.Errorf("multipart reader: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
part, err := reader.NextPart()
if err != nil {
t.Errorf("next part: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
if part.FormName() != "file" {
t.Errorf("form name=%q", part.FormName())
}
data, err := io.ReadAll(part)
if err != nil {
t.Errorf("read part: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
bodyRead <- data
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
errCh := make(chan error, 1)
go func() {
errCh <- uploadDaytonaFileStream(t.Context(), srv.Client(), srv.URL+"/sbx-123/files/upload?path=%2Ftmp%2Farchive.tgz", map[string]string{
"Authorization": "Bearer token",
}, sourceReader, "archive.tgz")
}()
select {
case <-requestStarted:
case <-time.After(time.Second):
t.Fatal("upload did not start until the source reader completed")
}
if _, err := sourceWriter.Write([]byte("hello archive")); err != nil {
t.Fatal(err)
}
if err := sourceWriter.Close(); err != nil {
t.Fatal(err)
}
select {
case err := <-errCh:
if err != nil {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("upload did not finish")
}
select {
case got := <-bodyRead:
if string(got) != "hello archive" {
t.Fatalf("body=%q", got)
}
default:
t.Fatal("server did not read body")
}
}
func TestDaytonaAuthRequiresOrganizationForJWT(t *testing.T) {
cfg := baseConfig()
cfg.Provider = daytonaProvider
cfg.Daytona.APIKey = ""
cfg.Daytona.JWTToken = "jwt"
cfg.Daytona.OrganizationID = ""
_, err := newDaytonaClient(cfg, Runtime{})
if err == nil || !strings.Contains(err.Error(), "DAYTONA_ORGANIZATION_ID") {
t.Fatalf("err=%v, want organization requirement", err)
}
}
func TestDaytonaSSHTargetUsesReturnedSSHCommand(t *testing.T) {
cfg := baseConfig()
cfg.Daytona.SSHGatewayHost = "fallback.example"
target, err := daytonaSSHTargetFromAccess(cfg, daytonaSSHAccess{
Token: "tok_live_secret",
Command: "ssh -p 2222 tok_live_secret@region-ssh.example.com",
})
if err != nil {
t.Fatal(err)
}
if target.User != "tok_live_secret" || target.Host != "region-ssh.example.com" || target.Port != "2222" {
t.Fatalf("target=%#v", target)
}
if target.Key != "" || !target.AuthSecret || target.NetworkKind != NetworkPublic {
t.Fatalf("target auth/network=%#v", target)
}
}
func TestDaytonaSSHTargetFallsBackWhenCommandMissing(t *testing.T) {
cfg := baseConfig()
cfg.Daytona.SSHGatewayHost = "fallback.example"
target, err := daytonaSSHTargetFromAccess(cfg, daytonaSSHAccess{Token: "tok_live_secret"})
if err != nil {
t.Fatal(err)
}
if target.User != "tok_live_secret" || target.Host != "fallback.example" || target.Port != "22" {
t.Fatalf("target=%#v", target)
}
}
func TestDaytonaBackendIsHybridSDKRunAndSSHAccess(t *testing.T) {
backend := NewDaytonaLeaseBackend(ProviderSpec{Name: daytonaProvider}, baseConfig(), Runtime{})
if _, ok := backend.(DelegatedRunBackend); !ok {
t.Fatal("daytona should use delegated SDK run path")
}
if _, ok := backend.(SSHLeaseBackend); !ok {
t.Fatal("daytona should still expose explicit SSH access")
}
}
func TestDaytonaCommandString(t *testing.T) {
if got := daytonaCommandString([]string{"go", "test", "./..."}, false); got != "'go' 'test' './...'" {
t.Fatalf("command=%q", got)
}
if got := daytonaCommandString([]string{"FOO=bar", "go", "test"}, false); !strings.Contains(got, "FOO=") || !strings.Contains(got, "go") {
t.Fatalf("shell command=%q", got)
}
if got := daytonaCommandString([]string{"echo hello && pwd"}, true); got != "echo hello && pwd" {
t.Fatalf("shell mode=%q", got)
}
}

View File

@ -1,4 +1,4 @@
package cli
package daytona
import (
"context"

View File

@ -1,4 +1,4 @@
package cli
package daytona
import (
"context"

View File

@ -0,0 +1,193 @@
package daytona
import (
"context"
"flag"
"io"
"time"
core "github.com/openclaw/crabbox/internal/cli"
)
type Config = core.Config
type DaytonaConfig = core.DaytonaConfig
type ProviderSpec = core.ProviderSpec
type Runtime = core.Runtime
type Backend = core.Backend
type SSHLeaseBackend = core.SSHLeaseBackend
type DelegatedRunBackend = core.DelegatedRunBackend
type AcquireRequest = core.AcquireRequest
type ResolveRequest = core.ResolveRequest
type ReleaseLeaseRequest = core.ReleaseLeaseRequest
type TouchRequest = core.TouchRequest
type WarmupRequest = core.WarmupRequest
type RunRequest = core.RunRequest
type RunResult = core.RunResult
type ListRequest = core.ListRequest
type LeaseView = core.LeaseView
type StatusRequest = core.StatusRequest
type StatusView = core.StatusView
type StopRequest = core.StopRequest
type Server = core.Server
type Repo = core.Repo
type LeaseTarget = core.LeaseTarget
type SSHTarget = core.SSHTarget
type SyncManifest = core.SyncManifest
type ExitError = core.ExitError
type timingReport = core.TimingReport
type timingPhase = core.TimingPhase
const (
targetLinux = core.TargetLinux
NetworkPublic = core.NetworkPublic
)
type statusView = core.StatusView
func exit(code int, format string, args ...any) core.ExitError {
return core.Exit(code, format, args...)
}
func flagWasSet(fs *flag.FlagSet, name string) bool {
return core.FlagWasSet(fs, name)
}
func blank(value, fallback string) string {
return core.Blank(value, fallback)
}
func newLeaseID() string {
return core.NewLeaseID()
}
func newLeaseSlug(leaseID string) string {
return core.NewLeaseSlug(leaseID)
}
func normalizeLeaseSlug(value string) string {
return core.NormalizeLeaseSlug(value)
}
func leaseProviderName(leaseID, slug string) string {
return core.LeaseProviderName(leaseID, slug)
}
func allocateDirectLeaseSlug(leaseID string, servers []Server) string {
return core.AllocateDirectLeaseSlug(leaseID, servers)
}
func serverSlug(server Server) string {
return core.ServerSlug(server)
}
func isCanonicalLeaseID(value string) bool {
return core.IsCanonicalLeaseID(value)
}
func directLeaseLabels(cfg Config, leaseID, slug, provider, market string, keep bool, now time.Time) map[string]string {
return core.DirectLeaseLabels(cfg, leaseID, slug, provider, market, keep, now)
}
func touchDirectLeaseLabels(labels map[string]string, cfg Config, state string, now time.Time) map[string]string {
return core.TouchDirectLeaseLabels(labels, cfg, state, now)
}
func leaseLabelTime(t time.Time) string {
return core.LeaseLabelTime(t)
}
func leaseLabelTimeDisplay(value string) string {
return core.LeaseLabelTimeDisplay(value)
}
func leaseLabelDurationDisplay(secondsValue, fallbackValue string) string {
return core.LeaseLabelDurationDisplay(secondsValue, fallbackValue)
}
func removeLeaseClaim(leaseID string) {
core.RemoveLeaseClaim(leaseID)
}
func resolveLeaseClaim(identifier string) (core.LeaseClaim, bool, error) {
return core.ResolveLeaseClaim(identifier)
}
func claimLeaseForRepoConfig(leaseID, slug string, cfg Config, repoRoot string, idleTimeout time.Duration, reclaim bool) error {
return core.ClaimLeaseForRepoProvider(leaseID, slug, cfg.Provider, repoRoot, idleTimeout, reclaim)
}
func sshTargetFromConfig(cfg Config, host string) SSHTarget {
return core.SSHTargetFromConfig(cfg, host)
}
func waitForSSHReady(ctx context.Context, target *SSHTarget, stderr io.Writer, phase string, timeout time.Duration) error {
return core.WaitForSSHReady(ctx, target, stderr, phase, timeout)
}
func bootstrapWaitTimeout(cfg Config) time.Duration {
return core.BootstrapWaitTimeout(cfg)
}
func durationMinutesCeil(duration time.Duration) int {
return core.DurationMinutesCeil(duration)
}
func writeTimingJSON(w io.Writer, report timingReport) error {
return core.WriteTimingJSON(w, report)
}
func shellQuote(s string) string {
return core.ShellQuote(s)
}
func shellScriptFromArgv(command []string) string {
return core.ShellScriptFromArgv(command)
}
func shellWords(words []string) []string {
return core.ShellWords(words)
}
func shouldUseShell(command []string) bool {
return core.ShouldUseShell(command)
}
func leadingEnvAssignment(command []string) bool {
return core.LeadingEnvAssignment(command)
}
func remoteJoin(cfg Config, parts ...string) string {
return core.RemoteJoin(cfg, parts...)
}
func allowedEnv(allow []string) map[string]string {
return core.AllowedEnv(allow)
}
func syncExcludes(root string, cfg Config) ([]string, error) {
return core.SyncExcludes(root, cfg)
}
func syncManifest(root string, excludes []string) (SyncManifest, error) {
return core.BuildSyncManifest(root, excludes)
}
func checkSyncPreflight(manifest SyncManifest, cfg Config, force bool, stderr io.Writer) error {
return core.CheckSyncPreflight(manifest, cfg, force, stderr)
}
func serverTypeForProviderClass(provider, class string) string {
return core.ServerTypeForProviderClass(provider, class)
}
func idleForString(value string, now time.Time) string {
return core.IdleForString(value, now)
}
func summarizeJSON(data []byte) string {
return core.SummarizeJSON(data)
}
func baseConfig() Config {
return core.BaseConfig()
}

View File

@ -3,11 +3,11 @@ package daytona
import (
"flag"
"github.com/openclaw/crabbox/internal/cli"
core "github.com/openclaw/crabbox/internal/cli"
)
func init() {
cli.RegisterProvider(Provider{})
core.RegisterProvider(Provider{})
}
type Provider struct{}
@ -16,21 +16,21 @@ func (Provider) Name() string { return "daytona" }
func (Provider) Aliases() []string {
return nil
}
func (Provider) Spec() cli.ProviderSpec {
return cli.ProviderSpec{
func (Provider) Spec() core.ProviderSpec {
return core.ProviderSpec{
Name: "daytona",
Kind: cli.ProviderKindSSHLease,
Targets: []cli.TargetSpec{{OS: "linux"}},
Features: cli.FeatureSet{cli.FeatureSSH, cli.FeatureCrabboxSync},
Coordinator: cli.CoordinatorNever,
Kind: core.ProviderKindSSHLease,
Targets: []core.TargetSpec{{OS: core.TargetLinux}},
Features: core.FeatureSet{core.FeatureSSH, core.FeatureCrabboxSync},
Coordinator: core.CoordinatorNever,
}
}
func (Provider) RegisterFlags(fs *flag.FlagSet, defaults cli.Config) any {
return cli.RegisterDaytonaProviderFlags(fs, defaults)
func (Provider) RegisterFlags(fs *flag.FlagSet, defaults core.Config) any {
return RegisterDaytonaProviderFlags(fs, defaults)
}
func (Provider) ApplyFlags(cfg *cli.Config, fs *flag.FlagSet, values any) error {
return cli.ApplyDaytonaProviderFlags(cfg, fs, values)
func (Provider) ApplyFlags(cfg *core.Config, fs *flag.FlagSet, values any) error {
return ApplyDaytonaProviderFlags(cfg, fs, values)
}
func (p Provider) Configure(cfg cli.Config, rt cli.Runtime) (cli.Backend, error) {
return cli.NewDaytonaLeaseBackend(p.Spec(), cfg, rt), nil
func (p Provider) Configure(cfg core.Config, rt core.Runtime) (core.Backend, error) {
return NewDaytonaLeaseBackend(p.Spec(), cfg, rt), nil
}

View File

@ -0,0 +1,130 @@
package daytona
import (
"context"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path"
"strings"
apidaytona "github.com/daytonaio/daytona/libs/api-client-go"
sdkdaytona "github.com/daytonaio/daytona/libs/sdk-go/pkg/daytona"
)
func (b *daytonaLeaseBackend) uploadDaytonaArchive(ctx context.Context, sandboxID, archivePath string, archive *os.File) error {
client, err := newDaytonaClient(b.cfg, b.rt)
if err != nil {
return err
}
apiSandbox, err := client.GetSandbox(ctx, sandboxID)
if err != nil {
return daytonaError("get sandbox", err)
}
endpoint, err := daytonaToolboxUploadURL(apiSandbox, sandboxID, archivePath)
if err != nil {
return err
}
headers, err := daytonaToolboxHeaders(b.cfg)
if err != nil {
return err
}
httpClient := b.rt.HTTP
if httpClient == nil {
httpClient = http.DefaultClient
}
return uploadDaytonaFileStream(ctx, httpClient, endpoint, headers, archive, path.Base(archivePath))
}
func daytonaToolboxUploadURL(sandbox *apidaytona.Sandbox, sandboxID, remotePath string) (string, error) {
proxyURL := strings.TrimRight(strings.TrimSpace(sandbox.GetToolboxProxyUrl()), "/")
if proxyURL == "" {
return "", fmt.Errorf("daytona sandbox %s has no toolbox proxy URL", sandboxID)
}
u, err := url.Parse(proxyURL + "/" + strings.Trim(sandboxID, "/") + "/files/upload")
if err != nil {
return "", fmt.Errorf("daytona toolbox upload URL: %w", err)
}
q := u.Query()
q.Set("path", remotePath)
u.RawQuery = q.Encode()
return u.String(), nil
}
func daytonaToolboxHeaders(cfg Config) (map[string]string, error) {
auth, err := daytonaAuthConfig(cfg)
if err != nil {
return nil, err
}
headers := map[string]string{
"Authorization": "Bearer " + auth.token(),
"User-Agent": "sdk-go/" + sdkdaytona.Version,
"X-Daytona-SDK-Version": sdkdaytona.Version,
"X-Daytona-Source": "sdk-go",
}
if auth.OrganizationID != "" {
headers["X-Daytona-Organization-ID"] = auth.OrganizationID
}
return headers, nil
}
func uploadDaytonaFileStream(ctx context.Context, client *http.Client, endpoint string, headers map[string]string, file io.Reader, filename string) error {
if client == nil {
client = http.DefaultClient
}
pr, pw := io.Pipe()
writer := multipart.NewWriter(pw)
writeDone := make(chan error, 1)
go func() {
writeDone <- writeMultipartFile(pw, writer, file, filename)
}()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, pr)
if err != nil {
_ = pr.CloseWithError(err)
_ = pw.CloseWithError(err)
return err
}
req.Header.Set("Accept", "*/*")
req.Header.Set("Content-Type", writer.FormDataContentType())
for key, value := range headers {
req.Header.Set(key, value)
}
resp, err := client.Do(req)
if err != nil {
_ = pr.CloseWithError(err)
return fmt.Errorf("daytona upload archive: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= http.StatusBadRequest {
_ = pr.CloseWithError(fmt.Errorf("daytona upload archive: %s", resp.Status))
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if len(body) > 0 {
return fmt.Errorf("daytona upload archive: %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
return fmt.Errorf("daytona upload archive: %s", resp.Status)
}
writeErr := <-writeDone
if writeErr != nil {
return fmt.Errorf("daytona upload archive: %w", writeErr)
}
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
func writeMultipartFile(pipe *io.PipeWriter, writer *multipart.Writer, file io.Reader, filename string) error {
part, err := writer.CreateFormFile("file", filename)
if err == nil {
_, err = io.Copy(part, file)
}
if closeErr := writer.Close(); err == nil {
err = closeErr
}
if err != nil {
_ = pipe.CloseWithError(err)
return err
}
return pipe.Close()
}

View File

@ -0,0 +1,192 @@
package hetzner
import (
"context"
"fmt"
"io"
"os"
"time"
core "github.com/openclaw/crabbox/internal/cli"
"github.com/openclaw/crabbox/internal/providers/shared"
)
type Config = core.Config
type Runtime = core.Runtime
type ProviderSpec = core.ProviderSpec
type Backend = core.Backend
type AcquireRequest = core.AcquireRequest
type ResolveRequest = core.ResolveRequest
type ListRequest = core.ListRequest
type LeaseView = core.LeaseView
type ReleaseLeaseRequest = core.ReleaseLeaseRequest
type TouchRequest = core.TouchRequest
type CleanupRequest = core.CleanupRequest
type LeaseTarget = core.LeaseTarget
type Server = core.Server
type SSHTarget = core.SSHTarget
type hetznerLeaseBackend struct{ shared.DirectSSHBackend }
func NewHetznerLeaseBackend(spec ProviderSpec, cfg Config, rt Runtime) Backend {
cfg.Provider = "hetzner"
return &hetznerLeaseBackend{DirectSSHBackend: shared.DirectSSHBackend{SpecValue: spec, Cfg: cfg, RT: rt}}
}
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.DirectSSHBackend.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)
}
func acquireAttemptsRetry(rt Runtime, keep bool, acquire func() (LeaseTarget, error)) (LeaseTarget, error) {
return shared.AcquireAttemptsRetry(rt, keep, acquire)
}
func exit(code int, format string, args ...any) core.ExitError {
return core.Exit(code, format, args...)
}
func newHetznerClient() (*core.HetznerClient, error) { return core.NewHetznerClient() }
func newLeaseID() string { return core.NewLeaseID() }
func allocateDirectLeaseSlug(id string, servers []Server) string {
return core.AllocateDirectLeaseSlug(id, servers)
}
func ensureTestboxKeyForConfig(cfg Config, leaseID string) (string, string, error) {
return core.EnsureTestboxKeyForConfig(cfg, leaseID)
}
func providerKeyForLease(leaseID string) string { return core.ProviderKeyForLease(leaseID) }
func waitForServerIP(ctx context.Context, client *core.HetznerClient, id int64) (Server, error) {
return core.WaitForServerIP(ctx, client, id)
}
func sshTargetFromConfig(cfg Config, host string) SSHTarget {
return core.SSHTargetFromConfig(cfg, host)
}
func waitForSSHReady(ctx context.Context, target *SSHTarget, stderr io.Writer, phase string, timeout time.Duration) error {
return core.WaitForSSHReady(ctx, target, stderr, phase, timeout)
}
func bootstrapWaitTimeout(cfg Config) time.Duration { return core.BootstrapWaitTimeout(cfg) }
func deleteServer(ctx context.Context, cfg Config, server Server) error {
return core.DeleteServer(ctx, cfg, server)
}
func parseServerID(s string) (int64, bool) { return core.ParseServerID(s) }
func blank(value, fallback string) string { return core.Blank(value, fallback) }
func useStoredTestboxKey(target *SSHTarget, leaseID string) {
if keyPath, err := core.TestboxKeyPath(leaseID); err == nil {
if _, statErr := os.Stat(keyPath); statErr == nil {
target.Key = keyPath
}
}
}
func findServerByAlias(servers []Server, id string) (Server, string, error) {
return core.FindServerByAlias(servers, id)
}
func removeLeaseClaim(leaseID string) { core.RemoveLeaseClaim(leaseID) }

View File

@ -3,30 +3,30 @@ package hetzner
import (
"flag"
"github.com/openclaw/crabbox/internal/cli"
core "github.com/openclaw/crabbox/internal/cli"
)
func init() {
cli.RegisterProvider(Provider{})
core.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{
func (Provider) Spec() core.ProviderSpec {
return core.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,
Kind: core.ProviderKindSSHLease,
Targets: []core.TargetSpec{{OS: core.TargetLinux}},
Features: core.FeatureSet{core.FeatureSSH, core.FeatureCrabboxSync, core.FeatureCleanup, core.FeatureDesktop, core.FeatureBrowser, core.FeatureCode, core.FeatureTailscale},
Coordinator: core.CoordinatorSupported,
}
}
func (Provider) RegisterFlags(*flag.FlagSet, cli.Config) any { return cli.NoProviderFlags() }
func (Provider) ApplyFlags(*cli.Config, *flag.FlagSet, any) error {
func (Provider) RegisterFlags(*flag.FlagSet, core.Config) any { return core.NoProviderFlags() }
func (Provider) ApplyFlags(*core.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
func (p Provider) Configure(cfg core.Config, rt core.Runtime) (core.Backend, error) {
return NewHetznerLeaseBackend(p.Spec(), cfg, rt), nil
}

View File

@ -1,4 +1,4 @@
package cli
package islo
import (
"context"
@ -11,6 +11,31 @@ import (
"time"
gosdk "github.com/islo-labs/go-sdk"
core "github.com/openclaw/crabbox/internal/cli"
)
type Config = core.Config
type ProviderSpec = core.ProviderSpec
type Runtime = core.Runtime
type Backend = core.Backend
type IsloConfig = core.IsloConfig
type WarmupRequest = core.WarmupRequest
type RunRequest = core.RunRequest
type RunResult = core.RunResult
type ListRequest = core.ListRequest
type LeaseView = core.LeaseView
type StatusRequest = core.StatusRequest
type StatusView = core.StatusView
type StopRequest = core.StopRequest
type Server = core.Server
type Repo = core.Repo
type ExitError = core.ExitError
type timingReport = core.TimingReport
type timingPhase = core.TimingPhase
const (
targetLinux = core.TargetLinux
NetworkPublic = core.NetworkPublic
)
const (

View File

@ -1,4 +1,4 @@
package cli
package islo
import (
"bytes"

View File

@ -1,4 +1,4 @@
package cli
package islo
import (
"bufio"

View File

@ -0,0 +1,59 @@
package islo
import (
"flag"
"io"
"time"
core "github.com/openclaw/crabbox/internal/cli"
)
type statusView = core.StatusView
func exit(code int, format string, args ...any) core.ExitError {
return core.Exit(code, format, args...)
}
func flagWasSet(fs *flag.FlagSet, name string) bool {
return core.FlagWasSet(fs, name)
}
func rejectDelegatedSyncOptions(provider string, req RunRequest) error {
return core.RejectDelegatedSyncOptions(provider, req)
}
func writeTimingJSON(w io.Writer, report timingReport) error {
return core.WriteTimingJSON(w, report)
}
func shouldUseShell(command []string) bool {
return core.ShouldUseShell(command)
}
func shellScriptFromArgv(command []string) string {
return core.ShellScriptFromArgv(command)
}
func newLeaseSlug(leaseID string) string {
return core.NewLeaseSlug(leaseID)
}
func normalizeLeaseSlug(value string) string {
return core.NormalizeLeaseSlug(value)
}
func blank(value, fallback string) string {
return core.Blank(value, fallback)
}
func claimLeaseForRepoProvider(leaseID, slug, provider, repoRoot string, idleTimeout time.Duration, reclaim bool) error {
return core.ClaimLeaseForRepoProvider(leaseID, slug, provider, repoRoot, idleTimeout, reclaim)
}
func resolveLeaseClaim(identifier string) (core.LeaseClaim, bool, error) {
return core.ResolveLeaseClaim(identifier)
}
func removeLeaseClaim(leaseID string) {
core.RemoveLeaseClaim(leaseID)
}

View File

@ -3,11 +3,11 @@ package islo
import (
"flag"
"github.com/openclaw/crabbox/internal/cli"
core "github.com/openclaw/crabbox/internal/cli"
)
func init() {
cli.RegisterProvider(Provider{})
core.RegisterProvider(Provider{})
}
type Provider struct{}
@ -16,21 +16,21 @@ func (Provider) Name() string { return "islo" }
func (Provider) Aliases() []string {
return nil
}
func (Provider) Spec() cli.ProviderSpec {
return cli.ProviderSpec{
func (Provider) Spec() core.ProviderSpec {
return core.ProviderSpec{
Name: "islo",
Kind: cli.ProviderKindDelegatedRun,
Targets: []cli.TargetSpec{{OS: "linux"}},
Kind: core.ProviderKindDelegatedRun,
Targets: []core.TargetSpec{{OS: core.TargetLinux}},
Features: nil,
Coordinator: cli.CoordinatorNever,
Coordinator: core.CoordinatorNever,
}
}
func (Provider) RegisterFlags(fs *flag.FlagSet, defaults cli.Config) any {
return cli.RegisterIsloProviderFlags(fs, defaults)
func (Provider) RegisterFlags(fs *flag.FlagSet, defaults core.Config) any {
return RegisterIsloProviderFlags(fs, defaults)
}
func (Provider) ApplyFlags(cfg *cli.Config, fs *flag.FlagSet, values any) error {
return cli.ApplyIsloProviderFlags(cfg, fs, values)
func (Provider) ApplyFlags(cfg *core.Config, fs *flag.FlagSet, values any) error {
return ApplyIsloProviderFlags(cfg, fs, values)
}
func (p Provider) Configure(cfg cli.Config, rt cli.Runtime) (cli.Backend, error) {
return cli.NewIsloBackend(p.Spec(), cfg, rt), nil
func (p Provider) Configure(cfg core.Config, rt core.Runtime) (core.Backend, error) {
return NewIsloBackend(p.Spec(), cfg, rt), nil
}

View File

@ -0,0 +1,55 @@
package shared
import (
"context"
"fmt"
"time"
core "github.com/openclaw/crabbox/internal/cli"
)
type DirectSSHBackend struct {
SpecValue core.ProviderSpec
Cfg core.Config
RT core.Runtime
}
func (b *DirectSSHBackend) Spec() core.ProviderSpec { return b.SpecValue }
func (b *DirectSSHBackend) CleanupServers(ctx context.Context, req core.CleanupRequest, servers []core.Server) error {
for _, s := range servers {
shouldDelete, reason := core.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 := core.DeleteServer(ctx, b.Cfg, s); err != nil {
return err
}
}
}
return nil
}
func (b *DirectSSHBackend) Touch(ctx context.Context, server core.Server, state string) core.Server {
return core.TouchDirectLeaseBestEffort(ctx, b.Cfg, server, state, b.RT.Stderr)
}
func AcquireAttemptsRetry(rt core.Runtime, keep bool, acquire func() (core.LeaseTarget, error)) (core.LeaseTarget, error) {
var lastErr error
attempts := core.AcquireAttempts(keep)
for attempt := 1; attempt <= attempts; attempt++ {
lease, err := acquire()
if err == nil {
return lease, nil
}
lastErr = err
if attempt == attempts || !core.IsBootstrapWaitError(err) {
return core.LeaseTarget{}, err
}
fmt.Fprintf(rt.Stderr, "warning: bootstrap failed; retrying with fresh lease: %v\n", err)
}
return core.LeaseTarget{}, lastErr
}

View File

@ -0,0 +1,97 @@
package ssh
import (
"context"
"fmt"
"io"
"time"
core "github.com/openclaw/crabbox/internal/cli"
"github.com/openclaw/crabbox/internal/providers/shared"
)
type Config = core.Config
type Runtime = core.Runtime
type ProviderSpec = core.ProviderSpec
type Backend = core.Backend
type AcquireRequest = core.AcquireRequest
type ResolveRequest = core.ResolveRequest
type ListRequest = core.ListRequest
type LeaseView = core.LeaseView
type ReleaseLeaseRequest = core.ReleaseLeaseRequest
type TouchRequest = core.TouchRequest
type CleanupRequest = core.CleanupRequest
type LeaseTarget = core.LeaseTarget
type Server = core.Server
type SSHTarget = core.SSHTarget
type staticLeaseBackend struct{ shared.DirectSSHBackend }
func NewStaticSSHLeaseBackend(spec ProviderSpec, cfg Config, rt Runtime) Backend {
cfg.Provider = "ssh"
return &staticLeaseBackend{DirectSSHBackend: shared.DirectSSHBackend{SpecValue: spec, Cfg: cfg, RT: rt}}
}
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)
}
func staticLease(cfg Config) (Server, SSHTarget, string, error) { return core.StaticLease(cfg) }
func serverSlug(server Server) string { return core.ServerSlug(server) }
func waitForSSH(ctx context.Context, target *SSHTarget, stderr io.Writer) error {
return core.WaitForSSH(ctx, target, stderr)
}
func exit(code int, format string, args ...any) core.ExitError {
return core.Exit(code, format, args...)
}
func removeLeaseClaim(leaseID string) { core.RemoveLeaseClaim(leaseID) }
func touchDirectLeaseLabels(labels map[string]string, cfg Config, state string, now time.Time) map[string]string {
return core.TouchDirectLeaseLabels(labels, cfg, state, now)
}

View File

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