From 904f1c43b3d3725acc6f4fc98443c6b9a3b36a0b Mon Sep 17 00:00:00 2001 From: Andris Enins Date: Mon, 18 May 2026 23:58:44 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20Raspberry=20Pi=20deployment=20=E2=80=94?= =?UTF-8?q?=20Dockerfile,=20CI/CD=20workflows,=20actuator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - backend/Dockerfile: multi-stage build (local + ci-image targets) - eclipse-temurin:21-jre-alpine runtime, non-root user caloriecounter - DOCKER_BUILDKIT=0 + native JAR build pattern for ARM64 Pi runner - pom.xml: add spring-boot-starter-actuator - application.yml: expose /actuator/health (liveness probe for Docker healthcheck) CI/CD: - .gitea/workflows/ci.yml: Maven test on pi-runner (push + PR) - .gitea/workflows/docker.yml: build + push calorie-counter-api:latest/:sha - Triggers after CI passes (workflow_run) to avoid OOM on single-capacity Pi - Docker login before Maven build (avoids daemon timeout after heavy CPU) - Deploys immediately via docker.sock to /home/andris/homelab/calorie-counter/ Mobile: - api.ts: update baseURL default to https://calories.amlab.dev (LAN fallback: http://10.18.1.135:8085 via WireGuard) Homelab changes pushed directly via Gitea MCP: - homelab/calorie-counter/docker-compose.yml (port 8085, 120s start_period) - homelab/calorie-counter/.env.example - homelab/.gitea/workflows/deploy-calorie-counter.yml (cron every 10 min) - homelab/backup/backup.sh (added calorie-counter pg_dump) --- .gitea/workflows/ci.yml | 42 ++++++ .gitea/workflows/docker.yml | 168 +++++++++++++++++++++ backend/Dockerfile | 51 +++++++ backend/pom.xml | 6 + backend/src/main/resources/application.yml | 12 ++ mobile/src/services/api.ts | 5 +- 6 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitea/workflows/docker.yml create mode 100644 backend/Dockerfile diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..b9a9226 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,42 @@ +# Generated by GitHub Copilot +# +# ci.yml — Build and test the calorie-counter backend +# +# Runs on every push to main and every PR targeting main. +# Produces a green/red signal that docker.yml waits for before building images. +# +# Runs on pi-runner (native ARM64) — no QEMU, no cross-compilation overhead. + +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +# Cancel any in-progress CI run for the same ref so the single-capacity Pi +# runner is not blocked by a superseded run. +concurrency: + group: ci-${{ gitea.ref }} + cancel-in-progress: true + +jobs: + test: + name: Build & test backend + runs-on: pi-runner + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Java 21 LTS + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: maven + + - name: Run tests + working-directory: backend + run: mvn -q -ntp clean verify diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml new file mode 100644 index 0000000..3953f74 --- /dev/null +++ b/.gitea/workflows/docker.yml @@ -0,0 +1,168 @@ +# Generated by GitHub Copilot +# +# docker.yml — Build and push Docker image to Gitea Container Registry +# +# Triggers after CI passes on main (workflow_run) to prevent this heavyweight job +# from competing with the Maven+test job on the single-capacity Pi runner. +# +# Produces images tagged with the short SHA and 'latest'. +# Immediately deploys to the Pi production stack via docker.sock. +# +# Required Gitea repository secrets (Settings → Actions → Secrets): +# REGISTRY_USERNAME — Gitea username (andrisenins) +# REGISTRY_PASSWORD — Gitea access token (package:write + repo:write scope) + +name: Docker + +on: + # Only run after CI succeeds — prevents OOM kills on the Pi from parallel jobs. + workflow_run: + workflows: ["CI"] + types: [completed] + branches: [main] + workflow_dispatch: + +# Cancel any in-progress docker build for the same branch so stale BuildKit +# containers from the previous run are cleaned up before the new build starts. +concurrency: + group: docker-${{ gitea.event.workflow_run.head_branch || gitea.ref }} + cancel-in-progress: true + +env: + REGISTRY: git.amlab.dev + +jobs: + build-api: + name: Build & push API image + runs-on: pi-runner + # For workflow_run events: only build if CI succeeded. + # For workflow_dispatch (manual): always run. + if: > + github.event_name == 'workflow_dispatch' || + github.event.workflow_run.conclusion == 'success' + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # In a workflow_run context, checkout the commit that triggered CI. + ref: ${{ gitea.event.workflow_run.head_sha || gitea.sha }} + + - name: Log in to Gitea Container Registry + # Login BEFORE the Maven build while the Pi CPU is idle. + # When login is attempted after a long Maven build the host Docker daemon + # exhausts its 30s HTTP timeout on every attempt (confirmed in MYPHOTOS + # task logs — same issue applies here). + # NOTE: use 'if' not '&&' — act_runner uses 'bash -e', so 'cmd && break' + # exits the entire script on the first non-zero cmd result. + env: + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + run: | + for i in 1 2 3 4 5 6 7 8 9 10; do + if echo "$REGISTRY_PASSWORD" | docker login --username "$REGISTRY_USERNAME" --password-stdin "$REGISTRY"; then + break + fi + if [ "$i" -eq 10 ]; then + echo "ERROR: All 10 docker login attempts failed" + exit 1 + fi + echo "Login attempt $i failed, retrying in 30s..." + sleep 30 + done + + # Build JAR natively (inside the job container, outside BuildKit/QEMU). + # Without this, Maven runs under QEMU cross-emulation during the Docker build + # RUN step, causing TLS connections to Maven Central to break after ~20 min. + - name: Set up Java 21 LTS + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: maven + + - name: Build JAR (native — avoids QEMU Maven download) + working-directory: backend + run: mvn -q -ntp clean package -DskipTests + + - name: Build and push API image + # Use plain docker build+push (no buildx, no JS action) to avoid + # docker/build-push-action@v6 crashing silently in act_runner v0.5.0. + # DOCKER_BUILDKIT=0 uses legacy builder — no gRPC DeadlineExceeded on RPi. + env: + DOCKER_BUILDKIT: "0" + SHA: ${{ gitea.event.workflow_run.head_sha || gitea.sha }} + OWNER: ${{ gitea.repository_owner }} + run: | + SHORT_SHA="${SHA:0:7}" + IMAGE="${REGISTRY}/${OWNER}/calorie-counter-api" + docker build \ + --target ci-image \ + --build-arg JAR_FILE=target/calorie-counter-backend-1.0.0-SNAPSHOT.jar \ + -t "${IMAGE}:${SHORT_SHA}" \ + -t "${IMAGE}:latest" \ + backend/ + docker push "${IMAGE}:${SHORT_SHA}" + docker push "${IMAGE}:latest" + + deploy: + name: Deploy to Pi — pull & restart stack + runs-on: pi-runner + needs: [build-api] + if: > + github.event_name == 'workflow_dispatch' || + github.event.workflow_run.conclusion == 'success' + + steps: + - name: Pull and restart production stack + # Run docker compose against the Pi HOST's homelab directory. + # Volume paths in a `docker run` issued via the mounted socket are + # resolved against the Docker DAEMON's filesystem (the Pi host), not + # the job container's filesystem — standard DinD-via-socket behaviour. + # The .env file in that directory supplies all production secrets so + # no secrets need to be passed into this job. + env: + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + run: | + docker run --rm \ + -v /home/andris/homelab/calorie-counter:/project \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -e REGISTRY_PASSWORD \ + -e REGISTRY_USERNAME \ + -e "REGISTRY=${REGISTRY}" \ + -w /project \ + docker:27.5.1-cli \ + sh -c ' + echo "$REGISTRY_PASSWORD" | docker login \ + --username "$REGISTRY_USERNAME" \ + --password-stdin "$REGISTRY" && + docker compose pull && + docker compose up -d && + echo "=== Stack status ===" && + docker compose ps + ' + + # ── Build failure notification ─────────────────────────────────────────────── + notify-failure: + name: Notify — build failure + runs-on: ubuntu-latest + needs: [build-api, deploy] + # Only run when something failed; skip on success or cancellation + if: failure() + steps: + - name: Post commit comment on failure + env: + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + OWNER: ${{ gitea.repository_owner }} + SHA: ${{ gitea.event.workflow_run.head_sha || gitea.sha }} + RUN_URL: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }} + run: | + BODY="❌ **Docker build failed** for \`${SHA:0:7}\`\n\nSee the failed run: ${RUN_URL}" + curl -sf -X POST \ + "https://git.amlab.dev/api/v1/repos/${OWNER}/calorie-counter/commits/${SHA}/comments" \ + -H "Authorization: token ${REGISTRY_PASSWORD}" \ + -H "Content-Type: application/json" \ + -d "{\"body\": \"${BODY}\"}" \ + && echo "Failure comment posted" \ + || echo "Warning: could not post comment (non-fatal)" diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..fb19278 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,51 @@ +# Generated by GitHub Copilot +# +# Build stage — Java 21 LTS + Maven (used only for local dev via --target local). +# The CI workflow builds the JAR natively on the Pi runner before calling docker build +# to avoid QEMU's unreliable TLS stack causing Maven Central downloads to time out. +FROM eclipse-temurin:21-jdk-alpine AS build +WORKDIR /workspace + +# Download Maven via curl — avoids apk's OpenJDK pulling in a conflicting JDK version. +ENV MAVEN_VERSION=3.9.9 +RUN apk add --no-cache curl && \ + curl -fsSL https://archive.apache.org/dist/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz \ + | tar -xz -C /opt && \ + ln -s /opt/apache-maven-${MAVEN_VERSION} /opt/maven +ENV PATH="/opt/maven/bin:${PATH}" + +COPY pom.xml . +# Pre-fetch deps as a cached layer; rebuilds on source-only changes skip this step. +RUN mvn -q -ntp dependency:go-offline || true + +COPY src src +RUN mvn -q -ntp clean package -DskipTests + +# ── Runtime base — shared between local and ci-image targets ────────────────── +FROM eclipse-temurin:21-jre-alpine AS runtime-base +WORKDIR /app + +# Non-root user — principle of least privilege (REQ-SEC-001) +RUN addgroup -S caloriecounter && adduser -S caloriecounter -G caloriecounter +USER caloriecounter + +EXPOSE 8080 + +# Container-aware heap sizing: use 75% of cgroup memory limit +ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75" +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] + +# ── local target ────────────────────────────────────────────────────────────── +# Copies JAR from the Maven build stage above. +# Usage: docker build --target local -t calorie-counter-api . +FROM runtime-base AS local +COPY --from=build /workspace/target/*.jar app.jar + +# ── ci-image target (DEFAULT) ───────────────────────────────────────────────── +# Copies a pre-built JAR from the Docker build context. +# The CI workflow builds the JAR natively (outside QEMU) before docker build, +# avoiding 20-min Maven downloads through QEMU's unreliable TLS stack. +# Usage: docker build --build-arg JAR_FILE=target/calorie-counter-backend-1.0.0-SNAPSHOT.jar . +FROM runtime-base AS ci-image +ARG JAR_FILE=target/*.jar +COPY ${JAR_FILE} app.jar diff --git a/backend/pom.xml b/backend/pom.xml index ec58744..9cecc8e 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -86,6 +86,12 @@ spring-boot-starter-webflux + + + org.springframework.boot + spring-boot-starter-actuator + + org.springframework.boot diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 89dcf3e..f08737c 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -36,6 +36,18 @@ openai: openfoodfacts: base-url: https://world.openfoodfacts.org +management: + endpoints: + web: + exposure: + include: health + endpoint: + health: + probes: + # Enable liveness + readiness probes — used by Docker HEALTHCHECK + enabled: true + show-details: never + logging: level: root: WARN diff --git a/mobile/src/services/api.ts b/mobile/src/services/api.ts index ce021e8..f7b34da 100644 --- a/mobile/src/services/api.ts +++ b/mobile/src/services/api.ts @@ -2,7 +2,10 @@ import axios from 'axios'; import AsyncStorage from '@react-native-async-storage/async-storage'; -const BASE_URL = process.env.API_BASE_URL ?? 'http://localhost:8080'; +// Production: https://calories.amlab.dev (Nginx Proxy Manager → Pi:8085) +// Local dev: http://10.18.1.135:8085 (Pi LAN, via WireGuard if off-network) +// Override: set API_BASE_URL env var in your .env file +const BASE_URL = process.env.API_BASE_URL ?? 'https://calories.amlab.dev'; const api = axios.create({ baseURL: BASE_URL,