Overhaul build pipeline: pnpm, non-root image, Helm chart, CI+release workflows #1

Merged
mpuchstein merged 1 commits from pipeline-overhaul into main 2026-04-29 21:15:46 +02:00
23 changed files with 481 additions and 161 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
frontend/node_modules
frontend/build
frontend/test-results
frontend/playwright-report
.svelte-kit
backend/target
data/
*.log
*.db
node_modules

View File

@@ -1,8 +1,11 @@
name: Test
name: CI
on:
push:
branches: [main]
branches-ignore:
- main
tags-ignore:
- '**'
pull_request:
jobs:
@@ -13,7 +16,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
- uses: pnpm/action-setup@v4
with:
@@ -78,3 +81,12 @@ jobs:
frontend/test-results/
frontend/playwright-report/
retention-days: 7
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker build (no push)
uses: docker/build-push-action@v6
with:
push: false
tags: tutortool:ci

View File

@@ -0,0 +1,95 @@
name: Release
on:
push:
tags:
- 'v*.*.*'
env:
IMAGE: registry.itsh.dev/s0wlz/tutortool
NAMESPACE: tenant-5
RELEASE_NAME: tutortool
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.95.0'
- name: Cache Cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
backend/target
key: cargo-${{ hashFiles('backend/Cargo.lock') }}
restore-keys: cargo-
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ~/.local/share/pnpm/store
key: pnpm-${{ hashFiles('frontend/pnpm-lock.yaml') }}
restore-keys: pnpm-
- name: Install frontend deps
run: pnpm --dir frontend install --frozen-lockfile
- name: Type check (frontend)
run: pnpm --dir frontend exec tsgo --version && pnpm --dir frontend check
- name: Unit tests (backend)
run: cargo test --manifest-path backend/Cargo.toml
- name: Build frontend
run: pnpm --dir frontend build
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: registry.itsh.dev
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
push: true
tags: |
${{ env.IMAGE }}:${{ github.ref_name }}
${{ env.IMAGE }}:latest
- name: Configure kubectl
run: |
mkdir -p ~/.kube
echo "${{ secrets.K8S_CONFIG }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
- name: Set up Helm
uses: azure/setup-helm@v4
with:
version: v3.16.2
- name: Deploy via Helm
run: |
helm upgrade --install ${{ env.RELEASE_NAME }} ./deploy \
-f ./deploy/values_override.yaml \
--set image.tag=${{ github.ref_name }} \
-n ${{ env.NAMESPACE }} \
--wait --timeout 5m

View File

@@ -85,10 +85,11 @@ See `docs/testing.md` for the full guide including seed data, MCP-driven verific
### Container / K8s / CI
- `Dockerfile`: 3-stage build (Node 20 frontend → Rust 1.95 backend → Debian slim runtime)
- `k8s/`: Deployment, Service, PVC for SQLite, CronJob for nightly vacuum + backup rotation
- Live at `tutor.puchstein.dev` (tenant-5, ITSH Cloud)
- CI: Gitea Actions at `.gitea/workflows/test.yml` — runs `cargo check`, `pnpm check`, `cargo test`, `pnpm build`, and `make test-e2e` on every push to `main` and on PRs
- `Dockerfile`: 3-stage build (Node 22/pnpm frontend → Rust 1.95 backend → Debian slim runtime, non-root)
- `deploy/`: Helm chart — Deployment, Service, HTTPRoute (Gateway API), PVC, CronJob for nightly vacuum + backup rotation
- Live at `tutor.puchstein.dev` (tenant-5, ITSH Cloud); image at `registry.itsh.dev/s0wlz/tutortool`
- CI: Gitea Actions at `.gitea/workflows/ci.yml` — runs `cargo check`, `pnpm check` (tsgo + svelte-check), `cargo test`, `pnpm build`, `make test-e2e`, and a no-push Docker build on every non-main push and PR
- Release: `.gitea/workflows/release.yml` — triggered by `v*.*.*` tags; builds + pushes image, then `helm upgrade`
## Conventions

