refactor: split provider backends
This commit is contained in:
parent
ffb14bf5ba
commit
379b4f4faf
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -225,3 +225,7 @@ func blank(value, fallback string) string {
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func Blank(value, fallback string) string {
|
||||
return blank(value, fallback)
|
||||
}
|
||||
|
||||
@ -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...)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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 := ""
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 ""
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
219
internal/providers/aws/backend.go
Normal file
219
internal/providers/aws/backend.go
Normal 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) }
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
25
internal/providers/blacksmith/test_helpers_test.go
Normal file
25
internal/providers/blacksmith/test_helpers_test.go
Normal 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)
|
||||
}
|
||||
@ -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")
|
||||
214
internal/providers/daytona/backend_run_test.go
Normal file
214
internal/providers/daytona/backend_run_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package cli
|
||||
package daytona
|
||||
|
||||
import (
|
||||
"context"
|
||||
@ -1,4 +1,4 @@
|
||||
package cli
|
||||
package daytona
|
||||
|
||||
import (
|
||||
"context"
|
||||
193
internal/providers/daytona/core.go
Normal file
193
internal/providers/daytona/core.go
Normal 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()
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
130
internal/providers/daytona/upload.go
Normal file
130
internal/providers/daytona/upload.go
Normal 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()
|
||||
}
|
||||
192
internal/providers/hetzner/backend.go
Normal file
192
internal/providers/hetzner/backend.go
Normal 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) }
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
@ -1,4 +1,4 @@
|
||||
package cli
|
||||
package islo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@ -1,4 +1,4 @@
|
||||
package cli
|
||||
package islo
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
59
internal/providers/islo/core.go
Normal file
59
internal/providers/islo/core.go
Normal 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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
55
internal/providers/shared/direct.go
Normal file
55
internal/providers/shared/direct.go
Normal 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
|
||||
}
|
||||
97
internal/providers/ssh/backend.go
Normal file
97
internal/providers/ssh/backend.go
Normal 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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user