From 953f016549dab197007e6025fca6ee59807a302a Mon Sep 17 00:00:00 2001 From: "Claude Opus 4.8" Date: Sun, 14 Jun 2026 19:14:35 -0700 Subject: [PATCH 1/2] feat: workspace migrate-provider + migration-status CLI commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --to

[--from

] --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 ' 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) --- internal/client/management.go | 47 ++++++++++ internal/client/management_test.go | 94 +++++++++++++++++++ internal/cmd/management_test.go | 15 +++ internal/cmd/workspace.go | 1 + internal/cmd/workspace_mgmt.go | 142 +++++++++++++++++++++++++++++ 5 files changed, 299 insertions(+) diff --git a/internal/client/management.go b/internal/client/management.go index 9ecbaa8..b1a2231 100644 --- a/internal/client/management.go +++ b/internal/client/management.go @@ -88,6 +88,53 @@ func (p *Platform) CreateOrg(req CreateOrgRequest) (*Org, error) { return &out, nil } +// --------------------------------------------------------------------------- +// Control-plane: cross-cloud compute-provider migration +// (ADMIN bearer surface — /api/v1/admin/workspaces/:id/migrate-provider) +// +// Moves a workspace's compute box across clouds (AWS ↔ Hetzner ↔ GCP). The +// migration is DATA-SAFE + ASYNC (~15-20 min): the CP snapshots the source's +// /workspace to R2, provisions the target (which restores on boot), verifies +// it's healthy, then retires the source (verify-before-destroy + rollback live +// in the CP). Like the other org-lifecycle verbs this is a CP-admin route, so +// it requires a CP-admin bearer (MOLECULE_CP_ADMIN_TOKEN) — the tenant Org API +// Key has no standing on the control plane. +// --------------------------------------------------------------------------- + +// MigrateProviderRequest is the body for +// POST /api/v1/admin/workspaces/:id/migrate-provider. +// +// confirm MUST be true — a real migration mutates two clouds; the CP 400s +// without it. from_instance_id is required by the CP for non-AWS (Hetzner/GCP) +// sources (they have no workspace→instance resolver) and optional for AWS +// (the CP resolves the real instance from EC2 tags). org_id/runtime are hints +// the CP fills from tenant_resources for non-AWS sources when omitted. +type MigrateProviderRequest struct { + From string `json:"from"` + To string `json:"to"` + Confirm bool `json:"confirm"` + FromInstanceID string `json:"from_instance_id,omitempty"` + OrgID string `json:"org_id,omitempty"` + Runtime string `json:"runtime,omitempty"` +} + +// MigrateProvider starts a cross-cloud provider migration for a workspace +// (POST /api/v1/admin/workspaces/:id/migrate-provider). Requires a CP-admin +// bearer. Returns the raw 202 {status:"migration_started", …} body. The +// migration runs asynchronously — poll GetMigrationStatus for progress. +func (p *Platform) MigrateProvider(id string, req MigrateProviderRequest) (json.RawMessage, error) { + return p.postRaw("/api/v1/admin/workspaces/"+url.PathEscape(id)+"/migrate-provider", req) +} + +// GetMigrationStatus reads the latest cross-cloud provider-migration record for +// a workspace (GET /api/v1/admin/workspaces/:id/migrate-provider). Requires a +// CP-admin bearer. Returns the raw {migration:{state, from_provider, +// to_provider, detail, …}, terminal} body. The CP 404s when the workspace has +// never been migrated. +func (p *Platform) GetMigrationStatus(id string) (json.RawMessage, error) { + return p.getRaw("/api/v1/admin/workspaces/" + url.PathEscape(id) + "/migrate-provider") +} + // --------------------------------------------------------------------------- // Tenant: org-from-template (POST /org/import) + allowlist + org tokens // --------------------------------------------------------------------------- diff --git a/internal/client/management_test.go b/internal/client/management_test.go index 3ba5925..f7cfdb4 100644 --- a/internal/client/management_test.go +++ b/internal/client/management_test.go @@ -385,3 +385,97 @@ func TestManagementRequestConstruction(t *testing.T) { }) } } + +// 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) + } +} diff --git a/internal/cmd/management_test.go b/internal/cmd/management_test.go index b176d51..e8c2883 100644 --- a/internal/cmd/management_test.go +++ b/internal/cmd/management_test.go @@ -237,3 +237,18 @@ func TestCPAdminClientCredentialTargeting(t *testing.T) { t.Fatal("cpAdminClient with no MOLECULE_CP_ADMIN_TOKEN should fail fast") } } + +// TestValidMigrationProvider walks the provider validation used by +// 'workspace migrate-provider' (shared helper, same code prod runs). +func TestValidMigrationProvider(t *testing.T) { + for _, p := range []string{"aws", "hetzner", "gcp"} { + if !validMigrationProvider(p) { + t.Errorf("%q should be a valid provider", p) + } + } + for _, p := range []string{"", "azure", "AWS", "do", "linode"} { + if validMigrationProvider(p) { + t.Errorf("%q should NOT be a valid provider", p) + } + } +} diff --git a/internal/cmd/workspace.go b/internal/cmd/workspace.go index 4f2ae79..4834b22 100644 --- a/internal/cmd/workspace.go +++ b/internal/cmd/workspace.go @@ -28,6 +28,7 @@ func init() { workspaceDeleteCmd, workspaceRestartCmd, workspaceAuditCmd, workspaceDelegateCmd, workspaceGetCmd, workspacePauseCmd, workspaceResumeCmd, workspaceBudgetCmd, workspaceBillingModeCmd, workspaceTokenCmd, + workspaceMigrateProviderCmd, workspaceMigrationStatusCmd, ) workspaceTokenCmd.AddCommand(workspaceTokenMintCmd) } diff --git a/internal/cmd/workspace_mgmt.go b/internal/cmd/workspace_mgmt.go index 1866b8c..55e067e 100644 --- a/internal/cmd/workspace_mgmt.go +++ b/internal/cmd/workspace_mgmt.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/spf13/cobra" + "go.moleculesai.app/cli/internal/client" ) // --------------------------------------------------------------------------- @@ -180,6 +181,147 @@ func runWorkspaceBillingMode(_ *cobra.Command, args []string) error { return printRaw(raw) } +// =========================================================================== +// molecule workspace migrate-provider --to

