# 0049. The CLI calls the Connect API through ichor ui's tunnel and falls back to the same handlers in-process

Status: Accepted · Date: 2026-10-26 · Design section: CLI and UI

Decided: `ichor machine|cluster|patch|node|config` call ichor-ui's Connect API over the `ichor ui` port-forward discovery, and when ichor-ui is unreachable run the very same `uiapi.Server` in-process on the user's Kubernetes credentials, so no check is duplicated or skipped

## Context

architecture.md ("CLI and UI", principle 8) says the CLI and UI "use the same
Connect API, and the CLI falls back to local computation when the backend is
unreachable". Until now `internal/cli` never called it, and there was no CLI
for approval, patches, effective config, node detail or logs.

## Decision 1: reach the backend with `ichor ui`'s machinery

`dialBackend` (`internal/cli/api.go`) reuses ADR 0030 verbatim: read the
`ichor-ui-service` Service, `waitForReadyPod`, `portForwardToPod`, on a free
local port, one `GET /` probe, then the generated Connect clients
(`connect.NewXServiceClient`, plain `http.DefaultClient`; unary and server
streaming work over HTTP/1.1). No new connection method. Each command opens
its own short-lived tunnel; only `node logs --follow` reconnects.

## Decision 2: the fallback is the same handler, not a copy

`uiapi.Server` already is the implementation of the Connect service
interfaces, over a `client.Client`; its methods have exactly the generated
clients' signatures. The fallback is `&uiapi.Server{Client: <CLI's kube
client>, ControllerNamespace: <ichor namespace>, Actor: "ichor-cli"}` used as
the client. So `ApproveMachine`'s checks (strong identity, role required, not
already approved), strict field validation on every write, `PreviewImpact`,
and `GetEffectiveConfig`'s secret masking (ADR 0018) run the same code on
both paths. There is nothing to keep in sync and no way to build a path that
skips a check. `uiapi.Server.Actor` (new, default `ichor-ui`) lets the
audit record say which path wrote the CR.

The fallback is chosen once, up front, only when ichor-ui cannot be reached
(no Service, no ready pod, tunnel failure, no HTTP answer), printed to
stderr. An error the backend *returns* (a refused approval, a rejected
patch) is the answer: it is never retried through the other path, and a write
is never re-sent after it may have reached the backend. `--local` forces the
fallback.

| Command | Backend reachable | Backend unreachable |
|---|---|---|
| `machine list/get`, `cluster list/get`, `patch list/get` | Connect | in-process, reads the CRs |
| `machine approve/reject` | Connect | in-process, same handler, writes the same CR |
| `patch create/update/delete` | Connect | in-process, same handler, writes the same CR |
| `patch preview` | Connect | in-process (`pkg/fieldmeta`, pure) |
| `config effective` | Connect | in-process: renders from the CRs and the bundle with the caller's RBAC, masked by the same code |
| `node get`, `node logs` | Connect | **no fallback**: error. The read-only Talos identity lives in ichor-ui and the CLI never talks to a node |

`init`, `adopt` and full-loss recovery are unchanged: they exist before the
cluster.

## Decision 3: command shape

architecture.md fixes only `ichor machine approve`. The rest follows it:
`ichor machine list|get|approve|reject`, `cluster list|get`,
`patch list|get|create|update|delete|preview`, `node get|logs`,
`config effective`. Scaffolded with `cobra-cli add` (in a scratch module, then
moved, as in ADR 0014). Output is the wire message as `-o yaml|json`. `approve`,
`reject` and `patch delete` confirm unless `--yes`. `patch create|update`
preview first and confirm only when the patch needs a reboot, touches an
install-time-only field or one too new for the cluster's Talos. Patches are
read from a typed `TalosConfigPatch` manifest (`-f`, `-` for stdin) decoded
with unknown fields rejected; `uiapi.PatchToWire` is the one conversion.

## Decision 4: `--follow`

`StreamNodeLogs` already takes and returns an opaque resume token. `node logs
--follow` reconnects (new tunnel) on `CodeUnavailable` or a clean end, passing
the latest token, the Go twin of `ui/src/lib/resumableStream.ts`; the same
caveat about a few repeated or missed lines applies (ADR 0018). The API has
no "snapshot" mode, so without `--follow` the command stops after
`--idle-timeout` (3s) without a new line. ponytail: a `follow` field on
`StreamNodeLogsRequest` would replace the idle timer; it needs a `proto/`
change and is left for whoever owns it.

## Not done

- `--watch` on list/get commands (the API has no watch RPC; polling would be
  the CLI inventing semantics).
- Table output; everything is structured `-o yaml|json`.
- Per-user identity: both paths act as the backend/caller until OIDC and
  impersonation (roadmap phase 9).

## Amendment: Service discovery by label

The CLI finds the ichor-ui Service by `app.kubernetes.io/component=ui` in the Ichor namespace, not by name. Kustomize `namePrefix` makes the init-installed Service `ichor-ichor-ui-service`, the chart names it `ichor-ui-service`, and both now set the label. Names are left as they are because `internal/installer` classifies UI objects by the `ichor-ichor-ui` prefix.

- `patch create|update` always print the impact (reboot, install-time-only, unsupported, or "no reboot needed") on stderr before writing, as the help says; the written object stays alone on stdout.
