diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3692087..47927df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -226,4 +226,4 @@ jobs: - name: Upload coverage to Coveralls uses: coverallsapp/github-action@v2 with: - github-token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/containers.yml b/.github/workflows/containers.yml new file mode 100644 index 0000000..9b51a4c --- /dev/null +++ b/.github/workflows/containers.yml @@ -0,0 +1,65 @@ +name: Create containers + +on: + # run every night + schedule: + - cron: "0 22 * * *" + + # schedule manually + workflow_dispatch: + inputs: + # On workflow dispatch, `branch` is selected by default + # You can access it in `github.ref_name` + + tag_name: + description: "Tag name for the container" + required: true + default: "nightly" + + container_repository_branch: + description: "Branch of the container repository" + required: true + default: "main" + +jobs: + build-and-push-containers: + name: Build and push container images to Quay + if: github.event_name != 'schedule' || github.repository_owner == 'geo-engine' + + runs-on: ubuntu-24.04 + + permissions: + contents: read + + env: + TAG_NAME: nightly + BACKEND_CONTAINER_NAME: biois-backend + FRONTEND_CONTAINER_NAME: biois-frontend + + steps: + - name: Modify TAG_NAME if on `tag_name` is set on `workflow_dispatch` + if: github.event.inputs.tag_name != '' + run: | + echo "TAG_NAME=${{ github.event.inputs.tag_name }}" >> $GITHUB_ENV + + - name: Checkout code + uses: actions/checkout@v6 + + - name: Login to quay.io + run: podman login -u="geoengine+bot" -p="${{secrets.QUAY_IO_TOKEN}}" quay.io + + - name: Build containers + run: | + just build-backend-container + just build-frontend-container + + - name: Push image to quay.io + run: | + podman push ${{env.BACKEND_CONTAINER_NAME}}:${{env.TAG_NAME}} quay.io/geoengine/${{env.BACKEND_CONTAINER_NAME}}:${{env.TAG_NAME}} + podman push ${{env.FRONTEND_CONTAINER_NAME}}:${{env.TAG_NAME}} quay.io/geoengine/${{env.FRONTEND_CONTAINER_NAME}}:${{env.TAG_NAME}} + + - name: Push nightly with date + if: env.TAG_NAME == 'nightly' + run: | + podman push ${{env.BACKEND_CONTAINER_NAME}}:${{env.TAG_NAME}} quay.io/geoengine/${{env.BACKEND_CONTAINER_NAME}}:${{env.TAG_NAME}}-$(date +'%Y-%m-%d') + podman push ${{env.FRONTEND_CONTAINER_NAME}}:${{env.TAG_NAME}} quay.io/geoengine/${{env.FRONTEND_CONTAINER_NAME}}:${{env.TAG_NAME}}-$(date +'%Y-%m-%d') \ No newline at end of file diff --git a/README.md b/README.md index 86d3011..c742d15 100644 --- a/README.md +++ b/README.md @@ -38,4 +38,4 @@ The API client is a TypeScript library generated from the OpenAPI specification ### [Frontend](frontend/README.md) -_TODO: Add description of frontend component._ +The frontend is an Angular application that provides a user interface for interacting with the BioIS service. diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..edb15d7 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,11 @@ +target +.git +.gitignore +node_modules +dist +*.log +*.env +/.venv +# Keep Cargo.lock in context for reproducible builds +# Cargo.lock +**/target \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..026339c --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,39 @@ +# Backend multi-stage build: +# 1. build with Rust +# 2. copy release binary into distroless final image + +FROM quay.io/geoengine/devcontainer:latest AS builder + +WORKDIR /usr/src/biois + +# Cache dependencies: copy manifest first +COPY \ + Cargo.toml \ + Cargo.lock \ + rust-toolchain.toml \ + ./ +# create dummy src to allow caching of cargo registry build step +RUN mkdir src && printf "fn main() { println!(\"cargo build cache\"); }\n" > src/main.rs +RUN cargo build --release +RUN rm -rf src + +# Copy full source and build the release binary +COPY conf ./conf +COPY migrations ./migrations +COPY src ./src +COPY build.rs ./build.rs +RUN cargo build --release --bin biois + +# Strip the binary to reduce size if possible +RUN strip target/release/biois || true + +# Final image: distroless for minimal attack surface and size +FROM gcr.io/distroless/cc-debian13:nonroot + +# Copy ca-certificates and the built binary +COPY --from=builder /usr/src/biois/target/release/biois /usr/local/bin/biois +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ + +USER nonroot +EXPOSE 4040 +ENTRYPOINT ["/usr/local/bin/biois"] diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..f862f8f --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,12 @@ +node_modules +dist +.git +.gitignore +npm-debug.log +yarn-error.log +.cache +/.idea +/.vscode +coverage +docs +*.local \ No newline at end of file diff --git a/frontend/Caddyfile b/frontend/Caddyfile new file mode 100644 index 0000000..922542e --- /dev/null +++ b/frontend/Caddyfile @@ -0,0 +1,34 @@ +{ + # Disable Caddy admin api + admin off +} + +:80 + +# Enable Compression +encode zstd gzip + +# Proxy API requests to the backend +handle /api* { + uri strip_prefix /api + + reverse_proxy biois-backend:4040 +} + +# Match static assets +@static { + file + path *.js *.css *.png *.jpg *.jpeg *.gif *.svg *.ico *.woff *.woff2 +} + +# Set Cache-Control to 1 day (86400 seconds) +header @static Cache-Control "public, max-age=86400" + +# ALWAYS keep index.html fresh +header /index.html Cache-Control "no-cache, no-store, must-revalidate" + +handle { + root * /usr/share/caddy + try_files {path} /index.html + file_server +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..93f3f62 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,38 @@ +# Frontend multi-stage build: +# 1. build with devcontainer +# 2. serve static with Caddy + +FROM quay.io/geoengine/devcontainer:latest AS builder + +# 1.1 Install dependencies + +WORKDIR /app/frontend +COPY frontend/package*.json ./ +RUN npm ci --silent + +WORKDIR /app/api-client/typescript +COPY api-client/typescript . +RUN npm ci --silent + +# 1.2 Copy source and build +WORKDIR /app/frontend +COPY frontend/public ./public +COPY frontend/src ./src +COPY \ + frontend/_theme-colors.scss \ + frontend/angular.json \ + frontend/tsconfig*.json \ + ./ +RUN npm run build --silent + +# 2. Final image: Caddy to serve static files and reverse-proxy API +FROM caddy:2-alpine AS runner + +# Copy built site into caddy's web root +COPY --from=builder /app/frontend/dist/BioIS/browser /usr/share/caddy + +# Copy Caddyfile for SPA fallback and API proxy +COPY frontend/Caddyfile /etc/caddy/Caddyfile + +EXPOSE 80 +CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] diff --git a/frontend/angular.json b/frontend/angular.json index c004b0b..5c6ff2a 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -63,6 +63,9 @@ }, "serve": { "builder": "@angular/build:dev-server", + "options": { + "proxyConfig": "proxy.conf.json" + }, "configurations": { "production": { "buildTarget": "BioIS:build:production" diff --git a/frontend/proxy.conf.json b/frontend/proxy.conf.json new file mode 100644 index 0000000..da78ad8 --- /dev/null +++ b/frontend/proxy.conf.json @@ -0,0 +1,9 @@ +{ + "/api": { + "target": "http://localhost:4040", + "secure": false, + "changeOrigin": true, + "logLevel": "info", + "pathRewrite": { "^/api": "" } + } +} diff --git a/frontend/src/app/user.service.ts b/frontend/src/app/user.service.ts index 4ecd74a..1690a93 100644 --- a/frontend/src/app/user.service.ts +++ b/frontend/src/app/user.service.ts @@ -75,7 +75,7 @@ export class UserService { accessToken: 'Bearer ' + this.user()?.id /* TODO: handle missing/expired token */, }; const config = createConfiguration({ - baseServer: new ServerConfiguration('http://localhost:4040', {}), + baseServer: new ServerConfiguration('/api', {}), // authMethods: authMethods as AuthMethodsConfiguration, authMethods: { default: { @@ -105,7 +105,7 @@ function oidcRedirectUri(): string { function configuration(/*options: { authMethods?: OAuth2Configuration } = {}*/): Configuration { return createConfiguration({ - baseServer: new ServerConfiguration('http://localhost:4040', {}), + baseServer: new ServerConfiguration('/api', {}), // ...options, }); } diff --git a/justfile b/justfile index e91c5a7..6bf9dbc 100644 --- a/justfile +++ b/justfile @@ -64,6 +64,26 @@ build-frontend: npm ci npm run build +# Build the frontend container. Usage: `just build-frontend-container`. +[group('build')] +[group('container')] +build-frontend-container: + @-clear + podman build \ + -f frontend/Dockerfile \ + -t biois-frontend:latest \ + . + +# Build the frontend container. Usage: `just build-frontend-container`. +[group('build')] +[group('container')] +build-backend-container: + @-clear + podman build \ + -f backend/Dockerfile \ + -t biois-backend:latest \ + backend + ### LINT ### [group('api-client')] @@ -204,6 +224,43 @@ run-frontend: @-clear npm run ng serve +# Run the backend container in dev mode. Usage: `just run-backend-container`. +[group('container')] +[group('run')] +run-backend-container: build-backend-container + podman run --rm --replace \ + --name biois-backend-dev \ + --network host \ + -p 4040:4040 \ + -v $(pwd)/backend/Settings.toml:/usr/local/bin/Settings.toml \ + biois-backend:latest + +# Run the frontend container in dev mode. Usage: `just run-frontend-container`. +[group('container')] +[group('run')] +run-frontend-container: build-frontend-container + podman run --rm --replace \ + --name biois-frontend-dev \ + -p 4200:80 \ + biois-frontend:latest + +# Run the container as a pod in dev mode. Usage: `just run-pod`. +[group('container')] +[group('run')] +run-pod: build-backend-container build-frontend-container + cat k8s/dev-config.yaml k8s/pod.yaml | \ + podman play kube \ + --network=pasta:-T,3030:3030 `# Map local Geo Engine at port 3030 into pod` \ + --replace - + +# Stop the pod in dev mode. Usage: `just down-pod`. +[group('container')] +[group('run')] +down-pod: + cat k8s/dev-config.yaml k8s/pod.yaml | \ + podman play kube \ + --down - + ### MISC ### # Generate the OpenAPI spec and write it to `openapi.json`. diff --git a/k8s/README.md b/k8s/README.md new file mode 100644 index 0000000..c4f0936 --- /dev/null +++ b/k8s/README.md @@ -0,0 +1,59 @@ +# Containers + +This directory contains Kubernetes manifests for running the BioIS backend and frontend in containers. +The manifests are designed for development and testing purposes, using a single Pod to host both services and a PVC for PostgreSQL data persistence. + +## Running with Podman + +Build images with Podman (from repository root): + +```bash +just build-backend-container +just build-frontend-container +``` + +You can run them individually with `podman run`: + +```bash +just run-frontend-container +just run-backend-container +``` + +## Running with Podman and Kubernetes manifests + +Run locally using `podman play kube` with the provided manifest. The manifest now contains a single Pod and a PVC; Podman does not support Service objects. + +Before applying, create a named Podman volume and use its mountpoint as the hostPath backing store for Postgres (no PersistentVolume resource is required): + +```bash +# optionally, create a named podman volume +podman volume create biois-postgres + +# build containers and run pods +just run-pod +``` + +Inspect running containers and ports: + +```bash +podman ps +podman port +``` + +Stop the deployed resources: + +```bash +just down-pod +``` + +## Podman: Persistent volumes & Secrets + +- The development `ConfigMap` and `Secret` are in `k8s/dev-config.yaml`. + It contains the `biois-config` ConfigMap and `biois-postgres-secret` Secret used by the Pod. + To override credentials, update that file or create a different Secret and apply it before the Pod. + +Example: create an overriding secret and then apply manifests + +```bash +kubectl create secret generic biois-postgres-secret --from-literal=POSTGRES_USER=youruser --from-literal=POSTGRES_PASSWORD=yourpass --from-literal=POSTGRES_DB=yourdb +``` diff --git a/k8s/dev-config.yaml b/k8s/dev-config.yaml new file mode 100644 index 0000000..0865a5b --- /dev/null +++ b/k8s/dev-config.yaml @@ -0,0 +1,20 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: biois-config +data: + # When running as a single Pod, Postgres is reachable on localhost + POSTGRES_HOST: "127.0.0.1" + POSTGRES_PORT: "5432" + +--- +apiVersion: v1 +kind: Secret +metadata: + name: biois-postgres-secret +type: Opaque +stringData: + POSTGRES_USER: "geoengine" + POSTGRES_PASSWORD: "geoengine" + POSTGRES_DB: "biois" diff --git a/k8s/pod.yaml b/k8s/pod.yaml new file mode 100644 index 0000000..d61ff9e --- /dev/null +++ b/k8s/pod.yaml @@ -0,0 +1,87 @@ +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: biois-postgres-data +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + storageClassName: local + +--- +apiVersion: v1 +kind: Pod +metadata: + name: biois +spec: + # keep restart policy to Always to mimic Deployment behavior + restartPolicy: Always + containers: + - name: frontend + image: biois-frontend:latest + ports: + - containerPort: 80 + hostPort: 4200 + livenessProbe: + tcpSocket: + port: 80 + initialDelaySeconds: 10 + periodSeconds: 10 + readinessProbe: + tcpSocket: + port: 80 + initialDelaySeconds: 5 + periodSeconds: 5 + - name: backend + image: biois-backend:latest + # ports: + # - containerPort: 8080 + # hostPort: 8080 + # ports: + # - containerPort: 3030 + # hostPort: 3030 + readinessProbe: + httpGet: + path: /health + port: 4040 + initialDelaySeconds: 5 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /health + port: 4040 + initialDelaySeconds: 15 + periodSeconds: 10 + envFrom: + - configMapRef: + name: biois-config + - secretRef: + name: biois-postgres-secret + - name: postgres + image: postgres:18-alpine + # ports: + # - containerPort: 5432 + # hostPort: 5432 + envFrom: + - secretRef: + name: biois-postgres-secret + readinessProbe: + tcpSocket: + port: 5432 + initialDelaySeconds: 5 + periodSeconds: 5 + livenessProbe: + tcpSocket: + port: 5432 + initialDelaySeconds: 15 + periodSeconds: 10 + volumeMounts: + - name: pgdata + mountPath: /var/lib/postgresql/data + volumes: + - name: pgdata + persistentVolumeClaim: + claimName: biois-postgres-data