Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 17 additions & 23 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,9 @@ name: Build
# its own GHA cache scope.
#
# main tags :sha-<short> only. release.yml calls this with release-tag set,
# which additionally publishes :<tag> and then, in a single promote job, moves
# :latest — the tag Keel polls — for every service at once, so only releases
# reach production and no service reaches it alone. It is called rather than
# triggered by `release: published` because a release cut with GITHUB_TOKEN
# raises no event.
# which additionally publishes :<tag>, the tag Flux pins api and frontend to.
# It is called rather than triggered by `release: published` because a release
# cut with GITHUB_TOKEN raises no event.
on:
push:
branches: [main]
Expand All @@ -28,9 +26,9 @@ on:
inputs:
release-tag:
description: >-
Tag to publish and point :latest at, e.g. v1.2.0. Empty builds and pushes
images without moving :latest. Naming an older tag moves :latest backwards and
Keel will roll it out, so that is a rollback rather than a retry.
Tag to republish, e.g. v1.2.0, when a release half-finished. Empty builds and
pushes images without moving any :latest. Rolling api or frontend back is a git
change to the pinned tag, not a run of this workflow.
type: string
default: ''

Expand Down Expand Up @@ -322,20 +320,9 @@ jobs:
fi

promote:
# Moves :latest, the tag Keel polls, for every service in this release —
# in one job, one after the other, and only once every image exists under
# its release tag.
#
# A matrix would publish each service's :latest in its own job, and Keel
# polling in the gap between two of them would roll one Deployment against
# the previous version of another. The gap is seconds and the poll is every
# two minutes, which makes it the kind of race that shows up in production
# rather than in a pipeline.
#
# Skew is narrowed rather than eliminated: Keel still rolls each Deployment
# separately and they come up at different speeds, so an api and a frontend
# from adjacent releases can still meet briefly. One release of backward
# compatibility remains the contract.
# Moves :latest for the Keel-driven images only. api and frontend deploy
# from the tag pinned in git, so a :latest for them would be a second,
# racing trigger on the same Deployments.
name: Promote release to :latest
needs: [detect-changes, merge]
if: needs.detect-changes.outputs.has-matrix == 'true' && inputs.release-tag != ''
Expand All @@ -361,6 +348,8 @@ jobs:
env:
SERVICES: ${{ needs.detect-changes.outputs.services }}
RELEASE_TAG: ${{ inputs.release-tag }}
# Images whose Deployment carries keel.sh annotations.
KEEL_SERVICES: '["stalwart-tools"]'
run: |
set -euo pipefail

