Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
cd7e435
docs(specs): design add-on-demand bricks and a nested service layout
javaAndScriptDeveloper Oct 11, 2026
90d70ec
docs(plans): implementation plan for bricks and the nested service
javaAndScriptDeveloper Oct 11, 2026
940392b
refactor(build): nest the service under services/app
javaAndScriptDeveloper Oct 11, 2026
cce4c89
feat(bricks): read enabled bricks from .bricks in Make, Gradle and th…
javaAndScriptDeveloper Oct 11, 2026
737b1bd
feat(bricks): add scripts/brick.sh to wire a brick in or out
javaAndScriptDeveloper Oct 11, 2026
9396642
refactor(observability): move Alloy, Grafana and the dashboard into a…
javaAndScriptDeveloper Oct 11, 2026
3a8066d
refactor(backup): move the off-host backup sidecar into a brick
javaAndScriptDeveloper Oct 11, 2026
ebf67f4
test(backup): restore the build-context and lint guards for the backu…
javaAndScriptDeveloper Oct 11, 2026
1eac2fc
feat(deploy): generate every empty *PASSWORD= in .env.prod
javaAndScriptDeveloper Oct 11, 2026
1b06974
fix(deploy): fail closed when a password cannot be generated
javaAndScriptDeveloper Oct 11, 2026
a0ff685
feat(bricks): add the minio brick with an S3 ObjectStorageService
javaAndScriptDeveloper Oct 11, 2026
9e610b9
fix(minio): probe the bucket, bound SDK calls, keep the brick round t…
javaAndScriptDeveloper Oct 11, 2026
c6da256
ci: build every brick on a clean checkout without gating the image
javaAndScriptDeveloper Oct 11, 2026
6393b59
docs: describe bricks in README, CLAUDE.md and a bricks skill
javaAndScriptDeveloper Oct 11, 2026
b850236
fix(bricks): validate names, report lock failures, keep .bricks lines…
javaAndScriptDeveloper Oct 11, 2026
a010bf9
fix(release): capture commit bodies before grepping for BREAKING CHANGE
javaAndScriptDeveloper Oct 11, 2026
546d2bf
ci(bricks): skip bricks the repository already has enabled
javaAndScriptDeveloper Oct 11, 2026
ab44189
fix(make): split the Gradle project from the logs filter
javaAndScriptDeveloper Oct 11, 2026
6a67b29
fix(minio): keep the secret key out of MinioProperties.toString
javaAndScriptDeveloper Oct 11, 2026
198672b
test(deploy): assert core app labels survive the brick merge
javaAndScriptDeveloper Oct 11, 2026
2093dea
docs: document brick rollout on a running host and the profile-layout…
javaAndScriptDeveloper Oct 11, 2026
7ef382f
feat(bricks): include a brick's Java as a Gradle subproject instead o…
javaAndScriptDeveloper Oct 12, 2026
ca86c06
refactor(minio): move the brick into the template.bricks.minio subpro…
javaAndScriptDeveloper Oct 12, 2026
bf8dffe
refactor(observability): move DashboardJsonTest into the brick's subp…
javaAndScriptDeveloper Oct 12, 2026
1c09f48
build(deps): let Dependabot see brick build files
javaAndScriptDeveloper Oct 12, 2026
18eb754
docs(bricks): describe the subproject model
javaAndScriptDeveloper Oct 12, 2026
399f28a
fix(bricks): make lock covers brick lockfiles; correct stale brick docs
javaAndScriptDeveloper Oct 12, 2026
0fbbb9b
test(minio): cover the auto-configuration without Docker
javaAndScriptDeveloper Oct 12, 2026
58ef338
fix(bricks): merge core-service partials with -f, not include
javaAndScriptDeveloper Oct 12, 2026
c3c4f69
fix(deps): bump Jackson, Netty, httpcore5 and Spring ahead of the Boo…
javaAndScriptDeveloper Oct 12, 2026
bf865c2
build(minio): put jackson-annotations on the brick's compile classpath
javaAndScriptDeveloper Oct 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .bricks
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Enabled bricks, one per line. `make bricks` lists them, `make add BRICK=<name>` / `make remove BRICK=<name>`
# edit this file. See bricks/README.md.
15 changes: 15 additions & 0 deletions .claude/skills/bricks/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
name: bricks
description: Add, remove, operate or author a brick (optional infrastructure under bricks/). Use when the user wants MinIO, Grafana, backups or another component, asks what bricks exist, or wants a new component packaged so it can be added with one command.
---

