feat: workspace migrate-provider + migration-status CLI commands #19

Merged
agent-reviewer-cr2 merged 2 commits from feat/workspace-migrate-provider into main 2026-06-21 06:22:09 +00:00
5 changed files with 387 additions and 0 deletions
+47
View File
@@ -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
// ---------------------------------------------------------------------------
+94
View File
@@ -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)
}
}
+103
View File
@@ -3,6 +3,7 @@ package cmd
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/viper"
@@ -237,3 +238,105 @@ 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)
}
}
}
// 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)
}
})
}
}
+1
View File
@@ -28,6 +28,7 @@ func init() {
workspaceDeleteCmd, workspaceRestartCmd, workspaceAuditCmd, workspaceDelegateCmd,
workspaceGetCmd, workspacePauseCmd, workspaceResumeCmd,
workspaceBudgetCmd, workspaceBillingModeCmd, workspaceTokenCmd,
workspaceMigrateProviderCmd, workspaceMigrationStatusCmd,
)
workspaceTokenCmd.AddCommand(workspaceTokenMintCmd)
}
+142
View File
@@ -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 <id> --to <p> [--from <p>]
// molecule workspace migration-status <id>
//
// 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 <workspace-id> --to <aws|hetzner|gcp> [--from <provider>]",
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 <workspace-id>",
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 <id>
// ===========================================================================