| title | Architecture | ||
|---|---|---|---|
| description | Runtime model, package layout, and place in the Kupe platform for the kupe CLI | ||
| owner | platform-team | ||
| lastReviewed | 2026-10-15 | ||
| sidebar |
|
kupe is a single statically linked Go binary that wraps kupe-api for interactive and scripted use. It is the fourth access path to the platform, alongside the web console, the Terraform provider, and direct API calls — chosen when a user wants minimal setup (compared to Terraform) and a pipeable interface (compared to a browser).
The CLI is stateless apart from its config file. It holds no background processes, no cache of cluster state, and no local reconciliation logic. Every command resolves configuration, makes one or more HTTP calls to kupe-api, renders the response, and exits. Long-running "wait for provisioning" behavior is implemented by a polling loop over status.phase, not by watch connections.
graph TD
User[Developer @ TTY] --> CLI[kupe CLI]
CI[CI Pipeline] --> CLI
Kubectl[kubectl<br/>exec-plugin] --> GetToken[kupe auth get-token]
GetToken --> CLI
CLI --> Config[~/.config/kupe/<br/>config.yaml]
CLI --> Keyring[OS Keyring<br/>token storage]
CLI --> GW[Cilium Gateway<br/>api.kupe.cloud]
GW --> API[kupe-api]
API --> K8s[Kubernetes API]
K8s --> CRDs[Tenant, ManagedCluster,<br/>ManagedSecret, APIKey CRDs]
CRDs -. "async" .-> Operator[kupe-control-operator]
style CLI fill:#4a90d9,color:#fff
style API fill:#2d8659,color:#fff
style Operator fill:#c97b3a,color:#fff
The CLI never talks to the Kubernetes API directly, never reads CRDs, and never knows the name of the host cluster a vcluster is placed on. All of that is firewalled behind kupe-api's tenant-scoped endpoints.
A single command invocation passes through six layers:
user input
↓ cobra
command bodies (internal/cmd/*)
↓ factory
HTTP client (internal/client)
↓ net/http
kupe-api
The factory (internal/cli/factory.go) is the seam between command bodies and everything else. A command function takes a *cli.Factory — not a pile of globals — and pulls what it needs lazily:
// Simplified
type Factory struct {
Config func() (*config.Config, error)
Client func() (client.Interface, error)
IOStreams *cli.IOStreams
}
func NewListCmd(f *cli.Factory) *cobra.Command {
return &cobra.Command{
RunE: func(cmd *cobra.Command, args []string) error {
cli, err := f.Client()
if err != nil { return err }
clusters, err := cli.ListClusters(cmd.Context())
// render via f.IOStreams ...
},
}
}Tests inject a *cli.Factory backed by fakes; production wires real implementations. No command function ever touches os.Stdout, os.Stderr, or the filesystem directly.
For a typical command (e.g., kupe cluster list):
- Cobra parses args and flags. Global flags (
--api-url,--token,--tenant,--context,--config,--no-color,-q,-v) are bound to the root command and inherited by subcommands.-ois local to commands that render data. - Factory resolves configuration using the precedence chain
flag > KUPE_* env > config context > default, then resolves the token from--token/KUPE_API_TOKENor the resolved context's keyring/plaintexttokenRef. This happens lazily — commands that don't need the API client (likekupe version) never touch the config. - Factory builds a client with the resolved base URL, tenant, and bearer token. User-Agent is
kupe-cli/<version> (<os>/<arch>) <runtime.Version()>. - Command body calls the service (
cli.ListClusters(ctx)etc.). Client internally handles retry/backoff on5xx,Retry-Afteron429, and typed error classification. - Command body renders via
internal/printer. Output format (table/wide/json/yaml/name/go-template/jsonpath) is chosen by the command-local-oflag orpreferences.output, defaulting totable. Rendering writes tof.IOStreams.Out. - Exit code is mapped from the error type via
internal/cli/exit.go(0success,3auth,4not-found,5conflict,1general).
Long-running commands (cluster create|delete|update) add a polling loop between steps 4 and 5, discussed below.
cmd/kupe/main.go — 10 lines; calls internal/cmd.Execute()
internal/
├── cmd/ — Cobra command tree. One package per noun.
│ ├── root.go — Root cmd, global flags, factory wiring, completion generator.
│ ├── version.go — Build info via ldflags.
│ ├── completion.go — Thin shim over Cobra's built-in completion generator.
│ ├── auth/ — login, logout, whoami, get-token (exec-plugin mode).
│ ├── config/ — view, get, set, use-context, set-context,
│ │ delete-context, current-context.
│ ├── cluster/ — list, get, create, delete, update, kubeconfig, wait.
│ ├── apikey/ — list, create, delete.
│ ├── secret/ — list, get, create, update, delete.
│ ├── database/ — list, get, create, update, upgrade, delete,
│ │ rotate-credentials, wait, binding add/list/get/update/delete.
│ ├── cmdutil/ — Shared helpers: phase waits, wait-error mapping,
│ │ resource-quantity checks.
│ ├── member/ — list, add, update, remove.
│ ├── tenant/ — get.
│ ├── invoice/ — list, get.
│ └── plan/ — list, get.
├── cli/
│ ├── factory.go — Lazy config/client resolution. Injected into every cmd.
│ ├── iostreams.go — Stdin/stdout/stderr with TTY + color + spinner gating.
│ ├── flags.go — GlobalFlags struct + Bind helper.
│ ├── exit.go — Error → exit-code mapping (incl. client.APIError dispatch).
│ ├── confirm.go — Shared TTY/non-TTY confirmation helpers (yes, typed name).
│ └── verbose.go — Factory helper wiring --verbose to client.TraceFunc.
├── client/ — HTTP client, lifted from terraform-provider-kupe and extended.
│ ├── client.go — Base request/response plumbing, retry, 429 handling, TraceFunc.
│ ├── cluster.go — ListClusters, GetCluster, CreateCluster, UpdateCluster, DeleteCluster.
│ ├── cluster_kubeconfig.go — GetClusterKubeconfig (endpoint + CA envelope).
│ ├── cluster_rmw.go — UpdateClusterRMW helper with 412-retry.
│ ├── apikey.go — ListAPIKeys, CreateAPIKey, DeleteAPIKey.
│ ├── secret.go — ListSecrets, GetSecret, CreateSecret, UpdateSecret,
│ │ DeleteSecret, UpdateSecretRMW.
│ ├── database.go — Database + binding CRUD, UpdateDatabaseRMW,
│ │ UpdateDatabaseBindingRMW, RotateDatabaseCredentials.
│ ├── rmw.go — Generic ETag read-modify-write loop behind every *RMW helper.
│ ├── member.go — ListMembers, AddMember, UpdateMember, RemoveMember.
│ ├── invoice.go — ListInvoices, GetInvoice.
│ ├── plan.go — ListPlans, GetPlan.
│ ├── tenant.go — GetTenant (used by whoami).
│ ├── errors.go — APIError with Is* helpers.
│ ├── retry.go — Exponential backoff + idempotent-method gating.
│ ├── ratelimit.go — Retry-After parsing (seconds + HTTP-date).
│ └── interface.go — Interface the command layer depends on (fake'd in tests).
├── config/
│ ├── config.go — Load, save, atomic write.
│ ├── schema.go — Config / Context / Preferences structs.
│ └── precedence.go — Resolve with flag > env > context > default.
├── auth/
│ ├── token.go — Keyring-aware token get/set/delete.
│ ├── keyring.go — zalando/go-keyring wrapper (Service = "cloud.kupe.cli").
│ ├── oidc.go — Token refresh / revoke helpers.
│ ├── oidc_device.go — OIDC device-code login flow.
│ └── plaintext.go — ~/.config/kupe/credentials.yaml fallback (mode 0600).
├── printer/
│ ├── format.go — Parse + MustParse for the -o flag.
│ ├── render.go — Generic RenderList / RenderOne dispatcher.
│ ├── table.go — Column spec + tabwriter rendering + PrintDetails.
│ ├── plain.go — PrintJSON / PrintYAML / PrintNames / PrintTemplate.
│ │ (PrintJSONPath is a stub — see output.md.)
│ ├── cluster.go — Cluster column specs + age helpers.
│ ├── apikey.go — APIKey column specs + ID truncation.
│ ├── secret.go — Secret column specs + sync-target rendering.
│ ├── database.go — Database and binding column specs.
│ ├── member.go — Member column specs.
│ ├── tenant.go — Tenant detail rows.
│ ├── invoice.go — Invoice column specs.
│ └── plan.go — Plan column specs.
├── kubeconfig/
│ ├── build.go — Assemble kubeconfig from {endpoint, CA, token} or exec-plugin.
│ ├── merge.go — clientcmd-based merge into $KUBECONFIG / ~/.kube/config.
│ └── yaml.go — sigs.k8s.io/yaml wrapper (shared by build).
├── ux/
│ ├── spinner.go — Bubbletea standalone program for single-line TTY status.
│ ├── progress.go — Polling loop orchestration (WaitFor).
│ ├── style.go — Lipgloss palette; respects NO_COLOR / --no-color.
│ └── plain.go — Non-TTY fallback (stderr ticks per phase transition).
└── build/info.go — Version, Commit, Date injected by goreleaser ldflags.
pkg/ is deliberately absent. Nothing here is a public Go library — internal/ keeps the API surface controlled and matches kupe-api / terraform-provider-kupe conventions.
| Component | Relationship |
|---|---|
| kupe-api | The only remote endpoint the CLI talks to. api/swagger.json is the authoritative endpoint list. |
| terraform-provider-kupe | Sibling client. The CLI's internal/client/ starts as a copy of the tf provider's internal/client/, extended with retry/backoff and typed error helpers. Phase 2 extracts this into a shared module (kupe-go-client) consumed by both. |
| kupe-control-operator | Owns the CRD types in api/v1alpha1/. The CLI never reads CRDs directly, but field shapes (e.g., ClusterResources.CPU format regex) are inherited through the API and surfaced in validation errors. |
| console | Parallel access path. Both hit the same kupe-api endpoints and the same auth model. Config created by one is visible to the other (API keys are tenant-scoped, not path-scoped). |
| docs-public | User-facing quickstart and CLI reference live there. Written with the CLI installed — docs are verified against real binary output. |
| docs-internal | Starlight site. Consumes this repo's docs/ directory alongside every other kupe repo's docs/. |
Clusters, databases and the registry share cmdutil.Service (internal/cmd/cmdutil/service.go), which builds
list, get, wait and delete from one descriptor; create, update and any other verbs are
written per service on the same pieces (WaitForPhase, WaitForConverged, Translate, the printer).
The cross-repo order of work is Add a managed service. In this
repository a service (example: kupe cache) adds:
internal/client/cache.go: the resource, its create and patch bodies, and its error codes (seeDatabaseCodeUnavailableinclient/database.go).internal/cmd/cache/:NewCmdand thecmdutil.Servicevalue, thinlist.go,get.go,wait.goanddelete.go, hand-writtencreate.goandupdate.go, anderrors.go, mirroringinternal/cmd/database/.internal/printer/cache.go: aprinter.Resourcewith the table and detail columns.- Tests beside each, and a live test under
test/live/.
It edits these shared files:
| File | Change |
|---|---|
internal/client/interface.go, internal/client/resource.go |
One accessor (Caches()), built on the generic newResource. |
internal/client/clienttest/fake.go, internal/client/clienttest/resource.go |
The fake's per-kind fields and maps, and its store. var _ client.Interface = (*Fake)(nil) fails to compile until the accessor exists. |
internal/cmd/cmdutil/translate.go |
A refusal row for the service's switched-off code. The databases_unavailable and registries_unavailable rows each belong to one service; every other row is shared. |
internal/cmd/root.go |
root.AddCommand(...) for the new noun. |
internal/cmd/cmdutil/wait.go, internal/cmd/cmdutil/service.go |
Only if the service has a phase the shared set lacks. |
internal/client/tenant.go, internal/cmd/tenant/delete.go |
Only if kupe-api refuses tenant deletion while the service has instances. |
internal/client/plan.go, internal/printer/plan.go |
Only if the plan gains a limit to show: a Plan field, a MAX-… column for kupe plan list and a Max … row for kupe plan get, as MaxDatabases has. The registry added one: Registry.MaxStorage, shown as MAX-REGISTRY-STORAGE and Max Registry Storage. |
docs/commands.md, docs/output.md, README.md |
The command section and coverage rows, the columns, and the command overview. |
The CLI reads no platform config: it learns that a service is switched off or paused from kupe-api's error codes.
kupe registry (internal/cmd/registry/) shows three variations on this:
- One resource per tenant. A tenant's registry is always named
registry, so itscmdutil.ServicesetsSingleton: client.RegistryName: the builtgetanddeletetake no NAME, and progress labels and hints leave the name out (kupe registry get).waitis written by hand so--for readyreads as phaseRunning. - Sub-resources off
ResourceAPI. Cluster connections are aResourceAPIof their own (RegistryBindings(registry)), but repositories, images, tags, access tokens and login sessions are plainInterfacemethods (ListRepositories,GetImage,CreateRegistrySession, ...): they are paged, read from the registry rather than the cluster, or have no update. Every path goes throughtenantPath, so a repository name is one escaped segment (team-a%2Fapi). - A credential writer.
kupe registry loginstores its credential the waydocker logindoes, throughinternal/dockerconfig: the Docker credential helper the config names (credHelpers, then credsStore), run asdocker-credential-NAMEfromPATHwith the secret on stdin, or base64 in the config'sauthswith a warning. The config is written atomically at0600and every key the CLI does not own is kept. The CLI does not depend on the Docker CLI's modules.
kupe-api is synchronous over HTTP but the work it triggers is asynchronous. When you call POST /clusters, the API returns 201 Created with the freshly created ManagedCluster object whose status.phase is Pending or empty — the actual provisioning (Argo Application creation, Helm install on the host cluster, vcluster boot, credential sync) happens in kupe-control-operator minutes later.
The CLI's --wait behavior (default on for create/delete/update) implements this as a polling loop:
- After the initial write, poll
GET /clusters/{name}every 2s (exponential to 10s cap, capped at--wait-timeout). - Compare
status.phasetransitions for create/delete. Updates wait forstatus.observedGeneration >= metadata.generationandphase == Running, so resource-only updates that never leave Running still converge. - On the target phase, return success. On
Degraded, exit1naming the cluster and its phase, with akupe cluster get <name>hint. Transient API errors are ridden out for 90 seconds before exiting7. - On Ctrl+C, cancel the context, print a
Use "kupe cluster get <name>" to check statushint, and exit cleanly.
See output.md for the TTY/CI split of how these transitions are rendered.
Three entry points produce an authenticated client:
- Interactive
kupe auth login— default OIDC device-code flow, validate viaGET /tenants/{tenant}, store the token set in keyring/plaintext fallback, save context in config file. - Env-driven
KUPE_API_TOKEN— the CI short-circuit. When set, the config file is bypassed entirely; token is used as-is with whatever tenant is resolved from--tenant/KUPE_TENANT. - Exec-plugin
kupe auth get-token— called bykubectlwhen a kubeconfig was produced bykupe cluster kubeconfig NAME --exec. Prints anExecCredentialJSON object perclient.authentication.k8s.io/v1to stdout. This letskubectlacquire tokens on demand without embedding them in the kubeconfig.
See auth.md for the full token lifecycle.
- Watching resources. No
kupe cluster watch.kupe-apidoesn't expose SSE/WebSocket streams; polling is sufficient at current scale. - Local Kubernetes operations. The CLI never calls
kubectlinternally or shells tohelm. Kubeconfig generation is where it stops — users runkubectlthemselves. - CRD-level operations. No direct K8s API access, no unstructured reads, no bypass path around
kupe-api. - Multi-tenant fan-out. Every command is scoped to one tenant at a time (the current context's tenant or
--tenant). Listing across tenants is not a supported workflow in v1. - Templating / GitOps. That's Terraform's job.
| Dependency | Direction | Purpose |
|---|---|---|
kupe-api (HTTPS) |
outbound | All resource CRUD, auth validation, kubeconfig endpoint fetch. |
| OS keyring | local | Token storage per-context. |
Filesystem (~/.config/kupe/) |
local | YAML config file. |
$KUBECONFIG / ~/.kube/config |
local | Merge target for kubeconfig --merge. |
| Standard output | local | Data and renders. |
| Standard error | local | Status, prompts, logs. |
No database, no cache, no background service, no telemetry (v1).