Files
molecule-cli/internal/cmd/template.go
sdk-dev a002b412e9
Release Go binaries / release (pull_request) Blocked by required conditions
CI / Test / test (pull_request) Successful in 1m51s
Release Go binaries / test (pull_request) Successful in 1m11s
feat(cli): fix runHTTP auth bug + add management verbs
Fix the auth bug FIRST: internal/cmd/http.go runHTTP (and the
internal/client Platform HTTP helpers, which had the same gap) sent NO
Authorization header, so management calls (workspace create/delete,
secrets, tokens, …) 401'd a hardened tenant. Now every request attaches
`Authorization: Bearer $MOLECULE_API_KEY` and, when set,
`X-Molecule-Org-Id: $MOLECULE_ORG_ID` (the tenant TenantGuard routing
gate). Headers are omitted when the env vars are unset so fresh
self-host/dev tenants keep working. Regression test
TestRunHTTP_SetsAuthHeader asserts the header is set and is proven
load-bearing (fails with `Authorization header = ""` when the fix is
reverted).

Add the management verbs (PLATFORM-MANAGEMENT-API.md §5(b)), each wired
to the OpenAPI-documented endpoint at the correct auth tier (verified
against the live workspace-server router.go + handlers and controlplane
orgs handler, since the parallel feat/openapi-management-spec branch
does not exist in molecule-core — reconciled to the actual handler
source instead):

  org   list|get|create --slug/--name|create --template|export
        token list|create|revoke | allowlist
  workspace list|get|create|delete|restart|pause|resume
            budget|billing-mode|token mint
  secret  ws  list|set|delete
          org list|set|delete
  template list|import|refresh
  bundle  export|import
  events
  approvals

Org-lifecycle verbs target the control plane (MOLECULE_CP_URL, default
= api-url); tenant verbs target the tenant host with the Org API Key.
All verbs honor --json (and existing -o table|json|yaml). Request/
response shapes match the handler structs (budget USD-cents
budget_limits; billing-mode {mode}; org import {dir,mode}; secrets
{key,value}; template import {name,files}; etc.).

Tests: table-driven request-construction tests (method/path/body/auth)
for all 30 management methods against an httptest mock, plus
cmd-layer branch tests (budget flag→limits, billing-mode validation,
template file mapping, --json resolution, CP-url fallback). Existing
workspace/agent/platform commands switched to the authenticated client.

go build ./..., go vet ./..., go test ./... all green; gofmt clean on
edited files. Binary smoke-tested end-to-end: auth headers reach the
server and --json output renders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:53:04 -07:00

115 lines
3.3 KiB
Go

// Package cmd implements the CLI command tree.
package cmd
import (
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
)
// ---------------------------------------------------------------------------
// Template command group (PLATFORM-MANAGEMENT-API.md §5(b)):
// molecule template {list, import --name <n> --file <k>=<path>..., refresh}
// All go to the tenant host with the Org API Key (AdminAuth).
// ---------------------------------------------------------------------------
var templateCmd = &cobra.Command{
Use: "template",
Short: "Manage workspace templates",
}
func init() {
templateCmd.AddCommand(templateListCmd, templateImportCmd, templateRefreshCmd)
}
var templateListCmd = &cobra.Command{
Use: "list",
Short: "List workspace templates",
RunE: runTemplateList,
}
func runTemplateList(_ *cobra.Command, _ []string) error {
raw, err := newClient().ListTemplates()
if err != nil {
return fmt.Errorf("template list: %w", err)
}
return printRaw(raw)
}
var templateImportFlags struct {
name string
files []string // KEY=PATH pairs
}
var templateImportCmd = &cobra.Command{
Use: "import --name <name> --file <relpath>=<localpath> [--file ...]",
Short: "Import a template from local files",
Long: `Imports a template (POST /templates/import). Each --file maps a path
inside the template to a local file whose contents are read and uploaded.
molecule template import --name my-tmpl \
--file org.yaml=./org.yaml \
--file config.yaml=./config.yaml`,
RunE: runTemplateImport,
}
func init() {
f := templateImportCmd.Flags()
f.StringVar(&templateImportFlags.name, "name", "", "Template name (required)")
f.StringArrayVar(&templateImportFlags.files, "file", nil, "Template file mapping relpath=localpath (repeatable, required)")
templateImportCmd.MarkFlagRequired("name")
}
func runTemplateImport(_ *cobra.Command, _ []string) error {
if len(templateImportFlags.files) == 0 {
return &exitError{code: 2, msg: "template import: at least one --file relpath=localpath is required"}
}
files, err := readFileMappings(templateImportFlags.files)
if err != nil {
return &exitError{code: 2, msg: "template import: " + err.Error()}
}
raw, err := newClient().ImportTemplate(templateImportFlags.name, files)
if err != nil {
return fmt.Errorf("template import: %w", err)
}
return printRaw(raw)
}
// readFileMappings parses "relpath=localpath" pairs and reads each local file
// into the returned map[relpath]contents.
func readFileMappings(pairs []string) (map[string]string, error) {
out := make(map[string]string, len(pairs))
for _, p := range pairs {
rel, local, ok := strings.Cut(p, "=")
if !ok || rel == "" || local == "" {
return nil, fmt.Errorf("invalid --file %q (want relpath=localpath)", p)
}
data, err := os.ReadFile(local)
if err != nil {
return nil, fmt.Errorf("read %s: %w", local, err)
}
out[rel] = string(data)
}
return out, nil
}
var templateRefreshCmd = &cobra.Command{
Use: "refresh",
Short: "Refresh the template cache",
RunE: runTemplateRefresh,
}
func runTemplateRefresh(_ *cobra.Command, _ []string) error {
raw, err := newClient().RefreshTemplates()
if err != nil {
return fmt.Errorf("template refresh: %w", err)
}
if len(raw) == 0 {
fmt.Println("Template cache refresh triggered.")
return nil
}
return printRaw(raw)
}