From 4a8a6f7564f29c8a4edb8b106c57591ad71cc183a9c810596bdfc0f80484c816 Mon Sep 17 00:00:00 2001 From: Andrew Date: Sun, 26 Apr 2026 22:31:39 -0400 Subject: [PATCH] docs(plan): k8s deployment implementation plan 19 bite-sized tasks covering README/docs cleanup, multi-stage prod Dockerfile, Helm chart with all templates, values-local + values-us-prod, Gitea Actions release workflow, and a local k3d e2e smoke test. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-04-26-k8s-deployment.md | 2306 +++++++++++++++++ 1 file changed, 2306 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-26-k8s-deployment.md diff --git a/docs/superpowers/plans/2026-04-26-k8s-deployment.md b/docs/superpowers/plans/2026-04-26-k8s-deployment.md new file mode 100644 index 0000000..8016ae6 --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-k8s-deployment.md @@ -0,0 +1,2306 @@ +# EZSCALE Website K8s Deployment Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a production-ready multi-stage Docker image and a Helm chart (`ezscale-website`) that deploys the EZSCALE Laravel app to the existing K3s cluster, plus reconcile docs against reality. + +**Architecture:** Three Dockerfile targets (`app`, `horizon`, `scheduler`) sharing a runtime base. Helm chart with three Deployments, optional in-cluster Valkey + MariaDB (toggleable per env), operator-managed `Database`/`User`/`Grant` CRDs against the existing `mariadb` instance in prod, Traefik IngressRoute + cert-manager TLS, Helm-hook migration Job. Two environments: `local` (k3d/minikube), `us-prod` (cluster `ezs-us-east-prod-01`, namespace `ezscale`). + +**Tech Stack:** PHP 8.3-FPM, nginx 1.30-alpine, Composer 2, Node 24, Helm 3, mariadb-operator (`k8s.mariadb.com/v1alpha1`), Valkey 9, Traefik 3 IngressRoute, cert-manager, Gitea Container Registry, Gitea Actions. + +**Spec:** [`docs/superpowers/specs/2026-04-26-k8s-deployment-design.md`](../specs/2026-04-26-k8s-deployment-design.md) + +--- + +## Task 1: Reconcile README.md against reality + +**Files:** +- Modify: `README.md` + +**Context:** README claims 59 migrations / 29 models / 85 pages / 43 tests. Reality is 94 / 53 / 165 / 51. Lists multi-currency, KB/FAQ, bandwidth as "Not Yet Implemented" — they exist (`currencies`, `knowledge_base_*`, `bandwidth_usage` migrations). Repo URL says GitHub `EZSCALE/accounting`; real remote is Gitea `EZSCALE/website`. "Last Updated: March 16, 2026" is stale. + +- [ ] **Step 1: Pull live counts** + +```bash +cd website +ls database/migrations | wc -l +find app/Models -maxdepth 1 -name '*.php' | wc -l +find resources/ts/Pages -name '*.vue' | wc -l +find tests -name '*Test.php' -o -name '*.spec.php' | wc -l +git -C .. log -1 --format=%cs # last-commit date +git -C .. remote get-url origin # real remote +``` + +Record outputs. Use them in step 2. + +- [ ] **Step 2: Edit README.md** + +In the "Repository" section, replace the GitHub URL with `git@git.ezscale.cloud:EZSCALE/website.git`. Note Gitea, not GitHub. Issues at `https://git.ezscale.cloud/EZSCALE/website/issues`. + +In "Codebase at a Glance" table, update counts to live values (migrations, models, pages, tests). Recompute test count via `find tests -name '*Test.php' | wc -l`. Drop the "Assertions" row — it's stale and not worth keeping in sync. + +In "Current Status → Implemented Features", add bullets for: multi-currency support, knowledge base + categories, blog (verify by `ls website/database/migrations | grep -i blog`). + +In "Current Status → Not Yet Implemented", remove multi-currency, KB/FAQ, blog (whichever are now done). Keep CI/CD pipeline, staging environment, Cloudflare Zero Trust if still pending — verify each by spot-check. + +In the bottom footer, change "Last Updated: March 16, 2026" to today's date and update the line summary if phase status has shifted. + +- [ ] **Step 3: Verify README markdown renders** + +```bash +cd /home/andrew/local_projects/website +grep -nE 'github\.com|59 |29 |85 |43 ' README.md +``` + +Expected: no matches (all stale references gone). + +- [ ] **Step 4: Commit** + +```bash +git add README.md +git commit -m "docs(readme): reconcile against current reality + +Update codebase counts to live values, fix Gitea repo URL (was GitHub), +move multi-currency/KB/blog from 'not yet implemented' to 'implemented', +refresh footer date. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 2: Audit and clean outdated top-level files + +**Files:** +- Inspect: `IDEAS.md`, `TASKS.md`, `PROJECT_DEVELOPMENT.md`, `FEATURES.md`, `ADVANCED_FEATURES.md`, `KASM_AND_MULTITENANCY.md`, `GETTING_STARTED.md`, `ipv4-outreach-tickets.txt` +- Likely delete: `ipv4-outreach-tickets.txt` +- Likely modify: `GETTING_STARTED.md` (verify still accurate), `TASKS.md` (mark phase status truthfully) + +**Context:** Goal is "remove what's stale, keep what's load-bearing". Don't delete spec docs that future planning still references. The `ipv4-outreach-tickets.txt` is loose vendor outreach notes that has no business in the repo. + +- [ ] **Step 1: Inspect each file's current relevance** + +```bash +cd /home/andrew/local_projects/website +for f in IDEAS.md TASKS.md PROJECT_DEVELOPMENT.md FEATURES.md ADVANCED_FEATURES.md KASM_AND_MULTITENANCY.md GETTING_STARTED.md; do + echo "=== $f ===" + head -5 "$f" + echo "(last commit:)" + git log -1 --format='%cs %s' -- "$f" +done +file ipv4-outreach-tickets.txt +head -10 ipv4-outreach-tickets.txt +``` + +Decision rules: +- `ipv4-outreach-tickets.txt` → **delete**, it's operational outreach notes, not project docs. +- `IDEAS.md` → keep (long-term ideation, referenced by CLAUDE.md indirectly). +- `TASKS.md` → keep but verify accuracy of phase statuses. +- `PROJECT_DEVELOPMENT.md` → keep (architecture decisions doc, referenced by CLAUDE.md). +- `FEATURES.md`, `ADVANCED_FEATURES.md` → keep (feature specs). +- `KASM_AND_MULTITENANCY.md` → keep (referenced by CLAUDE.md). +- `GETTING_STARTED.md` → keep, but verify against current `composer run dev` flow — fix anything wrong. + +- [ ] **Step 2: Remove `ipv4-outreach-tickets.txt`** + +```bash +git rm ipv4-outreach-tickets.txt +``` + +- [ ] **Step 3: Spot-check `GETTING_STARTED.md` for staleness** + +```bash +grep -nE 'php artisan|npm|composer|node|mysql' GETTING_STARTED.md | head -20 +``` + +Compare against actual `website/composer.json` scripts and `website/package.json`. If the doc references commands that don't exist, edit it. Common breakage: references to `npm run dev` when `composer run dev` is now the canonical one-shot. + +- [ ] **Step 4: Spot-check `TASKS.md` Phase 10 status** + +`TASKS.md` Phase 10 should reflect that migrations / Cloudflare Zero Trust / staging / CI are still pending, since README still lists them. If `TASKS.md` already shows them as in-progress with sub-bullets, leave it. If it claims them complete but README says pending, reconcile by updating one to match the other (TASKS.md is generally the source of truth for phase status — update README to match TASKS.md, not the reverse). + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "docs: remove ipv4-outreach-tickets.txt; refresh GETTING_STARTED + +Outreach notes don't belong in the repo. GETTING_STARTED reconciled +against current composer/npm scripts. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 3: Production multi-stage Dockerfile + +**Files:** +- Create: `Dockerfile` (at repo root, NOT inside `website/`) +- Create: `.dockerignore` (at repo root) + +**Context:** The repo has `website/` as the Laravel app, plus chart/docs/specs at the root. The build context for the production image is `./website`, so the Dockerfile is also under `./website` is fine — but the Helm chart and CI all live at the repo root, and we want one image build per repo. Place the Dockerfile at the repo root and use `./website` as the build copy source. + +- [ ] **Step 1: Create `.dockerignore`** + +``` +# .dockerignore +.git +.gitignore +.github +.gitea +.idea +.vscode +.claude +.superpowers +.playwright-mcp +docker +docker-compose*.yml +.env* +!website/.env.example +docs +helm +node_modules +website/node_modules +website/vendor +website/storage/logs/* +website/storage/framework/cache/data/* +website/storage/framework/sessions/* +website/storage/framework/views/* +website/.phpunit.cache +website/tests +*.md +Makefile +scripts/whmcs-migrate +ipv4-outreach-tickets.txt +``` + +- [ ] **Step 2: Create `Dockerfile` (repo root)** + +```dockerfile +# syntax=docker/dockerfile:1.7 + +# ============================================================================== +# composer-deps — install PHP deps (no scripts, no dev) +# ============================================================================== +FROM composer:2 AS composer-deps +WORKDIR /app +COPY website/composer.json website/composer.lock ./ +RUN composer install \ + --no-dev \ + --no-scripts \ + --no-autoloader \ + --prefer-dist \ + --no-interaction \ + --no-progress +COPY website/ ./ +RUN composer dump-autoload --optimize --classmap-authoritative + +# ============================================================================== +# node-build — compile assets (Vite) +# ============================================================================== +FROM node:24-alpine AS node-build +WORKDIR /app +COPY website/package.json website/package-lock.json* ./ +RUN npm ci --no-audit --no-fund +COPY website/ ./ +# vendor/ is needed because Vite reads Laravel's helpers via @vite() +COPY --from=composer-deps /app/vendor ./vendor +RUN npm run build + +# ============================================================================== +# runtime-base — common PHP 8.3-FPM image with extensions +# ============================================================================== +FROM php:8.3-fpm-bookworm AS runtime-base + +ENV COMPOSER_ALLOW_SUPERUSER=1 \ + COMPOSER_NO_INTERACTION=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + git unzip curl ca-certificates \ + libzip-dev libpng-dev libjpeg-dev libfreetype6-dev libwebp-dev \ + libicu-dev libonig-dev libxml2-dev libcurl4-openssl-dev libssl-dev \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +RUN docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \ + && docker-php-ext-install -j"$(nproc)" \ + pdo_mysql intl bcmath gd zip pcntl posix exif sockets opcache + +RUN pecl install redis \ + && docker-php-ext-enable redis + +# Production PHP / opcache config +RUN { \ + echo 'memory_limit=512M'; \ + echo 'upload_max_filesize=50M'; \ + echo 'post_max_size=50M'; \ + echo 'max_execution_time=120'; \ + echo 'expose_php=Off'; \ + } > /usr/local/etc/php/conf.d/zz-app.ini + +RUN { \ + echo 'opcache.enable=1'; \ + echo 'opcache.enable_cli=0'; \ + echo 'opcache.validate_timestamps=0'; \ + echo 'opcache.memory_consumption=256'; \ + echo 'opcache.interned_strings_buffer=16'; \ + echo 'opcache.max_accelerated_files=20000'; \ + echo 'opcache.preload_user=www-data'; \ + } > /usr/local/etc/php/conf.d/zz-opcache.ini + +# php-fpm pool — listen on 0.0.0.0:9000 (sidecar nginx connects to localhost) +RUN { \ + echo '[www]'; \ + echo 'user = www-data'; \ + echo 'group = www-data'; \ + echo 'listen = 0.0.0.0:9000'; \ + echo 'pm = dynamic'; \ + echo 'pm.max_children = 20'; \ + echo 'pm.start_servers = 4'; \ + echo 'pm.min_spare_servers = 2'; \ + echo 'pm.max_spare_servers = 6'; \ + echo 'pm.max_requests = 500'; \ + echo 'clear_env = no'; \ + } > /usr/local/etc/php-fpm.d/zz-app.conf + +WORKDIR /var/www/html + +# Bring in source + autoloaded vendor + built assets +COPY --chown=www-data:www-data --from=composer-deps /app /var/www/html +COPY --chown=www-data:www-data --from=node-build /app/public/build /var/www/html/public/build + +# Cache config & routes at build time. Skip if no APP_KEY available. +# These are best-effort — caches will rebuild at runtime if needed. +RUN php artisan config:clear || true \ + && php artisan route:clear || true \ + && php artisan view:clear || true + +# Entrypoint +COPY docker/prod-entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +USER www-data + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] + +# ============================================================================== +# app — php-fpm (paired with nginx sidecar in k8s) +# ============================================================================== +FROM runtime-base AS app +EXPOSE 9000 +CMD ["php-fpm"] + +# ============================================================================== +# horizon — queue worker +# ============================================================================== +FROM runtime-base AS horizon +STOPSIGNAL SIGTERM +CMD ["php", "artisan", "horizon"] + +# ============================================================================== +# scheduler — schedule:work loop +# ============================================================================== +FROM runtime-base AS scheduler +CMD ["php", "artisan", "schedule:work", "--no-interaction"] +``` + +- [ ] **Step 3: Create production entrypoint** + +```bash +mkdir -p docker +``` + +Create `docker/prod-entrypoint.sh`: + +```bash +#!/bin/sh +set -e + +cd /var/www/html + +# Install Passport keys from Secret-mounted location, if present. +# Chart mounts the keys at /var/www/html/secrets/oauth-{public,private}.key. +if [ -f /var/www/html/secrets/oauth-private.key ] \ + && [ -f /var/www/html/secrets/oauth-public.key ]; then + cp /var/www/html/secrets/oauth-private.key storage/oauth-private.key + cp /var/www/html/secrets/oauth-public.key storage/oauth-public.key + chmod 600 storage/oauth-private.key storage/oauth-public.key +fi + +# Refresh Laravel caches against current env. Safe to run on every boot — +# config/route/view caching is per-pod and idempotent. +php artisan config:cache +php artisan route:cache +php artisan view:cache +php artisan event:cache || true + +exec "$@" +``` + +- [ ] **Step 4: Build all three targets locally** + +```bash +cd /home/andrew/local_projects/website + +docker build --target app -t ezscale-website:test-app . +docker build --target horizon -t ezscale-website:test-horizon . +docker build --target scheduler -t ezscale-website:test-scheduler . +``` + +Expected: each build succeeds, no errors. The first build is slow (~5min); subsequent builds reuse layers. + +- [ ] **Step 5: Smoke-test images** + +```bash +docker run --rm ezscale-website:test-app php -v +docker run --rm ezscale-website:test-horizon php artisan --version +docker run --rm ezscale-website:test-scheduler php -m | grep -E 'opcache|pdo_mysql|redis' +``` + +Expected: PHP 8.3.x version, Laravel 12.x version, all three modules listed. + +- [ ] **Step 6: Verify built assets are present** + +```bash +docker run --rm ezscale-website:test-app ls -la public/build/ +docker run --rm ezscale-website:test-app ls -la public/build/assets/ | head +``` + +Expected: a `manifest.json` and an `assets/` directory with hashed JS/CSS files. + +- [ ] **Step 7: Commit** + +```bash +git add Dockerfile .dockerignore docker/prod-entrypoint.sh +git commit -m "feat(docker): production multi-stage Dockerfile + +Three named targets (app, horizon, scheduler) sharing a runtime-base +with PHP 8.3-FPM, opcache, redis, and pinned php-fpm pool config. +Composer + Node build stages are separate so vendor/ and public/build/ +are baked into the runtime image. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 4: Helm chart skeleton + +**Files:** +- Create: `helm/ezscale-website/Chart.yaml` +- Create: `helm/ezscale-website/values.yaml` +- Create: `helm/ezscale-website/templates/_helpers.tpl` +- Create: `helm/ezscale-website/.helmignore` + +- [ ] **Step 1: Create `Chart.yaml`** + +```yaml +apiVersion: v2 +name: ezscale-website +description: EZSCALE billing platform — Laravel app (web + horizon + scheduler) +type: application +version: 0.1.0 +appVersion: "0.1.0" +maintainers: + - name: EZSCALE + email: dev@ezscale.cloud +``` + +- [ ] **Step 2: Create `values.yaml` (defaults — dev-friendly, prod overrides via separate file)** + +```yaml +# Default values: lean toward "self-contained dev cluster" so `helm install` +# with no flags produces a working stack on a local k3d. Production values +# live in values-us-prod.yaml and disable the in-cluster MariaDB/Valkey when +# pointing at the existing ezscale-namespace MariaDB. + +replicaCount: 1 + +image: + registry: git.ezscale.cloud + repository: ezscale/website + # The chart appends `-{role}-{tag}` to derive each role's image. + # Override `tag` per-release via --set image.tag=v0.1.0 + tag: latest + pullPolicy: IfNotPresent + +imagePullSecrets: + - name: gitea-registry + +nameOverride: "" +fullnameOverride: "" + +# --- App (php-fpm + nginx sidecar) --- +app: + replicaCount: 1 + autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 8 + targetCPU: 70 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 1000m + memory: 1Gi + +# --- Horizon --- +horizon: + replicaCount: 1 + resources: + requests: + cpu: 100m + memory: 256Mi + +# --- Scheduler --- +scheduler: + replicaCount: 1 + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 200m + memory: 256Mi + +# --- In-cluster MariaDB (mariadb-operator CRD) --- +mariadb: + enabled: true + # When enabled, deploys a MariaDB CR named `{release}-mariadb` in this + # release's namespace. When disabled, the chart still creates Database/User/ + # Grant CRDs but they reference an externally-managed MariaDB CR via + # `mariadb.externalRef`. + externalRef: + name: "" # e.g. "mariadb" + namespace: "" # e.g. "ezscale" + image: mariadb:11.4 + replicas: 1 + storage: + size: 5Gi + storageClassName: local-path + rootPasswordSecret: "" # if empty, chart generates a random secret + database: ezscale_billing + username: ezscale_billing_app + +# --- In-cluster Valkey (StatefulSet) --- +valkey: + enabled: true + image: valkey/valkey:9-alpine + password: "" # if empty, chart generates a random secret + maxmemory: "1gb" + storage: + size: 5Gi + storageClassName: local-path + +# --- Migration Job (Helm hook) --- +migrate: + enabled: true + seed: false + seedClass: ProductionSeeder + +# --- Ingress (Traefik IngressRoute) --- +ingressRoute: + enabled: false + hosts: + - ezscale.cloud + - account.ezscale.cloud + - admin.ezscale.cloud + tls: + secretName: ezscale-website-tls + issuerName: letsencrypt + middlewares: + cloudflarewarp: + enabled: false + namespace: kube-system + name: cloudflarewarp + httpToHttps: + enabled: false + namespace: kube-system + name: http-to-https + +# --- Service --- +service: + type: ClusterIP + port: 80 + +# --- Non-secret env vars (rendered into ConfigMap) --- +env: + APP_NAME: "EZSCALE Billing" + APP_ENV: production + APP_DEBUG: "false" + APP_URL: https://ezscale.cloud + APP_MAINTENANCE_DRIVER: file + LOG_CHANNEL: stack + LOG_STACK: single + LOG_LEVEL: info + DB_CONNECTION: mysql + DB_PORT: "3306" + DB_DATABASE: ezscale_billing + DB_USERNAME: ezscale_billing_app + REDIS_CLIENT: phpredis + REDIS_PORT: "6379" + SESSION_DRIVER: redis + SESSION_LIFETIME: "120" + SESSION_DOMAIN: .ezscale.cloud + CACHE_STORE: redis + QUEUE_CONNECTION: redis + BROADCAST_CONNECTION: log + FILESYSTEM_DISK: s3 + MAIL_MAILER: smtp + AWS_DEFAULT_REGION: us-east-1 + AWS_USE_PATH_STYLE_ENDPOINT: "true" + +# --- Secret references (chart does NOT generate APP_KEY or Passport keys) --- +secret: + # When false, chart assumes a Secret named `secret.existingSecretName` is + # already present. This is the production path. + create: false + existingSecretName: ezscale-website-secrets + # Used only when create=true (local dev convenience). + values: {} + +# --- Probes --- +healthCheck: + livenessPath: /up + readinessPath: /up + initialDelaySeconds: 15 + periodSeconds: 15 + timeoutSeconds: 5 + failureThreshold: 3 +``` + +- [ ] **Step 3: Create `templates/_helpers.tpl`** + +```yaml +{{/* Common name helpers */}} +{{- define "ezscale-website.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "ezscale-website.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 -}} + +{{- define "ezscale-website.labels" -}} +app.kubernetes.io/name: {{ include "ezscale-website.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} +{{- end -}} + +{{- define "ezscale-website.selectorLabels" -}} +app.kubernetes.io/name: {{ include "ezscale-website.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{/* +Image reference for a given role (app/horizon/scheduler). +Usage: {{ include "ezscale-website.image" (dict "ctx" . "role" "app") }} +*/}} +{{- define "ezscale-website.image" -}} +{{- $ctx := .ctx -}} +{{- printf "%s/%s:%s-%s" $ctx.Values.image.registry $ctx.Values.image.repository .role $ctx.Values.image.tag -}} +{{- end -}} + +{{/* Secret name (existing or generated) */}} +{{- define "ezscale-website.secretName" -}} +{{- if .Values.secret.create -}} +{{- include "ezscale-website.fullname" . -}}-secrets +{{- else -}} +{{- .Values.secret.existingSecretName -}} +{{- end -}} +{{- end -}} + +{{/* DB host — points at in-cluster MariaDB or external one */}} +{{- define "ezscale-website.dbHost" -}} +{{- if .Values.mariadb.enabled -}} +{{ include "ezscale-website.fullname" . }}-mariadb +{{- else -}} +{{- $ref := .Values.mariadb.externalRef -}} +{{- printf "%s.%s.svc.cluster.local" $ref.name $ref.namespace -}} +{{- end -}} +{{- end -}} + +{{/* Redis host */}} +{{- define "ezscale-website.redisHost" -}} +{{ include "ezscale-website.fullname" . }}-valkey +{{- end -}} +``` + +- [ ] **Step 4: Create `.helmignore`** + +``` +.DS_Store +.git/ +.gitignore +*.tmproj +.vscode/ +*.swp +*.tmp +*.bak +README.md.bak +``` + +- [ ] **Step 5: Lint the chart** + +```bash +cd /home/andrew/local_projects/website +helm lint helm/ezscale-website +``` + +Expected: `1 chart(s) linted, 0 chart(s) failed`. Templates directory is empty so far — that's fine for lint. + +- [ ] **Step 6: Commit** + +```bash +git add helm/ezscale-website/ +git commit -m "feat(helm): chart skeleton (Chart.yaml, values, helpers) + +Initial scaffold for the ezscale-website chart. Defaults assume +self-contained local dev (in-cluster MariaDB + Valkey). Production +overrides will live in values-us-prod.yaml. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 5: ConfigMap and Secret placeholder templates + +**Files:** +- Create: `helm/ezscale-website/templates/configmap.yaml` +- Create: `helm/ezscale-website/templates/secret.yaml` + +- [ ] **Step 1: Create `configmap.yaml`** + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "ezscale-website.fullname" . }}-env + labels: {{- include "ezscale-website.labels" . | nindent 4 }} +data: + {{- range $key, $value := .Values.env }} + {{ $key }}: {{ $value | quote }} + {{- end }} + DB_HOST: {{ include "ezscale-website.dbHost" . | quote }} + REDIS_HOST: {{ include "ezscale-website.redisHost" . | quote }} +``` + +- [ ] **Step 2: Create `secret.yaml`** + +```yaml +{{- if .Values.secret.create -}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "ezscale-website.fullname" . }}-secrets + labels: {{- include "ezscale-website.labels" . | nindent 4 }} +type: Opaque +stringData: + {{- range $key, $value := .Values.secret.values }} + {{ $key }}: {{ $value | quote }} + {{- end }} +{{- end -}} +``` + +- [ ] **Step 3: Render & validate** + +```bash +cd /home/andrew/local_projects/website +helm template ezscale-website helm/ezscale-website > /tmp/render.yaml +kubectl apply --dry-run=client -f /tmp/render.yaml +``` + +Expected: ConfigMap dry-run passes (`configmap/release-name-ezscale-website-env created (dry run)`). No Secret because `secret.create=false` by default. + +- [ ] **Step 4: Commit** + +```bash +git add helm/ezscale-website/templates/configmap.yaml helm/ezscale-website/templates/secret.yaml +git commit -m "feat(helm): ConfigMap + Secret templates + +ConfigMap renders all non-secret env vars including dynamic DB_HOST +and REDIS_HOST. Secret template only renders when secret.create=true +(dev convenience); production references an existing Secret. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 6: Service template + +**Files:** +- Create: `helm/ezscale-website/templates/service.yaml` + +- [ ] **Step 1: Create `service.yaml`** + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: {{ include "ezscale-website.fullname" . }} + labels: {{- include "ezscale-website.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "ezscale-website.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: app +``` + +- [ ] **Step 2: Verify rendering** + +```bash +helm template ezscale-website helm/ezscale-website | grep -A 12 'kind: Service' +``` + +Expected: a Service block with `targetPort: http` and the right selector. + +- [ ] **Step 3: Commit** + +```bash +git add helm/ezscale-website/templates/service.yaml +git commit -m "feat(helm): Service template (ClusterIP, port 80 → http) + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 7: App Deployment (php-fpm + nginx sidecar) + +**Files:** +- Create: `helm/ezscale-website/templates/deployment-app.yaml` +- Create: `helm/ezscale-website/templates/configmap-nginx.yaml` + +**Context:** The pod runs two containers (`nginx` + `app`) sharing source via an `emptyDir` populated by an init container. nginx vhost is rendered into a ConfigMap separate from the env ConfigMap so it can be edited without restart-cycling secrets. + +- [ ] **Step 1: Create `configmap-nginx.yaml`** + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "ezscale-website.fullname" . }}-nginx + labels: {{- include "ezscale-website.labels" . | nindent 4 }} +data: + default.conf: | + server { + listen 80 default_server; + server_name _; + root /var/www/html/public; + index index.php index.html; + + client_max_body_size 50M; + charset utf-8; + + add_header X-Frame-Options "SAMEORIGIN"; + add_header X-Content-Type-Options "nosniff"; + add_header Referrer-Policy "strict-origin-when-cross-origin"; + + location = /favicon.ico { access_log off; log_not_found off; } + location = /robots.txt { access_log off; log_not_found off; } + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ \.php$ { + try_files $uri =404; + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_pass 127.0.0.1:9000; + fastcgi_index index.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + fastcgi_param DOCUMENT_ROOT $realpath_root; + fastcgi_param HTTP_PROXY ""; + fastcgi_param HTTPS $http_x_forwarded_proto; + fastcgi_buffers 16 16k; + fastcgi_buffer_size 32k; + fastcgi_read_timeout 300; + } + + location ~ /\.(?!well-known).* { + deny all; + access_log off; + log_not_found off; + } + } +``` + +- [ ] **Step 2: Create `deployment-app.yaml`** + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "ezscale-website.fullname" . }}-app + labels: + {{- include "ezscale-website.labels" . | nindent 4 }} + app.kubernetes.io/component: app +spec: + {{- if not .Values.app.autoscaling.enabled }} + replicas: {{ .Values.app.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "ezscale-website.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: app + template: + metadata: + labels: + {{- include "ezscale-website.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: app + annotations: + # Restart pods when env or nginx config changes + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + checksum/nginx: {{ include (print $.Template.BasePath "/configmap-nginx.yaml") . | sha256sum }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + initContainers: + - name: copy-source + image: {{ include "ezscale-website.image" (dict "ctx" . "role" "app") }} + command: + - sh + - -c + - | + cp -a /var/www/html/. /shared/ + volumeMounts: + - name: shared + mountPath: /shared + containers: + - name: nginx + image: nginx:1.30-alpine + ports: + - name: http + containerPort: 80 + volumeMounts: + - name: shared + mountPath: /var/www/html + readOnly: true + - name: nginx-config + mountPath: /etc/nginx/conf.d + readOnly: true + livenessProbe: + httpGet: + path: {{ .Values.healthCheck.livenessPath }} + port: http + initialDelaySeconds: {{ .Values.healthCheck.initialDelaySeconds }} + periodSeconds: {{ .Values.healthCheck.periodSeconds }} + timeoutSeconds: {{ .Values.healthCheck.timeoutSeconds }} + failureThreshold: {{ .Values.healthCheck.failureThreshold }} + readinessProbe: + httpGet: + path: {{ .Values.healthCheck.readinessPath }} + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + - name: app + image: {{ include "ezscale-website.image" (dict "ctx" . "role" "app") }} + ports: + - name: php-fpm + containerPort: 9000 + envFrom: + - configMapRef: + name: {{ include "ezscale-website.fullname" . }}-env + - secretRef: + name: {{ include "ezscale-website.secretName" . }} + volumeMounts: + - name: shared + mountPath: /var/www/html + - name: oauth-keys + mountPath: /var/www/html/secrets + readOnly: true + resources: + {{- toYaml .Values.app.resources | nindent 12 }} + volumes: + - name: shared + emptyDir: {} + - name: nginx-config + configMap: + name: {{ include "ezscale-website.fullname" . }}-nginx + - name: oauth-keys + secret: + secretName: {{ include "ezscale-website.secretName" . }} + items: + - key: oauth-private.key + path: oauth-private.key + - key: oauth-public.key + path: oauth-public.key + optional: true +``` + +- [ ] **Step 3: Render & validate** + +```bash +helm template ezscale-website helm/ezscale-website \ + --set secret.existingSecretName=test-secret \ + > /tmp/render.yaml +kubectl apply --dry-run=client -f /tmp/render.yaml +``` + +Expected: deployment dry-runs cleanly. Confirm two containers + one init container + one Service in the rendered output. + +- [ ] **Step 4: Commit** + +```bash +git add helm/ezscale-website/templates/deployment-app.yaml helm/ezscale-website/templates/configmap-nginx.yaml +git commit -m "feat(helm): app Deployment (nginx + php-fpm sidecar) + +Two-container pod sharing source via emptyDir populated by init +container. Nginx vhost in a separate ConfigMap. OAuth keys mounted +from the chart Secret as files under /var/www/html/secrets/, copied +into storage/ by the prod entrypoint. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 8: HPA for app + +**Files:** +- Create: `helm/ezscale-website/templates/hpa-app.yaml` + +- [ ] **Step 1: Create `hpa-app.yaml`** + +```yaml +{{- if .Values.app.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "ezscale-website.fullname" . }}-app + labels: + {{- include "ezscale-website.labels" . | nindent 4 }} + app.kubernetes.io/component: app +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "ezscale-website.fullname" . }}-app + minReplicas: {{ .Values.app.autoscaling.minReplicas }} + maxReplicas: {{ .Values.app.autoscaling.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.app.autoscaling.targetCPU }} +{{- end }} +``` + +- [ ] **Step 2: Verify HPA renders only when enabled** + +```bash +helm template ezscale-website helm/ezscale-website | grep -c 'kind: HorizontalPodAutoscaler' +helm template ezscale-website helm/ezscale-website --set app.autoscaling.enabled=true | grep -c 'kind: HorizontalPodAutoscaler' +``` + +Expected: `0` then `1`. + +- [ ] **Step 3: Commit** + +```bash +git add helm/ezscale-website/templates/hpa-app.yaml +git commit -m "feat(helm): HPA for app deployment (toggleable, CPU-based) + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 9: Horizon Deployment + +**Files:** +- Create: `helm/ezscale-website/templates/deployment-horizon.yaml` + +- [ ] **Step 1: Create `deployment-horizon.yaml`** + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "ezscale-website.fullname" . }}-horizon + labels: + {{- include "ezscale-website.labels" . | nindent 4 }} + app.kubernetes.io/component: horizon +spec: + replicas: {{ .Values.horizon.replicaCount }} + # Horizon needs SIGTERM + drain time. Don't run two replicas during update. + strategy: + type: Recreate + selector: + matchLabels: + {{- include "ezscale-website.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: horizon + template: + metadata: + labels: + {{- include "ezscale-website.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: horizon + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + terminationGracePeriodSeconds: 60 + containers: + - name: horizon + image: {{ include "ezscale-website.image" (dict "ctx" . "role" "horizon") }} + envFrom: + - configMapRef: + name: {{ include "ezscale-website.fullname" . }}-env + - secretRef: + name: {{ include "ezscale-website.secretName" . }} + volumeMounts: + - name: oauth-keys + mountPath: /var/www/html/secrets + readOnly: true + resources: + {{- toYaml .Values.horizon.resources | nindent 12 }} + livenessProbe: + exec: + command: + - php + - artisan + - horizon:status + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 10 + volumes: + - name: oauth-keys + secret: + secretName: {{ include "ezscale-website.secretName" . }} + items: + - key: oauth-private.key + path: oauth-private.key + - key: oauth-public.key + path: oauth-public.key + optional: true +``` + +- [ ] **Step 2: Validate** + +```bash +helm template ezscale-website helm/ezscale-website \ + --set secret.existingSecretName=test-secret \ + > /tmp/render.yaml +kubectl apply --dry-run=client -f /tmp/render.yaml +``` + +Expected: clean dry-run. + +- [ ] **Step 3: Commit** + +```bash +git add helm/ezscale-website/templates/deployment-horizon.yaml +git commit -m "feat(helm): Horizon deployment (Recreate strategy, 60s grace) + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 10: Scheduler Deployment + +**Files:** +- Create: `helm/ezscale-website/templates/deployment-scheduler.yaml` + +- [ ] **Step 1: Create `deployment-scheduler.yaml`** + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "ezscale-website.fullname" . }}-scheduler + labels: + {{- include "ezscale-website.labels" . | nindent 4 }} + app.kubernetes.io/component: scheduler +spec: + # Single replica only — running two schedule:work instances doubles tasks. + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + {{- include "ezscale-website.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: scheduler + template: + metadata: + labels: + {{- include "ezscale-website.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: scheduler + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: scheduler + image: {{ include "ezscale-website.image" (dict "ctx" . "role" "scheduler") }} + envFrom: + - configMapRef: + name: {{ include "ezscale-website.fullname" . }}-env + - secretRef: + name: {{ include "ezscale-website.secretName" . }} + resources: + {{- toYaml .Values.scheduler.resources | nindent 12 }} +``` + +- [ ] **Step 2: Validate** + +```bash +helm template ezscale-website helm/ezscale-website \ + --set secret.existingSecretName=test-secret | kubectl apply --dry-run=client -f - +``` + +Expected: clean dry-run. + +- [ ] **Step 3: Commit** + +```bash +git add helm/ezscale-website/templates/deployment-scheduler.yaml +git commit -m "feat(helm): scheduler deployment (single replica, schedule:work) + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 11: Migration Job (Helm hook) + +**Files:** +- Create: `helm/ezscale-website/templates/job-migrate.yaml` + +- [ ] **Step 1: Create `job-migrate.yaml`** + +```yaml +{{- if .Values.migrate.enabled }} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "ezscale-website.fullname" . }}-migrate + labels: + {{- include "ezscale-website.labels" . | nindent 4 }} + app.kubernetes.io/component: migrate + annotations: + "helm.sh/hook": pre-install,pre-upgrade + "helm.sh/hook-weight": "0" + "helm.sh/hook-delete-policy": before-hook-creation +spec: + backoffLimit: 1 + ttlSecondsAfterFinished: 3600 + template: + metadata: + labels: + {{- include "ezscale-website.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: migrate + spec: + restartPolicy: Never + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: migrate + image: {{ include "ezscale-website.image" (dict "ctx" . "role" "app") }} + command: + - sh + - -c + - | + set -e + php artisan migrate --force --no-interaction + {{- if .Values.migrate.seed }} + php artisan db:seed --class={{ .Values.migrate.seedClass }} --force --no-interaction + {{- end }} + envFrom: + - configMapRef: + name: {{ include "ezscale-website.fullname" . }}-env + - secretRef: + name: {{ include "ezscale-website.secretName" . }} +{{- end }} +``` + +- [ ] **Step 2: Validate** + +```bash +helm template ezscale-website helm/ezscale-website \ + --set secret.existingSecretName=test-secret | grep -A 5 'kind: Job' +``` + +Expected: a Job block with the `helm.sh/hook` annotations. + +- [ ] **Step 3: Commit** + +```bash +git add helm/ezscale-website/templates/job-migrate.yaml +git commit -m "feat(helm): pre-install/pre-upgrade migration Job + +Helm hook runs migrate (and optionally seed) before any pod rolls. +If the Job fails, helm upgrade aborts and the previous ReplicaSet +keeps serving traffic. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 12: MariaDB CRDs (Database, User, Grant) + +**Files:** +- Create: `helm/ezscale-website/templates/mariadb-database.yaml` +- Create: `helm/ezscale-website/templates/mariadb-user.yaml` +- Create: `helm/ezscale-website/templates/mariadb-grant.yaml` + +**Context:** When `mariadb.enabled=true`, the CRDs reference the in-cluster MariaDB instance this chart deploys (Task 13). When `mariadb.enabled=false`, they reference an external one specified in `mariadb.externalRef`. Either way, the chart's app pods authenticate as the user this declares. + +- [ ] **Step 1: Create `mariadb-database.yaml`** + +```yaml +apiVersion: k8s.mariadb.com/v1alpha1 +kind: Database +metadata: + name: {{ include "ezscale-website.fullname" . }}-db + labels: {{- include "ezscale-website.labels" . | nindent 4 }} +spec: + mariaDbRef: + {{- if .Values.mariadb.enabled }} + name: {{ include "ezscale-website.fullname" . }}-mariadb + {{- else }} + name: {{ .Values.mariadb.externalRef.name }} + namespace: {{ .Values.mariadb.externalRef.namespace }} + {{- end }} + characterSet: utf8mb4 + collate: utf8mb4_unicode_ci + name: {{ .Values.mariadb.database }} +``` + +- [ ] **Step 2: Create `mariadb-user.yaml`** + +```yaml +apiVersion: k8s.mariadb.com/v1alpha1 +kind: User +metadata: + name: {{ include "ezscale-website.fullname" . }}-user + labels: {{- include "ezscale-website.labels" . | nindent 4 }} +spec: + # spec.name overrides metadata.name as the SQL identifier — needed because + # k8s resource names can't contain underscores but our SQL username can. + name: {{ .Values.mariadb.username }} + mariaDbRef: + {{- if .Values.mariadb.enabled }} + name: {{ include "ezscale-website.fullname" . }}-mariadb + {{- else }} + name: {{ .Values.mariadb.externalRef.name }} + namespace: {{ .Values.mariadb.externalRef.namespace }} + {{- end }} + passwordSecretKeyRef: + name: {{ include "ezscale-website.secretName" . }} + key: DB_PASSWORD + host: "%" + maxUserConnections: 50 +``` + +- [ ] **Step 3: Create `mariadb-grant.yaml`** + +```yaml +apiVersion: k8s.mariadb.com/v1alpha1 +kind: Grant +metadata: + name: {{ include "ezscale-website.fullname" . }}-grant + labels: {{- include "ezscale-website.labels" . | nindent 4 }} +spec: + mariaDbRef: + {{- if .Values.mariadb.enabled }} + name: {{ include "ezscale-website.fullname" . }}-mariadb + {{- else }} + name: {{ .Values.mariadb.externalRef.name }} + namespace: {{ .Values.mariadb.externalRef.namespace }} + {{- end }} + username: {{ .Values.mariadb.username }} + host: "%" + privileges: + - "ALL PRIVILEGES" + database: {{ .Values.mariadb.database }} + table: "*" +``` + +- [ ] **Step 4: Validate** + +```bash +helm template ezscale-website helm/ezscale-website | grep -E 'kind: (Database|User|Grant)' +``` + +Expected: three matches. Note `kubectl apply --dry-run=client` will fail on these since the CRDs aren't installed in the local kubectl context — that's expected. The template syntax check via `helm template` is enough here. + +- [ ] **Step 5: Commit** + +```bash +git add helm/ezscale-website/templates/mariadb-database.yaml \ + helm/ezscale-website/templates/mariadb-user.yaml \ + helm/ezscale-website/templates/mariadb-grant.yaml +git commit -m "feat(helm): mariadb-operator Database/User/Grant CRDs + +When mariadb.enabled=true, references the in-cluster MariaDB this +chart deploys. When false, references an external CR via +mariadb.externalRef. Privileges scoped to the website's database +only — no global ALL PRIVILEGES. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 13: MariaDB instance template (toggleable) + +**Files:** +- Create: `helm/ezscale-website/templates/mariadb-instance.yaml` + +- [ ] **Step 1: Create `mariadb-instance.yaml`** + +```yaml +{{- if .Values.mariadb.enabled }} +{{- $rootSecretName := default (printf "%s-mariadb-root" (include "ezscale-website.fullname" .)) .Values.mariadb.rootPasswordSecret }} +{{- if not .Values.mariadb.rootPasswordSecret }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ $rootSecretName }} + labels: {{- include "ezscale-website.labels" . | nindent 4 }} + annotations: + helm.sh/resource-policy: keep +type: Opaque +stringData: + password: {{ randAlphaNum 32 | quote }} +--- +{{- end }} +apiVersion: k8s.mariadb.com/v1alpha1 +kind: MariaDB +metadata: + name: {{ include "ezscale-website.fullname" . }}-mariadb + labels: {{- include "ezscale-website.labels" . | nindent 4 }} +spec: + image: {{ .Values.mariadb.image }} + rootPasswordSecretKeyRef: + name: {{ $rootSecretName }} + key: password + generate: false + replicas: {{ .Values.mariadb.replicas }} + storage: + size: {{ .Values.mariadb.storage.size }} + storageClassName: {{ .Values.mariadb.storage.storageClassName }} +{{- end }} +``` + +**Note** about the `helm.sh/resource-policy: keep` annotation on the generated root password Secret: it prevents `helm uninstall` from deleting the root password, which would orphan the data PVC. For local dev clean slates use `kubectl delete secret ` manually. + +- [ ] **Step 2: Validate** + +```bash +helm template ezscale-website helm/ezscale-website > /tmp/render.yaml +grep -E 'kind: MariaDB|generate: false' /tmp/render.yaml +``` + +Expected: one `kind: MariaDB` plus `generate: false`. + +- [ ] **Step 3: Commit** + +```bash +git add helm/ezscale-website/templates/mariadb-instance.yaml +git commit -m "feat(helm): in-cluster MariaDB CR (toggleable for dev) + +Renders only when mariadb.enabled=true. Generates a random root +password Secret with helm.sh/resource-policy=keep so uninstall +doesn't orphan the data volume. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 14: Valkey StatefulSet template + +**Files:** +- Create: `helm/ezscale-website/templates/statefulset-valkey.yaml` + +- [ ] **Step 1: Create `statefulset-valkey.yaml`** + +```yaml +{{- if .Values.valkey.enabled }} +{{- $secretName := printf "%s-valkey" (include "ezscale-website.fullname" .) }} +{{- if .Values.valkey.password }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ $secretName }} + labels: {{- include "ezscale-website.labels" . | nindent 4 }} + annotations: + helm.sh/resource-policy: keep +type: Opaque +stringData: + password: {{ .Values.valkey.password | quote }} +--- +{{- end }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "ezscale-website.redisHost" . }} + labels: + {{- include "ezscale-website.labels" . | nindent 4 }} + app.kubernetes.io/component: valkey +spec: + type: ClusterIP + ports: + - port: 6379 + targetPort: redis + name: redis + selector: + {{- include "ezscale-website.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: valkey +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "ezscale-website.redisHost" . }} + labels: + {{- include "ezscale-website.labels" . | nindent 4 }} + app.kubernetes.io/component: valkey +spec: + serviceName: {{ include "ezscale-website.redisHost" . }} + replicas: 1 + selector: + matchLabels: + {{- include "ezscale-website.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: valkey + template: + metadata: + labels: + {{- include "ezscale-website.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: valkey + spec: + containers: + - name: valkey + image: {{ .Values.valkey.image }} + command: + - valkey-server + - --appendonly + - "yes" + - --maxmemory + - {{ .Values.valkey.maxmemory | quote }} + - --maxmemory-policy + - allkeys-lru + {{- if .Values.valkey.password }} + - --requirepass + - $(REDIS_PASSWORD) + {{- end }} + {{- if .Values.valkey.password }} + env: + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: password + {{- end }} + ports: + - name: redis + containerPort: 6379 + volumeMounts: + - name: data + mountPath: /data + livenessProbe: + exec: + command: ["valkey-cli", "ping"] + initialDelaySeconds: 10 + periodSeconds: 10 + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + storageClassName: {{ .Values.valkey.storage.storageClassName }} + resources: + requests: + storage: {{ .Values.valkey.storage.size }} +{{- end }} +``` + +- [ ] **Step 2: Validate** + +```bash +helm template ezscale-website helm/ezscale-website > /tmp/render.yaml +grep -E 'kind: (Service|StatefulSet)' /tmp/render.yaml +``` + +Expected: at least one Service (the app) + one Service (valkey) + one StatefulSet. + +- [ ] **Step 3: Commit** + +```bash +git add helm/ezscale-website/templates/statefulset-valkey.yaml +git commit -m "feat(helm): in-cluster Valkey StatefulSet (toggleable) + +AOF persistence + LRU eviction + optional password. PVC for the +queue data so Horizon doesn't lose pending jobs on pod restart. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 15: IngressRoute and Certificate templates + +**Files:** +- Create: `helm/ezscale-website/templates/ingressroute.yaml` +- Create: `helm/ezscale-website/templates/certificate.yaml` + +- [ ] **Step 1: Create `ingressroute.yaml`** + +```yaml +{{- if .Values.ingressRoute.enabled }} +{{- $hostMatch := "" -}} +{{- range $i, $h := .Values.ingressRoute.hosts -}} +{{- if eq $i 0 -}} +{{- $hostMatch = printf "Host(`%s`)" $h -}} +{{- else -}} +{{- $hostMatch = printf "%s || Host(`%s`)" $hostMatch $h -}} +{{- end -}} +{{- end }} +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: {{ include "ezscale-website.fullname" . }}-https + labels: {{- include "ezscale-website.labels" . | nindent 4 }} +spec: + entryPoints: [websecure] + routes: + - kind: Rule + match: {{ $hostMatch }} + middlewares: + {{- if .Values.ingressRoute.middlewares.cloudflarewarp.enabled }} + - name: {{ .Values.ingressRoute.middlewares.cloudflarewarp.name }} + namespace: {{ .Values.ingressRoute.middlewares.cloudflarewarp.namespace }} + {{- end }} + services: + - name: {{ include "ezscale-website.fullname" . }} + port: {{ .Values.service.port }} + tls: + secretName: {{ .Values.ingressRoute.tls.secretName }} +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: {{ include "ezscale-website.fullname" . }}-http + labels: {{- include "ezscale-website.labels" . | nindent 4 }} +spec: + entryPoints: [web] + routes: + - kind: Rule + match: {{ $hostMatch }} + middlewares: + {{- if .Values.ingressRoute.middlewares.httpToHttps.enabled }} + - name: {{ .Values.ingressRoute.middlewares.httpToHttps.name }} + namespace: {{ .Values.ingressRoute.middlewares.httpToHttps.namespace }} + {{- end }} + services: + - name: {{ include "ezscale-website.fullname" . }} + port: {{ .Values.service.port }} +{{- end }} +``` + +- [ ] **Step 2: Create `certificate.yaml`** + +```yaml +{{- if .Values.ingressRoute.enabled }} +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: {{ .Values.ingressRoute.tls.secretName }} + labels: {{- include "ezscale-website.labels" . | nindent 4 }} +spec: + secretName: {{ .Values.ingressRoute.tls.secretName }} + issuerRef: + kind: ClusterIssuer + name: {{ .Values.ingressRoute.tls.issuerName }} + dnsNames: + {{- range .Values.ingressRoute.hosts }} + - {{ . | quote }} + {{- end }} +{{- end }} +``` + +- [ ] **Step 3: Validate** + +```bash +helm template ezscale-website helm/ezscale-website \ + --set ingressRoute.enabled=true \ + --set ingressRoute.middlewares.cloudflarewarp.enabled=true \ + --set ingressRoute.middlewares.httpToHttps.enabled=true \ + | grep -E 'kind: (IngressRoute|Certificate)' +``` + +Expected: 2 IngressRoutes + 1 Certificate. + +- [ ] **Step 4: Commit** + +```bash +git add helm/ezscale-website/templates/ingressroute.yaml \ + helm/ezscale-website/templates/certificate.yaml +git commit -m "feat(helm): Traefik IngressRoute + cert-manager Certificate + +Two IngressRoutes (web → http-to-https redirect, websecure → app) +covering all configured hosts. Certificate covers all hosts as SANs. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 16: Environment values files + +**Files:** +- Create: `helm/ezscale-website/values-local.yaml` +- Create: `helm/ezscale-website/values-us-prod.yaml` + +- [ ] **Step 1: Create `values-local.yaml`** + +```yaml +# =========================================================================== +# Local k3d/minikube cluster — fully self-contained. +# Prerequisite: mariadb-operator installed in the cluster. +# helm install mariadb-operator -n mariadb-operator --create-namespace \ +# mariadb-operator/mariadb-operator +# =========================================================================== + +image: + tag: latest + pullPolicy: Always + +imagePullSecrets: [] # no registry auth needed for locally-built images + +app: + replicaCount: 1 + resources: + requests: { cpu: 100m, memory: 256Mi } + +mariadb: + enabled: true + replicas: 1 + storage: + size: 5Gi + storageClassName: local-path + +valkey: + enabled: true + storage: + size: 1Gi + storageClassName: local-path + +migrate: + enabled: true + seed: true + seedClass: DemoDataSeeder + +ingressRoute: + enabled: false # local uses port-forward, not Traefik + +# Local dev: chart generates a random APP_KEY on first install. +# This is OK in local because there's no encrypted prod data to lose. +# In production this MUST be `secret.create=false`. +secret: + create: true + existingSecretName: "" + values: + APP_KEY: "base64:CHANGEME_GENERATE_VIA_PHP_ARTISAN_KEY_GENERATE_SHOW" + DB_PASSWORD: "local_dev_password" + AWS_ACCESS_KEY_ID: "" + AWS_SECRET_ACCESS_KEY: "" + STRIPE_KEY: "" + STRIPE_SECRET: "" + +env: + APP_ENV: local + APP_DEBUG: "true" + APP_URL: http://localhost + LOG_LEVEL: debug + FILESYSTEM_DISK: local + MAIL_MAILER: log + SESSION_DOMAIN: "" +``` + +**Note:** `APP_KEY` placeholder must be replaced before first install. Run `php artisan key:generate --show` against any working Laravel checkout, paste the result here. + +- [ ] **Step 2: Create `values-us-prod.yaml`** + +```yaml +# =========================================================================== +# Production: ezs-us-east-prod-01.node.ezscale.tech +# Namespace: ezscale (shared with mariadb instance + ezscale_api) +# =========================================================================== + +image: + registry: git.ezscale.cloud + repository: ezscale/website + tag: "" # SET via --set image.tag=v0.1.0 at deploy time + pullPolicy: IfNotPresent + +imagePullSecrets: + - name: gitea-registry + +app: + replicaCount: 2 + autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 8 + targetCPU: 70 + resources: + requests: { cpu: 200m, memory: 512Mi } + limits: { cpu: 1500m, memory: 1536Mi } + +horizon: + replicaCount: 1 + resources: + requests: { cpu: 200m, memory: 512Mi } + limits: { cpu: 1000m, memory: 1Gi } + +scheduler: + replicaCount: 1 + resources: + requests: { cpu: 50m, memory: 128Mi } + +# Reuse the cluster's existing replicated MariaDB. +mariadb: + enabled: false + externalRef: + name: mariadb + namespace: ezscale + database: ezscale_billing + username: ezscale_billing_app + +# Per-app Valkey for sessions/cache/queue. +valkey: + enabled: true + storage: + size: 10Gi + storageClassName: longhorn + +migrate: + enabled: true + seed: false + +ingressRoute: + enabled: true + hosts: + - ezscale.cloud + - account.ezscale.cloud + - admin.ezscale.cloud + tls: + secretName: ezscale-website-tls + issuerName: letsencrypt + middlewares: + cloudflarewarp: + enabled: true + namespace: kube-system + name: cloudflarewarp + httpToHttps: + enabled: true + namespace: kube-system + name: http-to-https + +# Production NEVER lets the chart generate APP_KEY. Bootstrap procedure +# in helm/ezscale-website/README.md. +secret: + create: false + existingSecretName: ezscale-website-secrets + +env: + APP_NAME: "EZSCALE Billing" + APP_ENV: production + APP_DEBUG: "false" + APP_URL: https://ezscale.cloud + LOG_LEVEL: warning + FILESYSTEM_DISK: s3 + AWS_BUCKET: ezscale-website-prod + AWS_DEFAULT_REGION: us-east-1 + AWS_ENDPOINT: https://gateway.storjshare.io + AWS_USE_PATH_STYLE_ENDPOINT: "true" + SESSION_DRIVER: redis + SESSION_DOMAIN: .ezscale.cloud + CACHE_STORE: redis + QUEUE_CONNECTION: redis + MAIL_MAILER: smtp + TRUSTED_PROXIES: "*" +``` + +- [ ] **Step 3: Render both and validate template-only** + +```bash +cd /home/andrew/local_projects/website +helm template ezscale-website helm/ezscale-website -f helm/ezscale-website/values-local.yaml > /tmp/local.yaml +helm template ezscale-website helm/ezscale-website -f helm/ezscale-website/values-us-prod.yaml --set image.tag=v0.1.0 > /tmp/prod.yaml +echo "Local: $(wc -l < /tmp/local.yaml) lines" +echo "Prod: $(wc -l < /tmp/prod.yaml) lines" +grep -E '^kind: ' /tmp/prod.yaml | sort | uniq -c +``` + +Expected: prod render shows ConfigMap, Database, Deployment×3, Grant, IngressRoute×2, Job, Service×2, StatefulSet, User, Certificate, HorizontalPodAutoscaler. Not MariaDB (because external) and not the local-only generated Secret. + +- [ ] **Step 4: Commit** + +```bash +git add helm/ezscale-website/values-local.yaml helm/ezscale-website/values-us-prod.yaml +git commit -m "feat(helm): values-local + values-us-prod + +Local: in-cluster MariaDB + Valkey, port-forward instead of ingress, +chart-generated APP_KEY (dev only). +Prod: external MariaDB (ezscale ns), Longhorn-backed Valkey, Traefik +IngressRoute with cloudflarewarp + cert-manager TLS, image.tag set +at deploy time, secret pre-created out-of-band. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 17: Helm chart README + bootstrap procedure + +**Files:** +- Create: `helm/ezscale-website/README.md` + +- [ ] **Step 1: Create `helm/ezscale-website/README.md`** + +```markdown +# ezscale-website Helm Chart + +Production deployment for the EZSCALE billing platform Laravel app. + +## Environments + +| Env | Values file | Notes | +|---|---|---| +| Local k3d | `values-local.yaml` | Self-contained: in-cluster MariaDB + Valkey. No ingress (use port-forward). | +| US prod | `values-us-prod.yaml` | Connects to existing `mariadb` CR in `ezscale` ns; per-app Valkey on Longhorn. | + +## Prerequisites (us-prod) + +The `ezscale` namespace must already have: +- mariadb-operator installed cluster-wide (provides `MariaDB`/`Database`/`User`/`Grant` CRDs) +- A `MariaDB` CR named `mariadb` +- cert-manager `ClusterIssuer` named `letsencrypt` +- Traefik with `cloudflarewarp` and `http-to-https` middlewares in `kube-system` +- Image-pull `Secret` named `gitea-registry` + +These are all already provisioned per `infrastructure/kubernetes/`. + +## First-time bootstrap (us-prod) + +This step is run **once** per environment, never repeated. The Secret it creates lives forever — re-running it would invalidate every encrypted DB column and every issued OAuth token. + +```bash +# 1. Generate APP_KEY locally (any Laravel checkout works) +APP_KEY=$(cd /tmp && composer create-project laravel/laravel _key_gen --quiet --no-install \ + && cd _key_gen && composer install --quiet \ + && php artisan key:generate --show) + +# 2. Generate Passport keys +TMP_KEYS=$(mktemp -d) +openssl genrsa -out "$TMP_KEYS/oauth-private.key" 4096 +openssl rsa -in "$TMP_KEYS/oauth-private.key" -pubout -out "$TMP_KEYS/oauth-public.key" + +# 3. Gather production secrets. Read each one and store in shell vars. +DB_PASS=$(openssl rand -base64 32 | tr -d '/=+' | cut -c1-32) +read -sp "STRIPE_KEY (pk_live_...): " STRIPE_KEY; echo +read -sp "STRIPE_SECRET (sk_live_...): " STRIPE_SECRET; echo +read -sp "STRIPE_WEBHOOK_SECRET (whsec_...): " STRIPE_WEBHOOK; echo +read -sp "PAYPAL_CLIENT_ID: " PAYPAL_ID; echo +read -sp "PAYPAL_CLIENT_SECRET: " PAYPAL_SECRET; echo +read -sp "AWS_ACCESS_KEY_ID (Storj): " AWS_KEY; echo +read -sp "AWS_SECRET_ACCESS_KEY (Storj): " AWS_SECRET; echo +read -sp "MAIL_USERNAME (SMTP): " MAIL_USER; echo +read -sp "MAIL_PASSWORD (SMTP): " MAIL_PASS; echo +read -sp "VIRTFUSION_API_KEY: " VF_KEY; echo +read -sp "PTERODACTYL_APP_KEY: " PTERO_KEY; echo + +# 4. Create the Secret in the ezscale namespace +kubectl create secret generic ezscale-website-secrets \ + --namespace ezscale \ + --from-literal=APP_KEY="$APP_KEY" \ + --from-literal=DB_PASSWORD="$DB_PASS" \ + --from-literal=STRIPE_KEY="$STRIPE_KEY" \ + --from-literal=STRIPE_SECRET="$STRIPE_SECRET" \ + --from-literal=STRIPE_WEBHOOK_SECRET="$STRIPE_WEBHOOK" \ + --from-literal=PAYPAL_CLIENT_ID="$PAYPAL_ID" \ + --from-literal=PAYPAL_CLIENT_SECRET="$PAYPAL_SECRET" \ + --from-literal=AWS_ACCESS_KEY_ID="$AWS_KEY" \ + --from-literal=AWS_SECRET_ACCESS_KEY="$AWS_SECRET" \ + --from-literal=MAIL_USERNAME="$MAIL_USER" \ + --from-literal=MAIL_PASSWORD="$MAIL_PASS" \ + --from-literal=VIRTFUSION_API_KEY="$VF_KEY" \ + --from-literal=PTERODACTYL_APP_KEY="$PTERO_KEY" \ + --from-file=oauth-private.key="$TMP_KEYS/oauth-private.key" \ + --from-file=oauth-public.key="$TMP_KEYS/oauth-public.key" + +# 5. Wipe the local key copies +rm -rf /tmp/_key_gen "$TMP_KEYS" +``` + +After this, `helm install` and every subsequent `helm upgrade` reference the existing Secret without ever rewriting it. + +## Deploy + +```bash +helm upgrade --install ezscale-website ./helm/ezscale-website \ + --namespace ezscale \ + -f helm/ezscale-website/values-us-prod.yaml \ + --set image.tag=v0.1.0 +``` + +## Local dev cluster + +```bash +# 1. Create cluster with Longhorn (or local-path) and the operator +k3d cluster create ezscale-local +helm repo add mariadb-operator https://helm.mariadb.com/mariadb-operator +helm install mariadb-operator -n mariadb-operator --create-namespace mariadb-operator/mariadb-operator + +# 2. Build the image into the cluster +docker build --target app -t ezscale-website:app-local . +docker build --target horizon -t ezscale-website:horizon-local . +docker build --target scheduler -t ezscale-website:scheduler-local . +k3d image import ezscale-website:app-local ezscale-website:horizon-local ezscale-website:scheduler-local -c ezscale-local + +# 3. Edit values-local.yaml — replace APP_KEY with `php artisan key:generate --show` output + +# 4. Install +helm upgrade --install ezscale-website ./helm/ezscale-website \ + --namespace ezscale --create-namespace \ + -f helm/ezscale-website/values-local.yaml \ + --set image.registry=docker.io \ + --set image.repository=library/ezscale-website \ + --set image.tag=local + +# 5. Port-forward and visit http://localhost:8080 +kubectl port-forward -n ezscale svc/ezscale-website 8080:80 +``` + +## Operations + +```bash +# Tail Horizon logs +kubectl logs -n ezscale -l app.kubernetes.io/component=horizon -f + +# Run an artisan command in a one-off pod +kubectl run -n ezscale --rm -it --image=git.ezscale.cloud/ezscale/website:app-v0.1.0 \ + --env-from='[{"configMapRef":{"name":"ezscale-website-env"}},{"secretRef":{"name":"ezscale-website-secrets"}}]' \ + artisan-shell -- bash + +# Force a Horizon rolling restart +kubectl rollout restart deployment -n ezscale ezscale-website-horizon +``` + +## What's NOT in this chart (intentionally) + +- Cloudflare Zero Trust for the admin panel — layered on later. +- ExternalDNS records — DNS managed manually in Cloudflare Terraform. +- Backup CronJob for the application database — covered by the existing `mariadb` instance's backup CronJob in `infrastructure/kubernetes/ezscale/mysql/`. +- Backups for Storj uploads — Storj provides its own replication; per-bucket lifecycle policies live in the Storj console. +``` + +- [ ] **Step 2: Commit** + +```bash +git add helm/ezscale-website/README.md +git commit -m "docs(helm): chart README + APP_KEY/Passport bootstrap procedure + +Spells out the one-time secret generation that must NEVER be re-run. +Documents local k3d setup and operations runbooks. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 18: Gitea Actions release workflow + +**Files:** +- Create: `.gitea/workflows/release.yml` + +- [ ] **Step 1: Create `.gitea/workflows/release.yml`** + +```yaml +name: Release + +on: + push: + tags: + - 'v*' + +env: + REGISTRY: git.ezscale.cloud + IMAGE_BASE: ezscale/website + +jobs: + build-release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Get version from tag + id: version + run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Gitea registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.CI_TOKEN }} + + - name: Build and push app image + uses: docker/build-push-action@v5 + with: + context: . + target: app + push: true + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:cache-app + cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:cache-app,mode=max + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:app-${{ steps.version.outputs.VERSION }} + ${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:app-latest + + - name: Build and push horizon image + uses: docker/build-push-action@v5 + with: + context: . + target: horizon + push: true + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:cache-horizon + cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:cache-horizon,mode=max + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:horizon-${{ steps.version.outputs.VERSION }} + ${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:horizon-latest + + - name: Build and push scheduler image + uses: docker/build-push-action@v5 + with: + context: . + target: scheduler + push: true + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:cache-scheduler + cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:cache-scheduler,mode=max + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:scheduler-${{ steps.version.outputs.VERSION }} + ${{ env.REGISTRY }}/${{ env.IMAGE_BASE }}:scheduler-latest + + deploy-us: + runs-on: ubuntu-latest + needs: build-release + steps: + - uses: actions/checkout@v4 + + - name: Get version from tag + id: version + run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + + - name: Install helm + uses: azure/setup-helm@v4 + with: + version: v3.15.0 + + - name: Configure kubeconfig + run: | + mkdir -p ~/.kube + echo "${{ secrets.KUBECONFIG_US_PROD }}" | base64 -d > ~/.kube/config + chmod 600 ~/.kube/config + + - name: Deploy to us-prod + run: | + helm upgrade --install ezscale-website ./helm/ezscale-website \ + --namespace ezscale \ + -f helm/ezscale-website/values-us-prod.yaml \ + --set image.tag=${{ steps.version.outputs.VERSION }} \ + --wait \ + --timeout 10m +``` + +**Note:** Requires two repository secrets in Gitea: +- `CI_TOKEN` — Gitea token with `write:package` scope (for image push) +- `KUBECONFIG_US_PROD` — base64-encoded kubeconfig with permission to deploy into the `ezscale` namespace + +- [ ] **Step 2: Validate YAML syntax** + +```bash +python3 -c "import yaml; yaml.safe_load(open('.gitea/workflows/release.yml'))" +``` + +Expected: no output (YAML is valid). + +- [ ] **Step 3: Commit** + +```bash +git add .gitea/workflows/release.yml +git commit -m "feat(ci): Gitea Actions release workflow on v* tags + +Builds and pushes three images per tag, then runs helm upgrade +against us-prod. Cache-from/cache-to layers reuse buildx cache +across runs. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 19: Local end-to-end smoke test + +**Files:** None created — verification only. + +**Context:** Spin up a k3d cluster, install the operator, install the chart, verify pods become Ready and the app responds. + +- [ ] **Step 1: Create k3d cluster** + +```bash +k3d cluster create ezscale-local --wait +kubectl cluster-info +``` + +Expected: cluster reachable. + +- [ ] **Step 2: Install mariadb-operator** + +```bash +helm repo add mariadb-operator https://helm.mariadb.com/mariadb-operator +helm repo update +helm install mariadb-operator -n mariadb-operator --create-namespace mariadb-operator/mariadb-operator --wait +``` + +Expected: operator pods Ready. + +- [ ] **Step 3: Build & import images** + +```bash +cd /home/andrew/local_projects/website +docker build --target app -t ezscale-website:app-local . +docker build --target horizon -t ezscale-website:horizon-local . +docker build --target scheduler -t ezscale-website:scheduler-local . +k3d image import ezscale-website:app-local ezscale-website:horizon-local ezscale-website:scheduler-local -c ezscale-local +``` + +Expected: three images imported. + +- [ ] **Step 4: Generate APP_KEY and patch values-local.yaml** + +```bash +# Generate an APP_KEY without depending on website/vendor existing. +# Format matches `php artisan key:generate --show`. +APP_KEY="base64:$(openssl rand -base64 32)" +sed -i "s|base64:CHANGEME_GENERATE_VIA_PHP_ARTISAN_KEY_GENERATE_SHOW|$APP_KEY|" helm/ezscale-website/values-local.yaml +``` + +Don't commit the patched values file. Reset it after the test: + +```bash +git diff helm/ezscale-website/values-local.yaml | head +``` + +- [ ] **Step 5: Install the chart** + +```bash +helm upgrade --install ezscale-website ./helm/ezscale-website \ + --namespace ezscale --create-namespace \ + -f helm/ezscale-website/values-local.yaml \ + --set image.registry=docker.io \ + --set image.repository=library/ezscale-website \ + --set image.tag=local \ + --wait --timeout 10m +``` + +Expected: install completes, no failed Job, pods rolling. + +- [ ] **Step 6: Verify pods are Ready** + +```bash +kubectl get pods -n ezscale +``` + +Expected: app, horizon, scheduler, mariadb (1 replica), valkey-0 — all `Running` and `1/1` Ready (app is `2/2` Ready). The migration Job should be `Completed`. + +- [ ] **Step 7: Smoke-test the app** + +```bash +kubectl port-forward -n ezscale svc/ezscale-website 8080:80 & +PF_PID=$! +sleep 3 +curl -fsS http://localhost:8080/up +echo +curl -fsS -o /dev/null -w '%{http_code}\n' http://localhost:8080/ +kill $PF_PID +``` + +Expected: `/up` returns 200 (Laravel health endpoint), `/` returns 200 or 302 (redirect to login). + +- [ ] **Step 8: Reset values-local.yaml and tear down** + +```bash +git checkout -- helm/ezscale-website/values-local.yaml +helm uninstall ezscale-website -n ezscale +k3d cluster delete ezscale-local +``` + +- [ ] **Step 9: No commit** + +This task is verification-only. If anything broke, return to the failing task and fix. + +--- + +## Final review + +After all tasks above are complete: + +- [ ] `helm lint helm/ezscale-website` — clean +- [ ] `helm template helm/ezscale-website -f helm/ezscale-website/values-local.yaml` — renders without error +- [ ] `helm template helm/ezscale-website -f helm/ezscale-website/values-us-prod.yaml --set image.tag=v0.1.0` — renders without error +- [ ] `docker build --target app .` — succeeds +- [ ] Local k3d e2e (Task 19) — green +- [ ] README is accurate (counts, status, repo URL) +- [ ] No top-level outdated docs left + +When all green: **first production tag**: + +```bash +git tag v0.1.0 +git push origin v0.1.0 +``` + +The Gitea workflow builds + pushes + deploys. Watch via `kubectl rollout status -n ezscale deploy/ezscale-website-app`.