# Bricks

- List: `make bricks`. Enable: `make add BRICK=<name>`. Disable: `make remove BRICK=<name>`. Enabled list: `.bricks`.
- After add or remove: `make build && make test-scripts`; commit the whole diff as one `feat(<name>)` / `chore(<name>)`.
- Before touching a brick's files or operating it in prod: read `bricks/<name>/README.md` (it is the runbook).
- Before writing a new brick: read `bricks/README.md` (anatomy, rules, checklist). Keep it self-contained; test the
round trip; add it to the CI matrix.
- A brick extends `app`/`db` only through `override.yml` / `override.prod.yml` (merged by the Makefile with `-f`), never inside `compose*.yml`; `make config` / `make prod-config` validate.
- What add does, in order: compose includes → env blocks → skill → `.bricks` → app lockfile. A brick's `java/` is not
copied: it is Gradle project `:bricks:<name>`, included by `settings.gradle.kts` while listed in `.bricks`. `scripts/brick.sh` is the source of truth.
4 changes: 2 additions & 2 deletions .claude/skills/vps-db/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ Close it with `pkill -f 'ssh .*-L 15432:'` when done.
- **Never paste secrets into the chat.** Use `env` (masked), not `cat`. The JDBC URL from `datagrip` contains the
password: show it once because the user asked for it, do not repeat it in summaries.
- Schema belongs to Liquibase. Never fix a schema problem with ad-hoc DDL in prod; write a changeset.
- Before a restore or rollback, read `docs/deployment.md` (§6 backups/restore, rollback, expand/contract rules).
- Before a restore or rollback, read `docs/deployment.md` (rollback, expand/contract rules) and `bricks/backup/README.md` (backups/restore).
- **Never run a restore.** `make prod-restore` overwrites the production database; hand the user the command
(`scripts/vps.sh ssh`, then `make prod-backup-list` and `make prod-restore STAMP=<stamp|latest>`) and let them run it.
(`scripts/vps.sh ssh`, then `make prod-backup-list` and `make prod-restore STAMP=<stamp|latest>`, both from the backup brick) and let them run it.
- A `deploy` job failure means the image is published but the host did not pick it up: check
`scripts/vps.sh logs watchtower` first (`Scanned=…` lines and `DOCKER_API_VERSION` errors).
2 changes: 1 addition & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# The image needs exactly one file. Everything else (sources, secrets, .git) never reaches the daemon.
*
!build/libs/app.jar
!services/app/build/libs/app.jar
12 changes: 0 additions & 12 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,10 @@ POSTGRES_PASSWORD=app
# Host port for the local Postgres (container port stays 5432). Change if something already listens on 5432.
DB_PORT=5432

# --- Local observability stack (`make observability-up`) ---
GRAFANA_PORT=3000
PROMETHEUS_PORT=9090

# --- Personal: production access from this machine (put these in .env, never commit them) ---
# SSH target for scripts/vps.sh and the vps-db skill: user@host or an alias from ~/.ssh/config.
#[email protected]
# Repository checkout on the VPS (default: ~/<repo name>).
#VPS_APP_DIR=~/spring-template
# Local end of the SSH tunnel to the production database.
#VPS_DB_LOCAL_PORT=15432

# --- Personal: ad-hoc dashboard push to Grafana Cloud (CI has the same values as repository secrets) ---
# Stack URL and a service-account token with dashboards:write (+ alert.provisioning:write for alerts).
#GRAFANA_URL=https://yourstack.grafana.net
#GRAFANA_API_TOKEN=
# Telegram alerts: set both to push alert rules; leave unset to skip alerting entirely.
#TELEGRAM_BOT_TOKEN=
#TELEGRAM_CHAT_ID=
9 changes: 6 additions & 3 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
version: 2
updates:
- package-ecosystem: gradle
directory: /
directories:
- /services/app
- "/bricks/*/java"
schedule:
interval: weekly
day: monday
Expand All @@ -31,8 +33,8 @@ updates:

