From d0ea6e9eecb43ce8112987a5285fc3ba20c2bf18 Mon Sep 17 00:00:00 2001 From: Daniel Noulet Date: Thu, 8 Oct 2026 10:36:32 +0200 Subject: [PATCH] ci: add Forgejo build and deployment workflows --- .forgejo/scripts/deploy-prd.sh | 175 +++++++++++++++++++++++++ .forgejo/workflows/ci-cd.yaml | 101 ++++++++++++++ deploy/helm/go-bananas/values-prd.yaml | 48 ++++--- 3 files changed, 306 insertions(+), 18 deletions(-) create mode 100644 .forgejo/scripts/deploy-prd.sh create mode 100644 .forgejo/workflows/ci-cd.yaml diff --git a/.forgejo/scripts/deploy-prd.sh b/.forgejo/scripts/deploy-prd.sh new file mode 100644 index 0000000..8ab0098 --- /dev/null +++ b/.forgejo/scripts/deploy-prd.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +NAMESPACE=go-insane-prd +REGISTRY=nexus.urpi.be +CERT_MANAGER_VERSION=v1.19.1 +LOCAL_PATH_VERSION=v0.0.37 +LOCK_NAME=forgejo-prd-deploy-lock + +if [[ $# -lt 4 ]]; then + echo "Usage: $0 [helm args...]" >&2 + exit 2 +fi +CHART=$1 +RELEASE=$2 +IMAGE_NAME=$3 +IMAGE_TAG=$4 +shift 4 + +: "${KUBECONFIG_PRD:?Set the Forgejo secret KUBECONFIG_PRD to the PRD kubeconfig contents}" +: "${NEXUS_USERNAME:?Set the Forgejo secret NEXUS_USERNAME}" +: "${NEXUS_PASSWORD:?Set the Forgejo secret NEXUS_PASSWORD}" + +KUBECONFIG="${RUNNER_TEMP:-/tmp}/kubeconfig-prd-${GITHUB_RUN_ID:-$$}" +umask 077 +printf '%s\n' "$KUBECONFIG_PRD" > "$KUBECONFIG" +export KUBECONFIG +LOCK_ACQUIRED=false +cleanup() { + if [[ "$LOCK_ACQUIRED" == true ]]; then kubectl -n "$NAMESPACE" delete configmap "$LOCK_NAME" --ignore-not-found --wait=false >/dev/null 2>&1 || true; fi + rm -f "$KUBECONFIG" +} +trap cleanup EXIT + +secret_value() { + local name=$1 key=$2 encoded + encoded="$(kubectl -n "$NAMESPACE" get secret "$name" -o go-template="{{index .data \"${key}\"}}" 2>/dev/null || true)" + [[ -z "$encoded" || "$encoded" == "" ]] && return 0 + printf '%s' "$encoded" | base64 -d 2>/dev/null || true +} + +kubectl get --raw=/readyz >/dev/null + +# Create the workload namespace and its Nexus pull credential idempotently. +kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - +for attempt in {1..360}; do + if kubectl -n "$NAMESPACE" create configmap "$LOCK_NAME" --from-literal=owner="${GITHUB_REPOSITORY:-local}:${GITHUB_RUN_ID:-$$}" >/dev/null 2>&1; then LOCK_ACQUIRED=true; break; fi + sleep 5 +done +if [[ "$LOCK_ACQUIRED" != true ]]; then echo "Timed out waiting for another go-insane-prd deploy to release its lock." >&2; exit 1; fi +kubectl -n "$NAMESPACE" create secret docker-registry nexus-registry \ + --docker-server="$REGISTRY" \ + --docker-username="$NEXUS_USERNAME" \ + --docker-password="$NEXUS_PASSWORD" \ + --dry-run=client -o yaml | kubectl apply -f - + +# Stateful charts use the cluster default StorageClass. Install the Rancher local +# path provisioner only when the cluster has no default class at all. +DEFAULT_STORAGE_CLASS="$(kubectl get storageclass -o=jsonpath='{range .items[*]}{.metadata.name}{" "}{.metadata.annotations.storageclass\.kubernetes\.io/is-default-class}{"\n"}{end}' | awk '$2 == "true" { print $1; exit }')" +if [[ -z "$DEFAULT_STORAGE_CLASS" ]]; then + if ! kubectl get storageclass local-path >/dev/null 2>&1 || ! kubectl -n local-path-storage get deployment local-path-provisioner >/dev/null 2>&1; then + kubectl apply -f "https://raw.githubusercontent.com/rancher/local-path-provisioner/${LOCAL_PATH_VERSION}/deploy/local-path-storage.yaml" + kubectl -n local-path-storage rollout status deployment/local-path-provisioner --timeout=5m + fi + kubectl annotate storageclass local-path storageclass.kubernetes.io/is-default-class=true --overwrite +fi + +# Applications' production charts request nginx ingress and Let's Encrypt TLS. +# Install only missing cluster prerequisites; leave existing installations intact. +if ! kubectl get ingressclass nginx >/dev/null 2>&1; then + helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx --force-update + helm repo update ingress-nginx + helm upgrade --install ingress-nginx ingress-nginx/ingress-nginx \ + --namespace ingress-nginx --create-namespace \ + --set controller.service.type=NodePort \ + --set controller.hostPort.enabled=true \ + --set controller.kind=DaemonSet \ + --set controller.admissionWebhooks.enabled=false \ + --wait --timeout 10m +fi + +if ! kubectl get crd clusterissuers.cert-manager.io >/dev/null 2>&1 || ! kubectl -n cert-manager get deployment cert-manager >/dev/null 2>&1; then + kubectl apply --validate=false -f "https://github.com/cert-manager/cert-manager/releases/download/${CERT_MANAGER_VERSION}/cert-manager.yaml" + kubectl -n cert-manager rollout status deployment/cert-manager --timeout=5m + kubectl -n cert-manager rollout status deployment/cert-manager-cainjector --timeout=5m + kubectl -n cert-manager rollout status deployment/cert-manager-webhook --timeout=5m +fi + +if ! kubectl get clusterissuer letsencrypt-prod >/dev/null 2>&1; then + kubectl apply -f - <<'ISSUER' +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-prod +spec: + acme: + server: https://acme-v02.api.letsencrypt.org/directory + email: admin@urpi.be + privateKeySecretRef: + name: letsencrypt-prod + solvers: + - http01: + ingress: + class: nginx +ISSUER +fi +kubectl wait --for=condition=Ready clusterissuer/letsencrypt-prod --timeout=2m + +if [[ ! -d "$CHART" ]]; then + echo "Helm chart not found: $CHART" >&2 + exit 1 +fi +if [[ -f "$CHART/Chart.lock" ]]; then helm dependency build "$CHART"; fi +HELM_ARGS=( + --namespace "$NAMESPACE" + --create-namespace +) +if [[ "$RELEASE" != go-guess ]]; then + HELM_ARGS+=(--set-string "image.repository=${REGISTRY}/docker-hosted/${IMAGE_NAME}" --set-string "image.tag=${IMAGE_TAG}") +fi +if [[ -f "$CHART/values-prd.yaml" ]]; then + HELM_ARGS+=(--values "$CHART/values-prd.yaml") +fi +case "$RELEASE" in + go-loose) + if ! kubectl -n "$NAMESPACE" get secret go-loose-runtime >/dev/null 2>&1; then + db_password="$(secret_value go-loose-postgresql postgres-password)" + if [[ -z "$db_password" ]] && kubectl -n "$NAMESPACE" get pvc -o name | grep -Eq 'go-loose.*postgresql|data-go-loose'; then + echo "Existing go-loose database storage has no recoverable password secret; refusing to rotate its credentials." >&2 + exit 1 + fi + [[ -n "$db_password" ]] || db_password="$(openssl rand -hex 24)" + session_secret="$(secret_value go-loose-env GO_LOOSE_SESSION_SECRET)" + [[ -n "$session_secret" ]] || session_secret="$(openssl rand -hex 32)" + kubectl -n "$NAMESPACE" create secret generic go-loose-runtime --from-literal=postgresql-password="$db_password" --from-literal=session-secret="$session_secret" + fi + db_password="$(secret_value go-loose-runtime postgresql-password)" + session_secret="$(secret_value go-loose-runtime session-secret)" + database_url="postgres://goloose:${db_password}@go-loose-postgresql:5432/goloose?sslmode=disable" + HELM_ARGS+=(--set-string "postgresql.auth.password=${db_password}" --set-string "envSecrets.GO_LOOSE_DATABASE_URL=${database_url}" --set-string "envSecrets.GO_LOOSE_SESSION_SECRET=${session_secret}") + ;; + go-mad) + if ! kubectl -n "$NAMESPACE" get secret go-mad-runtime >/dev/null 2>&1; then + db_password="$(secret_value go-mad-postgresql password)" + [[ -n "$db_password" ]] || db_password="$(secret_value go-mad-postgresql postgres-password)" + if [[ -z "$db_password" ]] && kubectl -n "$NAMESPACE" get pvc -o name | grep -Eq 'go-mad.*postgresql|data-go-mad'; then + echo "Existing go-mad database storage has no recoverable password secret; refusing to rotate its credentials." >&2 + exit 1 + fi + [[ -n "$db_password" ]] || db_password="$(openssl rand -hex 24)" + kubectl -n "$NAMESPACE" create secret generic go-mad-runtime --from-literal=postgresql-password="$db_password" --from-literal=jwt-secret="$(openssl rand -hex 32)" + fi + db_password="$(secret_value go-mad-runtime postgresql-password)" + jwt_secret="$(secret_value go-mad-runtime jwt-secret)" + HELM_ARGS+=(--set-string "postgresql.auth.password=${db_password}" --set-string "go-mad.jwtSecret=${jwt_secret}") + ;; + go-first) + if ! kubectl -n "$NAMESPACE" get secret go-first-runtime >/dev/null 2>&1; then + kubectl -n "$NAMESPACE" create secret generic go-first-runtime --from-literal=jwt-secret="$(openssl rand -hex 32)" + fi + ;; + go-places) + : "${GO_LOOSE_JWT_SECRET:?Set Forgejo secret GO_LOOSE_JWT_SECRET to the same signing key used by go-loose}" + kubectl -n "$NAMESPACE" create secret generic go-loose-jwt --from-literal=secret="$GO_LOOSE_JWT_SECRET" --dry-run=client -o yaml | kubectl apply -f - + ;; + go-bananas) + : "${GO_BANANAS_OIDC_CLIENT_SECRET:?Set Forgejo secret GO_BANANAS_OIDC_CLIENT_SECRET}" + session_secret="$(secret_value go-bananas-auth session-secret)" + if [[ -z "$session_secret" ]]; then session_secret="$(openssl rand -hex 32)"; fi + kubectl -n "$NAMESPACE" create secret generic go-bananas-auth --from-literal=session-secret="$session_secret" --from-literal=oidc-client-secret="$GO_BANANAS_OIDC_CLIENT_SECRET" --dry-run=client -o yaml | kubectl apply -f - + ;; +esac +helm upgrade --install "$RELEASE" "$CHART" "${HELM_ARGS[@]}" "$@" --wait --timeout 15m + +kubectl -n "$NAMESPACE" get deployments,statefulsets,pods,services,ingresses diff --git a/.forgejo/workflows/ci-cd.yaml b/.forgejo/workflows/ci-cd.yaml new file mode 100644 index 0000000..c570f55 --- /dev/null +++ b/.forgejo/workflows/ci-cd.yaml @@ -0,0 +1,101 @@ +name: Build, test, publish, deploy + +on: + push: + pull_request: + workflow_dispatch: + +env: + GO_VERSION: "1.27.1" + NODE_VERSION: "24" + REGISTRY: nexus.urpi.be + IMAGE_NAME: docker-hosted/go-bananas + +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: | + web/package-lock.json + sdk/package-lock.json + - name: Build and test Go server + run: | + go vet ./... + make test-go + make build-go + - name: Build and test admin UI + run: | + npm ci --no-audit --no-fund + npm run lint + npm test + npm run build + working-directory: web + - name: Build and test SDK + run: | + npm ci --no-audit --no-fund + npm run typecheck + npm test + npm run build + working-directory: sdk + - uses: azure/setup-helm@v4 + with: + version: v3.18.6 + - name: Validate production chart + run: helm lint --strict deploy/helm/go-bananas -f deploy/helm/go-bananas/values-prd.yaml + + publish: + if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') + needs: build-test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.NEXUS_USERNAME }} + password: ${{ secrets.NEXUS_PASSWORD }} + - uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + + deploy-prd: + if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') + needs: publish + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: azure/setup-helm@v4 + with: + version: v3.18.6 + - uses: azure/setup-kubectl@v4 + with: + version: v1.32.2 + - name: Bootstrap resources and deploy go-bananas + env: + KUBECONFIG_PRD: ${{ secrets.KUBECONFIG_PRD }} + NEXUS_USERNAME: ${{ secrets.NEXUS_USERNAME }} + NEXUS_PASSWORD: ${{ secrets.NEXUS_PASSWORD }} + GO_BANANAS_OIDC_CLIENT_SECRET: ${{ secrets.GO_BANANAS_OIDC_CLIENT_SECRET }} + run: | + set -Eeuo pipefail + : "${GO_BANANAS_OIDC_CLIENT_SECRET:?Add Forgejo secret GO_BANANAS_OIDC_CLIENT_SECRET}" + bash .forgejo/scripts/deploy-prd.sh deploy/helm/go-bananas go-bananas go-bananas "${{ github.sha }}" \ + --set-string identity.oidc.issuer=https://auth.urpi.be/realms/urpi \ + --set-string identity.oidc.clientId=go-bananas \ + --set-string identity.oidc.existingSecret=go-bananas-auth \ + --set identity.oidc.generateSecret=false diff --git a/deploy/helm/go-bananas/values-prd.yaml b/deploy/helm/go-bananas/values-prd.yaml index f86abb3..ad834de 100644 --- a/deploy/helm/go-bananas/values-prd.yaml +++ b/deploy/helm/go-bananas/values-prd.yaml @@ -1,20 +1,31 @@ -# PRD values for go-bananas -nameOverride: "" -fullnameOverride: "" +# Production values for go-bananas. Secret values remain in the Kubernetes Secret +# created by the Forgejo deployment job. +global: + imagePullSecrets: + - name: nexus-registry -replicaCount: 1 - -imagePullSecrets: - - name: nexus-registry +deployment: + replicaCount: 1 image: - repository: nexus.urpi.be/docker-hosted/go-bananas - tag: "0.0.1" + registry: nexus.urpi.be + repository: docker-hosted/go-bananas + tag: latest pullPolicy: Always +config: + environment: production + publicBaseUrl: https://bananas.urpi.be + +persistence: + enabled: true + type: persistentVolumeClaim + size: 2Gi + service: type: ClusterIP port: 80 + sessionSecure: true ingress: enabled: true @@ -32,16 +43,17 @@ ingress: hosts: - bananas.urpi.be -certManager: - enabled: true - issuerRef: - name: letsencrypt-prod - kind: ClusterIssuer +identity: + mode: oidc + oidc: + issuer: https://auth.urpi.be/realms/urpi + clientId: go-bananas + existingSecret: go-bananas-auth + generateSecret: false resources: - limits: - cpu: 500m - memory: 512Mi requests: cpu: 100m - memory: 256Mi + memory: 128Mi + limits: + memory: 512Mi