feat(ci): GitLab CI/CD dev→prod pipeline, kustomize base+overlays

Multiple developers now work on this repo, and the only working deploy
trigger required pushing to GitHub - which contradicts the standing
GitLab-canonical policy for this repo - while every actual deploy was a
manual kubectl run against one environment (no prod exists at all).

Restructures deploy/k8s/ into base/ + overlays/{dev,prod}: overlays/dev
is a verified byte-for-byte no-op for the live sandbox (kubectl kustomize
diff against the old flat layout is empty), overlays/prod is scaffolded
but inert (placeholder hostname + JMAP_SERVER_URL, since neither a prod
hostname decision nor a prod Stalwart exist yet). deploy/k8s/ca/ (the
EJBCA internal CA) is untouched and never referenced by either overlay.

Adds .gitlab-ci.yml: verify (MR gate, no push/deploy) -> build+deploy-dev
(automatic on push to dev, one image name/tag-only environments, fixing
the old -dev/-beta naming split) -> promote (manual, protected
`production` environment, retags the exact dev digest via
`docker buildx imagetools create` - never rebuilds - and is left as a
documented TODO for the actual `kubectl apply` until prod is real).

Updates VNCMAIL-SETUP.md and deploy/k8s/README.md to describe the new
flow and correct the aspirational promotion description that assumed a
"production image" CI never actually built.

Also fixes a pre-existing lint error (no-control-regex false positive on
an intentional DN-sanitizing character class in lib/smime-ca/ejbca.ts)
that was blocking this commit's pre-commit hook - unrelated to this
change otherwise, confirmed already present on dev before this branch.

