Compare commits

..
10 Commits
Author SHA1 Message Date
Mukul SharmaandClaude Opus 5 5aa0439a20 GKE: app registry and bootstrap Application for gke-toolshed-prd-usc1
Adds the GCP counterpart of the homelab's cluster entry point, alongside
it rather than replacing it, so the two clusters never share a values
file:

- values/incubator-infra-gke-toolshed-prd-usc1-values.yaml, starting with
  only argocd, gitea and cert-manager. Every appSpec entry's nameOverride
  equals the Helm release name each is installed under by hand, which is
  what makes ArgoCD adopt those releases instead of rendering a second
  copy of each.
- incubator/incubator-infra-gke-toolshed-prd-usc1.yaml, the parent
  Application, applied once by hand.
- app-of-projects.yaml and the devops AppProject now point at this
  cluster's Gitea over cluster DNS, not the homelab's LAN hostname.

Sources are reached at gitea-http.gitea.svc.cluster.local:3000 rather
than through an Ingress: ArgoCD has to read them before Contour exists,
since Contour is itself one of the Applications it installs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LEsTefWWifp4ikvhHF5s6N
2026-09-12 13:16:01 +05:30
Mukul SharmaandClaude Opus 5 bf814854fd Grant deployer Job rights, for one-off commands against an app
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
2026-09-10 07:38:20 +05:30
mukul 6d3f1643db Merge pull request 'Grant deployer StatefulSet rights, for apps with persistent storage' (#2) from toolshed-statefulset-rbac into main
Reviewed-on: http://gitea.100.90.248.118.nip.io/mukul/devops-infra-argo-config/pulls/2
2026-09-09 09:48:43 +00:00
Mukul SharmaandClaude Opus 5 3aebc99635 Grant deployer StatefulSet rights, for apps with persistent storage
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
2026-09-09 15:16:16 +05:30
mukul dde2622149 Merge pull request 'Toolshed redis and domain rbac' (#1) from toolshed-redis-and-domain-rbac into main
Reviewed-on: http://gitea.100.90.248.118.nip.io/mukul/devops-infra-argo-config/pulls/1
2026-09-09 07:29:49 +00:00
Mukul SharmaandClaude Opus 5 dcd3583656 Deploy Redis, backing toolshed's managed cache add-on
Registers the new hand-written redis chart (devops-infra-helm-charts,
separate commit) and the ExternalSecret feeding its admin password from
Vault. Own namespace, addressed over cluster DNS like every other platform
component here:

  redis.redis.svc.cluster.local:6379

Only one consumer for the credential, unlike the Postgres one next door:
the server itself, to seed its ACL file on first boot. toolshed's api gets
it from the connection an operator configures in the dashboard, encrypted
in toolshed's own database — so there is deliberately no second
ExternalSecret into the toolshed namespace.

Order matters: put the password in Vault at secret/toolshed/redis before
syncing, or the init container sits in CreateContainerConfigError. The
exact command, the reason the password must be alphanumeric (it is written
into an ACL directive where a space or quote would split it), and the
manual rotation procedure are all recorded in the ExternalSecret's own
header.

Nothing here needs to change for Postgres: toolshed's managed database
add-on points at the existing postgresql.postgres.svc.cluster.local, whose
POSTGRES_USER is the initdb superuser and so already has the CREATEDB and
CREATEROLE that provisioning needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajog7nELA3i8JWTjxYGHF
2026-09-09 12:35:46 +05:30
Mukul SharmaandClaude Opus 5 4a5f4d94ab Grant deployer Ingress rights in its own namespace, for custom domains
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
2026-09-09 12:35:35 +05:30
Mukul SharmaandClaude Opus 5 9014a170d7 Add autoscaling/horizontalpodautoscalers to deployer's ClusterRole
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
2026-09-08 06:58:22 +05:30
Mukul Sharma 94a055b341 Add quant-agent ExternalSecrets 2026-09-07 07:10:56 +05:30
Mukul SharmaandClaude Opus 5 89a0aacbd7 Add toolshed-harbor-cleanup: a delete-scoped Harbor robot for builder
Deliberately separate from harbor-robot-dockerconfig, which is mounted
into build pods running arbitrary user Dockerfiles and is scoped to
push+pull only. This one is held by builder itself — a trusted platform
service that never executes user code directly — and carries a
permission the other should never have: delete. A leaked build-pod
credential can never delete anything this way, and a leaked cleanup
credential can never push.

Used by toolshed's cleanupImage (internal/builder/builder.go) to remove
an app's Harbor repository when the app itself is deleted. Nothing
destructive happens until the Vault path this pulls from is actually
populated — builder logs and skips that step otherwise, per its own
code comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wajog7nELA3i8JWTjxYGHF
2026-09-06 21:17:45 +05:30
10 changed files with 336 additions and 4 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ metadata:
spec:
project: default
source:
repoURL: 'http://gitea.192.168.1.7.nip.io/mukul/devops-infra-argo-config.git'
repoURL: 'http://gitea-http.gitea.svc.cluster.local:3000/gitadmin/devops-infra-argo-config-gcp.git'
targetRevision: main
path: projects
directory:
@@ -57,10 +57,39 @@ rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# A one-off command run against an app executes in a throwaway Job built
# from the app's own image — see internal/deploy.Client.RunCommand. Delete
# is needed because the Job is removed once its output has been captured;
# without it they accumulate one per command forever.
- apiGroups: ["batch"]
resources: ["jobs"]
verbs: ["get", "list", "watch", "create", "delete"]
# An app with persistent storage is a StatefulSet instead of a Deployment
# (internal/deploy.Client.ensureStatefulSet). Delete is needed on both:
# switching an app between stateless and stateful must remove whichever
# controller it no longer is, or the two fight over the same pods.
- apiGroups: ["apps"]
resources: ["statefulsets"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Read-only. The claims themselves are created by the StatefulSet's own
# volumeClaimTemplates, never directly here — this is only to observe
# them, and deleting one would destroy an app's data.
- apiGroups: [""]
resources: ["persistentvolumeclaims"]
verbs: ["get", "list", "watch"]
# The policy that stops one app reaching another.
- apiGroups: ["networking.k8s.io"]
resources: ["networkpolicies"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Created only for an app with autoscaling enabled (max replicas set
# above min); removed again if it's turned back off. See toolshed's own
# internal/deploy.Client.ensureAutoscaler. Added alongside that feature —
# keep this file and toolshed's deploy/helm/toolshed/templates/rbac.yaml
# in sync, per internal/deploy/kubernetes.go's own package doc warning
# that the two are unsynchronized copies in two repositories.
- apiGroups: ["autoscaling"]
resources: ["horizontalpodautoscalers"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
@@ -76,3 +105,47 @@ roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: toolshed-deployer
---
# A custom domain's Ingress and TLS secret live in the gateway's own
# namespace (toolshed, same as above) — never an app's namespace. Scoped
# with a namespaced Role/RoleBinding rather than widening the ClusterRole
# above: Ingress management here only ever targets this one fixed
# namespace, unlike the per-app namespaces the ClusterRole necessarily
# spans. Added alongside toolshed's custom-domains feature — see
# internal/deploy.Client.EnsureDomainIngress/DomainCertReady/
# RemoveDomainIngress and this file's own header comment about keeping it
# and deploy/helm/toolshed/templates/rbac.yaml in sync.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: toolshed-deployer-ingress
namespace: toolshed
labels:
app.kubernetes.io/part-of: toolshed
rules:
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Reads the TLS secret cert-manager's ingress-shim populates for a domain
# Ingress, and deletes it (and the Ingress above) when a domain is
# removed. Never create/update — cert-manager, not deployer, writes this
# secret.
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "watch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: toolshed-deployer-ingress
namespace: toolshed
labels:
app.kubernetes.io/part-of: toolshed
subjects:
- kind: ServiceAccount
name: toolshed-deployer
namespace: toolshed
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: toolshed-deployer-ingress
@@ -0,0 +1,19 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: incubator-infra-gke-toolshed-prd-usc1
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: 'http://gitea-http.gitea.svc.cluster.local:3000/gitadmin/devops-infra-argo-config-gcp.git'
targetRevision: main
path: generic-argo-apps-chart
helm:
valueFiles:
- ../values/incubator-infra-gke-toolshed-prd-usc1-values.yaml
destination:
name: in-cluster
namespace: argocd
+2 -3
View File
@@ -3,13 +3,12 @@ kind: AppProject
metadata:
name: devops
# Single ArgoCD instance — projects live in the same namespace as
# ArgoCD itself (see the argocd/argocd-admin note in
# values/incubator-infra-k8s-admin-prd-ase1-values.yaml).
# ArgoCD itself (see values/incubator-infra-gke-toolshed-prd-usc1-values.yaml).
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
description: "devops project for infra apps on the homelab VM (team: devops in the appSpec labels)."
description: "devops project for infra apps on the GKE cluster (team: devops in the appSpec labels)."
sourceRepos:
- '*'
destinations:
+27
View File
@@ -0,0 +1,27 @@
# Anthropic API key for quant-agent's Claude review stage.
#
# Without this the agent still runs — it logs the absence at startup and
# operates as a pure quantitative strategy, with every filter and risk
# limit still enforced. It simply never asks Claude for a second opinion.
#
# One-time setup:
# kubectl -n vault exec -i vault-0 -- \
# vault kv put secret/quant-agent/anthropic apiKey="sk-ant-..."
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: quant-agent-anthropic
namespace: quant-agent
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: quant-agent-anthropic
creationPolicy: Owner
data:
- secretKey: apiKey
remoteRef:
key: quant-agent/anthropic
property: apiKey
+41
View File
@@ -0,0 +1,41 @@
# Zerodha Kite Connect credentials for quant-agent.
#
# Entirely optional. With no apiKey present the agent uses its built-in
# simulated NSE feed, which is what lets the whole system run with no
# broker account.
#
# The access token is the awkward one: Kite tokens are single-session
# and expire every trading day around 06:00 IST, and minting a new one
# requires an interactive browser redirect that cannot be automated from
# inside a pod. Two options, neither automatic:
# - re-run the vault kv put below each morning, or
# - paste the fresh token into the agent's UI, which applies it live
# without a restart (POST /api/kite-token).
#
# One-time setup:
# kubectl -n vault exec -i vault-0 -- \
# vault kv put secret/quant-agent/kite apiKey="..." accessToken="..."
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: quant-agent-kite
namespace: quant-agent
spec:
# Shorter than the usual 1h so a token refreshed in Vault mid-morning
# reaches the pod reasonably quickly.
refreshInterval: 15m
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: quant-agent-kite
creationPolicy: Owner
data:
- secretKey: apiKey
remoteRef:
key: quant-agent/kite
property: apiKey
- secretKey: accessToken
remoteRef:
key: quant-agent/kite
property: accessToken
+49
View File
@@ -0,0 +1,49 @@
# Harbor management-API credentials for toolshed's builder, used only to
# delete a repository (every tagged image an app's builds ever pushed) when
# the app itself is deleted.
#
# Deliberately a separate robot account from harbor-robot-dockerconfig
# (secretstores/harbor-robot-dockerconfig.yaml), not a reuse of it. That one
# is mounted into build pods, which run arbitrary user-supplied Dockerfiles —
# scoped to push+pull only. This one is held by builder itself, a trusted
# platform service that never executes user code directly, and needs a
# permission the other should never carry: delete. Keeping them on separate
# credentials means a leaked build-pod credential can never delete anything,
# and a leaked cleanup credential can never push.
#
# Create the robot in Harbor's UI: homelab project -> Robot Accounts ->
# New Robot Account. Scope it to that one project, permission "Delete
# repository" (and "Delete artifact") only — leave push/pull unchecked, it
# never needs them. Harbor shows the generated secret exactly once.
#
# kubectl -n vault exec -i vault-0 -- sh -lc '
# vault login <root-token> >/dev/null &&
# vault kv put secret/harbor/toolshed-cleanup \
# username="robot\$homelab+toolshed-cleanup" \
# password="<the generated secret>"'
#
# Until this exists, builder logs "no management credentials, leaving its
# image alone" for every app deletion and does nothing destructive — see
# internal/builder/builder.go's cleanupImage in the toolshed repo.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: toolshed-harbor-cleanup
namespace: toolshed
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: toolshed-harbor-cleanup
creationPolicy: Owner
data:
- secretKey: username
remoteRef:
key: harbor/toolshed-cleanup
property: username
- secretKey: password
remoteRef:
key: harbor/toolshed-cleanup
property: password
@@ -0,0 +1,54 @@
# Redis admin password, backing toolshed's managed cache add-on.
#
# Only one consumer, unlike the Postgres credential next door: the Redis
# server itself needs it to seed its ACL file on first boot. toolshed's api
# reads it from the *connection* an operator configures in the dashboard
# (encrypted in toolshed's own database via the secretbox keyring), not from
# a Kubernetes Secret — so there is deliberately no second ExternalSecret
# into the toolshed namespace here.
#
# Put the credential in Vault BEFORE syncing this. External Secrets cannot
# create a Secret for a path that does not exist, and the Redis pod's init
# container will sit in CreateContainerConfigError until it can:
#
# kubectl -n vault exec -i vault-0 -- sh -lc '
# vault login <root-token> >/dev/null &&
# vault kv put secret/toolshed/redis \
# password=<a long alphanumeric password>'
#
# Use an alphanumeric password. It is written into the ACL file as
# `user default on ><password> ...` by the init container, where a space or
# a quote would split the directive and produce a server that either fails
# to start or, worse, starts with different rules than intended.
#
# Remember that `kubectl exec` into Vault is unauthenticated by default —
# without the `vault login` the commands fail with a "preflight capability
# check" error that reads like a permissions bug rather than a missing
# login.
#
# Rotating this password later does NOT propagate to a running server: the
# init container only ever writes the ACL file when it is absent, precisely
# so it cannot delete the per-app users toolshed has provisioned into it.
# To rotate, update Vault and then, against the running server:
#
# ACL SETUSER default >newpassword
# ACL SAVE
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: redis-credentials
namespace: redis
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: redis-credentials
creationPolicy: Owner
data:
- secretKey: password
remoteRef:
key: toolshed/redis
property: password
@@ -0,0 +1,49 @@
# GKE counterpart of incubator-infra-k8s-admin-prd-ase1-values.yaml. Starts
# with only the three components installed by hand at bootstrap; every
# other homelab component is added here as it is ported, each with its own
# helm-overrides/gke-toolshed-prd-usc1/<name>/custom-values.yaml.
clusterSpec:
destination:
server: ""
name: "in-cluster"
argocdSpec:
namespace: argocd
teamSpec:
devops:
source:
# Cluster DNS, not an Ingress: ArgoCD must be able to read this before
# Contour exists, because Contour is itself one of these Applications.
repoURL: http://gitea-http.gitea.svc.cluster.local:3000/gitadmin/devops-infra-helm-charts-gcp.git
targetRevision: main
path: helm-templates
valueFiles: ../../helm-overrides/gke-toolshed-prd-usc1
labels:
bu: infra
team: devops
env: prd
cluster: gke-toolshed-prd-usc1
# nameOverride on every entry must equal the Helm release name used for the
# manual bootstrap install. ArgoCD uses the Application name as the release
# name, so a mismatch renders a second copy instead of adopting the first.
appSpec:
- name: argocd
nameOverride: argocd-admin-prd
namespace: argocd
chartDir: argo-cd
valuesDir: argocd-admin-prd
- name: gitea
nameOverride: gitea
namespace: gitea
chartDir: gitea
valuesDir: gitea
- name: cert-manager
nameOverride: cert-manager
namespace: cert-manager
chartDir: cert-manager
valuesDir: cert-manager
# cert-manager's CRDs embed large OpenAPI schemas; SSA avoids the
# 256KiB last-applied-configuration limit, same as external-secrets.
serverSideApply: true
@@ -132,6 +132,27 @@ appSpec:
namespace: postgres
chartDir: postgresql
valuesDir: postgresql
- name: redis
# Backs toolshed's managed cache add-on — toolshed provisions a per-app
# ACL user, scoped to its own key prefix, on request. Own namespace for
# the same reason postgresql has one: addressed over cluster DNS like
# any other platform component, outliving whatever consumes it:
# redis.redis.svc.cluster.local:6379
#
# Hand-written chart, not Bitnami's, for the same reason as postgresql
# (infra issue #4) — Redis ships no official chart either.
#
# Authentication is defined by an ACL file with no requirepass, which
# is a security property rather than a preference: see the chart's own
# values.yaml, where getting it wrong leaves the server open to
# unauthenticated access after its first restart.
#
# Requires secretstores/toolshed-redis-credentials.yaml to have synced
# first — the init container cannot seed the ACL file without it.
nameOverride: redis
namespace: redis
chartDir: redis
valuesDir: redis
- name: victoria-metrics-single
# Replaces the Prometheus server this entry briefly was (see git
# history on this file) — same job, lower RAM/disk footprint for the