953f016549
Add cross-cloud compute-provider migration to the CLI, closing the gap
where the canvas can migrate a workspace's compute box across clouds
(AWS <-> Hetzner <-> GCP) but the CLI could not (molecule-mcp-server#64).
- 'molecule workspace migrate-provider <id> --to <p> [--from <p>] --confirm'
POSTs to the CP-admin POST /api/v1/admin/workspaces/:id/migrate-provider
endpoint via cpAdminClient() (CP-admin bearer + MOLECULE_CP_URL — the
tenant Org API Key has no standing on the control plane). Validates the
provider enum + from!=to client-side, requires --from-instance-id for
non-AWS sources, and refuses without --confirm (a real migration mutates
two clouds — never auto-confirmed).
- 'molecule workspace migration-status <id>' GETs the same path and prints
the {migration:{state,from_provider,to_provider,detail,...}, terminal}
record.
New client methods MigrateProvider / GetMigrationStatus + MigrateProviderRequest
mirror the existing CP-admin org methods. Tests cover the URL/method/body/auth
(client httptest capture) and the provider validation helper.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
482 lines
16 KiB
Go
482 lines
16 KiB
Go
package client
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
// capture records what the test server received for one request.
|
|
type capture struct {
|
|
method string
|
|
path string
|
|
query string
|
|
auth string
|
|
org string
|
|
body string
|
|
}
|
|
|
|
// newCaptureServer returns an httptest server that records the first request
|
|
// into *cap and replies with replyJSON (status 200).
|
|
func newCaptureServer(t *testing.T, cap *capture, replyJSON string) *httptest.Server {
|
|
t.Helper()
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
b, _ := io.ReadAll(r.Body)
|
|
cap.method = r.Method
|
|
cap.path = r.URL.Path
|
|
cap.query = r.URL.RawQuery
|
|
cap.auth = r.Header.Get("Authorization")
|
|
cap.org = r.Header.Get("X-Molecule-Org-Id")
|
|
cap.body = string(b)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if replyJSON == "" {
|
|
replyJSON = "{}"
|
|
}
|
|
_, _ = io.WriteString(w, replyJSON)
|
|
}))
|
|
}
|
|
|
|
// TestClientAuthHeaders proves NewWithAuth attaches the bearer + org id on
|
|
// every verb (the broader half of the auth-bug fix — the client helpers used
|
|
// to send no Authorization header either).
|
|
func TestClientAuthHeaders(t *testing.T) {
|
|
var cap capture
|
|
srv := newCaptureServer(t, &cap, `[]`)
|
|
defer srv.Close()
|
|
|
|
p := NewWithAuth(srv.URL, "key-xyz", "org_1")
|
|
if _, err := p.ListWorkspaces(); err != nil {
|
|
t.Fatalf("ListWorkspaces: %v", err)
|
|
}
|
|
if cap.auth != "Bearer key-xyz" {
|
|
t.Errorf("Authorization = %q, want %q", cap.auth, "Bearer key-xyz")
|
|
}
|
|
if cap.org != "org_1" {
|
|
t.Errorf("X-Molecule-Org-Id = %q, want %q", cap.org, "org_1")
|
|
}
|
|
}
|
|
|
|
// TestPathSegmentEscaping proves caller-supplied IDs are url.PathEscape'd into
|
|
// their path segment, so an ID containing '/', '?' or '#' cannot alter the
|
|
// endpoint, slip into the query, or open a fragment. Covers the platform.go +
|
|
// management.go methods that interpolate IDs.
|
|
func TestPathSegmentEscaping(t *testing.T) {
|
|
// An ID engineered to break naive concatenation: a slash to escape the
|
|
// segment, a '?' to start a bogus query, a '#' to start a fragment.
|
|
const evil = "ws/../admin?x=1#frag"
|
|
|
|
cases := []struct {
|
|
name string
|
|
reply string
|
|
call func(p *Platform) error
|
|
wantPath string // decoded path the server must see (single segment intact)
|
|
}{
|
|
{"GetWorkspace", `{}`, func(p *Platform) error { _, e := p.GetWorkspace(evil); return e }, "/workspaces/" + evil},
|
|
{"RestartWorkspace", `{}`, func(p *Platform) error { return p.RestartWorkspace(evil) }, "/workspaces/" + evil + "/restart"},
|
|
{"ListWorkspaceAgents", `[]`, func(p *Platform) error { _, e := p.ListWorkspaceAgents(evil); return e }, "/workspaces/" + evil + "/agents"},
|
|
{"GetAgent", `{}`, func(p *Platform) error { _, e := p.GetAgent(evil); return e }, "/agents/" + evil},
|
|
{"GetPeers", `[]`, func(p *Platform) error { _, e := p.GetPeers(evil); return e }, "/registry/" + evil + "/peers"},
|
|
{"GetDelegations", `[]`, func(p *Platform) error { _, e := p.GetDelegations(evil); return e }, "/workspaces/" + evil + "/delegations"},
|
|
{"PauseWorkspace", `{}`, func(p *Platform) error { return p.PauseWorkspace(evil) }, "/workspaces/" + evil + "/pause"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var cap capture
|
|
srv := newCaptureServer(t, &cap, tc.reply)
|
|
defer srv.Close()
|
|
p := NewWithAuth(srv.URL, "k", "o")
|
|
if err := tc.call(p); err != nil {
|
|
t.Fatalf("call: %v", err)
|
|
}
|
|
// net/http decodes the escaped path back to the original segment,
|
|
// so the server sees the full ID as one intact path — not split by
|
|
// '/', and with '?'/'#' NOT promoted to query/fragment.
|
|
if cap.path != tc.wantPath {
|
|
t.Errorf("decoded path = %q, want %q", cap.path, tc.wantPath)
|
|
}
|
|
if cap.query != "" {
|
|
t.Errorf("query = %q, want empty (ID '?' must not leak into the query)", cap.query)
|
|
}
|
|
})
|
|
}
|
|
|
|
// DeleteWorkspace appends its own ?confirm=true; the escaped ID must not
|
|
// inject extra query params.
|
|
t.Run("DeleteWorkspace", func(t *testing.T) {
|
|
var cap capture
|
|
srv := newCaptureServer(t, &cap, `{}`)
|
|
defer srv.Close()
|
|
p := NewWithAuth(srv.URL, "k", "o")
|
|
if err := p.DeleteWorkspace(evil); err != nil {
|
|
t.Fatalf("DeleteWorkspace: %v", err)
|
|
}
|
|
if cap.path != "/workspaces/"+evil {
|
|
t.Errorf("decoded path = %q, want %q", cap.path, "/workspaces/"+evil)
|
|
}
|
|
if cap.query != "confirm=true" {
|
|
t.Errorf("query = %q, want exactly confirm=true (ID must not inject params)", cap.query)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestManagementRequestConstruction is the table-driven proof that each new
|
|
// management verb builds the right method, path, and body. The handler shapes
|
|
// are aligned to the live workspace-server / controlplane handlers.
|
|
func TestManagementRequestConstruction(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
reply string
|
|
call func(p *Platform) error
|
|
wantMethod string
|
|
wantPath string
|
|
wantBody string // exact JSON when non-empty; "" = don't assert body
|
|
}{
|
|
{
|
|
// org list now hits the CP ADMIN surface (the customer
|
|
// /api/v1/orgs is WorkOS-session-gated and 401s a bearer CLI).
|
|
name: "ListOrgs",
|
|
reply: `{"limit":100,"offset":0,"orgs":[]}`,
|
|
call: func(p *Platform) error { _, e := p.ListOrgs(); return e },
|
|
wantMethod: "GET",
|
|
wantPath: "/api/v1/admin/orgs",
|
|
},
|
|
{
|
|
// org create hits the CP ADMIN surface and carries the required
|
|
// owner_user_id (the admin route has no implicit session).
|
|
name: "CreateOrg",
|
|
reply: `{"slug":"acme","name":"Acme"}`,
|
|
call: func(p *Platform) error {
|
|
_, e := p.CreateOrg(CreateOrgRequest{Slug: "acme", Name: "Acme", OwnerUserID: "user_123"})
|
|
return e
|
|
},
|
|
wantMethod: "POST",
|
|
wantPath: "/api/v1/admin/orgs",
|
|
wantBody: `{"slug":"acme","name":"Acme","owner_user_id":"user_123"}`,
|
|
},
|
|
{
|
|
name: "CreateOrgFromTemplate",
|
|
reply: `[]`,
|
|
call: func(p *Platform) error {
|
|
_, e := p.CreateOrgFromTemplate(ImportOrgRequest{Dir: "tmpl", Mode: "reconcile"})
|
|
return e
|
|
},
|
|
wantMethod: "POST",
|
|
wantPath: "/org/import",
|
|
wantBody: `{"dir":"tmpl","mode":"reconcile"}`,
|
|
},
|
|
{
|
|
name: "GetAllowlist",
|
|
reply: `{"plugins":[]}`,
|
|
call: func(p *Platform) error { _, e := p.GetAllowlist("org_1"); return e },
|
|
wantMethod: "GET",
|
|
wantPath: "/orgs/org_1/plugins/allowlist",
|
|
},
|
|
{
|
|
name: "ListOrgTokens",
|
|
reply: `[]`,
|
|
call: func(p *Platform) error { _, e := p.ListOrgTokens(); return e },
|
|
wantMethod: "GET",
|
|
wantPath: "/org/tokens",
|
|
},
|
|
{
|
|
name: "CreateOrgToken",
|
|
reply: `{"id":"t1","auth_token":"secret"}`,
|
|
call: func(p *Platform) error { _, e := p.CreateOrgToken("ci"); return e },
|
|
wantMethod: "POST",
|
|
wantPath: "/org/tokens",
|
|
wantBody: `{"name":"ci"}`,
|
|
},
|
|
{
|
|
name: "RevokeOrgToken",
|
|
reply: ``,
|
|
call: func(p *Platform) error { return p.RevokeOrgToken("t1") },
|
|
wantMethod: "DELETE",
|
|
wantPath: "/org/tokens/t1",
|
|
},
|
|
{
|
|
name: "PauseWorkspace",
|
|
reply: ``,
|
|
call: func(p *Platform) error { return p.PauseWorkspace("ws_1") },
|
|
wantMethod: "POST",
|
|
wantPath: "/workspaces/ws_1/pause",
|
|
},
|
|
{
|
|
name: "ResumeWorkspace",
|
|
reply: ``,
|
|
call: func(p *Platform) error { return p.ResumeWorkspace("ws_1") },
|
|
wantMethod: "POST",
|
|
wantPath: "/workspaces/ws_1/resume",
|
|
},
|
|
{
|
|
name: "GetBudget",
|
|
reply: `{"budget_limits":{}}`,
|
|
call: func(p *Platform) error { _, e := p.GetBudget("ws_1"); return e },
|
|
wantMethod: "GET",
|
|
wantPath: "/workspaces/ws_1/budget",
|
|
},
|
|
{
|
|
name: "SetBudget",
|
|
reply: `{}`,
|
|
call: func(p *Platform) error {
|
|
v := int64(50000)
|
|
_, e := p.SetBudget("ws_1", map[string]*int64{"monthly": &v})
|
|
return e
|
|
},
|
|
wantMethod: "PATCH",
|
|
wantPath: "/workspaces/ws_1/budget",
|
|
wantBody: `{"budget_limits":{"monthly":50000}}`,
|
|
},
|
|
{
|
|
name: "SetBudget_clear",
|
|
reply: `{}`,
|
|
call: func(p *Platform) error { _, e := p.SetBudget("ws_1", map[string]*int64{"daily": nil}); return e },
|
|
wantMethod: "PATCH",
|
|
wantPath: "/workspaces/ws_1/budget",
|
|
wantBody: `{"budget_limits":{"daily":null}}`,
|
|
},
|
|
{
|
|
name: "SetBillingMode",
|
|
reply: `{}`,
|
|
call: func(p *Platform) error { _, e := p.SetBillingMode("ws_1", "byok"); return e },
|
|
wantMethod: "PUT",
|
|
wantPath: "/admin/workspaces/ws_1/llm-billing-mode",
|
|
wantBody: `{"mode":"byok"}`,
|
|
},
|
|
{
|
|
name: "SetBillingMode_clear",
|
|
reply: `{}`,
|
|
call: func(p *Platform) error { _, e := p.SetBillingMode("ws_1", ""); return e },
|
|
wantMethod: "PUT",
|
|
wantPath: "/admin/workspaces/ws_1/llm-billing-mode",
|
|
wantBody: `{"mode":null}`,
|
|
},
|
|
{
|
|
// Body must be an empty JSON object ({}), never the literal `null`
|
|
// that a nil body would marshal to (a struct/map handler can reject
|
|
// null). {} matches sibling tooling.
|
|
name: "MintWorkspaceToken",
|
|
reply: `{"auth_token":"x","workspace_id":"ws_1"}`,
|
|
call: func(p *Platform) error { _, e := p.MintWorkspaceToken("ws_1"); return e },
|
|
wantMethod: "POST",
|
|
wantPath: "/workspaces/ws_1/tokens",
|
|
wantBody: `{}`,
|
|
},
|
|
{
|
|
name: "ListWorkspaceSecrets",
|
|
reply: `[]`,
|
|
call: func(p *Platform) error { _, e := p.ListWorkspaceSecrets("ws_1"); return e },
|
|
wantMethod: "GET",
|
|
wantPath: "/workspaces/ws_1/secrets",
|
|
},
|
|
{
|
|
name: "SetWorkspaceSecret",
|
|
reply: `{}`,
|
|
call: func(p *Platform) error { return p.SetWorkspaceSecret("ws_1", "K", "V") },
|
|
wantMethod: "POST",
|
|
wantPath: "/workspaces/ws_1/secrets",
|
|
wantBody: `{"key":"K","value":"V"}`,
|
|
},
|
|
{
|
|
name: "DeleteWorkspaceSecret",
|
|
reply: `{}`,
|
|
call: func(p *Platform) error { return p.DeleteWorkspaceSecret("ws_1", "K") },
|
|
wantMethod: "DELETE",
|
|
wantPath: "/workspaces/ws_1/secrets/K",
|
|
},
|
|
{
|
|
name: "ListOrgSecrets",
|
|
reply: `[]`,
|
|
call: func(p *Platform) error { _, e := p.ListOrgSecrets(); return e },
|
|
wantMethod: "GET",
|
|
wantPath: "/settings/secrets",
|
|
},
|
|
{
|
|
name: "SetOrgSecret",
|
|
reply: `{}`,
|
|
call: func(p *Platform) error { return p.SetOrgSecret("K", "V") },
|
|
wantMethod: "POST",
|
|
wantPath: "/settings/secrets",
|
|
wantBody: `{"key":"K","value":"V"}`,
|
|
},
|
|
{
|
|
name: "DeleteOrgSecret",
|
|
reply: `{}`,
|
|
call: func(p *Platform) error { return p.DeleteOrgSecret("K") },
|
|
wantMethod: "DELETE",
|
|
wantPath: "/settings/secrets/K",
|
|
},
|
|
{
|
|
name: "ListTemplates",
|
|
reply: `[]`,
|
|
call: func(p *Platform) error { _, e := p.ListTemplates(); return e },
|
|
wantMethod: "GET",
|
|
wantPath: "/templates",
|
|
},
|
|
{
|
|
name: "ImportTemplate",
|
|
reply: `{}`,
|
|
call: func(p *Platform) error { _, e := p.ImportTemplate("t", map[string]string{"org.yaml": "x"}); return e },
|
|
wantMethod: "POST",
|
|
wantPath: "/templates/import",
|
|
wantBody: `{"files":{"org.yaml":"x"},"name":"t"}`,
|
|
},
|
|
{
|
|
name: "RefreshTemplates",
|
|
reply: `{}`,
|
|
call: func(p *Platform) error { _, e := p.RefreshTemplates(); return e },
|
|
wantMethod: "POST",
|
|
wantPath: "/admin/templates/refresh",
|
|
},
|
|
{
|
|
name: "ExportBundle",
|
|
reply: `{"name":"b"}`,
|
|
call: func(p *Platform) error { _, e := p.ExportBundle("ws_1"); return e },
|
|
wantMethod: "GET",
|
|
wantPath: "/bundles/export/ws_1",
|
|
},
|
|
{
|
|
name: "ImportBundle",
|
|
reply: `{}`,
|
|
call: func(p *Platform) error { _, e := p.ImportBundle(json.RawMessage(`{"name":"b"}`)); return e },
|
|
wantMethod: "POST",
|
|
wantPath: "/bundles/import",
|
|
wantBody: `{"name":"b"}`,
|
|
},
|
|
{
|
|
name: "ListEvents",
|
|
reply: `[]`,
|
|
call: func(p *Platform) error { _, e := p.ListEvents(); return e },
|
|
wantMethod: "GET",
|
|
wantPath: "/events",
|
|
},
|
|
{
|
|
name: "ListPendingApprovals",
|
|
reply: `[]`,
|
|
call: func(p *Platform) error { _, e := p.ListPendingApprovals(); return e },
|
|
wantMethod: "GET",
|
|
wantPath: "/approvals/pending",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var cap capture
|
|
srv := newCaptureServer(t, &cap, tc.reply)
|
|
defer srv.Close()
|
|
|
|
p := NewWithAuth(srv.URL, "k", "o")
|
|
if err := tc.call(p); err != nil {
|
|
t.Fatalf("call: %v", err)
|
|
}
|
|
if cap.method != tc.wantMethod {
|
|
t.Errorf("method = %q, want %q", cap.method, tc.wantMethod)
|
|
}
|
|
if cap.path != tc.wantPath {
|
|
t.Errorf("path = %q, want %q", cap.path, tc.wantPath)
|
|
}
|
|
if cap.auth != "Bearer k" {
|
|
t.Errorf("Authorization = %q, want %q (auth must flow on every verb)", cap.auth, "Bearer k")
|
|
}
|
|
if tc.wantBody != "" && cap.body != tc.wantBody {
|
|
t.Errorf("body = %q, want %q", cap.body, tc.wantBody)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestMigrateProvider proves MigrateProvider POSTs {from,to,confirm,...} to the
|
|
// CP-admin migrate-provider route with the bearer, and that the workspace id is
|
|
// PathEscape'd into its own segment.
|
|
func TestMigrateProvider(t *testing.T) {
|
|
var cap capture
|
|
srv := newCaptureServer(t, &cap, `{"status":"migration_started","from":"aws","to":"hetzner"}`)
|
|
defer srv.Close()
|
|
|
|
p := NewWithAuth(srv.URL, "cp-admin-bearer", "")
|
|
raw, err := p.MigrateProvider("ws/1", MigrateProviderRequest{
|
|
From: "aws", To: "hetzner", Confirm: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("MigrateProvider: %v", err)
|
|
}
|
|
if cap.method != "POST" {
|
|
t.Errorf("method = %q, want POST", cap.method)
|
|
}
|
|
// cap.path is the DECODED r.URL.Path (same convention as
|
|
// TestPathSegmentEscaping): the id's '/' stays inside its segment rather
|
|
// than breaking the route, which proves it was PathEscape'd on the wire.
|
|
if cap.path != "/api/v1/admin/workspaces/ws/1/migrate-provider" {
|
|
t.Errorf("path = %q (id must be a single escaped segment)", cap.path)
|
|
}
|
|
if cap.auth != "Bearer cp-admin-bearer" {
|
|
t.Errorf("Authorization = %q", cap.auth)
|
|
}
|
|
var sent map[string]any
|
|
if err := json.Unmarshal([]byte(cap.body), &sent); err != nil {
|
|
t.Fatalf("body not json: %v (%s)", err, cap.body)
|
|
}
|
|
if sent["from"] != "aws" || sent["to"] != "hetzner" || sent["confirm"] != true {
|
|
t.Errorf("body = %s, want from=aws to=hetzner confirm=true", cap.body)
|
|
}
|
|
// omitempty: no from_instance_id/org_id/runtime sent when unset.
|
|
if _, ok := sent["from_instance_id"]; ok {
|
|
t.Errorf("from_instance_id should be omitted when empty (body=%s)", cap.body)
|
|
}
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(raw, &resp); err != nil || resp["status"] != "migration_started" {
|
|
t.Errorf("response not propagated: %s", raw)
|
|
}
|
|
}
|
|
|
|
// TestMigrateProviderForwardsFromInstanceID proves from_instance_id is sent when
|
|
// provided (required for non-AWS sources).
|
|
func TestMigrateProviderForwardsFromInstanceID(t *testing.T) {
|
|
var cap capture
|
|
srv := newCaptureServer(t, &cap, `{"status":"migration_started"}`)
|
|
defer srv.Close()
|
|
|
|
p := NewWithAuth(srv.URL, "k", "")
|
|
if _, err := p.MigrateProvider("ws1", MigrateProviderRequest{
|
|
From: "gcp", To: "aws", Confirm: true, FromInstanceID: "gcp-box-9",
|
|
}); err != nil {
|
|
t.Fatalf("MigrateProvider: %v", err)
|
|
}
|
|
var sent map[string]any
|
|
_ = json.Unmarshal([]byte(cap.body), &sent)
|
|
if sent["from_instance_id"] != "gcp-box-9" {
|
|
t.Errorf("from_instance_id = %v, want gcp-box-9 (body=%s)", sent["from_instance_id"], cap.body)
|
|
}
|
|
}
|
|
|
|
// TestGetMigrationStatus proves GetMigrationStatus GETs the migrate-provider
|
|
// route and propagates the record body.
|
|
func TestGetMigrationStatus(t *testing.T) {
|
|
var cap capture
|
|
srv := newCaptureServer(t, &cap, `{"migration":{"state":"provisioning_target"},"terminal":false}`)
|
|
defer srv.Close()
|
|
|
|
p := NewWithAuth(srv.URL, "k", "")
|
|
raw, err := p.GetMigrationStatus("ws1")
|
|
if err != nil {
|
|
t.Fatalf("GetMigrationStatus: %v", err)
|
|
}
|
|
if cap.method != "GET" {
|
|
t.Errorf("method = %q, want GET", cap.method)
|
|
}
|
|
if cap.path != "/api/v1/admin/workspaces/ws1/migrate-provider" {
|
|
t.Errorf("path = %q", cap.path)
|
|
}
|
|
var resp struct {
|
|
Migration map[string]any `json:"migration"`
|
|
Terminal bool `json:"terminal"`
|
|
}
|
|
if err := json.Unmarshal(raw, &resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if resp.Migration["state"] != "provisioning_target" {
|
|
t.Errorf("state not propagated: %s", raw)
|
|
}
|
|
}
|