Compare commits

..
108 Commits
Author SHA1 Message Date
CI 5adc79976d Bump version to 0.1.0-RC.12 [skip ci] 2026-11-07 18:26:34 +00:00
james.maes 0eb18a36da Merge pull request 'Merge develop into release/0.1 (Gitea-aligned RC)' (#54) from align/develop-into-release-0.1 into release/0.1
build / maven (push) Successful in 5m8s
maven / build (push) Successful in 5m8s
2026-11-07 18:11:14 +00:00
james.maesandClaude Opus 5.5 4d56012352 Merge develop into release/0.1 (Gitea-aligned RC)
build / maven (push) Canceled after 0s
maven / build (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 12:10:56 -06:00
james.maes 4ef8a6293f Merge pull request 'build(deps): align with Gitea registry (parent 2.2.1, QQQ 4.1.0-RC.26)' (#53) from deps/align-gitea into develop
build / maven (push) Successful in 1m50s
maven / build (push) Successful in 1m50s
2026-11-07 18:06:38 +00:00
james.maesandClaude Opus 5.5 c58324bccf build(deps): align with Gitea registry (parent 2.2.1, QQQ 4.1.0-RC.26)
build / maven (push) Successful in 3m28s
maven / build (push) Successful in 3m28s
pom.xml: parent -> 2.2.1
pom.xml: qqq.snapshot.version: 4.1.0-RC.14 -> 4.1.0-RC.26
pom.xml: qbit-object-storage: 0.1.0-RC.2 -> 0.1.0-RC.10
pom.xml: qqq-frontend-next: 1.0.0-RC.14 -> 1.0.0-RC.38
pom.xml: added qqq.version=4.1.0-RC.26 (parent BOM import)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:55:30 +00:00
james.maes 06b766d516 Merge pull request 'ci: pin shared workflow to v1' (#52) from ci/pin-v1-develop into develop
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (push) Successful in 20s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 16s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 11s
build / maven (push) Successful in 3m59s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
maven / build (push) Successful in 3m59s
2026-11-06 16:03:45 +00:00
james.maesandClaude Opus 5.5 5c32e3c5f3 ci: pin shared workflow to v1
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Waiting to run
build / maven (push) Successful in 3m27s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
maven / build (push) Successful in 3m27s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:04:43 +00:00
james.maes 1de785328e Merge pull request 'ci: use shared ci-workflows Maven workflow' (#51) from ci/ci-workflows into develop
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 6s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 7s
build / maven (push) Successful in 2m49s
QRun-IO/ci-policy: ci / Scoped workflow [workflow d20a578c7d818bc7ca32a2ac2c675da4e4f27385] (push) Successful in 8s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
maven / build (push) Successful in 2m48s
2026-11-05 23:14:34 +00:00
james.maesandClaude Opus 5.5 dbcfd85da8 ci: use shared ci-workflows Maven workflow
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
build / maven (push) Successful in 2m32s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 2s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
maven / build (push) Successful in 2m33s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 2m3s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 16s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Failing after 2s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Failing after 19m29s
Replace the platform-ci release callers with one caller of
KofTwentyTwo/ci-workflows maven.yaml (qqq-orb port).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:02:21 +00:00
forge-recovery ff76412d80 ci: enroll shared Gitea lifecycle
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
Shared Java Maven CI with Docker / secrets (push) Successful in 27s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (push) Successful in 24s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 10s
QRun-IO/ci-policy: ci / java-docker-3 (push) Successful in 11m52s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 6s
Shared Java Maven CI with Docker / verify (push) Successful in 2m38s
qrun-release / postpublish (push) Failing after 10m40s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (push) Successful in 1m52s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (push) Successful in 13s
Shared Java Maven CI with Docker / gate (push) Successful in 11s
qrun-release / release (push) Successful in 12m25s
Shared QRun source version / version (push) Failing after 10m40s
2026-11-02 22:10:12 +00:00
forge-recovery 76ac13c14f ci: enroll shared Gitea lifecycle
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 7s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (push) Successful in 22s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Successful in 7m24s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 11s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (push) Successful in 2m6s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (push) Successful in 20s
2026-11-02 21:30:25 +00:00
forge-recoveryandCodex 7ba2740283 ci: enroll shared Gitea release-candidate lifecycle
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 6s
Release candidate / release-rc (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 2m7s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 16s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Successful in 55m33s
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 20:13:41 +00:00
c82c847b35 ci: add the isolated postpublish job to the QRun release caller
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
Shared Java Maven CI with Docker / secrets (push) Successful in 15s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 4s
Shared Java Maven CI with Docker / verify (push) Successful in 1m35s
qrun-release / postpublish (push) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 5s
Release candidate / release-rc (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 14s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
Shared Java Maven CI with Docker / gate (push) Successful in 10s
qrun-release / release (push) Successful in 2h25m44s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 1m31s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Successful in 2h27m8s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 15:45:49 +00:00
dc69ffe245 ci: move the QRun release caller to the thin v2 contract
QRun-IO/ci-policy: ci / java-frontend-browser-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-9 (pull_request) Skipped
Shared Java Maven CI with Docker / secrets (push) Failing after 17s
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Canceled after 1h25m12s
Shared Java Maven CI with Docker / verify (push) Canceled after 0s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-7 (pull_request) Skipped
Shared Java Maven CI with Docker / gate (push) Canceled after 0s
qrun-release / release (push) Canceled after 1h25m16s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 21s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 1m39s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Successful in 1h41m21s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 06:16:09 +00:00
forge-recoveryandCodex 66fd64e1c4 ci: add the identical QRun release caller
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-2 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-1 (pull_request) Failing after 14m1s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 5s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Failing after 16s
QRun-IO/ci-policy: ci / gradle-0 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 1m46s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Failing after 15s
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 03:25:46 +00:00
james.maesandCodex 895727acbb ci: adopt managed orb 0.8 patch track (#45)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 15:28:38 -05:00
james.maesandCodex 886e76b333 ci: adopt shared orb 0.7.18 (#44)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:48:47 -05:00
james.maesandCodex 603f0c9d82 ci: adopt shared orb 0.7.17 (#43)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:16:17 -05:00
james.maesandCodex a17c6739bb ci: centralize release branch verification and workflows (#42)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 12:40:15 -05:00
james.maesandCodex bdf79eae09 ci: centralize QBit source scanning (#40)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 10:43:14 -05:00
james.maesandCodex ec5fef94bc ci: adopt central orb 0.7.12 and stable gates (#39)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 08:56:47 -05:00
james.maesandCodex b21db17a9f ci: centralize rc release completion in qqq orb (#38)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:25:46 -05:00
james.maesandCodex 7fa71bbaa6 ci: adopt shared orb 0.7.11 (#37)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 00:59:34 -05:00
james.maes 84f030d048 Merge pull request #36 from QRun-IO/feature/central-ci
ci: adopt shared QQQ orb standard
2026-10-20 18:57:40 -05:00
james.maesandCodex 9cb78190d8 ci: adopt verified central QQQ orb 0.7.3
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 18:52:23 -05:00
CircleCI 11156c8e8c Bump version to 0.1.0-RC.11 [skip ci] 2026-10-16 16:57:05 +00:00
james.maesandClaude Opus 5.5 0cc273bb52 Merge develop into release/0.1 for 0.1.0-RC.11 (#33 skip non-queryable tables, full error detail)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:54:24 -05:00
james.maes 91d86b016a Merge pull request #33 from QRun-IO/feature/backups-error-detail-unqueryable
fix(run): skip unqueryable tables and record sanitized error detail
2026-10-16 11:51:14 -05:00
james.maesandClaude Opus 5.5 0c28ea6263 fix(run): skip unqueryable tables and record sanitized error detail
A table on a backend module with no query interface (a storage-only
module such as qbit-edi's edi_preview_download) was classified DATA,
read by the keyset pager, and failed the whole run with
"IllegalStateException: Query is not implemented in this module".
TableRoles.isQueryable() now resolves the module through
QBackendModuleDispatcher (cached per backend type) and such tables are
EXTERNAL and always excluded by ScopeResolver with reason NOT_QUERYABLE,
even when selected by name or role. The Run backup review lists them,
and no per-table export process is offered for them.

Failed runs now persist "Backup failed: Class: message" in error_message
and the class, cause chain and top root-cause frames (bounded to 8 KB)
in error_detail, both sanitized by the new FailureText (URL user-info,
presigned query parameters, key=value secrets, Authorization/Bearer
values, AWS access key ids, PEM blocks and Postgres row values are
redacted; object keys and bucket names are kept).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:41:15 -05:00
CircleCI 036cd45f32 Bump version to 0.1.0-RC.10 [skip ci] 2026-10-16 16:31:49 +00:00
github-actions[bot] eaa6c9b93a Merge develop into release/0.1 for the next 0.1.0 RC 2026-10-16 15:57:53 +00:00
james.maes 825944fab4 Merge pull request #32 from QRun-IO/feature/backups-setup-attention-consistent
fix(app): Backups Setup and Needs attention agree
2026-10-16 10:19:49 -05:00
james.maesandClaude Opus 5.5 b25f4ff244 fix(app): Backups Setup and Needs attention derive from one list
The home said "1 item needs attention" in Setup while Needs attention said
"Nothing needs attention" (unresolved storage aliases). Every Setup item that
needs setup is now also listed under Needs attention, from the same item list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:08:20 -05:00
james.maes b58c0edbd5 Merge pull request #31 from QRun-IO/ci/release-rc-secrets-inherit
ci(release-rc): pass secrets so the shared workflow can sign the RC tag
2026-10-16 08:04:37 -05:00
james.maesandClaude Opus 5.5 17aad52cdf ci(release-rc): pass secrets to the shared workflow so it can sign the RC tag
QRun-IO/.github#2 adds a tag-and-release job that signs vX with the
QRUN_TAG_SIGNING_KEY secret after Maven Central confirms the artifact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:20:00 -05:00
CircleCI 33cb1f7a17 Bump version to 0.1.0-RC.9 [skip ci] 2026-10-15 19:24:05 +00:00
james.maesandClaude Opus 5.5 773629d253 Merge develop into release/0.1 for 0.1.0-RC.9 (cluster caches, QQQ RC.14)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:20:21 -05:00
james.maes 1b37582233 Merge pull request #30 from QRun-IO/feature/cluster-caches
Register backups caches with ClusterCaches (multi-node)
2026-10-15 14:11:22 -05:00
james.maesandClaude Opus 5.5 2f1964ddd1 build: QQQ 4.1.0-RC.14 (ships ClusterCaches)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:05:06 -05:00
james.maesandClaude Opus 5.5 e57ab62193 feat(cluster): register backups caches with ClusterCaches
The cached Prometheus text is shared as qbit-backups.metrics and
cleared on every node when a run finishes or a plan is saved or
deleted (the plan customizer now also runs post insert/update/delete);
the 60 s TTL stays as the safety net. SlaChecker's manifest-head and
bucket-audit throttles and the process-permission map are node-local.

Builds against QQQ 4.1.0-SNAPSHOT (qqq.snapshot.version) until the QQQ
RC with ClusterCaches is published; a release must pin that RC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:11:11 -05:00
james.maes e263f3bc8b Merge pull request #29 from QRun-IO/fix/producer-version-from-build
fix: qbit metadata version comes from the build
2026-10-15 01:42:56 -05:00
james.maesandClaude Opus 5.5 ab969bf99b fix: the qbit metadata version comes from the build, not a hardcoded constant
The producer read a literal version, so released artifacts reported a stale
or -SNAPSHOT version. qbit-backups.properties (version=${project.version}) is
Maven-filtered and the producer loads it; tests assert producer == bundle ==
artifact version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:27:59 -05:00
james.maes b17664db8c Merge pull request #28 from QRun-IO/fix/help-bundle-version
fix(help): help bundle version comes from the build, not a hardcoded SNAPSHOT
2026-10-14 22:37:20 -05:00
CircleCI 9855521637 Bump version to 0.1.0-RC.8 [skip ci] 2026-10-15 03:36:37 +00:00
james.maesandClaude Opus 5.5 99f74b4b5f Merge develop into release/0.1 for 0.1.0-RC.8 (#23 health checks)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:32:35 -05:00
james.maes eb413b6e15 Merge pull request #23 from QRun-IO/feature/health-checks
feat(health): register backups SLA and destination checks with core central health
2026-10-14 22:05:12 -05:00
james.maes 1e17dada29 Merge remote-tracking branch 'origin/develop' into feature/health-checks
# Conflicts:
#	CHANGELOG.md
2026-10-14 22:02:35 -05:00
james.maesandClaude Opus 5.5 991f310ccc fix(help): the help bundle carries the artifact version (Maven-filtered) instead of a hardcoded SNAPSHOT
On release branches the bundle's version and the producer's differed, so the
bundle test that asserts they match failed, and released bundles recorded
SNAPSHOT as their provenance. help/*.yaml is now filtered:
version: ${project.version}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:01:33 -05:00
CircleCI fa9c4c1774 Bump version to 0.1.0-RC.7 [skip ci] 2026-10-15 02:21:57 +00:00
james.maesandClaude Opus 5.5 c8ba79f55b Merge develop into release/0.1 for 0.1.0-RC.7 (#26 QQQ RC.8, #27 help bundle)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:18:12 -05:00
james.maes 0e798e3079 Merge pull request #26 from QRun-IO/feature/job-progress
feat(progress): backup phases, counts, bytes and log lines (durable job progress)
2026-10-14 21:15:10 -05:00
james.maesandClaude Opus 5.5 9a2a1dd88e Merge origin/develop into feature/health-checks; pin QQQ 4.1.0-RC.8
RC.8 ships the central health kernel (QRun-IO/qqq#1207).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:14:59 -05:00
james.maes 20dbcc223c Merge remote-tracking branch 'origin/develop' into feature/job-progress
# Conflicts:
#	CHANGELOG.md
#	README.md
#	pom.xml
#	src/main/java/com/kingsrook/qbits/backups/processes/BackupsProcesses.java
2026-10-14 21:09:15 -05:00
james.maes ab4c578ba8 Merge pull request #27 from QRun-IO/feature/help-bundle
feat(help): qbit-backups help bundle via QQQ help bundles, with coverage gate
2026-10-14 20:29:38 -05:00
james.maesandClaude Opus 5.5 acf51b6749 feat(help): ship the qbit-backups help bundle through QQQ help bundles, with coverage gate
Host-neutral help (deployment-specific facts removed), registered with
HelpBundles, gated by HelpBundleAssertions. QQQ 4.1.0-RC.7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:17:05 -05:00
CircleCI 7830226982 Bump version to 0.1.0-RC.6 [skip ci] 2026-10-14 22:44:27 +00:00
james.maesandClaude Opus 5.5 b1f258a3d5 Merge develop into release/0.1 for 0.1.0-RC.6 (#24)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:41:40 -05:00
james.maes a825360ba1 Merge pull request #24 from QRun-IO/feature/health-system-session
fix(health): run the context-less health check as the system user
2026-10-14 17:36:41 -05:00
james.maesandClaude Opus 5.5 b06c1fd8e4 feat(progress): report backup phases, counts, bytes and log lines as a durable job
Run backup now, restore and verify always run as durable jobs and report
their work through QQQ JobProgress: prepare, tables (N of M, bytes),
media (N of M, copied vs already stored) and upload phases, one log line
per table and artifact (keys relative to the run, never credentials or
row values), warnings and the failure. Restore and verify honour the job
page check for pause and cancel.

Needs QQQ 4.1.0-RC.8 (QRun-IO/qqq#1212).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:32:53 -05:00
CircleCI 3df24cda5a Bump version to 0.1.0-RC.5 [skip ci] 2026-10-14 22:22:38 +00:00
james.maesandClaude Opus 5.5 987c20899b Merge develop into release/0.1 for 0.1.0-RC.5 (#25 setup card, default plan, scheduled verify)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:19:54 -05:00
james.maes 01db11b123 Merge pull request #25 from QRun-IO/feature/tables-populated-defaults
feat: populated defaults, guided empty states, scheduled verify
2026-10-14 17:13:31 -05:00
james.maesandClaude Opus 5.5 6dea78881d feat: host-scheduler config so a host that ticks plans itself is not shown a false needs-setup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:10:21 -05:00
james.maesandClaude Opus 5.5 89f218937a fix: setup card reflects the scheduler, review checks the plan name, host help test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:07:54 -05:00
james.maesandClaude Opus 5.5 85f4ca0a6f feat: guided empty states, setup card, Create default plan, scheduled verify process
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:49:20 -05:00
james.maesandClaude Opus 5.5 e2d07b275a fix(health): run the context-less health check as the system user
BackupsHealthIndicator made its own context with a plain tokenless
QSession. Token-based authentication modules (OAuth2, Auth0) reject any
tokenless session other than QSystemUserSession, so the plan query failed
session validation and the check errored on such hosts (kof22). Use core
QSystemUserSession(instance).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:14:13 -05:00
james.maesandClaude Opus 5.5 f9767ede83 chore: drop optional qqq-middleware-health dependency; docs for backups health checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:55:03 -05:00
james.maesandClaude Opus 5.5 8d56613f7d feat(health): register backups SLA and destination checks; drop BackupsHealthIndicator
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:55:03 -05:00
james.maesandClaude Opus 5.5 0f9178dc14 feat(health): read-only destination probe mode
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:55:02 -05:00
james.maes 48e7320228 Merge pull request #22 from QRun-IO/feature/orb-0.6.9
ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
2026-10-14 10:56:39 -05:00
james.maesandClaude Opus 5.5 a5297e709d ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
Moves qqq-orb@0.6.8 to qqq-orb@0.6.9, which passes --update-snapshots so cached SNAPSHOT dependencies are re-checked on every build (QRun-IO/qqq-orb#22).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:06:12 -05:00
CircleCI 14bb6fa1b4 Bump version to 0.1.0-RC.4 [skip ci] 2026-10-14 12:26:09 +00:00
james.maesandClaude Opus 5.5 b1ff70d7d3 Merge develop into release/0.1 for 0.1.0-RC.4 (#21 schema fingerprint, keyset pager, guardrails)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:21:32 -05:00
james.maes f2af36ec5f Merge pull request #21 from QRun-IO/feature/arch-rc6-keyset
refactor(backups): core TableSchemaFingerprint + KeysetPager on RC.6 (#19 part 2)
2026-10-14 07:19:52 -05:00
james.maesandClaude Opus 5.5 054f6d90d7 build: qbit-build-parent 2.1.0 with architecture guardrails enforced
Enforce the qbit-bom#10 guardrails (G1, G3, G4, G7) and run tests in
strict raw-route mode (G2). All pass on the released QQQ 4.1.0-RC.6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:15:24 -05:00
james.maesandClaude Opus 5.5 5b7106b981 refactor(backups): core TableSchemaFingerprint and KeysetPager (QQQ 4.1.0-RC.6)
Replace the RC.6 stand-ins in TableFingerprints, TableReader and
StandardRestoreWriter with core TableSchemaFingerprint and KeysetPager.

Core fingerprints differ from the local ones: descriptor names, unique
keys as lists, association joinName, and empty-string backend types. New
manifests therefore record fingerprintAlgorithm "qqq-table-schema-v1".
A manifest without the field uses the frozen LegacyTableFingerprint, so
old backups still verify and restore.

Paging keeps ascending primary-key order, exclusive resume cursors,
bounded pages and the caller's transaction. Restore writes now require
the explicit backups.restore permission, checked during Review.

Closes #19 (architecture review QRun-IO/qqq#1150, finding F3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:36:44 -05:00
CircleCI cda334f2fe Bump version to 0.1.0-RC.3 [skip ci] 2026-10-14 00:53:05 +00:00
james.maesandClaude Opus 5.5 590b132fb6 Merge develop into release/0.1 for 0.1.0-RC.3 (core alerts, renamed alert types, #20)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:50:34 -05:00
james.maes 32034e5762 Merge pull request #20 from QRun-IO/feature/arch-alerts-core
refactor(alerts): direct core QAlerts binding on QQQ RC.5 (#19 part 1)
2026-10-13 19:37:01 -05:00
james.maesandClaude Opus 5.5 bc97d59b96 refactor(alerts): bind core QAlerts directly (QQQ 4.1.0-RC.5)
Core QAlerts shipped in 4.1.0-RC.5. Replace the reflective
AlertsQBitAlertSink stand-in with a compiled binding:
- raise maps to QAlerts.raise, resolve to QAlerts.resolve and heartbeat to
  QAlerts.heartbeat;
- the backup alert types register as QAlertTypeMetaData.
It stays quiet when no alerts implementation is installed, and an alerting
failure never fails a backup. The fake SPI test classes are deleted.

RC.5 rejects underscores in alert type names, so two types are renamed:
- backups.run.missing_in_storage becomes backups.run.missing-in-storage;
- backups.plan.owner_invalid becomes backups.plan.owner-invalid.

TableFingerprints, TableReader and StandardRestoreWriter are tagged
STAND-IN(..., removeBy=4.1.0-RC.6) for TableSchemaFingerprint and
KeysetPager.

Refs #19 (architecture review QRun-IO/qqq#1150, finding F3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:33:55 -05:00
james.maes 506741bd90 Merge pull request #18 from QRun-IO/feature/release-rc-automation
ci: automate RC releases; hotfix/* pushes no longer publish
2026-10-13 14:42:59 -05:00
james.maesandClaude Opus 5.5 95b971a5fb ci: automate RC releases; hotfix/* pushes no longer publish
Removes publish_hotfix_release: a hotfix/* branch push published a GA
release to Maven Central with no tag or review (QRun-IO/qbit-cdn#12,
QRun-IO/qqq#1121). hotfix/* branches now run test_only; a hotfix
release publishes from a reviewed vX.Y.Z tag through publish_release.
Adds .github/workflows/release-rc.yml (reusable QRun-IO/.github
qbit-release-rc): a manual dispatch, or merging a develop PR labelled
release:rc, merges develop into the release line and the release/* push
runs the existing CircleCI RC publish.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:29:57 -05:00
CircleCI f0a8c5000e Bump version to 0.1.0-RC.2 [skip ci] 2026-10-13 16:11:19 +00:00
james.maesandClaude Opus 5.5 8f3f9ec60a Merge develop into release/0.1 for 0.1.0-RC.2
qbit-object-storage 0.1.0-RC.2 (AWS SDK v2 2.41.34, GHSA-443w-3rq3-5m5h; #17).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:08:28 -05:00
james.maes e56cc1b021 Merge pull request #17 from QRun-IO/feature/object-storage-rc2
build(deps): qbit-object-storage 0.1.0-RC.2 (AWS SDK security fix)
2026-10-13 11:07:46 -05:00
james.maesandClaude Opus 5.5 664f467d11 build(deps): qbit-object-storage 0.1.0-RC.2 (AWS SDK GHSA-443w-3rq3-5m5h)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:05:06 -05:00
CircleCI cdaf218201 Bump version to 0.1.0-RC.1 [skip ci] 2026-10-13 00:26:41 +00:00
james.maes 4fb182e824 Merge pull request #16 from QRun-IO/chore/object-storage-rc1
build: depend on qbit-object-storage 0.1.0-RC.1 (no SNAPSHOT dependency)
2026-10-12 19:15:20 -05:00
james.maesandClaude Sonnet 5.5 1de38f03ae build: depend on qbit-object-storage 0.1.0-RC.1 (Central), not a SNAPSHOT
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 19:11:25 -05:00
james.maes cf2d027f9a Merge pull request #9 from QRun-IO/feature/home-polish
feat(home): readable Backups home, record screens, runnable demo
2026-10-12 19:10:47 -05:00
james.maesandClaude Sonnet 5.5 714ef906fe feat(home): drop duplicate app section, bounded trend bars, screenshots
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 19:07:45 -05:00
james.maesandClaude Sonnet 5.5 26c174f7bf feat(home): null-safe home, readable plan rows, record sections and child lists, runnable demo
- Fix the NPE in BackupsHomeData.plansNeedingAttention when slaStatus is null (and null plan ids)
- Home: full-width plan status rows, derived lateness, links that work in the Next UI (tablePathPrefix), bars for the size trend
- The Backups app attaches itself to its parent app (Admin) so the nav shows it
- Nav lists the main tables only; artifacts, verifications, restores and per-table results are child lists on their parents
- Record screens grouped into sections
- Test-scope demo server (memory backend, real engine history incl. a failed run)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 19:07:45 -05:00
james.maes 284f0cec8c Merge pull request #8 from QRun-IO/feature/object-storage-adapter
feat(storage): BackupStorage adapter over qbit-object-storage (MinIO tested)
2026-10-12 19:07:11 -05:00
james.maesandClaude Sonnet 5.5 2f16bd6515 test(storage): skip, do not fail, when the MinIO image cannot be started
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 19:04:44 -05:00
james.maesandClaude Sonnet 5.5 d4ad39f19d test(storage): skip the MinIO tests when Docker is not available
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 19:02:10 -05:00
james.maesandClaude Sonnet 5.5 ba3959e7dc feat(storage): BackupStorage adapter over qbit-object-storage, tested on MinIO
A streamed write of unknown length is bridged to the storage qbit put; permanent
delete reports locked objects; failures map to BackupStorageException reasons.
Object storage is an optional dependency. Tested against a MinIO container with
object lock: the adapter contract and a full backup run.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 19:00:59 -05:00
james.maes a67e4c4a57 Merge pull request #7 from QRun-IO/feature/prune-schedule
feat: run lifecycle - schedule, lease, cancel, resume, GFS prune, object lock, plan validation
2026-10-12 19:00:25 -05:00
james.maesandClaude Sonnet 5.5 4022fa1eee test(verify): do not depend on which mismatch is reported first for a flipped byte
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 18:57:50 -05:00
james.maesandClaude Sonnet 5.5 6017f3ed2d test(sla): save the plan with a bucket that exists, then remove it from the config
Plan save now validates the bucket alias (lifecycle), so the SLA test creates the
plan while the bucket is configured and removes it afterwards.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 18:55:21 -05:00
james.maesandClaude Sonnet 5.5 01b60708f8 feat: run lifecycle - plan schedule, run lease, cancel, resume, GFS prune, object lock, plan validation
- BackupPlanSyncToScheduledJobProcess (+ verify variant) and the plan table wiring
- BackupRunCoordinator: one run per plan (live-heartbeat lease, QUEUED to RUNNING,
  SKIPPED_CONCURRENT warning), maxConcurrentRuns queueing, scheduled entry point
  (backupRunScheduledPlan) as the system session with plan-owner validation
- plan save validation (admin and read on resolved tables, object lock vs bucket
  capabilities, encryption rules, unsupported formats, planKey immutable after a run)
- PruneCalculator (GFS) and BackupPruner (retention classes, deletePermanent on the
  prune alias, kept: locked until, debris, file store mark-and-sweep)
- object lock applied at write time in the engine, parts catalogued as they complete
- Back up these records, Cancel, Resume (PER_TABLE, from the last complete part),
  Test destination and Prune processes with Review and Result lines

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 18:54:24 -05:00
james.maes 06c87afa20 Merge pull request #6 from QRun-IO/feature/restore
feat(restore): restore, catalog import and download (spec 9.3-9.7)
2026-10-12 18:53:53 -05:00
james.maesandClaude Sonnet 5.5 b928920989 feat(restore): restore, catalog import and download processes
backupRestore (Restore... on backups_run) and backupRestoreTable (Restore this table
on backups_artifact): inputs for tables, per-table filters, target table, mode
(INSERT_NEW, UPSERT, REPLACE), match key, id strategy (PRESERVE, REMAP_NEW_IDS with
backups_id_map and reference rewriting) and options. Review is a real dry run with
per-table summary lines (inserted, updated, unchanged, skipped, deleted, conflicts,
orphans, schema, sequences, files) and a masked sample diff. Execute takes the
pre-restore backup, writes in manifest order in page transactions behind a
RestoreWriter (core RestoreWriteFlag and resyncKeySequences selected reflectively),
restores files through the content-addressed store with a SHA-256 re-check, and
records backups_restore_run and backups_restore_run_table rows. Safety rails enforced
in the engine.

backupImportCatalog rebuilds backups_run and backups_artifact from the manifests under a
bucket alias and prefix (trigger IMPORTED). backupDownload issues audited presigned
links, ciphertext only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 18:51:37 -05:00
james.maes 758ef90cc1 Merge pull request #5 from QRun-IO/feature/verify-sla-alerts
feat(verify): backup verification, SLA checker, alerts, health, events
2026-10-12 18:51:06 -05:00
james.maesandClaude Sonnet 5.5 eb481a475a feat(verify): backup verification, SLA checker, alerts, health, events, metrics
Verify backup (STORAGE, CONTENT, TEST_RESTORE with a MEMORY scratch target), the
scheduled backupCheckSla, every alert of the spec table with stable dedupe keys, a
reflective adapter for the Alerts qbit producer SPI, the health indicator, ESB events
and the Prometheus text.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 18:48:54 -05:00
james.maes 3d7343c394 Merge pull request #4 from QRun-IO/feature/backups-foundation
feat: backups foundation (tables, backup engine, files, manifest, home)
2026-10-12 18:48:20 -05:00
james.maesandClaude Sonnet 5.5 eea9f63878 feat: backups foundation - tables, scope, codecs, manifest, backup engine, file copy, home
Tables (spec 4), permissions, scope resolver and table roles, canonical JSONL
and CSV codecs, per-part SHA-256 and rowsHash, manifest written last, the backup
engine with referenced-file copy into the deduplicated store, a narrow storage
interface with an in-memory implementation, Run backup now (streamed ETL), and the
Backups app home. Stand-ins for unreleased core hooks are marked in the code.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 16:14:32 -05:00
james.maes 2f520e9f15 Merge pull request #3 from QRun-IO/feature/ga-only-release-tags
ci: publish only GA tags (vX.Y.Z), never RC tags
2026-10-12 16:02:11 -05:00
james.maesandClaude Opus 5.5 7ef0849a02 ci: publish only GA tags (vX.Y.Z), never RC tags
An RC tag matched /v.*/ and the orb republished it as the next RC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:59:28 -05:00
224 changed files with 35633 additions and 39 deletions

No files matched your search

+1 -1
View File
@@ -11,4 +11,4 @@ workflows:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /v.*/
only: /^v\d+\.\d+\.\d+$/
+7
View File
@@ -0,0 +1,7 @@
name: build
on:
push:
jobs:
maven:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
secrets: inherit
-25
View File
@@ -1,25 +0,0 @@
# Standard QRun release caller (v2, thin contract): every push and manual dispatch delegates to the protected qrun-release-v1 channel. Do not edit per repository.
name: qrun-release
on:
push: {}
workflow_dispatch: {}
permissions:
contents: read
actions: read
jobs:
release:
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/java-docker.yaml@qrun-release-v1
with:
runner: linux-small
docker-runner: container-build
browsers: true
browser-runner: browser-build
postpublish:
if: ${{ needs.release.outputs.capture == 'true' }}
needs: release
permissions:
contents: write
actions: write
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qrun-source-version.yaml@qrun-release-v1
with:
runner: linux-small
+74
View File
@@ -0,0 +1,74 @@
name: Release candidate
'on':
workflow_dispatch:
inputs:
dry_run:
description: Plan a cut without changing release state
type: boolean
default: false
finalize:
description: Retry finalization of an existing cut; do not cut another RC
type: boolean
default: false
recover:
description: Recover the same published RC after a deployment timeout; never
reupload
type: boolean
default: false
circle_build:
description: Original Circle publisher build number (recovery only)
type: string
default: ''
merge_sha:
description: Existing cut merge_sha (finalize retry only)
type: string
default: ''
bump_sha:
description: Existing cut bump_sha (finalize retry only)
type: string
default: ''
branch:
description: Existing cut branch (finalize retry only)
type: string
default: ''
version:
description: Existing cut version (finalize retry only)
type: string
default: ''
pull_request:
types:
- closed
branches:
- develop
repository_dispatch:
types:
- qbit-rc-published
permissions:
contents: write
actions: read
checks: read
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
'release:rc'))
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
== 'workflow_dispatch' && inputs.finalize) }}
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
}}
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|| '' }}
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|| inputs.merge_sha || '' }}
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|| inputs.bump_sha || '' }}
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|| inputs.branch || '' }}
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|| inputs.version || '' }}
secrets: inherit
+3 -3
View File
@@ -6,11 +6,11 @@ browsers: true
api_checks: false
docs_only: false
release_on_main: false
release_tag_policy: v-prefix
test_branch_policy: non-release-no-hotfix
release_tag_policy: stable
test_branch_policy: non-release
publish:
snapshot: true
release_candidate: true
release: true
hotfix: true
asciidoc: false
release_completion: true
+1 -1
View File
@@ -35,7 +35,7 @@ This file provides guidance to Codex when working with code in this repository.
## Dependencies
- Java 21.
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.4` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.6` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
- Tests: JUnit 5, AssertJ.
## Release
+29
View File
@@ -5,4 +5,33 @@ All notable changes to this project are documented here. The format follows Keep
## [0.1.0] - Unreleased
### Added
- Central `backups.sla` and per-active-plan `backups.destination.<planId>` health checks, with bounded read-only destination diagnostics and secret-free status details.
- Always-durable backup, resume, scheduled backup, restore and verify processes with conservative `NONE` idempotency and stable backup identities on explicit job retry.
- Weighted backup phases, table/file counts, stored bytes, safe per-table/artifact logs, warnings, failures and resume notices through QQQ `JobProgress`.
- Cooperative job cancellation during backup pages, media copying, restore pages and verification scans; local media-reference spooling keeps phase separation from growing heap usage across tables.
- Complete host-neutral in-app help bundle with live references and a help coverage gate. Hosts can override entries with a HOST bundle.
- Guided empty states: help bundle `help/qbit-backups.yaml` (app and all nine tables, with "How rows get created") applied by `BackupsHelp`, and a Setup card on the Backups home (plan, scope rules, storage, schedule, verification, prune bucket).
- "Create default plan" table action on Backup plans: adds "Nightly full" for a storage bucket alias; insert-only.
- `backupVerifyPlan`: the scheduled verification process. A plan verify cron created a scheduled job for a process that did not exist, so scheduled verification never ran.
- Repository scaffold: `BackupsQBitConfig` and `BackupsQBitProducer` (table prefix `backups_`, permissions `backups.*`), build, CI and repository standard files.
### Changed
- Tables whose backend module has no query interface (storage-only modules such as qbit-edi `edi_preview_download`) are classified EXTERNAL and always left out of a backup with the reason `NOT_QUERYABLE`, even when selected by name or role; the Run backup review lists them. Before, such a table was read as DATA and the whole run FAILED with "Query is not implemented in this module".
- Failed runs persist an actionable `error_message` ("Backup failed: Class: message") and fill `error_detail` with the exception class, the cause chain and the top root-cause frames (bounded to 8 KB). Both go through `FailureText.sanitize`, which redacts URL user-info, presigned query parameters, key=value secrets, Authorization/Bearer values, AWS access key ids, PEM blocks and Postgres row values while keeping object keys and bucket names.
- Multi-node deployments: the cached Prometheus text (`BackupsMetrics`) is registered with QQQ `ClusterCaches` as `qbit-backups.metrics` and cleared on every node when a run finishes or a plan is inserted, updated or deleted; the 60 s TTL remains the safety net. `SlaChecker`'s manifest-head and bucket-audit throttles and `BackupsRuntime`'s process-permission map are registered as node-local, with reasons. `BackupsClusterCachesTest` audits the package for unregistered static caches and covers a remote invalidation.
- Builds against QQQ `4.1.0-SNAPSHOT` (`qqq.snapshot.version`) for `ClusterCaches` until the next QQQ RC ships; a release must pin that RC.
- Read the QBit metadata version from the Maven artifact version so release builds report their released version.
- Target QQQ `4.1.0-RC.8` (job progress).
- Persist controlled backup failure messages instead of untrusted provider exception text.
- Pin QQQ to released 4.1.0-RC.7 and replace the temporary help loader with core HelpBundles registration.
- Saving a plan with Use read replica on is refused (it was never read); the not-yet-available settings (read replica, native dump, incremental, PARQUET, ZSTD) say so in their field help; scheduledJobId is documented as unused.
- Build against the released QQQ BOM 4.1.0-RC.5; replace reflective alerts binding with core QAlerts and real sink integration tests.
- Register backup alert types for every instance while preserving custom backup sinks and no-op behavior without a configured core sink.
- Use RC.5-valid alert names `backups.run.missing-in-storage` and `backups.plan.owner-invalid` (and matching deduplication keys). Hosts using the old underscore names must update routing rules.
- Target QQQ 4.1.0-RC.6 and replace the fingerprint, keyset pager and reflective restore bridges with typed core APIs.
- New manifests identify `qqq-table-schema-v1` and carry core schema descriptors; restore and content verification still accept pre-RC.6 manifests with the original fingerprint algorithm.
- Preserve primary-key ordering, exclusive resume cursors, bounded pages and caller-owned transactions; reject non-orderable primary keys and scope filters with skip/limit.
- Require the core `backups.restore` permission during Review when using a core restore writer, including anonymous-mode hosts; preserve supplied timestamps through `RestoreWriteFlag`.
### Removed
- The legacy `BackupsHealthIndicator` and its optional `qqq-middleware-health` dependency; central health registration replaces manual indicator configuration.
+80 -1
View File
@@ -10,7 +10,7 @@
## Requirements
- Java 21.
- QQQ 4.1 (built against `4.1.0-RC.4`).
- QQQ `4.1.0-RC.8` or later.
## Install
@@ -22,6 +22,10 @@ new BackupsQBitProducer()
.produce(qInstance);
```
## Setup
This qbit ships no DDL and seeds nothing from a changelog: its one setup value, the storage bucket alias, belongs to your application. On a fresh install open Backups, read the Setup card, and run **Create default plan** on Backup plans (it asks only for the alias of a bucket your storage resolver provides). It creates the insert-only "Nightly full" plan: whole instance, data and audit tables, JSONL and gzip, 02:00 UTC, verified Sundays 04:00 UTC. Edit any setting afterwards. Help for every table, including how its rows get created, ships in `help/qbit-backups.yaml`.
## Naming
Tables are `backups_<name>` (lowercase snake_case, backend name equal to the table name). Permissions are `backups.<action>`.
@@ -36,8 +40,83 @@ Follow the QQQ [adoption checklist](https://github.com/QRun-IO/qqq/blob/develop/
./mvnw verify
```
See [the RC.6 migration notes](docs/RC6-MIGRATION.md) for fingerprint compatibility,
paging guarantees and restore permissions.
Health checks use the core health API released in QQQ `4.1.0-RC.8`.
The producer registers `backups.sla` and a read-only destination check for each
active plan (`backups.destination.<planId>`). With dependencies cached locally,
run `./mvnw -o verify`; no enforcer skip flags are required.
## In-app help
The producer registers [help/qbit-backups.yaml](src/main/resources/help/qbit-backups.yaml)
as a QBIT bundle. Hosts can register a `layer: HOST` bundle with matching entry keys
and roles to override its text. Core's help startup sync applies the bundle when the
host installs the help content table; registration also works without that table.
`BackupPlanScheduleTest` checks coverage with `HelpBundleAssertions`, including
live references and optional scheduler processes. `BackupsTablesGuardrailTest`
checks empty-state, widget and possible-value-source coverage. Run it with
`./mvnw verify`. Keep the bundle's literal version aligned with the POM version.
Always use the Maven wrapper (Maven 3.9.11); Maven 3.10 cannot read the parent POM.
## Durable jobs and progress
Run backup now, Resume, scheduled backups, both Restore actions, and Verify backup
are always durable jobs. Hosts must configure QQQ durable jobs and its worker.
Review remains a preview; execution runs in the background and reports through
the job page and jobs tray.
Backups report Resolve scope, Back up tables, Copy media files, and Write catalog
and manifest. Separate verification jobs report a single Verify phase.
The job includes table/file counts, stored bytes, per-table
rows and duration, artifact names, warnings and failures. Logs exclude row values,
source file keys, storage credentials and endpoints. Provider errors use controlled
failure messages; detailed exceptions stay in server diagnostics.
These processes use `NONE` idempotency: a worker interruption requires review before
retry because object writes and catalog updates are not atomic. Explicitly retrying
a backup job reuses its run and resumes completed parts; it does not create another
backup. Snapshot runs that cannot resume must be started as a new backup. See
[the progress and retry contract](docs/DESIGN.md#job-progress-implementation) for limits and cancellation.
## License
Apache License 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
### Alerts integration
Backups binds directly to core QAlerts in QQQ 4.1.0-RC.5 and declares its nine
alert types on each instance. Hosts install an `AlertSinkInterface` through
`AlertsInstanceMetaData.withSink(QCodeReference)`. Without a configured sink,
backup alert calls remain no-ops (they do not use core's logging fallback).
Custom `BackupAlertSink` bindings are preserved. Core handles dispatch failures;
adapter failures are caught and logged once, so alerting cannot fail a backup.
Raise and resolve follow core's transaction-aware dispatch; heartbeats run immediately.
The backup SLA checker still owns expected intervals and grace periods.
RC.5 requires hyphenated alert names: `backups.run.missing-in-storage` and
`backups.plan.owner-invalid` replace the former underscore spellings, including
their deduplication keys. Update host routing rules that used those spellings.
## Health checks
The producer registers these central checks under component `backups`, category `STORAGE`:
| Name | Kind | Interval | Timeout |
| --- | --- | --- | --- |
| `backups.sla` | DEPENDENCY | 900s (15 minutes) | 5000ms |
| `backups.destination.<planId>` | DEPENDENCY | 86400s (24 hours) | 30000ms |
The SLA check reads persisted `slaStatus`: critical active plans that are LATE,
FAILING or MISSING_IN_STORAGE yield DOWN; non-critical failures yield DEGRADED.
Only UNVERIFIED/UNKNOWN plans yield UNKNOWN; no active plans yield UP. Details
contain counts per SLA status. The scheduled `backupCheckSla` still computes SLA state.
Destination checks are supplied for active plans and reuse `DestinationProbe` in
read-only list/head mode, including on unlocked buckets. They never write, delete,
or resolve prune credentials. The Test destination action retains its write tests.
Storage calls are bounded by the central runner, with deadline checks between calls.
Override intervals in `health_check` and disable checks through `health_check.enabled`.
+7
View File
@@ -0,0 +1,7 @@
# Upgrading
## Unreleased
- No schema change and no new include line. Hosts get the help text and the Setup card automatically.
- Plans with `use_read_replica` true are now refused on the next save (the setting was never used). Turn it off.
- Hosts that set a plan `verifyCronExpression` and a scheduler now get working scheduled verification: the `backupVerifyPlan` process exists. It verifies the plan's latest successful run through the plan's `verifyLevel` (CONTENT when unset). Expect verification rows and, on failure, alerts.
+15
View File
@@ -1,3 +1,18 @@
# QBit Backups: design
The design for this qbit is tracked in [QRun-IO/qqq#1091](https://github.com/QRun-IO/qqq/pull/1091). This repository implements it; the implementation is in progress.
## Job progress implementation
Backup, resume, scheduled backup, restore, and verify execution use always-durable
jobs with `NONE` idempotency against the QQQ job-progress API targeted for RC.8.
Backup phases are `prepare`, `tables`, `media`, and `upload`, weighted 2, 75, 18,
and 5. Verification runs separately with a single `verify` phase.
Progress reports table/file counts, stored bytes, and safe artifact names without
row data, source keys, credentials, or endpoints. Cancellation is cooperative at
page and media-copy boundaries. Media references spool locally between phases.
Object writes and catalog updates are not atomic, so worker interruptions require
review before retry. Explicit backup retries reuse the run and resume completed
parts; snapshot runs that cannot resume require a new backup.
+100
View File
@@ -0,0 +1,100 @@
# RC.6 core migration (issue #19, F3)
The POM targets `4.1.0-RC.6`. Until that release is available, local verification
uses `./mvnw -B verify -Dqqq.snapshot.version=4.1.0-ARCHRC6-SNAPSHOT` against the
installed build from `qqq-wt-archrc6`. No core source changes are needed.
## Fingerprint compatibility
The algorithms are **not byte-compatible**, even for identical table metadata:
| Descriptor property | Pre-RC.6 backup algorithm | Core algorithm |
| --- | --- | --- |
| Table and key | `name`, `primaryKey` | `table`, `primaryKeyField` |
| Fields | Map keyed by name | Sorted list with explicit `name` |
| Field flags | `required`, `pvs`, `heavy`, `sensitive` | `isRequired`, `possibleValueSource`, `isHeavy`, `isSensitive` |
| Unique keys | Sorted `+`-joined strings | Sorted lists of field names |
| Associations | Sorted `name->table` strings | Objects with name, associatedTable and joinName |
| Null backend type | JSON null | Empty string |
| Escaping | Historical hand-written escaping | Jackson JSON escaping |
New table entries contain `fingerprintAlgorithm: "qqq-table-schema-v1"`, the core
`schema` descriptor, and `fingerprint: "sha256:" + core.getHash()`. The outer
manifest remains version 1; row parts, checksums, compression and storage paths
are unchanged. The instance/catalog hash still combines the ordered table
fingerprint strings, so its value changes too. New manifests require this RC.6
reader; older readers do not understand the new descriptor shape.
An absent algorithm identifies the old `qbit-backups-table-v1` algorithm. The
reader also accepts that explicit identifier. `LegacyTableFingerprint` freezes
the original descriptor and hash implementation solely for comparison against
existing backups; no new backup uses it. Restore and content verification select
the declared algorithm. Unknown identifiers never match a known fingerprint.
Restore normalizes either field shape for its existing compatibility analysis
when a table has changed. Fingerprint equality is not required for a compatible
schema migration, as before.
`src/test/resources/manifests/legacy-schema.json` freezes descriptors and hashes
produced by the original stand-in for the blog fixture. Tests place these into a
real backup manifest without the algorithm field, verify its content, mutate the
live data, then restore and compare the complete content hash including dates.
They also cover optional additions and incompatible type changes in old schemas.
## Paging and consistency
`TableReader.Keyset` delegates to core `KeysetPager`. Every query uses
`(scope filter) AND pk > lastKey ORDER BY pk ASC LIMIT pageSize`, with no offset.
The resume key is exclusive. Page size is a row bound, **not a JDBC fetch-size
setting**. Core's normal bounded QueryAction path retains backend driver defaults;
this adapter does not opt into the large-result streaming query hint or change
JDBC fetch size. Heavy/hidden/password fields and untranslated stored values keep
the same query settings and existing backup permission boundary.
Every page receives the caller's transaction. The pager does not open, commit,
close, or replace that transaction. A test opens a real core memory snapshot,
changes a later page's data, and proves every page still sees the original state.
The existing BackupEngine continues to report BEST_EFFORT and reject
SNAPSHOT_REQUIRED: adopting the consistent-read opening API at engine level is
separate work. This migration does not claim snapshot guarantees for ordinary
backup runs.
Core can stream non-orderable keys without deterministic key order; backups
explicitly reject those keys instead. Core rejects scope filters carrying skip
or limit, and replaces user ordering with ascending primary-key ordering. Tests
cover OR filters, resume, full and partial pages, empty scans and invalid inputs.
## Restore writes
`StandardRestoreWriter` now constructs `RestoreWriteFlag` directly and calls
`QBackendModuleInterface.resyncKeySequences`, consuming typed `SequenceResyncResult`
values. Only RESYNCED results are reported as sequence changes; NOT_NEEDED and
NOT_APPLICABLE remain normal outcomes. There is no reflection or unavailable-core
fallback. The core flag preserves supplied dates and carries the requested
customizer, automation, audit and event settings.
Core always requires an actual session permission `backups.restore` for its
restore flag, including hosts with general core enforcement disabled. The qbit's
anonymous-mode convenience setting does not grant that permission. Review now
reports this requirement before executing a restore; test sessions explicitly
hold it. Custom writers that do not use the core flag keep their existing qbit
permission checks.
## HTTP operations and wire changes
No HTTP operations are added, removed or modified: method, path and operationId
are unchanged (no operation list entries for this migration). The RC.6 UI API
extension documentation was reviewed; this task introduces no Javalin route or
middleware extension. Wire changes are confined to the manifest fields and
fingerprint values described above, plus an earlier restore permission error in
Review for anonymous sessions lacking `backups.restore`.
## Local verification (2026-10-13)
- `./mvnw -B clean verify -Dqqq.snapshot.version=4.1.0-ARCHRC6-SNAPSHOT`:
BUILD SUCCESS; 158 tests, zero failures/errors/skips; both JaCoCo gates passed.
Existing backup, restore and MinIO integration tests ran.
- Separate Checkstyle 14.3.0 audit of all 16 changed Java files (main and test):
zero violations, using the shared Kingsrook-derived rules with this repository's
Apache file header. This repository has no Checkstyle execution configured in
its POM; audit configuration and results are local under `.cache/style/`.
- `git diff --check`: passed. No commit, push or core source modification.
+7
View File
@@ -0,0 +1,7 @@
# Backups home and record screenshots
Taken 2026-10-12 against `BackupsDemoServer` (src/test/.../demo, memory backend, real engine history) and the Next UI 1.0.0-RC.14 jar.
Light and dark at 390, 600, 768, 1024, 1440, 1920 px (home) and 390, 1440 px (records).
- `.` : this branch (foundation base): home, plan, run, failed run, lists.
- `with-restore-verify/` : a scratch merge of PRs #5 (verify), #6 (restore), #7 (prune/schedule) onto this branch, so verifications, a restore run and retention classes exist. Not part of this PR.
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

+76 -4
View File
@@ -18,7 +18,10 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<revision>0.1.0-SNAPSHOT</revision>
<qqq.version>4.1.0-RC.26</qqq.version> <!-- feeds qbit-build-parent's qqq-bom-pom import -->
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
<revision>0.1.0-RC.12</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
@@ -27,7 +30,7 @@
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.0.0</version>
<version>2.2.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -72,6 +75,42 @@
<artifactId>qqq-backend-core</artifactId>
</dependency>
<!-- optional: ESB events (apps without qqq-esb never load it) -->
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-esb</artifactId>
<optional>true</optional>
</dependency>
<!-- Optional: the production BackupStorage adapter. Apps that install this qbit -->
<!-- with object storage already have it; others use their own BackupStorage. -->
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-object-storage</artifactId>
<version>0.1.0-RC.10</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>2.0.3</version>
<scope>test</scope>
</dependency>
<!-- Demo server only (src/test/.../demo): never shipped. -->
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-javalin</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>1.0.0-RC.38</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
@@ -86,7 +125,40 @@
</dependencies>
<build>
<resources>
<!-- help bundles carry the artifact version (QQQ help bundle provenance) -->
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>help/*.yaml</include>
<include>qbit-backups.properties</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>help/*.yaml</exclude>
<exclude>qbit-backups.properties</exclude>
</excludes>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<systemPropertyVariables combine.children="append">
<qbit.artifactVersion>${project.version}</qbit.artifactVersion>
</systemPropertyVariables>
<!-- the MinIO test container default credentials, resolved through ${env.X} refs like production -->
<environmentVariables>
<STORAGE_TEST_ACCESS_KEY>minioadmin</STORAGE_TEST_ACCESS_KEY>
<STORAGE_TEST_SECRET_KEY>minioadmin</STORAGE_TEST_SECRET_KEY>
</environmentVariables>
</configuration>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
@@ -138,14 +210,14 @@
<profiles>
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
<!-- imports the released 4.1 BOM, this profile is active by default and -->
<!-- imports qqq-bom-pom 4.1.0-RC.4 ahead of the parent's 4.0.0 BOM. -->
<!-- imports qqq-bom-pom 4.1.0-RC.8 ahead of the parent's 4.0.0 BOM. -->
<profile>
<id>qqq-snapshot</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.4</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -18,16 +18,23 @@ package com.kingsrook.qbits.backups;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;
import com.kingsrook.qbits.backups.codec.ArtifactTransform;
import com.kingsrook.qbits.backups.verify.ScratchTarget;
import com.kingsrook.qbits.backups.storage.BackupStorage;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** Host-facing configuration for the Backups QBit.
** Configuration of the Backups qbit.
**
** Tables are named backups_<name> (lowercase snake_case, backend name equal
** to the table name); permissions are backups.<action>.
** Storage is resolved through a function from a bucket alias (the value of
** backups_plan.storageBucketAlias) to a BackupStorage. The production resolver
** is backed by qbit-object-storage; tests and local development use the
** in-memory implementation. No credential is ever held here or in a table.
*******************************************************************************/
public class BackupsQBitConfig implements QBitConfig
{
@@ -35,6 +42,27 @@ public class BackupsQBitConfig implements QBitConfig
public static final String PERMISSION_BASE_NAME = "backups";
private String backendName;
private String appName = "backups";
private String appLabel = "Backups";
private String parentAppName;
private String tablePathPrefix;
private String schedulerName;
private String hostSchedulerDescription;
private Boolean hostSchedulerRunsVerification = false;
private String instanceName;
private Integer maxConcurrentRuns = 1;
private Integer slaCheckIntervalSeconds = 300;
private Boolean openWhenAnonymous = false;
private Boolean prometheusEndpoint = false;
private Function<String, BackupStorage> storageResolver;
private Integer fileReverifyDays = 30;
private Function<String, ArtifactTransform> artifactTransformResolver;
private ScratchTarget scratchTarget;
private Predicate<String> planOwnerValidator;
private String esbDestinationName;
@@ -89,4 +117,386 @@ public class BackupsQBitConfig implements QBitConfig
return (this);
}
/*******************************************************************************
** Getter for appName
*******************************************************************************/
public String getAppName()
{
return (appName);
}
/*******************************************************************************
** Fluent setter for appName
*******************************************************************************/
public BackupsQBitConfig withAppName(String appName)
{
this.appName = appName;
return (this);
}
/*******************************************************************************
** Getter for appLabel
*******************************************************************************/
public String getAppLabel()
{
return (appLabel);
}
/*******************************************************************************
** Fluent setter for appLabel
*******************************************************************************/
public BackupsQBitConfig withAppLabel(String appLabel)
{
this.appLabel = appLabel;
return (this);
}
/*******************************************************************************
** Getter for parentAppName (for example "admin", so this is Admin, Backups).
*******************************************************************************/
public String getParentAppName()
{
return (parentAppName);
}
/*******************************************************************************
** Fluent setter for parentAppName
*******************************************************************************/
public BackupsQBitConfig withParentAppName(String parentAppName)
{
this.parentAppName = parentAppName;
return (this);
}
/*******************************************************************************
** Getter for tablePathPrefix (the front of the links the home makes to tables and
** records, "/app" for the Next dashboard, which routes /app/{table}/{id}).
*******************************************************************************/
public String getTablePathPrefix()
{
return (tablePathPrefix == null ? "/app" : tablePathPrefix);
}
/*******************************************************************************
** Fluent setter for tablePathPrefix
*******************************************************************************/
public BackupsQBitConfig withTablePathPrefix(String tablePathPrefix)
{
this.tablePathPrefix = tablePathPrefix;
return (this);
}
/*******************************************************************************
** Getter for hostSchedulerDescription: when the application runs plan crons with
** its own tick instead of a QQQ scheduler (schedulerName), a short name for it.
** The Setup card then treats plan crons as firing.
*******************************************************************************/
public String getHostSchedulerDescription()
{
return (hostSchedulerDescription);
}
/*******************************************************************************
** Fluent setter for hostSchedulerDescription.
*******************************************************************************/
public BackupsQBitConfig withHostSchedulerDescription(String hostSchedulerDescription)
{
this.hostSchedulerDescription = hostSchedulerDescription;
return (this);
}
/*******************************************************************************
** Getter for hostSchedulerRunsVerification: whether the host's own tick also runs
** the verify cron (by calling the backupVerifyPlan process). False by default.
*******************************************************************************/
public Boolean getHostSchedulerRunsVerification()
{
return (hostSchedulerRunsVerification);
}
/*******************************************************************************
** Fluent setter for hostSchedulerRunsVerification.
*******************************************************************************/
public BackupsQBitConfig withHostSchedulerRunsVerification(Boolean hostSchedulerRunsVerification)
{
this.hostSchedulerRunsVerification = hostSchedulerRunsVerification;
return (this);
}
/*******************************************************************************
** Getter for schedulerName (the QQQ scheduler used for plan jobs and the SLA checker)
*******************************************************************************/
public String getSchedulerName()
{
return (schedulerName);
}
/*******************************************************************************
** Fluent setter for schedulerName
*******************************************************************************/
public BackupsQBitConfig withSchedulerName(String schedulerName)
{
this.schedulerName = schedulerName;
return (this);
}
/*******************************************************************************
** Getter for instanceName (used in object keys); defaults to the QInstance name
*******************************************************************************/
public String getInstanceName()
{
return (instanceName);
}
/*******************************************************************************
** Fluent setter for instanceName
*******************************************************************************/
public BackupsQBitConfig withInstanceName(String instanceName)
{
this.instanceName = instanceName;
return (this);
}
/*******************************************************************************
** Getter for maxConcurrentRuns
*******************************************************************************/
public Integer getMaxConcurrentRuns()
{
return (maxConcurrentRuns);
}
/*******************************************************************************
** Fluent setter for maxConcurrentRuns
*******************************************************************************/
public BackupsQBitConfig withMaxConcurrentRuns(Integer maxConcurrentRuns)
{
this.maxConcurrentRuns = maxConcurrentRuns;
return (this);
}
/*******************************************************************************
** Getter for slaCheckIntervalSeconds
*******************************************************************************/
public Integer getSlaCheckIntervalSeconds()
{
return (slaCheckIntervalSeconds);
}
/*******************************************************************************
** Fluent setter for slaCheckIntervalSeconds
*******************************************************************************/
public BackupsQBitConfig withSlaCheckIntervalSeconds(Integer slaCheckIntervalSeconds)
{
this.slaCheckIntervalSeconds = slaCheckIntervalSeconds;
return (this);
}
/*******************************************************************************
** Getter for openWhenAnonymous (local development only)
*******************************************************************************/
public Boolean getOpenWhenAnonymous()
{
return (openWhenAnonymous);
}
/*******************************************************************************
** Fluent setter for openWhenAnonymous
*******************************************************************************/
public BackupsQBitConfig withOpenWhenAnonymous(Boolean openWhenAnonymous)
{
this.openWhenAnonymous = openWhenAnonymous;
return (this);
}
/*******************************************************************************
** Getter for prometheusEndpoint
*******************************************************************************/
public Boolean getPrometheusEndpoint()
{
return (prometheusEndpoint);
}
/*******************************************************************************
** Fluent setter for prometheusEndpoint
*******************************************************************************/
public BackupsQBitConfig withPrometheusEndpoint(Boolean prometheusEndpoint)
{
this.prometheusEndpoint = prometheusEndpoint;
return (this);
}
/*******************************************************************************
** Getter for storageResolver
*******************************************************************************/
public Function<String, BackupStorage> getStorageResolver()
{
return (storageResolver);
}
/*******************************************************************************
** Fluent setter for storageResolver: bucket alias to storage
*******************************************************************************/
public BackupsQBitConfig withStorageResolver(Function<String, BackupStorage> storageResolver)
{
this.storageResolver = storageResolver;
return (this);
}
/*******************************************************************************
** Getter for fileReverifyDays: CONTENT verification skips file-store objects
** verified more recently than this.
*******************************************************************************/
public Integer getFileReverifyDays()
{
return (fileReverifyDays);
}
/*******************************************************************************
** Fluent setter for fileReverifyDays
*******************************************************************************/
public BackupsQBitConfig withFileReverifyDays(Integer fileReverifyDays)
{
this.fileReverifyDays = fileReverifyDays;
return (this);
}
/*******************************************************************************
** Getter for artifactTransformResolver (the decrypt hook for verify)
*******************************************************************************/
public Function<String, ArtifactTransform> getArtifactTransformResolver()
{
return (artifactTransformResolver);
}
/*******************************************************************************
** Fluent setter for artifactTransformResolver: encryption key reference (the name
** of a key in a key provider) to the transform that decrypts what that key wrote.
*******************************************************************************/
public BackupsQBitConfig withArtifactTransformResolver(Function<String, ArtifactTransform> artifactTransformResolver)
{
this.artifactTransformResolver = artifactTransformResolver;
return (this);
}
/*******************************************************************************
** Getter for scratchTarget (where TEST_RESTORE restores to; null means MEMORY)
*******************************************************************************/
public ScratchTarget getScratchTarget()
{
return (scratchTarget);
}
/*******************************************************************************
** Fluent setter for scratchTarget
*******************************************************************************/
public BackupsQBitConfig withScratchTarget(ScratchTarget scratchTarget)
{
this.scratchTarget = scratchTarget;
return (this);
}
/*******************************************************************************
** Getter for planOwnerValidator (true when the owner still exists and holds backups.admin)
*******************************************************************************/
public Predicate<String> getPlanOwnerValidator()
{
return (planOwnerValidator);
}
/*******************************************************************************
** Fluent setter for planOwnerValidator: with none, plan owners are not checked.
*******************************************************************************/
public BackupsQBitConfig withPlanOwnerValidator(Predicate<String> planOwnerValidator)
{
this.planOwnerValidator = planOwnerValidator;
return (this);
}
/*******************************************************************************
** Getter for esbDestinationName (ESB events are published here, when set)
*******************************************************************************/
public String getEsbDestinationName()
{
return (esbDestinationName);
}
/*******************************************************************************
** Fluent setter for esbDestinationName
*******************************************************************************/
public BackupsQBitConfig withEsbDestinationName(String esbDestinationName)
{
this.esbDestinationName = esbDestinationName;
return (this);
}
}
@@ -17,6 +17,18 @@
package com.kingsrook.qbits.backups;
import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;
import com.kingsrook.qbits.backups.health.BackupsDestinationHealthChecks;
import com.kingsrook.qbits.backups.health.BackupsSlaHealthCheck;
import com.kingsrook.qqq.backend.core.health.HealthCategory;
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
import com.kingsrook.qqq.backend.core.health.HealthCheckSupplierMetaData;
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
@@ -29,13 +41,41 @@ public class BackupsQBitProducer implements QBitMetaDataProducer<BackupsQBitConf
{
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "qbit-backups";
public static final String VERSION = "0.1.0";
public static final String VERSION = loadVersion();
private BackupsQBitConfig qBitConfig;
private String namespace;
/*******************************************************************************
** Read the artifact version from the Maven-filtered resource.
*******************************************************************************/
private static String loadVersion()
{
try(InputStream inputStream = BackupsQBitProducer.class.getClassLoader().getResourceAsStream("qbit-backups.properties"))
{
if(inputStream == null)
{
throw (new IllegalStateException("Missing qbit-backups.properties; build with Maven to generate the artifact version"));
}
Properties properties = new Properties();
properties.load(inputStream);
String version = properties.getProperty("version");
if(version == null || version.isBlank() || version.contains("${"))
{
throw (new IllegalStateException("Missing or unfiltered version in qbit-backups.properties; build with Maven"));
}
return (version);
}
catch(IOException e)
{
throw (new IllegalStateException("Could not read artifact version from qbit-backups.properties", e));
}
}
/*******************************************************************************
** Fluent setter for the config.
*******************************************************************************/
@@ -100,4 +140,83 @@ public class BackupsQBitProducer implements QBitMetaDataProducer<BackupsQBitConf
return (qBitConfig != null);
}
/*******************************************************************************
** Build everything and add it to the instance: possible-value sources, joins,
** tables, processes, widgets and the app.
*******************************************************************************/
@Override
public com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput produce(com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance) throws com.kingsrook.qqq.backend.core.exceptions.QException
{
java.util.List<String> errors = new java.util.ArrayList<>();
qBitConfig.validate(qInstance, errors);
if(!errors.isEmpty())
{
throw (new com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfigValidationException(qBitConfig, errors));
}
QBitMetaData qBit = getQBitMetaData();
qInstance.addQBit(qBit);
BackupsRuntime.install(qBitConfig);
com.kingsrook.qbits.backups.alerts.AlertsQBitAlertSink.bindIfAbsent();
new com.kingsrook.qbits.backups.alerts.AlertsQBitAlertSink().registerAlertTypes(qInstance);
com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput output = new com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput();
for(var possibleValueSource : com.kingsrook.qbits.backups.model.BackupsTables.possibleValueSources())
{
output.add(possibleValueSource);
}
for(var join : com.kingsrook.qbits.backups.model.BackupsTables.joins())
{
output.add(join);
}
java.util.List<com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData> tables = new java.util.ArrayList<>();
for(var table : com.kingsrook.qbits.backups.model.BackupsTables.tables(qBitConfig))
{
var customized = qBitConfig.getTableMetaDataCustomizer() == null ? table : qBitConfig.getTableMetaDataCustomizer().customizeMetaData(qInstance, table);
customized.setSourceQBitName(qBit.getName());
tables.add(customized);
output.add(customized);
}
for(var process : com.kingsrook.qbits.backups.processes.BackupsProcesses.processes())
{
process.setSourceQBitName(qBit.getName());
output.add(process);
}
for(var process : com.kingsrook.qbits.backups.processes.ExportTableProcess.exportProcesses(qInstance))
{
process.setSourceQBitName(qBit.getName());
output.add(process);
}
for(var widget : com.kingsrook.qbits.backups.app.BackupsApp.widgets())
{
output.add(widget);
}
for(var widget : com.kingsrook.qbits.backups.model.BackupsTables.widgets())
{
output.add(widget);
}
com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData app = com.kingsrook.qbits.backups.app.BackupsApp.app(qBitConfig, tables);
output.add(app);
output.addSelfToInstance(qInstance);
// Registration is metadata-only; core guards startup sync when helpContent is absent.
HelpBundles.register(qInstance, "help/qbit-backups.yaml");
HealthChecksMetaData.ofOrWithNew(qInstance)
.withCheck(new HealthCheckMetaData().withName(BackupsSlaHealthCheck.NAME).withLabel("Backup SLA")
.withComponent("backups").withKind(HealthCheckKind.DEPENDENCY).withCategory(HealthCategory.STORAGE)
.withIntervalSeconds(900).withCheck(new QCodeReference(BackupsSlaHealthCheck.class)))
.withSupplier(new HealthCheckSupplierMetaData().withNamePrefix(BackupsDestinationHealthChecks.PREFIX)
.withComponent("backups").withSupplier(new QCodeReference(BackupsDestinationHealthChecks.class)));
// A parent app only lists the apps that are its children, so attach ourselves (for example Admin, then Backups).
com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData parent = qBitConfig.getParentAppName() == null ? null : qInstance.getApp(qBitConfig.getParentAppName());
if(parent != null && (parent.getChildren() == null || parent.getChildren().stream().noneMatch(child -> app.getName().equals(child.getName()))))
{
parent.withChild(app);
}
return (new com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput());
}
}
@@ -0,0 +1,197 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.backups;
import java.time.Clock;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import com.kingsrook.qbits.backups.alerts.BackupAlertSink;
import com.kingsrook.qbits.backups.alerts.NoOpBackupAlertSink;
import com.kingsrook.qbits.backups.storage.BackupStorage;
import com.kingsrook.qbits.backups.storage.BackupStorageException;
import com.kingsrook.qqq.backend.core.cluster.ClusterCaches;
/*******************************************************************************
** Process-wide state of the installed qbit: the config, the clock, the alert
** sink and the per-process permission map. Static because QQQ instantiates
** steps and permission checkers by class reference.
*******************************************************************************/
public final class BackupsRuntime
{
private static final Map<String, String> PROCESS_PERMISSIONS = ClusterCaches.nodeLocal(new ConcurrentHashMap<>(),
"built from the qbit config at startup, the same on every node");
private static volatile BackupsQBitConfig config;
private static volatile Clock clock = Clock.systemUTC();
private static volatile BackupAlertSink alertSink = new NoOpBackupAlertSink();
private static volatile Function<String, String> environment = System::getenv;
/*******************************************************************************
** Utility class.
*******************************************************************************/
private BackupsRuntime()
{
}
/*******************************************************************************
** Install the config (called by the producer).
*******************************************************************************/
public static void install(BackupsQBitConfig newConfig)
{
config = newConfig;
}
/*******************************************************************************
** Forget everything (tests).
*******************************************************************************/
public static void reset()
{
config = null;
clock = Clock.systemUTC();
alertSink = new NoOpBackupAlertSink();
environment = System::getenv;
PROCESS_PERMISSIONS.clear();
}
/*******************************************************************************
** The installed config, or null.
*******************************************************************************/
public static BackupsQBitConfig getConfig()
{
return (config);
}
/*******************************************************************************
** Whether the qbit is installed.
*******************************************************************************/
public static boolean isInstalled()
{
return (config != null);
}
/*******************************************************************************
** Whether permissions are open on fully anonymous instances (local development).
*******************************************************************************/
public static boolean isOpenWhenAnonymous()
{
return (config != null && Boolean.TRUE.equals(config.getOpenWhenAnonymous()));
}
/*******************************************************************************
** The clock (replaceable in tests).
*******************************************************************************/
public static Clock getClock()
{
return (clock);
}
/*******************************************************************************
** Replace the clock.
*******************************************************************************/
public static void setClock(Clock newClock)
{
clock = newClock;
}
/*******************************************************************************
** The alert sink.
*******************************************************************************/
public static BackupAlertSink getAlertSink()
{
return (alertSink);
}
/*******************************************************************************
** Bind an alert sink (the Alerts qbit adapter, or a test double).
*******************************************************************************/
public static void setAlertSink(BackupAlertSink newSink)
{
alertSink = newSink == null ? new NoOpBackupAlertSink() : newSink;
}
/*******************************************************************************
** Environment lookup (replaceable in tests).
*******************************************************************************/
public static Function<String, String> getEnvironment()
{
return (environment);
}
/*******************************************************************************
** Replace the environment lookup.
*******************************************************************************/
public static void setEnvironment(Function<String, String> newEnvironment)
{
environment = newEnvironment;
}
/*******************************************************************************
** The permission each qbit process needs, by process name.
*******************************************************************************/
public static Map<String, String> processPermissions()
{
return (PROCESS_PERMISSIONS);
}
/*******************************************************************************
** Resolve the storage for a bucket alias.
*******************************************************************************/
public static BackupStorage storageFor(String bucketAlias)
{
if(config == null || config.getStorageResolver() == null)
{
throw (new BackupStorageException(BackupStorageException.Reason.UNSUPPORTED, "No storage resolver is configured for the Backups qbit"));
}
BackupStorage storage = config.getStorageResolver().apply(bucketAlias);
if(storage == null)
{
throw (new BackupStorageException(BackupStorageException.Reason.NOT_FOUND, "No storage bucket is configured with the alias: " + bucketAlias));
}
return (storage);
}
}
@@ -0,0 +1,191 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.backups.alerts;
import java.util.Map;
import java.util.stream.Collectors;
import com.kingsrook.qbits.backups.BackupsRuntime;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
/*******************************************************************************
** Direct adapter to core QAlerts. Calls are no-ops without a configured alerts
** sink, including when only the backup alert types have been registered.
** QAlerts catches and logs dispatch failures; adapter failures are logged once.
** Heartbeat intervals remain the responsibility of the backup SLA checker.
*******************************************************************************/
public class AlertsQBitAlertSink implements BackupAlertSink
{
private static final QLogger LOG = QLogger.getLogger(AlertsQBitAlertSink.class);
private boolean warned = false;
/*******************************************************************************
** Bind the core adapter unless the host already supplied a backup sink.
*******************************************************************************/
public static boolean bindIfAbsent()
{
if(!(BackupsRuntime.getAlertSink() instanceof NoOpBackupAlertSink))
{
return (false);
}
BackupsRuntime.setAlertSink(new AlertsQBitAlertSink());
return (true);
}
/*******************************************************************************
** Declare missing backup alert types on each instance, independently of sink
** installation order. Returns how many were added.
*******************************************************************************/
public int registerAlertTypes(QInstance qInstance)
{
int added = 0;
for(BackupAlerts.AlertType alertType : BackupAlerts.alertTypes())
{
try
{
if(QAlertTypeMetaData.of(qInstance, alertType.name()) == null)
{
new QAlertTypeMetaData()
.withName(alertType.name())
.withLabel(alertType.label())
.withGroupName("Backups")
.withHowToFix(alertType.howToFix())
.withDefaultSeverity(AlertSeverity.valueOf(alertType.defaultSeverity().name()))
.addSelfToInstance(qInstance);
added++;
}
}
catch(RuntimeException | LinkageError e)
{
warnOnce("Could not declare the alert type " + alertType.name(), e);
}
}
return (added);
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public void raise(String type, String dedupeKey, Severity severity, String title, Map<String, String> detail)
{
try
{
if(!isAlertsQBitInstalled())
{
return;
}
AlertSignal signal = new AlertSignal(type)
.withDedupKey(dedupeKey)
.withSeverity(AlertSeverity.valueOf(severity.name()))
.withTitle(title);
if(detail != null && !detail.isEmpty())
{
signal.withSummary(detail.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()).collect(Collectors.joining(", ")));
detail.forEach(signal::withAttribute);
}
QAlerts.raise(signal);
}
catch(RuntimeException | LinkageError e)
{
warnOnce("Could not raise an alert", e);
}
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public void resolve(String type, String dedupeKey)
{
try
{
if(isAlertsQBitInstalled())
{
QAlerts.resolve(type, dedupeKey, "Cleared by the Backups qbit");
}
}
catch(RuntimeException | LinkageError e)
{
warnOnce("Could not resolve an alert", e);
}
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public void heartbeat(String monitorKey, long expectedEverySeconds, long graceSeconds)
{
try
{
if(isAlertsQBitInstalled())
{
QAlerts.heartbeat(monitorKey);
}
}
catch(RuntimeException | LinkageError e)
{
warnOnce("Could not check in a heartbeat", e);
}
}
/*******************************************************************************
** Check on every call so installing the alerts sink after this qbit works.
** Type declarations alone do not mean an alerts implementation is installed.
*******************************************************************************/
boolean isAlertsQBitInstalled()
{
AlertsInstanceMetaData metaData = AlertsInstanceMetaData.of(QContext.getQInstance());
return (metaData != null && metaData.getSink() != null);
}
/*******************************************************************************
** Log adapter failures once; dispatch failures are already logged by core.
*******************************************************************************/
private synchronized void warnOnce(String message, Throwable t)
{
if(!warned)
{
warned = true;
LOG.warn(message + " (the Alerts qbit adapter will keep failing quietly)", t);
}
}
}
@@ -0,0 +1,65 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.backups.alerts;
import java.util.Map;
/*******************************************************************************
** Where backups raises and resolves alerts. The adapter for the Alerts qbit
** producer SPI binds here when that qbit is installed; otherwise the no-op
** sink is used and every condition is still visible on the Backups home and in
** the tables. An alert has a stable dedupe key; counts and links only, never
** values, keys or presigned URLs.
*******************************************************************************/
public interface BackupAlertSink
{
/***************************************************************************
** Raise (or refresh) an alert.
**
** @param type alert type, e.g. backups.plan.missed
** @param dedupeKey stable key, type plus plan or run
** @param severity severity
** @param title short title
** @param detail counts and context only
***************************************************************************/
void raise(String type, String dedupeKey, Severity severity, String title, Map<String, String> detail);
/***************************************************************************
** Resolve an alert that was raised under the dedupe key.
***************************************************************************/
void resolve(String type, String dedupeKey);
/***************************************************************************
** Check in a heartbeat monitor: the plan ran successfully.
***************************************************************************/
void heartbeat(String monitorKey, long expectedEverySeconds, long graceSeconds);
/***************************************************************************
** Severities.
***************************************************************************/
enum Severity
{
INFO, WARNING, ERROR, CRITICAL
}
}
@@ -0,0 +1,203 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.backups.alerts;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.backups.BackupsRuntime;
/*******************************************************************************
** Every alert the Backups qbit raises (design spec section 10.4), with stable
** dedupe keys, and helpers that send them through the bound BackupAlertSink.
**
** A dedupe key is the alert type plus a plan key (or a run key, or a plan key
** and a verify level), so a repeated condition refreshes one alert and a cleared
** condition resolves exactly that alert. Details carry counts and names only:
** never row values, object keys with secrets in them, or presigned URLs.
*******************************************************************************/
public final class BackupAlerts
{
public static final String PLAN_MISSED = "backups.plan.missed";
public static final String RUN_FAILED = "backups.run.failed";
public static final String RUN_MISSING = "backups.run.missing-in-storage";
public static final String FILES_MISSING = "backups.files.missing";
public static final String VERIFY_FAILED = "backups.verify.failed";
public static final String VERIFY_OVERDUE = "backups.verify.overdue";
public static final String PLAN_OWNER_INVALID = "backups.plan.owner-invalid";
public static final String STORAGE_DEBRIS = "backups.storage.debris";
public static final String RESTORE_COMPLETED = "backups.restore.completed";
/** The prefix of the heartbeat monitor key of a plan. */
public static final String HEARTBEAT_PREFIX = "backups.plan:";
/***************************************************************************
** A declared alert type, for the Alerts qbit's type registry.
**
** @param name the alert type name
** @param label a short label
** @param defaultSeverity the severity when a signal does not say
** @param howToFix one sentence for the person looking at the alert
***************************************************************************/
public record AlertType(String name, String label, BackupAlertSink.Severity defaultSeverity, String howToFix)
{
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private BackupAlerts()
{
}
/*******************************************************************************
** The alert types of the spec table (section 10.4), in table order.
*******************************************************************************/
public static List<AlertType> alertTypes()
{
return (List.of(
new AlertType(PLAN_MISSED, "Backup did not run", BackupAlertSink.Severity.ERROR,
"Open the plan and its latest runs, check the schedule and the scheduler, then run the backup by hand."),
new AlertType(RUN_FAILED, "Backup run failed", BackupAlertSink.Severity.WARNING,
"Open the failed run for the error, fix the cause, and run the plan again."),
new AlertType(RUN_MISSING, "Backup is missing from storage", BackupAlertSink.Severity.CRITICAL,
"The catalog says the run succeeded but its manifest is gone from the bucket. Check the bucket, its lifecycle rules and the credentials, then run a new backup."),
new AlertType(FILES_MISSING, "Backed up files were missing at the source", BackupAlertSink.Severity.WARNING,
"Open the run's file index: records point at files that no longer exist."),
new AlertType(VERIFY_FAILED, "Backup verification failed", BackupAlertSink.Severity.ERROR,
"Open the verification for the mismatches. Treat the run as unusable and take a new backup."),
new AlertType(VERIFY_OVERDUE, "Backup verification is overdue", BackupAlertSink.Severity.WARNING,
"Run Verify backup on a recent run of the plan, or set a verify schedule on the plan."),
new AlertType(PLAN_OWNER_INVALID, "Backup plan owner is not valid", BackupAlertSink.Severity.WARNING,
"Set an owner who exists and holds backups.admin on the plan."),
new AlertType(STORAGE_DEBRIS, "Backup storage has debris or orphans", BackupAlertSink.Severity.INFO,
"Prune clears debris. Orphan prefixes can be imported into the catalog or deleted."),
new AlertType(RESTORE_COMPLETED, "A restore finished", BackupAlertSink.Severity.INFO,
"Nothing to fix unless the restore failed. Open the restore run.")));
}
/*******************************************************************************
** The stable dedupe key for a type and an id (plan key, run key...).
*******************************************************************************/
public static String key(String type, String id)
{
return (type + ":" + id);
}
/*******************************************************************************
** The dedupe key of a verify failure: it includes the level so a pass at a
** higher level clears the lower failures, and a cheap pass never hides a
** content failure.
*******************************************************************************/
public static String verifyKey(String planKey, String level)
{
return (VERIFY_FAILED + ":" + planKey + ":" + level);
}
/*******************************************************************************
** Raise (or refresh) an alert. Never throws: an alerting problem must not fail
** a backup or a check.
*******************************************************************************/
public static void raise(String type, String id, BackupAlertSink.Severity severity, String title, String... detailPairs)
{
try
{
Map<String, String> detail = new LinkedHashMap<>();
for(int i = 0; i + 1 < detailPairs.length; i += 2)
{
detail.put(detailPairs[i], detailPairs[i + 1]);
}
BackupsRuntime.getAlertSink().raise(type, key(type, id), severity, title, detail);
}
catch(RuntimeException e)
{
// fire and forget
}
}
/*******************************************************************************
** Raise with an explicit dedupe key.
*******************************************************************************/
public static void raiseWithKey(String type, String dedupeKey, BackupAlertSink.Severity severity, String title, Map<String, String> detail)
{
try
{
BackupsRuntime.getAlertSink().raise(type, dedupeKey, severity, title, detail);
}
catch(RuntimeException e)
{
// fire and forget
}
}
/*******************************************************************************
** Resolve the alert of a type and id. Never throws.
*******************************************************************************/
public static void resolve(String type, String id)
{
resolveKey(type, key(type, id));
}
/*******************************************************************************
** Resolve an alert by its dedupe key. Never throws.
*******************************************************************************/
public static void resolveKey(String type, String dedupeKey)
{
try
{
BackupsRuntime.getAlertSink().resolve(type, dedupeKey);
}
catch(RuntimeException e)
{
// fire and forget
}
}
/*******************************************************************************
** A restore finished: an info alert, so restores are never silent (error when
** it failed). The Alerts qbit clears it after 7 days; nothing resolves it here.
*******************************************************************************/
public static void restoreCompleted(String restoreRunKey, String status, String planKey, long rowsRestored)
{
boolean failed = "FAILED".equals(status);
raise(RESTORE_COMPLETED, restoreRunKey, failed ? BackupAlertSink.Severity.ERROR : BackupAlertSink.Severity.INFO,
failed ? "A restore failed" : "A restore finished",
"restoreRun", restoreRunKey, "status", String.valueOf(status), "plan", String.valueOf(planKey), "rows", String.valueOf(rowsRestored));
}
}
@@ -0,0 +1,56 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.backups.alerts;
import java.util.Map;
/*******************************************************************************
** The sink used when the Alerts qbit is not installed.
*******************************************************************************/
public class NoOpBackupAlertSink implements BackupAlertSink
{
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public void raise(String type, String dedupeKey, Severity severity, String title, Map<String, String> detail)
{
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public void resolve(String type, String dedupeKey)
{
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public void heartbeat(String monitorKey, long expectedEverySeconds, long graceSeconds)
{
}
}
@@ -0,0 +1,141 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.backups.app;
import java.util.List;
import com.kingsrook.qbits.backups.BackupsQBitConfig;
import com.kingsrook.qbits.backups.model.BackupsTables;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** The Backups app (a sub-app under Admin when configured) and its widgets. The
** home is the single owner of backup data: every card is built from the qbit
** tables, and each has an honest empty state. The nav lists the main tables;
** sub-tables appear as child lists on their parents.
*******************************************************************************/
public final class BackupsApp
{
public static final String HOME_WIDGET = "backupsHome";
public static final String HEALTH_CARD_WIDGET = "backupsHealthCard";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private BackupsApp()
{
}
/*******************************************************************************
** The widgets.
*******************************************************************************/
public static List<QWidgetMetaData> widgets()
{
return (List.of(
new QWidgetMetaData().withName(HOME_WIDGET).withLabel("Overview").withType(WidgetType.HTML.getType())
.withCodeReference(new QCodeReference(HomeRenderer.class)).withGridColumns(12).withIsCard(false).withPermissionRules(BackupsTables.permissionRules()),
new QWidgetMetaData().withName(HEALTH_CARD_WIDGET).withLabel("Backups").withType(WidgetType.HTML.getType())
.withCodeReference(new QCodeReference(HealthCardRenderer.class)).withGridColumns(12).withIsCard(true).withPermissionRules(BackupsTables.permissionRules())));
}
/*******************************************************************************
** The app: the main tables only; sub-tables are relations on their parents.
*******************************************************************************/
public static QAppMetaData app(BackupsQBitConfig config, List<QTableMetaData> tables)
{
QAppMetaData app = new QAppMetaData()
.withName(config.getAppName())
.withLabel(config.getAppLabel())
.withIcon(new QIcon("backup"))
.withPermissionRules(BackupsTables.permissionRules())
.withWidgets(List.of(HOME_WIDGET));
if(config.getParentAppName() != null)
{
app.setParentAppName(config.getParentAppName());
}
for(QTableMetaData table : tables)
{
if(BackupsTables.NAV_TABLES.contains(table.getName()))
{
app.withChild(table);
}
}
return (app);
}
/*******************************************************************************
** The home.
*******************************************************************************/
public static class HomeRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
return (new RenderWidgetOutput(new RawHTML(null, BackupsHomeView.render(BackupsHomeData.load(), pathPrefix()))));
}
}
/*******************************************************************************
** The status card for Admin, Health.
*******************************************************************************/
public static class HealthCardRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
return (new RenderWidgetOutput(new RawHTML("Backups", BackupsHomeView.renderHealthCard(BackupsHomeData.load(), pathPrefix()))));
}
}
/*******************************************************************************
** The path prefix of table and record links.
*******************************************************************************/
static String pathPrefix()
{
var config = com.kingsrook.qbits.backups.BackupsRuntime.getConfig();
return (config == null ? "/app" : config.getTablePathPrefix());
}
}
@@ -0,0 +1,304 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.backups.app;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.backups.BackupsRuntime;
import com.kingsrook.qbits.backups.model.BackupsTables;
import com.kingsrook.qbits.backups.run.BackupCatalog;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** The data behind the Backups home: plan status, what needs attention, recent
** activity and the size trend. Everything is read from the qbit tables; nothing
** is invented. Home cards read the denormalized plan fields (slaStatus, last
** success) that the processes and the SLA checker maintain.
*******************************************************************************/
public class BackupsHomeData
{
/** Plan statuses that need attention (the plan or its backups have a problem). */
public static final List<String> ATTENTION = List.of("LATE", "FAILING", "MISSING_IN_STORAGE", "UNVERIFIED", "ABANDONED", "OWNER_INVALID");
private final List<QRecord> plans;
private final List<QRecord> recentRuns;
private final List<QRecord> failedRuns;
private final List<QRecord> verifications;
private final List<QRecord> restores;
private final Instant now;
/*******************************************************************************
** Constructor.
*******************************************************************************/
BackupsHomeData(List<QRecord> plans, List<QRecord> recentRuns, List<QRecord> failedRuns, List<QRecord> verifications, List<QRecord> restores, Instant now)
{
this.plans = plans;
this.recentRuns = recentRuns;
this.failedRuns = failedRuns;
this.verifications = verifications;
this.restores = restores;
this.now = now;
}
/*******************************************************************************
** Load from the tables.
*******************************************************************************/
public static BackupsHomeData load() throws QException
{
BackupCatalog catalog = new BackupCatalog();
Instant now = BackupsRuntime.getClock().instant();
List<QRecord> plans = catalog.query(BackupsTables.PLAN, new QQueryFilter().withOrderBy(new QFilterOrderBy("name", true)));
QQueryFilter recent = new QQueryFilter().withOrderBy(new QFilterOrderBy("id", false));
recent.setLimit(200);
List<QRecord> runs = catalog.query(BackupsTables.RUN, recent);
List<QRecord> failed = new ArrayList<>();
Instant weekAgo = now.minus(Duration.ofDays(7));
for(QRecord run : runs)
{
Instant start = run.getValueInstant("startDate");
if(List.of("FAILED", "TIMED_OUT", "ABANDONED").contains(run.getValueString("status")) && (start == null || start.isAfter(weekAgo)))
{
failed.add(run);
}
}
QQueryFilter verificationFilter = new QQueryFilter().withOrderBy(new QFilterOrderBy("id", false));
verificationFilter.setLimit(100);
QQueryFilter restoreFilter = new QQueryFilter().withOrderBy(new QFilterOrderBy("id", false));
restoreFilter.setLimit(20);
return (new BackupsHomeData(plans, runs, failed, catalog.query(BackupsTables.VERIFICATION, verificationFilter),
catalog.query(BackupsTables.RESTORE_RUN, restoreFilter), now));
}
/*******************************************************************************
** Plans.
*******************************************************************************/
public List<QRecord> plans()
{
return (plans);
}
/*******************************************************************************
** Recent runs (newest first).
*******************************************************************************/
public List<QRecord> recentRuns()
{
return (recentRuns);
}
/*******************************************************************************
** Runs that failed in the last 7 days.
*******************************************************************************/
public List<QRecord> failedRuns()
{
return (failedRuns);
}
/*******************************************************************************
** Verifications (newest first).
*******************************************************************************/
public List<QRecord> verifications()
{
return (verifications);
}
/*******************************************************************************
** Restores (newest first).
*******************************************************************************/
public List<QRecord> restores()
{
return (restores);
}
/*******************************************************************************
** The time the data was loaded.
*******************************************************************************/
public Instant now()
{
return (now);
}
/*******************************************************************************
** The status to show for a plan: the stored slaStatus when it is a problem state,
** otherwise LATE when the age of the last success is past the SLA (the SLA
** checker may not have run yet), otherwise the stored status (UNKNOWN when none).
*******************************************************************************/
public String effectiveStatus(QRecord plan)
{
String stored = plan.getValueString("slaStatus");
if(stored != null && ATTENTION.contains(stored))
{
return (stored);
}
Long age = ageHours(plan);
Integer sla = plan.getValueInteger("slaHours");
if(age != null && sla != null && age > sla)
{
return ("LATE");
}
return (stored == null ? "UNKNOWN" : stored);
}
/*******************************************************************************
** Plans that need attention.
*******************************************************************************/
public List<QRecord> plansNeedingAttention()
{
return (plans.stream().filter(p -> Boolean.TRUE.equals(p.getValueBoolean("isActive")) && ATTENTION.contains(effectiveStatus(p))).toList());
}
/*******************************************************************************
** The last run of a plan (from the recent runs), or null.
*******************************************************************************/
public QRecord lastRun(QRecord plan)
{
Integer lastRunId = plan.getValueInteger("lastRunId");
for(QRecord run : recentRuns)
{
if(lastRunId != null && lastRunId.equals(run.getValueInteger("id")))
{
return (run);
}
}
return (null);
}
/*******************************************************************************
** The overall state: NO_PLANS, ATTENTION or OK.
*******************************************************************************/
public String overallState()
{
if(plans.isEmpty())
{
return ("NO_PLANS");
}
return (plansNeedingAttention().isEmpty() && failedRuns.isEmpty() ? "OK" : "ATTENTION");
}
/*******************************************************************************
** Hours since the last successful backup of a plan, or null when it has none.
*******************************************************************************/
public Long ageHours(QRecord plan)
{
Instant last = plan.getValueInstant("lastSuccessDate");
return (last == null ? null : Duration.between(last, now).toHours());
}
/*******************************************************************************
** The latest test restore of a plan, or null.
*******************************************************************************/
public QRecord latestTestRestore(Integer planId)
{
for(QRecord verification : verifications)
{
if("TEST_RESTORE".equals(verification.getValueString("level")))
{
Integer runId = verification.getValueInteger("backupRunId");
for(QRecord run : recentRuns)
{
if(runId != null && planId != null && runId.equals(run.getValueInteger("id")) && planId.equals(run.getValueInteger("backupPlanId")))
{
return (verification);
}
}
}
}
return (null);
}
/*******************************************************************************
** Successful runs of a plan, oldest first, for the trend (bytes stored, rows, duration).
*******************************************************************************/
public List<QRecord> successfulRuns(Integer planId)
{
List<QRecord> rs = new ArrayList<>();
if(planId == null)
{
return (rs);
}
for(QRecord run : recentRuns)
{
if(planId.equals(run.getValueInteger("backupPlanId")) && run.getValueString("status") != null && run.getValueString("status").startsWith("SUCCEEDED"))
{
rs.add(0, run);
}
}
return (rs);
}
/*******************************************************************************
** Runs kept per retention class for a plan (as classified by the last prune).
*******************************************************************************/
public Map<String, Integer> retentionCounts(Integer planId)
{
Map<String, Integer> counts = new LinkedHashMap<>();
if(planId == null)
{
return (counts);
}
for(QRecord run : recentRuns)
{
if(planId.equals(run.getValueInteger("backupPlanId")) && run.getValueString("status") != null && run.getValueString("status").startsWith("SUCCEEDED")
&& run.getValue("prunedDate") == null)
{
counts.merge(run.getValueString("retentionClass") == null ? "UNCLASSIFIED" : run.getValueString("retentionClass"), 1, Integer::sum);
}
}
return (counts);
}
}
@@ -0,0 +1,553 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.backups.app;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.backups.model.BackupsTables;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Renders the Backups home: a plan status row per plan across the full width,
** then cards for what needs attention, the latest test restore, recent activity,
** the size trend and retention. Every card has an honest empty state. Layout
** is flow and grid only (no tables, no fixed widths, no inner scrolling): plan
** rows wrap their cells and the cards stack to one column on narrow screens,
** long names wrap, and colors are inherited (only the status dot is colored), so
** it reads at every width in light and dark mode.
*******************************************************************************/
public final class BackupsHomeView
{
private static final DateTimeFormatter WHEN = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm 'UTC'").withZone(ZoneOffset.UTC);
private static final String ROOT = "display:grid;gap:1rem;min-width:0;";
private static final String GRID = "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,24rem),1fr));gap:1rem;align-items:start;min-width:0;";
private static final String CARD = "border:1px solid rgba(127,127,127,.3);border-radius:8px;padding:1rem;min-width:0;overflow-wrap:anywhere;";
private static final String ROW = "border:1px solid rgba(127,127,127,.25);border-radius:6px;padding:.75rem;margin:0 0 .75rem;min-width:0;";
private static final String CELLS = "display:grid;grid-template-columns:repeat(auto-fit,minmax(9.5rem,1fr));gap:.6rem 1rem;margin:.6rem 0 0;font-size:.9rem;";
private static final String LIST = "display:grid;grid-template-columns:minmax(7rem,auto) 1fr;gap:.25rem .75rem;margin:0 0 .75rem;font-size:.9rem;";
private static final String ITEMS = "margin:0 0 .75rem;padding-left:1.25rem;list-style:disc;font-size:.9rem;display:grid;gap:.35rem;";
private static final String MUTED = "opacity:.75;";
private static final String LINK = "color:inherit;text-decoration:underline;text-underline-offset:2px;";
private static final String CHIP = "display:inline-flex;align-items:center;gap:.4rem;padding:.05rem .6rem;border-radius:999px;border:1px solid rgba(127,127,127,.4);font-size:.8rem;white-space:nowrap;";
private static final int TREND_BARS = 30;
/*******************************************************************************
** Utility class.
*******************************************************************************/
private BackupsHomeView()
{
}
/*******************************************************************************
** The whole home; pathPrefix is the prefix of table and record links.
*******************************************************************************/
public static String render(BackupsHomeData data, String pathPrefix)
{
StringBuilder html = new StringBuilder("<div class=\"backups-home\" style=\"" + ROOT + "\">");
List<BackupsSetupStatus.Item> setupItems = BackupsSetupStatus.load(data.plans());
planStatus(html, data, pathPrefix);
setup(html, setupItems, pathPrefix);
html.append("<div style=\"").append(GRID).append("\">");
needsAttention(html, data, setupItems, pathPrefix);
latestTestRestore(html, data, pathPrefix);
recentActivity(html, data, pathPrefix);
trend(html, data);
retention(html, data);
return (html.append("</div></div>").toString());
}
/*******************************************************************************
** The one status card for Admin, Health.
*******************************************************************************/
public static String renderHealthCard(BackupsHomeData data, String pathPrefix)
{
String text = switch(data.overallState())
{
case "NO_PLANS" -> "No backup plans yet.";
case "ATTENTION" -> "Needs attention: see the Backups home.";
default -> "All backups are within their SLA.";
};
return ("<div style=\"" + CARD + "\"><strong>Backups</strong><p>" + esc(text) + "</p><a style=\"" + LINK + "\" href=\"" + esc(pathPrefix) + "\">Open the Backups home</a></div>");
}
/*******************************************************************************
** Plan status: one row per plan, the cells wrap to the width.
*******************************************************************************/
private static void planStatus(StringBuilder html, BackupsHomeData data, String prefix)
{
open(html, "Plan status");
if(data.plans().isEmpty())
{
empty(html, "No backup plans yet. Create one from Backup plans, New.");
}
for(QRecord plan : data.plans())
{
Long age = data.ageHours(plan);
Integer sla = plan.getValueInteger("slaHours");
String status = data.effectiveStatus(plan);
QRecord last = data.lastRun(plan);
html.append("<div style=\"").append(ROW).append("\"><div style=\"display:flex;flex-wrap:wrap;align-items:center;gap:.5rem .75rem;\">");
html.append("<strong>").append(recordLink(prefix, BackupsTables.PLAN, plan.getValue("id"), plan.getValueString("name"))).append("</strong>");
html.append(chip(status));
if(!Boolean.TRUE.equals(plan.getValueBoolean("isActive")))
{
html.append("<span style=\"").append(MUTED).append("\">Not active</span>");
}
html.append("</div><div style=\"").append(CELLS).append("\">");
cell(html, "Last backup", plan.getValueInstant("lastSuccessDate") == null ? "No successful backup yet" : WHEN.format(plan.getValueInstant("lastSuccessDate")));
cell(html, "Age against SLA", age != null && sla != null ? age + " h of " + sla + " h" : (sla == null ? "No SLA set" : "Not run yet"));
cell(html, "Last run", last == null ? "None yet" : label(last.getValueString("status")) + (last.getValueInstant("startDate") == null ? "" : ", " + WHEN.format(last.getValueInstant("startDate"))));
cell(html, "Schedule", plan.getValueString("cronExpression") == null ? "Manual only" : plan.getValueString("cronExpression"));
cell(html, "Last verified", plan.getValueInstant("lastVerifiedDate") == null ? "Not verified yet" : WHEN.format(plan.getValueInstant("lastVerifiedDate")));
if(plan.getValueString("objectLockMode") != null && !"NONE".equals(plan.getValueString("objectLockMode")))
{
cell(html, "Object lock", plan.getValueString("objectLockMode") + (plan.getValueInteger("objectLockDays") == null ? "" : ", " + plan.getValueInteger("objectLockDays") + " days"));
}
html.append("</div></div>");
}
link(html, prefix, BackupsTables.PLAN, "Backup plans");
close(html);
}
/*******************************************************************************
** The Setup card: what a working backup needs, whether each part is in place,
** and the next step, linked to its table.
*******************************************************************************/
private static void setup(StringBuilder html, List<BackupsSetupStatus.Item> items, String prefix)
{
open(html, "Setup");
long todo = items.stream().filter(i -> i.state() == BackupsSetupStatus.State.TODO).count();
html.append("<p style=\"margin:0 0 .6rem;font-size:.9rem;\">")
.append(todo == 0 ? "Everything backups need is set up." : esc(todo + (todo == 1 ? " item needs" : " items need") + " attention."))
.append("</p><ul style=\"list-style:none;margin:0;padding:0;\">");
for(BackupsSetupStatus.Item item : items)
{
String color = switch(item.state())
{
case OK -> "#16a34a";
case TODO -> "#dc2626";
case DEFAULT -> "#d97706";
case OPTIONAL -> "#9ca3af";
};
String label = switch(item.state())
{
case OK -> "Set up";
case TODO -> "Needs setup";
case DEFAULT -> "Using defaults";
case OPTIONAL -> "Optional";
};
html.append("<li style=\"").append(ROW).append("\"><div style=\"display:flex;flex-wrap:wrap;align-items:center;gap:.5rem .75rem;\">")
.append("<strong>").append("<a style=\"").append(LINK).append("\" href=\"").append(esc(prefix + "/" + item.tableName())).append("\">").append(esc(item.label())).append("</a></strong>")
.append("<span style=\"").append(CHIP).append("\"><span aria-hidden=\"true\" style=\"width:.55rem;height:.55rem;border-radius:50%;background:").append(color).append(";display:inline-block;\"></span>").append(esc(label)).append("</span>")
.append("</div><div style=\"font-size:.9rem;margin-top:.35rem;\">").append(esc(item.detail())).append("</div>");
if(item.nextStep() != null)
{
html.append("<div style=\"").append(MUTED).append("font-size:.9rem;margin-top:.2rem;\">Next: ").append(esc(item.nextStep())).append("</div>");
}
html.append("</li>");
}
html.append("</ul>");
close(html);
}
/*******************************************************************************
** Needs attention: every Setup item that needs setup (the same list the Setup
** card counts), plus plans in a problem state and recent failed runs, so the two
** cards never disagree.
*******************************************************************************/
private static void needsAttention(StringBuilder html, BackupsHomeData data, List<BackupsSetupStatus.Item> setupItems, String prefix)
{
open(html, "Needs attention");
List<BackupsSetupStatus.Item> setupTodo = setupItems.stream().filter(i -> i.state() == BackupsSetupStatus.State.TODO).toList();
List<QRecord> plans = data.plansNeedingAttention();
if(setupTodo.isEmpty() && plans.isEmpty() && data.failedRuns().isEmpty())
{
empty(html, data.plans().isEmpty() ? "Nothing to check yet: no plans." : "Nothing needs attention.");
}
else
{
html.append("<ul style=\"").append(ITEMS).append("\">");
for(BackupsSetupStatus.Item item : setupTodo)
{
html.append("<li>Setup: <a style=\"").append(LINK).append("\" href=\"").append(esc(prefix + "/" + item.tableName())).append("\">").append(esc(item.label())).append("</a>: ")
.append(esc(item.detail())).append("</li>");
}
for(QRecord plan : plans)
{
html.append("<li>").append(recordLink(prefix, BackupsTables.PLAN, plan.getValue("id"), plan.getValueString("name")))
.append(": ").append(esc(label(data.effectiveStatus(plan)))).append("</li>");
}
for(QRecord run : data.failedRuns())
{
html.append("<li>Run ").append(recordLink(prefix, BackupsTables.RUN, run.getValue("id"), run.getValueString("runKey")))
.append(": ").append(esc(label(run.getValueString("status")))).append("</li>");
}
html.append("</ul>");
}
link(html, prefix, BackupsTables.RUN, "All runs");
close(html);
}
/*******************************************************************************
** Latest test restore per plan.
*******************************************************************************/
private static void latestTestRestore(StringBuilder html, BackupsHomeData data, String prefix)
{
open(html, "Latest test restore");
boolean any = false;
for(QRecord plan : data.plans())
{
QRecord v = data.latestTestRestore(plan.getValueInteger("id"));
if(v == null)
{
continue;
}
any = true;
html.append("<dl style=\"").append(LIST).append("\">");
row(html, "Plan", plan.getValueString("name"));
row(html, "Result", label(v.getValueString("status")));
row(html, "Compared", number(v.getValueLong("rowsChecked")) + " rows in " + number(toLong(v.getValueInteger("tablesChecked"))) + " tables, "
+ number(toLong(v.getValueInteger("mismatches"))) + " mismatches");
row(html, "Scratch target", v.getValueString("scratchTarget") == null ? "Not recorded" : v.getValueString("scratchTarget"));
html.append("</dl>");
}
if(!any)
{
empty(html, "No test restore yet. Run Verify backup, Test restore on a run, or set a verify schedule on the plan.");
}
link(html, prefix, BackupsTables.VERIFICATION, "All verifications");
close(html);
}
/*******************************************************************************
** Size trend per plan: bars for the stored size of each successful run (oldest
** left), then the numbers.
*******************************************************************************/
private static void trend(StringBuilder html, BackupsHomeData data)
{
open(html, "Size and duration trend");
boolean any = false;
for(QRecord plan : data.plans())
{
List<QRecord> runs = data.successfulRuns(plan.getValueInteger("id"));
if(runs.isEmpty())
{
continue;
}
any = true;
QRecord first = runs.get(0);
QRecord last = runs.get(runs.size() - 1);
html.append("<div style=\"margin:0 0 .75rem;\"><strong style=\"font-size:.9rem;\">").append(esc(plan.getValueString("name"))).append("</strong>");
bars(html, runs);
html.append("<dl style=\"").append(LIST).append("\">");
row(html, "Successful runs", String.valueOf(runs.size()));
row(html, "Stored", bytes(first.getValueLong("bytesStored")) + " at first, " + bytes(last.getValueLong("bytesStored")) + " latest");
row(html, "Rows", number(first.getValueLong("rowsTotal")) + " at first, " + number(last.getValueLong("rowsTotal")) + " latest");
row(html, "Duration", last.getValueLong("durationMs") == null ? "Not recorded" : duration(last.getValueLong("durationMs")) + " latest");
html.append("</dl></div>");
}
if(!any)
{
empty(html, "Trends appear after the first successful run.");
}
close(html);
}
/*******************************************************************************
** A row of bars, one per run, tall in proportion to the stored bytes.
*******************************************************************************/
private static void bars(StringBuilder html, List<QRecord> runs)
{
List<QRecord> shown = runs.size() > TREND_BARS ? runs.subList(runs.size() - TREND_BARS, runs.size()) : runs;
long max = 1;
for(QRecord run : shown)
{
max = Math.max(max, run.getValueLong("bytesStored") == null ? 0 : run.getValueLong("bytesStored"));
}
html.append("<div role=\"img\" aria-label=\"Stored size of the last ").append(shown.size()).append(" successful runs\" style=\"display:flex;align-items:flex-end;gap:2px;height:2.5rem;margin:.4rem 0 .6rem;\">");
for(QRecord run : shown)
{
long value = run.getValueLong("bytesStored") == null ? 0 : run.getValueLong("bytesStored");
long pct = Math.max(4, Math.round(100.0 * value / max));
String title = (run.getValueInstant("startDate") == null ? "" : WHEN.format(run.getValueInstant("startDate")) + ": ") + bytes(run.getValueLong("bytesStored"));
html.append("<span title=\"").append(esc(title)).append("\" style=\"flex:1 1 0;min-width:2px;max-width:1.5rem;height:").append(pct).append("%;background:currentColor;opacity:.55;border-radius:2px 2px 0 0;\"></span>");
}
html.append("</div>");
}
/*******************************************************************************
** Recent activity: the last runs, restores and verifications.
*******************************************************************************/
private static void recentActivity(StringBuilder html, BackupsHomeData data, String prefix)
{
open(html, "Recent activity");
if(data.recentRuns().isEmpty() && data.restores().isEmpty() && data.verifications().isEmpty())
{
empty(html, "Nothing has run yet.");
}
else
{
html.append("<ul style=\"").append(ITEMS).append("\">");
data.recentRuns().stream().limit(6).forEach(run ->
{
Instant start = run.getValueInstant("startDate");
html.append("<li>Backup ").append(recordLink(prefix, BackupsTables.RUN, run.getValue("id"), run.getValueString("runKey")))
.append(": ").append(esc(label(run.getValueString("status")))).append(start == null ? "" : ", " + esc(WHEN.format(start))).append("</li>");
});
data.restores().stream().limit(2).forEach(restore -> html.append("<li>Restore ").append(recordLink(prefix, BackupsTables.RESTORE_RUN, restore.getValue("id"), "#" + restore.getValue("id")))
.append(": ").append(esc(label(restore.getValueString("status")))).append("</li>"));
data.verifications().stream().limit(2).forEach(v -> html.append("<li>Verify ").append(recordLink(prefix, BackupsTables.VERIFICATION, v.getValue("id"), label(v.getValueString("level"))))
.append(": ").append(esc(label(v.getValueString("status")))).append("</li>"));
html.append("</ul>");
}
link(html, prefix, BackupsTables.RUN, "All runs");
close(html);
}
/*******************************************************************************
** Retention: runs kept per class and the oldest restorable point.
*******************************************************************************/
private static void retention(StringBuilder html, BackupsHomeData data)
{
open(html, "Retention");
boolean any = false;
for(QRecord plan : data.plans())
{
Map<String, Integer> counts = data.retentionCounts(plan.getValueInteger("id"));
if(counts.isEmpty())
{
continue;
}
any = true;
html.append("<dl style=\"").append(LIST).append("\">");
row(html, "Plan", plan.getValueString("name"));
row(html, "Kept", counts.entrySet().stream().map(e -> e.getValue() + " " + ("UNCLASSIFIED".equals(e.getKey()) ? "not classified yet (no prune has run)" : label(e.getKey()).toLowerCase()))
.reduce((a, b) -> a + ", " + b).orElse(""));
List<QRecord> runs = data.successfulRuns(plan.getValueInteger("id"));
Instant oldest = runs.isEmpty() ? null : runs.get(0).getValueInstant("startDate");
row(html, "Oldest restorable", oldest == null ? "Not known" : WHEN.format(oldest));
html.append("</dl>");
}
if(!any)
{
empty(html, "Retention starts once runs exist.");
}
close(html);
}
/*******************************************************************************
** Open a card.
*******************************************************************************/
private static void open(StringBuilder html, String title)
{
html.append("<section style=\"").append(CARD).append("\"><h3 style=\"margin:0 0 .75rem;font-size:1rem;font-weight:600;\">").append(esc(title)).append("</h3>");
}
/*******************************************************************************
** Close a card.
*******************************************************************************/
private static void close(StringBuilder html)
{
html.append("</section>");
}
/*******************************************************************************
** A muted empty-state line.
*******************************************************************************/
private static void empty(StringBuilder html, String text)
{
html.append("<p style=\"").append(MUTED).append("margin:0 0 .75rem;font-size:.9rem;\">").append(esc(text)).append("</p>");
}
/*******************************************************************************
** A name and value pair in a definition list.
*******************************************************************************/
private static void row(StringBuilder html, String name, String value)
{
html.append("<dt style=\"").append(MUTED).append("\">").append(esc(name)).append("</dt><dd style=\"margin:0;\">").append(esc(value == null ? "" : value)).append("</dd>");
}
/*******************************************************************************
** A small label over its value (a plan row cell).
*******************************************************************************/
private static void cell(StringBuilder html, String name, String value)
{
html.append("<div style=\"min-width:0;\"><div style=\"").append(MUTED).append("font-size:.75rem;\">").append(esc(name)).append("</div><div>").append(esc(value)).append("</div></div>");
}
/*******************************************************************************
** A status chip: a colored dot (the only color) and the status label.
*******************************************************************************/
private static String chip(String status)
{
String color = switch(status == null ? "UNKNOWN" : status)
{
case "OK" -> "#16a34a";
case "LATE", "UNVERIFIED", "UNKNOWN" -> "#d97706";
default -> "#dc2626";
};
return ("<span style=\"" + CHIP + "\"><span aria-hidden=\"true\" style=\"width:.55rem;height:.55rem;border-radius:50%;background:" + color + ";display:inline-block;\"></span>" + esc(label(status)) + "</span>");
}
/*******************************************************************************
** A link to the table list at the end of a card.
*******************************************************************************/
private static void link(StringBuilder html, String prefix, String table, String text)
{
html.append("<a style=\"").append(LINK).append("font-size:.9rem;\" href=\"").append(esc(prefix + "/" + table)).append("\">").append(esc(text)).append("</a>");
}
/*******************************************************************************
** A link to a record.
*******************************************************************************/
private static String recordLink(String prefix, String table, Object id, String text)
{
return ("<a style=\"" + LINK + "\" href=\"" + esc(prefix + "/" + table + "/" + id) + "\">" + esc(text == null ? String.valueOf(id) : text) + "</a>");
}
/*******************************************************************************
** SOME_STATUS to Some status (OK stays OK).
*******************************************************************************/
static String label(String status)
{
if(status == null || status.isEmpty())
{
return ("Unknown");
}
if("OK".equals(status))
{
return ("OK");
}
String s = status.replace('_', ' ').toLowerCase();
return (Character.toUpperCase(s.charAt(0)) + s.substring(1));
}
/*******************************************************************************
** An Integer as a Long, null-safe.
*******************************************************************************/
private static Long toLong(Integer value)
{
return (value == null ? null : value.longValue());
}
/*******************************************************************************
** Milliseconds as a short duration.
*******************************************************************************/
static String duration(long millis)
{
if(millis < 1000)
{
return (millis + " ms");
}
long seconds = millis / 1000;
return (seconds < 60 ? seconds + " s" : seconds / 60 + " min " + seconds % 60 + " s");
}
/*******************************************************************************
** Bytes as a short size.
*******************************************************************************/
static String bytes(Long value)
{
if(value == null)
{
return ("Not recorded");
}
double v = value;
String[] units = { "B", "KB", "MB", "GB", "TB" };
int i = 0;
while(v >= 1024 && i < units.length - 1)
{
v /= 1024;
i++;
}
return (i == 0 ? String.format("%d B", value) : String.format("%.1f %s", v, units[i]));
}
/*******************************************************************************
** A number with separators.
*******************************************************************************/
static String number(Long value)
{
return (value == null ? "Not recorded" : String.format("%,d", value));
}
/*******************************************************************************
** HTML-escape.
*******************************************************************************/
static String esc(String s)
{
return (s == null ? "" : s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;"));
}
}
@@ -0,0 +1,207 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.backups.app;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.backups.BackupsQBitConfig;
import com.kingsrook.qbits.backups.BackupsRuntime;
import com.kingsrook.qbits.backups.model.BackupsTables;
import com.kingsrook.qbits.backups.processes.DefaultPlanProcess;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** The Setup card on the Backups home: what a working backup needs (a plan, a
** storage bucket that resolves, a schedule, verification, a separate prune
** bucket), whether each is in place, and the next step. Computed from the plans
** the home already loaded and the runtime's storage resolver; it reads nothing
** else.
*******************************************************************************/
public final class BackupsSetupStatus
{
/*******************************************************************************
** OK: in place. TODO: backups need it. DEFAULT: working, with a default worth a
** look. OPTIONAL: a feature that is off until set up.
*******************************************************************************/
public enum State
{
OK, TODO, DEFAULT, OPTIONAL
}
/*******************************************************************************
** One line of the card.
*******************************************************************************/
public record Item(String label, String tableName, State state, String detail, String nextStep)
{
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private BackupsSetupStatus()
{
}
/*******************************************************************************
** Build the items from the plans.
*******************************************************************************/
public static List<Item> load(List<QRecord> plans)
{
BackupsQBitConfig config = BackupsRuntime.isInstalled() ? BackupsRuntime.getConfig() : null;
return (load(plans, config != null && hasContent(config.getSchedulerName()), config == null ? null : config.getHostSchedulerDescription(),
config != null && Boolean.TRUE.equals(config.getHostSchedulerRunsVerification())));
}
/*******************************************************************************
** Build the items from the plans, given whether a scheduler is configured (a plan
** cron only fires through the scheduler the qbit config names).
*******************************************************************************/
public static List<Item> load(List<QRecord> plans, boolean schedulerConfigured)
{
return (load(plans, schedulerConfigured, null, false));
}
/*******************************************************************************
** Build the items from the plans. A host that runs plan crons with its own tick
** (hostScheduler names it) has crons that fire; hostRunsVerification says whether
** that tick also runs the verify cron.
*******************************************************************************/
public static List<Item> load(List<QRecord> plans, boolean schedulerConfigured, String hostScheduler, boolean hostRunsVerification)
{
boolean host = hasContent(hostScheduler);
List<Item> items = new ArrayList<>();
List<QRecord> active = plans.stream().filter(p -> Boolean.TRUE.equals(p.getValueBoolean("isActive"))).toList();
///////////
// plans //
///////////
if(plans.isEmpty())
{
items.add(new Item("Backup plans", BackupsTables.PLAN, State.TODO, "No plan: nothing is backed up.",
"Run Create default plan on Backup plans (it asks only for the storage bucket alias), or add a plan with New."));
items.add(new Item("Plan scope rules", BackupsTables.PLAN_SCOPE, State.OPTIONAL, "None needed yet.",
"A plan that backs up everything (scope Instance) needs no rules. Add them on a plan that is limited to apps, qbits or tables."));
return (items);
}
items.add(active.isEmpty()
? new Item("Backup plans", BackupsTables.PLAN, State.TODO, plans.size() + (plans.size() == 1 ? " plan, but it is" : " plans, but none is") + " active: nothing runs on its own.", "Turn a plan on (Active).")
: new Item("Backup plans", BackupsTables.PLAN, State.OK, active.size() + " active plan" + (active.size() == 1 ? "" : "s") + " of " + plans.size() + ".", null));
/////////////////////////////////////////
// scope rules: needed unless INSTANCE //
/////////////////////////////////////////
List<String> limited = plans.stream().filter(p -> p.getValueString("scopeType") != null && !"INSTANCE".equals(p.getValueString("scopeType"))).map(p -> p.getValueString("name")).toList();
items.add(limited.isEmpty()
? new Item("Plan scope rules", BackupsTables.PLAN_SCOPE, State.OPTIONAL, "Every plan backs up the whole instance, so no rules are needed.",
"Add rules to a plan only to include or leave out apps, qbits, tables or roles.")
: new Item("Plan scope rules", BackupsTables.PLAN_SCOPE, State.OK, limited.size() + (limited.size() == 1 ? " plan is" : " plans are") + " limited by scope rules: " + String.join(", ", limited) + ".",
"Check each plan's Scope rules to see what it covers."));
///////////////////////////////////////////////
// storage: every alias a plan names resolves //
///////////////////////////////////////////////
Set<String> aliases = new LinkedHashSet<>();
for(QRecord plan : plans)
{
for(String field : List.of("storageBucketAlias", "pruneBucketAlias"))
{
if(plan.getValueString(field) != null)
{
aliases.add(plan.getValueString(field));
}
}
}
List<String> broken = new ArrayList<>();
for(String alias : aliases)
{
try
{
BackupsRuntime.storageFor(alias);
}
catch(RuntimeException e)
{
broken.add(alias);
}
}
items.add(broken.isEmpty()
? new Item("Storage", BackupsTables.PLAN, State.OK, aliases.size() + " storage bucket alias" + (aliases.size() == 1 ? "" : "es") + " in use, and each resolves.", "Run Test destination on a plan to prove it can write.")
: new Item("Storage", BackupsTables.PLAN, State.TODO, "These aliases do not resolve to a bucket: " + String.join(", ", broken) + ".",
"Fix the alias on the plan, or add the bucket to the application's storage resolver."));
/////////////////////////////////////////
// schedule: an active plan has a cron //
/////////////////////////////////////////
long scheduled = active.stream().filter(p -> hasContent(p.getValueString("cronExpression"))).count();
String noScheduler = "No scheduler is configured (schedulerName on the qbit config), so plan crons are saved but never fire";
items.add(!schedulerConfigured && !host
? new Item("Schedule", BackupsTables.PLAN, State.TODO, noScheduler + ". Backups run only when someone clicks Run backup now.",
"Set schedulerName on the qbit config (or run your own tick if the application does), then give each plan a cron expression.")
: scheduled == 0
? new Item("Schedule", BackupsTables.PLAN, State.TODO, "No active plan has a schedule: backups run only when someone clicks Run backup now.", "Set a Cron expression on a plan (for example " + DefaultPlanProcess.CRON + ").")
: new Item("Schedule", BackupsTables.PLAN, State.OK, scheduled + " active plan" + (scheduled == 1 ? " runs" : "s run") + " on a schedule"
+ (host && !schedulerConfigured ? ", run by " + hostScheduler + " (the application scheduler)." : "."), null));
//////////////////
// verification //
//////////////////
long verified = active.stream().filter(p -> hasContent(p.getValueString("verifyCronExpression"))).count();
items.add(host && !schedulerConfigured && !hostRunsVerification
? new Item("Verification", BackupsTables.VERIFICATION, State.OPTIONAL, "Not run by " + hostScheduler + ": verify cron expressions are saved but nothing fires them.",
"Run Verify backup on a run by hand, or have the application scheduler run the backupVerifyPlan process for each plan.")
: !schedulerConfigured && !host
? new Item("Verification", BackupsTables.VERIFICATION, State.TODO, noScheduler + ", so no verification is scheduled.",
"Set schedulerName on the qbit config, then a verify cron expression on a plan; or run Verify backup on a run by hand.")
: verified == 0
? new Item("Verification", BackupsTables.VERIFICATION, State.OPTIONAL, "No plan verifies its backups on a schedule.",
"Set a Verify cron expression on a plan, or run Verify backup on a run. A backup nobody has restored is a guess.")
: new Item("Verification", BackupsTables.VERIFICATION, State.OK, verified + " active plan" + (verified == 1 ? " verifies" : "s verify") + " its latest backup on a schedule.", null));
////////////////////////////////////////////
// prune bucket: separate delete authority //
////////////////////////////////////////////
long noPrune = plans.stream().filter(p -> !hasContent(p.getValueString("pruneBucketAlias"))).count();
items.add(noPrune == 0
? new Item("Prune bucket", BackupsTables.PLAN, State.OK, "Every plan prunes through its own delete-capable bucket alias.", null)
: new Item("Prune bucket", BackupsTables.PLAN, State.DEFAULT, noPrune + (noPrune == 1 ? " plan has" : " plans have") + " no prune bucket: Prune uses the backup bucket, so one key can both write and delete backups.",
"Give the plan a separate delete-capable Prune bucket alias."));
return (items);
}
/*******************************************************************************
**
*******************************************************************************/
private static boolean hasContent(String value)
{
return (value != null && !value.isBlank());
}
}
@@ -0,0 +1,86 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.backups.codec;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
/*******************************************************************************
** A reversible transform applied to the bytes of an artifact after compression,
** before they reach storage. The only implementation is client-side encryption
** (the streaming cipher of the QQQ crypto package); IDENTITY is the default.
*******************************************************************************/
public interface ArtifactTransform
{
/** No transform. */
ArtifactTransform IDENTITY = new ArtifactTransform()
{
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public OutputStream wrap(OutputStream out)
{
return (out);
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public InputStream unwrap(InputStream in)
{
return (in);
}
};
/***************************************************************************
** Wrap the stream going to storage.
***************************************************************************/
OutputStream wrap(OutputStream out) throws IOException;
/***************************************************************************
** Wrap a stream read from storage, to undo the transform.
***************************************************************************/
InputStream unwrap(InputStream in) throws IOException;
/***************************************************************************
** Whether this transform encrypts.
***************************************************************************/
default boolean encrypts()
{
return (false);
}
/***************************************************************************
** File name suffix added to artifact keys, e.g. ".tink".
***************************************************************************/
default String keySuffix()
{
return ("");
}
}
@@ -0,0 +1,277 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.backups.codec;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** The CSV row format (spec 8.3): RFC 4180, UTF-8 without a BOM, a header of
** field names, and the same canonical value text as JSONL. Null is written as
** \N (unquoted) so it can be told from the empty string; a string that is
** literally \N is quoted. A multi-line value is one record in a quoted field,
** so the codec works on records (a record may span lines): encode() returns a
** text that may contain embedded newlines, and the part writer appends one
** newline after each.
*******************************************************************************/
public class CsvRowCodec implements RowCodec
{
public static final String NULL_TOKEN = "\\N";
private final QTableMetaData table;
private final List<String> fieldNames;
/*******************************************************************************
** Constructor.
*******************************************************************************/
public CsvRowCodec(QTableMetaData table, java.util.Collection<String> excludedFields)
{
this.table = table;
TreeSet<String> names = new TreeSet<>(table.getFields().keySet());
if(excludedFields != null)
{
names.removeAll(excludedFields);
}
this.fieldNames = new ArrayList<>(names);
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public List<String> getFieldNames()
{
return (fieldNames);
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public String header()
{
return (String.join(",", fieldNames.stream().map(CsvRowCodec::quoteIfNeeded).toList()));
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public String encode(QRecord record)
{
StringBuilder sb = new StringBuilder();
for(int i = 0; i < fieldNames.size(); i++)
{
if(i > 0)
{
sb.append(',');
}
String name = fieldNames.get(i);
Serializable value = record.getValue(name);
if(value == null)
{
sb.append(NULL_TOKEN);
}
else
{
String text = FieldValueCodec.toCanonicalText(table.getField(name).getType(), value);
sb.append(NULL_TOKEN.equals(text) ? "\"" + text + "\"" : quoteIfNeeded(text));
}
}
return (sb.toString());
}
/*******************************************************************************
** Parse one CSV record (as returned by CsvRecordReader, header excluded) to a record.
** The line may contain embedded newlines inside quoted fields.
*******************************************************************************/
@Override
public QRecord decode(String line) throws QException
{
List<String> cells = splitCells(line);
return (toRecord(cells, fieldNames));
}
/*******************************************************************************
** Build a record from already-split cells and the header names.
*******************************************************************************/
public QRecord toRecord(List<String> cells, List<String> header) throws QException
{
if(cells.size() != header.size())
{
throw (new QException("CSV row has " + cells.size() + " cells but the header has " + header.size() + " for table " + table.getName()));
}
Map<String, Serializable> values = new LinkedHashMap<>();
for(int i = 0; i < header.size(); i++)
{
String cell = cells.get(i);
QFieldMetaData field = table.getFields().get(header.get(i));
if(cell == null)
{
values.put(header.get(i), null);
}
else if(field == null)
{
values.put(header.get(i), cell);
}
else
{
values.put(header.get(i), FieldValueCodec.fromCanonicalText(field.getType(), cell));
}
}
QRecord record = new QRecord();
record.setTableName(table.getName());
record.setValues(values);
return (record);
}
/*******************************************************************************
** Quote a cell when it contains a comma, quote, CR or LF (or is empty after
** being a header, which never happens), doubling embedded quotes.
*******************************************************************************/
static String quoteIfNeeded(String s)
{
if(s.indexOf(',') >= 0 || s.indexOf('"') >= 0 || s.indexOf('\n') >= 0 || s.indexOf('\r') >= 0)
{
return ("\"" + s.replace("\"", "\"\"") + "\"");
}
return (s);
}
/*******************************************************************************
** Split one CSV record into cells. Returns null for an unquoted \N cell (a null).
*******************************************************************************/
public static List<String> splitCells(String line)
{
List<String> cells = new ArrayList<>();
StringBuilder cell = new StringBuilder();
boolean quoted = false;
boolean wasQuoted = false;
for(int i = 0; i < line.length(); i++)
{
char c = line.charAt(i);
if(quoted)
{
if(c == '"')
{
if(i + 1 < line.length() && line.charAt(i + 1) == '"')
{
cell.append('"');
i++;
}
else
{
quoted = false;
}
}
else
{
cell.append(c);
}
}
else if(c == '"')
{
quoted = true;
wasQuoted = true;
}
else if(c == ',')
{
cells.add(finishCell(cell, wasQuoted));
cell = new StringBuilder();
wasQuoted = false;
}
else
{
cell.append(c);
}
}
cells.add(finishCell(cell, wasQuoted));
return (cells);
}
/*******************************************************************************
**
*******************************************************************************/
private static String finishCell(StringBuilder cell, boolean wasQuoted)
{
String s = cell.toString();
return (!wasQuoted && NULL_TOKEN.equals(s) ? null : s);
}
/*******************************************************************************
** Split a whole CSV text into records, honoring newlines inside quoted fields.
*******************************************************************************/
public static List<String> splitRecords(String text)
{
List<String> records = new ArrayList<>();
StringBuilder current = new StringBuilder();
boolean quoted = false;
for(int i = 0; i < text.length(); i++)
{
char c = text.charAt(i);
if(c == '"')
{
quoted = !quoted;
current.append(c);
}
else if(c == '\n' && !quoted)
{
records.add(current.toString());
current = new StringBuilder();
}
else
{
current.append(c);
}
}
if(current.length() > 0)
{
records.add(current.toString());
}
return (records);
}
}
@@ -0,0 +1,172 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.backups.codec;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
import com.fasterxml.jackson.databind.JsonNode;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
** The canonical, lossless text encoding of a field value, by QFieldType
** (spec 8.2). The same canonical text is used by JSONL and CSV, so a value
** means the same thing in both, and by the content hash.
**
** STRING, TEXT, HTML, PASSWORD: string
** INTEGER, LONG: number
** DECIMAL: string (BigDecimal.toPlainString), so there is no float rounding
** BOOLEAN: true or false
** DATE: yyyy-MM-dd
** TIME: HH:mm:ss, with .SSSSSS only when there are fractional seconds
** DATE_TIME: an ISO-8601 instant in UTC, with .SSSSSS only when there are fractional seconds
** BLOB: base64
*******************************************************************************/
public final class FieldValueCodec
{
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm:ss");
private static final DateTimeFormatter TIME_FORMAT_MICROS = DateTimeFormatter.ofPattern("HH:mm:ss.SSSSSS");
private static final DateTimeFormatter INSTANT_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'").withZone(ZoneOffset.UTC);
private static final DateTimeFormatter INSTANT_FORMAT_MICRO = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'").withZone(ZoneOffset.UTC);
/*******************************************************************************
** Utility class.
*******************************************************************************/
private FieldValueCodec()
{
}
/*******************************************************************************
** Whether the canonical JSON form of a type is a bare number or boolean (not quoted).
*******************************************************************************/
public static boolean isBareJson(QFieldType type)
{
return (type == QFieldType.INTEGER || type == QFieldType.LONG || type == QFieldType.BOOLEAN);
}
/*******************************************************************************
** The canonical text of a non-null value.
*******************************************************************************/
public static String toCanonicalText(QFieldType type, Serializable value)
{
switch(type)
{
case INTEGER, LONG:
return (String.valueOf(ValueUtils.getValueAsLong(value)));
case DECIMAL:
return (ValueUtils.getValueAsBigDecimal(value).toPlainString());
case BOOLEAN:
return (String.valueOf(ValueUtils.getValueAsBoolean(value)));
case DATE:
return (ValueUtils.getValueAsLocalDate(value).toString());
case TIME:
{
LocalTime time = ValueUtils.getValueAsLocalTime(value);
return (time.getNano() == 0 ? TIME_FORMAT.format(time) : TIME_FORMAT_MICROS.format(time));
}
case DATE_TIME:
{
Instant instant = ValueUtils.getValueAsInstant(value);
return (instant.getNano() == 0 ? INSTANT_FORMAT.format(instant) : INSTANT_FORMAT_MICRO.format(instant));
}
case BLOB:
return (Base64.getEncoder().encodeToString(blobBytes(value)));
default:
return (String.valueOf(value));
}
}
/*******************************************************************************
**
*******************************************************************************/
private static byte[] blobBytes(Serializable value)
{
if(value instanceof byte[] bytes)
{
return (bytes);
}
return (String.valueOf(value).getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
/*******************************************************************************
** Parse canonical text back to a Java value.
*******************************************************************************/
public static Serializable fromCanonicalText(QFieldType type, String text)
{
if(text == null)
{
return (null);
}
switch(type)
{
case INTEGER:
return (Integer.valueOf(text));
case LONG:
return (Long.valueOf(text));
case DECIMAL:
return (new BigDecimal(text));
case BOOLEAN:
return (Boolean.valueOf(text));
case DATE:
return (LocalDate.parse(text));
case TIME:
return (LocalTime.parse(text));
case DATE_TIME:
return (Instant.parse(text));
case BLOB:
return (Base64.getDecoder().decode(text));
default:
return (text);
}
}
/*******************************************************************************
** Convert a parsed JSON value to a Java value.
*******************************************************************************/
public static Serializable fromJson(QFieldType type, JsonNode node)
{
if(node == null || node.isNull())
{
return (null);
}
if(type == QFieldType.BOOLEAN)
{
return (node.asBoolean());
}
return (fromCanonicalText(type, node.isNumber() ? node.asText() : node.textValue()));
}
}
Loaded 100 of 224 files, more files were not shown because too many files have changed in this diff. Show more