feat: Raspberry Pi deployment — Dockerfile, CI/CD workflows, actuator
Some checks failed
CI / Build & test backend (push) Failing after 13m38s

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)
This commit is contained in:
2026-05-18 23:58:44 +03:00
parent 8a031b30b6
commit 904f1c43b3
6 changed files with 283 additions and 1 deletions

42
.gitea/workflows/ci.yml Normal file
View File

@@ -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

168
.gitea/workflows/docker.yml Normal file
View File

@@ -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)"

51
backend/Dockerfile Normal file
View File

@@ -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

View File

@@ -86,6 +86,12 @@
<artifactId>spring-boot-starter-webflux</artifactId> <artifactId>spring-boot-starter-webflux</artifactId>
</dependency> </dependency>
<!-- Actuator — liveness/readiness probes for Docker healthcheck -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Test --> <!-- Test -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>

View File

@@ -36,6 +36,18 @@ openai:
openfoodfacts: openfoodfacts:
base-url: https://world.openfoodfacts.org 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: logging:
level: level:
root: WARN root: WARN

View File

@@ -2,7 +2,10 @@
import axios from 'axios'; import axios from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage'; 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({ const api = axios.create({
baseURL: BASE_URL, baseURL: BASE_URL,