View File

@@ -1,31 +1,36 @@
# --- Frontend Build ---
FROM node:20-alpine as frontend-builder
FROM node:22-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm install --force
RUN corepack enable && corepack prepare pnpm --activate
COPY frontend/package.json frontend/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY frontend/ ./
RUN npm run build
RUN pnpm run check
RUN pnpm run build
# --- Backend Build ---
FROM rust:1.95-slim as backend-builder
FROM rust:1.95-slim AS backend-builder
WORKDIR /app/backend
# Pre-build dependencies for caching
COPY backend/Cargo.toml backend/Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release && rm -rf src
COPY backend/src ./src
COPY backend/migrations ./migrations
# Ensure we actually rebuild the binary with the real source
COPY backend/demo ./demo
RUN touch src/main.rs && cargo build --release
# --- Final Image ---
# --- Runtime ---
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y sqlite3 ca-certificates && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y ca-certificates curl && rm -rf /var/lib/apt/lists/*
RUN useradd -u 1000 -m app
WORKDIR /app
COPY --from=backend-builder /app/backend/target/release/attendance ./server
COPY --from=frontend-builder /app/frontend/build ./frontend/build
COPY --from=backend-builder /app/backend/demo ./backend/demo
COPY --from=frontend-builder /app/frontend/build ./frontend/build
ENV STATIC_DIR=/app/frontend/build
ENV DATABASE_URL=sqlite:/data/attendance.db
ENV JWT_SECRET=change-me-at-runtime
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
CMD curl -fs http://localhost:3000/health || exit 1
USER app
CMD ["./server"]

View File

@@ -1,15 +1,14 @@
use sqlx::{sqlite::SqlitePoolOptions, SqlitePool};
use std::str::FromStr;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use sqlx::SqlitePool;
pub async fn init() -> Result<SqlitePool, sqlx::Error> {
let url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "sqlite:/data/attendance.db".into());
let pool = SqlitePoolOptions::new()
.after_connect(|conn, _| Box::pin(async move {
sqlx::query("PRAGMA foreign_keys = ON")
.execute(conn).await?;
Ok(())
}))
.connect(&url).await?;
let opts = SqliteConnectOptions::from_str(&url)?
.create_if_missing(true)
.foreign_keys(true);
let pool = SqlitePoolOptions::new().connect_with(opts).await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)
}

6
deploy/Chart.yaml Normal file
View File

@@ -0,0 +1,6 @@
apiVersion: v2
name: tutortool
description: TutorTool attendance tracker (Rust + SvelteKit + SQLite)
type: application
version: 0.1.0
appVersion: "0.1.0"

View File

@@ -0,0 +1,17 @@
TutorTool has been deployed!
Application URL: https://{{ index .Values.httpRoute.hostnames 0 }}
To verify the deployment:
kubectl -n {{ .Release.Namespace }} rollout status deploy/{{ include "tutortool.fullname" . }}
Health check:
kubectl -n {{ .Release.Namespace }} exec deploy/{{ include "tutortool.fullname" . }} -- curl -s http://localhost:3000/health
The JWT_SECRET is read from the K8s Secret named "{{ .Values.jwtSecretName }}".
Provision it before deploying if it doesn't already exist:
kubectl -n {{ .Release.Namespace }} create secret generic {{ .Values.jwtSecretName }} \
--from-literal=JWT_SECRET=<your-secret>
SQLite data persists on PVC: {{ include "tutortool.fullname" . }}-data
Nightly VACUUM backups run at 03:00 UTC, retained for 7 days.

View File

@@ -0,0 +1,50 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "tutortool.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "tutortool.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "tutortool.labels" -}}
helm.sh/chart: {{ include "tutortool.name" . }}-{{ .Chart.Version }}
{{ include "tutortool.selectorLabels" . }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "tutortool.selectorLabels" -}}
app.kubernetes.io/name: {{ include "tutortool.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Service account name
*/}}
{{- define "tutortool.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "tutortool.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,31 @@
apiVersion: batch/v1
kind: CronJob
metadata:
name: {{ include "tutortool.fullname" . }}-backup
namespace: {{ .Release.Namespace }}
labels:
{{- include "tutortool.labels" . | nindent 4 }}
spec:
schedule: "0 3 * * *"
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: backup
image: alpine:latest
command:
- /bin/sh
- -c
- |
apk add --no-cache sqlite
sqlite3 /data/attendance.db "VACUUM INTO '/data/backup-$(date +%F).sqlite'"
find /data -name "backup-*.sqlite" -mtime +7 -delete
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: {{ include "tutortool.fullname" . }}-data

View File

@@ -0,0 +1,60 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "tutortool.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "tutortool.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "tutortool.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "tutortool.selectorLabels" . | nindent 8 }}
spec:
serviceAccountName: {{ include "tutortool.serviceAccountName" . }}
securityContext:
fsGroup: 1000
containers:
- name: app
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- containerPort: {{ .Values.containerPort }}
env:
- name: DATABASE_URL
value: {{ .Values.env.DATABASE_URL | quote }}
- name: STATIC_DIR
value: {{ .Values.env.STATIC_DIR | quote }}
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: {{ .Values.jwtSecretName }}
key: JWT_SECRET
volumeMounts:
- name: data
mountPath: /data
livenessProbe:
httpGet:
path: /health
port: {{ .Values.containerPort }}
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: {{ .Values.containerPort }}
initialDelaySeconds: 5
periodSeconds: 10
securityContext:
readOnlyRootFilesystem: false
allowPrivilegeEscalation: false
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumes:
- name: data
persistentVolumeClaim:
claimName: {{ include "tutortool.fullname" . }}-data

View File

@@ -0,0 +1,45 @@
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ include "tutortool.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "tutortool.labels" . | nindent 4 }}
spec:
parentRefs:
- name: itsh-gateway
sectionName: {{ .Values.httpRoute.sectionName }}
hostnames:
{{- range .Values.httpRoute.hostnames }}
- {{ . | quote }}
{{- end }}
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ include "tutortool.fullname" . }}
port: {{ .Values.service.port }}
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ include "tutortool.fullname" . }}-http-redirect
namespace: {{ .Release.Namespace }}
labels:
{{- include "tutortool.labels" . | nindent 4 }}
spec:
parentRefs:
- name: itsh-gateway
sectionName: http-tutor-puchstein-dev
hostnames:
{{- range .Values.httpRoute.hostnames }}
- {{ . | quote }}
{{- end }}
rules:
- filters:
- type: RequestRedirect
requestRedirect:
scheme: https
statusCode: 301