- package-ecosystem: docker
directories:
- /
- /deploy/backup
- /services/app
- /bricks/backup
schedule:
interval: weekly
day: monday
Expand All @@ -45,6 +47,7 @@ updates:
directories:
- /
- /deploy
- "/bricks/*"
schedule:
interval: weekly
day: monday
Expand Down
72 changes: 67 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
# git tag vX.Y.Z + GitHub Release generated notes list the commits since the previous release
#
# Pull requests run the same build, security checks and image build, and publish nothing.
#
# bricks: every brick added, built and removed on its own runner (not a gate)
name: ci

on:
Expand Down Expand Up @@ -72,7 +74,7 @@ jobs:
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: jar
path: build/libs/app.jar
path: services/app/build/libs/app.jar
retention-days: 3

- name: Upload test and coverage reports
Expand All @@ -81,8 +83,8 @@ jobs:
with:
name: reports
path: |
build/reports/tests/
build/reports/jacoco/
services/app/build/reports/tests/
services/app/build/reports/jacoco/
if-no-files-found: ignore

# Fast security lane: runs in parallel with the build and takes about a minute, so it adds nothing to the time from
Expand All @@ -106,7 +108,7 @@ jobs:
with:
scan-type: fs
scan-ref: .
skip-dirs: build,.gradle
skip-dirs: "**/build,**/.gradle"
scanners: vuln,secret,misconfig
severity: HIGH,CRITICAL
exit-code: "1"
Expand All @@ -117,6 +119,65 @@ jobs:
- name: zizmor (workflow misconfiguration)
run: uvx [email protected] --min-severity medium .github/workflows

