fix: harden daytona auth and resource flags
Use the authenticated Daytona CLI profile as a Daytona auth fallback, reject snapshot-incompatible resource flags, and document the auth path. Verified locally with Go/docs gates and live Daytona CLI-auth run.
This commit is contained in:
parent
0f192f58a0
commit
0e3515023b
@ -20,6 +20,7 @@
|
||||
- Documented the prebaked runner image boundary: provider-owned AMIs/snapshots hold machine capabilities while repo/runtime caches stay in QA workflows or warm leases.
|
||||
- Added a provider backend registry and authoring guide so delegated and SSH-backed providers can live in provider-owned packages while core keeps command parsing, rendering, and capability validation.
|
||||
- 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 Daytona CLI profile auth fallback so `daytona login --api-key ...` can satisfy Crabbox Daytona auth without duplicating `DAYTONA_API_KEY`.
|
||||
- Added `provider: islo` for delegated Islo sandbox runs using the Islo Go SDK.
|
||||
- Added best-effort GitHub Actions run and workflow links for external Blacksmith Testbox rows in the portal.
|
||||
- Added GitHub Actions status badges, stuck filters, and copyable local stop commands for external Blacksmith Testbox rows in the portal.
|
||||
@ -53,6 +54,7 @@
|
||||
- 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 tar creation and Daytona toolbox upload stream from disk instead of buffering large archives in memory.
|
||||
- Fixed Daytona resource override handling so snapshot-only sandboxes reject generic `--class` and `--type` flags instead of accepting no-op compute settings.
|
||||
- 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.
|
||||
|
||||
@ -295,7 +295,9 @@ With `provider: blacksmith-testbox`, Crabbox delegates machine setup, sync, and
|
||||
With `provider: daytona`, Crabbox creates Daytona sandboxes from
|
||||
`daytona.snapshot`, uploads workspaces through Daytona toolbox file APIs, and
|
||||
runs commands through Daytona toolbox process APIs. `crabbox ssh` mints
|
||||
short-lived Daytona SSH tokens and redacts those tokens from output. With
|
||||
short-lived Daytona SSH tokens and redacts those tokens from output. Daytona
|
||||
auth can come from `DAYTONA_API_KEY` / `DAYTONA_JWT_TOKEN` env or an
|
||||
authenticated Daytona CLI profile created by `daytona login --api-key`. With
|
||||
`provider: islo`, Crabbox delegates sandbox setup and command execution to the
|
||||
Islo Go SDK; sync is delegated and `--sync-only`, `--checksum`, and
|
||||
`--force-sync-large` are unsupported.
|
||||
|
||||
@ -13,7 +13,16 @@ requested.
|
||||
|
||||
## Auth
|
||||
|
||||
Set one of:
|
||||
Run Daytona's CLI login:
|
||||
|
||||
```sh
|
||||
daytona login --api-key ...
|
||||
```
|
||||
|
||||
Crabbox uses the active Daytona CLI profile when no explicit Daytona auth
|
||||
environment variables are set.
|
||||
|
||||
Alternatively, set one of:
|
||||
|
||||
```sh
|
||||
export DAYTONA_API_KEY=...
|
||||
@ -28,6 +37,7 @@ export DAYTONA_ORGANIZATION_ID=...
|
||||
|
||||
`DAYTONA_ORGANIZATION_ID` is required when JWT auth is used. `DAYTONA_API_URL`
|
||||
or `daytona.apiUrl` can override the default `https://app.daytona.io/api`.
|
||||
Explicit environment or Crabbox config values override the Daytona CLI profile.
|
||||
|
||||
## Config
|
||||
|
||||
|
||||
@ -30,7 +30,16 @@ crabbox stop --provider daytona blue-lobster
|
||||
|
||||
## Auth
|
||||
|
||||
Use an API key:
|
||||
Use the Daytona CLI login:
|
||||
|
||||
```sh
|
||||
daytona login --api-key ...
|
||||
```
|
||||
|
||||
Crabbox reads the active Daytona CLI profile when no Daytona auth environment
|
||||
variables are set.
|
||||
|
||||
You can also use explicit environment auth with an API key:
|
||||
|
||||
```sh
|
||||
export DAYTONA_API_KEY=...
|
||||
@ -44,6 +53,7 @@ export DAYTONA_ORGANIZATION_ID=...
|
||||
```
|
||||
|
||||
`DAYTONA_ORGANIZATION_ID` is required with JWT auth.
|
||||
Explicit environment or Crabbox config values override the Daytona CLI profile.
|
||||
|
||||
## Config
|
||||
|
||||
|
||||
@ -85,6 +85,29 @@ func TestLeaseCreateFlagsApplySelectedProviderFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaseCreateFlagsRejectDaytonaResourceNoops(t *testing.T) {
|
||||
defaults := baseConfig()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "class", args: []string{"--provider", "daytona", "--class", "standard"}},
|
||||
{name: "type", args: []string{"--provider", "daytona", "--type", "large"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fs := newFlagSet("test", io.Discard)
|
||||
values := registerLeaseCreateFlags(fs, defaults)
|
||||
if err := parseFlags(fs, tc.args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := defaults
|
||||
if err := applyLeaseCreateFlags(&cfg, fs, values); err == nil {
|
||||
t.Fatalf("expected %v to be rejected", tc.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequestedCapabilitiesUsesProviderSpec(t *testing.T) {
|
||||
cfg := baseConfig()
|
||||
cfg.Provider = "blacksmith-testbox"
|
||||
|
||||
@ -171,6 +171,14 @@ func (testDaytonaProvider) RegisterFlags(fs *flag.FlagSet, defaults Config) any
|
||||
}
|
||||
}
|
||||
func (testDaytonaProvider) ApplyFlags(cfg *Config, fs *flag.FlagSet, values any) error {
|
||||
if cfg.Provider == "daytona" {
|
||||
if flagWasSet(fs, "class") {
|
||||
return exit(2, "--class is not supported for provider=daytona")
|
||||
}
|
||||
if flagWasSet(fs, "type") {
|
||||
return exit(2, "--type is not supported for provider=daytona")
|
||||
}
|
||||
}
|
||||
v, ok := values.(testDaytonaFlagValues)
|
||||
if !ok {
|
||||
return nil
|
||||
|
||||
@ -3,6 +3,7 @@ package daytona
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"flag"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@ -161,6 +162,118 @@ func TestDaytonaAuthRequiresOrganizationForJWT(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaytonaAuthFallsBackToCLIConfig(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(configDir, "daytona", "config.json")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(`{
|
||||
"activeProfile": "prod",
|
||||
"profiles": [
|
||||
{
|
||||
"id": "dev",
|
||||
"name": "dev",
|
||||
"api": {"url": "https://dev.example/api", "key": "wrong"}
|
||||
},
|
||||
{
|
||||
"id": "prod",
|
||||
"name": "prod",
|
||||
"api": {"url": "https://daytona.example/api/", "key": "cli-api-key"},
|
||||
"activeOrganizationId": "org-123"
|
||||
}
|
||||
]
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := baseConfig()
|
||||
cfg.Provider = daytonaProvider
|
||||
cfg.Daytona.APIKey = ""
|
||||
cfg.Daytona.JWTToken = ""
|
||||
cfg.Daytona.OrganizationID = ""
|
||||
cfg.Daytona.APIURL = "https://app.daytona.io/api"
|
||||
auth, err := daytonaAuthConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if auth.APIKey != "cli-api-key" || auth.OrganizationID != "org-123" {
|
||||
t.Fatalf("auth=%#v", auth)
|
||||
}
|
||||
if got := daytonaAPIURL(cfg, auth); got != "https://daytona.example/api" {
|
||||
t.Fatalf("api url=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaytonaEnvAuthOverridesCLIConfig(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(configDir, "daytona", "config.json")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(`{
|
||||
"activeProfile": "initial",
|
||||
"profiles": [
|
||||
{"id": "initial", "name": "initial", "api": {"url": "https://cli.example/api", "key": "cli-api-key"}}
|
||||
]
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := baseConfig()
|
||||
cfg.Provider = daytonaProvider
|
||||
cfg.Daytona.APIKey = "env-api-key"
|
||||
cfg.Daytona.APIURL = "https://env.example/api"
|
||||
auth, err := daytonaAuthConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if auth.APIKey != "env-api-key" {
|
||||
t.Fatalf("api key=%q", auth.APIKey)
|
||||
}
|
||||
if got := daytonaAPIURL(cfg, auth); got != "https://env.example/api" {
|
||||
t.Fatalf("api url=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDaytonaProviderFlagsRejectsResourceNoops(t *testing.T) {
|
||||
cfg := baseConfig()
|
||||
cfg.Provider = daytonaProvider
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "class", args: []string{"--class", "standard"}},
|
||||
{name: "type", args: []string{"--type", "large"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
fs.String("class", "", "")
|
||||
fs.String("type", "", "")
|
||||
values := RegisterDaytonaProviderFlags(fs, cfg)
|
||||
if err := fs.Parse(tc.args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := ApplyDaytonaProviderFlags(&cfg, fs, values)
|
||||
if err == nil || !strings.Contains(err.Error(), "provider=daytona") {
|
||||
t.Fatalf("err=%v, want daytona resource flag rejection", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaytonaSSHTargetUsesReturnedSSHCommand(t *testing.T) {
|
||||
cfg := baseConfig()
|
||||
cfg.Daytona.SSHGatewayHost = "fallback.example"
|
||||
|
||||
@ -38,6 +38,14 @@ func RegisterDaytonaProviderFlags(fs *flag.FlagSet, defaults Config) any {
|
||||
}
|
||||
|
||||
func ApplyDaytonaProviderFlags(cfg *Config, fs *flag.FlagSet, values any) error {
|
||||
if cfg.Provider == daytonaProvider {
|
||||
if flagWasSet(fs, "class") {
|
||||
return exit(2, "--class is not supported for provider=daytona; choose CPU, memory, and disk in the Daytona snapshot")
|
||||
}
|
||||
if flagWasSet(fs, "type") {
|
||||
return exit(2, "--type is not supported for provider=daytona; choose CPU, memory, and disk in the Daytona snapshot")
|
||||
}
|
||||
}
|
||||
v, ok := values.(daytonaFlagValues)
|
||||
if !ok {
|
||||
return nil
|
||||
|
||||
@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -35,12 +37,14 @@ type daytonaSDKClient struct {
|
||||
orgID string
|
||||
}
|
||||
|
||||
const defaultDaytonaAPIURL = "https://app.daytona.io/api"
|
||||
|
||||
func newDaytonaClient(cfg Config, rt Runtime) (daytonaAPI, error) {
|
||||
auth, err := daytonaAuthConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apiURL := strings.TrimRight(blank(cfg.Daytona.APIURL, "https://app.daytona.io/api"), "/")
|
||||
apiURL := daytonaAPIURL(cfg, auth)
|
||||
apiCfg := daytona.NewConfiguration()
|
||||
apiCfg.Servers = daytona.ServerConfigurations{{URL: apiURL}}
|
||||
if rt.HTTP != nil {
|
||||
@ -53,6 +57,7 @@ type daytonaAuth struct {
|
||||
APIKey string
|
||||
JWTToken string
|
||||
OrganizationID string
|
||||
APIURL string
|
||||
}
|
||||
|
||||
func (a daytonaAuth) token() string {
|
||||
@ -67,9 +72,17 @@ func daytonaAuthConfig(cfg Config) (daytonaAuth, error) {
|
||||
APIKey: strings.TrimSpace(cfg.Daytona.APIKey),
|
||||
JWTToken: strings.TrimSpace(cfg.Daytona.JWTToken),
|
||||
OrganizationID: strings.TrimSpace(cfg.Daytona.OrganizationID),
|
||||
APIURL: strings.TrimSpace(cfg.Daytona.APIURL),
|
||||
}
|
||||
if auth.APIKey == "" && auth.JWTToken == "" {
|
||||
return daytonaAuth{}, exit(3, "provider=daytona requires DAYTONA_API_KEY or DAYTONA_JWT_TOKEN")
|
||||
if cliAuth, err := daytonaCLIAuthConfig(); err == nil {
|
||||
auth = mergeDaytonaCLIAuth(auth, cliAuth)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return daytonaAuth{}, err
|
||||
}
|
||||
}
|
||||
if auth.APIKey == "" && auth.JWTToken == "" {
|
||||
return daytonaAuth{}, exit(3, "provider=daytona requires DAYTONA_API_KEY, DAYTONA_JWT_TOKEN, or an authenticated Daytona CLI profile")
|
||||
}
|
||||
if auth.APIKey == "" && auth.JWTToken != "" && auth.OrganizationID == "" {
|
||||
return daytonaAuth{}, exit(3, "provider=daytona with DAYTONA_JWT_TOKEN requires DAYTONA_ORGANIZATION_ID")
|
||||
@ -77,6 +90,17 @@ func daytonaAuthConfig(cfg Config) (daytonaAuth, error) {
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
func daytonaAPIURL(cfg Config, auth daytonaAuth) string {
|
||||
configured := strings.TrimSpace(cfg.Daytona.APIURL)
|
||||
if configured != "" && configured != defaultDaytonaAPIURL {
|
||||
return strings.TrimRight(configured, "/")
|
||||
}
|
||||
if auth.APIURL != "" {
|
||||
return strings.TrimRight(auth.APIURL, "/")
|
||||
}
|
||||
return strings.TrimRight(blank(configured, defaultDaytonaAPIURL), "/")
|
||||
}
|
||||
|
||||
func newDaytonaToolboxClient(cfg Config) (*sdkdaytona.Client, error) {
|
||||
auth, err := daytonaAuthConfig(cfg)
|
||||
if err != nil {
|
||||
@ -86,11 +110,113 @@ func newDaytonaToolboxClient(cfg Config) (*sdkdaytona.Client, error) {
|
||||
APIKey: auth.APIKey,
|
||||
JWTToken: auth.JWTToken,
|
||||
OrganizationID: auth.OrganizationID,
|
||||
APIUrl: strings.TrimRight(blank(cfg.Daytona.APIURL, "https://app.daytona.io/api"), "/"),
|
||||
APIUrl: daytonaAPIURL(cfg, auth),
|
||||
Target: strings.TrimSpace(cfg.Daytona.Target),
|
||||
})
|
||||
}
|
||||
|
||||
type daytonaCLIConfig struct {
|
||||
ActiveProfile string `json:"activeProfile"`
|
||||
Profiles []daytonaCLIProfile `json:"profiles"`
|
||||
}
|
||||
|
||||
type daytonaCLIProfile struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ActiveOrganizationID string `json:"activeOrganizationId"`
|
||||
API struct {
|
||||
URL string `json:"url"`
|
||||
Key string `json:"key"`
|
||||
} `json:"api"`
|
||||
}
|
||||
|
||||
func daytonaCLIAuthConfig() (daytonaAuth, error) {
|
||||
paths := daytonaCLIConfigPaths()
|
||||
for _, path := range paths {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return daytonaAuth{}, fmt.Errorf("read Daytona CLI config %s: %w", path, err)
|
||||
}
|
||||
auth, err := parseDaytonaCLIAuthConfig(data)
|
||||
if err != nil {
|
||||
return daytonaAuth{}, fmt.Errorf("read Daytona CLI config %s: %w", path, err)
|
||||
}
|
||||
if auth.APIKey != "" || auth.JWTToken != "" {
|
||||
return auth, nil
|
||||
}
|
||||
}
|
||||
return daytonaAuth{}, os.ErrNotExist
|
||||
}
|
||||
|
||||
func daytonaCLIConfigPaths() []string {
|
||||
var candidates []string
|
||||
if dir, err := os.UserConfigDir(); err == nil && dir != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(dir, "daytona", "config.json"),
|
||||
filepath.Join(dir, "Daytona", "config.json"),
|
||||
)
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".config", "daytona", "config.json"),
|
||||
filepath.Join(home, ".daytona", "config.json"),
|
||||
)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
if candidate == "" || seen[candidate] {
|
||||
continue
|
||||
}
|
||||
seen[candidate] = true
|
||||
out = append(out, candidate)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseDaytonaCLIAuthConfig(data []byte) (daytonaAuth, error) {
|
||||
var config daytonaCLIConfig
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return daytonaAuth{}, err
|
||||
}
|
||||
var selected *daytonaCLIProfile
|
||||
for i := range config.Profiles {
|
||||
profile := &config.Profiles[i]
|
||||
if config.ActiveProfile == "" || profile.ID == config.ActiveProfile || profile.Name == config.ActiveProfile {
|
||||
selected = profile
|
||||
break
|
||||
}
|
||||
}
|
||||
if selected == nil && len(config.Profiles) > 0 {
|
||||
selected = &config.Profiles[0]
|
||||
}
|
||||
if selected == nil {
|
||||
return daytonaAuth{}, nil
|
||||
}
|
||||
return daytonaAuth{
|
||||
APIKey: strings.TrimSpace(selected.API.Key),
|
||||
OrganizationID: strings.TrimSpace(selected.ActiveOrganizationID),
|
||||
APIURL: strings.TrimSpace(selected.API.URL),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func mergeDaytonaCLIAuth(auth, cliAuth daytonaAuth) daytonaAuth {
|
||||
if auth.APIKey == "" && auth.JWTToken == "" {
|
||||
auth.APIKey = cliAuth.APIKey
|
||||
auth.JWTToken = cliAuth.JWTToken
|
||||
}
|
||||
if auth.OrganizationID == "" {
|
||||
auth.OrganizationID = cliAuth.OrganizationID
|
||||
}
|
||||
if auth.APIURL == "" || auth.APIURL == defaultDaytonaAPIURL {
|
||||
auth.APIURL = cliAuth.APIURL
|
||||
}
|
||||
return auth
|
||||
}
|
||||
|
||||
func (c *daytonaSDKClient) ctx(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, daytona.ContextAccessToken, c.token)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user