14
deploy/templates/pvc.yaml Normal file
View File

@@ -0,0 +1,14 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "tutortool.fullname" . }}-data
namespace: {{ .Release.Namespace }}
labels:
{{- include "tutortool.labels" . | nindent 4 }}
spec:
accessModes:
- ReadWriteOnce
storageClassName: {{ .Values.pvc.storageClassName }}
resources:
requests:
storage: {{ .Values.pvc.storage }}

View File

@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "tutortool.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "tutortool.labels" . | nindent 4 }}
spec:
selector:
{{- include "tutortool.selectorLabels" . | nindent 4 }}
ports:
- port: {{ .Values.service.port }}
targetPort: {{ .Values.service.targetPort }}

View File

@@ -0,0 +1,9 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "tutortool.serviceAccountName" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "tutortool.labels" . | nindent 4 }}
{{- end }}

29
deploy/templates/vpa.yaml Normal file
View File

@@ -0,0 +1,29 @@
{{- if .Values.vpa.enabled }}
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: {{ include "tutortool.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "tutortool.labels" . | nindent 4 }}
spec:
targetRef:
apiVersion: "apps/v1"
kind: Deployment
name: {{ include "tutortool.fullname" . }}
updatePolicy:
updateMode: {{ .Values.vpa.updateMode | default "Off" | quote }}
resourcePolicy:
containerPolicies:
{{- range .Values.vpa.containerPolicies }}
- containerName: {{ .containerName }}
minAllowed:
{{- toYaml .minAllowed | nindent 10 }}
maxAllowed:
{{- toYaml .maxAllowed | nindent 10 }}
controlledResources:
- cpu
- memory
controlledValues: RequestsAndLimits
{{- end }}
{{- end }}

53
deploy/values.yaml Normal file
View File

@@ -0,0 +1,53 @@
replicaCount: 1
image:
repository: registry.itsh.dev/s0wlz/tutortool
pullPolicy: IfNotPresent
tag: latest
serviceAccount:
create: true
name: ""
service:
port: 80
targetPort: 3000
containerPort: 3000
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi
pvc:
storageClassName: hcloud-volumes
storage: 1Gi
httpRoute:
hostnames:
- tutor.puchstein.dev
sectionName: https-tutor-puchstein-dev
# JWT_SECRET provisioned as a pre-existing K8s Secret named here.
# Do not set jwtSecretValue in committed values — provision via kubectl manually.
jwtSecretName: tutortool-jwt
env:
DATABASE_URL: sqlite:/data/attendance.db
STATIC_DIR: /app/frontend/build
vpa:
enabled: false
updateMode: "Off"
containerPolicies:
- containerName: app
minAllowed:
cpu: 10m
memory: 32Mi
maxAllowed:
cpu: 1000m
memory: 512Mi

View File

@@ -0,0 +1,6 @@
httpRoute:
hostnames:
- tutor.puchstein.dev
image:
tag: latest

View File

@@ -1,28 +0,0 @@
apiVersion: batch/v1
kind: CronJob
metadata:
name: tutortool-backup
spec:
schedule: "0 3 * * *"
jobTemplate:
spec:
template:
spec:
containers:
- name: backup
image: alpine:latest
command:
- /bin/sh
- -c
- |
apk add --no-cache sqlite
sqlite3 /data/attendance.db "VACUUM INTO '/data/backup-$(date +%F).sqlite'"
find /data -name "backup-*.sqlite" -mtime +7 -delete
volumeMounts:
- name: db-storage
mountPath: /data
restartPolicy: OnFailure
volumes:
- name: db-storage
persistentVolumeClaim:
claimName: tutortool-pvc

View File

@@ -1,59 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: tutortool
spec:
replicas: 1
selector:
matchLabels:
app: tutortool
template:
metadata:
labels:
app: tutortool
spec:
containers:
- name: app
image: tutor-registry:latest
ports:
- containerPort: 3000
env:
- name: DATABASE_URL
value: "sqlite:/data/attendance.db"
- name: STATIC_DIR
value: "/app/frontend/build"
volumeMounts:
- name: db-storage
mountPath: /data
volumes:
- name: db-storage
persistentVolumeClaim:
claimName: tutortool-pvc
---
apiVersion: v1
kind: Service
metadata:
name: tutortool
spec:
selector:
app: tutortool
ports:
- port: 80
targetPort: 3000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: tutortool-ingress
spec:
rules:
- host: tutor.puchstein.dev
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: tutortool
port:
number: 80

View File

@@ -1,25 +0,0 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: tutortool
namespace: tutortool
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
tls:
- hosts:
- tutor.puchstein.dev
secretName: tutortool-tls
rules:
- host: tutor.puchstein.dev
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: tutortool
port:
number: 80

View File

@@ -1,10 +0,0 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: tutortool-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi

View File

@@ -1,13 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: tutortool
namespace: tutortool
spec:
selector:
app: tutortool
ports:
- protocol: TCP
port: 80
targetPort: 3000
type: ClusterIP