diff --git a/README.md b/README.md index 12abefd..f5a1c48 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,19 @@ under `machines ssh` and `machines executions`. The generated contract is defined by the [OpenAPI snapshot](https://storage.googleapis.com/stainless-sdk-openapi-specs/dedalus-labs/dedalus-c0e28b234478af75f61bcf42f5f32818d96ec93b1d8239fa0ff1c801c0e4b64f.yml). +### Selecting the create API + +`dedalus machines create` uses `/v1/machines` by default. To request controller +placement from a server that supports `/v2/machines`, select v2 explicitly: + +```sh +dedalus machines create --api-version v2 --vcpu 2 --memory-mib 4096 --storage-gib 20 +``` + +The request body and authentication are unchanged. V2 returns acceptance before +the Machine is running. Use the existing retrieve and lifecycle commands for the +returned Machine. An unavailable v2 endpoint returns an error without retrying v1. + ### Running commands on a machine `exec` is an alias for `executions`. Pass the executable and its arguments after diff --git a/pkg/cmd/create_version_test.go b/pkg/cmd/create_version_test.go new file mode 100644 index 0000000..b8157d0 --- /dev/null +++ b/pkg/cmd/create_version_test.go @@ -0,0 +1,85 @@ +// @custom start +// Copyright (c) 2026 Dedalus Labs, Inc. All rights reserved. + +package cmd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A create selects exactly one API version, preserving its body and credentials. +// Invalid versions send nothing. An unavailable v2 never triggers a v1 create. +func TestCreateAPIVersion(t *testing.T) { + binary := filepath.Join(t.TempDir(), "dedalus") + if runtime.GOOS == "windows" { + binary += ".exe" + } + output, err := exec.Command("go", "build", "-o", binary, "../../cmd/dedalus").CombinedOutput() + require.NoError(t, err, "%s", output) + for _, test := range []struct { + name, version, prefix, stdin, path, errorText string + status, requests int + }{ + {name: "default", path: "/v1/machines", status: 202, requests: 1}, + {name: "explicit v1", version: "v1", path: "/v1/machines", status: 202, requests: 1}, + {name: "v2", version: "v2", path: "/v2/machines", status: 202, requests: 1}, + {name: "v2 gateway prefix", version: "v2", prefix: "/dcs", path: "/dcs/v2/machines", status: 202, requests: 1}, + {name: "v2 piped body", version: "v2", stdin: `{"vcpu":2,"memory_mib":2048,"storage_gib":20,"autosleep":"never"}`, path: "/v2/machines", status: 202, requests: 1}, + {name: "invalid", version: "v3", errorText: "api-version must be v1 or v2"}, + {name: "v2 unavailable", version: "v2", path: "/v2/machines", status: 404, requests: 1, errorText: "404"}, + } { + t.Run(test.name, func(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, test.path, r.URL.Path) + assert.Equal(t, "Bearer test-key", r.Header.Get("Authorization")) + var body map[string]any + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, map[string]any{"vcpu": float64(2), "memory_mib": float64(2048), "storage_gib": float64(20), "autosleep": "never"}, body) + w.Header().Set("Content-Type", "application/json") + if test.status == 0 { + t.Error("invalid version reached the API") + w.WriteHeader(http.StatusBadRequest) + return + } + w.WriteHeader(test.status) + _, err := w.Write([]byte(`{"machine_id":"dm-test","phase":"pending"}`)) + assert.NoError(t, err) + })) + defer server.Close() + args := []string{"--base-url", server.URL + test.prefix, "--api-key", "test-key", "machines", "create"} + if test.version != "" { + args = append(args, "--api-version", test.version) + } + if test.stdin == "" { + args = append(args, "--vcpu", "2", "--memory-mib", "2048", "--storage-gib", "20", "--autosleep", "never") + } + command := exec.Command(binary, args...) + command.Stdin = strings.NewReader(test.stdin) + output, err := command.CombinedOutput() + if test.errorText != "" { + require.Error(t, err, "%s", output) + require.Contains(t, string(output), test.errorText) + } else { + require.NoError(t, err, "%s", output) + require.Contains(t, string(output), "dm-test") + } + require.Equal(t, int32(test.requests), calls.Load()) + }) + } +} + +// @custom end diff --git a/pkg/cmd/machine.go b/pkg/cmd/machine.go index 73259d3..af125e1 100644 --- a/pkg/cmd/machine.go +++ b/pkg/cmd/machine.go @@ -19,6 +19,19 @@ var machinesCreate = cli.Command{ Usage: "Create machine", Suggest: true, Flags: []cli.Flag{ + // @custom start + &cli.StringFlag{ + Name: "api-version", + Usage: "Create endpoint version: v1 or v2 (controller placement)", + Value: "v1", + Validator: func(version string) error { + if version != "v1" && version != "v2" { + return fmt.Errorf("api-version must be v1 or v2") + } + return nil + }, + }, + // @custom end &requestflag.Flag[string]{ Name: "autosleep", Usage: `Idle window before autosleep. Accepts fixed duration units like 30s, 30m, 2h, 7d3h4s, or 1w3d, raw seconds ("1800"), or never to disable.`, @@ -187,8 +200,10 @@ func handleMachinesCreate(ctx context.Context, cmd *cli.Command) error { params := dedalus.MachineNewParams{} var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Machines.New(ctx, params, options...) + // @custom start + // Use the selected endpoint with the existing body, transport, and errors. + err = client.Post(ctx, cmd.String("api-version")+"/machines", params, &res, options...) + // @custom end if err != nil { return err }