Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d044277194 | ||
|
|
d13443e338 | ||
|
|
7a6139017f | ||
|
|
cea4378f56 | ||
|
|
b13c547068 | ||
|
|
a9fb948769 |
@@ -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
|
||||
@@ -0,0 +1,20 @@
|
||||
# Production Compose example only. Store the real file outside the repository with mode 0600.
|
||||
# Use an immutable sha-<full-commit> 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
|
||||
@@ -0,0 +1,210 @@
|
||||
name: Container
|
||||
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: container-${{ gitea.workflow }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
TESTCONTAINERS_HOST_OVERRIDE: host.docker.internal
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Java 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25'
|
||||
cache: maven
|
||||
|
||||
- name: Set up Node 24
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
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
|
||||
|
||||
amd64:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- 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<<EOF"
|
||||
echo "$image:sha-${GITEA_SHA}-amd64"
|
||||
if [ "$publish" = "true" ]; then
|
||||
echo "$image:sha-${GITEA_SHA}"
|
||||
echo "$image:main"
|
||||
fi
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to registry
|
||||
if: steps.image.outputs.publish == 'true'
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
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@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
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: vars.ARM64_RUNNER_AVAILABLE == 'true'
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Select image tag
|
||||
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" >> "$GITHUB_OUTPUT"
|
||||
echo "registry=${image%%/*}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$image:sha-${GITEA_SHA}-arm64" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to registry
|
||||
if: steps.image.outputs.publish == 'true'
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ${{ steps.image.outputs.registry }}
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build native ARM64 image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
push: ${{ steps.image.outputs.publish }}
|
||||
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@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- 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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
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"
|
||||
@@ -0,0 +1,61 @@
|
||||
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
|
||||
env:
|
||||
TESTCONTAINERS_HOST_OVERRIDE: host.docker.internal
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Java 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25'
|
||||
cache: maven
|
||||
|
||||
- name: Set up Node 24
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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-<full-commit>` 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` runs only when manually dispatched or when `main` is pushed, and it repeats verification before either architecture build. Manual runs build without publishing. A push to `main` publishes immutable `sha-<commit>` 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.
|
||||
+7
-2
@@ -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.
|
||||
|
||||
+38
@@ -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"]
|
||||
@@ -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,21 @@ 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 verifies every pull request and push. The separate container
|
||||
workflow runs only for a manual dispatch or a push to `main`, and its verification
|
||||
job must pass before either image build starts. Manual runs build without publishing;
|
||||
`main` pushes publish Linux AMD64 and add native Linux ARM64 only when the repository
|
||||
explicitly declares that its ARM runner is online. Every published revision has an
|
||||
immutable `sha-<full-commit>` 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 |
|
||||
|
||||
+15
@@ -219,6 +219,17 @@ 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 runs only when manually dispatched or when
|
||||
`main` is pushed. It repeats the verification job before building either image.
|
||||
Manual runs do not publish; only a push to `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
|
||||
@@ -226,6 +237,10 @@ claim never replaces a test command and result.
|
||||
Start Docker Desktop or OrbStack. Run `docker version`. OrbStack users should
|
||||
also check the `DOCKER_HOST` command shown in Section 1.
|
||||
|
||||
The Gitea Docker runner exposes the daemon through Docker Desktop, so its jobs
|
||||
set `TESTCONTAINERS_HOST_OVERRIDE=host.docker.internal`. Keep that override if
|
||||
the runner stays containerized; otherwise Ryuk may try an unreachable bridge IP.
|
||||
|
||||
### The wrong Java version is used
|
||||
|
||||
Run `java -version` and `./mvnw -version`. Both should report Java 25. Set
|
||||
|
||||
+60
-6
@@ -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:
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# Test Evidence: Gitea Testcontainers and container workflow gates
|
||||
|
||||
- **Test type:** Integration
|
||||
- **Requirement IDs:** `OPS-011`, `OPS-012`, `TST-001`, `TST-005`, `TST-009`
|
||||
- **Scenario IDs:** `AC-OPS-002`, `AC-OPS-004`
|
||||
- **Test class/method:** `src/test/js/delivery-contract.test.mjs`
|
||||
- **Implementation commit:** `d13443e338770dec0ca9822600f9a9d8405dfdbb`
|
||||
|
||||
## Protected behavior
|
||||
|
||||
Gitea verification must reach Docker Desktop-published Testcontainers ports from
|
||||
inside its job container. Container builds may start only after an equivalent
|
||||
verification job succeeds, and the container workflow may run only by manual
|
||||
dispatch or by a push to `main`. Every third-party workflow action is pinned to
|
||||
the reviewed latest release commit rather than a moving tag.
|
||||
|
||||
## Test method
|
||||
|
||||
The dependency-free delivery contract reads both workflow files and checks the
|
||||
Testcontainers host override, event filters, verify-to-build dependencies, and
|
||||
the complete allowlist of immutable action SHAs. The remote failure log supplies
|
||||
the production-shaped network reproduction because it ran inside the real Gitea
|
||||
Docker runner.
|
||||
|
||||
## Hand-derived expected result
|
||||
|
||||
The runner already resolves `host.docker.internal` to its Docker host. Therefore
|
||||
Testcontainers must use that host instead of the job-network gateway
|
||||
`172.17.0.1`. Pull requests and non-main branch pushes must never schedule the
|
||||
container workflow. Manual dispatches build but do not publish, while main pushes
|
||||
publish only after verification succeeds.
|
||||
|
||||
## RED
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
env PATH=/opt/homebrew/opt/node@24/bin:/usr/bin:/bin \
|
||||
node --test src/test/js/delivery-contract.test.mjs
|
||||
|
||||
tea actions runs logs 174 --repo sechmachine/labtimesheet \
|
||||
--login sechmachine-git
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
Delivery contract: 4 tests, 1 passed, 3 failed. The workflows lacked the
|
||||
Testcontainers host override, container event/dependency gates, and current
|
||||
action pins.
|
||||
|
||||
Gitea run 174 found Docker at unix:///var/run/docker.sock but selected host
|
||||
172.17.0.1. Ryuk started, then repeated connections to 172.17.0.1:57499 were
|
||||
refused. Maven ended with 217 tests, 64 errors.
|
||||
```
|
||||
|
||||
## GREEN
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
env PATH=/opt/homebrew/opt/node@24/bin:/usr/bin:/bin \
|
||||
node --test src/test/js/delivery-contract.test.mjs
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
Delivery contract: 4 tests, 4 passed.
|
||||
```
|
||||
|
||||
## Affected suite
|
||||
|
||||
**Command and result**
|
||||
|
||||
```text
|
||||
env PATH=/opt/homebrew/opt/node@24/bin:/usr/bin:/bin npm run test:ui
|
||||
Result: 5 tests passed.
|
||||
|
||||
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 \
|
||||
PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin \
|
||||
DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock \
|
||||
./mvnw -B test
|
||||
Result: 205 tests passed across 44 suites; 0 failures, errors, or skips.
|
||||
|
||||
npm ci && npm run build
|
||||
Result: Tailwind and Lucide assets built successfully; tracked assets remained unchanged.
|
||||
|
||||
./mvnw -B -DskipTests -Ddoclint=all javadoc:javadoc
|
||||
Result: BUILD SUCCESS with 83 existing missing-comment warnings and no production Java change.
|
||||
|
||||
Ruby YAML parsing and git diff --check
|
||||
Result: both workflow files parsed and the diff check passed.
|
||||
|
||||
Gitea Actions run 177 on `work/fix/platform/ci-testcontainers-actions`
|
||||
Result: Verify completed successfully in 8 minutes on the real Docker-mode runner.
|
||||
The non-main branch push scheduled `verify.yml` only; `container.yml` did not run.
|
||||
```
|
||||
|
||||
## External-test boundaries
|
||||
|
||||
The local contract cannot prove action-runner compatibility, registry credentials,
|
||||
or availability of the optional ARM runner. Those are checked by the actual Gitea
|
||||
branch verification and main container runs. Release freshness was checked against
|
||||
the official upstream release APIs on 2026-08-15; the immutable pins remain stable,
|
||||
but a later release requires an intentional reviewed update.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Test Evidence: Production delivery baseline
|
||||
|
||||
- **Test type:** Integration
|
||||
- **Requirement IDs:** `OPS-005`–`OPS-013`, `OPS-017`, `TST-001`, `TST-005`, `TST-009`
|
||||
- **Scenario IDs:** `AC-OPS-002`, `AC-OPS-003`, `AC-OPS-004`
|
||||
- **Test class/method:** `com.lab.labtimesheet.feature.account.service.BootstrapIntegrationTest.rootGuidesFreshInstallToBootstrapWhileOtherRoutesRemainHidden`, `src/test/js/delivery-contract.test.mjs`
|
||||
- **Implementation commit:** `cea4378f5699de4919c283b12371941d6742ca4b`
|
||||
|
||||
## Protected behavior
|
||||
|
||||
The production image runs as a non-root Java 25 process, exposes health probes, and accepts the same environment-backed datasource configuration with either the optional PostgreSQL 18.4 Compose sidecar or an external database. Gitea verifies every pull request and push, publishes immutable SHA plus `main` image tags only from `main`, and skips native ARM64 work unless the matching runner is explicitly available.
|
||||
|
||||
## Test method
|
||||
|
||||
The existing PostgreSQL-backed bootstrap integration test requests the liveness and readiness endpoints before initialization. A dependency-free Node contract checks the deployment files for the required runtime, Compose, trigger, permission, publication, and optional-runner boundaries. Docker and Compose validation then exercise the real build and both database topologies.
|
||||
|
||||
## Hand-derived expected result
|
||||
|
||||
An absent ARM runner must skip the ARM job without blocking AMD64 publication. Enabling the runner creates an ARM64 architecture tag and a combined manifest, while the canonical SHA and `main` tags remain valid AMD64 images when ARM is disabled. Compose must preserve PostgreSQL data in a named volume and must not require the bundled database when an external JDBC URL is supplied.
|
||||
|
||||
## RED
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
env PATH=/opt/homebrew/opt/node@24/bin:/usr/bin:/bin node --test src/test/js/delivery-contract.test.mjs
|
||||
|
||||
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 \
|
||||
PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin \
|
||||
DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock \
|
||||
./mvnw '-Dtest=BootstrapIntegrationTest#rootGuidesFreshInstallToBootstrapWhileOtherRoutesRemainHidden' test
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
The delivery contract ran 3 tests and failed all 3 because Dockerfile,
|
||||
.gitea/workflows/verify.yml, and .gitea/workflows/container.yml did not exist.
|
||||
|
||||
The PostgreSQL-backed bootstrap test ran 1 test and failed because
|
||||
/actuator/health/liveness returned 404 instead of 200.
|
||||
```
|
||||
|
||||
## GREEN
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
env PATH=/opt/homebrew/opt/node@24/bin:/usr/bin:/bin node --test src/test/js/delivery-contract.test.mjs
|
||||
|
||||
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 \
|
||||
PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin \
|
||||
DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock \
|
||||
./mvnw '-Dtest=BootstrapIntegrationTest#rootGuidesFreshInstallToBootstrapWhileOtherRoutesRemainHidden' test
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
Delivery contract: 3 tests, 3 passed.
|
||||
Bootstrap health regression: 1 test, 1 passed against PostgreSQL 18.4.
|
||||
```
|
||||
|
||||
## Affected suite
|
||||
|
||||
**Command and result**
|
||||
|
||||
```text
|
||||
npm ci
|
||||
npm run test:ui
|
||||
npm run build
|
||||
git diff --exit-code -- src/main/resources/static/assets/app.css src/main/resources/static/assets/icons.svg
|
||||
|
||||
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 \
|
||||
PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin \
|
||||
DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock \
|
||||
./mvnw test
|
||||
|
||||
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 \
|
||||
PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin \
|
||||
./mvnw -DskipTests -Ddoclint=all javadoc:javadoc
|
||||
|
||||
docker build --check .
|
||||
docker build --platform linux/amd64 --build-arg VCS_REF=validation -t labtimesheet:ci-amd64 .
|
||||
docker build --platform linux/arm64 --build-arg VCS_REF=validation -t labtimesheet:ci-arm64 .
|
||||
docker compose --env-file .env.compose.example config
|
||||
docker compose --env-file .env.compose.example --profile bundled-db config
|
||||
|
||||
Frontend tests: 4 passed; generated assets remained byte-clean.
|
||||
Maven: 217 tests across 49 suites, 0 failures, 0 errors, 0 skipped.
|
||||
Javadoc: BUILD SUCCESS; 83 pre-existing repository-wide warnings.
|
||||
Dockerfile check: passed without warnings. Both Linux architecture images built
|
||||
and reported the requested platform, UID/GID 10001, and readiness HEALTHCHECK.
|
||||
Both Compose configurations parsed successfully.
|
||||
|
||||
Real smoke tests used the AMD64 image with disposable resources. Bundled mode
|
||||
started PostgreSQL 18.4 with the named volume and returned UP from liveness and
|
||||
readiness on 127.0.0.1:28080. External mode used a separately started
|
||||
PostgreSQL 18.4 service and returned UP from readiness on 127.0.0.1:28081.
|
||||
All disposable containers, networks, and the bundled test volume were removed.
|
||||
|
||||
git diff --check: passed.
|
||||
```
|
||||
|
||||
## External-test boundaries
|
||||
|
||||
Local validation cannot prove that the private Gitea registry credentials are configured or that an `ubuntu-latest-arm` runner is online. Repository variable `ARM64_RUNNER_AVAILABLE` is the scheduler-safe availability signal because an unavailable runner label cannot be discovered from inside a job that has not yet been scheduled.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Test Evidence: Windows legacy Vietnam timezone startup
|
||||
|
||||
- **Test type:** Integration
|
||||
- **Requirement IDs:** `ARC-001`, `ARC-003`, `GOV-011`, `ATT-002`, `TST-001`, `TST-005`
|
||||
- **Scenario IDs:** `N/A — user-reported cross-platform startup defect`
|
||||
- **Test class/method:** `com.lab.labtimesheet.ApplicationTimeZoneIntegrationTest.mainCanonicalizesLegacyAliasBeforeStartingSpring`, `com.lab.labtimesheet.ApplicationTimeZoneIntegrationTest.canonicalizesLegacyVietnamAliasBeforePostgresConnects`, `com.lab.labtimesheet.ApplicationTimeZoneIntegrationTest.leavesSupportedSystemTimeZoneUnchanged`
|
||||
- **Implementation commit:** `a9fb9487692e84f5a7e7923570cbded58c362a2f`
|
||||
|
||||
## Protected behavior
|
||||
|
||||
The executable entry point replaces the legacy Windows JVM timezone ID `Asia/Saigon` with the canonical business timezone ID `Asia/Ho_Chi_Minh` before pgJDBC opens a PostgreSQL connection. Other supported operating-system timezone IDs remain unchanged.
|
||||
|
||||
## Test method
|
||||
|
||||
The entry-point test replaces Spring startup with Mockito's existing static test seam, invokes the real `main` method with a legacy JVM default, and checks that normalization happens before Spring starts. The PostgreSQL test starts a real PostgreSQL 18.4 Testcontainer, proves pgJDBC 42.7.11 is rejected while the JVM default is `Asia/Saigon`, invokes the same startup normalization, and then opens a valid JDBC connection. A negative test verifies that an unrelated supported timezone is not overwritten.
|
||||
|
||||
## Hand-derived expected result
|
||||
|
||||
PostgreSQL does not accept `Asia/Saigon` as a startup `TimeZone`, while the approved business timezone is `Asia/Ho_Chi_Minh`. Therefore only the legacy alias is replaced, the following connection succeeds, and a supported non-Vietnam timezone remains unchanged.
|
||||
|
||||
## RED
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw -Dtest=ApplicationTimeZoneIntegrationTest test
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
BUILD FAILURE during test compilation.
|
||||
ApplicationTimeZoneIntegrationTest.java: cannot find symbol normalizeDefaultTimeZone()
|
||||
```
|
||||
|
||||
The failing test established that the application had no pre-Spring normalization boundary.
|
||||
|
||||
A second mutation check temporarily removed the new call from `main` and ran:
|
||||
|
||||
```text
|
||||
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin ./mvnw '-Dtest=ApplicationTimeZoneIntegrationTest#mainCanonicalizesLegacyAliasBeforeStartingSpring' test
|
||||
```
|
||||
|
||||
It failed `1/1` with `expected: "Asia/Ho_Chi_Minh" but was: "Asia/Saigon"`, proving the test protects the entry-point ordering rather than only the helper.
|
||||
|
||||
## GREEN
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw -Dtest=ApplicationTimeZoneIntegrationTest test
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
|
||||
BUILD SUCCESS
|
||||
```
|
||||
|
||||
## Affected suite
|
||||
|
||||
**Command and result**
|
||||
|
||||
```text
|
||||
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=ApplicationTimeZoneIntegrationTest,LabtimesheetApplicationTests,PlatformFoundationTest,TimeConfigurationTest,CalendarDevelopmentProfileWebIntegrationTest' test
|
||||
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
|
||||
BUILD SUCCESS
|
||||
|
||||
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw test
|
||||
Tests run: 217, Failures: 0, Errors: 0, Skipped: 0
|
||||
BUILD SUCCESS
|
||||
|
||||
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin ./mvnw -DskipTests -Ddoclint=all javadoc:javadoc
|
||||
BUILD SUCCESS
|
||||
```
|
||||
|
||||
A local Java 25 process was also started with `-Duser.timezone=Asia/Saigon` against a disposable PostgreSQL 18.4 database on port `55439`. Hikari connected, Flyway migrated the fresh database, Tomcat started on port `18080`, and Spring reported `Started LabtimesheetApplication`. The process shut down cleanly and the disposable database container was removed.
|
||||
|
||||
## External-test boundaries
|
||||
|
||||
The regression executes the installed pgJDBC version against PostgreSQL 18.4 and reproduces the exact rejected timezone value from the Windows report. It does not run the Windows JVM itself; the supplied Windows log is the evidence that its OS/JDK mapping produced `Asia/Saigon`.
|
||||
@@ -1,23 +1,43 @@
|
||||
package com.lab.labtimesheet;
|
||||
|
||||
import java.util.TimeZone;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
|
||||
import com.lab.labtimesheet.config.SecurityProperties;
|
||||
|
||||
/** Application entry point and root component-scan boundary for Lab Timesheet. */
|
||||
/**
|
||||
* Application entry point and root component-scan boundary for Lab Timesheet.
|
||||
*
|
||||
* <p>The entry point also canonicalizes the legacy Windows Vietnam timezone alias before database
|
||||
* drivers inspect the JVM default timezone.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@EnableConfigurationProperties(SecurityProperties.class)
|
||||
public class LabtimesheetApplication {
|
||||
private static final String LEGACY_VIETNAM_TIME_ZONE = "Asia/Saigon";
|
||||
private static final String BUSINESS_TIME_ZONE = "Asia/Ho_Chi_Minh";
|
||||
|
||||
/**
|
||||
* Starts the standalone Spring Boot process.
|
||||
* Canonicalizes the process timezone and starts the standalone Spring Boot process.
|
||||
*
|
||||
* @param args command-line arguments forwarded to Spring Boot
|
||||
*/
|
||||
public static void main(String[] args) {
|
||||
normalizeDefaultTimeZone();
|
||||
SpringApplication.run(LabtimesheetApplication.class, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the legacy Windows Vietnam alias before pgJDBC sends it to PostgreSQL as a startup
|
||||
* parameter. Other supported system timezones remain unchanged.
|
||||
*/
|
||||
static void normalizeDefaultTimeZone() {
|
||||
if (LEGACY_VIETNAM_TIME_ZONE.equals(TimeZone.getDefault().getID())) {
|
||||
TimeZone.setDefault(TimeZone.getTimeZone(BUSINESS_TIME_ZONE));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
+2
-1
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
@@ -10,3 +10,9 @@ spring:
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
open-in-view: false
|
||||
|
||||
management:
|
||||
endpoint:
|
||||
health:
|
||||
probes:
|
||||
enabled: true
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.lab.labtimesheet;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.SQLException;
|
||||
import java.util.TimeZone;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.testcontainers.postgresql.PostgreSQLContainer;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
|
||||
class ApplicationTimeZoneIntegrationTest {
|
||||
|
||||
@Test
|
||||
void mainCanonicalizesLegacyAliasBeforeStartingSpring() {
|
||||
TimeZone originalTimeZone = TimeZone.getDefault();
|
||||
String[] args = {"--spring.profiles.active=test"};
|
||||
|
||||
try (MockedStatic<SpringApplication> springApplication = mockStatic(SpringApplication.class)) {
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Saigon"));
|
||||
|
||||
LabtimesheetApplication.main(args);
|
||||
|
||||
assertThat(TimeZone.getDefault().getID()).isEqualTo("Asia/Ho_Chi_Minh");
|
||||
springApplication.verify(() -> SpringApplication.run(LabtimesheetApplication.class, args));
|
||||
} finally {
|
||||
TimeZone.setDefault(originalTimeZone);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalizesLegacyVietnamAliasBeforePostgresConnects() throws SQLException {
|
||||
TimeZone originalTimeZone = TimeZone.getDefault();
|
||||
|
||||
try (PostgreSQLContainer postgres =
|
||||
new PostgreSQLContainer(DockerImageName.parse("postgres:18.4"))) {
|
||||
postgres.start();
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Saigon"));
|
||||
|
||||
assertThat(TimeZone.getDefault().getID()).isEqualTo("Asia/Saigon");
|
||||
assertThatThrownBy(() -> openConnection(postgres))
|
||||
.isInstanceOf(SQLException.class)
|
||||
.hasMessageContaining("Asia/Saigon");
|
||||
|
||||
LabtimesheetApplication.normalizeDefaultTimeZone();
|
||||
|
||||
assertThat(TimeZone.getDefault().getID()).isEqualTo("Asia/Ho_Chi_Minh");
|
||||
try (Connection connection = openConnection(postgres)) {
|
||||
assertThat(connection.isValid(1)).isTrue();
|
||||
}
|
||||
} finally {
|
||||
TimeZone.setDefault(originalTimeZone);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void leavesSupportedSystemTimeZoneUnchanged() {
|
||||
TimeZone originalTimeZone = TimeZone.getDefault();
|
||||
|
||||
try {
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Paris"));
|
||||
|
||||
LabtimesheetApplication.normalizeDefaultTimeZone();
|
||||
|
||||
assertThat(TimeZone.getDefault().getID()).isEqualTo("Europe/Paris");
|
||||
} finally {
|
||||
TimeZone.setDefault(originalTimeZone);
|
||||
}
|
||||
}
|
||||
|
||||
private static Connection openConnection(PostgreSQLContainer postgres) throws SQLException {
|
||||
return DriverManager.getConnection(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
|
||||
}
|
||||
}
|
||||
+2
@@ -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"));
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
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");
|
||||
const expectedActionPins = new Map([
|
||||
["actions/checkout", "3d3c42e5aac5ba805825da76410c181273ba90b1"],
|
||||
["actions/setup-java", "b6effb05e454b25005698d916606bdc6ffcbf961"],
|
||||
["actions/setup-node", "820762786026740c76f36085b0efc47a31fe5020"],
|
||||
["docker/setup-buildx-action", "bb05f3f5519dd87d3ba754cc423b652a5edd6d2c"],
|
||||
["docker/login-action", "dbcb813823bdd20940b903addbd779551569679f"],
|
||||
["docker/build-push-action", "53b7df96c91f9c12dcc8a07bcb9ccacbed38856a"],
|
||||
]);
|
||||
|
||||
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.match(workflow, /TESTCONTAINERS_HOST_OVERRIDE: host\.docker\.internal/);
|
||||
assert.doesNotMatch(workflow, /permissions:\s*write-all/);
|
||||
});
|
||||
|
||||
test("container workflow runs only manually or on main and verifies before either image build", () => {
|
||||
const workflow = read(".gitea/workflows/container.yml");
|
||||
|
||||
assert.match(workflow, /'on':\n workflow_dispatch:\n push:\n branches:\n - main/);
|
||||
assert.doesNotMatch(workflow, /^ pull_request:/m);
|
||||
assert.match(workflow, /jobs:\n verify:/);
|
||||
assert.match(workflow, /amd64:\n needs: verify/);
|
||||
assert.match(workflow, /arm64:[\s\S]*?needs: verify/);
|
||||
assert.match(workflow, /TESTCONTAINERS_HOST_OVERRIDE: host\.docker\.internal/);
|
||||
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);
|
||||
});
|
||||
|
||||
test("workflows pin every action to the latest reviewed immutable release", () => {
|
||||
const workflows = [
|
||||
read(".gitea/workflows/verify.yml"),
|
||||
read(".gitea/workflows/container.yml"),
|
||||
].join("\n");
|
||||
const uses = [...workflows.matchAll(/uses:\s+([^@\s]+)@([0-9a-f]{40})/g)];
|
||||
|
||||
assert.ok(uses.length > 0);
|
||||
for (const [, action, pin] of uses) {
|
||||
assert.equal(pin, expectedActionPins.get(action), `unexpected pin for ${action}`);
|
||||
}
|
||||
assert.deepEqual(new Set(uses.map(([, action]) => action)), new Set(expectedActionPins.keys()));
|
||||
assert.doesNotMatch(workflows, /uses:\s+[^\s]+@v\d/);
|
||||
});
|
||||
Reference in New Issue
Block a user