# Bricks are inert until enabled, so the plain build cannot catch a brick that rotted. Each one is added on a
# clean checkout, built with its tests, and removed again; nothing here gates the image, so push → live stays fast.
bricks:
name: Brick ${{ matrix.brick }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
brick: [observability, backup, minio]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: temurin
java-version: ${{ env.JAVA_VERSION }}

- uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: true

- name: Skip bricks this repository has enabled
id: enabled
env:
BRICK: ${{ matrix.brick }}
run: |
if grep -qx "$BRICK" .bricks 2>/dev/null; then
echo "::notice::brick $BRICK is enabled in this repository; the build job already covers it"
echo "skip=true" >> "$GITHUB_OUTPUT"
fi

- name: Add the brick
if: steps.enabled.outputs.skip != 'true'
env:
BRICK: ${{ matrix.brick }}
run: scripts/brick.sh add "$BRICK"

- name: Build with the brick
if: steps.enabled.outputs.skip != 'true'
run: ./gradlew build --no-daemon

- name: Script tests and compose render with the brick
if: steps.enabled.outputs.skip != 'true'
run: |
scripts/tests/run.sh
make config
make prod-config PROD_ENV_FILE=scripts/tests/fixtures/env.prod.test

- name: Remove the brick and prove nothing is left
if: steps.enabled.outputs.skip != 'true'
env:
BRICK: ${{ matrix.brick }}
run: |
scripts/brick.sh remove "$BRICK"
git status --porcelain
test -z "$(git status --porcelain)"

image:
name: Build image
needs: [build, security-fast]
Expand All @@ -139,7 +200,7 @@ jobs:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: jar
path: build/libs/
path: services/app/build/libs/

- name: Derive version and image name
id: ver
Expand Down Expand Up @@ -185,6 +246,7 @@ jobs:
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: services/app/Dockerfile
platforms: linux/amd64,linux/arm64
build-args: |
APP_VERSION=${{ steps.ver.outputs.version }}
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/dashboards.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Pushes deploy/grafana/** to Grafana Cloud whenever it changes on main, so dashboards deploy the same way code does.
# Pushes bricks/observability/grafana/** to Grafana Cloud whenever it changes on main, so dashboards deploy the same way code does.
#
# Needs repository secrets GRAFANA_URL and GRAFANA_API_TOKEN (service account token: dashboards:write and
# alert.provisioning:write). Without them the job skips with a notice. Alert rules and the Telegram contact point
Expand All @@ -9,8 +9,8 @@ on:
push:
branches: [main]
paths:
- "deploy/grafana/**"
- "scripts/grafana-push.sh"
- "bricks/observability/grafana/**"
- "bricks/observability/grafana-push.sh"
- ".github/workflows/dashboards.yml"
workflow_dispatch:

Expand All @@ -37,4 +37,4 @@ jobs:
echo "::notice::GRAFANA_URL / GRAFANA_API_TOKEN secrets not set; dashboards not pushed."
exit 0
fi
scripts/grafana-push.sh cloud
bricks/observability/grafana-push.sh cloud
2 changes: 1 addition & 1 deletion .github/workflows/security-weekly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ jobs:
with:
scan-type: fs
scan-ref: .
skip-dirs: build,.gradle
skip-dirs: "**/build,**/.gradle"
scanners: vuln,secret,misconfig
severity: HIGH,CRITICAL
exit-code: "1"
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ Thumbs.db
.env.local
deploy/.env.prod
deploy/backups/
bricks/backup/rclone/*
!bricks/backup/rclone/.gitkeep
# old layout; remove after every host has migrated
deploy/backup/rclone/
*.log

Expand Down
2 changes: 1 addition & 1 deletion .trivyignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Accepted Trivy findings, one ID per line, each with its reason. Never silence a scan with continue-on-error;
# record the acceptance here instead. Plain format (not YAML): it is what trivy-action's `trivyignores` consumes.

# deploy/backup/Dockerfile runs as root: pg_dump writes to a host bind mount that Docker creates root-owned, and the
# bricks/backup/Dockerfile runs as root: pg_dump writes to a host bind mount that Docker creates root-owned, and the
# sidecar has no published port and only the `data` network. The app image does run as a non-root user.
DS-0002
31 changes: 16 additions & 15 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,20 @@
Spring Boot 4.1 / Java 25 service created from `spring-template`. Replace this paragraph with one sentence about
what the service does; everything below stays true for every project built from the template.

Core is one Spring service (`services/app`) + Postgres; everything else is a brick (`bricks/`, `make bricks`).

## Commands

| Command | What | Time |
|---|---|---|
| `make run` | app on :8080, Postgres from `compose.yml` | 20 s |
| `make run` | app on :8080, Postgres from `compose.yml` (`GRADLE_SERVICE=` picks the Gradle project, default `app`) | 20 s |
| `make test` | unit tests, no Docker | 10 s |
| `make itest` | integration tests, Testcontainers, one Postgres per fork | 1 min |
| `make build` | everything CI's `build` job runs: Spotless, unit, integration, JaCoCo gate 60 % | 1–2 min |
| `make test-scripts` | bash tests under `scripts/tests` | 5 s |
| `make format` | `spotlessApply`; run before every commit that touches Java or `*.gradle.kts` | |
| `make observability-up` | Prometheus + Grafana (`:3000`) + Alloy | |
| `make bricks` | list bricks, `[x]` when enabled | |
| `make add BRICK=<name>` / `make remove BRICK=<name>` | wire a brick in / take it out | |
| `make release-name` | version the next push to main would release | |

The tree is clean at `HEAD` (build, tests, Spotless): any failure after your change is yours. `make run`/`test`/
Expand All @@ -22,12 +25,12 @@ The tree is clean at `HEAD` (build, tests, Spotless): any failure after your cha
## How this is hosted and deployed

- One Hetzner VPS running `deploy/compose.prod.yml`: Caddy (TLS, only published ports) → 2 `app` replicas →
Postgres (loopback only), plus Watchtower and, by compose profile, an off-host backup sidecar (`backup`: pg_dump →
rclone, `BackupStale` alert; `docs/deployment.md` §6) and Alloy.
Postgres (loopback only), plus Watchtower. Enabled bricks add their services through compose `include:`.
- **Push to `main` = deploy.** `ci.yml` builds and tests, packages `ghcr.io/<owner>/<repo>:{latest,X.Y.Z,X.Y,sha-…}`,
tags `vX.Y.Z`, publishes a GitHub Release, and waits until `${PRODUCTION_URL}/version` reports the new revision
from every replica. Watchtower on the VPS pulls `latest` within 60 s and restarts replicas one at a time.
Push → live ≈ 5–6 min. Nobody ever SSHes to deploy.
Push → live ≈ 5–6 min. Nobody SSHes for app releases; enabling a brick or changing
compose needs `git pull && make prod-up` on the host (`docs/deployment.md`).
- Rollback on the VPS: `make prod-rollback TAG=<previous version>`. Schema is not rolled back: changesets must work
with the previous release (expand/contract; `docs/deployment.md` §5).
- `MANAGEMENT_PORT=8081` in prod: actuator is never published or proxied. `/version` is the only public build info.
Expand All @@ -45,15 +48,17 @@ explains why. The hook and CI reject anything else. Details: `.claude/skills/rel
locally. Read-only unless the user asked for a specific change; never fix schema by hand. Skill:
`.claude/skills/vps-db/SKILL.md`.

## Observability
## Bricks

`deploy/grafana/dashboards/app-overview.json` is the one dashboard; local Grafana loads it from disk, CI pushes it to
Grafana Cloud on merge. `DashboardJsonTest` guards it. Alerts ship only when Telegram secrets exist. Metric names
`app.<area>.<thing>`, low-cardinality tags only. Skill: `.claude/skills/grafana/SKILL.md`.
`.bricks` lists enabled bricks. `make add BRICK=<name>` wires `bricks/<name>/` in (compose include, env blocks, skill,
lockfile; its Java is a Gradle subproject `:bricks:<name>` while enabled); `make remove` reverses it. Read `bricks/<name>/README.md` before touching a
brick, `bricks/README.md` before authoring one. Skill: `.claude/skills/bricks/SKILL.md`. Observability (Grafana,
alerts, dashboard) and backups are bricks; their invariants live in their READMEs.

## Conventions

- Layers: `controller` → `service` → `repository`; `job` (schedulers, `*Scheduler`) may call services. Enforced by
- Service code lives under `services/app` (Gradle project `:app`; `make run`/`dev`/`lock` take `GRADLE_SERVICE=`). Layers:
`controller` → `service` → `repository`; `job` (schedulers, `*Scheduler`) may call services. Enforced by
`ArchitectureTest`, including constructor injection only, no `System.out`, `@Scheduled` only under `job`.
- Liquibase owns the schema (`db/changelog/changes/*.yaml`, `includeAll`); Hibernate validates. Boot 4 needs
`spring-boot-liquibase` on the classpath or nothing runs — it is there, do not remove it.
Expand All @@ -77,11 +82,7 @@ Grafana Cloud on merge. `DashboardJsonTest` guards it. Alerts ship only when Tel
- `APP_REPLICAS × DB_POOL_SIZE < max_connections (50)`.
- `stop_grace_period` (45 s) > `SPRING_LIFECYCLE_TIMEOUT_PER_SHUTDOWN_PHASE` (30 s) < `WATCHTOWER_TIMEOUT` (60 s).
- `deploy/.env.prod` required values carry `:?` guards; keep them when adding variables.
- `BackupStale` threshold (43200 s in `rules.json`) ≥ `BACKUP_MAX_AGE_HOURS` and ≥ 2 × the `BACKUP_CRON` interval,
or every late run pages.
- The rclone config is a mounted read-write **directory** (`deploy/backup/rclone/`), never a single file: rclone
saves refreshed tokens by rename, so a single-file mount logs a save error on every token refresh and breaks
providers that rotate refresh tokens.
- Brick partials of core services live in `override*.yml`; the Makefile is the only place that builds the `docker compose -f …` list (`make print-prod`).

## Process

Expand Down
Loading
Loading