From 5e6fc799213c45f0d52e7a8b31d61e1b7bd04d4a Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 12 Feb 2026 11:03:33 -0800 Subject: [PATCH] Add CV/resume web app at cv.ops.eblu.me Container (nginx:alpine), k8s manifests, ArgoCD app, Caddy route, and deploy workflow. Content is built and released from the separate cv repo (forge.ops.eblu.me/eblume/cv). Also removes unnecessary sh -c wrapper around tar in build_docs Dagger function. Co-Authored-By: Claude Opus 4.6 --- .dagger/src/blumeops_ci/main.py | 9 +- .forgejo/workflows/cv-deploy.yaml | 123 +++++++++++++++++++++ ansible/roles/caddy/defaults/main.yml | 3 + argocd/apps/cv.yaml | 18 +++ argocd/manifests/cv/deployment.yaml | 43 +++++++ argocd/manifests/cv/ingress-tailscale.yaml | 27 +++++ argocd/manifests/cv/kustomization.yaml | 8 ++ argocd/manifests/cv/service.yaml | 13 +++ containers/cv/Dockerfile | 21 ++++ containers/cv/default.conf | 33 ++++++ containers/cv/start.sh | 31 ++++++ docs/changelog.d/cv-web-app.feature.md | 1 + 12 files changed, 327 insertions(+), 3 deletions(-) create mode 100644 .forgejo/workflows/cv-deploy.yaml create mode 100644 argocd/apps/cv.yaml create mode 100644 argocd/manifests/cv/deployment.yaml create mode 100644 argocd/manifests/cv/ingress-tailscale.yaml create mode 100644 argocd/manifests/cv/kustomization.yaml create mode 100644 argocd/manifests/cv/service.yaml create mode 100644 containers/cv/Dockerfile create mode 100644 containers/cv/default.conf create mode 100644 containers/cv/start.sh create mode 100644 docs/changelog.d/cv-web-app.feature.md diff --git a/.dagger/src/blumeops_ci/main.py b/.dagger/src/blumeops_ci/main.py index 2b780ac..b0b503f 100644 --- a/.dagger/src/blumeops_ci/main.py +++ b/.dagger/src/blumeops_ci/main.py @@ -78,9 +78,12 @@ class BlumeopsCi: .with_exec(["npx", "quartz", "build", "-d", "docs"]) .with_exec( [ - "sh", - "-c", - f"tar -czf /docs-{version}.tar.gz -C public .", + "tar", + "-czf", + f"/docs-{version}.tar.gz", + "-C", + "public", + ".", ] ) .file(f"/docs-{version}.tar.gz") diff --git a/.forgejo/workflows/cv-deploy.yaml b/.forgejo/workflows/cv-deploy.yaml new file mode 100644 index 0000000..553185c --- /dev/null +++ b/.forgejo/workflows/cv-deploy.yaml @@ -0,0 +1,123 @@ +# CV Deploy Workflow +# +# Updates the CV deployment to a specific package version, commits +# the change, and syncs via ArgoCD. +# +# Usage: +# 1. Release a new CV package from the cv repo first +# 2. Go to Actions > Deploy CV > Run workflow +# 3. Enter the version to deploy, or leave as "latest" + +name: Deploy CV + +on: + workflow_dispatch: + inputs: + version: + description: 'CV package version to deploy (e.g., v1.0.0, or "latest")' + required: true + default: 'latest' + type: string + +jobs: + deploy: + runs-on: k8s + steps: + - name: Resolve version + id: version + run: | + INPUT_VERSION="${{ inputs.version }}" + + if [ "$INPUT_VERSION" = "latest" ]; then + echo "Resolving latest CV package version..." + VERSION=$(curl -s "https://forge.ops.eblu.me/api/v1/packages/eblume?type=generic&q=cv" \ + | jq -r '[.[] | select(.name == "cv")] | sort_by(.version) | last | .version // empty') + + if [ -z "$VERSION" ]; then + echo "Error: No CV packages found" + exit 1 + fi + echo "Resolved latest version: $VERSION" + else + VERSION="$INPUT_VERSION" + if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Version must be in format vX.Y.Z (e.g., v1.0.0)" + exit 1 + fi + fi + + # Verify the package exists + TARBALL="cv-${VERSION}.tar.gz" + PACKAGE_URL="https://forge.ops.eblu.me/api/packages/eblume/generic/cv/${VERSION}/${TARBALL}" + if ! curl -fsSL --head "$PACKAGE_URL" > /dev/null 2>&1; then + echo "Error: Package not found at $PACKAGE_URL" + echo "Run the 'Release CV' workflow in the cv repo first." + exit 1 + fi + echo "Package verified: $PACKAGE_URL" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Checkout + uses: actions/checkout@v4 + + - name: Update CV deployment + run: | + VERSION="${{ steps.version.outputs.version }}" + TARBALL="cv-${VERSION}.tar.gz" + DEPLOYMENT_FILE="argocd/manifests/cv/deployment.yaml" + RELEASE_URL="https://forge.ops.eblu.me/api/packages/eblume/generic/cv/${VERSION}/${TARBALL}" + + echo "Updating $DEPLOYMENT_FILE with CV_RELEASE_URL..." + sed -i "s|value: \"https://forge.ops.eblu.me/api/packages/eblume/generic/cv/[^\"]*\"|value: \"${RELEASE_URL}\"|" "$DEPLOYMENT_FILE" + + echo "Updated deployment:" + grep -A1 "CV_RELEASE_URL" "$DEPLOYMENT_FILE" + + - name: Commit release changes + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${{ steps.version.outputs.version }}" + + git config user.name "Forgejo Actions" + git config user.email "actions@forge.ops.eblu.me" + + git add argocd/manifests/cv/deployment.yaml + + if git diff --cached --quiet; then + echo "No changes to commit (already at $VERSION)" + else + git commit -m "Update CV release to $VERSION + + [skip ci]" + git push origin HEAD:main + echo "Changes committed and pushed" + fi + + - name: Deploy CV + env: + ARGOCD_AUTH_TOKEN: ${{ secrets.ARGOCD_AUTH_TOKEN }} + run: | + echo "Syncing CV app via ArgoCD..." + + argocd app sync cv \ + --server argocd.ops.eblu.me \ + --grpc-web \ + --prune + + argocd app wait cv \ + --server argocd.ops.eblu.me \ + --grpc-web \ + --timeout 120 + + echo "CV app synced successfully!" + + - name: Summary + run: | + VERSION="${{ steps.version.outputs.version }}" + echo "================================================" + echo "CV Deployed: $VERSION" + echo "================================================" + echo "" + echo "CV should now be live at:" + echo " https://cv.ops.eblu.me/" diff --git a/ansible/roles/caddy/defaults/main.yml b/ansible/roles/caddy/defaults/main.yml index 5d88fbf..15bdd8a 100644 --- a/ansible/roles/caddy/defaults/main.yml +++ b/ansible/roles/caddy/defaults/main.yml @@ -73,6 +73,9 @@ caddy_services: - name: docs host: "docs.{{ caddy_domain }}" backend: "https://docs.tail8d86e.ts.net" + - name: cv + host: "cv.{{ caddy_domain }}" + backend: "https://cv.tail8d86e.ts.net" - name: sifaka host: "nas.{{ caddy_domain }}" backend: "http://sifaka:5000" diff --git a/argocd/apps/cv.yaml b/argocd/apps/cv.yaml new file mode 100644 index 0000000..ad09a8d --- /dev/null +++ b/argocd/apps/cv.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: cv + namespace: argocd +spec: + project: default + source: + repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + targetRevision: main + path: argocd/manifests/cv + destination: + server: https://kubernetes.default.svc + namespace: cv + syncPolicy: + syncOptions: + - CreateNamespace=true diff --git a/argocd/manifests/cv/deployment.yaml b/argocd/manifests/cv/deployment.yaml new file mode 100644 index 0000000..0a68f11 --- /dev/null +++ b/argocd/manifests/cv/deployment.yaml @@ -0,0 +1,43 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: cv + namespace: cv +spec: + replicas: 1 + selector: + matchLabels: + app: cv + template: + metadata: + labels: + app: cv + spec: + containers: + - name: cv + image: registry.ops.eblu.me/blumeops/cv:v1.0.0 + ports: + - containerPort: 80 + name: http + env: + - name: CV_RELEASE_URL + value: "https://forge.ops.eblu.me/api/packages/eblume/generic/cv/v0.1.0/cv-v0.1.0.tar.gz" + resources: + requests: + memory: "64Mi" + cpu: "10m" + limits: + memory: "128Mi" + livenessProbe: + httpGet: + path: /healthz + port: 80 + initialDelaySeconds: 10 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /healthz + port: 80 + initialDelaySeconds: 5 + periodSeconds: 10 diff --git a/argocd/manifests/cv/ingress-tailscale.yaml b/argocd/manifests/cv/ingress-tailscale.yaml new file mode 100644 index 0000000..90158be --- /dev/null +++ b/argocd/manifests/cv/ingress-tailscale.yaml @@ -0,0 +1,27 @@ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: cv-tailscale + namespace: cv + annotations: + tailscale.com/proxy-class: "default" + tailscale.com/proxy-group: "ingress" + tailscale.com/tags: "tag:k8s" + gethomepage.dev/enabled: "true" + gethomepage.dev/name: "CV" + gethomepage.dev/group: "Apps" + gethomepage.dev/icon: "mdi-file-document" + gethomepage.dev/description: "Resume / CV" + gethomepage.dev/href: "https://cv.ops.eblu.me" + gethomepage.dev/pod-selector: "app=cv" +spec: + ingressClassName: tailscale + defaultBackend: + service: + name: cv + port: + number: 80 + tls: + - hosts: + - cv diff --git a/argocd/manifests/cv/kustomization.yaml b/argocd/manifests/cv/kustomization.yaml new file mode 100644 index 0000000..43c806c --- /dev/null +++ b/argocd/manifests/cv/kustomization.yaml @@ -0,0 +1,8 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: cv +resources: + - deployment.yaml + - service.yaml + - ingress-tailscale.yaml diff --git a/argocd/manifests/cv/service.yaml b/argocd/manifests/cv/service.yaml new file mode 100644 index 0000000..23e0e94 --- /dev/null +++ b/argocd/manifests/cv/service.yaml @@ -0,0 +1,13 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: cv + namespace: cv +spec: + selector: + app: cv + ports: + - name: http + port: 80 + targetPort: 80 diff --git a/containers/cv/Dockerfile b/containers/cv/Dockerfile new file mode 100644 index 0000000..a36f8bc --- /dev/null +++ b/containers/cv/Dockerfile @@ -0,0 +1,21 @@ +# CV/Resume Static Site Server +# Downloads and serves a CV site tarball (HTML+CSS+PDF) via nginx +# +# Configuration (via environment): +# CV_RELEASE_URL - URL to download the CV content tarball +# +# The container downloads the tarball on startup, extracts it, and serves with nginx. + +FROM nginx:alpine + +# Install curl for downloading release assets +RUN apk add --no-cache curl + +# Copy startup script and nginx config +COPY start.sh /start.sh +COPY default.conf /etc/nginx/conf.d/default.conf +RUN chmod +x /start.sh + +EXPOSE 80 + +CMD ["/start.sh"] diff --git a/containers/cv/default.conf b/containers/cv/default.conf new file mode 100644 index 0000000..7c89b08 --- /dev/null +++ b/containers/cv/default.conf @@ -0,0 +1,33 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Enable gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript; + + # Cache static assets + location ~* \.(css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Force PDF download + location = /resume.pdf { + add_header Content-Disposition 'attachment; filename="erich-blume-resume.pdf"'; + } + + # Serve files directly + location / { + try_files $uri $uri/ =404; + } + + # Health check endpoint + location /healthz { + access_log off; + return 200 "ok\n"; + add_header Content-Type text/plain; + } +} diff --git a/containers/cv/start.sh b/containers/cv/start.sh new file mode 100644 index 0000000..bb81c20 --- /dev/null +++ b/containers/cv/start.sh @@ -0,0 +1,31 @@ +#!/bin/sh +set -e + +HTML_DIR="/usr/share/nginx/html" + +# Check for required environment variable +if [ -z "$CV_RELEASE_URL" ]; then + echo "Error: CV_RELEASE_URL environment variable is required" + echo "Set it to the URL of the CV content tarball to serve" + exit 1 +fi + +echo "Downloading CV content from: $CV_RELEASE_URL" + +# Download the tarball +if ! curl -fsSL "$CV_RELEASE_URL" -o /tmp/cv.tar.gz; then + echo "Error: Failed to download CV content from $CV_RELEASE_URL" + exit 1 +fi + +# Clear existing content and extract +rm -rf "${HTML_DIR:?}"/* +echo "Extracting CV content to $HTML_DIR" +tar -xzf /tmp/cv.tar.gz -C "$HTML_DIR" +rm /tmp/cv.tar.gz + +echo "CV content extracted successfully" +echo "Starting nginx..." + +# Start nginx in foreground +exec nginx -g "daemon off;" diff --git a/docs/changelog.d/cv-web-app.feature.md b/docs/changelog.d/cv-web-app.feature.md new file mode 100644 index 0000000..1eac6d5 --- /dev/null +++ b/docs/changelog.d/cv-web-app.feature.md @@ -0,0 +1 @@ +Add CV/resume web app at cv.ops.eblu.me — container, k8s manifests, Caddy route, and deploy workflow. Content built from separate cv repo.