442d1ebaf4
Review fix for #13. The CP org verbs targeted /api/v1/orgs*, which is gated by RequireSession() (WorkOS cookie-only) — a bearer-token CLI can't authenticate and these 401 in prod; the tenant Org API Key has no standing on the CP at all. - org create/list now target the CP ADMIN routes (POST/GET /api/v1/admin/orgs, AdminGate bearer), authenticated with a DISTINCT credential MOLECULE_CP_ADMIN_TOKEN (never the tenant MOLECULE_API_KEY). create now requires --owner-user-id, per controlplane adminCreateOrgRequest{slug,name,owner_user_id}. ListOrgs decodes the {limit,offset,orgs[]} admin-summary envelope. Two-credential split is documented in `org`/`org create` help text; the org key is never sent to the CP. - org get/export have NO AdminGate-reachable route on the CP (session-only), so they fail fast with a clear "session-only, use the dashboard" error instead of shipping verbs that 401. - cpAdminClient() fails fast with guidance when MOLECULE_CP_ADMIN_TOKEN is unset (wrong-credential path), rather than silently sending the org key to the CP. - Wire Execute() through handleErr so SilenceErrors'd exitError messages actually print (they were previously swallowed by main's bare os.Exit(1)) — required for the fail-fast guidance to reach the user. - Optional cleanup: extract resolveBillingMode()/budgetLimitsFromFlags() so prod and tests share one definition. - Tests: client + cmd assert org verbs hit /api/v1/admin/orgs with the CP-admin bearer (no org-id header, no org-key leak), the missing-owner and missing-admin-token fail-fast paths, get/export fail-fast, and an e2e CLI test that `org list` without the admin token exits non-zero naming MOLECULE_CP_ADMIN_TOKEN. Budget shape (budget_limits) left unchanged — confirmed correct; the OpenAPI spec is the stale one (fixed separately on #2056). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
216 lines
7.0 KiB
Go
216 lines
7.0 KiB
Go
// Package cmd implements the CLI command tree.
|
|
package cmd
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Workspace management verbs (PLATFORM-MANAGEMENT-API.md §5(b)):
|
|
// get, pause, resume, budget, billing-mode, token mint.
|
|
// list / create / delete / restart / inspect live in workspace.go.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ===========================================================================
|
|
// molecule workspace get <id> (alias of inspect)
|
|
// ===========================================================================
|
|
var workspaceGetCmd = &cobra.Command{
|
|
Use: "get <workspace-id>",
|
|
Short: "Show full details for a workspace (alias of inspect)",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runWorkspaceInspect,
|
|
}
|
|
|
|
// ===========================================================================
|
|
// molecule workspace pause <id>
|
|
// ===========================================================================
|
|
var workspacePauseCmd = &cobra.Command{
|
|
Use: "pause <workspace-id>",
|
|
Short: "Pause a workspace",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runWorkspacePause,
|
|
}
|
|
|
|
func runWorkspacePause(_ *cobra.Command, args []string) error {
|
|
if err := newClient().PauseWorkspace(args[0]); err != nil {
|
|
return fmt.Errorf("workspace pause: %w", err)
|
|
}
|
|
fmt.Printf("Pause triggered for workspace %q.\n", args[0])
|
|
return nil
|
|
}
|
|
|
|
// ===========================================================================
|
|
// molecule workspace resume <id>
|
|
// ===========================================================================
|
|
var workspaceResumeCmd = &cobra.Command{
|
|
Use: "resume <workspace-id>",
|
|
Short: "Resume a paused workspace",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runWorkspaceResume,
|
|
}
|
|
|
|
func runWorkspaceResume(_ *cobra.Command, args []string) error {
|
|
if err := newClient().ResumeWorkspace(args[0]); err != nil {
|
|
return fmt.Errorf("workspace resume: %w", err)
|
|
}
|
|
fmt.Printf("Resume triggered for workspace %q.\n", args[0])
|
|
return nil
|
|
}
|
|
|
|
// ===========================================================================
|
|
// molecule workspace budget <id> [--hourly|--daily|--weekly|--monthly cents]
|
|
// ===========================================================================
|
|
var budgetFlags struct {
|
|
hourly int64
|
|
daily int64
|
|
weekly int64
|
|
monthly int64
|
|
}
|
|
|
|
var workspaceBudgetCmd = &cobra.Command{
|
|
Use: "budget <workspace-id> [--hourly|--daily|--weekly|--monthly <cents>]",
|
|
Short: "Show or set a workspace's LLM budget (USD cents per period)",
|
|
Long: `With no period flags, prints the current budget.
|
|
With one or more period flags, sets those period limits (USD cents).
|
|
Pass a period flag with value -1 to CLEAR that period's limit.
|
|
|
|
molecule workspace budget ws_123 # show
|
|
molecule workspace budget ws_123 --monthly 50000 # $500/mo
|
|
molecule workspace budget ws_123 --daily -1 # clear daily limit`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runWorkspaceBudget,
|
|
}
|
|
|
|
func init() {
|
|
f := workspaceBudgetCmd.Flags()
|
|
// Sentinel: math.MinInt64 means "flag not provided". -1 means "clear".
|
|
f.Int64Var(&budgetFlags.hourly, "hourly", budgetUnset, "Hourly limit (USD cents); -1 to clear")
|
|
f.Int64Var(&budgetFlags.daily, "daily", budgetUnset, "Daily limit (USD cents); -1 to clear")
|
|
f.Int64Var(&budgetFlags.weekly, "weekly", budgetUnset, "Weekly limit (USD cents); -1 to clear")
|
|
f.Int64Var(&budgetFlags.monthly, "monthly", budgetUnset, "Monthly limit (USD cents); -1 to clear")
|
|
}
|
|
|
|
const budgetUnset = -1 << 62
|
|
|
|
// budgetLimitsFromFlags translates the four --hourly/--daily/--weekly/--monthly
|
|
// flags into the budget_limits map sent to PATCH /workspaces/:id/budget:
|
|
// an unset flag (budgetUnset) is omitted; a negative value clears that period
|
|
// (nil); a non-negative value sets the limit. Shared by prod and tests so the
|
|
// translation logic has exactly one definition.
|
|
func budgetLimitsFromFlags(hourly, daily, weekly, monthly int64) map[string]*int64 {
|
|
limits := map[string]*int64{}
|
|
add := func(period string, v int64) {
|
|
if v == budgetUnset {
|
|
return
|
|
}
|
|
if v < 0 {
|
|
limits[period] = nil // clear
|
|
return
|
|
}
|
|
vv := v
|
|
limits[period] = &vv
|
|
}
|
|
add("hourly", hourly)
|
|
add("daily", daily)
|
|
add("weekly", weekly)
|
|
add("monthly", monthly)
|
|
return limits
|
|
}
|
|
|
|
// resolveBillingMode validates a billing-mode argument and returns the value
|
|
// to send to the tenant: the three real modes pass through; clear/null/"" map
|
|
// to "" (clear the override); anything else is an error. Shared by prod and
|
|
// tests.
|
|
func resolveBillingMode(in string) (string, error) {
|
|
switch in {
|
|
case "platform_managed", "byok", "disabled":
|
|
return in, nil
|
|
case "clear", "null", "":
|
|
return "", nil
|
|
default:
|
|
return "", &exitError{code: 2, msg: "workspace billing-mode: mode must be platform_managed, byok, disabled, or clear"}
|
|
}
|
|
}
|
|
|
|
func runWorkspaceBudget(cmd *cobra.Command, args []string) error {
|
|
cl := newClient()
|
|
id := args[0]
|
|
|
|
limits := budgetLimitsFromFlags(
|
|
budgetFlags.hourly, budgetFlags.daily, budgetFlags.weekly, budgetFlags.monthly,
|
|
)
|
|
|
|
if len(limits) == 0 {
|
|
// Show current budget.
|
|
raw, err := cl.GetBudget(id)
|
|
if err != nil {
|
|
return fmt.Errorf("workspace budget: %w", err)
|
|
}
|
|
return printRaw(raw)
|
|
}
|
|
raw, err := cl.SetBudget(id, limits)
|
|
if err != nil {
|
|
return fmt.Errorf("workspace budget: %w", err)
|
|
}
|
|
return printRaw(raw)
|
|
}
|
|
|
|
// ===========================================================================
|
|
// molecule workspace billing-mode <id> <mode|clear>
|
|
// ===========================================================================
|
|
var workspaceBillingModeCmd = &cobra.Command{
|
|
Use: "billing-mode <workspace-id> <platform_managed|byok|disabled|clear>",
|
|
Short: "Set a workspace's LLM billing-mode override",
|
|
Args: cobra.ExactArgs(2),
|
|
RunE: runWorkspaceBillingMode,
|
|
}
|
|
|
|
func runWorkspaceBillingMode(_ *cobra.Command, args []string) error {
|
|
id := args[0]
|
|
mode, err := resolveBillingMode(args[1])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
raw, err := newClient().SetBillingMode(id, mode)
|
|
if err != nil {
|
|
return fmt.Errorf("workspace billing-mode: %w", err)
|
|
}
|
|
return printRaw(raw)
|
|
}
|
|
|
|
// ===========================================================================
|
|
// molecule workspace token mint <id>
|
|
// ===========================================================================
|
|
var workspaceTokenCmd = &cobra.Command{
|
|
Use: "token",
|
|
Short: "Manage per-workspace auth tokens",
|
|
}
|
|
|
|
var workspaceTokenMintCmd = &cobra.Command{
|
|
Use: "mint <workspace-id>",
|
|
Short: "Mint a new per-workspace auth token (plaintext shown once)",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runWorkspaceTokenMint,
|
|
}
|
|
|
|
func runWorkspaceTokenMint(_ *cobra.Command, args []string) error {
|
|
resp, err := newClient().MintWorkspaceToken(args[0])
|
|
if err != nil {
|
|
return fmt.Errorf("workspace token mint: %w", err)
|
|
}
|
|
if outputFormat == "json" {
|
|
return printJSON(resp)
|
|
}
|
|
if outputFormat == "yaml" {
|
|
return printYAML(resp)
|
|
}
|
|
fmt.Printf("Token minted for workspace %s\n", resp.WorkspaceID)
|
|
fmt.Printf("auth_token: %s\n", resp.Token)
|
|
if resp.Message != "" {
|
|
fmt.Printf("%s\n", resp.Message)
|
|
}
|
|
return nil
|
|
}
|