feat: migrate_workspace_provider + get_workspace_migration_status MCP tools #65

Merged
agent-reviewer-cr2 merged 1 commits from feat/migrate-workspace-provider into main 2026-06-21 06:27:51 +00:00
2 changed files with 442 additions and 1 deletions
+181 -1
View File
@@ -62,7 +62,11 @@ import {
handleListOrgEvents,
handleCreateApproval as mgmtCreateApproval,
} from "../tools/management/index.js";
import { handleRecreateWorkspace } from "../tools/management/cp_admin.js";
import {
handleRecreateWorkspace,
handleMigrateWorkspaceProvider,
handleGetWorkspaceMigrationStatus,
} from "../tools/management/cp_admin.js";
const ORG_KEY = "org_testkey_abcdef";
const ORG_ID = "org-11111111";
@@ -591,6 +595,181 @@ describe("recreate_workspace (CP-tier hard redeploy)", () => {
});
});
describe("migrate_workspace_provider (CP-tier cross-cloud migration)", () => {
const CP = "https://api.moleculesai.app";
beforeEach(() => {
process.env.CP_ADMIN_API_TOKEN = "cp_admin_token";
process.env.MOLECULE_CP_URL = CP;
});
it("returns CP_TIER_NOT_CONFIGURED and makes no call when CP token absent", async () => {
delete process.env.CP_ADMIN_API_TOKEN;
const f = mockFetch({});
global.fetch = f as unknown as typeof fetch;
const res = parsed(await handleMigrateWorkspaceProvider({ workspace_id: "w1", to: "hetzner", from: "aws", confirm: true }));
expect(res.error).toBe("CP_TIER_NOT_CONFIGURED");
expect(f).not.toHaveBeenCalled();
});
it("POSTs {from,to,confirm:true} to the admin migrate-provider endpoint with the admin bearer", async () => {
const f = mockFetch({ status: "migration_started", workspace_id: "w1", from: "aws", to: "hetzner" });
global.fetch = f as unknown as typeof fetch;
const res = parsed(await handleMigrateWorkspaceProvider({ workspace_id: "w1", to: "hetzner", from: "aws", confirm: true }));
const { url, init } = lastCall(f);
expect(url).toBe(`${CP}/api/v1/admin/workspaces/w1/migrate-provider`);
expect(init.method).toBe("POST");
expect(headersOf(init).Authorization).toBe("Bearer cp_admin_token");
const body = JSON.parse(init.body as string);
expect(body).toEqual({ from: "aws", to: "hetzner", confirm: true });
expect(res.ok).toBe(true);
expect(res.from_source).toBe("explicit");
expect(res.result.status).toBe("migration_started");
});
it("REFUSES without confirm:true — no CP call (defaults confirm to false)", async () => {
const f = mockFetch({ ok: true });
global.fetch = f as unknown as typeof fetch;
const res = parsed(await handleMigrateWorkspaceProvider({ workspace_id: "w1", to: "hetzner", from: "aws" }));
expect(res.error).toBe("CONFIRMATION_REQUIRED");
expect(f).not.toHaveBeenCalled();
});
it("rejects from === to at the schema layer (no fetch)", async () => {
const f = mockFetch({ ok: true });
global.fetch = f as unknown as typeof fetch;
await expect(
handleMigrateWorkspaceProvider({ workspace_id: "w1", to: "aws", from: "aws", confirm: true }),
).rejects.toThrow();
expect(f).not.toHaveBeenCalled();
});
it("rejects an invalid provider enum (no fetch)", async () => {
const f = mockFetch({ ok: true });
global.fetch = f as unknown as typeof fetch;
await expect(
handleMigrateWorkspaceProvider({ workspace_id: "w1", to: "azure" as never, from: "aws", confirm: true }),
).rejects.toThrow();
expect(f).not.toHaveBeenCalled();
});
it("requires from_instance_id for a non-AWS source (no CP call)", async () => {
const f = mockFetch({ ok: true });
global.fetch = f as unknown as typeof fetch;
const res = parsed(await handleMigrateWorkspaceProvider({ workspace_id: "w1", to: "aws", from: "hetzner", confirm: true }));
expect(res.error).toBe("INVALID_ARGUMENTS");
expect(res.detail).toMatch(/from_instance_id is required/i);
expect(f).not.toHaveBeenCalled();
});
it("forwards from_instance_id for a non-AWS source", async () => {
const f = mockFetch({ status: "migration_started" });
global.fetch = f as unknown as typeof fetch;
await handleMigrateWorkspaceProvider({ workspace_id: "w1", to: "aws", from: "gcp", from_instance_id: "gcp-box-9", confirm: true });
const body = JSON.parse(lastCall(f).init.body as string);
expect(body).toEqual({ from: "gcp", to: "aws", confirm: true, from_instance_id: "gcp-box-9" });
});
it("auto-resolves `from` from the workspace's current provider when omitted", async () => {
// First fetch = tenant GET /workspaces/:id (carries provider); second = CP POST.
// mockFetch returns the same payload for both, so include a `provider` field.
const f = mockFetch({ id: "w1", provider: "aws", status: "migration_started" });
global.fetch = f as unknown as typeof fetch;
const res = parsed(await handleMigrateWorkspaceProvider({ workspace_id: "w1", to: "hetzner", confirm: true }));
// First call = tenant lookup on the org-key host; last = CP migrate POST.
expect(f.mock.calls[0][0]).toContain("/workspaces/w1");
const { url, init } = lastCall(f);
expect(url).toBe(`${CP}/api/v1/admin/workspaces/w1/migrate-provider`);
expect(JSON.parse(init.body as string)).toEqual({ from: "aws", to: "hetzner", confirm: true });
expect(res.from_source).toBe("workspace_lookup");
});
it("FROM_UNRESOLVED when `from` omitted and the workspace reports no provider", async () => {
const f = mockFetch({ id: "w1" }); // no provider field
global.fetch = f as unknown as typeof fetch;
const res = parsed(await handleMigrateWorkspaceProvider({ workspace_id: "w1", to: "hetzner", confirm: true }));
expect(res.error).toBe("FROM_UNRESOLVED");
// Only the tenant lookup happened — the CP migrate POST was never issued.
expect(f).toHaveBeenCalledTimes(1);
expect(f.mock.calls[0][0]).not.toMatch(/migrate-provider/);
});
it("INVALID_ARGUMENTS when an auto-resolved `from` equals `to`", async () => {
const f = mockFetch({ id: "w1", provider: "hetzner" });
global.fetch = f as unknown as typeof fetch;
const res = parsed(await handleMigrateWorkspaceProvider({ workspace_id: "w1", to: "hetzner", confirm: true }));
expect(res.error).toBe("INVALID_ARGUMENTS");
expect(res.detail).toMatch(/same provider/i);
expect(f).toHaveBeenCalledTimes(1); // lookup only, no migrate POST
});
it("surfaces MIGRATION_START_FAILED on an upstream CP error", async () => {
const f = mockFetch({ error: "migrator not configured" }, false, 503);
global.fetch = f as unknown as typeof fetch;
const res = parsed(await handleMigrateWorkspaceProvider({ workspace_id: "w1", to: "hetzner", from: "aws", confirm: true }));
expect(res.error).toBe("MIGRATION_START_FAILED");
});
it("url-encodes the workspace id in the path", async () => {
const f = mockFetch({ status: "migration_started" });
global.fetch = f as unknown as typeof fetch;
await handleMigrateWorkspaceProvider({ workspace_id: "w/1", to: "hetzner", from: "aws", confirm: true });
expect(lastCall(f).url).toBe(`${CP}/api/v1/admin/workspaces/w%2F1/migrate-provider`);
});
});
describe("get_workspace_migration_status (CP-tier read)", () => {
const CP = "https://api.moleculesai.app";
beforeEach(() => {
process.env.CP_ADMIN_API_TOKEN = "cp_admin_token";
process.env.MOLECULE_CP_URL = CP;
});
it("returns CP_TIER_NOT_CONFIGURED and makes no call when CP token absent", async () => {
delete process.env.CP_ADMIN_API_TOKEN;
const f = mockFetch({});
global.fetch = f as unknown as typeof fetch;
const res = parsed(await handleGetWorkspaceMigrationStatus({ workspace_id: "w1" }));
expect(res.error).toBe("CP_TIER_NOT_CONFIGURED");
expect(f).not.toHaveBeenCalled();
});
it("GETs the migrate-provider endpoint and returns the migration record", async () => {
const f = mockFetch({ migration: { state: "provisioning_target", from_provider: "aws", to_provider: "hetzner" }, terminal: false });
global.fetch = f as unknown as typeof fetch;
const res = parsed(await handleGetWorkspaceMigrationStatus({ workspace_id: "w1" }));
const { url, init } = lastCall(f);
expect(url).toBe(`${CP}/api/v1/admin/workspaces/w1/migrate-provider`);
expect(init.method).toBe("GET");
expect(headersOf(init).Authorization).toBe("Bearer cp_admin_token");
expect(res.ok).toBe(true);
expect(res.migration.state).toBe("provisioning_target");
expect(res.terminal).toBe(false);
});
it("maps a 404 to a clean NOT_FOUND (never migrated)", async () => {
const f = mockFetch({ error: "no migration found" }, false, 404);
global.fetch = f as unknown as typeof fetch;
const res = parsed(await handleGetWorkspaceMigrationStatus({ workspace_id: "w1" }));
expect(res.error).toBe("NOT_FOUND");
});
it("surfaces MIGRATION_STATUS_FAILED on a non-404 CP error", async () => {
const f = mockFetch({ error: "boom" }, false, 500);
global.fetch = f as unknown as typeof fetch;
const res = parsed(await handleGetWorkspaceMigrationStatus({ workspace_id: "w1" }));
expect(res.error).toBe("MIGRATION_STATUS_FAILED");
});
it("url-encodes the workspace id", async () => {
const f = mockFetch({ migration: {}, terminal: true });
global.fetch = f as unknown as typeof fetch;
await handleGetWorkspaceMigrationStatus({ workspace_id: "w/1" });
expect(lastCall(f).url).toBe(`${CP}/api/v1/admin/workspaces/w%2F1/migrate-provider`);
});
});
describe("registration + mode", () => {
it("isManagementMode reflects MOLECULE_MCP_MODE=management", () => {
process.env.MOLECULE_MCP_MODE = "management";
@@ -605,6 +784,7 @@ describe("registration + mode", () => {
const names = srv.registeredToolNames;
for (const expected of [
"list_orgs", "get_org", "recreate_workspace",
"migrate_workspace_provider", "get_workspace_migration_status",
"list_workspaces", "get_workspace", "provision_workspace", "deprovision_workspace",
"restart_workspace", "pause_workspace", "resume_workspace",
"set_workspace_secret", "list_workspace_secrets", "delete_workspace_secret",
+261
View File
@@ -362,6 +362,238 @@ export async function handleRecreateWorkspace(args: unknown) {
});
}
// ---------------------------------------------------------------------------
// Cross-cloud compute-provider migration (mcp-server#64)
//
// The canvas can move a workspace's compute box across clouds (AWS ↔ Hetzner ↔
// GCP) but the management MCP/CLI could not — a real capability gap. These two
// tools wrap the CP-admin endpoint:
//
// POST /api/v1/admin/workspaces/:id/migrate-provider
// {from, to, confirm:true, [from_instance_id], [org_id], [runtime], …}
// → 202 {status:"migration_started", workspace_id, from, to}
// GET /api/v1/admin/workspaces/:id/migrate-provider (alias …/migration-status)
// → 200 {migration:{state, from_provider, to_provider, detail, …}, terminal}
//
// (controlplane internal/handlers/admin_workspace_migrate_provider.go). The
// migration is DATA-SAFE + ASYNC (~15-20 min): 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 CP.
//
// This is a CP-tier op (CP_ADMIN_API_TOKEN) — the Org API Key cannot reach the
// control plane, so it lives here alongside the other cp_admin tools.
//
// Client-side guards mirror the CP handler so a bad call fails fast with a clear
// message instead of round-tripping a 400/503:
// - `to` is required and must be aws|hetzner|gcp.
// - `from` is required by CP and must differ from `to`.
// - `confirm:true` is mandatory (a real migration mutates two clouds). We
// DEFAULT confirm to false and refuse without it — never auto-confirm a
// destructive cross-cloud op.
// - `from_instance_id` is required by CP for NON-AWS sources (Hetzner/GCP have
// no workspace→instance resolver). For AWS it's optional (CP resolves the
// real instance from EC2 tags, cp#711). We enforce the same so a non-AWS
// migration doesn't fail downstream with a confusing CP 400.
// ---------------------------------------------------------------------------
const PROVIDERS = ["aws", "hetzner", "gcp"] as const;
const MigrateWorkspaceProviderSchema = z
.object({
workspace_id: z.string().describe("Workspace UUID whose compute box to migrate across clouds."),
to: z.enum(PROVIDERS).describe("Target compute provider (aws|hetzner|gcp). REQUIRED."),
from: z
.enum(PROVIDERS)
.optional()
.describe(
"Current compute provider (aws|hetzner|gcp). Required by the control plane; must differ from `to`. If omitted, the tool resolves it from the workspace's current provider via the tenant API.",
),
from_instance_id: z
.string()
.optional()
.describe(
"Current box id to snapshot + retire. REQUIRED for non-AWS (Hetzner/GCP) sources — they have no workspace→instance resolver. Optional for AWS (CP resolves the real instance from EC2 tags).",
),
org_id: z
.string()
.optional()
.describe("Hint for non-AWS sources; CP resolves org from EC2 tags for AWS. Usually unnecessary — CP fills it from tenant_resources."),
runtime: z
.string()
.optional()
.describe("Runtime hint for non-AWS sources (e.g. 'claude-code'). Usually unnecessary — CP fills it from tenant_resources."),
confirm: z
.boolean()
.optional()
.describe(
"MUST be true to actually migrate — a real migration mutates two clouds. Defaults to false; the tool refuses without explicit confirmation.",
),
})
.refine((v) => v.from === undefined || v.from !== v.to, {
message: "`from` and `to` are the same provider — nothing to migrate",
});
const GetWorkspaceMigrationStatusSchema = z.object({
workspace_id: z.string().describe("Workspace UUID to read the latest provider-migration status for."),
});
/**
* migrate_workspace_provider — start a data-safe cross-cloud provider switch.
*
* Resolves `from` (when omitted) from the workspace's current provider via the
* tenant API, enforces the CP contract's guards client-side, then POSTs to the
* CP-admin endpoint. Returns the 202 {status:"migration_started", …} body. The
* migration runs asynchronously (~15-20 min) — poll get_workspace_migration_status.
*/
export async function handleMigrateWorkspaceProvider(args: unknown) {
const p = validate(args, MigrateWorkspaceProviderSchema);
if (!cpConfigured()) return toMcpResult(cpNotConfigured("migrate_workspace_provider"));
// Resolve `from` when omitted — the CP handler REQUIRES it. The workspace's
// current provider is on its tenant row (org-key host); fall back to a clear
// error rather than letting CP 400 with "from and to must each be one of …".
let from = p.from as string | undefined;
let fromSource: "explicit" | "workspace_lookup" = from ? "explicit" : "workspace_lookup";
if (!from) {
const ws = await mgmtGet(`/workspaces/${encodeURIComponent(p.workspace_id)}`);
if (!isApiError(ws) && ws && typeof ws === "object") {
const rec = ws as Record<string, unknown>;
const prov = rec.provider ?? rec.compute_provider;
if (typeof prov === "string" && PROVIDERS.includes(prov as (typeof PROVIDERS)[number])) {
from = prov;
fromSource = "workspace_lookup";
}
}
if (!from) {
return toMcpResult({
error: "FROM_UNRESOLVED",
detail:
`could not resolve the current provider for workspace '${p.workspace_id}' ` +
"(tenant lookup unavailable, workspace not found, or it reports no provider). " +
"Pass `from` explicitly (one of aws|hetzner|gcp).",
workspace_id: p.workspace_id,
to: p.to,
});
}
}
if (from === p.to) {
return toMcpResult({
error: "INVALID_ARGUMENTS",
detail: `from and to are the same provider (${from}) — nothing to migrate`,
workspace_id: p.workspace_id,
});
}
// from_instance_id is REQUIRED for non-AWS sources (no workspace→instance
// resolver). Enforce it here so the call fails fast with a clear message
// instead of a confusing CP 400.
if (from !== "aws" && !p.from_instance_id) {
return toMcpResult({
error: "INVALID_ARGUMENTS",
detail:
`from_instance_id is required for a non-AWS (${from}) source — it has no ` +
"workspace→instance resolver, so the current box id is needed to snapshot + retire it.",
workspace_id: p.workspace_id,
from,
to: p.to,
});
}
// confirm defaults to FALSE — never auto-confirm a destructive two-cloud op.
const confirm = p.confirm ?? false;
if (!confirm) {
return toMcpResult({
error: "CONFIRMATION_REQUIRED",
detail:
"refusing to migrate without confirmation — a real migration mutates two clouds " +
"(snapshot source → provision target → retire source). Pass confirm:true to proceed.",
workspace_id: p.workspace_id,
from,
to: p.to,
});
}
logWarn("migrate_workspace_provider: CP-admin cross-cloud provider switch", {
audit: true,
operation: "migrate_workspace_provider",
workspace_id: p.workspace_id,
from,
to: p.to,
from_source: fromSource,
from_instance_id: p.from_instance_id ?? null,
timestamp: new Date().toISOString(),
});
const body: Record<string, unknown> = { from, to: p.to, confirm: true };
if (p.from_instance_id !== undefined) body.from_instance_id = p.from_instance_id;
if (p.org_id !== undefined) body.org_id = p.org_id;
if (p.runtime !== undefined) body.runtime = p.runtime;
const res = await cpCall(
"POST",
`/api/v1/admin/workspaces/${encodeURIComponent(p.workspace_id)}/migrate-provider`,
body,
);
if (isApiError(res)) {
return toMcpResult({
error: "MIGRATION_START_FAILED",
detail: res,
workspace_id: p.workspace_id,
from,
to: p.to,
from_source: fromSource,
});
}
return toMcpResult({
ok: true,
workspace_id: p.workspace_id,
from,
to: p.to,
from_source: fromSource,
result: res,
});
}
/**
* get_workspace_migration_status — read the latest provider-migration record.
*
* Read-only. Returns {migration:{state, from_provider, to_provider, detail, …},
* terminal}. 404 (surfaced as a structured NOT_FOUND) when the workspace has
* never been migrated.
*/
export async function handleGetWorkspaceMigrationStatus(args: unknown) {
const p = validate(args, GetWorkspaceMigrationStatusSchema);
if (!cpConfigured()) return toMcpResult(cpNotConfigured("get_workspace_migration_status"));
const res = await cpCall(
"GET",
`/api/v1/admin/workspaces/${encodeURIComponent(p.workspace_id)}/migrate-provider`,
);
if (isApiError(res)) {
// A 404 here is the meaningful "never migrated" signal — surface it cleanly.
if (typeof res === "object" && res !== null && (res as ApiError).status === 404) {
return toMcpResult({
error: "NOT_FOUND",
detail: "no provider-migration record for this workspace (it has never been migrated)",
workspace_id: p.workspace_id,
});
}
return toMcpResult({
error: "MIGRATION_STATUS_FAILED",
detail: res,
workspace_id: p.workspace_id,
});
}
return toMcpResult({ ok: true, workspace_id: p.workspace_id, ...(res as Record<string, unknown>) });
}
export function registerCpAdminTools(srv: McpServer) {
srv.tool(
"list_orgs",
@@ -408,4 +640,33 @@ export function registerCpAdminTools(srv: McpServer) {
},
handleRecreateWorkspace,
);
srv.tool(
"migrate_workspace_provider",
"Management (CP-TIER): migrate a workspace's compute box across clouds (AWS ↔ Hetzner ↔ GCP). Data-safe + ASYNC (~15-20 min): 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 CP). `to` is required; `from` is auto-resolved from the workspace when omitted. confirm:true is REQUIRED — a real migration mutates two clouds; the tool refuses without it. `from_instance_id` is required for non-AWS sources. Poll get_workspace_migration_status for progress. Requires CP_ADMIN_API_TOKEN — the Org API Key CANNOT reach the control plane.",
{
workspace_id: z.string().describe("Workspace UUID to migrate."),
to: z.enum(PROVIDERS).describe("Target provider (aws|hetzner|gcp). REQUIRED."),
from: z
.enum(PROVIDERS)
.optional()
.describe("Current provider (aws|hetzner|gcp); must differ from `to`. Auto-resolved from the workspace when omitted."),
from_instance_id: z
.string()
.optional()
.describe("Current box id to snapshot + retire. REQUIRED for non-AWS (Hetzner/GCP) sources; optional for AWS (resolved from EC2 tags)."),
org_id: z.string().optional().describe("Org hint for non-AWS sources (usually unnecessary — CP fills it from tenant_resources)."),
runtime: z.string().optional().describe("Runtime hint for non-AWS sources (usually unnecessary — CP fills it from tenant_resources)."),
confirm: z
.boolean()
.optional()
.describe("MUST be true to actually migrate (mutates two clouds). Defaults to false; the tool refuses without it."),
},
handleMigrateWorkspaceProvider,
);
srv.tool(
"get_workspace_migration_status",
"Management (CP-TIER): read the latest cross-cloud provider-migration status for a workspace. Read-only. Returns {migration:{state, from_provider, to_provider, detail, …}, terminal}. States: snapshotting → provisioning_target → target_healthy → retiring_source → completed (terminal also: failed, rolled_back). NOT_FOUND when the workspace has never been migrated. Requires CP_ADMIN_API_TOKEN — the Org API Key CANNOT reach the control plane.",
{ workspace_id: z.string().describe("Workspace UUID to read provider-migration status for.") },
handleGetWorkspaceMigrationStatus,
);
}