Files
Claude Opus 4.8 953f016549 feat: workspace migrate-provider + migration-status CLI commands
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>
2026-06-15 22:52:27 +00:00

518 lines
20 KiB
Go

// Package client — management surface (orgs, secrets, tokens, templates,
// bundles, budget, billing-mode, events, approvals).
//
// Request/response shapes are aligned to the live workspace-server handlers
// (tenant) and controlplane handlers (CP). Auth tiers per
// PLATFORM-MANAGEMENT-API.md §1: tenant calls use the Org API Key as a bearer
// (+ X-Molecule-Org-Id); CP org calls use a DISTINCT CP-admin bearer
// (MOLECULE_CP_ADMIN_TOKEN) — the tenant Org API Key is never sent to the CP.
package client
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
)
// ---------------------------------------------------------------------------
// Control-plane: orgs (ADMIN bearer surface — /api/v1/admin/orgs)
//
// The customer-facing /api/v1/orgs* routes are gated by a WorkOS browser
// session (controlplane auth.RequireSession), which a bearer-token CLI cannot
// satisfy — every call 401s in production, and the tenant Org API Key has no
// standing on the CP at all. So org-lifecycle verbs go to the CP ADMIN routes
// (controlplane router AdminGate), authenticated with a DISTINCT CP-admin
// bearer (MOLECULE_CP_ADMIN_TOKEN). The org key is never sent to the CP.
//
// Note: only create + list exist on the admin surface. There is NO admin
// GET /orgs/:slug and NO admin /orgs/:slug/export in controlplane — those are
// session-only — so the CLI fails those verbs fast rather than 401'ing.
// ---------------------------------------------------------------------------
// Org mirrors controlplane handlers.adminOrgSummary, the per-row shape of
// GET /api/v1/admin/orgs. Intentionally narrower than the session-facing
// customer org view (no credits/overage — those aren't on the admin summary).
type Org struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Plan string `json:"plan"`
Status string `json:"status,omitempty"`
StripeCustomerID string `json:"stripe_customer_id,omitempty"`
MemberCount int `json:"member_count,omitempty"`
InstanceStatus string `json:"instance_status,omitempty"`
LastError string `json:"last_error,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// adminListOrgsResponse is the envelope returned by GET /api/v1/admin/orgs:
// {"limit":N,"offset":N,"orgs":[...]}.
type adminListOrgsResponse struct {
Limit int `json:"limit"`
Offset int `json:"offset"`
Orgs []Org `json:"orgs"`
}
// ListOrgs returns all orgs from the CP admin surface
// (GET /api/v1/admin/orgs). Requires a CP-admin bearer.
func (p *Platform) ListOrgs() ([]Org, error) {
var out adminListOrgsResponse
if err := p.getInto("/api/v1/admin/orgs", &out); err != nil {
return nil, err
}
return out.Orgs, nil
}
// CreateOrgRequest is the body for POST /api/v1/admin/orgs. owner_user_id is
// REQUIRED by the admin route (controlplane adminCreateOrgRequest): the
// server-to-server path has no implicit WorkOS session to hang the owner
// membership off of, so the caller passes it explicitly.
type CreateOrgRequest struct {
Slug string `json:"slug"`
Name string `json:"name"`
OwnerUserID string `json:"owner_user_id"`
}
// CreateOrg creates an org on the CP admin surface (POST /api/v1/admin/orgs).
// Requires a CP-admin bearer. Returns the full created org.
func (p *Platform) CreateOrg(req CreateOrgRequest) (*Org, error) {
var out Org
if err := p.postInto("/api/v1/admin/orgs", req, &out); err != nil {
return nil, err
}
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
// ---------------------------------------------------------------------------
// ImportOrgRequest is the body for POST /org/import.
type ImportOrgRequest struct {
Dir string `json:"dir,omitempty"` // org template directory name
Mode string `json:"mode,omitempty"` // "merge" (default) | "reconcile"
}
// CreateOrgFromTemplate provisions workspaces from an org template directory
// (POST /org/import). Returns the raw per-workspace result list.
func (p *Platform) CreateOrgFromTemplate(req ImportOrgRequest) (json.RawMessage, error) {
return p.postRaw("/org/import", req)
}
// GetAllowlist returns the org plugin allowlist (GET /orgs/:id/plugins/allowlist).
func (p *Platform) GetAllowlist(orgID string) (json.RawMessage, error) {
return p.getRaw("/orgs/" + url.PathEscape(orgID) + "/plugins/allowlist")
}
// OrgToken is a row from GET /org/tokens.
type OrgToken struct {
ID string `json:"id"`
Prefix string `json:"prefix"`
Name string `json:"name,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
LastUsed string `json:"last_used_at,omitempty"`
}
// ListOrgTokens lists the org's Org API Keys (GET /org/tokens).
func (p *Platform) ListOrgTokens() ([]OrgToken, error) {
var out []OrgToken
if err := p.getInto("/org/tokens", &out); err != nil {
return nil, err
}
return out, nil
}
// CreateOrgTokenResponse is the POST /org/tokens response. The plaintext
// token is shown exactly once.
type CreateOrgTokenResponse struct {
ID string `json:"id"`
Prefix string `json:"prefix"`
Name string `json:"name,omitempty"`
Token string `json:"auth_token"`
Warning string `json:"warning,omitempty"`
}
// CreateOrgToken mints a new Org API Key (POST /org/tokens).
func (p *Platform) CreateOrgToken(name string) (*CreateOrgTokenResponse, error) {
var out CreateOrgTokenResponse
if err := p.postInto("/org/tokens", map[string]string{"name": name}, &out); err != nil {
return nil, err
}
return &out, nil
}
// RevokeOrgToken revokes an Org API Key by id (DELETE /org/tokens/:id).
func (p *Platform) RevokeOrgToken(id string) error {
_, err := p.delete("/org/tokens/" + url.PathEscape(id))
return err
}
// ---------------------------------------------------------------------------
// Tenant: workspace lifecycle (pause/resume) + budget + billing-mode + token
// ---------------------------------------------------------------------------
// PauseWorkspace pauses a workspace (POST /workspaces/:id/pause).
func (p *Platform) PauseWorkspace(id string) error {
_, err := p.postEmpty("/workspaces/" + url.PathEscape(id) + "/pause")
return err
}
// ResumeWorkspace resumes a workspace (POST /workspaces/:id/resume).
func (p *Platform) ResumeWorkspace(id string) error {
_, err := p.postEmpty("/workspaces/" + url.PathEscape(id) + "/resume")
return err
}
// SetRuntime updates a workspace's runtime (PATCH /workspaces/:id {runtime}).
// The response is the handler's raw JSON (e.g. {"status":"updated","needs_restart":true}).
func (p *Platform) SetRuntime(id, runtime string) (json.RawMessage, error) {
body := map[string]string{"runtime": runtime}
return p.patchRaw("/workspaces/"+url.PathEscape(id), body)
}
// SetModel updates a workspace's model override (PUT /workspaces/:id/model).
// The workspace-server validates the (runtime, model) pair and auto-restarts.
func (p *Platform) SetModel(id, model string) (json.RawMessage, error) {
body := map[string]string{"model": model}
return p.putRaw("/workspaces/"+url.PathEscape(id)+"/model", body)
}
// OfferedModel is one selectable (runtime, model) entry from the registry.
type OfferedModel struct {
Model string `json:"model"`
Provider string `json:"provider"`
PlatformBilled bool `json:"platform_billed"`
AuthEnv []string `json:"auth_env,omitempty"`
}
// OfferedModelsResponse is the envelope returned by GET /admin/llm/offered-models.
type OfferedModelsResponse struct {
Runtime string `json:"runtime"`
Models []OfferedModel `json:"models"`
}
// ErrRuntimeNotInRegistry is returned by ListOfferedModels when the server
// reports that the runtime is unknown to the provider registry. Callers that
// enforce model/runtime compatibility should treat this as a federation case
// and fail-open: the registry cannot validate runtimes it does not know.
var ErrRuntimeNotInRegistry = errors.New("runtime not in provider registry")
// ListOfferedModels returns the registry's native model menu for a runtime
// (GET /admin/llm/offered-models?runtime=...).
//
// - If the server returns 200 OK, the menu is returned.
// - If the server returns 404 because the runtime is unknown to the registry,
// it returns ErrRuntimeNotInRegistry so callers can fail-open for federated
// / third-party runtimes.
// - Any other HTTP error or network failure is returned as a normal error and
// MUST be treated as fail-closed by callers.
func (p *Platform) ListOfferedModels(runtime string) (*OfferedModelsResponse, error) {
u, err := url.Parse(p.BaseURL + "/admin/llm/offered-models")
if err != nil {
return nil, fmt.Errorf("parse offered-models URL: %w", err)
}
q := u.Query()
q.Set("runtime", runtime)
u.RawQuery = q.Encode()
req, err := http.NewRequest("GET", u.String(), nil)
if err != nil {
return nil, fmt.Errorf("new GET request: %w", err)
}
p.setAuth(req)
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("GET %s: %w", u.String(), err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusNotFound {
return nil, ErrRuntimeNotInRegistry
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("GET %s: HTTP %d — %s", u.String(), resp.StatusCode, string(body))
}
var out OfferedModelsResponse
if err := json.Unmarshal(body, &out); err != nil {
return nil, fmt.Errorf("decode GET %s: %w", u.String(), err)
}
return &out, nil
}
// GetBudget returns a workspace's budget (GET /workspaces/:id/budget).
func (p *Platform) GetBudget(id string) (json.RawMessage, error) {
return p.getRaw("/workspaces/" + url.PathEscape(id) + "/budget")
}
// SetBudget sets per-period budget limits in USD cents
// (PATCH /workspaces/:id/budget, body {"budget_limits": {...}}). A nil value
// for a period clears that period's limit.
func (p *Platform) SetBudget(id string, limits map[string]*int64) (json.RawMessage, error) {
body := map[string]interface{}{"budget_limits": limits}
return p.patchRaw("/workspaces/"+url.PathEscape(id)+"/budget", body)
}
// SetBillingMode sets a workspace's LLM billing-mode override
// (PUT /admin/workspaces/:id/llm-billing-mode, body {"mode": ...}).
// Pass mode="" to clear the override (sends null).
func (p *Platform) SetBillingMode(id, mode string) (json.RawMessage, error) {
var modeVal interface{}
if mode == "" {
modeVal = nil
} else {
modeVal = mode
}
body := map[string]interface{}{"mode": modeVal}
return p.putRaw("/admin/workspaces/"+url.PathEscape(id)+"/llm-billing-mode", body)
}
// MintWorkspaceTokenResponse is the POST /workspaces/:id/tokens response.
type MintWorkspaceTokenResponse struct {
Token string `json:"auth_token"`
WorkspaceID string `json:"workspace_id"`
Message string `json:"message,omitempty"`
}
// MintWorkspaceToken mints a new per-workspace auth token
// (POST /workspaces/:id/tokens). Plaintext is returned exactly once.
func (p *Platform) MintWorkspaceToken(id string) (*MintWorkspaceTokenResponse, error) {
var out MintWorkspaceTokenResponse
// Send an empty JSON object ({}) rather than a nil body: postInto would
// marshal nil to the literal `null`, which a handler that decodes into a
// struct/map can reject. {} matches sibling tooling and is always safe.
if err := p.postInto("/workspaces/"+url.PathEscape(id)+"/tokens", struct{}{}, &out); err != nil {
return nil, err
}
return &out, nil
}
// ---------------------------------------------------------------------------
// Tenant: secrets (workspace + org)
// ---------------------------------------------------------------------------
// Secret is a key entry from a secrets list. Values are not returned by the
// list endpoints (only keys + metadata).
type Secret struct {
Key string `json:"key"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// ListWorkspaceSecrets lists a workspace's secret keys
// (GET /workspaces/:id/secrets).
func (p *Platform) ListWorkspaceSecrets(id string) (json.RawMessage, error) {
return p.getRaw("/workspaces/" + url.PathEscape(id) + "/secrets")
}
// SetWorkspaceSecret upserts a workspace secret (POST /workspaces/:id/secrets,
// body {"key","value"}). The tenant auto-restarts the workspace.
func (p *Platform) SetWorkspaceSecret(id, key, value string) error {
body := map[string]string{"key": key, "value": value}
return p.postInto("/workspaces/"+url.PathEscape(id)+"/secrets", body, nil)
}
// DeleteWorkspaceSecret deletes a workspace secret by key
// (DELETE /workspaces/:id/secrets/:key).
func (p *Platform) DeleteWorkspaceSecret(id, key string) error {
_, err := p.delete("/workspaces/" + url.PathEscape(id) + "/secrets/" + url.PathEscape(key))
return err
}
// ListOrgSecrets lists org-wide secret keys (GET /settings/secrets).
func (p *Platform) ListOrgSecrets() (json.RawMessage, error) {
return p.getRaw("/settings/secrets")
}
// SetOrgSecret upserts an org-wide secret (POST /settings/secrets).
func (p *Platform) SetOrgSecret(key, value string) error {
body := map[string]string{"key": key, "value": value}
return p.postInto("/settings/secrets", body, nil)
}
// DeleteOrgSecret deletes an org-wide secret by key
// (DELETE /settings/secrets/:key).
func (p *Platform) DeleteOrgSecret(key string) error {
_, err := p.delete("/settings/secrets/" + url.PathEscape(key))
return err
}
// ---------------------------------------------------------------------------
// Tenant: templates + bundles
// ---------------------------------------------------------------------------
// ListTemplates lists workspace templates (GET /templates).
func (p *Platform) ListTemplates() (json.RawMessage, error) {
return p.getRaw("/templates")
}
// ImportTemplate imports a template (POST /templates/import,
// body {"name","files"}).
func (p *Platform) ImportTemplate(name string, files map[string]string) (json.RawMessage, error) {
body := map[string]interface{}{"name": name, "files": files}
return p.postRaw("/templates/import", body)
}
// RefreshTemplates refreshes the template cache (POST /admin/templates/refresh).
func (p *Platform) RefreshTemplates() (json.RawMessage, error) {
return p.postRaw("/admin/templates/refresh", nil)
}
// ExportBundle exports a workspace bundle (GET /bundles/export/:id).
func (p *Platform) ExportBundle(id string) (json.RawMessage, error) {
return p.getRaw("/bundles/export/" + url.PathEscape(id))
}
// ImportBundle imports a bundle (POST /bundles/import). bundle is the raw
// bundle JSON as exported by ExportBundle.
func (p *Platform) ImportBundle(bundle json.RawMessage) (json.RawMessage, error) {
return p.postRaw("/bundles/import", bundle)
}
// ---------------------------------------------------------------------------
// Tenant: events + approvals
// ---------------------------------------------------------------------------
// ListEvents lists recent structure_events (GET /events, AdminAuth).
func (p *Platform) ListEvents() (json.RawMessage, error) {
return p.getRaw("/events")
}
// ListPendingApprovals lists pending approvals (GET /approvals/pending).
func (p *Platform) ListPendingApprovals() (json.RawMessage, error) {
return p.getRaw("/approvals/pending")
}
// ---------------------------------------------------------------------------
// Raw helpers — return the response body verbatim for endpoints whose shape
// is loose / pass-through (events, secrets lists, budget, exports, …).
// ---------------------------------------------------------------------------
func (p *Platform) getRaw(path string) (json.RawMessage, error) {
req, err := http.NewRequest("GET", p.BaseURL+path, nil)
if err != nil {
return nil, fmt.Errorf("new GET request: %w", err)
}
p.setAuth(req)
return p.doRaw(req, "GET", path)
}
func (p *Platform) postRaw(path string, body interface{}) (json.RawMessage, error) {
var encoded []byte
if body != nil {
// Allow callers to pass a pre-encoded json.RawMessage straight through.
if rm, ok := body.(json.RawMessage); ok {
encoded = rm
} else {
b, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal POST body: %w", err)
}
encoded = b
}
}
var req *http.Request
var err error
if encoded != nil {
req, err = http.NewRequest("POST", p.BaseURL+path, bytes.NewReader(encoded))
} else {
req, err = http.NewRequest("POST", p.BaseURL+path, nil)
}
if err != nil {
return nil, fmt.Errorf("new POST request: %w", err)
}
if encoded != nil {
req.Header.Set("Content-Type", "application/json")
}
p.setAuth(req)
return p.doRaw(req, "POST", path)
}
func (p *Platform) putRaw(path string, body interface{}) (json.RawMessage, error) {
return p.bodyRaw("PUT", path, body)
}
func (p *Platform) patchRaw(path string, body interface{}) (json.RawMessage, error) {
return p.bodyRaw("PATCH", path, body)
}
func (p *Platform) bodyRaw(method, path string, body interface{}) (json.RawMessage, error) {
encoded, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal %s body: %w", method, err)
}
req, err := http.NewRequest(method, p.BaseURL+path, bytes.NewReader(encoded))
if err != nil {
return nil, fmt.Errorf("new %s request: %w", method, err)
}
req.Header.Set("Content-Type", "application/json")
p.setAuth(req)
return p.doRaw(req, method, path)
}
func (p *Platform) doRaw(req *http.Request, method, path string) (json.RawMessage, error) {
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("%s %s: %w", method, path, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("%s %s: HTTP %d — %s", method, path, resp.StatusCode, string(body))
}
return json.RawMessage(body), nil
}