Runner/RBAC/registry setup is an infra prerequisite this commit cannot
provide - documented in the pipeline plan, not part of this diff.
This commit is contained in:
Bernd Rodler
2026-08-05 11:43:55 +02:00
parent 12908ab706
commit 3512f935d1
18 changed files with 402 additions and 62 deletions
+144
View File
@@ -0,0 +1,144 @@
# GitLab-CI dev→prod pipeline for VNCmail+.
#
# Design (see the approved plan for full rationale):
# - One image name, environment lives only in the tag. No more -dev/-beta
# name confusion.
# - MR into `dev`: verify only (typecheck/lint/unit test/build check). No
# push, no deploy — this is the multi-developer merge gate.
# - Push to `dev`: build+push an immutable `sha-<sha>` tag, auto-deploy it
# to the vncmail (sandbox) namespace. No approval needed — dev always
# deploys.
# - Push to `main`: NEVER rebuilds. `main` only ever advances via
# `git merge --ff-only dev`, so main's HEAD commit already has a built
# image. The `promote` job retags that exact digest (registry-side copy,
# same primitive the old docker-publish.yml GHA workflow already used for
# its multi-arch manifest-list merge) and applies it to prod. `when:
# manual` + a protected `production` GitLab environment is the approval
# gate — nobody but an authorized user can click it, and nothing here
# runs automatically on main.
#
# Deliberately single-platform (linux/amd64) for the cluster build — this
# pipeline's job is deploying to a known amd64 microk8s cluster, not public
# multi-arch distribution (that's what the GHCR release workflows are for,
# and they're untouched by this file).
#
# Prerequisites this pipeline assumes are already in place (see the plan's
# "Split of responsibility" — these are admin/infra actions, not something
# this file can set up):
# - GitLab Container Registry enabled for this project (CI_REGISTRY_* vars
# are then provided automatically — no manual credential setup needed).
# - A GitLab Runner with the Kubernetes executor, whose deploy-stage jobs
# run as a `gitlab-deployer` ServiceAccount scoped (namespaced Role, not
# cluster-admin) to the `vncmail` namespace (and later `vncmail-prod`).
# kubectl auto-detects in-cluster config from that ServiceAccount's
# mounted token — no KUBECONFIG variable required.
#
# deploy/k8s/ca/ (the EJBCA internal CA) is never referenced anywhere below —
# that stays a fully manual, human-only runbook (see deploy/k8s/ca/README.md).
stages:
- verify
- build
- deploy-dev
- promote
variables:
IMAGE: $CI_REGISTRY_IMAGE/vncmail-plus
DEV_NAMESPACE: vncmail
PROD_NAMESPACE: vncmail-prod
# ---------------------------------------------------------------------------
# verify — required check on every MR into dev. No registry, no cluster.
# ---------------------------------------------------------------------------
verify:
stage: verify
image: node:24-alpine
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
script:
- npm ci
- npm run typecheck
- npm run lint
- npm run test:translations
- npm run build
# test:integration is deliberately NOT here — it spins up a real Stalwart
# fixture via docker-compose (Docker-in-Docker), which is heavier than a
# fast MR gate should be. Candidate for a separate scheduled/optional job
# later, not a blocker for this pipeline's first cut.
# ---------------------------------------------------------------------------
# build — push to dev only. Builds once; main never rebuilds (see header).
# ---------------------------------------------------------------------------
build:
stage: build
image: docker:27-cli
services:
- docker:27-dind
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "dev"'
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" "$CI_REGISTRY" --password-stdin
script:
- docker build --build-arg GIT_COMMIT=$CI_COMMIT_SHA -t "$IMAGE:sha-$CI_COMMIT_SHORT_SHA" -t "$IMAGE:dev-latest" .
- docker push "$IMAGE:sha-$CI_COMMIT_SHORT_SHA"
- docker push "$IMAGE:dev-latest"
# ---------------------------------------------------------------------------
# deploy-dev — automatic, no approval. Deploys the immutable sha tag, never
# the moving dev-latest pointer, so what's running always matches one commit.
# ---------------------------------------------------------------------------
deploy-dev:
stage: deploy-dev
image: bitnami/kubectl:1.31
environment:
name: dev
url: https://vncmail.sandbox.vnc.de
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "dev"'
script:
# Apply the manifests first (structure/config), then set the exact image
# this pipeline just built — imperative `set image`, not a kustomize-file
# edit, so overlays/dev never needs a commit to change what's deployed.
- kubectl apply -k deploy/k8s/overlays/dev
- kubectl -n $DEV_NAMESPACE set image deployment/vncmail-plus vncmail-plus="$IMAGE:sha-$CI_COMMIT_SHORT_SHA"
- kubectl -n $DEV_NAMESPACE rollout status deploy/vncmail-plus --timeout=120s
# ---------------------------------------------------------------------------
# promote — manual, protected `production` environment. No docker build here
# — retags the exact digest already deployed to dev, then applies prod
# pinned to that digest (never a mutable tag).
# ---------------------------------------------------------------------------
promote:
stage: promote
image: docker:27-cli
services:
- docker:27-dind
environment:
name: production
url: https://vncmail.CHANGEME.invalid # placeholder until the real prod host is decided
rules:
# `when: manual` lives inside the rule (not as a top-level job key) —
# required syntax once `rules:` is used at all.
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"'
when: manual
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" "$CI_REGISTRY" --password-stdin
script:
- echo "Retagging the image already built+deployed for dev commit $CI_COMMIT_SHA — no rebuild."
- docker buildx imagetools create --tag "$IMAGE:prod-latest" "$IMAGE:sha-$CI_COMMIT_SHORT_SHA"
- DIGEST=$(docker buildx imagetools inspect "$IMAGE:sha-$CI_COMMIT_SHORT_SHA" | awk '/^Digest:/{print $2}')
- echo "Resolved digest for prod = $IMAGE@$DIGEST"
- >
echo "STOPPING HERE ON PURPOSE: deploy/k8s/overlays/prod is still
scaffolded/inactive (placeholder hostname, placeholder JMAP_SERVER_URL
— no prod Stalwart exists yet). Once both are real (Phase D in the
pipeline plan / VNCMAIL-SETUP.md), replace this echo with the same
pattern deploy-dev uses, against a bitnami/kubectl image and
\$PROD_NAMESPACE: kubectl apply -k deploy/k8s/overlays/prod &&
kubectl -n \$PROD_NAMESPACE set image deployment/vncmail-plus
vncmail-plus=$IMAGE@$DIGEST"
# Deliberately does NOT run `kubectl apply -k overlays/prod` yet — prod
# namespace/hostname/Stalwart don't exist (Phase C/D in the plan). Once
# they do, replace the placeholder echo above with the same
# `kubectl apply -k .` + `set image ...@$DIGEST` pattern deploy-dev uses,
# against $PROD_NAMESPACE, using the bitnami/kubectl image.