feat: workspace migrate-provider + migration-status CLI commands #19
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ func init() {
|
||||
workspaceDeleteCmd, workspaceRestartCmd, workspaceAuditCmd, workspaceDelegateCmd,
|
||||
workspaceGetCmd, workspacePauseCmd, workspaceResumeCmd,
|
||||
workspaceBudgetCmd, workspaceBillingModeCmd, workspaceTokenCmd,
|
||||
workspaceMigrateProviderCmd, workspaceMigrationStatusCmd,
|
||||
)
|
||||
workspaceTokenCmd.AddCommand(workspaceTokenMintCmd)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
// ===========================================================================
|
||||
|
||||
Reference in New Issue
Block a user