Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions docs/rfcs/0001-cli-auth-and-client-provisioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@
>
> The §6.6/§6.7/§7.7 bodies below are retained as the original design-of-record; the
> inline **Rev 8** callouts mark where the shipped behavior now differs.
>
> **Rev 9 (2026-07-06) — PROPOSED amendment, not yet accepted ([cli#136], draft).**
> Simplifies the client command surface to a strict **one machine = one client =
> one cluster** model and reaffirms the installer as the create front door. See
> **§15** for the full amendment. Net: `client use` is **removed** and the D3
> arrow-key picker is dropped (nothing to select); `client list` is **hidden**
> (kept only for the installer's client#303 pre-flight); `client create` stays
> **provision-only** (the one-line installer bootstraps the cluster, then calls it —
> the CLI does **not** reimplement k3s/k3d/GPU/proxy); `client delete` gains
> ownership of the inverse **teardown** (deprovision + Helm uninstall + local
> cluster delete), folding in R12. This narrows §6.2/§7.1/§7.3 and supersedes the
> §7.3 "selected-vs-connected" picker work (the closed [cli#133]).

## 0. Decisions settled in this revision

Expand Down Expand Up @@ -1298,6 +1310,59 @@ class Meta:
// `login --env X` switches current_env without clearing the other profiles.
```

## 15. Amendment (Rev 9) — CLI client-surface simplification

> **Status: PROPOSED, not yet accepted** — prototyped on [cli#136] (draft).
> This section narrows the command surface in §6.2 / §7.1 / §7.3 and the D3
> decision. Where it conflicts with the body above, this amendment wins **once
> accepted**; until then the body is the design of record.

### 15.1 Motivation

Live testing surfaced that the account-context, multi-client surface (`list`,
`use`, the D3 picker, selected-vs-connected) is confusing on the box that *is*
a cluster: an operator saw ~18 unrelated clients, a stale "active" pointer to a
client running on another machine, and `cluster info` targeting the wrong
namespace. The product reality is stricter than the RFC assumed: **one machine
runs one cluster, which holds one client** (the §3.1 / Q5 invariant). There is
nothing to *select*, so the selection surface is cost without benefit.

### 15.2 Decisions

| # | Was (body) | Amended |
|---|---|---|
| A1 | `client use [<slug>]` selects among clients; D3 arrow-key picker (§6.2/§7.1). | **Removed.** One client per machine — nothing to select. Drop `use` and the picker. |
| A2 | `client list` is the account-wide fleet view (§8.4). | **Hidden** (not user-facing). Kept callable only for the installer's one-client-per-machine pre-flight (client#303, `provision.sh:_account_owns_namespace`). |
| A3 | `create` "operates against an already-reachable cluster… never creates a cluster" (§6.2); installer bootstraps first (§6.4). | **Reaffirmed, explicitly.** The one-line installer is the create front door: it bootstraps the cluster (k3s/k3d + GPU + proxy — its existing `scripts/lib/*`) and then calls `client create` to provision. The Go CLI does **not** reimplement cluster bootstrap (a `create → installer` call would recurse; reimplementing the installer in Go is a large, duplicative risk). |
| A4 | Clean uninstall deferred to a `--uninstall` flag (R12, §6.2). | **`client delete` owns the inverse teardown**: deprovision the backend client (`DELETE /edge-device/<id>/`, C.3), `helm uninstall`, delete the local cluster, clear the local pointer. Confirms; `--yes` skips; `403 → ask-an-admin`. This is R12, realized as `delete`. |

### 15.3 What stays

- **Auth** (§6.1/§6.3), **provisioning + cluster_id anchor** (§6.6/§7.2/§7.9),
**the installer/CLI credential contract** (§6.4) — all unchanged. `create`
is still get-or-create keyed on the cluster.
- **§7.3 binding for the *data* + `cluster info` commands** — they still default
their namespace to the **active client** (now set only by `create`, since
`use` is gone) and give the "runs on another machine" guidance when the
reachable cluster doesn't host it.
- The **1:1 client↔cluster invariant** and **re-parenting deferred** (Q5) are
unchanged; a rebuilt cluster still mints a new client (R3).

### 15.4 Consequences / open items

- The §7.3 "selected-vs-connected" picker + connection-state-on-`use` work is
**superseded** (the closed [cli#133]); only its `cluster info` binding + the
`explain`-on-`ErrNoParentRelease` cleanup were folded into [cli#136].
- `client delete`'s teardown is **k3d/Linux + shell-out** in the prototype, and
is unit-tested only — it needs a real run against a throwaway cluster before
release.
- **Tickets to open on acceptance:** (a) the CLI surface change (this amendment)
under the epic; (b) e2e-verify `client delete` teardown; (c) confirm the
installer's client#303 pre-flight still works against a hidden `list`.

[cli#133]: https://github.com/tracebloc/cli/pull/133
[cli#136]: https://github.com/tracebloc/cli/pull/136

[backend#830]: https://github.com/tracebloc/backend/issues/830
[backend#835]: https://github.com/tracebloc/backend/issues/835
[backend#836]: https://github.com/tracebloc/backend/issues/836
Expand Down
15 changes: 15 additions & 0 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,21 @@ func (c *Client) patch(ctx context.Context, path string, body any) (int, []byte,
return c.bodyRequest(ctx, http.MethodPatch, path, body)
}

// DeleteClient deprovisions an EdgeDevice by its numeric id (RFC-0001 C.3,
// DELETE /edge-device/<id>/). 2xx or 404 both mean "gone" (idempotent); a 403
// surfaces as an APIError so the caller can route it to "ask an admin".
func (c *Client) DeleteClient(ctx context.Context, id int) error {
path := fmt.Sprintf("/edge-device/%d/", id)
status, raw, err := c.bodyRequest(ctx, http.MethodDelete, path, nil)
if err != nil {
return err
}
if status == http.StatusNotFound || (status >= 200 && status < 300) {
return nil
}
return &APIError{StatusCode: status, Body: string(raw), URL: c.BaseURL + path}
}

// bodyRequest sends an authenticated JSON-body request (POST/PATCH) and returns
// the status code + raw response. Shared so POST and PATCH stay identical on
// auth, content-type, and the 426 upgrade-required handling.
Expand Down
154 changes: 117 additions & 37 deletions internal/cli/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/tracebloc/cli/internal/api"
"github.com/tracebloc/cli/internal/cluster"
"github.com/tracebloc/cli/internal/config"
"github.com/tracebloc/cli/internal/nodeboot"
"github.com/tracebloc/cli/internal/slug"
"github.com/tracebloc/cli/internal/ui"
)
Expand All @@ -31,17 +32,30 @@ var readClusterID = cluster.ClusterID
// anchor. A package var so tests can stub it without a reachable cluster.
var readInClusterClient = cluster.DiscoverInClusterClient

// newClientCmd wires the `tracebloc client` subtree — provisioning + selecting
// the client (machine) this host enrolls as. Consumes the backend provisioning
// endpoints (backend#836) with the user token from `tracebloc login`.
// nodeboot hooks — package vars so tests can stub the k3d/helm shell-outs.
// PROTOTYPE (RFC-0001 §15, cli#136): the one-line installer stays the create
// front door; `client delete` owns the inverse teardown.
var (
teardownCluster = nodeboot.TeardownCluster
uninstallChart = nodeboot.UninstallChart
)

// newClientCmd wires the `tracebloc client` subtree. PROTOTYPE (RFC-0001 §15):
// `use` is withdrawn (one machine owns one client — nothing to select), and
// `list` is hidden (kept callable for the installer's client#303 pre-flight,
// off the user-facing surface). `create` provisions this machine's client (the
// installer bootstraps the cluster first); `delete` tears it all down.
func newClientCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "client",
Short: "Provision and manage the clients in your account",
Long: `Provision a tracebloc client for this machine and list/select clients
in your account. Requires sign-in first (` + "`tracebloc login`" + `).`,
Short: "Provision or remove this machine's tracebloc client",
Long: `Provision this machine as a tracebloc client, or tear it (and its
local cluster) down. Requires sign-in first (` + "`tracebloc login`" + `).

To set up a machine from scratch, run the one-line installer — it bootstraps
the cluster and provisions the client for you.`,
}
cmd.AddCommand(newClientCreateCmd(), newClientListCmd(), newClientUseCmd())
cmd.AddCommand(newClientCreateCmd(), newClientDeleteCmd(), newClientListCmd())
return cmd
}

Expand Down Expand Up @@ -81,27 +95,116 @@ type clientCreateOpts struct {
yes bool
}

// newClientListCmd is hidden (RFC-0001 §15): `use` is gone so users have
// nothing to select, but the installer's client#303 one-client-per-machine
// pre-flight (provision.sh:_account_owns_namespace) still shells out to
// `client list`. Keep it callable, off the user-facing surface.
func newClientListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List the clients in your account",
Hidden: true,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return runClientList(cmd.Context(), printerFor(cmd))
},
}
}

func newClientUseCmd() *cobra.Command {
return &cobra.Command{
Use: "use <client-id>",
Short: "Enroll this machine as an existing client",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runClientUse(cmd.Context(), printerFor(cmd), args[0])
// newClientDeleteCmd implements `tracebloc client delete` (RFC-0001 §15): the
// inverse of the installer's setup — deprovision this machine's client,
// uninstall its Helm release, delete the local cluster.
func newClientDeleteCmd() *cobra.Command {
var yes bool
cmd := &cobra.Command{
Use: "delete",
Short: "Delete this machine's client, its chart release, and its local cluster",
Long: `Tears down the client this machine is enrolled as: deprovisions the
backend client, uninstalls the Helm release, and deletes the local cluster.
Destructive and not undoable.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
var pr prompter
if !yes && isInteractiveTTY() {
pr = surveyPrompter{}
}
return runClientDelete(cmd.Context(), printerFor(cmd), pr, yes)
},
}
cmd.Flags().BoolVar(&yes, "yes", false, "skip the confirmation prompt")
return cmd
}

func runClientDelete(ctx context.Context, p *ui.Printer, pr prompter, yes bool) error {
client, cfg, err := authedClient()
if err != nil {
return &exitError{code: 1, err: err}
}
prof := cfg.Current()
if prof.ActiveClientID == "" {
return &exitError{code: 1, err: errors.New("no active client on this machine — nothing to delete")}
}
id, cerr := strconv.Atoi(prof.ActiveClientID)
if cerr != nil {
return &exitError{code: 1, err: fmt.Errorf("stored active client id %q is not numeric: %w", prof.ActiveClientID, cerr)}
}
ns, name := prof.ActiveClientNamespace, prof.ActiveClientName
if name == "" {
name = prof.ActiveClientID
}

p.Banner("tracebloc", "delete this machine's client")
p.Warnf("This deletes client %q (namespace %s), its Helm release, and the local cluster %q. Not undoable.",
name, ns, nodeboot.ClusterName)

if !yes {
if pr == nil {
return &exitError{code: 1, err: errors.New(
"refusing to delete without confirmation: pass --yes or run on a terminal")}
}
ok, perr := pr.Confirm(fmt.Sprintf("Delete client %q and its cluster?", name), false)
if perr != nil {
return mapClientErr(perr)
}
if !ok {
p.Infof("Cancelled — nothing was deleted.")
return nil
}
}

// 1. Deprovision the backend client (403 → ask an admin).
if derr := client.DeleteClient(ctx, id); derr != nil {
var ae *api.APIError
if errors.As(derr, &ae) && ae.StatusCode == http.StatusForbidden {
return askAnAdmin(ctx, p, client)
}
return &exitError{code: 1, err: fmt.Errorf("deprovisioning the client: %w", derr)}
}
p.Successf("Deprovisioned client %q.", name)

// 2. Uninstall the chart release (best-effort — the backend record is gone).
if ns != "" {
if uerr := uninstallChart(ctx, ns); uerr != nil {
p.Warnf("Chart uninstall reported: %v", uerr)
} else {
p.Successf("Uninstalled the Helm release %s.", ns)
}
}

// 3. Tear down the local cluster.
if terr := teardownCluster(ctx, nodeboot.ClusterName); terr != nil {
p.Warnf("Cluster teardown reported: %v", terr)
} else {
p.Successf("Deleted local cluster %q.", nodeboot.ClusterName)
}

// 4. Clear the local pointer so a stale client isn't left selected.
prof.ActiveClientID, prof.ActiveClientNamespace, prof.ActiveClientName = "", "", ""
if serr := cfg.Save(); serr != nil {
p.Hintf("Couldn't clear the local active-client pointer: %v", serr)
}
return nil
}

// clientPrompter returns the interactive prompter on a TTY, else nil (so
Expand Down Expand Up @@ -668,29 +771,6 @@ func clientStateLabel(status int) string {
}
}

func runClientUse(ctx context.Context, p *ui.Printer, id string) error {
client, cfg, err := authedClient()
if err != nil {
return &exitError{code: 1, err: err}
}
clients, err := client.ListClients(ctx)
if err != nil {
return &exitError{code: 1, err: err}
}
for _, c := range clients {
if strconv.Itoa(c.ID) == id {
setActiveClient(cfg.Current(), &c)
if serr := cfg.Save(); serr != nil {
return &exitError{code: 1, err: serr}
}
p.Successf("This machine is now set to enroll as client %s (%s).", id, c.Name)
return nil
}
}
return &exitError{code: 1, err: fmt.Errorf(
"no client %s in your account — run `tracebloc client list` to see the ids", id)}
}

// setActiveClient points this env's profile at c, caching its namespace and
// display name alongside the id so the data commands can bind to the active
// client's cluster (§7.3) without a backend round-trip. Callers Save() after.
Expand Down
56 changes: 46 additions & 10 deletions internal/cli/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/tracebloc/cli/internal/api"
"github.com/tracebloc/cli/internal/cluster"
"github.com/tracebloc/cli/internal/config"
"github.com/tracebloc/cli/internal/nodeboot"
"github.com/tracebloc/cli/internal/ui"
)

Expand Down Expand Up @@ -54,6 +55,13 @@ func withClientBackend(t *testing.T, h http.HandlerFunc) {
return nil, nil
}
t.Cleanup(func() { readInClusterClient = origLive })

// Stub the delete-side nodeboot shell-outs (k3d/helm) to no-ops so tests
// never spawn real processes. Tests asserting teardown override these.
otc, ouc := teardownCluster, uninstallChart
teardownCluster = func(context.Context, string) error { return nil }
uninstallChart = func(context.Context, string) error { return nil }
t.Cleanup(func() { teardownCluster, uninstallChart = otc, ouc })
}

// stubClusterID overrides the cluster-anchor read for a single test.
Expand Down Expand Up @@ -271,19 +279,47 @@ func TestClientList(t *testing.T) {
}
}

func TestClientUse(t *testing.T) {
withClientBackend(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`[{"id":7,"first_name":"gamma","namespace":"gamma"}]`))
func TestClientDelete(t *testing.T) {
deleted := 0
withClientBackend(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodDelete && r.URL.Path == "/edge-device/7/" {
deleted++
w.WriteHeader(http.StatusNoContent)
return
}
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
})
if err := runClientUse(context.Background(), ui.New(&bytes.Buffer{}), "7"); err != nil {
t.Fatal(err)
}
cfg, _ := config.Load()
if cfg.Current().ActiveClientID != "7" {
t.Errorf("active = %q, want 7", cfg.Current().ActiveClientID)
setActiveClient(cfg.Current(), &api.ProvisionedClient{ID: 7, Name: "box", Namespace: "ns7"})
_ = cfg.Save()

var uninstalledNS, torndown string
uninstallChart = func(_ context.Context, ns string) error { uninstalledNS = ns; return nil }
teardownCluster = func(_ context.Context, name string) error { torndown = name; return nil }

var out bytes.Buffer
if err := runClientDelete(context.Background(), ui.New(&out), nil, true); err != nil {
t.Fatalf("delete: %v", err)
}
if deleted != 1 {
t.Errorf("expected 1 backend DELETE, got %d", deleted)
}
if uninstalledNS != "ns7" || torndown != nodeboot.ClusterName {
t.Errorf("teardown wrong: uninstalled=%q cluster=%q", uninstalledNS, torndown)
}
cfg, _ = config.Load()
if cfg.Current().ActiveClientID != "" {
t.Errorf("active pointer not cleared: %q", cfg.Current().ActiveClientID)
}
if err := runClientUse(context.Background(), ui.New(&bytes.Buffer{}), "99"); err == nil {
t.Error("expected an error for an unknown client id")
}

func TestClientDelete_NoActiveClient(t *testing.T) {
withClientBackend(t, func(w http.ResponseWriter, r *http.Request) {
t.Errorf("no backend call expected, got %s %s", r.Method, r.URL.Path)
})
err := runClientDelete(context.Background(), ui.New(&bytes.Buffer{}), nil, true)
if err == nil || !strings.Contains(err.Error(), "no active client") {
t.Errorf("want no-active-client error, got %v", err)
}
}

Expand Down
Loading
Loading