toolshed can now run a one-off command against an app — a migration, a
seed, a backfill — in a throwaway Job built from the app's own image with
its own configuration (internal/deploy.Client.RunCommand). It exists
because a user with a provisioned database otherwise has no way to reach
it: the operator can exec into the Postgres pod, a user cannot, and the
premise is that nobody needs kubectl.
Without this the deployer gets "forbidden" the moment anyone runs one —
the two-repositories drift internal/deploy/kubernetes.go's own package doc
warns about, and the third feature in a row to need both copies changed
together.
Delete is included because the Job is removed once its output has been
captured into the run record; without it they would accumulate one per
command forever. Notably absent is pods/exec, which an exec-into-the-
running-pod design would have required — a far more dangerous grant on the
one credential here whose compromise means the cluster, and one reason the
Job approach was chosen over it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajog7nELA3i8JWTjxYGHF
toolshed deploys an app that asked for a persistent volume as a StatefulSet
with a volumeClaimTemplate rather than a Deployment
(internal/deploy.Client.ensureStatefulSet). Without this the deployer gets
"forbidden" the moment anyone creates one — the two-repositories drift
internal/deploy/kubernetes.go's own package doc warns about, and the same
way the custom-domains Ingress rights were missed until after that feature
shipped.
Delete is included deliberately, not for tidiness: a Deployment and a
StatefulSet share the app's selector, so switching an app between stateless
and stateful must remove whichever controller it no longer is, or both stay
alive fighting over the same pods.
persistentvolumeclaims is read-only. The claims are created by the
StatefulSet's own volumeClaimTemplates, never directly by toolshed, and
deleting one would destroy an app's data — so there is no reason for this
credential to be able to.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajog7nELA3i8JWTjxYGHF
Overdue: this belongs with toolshed's custom-domains feature, which has
already merged and shipped. Without it, deployer gets "forbidden" the
moment anyone actually adds a custom domain — the exact failure mode
internal/deploy/kubernetes.go's own package doc warns about, since its
resource list and this file are unsynchronised copies in two repositories.
Scoped as a namespaced Role/RoleBinding rather than widening the
ClusterRole above it. A custom domain's Ingress and TLS secret live in the
gateway's own namespace and never in an app's, so this only ever targets
one fixed namespace — unlike the per-app namespaces the ClusterRole
necessarily spans. Secrets are get/list/watch/delete only: cert-manager
writes them, deployer just reads readiness and cleans up on removal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajog7nELA3i8JWTjxYGHF
Companion change to toolshed's new multi-replica/autoscaling feature
(internal/deploy.Client.ensureAutoscaler in the toolshed repo) — without
this, deployer's own attempt to create a HorizontalPodAutoscaler for any
app with autoscaling enabled fails with "forbidden" the first time
someone actually uses the feature, exactly the failure mode
internal/deploy/kubernetes.go's own package doc comment warns about
for these two unsynchronized copies of deployer's permission list.
Kept in sync with toolshed's own deploy/helm/toolshed/templates/rbac.yaml,
which received the identical addition.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajog7nELA3i8JWTjxYGHF
Environment variables are delivered through a Secret so values never appear
in the pod spec, but the ClusterRole was never given the resource — so every
deploy with configuration failed on a forbidden error, minutes after the
change looked fine.
Granted without list or watch. Kubernetes RBAC cannot scope a ClusterRole to
a namespace pattern, so this necessarily covers every namespace; withholding
list at least stops deployer enumerating the cluster's secrets, leaving only
access by a name it already knows. That narrows the blast radius rather than
removing it, and is called out in the manifest.
The proper fix, once there are tenants who are not the operator, is a
RoleBinding created per app namespace. That requires deployer to be able to
create RoleBindings, which is its own escalation path and wants deciding
deliberately rather than being slipped in here.
Scoped to the resource kinds toolshed creates for an app — not
cluster-admin, not a wildcard. This is the one credential in the system
whose compromise means the cluster, so what it can do should fit on one
screen.
Here rather than in toolshed's chart because these are cluster-scoped and
that Application runs in the webapp project, which permits only Namespace.
Widening webapp would have given every demo app in it the ability to create
cluster-wide RBAC.