Merge remote-tracking branch 'gitlab/claude/gitlab-ci-dev-prod-pipeline' into dev-merge-batch1

This commit is contained in:
Bernd Rodler
2026-08-05 17:58:03 +02:00
24 changed files with 640 additions and 96 deletions
+2 -2
View File
@@ -59,8 +59,8 @@ next-env.d.ts
# Sibling repos # Sibling repos
/repos/ /repos/
# k8s deploy secret (create from deploy/k8s/secret.example.yaml) # k8s deploy secrets (create from the matching overlay's secret.example.yaml)
/deploy/k8s/secret.yaml /deploy/k8s/overlays/*/secret.yaml
# S/MIME plugin build output (rebuild with: cd vnc/plugins/smime && npm run build) # S/MIME plugin build output (rebuild with: cd vnc/plugins/smime && npm run build)
vnc/plugins/smime/node_modules/ vnc/plugins/smime/node_modules/
+165
View File
@@ -0,0 +1,165 @@
# GitLab-CI dev→prod pipeline for VNCmail+ — GitOps via ArgoCD.
#
# Revised after direct inspection of the real infrastructure found ArgoCD
# already installed (idle, zero Applications) on the dev-k8s-1/2/3 cluster.
# That's more idiomatic than a runner-executes-kubectl design, and it means
# this pipeline needs ZERO cluster credentials — CI only ever talks to the
# container registry and to this git repo. ArgoCD (which already has
# whatever cluster access it needs, set up once when its Applications were
# registered — see deploy/argocd/) is what actually applies anything.
#
# Design:
# - One image name, environment lives only in the tag. No more -dev/-beta
# name confusion from the old GitHub Actions workflow.
# - 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, then commit a
# one-line tag-bump into overlays/dev/image-tag/kustomization.yaml
# (`[skip ci]`, so this doesn't retrigger itself). ArgoCD's `vncmail-dev`
# Application has automated sync — it notices the git change and applies
# it. No approval needed, dev always deploys, and this job never touches
# the cluster directly.
# - 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 same sha- tag dev already deployed). This job just bumps
# overlays/prod/image-tag/kustomization.yaml to point at that same tag.
# The actual promotion gate is a HUMAN clicking Sync on the `vncmail-prod` ArgoCD
# Application (deliberately NOT automated sync) — not a GitLab manual
# job, since ArgoCD already provides that exact gate more directly.
# Until prod Stalwart/hostname/secrets are real (see VNCMAIL-SETUP.md),
# nobody should click that Sync button — but nothing here does it for
# you either way.
#
# Deliberately single-platform (linux/amd64) — this pipeline serves two
# known amd64 microk8s clusters, not public multi-arch distribution (that's
# what the GHCR release workflows are for, untouched by this file).
#
# Prerequisite this file assumes (documented in VNCMAIL-SETUP.md, not
# something this file can set up itself):
# - GitLab Container Registry enabled for this project (confirmed done).
# - A GitLab Runner (any kind — no cluster access needed at all now).
# - Either "allow this job token to push to this project" enabled
# (Settings → CI/CD → Job token permissions), OR a project access token
# with `write_repository` scope in $GITLAB_PUSH_TOKEN. The job below
# tries CI_JOB_TOKEN first (see the script).
#
# deploy/k8s/ca/ (the EJBCA internal CA) is never referenced anywhere below,
# and neither ArgoCD Application in deploy/argocd/ points at it — that stays
# a fully manual, human-only runbook (see deploy/k8s/ca/README.md).
stages:
- verify
- build
- bump-dev
- bump-prod
variables:
IMAGE: $CI_REGISTRY_IMAGE/vncmail-plus
GIT_STRATEGY: clone
# ---------------------------------------------------------------------------
# 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), heavier than a fast MR
# gate should be. Candidate for a separate scheduled job, not a blocker.
# ---------------------------------------------------------------------------
# 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"
# ---------------------------------------------------------------------------
# bump-dev — no cluster access. Commits the just-built tag into the overlay
# ArgoCD watches; ArgoCD's automated sync does the actual apply.
# ---------------------------------------------------------------------------
bump-dev:
stage: bump-dev
image: alpine/git:2.47.0
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "dev"'
script:
- TAG="sha-$CI_COMMIT_SHORT_SHA"
- |
cat > deploy/k8s/overlays/dev/image-tag/kustomization.yaml <<EOF
# Owned by CI (bump-dev job in .gitlab-ci.yml) - regenerated every
# push to dev. Do not hand-edit; edits here get overwritten.
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component
images:
- name: ghcr.io/brvncde-dotcom/vncmail-plus-dev
newName: $IMAGE
newTag: $TAG
EOF
- git config user.name "vncmail-ci"
- git config user.email "ci@vnc.biz"
- git add deploy/k8s/overlays/dev/image-tag/kustomization.yaml
- |
if git diff --cached --quiet; then
echo "No change (tag already pinned) - nothing to commit"
else
git commit -m "chore(deploy): pin dev to $TAG [skip ci]"
git push "https://gitlab-ci-token:${GITLAB_PUSH_TOKEN:-$CI_JOB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" HEAD:dev
fi
# ---------------------------------------------------------------------------
# bump-prod — no cluster access, no rebuild. Points overlays/prod at the
# exact tag already running on dev. Does NOT deploy anything: vncmail-prod's
# ArgoCD Application has manual sync, so this only prepares what a human
# would be syncing, it doesn't sync it.
# ---------------------------------------------------------------------------
bump-prod:
stage: bump-prod
image: alpine/git:2.47.0
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"'
script:
- TAG="sha-$CI_COMMIT_SHORT_SHA"
- echo "main advanced to $CI_COMMIT_SHA (must be a dev commit, ff-only) - that image already exists as $IMAGE:$TAG"
- |
cat > deploy/k8s/overlays/prod/image-tag/kustomization.yaml <<EOF
# Owned by CI (bump-prod job in .gitlab-ci.yml) - regenerated every
# push to main. Do not hand-edit; edits here get overwritten. Bumping
# this is NOT the same as deploying it - vncmail-prod's ArgoCD
# Application has manual sync, see the note in the parent
# kustomization.yaml.
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component
images:
- name: ghcr.io/brvncde-dotcom/vncmail-plus-dev
newName: $IMAGE
newTag: $TAG
EOF
- git config user.name "vncmail-ci"
- git config user.email "ci@vnc.biz"
- git add deploy/k8s/overlays/prod/image-tag/kustomization.yaml
- |
if git diff --cached --quiet; then
echo "No change (tag already pinned) - nothing to commit"
else
git commit -m "chore(deploy): point prod overlay at $TAG (not synced - manual gate in ArgoCD) [skip ci]"
git push "https://gitlab-ci-token:${GITLAB_PUSH_TOKEN:-$CI_JOB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" HEAD:main
fi
+106 -13
View File
@@ -18,8 +18,8 @@ of truth; VNCmail+ is the UI. It deploys as a **container on Kubernetes
except `/tmp`, so Bulwark's `mkdir ./data` crashes (`ENOENT /var/task/data`). except `/tmp`, so Bulwark's `mkdir ./data` crashes (`ENOENT /var/task/data`).
You cannot point its data dirs at a remote host either (they're POSIX paths, You cannot point its data dirs at a remote host either (they're POSIX paths,
not URLs). Bulwark's native model is a container + persistent volumes. not URLs). Bulwark's native model is a container + persistent volumes.
- So VNCmail+ runs as a Docker image (`ghcr.io/brvncde-dotcom/vncmail-plus-*`) - So VNCmail+ runs as a Docker image with **4 persistent volumes**, exactly
with **4 persistent volumes**, exactly like the existing `bulwark.sandbox.vnc.de`. like the existing `bulwark.sandbox.vnc.de`.
- JMAP calls go through **server-side `/api/*` routes** (`proxy.ts`) → server-to- - JMAP calls go through **server-side `/api/*` routes** (`proxy.ts`) → server-to-
server to Stalwart, **no browser CORS**. Config is **runtime-read**. server to Stalwart, **no browser CORS**. Config is **runtime-read**.
@@ -27,37 +27,130 @@ of truth; VNCmail+ is the UI. It deploys as a **container on Kubernetes
| Branch | Role | | Branch | Role |
|--------|------| |--------|------|
| `main` | **Production** — CI builds `…/vncmail-plus-beta`. Only updated by an explicit promote. | | `main` | **Production.** Only updated by `git merge --ff-only dev`, then an explicit manual promote in CI. No prod environment exists yet — see "CI/CD" below. |
| `dev` | Integration + QA — CI builds `…/vncmail-plus-dev` on push. Default working branch. | | `dev` | Integration + QA — default working branch. Every push auto-builds and auto-deploys to the sandbox (`vncmail.sandbox.vnc.de`). |
| `vnc/*`| Feature branches for UI work (branch off `dev`, PR into `dev`). | | `vnc/*`| Feature branches for UI work (branch off `dev`, MR into `dev` — required, gated by CI). |
All VNC customization lives under `vnc/` (see `vnc/VNC-CHANGES.md`). All VNC customization lives under `vnc/` (see `vnc/VNC-CHANGES.md`).
## CI/CD — GitLab (canonical) + ArgoCD GitOps, Vercel-style dev→prod
Multiple developers work on this repo now. `.gitlab-ci.yml` on
[gitlab.vnc.biz](https://gitlab.vnc.biz/gitlab-instance-b9b5cf2f/vncmail-plus)
(the canonical remote — GitHub `origin` is a passive mirror, not where CI or
deploys happen) builds images and bumps a tag in git; **ArgoCD does the
actual deploying** — already installed and idle on the `dev-k8s-1/2/3`
cluster, discovered when standing this up. GitLab CI needs zero cluster
credentials as a result.
Two real clusters, confirmed by direct inspection:
| Cluster | Role | Notes |
|---|---|---|
| `dev-k8s-1/2/3` | dev/sandbox | ~hours old when set up here. Traefik, metallb, cert-manager (`letsencrypt-staging` issuer only), **ArgoCD already running**. |
| `node1/node2/node3` | prod (HA) | Older, rook-ceph+traefik+metallb+cert-manager, but **zero apps and zero ClusterIssuers** — genuinely a clean slate. |
Neither cluster had a `vncmail` namespace, `vnc-ca` namespace, or `bulwark`
ingress — the "live sandbox at vncmail.sandbox.vnc.de" referenced earlier in
this doc's history was aspirational (manifests + docs existed, nothing was
ever actually applied). The ingress manifests also assumed nginx (`class:
public`, an nginx body-size annotation) — fixed to Traefik's real
`ingressClassName: traefik` (Traefik has no default body-size cap, so no
replacement annotation is needed).
Flow:
1. **MR into `dev`**`verify` stage (typecheck/lint/unit test/build).
Required check — no push, no deploy.
2. **Merge to `dev`**`build` pushes one image,
`registry.gitlab.vnc.biz/.../vncmail-plus:sha-<sha>`, then `bump-dev`
commits that tag into `deploy/k8s/overlays/dev/image-tag/kustomization.yaml`
(`[skip ci]`). ArgoCD's `vncmail-dev` Application picks up the git change.
3. **Merge to `main`** (fast-forward only, see below) → `bump-prod` points
`overlays/prod/image-tag/` at that same tag — **no rebuild**. The actual
promotion gate is a **human clicking Sync** on the `vncmail-prod` ArgoCD
Application, which is permanently manual-sync (never automated) — that's
the Vercel-style "Promote to Production" button, just living in ArgoCD's
UI instead of GitLab's.
### What's left to wire up (one-time, human steps)
1. **Add the ArgoCD deploy key to GitLab** — Project → Settings → Repository
→ Deploy keys → add (read-only is enough):
```
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOURjX/Y9zfB785DyLEF1GUq4HhWujrqeXag8oxdMciq argocd@dev-k8s (vncmail-plus read-only)
```
Until this is added, `vncmail-dev`'s ArgoCD Application (already created,
`kubectl -n argocd get application vncmail-dev`) shows a benign
`ComparisonError` (SSH handshake failing) — expected, not a bug.
2. **Let CI push tag-bumps back to this repo** — either enable "this project
can be accessed by CI/CD job tokens from other projects" → actually
simpler: Settings → CI/CD → Job token permissions → allow this project's
own job token to push to itself, OR create a Project Access Token
(`write_repository` scope) and add it as a masked CI/CD variable
`GITLAB_PUSH_TOKEN` (the pipeline tries that first, falls back to
`CI_JOB_TOKEN`).
3. **One-time namespace bootstrap** (CI/ArgoCD deliberately never manage
secret contents — see `deploy/k8s/README.md` §3):
```bash
# against dev-k8s (ArgoCD's CreateNamespace=true will make `vncmail` on
# first sync, or create it yourself first — either order works)
kubectl create secret docker-registry ghcr-pull -n vncmail ... # or make the GHCR package public
cp deploy/k8s/overlays/dev/secret.example.yaml secret.yaml # edit SESSION_SECRET
kubectl apply -f secret.yaml
```
4. **First sync** — ArgoCD UI at `https://argo.devcluster.vnc.de`
(username `admin`, password: `kubectl -n argocd get secret
argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d`
— rotate it after logging in once) → `vncmail-dev` → Sync. Once that's
clean, flip `deploy/argocd/vncmail-dev-app.yaml`'s commented-out
`automated:` block on and re-apply, so dev auto-syncs on every push from
then on.
5. **Production** (later, deliberately not wired yet): decide a real
hostname, stand up prod Stalwart, register `node1-3` as an ArgoCD-managed
cluster, apply `deploy/argocd/vncmail-prod-app.yaml`, fill in real
`overlays/prod` values, create a real ClusterIssuer on `node1-3` (there
isn't one today), then click Sync once — deliberately not before.
Historical note: the old `-dev`/`-beta` GHCR image-name split
(`.github/workflows/docker-publish.yml`) is retired by this — one image name
now, environment lives only in the tag.
## Deploy (Kubernetes / microk8s) ## Deploy (Kubernetes / microk8s)
Full runbook: **[deploy/k8s/README.md](deploy/k8s/README.md)**. In short: Full runbook: **[deploy/k8s/README.md](deploy/k8s/README.md)**. In short:
1. CI builds the image on push to `dev`/`main``ghcr.io/brvncde-dotcom/vncmail-plus-dev` (`.github/workflows/docker-publish.yml`). 1. CI (above) builds and pushes the image, one name/many tags, to GitLab's
2. `kubectl apply` the manifests in `deploy/k8s/` (namespace, 4 PVCs, deployment, service, ingress) + a `secret.yaml` (from `secret.example.yaml`) + a `ghcr-pull` image-pull secret. registry.
3. Point `vncmail.sandbox.vnc.de` DNS at the ingress; cert-manager issues TLS. 2. `kubectl apply -k deploy/k8s/overlays/dev` (or `overlays/prod`, once real)
— base manifests (namespace, 4 PVCs, deployment, service, ingress) live in
`deploy/k8s/base/`, environment differences (namespace, hostname, replica
count) are overlay patches.
3. DNS + a `secret.yaml` (from the overlay's `secret.example.yaml`, gitignored,
created once by hand — CI never manages secret contents) + an image-pull
secret are the remaining manual, human, one-time steps per environment.
Runs alongside the existing `bulwark.sandbox.vnc.de`. Match your cluster's Runs alongside the existing `bulwark.sandbox.vnc.de`. Match your cluster's
StorageClass / IngressClass / cert issuer to bulwark's (see the runbook). StorageClass / IngressClass / cert issuer to bulwark's (see the runbook).
## Deploy workflow (dev-first — ALWAYS) ## Deploy workflow (dev-first — ALWAYS)
Same flow as every other VNC/SRC repo: Same flow as every other VNC/SRC repo, now enforced structurally by CI rather
than by convention:
1. Work on `dev` (or `vnc/*`PR into `dev`). Push to `dev` → CI builds the `-dev` image → `kubectl -n vncmail rollout restart deploy/vncmail-plus` to pull it. QA at `vncmail.sandbox.vnc.de`. 1. Work on `dev` (or `vnc/*` → MR into `dev`, CI-gated). Merge → auto-builds
and auto-deploys to `vncmail.sandbox.vnc.de`. QA there.
2. **Promote to production only on explicit go-live** — merge `dev` → `main`: 2. **Promote to production only on explicit go-live** — merge `dev` → `main`:
```bash ```bash
git log dev..main # MUST be empty — main must have nothing dev lacks (else prod would revert) git log dev..main # MUST be empty — main must have nothing dev lacks (else prod would revert)
git checkout main && git merge --ff-only dev git checkout main && git merge --ff-only dev
git push origin main # CI builds the production image git push gitlab main # never GitHub — opens the manual `promote` job, does not run it
git checkout dev git checkout dev
``` ```
Then roll the production deployment to the new image (pin its digest — see deploy/k8s/README.md). Then click `promote` in the GitLab pipeline UI (protected `production`
Never push straight to `main`. Never let a dev→main merge silently revert prod. environment — requires the right role) once prod actually exists (see
"CI/CD" above). Never push straight to `main`. Never let a dev→main merge
silently revert prod.
## Syncing upstream (Bulwark releases) ## Syncing upstream (Bulwark releases)
+29
View File
@@ -0,0 +1,29 @@
# Registered on the dev-k8s-1/2/3 cluster (where ArgoCD already lives) via
# `kubectl apply` directly to the argocd namespace — this file is the
# version-controlled record of that, not something ArgoCD itself syncs
# (no app-of-apps here, deliberately kept simple for two Applications).
#
# syncPolicy starts WITHOUT automated — manual sync until the one-time
# per-namespace bootstrap (vncmail-env secret, image-pull secret — see
# deploy/k8s/README.md §3) is done by hand once. Flip to automated (see
# commented block below) only after a first manual sync succeeds cleanly.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: vncmail-dev
namespace: argocd
spec:
project: default
source:
repoURL: git@gitlab.vnc.biz:gitlab-instance-b9b5cf2f/vncmail-plus.git
targetRevision: dev
path: deploy/k8s/overlays/dev
destination:
server: https://kubernetes.default.svc # in-cluster — ArgoCD and vncmail-dev share this cluster
namespace: vncmail
syncPolicy:
syncOptions:
- CreateNamespace=true
# automated:
# prune: true
# selfHeal: true
+30
View File
@@ -0,0 +1,30 @@
# NOT YET APPLIED to any cluster. Scaffolding only, matching
# deploy/k8s/overlays/prod's own "inert until Phase D" status.
#
# Unlike vncmail-dev-app.yaml, this targets a DIFFERENT cluster (node1-3,
# the HA "prod" cluster) than the one ArgoCD itself runs on (dev-k8s).
# That means before this can be applied, node1-3 needs to be registered as
# an ArgoCD-managed cluster (`argocd cluster add`, or an equivalent
# ServiceAccount+kubeconfig secret) — deliberately not done yet: there's no
# reason to wire cross-cluster RBAC into the prod HA cluster before prod
# hostname/Stalwart/secrets are real and someone's actually promoting.
#
# syncPolicy has no automated block at all, and won't get one even later —
# prod stays manual-sync-only permanently. That's the promotion gate.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: vncmail-prod
namespace: argocd
spec:
project: default
source:
repoURL: git@gitlab.vnc.biz:gitlab-instance-b9b5cf2f/vncmail-plus.git
targetRevision: main
path: deploy/k8s/overlays/prod
destination:
server: CHANGEME # the node1-3 cluster's registered ArgoCD server URL, once added
namespace: vncmail-prod
syncPolicy:
syncOptions:
- CreateNamespace=true
+87 -38
View File
@@ -1,29 +1,68 @@
# VNCmail+ — Admin Deployment Guide (microk8s) # VNCmail+ — Admin Deployment Guide (microk8s)
Deploy VNCmail+ (VNC's Bulwark fork) as a container at **`vncmail.sandbox.vnc.de`**, Deploy VNCmail+ (VNC's Bulwark fork) as a container at **`vncmail.sandbox.vnc.de`**,
**alongside** the existing `bulwark.sandbox.vnc.de`. Plain `kubectl apply` — no **alongside** the existing `bulwark.sandbox.vnc.de`.
GitOps needed.
> Why a container (not Vercel): Bulwark is stateful — it writes settings/admin/ > Why a container (not Vercel): Bulwark is stateful — it writes settings/admin/
> telemetry to `/app/data`, which needs persistent volumes. > telemetry to `/app/data`, which needs persistent volumes.
## Structure — base + overlays
```
deploy/k8s/
base/ # shared manifest shapes (namespace-agnostic)
overlays/
dev/ # the live sandbox — vncmail.sandbox.vnc.de, namespace vncmail
prod/ # scaffolded, NOT YET LIVE — see "Production status" below
ca/ # separate, isolated EJBCA internal CA — see ca/README.md.
# Never composed with base/ or either overlay above.
```
`kubectl apply -k overlays/dev` (or `overlays/prod`, once real) instead of
applying `base/` directly — `base/` alone has no namespace and won't apply
meaningfully on its own.
## Routine deploys go through CI + ArgoCD now
As of the GitLab CI/CD pipeline (`.gitlab-ci.yml`, see `../../VNCMAIL-SETUP.md`
§ CI/CD), **pushing to `dev` auto-builds and bumps the deploy tag; ArgoCD's
`vncmail-dev` Application applies it** — you should not normally need to run
`kubectl apply` for the sandbox by hand anymore, and CI never touches the
cluster directly (it only ever talks to the registry and to this git repo).
This guide's manual steps below are for first-time setup, the one-time
secret creation CI/ArgoCD deliberately never automate, and troubleshooting.
## Production status
**There is no production VNCmail+ deployment yet.** `overlays/prod/` exists
in the repo but is inert: its ingress hostname and its secret's
`JMAP_SERVER_URL` are both obvious placeholders (`vncmail.CHANGEME.invalid` /
`https://REPLACE-ME-prod-stalwart-not-yet-deployed.invalid`) that will fail
loudly rather than silently deploy against the wrong backend. Applying it
requires, in order: a real prod Stalwart instance to exist, a real hostname
decision, DNS, a real `secret.yaml`, and the `.gitlab-ci.yml` `promote` job's
`kubectl apply` step (currently a TODO placeholder) filled in. None of that
is CI's job to decide — it's an explicit, human-triggered event.
--- ---
## 1. What you are deploying ## 1. What you are deploying (per overlay)
| # | Object | File | Purpose | | # | Object | File | Purpose |
|---|--------|------|---------| |---|--------|------|---------|
| 1 | Namespace `vncmail` | `namespace.yaml` | Isolates the app | | 1 | Namespace | `overlays/<env>/namespace.yaml` | Isolates the app (`vncmail` for dev, `vncmail-prod` for prod) |
| 2 | 4× PersistentVolumeClaim | `pvc.yaml` | `/app/data/{settings,admin,admin-state,telemetry}` | | 2 | 4× PersistentVolumeClaim | `base/pvc.yaml` | `/app/data/{settings,admin,admin-state,telemetry}` |
| 3 | Secret `vncmail-env` | `secret.yaml` *(you create it)* | App config (JMAP URL, session secret, branding) | | 3 | Secret `vncmail-env` | `overlays/<env>/secret.yaml` *(you create it)* | App config (JMAP URL, session secret, branding) |
| 4 | Secret `ghcr-pull` | *(you create it — command below)* | Pull the private image from GHCR | | 4 | Image-pull secret | *(you create it — command below)* | Pull the (currently private) image |
| 5 | Deployment `vncmail-plus` | `deployment.yaml` | The app pod | | 5 | Deployment `vncmail-plus` | `base/deployment.yaml` (+ overlay patches) | The app pod |
| 6 | Service `vncmail-plus` | `service.yaml` | ClusterIP :80 → pod :3000 | | 6 | Service `vncmail-plus` | `base/service.yaml` | ClusterIP :80 → pod :3000 |
| 7 | Ingress `vncmail-plus` | `ingress.yaml` | TLS host `vncmail.sandbox.vnc.de` | | 7 | Ingress `vncmail-plus` | `base/ingress.yaml` (+ overlay patches for prod) | TLS host |
**Image:** `ghcr.io/brvncde-dotcom/vncmail-plus-dev:latest` **Image:** CI builds and pushes to `registry.gitlab.vnc.biz/gitlab-instance-b9b5cf2f/vncmail-plus`
(built automatically by CI from the `dev` branch). For anything beyond the (tag `sha-<sha>` per deploy, moving pointers `dev-latest`/`prod-latest`). The
sandbox, pin a digest — see §5. `ghcr.io/brvncde-dotcom/vncmail-plus-dev` image referenced in `base/deployment.yaml`
is a legacy default only — CI overrides it per-deploy via `kubectl set image`,
so what's committed there never needs to track what's actually running.
--- ---
@@ -43,45 +82,48 @@ kubectl get ingressclass
kubectl get clusterissuer # cert-manager issuers (if used) kubectl get clusterissuer # cert-manager issuers (if used)
``` ```
Then edit if they differ from the defaults below: Then edit if they differ from the defaults below (in `base/`, so both overlays
pick up the fix):
| Value | Default in manifests | File to edit | | Value | Default in manifests | File to edit |
|-------|----------------------|--------------| |-------|----------------------|--------------|
| StorageClass | `microk8s-hostpath` | `pvc.yaml` (all 4) | | StorageClass | `microk8s-hostpath` | `base/pvc.yaml` (all 4) |
| IngressClass | `public` | `ingress.yaml` | | IngressClass | `public` | `base/ingress.yaml` |
| cert-manager issuer | `letsencrypt-prod` | `ingress.yaml` | | cert-manager issuer | `letsencrypt-prod` | `base/ingress.yaml` |
--- ---
## 3. Deploy (copy-paste, in order) ## 3. First-time setup (one-time, per environment — CI never does this)
```bash ```bash
cd deploy/k8s cd deploy/k8s/overlays/dev # or overlays/prod, once real
# a) Namespace # a) Image-pull secret — the registry package is private.
kubectl apply -f namespace.yaml
# b) Image-pull secret — the GHCR package is private.
# Use a GitHub PAT (classic) with the read:packages scope.
kubectl create secret docker-registry ghcr-pull \ kubectl create secret docker-registry ghcr-pull \
--namespace vncmail \ --namespace vncmail \
--docker-server=ghcr.io \ --docker-server=ghcr.io \
--docker-username=brvncde-dotcom \ --docker-username=brvncde-dotcom \
--docker-password='<GITHUB_PAT_read:packages>' \ --docker-password='<GITHUB_PAT_read:packages>' \
--docker-email=br@vnc.biz --docker-email=br@vnc.biz
# Once CI has cut over to registry.gitlab.vnc.biz, this becomes a
# docker-registry secret for that registry instead — see VNCMAIL-SETUP.md.
# c) App config secret — copy the template, set a real SESSION_SECRET, apply. # b) App config secret — copy the template, set a real SESSION_SECRET, apply.
cp secret.example.yaml secret.yaml cp secret.example.yaml secret.yaml
# edit secret.yaml: SESSION_SECRET: "$(openssl rand -base64 32)" # edit secret.yaml: SESSION_SECRET: "$(openssl rand -base64 32)"
kubectl apply -f secret.yaml kubectl apply -f secret.yaml
# d) Everything else (PVCs, Deployment, Service, Ingress) # c) Everything else (namespace, PVCs, Deployment, Service, Ingress)
kubectl apply -k . kubectl apply -k .
``` ```
> Alternative to (b): make the GHCR package public > Alternative to (a): make the registry package public, then delete the
> (GitHub → Packages → vncmail-plus-dev → Package settings → Change visibility), > `imagePullSecrets:` block from `base/deployment.yaml`.
> then delete the `imagePullSecrets:` block from `deployment.yaml`.
After this one-time setup, routine deploys to `dev` happen automatically via
CI on every push — see "Routine deploys go through CI now" above. This
section is for first-time bring-up (or `overlays/prod`, once it's real) and
troubleshooting, not the everyday path.
--- ---
@@ -104,16 +146,23 @@ a bare username.
## 5. Update to a new build ## 5. Update to a new build
```bash Normally you don't — CI's `bump-dev` job + ArgoCD's automated sync do this
# CI rebuilds ghcr.io/brvncde-dotcom/vncmail-plus-dev on every push to `dev`. on every push to `dev`. To do it by hand (e.g. troubleshooting, before
kubectl -n vncmail rollout restart deploy/vncmail-plus # pulls :latest (imagePullPolicy: Always) automated sync is turned on):
# Production: pin a digest instead of :latest so rollouts are deterministic. ```bash
kubectl -n vncmail set image deploy/vncmail-plus \ kubectl -n vncmail set image deploy/vncmail-plus \
vncmail-plus=ghcr.io/brvncde-dotcom/vncmail-plus-dev@sha256:<digest> vncmail-plus=registry.gitlab.vnc.biz/gitlab-instance-b9b5cf2f/vncmail-plus:sha-<sha>
``` ```
Rollback: `kubectl -n vncmail rollout undo deploy/vncmail-plus` ArgoCD will overwrite this on its next sync unless you also update
`deploy/k8s/overlays/dev/image-tag/kustomization.yaml` to match — that file
is CI-owned (see its header comment), so a by-hand `set image` is only ever
a temporary override, not a real fix.
Rollback (bypassing ArgoCD temporarily): `kubectl -n vncmail rollout undo deploy/vncmail-plus`.
The real rollback is reverting the commit that bumped the tag and letting
ArgoCD re-sync.
--- ---
@@ -121,9 +170,9 @@ Rollback: `kubectl -n vncmail rollout undo deploy/vncmail-plus`
| Symptom | Cause / fix | | Symptom | Cause / fix |
|---------|-------------| |---------|-------------|
| Pod `ImagePullBackOff` | `ghcr-pull` secret missing/expired, or package still private. Recreate the secret (§3b) or make the package public. | | Pod `ImagePullBackOff` | `ghcr-pull` secret missing/expired, or package still private. Recreate the secret (§3a) or make the package public. |
| Pod `CrashLoopBackOff`, logs show `EACCES`/permission on `/app/data` | Volume not writable by uid 1001. `securityContext.fsGroup: 1001` is set in `deployment.yaml` — keep it; some storage drivers also need it on the PVC. | | Pod `CrashLoopBackOff`, logs show `EACCES`/permission on `/app/data` | Volume not writable by uid 1001. `securityContext.fsGroup: 1001` is set in `base/deployment.yaml` — keep it; some storage drivers also need it on the PVC. |
| PVC stuck `Pending` | Wrong `storageClassName` in `pvc.yaml`. Set it to one from `kubectl get sc`. | | PVC stuck `Pending` | Wrong `storageClassName` in `base/pvc.yaml`. Set it to one from `kubectl get sc`. |
| Ingress has no address / no cert | Wrong `ingressClassName` or cert issuer. Match bulwark's (§2). Check `kubectl -n vncmail describe ingress vncmail-plus`. | | Ingress has no address / no cert | Wrong `ingressClassName` or cert issuer. Match bulwark's (§2). Check `kubectl -n vncmail describe ingress vncmail-plus`. |
| Login shows "Ein Fehler ist aufgetreten" | Use the **full** email (`user@sandbox.vnc.de`), not a bare username. | | Login shows "Ein Fehler ist aufgetreten" | Use the **full** email (`user@sandbox.vnc.de`), not a bare username. |
| Can't reach Stalwart | Check `JMAP_SERVER_URL` in the secret = `https://stalwart.sandbox.vnc.de`. | | Can't reach Stalwart | Check `JMAP_SERVER_URL` in the secret = `https://stalwart.sandbox.vnc.de`. |
@@ -2,7 +2,6 @@ apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: vncmail-plus name: vncmail-plus
namespace: vncmail
labels: labels:
app: vncmail-plus app: vncmail-plus
spec: spec:
@@ -26,12 +25,17 @@ spec:
runAsGroup: 1001 runAsGroup: 1001
# ghcr package is private by default — see deploy/k8s/README.md to create # ghcr package is private by default — see deploy/k8s/README.md to create
# this pull secret. Delete this block if you make the package public. # this pull secret. Delete this block if you make the package public.
# NOTE: once CI moves to pushing registry.gitlab.vnc.biz images (the
# dev-auto-deploy phase of the GitLab pipeline), this needs to become a
# docker-registry secret for that registry instead — comments only,
# deliberately not renamed here, so this file stays a no-op today.
imagePullSecrets: imagePullSecrets:
- name: ghcr-pull - name: ghcr-pull
containers: containers:
- name: vncmail-plus - name: vncmail-plus
# dev image (built from the `dev` branch by CI). For production pin a # Default/legacy value — CI overrides the image per-deploy via
# digest: ghcr.io/brvncde-dotcom/vncmail-plus-dev@sha256:<digest> # `kustomize edit set image`, so what's committed here never goes
# stale. For a one-off manual apply, pin a digest instead of :latest.
image: ghcr.io/brvncde-dotcom/vncmail-plus-dev:latest image: ghcr.io/brvncde-dotcom/vncmail-plus-dev:latest
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
+34
View File
@@ -0,0 +1,34 @@
# Both real clusters (node1-3 "prod", dev-k8s-1-3 "dev") run Traefik, not
# nginx — confirmed via `kubectl get ingressclass` (class is literally named
# `traefik`). Unlike nginx's restrictive 1MB default, Traefik has no default
# request-body-size cap, so there's no equivalent needed for mail attachment
# uploads (the old nginx.ingress.kubernetes.io/proxy-body-size annotation
# this file used to carry is simply not applicable here).
#
# Host, TLS secretName, and cert-manager issuer are ALL overlay-specific now
# (dev-k8s only has a `letsencrypt-staging` issuer; node1-3/prod has none
# configured yet) — every overlay's patch-ingress.yaml must override the
# CHANGEME placeholders below.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: vncmail-plus
annotations:
cert-manager.io/cluster-issuer: CHANGEME
spec:
ingressClassName: traefik
tls:
- hosts:
- CHANGEME.invalid
secretName: vncmail-plus-tls
rules:
- host: CHANGEME.invalid
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: vncmail-plus
port:
number: 80
+14
View File
@@ -0,0 +1,14 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- pvc.yaml
- deployment.yaml
- service.yaml
- ingress.yaml
# - secret.yaml # create from an overlay's secret.example.yaml; not committed
# Namespace is intentionally NOT set here. Kustomize's `namespace:` transformer
# doesn't rename cluster-scoped Namespace objects, so each overlay ships its own
# namespace.yaml (the actual object) and its own `namespace:` field (which
# injects metadata.namespace into every namespaced resource below). Applying
# this base directly is meaningless — always go through an overlay.
@@ -5,7 +5,6 @@ apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim
metadata: metadata:
name: vncmail-settings name: vncmail-settings
namespace: vncmail
spec: spec:
accessModes: [ReadWriteOnce] accessModes: [ReadWriteOnce]
storageClassName: microk8s-hostpath storageClassName: microk8s-hostpath
@@ -17,7 +16,6 @@ apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim
metadata: metadata:
name: vncmail-admin name: vncmail-admin
namespace: vncmail
spec: spec:
accessModes: [ReadWriteOnce] accessModes: [ReadWriteOnce]
storageClassName: microk8s-hostpath storageClassName: microk8s-hostpath
@@ -29,7 +27,6 @@ apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim
metadata: metadata:
name: vncmail-admin-state name: vncmail-admin-state
namespace: vncmail
spec: spec:
accessModes: [ReadWriteOnce] accessModes: [ReadWriteOnce]
storageClassName: microk8s-hostpath storageClassName: microk8s-hostpath
@@ -41,7 +38,6 @@ apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim
metadata: metadata:
name: vncmail-telemetry name: vncmail-telemetry
namespace: vncmail
spec: spec:
accessModes: [ReadWriteOnce] accessModes: [ReadWriteOnce]
storageClassName: microk8s-hostpath storageClassName: microk8s-hostpath
@@ -2,7 +2,6 @@ apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: vncmail-plus name: vncmail-plus
namespace: vncmail
labels: labels:
app: vncmail-plus app: vncmail-plus
spec: spec:
-34
View File
@@ -1,34 +0,0 @@
# Exposes VNCmail+ at vncmail.sandbox.vnc.de, alongside bulwark.sandbox.vnc.de.
# MATCH YOUR CLUSTER — inspect the existing Bulwark ingress and copy its
# ingressClassName + TLS/cert-manager annotations:
# kubectl get ingress -A | grep bulwark
# kubectl get ingress <bulwark-ingress> -n <ns> -o yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: vncmail-plus
namespace: vncmail
annotations:
# cert-manager issuer — set to whatever bulwark.sandbox.vnc.de uses.
cert-manager.io/cluster-issuer: letsencrypt-prod
# Mail attachments can be large; raise the nginx body limit.
nginx.ingress.kubernetes.io/proxy-body-size: "100m"
spec:
# microk8s ingress addon class is usually "public" (nginx). Confirm with
# `kubectl get ingressclass` and match bulwark's.
ingressClassName: public
tls:
- hosts:
- vncmail.sandbox.vnc.de
secretName: vncmail-plus-tls
rules:
- host: vncmail.sandbox.vnc.de
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: vncmail-plus
port:
number: 80
@@ -0,0 +1,11 @@
# Owned by CI (the bump-dev job in .gitlab-ci.yml), not by hand. Kept as its
# own Component so CI only ever rewrites this 6-line file, never the parent
# overlays/dev/kustomization.yaml (structure/patches there stay under normal
# code review — CI regenerating a whole hand-maintained file on every push
# would silently revert any change made there between deploys).
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component
images:
- name: ghcr.io/brvncde-dotcom/vncmail-plus-dev
newName: ghcr.io/brvncde-dotcom/vncmail-plus-dev
newTag: latest
@@ -0,0 +1,18 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: vncmail
resources:
- ../../base
- namespace.yaml
# - secret.yaml # create from secret.example.yaml; not committed
patches:
- path: patch-ingress.yaml
components:
- image-tag
# Targets the dev-k8s-1/2/3 cluster (confirmed via direct access: this is
# where ArgoCD already lives). The image tag lives in image-tag/ (a separate
# Component CI owns — see .gitlab-ci.yml's bump-dev job) rather than here, so
# CI never needs to touch this file.
@@ -0,0 +1,27 @@
# dev-k8s cluster confirmed to have a `letsencrypt-staging` ClusterIssuer
# already (no `letsencrypt-prod` exists there) - staging avoids burning
# Let's Encrypt's real rate limits while this is still being stood up.
# vncmail.sandbox.vnc.de DNS does not point here yet either - this is the
# intended host, not a live one (see VNCMAIL-SETUP.md for what's still open).
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: vncmail-plus
annotations:
cert-manager.io/cluster-issuer: letsencrypt-staging
spec:
tls:
- hosts:
- vncmail.sandbox.vnc.de
secretName: vncmail-plus-tls
rules:
- host: vncmail.sandbox.vnc.de
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: vncmail-plus
port:
number: 80
@@ -0,0 +1,12 @@
# Owned by CI (the bump-prod job in .gitlab-ci.yml), not by hand — same
# reasoning as overlays/dev/image-tag/. Starts pointed at an obviously-fake
# tag on purpose: nothing has been promoted yet, and vncmail-prod's ArgoCD
# Application has manual sync anyway, so this being "wrong" doesn't deploy
# anything wrong — it just means there's nothing to sync until a real
# `git push` to main updates it.
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component
images:
- name: ghcr.io/brvncde-dotcom/vncmail-plus-dev
newName: registry.gitlab.vnc.biz/gitlab-instance-b9b5cf2f/vncmail-plus
newTag: not-yet-promoted
@@ -0,0 +1,21 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: vncmail-prod
resources:
- ../../base
- namespace.yaml
# - secret.yaml # create from secret.example.yaml; not committed
patches:
- path: patch-ingress.yaml
- path: patch-deployment.yaml
components:
- image-tag
# NOT MEANT TO BE SYNCED AS COMMITTED. Scaffolding only (see the pipeline
# plan's Phase C/D) — image-tag/'s placeholder tag is obviously-invalid on
# purpose. The bump-prod job in .gitlab-ci.yml keeps that tag pointed at
# whatever's already on dev once main advances, but vncmail-prod's ArgoCD
# Application has manual sync — a human still has to click Sync (or
# `argocd app sync vncmail-prod`) for any of this to actually apply.
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: vncmail-prod
labels:
app.kubernetes.io/part-of: vnclagoon-suite
@@ -0,0 +1,9 @@
# Basic HA. Still `strategy: Recreate` (inherited from base) since the PVCs
# are RWO — 2 replicas doesn't buy zero-downtime rollouts by itself, only
# tolerance for a node loss between deploys. Revisit if that's not enough.
apiVersion: apps/v1
kind: Deployment
metadata:
name: vncmail-plus
spec:
replicas: 2
@@ -0,0 +1,31 @@
# PLACEHOLDER — the real production hostname has not been decided yet (see
# VNCMAIL-SETUP.md / the pipeline plan). vncmail.CHANGEME.invalid is
# deliberately unresolvable: applying this overlay as committed will not
# issue a cert or route traffic anywhere. Replace both occurrences below,
# and the matching TLS secretName, before Phase D (first real prod deploy).
#
# Targets node1-3 (the HA "prod" cluster). Deliberately does NOT override
# base's `cert-manager.io/cluster-issuer: CHANGEME` — node1-3 has ZERO
# ClusterIssuers configured today (confirmed via direct access). A human
# needs to create a real one there (ACME account, DNS-01 or HTTP-01 solver)
# before this can be anything but a placeholder.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: vncmail-plus
spec:
tls:
- hosts:
- vncmail.CHANGEME.invalid
secretName: vncmail-plus-prod-tls
rules:
- host: vncmail.CHANGEME.invalid
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: vncmail-plus
port:
number: 80
@@ -0,0 +1,28 @@
# Copy to secret.yaml, fill in real values, and apply. DO NOT commit secret.yaml
# (it is gitignored). Generate SESSION_SECRET with: openssl rand -base64 32
#
# JMAP_SERVER_URL is a PLACEHOLDER — there is no production Stalwart instance
# yet. This overlay cannot go live (Phase D) until one exists and this value
# points at it for real.
apiVersion: v1
kind: Secret
metadata:
name: vncmail-env
namespace: vncmail-prod
type: Opaque
stringData:
# Core — connect to Stalwart over JMAP
JMAP_SERVER_URL: "https://REPLACE-ME-prod-stalwart-not-yet-deployed.invalid"
SESSION_SECRET: "REPLACE_ME__openssl_rand_base64_32"
# Branding (theme defaults to VNClagoon in code; these set name + logo)
APP_NAME: "VNCmail+"
APP_SHORT_NAME: "VNCmail+"
LOGIN_COMPANY_NAME: "VNClagoon"
LOGIN_LOGO_DARK_URL: "/branding/vncmail-wordmark-on-dark.svg"
LOGIN_LOGO_LIGHT_URL: "/branding/vncmail-wordmark-on-light.svg"
APP_LOGO_DARK_URL: "/branding/vncmail-wordmark-on-dark.svg"
APP_LOGO_LIGHT_URL: "/branding/vncmail-wordmark-on-light.svg"
LOGIN_LOGO_MAX_HEIGHT: "52"
# Housekeeping
BULWARK_UPDATE_CHECK: "off"
# Data dirs default to /app/data/* (mounted to the PVCs) — no need to set them.
+3 -1
View File
@@ -210,7 +210,9 @@ function escapeDn(value: string): string {
.replace(/([\\,+"<>;=])/g, '\\$1') .replace(/([\\,+"<>;=])/g, '\\$1')
.replace(/^([ #])/, '\\$1') .replace(/^([ #])/, '\\$1')
.replace(/ $/, '\\ ') .replace(/ $/, '\\ ')
// Control characters have no legitimate place in a DN. // Control characters have no legitimate place in a DN — the class below
// is intentional, not a typo.
// eslint-disable-next-line no-control-regex
.replace(/[\x00-\x1f\x7f]/g, ''); .replace(/[\x00-\x1f\x7f]/g, '');
} }