From 9c49e7965758dfd40b5bb00579cf94c824e2305e Mon Sep 17 00:00:00 2001 From: Ketan Patel Date: Thu, 16 Apr 2026 18:38:13 -0400 Subject: [PATCH] Add melange/apko build pipeline and Kubernetes manifests - Add melange.yaml for distroless package build - Add apko.yaml for minimal container image - Add GitHub Actions workflow for melange/apko CI build - Add Kubernetes manifests (deployment, service, configmap, secret, redis) - Update Dockerfile, .gitignore, and docker-compose.yml --- .github/workflows/docker-image.yml | 43 +++++++++++++++++- .gitignore | 7 ++- Dockerfile | 2 + apko.yaml | 31 +++++++++++++ docker-compose.yml | 5 +-- k8s/configmap.yaml | 17 +++++++ k8s/deployment.yaml | 71 +++++++++++++++++++++++++++++ k8s/kustomization.yaml | 11 +++++ k8s/namespace.yaml | 6 +++ k8s/redis-service.yaml | 17 +++++++ k8s/redis-statefulset.yaml | 72 ++++++++++++++++++++++++++++++ k8s/secret.yaml | 14 ++++++ k8s/service.yaml | 17 +++++++ melange.yaml | 61 +++++++++++++++++++++++++ requirements.prod.txt | 15 +++++++ 15 files changed, 382 insertions(+), 7 deletions(-) create mode 100644 apko.yaml create mode 100644 k8s/configmap.yaml create mode 100644 k8s/deployment.yaml create mode 100644 k8s/kustomization.yaml create mode 100644 k8s/namespace.yaml create mode 100644 k8s/redis-service.yaml create mode 100644 k8s/redis-statefulset.yaml create mode 100644 k8s/secret.yaml create mode 100644 k8s/service.yaml create mode 100644 melange.yaml create mode 100644 requirements.prod.txt diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 4d7ee5f..61dca92 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -36,7 +36,46 @@ jobs: coverage run -m pytest tests/ -v coverage report - build: + build-melange-apko: + runs-on: ubuntu-latest + needs: lint-and-test + if: github.event_name != 'pull_request' + permissions: + contents: read + packages: write + id-token: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Generate melange signing keypair + uses: chainguard-dev/actions/melange-keygen@main + with: + signing-key-path: melange.rsa + + - name: Build package with melange + uses: chainguard-dev/actions/melange-build@main + with: + config: melange.yaml + signing-key-path: melange.rsa + archs: x86_64,aarch64 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish image with apko + uses: chainguard-images/actions/apko-publish@main + with: + config: apko.yaml + archs: x86_64,aarch64 + tag: ghcr.io/k2patel/apcupsd-client:latest + keyring-append: melange.rsa.pub + + build-docker: runs-on: ubuntu-latest needs: lint-and-test if: github.event_name != 'pull_request' @@ -60,4 +99,4 @@ jobs: docker build . -t ${{ env.IMAGE_NAME }} - name: Push the Docker image - run: docker push ${{ env.IMAGE_NAME }} \ No newline at end of file + run: docker push ${{ env.IMAGE_NAME }} diff --git a/.gitignore b/.gitignore index b65e1d4..1d18d15 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,9 @@ Thumbs.db # Temporary files *.tmp -*.temp \ No newline at end of file +*.temp + +# Melange / apko build artifacts +packages/ +melange.rsa +melange.rsa.pub \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index c831866..b7e6230 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,6 @@ # syntax=docker/dockerfile:1.6 +# NOTE: The primary build method is now melange + apko (see melange.yaml / apko.yaml). +# This Dockerfile is kept for local dev and Docker Compose backward compatibility. FROM python:3.12.7-slim AS builder ENV PYTHONDONTWRITEBYTECODE=1 \ diff --git a/apko.yaml b/apko.yaml new file mode 100644 index 0000000..3265158 --- /dev/null +++ b/apko.yaml @@ -0,0 +1,31 @@ +contents: + repositories: + - https://dl-cdn.alpinelinux.org/alpine/edge/main + - https://dl-cdn.alpinelinux.org/alpine/edge/community + - '@local ./packages' + packages: + - alpine-baselayout + - busybox + - python3 + - apcupsd + - apcupsd-client@local + +accounts: + groups: + - groupname: appuser + gid: 10001 + users: + - username: appuser + uid: 10001 + gid: 10001 + run-as: 10001 + +environment: + PYTHONDONTWRITEBYTECODE: "1" + PYTHONUNBUFFERED: "1" + REDIS_URL: "redis://redis:6379/0" + +entrypoint: + command: /usr/bin/start-server + +work-dir: /app diff --git a/docker-compose.yml b/docker-compose.yml index 8862aab..abbcecc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,6 @@ -version: '3.9' services: web: - build: . + image: docker.io/k2patel/apcupsd-client:latest container_name: apcups-dashboard ports: - "10280:8000" @@ -22,8 +21,6 @@ services: image: redis:7-alpine container_name: redis restart: always - # Uncomment to require password (must also set REDIS_URL=redis://:PASS@redis:6379/0): - # command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec", "--requirepass", "${REDIS_PASSWORD}"] command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec"] healthcheck: test: ["CMD", "redis-cli", "ping"] diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml new file mode 100644 index 0000000..c2324c3 --- /dev/null +++ b/k8s/configmap.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: apcupsd-client-config + namespace: apcupsd + labels: + app.kubernetes.io/name: apcupsd-client + app.kubernetes.io/part-of: apcupsd-client +data: + REDIS_URL: "redis://redis:6379/0" + LOG_LEVEL: "info" + TZ: "UTC" + ALLOW_PRIVATE_IPS: "true" + TRUST_PROXY: "true" + RATE_LIMIT_ENABLED: "true" + ADMIN_USERNAME: "admin" + SESSION_MAX_AGE_SECONDS: "86400" diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml new file mode 100644 index 0000000..ee31917 --- /dev/null +++ b/k8s/deployment.yaml @@ -0,0 +1,71 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: apcupsd-client + namespace: apcupsd + labels: + app.kubernetes.io/name: apcupsd-client + app.kubernetes.io/part-of: apcupsd-client +spec: + replicas: 1 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + selector: + matchLabels: + app.kubernetes.io/name: apcupsd-client + template: + metadata: + labels: + app.kubernetes.io/name: apcupsd-client + spec: + securityContext: + runAsUser: 10001 + runAsGroup: 10001 + runAsNonRoot: true + fsGroup: 10001 + containers: + - name: web + image: ghcr.io/k2patel/apcupsd-client:latest + ports: + - containerPort: 8000 + protocol: TCP + envFrom: + - configMapRef: + name: apcupsd-client-config + - secretRef: + name: apcupsd-client-secret + livenessProbe: + httpGet: + path: /healthz + port: 8000 + initialDelaySeconds: 15 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /readyz + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "256Mi" + cpu: "500m" + securityContext: + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml new file mode 100644 index 0000000..1552ade --- /dev/null +++ b/k8s/kustomization.yaml @@ -0,0 +1,11 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - namespace.yaml + - configmap.yaml + - secret.yaml + - redis-statefulset.yaml + - redis-service.yaml + - deployment.yaml + - service.yaml diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml new file mode 100644 index 0000000..12d715c --- /dev/null +++ b/k8s/namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: apcupsd + labels: + app.kubernetes.io/part-of: apcupsd-client diff --git a/k8s/redis-service.yaml b/k8s/redis-service.yaml new file mode 100644 index 0000000..8117ca6 --- /dev/null +++ b/k8s/redis-service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: redis + namespace: apcupsd + labels: + app.kubernetes.io/name: redis + app.kubernetes.io/part-of: apcupsd-client +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: redis + ports: + - name: redis + protocol: TCP + port: 6379 + targetPort: 6379 diff --git a/k8s/redis-statefulset.yaml b/k8s/redis-statefulset.yaml new file mode 100644 index 0000000..7008a0e --- /dev/null +++ b/k8s/redis-statefulset.yaml @@ -0,0 +1,72 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: redis + namespace: apcupsd + labels: + app.kubernetes.io/name: redis + app.kubernetes.io/part-of: apcupsd-client +spec: + serviceName: redis + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: redis + template: + metadata: + labels: + app.kubernetes.io/name: redis + spec: + securityContext: + runAsUser: 999 + runAsGroup: 999 + fsGroup: 999 + containers: + - name: redis + image: redis:7-alpine + command: + - redis-server + - --appendonly + - "yes" + - --appendfsync + - everysec + ports: + - containerPort: 6379 + protocol: TCP + livenessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 5 + readinessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "128Mi" + cpu: "250m" + volumeMounts: + - name: redis-data + mountPath: /data + volumeClaimTemplates: + - metadata: + name: redis-data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi diff --git a/k8s/secret.yaml b/k8s/secret.yaml new file mode 100644 index 0000000..8a39968 --- /dev/null +++ b/k8s/secret.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Secret +metadata: + name: apcupsd-client-secret + namespace: apcupsd + labels: + app.kubernetes.io/name: apcupsd-client + app.kubernetes.io/part-of: apcupsd-client +type: Opaque +stringData: + SESSION_SECRET: "CHANGE_ME_TO_A_RANDOM_SECRET" + # Optional — uncomment and set as needed: + # SMTP_PASSWORD: "" + # ADMIN_PASSWORD_HASH: "" diff --git a/k8s/service.yaml b/k8s/service.yaml new file mode 100644 index 0000000..f1c9b3a --- /dev/null +++ b/k8s/service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: apcupsd-client + namespace: apcupsd + labels: + app.kubernetes.io/name: apcupsd-client + app.kubernetes.io/part-of: apcupsd-client +spec: + type: LoadBalancer + selector: + app.kubernetes.io/name: apcupsd-client + ports: + - name: http + protocol: TCP + port: 10280 + targetPort: 8000 diff --git a/melange.yaml b/melange.yaml new file mode 100644 index 0000000..5a222d5 --- /dev/null +++ b/melange.yaml @@ -0,0 +1,61 @@ +package: + name: apcupsd-client + version: 1.0.0 + epoch: 0 + description: FastAPI + Redis UPS monitoring dashboard + copyright: + - license: MIT + +environment: + contents: + repositories: + - https://dl-cdn.alpinelinux.org/alpine/edge/main + - https://dl-cdn.alpinelinux.org/alpine/edge/community + packages: + - alpine-baselayout + - busybox + - python3 + - python3-dev + - py3-pip + - py3-virtualenv + - gcc + - musl-dev + - libffi-dev + +pipeline: + - name: Create virtualenv and install dependencies + runs: | + set -ex + mkdir -p "${{targets.destdir}}/app" + python3 -m virtualenv "${{targets.destdir}}/app/.venv" + "${{targets.destdir}}/app/.venv/bin/pip" install \ + --no-cache-dir \ + -r requirements.prod.txt + + - name: Copy application source + runs: | + set -ex + cp -r app/ "${{targets.destdir}}/app/app/" + + - name: Create entrypoint script + runs: | + set -ex + mkdir -p "${{targets.destdir}}/usr/bin" + cat > "${{targets.destdir}}/usr/bin/start-server" <<'SCRIPT' + #!/bin/sh + exec /app/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 + SCRIPT + chmod +x "${{targets.destdir}}/usr/bin/start-server" + + - name: Fix virtualenv shebangs + runs: | + set -ex + # Rewrite shebangs so they reference the final installed path + find "${{targets.destdir}}/app/.venv/bin" -type f -exec \ + sed -i "s|${{targets.destdir}}||g" {} + + +data: + - name: runtime-deps + items: + python3: python3 + apcupsd: apcupsd diff --git a/requirements.prod.txt b/requirements.prod.txt new file mode 100644 index 0000000..7a15f37 --- /dev/null +++ b/requirements.prod.txt @@ -0,0 +1,15 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +pyyaml==6.0.2 +redis==5.0.7 +pydantic==2.9.2 +pydantic-settings==2.5.2 +jinja2==3.1.4 +python-multipart==0.0.9 +orjson==3.10.7 +passlib[argon2]==1.7.4 +itsdangerous==2.2.0 +slowapi==0.1.9 +python-json-logger==2.0.7 +tenacity==9.0.0 +prometheus-client==0.21.0