From cea4378f5699de4919c283b12371941d6742ca4b Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:45:35 +0700 Subject: [PATCH] feat(platform): add production CI and containers --- .dockerignore | 16 ++ .env.compose.example | 20 +++ .gitea/workflows/container.yml | 149 ++++++++++++++++++ .gitea/workflows/verify.yml | 59 +++++++ AGENTS.md | 4 +- DEPLOYMENT.md | 70 ++++++++ DEVELOPMENT.md | 9 +- Dockerfile | 38 +++++ README.md | 16 +- TESTING.md | 10 ++ compose.yaml | 66 +++++++- .../config/SecurityConfiguration.java | 7 +- .../controller/BootstrapAccessFilter.java | 3 +- src/main/resources/application-prod.yaml | 53 +++++++ src/main/resources/application.yaml | 6 + .../service/BootstrapIntegrationTest.java | 2 + src/test/js/delivery-contract.test.mjs | 53 +++++++ 17 files changed, 567 insertions(+), 14 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.compose.example create mode 100644 .gitea/workflows/container.yml create mode 100644 .gitea/workflows/verify.yml create mode 100644 DEPLOYMENT.md create mode 100644 Dockerfile create mode 100644 src/main/resources/application-prod.yaml create mode 100644 src/test/js/delivery-contract.test.mjs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b554860 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.gitea +.idea +.agents +.superpowers +.env +.env.* +!.env.compose.example +labtimesheet-docs-hub +node_modules +target +docs +src/test +*.log +*.7z +.DS_Store diff --git a/.env.compose.example b/.env.compose.example new file mode 100644 index 0000000..6fbdc3f --- /dev/null +++ b/.env.compose.example @@ -0,0 +1,20 @@ +# Production Compose example only. Store the real file outside the repository with mode 0600. +# Use an immutable sha- tag. The moving main tag is for convenience, not rollback. +LAB_IMAGE=git.sechmachine.io.vn/sechmachine/labtimesheet:sha-replace-with-full-commit + +# The app is intended to sit behind an HTTPS reverse proxy on the same host. +LAB_HTTP_BIND=127.0.0.1 +LAB_HTTP_PORT=8080 +LAB_PUBLIC_ORIGIN=https://timesheet.example.edu +LAB_FORWARD_HEADERS_STRATEGY=framework +LAB_SECURITY_MASTER_KEY=replace-with-base64-encoded-32-byte-key + +# Bundled mode uses the Compose service name. For external mode, replace this URL and credentials. +LAB_DB_URL=jdbc:postgresql://postgres:5432/labtimesheet +LAB_DB_USERNAME=labtimesheet +LAB_DB_PASSWORD=replace-with-database-password + +# Used only when the bundled-db profile is enabled. +POSTGRES_DB=labtimesheet +POSTGRES_USER=labtimesheet +POSTGRES_PASSWORD=replace-with-the-same-database-password diff --git a/.gitea/workflows/container.yml b/.gitea/workflows/container.yml new file mode 100644 index 0000000..97fb16c --- /dev/null +++ b/.gitea/workflows/container.yml @@ -0,0 +1,149 @@ +name: Container + +'on': + pull_request: + push: + +permissions: + contents: read + +concurrency: + group: container-${{ gitea.workflow }}-${{ gitea.ref }} + cancel-in-progress: true + +jobs: + amd64: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Check out source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Set up Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Select image tags + id: image + env: + CONFIGURED_IMAGE: ${{ vars.CONTAINER_IMAGE }} + EVENT_NAME: ${{ gitea.event_name }} + GITEA_REF: ${{ gitea.ref }} + GITEA_SHA: ${{ gitea.sha }} + run: | + publish=false + image=labtimesheet + if [ "$EVENT_NAME" = "push" ] && [ "$GITEA_REF" = "refs/heads/main" ]; then + test -n "$CONFIGURED_IMAGE" || { echo "Repository variable CONTAINER_IMAGE is required" >&2; exit 1; } + publish=true + image="$CONFIGURED_IMAGE" + fi + { + echo "publish=$publish" + echo "image=$image" + echo "registry=${image%%/*}" + echo "tags<> "$GITHUB_OUTPUT" + + - name: Log in to registry + if: steps.image.outputs.publish == 'true' + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ${{ steps.image.outputs.registry }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build AMD64 image and publish main + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + platforms: linux/amd64 + push: ${{ steps.image.outputs.publish }} + tags: ${{ steps.image.outputs.tags }} + build-args: | + VCS_REF=${{ gitea.sha }} + + arm64: + # Gitea cannot schedule a probe on a missing label. Enable this repository variable only + # while a trusted ubuntu-latest-arm runner is registered and online. + if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' && vars.ARM64_RUNNER_AVAILABLE == 'true' + runs-on: ubuntu-latest-arm + timeout-minutes: 30 + steps: + - name: Check out source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Set up Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Require image configuration + id: image + env: + CONFIGURED_IMAGE: ${{ vars.CONTAINER_IMAGE }} + GITEA_SHA: ${{ gitea.sha }} + run: | + test -n "$CONFIGURED_IMAGE" || { echo "Repository variable CONTAINER_IMAGE is required" >&2; exit 1; } + echo "image=$CONFIGURED_IMAGE" >> "$GITHUB_OUTPUT" + echo "registry=${CONFIGURED_IMAGE%%/*}" >> "$GITHUB_OUTPUT" + echo "tag=$CONFIGURED_IMAGE:sha-${GITEA_SHA}-arm64" >> "$GITHUB_OUTPUT" + + - name: Log in to registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ${{ steps.image.outputs.registry }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build native ARM64 image + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + platforms: linux/arm64 + push: true + tags: ${{ steps.image.outputs.tag }} + build-args: | + VCS_REF=${{ gitea.sha }} + + manifest: + if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' && vars.ARM64_RUNNER_AVAILABLE == 'true' + needs: [amd64, arm64] + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Set up Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Resolve registry + id: image + env: + CONFIGURED_IMAGE: ${{ vars.CONTAINER_IMAGE }} + run: | + test -n "$CONFIGURED_IMAGE" || { echo "Repository variable CONTAINER_IMAGE is required" >&2; exit 1; } + echo "registry=${CONFIGURED_IMAGE%%/*}" >> "$GITHUB_OUTPUT" + + - name: Log in to registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ${{ steps.image.outputs.registry }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Publish combined manifest + env: + IMAGE: ${{ vars.CONTAINER_IMAGE }} + GITEA_SHA: ${{ gitea.sha }} + run: | + docker buildx imagetools create \ + --tag "$IMAGE:sha-${GITEA_SHA}" \ + --tag "$IMAGE:main" \ + "$IMAGE:sha-${GITEA_SHA}-amd64" \ + "$IMAGE:sha-${GITEA_SHA}-arm64" diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml new file mode 100644 index 0000000..970ab7f --- /dev/null +++ b/.gitea/workflows/verify.yml @@ -0,0 +1,59 @@ +name: Verify + +'on': + pull_request: + push: + +permissions: + contents: read + +concurrency: + group: verify-${{ gitea.workflow }}-${{ gitea.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Check out source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Set up Java 25 + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 + with: + distribution: temurin + java-version: '25' + cache: maven + + - name: Set up Node 24 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '24' + cache: npm + + - name: Verify Docker for PostgreSQL tests + run: docker info + + - name: Install frontend dependencies + run: npm ci + + - name: Run frontend tests + run: npm run test:ui + + - name: Build frontend assets + run: npm run build + + - name: Verify generated assets are committed + run: git diff --exit-code -- src/main/resources/static/assets/app.css src/main/resources/static/assets/icons.svg + + - name: Run Maven tests + run: ./mvnw -B test + + - name: Verify Javadoc + run: ./mvnw -B -DskipTests -Ddoclint=all javadoc:javadoc + + - name: Verify whitespace + run: git diff --check diff --git a/AGENTS.md b/AGENTS.md index 353fe91..e855da2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,7 +146,9 @@ npm run build When using local OrbStack Testcontainers, set the actual Docker socket for that machine. Tests must not depend on the developer database or a real SMTP server. -Before completion, run focused tests, the affected suite, the full suite appropriate to the branch, frontend build when assets changed, `git diff --check`, and an adversarial diff review. The integrated iteration additionally requires Flyway/PostgreSQL validation and a real local Java process connected to PostgreSQL; an application container does not substitute for that gate when containerization is deferred. +Before completion, run focused tests, the affected suite, the full suite appropriate to the branch, frontend build when assets changed, `git diff --check`, and an adversarial diff review. The integrated iteration additionally requires Flyway/PostgreSQL validation and a real local Java process connected to PostgreSQL. Production container checks supplement those gates; they do not substitute for them. + +The root `Dockerfile`, `compose.yaml`, and `.env.compose.example` are production-only. Development runs Java from the IDE or Maven as documented in `DEVELOPMENT.md`. Only `main` may publish container images. Native ARM64 publication is gated by the repository variable `ARM64_RUNNER_AVAILABLE`; leave it absent or false unless a trusted `ubuntu-latest-arm` runner is online. ## Post-iteration integration and push diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..8e3f694 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,70 @@ +# Production Container Deployment + +These files deploy Lab Timesheet in production. They are not the development workflow; continue using [DEVELOPMENT.md](DEVELOPMENT.md) for IDE work. + +## 1. Prepare the host + +Install Docker Engine and Docker Compose v2.20 or newer. Put an HTTPS reverse proxy in front of the application. By default, Compose binds the application only to `127.0.0.1:8080`. + +Copy [`.env.compose.example`](.env.compose.example) to a protected path outside the repository: + +```bash +sudo install -d -m 0700 /etc/labtimesheet +sudo install -m 0600 .env.compose.example /etc/labtimesheet/compose.env +sudo editor /etc/labtimesheet/compose.env +``` + +Generate `LAB_SECURITY_MASTER_KEY` with `openssl rand -base64 32`. Use an immutable `sha-` application image tag. Never place Admin-managed SMTP or HolidayAPI credentials in this file. + +## 2. Choose the database topology + +### Bundled PostgreSQL 18.4 + +Keep the example JDBC host `postgres`, then run: + +```bash +docker compose --env-file /etc/labtimesheet/compose.env --profile bundled-db up -d +docker compose --env-file /etc/labtimesheet/compose.env ps +``` + +The application waits for PostgreSQL health and stores database files in the `postgres_data` named volume. + +### External PostgreSQL + +Set `LAB_DB_URL`, `LAB_DB_USERNAME`, and `LAB_DB_PASSWORD` for the external database. Do not enable the `bundled-db` profile: + +```bash +docker compose --env-file /etc/labtimesheet/compose.env up -d app +docker compose --env-file /etc/labtimesheet/compose.env ps +``` + +The same application image is used in both modes. + +## 3. Health and operation + +- Liveness: `GET /actuator/health/liveness` +- Readiness: `GET /actuator/health/readiness` (includes PostgreSQL) +- Logs: `docker compose --env-file /etc/labtimesheet/compose.env logs -f app` + +The container runs as UID/GID `10001`, with a read-only root filesystem, no Linux capabilities, and only `/tmp` writable. TLS termination is intentionally outside this Compose example. + +Back up PostgreSQL with database-aware tooling such as `pg_dump`. The named volume survives container replacement, but it is not a backup. Test restore procedures before upgrades. + +To update or roll back, change `LAB_IMAGE` to the required immutable SHA tag and run `docker compose ... up -d` again. Keep the previous SHA recorded until the new image is healthy. + +## 4. Gitea Actions setup + +Configure these repository settings: + +| Kind | Name | Value | +|---|---|---| +| Variable | `CONTAINER_IMAGE` | Full image name, for example `git.sechmachine.io.vn/sechmachine/labtimesheet` | +| Variable | `ARM64_RUNNER_AVAILABLE` | `true` only while a trusted `ubuntu-latest-arm` runner is registered and online; otherwise omit it or set `false` | +| Secret | `REGISTRY_USERNAME` | Registry user allowed to publish this package | +| Secret | `REGISTRY_TOKEN` | Registry token with package write access | + +`verify.yml` runs for every pull request and push. `container.yml` builds AMD64 for every pull request and push, but publishes only a push to `main`. Main always receives immutable `sha-` and convenience `main` tags. + +When ARM64 is disabled, those canonical tags remain valid AMD64 images and the workflow succeeds. When it is enabled, the native ARM runner publishes an architecture tag and the final job replaces the canonical tags with a combined AMD64/ARM64 manifest. Gitea cannot discover an unavailable runner from inside an unscheduled job, so the repository variable is the deliberate availability gate. + +The workflows stop at verification and image publication. They do not contain SSH deployment or receive host deployment secrets. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 350b893..2732b6e 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -4,8 +4,9 @@ This guide explains how to prepare and run Lab Timesheet on a developer computer. The application runs from Java. PostgreSQL and Mailpit run in Docker containers. -Application containerization and Docker Compose are planned for a later -iteration, so they are not required for Iteration 1 development. +The root Dockerfile and Compose file are production-only. They are not part of +the development loop. Development still runs Java from the IDE or Maven while +PostgreSQL and Mailpit run as separate local containers. ## 1. Install the required tools @@ -251,3 +252,7 @@ npm run build For test setup, commands, TDD, and test evidence rules, read [TESTING.md](TESTING.md). + +For production image and Compose operation, read [DEPLOYMENT.md](DEPLOYMENT.md). +Do not use the production Compose file as a replacement for this development +setup. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6ccc301 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +# Multi-stage production build. Base images are pinned multi-architecture indexes. +FROM node:24-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 AS frontend +WORKDIR /workspace +COPY package.json package-lock.json ./ +RUN npm ci +COPY src/main ./src/main +RUN npm run build + +FROM eclipse-temurin:25-jdk-alpine@sha256:5ecfde8e5ecde5954ea3721155b345ef56c1d579b940c761318ad4c05959a151 AS builder +WORKDIR /workspace +RUN apk add --no-cache curl +COPY .mvn .mvn +COPY mvnw pom.xml ./ +RUN ./mvnw -B -Dmaven.test.skip=true dependency:go-offline +COPY src/main ./src/main +COPY --from=frontend /workspace/src/main/resources/static/assets/app.css ./src/main/resources/static/assets/app.css +COPY --from=frontend /workspace/src/main/resources/static/assets/icons.svg ./src/main/resources/static/assets/icons.svg +RUN ./mvnw -B -Dmaven.test.skip=true package + +FROM eclipse-temurin:25-jre-alpine@sha256:28db6fdf60e38945e43d840c0333aeaec66c15943070104f7586fd3c9d1665b0 +ARG VCS_REF=unknown +ARG SOURCE_URL=https://git.sechmachine.io.vn/sechmachine/labtimesheet +LABEL org.opencontainers.image.title="Lab Timesheet" \ + org.opencontainers.image.source="${SOURCE_URL}" \ + org.opencontainers.image.revision="${VCS_REF}" + +RUN addgroup -S -g 10001 app && adduser -S -D -H -u 10001 -G app app +WORKDIR /app +COPY --from=builder --chown=10001:10001 /workspace/target/*.war /app/app.war + +ENV SPRING_PROFILES_ACTIVE=prod \ + JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0" +EXPOSE 8080 +USER 10001:10001 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 CMD wget -q -O /dev/null http://127.0.0.1:8080/actuator/health/readiness || exit 1 + +ENTRYPOINT ["java", "-jar", "/app/app.war"] diff --git a/README.md b/README.md index e8be202..3f4d0e2 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ The baseline schema includes later-workflow tables; table presence does not mean the corresponding feature is complete. - Iteration 2: Project invitations and approved membership exits, broader Project lifecycle transfers, Task edit/delete/reassignment and work logs, leave, missed-checkout corrections, notifications, schedulers, and complete metrics. -- Iteration 3: HTML/XLSX/PDF report parity, Chart.js trends, production security hardening, application containers, Compose, Gitea CI publication, and deployment scaffolding. +- Iteration 3: HTML/XLSX/PDF report parity, Chart.js trends, remaining production security hardening, and operational backup/restore qualification. - Mobile layouts are best-effort. Desktop is the supported interface target. ## Architecture and versions @@ -116,6 +116,20 @@ See [TESTING.md](TESTING.md) for setup, test commands, the required TDD cycle, evidence records, best practices, and common fixes. Every behavior test has a companion record under [`docs/tests`](docs/tests/README.md). +## Continuous integration and production containers + +Gitea Actions now verifies every pull request and push. A separate container +workflow builds Linux AMD64 and publishes only from `main`; native Linux ARM64 +is added only when the repository explicitly declares that its ARM runner is +online. Every published revision has an immutable `sha-` tag, with +`main` as a convenience alias. + +The production image is a non-root Java 25 image. The root [compose.yaml](compose.yaml) +supports either a persistent PostgreSQL 18.4 sidecar or an external PostgreSQL +database. It is not used for development. Follow [DEPLOYMENT.md](DEPLOYMENT.md) +and start from [`.env.compose.example`](.env.compose.example); keep the real +production environment file outside the repository. + ## Branch ownership | Branch | Primary area | diff --git a/TESTING.md b/TESTING.md index 1cf3015..d51faf8 100644 --- a/TESTING.md +++ b/TESTING.md @@ -219,6 +219,16 @@ claim never replaces a test command and result. - Give tests names that describe the rule and expected result. - Clean up temporary browser data, application processes, and manually started containers after end-to-end work. +### What CI runs + +Gitea runs the frontend tests/build, complete Maven/PostgreSQL suite, Javadoc, +generated-asset check, and whitespace check for every pull request and push. +The separate container workflow builds the production Dockerfile without +publishing pull-request or work-branch images. Only `main` publishes. + +Run focused and affected tests locally before pushing. CI is the shared +confirmation, not a substitute for local RED and GREEN evidence. + ## 8. Common problems ### Testcontainers cannot find Docker diff --git a/compose.yaml b/compose.yaml index 7c8044f..62194d3 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,9 +1,63 @@ +# Production deployment example. Development continues to run Java from the IDE. +name: labtimesheet-prod + services: - postgres: - image: 'postgres:latest' + app: + image: "${LAB_IMAGE:?Set LAB_IMAGE to an immutable sha-* image tag}" + restart: unless-stopped environment: - - 'POSTGRES_DB=mydatabase' - - 'POSTGRES_PASSWORD=secret' - - 'POSTGRES_USER=myuser' + SPRING_PROFILES_ACTIVE: prod + LAB_DB_URL: "${LAB_DB_URL:?Set the JDBC PostgreSQL URL}" + LAB_DB_USERNAME: "${LAB_DB_USERNAME:?Set the database username}" + LAB_DB_PASSWORD: "${LAB_DB_PASSWORD:?Set the database password}" + LAB_PUBLIC_ORIGIN: "${LAB_PUBLIC_ORIGIN:?Set the public HTTPS origin}" + LAB_SECURITY_MASTER_KEY: "${LAB_SECURITY_MASTER_KEY:?Set a Base64 256-bit key}" + LAB_FORWARD_HEADERS_STRATEGY: "${LAB_FORWARD_HEADERS_STRATEGY:?Set the explicit proxy strategy}" ports: - - '5432' + # Bind locally by default; terminate HTTPS in a reverse proxy on the same host. + - "${LAB_HTTP_BIND:-127.0.0.1}:${LAB_HTTP_PORT:-8080}:8080" + depends_on: + postgres: + condition: service_healthy + # External-database mode leaves the bundled-db profile disabled. + required: false + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + stop_grace_period: 40s + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/actuator/health/readiness"] + interval: 30s + timeout: 5s + start_period: 45s + retries: 3 + + postgres: + # Pinned PostgreSQL 18.4 multi-architecture image. + image: postgres:18.4@sha256:a02db8cac496f15b094798a38254f14d6e00741f709360e5e00bb6668ea31636 + profiles: ["bundled-db"] + restart: unless-stopped + environment: + POSTGRES_DB: "${POSTGRES_DB:-labtimesheet}" + POSTGRES_USER: "${POSTGRES_USER:-labtimesheet}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?Set the bundled PostgreSQL password}" + volumes: + # PostgreSQL 18 stores versioned data beneath this parent directory. + - postgres_data:/var/lib/postgresql + shm_size: 256mb + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""] + interval: 10s + timeout: 5s + retries: 10 + start_period: 10s + +volumes: + # Container replacement keeps this volume. It is not a substitute for backups. + postgres_data: diff --git a/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java b/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java index 28f7f66..54e84ec 100644 --- a/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java +++ b/src/main/java/com/lab/labtimesheet/config/SecurityConfiguration.java @@ -12,8 +12,9 @@ import org.springframework.security.web.access.intercept.AuthorizationFilter; import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter.ReferrerPolicy; /** - * Defines form authentication, role-based Admin routes, CSRF protection, and response security headers. - * Bootstrap access is further constrained by {@link BootstrapAccessFilter} until initialization completes. + * Defines form authentication, role-based Admin routes, public health probes, CSRF protection, and response + * security headers. Bootstrap access is further constrained by {@link BootstrapAccessFilter} until initialization + * completes. */ @Configuration(proxyBeanMethods = false) class SecurityConfiguration { @@ -34,7 +35,7 @@ class SecurityConfiguration { .authorizeHttpRequests(authorize -> authorize .requestMatchers( "/bootstrap/**", "/activate/**", "/login", "/error", "/assets/**", - "/actuator/health") + "/actuator/health", "/actuator/health/**") .permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated()) diff --git a/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapAccessFilter.java b/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapAccessFilter.java index 2d1a0ed..7cffb1e 100644 --- a/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapAccessFilter.java +++ b/src/main/java/com/lab/labtimesheet/feature/account/controller/BootstrapAccessFilter.java @@ -44,7 +44,8 @@ public class BootstrapAccessFilter extends OncePerRequestFilter { private static boolean allowedBeforeBootstrap(String path) { return path.equals("/bootstrap") || path.startsWith("/bootstrap/") - || path.equals("/actuator/health") || path.startsWith("/assets/") + || path.equals("/actuator/health") || path.startsWith("/actuator/health/") + || path.startsWith("/assets/") || path.equals("/error"); } } diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml new file mode 100644 index 0000000..ac9ae8b --- /dev/null +++ b/src/main/resources/application-prod.yaml @@ -0,0 +1,53 @@ +# Production profile. Supply every LAB_* value from the deployment environment. +spring: + datasource: + url: "${LAB_DB_URL}" + username: "${LAB_DB_USERNAME}" + password: "${LAB_DB_PASSWORD}" + flyway: + enabled: true + locations: classpath:db/migration + jpa: + hibernate: + ddl-auto: validate + open-in-view: false + docker: + compose: + enabled: false + lifecycle: + timeout-per-shutdown-phase: 30s + +server: + port: 8080 + shutdown: graceful + forward-headers-strategy: "${LAB_FORWARD_HEADERS_STRATEGY}" + servlet: + session: + cookie: + http-only: true + secure: true + same-site: strict + error: + include-message: never + include-stacktrace: never + +management: + endpoints: + web: + exposure: + include: "health,info" + endpoint: + health: + show-details: never + probes: + enabled: true + group: + liveness: + include: "livenessState" + readiness: + include: "readinessState,db" + +lab: + public-origin: "${LAB_PUBLIC_ORIGIN}" + security: + master-key: "${LAB_SECURITY_MASTER_KEY}" diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 345106f..34f48e2 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -10,3 +10,9 @@ spring: hibernate: ddl-auto: validate open-in-view: false + +management: + endpoint: + health: + probes: + enabled: true diff --git a/src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java index 604f167..022f5f3 100644 --- a/src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/account/service/BootstrapIntegrationTest.java @@ -61,6 +61,8 @@ class BootstrapIntegrationTest { void rootGuidesFreshInstallToBootstrapWhileOtherRoutesRemainHidden() throws Exception { mockMvc.perform(get("/bootstrap")).andExpect(status().isOk()); mockMvc.perform(get("/actuator/health")).andExpect(status().isOk()); + mockMvc.perform(get("/actuator/health/liveness")).andExpect(status().isOk()); + mockMvc.perform(get("/actuator/health/readiness")).andExpect(status().isOk()); mockMvc.perform(get("/")) .andExpect(status().is3xxRedirection()) .andExpect(redirectedUrl("/bootstrap")); diff --git a/src/test/js/delivery-contract.test.mjs b/src/test/js/delivery-contract.test.mjs new file mode 100644 index 0000000..6e53437 --- /dev/null +++ b/src/test/js/delivery-contract.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +const read = (path) => readFileSync(resolve(root, path), "utf8"); + +test("production image and Compose keep one image usable with bundled or external PostgreSQL", () => { + const dockerfile = read("Dockerfile"); + const compose = read("compose.yaml"); + const environment = read(".env.compose.example"); + const production = read("src/main/resources/application-prod.yaml"); + + assert.match(dockerfile, /FROM node:24-alpine@sha256:/); + assert.match(dockerfile, /FROM eclipse-temurin:25-jre-alpine@sha256:/); + assert.match(dockerfile, /USER 10001:10001/); + assert.match(dockerfile, /HEALTHCHECK .*health\/readiness/); + assert.match(compose, /profiles: \["bundled-db"\]/); + assert.match(compose, /condition: service_healthy/); + assert.match(compose, /required: false/); + assert.match(compose, /postgres_data:\s*$/m); + assert.match(environment, /^LAB_DB_URL=/m); + assert.match(environment, /^LAB_IMAGE=/m); + assert.match(production, /same-site: strict/); + assert.match(production, /include: "readinessState,db"/); +}); + +test("verification workflow checks every pull request and pushed branch without write permission", () => { + const workflow = read(".gitea/workflows/verify.yml"); + + assert.match(workflow, /pull_request:/); + assert.match(workflow, /push:/); + assert.match(workflow, /contents: read/); + assert.match(workflow, /npm ci/); + assert.match(workflow, /npm run test:ui/); + assert.match(workflow, /npm run build/); + assert.match(workflow, /\.\/mvnw -B test/); + assert.doesNotMatch(workflow, /permissions:\s*write-all/); +}); + +test("container workflow publishes only main and makes native ARM64 explicitly optional", () => { + const workflow = read(".gitea/workflows/container.yml"); + + assert.match(workflow, /runs-on: ubuntu-latest-arm/); + assert.match(workflow, /vars\.ARM64_RUNNER_AVAILABLE == 'true'/); + assert.match(workflow, /gitea\.ref == 'refs\/heads\/main'/); + assert.match(workflow, /sha-\$\{GITEA_SHA\}-amd64/); + assert.match(workflow, /sha-\$\{GITEA_SHA\}-arm64/); + assert.match(workflow, /imagetools create/); + assert.doesNotMatch(workflow, /ssh|DEPLOY_HOST|DEPLOY_KEY/i); +});