Expand All @@ -379,7 +368,12 @@ jobs:
# earlier can still read as missing; retrying was not enough and the
# release failed for a tag that had in fact moved. `imagetools create`
# reports its own failure, which is the signal that means something.
mapfile -t services < <(jq -r '.[]' <<< "$SERVICES")
mapfile -t services < <(jq -r --argjson keel "$KEEL_SERVICES" \
'. - (. - $keel) | .[]' <<< "$SERVICES")
if [[ ${#services[@]} -eq 0 ]]; then
echo "No Keel-driven image in this release — nothing to promote."
exit 0
fi
for service in "${services[@]}"; do
image="${REGISTRY}/${IMAGE_PREFIX}/${service}"
echo "::group::${service}"
Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,15 @@ IntelliJ: **Remote JVM Debug → host: localhost, port: 5005**.
## Production deployment

Production runs on a single-node NixOS + k3s + FluxCD stack. Flux reconciles
manifests from `platform/cluster/flux/` against `main`; Keel polls
`ghcr.io/esa-blueshell/*` for new `:latest` tags and rolls the matching
Deployments. There is no CI deploy step — pushing to `main` is the deploy.
manifests from `platform/cluster/flux/` against `main`. The api and the
frontend run one release tag, pinned in
`platform/cluster/flux/apps/stateless/kustomization.yaml`. Flagger runs each as
a blue/green canary and the two `confirm-promotion` gates wait for one another,
so a release promotes both or neither. Keel still polls `:latest` for the
remaining images.

That tag is bumped by hand for now. Until the release pipeline writes it
(#1293), cutting a release publishes images but does not deploy them.

Runbook: [`platform/docs/runbook.md`](platform/docs/runbook.md).

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
- namespace.yaml
- source.yaml
- release.yaml
- loadtester.yaml
58 changes: 58 additions & 0 deletions platform/cluster/flux/apps/delivery/flagger/loadtester.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: flagger-loadtester
namespace: flagger-system
spec:
interval: 30m
chart:
spec:
chart: loadtester
sourceRef:
kind: HelmRepository
name: flagger
namespace: flagger-system
values:
# Runs the Canary webhooks: the acceptance tests against the -canary
# Services, and the confirm-promotion gate that reads the other Canary's
# phase with the kubectl the image ships.
logLevel: info
cmd:
timeout: 1h
rbac:
create: true
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 128Mi
---
# The confirm-promotion gate reads the sibling Canary. Namespaced Role rather
# than a ClusterRole: the only Canaries it may see are these two.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: canary-phase-reader
namespace: default
rules:
- apiGroups:
- flagger.app
resources:
- canaries
verbs:
- get
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: canary-phase-reader
namespace: default
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: canary-phase-reader
subjects:
- kind: ServiceAccount
name: flagger-loadtester
namespace: flagger-system
4 changes: 4 additions & 0 deletions platform/cluster/flux/apps/delivery/flagger/namespace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: flagger-system
36 changes: 36 additions & 0 deletions platform/cluster/flux/apps/delivery/flagger/release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: flagger
namespace: flagger-system
spec:
interval: 30m
chart:
spec:
chart: flagger
sourceRef:
kind: HelmRepository
name: flagger
namespace: flagger-system
values:
# L4 only. There is no service mesh here, so Flagger drives blue/green
# by swapping Deployments behind a plain Service rather than by shifting
# traffic weights.
meshProvider: kubernetes
# No Prometheus here, so metricsServer keeps its unreachable chart default
# and Flagger logs one warning at boot. The Canaries define no metric
# checks, only webhooks, so nothing ever queries it.
prometheus:
install: false
podMonitor:
enabled: false
rbac:
create: true
serviceAccount:
create: true
resources:
requests:
cpu: 25m
memory: 64Mi
limits:
memory: 256Mi
8 changes: 8 additions & 0 deletions platform/cluster/flux/apps/delivery/flagger/source.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
name: flagger
namespace: flagger-system
spec:
interval: 1h
url: https://flagger.app
8 changes: 8 additions & 0 deletions platform/cluster/flux/apps/delivery/kustomization.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

# Flagger sits here rather than in apps-core because everything else depends on
# apps-core: a failed install there stops cert-manager, Traefik and Vault
# reconciling to deploy a canary controller. Only apps-stateless waits on this.
resources:
- flagger
50 changes: 50 additions & 0 deletions platform/cluster/flux/apps/stateless/api/canary.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
apiVersion: flagger.app/v1beta1
kind: Canary
metadata:
name: api
namespace: default
spec:
provider: kubernetes
targetRef:
apiVersion: apps/v1
kind: Deployment
name: api
# Flagger owns the api, api-primary and api-canary Services from this block.
# The Traefik IngressRoute keeps pointing at `api`, which Flagger holds on
# the primary pods except during a promotion.
service:
port: 8080
portName: http
targetPort: http
# Matches the Deployment: a cold Spring Boot start on a fresh image pull.
progressDeadlineSeconds: 900
analysis:
# Blue/green, not weighted traffic shifting: iterations without stepWeight.
# Four 30s rounds of acceptance tests against api-canary before promotion.
interval: 30s
iterations: 4
threshold: 3
webhooks:
- name: canary-answers-http
type: rollout
url: http://flagger-loadtester.flagger-system/
timeout: 15s
metadata:
type: bash
cmd: curl -sf http://api-canary.default:8080/health

# The rendezvous. Both Canaries hold here until the other one is out of
# Progressing and not Failed, so the release promotes as a pair or not at
# all. A gate that keeps failing holds the phase at WaitingPromotion
# without burning the failure threshold, so a broken frontend parks the
# api instead of shipping half a release.
- name: wait-for-frontend
type: confirm-promotion
url: http://flagger-loadtester.flagger-system/
timeout: 15s
metadata:
type: bash
cmd: >-
phase=$(kubectl -n default get canary frontend -o jsonpath='{.status.phase}');
echo "frontend=$phase";
[ "$phase" != Progressing ] && [ "$phase" != Failed ]
46 changes: 13 additions & 33 deletions platform/cluster/flux/apps/stateless/api/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,30 +9,22 @@ kind: Deployment
metadata:
name: api
namespace: default
annotations:
# Keel polls GHCR every 2 min for the :latest digest and rolls when it
# changes. match-tag scopes Keel to the tag already on the Deployment
# rather than scanning every tag in the repo and picking the "highest" —
# without it Keel would pin to an old :<sha> tag that sorts above latest.
keel.sh/policy: force
keel.sh/match-tag: 'true'
keel.sh/trigger: poll
keel.sh/pollSchedule: '@every 2m'
# Keel's rollout message can only name digests on :latest, so it
# appends this link.
keel.sh/releaseNotes: https://github.com/ESA-Blueshell/website/releases
# Keel annotations removed: Keel rolls each Deployment on its own, which is
# the skew this pinning exists to prevent.
spec:
replicas: 1
# No `replicas`: Flagger scales this Deployment between 0 and 1 around a
# canary run, and a value here would make Flux reset it every 2 minutes.
#
# Zero-downtime rollout: bring a second pod up first (maxSurge=1) and only
# tear the old one down once the new pod's readiness probe reports
# ACCEPTING_TRAFFIC (maxUnavailable=0). A broken image — failed Flyway
# migration, Vault token miss, anything that keeps /actuator/health/readiness
# red — never displaces the healthy old pod, so the rollout silently
# ProgressDeadline-stalls instead of taking the api offline.
# red — never displaces the healthy old pod, so the rollout stalls instead of
# taking the api offline.
#
# progressDeadlineSeconds is generous because Spring Boot's cold start with
# Flyway + Hibernate + Vault agent handshake can take several minutes on a
# fresh image pull.
# progressDeadlineSeconds is generous: Spring Boot cold start takes minutes,
# and the release commit reaches Flux before the release image is built, so
# the new pod sits in ImagePullBackOff first.
strategy:
type: RollingUpdate
rollingUpdate:
Expand Down Expand Up @@ -105,8 +97,9 @@ spec:
- name: ghcr-pull-secret
containers:
- name: api
image: ghcr.io/esa-blueshell/api:latest
imagePullPolicy: Always
# Tag comes from the images block in ../kustomization.yaml.
image: ghcr.io/esa-blueshell/api
imagePullPolicy: IfNotPresent
command:
- /bin/sh
- -ec
Expand Down Expand Up @@ -319,16 +312,3 @@ spec:
- name: storage
persistentVolumeClaim:
claimName: api-storage
---
apiVersion: v1
kind: Service
metadata:
name: api
namespace: default
spec:
selector:
app.kubernetes.io/name: api
ports:
- name: http
port: 8080
targetPort: http
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ kind: Kustomization
resources:
- pvc.yaml
- deployment.yaml
- canary.yaml
42 changes: 42 additions & 0 deletions platform/cluster/flux/apps/stateless/frontend/canary.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
apiVersion: flagger.app/v1beta1
kind: Canary
metadata:
name: frontend
namespace: default
spec:
provider: kubernetes
targetRef:
apiVersion: apps/v1
kind: Deployment
name: frontend
# Flagger owns the frontend, frontend-primary and frontend-canary Services
# from this block.
service:
port: 3000
portName: http
targetPort: http
progressDeadlineSeconds: 900
analysis:
interval: 30s
iterations: 4
threshold: 3
webhooks:
- name: canary-answers-http
type: rollout
url: http://flagger-loadtester.flagger-system/
timeout: 15s
metadata:
type: bash
cmd: curl -sf http://frontend-canary.default:3000/healthz

# Mirror of the api's gate; see the comment there.
- name: wait-for-api
type: confirm-promotion
url: http://flagger-loadtester.flagger-system/
timeout: 15s
metadata:
type: bash
cmd: >-
phase=$(kubectl -n default get canary api -o jsonpath='{.status.phase}');
echo "api=$phase";
[ "$phase" != Progressing ] && [ "$phase" != Failed ]
Loading
Loading