[--from

] +// molecule workspace migration-status +// +// Cross-cloud compute-provider migration (AWS ↔ Hetzner ↔ GCP). These hit the +// CONTROL-PLANE admin surface, so they use cpAdminClient() (CP-admin bearer + +// MOLECULE_CP_URL), NOT the tenant newClient() — the tenant Org API Key has no +// standing on the CP. +// =========================================================================== + +var migrateFlags struct { + to string + from string + fromInstanceID string + orgID string + runtime string + confirm bool +} + +var workspaceMigrateProviderCmd = &cobra.Command{ + Use: "migrate-provider --to [--from ]", + Short: "Migrate a workspace's compute box across clouds (AWS/Hetzner/GCP)", + Long: `Start a data-safe cross-cloud provider migration for a workspace. + +The control plane snapshots the source's /workspace to R2, provisions the +target (which restores on boot), verifies it's healthy, then retires the +source (verify-before-destroy + rollback live in the CP). It runs +asynchronously (~15-20 min) — poll 'molecule workspace migration-status'. + +This hits the control-plane admin surface and requires MOLECULE_CP_ADMIN_TOKEN +and MOLECULE_CP_URL (distinct from the tenant MOLECULE_API_KEY). + + molecule workspace migrate-provider ws_123 --to hetzner --from aws --confirm + # non-AWS sources need the current box id: + molecule workspace migrate-provider ws_123 --to aws --from gcp \ + --from-instance-id gcp-box-9 --confirm + +--confirm is REQUIRED: a real migration mutates two clouds.`, + Args: cobra.ExactArgs(1), + RunE: runWorkspaceMigrateProvider, +} + +var workspaceMigrationStatusCmd = &cobra.Command{ + Use: "migration-status ", + Short: "Show the latest cross-cloud provider-migration status for a workspace", + Long: `Read the most recent provider-migration record for a workspace +(state, from/to provider, detail, and whether it has reached a terminal state). + +States: snapshotting → provisioning_target → target_healthy → retiring_source +→ completed (terminal also: failed, rolled_back). + +Hits the control-plane admin surface — requires MOLECULE_CP_ADMIN_TOKEN and +MOLECULE_CP_URL.`, + Args: cobra.ExactArgs(1), + RunE: runWorkspaceMigrationStatus, +} + +func init() { + f := workspaceMigrateProviderCmd.Flags() + f.StringVar(&migrateFlags.to, "to", "", "Target provider: aws|hetzner|gcp (required)") + f.StringVar(&migrateFlags.from, "from", "", "Current provider: aws|hetzner|gcp (required)") + f.StringVar(&migrateFlags.fromInstanceID, "from-instance-id", "", "Current box id to snapshot + retire (required for non-AWS sources)") + f.StringVar(&migrateFlags.orgID, "org-id", "", "Org hint for non-AWS sources (usually unnecessary)") + f.StringVar(&migrateFlags.runtime, "runtime", "", "Runtime hint for non-AWS sources (usually unnecessary)") + f.BoolVar(&migrateFlags.confirm, "confirm", false, "Confirm the migration (REQUIRED — mutates two clouds)") + workspaceMigrateProviderCmd.MarkFlagRequired("to") +} + +// validMigrationProvider reports whether p is one of the three supported +// compute providers. Shared by the command and its tests. +func validMigrationProvider(p string) bool { + switch p { + case "aws", "hetzner", "gcp": + return true + default: + return false + } +} + +func runWorkspaceMigrateProvider(_ *cobra.Command, args []string) error { + id := args[0] + + // Validate providers client-side so a typo fails fast with a clear message + // instead of a CP 400. --to is cobra-required; --from is required by the CP. + if !validMigrationProvider(migrateFlags.to) { + return &exitError{code: 2, msg: "workspace migrate-provider: --to must be one of aws|hetzner|gcp"} + } + if migrateFlags.from == "" { + return &exitError{code: 2, msg: "workspace migrate-provider: --from is required (the current provider: aws|hetzner|gcp)"} + } + if !validMigrationProvider(migrateFlags.from) { + return &exitError{code: 2, msg: "workspace migrate-provider: --from must be one of aws|hetzner|gcp"} + } + if migrateFlags.from == migrateFlags.to { + return &exitError{code: 2, msg: "workspace migrate-provider: --from and --to are the same provider — nothing to migrate"} + } + // from-instance-id is required for non-AWS sources (no workspace→instance + // resolver). Enforce it here rather than round-trip a CP 400. + if migrateFlags.from != "aws" && migrateFlags.fromInstanceID == "" { + return &exitError{code: 2, msg: fmt.Sprintf("workspace migrate-provider: --from-instance-id is required for a non-AWS (%s) source (the current box to snapshot + retire)", migrateFlags.from)} + } + // Never auto-confirm a destructive two-cloud op. + if !migrateFlags.confirm { + return &exitError{code: 2, msg: "workspace migrate-provider: refusing to migrate without --confirm (a real migration mutates two clouds: snapshot source → provision target → retire source)"} + } + + cp, err := cpAdminClient() + if err != nil { + return err + } + raw, err := cp.MigrateProvider(id, client.MigrateProviderRequest{ + From: migrateFlags.from, + To: migrateFlags.to, + Confirm: true, + FromInstanceID: migrateFlags.fromInstanceID, + OrgID: migrateFlags.orgID, + Runtime: migrateFlags.runtime, + }) + if err != nil { + return fmt.Errorf("workspace migrate-provider: %w", err) + } + if outputFormat == "json" || outputFormat == "yaml" { + return printRaw(raw) + } + fmt.Printf("Migration started for workspace %q: %s → %s (runs asynchronously, ~15-20 min).\n", id, migrateFlags.from, migrateFlags.to) + fmt.Printf("Poll: molecule workspace migration-status %s\n", id) + return nil +} + +func runWorkspaceMigrationStatus(_ *cobra.Command, args []string) error { + cp, err := cpAdminClient() + if err != nil { + return err + } + raw, err := cp.GetMigrationStatus(args[0]) + if err != nil { + return fmt.Errorf("workspace migration-status: %w", err) + } + return printRaw(raw) +} + // =========================================================================== // molecule workspace token mint // =========================================================================== -- 2.52.0 From 1823eb9348765232e14b405d62858b6541e11e9a Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Mon, 15 Jun 2026 21:54:58 +0000 Subject: [PATCH 2/2] test(migrate-provider): cover confirm, from==to, and non-AWS from-instance-id guards (#19 CR2 12092) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds explicit cmd-level validation tests for workspace migrate-provider: - --confirm required - --from and --to cannot be the same provider - --from-instance-id required for non-AWS sources - valid provider passes guard and fails later on missing CP env (post-guard) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- internal/cmd/management_test.go | 88 +++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/internal/cmd/management_test.go b/internal/cmd/management_test.go index e8c2883..c27a302 100644 --- a/internal/cmd/management_test.go +++ b/internal/cmd/management_test.go @@ -3,6 +3,7 @@ package cmd import ( "os" "path/filepath" + "strings" "testing" "github.com/spf13/viper" @@ -252,3 +253,90 @@ func TestValidMigrationProvider(t *testing.T) { } } } + +// TestRunWorkspaceMigrateProviderValidation covers the client-side guards in +// runWorkspaceMigrateProvider: --confirm required, --from-instance-id required +// for non-AWS sources, and --from != --to. These fail fast before any CP call. +func TestRunWorkspaceMigrateProviderValidation(t *testing.T) { + orig := migrateFlags + defer func() { migrateFlags = orig }() + + resetFlags := func() { + migrateFlags.to = "" + migrateFlags.from = "" + migrateFlags.fromInstanceID = "" + migrateFlags.orgID = "" + migrateFlags.runtime = "" + migrateFlags.confirm = false + } + + cases := []struct { + name string + setup func() + wantErr string + }{ + { + name: "confirm required", + setup: func() { + resetFlags() + migrateFlags.to = "hetzner" + migrateFlags.from = "aws" + }, + wantErr: "refusing to migrate without --confirm", + }, + { + name: "from==to rejected", + setup: func() { + resetFlags() + migrateFlags.to = "aws" + migrateFlags.from = "aws" + migrateFlags.confirm = true + }, + wantErr: "--from and --to are the same provider", + }, + { + name: "non-aws source requires from-instance-id", + setup: func() { + resetFlags() + migrateFlags.to = "aws" + migrateFlags.from = "gcp" + migrateFlags.confirm = true + }, + wantErr: "--from-instance-id is required for a non-AWS", + }, + { + name: "hetzner source with instance id passes guard", + setup: func() { + resetFlags() + migrateFlags.to = "aws" + migrateFlags.from = "hetzner" + migrateFlags.confirm = true + migrateFlags.fromInstanceID = "hz-box-1" + }, + wantErr: "", // fails later at cpAdminClient because env is not set; guard is what we test + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resetFlags() + tc.setup() + err := runWorkspaceMigrateProvider(nil, []string{"ws-123"}) + if tc.wantErr == "" { + if err == nil { + t.Fatal("expected error after guard (cpAdminClient env missing), got nil") + } + if strings.Contains(err.Error(), "--confirm") || strings.Contains(err.Error(), "same provider") || strings.Contains(err.Error(), "--from-instance-id") { + t.Fatalf("expected post-guard error, got guard error: %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("expected error containing %q, got %v", tc.wantErr, err) + } + }) + } +} -- 2.52.0