0062. GitOps page and external tool links
Status: Superseded by ADR 0101 · Date: 2026-10-26 · Design section: CLI and UI
Decided: a new GitOpsService backs the GitOps page with three read-only RPCs: ListGitOps (every TalosCluster, TalosMachine and TalosConfigPatch the caller can list, with its tracking Application's status.sync.status and status.health.status, read with get only, no new RBAC), FindPullRequests (open pull requests of one CR, asked of the Git host only when a row's button is pressed, answered from a 30 s per-replica cache, one host call per verb the kind can propose) and GetLinks; external tool URLs are registered in the chart's values (ichorUI.links.{argocd,grafana,hubble,longhorn}, env ICHOR_LINK_*), not in a CRD spec, and a tool with no URL gets no link; links are http(s) only, checked in the backend and again in the UI with the function pull request URLs already use; Argo CD links go to the CR's Application, Grafana to the bundled dashboard's fixed uid, Hubble and Longhorn to their front page
Context
architecture.md lists, among the UI screens, "a GitOps page showing each CR's Application, sync state and pending
PRs" and "deep links into Grafana, Hubble, Longhorn and Argo CD when their URLs are registered". What exists:
gitopstracking.IsTracked/ArgoApplicationOf (ADR 0044, 0056), prmode.FindOpenPullRequest (ADR 0056), and a
backend that reads as the signed-in user when OIDC is on (ADR 0058).
Decisions
Data.
- Application state.
ListGitOpslists the three CR kinds as the caller (so the page shows what the caller may see), asksIsTrackedfor each, and for those Argo CD tracks reads the one ApplicationArgoApplicationOfnames withget. It never lists Applications, so ADR 0056'sgetrule is enough and no RBAC is added. One Application is read once per call however many CRs it tracks. Flux-tracked and untracked CRs are listed with who manages them and no state. - Whose permission reads the Application. The caller's (strict, decided 2026-10-26: "permissions are managed strictly"; see Amendment below). The first draft read it with the ServiceAccount; that showed a user the sync and health of an Application they may not read.
- Open pull requests. Not on page load. Every open pull request lookup is a call to an external host
(rate-limited, token-bearing), and a page open would make two per machine or patch. The row's "Check" button
calls
FindPullRequests, which reads the CR as the caller first, gets the plan fromplanWrite(the same decision that opens a PR, so the repository and base branch match), and looks for the heads Ichor would use (ichor/<verb>-<name>:approve/rejectfor a machine,update/deletefor a patch). Answers are cached 30 s per replica, keyed by host, repository and branches; an error is never cached and never read as "none" (ADR 0056). A CR that is written directly, or read-only because of Flux, answers with a note instead of calling the host. The token is read from the same Secret as PR creation and scrubbed from errors. - Not done. A single "list all open
ichor/pull requests of a repository" call would be cheaper than N lookups, but needs a new list function on all three hosts; it can replace the per-verb lookups later without changing the RPC. TalosStorage is not listed because no screen edits it yet. "Where Git, the cluster and the nodes disagree" is covered here only as far as Argo CD'sOutOfSyncgoes; the node side is the drift state already on the node page.
Where links are registered. In the chart's values, as environment for ichor-ui. They are install facts (the
URL a browser reaches Grafana at), change rarely, belong to the deployment rather than to any one cluster, and a
GitOps-managed chart already carries them. A TalosCluster.spec field would be per cluster although the tools are
per installation, would put a UI preference in the API, and would touch the API types. Unset means no link, so the
default install shows none.
Link shapes (verified against each tool's documentation, 2026-10-26).
- Argo CD: the Argo CD docs show
<base>/applications/<name>and say an Application in Argo CD's own namespace may omit the namespace, and that elsewhere applications are named<namespace>/<name>. The page writes the short form for namespaceargocdand/applications/<namespace>/<name>otherwise. The latter path is how the UI addresses a namespaced Application but is not spelled out as a URL in the docs. - Grafana:
<base>/d/<uid>(Grafana serves the dashboard by uid; the slug is optional). The bundled dashboard's uid isichor-overview(ADR 0040). It has no cluster or node variable (its series are the controller's and the backend's own), so there is nothing to pass;var-<name>=would only matter if a variable existed. - Hubble and Longhorn: the front page only. Neither project's documentation gives a deep-link form we could verify, so none is invented.
Safety. A registered URL is shown to every browser, so GetLinks drops anything that is not an absolute
http(s) URL with a host and no user:pass@, and the UI applies pullRequestHref (the pull request notice's
rule) again. Links open with rel="noreferrer". Nothing credential-bearing is in values or responses.
Consequences
- The page costs a handful of API-server reads per load and no host calls.
- A user sees an Application's sync and health only if their own RBAC allows
geton it (see Amendment). - A Git host failure shows in the row that asked, as "Backend unavailable" (the status code OpenPR failures already use), and other rows are unaffected.
Amendment (2026-10-26): reads are the caller's unless the result stays inside the backend
The rule: a read whose result, or anything derived from it, is returned to a user is made as that user. The
ServiceAccount (s.Client) is used only for a read that drives an internal decision, or whose content leaves only
masked or as a property of an object the caller already reads. With OIDC off there is no identity, s.Get falls
back to the ServiceAccount, and "anyone who reaches the UI may do anything" already holds (ADR 0058, point 4); that
stays, so nobody is locked out on upgrade.
A caller who may not get the tracking Application gets a permission_denied row (GitOpsItem.permission_denied):
no sync, no health, no error, and the UI shows "No permission" / "権限がありません" (common.noPermission). The other
rows render. tracked_by and the Application's name stay: both are read off the CR the caller already listed (its
tracking annotation or label); the ServiceAccount's argocd-cm and Flux inventory reads only decide how to read
those markers, and a Flux/Argo answer is the same gate that makes a write read-only, which the caller must be told.
Users need get on applications.argoproj.io in Argo CD's namespace through their own RBAC; the chart grants
nothing new, because the grant is the site's decision.
FindPullRequests already read the CR as the caller. The Git host is asked with the repository token, which is
the ServiceAccount's side and cannot be impersonated; the result is PR URLs of branches ichor/<verb>-<name> of a
CR the caller can read, in a repository that is the cluster's own. That alone is looser than the page: a caller
refused the Application's status would still see what is pending against it. So it also requires get on the
tracking Application as the caller (requireApplicationRead) and answers PermissionDenied otherwise, before any
host call. Untracked and Flux-tracked CRs have no Application and are unchanged.
Audit of every s.Client read in internal/uiapi
| Read | Call site | Returned to the user? | Reader |
|---|---|---|---|
Tracking Application get | ListGitOps | sync, health | caller (changed) |
Tracking Application get | FindPullRequests, new requireApplicationRead | only allow or refuse | caller (new) |
Kubernetes Node get/list | findKubernetesNode, GetNode | ready, kubelet version, schedulable | caller (changed); forbidden gives kubernetes_permission_denied, not "not joined" |
TalosStorage list | GetEffectiveConfig (ResolveSystemInputs listed it as the ServiceAccount) | presets appear unmasked in the rendering | caller (changed); the ServiceAccount list inside ResolveSystemInputs is overwritten |
Application get | planEdit (in planWrite) | no: decides whether a PR can be opened | ServiceAccount |
argocd-cm, Flux inventory (IsTracked, ArgoApplicationOf) | planWrite, ListGitOps, FindPullRequests | ListGitOps returns only the tracker's name for a CR the caller listed | ServiceAccount |
Secrets bundle (loadBundle) | GetEffectiveConfig, NodeTalosClient dialing | only as masked values (RedactSecrets); the client certificate never leaves | ServiceAccount |
Registry credentials (ResolveRegistryAuth) | GetEffectiveConfig | only masked | ServiceAccount |
| Git token Secret | gitToken | no: used for the host call | ServiceAccount |
Also changed: mapGetError now maps a forbidden read to PermissionDenied instead of Internal, and a forbidden
TalosStorage list does the same. Not changed and noted: the Flux inventory scan inside IsTracked can tell a
caller "Flux" for a CR they list without their own right to read Flux inventories; it is the same fact the write
gate acts on (ADR 0044), so it is kept on the ServiceAccount.