Add Gitea Actions deploy pipeline + deployer RBAC

- .gitea/workflows/deploy.yaml: PRs run a server-side dry-run; pushes to
  main apply the kustomization and wait for all rollouts. Modeled on
  socktop-webterm's pipeline; uses the same KUBECONFIG secret convention
  and gitea-deployer ServiceAccount.
- rbac/gitea-deployer.yaml: ClusterRole/Binding (admin bootstrap, outside
  the root kustomization) — repo resource kinds only, no secrets access,
  no delete verbs, no RBAC escalation. Applied to the cluster 2026-07-26.
- One-time env->secretKeyRef migration executed against the cluster;
  README updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jasonwitty 2026-07-26 02:37:49 -07:00
parent 8afac39d03
commit 96e1825629
4 changed files with 165 additions and 13 deletions

View File

@ -0,0 +1,95 @@
name: Validate and Deploy to K3s
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install kubectl
run: |
if ! command -v kubectl &> /dev/null; then
ARCH=$(uname -m)
if [ "$ARCH" = "aarch64" ]; then
ARCH="arm64"
elif [ "$ARCH" = "x86_64" ]; then
ARCH="amd64"
fi
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/${ARCH}/kubectl"
chmod +x kubectl
sudo mv kubectl /usr/local/bin/
fi
kubectl version --client
- name: Configure kubectl
run: |
mkdir -p $HOME/.kube
echo "${{ secrets.KUBECONFIG }}" | base64 -d > $HOME/.kube/config
chmod 600 $HOME/.kube/config
- name: Server-side dry-run of full kustomization
run: |
kubectl apply -k . --dry-run=server
deploy:
needs: validate
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install kubectl
run: |
if ! command -v kubectl &> /dev/null; then
ARCH=$(uname -m)
if [ "$ARCH" = "aarch64" ]; then
ARCH="arm64"
elif [ "$ARCH" = "x86_64" ]; then
ARCH="amd64"
fi
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/${ARCH}/kubectl"
chmod +x kubectl
sudo mv kubectl /usr/local/bin/
fi
kubectl version --client
- name: Configure kubectl
run: |
mkdir -p $HOME/.kube
echo "${{ secrets.KUBECONFIG }}" | base64 -d > $HOME/.kube/config
chmod 600 $HOME/.kube/config
- name: Apply kustomization
run: |
kubectl apply -k .
- name: Wait for rollouts
run: |
set -e
kubectl rollout status deploy/bitwarden-vaultwarden -n bitwarden --timeout=10m
kubectl rollout status deploy/searxng-1723974683 -n default --timeout=10m
kubectl rollout status deploy/home-assistant -n home-assistant --timeout=15m
kubectl rollout status deploy/nginx -n nginx --timeout=10m
kubectl rollout status deploy/pihole -n pihole --timeout=10m
kubectl rollout status deploy/unbound -n pihole --timeout=10m
kubectl rollout status deploy/unifiedstreaming -n unified-streaming --timeout=10m
- name: Deployment summary
if: always()
run: |
echo "## Deployment Summary" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
for ns in bitwarden default home-assistant nginx pihole unified-streaming; do
kubectl get deployments -n $ns >> $GITHUB_STEP_SUMMARY || true
done
echo '```' >> $GITHUB_STEP_SUMMARY

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
# Real secrets never go in git — only *.example.yaml templates do. # Real secrets never go in git — only *.example.yaml templates do.
apps/**/secret.yaml apps/**/secret.yaml
*.secret.yaml *.secret.yaml
.deploy-kubeconfig.b64

View File

@ -43,21 +43,26 @@ Three apps read secrets that are NOT in git. Each app dir has a
| `usp-license` | unified-streaming | `USP_LICENSE_KEY` | | `usp-license` | unified-streaming | `USP_LICENSE_KEY` |
| `searxng-1723974683-config` | default | searxng `settings.yml` | | `searxng-1723974683-config` | default | searxng `settings.yml` |
**One-time migration (pihole + unified-streaming):** the live cluster (as of ~~**One-time migration (pihole + unified-streaming)**~~ — DONE 2026-07-26:
2026-07-26) has WEBPASSWORD / USP_LICENSE_KEY as inline plaintext `value`s. secrets `pihole-admin` and `usp-license` created in-cluster and both
`kubectl apply` (client- or server-side) cannot patch an env var from `value` deployments switched to `secretKeyRef` via `kubectl replace` (a plain
to `valueFrom` — the API rejects the merged object. Validated procedure: `kubectl apply` cannot patch an env var from `value` to `valueFrom`).
`kubectl apply -k .` now converges cleanly (verified `--dry-run=server`).
```sh ## CI/CD (Gitea Actions)
# 1. create the two secrets (see secret.example.yaml in each app dir)
# 2. one-time replace (restarts the pods):
kubectl replace -f apps/pihole/deployment-pihole.yaml
kubectl replace -f apps/unified-streaming/deployment.yaml
```
After that, `kubectl apply -k .` works cleanly for everything (verified with `.gitea/workflows/deploy.yaml`, modeled on socktop-webterm's pipeline:
`--dry-run=server` against the live cluster, 2026-07-26 — zero immutable-field
conflicts). - **PRs**`kubectl apply -k . --dry-run=server` (validation only)
- **push to main**`kubectl apply -k .` + `kubectl rollout status` on all
seven deployments
Repo Actions secret required: `KUBECONFIG` — base64-encoded kubeconfig for the
`gitea-deployer` ServiceAccount (same identity socktop-webterm deploys with;
regenerate anytime from `default/gitea-deployer-token`). Its RBAC is
`rbac/gitea-deployer.yaml` — admin-applied once, deliberately outside the root
kustomization so the pipeline token cannot escalate itself; it has no access
to secrets and no delete verbs.
## Cluster assumptions (state that lives outside these manifests) ## Cluster assumptions (state that lives outside these manifests)

51
rbac/gitea-deployer.yaml Normal file
View File

@ -0,0 +1,51 @@
# RBAC for the Gitea Actions deploy pipeline.
#
# ADMIN BOOTSTRAP — applied once by a human with cluster-admin
# (NOT part of the root kustomization: the pipeline's own token must not be
# able to escalate its privileges):
# kubectl apply -f rbac/gitea-deployer.yaml
#
# Binds to the existing `gitea-deployer` ServiceAccount in `default`
# (token secret: default/gitea-deployer-token — the same identity the
# socktop-webterm pipeline uses).
#
# Deliberately granted: the resource kinds this repo contains, cluster-wide.
# Deliberately NOT granted: secrets (app secrets are created out-of-band),
# RBAC objects, nodes, or delete on anything.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: homelab-k3s-deployer
rules:
- apiGroups: [""]
resources:
- namespaces
- persistentvolumes
- persistentvolumeclaims
- services
verbs: [get, list, watch, create, update, patch]
- apiGroups: [apps]
resources: [deployments]
verbs: [get, list, watch, create, update, patch]
- apiGroups: [networking.k8s.io]
resources: [ingresses]
verbs: [get, list, watch, create, update, patch]
- apiGroups: [apps]
resources: [replicasets]
verbs: [get, list, watch]
- apiGroups: [""]
resources: [pods, events]
verbs: [get, list, watch]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: homelab-k3s-deployer
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: homelab-k3s-deployer
subjects:
- kind: ServiceAccount
name: gitea-deployer
namespace: default