Compare commits

...
Author SHA1 Message Date
james.maes c27034563c Merge pull request 'fix(permissions): let the service write its own tables when core actions enforce permissions' (#52) from fix/system-writes-under-core-enforcement into develop
build / maven (push) Successful in 2m53s
maven / build (push) Successful in 2m53s
2026-11-12 17:36:14 +00:00
james.maesandClaude Fable 5.2 a8e8750829 fix(permissions): mark the service's own writes with the core bypass; report a failed audit row
build / maven (push) Successful in 2m48s
maven / build (push) Successful in 2m48s
Review of the first fix found the checker's pass too broad: it let any
QSystemUserSession insert, update and delete every table nobody may write
directly, so server code running as the system user could edit or delete
operation log rows.

- ObjectStoragePermissionChecker no longer looks at the session class. A
  write of those tables that reaches it is refused to every session, the
  core system user included.
- Db.insert, Db.update and Db.delete mark their input with core's per-call
  permission bypass (withPermissionBypass, Db.OWN_WRITE_REASON), so core
  does not ask about the service's own writes when a host enforces
  permissions inside the core actions.
- A failed operation log row is logged at ERROR, raises the
  storage.operation_log.failed alert on the StorageAlertSink (resolved by
  the next row written for the bucket) and is counted in
  OperationLog.failedWriteCount(). A row the insert action hands back with
  a record error is now detected too. The operation still succeeds.

Tests: the service's own log, index insert, update and delete under core
enforcement, with the acting person on the row; a system session outside
Db refused insert, update and delete of an operation row; update and
delete of an operation row refused to a person, the administrator and the
system user; the failure signal.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 11:28:59 -06:00
james.maesandClaude Fable 5.2 cfe190634d fix(permissions): let the service write its own tables when core actions enforce permissions
build / maven (push) Successful in 5m33s
maven / build (push) Successful in 5m33s
In a host that sets QInstance.enforcePermissionsInCoreActions, core checks
table permissions inside InsertAction, UpdateAction and DeleteAction for
every outermost call. Db.asSystem starts a new context, so each write it
makes is outermost, and core asks ObjectStoragePermissionChecker about it.
The checker refused every write of a table that is not admin-editable
("This storage table changes only through storage actions.") to every
session, the system user the service itself writes as included.

Seen in a host application: every StorageBucket.put stored the object and
logged "Could not write a storage operation log row" with
QPermissionDeniedException, so object_storage_operation had no row for any
stored file. The same refusal applies to every other Db.asSystem write of
those tables.

The checker now lets a QSystemUserSession through for those tables. Such a
session is built by server code only; no authentication module gives one
to a request. A person is refused as before, the storage administrator
included (DbSystemSessionTest
.testServiceWritesItsOwnTablesWhenCoreActionsEnforcePermissions, which
failed before with no operation row).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 10:55:52 -06:00
CI ab16a1891b Bump version to 0.2.0-SNAPSHOT [skip ci] 2026-11-07 17:23:30 +00:00
james.maes 1c9a5c53b8 Merge pull request 'build(deps): align with Gitea releases (parent 2.2.1, QQQ 4.1.0-RC.25)' (#50) from deps/align-gitea into develop
build / maven (push) Successful in 6m18s
maven / build (push) Successful in 6m18s
2026-11-07 17:18:18 +00:00
james.maesandClaude Opus 5.5 04d61ec5f0 build(deps): align with Gitea releases (parent 2.2.1, QQQ 4.1.0-RC.25)
build / maven (push) Successful in 6m40s
maven / build (push) Successful in 6m40s
pom.xml: parent -> 2.2.1
pom.xml: qqq.snapshot.version: 4.1.0-RC.15 -> 4.1.0-RC.25

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:09:10 +00:00
james.maes 8169d4c3c8 Merge pull request 'ci: pin shared workflow to v1' (#49) from ci/pin-v1-develop into develop
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 12s
QRun-IO/ci-policy: native / native-result (push) Successful in 9s
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-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 13s
build / maven (push) Successful in 7m32s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (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-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-business (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 7m32s
2026-11-06 16:09:08 +00:00
james.maesandClaude Opus 5.5 e451267495 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 8s
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 / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (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) Successful in 7s
build / maven (push) Successful in 6m37s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
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 6m37s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:12:06 +00:00
james.maes adf3b7fe5d Merge pull request 'ci: use shared ci-workflows Maven workflow' (#48) from ci/ci-workflows into develop
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
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: native / native-result (push) Successful in 5s
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-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 6s
build / maven (push) Successful in 5m27s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow d20a578c7d818bc7ca32a2ac2c675da4e4f27385] (push) Successful in 5s
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-business (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 5m27s
2026-11-05 23:17:26 +00:00
CI 55163ca402 ci: rerun shared 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 9s
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: 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 5m19s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 5s
QRun-IO/ci-policy: native / native-result (pull_request) Failing after 3s
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
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Failing after 1s
maven / build (push) Successful in 5m19s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 4m41s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Failing after 11s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Failing after 17m57s
2026-11-05 16:25:54 -06:00
james.maesandClaude Opus 5.5 9b7c907715 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
build / maven (push) Failing after 0s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Failing after 1s
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: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 2s
maven / build (push) Failing after 1s
QRun-IO/ci-policy: native / native-result (pull_request) Failing after 1s
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
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:06:51 +00:00
forge-recovery 2dbcb4f6f0 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: ci / original-business (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 26s
QRun-IO/ci-policy: native / native-result (push) Successful in 10s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (push) Successful in 26s
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
Shared Java Maven CI with Docker / verify (push) Successful in 4m32s
QRun-IO/ci-policy: ci / java-docker-3 (push) Successful in 52m15s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 6s
qrun-release / postpublish (push) Successful in 1m42s
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
Shared Java Maven CI with Docker / gate (push) Successful in 11s
qrun-release / release (push) Successful in 52m3s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (push) Successful in 4m14s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (push) Successful in 12s
Shared QRun source version / version (push) Successful in 1m43s
2026-11-03 18:10:05 +00:00
forge-recovery c00d0e0d35 ci: enroll shared Gitea lifecycle
QRun-IO/ci-policy: ci / ci-result (push) Successful in 3s
QRun-IO/ci-policy: ci / java-docker-3 (push) Successful in 1h14m9s
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 10s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (push) Successful in 31s
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: Shared Java Maven CI with Docker / verify (push) Successful in 4m54s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (push) Successful in 10s
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
2026-11-02 22:34:56 +00:00
forge-recoveryandCodex aec65dcdb9 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 / original-mcp (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: 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: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 9s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 23s
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 5m58s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Successful in 14m43s
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 20:18:04 +00:00
7a949c32ab ci: add the isolated postpublish job to the QRun release caller
Shared Java Maven CI with Docker / secrets (push) Failing after 26s
Shared Java Maven CI with Docker / verify (push) Canceled after 0s
qrun-release / postpublish (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 / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (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: ci / ci-result (pull_request) Successful in 6s
Release candidate / release-rc (pull_request) Skipped
Shared Java Maven CI with Docker / gate (push) Canceled after 0s
qrun-release / release (push) Canceled after 2h33m16s
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: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 16s
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 3m57s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Successful in 24m39s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 15:51:31 +00:00
95b45414d8 ci: move the QRun release caller to the thin v2 contract
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
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
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 / ci-result (pull_request) Failing after 5s
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Canceled after 37m17s
Shared Java Maven CI with Docker / verify (push) Canceled after 0s
Shared Java Maven CI with Docker / secrets (push) Failing after 24s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-7 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Canceled after 0s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Canceled after 0s
Shared Java Maven CI with Docker / gate (push) Canceled after 0s
qrun-release / release (push) Canceled after 37m23s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Failing after 23s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 06:19:17 +00:00
james.maesandCodex a7b5f19626 ci: adopt managed orb 0.8 patch track (#41)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 15:35:15 -05:00
james.maesandCodex 79bc8fcf34 ci: adopt shared orb 0.7.18 (#40)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:52:27 -05:00
james.maesandCodex 4eec8b56f3 ci: adopt shared orb 0.7.17 (#39)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:20:13 -05:00
james.maesandCodex 988f4c56b1 ci: centralize QBit source scanning (#36)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 10:43:43 -05:00
james.maesandCodex 03ed383587 ci: adopt central orb 0.7.12 and stable gates (#35)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 08:58:38 -05:00
james.maesandCodex 85e9ab1316 ci: centralize rc release completion in qqq orb (#34)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:30:37 -05:00
james.maesandCodex 03215cab62 ci: adopt shared orb 0.7.11 (#33)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 01:02:00 -05:00
james.maes 6866e6653f Merge pull request #32 from QRun-IO/feature/central-ci
ci: adopt verified central QQQ orb 0.7.1
2026-10-20 18:49:56 -05:00
james.maesandCodex eceb2a6d59 ci: adopt verified central QQQ orb 0.7.2
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 18:42:41 -05:00
james.maes 3f8ca0ef1f Merge pull request #30 from QRun-IO/fix/object-storage-app-links
fix(app): Storage home links use the /app/<table> route
2026-10-16 10:19:59 -05:00
james.maesandClaude Opus 5.5 e341a90df3 fix(app): Storage home links use the /app/<table> route
The home emitted /objectStorageAdmin/<table> (or /<parent>/objectStorageAdmin/
<table>), which Next does not serve. Links now use /app/<table>, configurable
through ObjectStorageQBitConfig.tablePathPrefix (default /app), matching the
other qbits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:12:28 -05:00
james.maes a7e6a40924 Merge pull request #29 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:03:32 -05:00
james.maes 0222074cf9 Merge pull request #27 from QRun-IO/feature/cluster-caches
Register object-storage caches with ClusterCaches (multi-node)
2026-10-15 16:29:04 -05:00
james.maesandClaude Opus 5.5 4033590078 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:19:37 -05:00
james.maes 75b4647d08 Merge remote-tracking branch 'origin/develop' into feature/cluster-caches
# Conflicts:
#	pom.xml
2026-10-15 16:17:05 -05:00
james.maes 9590619e0d Merge pull request #28 from QRun-IO/feature/core-s3-session-file-refs
refactor: core S3 session credentials and ${file.PATH} refs; drop qqq#1165 stand-ins (QQQ RC.15)
2026-10-15 16:07:37 -05:00
james.maesandClaude Opus 5.5 4261382567 build: pin QQQ 4.1.0-RC.15 (core S3 session credentials and ${file.PATH}, qqq#1165)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:00:18 -05:00
james.maesandClaude Opus 5.5 88f7f33fe7 build: QQQ 4.1.0-RC.14 (ships ClusterCaches)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:05:09 -05:00
james.maesandClaude Opus 5.5 2b5a84e805 refactor: drop the qqq#1165 stand-ins for core session credentials and file refs
SessionCredentialsClient is gone: the session token now rides core
S3BackendMetaData.sessionToken, and the presigner takes its credentials from
core S3ClientFactory.buildCredentialsProvider. SecretRefs keeps only the
qbit's allow-list; ${file.PATH} resolution is core QMetaDataVariableInterpreter
(read-only, allowed roots /var/run/secrets and /run/secrets by default).
Tests point qqq.fileReference.roots at their temp dirs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:16:47 -05:00
james.maesandClaude Opus 5.5 878270503e feat(cluster): register object-storage caches with ClusterCaches
Shared (cleared on every node): bucket-row presign limits and enabled
header rules, invalidated by the new ConfigRowChangeCustomizer on the
bucket and header-rule tables, and capability statuses, invalidated per
key when a probe result is recorded (other nodes reload from the row).
The 15 s TTLs stay as the safety net. Row-id maps, S3 clients, process
permissions, provider defaults, the running-scan guard and the per-pod
presign rate limiter are node-local, with reasons.

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:20:02 -05:00
james.maes 7c825b05d5 Merge pull request #26 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 5401070321 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-object-storage.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:34:11 -05:00
james.maes 75bf74967d Merge pull request #25 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:21 -05:00
james.maesandClaude Opus 5.5 eb04621d4e 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:07:58 -05:00
james.maes 38e74cc07f Merge pull request #17 from QRun-IO/feature/health-checks
feat(health): register object storage connection checks with core central health
2026-10-14 21:27:01 -05:00
james.maesandClaude Opus 5.5 8aa4511839 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:18:29 -05:00
james.maes 9b2158bf97 Merge pull request #24 from QRun-IO/feature/help-bundle
feat(help): qbit-object-storage help bundle via QQQ help bundles, with coverage gate
2026-10-14 20:45:30 -05:00
james.maesandClaude Opus 5.5 d00c230140 feat(help): ship the qbit-object-storage 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:37:04 -05:00
james.maes 11f1fbff3d Merge pull request #23 from QRun-IO/feature/fix-fk-dbms-postgresql
Fix develop CI: skip Postgres-only FK DO blocks in H2 DefaultsChangelogTest
2026-10-14 17:51:25 -05:00
james.maesandClaude Opus 5.5 2964b91437 test: skip Postgres-only FK blocks when building the H2 schema in DefaultsChangelogTest
The FK changesets from #20 are PL/pgSQL DO blocks; H2 cannot run them, which broke
#21's DefaultsChangelogTest. FKs stay real on Postgres (PostgresIndexTest covers them).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:43:59 -05:00
james.maes 0b6ea1914a Merge pull request #20 from QRun-IO/feature/foreign-keys
feat(schema): foreign keys on every relation between the qbit tables
2026-10-14 17:36:55 -05:00
james.maes deccc44fd2 Merge pull request #19 from QRun-IO/feature/object-id-indexes
fix(schema): index the record-page child columns folder_id and object_id
2026-10-14 17:36:48 -05:00
james.maes 4498eac60b Merge pull request #18 from QRun-IO/feature/system-session-qsystemusersession
fix(internal): run system work as a QSystemUserSession
2026-10-14 17:36:42 -05:00
james.maes 632c2cfa2d Merge pull request #21 from QRun-IO/feature/tables-populated-defaults
feat: guided empty states, Setup card, starter header rule and bucket presign overrides
2026-10-14 17:19:50 -05:00
james.maesandClaude Opus 5.5 e9fd0cef4c fix: presign row limits are a cached lower cap, no config mutation; setup card reads runtime config; empty rollback
- effective limit is min(code, row); row 0/null/higher is ignored; config never mutated
- applied to presignGet/Put/Post, previewUrl and the download-link action; row read cached 15 s
- Setup card takes connections and buckets from the runtime config and counts rules as system
- seed changeset rollback is empty; test that a host rule survives rollback
- host help kept test, stand-in marker prose in the bundle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:10:53 -05:00
james.maesandClaude Opus 5.5 25e57e0315 feat: guided empty states, Setup card, starter header rule and bucket presign overrides
Help bundle for all 12 tables and the app (ObjectStorageHelp), Setup card on
the home, disabled default-mutable header rule via a jar changelog tested
through real Liquibase on H2, tables guardrail test. The bucket row's presign
limits are now read at runtime (ConfigSync.withBucketOverrides). The version
table is documented as reserved and not used yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:57:43 -05:00
james.maesandClaude Opus 5.5 1ef599f695 feat(schema): foreign keys on every relation between the qbit tables
ObjectStorageSchema now declares a foreign key for each join and table
possible value (fk_<table>_<column>): capabilities, folders, objects,
versions, references and usage cascade from their bucket or object;
object.folder_id and object_reference.object_id are set null; a bucket's
connection, its header and protection rules and the operation log
restrict. Each key is added by an idempotent DO block that first records
orphans in qqq_fk_quarantine and clears (nullable) or deletes (required)
them, so the same statements upgrade an existing schema. Three new
indexes back keys that had none.

PostgresIndexTest gains the guardrail (every relation has a database
foreign key with its rule, every key column leads an index), the
upgrade path with orphans run twice, and the delete paths the scan uses.

Builds on #19 (feature/object-id-indexes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:21:04 -05:00
james.maesandClaude Opus 5.5 ed1bbcfdf9 fix(schema): index the record-page child columns folder_id and object_id
The folder -> objects, object -> versions and object -> references child
lists filter on the parent id alone, but every existing index on those
tables leads with bucket_id. The generated schema now adds
ix_object_storage_object_7 (folder_id), ix_object_storage_object_version_2
(object_id) and ix_object_storage_object_reference_3 (object_id). They are
appended, so existing generated index names keep their numbers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:25:21 -05:00
james.maesandClaude Opus 5.5 7f8e926f0c fix(internal): run system work as a QSystemUserSession
Db.asSystem built an anonymous QSession subclass with every permission
but no token. Token-based authentication modules (OAuth2, Auth0) accept a
QSystemUserSession without a token and reject any other tokenless session,
so on such hosts every system read and write failed in session validation
(JWTDecodeException: The token is null). ConfigSync never mirrored the
code-defined connections and buckets, and the operation log, object index,
capabilities and protection/header rule scoping silently did nothing.

Use core QSystemUserSession(instance), which carries the same permissions
and all-access keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:12:32 -05:00
james.maesandClaude Opus 5.5 fd81c88f32 docs: object storage health checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:39:40 -05:00
james.maesandClaude Opus 5.5 63c441e6cd feat(health): register object storage connection checks with core central health
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:39:39 -05:00
james.maesandClaude Opus 5.5 b13122f1b0 feat(health): deadline-aware read-only capability probe
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:39:38 -05:00
james.maes 4741285e96 Merge pull request #15 from QRun-IO/feature/orb-0.6.9
ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
2026-10-14 10:46:16 -05:00
james.maesandClaude Opus 5.5 4cf6ed9d83 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:07:30 -05:00
james.maes 21d5172fc4 Merge pull request #14 from QRun-IO/feature/guardrails-rc6
build: QQQ 4.1.0-RC.6 + guardrails (parent 2.1.0, enforced)
2026-10-14 07:27:51 -05:00
james.maesandClaude Opus 5.5 5871f32b77 build: QQQ 4.1.0-RC.6 and qbit-build-parent 2.1.0 with guardrails enforced
Move the core baseline to the released 4.1.0-RC.6 (architecture review
finding F19, guardrail G7). Adopt qbit-build-parent 2.1.0 with the
architecture guardrails enforced (G1, G3, G4, G7) and tests in strict
raw-route mode (G2). Everything passes with no source changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:18:42 -05:00
james.maes 97a79e031d Merge pull request #13 from QRun-IO/feature/bucket-max-upload-bytes
feat(api): StorageBucket.maxUploadBytes()
2026-10-14 07:09:09 -05:00
james.maesandClaude Opus 5.5 6ffcd32f99 feat(api): StorageBucket.maxUploadBytes()
Expose a bucket's upload limit in the api package (null means no limit),
so consumers such as qbit-collaboration need not read the internal
StorageBucketConfig through config(). Found by the qbit-guardrails G1-arch
check (qbit-bom#10).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:23:03 -05:00
james.maes 64d23b03af Merge pull request #11 from QRun-IO/feature/arch-s3factory
refactor(connections): core S3ClientFactory on QQQ RC.5 (#10)
2026-10-13 19:46:14 -05:00
james.maesandClaude Opus 5.5 f56f40b8b7 docs: point the stand-in marker at QRun-IO/qqq#1165
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:36:15 -05:00
james.maesandClaude Opus 5.5 a58b179334 refactor(connections): build S3 clients with core S3ClientFactory (QQQ 4.1.0-RC.5)
ConnectionRuntime maps StorageConnectionConfig to S3BackendMetaData and
builds clients with core S3ClientFactory.buildClient, which is uncached.
The per-connection cache keyed by the settings hash is unchanged.
The duplicated HTTP, TLS and trust-manager code is deleted.

Still local:
- the presigner;
- a session-credentials bridge, because core has no session-token or
  credentials-provider hook;
- ${file.PATH} secret refs.
The last two are tagged STAND-IN(core=QRun-IO/qqq#1165, removeBy=4.1.0-RC.7).

Retries now follow core and SDK defaults instead of a forced ADAPTIVE_V2.
Unused SFTP/SSH transitive dependencies of the filesystem module are
excluded.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:36:05 -05:00
james.maes 4411376a9e Merge pull request #9 from QRun-IO/feature/release-rc-automation
ci: automate RC releases; hotfix/* pushes no longer publish
2026-10-13 14:48:20 -05:00
james.maesandClaude Opus 5.5 b5857dd26e 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:37:07 -05:00
james.maes 780cfa7742 Merge pull request #8 from QRun-IO/feature/awssdk-2.41.34
build(deps): AWS SDK for Java v2 2.41.34 (GHSA-443w-3rq3-5m5h)
2026-10-13 10:40:40 -05:00
james.maesandClaude Opus 5.5 2e95c64a78 build(deps): AWS SDK for Java v2 2.41.10 -> 2.41.34 (GHSA-443w-3rq3-5m5h)
2.41.10 carries the HIGH advisory GHSA-443w-3rq3-5m5h (flagged by a Trivy scan); 2.41.30+ fixes it. Same minor line, so no API change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:23:36 -05:00
james.maes 767f237622 Merge pull request #6 from QRun-IO/feature/storage-polish
feat: standalone mode, key-folder references, docs
2026-10-12 17:43:55 -05:00
james.maesandClaude Sonnet 5.5 bda6758b36 feat: standalone mode, key-folder references, child-list columns, full docs
- ObjectStorage.configureStandalone(config) for CLI tools and tests (no QInstance)
- FieldReferenceProvider.keyFolder(...) for key-per-folder references
- child lists on record screens show a curated column set
- README and CHANGELOG rewritten (usage, deployment, owner steps)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 17:36:58 -05:00
james.maes c909e21f13 Merge pull request #4 from QRun-IO/feature/p1-storage-service
feat: P1 storage service, tables, probe and conformance suite
2026-10-12 17:25:38 -05:00
59 changed files with 5420 additions and 403 deletions

No files matched your search

+11 -68
View File
@@ -1,71 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.8
commands:
configure-package-install:
steps:
- run:
name: Keep package installation unattended
command: |
# These build machines are discarded after the job.
sudo install -d /etc/needrestart/conf.d
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
test_only:
jobs:
- qqq-orb/mvn_test_only:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /(develop|release\/.*|hotfix\/.*)/
publish_snapshot:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: snapshot
filters:
branches:
only: [develop]
publish_release_candidate:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release_candidate
filters:
branches:
only: [/release\/.*/]
publish_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release
filters:
branches:
ignore: /.*/
tags:
only: [/^v\d+\.\d+\.\d+$/]
publish_hotfix_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: hotfix
filters:
branches:
only: [/hotfix\/.*/]
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
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
+23
View File
@@ -0,0 +1,23 @@
name: CodeQL
'on':
push:
branches:
- main
- develop
- release/**
- hotfix/**
pull_request: {}
schedule:
- cron: 41 16 * * 0
workflow_dispatch: {}
permissions:
contents: read
actions: read
security-events: write
packages: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
+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
+16
View File
@@ -0,0 +1,16 @@
profile: qbit
orb_version: '0.8'
default_branch: develop
java_version: '21'
browsers: true
api_checks: false
docs_only: false
release_on_main: false
release_tag_policy: stable
test_branch_policy: non-release
publish:
snapshot: true
release_candidate: true
release: 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.5` 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
+43 -1
View File
@@ -5,4 +5,46 @@ All notable changes to this project are documented here. The format follows Keep
## [0.1.0] - Unreleased
### Added
- Repository scaffold: `ObjectStorageQBitConfig` and `ObjectStorageQBitProducer` (table prefix `object_storage_`, permissions `object_storage.*`), build, CI and repository standard files.
- Central health supplier `object_storage.connection.<id>` (DEPENDENCY/STORAGE, 300s interval, 20000ms timeout), reusing bounded read-only capability probes with safe problem kinds and clock-skew details. Turn a connection's check off in the central `health_check` table.
- In-memory health tests for registration, refresh, UP/DOWN/DEGRADED/UNKNOWN outcomes, deadlines, read-only execution and sentinel-secret exclusion.
- Expanded the host-neutral in-app help bundle to cover storage fields, actions, widgets and possible values, with live references and a `HelpBundleAssertions` coverage gate. Registered it for QQQ startup sync and HOST overrides; retained inline table/app help for hosts without help-content tables.
- Every table explains itself: a help bundle (`help/qbit-object-storage.yaml`, QQQ help-bundle format) says what each `object_storage_*` table is for and how its rows get created; it shows on the table screen and when the table is empty. Applied inline by `ObjectStorageHelp` for hosts without help-content tables; a host's own help is kept.
- A Setup card on the Storage home (connections, buckets, header rules, protection rules) with the state and next step of each, resolved the way the runtime resolves them.
- A starter changelog `db/liquibase/qbit-object-storage/object-storage-defaults.changelog.sql`: a disabled `default-mutable` header rule. Insert-only, run through real Liquibase in `DefaultsChangelogTest`.
- `ObjectStorageTablesGuardrailTest` (each table is used at runtime, seeded or explained, and has help).
- The presign limits on the bucket row (`maxPresignGetSeconds`, `maxPresignPutSeconds`) are now read at runtime (`ConfigSync.effectiveMaxPresignGetSeconds` / `Put`) as a lower cap: the effective limit is the smaller of the code config and the row. It applies to presigned GET, PUT, POST, previews and the Create download link action. Previously the row value was never read.
- `object_storage_object_version` is documented as reserved and not used yet (the indexer lists current versions only).
- `StorageBucket.maxUploadBytes()` in the `api` package: the bucket's upload limit in bytes (null when none), so other qbits need not call `config()`, which returns an internal type.
- **The service (P1).** `ObjectStorage.bucket(alias)` and `ObjectStorage.asSystem(caller, runId)`: put (multipart above the part size), get with ranges, head, copy (in parts for very large objects), header and metadata changes, delete (delete marker, private trash, or permanent), restore, list, presigned GET, PUT and POST, public URLs, tags. Key building and URL encoding, the header policy (`object_storage_header_rule`), `PublicUrlResolver`, `StorageChangeListener`, `StorageReferenceProvider`, `StorageAlertSink`.
- Configuration in code, upserted into visible `object_storage_*` tables; secret references with an allow-list; per-connection rate limits; per-bucket and per-connection record security locks.
- The capability model and the Test connection probe (read-only by default, write tests on request, clock skew, presign host), with seeded provider defaults.
- The `object_storage_operation` audit log (presigned URLs are logged without their signature) and protection rules (`READ_ONLY`, `NO_DELETE`, `WARN`, with allowed system callers).
- Backups follow-ups SF1 to SF5: `BucketRole.BACKUP`, object lock on put, system-caller permanent delete, probes that skip locked backup buckets, and the system-caller context.
- **The index (P2).** `object_storage_object` and `object_storage_folder` kept in step with each indexed bucket: resumable full scan with a mass-removal safety stop, HEAD backlog, write-through on every service write, soft-deleted rows, folder rollups. Orphaned and missing objects from `FieldReferenceProvider`, daily usage snapshots, quota and freshness alerts, the nightly job.
- **The Storage app (P3, P4).** Admin -> Storage with a responsive home; record screens with sections, child lists and a thumbnail preview; actions Rescan bucket, Check configuration, Refresh from bucket, Apply header policy (replaces app-specific backfills), Change headers and metadata, Edit tags, Copy, Move or rename, Delete, Restore, Create download link, Create upload link, folder rename and delete, Upload files, Test connection; every one a standard Review and Result with a bulk cap.
- `ObjectStorageSchema`: PostgreSQL DDL generated from the table metadata.
- Foreign keys on every relation between the qbit tables (`fk_<table>_<column>`), generated by `ObjectStorageSchema`: owned observed state (capabilities, folders, objects, versions, usage) cascades from its bucket or object, `object.folder_id` and `object_reference.object_id` are set null, and a bucket's connection, header and protection rules and operation log restrict. Each key is added by an idempotent block that first records rows whose parent is gone in `qqq_fk_quarantine` and clears (nullable column) or deletes (required column) them, so it also upgrades an existing schema. New indexes `ix_object_storage_bucket_1` (connection_id), `ix_object_storage_header_rule_1` and `ix_object_storage_protection_rule_1` (bucket_id) back the keys. Upgrade: run the generated statements again (or the host's copy of them).
- Conformance suite against MinIO and Garage (Testcontainers), service, index and action tests, and the index and actions on PostgreSQL.
### Fixed
- The service's own writes are no longer refused in a host that enforces permissions inside the core actions (`QInstance.enforcePermissionsInCoreActions`). `Db.asSystem` starts a new context, so each of its writes is an outermost action there and core asks `ObjectStoragePermissionChecker`, which refuses every write of a table nobody may write directly, to every session. The `object_storage_operation` row of every stored object was lost (logged as "Could not write a storage operation log row", the object itself was stored), and so was each other system write of those tables. `Db.insert`, `Db.update` and `Db.delete` now mark their input with core's per-call permission bypass (`withPermissionBypass`, reason `Db.OWN_WRITE_REASON`), so core does not ask about the service's own writes. The checker is unchanged in what it refuses: a write of those tables that does not carry a bypass is refused to a person, to `object_storage.admin` and to the core system user alike, so server code running as the system user cannot edit or delete operation log rows through an enforced action.
- An operation log row that could not be written is no longer only a warning. It is logged at ERROR, raises the `storage.operation_log.failed` alert (`StorageAlertSink`, severity `error`, dedupe key `bucket:<alias>`, resolved by the next row written for that bucket) and is counted in `OperationLog.failedWriteCount()` (per node, since start). A row the insert action hands back with a record error (a refused value, a record lock) is now detected as well; before, it was dropped without any log line from the qbit. The operation the row describes still succeeds.
### Removed
- The QRun-IO/qqq#1165 stand-ins: `SessionCredentialsClient` (session tokens now go through core `S3BackendMetaData.sessionToken` / `S3ClientFactory`, including for the presigner) and the file reading in `SecretRefs` (core `QMetaDataVariableInterpreter` resolves `${file.PATH}`). **Behavior change:** core reads `${file.PATH}` only inside its allowed roots, which are `/var/run/secrets` and `/run/secrets` by default. A host that mounts storage secrets elsewhere must set `qqq.fileReference.roots` or `QQQ_FILE_REFERENCE_ROOTS`. The qbit's default allow-list (`file./var/run/secrets/storage/*`) is already inside the default roots.
- None for the health migration; both Test connection record actions, including on-demand write tests, are retained.
### Changed
- Multi-node deployments: the runtime caches are registered with QQQ `ClusterCaches`. Shared (cleared on every node): the bucket rows' presign limits (`qbit-object-storage.bucket-row-limits`, on a bucket row insert or update), the enabled header rules (`qbit-object-storage.header-rules`, on a header rule insert, update or delete), both through the new `ConfigRowChangeCustomizer`, and the capability statuses (`qbit-object-storage.capabilities`: a probe result recorded on one node clears that capability on the others, which reload it from the capability row; before, other nodes kept the old status until restart). The 15 s TTLs remain the safety net. Node-local, with reasons: the alias and connection row-id maps, the S3 client map, the process-permission map, the provider defaults, the running-scan guard and the per-pod presign rate limiter (`StorageBucket.PRESIGN_COUNTS`; per-node rate limits are accepted for now). `ObjectStorageClusterCachesTest` audits the package for unregistered static caches and covers remote and local 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-filtered artifact version so release metadata matches the build and help bundle.
- Rechecked the released RC.8 JARs: core still has no S3 session-token/provider hook or file-reference interpreter. Retained both compatibility shims and moved their review deadline to RC.9 under issue #10.
- Raised the QQQ BOM baseline to released `4.1.0-RC.8` for the central health API.
- Raised the QQQ BOM baseline to released `4.1.0-RC.7` for the core help-bundle registry and coverage assertions.
- Raised the QQQ BOM baseline to released `4.1.0-RC.5` and delegated S3 client construction to core `S3ClientFactory.buildClient`. Removed local HTTP client and trust-manager implementations; existing endpoint, region, addressing, checksum, TLS/CA, credentials and timeout settings remain supported.
- Clients remain owned by each connection runtime and are rebuilt on reset/reinstall. Retry behavior now follows core/SDK defaults instead of forcing `ADAPTIVE_V2`; hosts can select the SDK retry mode through `AWS_RETRY_MODE` or `aws.retryMode`.
- Retained local presigning (including a separate endpoint/key). A request-credentials bridge preserves static session tokens because RC.5 core metadata/factory has no session-token field or credentials-provider hook. This bridge and the existing `${file.PATH}` resolver remain necessary in released RC.7 (no session-token/provider hook or file-reference interpreter); their review deadline is now RC.8 under issue #10.
- Added the filesystem module with its unused SFTP/SSH stack excluded. Resolved dependencies otherwise only change QQQ RC.4 to RC.5 and jsoup 1.23.1 to 1.23.2; see [dependency details](docs/S3-FACTORY-MIGRATION.md).
### Notes
- Deliberate differences from the design text: Test connection is two actions (read-only, and with write tests) so the write variant can require `object_storage.admin`; a folder record has no subfolder child list (core does not allow a self join as an exposed join); there is no `withTableNamePrefix` (the `object_storage_` prefix is fixed by the naming rule).
+119 -10
View File
@@ -5,38 +5,147 @@
> QQQ QBit: S3-compatible object storage service and Admin -> Storage app (browse, upload, presigned links, permission-checked and logged writes).
**Status:** design: [QRun-IO/qqq#1089](https://github.com/QRun-IO/qqq/pull/1089); implementation in progress. Nothing is published yet.
S3-compatible storage as a capability for any QQQ app, and an admin app to run it. Design: [QRun-IO/qqq#1089](https://github.com/QRun-IO/qqq/pull/1089).
1. **The service.** One Java API (`ObjectStorage.bucket("files")`) for put, get, head, copy, delete, restore, list, header and metadata changes, presigned upload and download URLs, public URLs and key building, over configured connections and buckets. Works against AWS S3, MinIO, Cloudflare R2, Backblaze B2, Wasabi, DigitalOcean Spaces, Ceph RGW and Garage. A capability model, filled in by a **Test connection** probe, says what each provider can do.
2. **The Storage app** (`Admin -> Storage`, optional). Buckets, objects, folders, connections, operations, header rules and protection rules as standard QQQ tables, an index of every indexed bucket, a home with real numbers, and standard Review/Result actions for every write.
**Status:** P1 (service), P2 (index, references, orphaned and missing objects, audit log) and P3/P4 (the app, read and write) are implemented. Not yet released: see [CHANGELOG.md](CHANGELOG.md).
## Requirements
- Java 21.
- QQQ 4.1 (built against `4.1.0-RC.4`).
- QQQ `4.1.0-RC.8` (released BOM), including `qqq-backend-module-filesystem`. `ConnectionRuntime` maps connection settings to core's `S3BackendMetaData` and uses `S3ClientFactory.buildClient`; core owns HTTP, TLS and checksum setup. The unused SFTP/SSH dependency is excluded. See the [dependency diff](docs/S3-FACTORY-MIGRATION.md).
- A QQQ backend for the qbit's tables (PostgreSQL is the intended store). `ObjectStorageSchema.postgresScript(config)` generates the DDL from the same metadata the app runs on, for your Liquibase or Flyway migration.
## Install
Not yet published. Once released, add `com.kingsrook.qbits:qbit-object-storage` and produce the qbit into your `QInstance`:
```java
new ObjectStorageQBitProducer()
.withQBitConfig(new ObjectStorageQBitConfig().withBackendName("rdbms"))
.withQBitConfig(new ObjectStorageQBitConfig()
.withBackendName("rdbms")
.withConnection(new StorageConnectionConfig("primary")
.withProviderType(ProviderType.MINIO)
.withEndpoint("${env.S3_ENDPOINT}")
.withPresignEndpoint("${env.S3_PRESIGN_ENDPOINT}") // optional: the host clients reach the store by
.withRegion("${env.S3_REGION}")
.withAddressingStyle(AddressingStyle.PATH)
.withAccessKeyRef("${env.S3_ACCESS_KEY}")
.withSecretKeyRef("${env.S3_SECRET_KEY}")
.withPresignAccessKeyRef("${env.S3_PRESIGN_ACCESS_KEY}") // a least-privilege key for presigned URLs
.withPresignSecretKeyRef("${env.S3_PRESIGN_SECRET_KEY}"))
.withBucket(new StorageBucketConfig("files").withConnection("primary").withBucketName("${env.S3_BUCKET_NAME}")
.withRole(BucketRole.PUBLIC).withPublicBaseUrl("${env.PUBLIC_BASE_URL}").withIndexed(true).withReferenceTracked(true))
.withBucket(new StorageBucketConfig("incoming").withConnection("primary").withBucketName("${env.INCOMING_BUCKET}")
.withRole(BucketRole.INCOMING).withIndexed(false))
.withReferenceProvider(FieldReferenceProvider.keyPrefix("files", "media", "storageKey"))
.withAdminApp(true).withParentAppName("admin"))
.produce(qInstance);
```
Connections and buckets come from this code only. Secrets are **references** (`${env.X}`, `${prop.X}`, `${file.PATH}`) that must match `allowedSecretRefPatterns` (default `env.S3_*`, `env.STORAGE_*`, `file./var/run/secrets/storage/*`); no table stores a secret value. At startup the qbit upserts one `object_storage_connection` and one `object_storage_bucket` row per entry, so the tables always show the real configuration.
Client settings take effect when the qbit configuration is reinstalled or the runtime is reset. Presigning keeps its separate public endpoint and credentials. Retry behavior now follows core/SDK defaults; hosts requiring adaptive retries can set `AWS_RETRY_MODE=adaptive` or `aws.retryMode=adaptive`. Static session tokens remain supported through a small local bridge pending core support.
## In-app help
The producer registers [help/qbit-object-storage.yaml](src/main/resources/help/qbit-object-storage.yaml)
as a `QBIT` bundle. QQQ syncs it when the host has the help-content tables installed;
registration is safe without them, and inline table/app help still explains empty lists.
The bundle version is the literal POM version; update both when releasing.
Hosts can register their own `layer: HOST` bundle with `HelpBundles.register(qInstance, "help/my-host.yaml")`.
Use the same entry key and role to override qbit text, keeping live `{{...}}` references where useful.
Hosts that rename the optional Storage app should provide an app entry for their configured name.
`ObjectStorageHelpBundleTest` checks all owned tables, fields, processes, apps, widgets and
possible-value sources using `HelpBundleAssertions`, including valid keys, live references and
plain summaries of at most 120 characters. It runs with `./mvnw verify`.
## Use
```java
StorageBucket files = ObjectStorage.bucket("files");
files.put(PutRequest.of("media/42/hero.jpg", bytes).withContentType("image/jpeg")); // headers come from the header policy
URL link = files.presignGet(PresignGet.of("media/42/hero.jpg").ttl(Duration.ofMinutes(5)));
String publicUrl = files.publicUrl("media/42/hero.jpg");
files.delete(DeleteRequest.of("media/42/hero.jpg")); // soft where the bucket allows it
// a calling qbit that checked its own permission (for example backups):
ObjectStorage.asSystem("backups", runId).bucket("backups").put(PutRequest.of(key, stream).withObjectLock(LockMode.COMPLIANCE, until));
```
Every call is synchronous, rate limited per connection, throws `StorageException` with a stable reason (`NOT_FOUND`, `ACCESS_DENIED`, `UNSUPPORTED`, `PRECONDITION_FAILED`, `THROTTLED`, `PROVIDER_ERROR`) and the provider's request id, and every write lands in `object_storage_operation`. Other qbits plug in with `ObjectHeaderPolicy`, `PublicUrlResolver`, `StorageChangeListener`, `StorageReferenceProvider` and `StorageAlertSink` (a no-op unless the Alerts qbit is installed).
## What is in the app
| Where | What |
|---|---|
| Home | Buckets, data health (orphaned and missing objects), recent changes, cache-header coverage, usage trend, connections. Each section says what is missing when it is empty. |
| Tables | `object_storage_bucket`, `_object`, `_folder`, `_connection`, `_operation`, `_header_rule`, `_protection_rule` in the nav; capabilities, versions, references and daily usage appear as child lists on their parents. |
| Bucket actions | Rescan bucket, Check configuration, Apply header policy, Create upload link |
| Object actions | Refresh from bucket, Apply header policy, Change headers and metadata, Edit tags, Copy objects, Move or rename objects, Delete objects, Restore objects, Create download link |
| Folder actions | Move or rename folder, Delete folder contents, Upload files to this folder |
| Table actions | Upload files |
| Connection actions | Test connection, Test connection with write tests |
Every write is a standard streamed ETL: **Review** (the dry run, with summary lines) then **Result**. Deletes are soft wherever the bucket has versioning or a private trash (Restore undoes them); referenced, protected and object-locked objects are skipped and the Review says why; bulk runs are capped at 10,000 objects unless the user has `object_storage.admin`.
Permissions: `object_storage.view`, `.write`, `.delete`, `.admin` (admin implies the rest). Per-bucket scoping uses record security locks on the `object_storage_bucket` and `object_storage_connection` security key types (switch off with `withBucketScoping(false)` for a single-administrator app).
## Health checks
The producer registers `object_storage.connection.<id>` for each configured
`object_storage_connection` row: **DEPENDENCY**, **STORAGE**, every **300s**,
with a **20000ms** timeout and component `object_storage`. The central health runner
refreshes the supplier as rows change. The existing `CapabilityProbe` runs only
read-only calls within the deadline; it never runs write or presign tests or stores
free-form capability diagnostics. Connection failures are DOWN, clock skew above
900 seconds is DEGRADED, and connections without buckets are UNKNOWN. Details
contain only problem kind, clock skew seconds and bucket count.
Turn a connection's check off or change its interval in the central `health_check`
table. Both **Test connection** record actions remain available, including
on-demand write tests.
## Setup and starter defaults
The Storage home has a **Setup** card: connections and buckets (declared in code, synced into their tables), header rules, and protection rules, each with its state and next step. Every table has help (`help/qbit-object-storage.yaml`) saying what it is for and how its rows get created, which also shows when the table is empty.
The jar ships one optional seed changelog. After the changesets that create the tables (the script from `ObjectStorageSchema.postgresScript`), include it from your master changelog:
```yaml
- include: { file: db/liquibase/qbit-object-storage/object-storage-defaults.changelog.sql }
```
It is insert-only and inserts a **disabled** generic header rule, `default-mutable` (every key, `public, max-age=3600`, priority 1000), so nothing changes on deploy: enable and edit it when you want it. Edits survive re-runs. Protection rules are not seeded (a rule needs a bucket). Presign limits on the bucket row are read at runtime: `maxPresignGetSeconds` and `maxPresignPutSeconds` on the bucket row can lower the code config limits (the effective limit is the smaller of the two, cached up to 15 seconds); the row cannot raise them. `object_storage_object_version` is reserved and not used yet.
## Deployment notes
- **Uploads** go through the app server (100 MB per file by default, per bucket `maxUploadBytes`), using QQQ's standard file-upload field, so the app needs `uploadedFileArchiveTableName` set in its Javalin metadata, as for any file upload.
- **Previews** are short-lived (5 minute) presigned GETs, so the Next dashboard's `img-src` must allow the storage host (it allows `https:` by default). Video, audio and PDF previews need same-origin media (the dashboard's `media-src` and `frame-src` are `'self'`); a same-origin download route is the planned follow-up. Until then those files show the file card with download and open links.
- **Scheduler.** Set `withSchedulerName(...)` to run the nightly scan, HEAD backlog, reference scan, usage snapshot and trash purge. Without it, run Rescan bucket by hand.
- **Alerts.** With the Alerts qbit installed, bind a `StorageAlertSink` adapter (`ObjectStorageRuntime.setAlertSink`); without it every condition is still visible on the home and in the tables.
- **A missing audit row.** A storage operation succeeds even when its `object_storage_operation` row cannot be written, and that row is then the one condition no table shows. It is logged at ERROR ("Could not write a storage operation log row"), raised as the `storage.operation_log.failed` alert (severity `error`, dedupe key `bucket:<alias>`, resolved by the next row written for that bucket) and counted in `OperationLog.failedWriteCount()`, per node since it started. A host without an alert sink should export that count (a gauge, or a health check of its own) and alert when it is above zero.
## Conformance
`ObjectStorageConformanceSuite` runs the service against a store through Testcontainers on every PR: **MinIO** (the digest-pinned Chainguard build the QQQ tests use, since Docker Hub's `minio/minio` is gone) and **Garage** (digest-pinned). The index and the actions also run on **PostgreSQL**. AWS S3, R2 and B2 run from an owner-run workflow with scratch buckets; Wasabi, Spaces and Ceph results are community reported. See the design for the provider matrix.
## Demo server
`src/test/java/.../devserver/StorageDevServer` starts MinIO and an in-memory QQQ instance with generated images, header rules, references, an index and some history, and serves the Next dashboard, for looking at the app in a browser.
## Naming
Tables are `object_storage_<name>` (lowercase snake_case, backend name equal to the table name). Permissions are `object_storage.<action>`.
## Adopting in another app
Follow the QQQ [adoption checklist](https://github.com/QRun-IO/qqq/blob/develop/docs/migration/adoption-checklist.adoc) once the first release is published.
## Build
```bash
./mvnw verify
```
Always use the Maven wrapper (Maven 3.9.11); Maven 3.10 cannot read the parent POM.
Always use the Maven wrapper (Maven 3.9.11); Maven 3.10 cannot read the parent POM. The tests need Docker (Testcontainers); in CI a missing Docker fails the build instead of skipping.
## License
+8
View File
@@ -0,0 +1,8 @@
# Upgrading
## Unreleased
- Nothing is required. Help and the Setup card appear when the qbit is produced; the schema is unchanged.
- Optional: include `db/liquibase/qbit-object-storage/object-storage-defaults.changelog.sql` after the changesets that create the tables, to get a disabled generic header rule (`default-mutable`). It is insert-only and changes no behavior until you enable the rule.
- Behavior change: the presign limits on an `object_storage_bucket` row (`maxPresignGetSeconds`, `maxPresignPutSeconds`) are now read, as a lower cap only. Any bucket row whose presign values are lower than your code config shortens links after this upgrade (the config sync copied the code values into the row on first start, so a row someone lowered earlier counts). Check the values on your bucket rows; 0 or empty means no row limit, and a value above the code limit has no effect.
- `object_storage_object_version` stays empty: it is reserved.
+91
View File
@@ -0,0 +1,91 @@
# Core S3 factory migration (issue #10, F3)
Core source was inspected at local QRun-IO/qqq tag `v4.1.0-RC.5`:
`qqq-backend-module-filesystem/src/main/java/com/kingsrook/qqq/backend/module/filesystem/s3/{utils/S3ClientFactory,model/metadata/S3BackendMetaData}.java`.
## Ownership and host behavior
`ConnectionRuntime` resolves secret/setting references and maps endpoint, region,
path style, checksum mode, TLS verification, extra CA PEM, static keys, and both
HTTP timeouts into `S3BackendMetaData`. Default-chain and web-identity modes leave
keys unset so core uses the SDK default credentials chain. Provider-specific qbit
defaults and the existing 5-second connect / 60-second read timeouts are preserved.
Core handles custom CA trust in addition to the JVM trust store.
The qbit calls uncached `buildClient`, because it owns and closes each client.
`ObjectStorageRuntime` caches by connection name and resets clients on
configuration install/reset; `settingsHash` is an informational table value,
not the cache key. This migration keeps that lifecycle and avoids sharing a
core-cached client that the qbit would later close. Configuration changes take
effect on reinstall/reset, not through mutation of an already cached client.
Core does not expose retry configuration. Requests now use core/SDK retry
defaults instead of the former forced `ADAPTIVE_V2`. Hosts needing adaptive
retries can set the SDK's `AWS_RETRY_MODE=adaptive` or `aws.retryMode=adaptive`.
The public qbit configuration API is unchanged.
## Remaining local code
- Core has no presigner factory. The local presigner keeps its separate public
endpoint and least-privilege keys; its credentials (including the session
token) come from core `S3ClientFactory.buildCredentialsProvider`.
- `SecretRefs` keeps only the qbit's allow-list; resolution, including
`${file.PATH}`, is core `QMetaDataVariableInterpreter`.
## Stand-ins removed (QRun-IO/qqq#1165)
- `SessionCredentialsClient` is gone: core `S3BackendMetaData.sessionToken`
makes `S3ClientFactory` sign with `AwsSessionCredentials`.
- `SecretRefs` no longer reads files itself. Core reads `${file.PATH}`
read-only, and only inside its allowed roots: `/var/run/secrets` and
`/run/secrets` by default, or `qqq.fileReference.roots` /
`QQQ_FILE_REFERENCE_ROOTS` (comma-separated). The qbit's default allow-list
(`file./var/run/secrets/storage/*`) is inside the default roots.
## Resolved dependency diff
Captured using the Maven 3.9.11 wrapper's `-B dependency:tree` before and after
this change. Coordinates below are normalized to omit tree indentation and
parent ordering. Full raw trees are in `target/dependency-tree-{before,after}.txt`
for this working session (generated build files, not committed).
```diff
- com.kingsrook.qqq:qqq-backend-core:jar:4.1.0-RC.4:compile
- com.kingsrook.qqq:qqq-backend-module-postgres:jar:4.1.0-RC.4:test
- com.kingsrook.qqq:qqq-backend-module-rdbms:jar:4.1.0-RC.4:test
- com.kingsrook.qqq:qqq-middleware-javalin:jar:4.1.0-RC.4:test
- com.kingsrook.qqq:qqq-openapi:jar:4.1.0-RC.4:test
- org.jsoup:jsoup:jar:1.23.1:compile
+ com.kingsrook.qqq:qqq-backend-core:jar:4.1.0-RC.5:compile
+ com.kingsrook.qqq:qqq-backend-module-filesystem:jar:4.1.0-RC.5:compile
+ com.kingsrook.qqq:qqq-backend-module-postgres:jar:4.1.0-RC.5:test
+ com.kingsrook.qqq:qqq-backend-module-rdbms:jar:4.1.0-RC.5:test
+ com.kingsrook.qqq:qqq-middleware-javalin:jar:4.1.0-RC.5:test
+ com.kingsrook.qqq:qqq-openapi:jar:4.1.0-RC.5:test
+ org.jsoup:jsoup:jar:1.23.2:compile
```
The only new resolved artifact is `qqq-backend-module-filesystem`. It declares
`org.apache.sshd:sshd-sftp:2.16.0`, which would bring `sshd-core` and `sshd-common`;
that unused SFTP stack is excluded at the qbit dependency. No SSH libraries or
AWS SDK v1 artifacts are present in the resulting tree. AWS SDK v2 remains
`2.41.34`; `commons-lang3:3.20.0` was already present and is unchanged. The
filesystem module's test dependencies do not become runtime dependencies.
## Verification in this worktree
Java 21.0.12.1, Maven wrapper 3.9.11; `./mvnw.cmd -B verify`:
- Checkstyle: zero violations; production and test compilation succeeded.
- Surefire: 119 tests reported, 32 passed, 87 skipped, zero failures/errors.
All eight new `ConnectionRuntimeTest` tests passed, including actual HTTP
signing with a session token on GET, PUT and pagination.
- Docker Desktop access is denied at `npipe:////./pipe/dockerDesktopLinuxEngine`.
MinIO, Garage, standalone-service and PostgreSQL integration coverage could
not run. Testcontainers also reported its default `docker_engine` pipe unavailable.
- The full gate **failed** at JaCoCo: instructions 33.68% (required 70%), classes
70.75% (required 90%). Coverage thresholds and Docker test policy are unchanged.
- No unrelated RC.5 compatibility fixes were needed. `git diff --check` passed.
Rerun the full verification gate in an environment with Docker access before
merging. No changes were committed or pushed.
+60 -5
View File
@@ -18,7 +18,9 @@
<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>
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
<revision>0.2.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
@@ -27,7 +29,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,7 +74,7 @@
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>bom</artifactId>
<version>2.41.10</version>
<version>2.41.34</version>
<type>pom</type>
<scope>import</scope>
</dependency>
@@ -80,6 +82,17 @@
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-filesystem</artifactId>
<exclusions>
<!-- Only the S3 factory is used; do not ship the SFTP backend's SSH stack. -->
<exclusion>
<groupId>org.apache.sshd</groupId>
<artifactId>sshd-sftp</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
@@ -96,6 +109,20 @@
<scope>test</scope>
</dependency>
<!-- DefaultsChangelogTest runs the shipped seed changelog through real Liquibase on H2 (PostgreSQL mode). -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.3.232</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
<version>4.33.0</version>
<scope>test</scope>
</dependency>
<!-- the Postgres index test: the real SQL of the object index (src/test only) -->
<dependency>
<groupId>com.kingsrook.qqq</groupId>
@@ -145,7 +172,35 @@
</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-object-storage.properties</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>help/*.yaml</exclude>
<exclude>qbit-object-storage.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>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
@@ -228,14 +283,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.15 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.25</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -62,6 +62,7 @@ public class ObjectStorageQBitConfig implements QBitConfig
private String appName = "objectStorageAdmin";
private String appLabel = "Storage";
private String parentAppName;
private String tablePathPrefix;
private List<String> allowedSecretRefPatterns = new ArrayList<>(SecretRefs.DEFAULT_ALLOWED_PATTERNS);
private Integer bulkCap = 10_000;
private Boolean bucketScoping = true;
@@ -425,6 +426,39 @@ public class ObjectStorageQBitConfig implements QBitConfig
/********************************************************************************
** Getter for tablePathPrefix: the front of the links the Storage home makes to
** tables and records. Defaults to /app, the route every QQQ frontend serves
** tables at (/app/<tableName>).
*******************************************************************************/
public String getTablePathPrefix()
{
return (this.tablePathPrefix == null ? "/app" : this.tablePathPrefix);
}
/********************************************************************************
** Setter for tablePathPrefix.
*******************************************************************************/
public void setTablePathPrefix(String tablePathPrefix)
{
this.tablePathPrefix = tablePathPrefix;
}
/********************************************************************************
** Fluent setter for tablePathPrefix.
*******************************************************************************/
public ObjectStorageQBitConfig withTablePathPrefix(String tablePathPrefix)
{
this.tablePathPrefix = tablePathPrefix;
return (this);
}
/********************************************************************************
** Getter for allowedSecretRefPatterns.
*******************************************************************************/
@@ -21,18 +21,27 @@
package com.kingsrook.qbits.objectstorage;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
import com.kingsrook.qbits.objectstorage.app.StorageWidgets;
import com.kingsrook.qbits.objectstorage.health.ObjectStorageConnectionChecks;
import com.kingsrook.qbits.objectstorage.internal.ConfigSync;
import com.kingsrook.qbits.objectstorage.internal.DefaultObjectHeaderPolicy;
import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime;
import com.kingsrook.qbits.objectstorage.model.ObjectStorageHelp;
import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables;
import com.kingsrook.qbits.objectstorage.processes.ObjectStorageProcesses;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.health.HealthCheckSupplierMetaData;
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
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.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
@@ -54,13 +63,41 @@ public class ObjectStorageQBitProducer implements QBitMetaDataProducer<ObjectSto
{
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "qbit-object-storage";
public static final String VERSION = "0.1.0";
public static final String VERSION = loadVersion();
private ObjectStorageQBitConfig qBitConfig;
private String namespace;
/*******************************************************************************
** Read the artifact version from the Maven-filtered resource.
*******************************************************************************/
private static String loadVersion()
{
try(InputStream inputStream = ObjectStorageQBitProducer.class.getClassLoader().getResourceAsStream("qbit-object-storage.properties"))
{
if(inputStream == null)
{
throw new IllegalStateException("Missing qbit-object-storage.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-object-storage.properties; build with Maven");
}
return (version);
}
catch(IOException e)
{
throw new IllegalStateException("Could not read artifact version from qbit-object-storage.properties", e);
}
}
/*******************************************************************************
** Fluent setter for the config.
*******************************************************************************/
@@ -186,6 +223,9 @@ public class ObjectStorageQBitProducer implements QBitMetaDataProducer<ObjectSto
output.add(app);
}
output.addSelfToInstance(qInstance);
HealthChecksMetaData.ofOrWithNew(qInstance).withSupplier(new HealthCheckSupplierMetaData()
.withNamePrefix("object_storage.connection").withComponent("object_storage")
.withSupplier(new QCodeReference(ObjectStorageConnectionChecks.class)));
if(Boolean.TRUE.equals(qBitConfig.getAdminApp()) && qBitConfig.getParentAppName() != null && qInstance.getApp(qBitConfig.getParentAppName()) != null)
{
///////////////////////////////////////////////////////////////////////////////
@@ -193,6 +233,9 @@ public class ObjectStorageQBitProducer implements QBitMetaDataProducer<ObjectSto
///////////////////////////////////////////////////////////////////////////////
qInstance.getApp(qBitConfig.getParentAppName()).withChild(qInstance.getApp(qBitConfig.getAppName()));
}
// Registration is metadata-only; core skips startup sync without a helpContent table.
HelpBundles.register(qInstance, "help/qbit-object-storage.yaml");
ObjectStorageHelp.apply(qInstance, qBitConfig);
return (new MetaDataProducerMultiOutput());
}
}
@@ -53,6 +53,7 @@ public class FieldReferenceProvider implements StorageReferenceProvider
{
KEY,
KEY_PREFIX,
KEY_FOLDER,
URL
}
@@ -97,6 +98,18 @@ public class FieldReferenceProvider implements StorageReferenceProvider
/*******************************************************************************
** The field holds the key of one object of a set that lives together in a
** folder (an original and its renditions): every object in that key's folder
** is referenced.
*******************************************************************************/
public static FieldReferenceProvider keyFolder(String bucketAlias, String tableName, String fieldName)
{
return (new FieldReferenceProvider(bucketAlias, tableName, fieldName, Holds.KEY_FOLDER));
}
/*******************************************************************************
** The field holds a public URL.
*******************************************************************************/
@@ -150,6 +163,15 @@ public class FieldReferenceProvider implements StorageReferenceProvider
.withRecordId(String.valueOf(row.getValue("id"))).withFieldName(fieldName).withLabel(label));
}
}
else if(holds == Holds.KEY_FOLDER)
{
int slash = value.lastIndexOf('/');
if(slash > 0)
{
references.add(new StorageReference().withBucketAlias(alias).withKeyOrPrefix(StorageKeys.normalizePrefix(value.substring(0, slash + 1))).withMatchMode(MatchMode.PREFIX)
.withTableName(tableName).withRecordId(String.valueOf(row.getValue("id"))).withFieldName(fieldName).withLabel(label));
}
}
else
{
references.add(new StorageReference().withBucketAlias(alias).withKeyOrPrefix(StorageKeys.normalizePrefix(value)).withMatchMode(holds == Holds.KEY_PREFIX ? MatchMode.PREFIX : MatchMode.KEY)
@@ -49,6 +49,27 @@ public final class ObjectStorage
/*******************************************************************************
** Install a configuration without a QQQ instance, for command-line tools and
** tests that use the service but not the tables or the app. Connections and
** buckets work as usual; nothing is written to the object_storage_* tables
** (there are none in this mode), so writes are logged to the application log
** only. Inside a QQQ application, produce the qbit instead.
*******************************************************************************/
public static void configureStandalone(com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig config)
{
java.util.List<String> errors = new java.util.ArrayList<>();
config.validate(null, errors);
if(!errors.isEmpty())
{
throw (new IllegalArgumentException("Object storage configuration is invalid: " + String.join("; ", errors)));
}
ObjectStorageRuntime.install(config);
ConfigSync.reset();
}
/*******************************************************************************
** The bucket behind an alias from config, called as the current session user.
*******************************************************************************/
@@ -36,6 +36,7 @@ import com.kingsrook.qbits.objectstorage.api.StorageException.Reason;
import com.kingsrook.qbits.objectstorage.config.BucketRole;
import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig;
import com.kingsrook.qbits.objectstorage.internal.CapabilityStore;
import com.kingsrook.qbits.objectstorage.internal.ConfigSync;
import com.kingsrook.qbits.objectstorage.internal.ConnectionRuntime;
import com.kingsrook.qbits.objectstorage.internal.ErrorMapper;
import com.kingsrook.qbits.objectstorage.internal.IndexHook;
@@ -45,6 +46,7 @@ import com.kingsrook.qbits.objectstorage.internal.PostPolicySigner;
import com.kingsrook.qbits.objectstorage.internal.ProtectionRules;
import com.kingsrook.qbits.objectstorage.internal.RateLimiter;
import com.kingsrook.qqq.backend.core.actions.permissions.CorePermissionEnforcer;
import com.kingsrook.qqq.backend.core.cluster.ClusterCaches;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
@@ -95,7 +97,8 @@ public final class StorageBucket
{
private static final QLogger LOG = QLogger.getLogger(StorageBucket.class);
private static final Map<String, long[]> PRESIGN_COUNTS = new ConcurrentHashMap<>();
private static final Map<String, long[]> PRESIGN_COUNTS = ClusterCaches.nodeLocal(new ConcurrentHashMap<>(),
"per-pod presign rate limiter; per-node rate limits are accepted for now (cluster HA spec, owner decision 4)");
private final StorageBucketConfig bucket;
private final ConnectionRuntime connection;
@@ -128,6 +131,18 @@ public final class StorageBucket
/*******************************************************************************
** The largest object this bucket accepts on upload, in bytes, or null when
** the bucket sets no limit. Callers outside this qbit use this instead of
** config(), which returns an internal type.
*******************************************************************************/
public Long maxUploadBytes()
{
return (bucket.getMaxUploadBytes());
}
/*******************************************************************************
** The bucket configuration.
*******************************************************************************/
@@ -1016,7 +1031,7 @@ public final class StorageBucket
{
checkPermission("object_storage.view");
String key = StorageKeys.normalize(request.getKey());
Duration ttl = cap(request.getTtl(), Duration.ofMinutes(5), bucket.getMaxPresignGetSeconds());
Duration ttl = cap(request.getTtl(), Duration.ofMinutes(5), ConfigSync.effectiveMaxPresignGetSeconds(bucket));
countPresign();
GetObjectRequest.Builder b = GetObjectRequest.builder().bucket(name()).key(key);
if(StringUtils.hasContent(request.getDownloadAs()))
@@ -1049,7 +1064,7 @@ public final class StorageBucket
String key = StorageKeys.normalize(rawKey);
try
{
return (connection.presigner().presignGetObject(GetObjectPresignRequest.builder().signatureDuration(Duration.ofMinutes(5))
return (connection.presigner().presignGetObject(GetObjectPresignRequest.builder().signatureDuration(cap(null, Duration.ofMinutes(5), ConfigSync.effectiveMaxPresignGetSeconds(bucket)))
.getObjectRequest(GetObjectRequest.builder().bucket(name()).key(key).build()).build()).url());
}
catch(RuntimeException e)
@@ -1138,7 +1153,7 @@ public final class StorageBucket
{
checkPermission("object_storage.write");
String key = StorageKeys.normalize(request.getKey());
Duration ttl = cap(request.getTtl(), Duration.ofMinutes(15), bucket.getMaxPresignPutSeconds());
Duration ttl = cap(request.getTtl(), Duration.ofMinutes(15), ConfigSync.effectiveMaxPresignPutSeconds(bucket));
countPresign();
PutObjectRequest.Builder b = PutObjectRequest.builder().bucket(name()).key(key);
if(StringUtils.hasContent(request.getContentType()))
@@ -1180,7 +1195,7 @@ public final class StorageBucket
throw (new StorageException(Reason.UNSUPPORTED, "Presigned POST is not supported on this provider; verify the size after upload"));
}
String key = StorageKeys.normalize(request.getKey());
Duration ttl = cap(request.getTtl(), Duration.ofMinutes(15), bucket.getMaxPresignPutSeconds());
Duration ttl = cap(request.getTtl(), Duration.ofMinutes(15), ConfigSync.effectiveMaxPresignPutSeconds(bucket));
countPresign();
String[] creds = connection.presignCredentials();
PresignedPost post = PostPolicySigner.sign(creds[0], creds[1], creds[2], creds[3], connection.getConfig().effectiveAddressingStyle(), name(), key,
@@ -95,6 +95,7 @@ public final class StorageHome
LocalDate.ofInstant(now, ZoneOffset.UTC).minusDays(30))).withOrderBy(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy("snapshotDate")));
StringBuilder html = new StringBuilder("<div class=\"object-storage-home\" style=\"" + WRAP + "\">");
setupSection(html, tablePathPrefix);
bucketsSection(html, buckets, connections, caps, tablePathPrefix);
healthSection(html, buckets, tablePathPrefix);
recentSection(html, operations, tablePathPrefix);
@@ -116,6 +117,22 @@ public final class StorageHome
/*******************************************************************************
** 0. Setup: each configuration table, its state and the next step.
*******************************************************************************/
private static void setupSection(StringBuilder html, String prefix) throws QException
{
open(html, "Setup");
for(StorageSetupStatus.Step step : StorageSetupStatus.load())
{
html.append("<div style=\"").append(ROW).append("\"><strong><a href=\"").append(esc(prefix + "/" + step.table())).append("\">").append(esc(step.label())).append("</a></strong>: ")
.append(esc(step.state())).append(". <span style=\"").append(MUTED).append("\">").append(esc(step.next())).append("</span></div>");
}
close(html);
}
/*******************************************************************************
** 1. Buckets: one card per bucket, linking to its record. Cards flow into as
** many columns as fit, so the section reads at any width without scrolling.
@@ -0,0 +1,113 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.objectstorage.app;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig;
import com.kingsrook.qbits.objectstorage.internal.Db;
import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime;
import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables;
import com.kingsrook.qqq.backend.core.exceptions.QException;
/*******************************************************************************
** The Setup card on the Storage home: each configuration table, its state and
** the next step. States use the same resolution the runtime uses: connections
** and buckets come from code (so "set up" means declared and synced), and the
** header policy is the host's own when the config names one, otherwise the
** enabled rows of object_storage_header_rule.
*******************************************************************************/
public final class StorageSetupStatus
{
public static final String SET_UP = "Set up";
public static final String NEEDS_SETUP = "Needs setup";
public static final String USING_DEFAULTS = "Using defaults";
public static final String OPTIONAL = "Optional";
/*******************************************************************************
** One line of the card.
*******************************************************************************/
public record Step(String label, String state, String next, String table)
{
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private StorageSetupStatus()
{
}
/*******************************************************************************
** Read the current state. Connections and buckets come from the runtime
** config (what the runtime uses, whoever is looking), and the rule counts
** are read as the system, so a bucket-scoped user is never shown a false
** "needs setup".
*******************************************************************************/
public static List<Step> load() throws QException
{
ObjectStorageQBitConfig config = ObjectStorageRuntime.getConfig();
int headerRules = Db.asSystem(() -> Db.query(ObjectStorageTables.HEADER_RULE, "enabled", true).size());
int protection = Db.asSystem(() -> Db.query(ObjectStorageTables.PROTECTION_RULE, "enabled", true).size());
return (steps(config.getConnections().size(), config.getBuckets().size(), headerRules, config.getHeaderPolicy() != null, protection));
}
/*******************************************************************************
** The steps, from the numbers (pure, for tests).
*******************************************************************************/
static List<Step> steps(int connections, int buckets, int enabledHeaderRules, boolean hostHeaderPolicy, int enabledProtectionRules)
{
List<Step> steps = new ArrayList<>();
steps.add(connections > 0
? new Step("Connections", SET_UP, connections + " declared in code and synced here; set limits and timeouts on the row", ObjectStorageTables.CONNECTION)
: new Step("Connections", NEEDS_SETUP, "Declare a connection in the qbit config (withConnection) and restart: endpoint, region and the names of the key variables", ObjectStorageTables.CONNECTION));
steps.add(buckets > 0
? new Step("Buckets", SET_UP, buckets + " declared in code and synced here; set the presign limits on the row", ObjectStorageTables.BUCKET)
: new Step("Buckets", NEEDS_SETUP, "Declare a bucket in the qbit config (withBucket) and restart", ObjectStorageTables.BUCKET));
if(hostHeaderPolicy)
{
steps.add(new Step("Header rules", SET_UP, "The host's own header policy (code) decides; these rows are not read", ObjectStorageTables.HEADER_RULE));
}
else if(enabledHeaderRules > 0)
{
steps.add(new Step("Header rules", SET_UP, enabledHeaderRules + " enabled; the first match by priority sets Cache-Control", ObjectStorageTables.HEADER_RULE));
}
else
{
steps.add(new Step("Header rules", USING_DEFAULTS, "No rule is enabled, so objects keep the headers they were written with. Enable the starter rule or add one", ObjectStorageTables.HEADER_RULE));
}
steps.add(enabledProtectionRules > 0
? new Step("Protection rules", SET_UP, enabledProtectionRules + " enabled", ObjectStorageTables.PROTECTION_RULE)
: new Step("Protection rules", OPTIONAL, "Add a rule to make a bucket prefix read-only or delete-proof", ObjectStorageTables.PROTECTION_RULE));
return (steps);
}
}
@@ -106,16 +106,12 @@ public final class StorageWidgets
/*******************************************************************************
** The prefix of table and record links.
** The prefix of table and record links: /app, so a link is /app/<tableName>
** (the frontend serves every table, in any app, at that one flat route).
*******************************************************************************/
public static String tablePathPrefix()
{
if(!ObjectStorageRuntime.isInstalled())
{
return ("/objectStorageAdmin");
}
ObjectStorageQBitConfig config = ObjectStorageRuntime.getConfig();
return (config.getParentAppName() == null ? "/" + config.getAppName() : "/" + config.getParentAppName() + "/" + config.getAppName());
return (ObjectStorageRuntime.isInstalled() ? ObjectStorageRuntime.getConfig().getTablePathPrefix() : "/app");
}
@@ -0,0 +1,121 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.objectstorage.health;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import com.kingsrook.qbits.objectstorage.internal.CapabilityProbe;
import com.kingsrook.qbits.objectstorage.internal.ConfigSync;
import com.kingsrook.qbits.objectstorage.internal.Db;
import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime;
import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.health.HealthCategory;
import com.kingsrook.qqq.backend.core.health.HealthCheckInput;
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome;
import com.kingsrook.qqq.backend.core.health.HealthCheckSupplier;
import com.kingsrook.qqq.backend.core.health.HealthDetails;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
/*******************************************************************************
** Dynamic, read-only checks for configured connection rows. Diagnostic text and
** resolved settings never cross the health boundary.
*******************************************************************************/
public class ObjectStorageConnectionChecks implements HealthCheckSupplier
{
/*******************************************************************************
** Resolve current rows on every refresh; removed configuration is not probed.
*******************************************************************************/
@Override
public List<HealthCheckMetaData> supply(QInstance qInstance) throws QException
{
ConfigSync.ensureSynced();
List<HealthCheckMetaData> checks = new ArrayList<>();
for(QRecord row : Db.asSystem(() -> Db.query(ObjectStorageTables.CONNECTION)))
{
if(ObjectStorageRuntime.getConfig().findConnection(row.getValueString("name")) == null)
{
continue;
}
Integer id = row.getValueInteger("id");
String name = "object_storage.connection." + id.toString().toLowerCase(Locale.ROOT);
checks.add(new HealthCheckMetaData().withName(name).withLabel("Storage connection " + id)
.withComponent("object_storage").withCategory(HealthCategory.STORAGE).withKind(HealthCheckKind.DEPENDENCY)
.withIntervalSeconds(300).withTimeoutMs(20000).withSeverity(AlertSeverity.ERROR)
.withCheckInstance(input -> run(id, input)));
}
return (checks);
}
/*******************************************************************************
** Check the current row, reuse the bounded capability probe, and expose only
** numeric facts and a fixed problem vocabulary (never exception messages).
*******************************************************************************/
private static HealthCheckOutcome run(Integer id, HealthCheckInput input) throws QException
{
if(!Instant.now().isBefore(input.getDeadline()) || Thread.currentThread().isInterrupted())
{
return (HealthCheckOutcome.down("Storage probe timed out").withDetails(new HealthDetails().withValue("problemKind", "timeout")));
}
QRecord row = Db.asSystem(() -> Db.first(ObjectStorageTables.CONNECTION, "id", id));
if(row == null || ObjectStorageRuntime.getConfig().findConnection(row.getValueString("name")) == null)
{
return (HealthCheckOutcome.unknown("Storage connection is disabled or no longer configured"));
}
try
{
CapabilityProbe.Report report = CapabilityProbe.probeConnection(row.getValueString("name"), input.getDeadline());
HealthDetails details = new HealthDetails().withValue("bucketsProbed", report.getByBucket().size());
if(report.getClockSkewSeconds() != null)
{
details.withValue("clockSkewSeconds", report.getClockSkewSeconds());
}
if(report.getProblemKind() != null)
{
return (HealthCheckOutcome.down("Storage connection probe failed").withDetails(details.withValue("problemKind", report.getProblemKind())));
}
if(report.getClockSkewSeconds() != null && report.getClockSkewSeconds() > CapabilityProbe.CLOCK_SKEW_THRESHOLD_SECONDS)
{
return (HealthCheckOutcome.degraded("Storage clock skew exceeds the signing threshold").withDetails(details));
}
if(report.getByBucket().isEmpty())
{
return (HealthCheckOutcome.unknown("No storage buckets configured to probe").withDetails(details));
}
return (HealthCheckOutcome.up("Storage connection is reachable").withDetails(details));
}
catch(RuntimeException e)
{
return (HealthCheckOutcome.down("Storage connection probe failed")
.withDetails(new HealthDetails().withValue("problemKind", CapabilityProbe.problemKind(e))));
}
}
}
@@ -43,6 +43,7 @@ import com.kingsrook.qbits.objectstorage.internal.ConfigSync;
import com.kingsrook.qbits.objectstorage.internal.Db;
import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime;
import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables;
import com.kingsrook.qqq.backend.core.cluster.ClusterCaches;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
@@ -78,7 +79,8 @@ public final class ObjectIndex
public static final int PAGE_SIZE = 1000;
private static final Set<String> RUNNING = java.util.concurrent.ConcurrentHashMap.newKeySet();
private static final Set<String> RUNNING = ClusterCaches.nodeLocal(java.util.concurrent.ConcurrentHashMap.newKeySet(),
"the buckets this node is scanning now (an in-flight guard, not cached data)");
/** Test seam: simulate a crash after this many pages of a scan (so resume can be tested). */
static volatile Integer failAfterPages = null;
@@ -21,7 +21,9 @@
package com.kingsrook.qbits.objectstorage.internal;
import javax.net.ssl.SSLException;
import java.net.URI;
import java.net.UnknownHostException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
@@ -35,6 +37,7 @@ import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.function.Function;
import com.kingsrook.qbits.objectstorage.api.Capability;
import com.kingsrook.qbits.objectstorage.api.CapabilityStatus;
import com.kingsrook.qbits.objectstorage.api.PresignedPost;
@@ -45,7 +48,9 @@ import com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig;
import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import software.amazon.awssdk.awscore.AwsRequestOverrideConfiguration;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.DelegatingS3Client;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.CompleteMultipartUploadRequest;
import software.amazon.awssdk.services.s3.model.CompletedMultipartUpload;
@@ -61,6 +66,7 @@ import software.amazon.awssdk.services.s3.model.ListObjectsV2Response;
import software.amazon.awssdk.services.s3.model.ObjectIdentifier;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.S3Exception;
import software.amazon.awssdk.services.s3.model.S3Request;
import software.amazon.awssdk.services.s3.model.UploadPartRequest;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
@@ -79,6 +85,7 @@ public final class CapabilityProbe
private static final QLogger LOG = QLogger.getLogger(CapabilityProbe.class);
public static final String PROBE_PREFIX = ".qqq-probe/";
public static final int CLOCK_SKEW_THRESHOLD_SECONDS = 900;
private static final int MIN_PART_BYTES = 5 * 1024 * 1024;
@@ -94,6 +101,17 @@ public final class CapabilityProbe
private final List<String> problems = new ArrayList<>();
private Long clockSkewSeconds;
private Boolean presignHostReachable;
private String problemKind;
/*******************************************************************************
** Safe connection failure category, without provider messages or identifiers.
*******************************************************************************/
public String getProblemKind()
{
return (problemKind);
}
@@ -209,7 +227,7 @@ public final class CapabilityProbe
*******************************************************************************/
public static Report probeConnection(String connectionName, boolean includeWrite)
{
details.clear();
details.get().clear();
Report report = new Report();
ConnectionRuntime connection = ObjectStorageRuntime.connection(connectionName);
StorageConnectionConfig config = connection.getConfig();
@@ -230,7 +248,7 @@ public final class CapabilityProbe
List<String> notes = new ArrayList<>();
report.notes.put(bucket.getAlias(), notes);
readProbes(connection, bucket, results, report);
readProbes(connection, bucket, results, report, null);
recordVersioningState(bucket);
if(includeWrite)
@@ -251,7 +269,7 @@ public final class CapabilityProbe
}
for(Map.Entry<Capability, CapabilityStatus> e : results.entrySet())
{
CapabilityStore.record(bucket, e.getKey(), e.getValue(), details.getOrDefault(bucket.getAlias() + ":" + e.getKey(), null));
CapabilityStore.record(bucket, e.getKey(), e.getValue(), details.get().getOrDefault(bucket.getAlias() + ":" + e.getKey(), null));
}
}
@@ -262,7 +280,7 @@ public final class CapabilityProbe
report.byBucket.get(writable.getAlias()).putAll(presign);
for(Map.Entry<Capability, CapabilityStatus> e : presign.entrySet())
{
CapabilityStore.record(writable, e.getKey(), e.getValue(), details.get(writable.getAlias() + ":" + e.getKey()));
CapabilityStore.record(writable, e.getKey(), e.getValue(), details.get().get(writable.getAlias() + ":" + e.getKey()));
}
}
@@ -281,7 +299,116 @@ public final class CapabilityProbe
return (report);
}
private static final Map<String, String> details = new java.util.concurrent.ConcurrentHashMap<>();
private static final ThreadLocal<Map<String, String>> details = ThreadLocal.withInitial(java.util.HashMap::new);
/*******************************************************************************
** Reuse the read probes within a health deadline, without storing diagnostics
** or running write/presign probes. Only the returned report is consumed.
*******************************************************************************/
public static Report probeConnection(String connectionName, Instant deadline)
{
try
{
return (probeReadOnly(connectionName, deadline));
}
finally
{
details.remove();
}
}
/*******************************************************************************
** Run the shared read-only calls without updating capability or bucket rows.
*******************************************************************************/
private static Report probeReadOnly(String connectionName, Instant deadline)
{
Report report = new Report();
ConnectionRuntime connection = ObjectStorageRuntime.connection(connectionName);
for(StorageBucketConfig bucket : ObjectStorageRuntime.getConfig().getBuckets())
{
if(!connectionName.equals(bucket.getConnection()))
{
continue;
}
if(!Instant.now().isBefore(deadline) || Thread.currentThread().isInterrupted())
{
report.problemKind = "timeout";
break;
}
Map<Capability, CapabilityStatus> results = new EnumMap<>(Capability.class);
report.byBucket.put(bucket.getAlias(), results);
readProbes(connection, bucket, results, report, deadline);
}
if(!Instant.now().isBefore(deadline))
{
report.problemKind = "timeout";
}
return (report);
}
/*******************************************************************************
** Classify failures using types and known protocol codes, never message text.
*******************************************************************************/
public static String problemKind(Throwable error)
{
for(Throwable cause = error; cause != null; cause = cause.getCause())
{
if(cause instanceof UnknownHostException)
{
return ("dns");
}
if(cause instanceof SSLException)
{
return ("tls");
}
String code = ErrorMapper.errorCode(cause);
if("RequestTimeTooSkewed".equals(code) || "RequestExpired".equals(code))
{
return ("clock");
}
if(cause instanceof S3Exception s3 && (s3.statusCode() == 401 || s3.statusCode() == 403
|| "InvalidAccessKeyId".equals(code) || "SignatureDoesNotMatch".equals(code)))
{
return ("auth");
}
}
return ("connection");
}
/*******************************************************************************
** Bound each SDK call (including retries) by the remaining probe budget.
*******************************************************************************/
private static S3Client boundedClient(S3Client client, Instant deadline)
{
return (new DelegatingS3Client(client)
{
/*******************************************************************************
** Preserve credentials and other overrides while limiting the request.
*******************************************************************************/
@Override
@SuppressWarnings("unchecked") // toBuilder retains the concrete request type.
protected <T extends S3Request, ReturnT> ReturnT invokeOperation(T request, Function<T, ReturnT> operation)
{
Duration remaining = Duration.between(Instant.now(), deadline);
if(remaining.isNegative() || remaining.isZero() || Thread.currentThread().isInterrupted())
{
throw (new IllegalStateException("Storage probe deadline exceeded"));
}
AwsRequestOverrideConfiguration.Builder overrides = request.overrideConfiguration()
.map(AwsRequestOverrideConfiguration::toBuilder).orElseGet(AwsRequestOverrideConfiguration::builder);
T bounded = (T) request.toBuilder().overrideConfiguration(overrides.apiCallTimeout(remaining).apiCallAttemptTimeout(remaining).build()).build();
return (operation.apply(bounded));
}
});
}
@@ -291,7 +418,7 @@ public final class CapabilityProbe
*******************************************************************************/
private static void recordVersioningState(StorageBucketConfig bucket)
{
String seen = details.get(bucket.getAlias() + ":" + Capability.VERSIONING);
String seen = details.get().get(bucket.getAlias() + ":" + Capability.VERSIONING);
if(seen == null || !Db.available(ObjectStorageTables.BUCKET))
{
return;
@@ -317,14 +444,14 @@ public final class CapabilityProbe
/*******************************************************************************
** The read-only probes of one bucket.
*******************************************************************************/
private static void readProbes(ConnectionRuntime connection, StorageBucketConfig bucket, Map<Capability, CapabilityStatus> results, Report report)
private static void readProbes(ConnectionRuntime connection, StorageBucketConfig bucket, Map<Capability, CapabilityStatus> results, Report report, Instant deadline)
{
S3Client client = connection.client();
S3Client client = deadline == null ? connection.client() : boundedClient(connection.client(), deadline);
String name = name(bucket);
String alias = bucket.getAlias();
String sampleKey = null;
try(RateLimiter.Permit ignored = connection.getRateLimiter().acquire())
try(RateLimiter.Permit ignored = deadline == null ? connection.getRateLimiter().acquire() : connection.getRateLimiter().acquire(deadline))
{
ListObjectsV2Response r = client.listObjectsV2(ListObjectsV2Request.builder().bucket(name).maxKeys(1).build());
results.put(Capability.LIST_V2, CapabilityStatus.SUPPORTED);
@@ -352,6 +479,7 @@ public final class CapabilityProbe
{
results.put(Capability.LIST_V2, classify(alias, Capability.LIST_V2, e));
report.problems.add(alias + ": listing failed (" + shortMessage(e) + ")");
report.problemKind = problemKind(e);
}
probe(results, alias, Capability.VERSIONING, () ->
@@ -359,10 +487,10 @@ public final class CapabilityProbe
String status = client.getBucketVersioning(b -> b.bucket(name)).statusAsString();
if(status == null && ProviderDefaults.defaultStatus(connection.getConfig().getProviderType(), Capability.VERSIONING) == CapabilityStatus.UNSUPPORTED)
{
details.put(alias + ":" + Capability.VERSIONING, "the provider reports versioning off and documents it as a stub");
details.get().put(alias + ":" + Capability.VERSIONING, "the provider reports versioning off and documents it as a stub");
return (CapabilityStatus.UNSUPPORTED);
}
details.put(alias + ":" + Capability.VERSIONING, status == null ? "not enabled" : status);
details.get().put(alias + ":" + Capability.VERSIONING, status == null ? "not enabled" : status);
return (CapabilityStatus.SUPPORTED);
});
probe(results, alias, Capability.OBJECT_LOCK, () ->
@@ -376,7 +504,7 @@ public final class CapabilityProbe
{
if("ObjectLockConfigurationNotFoundError".equals(ErrorMapper.errorCode(e)))
{
details.put(alias + ":" + Capability.OBJECT_LOCK, "object lock is not enabled on this bucket");
details.get().put(alias + ":" + Capability.OBJECT_LOCK, "object lock is not enabled on this bucket");
return (CapabilityStatus.UNSUPPORTED);
}
throw (e);
@@ -391,7 +519,7 @@ public final class CapabilityProbe
if(key == null)
{
results.put(Capability.OBJECT_TAGGING, CapabilityStatus.UNKNOWN);
details.put(alias + ":" + Capability.OBJECT_TAGGING, "no object in the bucket to test");
details.get().put(alias + ":" + Capability.OBJECT_TAGGING, "no object in the bucket to test");
}
else
{
@@ -505,7 +633,7 @@ public final class CapabilityProbe
try
{
client.putObject(PutObjectRequest.builder().bucket(name).key(key).ifNoneMatch("*").build(), RequestBody.fromString("2"));
details.put(alias + ":" + Capability.CONDITIONAL_WRITE, "If-None-Match was ignored");
details.get().put(alias + ":" + Capability.CONDITIONAL_WRITE, "If-None-Match was ignored");
return (CapabilityStatus.UNSUPPORTED);
}
catch(S3Exception e)
@@ -564,7 +692,7 @@ public final class CapabilityProbe
report.presignHostReachable = true;
if(r.statusCode() == 200 && !"probe".equals(r.body()))
{
details.put(alias + ":" + Capability.PRESIGNED_GET, "the presigned GET returned unexpected content (does it reach the same store?)");
details.get().put(alias + ":" + Capability.PRESIGNED_GET, "the presigned GET returned unexpected content (does it reach the same store?)");
return (CapabilityStatus.UNKNOWN);
}
return (httpStatus(alias, Capability.PRESIGNED_GET, r.statusCode(), r.body()));
@@ -618,7 +746,7 @@ public final class CapabilityProbe
{
return (CapabilityStatus.SUPPORTED);
}
details.put(alias + ":" + capability, "HTTP " + status + (body == null ? "" : " " + body.replaceAll("\\s+", " ").substring(0, Math.min(200, body.length()))));
details.get().put(alias + ":" + capability, "HTTP " + status + (body == null ? "" : " " + body.replaceAll("\\s+", " ").substring(0, Math.min(200, body.length()))));
if(status == 403)
{
return (CapabilityStatus.NOT_PERMITTED);
@@ -669,7 +797,7 @@ public final class CapabilityProbe
static CapabilityStatus classify(String alias, Capability capability, Exception e)
{
String detail = shortMessage(e);
details.put(alias + ":" + capability, detail);
details.get().put(alias + ":" + capability, detail);
if(e instanceof S3Exception s3)
{
String code = ErrorMapper.errorCode(s3);
@@ -30,6 +30,7 @@ import com.kingsrook.qbits.objectstorage.api.Capability;
import com.kingsrook.qbits.objectstorage.api.CapabilityStatus;
import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig;
import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables;
import com.kingsrook.qqq.backend.core.cluster.ClusterCaches;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -47,7 +48,11 @@ public final class CapabilityStore
public static final Set<Capability> CONNECTION_SCOPE = EnumSet.of(Capability.LIST_V2, Capability.PRESIGNED_GET, Capability.PRESIGNED_PUT,
Capability.PRESIGNED_POST, Capability.USAGE_ADMIN_API, Capability.QUOTA_ADMIN_API);
private static final Map<String, CapabilityStatus> CACHE = new ConcurrentHashMap<>();
/** Cluster cache name of the capability statuses (a probe result recorded on one node clears that key on every node). */
public static final String CACHE_NAME = "qbit-object-storage.capabilities";
private static final Map<String, CapabilityStatus> CACHE = ClusterCaches.shared(CACHE_NAME, new ConcurrentHashMap<>(),
CapabilityStore::clearCache, CapabilityStore::clearKey);
private static final Set<String> LOADED = ConcurrentHashMap.newKeySet();
@@ -62,7 +67,7 @@ public final class CapabilityStore
/*******************************************************************************
** Drop everything cached.
** Drop everything cached on this node.
*******************************************************************************/
public static void clearCache()
{
@@ -72,6 +77,28 @@ public final class CapabilityStore
/*******************************************************************************
** Drop one cached status on this node; the next read reloads the missing
** statuses from the capability rows (cached ones are kept).
*******************************************************************************/
static void clearKey(String key)
{
CACHE.remove(key);
LOADED.clear();
}
/*******************************************************************************
** Whether a status is cached on this node (tests).
*******************************************************************************/
static boolean isCached(StorageBucketConfig bucket, Capability capability)
{
return (CACHE.containsKey(scopeKey(bucket, capability)));
}
/*******************************************************************************
** The status of a capability for a bucket alias.
*******************************************************************************/
@@ -112,10 +139,24 @@ public final class CapabilityStore
/*******************************************************************************
** Record one probe result (cache and, when the tables are there, the row).
** The row is written first, then the key is cleared on every node (they
** reload it from the row), then cached here.
*******************************************************************************/
public static void record(StorageBucketConfig bucket, Capability capability, CapabilityStatus status, String detail)
{
CACHE.put(scopeKey(bucket, capability), status);
String key = scopeKey(bucket, capability);
persist(bucket, capability, status, detail);
ClusterCaches.invalidateKey(CACHE_NAME, key);
CACHE.put(key, status);
}
/*******************************************************************************
** Write one probe result to its capability row; never throws.
*******************************************************************************/
private static void persist(StorageBucketConfig bucket, Capability capability, CapabilityStatus status, String detail)
{
if(!Db.available(ObjectStorageTables.BUCKET_CAPABILITY))
{
return;
@@ -0,0 +1,95 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.objectstorage.internal;
import java.util.List;
import java.util.Optional;
import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.cluster.ClusterCaches;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Post insert, update and delete of the editable config rows that the runtime
** caches: a bucket row (presign limits, ConfigSync) or a header rule
** (DefaultObjectHeaderPolicy). Clears that cache on every node, so an admin's
** edit takes effect everywhere at once; the short TTLs remain the safety net.
*******************************************************************************/
public class ConfigRowChangeCustomizer implements TableCustomizerInterface
{
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public List<QRecord> postInsert(InsertInput insertInput, List<QRecord> records) throws QException
{
invalidate(insertInput.getTableName());
return (records);
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public List<QRecord> postUpdate(UpdateInput updateInput, List<QRecord> records, Optional<List<QRecord>> oldRecordList) throws QException
{
invalidate(updateInput.getTableName());
return (records);
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public List<QRecord> postDelete(DeleteInput deleteInput, List<QRecord> records) throws QException
{
invalidate(deleteInput.getTableName());
return (records);
}
/*******************************************************************************
** Clear the cache that holds the table's rows, on every node.
*******************************************************************************/
static void invalidate(String tableName)
{
if(ObjectStorageTables.BUCKET.equals(tableName))
{
ClusterCaches.invalidate(ConfigSync.ROW_LIMITS_CACHE_NAME);
}
else if(ObjectStorageTables.HEADER_RULE.equals(tableName))
{
ClusterCaches.invalidate(DefaultObjectHeaderPolicy.CACHE_NAME);
}
}
}
@@ -30,6 +30,7 @@ import com.kingsrook.qbits.objectstorage.config.CredentialsMode;
import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig;
import com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig;
import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables;
import com.kingsrook.qqq.backend.core.cluster.ClusterCaches;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -48,8 +49,17 @@ public final class ConfigSync
{
private static final QLogger LOG = QLogger.getLogger(ConfigSync.class);
private static final Map<String, Integer> BUCKET_IDS = new ConcurrentHashMap<>();
private static final Map<String, Integer> CONNECTION_IDS = new ConcurrentHashMap<>();
/** Cluster cache name of the bucket rows' presign limits (cleared on every node when a bucket row changes). */
public static final String ROW_LIMITS_CACHE_NAME = "qbit-object-storage.bucket-row-limits";
private static final Map<String, Integer> BUCKET_IDS = ClusterCaches.nodeLocal(new ConcurrentHashMap<>(),
"bucket alias to row id, written by the config sync; bucket rows cannot be inserted or deleted through the UI or API, so the ids are the same on every node");
private static final Map<String, Integer> CONNECTION_IDS = ClusterCaches.nodeLocal(new ConcurrentHashMap<>(),
"connection name to row id, written by the config sync; connection rows cannot be inserted or deleted through the UI or API, so the ids are the same on every node");
private static final Map<String, RowLimits> ROW_LIMITS = ClusterCaches.shared(ROW_LIMITS_CACHE_NAME, new ConcurrentHashMap<>(),
ConfigSync::clearRowLimits, alias -> ConfigSync.ROW_LIMITS.remove(alias));
private static final long ROW_LIMIT_CACHE_MILLIS = 15_000L;
private static volatile boolean synced = false;
@@ -72,6 +82,7 @@ public final class ConfigSync
synced = false;
BUCKET_IDS.clear();
CONNECTION_IDS.clear();
ROW_LIMITS.clear();
}
@@ -273,4 +284,91 @@ public final class ConfigSync
}
return (base);
}
/*******************************************************************************
** What a bucket row says about presign limits (row values, cached briefly).
*******************************************************************************/
private record RowLimits(long readAt, Integer get, Integer put)
{
}
/*******************************************************************************
** The longest a presigned download link may last, in seconds: the smaller of
** the code config and the bucket row (Settings on object_storage_bucket). The
** row can only lower the code limit; a missing row, null or a value below one
** second means "no row limit". The config object is never modified.
*******************************************************************************/
public static int effectiveMaxPresignGetSeconds(StorageBucketConfig bucket)
{
return (lowest(bucket.getMaxPresignGetSeconds(), rowLimits(bucket.getAlias()).get()));
}
/*******************************************************************************
** The longest a presigned upload link may last, in seconds (see the GET twin).
*******************************************************************************/
public static int effectiveMaxPresignPutSeconds(StorageBucketConfig bucket)
{
return (lowest(bucket.getMaxPresignPutSeconds(), rowLimits(bucket.getAlias()).put()));
}
/*******************************************************************************
** Forget the cached row limits on this node (a bucket row edit clears them on
** every node through ConfigRowChangeCustomizer; they also expire).
*******************************************************************************/
public static void clearRowLimits()
{
ROW_LIMITS.clear();
}
/*******************************************************************************
** The smaller of the code value (3600 when unset) and a usable row value.
*******************************************************************************/
private static int lowest(Integer code, Integer row)
{
int base = code == null ? 3600 : code;
return (row != null && row > 0 && row < base ? row : base);
}
/*******************************************************************************
** The row's presign limits, read at most every 15 seconds per alias (the way
** the header rules are cached); never throws.
*******************************************************************************/
private static RowLimits rowLimits(String alias)
{
RowLimits cached = ROW_LIMITS.get(alias);
if(cached != null && System.currentTimeMillis() - cached.readAt() < ROW_LIMIT_CACHE_MILLIS)
{
return (cached);
}
RowLimits limits = new RowLimits(System.currentTimeMillis(), null, null);
if(Db.available(ObjectStorageTables.BUCKET))
{
try
{
QRecord row = Db.asSystem(() -> Db.first(ObjectStorageTables.BUCKET, "alias", alias));
if(row != null)
{
limits = new RowLimits(System.currentTimeMillis(), row.getValueInteger("maxPresignGetSeconds"), row.getValueInteger("maxPresignPutSeconds"));
}
}
catch(Exception e)
{
LOG.debug("Could not read bucket presign limits", e);
}
}
ROW_LIMITS.put(alias, limits);
return (limits);
}
}
@@ -21,57 +21,30 @@
package com.kingsrook.qbits.objectstorage.internal;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;
import java.io.ByteArrayInputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.KeyStore;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.util.Collection;
import com.kingsrook.qbits.objectstorage.config.AddressingStyle;
import com.kingsrook.qbits.objectstorage.config.ChecksumMode;
import com.kingsrook.qbits.objectstorage.config.CredentialsMode;
import com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3BackendMetaData;
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3ChecksumMode;
import com.kingsrook.qqq.backend.module.filesystem.s3.utils.S3ClientFactory;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.core.checksums.RequestChecksumCalculation;
import software.amazon.awssdk.core.checksums.ResponseChecksumValidation;
import software.amazon.awssdk.core.retry.RetryMode;
import software.amazon.awssdk.http.SdkHttpClient;
import software.amazon.awssdk.http.SdkHttpConfigurationOption;
import software.amazon.awssdk.http.apache.ApacheHttpClient;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.S3ClientBuilder;
import software.amazon.awssdk.services.s3.S3Configuration;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.utils.AttributeMap;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** One connection's live state: the settings, one cached SDK v2 S3Client and
** S3Presigner, and the rate limiter.
**
** STAND-IN: this is the small internal client factory used until QQQ core's
** S3ClientFactory (qqq #1095, RC.5) is released; replace it then. Nothing
** outside this package depends on how the client is built.
** Core owns S3 client construction. This runtime owns its uncached client and
** closes it on reset/reinstall; the presigner remains local.
*******************************************************************************/
public class ConnectionRuntime
{
private static final QLogger LOG = QLogger.getLogger(ConnectionRuntime.class);
private final StorageConnectionConfig config;
private final RateLimiter rateLimiter;
private final String settingsHash;
@@ -119,7 +92,7 @@ public class ConnectionRuntime
/*******************************************************************************
** Hash of the settings that shape the client (clients rebuild when it changes).
** Display hash of connection settings; runtime reset/reinstall rebuilds clients.
*******************************************************************************/
public String getSettingsHash()
{
@@ -217,36 +190,31 @@ public class ConnectionRuntime
*******************************************************************************/
private S3Client buildClient()
{
S3ClientBuilder builder = S3Client.builder()
.credentialsProvider(credentials(config.getAccessKeyRef(), config.getSecretKeyRef()))
.httpClient(httpClient())
.forcePathStyle(pathStyle())
.overrideConfiguration(o -> o.retryStrategy(RetryMode.ADAPTIVE_V2));
return (S3ClientFactory.buildClient(backendMetaData()));
}
String endpoint = SecretRefs.resolveSetting(config.getEndpoint());
if(StringUtils.hasContent(endpoint))
{
builder.endpointOverride(URI.create(endpoint));
}
String region = effectiveRegion();
if(region != null)
{
builder.region(Region.of(region));
}
if(config.effectiveChecksumMode() == ChecksumMode.WHEN_REQUIRED)
{
builder.requestChecksumCalculation(RequestChecksumCalculation.WHEN_REQUIRED);
builder.responseChecksumValidation(ResponseChecksumValidation.WHEN_REQUIRED);
}
else
{
builder.requestChecksumCalculation(RequestChecksumCalculation.WHEN_SUPPORTED);
builder.responseChecksumValidation(ResponseChecksumValidation.WHEN_SUPPORTED);
}
LOG.debug("Building S3 client", logPair("connection", config.getName()), logPair("endpoint", endpoint), logPair("checksumMode", config.effectiveChecksumMode()));
return (builder.build());
/*******************************************************************************
** Resolve references before passing the connection settings to core. No bucket
** is mapped: one connection serves multiple buckets. Presigning stays local.
*******************************************************************************/
S3BackendMetaData backendMetaData()
{
S3BackendMetaData backend = new S3BackendMetaData()
.withEndpoint(SecretRefs.resolveSetting(config.getEndpoint()))
.withRegion(effectiveRegion())
.withPathStyleAccess(pathStyle())
.withChecksumMode(S3ChecksumMode.valueOf(config.effectiveChecksumMode().name()))
.withTlsVerify(config.getTlsVerify())
.withCaCertificatesPem(SecretRefs.resolve(config.getCaBundleRef()))
.withConnectTimeoutMillis(config.getConnectTimeoutMs())
.withSocketTimeoutMillis(config.getReadTimeoutMs());
if(config.getCredentialsMode() == CredentialsMode.STATIC_REFS)
{
withStaticCredentials(backend, config.getAccessKeyRef(), config.getSecretKeyRef());
}
return (backend);
}
@@ -284,160 +252,33 @@ public class ConnectionRuntime
/*******************************************************************************
** Static keys from the references, or the SDK default chain.
** Static keys (with the session token, when set) from the references, or the
** SDK default chain; core builds the provider.
*******************************************************************************/
private AwsCredentialsProvider credentials(String accessRef, String secretRef)
{
S3BackendMetaData credentials = new S3BackendMetaData();
if(config.getCredentialsMode() == CredentialsMode.STATIC_REFS)
{
String accessKey = SecretRefs.resolve(accessRef);
String secretKey = SecretRefs.resolve(secretRef);
if(accessKey != null && secretKey != null)
{
String token = SecretRefs.resolve(config.getSessionTokenRef());
return (StaticCredentialsProvider.create(token == null ? AwsBasicCredentials.create(accessKey, secretKey)
: AwsSessionCredentials.create(accessKey, secretKey, token)));
}
withStaticCredentials(credentials, accessRef, secretRef);
}
return (DefaultCredentialsProvider.builder().build());
return (S3ClientFactory.buildCredentialsProvider(credentials));
}
/*******************************************************************************
** Apache HTTP client with the connection's timeouts and TLS settings.
** Resolve static keys onto core meta data. The session token applies only
** with both keys, as before (core rejects a token without keys).
*******************************************************************************/
private SdkHttpClient httpClient()
private void withStaticCredentials(S3BackendMetaData backend, String accessRef, String secretRef)
{
ApacheHttpClient.Builder builder = ApacheHttpClient.builder()
.connectionTimeout(Duration.ofMillis(config.getConnectTimeoutMs()))
.socketTimeout(Duration.ofMillis(config.getReadTimeoutMs()));
if(Boolean.FALSE.equals(config.getTlsVerify()))
String accessKey = SecretRefs.resolve(accessRef);
String secretKey = SecretRefs.resolve(secretRef);
backend.withAccessKey(accessKey).withSecretKey(secretKey);
if(accessKey != null && secretKey != null)
{
LOG.warn("TLS verification is disabled for a storage connection; use only for development", logPair("connection", config.getName()));
return (builder.buildWithDefaults(AttributeMap.builder().put(SdkHttpConfigurationOption.TRUST_ALL_CERTIFICATES, Boolean.TRUE).build()));
}
String caPem = SecretRefs.resolve(config.getCaBundleRef());
if(StringUtils.hasContent(caPem))
{
TrustManager[] trustManagers = trustManagers(caPem);
builder.tlsTrustManagersProvider(() -> trustManagers);
}
return (builder.build());
}
/*******************************************************************************
** Trust the given PEM bundle in addition to the JVM default trust store.
*******************************************************************************/
static TrustManager[] trustManagers(String pem)
{
try
{
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
Collection<? extends Certificate> certificates = certificateFactory.generateCertificates(new ByteArrayInputStream(pem.getBytes(StandardCharsets.UTF_8)));
if(certificates.isEmpty())
{
throw (new IllegalArgumentException("The CA bundle contains no certificates"));
}
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
keyStore.load(null, null);
int index = 0;
for(Certificate certificate : certificates)
{
keyStore.setCertificateEntry("object-storage-ca-" + index++, certificate);
}
X509TrustManager custom = x509(keyStore);
X509TrustManager fallback = x509(null);
return (new TrustManager[] { new Composite(custom, fallback) });
}
catch(IllegalArgumentException e)
{
throw (e);
}
catch(Exception e)
{
throw (new IllegalArgumentException("Could not load the CA bundle for a storage connection", e));
}
}
/*******************************************************************************
** The X509 trust manager of a key store (null: the JVM default).
*******************************************************************************/
private static X509TrustManager x509(KeyStore keyStore) throws Exception
{
TrustManagerFactory factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
factory.init(keyStore);
for(TrustManager trustManager : factory.getTrustManagers())
{
if(trustManager instanceof X509TrustManager x)
{
return (x);
}
}
throw (new IllegalStateException("No X509TrustManager available"));
}
/*******************************************************************************
** Trusts a chain if either the private-CA or the default trust manager does.
*******************************************************************************/
private record Composite(X509TrustManager custom, X509TrustManager fallback) implements X509TrustManager
{
/*******************************************************************************
** Client certs are not used.
*******************************************************************************/
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException
{
try
{
custom.checkClientTrusted(chain, authType);
}
catch(CertificateException e)
{
fallback.checkClientTrusted(chain, authType);
}
}
/*******************************************************************************
** Server certs.
*******************************************************************************/
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException
{
try
{
custom.checkServerTrusted(chain, authType);
}
catch(CertificateException e)
{
fallback.checkServerTrusted(chain, authType);
}
}
/*******************************************************************************
** Accepted issuers of both.
*******************************************************************************/
@Override
public X509Certificate[] getAcceptedIssuers()
{
X509Certificate[] a = custom.getAcceptedIssuers();
X509Certificate[] b = fallback.getAcceptedIssuers();
X509Certificate[] all = new X509Certificate[a.length + b.length];
System.arraycopy(a, 0, all, 0, a.length);
System.arraycopy(b, 0, all, a.length, b.length);
return (all);
backend.withSessionToken(SecretRefs.resolve(config.getSessionTokenRef()));
}
}
}
@@ -40,17 +40,29 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
/*******************************************************************************
** Small helpers for reading and writing the qbit's own tables as the system
** user (the service writes its audit log and index regardless of the calling
** user's per-bucket scope; the calling user's permissions were checked first).
**
** Every write made here carries core's per-call permission bypass
** (AbstractActionInput.withPermissionBypass). That mark, not the session, is
** what lets the service write tables nobody may write directly: a host that
** enforces permissions inside the core actions asks ObjectStoragePermissionChecker
** about every outermost write that does not carry a bypass, and the checker
** refuses those tables to every session, the system user included.
*******************************************************************************/
public final class Db
{
/** The reason recorded on (and logged by core for) each write the service makes of its own tables. */
public static final String OWN_WRITE_REASON = "The object storage service writes its own tables (operation log, index, capabilities, usage); the caller's storage permission was checked before the storage call.";
/*******************************************************************************
** Something that may throw.
*******************************************************************************/
@@ -131,30 +143,15 @@ public final class Db
/*******************************************************************************
** A session that holds every permission and every all-access security key of
** the given instance (computed per instance, never cached across instances).
** The core system-user session (every permission and every all-access
** security key of the given instance, read per instance). It must be a
** QSystemUserSession: token-based authentication modules (OAuth2, Auth0)
** accept that class without a token and reject any other tokenless session,
** which made every system read and write fail on such hosts.
*******************************************************************************/
private static QSession systemSession(QInstance instance)
{
QSession session = new QSession()
{
/*******************************************************************************
** The system user holds every permission.
*******************************************************************************/
@Override
public boolean hasPermission(String permissionName)
{
return (true);
}
};
for(QSecurityKeyType keyType : instance.getSecurityKeyTypes().values())
{
if(keyType.getAllAccessKeyName() != null)
{
session.withSecurityKeyValue(keyType.getAllAccessKeyName(), true);
}
}
return (session);
return (new QSystemUserSession(instance));
}
@@ -252,7 +249,9 @@ public final class Db
*******************************************************************************/
public static List<QRecord> insert(String table, List<QRecord> records) throws QException
{
return (new InsertAction().execute(new InsertInput(table).withRecords(records)).getRecords());
InsertInput input = new InsertInput(table).withRecords(records);
input.withPermissionBypass(OWN_WRITE_REASON);
return (new InsertAction().execute(input).getRecords());
}
@@ -274,7 +273,9 @@ public final class Db
*******************************************************************************/
public static List<QRecord> update(String table, List<QRecord> records) throws QException
{
return (new UpdateAction().execute(new UpdateInput(table).withRecords(records)).getRecords());
UpdateInput input = new UpdateInput(table).withRecords(records);
input.withPermissionBypass(OWN_WRITE_REASON);
return (new UpdateAction().execute(input).getRecords());
}
@@ -286,7 +287,9 @@ public final class Db
{
if(!ids.isEmpty())
{
new DeleteAction().execute(new DeleteInput(table).withPrimaryKeys(ids));
DeleteInput input = new DeleteInput(table).withPrimaryKeys(ids);
input.withPermissionBypass(OWN_WRITE_REASON);
new DeleteAction().execute(input);
}
}
@@ -23,12 +23,14 @@ package com.kingsrook.qbits.objectstorage.internal;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;
import com.kingsrook.qbits.objectstorage.api.ObjectHeaderPolicy;
import com.kingsrook.qbits.objectstorage.api.ObjectHeaders;
import com.kingsrook.qbits.objectstorage.config.BucketRole;
import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig;
import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables;
import com.kingsrook.qqq.backend.core.cluster.ClusterCaches;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
@@ -44,19 +46,31 @@ public class DefaultObjectHeaderPolicy implements ObjectHeaderPolicy
{
private static final QLogger LOG = QLogger.getLogger(DefaultObjectHeaderPolicy.class);
/** Cluster cache name of the enabled header rules (cleared on every node when a rule changes). */
public static final String CACHE_NAME = "qbit-object-storage.header-rules";
private static final long CACHE_MILLIS = 15_000L;
private static volatile List<QRecord> cachedRules;
private static volatile long cachedAt;
private static final AtomicReference<CachedRules> CACHED = ClusterCaches.shared(CACHE_NAME, new AtomicReference<>(), DefaultObjectHeaderPolicy::clearCache, null);
/*******************************************************************************
** Drop cached rules (after the rules change).
** The enabled rules and when they were read.
*******************************************************************************/
private record CachedRules(List<QRecord> rules, long readAt)
{
}
/*******************************************************************************
** Drop the cached rules on this node (a rule edit clears them on every node
** through ConfigRowChangeCustomizer).
*******************************************************************************/
public static void clearCache()
{
cachedRules = null;
CACHED.set(null);
}
@@ -119,11 +133,12 @@ public class DefaultObjectHeaderPolicy implements ObjectHeaderPolicy
*******************************************************************************/
private static List<QRecord> rules()
{
List<QRecord> rules = cachedRules;
if(rules != null && System.currentTimeMillis() - cachedAt < CACHE_MILLIS)
CachedRules cached = CACHED.get();
if(cached != null && System.currentTimeMillis() - cached.readAt() < CACHE_MILLIS)
{
return (rules);
return (cached.rules());
}
List<QRecord> rules;
if(!Db.available(ObjectStorageTables.HEADER_RULE))
{
return (List.of());
@@ -133,8 +148,7 @@ public class DefaultObjectHeaderPolicy implements ObjectHeaderPolicy
rules = Db.asSystem(() -> Db.query(ObjectStorageTables.HEADER_RULE, "enabled", true));
rules.sort(Comparator.comparing((QRecord r) -> r.getValueInteger("priority") == null ? 100 : r.getValueInteger("priority"))
.thenComparing(r -> r.getValueInteger("id") == null ? 0 : r.getValueInteger("id")));
cachedRules = rules;
cachedAt = System.currentTimeMillis();
CACHED.set(new CachedRules(rules, System.currentTimeMillis()));
return (rules);
}
catch(Exception e)
@@ -33,6 +33,7 @@ import com.kingsrook.qbits.objectstorage.api.StorageAlertSink;
import com.kingsrook.qbits.objectstorage.api.StorageChangeListener;
import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig;
import com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig;
import com.kingsrook.qqq.backend.core.cluster.ClusterCaches;
/*******************************************************************************
@@ -43,9 +44,11 @@ import com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig;
*******************************************************************************/
public final class ObjectStorageRuntime
{
private static final Map<String, ConnectionRuntime> CONNECTIONS = new ConcurrentHashMap<>();
private static final Map<String, ConnectionRuntime> CONNECTIONS = ClusterCaches.nodeLocal(new ConcurrentHashMap<>(),
"this node's S3 clients, built from the code config at first use; row overrides apply when a client is built, the same on every node");
private static final List<StorageChangeListener> LISTENERS = new CopyOnWriteArrayList<>();
private static final Map<String, String> PROCESS_PERMISSIONS = new ConcurrentHashMap<>();
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 ObjectStorageQBitConfig config;
private static volatile ObjectHeaderPolicy headerPolicy;
@@ -96,6 +99,7 @@ public final class ObjectStorageRuntime
clock = Clock.systemUTC();
indexHook = null;
CapabilityStore.clearCache();
OperationLog.reset();
}
@@ -21,10 +21,17 @@
package com.kingsrook.qbits.objectstorage.internal;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import com.kingsrook.qbits.objectstorage.api.StorageCaller;
import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables;
import com.kingsrook.qqq.backend.core.cluster.ClusterCaches;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -33,12 +40,22 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
** every caller (UI action, app code, scheduled job) lands here. Presigned URLs
** are logged without the signature (key, method, expiry and issuer only),
** because the URL itself is a bearer token. A logging failure never fails the
** operation it describes.
** operation it describes, so it is reported three ways instead: an ERROR log
** line, the storage.operation_log.failed alert (StorageAlertSink, resolved by
** the next row written for that bucket), and failedWriteCount().
*******************************************************************************/
public final class OperationLog
{
private static final QLogger LOG = QLogger.getLogger(OperationLog.class);
/** The alert raised (severity error, dedupe key "bucket:" + alias) when a row could not be written. */
public static final String ALERT_TYPE = "storage.operation_log.failed";
private static final AtomicLong FAILED_WRITES = new AtomicLong();
private static final Set<String> UNRESOLVED = ClusterCaches.nodeLocal(ConcurrentHashMap.newKeySet(),
"the buckets whose operation log alert this node raised and has not resolved (alert state, not cached data)");
/*******************************************************************************
@@ -183,6 +200,31 @@ public final class OperationLog
/*******************************************************************************
** How many operation log rows this node could not write since it started (or
** since the runtime was reset). The operations themselves were carried out, so
** anything above zero means storage changes that the audit log does not show.
** A host that has no alert sink reads this (a metrics gauge, a health check of
** its own) to alert on a missing audit row.
*******************************************************************************/
public static long failedWriteCount()
{
return (FAILED_WRITES.get());
}
/*******************************************************************************
** Forget the failures counted and the alerts raised (ObjectStorageRuntime.reset).
*******************************************************************************/
static void reset()
{
FAILED_WRITES.set(0);
UNRESOLVED.clear();
}
/*******************************************************************************
** Insert the row (as the system user), never throwing.
*******************************************************************************/
@@ -196,7 +238,7 @@ public final class OperationLog
try
{
Integer bucketId = ConfigSync.bucketId(e.bucketAlias);
Db.asSystem(() -> Db.insert(ObjectStorageTables.OPERATION, new QRecord()
QRecord written = Db.asSystem(() -> Db.insert(ObjectStorageTables.OPERATION, new QRecord()
.withValue("occurredAt", ObjectStorageRuntime.clock().instant())
.withValue("actor", e.caller.describe())
.withValue("action", e.action)
@@ -210,10 +252,66 @@ public final class OperationLog
.withValue("providerRequestId", e.requestId)
.withValue("processRunId", e.processRunId)
.withValue("detail", e.detail)));
////////////////////////////////////////////////////////////////////////////////
// the insert action does not throw for a record it refused (a missing value, //
// a value too long, a record lock): it hands the record back with its errors //
////////////////////////////////////////////////////////////////////////////////
if(CollectionUtils.nullSafeHasContents(written.getErrors()))
{
throw (new QException("The operation log row was refused: " + written.getErrors()));
}
written(e);
}
catch(Exception ex)
{
LOG.warn("Could not write a storage operation log row", ex, logPair("action", e.action), logPair("key", e.key));
failed(e, ex);
}
}
/*******************************************************************************
** A row was written: resolve the alert of an earlier failure for its bucket.
*******************************************************************************/
private static void written(Entry e)
{
String dedupeKey = "bucket:" + e.bucketAlias;
if(UNRESOLVED.remove(dedupeKey))
{
try
{
ObjectStorageRuntime.alertSink().resolve(ALERT_TYPE, dedupeKey);
}
catch(RuntimeException sinkFailure)
{
LOG.warn("Could not resolve the storage operation log alert", sinkFailure, logPair("bucket", e.bucketAlias));
}
}
}
/*******************************************************************************
** A row could not be written: the operation happened and the audit log does
** not show it. Say so where an operator sees it, without failing the operation.
*******************************************************************************/
private static void failed(Entry e, Exception cause)
{
long count = FAILED_WRITES.incrementAndGet();
LOG.error("Could not write a storage operation log row: the operation is missing from the audit log", cause,
logPair("action", e.action), logPair("bucket", e.bucketAlias), logPair("key", e.key), logPair("result", e.result), logPair("failedWriteCount", count));
String dedupeKey = "bucket:" + e.bucketAlias;
UNRESOLVED.add(dedupeKey);
try
{
ObjectStorageRuntime.alertSink().raise(ALERT_TYPE, dedupeKey, "error", "A storage operation on " + e.bucketAlias + " was not recorded in the operation log",
Map.of("bucket", String.valueOf(e.bucketAlias), "action", String.valueOf(e.action), "count", count));
}
catch(RuntimeException sinkFailure)
{
LOG.warn("Could not raise the storage operation log alert", sinkFailure, logPair("bucket", e.bucketAlias));
}
}
}
@@ -26,6 +26,7 @@ import java.util.Map;
import com.kingsrook.qbits.objectstorage.api.Capability;
import com.kingsrook.qbits.objectstorage.api.CapabilityStatus;
import com.kingsrook.qbits.objectstorage.config.ProviderType;
import com.kingsrook.qqq.backend.core.cluster.ClusterCaches;
import static com.kingsrook.qbits.objectstorage.api.CapabilityStatus.PARTIAL;
import static com.kingsrook.qbits.objectstorage.api.CapabilityStatus.SUPPORTED;
import static com.kingsrook.qbits.objectstorage.api.CapabilityStatus.UNKNOWN;
@@ -39,7 +40,8 @@ import static com.kingsrook.qbits.objectstorage.api.CapabilityStatus.UNSUPPORTED
*******************************************************************************/
public final class ProviderDefaults
{
private static final Map<ProviderType, Map<Capability, CapabilityStatus>> TABLE = new EnumMap<>(ProviderType.class);
private static final Map<ProviderType, Map<Capability, CapabilityStatus>> TABLE = ClusterCaches.nodeLocal(new EnumMap<>(ProviderType.class),
"provider defaults from code, the same on every node");
static
{
@@ -21,7 +21,10 @@
package com.kingsrook.qbits.objectstorage.internal;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
/*******************************************************************************
@@ -57,17 +60,52 @@ public class RateLimiter
*******************************************************************************/
public Permit acquire()
{
return (acquire(null));
}
/*******************************************************************************
** Wait no later than a caller's deadline, releasing the slot on interruption.
*******************************************************************************/
public Permit acquire(Instant deadline)
{
boolean acquired = false;
try
{
concurrency.acquire();
waitForToken();
if(deadline == null)
{
concurrency.acquire();
acquired = true;
}
else
{
acquired = concurrency.tryAcquire(Math.max(0, Duration.between(Instant.now(), deadline).toNanos()), TimeUnit.NANOSECONDS);
if(!acquired)
{
throw (new IllegalStateException("Storage probe deadline exceeded"));
}
}
waitForToken(deadline);
return (new Permit(concurrency));
}
catch(InterruptedException e)
{
if(acquired)
{
concurrency.release();
}
Thread.currentThread().interrupt();
throw (new IllegalStateException("Interrupted while waiting for the storage rate limiter", e));
}
catch(RuntimeException e)
{
if(acquired)
{
concurrency.release();
}
throw (e);
}
}
@@ -75,10 +113,14 @@ public class RateLimiter
/*******************************************************************************
** Block until a token is available.
*******************************************************************************/
private void waitForToken() throws InterruptedException
private void waitForToken(Instant deadline) throws InterruptedException
{
while(true)
{
if(deadline != null && !Instant.now().isBefore(deadline))
{
throw (new IllegalStateException("Storage probe deadline exceeded"));
}
long sleepMillis;
synchronized(this)
{
@@ -93,7 +135,7 @@ public class RateLimiter
throttledWaits++;
sleepMillis = Math.max(1, (long) ((1.0 - tokens) / opsPerSecond * 1000));
}
Thread.sleep(sleepMillis);
Thread.sleep(deadline == null ? sleepMillis : Math.min(sleepMillis, Math.max(1, Duration.between(Instant.now(), deadline).toMillis())));
}
}
@@ -21,10 +21,6 @@
package com.kingsrook.qbits.objectstorage.internal;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.regex.Pattern;
import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
@@ -32,12 +28,12 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** Resolves secret references (${env.X}, ${prop.X}, ${file.PATH}) and enforces
** the allow-list. Resolved values live only in memory, never in a record, a log
** line or a process value.
**
** STAND-IN: ${file.PATH} moves to QMetaDataVariableInterpreter in QQQ core
** (spec section 8.2); then this class delegates entirely.
** Enforces the qbit's secret-reference allow-list and resolves references
** (${env.X}, ${prop.X}, ${file.PATH}) through core QMetaDataVariableInterpreter.
** Core reads ${file.PATH} read-only from its allowed roots (default
** /var/run/secrets and /run/secrets; qqq.fileReference.roots or
** QQQ_FILE_REFERENCE_ROOTS). Resolved values live only in memory, never in a
** record, a log line or a process value.
*******************************************************************************/
public final class SecretRefs
{
@@ -91,19 +87,7 @@ public final class SecretRefs
{
return (null);
}
String trimmed = ref.trim();
if(trimmed.startsWith("${file.") && trimmed.endsWith("}"))
{
try
{
return (Files.readString(Path.of(trimmed.substring("${file.".length(), trimmed.length() - 1)), StandardCharsets.UTF_8).trim());
}
catch(IOException e)
{
return (null);
}
}
String value = new QMetaDataVariableInterpreter().interpret(trimmed);
String value = new QMetaDataVariableInterpreter().interpret(ref.trim());
return (value == null || value.startsWith("${") ? null : value);
}
@@ -0,0 +1,132 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.objectstorage.model;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Applies help/qbit-object-storage.yaml (the QQQ help-bundle format) to the
** Storage app and its object_storage_* tables: what each table is for and how
** its rows get created, shown on the table screen and when it is empty.
** Inline defaults preserve empty-state help for hosts without helpContent.
** The producer also registers the bundle for core sync and HOST overrides.
*******************************************************************************/
public final class ObjectStorageHelp
{
public static final String RESOURCE = "/help/qbit-object-storage.yaml";
public static final String SLOT = "help";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private ObjectStorageHelp()
{
}
/*******************************************************************************
** The bundle's entries by key, validated (fails startup if malformed).
*******************************************************************************/
public static Map<String, JsonNode> entries() throws QException
{
try(InputStream stream = ObjectStorageHelp.class.getResourceAsStream(RESOURCE))
{
if(stream == null)
{
throw (new IOException("missing resource"));
}
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
if(!"qbit-object-storage".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText()))
{
throw (new IOException("invalid bundle header"));
}
Map<String, JsonNode> entries = new LinkedHashMap<>();
for(JsonNode entry : bundle.path("entries"))
{
String key = entry.path("key").asText();
String summary = entry.path("summary").asText();
if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entries.put(key, entry) != null)
{
throw (new IOException("invalid or duplicate entry: " + key));
}
}
return (entries);
}
catch(IOException | RuntimeException e)
{
throw (new QException("Could not load " + RESOURCE, e));
}
}
/*******************************************************************************
** Apply the help to the app and every object_storage_* table in the
** instance. A host's own help on a table or the app is kept (the bundle only
** fills the slot).
*******************************************************************************/
public static void apply(QInstance qInstance, ObjectStorageQBitConfig config) throws QException
{
Map<String, JsonNode> entries = entries();
for(QTableMetaData table : qInstance.getTables().values())
{
JsonNode entry = entries.get("table:" + table.getName() + ";slot:" + SLOT);
if(entry != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
{
table.withHelpContent(SLOT, content(entry));
}
}
QAppMetaData app = qInstance.getApp(config.getAppName());
JsonNode appEntry = entries.get("app:objectStorageAdmin;slot:" + SLOT);
if(app != null && appEntry != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
{
app.withHelpContent(SLOT, content(appEntry));
}
}
/*******************************************************************************
** Summary as the first paragraph, then the content, as markdown.
*******************************************************************************/
static QHelpContent content(JsonNode entry)
{
return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip()));
}
}
@@ -38,7 +38,9 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
**
** The statements are idempotent (CREATE ... IF NOT EXISTS) and include the
** unique constraints and the browse indexes the design calls for
** ((bucket, key) unique; bucket with folder, last modified, size, content type).
** ((bucket, key) unique; bucket with folder, last modified, size, content type),
** then the foreign keys, each in a block that also upgrades an existing schema
** (orphans recorded in qqq_fk_quarantine first; see addForeignKey).
*******************************************************************************/
public final class ObjectStorageSchema
{
@@ -52,14 +54,79 @@ public final class ObjectStorageSchema
ObjectStorageTables.OBJECT, List.of(List.of("bucketId", "objectKey")),
ObjectStorageTables.USAGE_DAILY, List.of(List.of("bucketId", "snapshotDate")));
/** Browse and lookup indexes: table to column lists. */
/** Browse and lookup indexes: table to column lists. New entries go at the end of
** a table's list, so the generated names (ix_<table>_<n>) of existing ones never change.
** The child lists on record pages (folder -> objects, object -> versions and references)
** filter on the parent id alone, so those columns lead an index of their own. */
private static final Map<String, List<List<String>>> INDEXES = Map.of(
ObjectStorageTables.OBJECT, List.of(List.of("bucketId", "folderId"), List.of("bucketId", "lastModified"), List.of("bucketId", "size"), List.of("bucketId", "contentType"),
List.of("bucketId", "deleted", "referenceCount"), List.of("bucketId", "headersKnown", "deleted")),
ObjectStorageTables.OBJECT_REFERENCE, List.of(List.of("bucketId", "objectId"), List.of("bucketId", "missing")),
List.of("bucketId", "deleted", "referenceCount"), List.of("bucketId", "headersKnown", "deleted"), List.of("folderId")),
ObjectStorageTables.OBJECT_REFERENCE, List.of(List.of("bucketId", "objectId"), List.of("bucketId", "missing"), List.of("objectId")),
ObjectStorageTables.OPERATION, List.of(List.of("bucketId", "occurredAt"), List.of("objectKey", "occurredAt")),
ObjectStorageTables.FOLDER, List.of(List.of("parentFolderId")),
ObjectStorageTables.OBJECT_VERSION, List.of(List.of("bucketId", "objectKey")));
ObjectStorageTables.OBJECT_VERSION, List.of(List.of("bucketId", "objectKey"), List.of("objectId")),
ObjectStorageTables.BUCKET, List.of(List.of("connectionId")),
ObjectStorageTables.HEADER_RULE, List.of(List.of("bucketId")),
ObjectStorageTables.PROTECTION_RULE, List.of(List.of("bucketId")));
/** The table that records the rows an upgrade changed so a foreign key could be added
** (identical DDL in every qbit, so whichever runs first creates it). */
public static final String QUARANTINE_DDL = "CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "
+ "table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, "
+ "row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)";
/** Every parent/child relation, enforced in the database. The qbit never deletes a
** connection, bucket or folder row (ConfigSync only upserts; folders are only
** created), and its only deletes are object rows (scan removal, trash retention,
** hard-delete write-through) and reference rows (rebuilt by each reference scan),
** through QQQ deletes with no associations. So:
** - observed state that belongs to a parent (capabilities, folders, objects, versions,
** usage) CASCADEs: an object delete takes its versions, and an admin deleting a
** bucket row takes its index with it;
** - optional pointers into the index (object.folderId, reference.objectId) SET NULL:
** the next scan sets the folder again, and a reference to a removed object is
** missing until the next reference scan rebuilds the rows;
** - admin-authored config (header and protection rules), the operation audit log and
** a bucket's connection RESTRICT (NO ACTION): deleting a bucket row that has rules
** or history, or a connection that buckets use, is refused rather than silently
** dropping protection or audit rows (ConfigSync would re-create a configured bucket
** under a new id, with its rules gone).
** Inserts are parents first everywhere (connections before buckets, a folder's parent
** before the folder, folders before objects), so nothing needs to be deferred.
** New entries go at the end. */
private static final List<ForeignKey> FOREIGN_KEYS = List.of(
new ForeignKey(ObjectStorageTables.BUCKET, "connectionId", ObjectStorageTables.CONNECTION, "NO ACTION"),
new ForeignKey(ObjectStorageTables.CONNECTION_CAPABILITY, "connectionId", ObjectStorageTables.CONNECTION, "CASCADE"),
new ForeignKey(ObjectStorageTables.BUCKET_CAPABILITY, "bucketId", ObjectStorageTables.BUCKET, "CASCADE"),
new ForeignKey(ObjectStorageTables.HEADER_RULE, "bucketId", ObjectStorageTables.BUCKET, "NO ACTION"),
new ForeignKey(ObjectStorageTables.PROTECTION_RULE, "bucketId", ObjectStorageTables.BUCKET, "NO ACTION"),
new ForeignKey(ObjectStorageTables.OPERATION, "bucketId", ObjectStorageTables.BUCKET, "NO ACTION"),
new ForeignKey(ObjectStorageTables.USAGE_DAILY, "bucketId", ObjectStorageTables.BUCKET, "CASCADE"),
new ForeignKey(ObjectStorageTables.FOLDER, "bucketId", ObjectStorageTables.BUCKET, "CASCADE"),
new ForeignKey(ObjectStorageTables.FOLDER, "parentFolderId", ObjectStorageTables.FOLDER, "CASCADE"),
new ForeignKey(ObjectStorageTables.OBJECT, "bucketId", ObjectStorageTables.BUCKET, "CASCADE"),
new ForeignKey(ObjectStorageTables.OBJECT, "folderId", ObjectStorageTables.FOLDER, "SET NULL"),
new ForeignKey(ObjectStorageTables.OBJECT_VERSION, "bucketId", ObjectStorageTables.BUCKET, "CASCADE"),
new ForeignKey(ObjectStorageTables.OBJECT_VERSION, "objectId", ObjectStorageTables.OBJECT, "CASCADE"),
new ForeignKey(ObjectStorageTables.OBJECT_REFERENCE, "bucketId", ObjectStorageTables.BUCKET, "CASCADE"),
new ForeignKey(ObjectStorageTables.OBJECT_REFERENCE, "objectId", ObjectStorageTables.OBJECT, "SET NULL"));
/*******************************************************************************
** One foreign key: the child table and field, the parent table (always its id)
** and the ON DELETE rule.
*******************************************************************************/
public record ForeignKey(String table, String field, String parent, String onDelete)
{
/*******************************************************************************
** The constraint name: fk_<child table>_<child column>.
*******************************************************************************/
public String name()
{
return ("fk_" + table + "_" + column(field));
}
}
@@ -99,11 +166,56 @@ public final class ObjectStorageSchema
}
}
}
statements.add(QUARANTINE_DDL);
for(ForeignKey fk : FOREIGN_KEYS)
{
statements.add(addForeignKey(fk, tables));
}
return (statements);
}
/*******************************************************************************
** The foreign keys the schema declares.
*******************************************************************************/
public static List<ForeignKey> foreignKeys()
{
return (FOREIGN_KEYS);
}
/*******************************************************************************
** One idempotent block that adds a foreign key and upgrades an existing schema
** safely: rows whose parent is gone are first recorded in qqq_fk_quarantine, then
** their reference is cleared (nullable column) or the row is deleted (required
** column; the whole row is recorded), so adding the constraint cannot fail. A
** no-op once the constraint exists.
*******************************************************************************/
private static String addForeignKey(ForeignKey fk, List<QTableMetaData> tables)
{
String col = column(fk.field());
boolean required = tables.stream().filter(t -> t.getName().equals(fk.table())).findFirst()
.map(t -> Boolean.TRUE.equals(t.getField(fk.field()).getIsRequired())).orElse(false);
String orphans = "c." + col + " IS NOT NULL AND NOT EXISTS (SELECT 1 FROM " + fk.parent() + " p WHERE p.id = c." + col + ")";
return ("DO $$\n"
+ "BEGIN\n"
+ " IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = '" + fk.name() + "' AND conrelid = '" + fk.table() + "'::regclass) THEN\n"
+ " INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset)\n"
+ " SELECT '" + fk.table() + "', '" + col + "', c.id::text, c." + col + "::text, '" + (required ? "DELETED" : "NULLED") + "', row_to_json(c)::text, "
+ "'qbit-object-storage ObjectStorageSchema'\n"
+ " FROM " + fk.table() + " c WHERE " + orphans + ";\n"
+ (required
? " DELETE FROM " + fk.table() + " c WHERE " + orphans + ";\n"
: " UPDATE " + fk.table() + " c SET " + col + " = NULL WHERE " + orphans + ";\n")
+ " ALTER TABLE " + fk.table() + " ADD CONSTRAINT " + fk.name() + " FOREIGN KEY (" + col + ") REFERENCES " + fk.parent() + " (id) ON DELETE " + fk.onDelete() + ";\n"
+ " END IF;\n"
+ "END $$");
}
/*******************************************************************************
** The statements as one script.
*******************************************************************************/
@@ -25,6 +25,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig;
import com.kingsrook.qbits.objectstorage.internal.ConfigRowChangeCustomizer;
import com.kingsrook.qbits.objectstorage.permissions.ObjectStoragePermissionChecker;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
@@ -206,6 +207,20 @@ public final class ObjectStorageTables
new String[] { OBJECT, OBJECT_VERSION, "objectId", "Versions" },
new String[] { OBJECT, OBJECT_REFERENCE, "objectId", "Referenced by" });
/** The columns a child list shows for each child table (the rest is one click away on the record). */
private static final Map<String, List<String>> CHILD_LIST_COLUMNS = Map.ofEntries(
Map.entry(OBJECT, List.of("objectKey", "size", "contentType", "lastModified", "referenceCount")),
Map.entry(FOLDER, List.of("path", "objectCount", "totalBytes")),
Map.entry(OBJECT_REFERENCE, List.of("label", "keyOrPrefix", "missing", "sourceTable")),
Map.entry(OPERATION, List.of("occurredAt", "action", "objectKey", "result", "actor")),
Map.entry(USAGE_DAILY, List.of("snapshotDate", "objectCount", "totalBytes")),
Map.entry(CONNECTION_CAPABILITY, List.of("capability", "status", "detail")),
Map.entry(BUCKET_CAPABILITY, List.of("capability", "status", "detail")),
Map.entry(BUCKET, List.of("alias", "role", "indexed", "objectCount")),
Map.entry(HEADER_RULE, List.of("name", "keyGlob", "cacheControl", "priority", "enabled")),
Map.entry(PROTECTION_RULE, List.of("prefixGlob", "mode", "enabled", "reason")),
Map.entry(OBJECT_VERSION, List.of("versionId", "isDeleteMarker", "isLatest", "lastModified")));
/** Tables that are nav entries (the rest appear as relations and child lists on their parents). */
public static final List<String> NAV_TABLES = List.of(BUCKET, OBJECT, FOLDER, CONNECTION, OPERATION, HEADER_RULE, PROTECTION_RULE);
@@ -367,7 +382,12 @@ public final class ObjectStorageTables
{
QJoinMetaData join = new QJoinMetaData().withName(joinName(relation)).withLeftTable(relation[0]).withRightTable(relation[1]).withType(JoinType.ONE_TO_MANY)
.withJoinOn(new JoinOn("id", relation[2]));
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(join).withName(joinName(relation)).withLabel(relation[3]).withMaxRows(10).getWidgetMetaData());
ChildRecordListRenderer.Builder builder = ChildRecordListRenderer.widgetMetaDataBuilder(join).withName(joinName(relation)).withLabel(relation[3]).withMaxRows(10);
if(CHILD_LIST_COLUMNS.containsKey(relation[1]))
{
builder.withOnlyIncludeFieldNames(CHILD_LIST_COLUMNS.get(relation[1]));
}
widgets.add(builder.getWidgetMetaData());
}
return (widgets);
}
@@ -545,7 +565,9 @@ public final class ObjectStorageTables
.withField(ro("orphanedCount", QFieldType.LONG))
.withField(ro("orphanedBytes", QFieldType.LONG))
.withField(ro("missingCount", QFieldType.LONG))
.withField(ro("lastReferenceScanDate", QFieldType.DATE_TIME)), KEY_TYPE_BUCKET, "id", false));
.withField(ro("lastReferenceScanDate", QFieldType.DATE_TIME)), KEY_TYPE_BUCKET, "id", false)
.withCustomizer(TableCustomizers.POST_INSERT_RECORD, new QCodeReference(ConfigRowChangeCustomizer.class))
.withCustomizer(TableCustomizers.POST_UPDATE_RECORD, new QCodeReference(ConfigRowChangeCustomizer.class)));
}
@@ -682,7 +704,10 @@ public final class ObjectStorageTables
.withField(str("cacheControl", 300))
.withField(str("contentDisposition", 300))
.withField(new QFieldMetaData("priority", QFieldType.INTEGER).withDefaultValue(100))
.withField(new QFieldMetaData("enabled", QFieldType.BOOLEAN).withDefaultValue(true)), KEY_TYPE_BUCKET, "bucketId", true));
.withField(new QFieldMetaData("enabled", QFieldType.BOOLEAN).withDefaultValue(true)), KEY_TYPE_BUCKET, "bucketId", true)
.withCustomizer(TableCustomizers.POST_INSERT_RECORD, new QCodeReference(ConfigRowChangeCustomizer.class))
.withCustomizer(TableCustomizers.POST_UPDATE_RECORD, new QCodeReference(ConfigRowChangeCustomizer.class))
.withCustomizer(TableCustomizers.POST_DELETE_RECORD, new QCodeReference(ConfigRowChangeCustomizer.class)));
}
@@ -46,7 +46,9 @@ import com.kingsrook.qqq.backend.core.model.session.QSession;
** - writes to the editable config tables (connection operational fields, header
** rules, protection rules, bucket thresholds) need object_storage.admin;
** observed and history tables are never written through the UI or API (they
** change only through actions and the service);
** change only through actions and the service, whose own writes carry
** core's per-call permission bypass, see internal.Db; no session is let
** through, the core system user included);
** - each process needs the permission registered for it (default admin).
*******************************************************************************/
public class ObjectStoragePermissionChecker implements CustomPermissionChecker
@@ -65,6 +67,12 @@ public class ObjectStoragePermissionChecker implements CustomPermissionChecker
String needed = required(actionInput, metaData);
if(needed == null)
{
////////////////////////////////////////////////////////////////////////////////////////
// nobody writes these tables directly, whatever the session: a person, the storage //
// administrator and the core system user are all refused. the service's own writes //
// (internal.Db) carry core's per-call permission bypass, so core does not ask about //
// them; a write that reaches this point did not come from the service //
////////////////////////////////////////////////////////////////////////////////////////
throw (new QPermissionDeniedException("This storage table changes only through storage actions."));
}
if(!has(needed))
@@ -37,6 +37,7 @@ import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig;
import com.kingsrook.qbits.objectstorage.index.ObjectIndex;
import com.kingsrook.qbits.objectstorage.index.ReferenceScan;
import com.kingsrook.qbits.objectstorage.internal.CapabilityStore;
import com.kingsrook.qbits.objectstorage.internal.ConfigSync;
import com.kingsrook.qbits.objectstorage.internal.ConnectionRuntime;
import com.kingsrook.qbits.objectstorage.internal.Db;
import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime;
@@ -689,7 +690,7 @@ public final class BucketProcesses
request.setContentLength(Long.valueOf(String.valueOf(input.getValue(FIELD_LINK_LENGTH))));
}
URL url = ActionSupport.storage(alias).presignPut(request);
int minutes = (int) Math.min(ActionSupport.config(alias).getMaxPresignPutSeconds() / 60, 15);
int minutes = (int) Math.min(ConfigSync.effectiveMaxPresignPutSeconds(ActionSupport.config(alias)) / 60, 15);
ProcessSummaryLine line = EtlSupport.text(Status.OK, "Upload link for " + alias + "/" + request.getKey() + " (expires in about " + minutes + " minutes; shown once)");
line.setBulletsOfText(new ArrayList<>(List.of(url.toString())));
results.add(line);
@@ -27,6 +27,7 @@ import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.objectstorage.api.PresignGet;
import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig;
import com.kingsrook.qbits.objectstorage.internal.ConfigSync;
import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables;
import com.kingsrook.qbits.objectstorage.permissions.ObjectStoragePermissions;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -150,7 +151,7 @@ public final class LinkProcesses
{
String alias = ActionSupport.aliasOf(record);
StorageBucketConfig config = ActionSupport.config(alias);
Duration ttl = Duration.ofSeconds(Math.min(300, config.getMaxPresignGetSeconds()));
Duration ttl = Duration.ofSeconds(Math.min(300, ConfigSync.effectiveMaxPresignGetSeconds(config)));
URL url = ActionSupport.storage(alias).presignGet(PresignGet.of(record.getValueString("objectKey")).ttl(ttl).downloadAs(record.getValueString("name")));
ProcessSummaryLine line = EtlSupport.text(Status.OK, "Download link for " + alias + "/" + record.getValueString("objectKey") + " (expires in " + ttl.toMinutes() + " minutes; shown once)");
line.setBulletsOfText(new ArrayList<>(List.of(url.toString())));
@@ -227,7 +227,7 @@ public final class TestConnectionProcess
}
if(report.getClockSkewSeconds() != null)
{
boolean skewed = report.getClockSkewSeconds() > 900;
boolean skewed = report.getClockSkewSeconds() > CapabilityProbe.CLOCK_SKEW_THRESHOLD_SECONDS;
ok = ok && !skewed;
lines.add(new ProcessSummaryLine(skewed ? Status.ERROR : Status.INFO, "Clock skew against the store: " + report.getClockSkewSeconds() + " seconds"
+ (skewed ? " (SigV4 fails beyond 15 minutes)" : "")));
@@ -0,0 +1,34 @@
-- liquibase formatted sql
-- qbit-object-storage starter defaults.
-- Include it from the host's master changelog AFTER the changesets that create
-- the object_storage_* tables (the script from ObjectStorageSchema.postgresScript):
--
-- - include: { file: db/liquibase/qbit-object-storage/object-storage-defaults.changelog.sql }
--
-- Rules for this file:
-- * insert-only: a row is inserted only when its natural key is absent, so an
-- administrator's edits always survive and nothing is ever updated;
-- * a seed never changes what an existing host does on deploy: the rows below
-- are inserted DISABLED, so they are visible examples until someone turns
-- them on;
-- * rollback is empty: a seed row may have been edited or adopted by the host, so
-- rolling back never deletes data;
-- * no secrets, no host-specific values;
-- * portable SQL (PostgreSQL and H2): INSERT ... SELECT ... WHERE NOT EXISTS;
-- * every changeset has a precondition, so a host that has not created the
-- table yet simply skips it (MARK_RAN).
-- DefaultsChangelogTest runs this file through Liquibase, twice, on H2.
--
-- Not seeded: object_storage_protection_rule (a rule needs a bucket, which is
-- host-specific), and the connection and bucket rows (written from code by the
-- qbit's config sync).
-- changeset qbit-object-storage:defaults-1
-- preconditions onFail:MARK_RAN onError:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM object_storage_header_rule WHERE name = 'default-mutable'
-- comment: A generic header rule for mutable objects, disabled. Enable it to give every object (any bucket) Cache-Control public, max-age=3600; lower the priority number to rank it ahead of other rules.
INSERT INTO object_storage_header_rule (name, key_glob, cache_control, priority, enabled, create_date, modify_date)
SELECT 'default-mutable', '**', 'public, max-age=3600', 1000, FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM object_storage_header_rule WHERE name = 'default-mutable');
-- rollback empty
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
version=${project.version}
@@ -0,0 +1,82 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.objectstorage;
import java.util.Set;
import java.util.stream.Collectors;
import com.kingsrook.qbits.objectstorage.testsupport.StorageTestSupport;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Coverage of every object owned by the storage qbit, including its optional app.
*******************************************************************************/
class ObjectStorageHelpBundleTest
{
/*******************************************************************************
** Release the installed runtime after each test.
*******************************************************************************/
@AfterEach
void tearDown()
{
StorageTestSupport.tearDown();
}
/*******************************************************************************
** Register safely without help tables and cover all owned metadata.
*******************************************************************************/
@Test
void bundleIsCompleteWithoutHelpTables() throws Exception
{
QInstance instance = StorageTestSupport.setUp(new ObjectStorageQBitConfig()
.withBackendName(StorageTestSupport.MEMORY).withBucketScoping(false).withOpenWhenAnonymous(true).withAdminApp(true));
assertThat(instance.getTable("helpContent")).isNull();
assertThat(HelpBundles.of(instance).getBundles()).singleElement()
.satisfies(bundle ->
{
assertThat(bundle.getId()).isEqualTo("qbit-object-storage");
assertThat(System.getProperty("qbit.artifactVersion")).isNotBlank();
assertThat(bundle.getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion"))
.isEqualTo(ObjectStorageQBitProducer.VERSION);
});
HelpBundles.syncOnStartup(instance);
Set<String> keys = HelpBundles.of(instance).getBundles().get(0).getEntries().stream()
.map(entry -> entry.getKey()).collect(Collectors.toSet());
// RC.7 validates these entries but only requires app/table/process/field coverage.
instance.getWidgets().keySet().stream().filter(name -> name.startsWith("object_storage_") || name.startsWith("objectStorage"))
.forEach(name -> assertThat(keys).contains("widget:" + name + ";slot:label"));
instance.getPossibleValueSources().keySet().stream().filter(name -> name.startsWith("object_storage_"))
.forEach(name -> assertThat(keys).contains("possibleValueSource:" + name + ";slot:help"));
HelpBundleAssertions.forInstance(instance)
.bundles("help/qbit-object-storage.yaml")
.owning(target -> target.name().startsWith("object_storage_") || target.name().startsWith("objectStorage"))
.assertComplete();
}
}
@@ -44,6 +44,10 @@ class ObjectStorageQBitProducerTest
ObjectStorageQBitConfig config = new ObjectStorageQBitConfig().withBackendName("rdbms");
ObjectStorageQBitProducer producer = new ObjectStorageQBitProducer().withQBitConfig(config).withNamespace("object_storage");
String artifactVersion = System.getProperty("qbit.artifactVersion");
assertThat(artifactVersion).isNotBlank();
assertThat(ObjectStorageQBitProducer.VERSION).isEqualTo(artifactVersion);
assertThat(producer.getQBitMetaData().getVersion()).isEqualTo(artifactVersion);
assertThat(producer.isEnabled()).isTrue();
assertThat(producer.getQBitConfig()).isSameAs(config);
assertThat(producer.getNamespace()).isEqualTo("object_storage");
@@ -0,0 +1,218 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.objectstorage;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import com.kingsrook.qbits.objectstorage.internal.DefaultsChangelogTest;
import com.kingsrook.qbits.objectstorage.model.ObjectStorageHelp;
import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables;
import com.kingsrook.qbits.objectstorage.testsupport.StorageTestSupport;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Guardrail (owner requirement 2026-10-14: "all tables should be populated and
** the qbit should be fully using them"). For every object_storage_* table:
** - runtime code (not just the model, metadata or home screen) reads or writes
** it, or it is listed in NOT_USED_YET with what is missing;
** - it is either seeded by the shipped defaults changelog, or listed in
** STARTS_EMPTY with why;
** - it has bundle help that says how its rows get created (shown on the table
** screen and when it is empty).
** A new table fails this test until it is wired, seeded or explained.
*******************************************************************************/
class ObjectStorageTablesGuardrailTest
{
private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "objectstorage");
/*******************************************************************************
** Tables that no runtime code writes yet, and what is missing. Their help says
** "Not used yet".
*******************************************************************************/
static final Map<String, String> NOT_USED_YET = Map.of(
ObjectStorageTables.OBJECT_VERSION, "the indexer lists current versions only (ListObjectsV2); indexing version history is a later release");
/*******************************************************************************
** Tables that legitimately start empty, and where their rows come from.
*******************************************************************************/
static final Map<String, String> STARTS_EMPTY = Map.ofEntries(
Map.entry(ObjectStorageTables.CONNECTION, "written from code by the config sync: the host declares its connections"),
Map.entry(ObjectStorageTables.BUCKET, "written from code by the config sync: the host declares its buckets"),
Map.entry(ObjectStorageTables.CONNECTION_CAPABILITY, "runtime: Test connection probes the provider"),
Map.entry(ObjectStorageTables.BUCKET_CAPABILITY, "runtime: Test connection and Check configuration probe the bucket"),
Map.entry(ObjectStorageTables.FOLDER, "runtime: the index scan and uploads"),
Map.entry(ObjectStorageTables.OBJECT, "runtime: the index scan and write-through"),
Map.entry(ObjectStorageTables.OBJECT_VERSION, "not used yet: see NOT_USED_YET"),
Map.entry(ObjectStorageTables.OBJECT_REFERENCE, "runtime: the reference scan, when the host registers a reference provider"),
Map.entry(ObjectStorageTables.PROTECTION_RULE, "a rule needs a bucket, which is host-specific: an administrator adds each rule"),
Map.entry(ObjectStorageTables.OPERATION, "log: every operation the qbit performs"),
Map.entry(ObjectStorageTables.USAGE_DAILY, "runtime: the nightly job"));
/*******************************************************************************
** Tear down.
*******************************************************************************/
@AfterEach
void tearDown()
{
StorageTestSupport.tearDown();
}
/*******************************************************************************
** Every table is read or written by runtime code, or explicitly not used yet.
*******************************************************************************/
@Test
void everyTableIsUsedAtRuntime() throws Exception
{
List<String> runtimeSource;
try(Stream<Path> files = Files.walk(MAIN))
{
runtimeSource = files.filter(p -> p.toString().endsWith(".java"))
.filter(p -> !p.startsWith(MAIN.resolve("model")) && !p.startsWith(MAIN.resolve("app")))
.map(ObjectStorageTablesGuardrailTest::read).toList();
}
for(String table : ObjectStorageTables.ALL)
{
String constant = "ObjectStorageTables." + constantFor(table);
boolean used = runtimeSource.stream().anyMatch(source -> source.contains(constant));
if(NOT_USED_YET.containsKey(table))
{
assertThat(used).as(table + " is listed as not used yet, but runtime code uses it: remove it from NOT_USED_YET").isFalse();
}
else
{
assertThat(used).as(table + " must be read or written by runtime code, not only declared").isTrue();
}
}
}
/*******************************************************************************
** Every table is seeded or explained.
*******************************************************************************/
@Test
void everyTableIsSeededOrExplained() throws Exception
{
String changelog = DefaultsChangelogTest.changelogText();
for(String table : ObjectStorageTables.ALL)
{
boolean seeded = DefaultsChangelogTest.SEEDED_TABLES.contains(table);
assertThat(seeded || STARTS_EMPTY.containsKey(table)).as(table + " must be seeded by the defaults changelog or listed in STARTS_EMPTY").isTrue();
assertThat(seeded && STARTS_EMPTY.containsKey(table)).as(table + " cannot be both seeded and listed as starting empty").isFalse();
assertThat(changelog.contains("INSERT INTO " + table + " ")).as(table + " seeded iff the changelog inserts it").isEqualTo(seeded);
}
assertThat(STARTS_EMPTY.keySet()).allMatch(ObjectStorageTables.ALL::contains, "STARTS_EMPTY names only real tables");
assertThat(NOT_USED_YET.keySet()).allMatch(ObjectStorageTables.ALL::contains, "NOT_USED_YET names only real tables");
}
/*******************************************************************************
** Every table and the app have bundle help that says how rows get created,
** and the not-yet-used tables say so.
*******************************************************************************/
@Test
void everyTableHasEmptyStateHelp() throws Exception
{
QInstance instance = StorageTestSupport.setUp(new ObjectStorageQBitConfig().withBackendName(StorageTestSupport.MEMORY).withBucketScoping(false).withOpenWhenAnonymous(true).withAdminApp(true));
assertThat(ObjectStorageHelp.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120));
for(String table : ObjectStorageTables.ALL)
{
List<QHelpContent> help = instance.getTable(table).getHelpContent() == null ? null : instance.getTable(table).getHelpContent().get(ObjectStorageHelp.SLOT);
assertThat(help).as(table + " table help").isNotEmpty();
assertThat(help.get(0).getContent()).as(table + " help says how rows get created").contains("How rows get created");
if(NOT_USED_YET.containsKey(table))
{
assertThat(help.get(0).getContent()).as(table + " help says it is not used yet").contains("Not used yet");
}
}
assertThat(instance.getApp("objectStorageAdmin").getHelpContent()).containsKey(ObjectStorageHelp.SLOT);
assertThat(ObjectStorageHelp.entries().keySet()).allMatch(key -> !key.startsWith("table:") || instance.getTable(key.substring(6, key.indexOf(';'))) != null,
"help names only real tables");
}
/*******************************************************************************
** The ObjectStorageTables constant name of a table.
*******************************************************************************/
private static String constantFor(String table) throws Exception
{
for(Field field : ObjectStorageTables.class.getFields())
{
if(field.getType() == String.class && table.equals(field.get(null)) && !field.getName().startsWith("KEY_") && !field.getName().startsWith("PVS_") && !field.getName().startsWith("ALL_"))
{
return (field.getName());
}
}
throw (new IllegalStateException("no constant for " + table));
}
/*******************************************************************************
** Read a file.
*******************************************************************************/
private static String read(Path path)
{
try
{
return (Files.readString(path, StandardCharsets.UTF_8));
}
catch(Exception e)
{
throw (new IllegalStateException(e));
}
}
/*******************************************************************************
** A host's own help in the slot is kept; the bundle only fills empty slots.
*******************************************************************************/
@Test
void hostHelpIsKept() throws Exception
{
ObjectStorageQBitConfig config = new ObjectStorageQBitConfig().withBackendName(StorageTestSupport.MEMORY).withBucketScoping(false).withOpenWhenAnonymous(true).withAdminApp(true);
QInstance instance = StorageTestSupport.setUp(config);
instance.getTable(ObjectStorageTables.BUCKET).withHelpContent(ObjectStorageHelp.SLOT, new QHelpContent().withContentAsMarkdown("host text"));
instance.getApp("objectStorageAdmin").withHelpContent(ObjectStorageHelp.SLOT, new QHelpContent().withContentAsMarkdown("host app text"));
ObjectStorageHelp.apply(instance, config);
assertThat(instance.getTable(ObjectStorageTables.BUCKET).getHelpContent().get(ObjectStorageHelp.SLOT)).singleElement().extracting(QHelpContent::getContent).isEqualTo("host text");
assertThat(instance.getApp("objectStorageAdmin").getHelpContent().get(ObjectStorageHelp.SLOT)).singleElement().extracting(QHelpContent::getContent).isEqualTo("host app text");
}
}
@@ -0,0 +1,126 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.objectstorage.app;
import java.util.List;
import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig;
import com.kingsrook.qbits.objectstorage.api.ObjectHeaderPolicy;
import com.kingsrook.qbits.objectstorage.app.StorageSetupStatus.Step;
import com.kingsrook.qbits.objectstorage.config.BucketRole;
import com.kingsrook.qbits.objectstorage.config.ProviderType;
import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig;
import com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig;
import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables;
import com.kingsrook.qbits.objectstorage.testsupport.StorageTestSupport;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The Setup card: states follow what is configured, resolved the way the
** runtime resolves it (a host's own header policy beats the rows).
*******************************************************************************/
class StorageSetupStatusTest
{
/*******************************************************************************
** Tear down.
*******************************************************************************/
@AfterEach
void tearDown()
{
StorageTestSupport.tearDown();
}
/*******************************************************************************
** A bare install needs a connection and a bucket; headers use defaults; the
** protection rules are optional.
*******************************************************************************/
@Test
void bareInstall()
{
List<Step> steps = StorageSetupStatus.steps(0, 0, 0, false, 0);
assertThat(steps).extracting(Step::label).containsExactly("Connections", "Buckets", "Header rules", "Protection rules");
assertThat(steps).extracting(Step::state).containsExactly("Needs setup", "Needs setup", "Using defaults", "Optional");
assertThat(steps).allSatisfy(step -> assertThat(step.next()).isNotBlank());
}
/*******************************************************************************
** A host policy in code means the rows are not what decides, so it is never
** reported as needing setup.
*******************************************************************************/
@Test
void hostPolicyIsRespected()
{
assertThat(StorageSetupStatus.steps(1, 1, 0, true, 0).get(2)).satisfies(step ->
{
assertThat(step.state()).isEqualTo("Set up");
assertThat(step.next()).contains("host's own header policy");
});
assertThat(StorageSetupStatus.steps(1, 1, 2, false, 1)).extracting(Step::state).containsOnly("Set up");
}
/*******************************************************************************
** The live card reads the tables and the config.
*******************************************************************************/
@Test
void liveStatus() throws Exception
{
ObjectStorageQBitConfig config = new ObjectStorageQBitConfig().withBackendName(StorageTestSupport.MEMORY).withBucketScoping(false).withOpenWhenAnonymous(true).withAdminApp(true)
.withConnection(new StorageConnectionConfig("main").withProviderType(ProviderType.MINIO).withEndpoint("http://localhost:1").withRegion("us-east-1"))
.withBucket(new StorageBucketConfig("media").withConnection("main").withBucketName("media-bucket").withRole(BucketRole.PRIVATE).withIndexed(false));
StorageTestSupport.setUp(config);
assertThat(StorageSetupStatus.load()).extracting(Step::state).containsExactly("Set up", "Set up", "Using defaults", "Optional");
StorageTestSupport.insert(ObjectStorageTables.HEADER_RULE, new QRecord().withValue("name", "r").withValue("keyGlob", "*").withValue("cacheControl", "no-cache").withValue("priority", 1).withValue("enabled", true));
assertThat(StorageSetupStatus.load().get(2).state()).isEqualTo("Set up");
ObjectHeaderPolicy hostPolicy = (bucket, key, contentType) -> new com.kingsrook.qbits.objectstorage.api.ObjectHeaders();
config.withHeaderPolicy(hostPolicy);
assertThat(StorageSetupStatus.load().get(2).next()).contains("host's own");
}
/*******************************************************************************
** A bucket-scoped user with no bucket keys sees no rows, but the card reads
** the runtime config and counts rules as the system, so it never shows a
** false "needs setup".
*******************************************************************************/
@Test
void scopedUserSeesTrueStatus() throws Exception
{
ObjectStorageQBitConfig config = new ObjectStorageQBitConfig().withBackendName(StorageTestSupport.MEMORY).withBucketScoping(true).withOpenWhenAnonymous(true).withAdminApp(true)
.withConnection(new StorageConnectionConfig("main").withProviderType(ProviderType.MINIO).withEndpoint("http://localhost:1").withRegion("us-east-1"))
.withBucket(new StorageBucketConfig("media").withConnection("main").withBucketName("media-bucket").withRole(BucketRole.PRIVATE).withIndexed(false));
StorageTestSupport.setUp(config);
StorageTestSupport.insert(ObjectStorageTables.HEADER_RULE, new QRecord().withValue("name", "r").withValue("keyGlob", "*").withValue("cacheControl", "no-cache").withValue("priority", 1).withValue("enabled", true));
assertThat(StorageSetupStatus.load()).extracting(Step::state).containsExactly("Set up", "Set up", "Set up", "Optional");
}
}
@@ -0,0 +1,65 @@
package com.kingsrook.qbits.objectstorage.conformance;
import java.nio.charset.StandardCharsets;
import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig;
import com.kingsrook.qbits.objectstorage.api.DeleteRequest;
import com.kingsrook.qbits.objectstorage.api.ObjectStorage;
import com.kingsrook.qbits.objectstorage.api.PutRequest;
import com.kingsrook.qbits.objectstorage.api.Range;
import com.kingsrook.qbits.objectstorage.config.ChecksumMode;
import com.kingsrook.qbits.objectstorage.testsupport.StorageTestSupport;
import com.kingsrook.qqq.backend.core.context.QContext;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The service without a QQQ instance (command-line tools, tests): connections
** and buckets work, and nothing needs the object_storage_* tables.
*******************************************************************************/
class StandaloneTest
{
/*******************************************************************************
** Reset the runtime.
*******************************************************************************/
@AfterEach
void tearDown()
{
StorageTestSupport.tearDown();
}
@Test
void theServiceWorksWithoutAnInstanceOrTables() throws Exception
{
Assumptions.assumeTrue(Targets.dockerAvailable(), "Docker is not available");
ConformanceTarget target = Targets.minio();
ObjectStorageQBitConfig config = StorageTestSupport.config(target, ChecksumMode.WHEN_REQUIRED);
QContext.clear();
ObjectStorage.configureStandalone(config);
String key = "standalone/" + System.nanoTime() + ".txt";
ObjectStorage.bucket("files").put(PutRequest.of(key, "no tables needed".getBytes(StandardCharsets.UTF_8)).withContentType("text/plain"));
try(var in = ObjectStorage.bucket("files").get(key, Range.all()))
{
assertThat(new String(in.readAllBytes(), StandardCharsets.UTF_8)).isEqualTo("no tables needed");
}
ObjectStorage.bucket("files").delete(DeleteRequest.of(key));
assertThat(ObjectStorage.bucket("files").head(key)).isEmpty();
assertThat(ObjectStorage.bucket("files").maxUploadBytes()).isEqualTo(ObjectStorage.bucket("files").config().getMaxUploadBytes());
}
@Test
void aBadConfigIsRejectedWithEveryProblem()
{
ObjectStorageQBitConfig bad = new ObjectStorageQBitConfig();
assertThatThrownBy(() -> ObjectStorage.configureStandalone(bad)).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("backendName");
}
}
@@ -0,0 +1,364 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.objectstorage.health;
import javax.net.ssl.SSLHandshakeException;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.net.UnknownHostException;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig;
import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig;
import com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig;
import com.kingsrook.qbits.objectstorage.internal.CapabilityProbe;
import com.kingsrook.qbits.objectstorage.internal.ConfigSync;
import com.kingsrook.qbits.objectstorage.internal.ConnectionRuntime;
import com.kingsrook.qbits.objectstorage.internal.Db;
import com.kingsrook.qbits.objectstorage.internal.ObjectStorageRuntime;
import com.kingsrook.qbits.objectstorage.internal.RateLimiter;
import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables;
import com.kingsrook.qbits.objectstorage.processes.TestConnectionProcess;
import com.kingsrook.qbits.objectstorage.testsupport.StorageTestSupport;
import com.kingsrook.qqq.backend.core.health.HealthCategory;
import com.kingsrook.qqq.backend.core.health.HealthCheckInput;
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome;
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
import com.kingsrook.qqq.backend.core.health.HealthStatus;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import software.amazon.awssdk.awscore.exception.AwsErrorDetails;
import software.amazon.awssdk.http.SdkHttpResponse;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.ListObjectsV2Response;
import software.amazon.awssdk.services.s3.model.S3Exception;
import software.amazon.awssdk.services.s3.model.S3Request;
import software.amazon.awssdk.utils.builder.SdkBuilder;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Exercise the real probe using an in-memory S3 client and QQQ record store.
*******************************************************************************/
class ObjectStorageConnectionChecksTest
{
private static final String SECRET = "S3NT1NEL-secret";
private QInstance instance;
private ObjectStorageQBitConfig config;
private final List<String> calls = new ArrayList<>();
private RuntimeException failure;
private long skew;
private boolean expireAfterList;
private Instant deadline;
/*******************************************************************************
** Poison every credential reference; the fake prevents all network access.
*******************************************************************************/
@BeforeEach
void setUp() throws Exception
{
System.setProperty("os.health.secret", SECRET);
String ref = "${prop.os.health.secret}";
config = new ObjectStorageQBitConfig().withBackendName(StorageTestSupport.MEMORY)
.withAllowedSecretRefPatterns(List.of("prop.os.health.*"))
.withConnection(new StorageConnectionConfig("PRIMARY").withEndpoint("https://user:" + SECRET + "@private.example")
.withAccessKeyRef(ref).withSecretKeyRef(ref).withSessionTokenRef(ref)
.withPresignAccessKeyRef(ref).withPresignSecretKeyRef(ref).withCaBundleRef(ref).withMaxConcurrency(1))
.withBucket(new StorageBucketConfig("files").withConnection("PRIMARY").withBucketName("private-bucket").withIndexed(false));
instance = StorageTestSupport.setUp(config);
S3Client client = (S3Client) Proxy.newProxyInstance(S3Client.class.getClassLoader(), new Class<?>[] { S3Client.class }, (proxy, method, args) ->
{
if(method.getName().equals("close"))
{
return (null);
}
if(args != null && args.length == 1 && args[0] instanceof java.util.function.Consumer<?>)
{
return (InvocationHandler.invokeDefault(proxy, method, args));
}
calls.add(method.getName());
assertThat(method.getName()).matches("listObjectsV2|getBucket.*|getObjectLockConfiguration|getObjectTagging");
S3Request request = (S3Request) args[0];
Duration timeout = request.overrideConfiguration().orElseThrow().apiCallTimeout().orElseThrow();
assertThat(timeout).isPositive().isLessThanOrEqualTo(Duration.ofSeconds(20));
assertThat(request.overrideConfiguration().orElseThrow().apiCallAttemptTimeout()).contains(timeout);
if(failure != null)
{
throw (failure);
}
if(method.getName().equals("listObjectsV2"))
{
if(expireAfterList)
{
Thread.sleep(Math.max(1, Duration.between(Instant.now(), deadline).toMillis() + 10));
}
return (ListObjectsV2Response.builder().sdkHttpResponse(SdkHttpResponse.builder().statusCode(200)
.putHeader("Date", DateTimeFormatter.RFC_1123_DATE_TIME.format(Instant.now().minusSeconds(skew).atZone(ZoneOffset.UTC))).build()).build());
}
return (((SdkBuilder<?, ?>) method.getReturnType().getMethod("builder").invoke(null)).build());
});
Field field = ConnectionRuntime.class.getDeclaredField("client");
field.setAccessible(true);
field.set(ObjectStorageRuntime.connection("PRIMARY"), client);
}
/*******************************************************************************
** Restore process-wide fixtures and the poisoned property.
*******************************************************************************/
@AfterEach
void tearDown()
{
StorageTestSupport.tearDown();
System.clearProperty("os.health.secret");
}
/*******************************************************************************
** Producer registration includes the required metadata and keeps both actions.
*******************************************************************************/
@Test
void registrationAndRefresh() throws Exception
{
var registry = HealthChecksMetaData.ofOrWithNew(instance);
assertThat(registry.getSuppliers()).singleElement().satisfies(supplier ->
{
assertThat(supplier.getNamePrefix()).isEqualTo("object_storage.connection");
assertThat(supplier.getComponent()).isEqualTo("object_storage");
});
HealthCheckMetaData check = check();
assertThat(check.getName()).isEqualTo("object_storage.connection." + ConfigSync.connectionId("PRIMARY")).matches("[a-z0-9]+([._-][a-z0-9]+)*");
assertThat(check.getKind()).isEqualTo(HealthCheckKind.DEPENDENCY);
assertThat(check.getCategory()).isEqualTo(HealthCategory.STORAGE);
assertThat(check.getComponent()).isEqualTo("object_storage");
assertThat(check.getIntervalSeconds()).isEqualTo(300);
assertThat(check.getTimeoutMs()).isEqualTo(20000);
assertThat(instance.getProcess(TestConnectionProcess.READ_NAME)).isNotNull();
assertThat(instance.getProcess(TestConnectionProcess.WRITE_NAME)).isNotNull();
ConfigSync.sync();
assertThat(registry.resolveAll(instance)).hasSize(1);
config.withConnection(new StorageConnectionConfig("SECOND"));
ConfigSync.sync();
assertThat(registry.resolveAll(instance)).hasSize(2);
config.getConnections().clear();
assertThat(registry.resolveAll(instance)).isEmpty();
assertClean(check.getCheckInstance().run(input()), HealthStatus.UNKNOWN);
}
/*******************************************************************************
** Successful checks read buckets without persisting any capability diagnostics.
*******************************************************************************/
@Test
void upReadOnlyAndNoSecrets() throws Exception
{
HealthCheckOutcome outcome = run();
assertClean(outcome, HealthStatus.UP);
assertThat(outcome.getDetails().getValues()).containsEntry("bucketsProbed", 1);
assertThat(calls).contains("listObjectsV2", "getBucketVersioning", "getBucketAcl");
assertThat(Db.asSystem(() -> Db.query(ObjectStorageTables.CONNECTION_CAPABILITY))).isEmpty();
assertThat(Db.asSystem(() -> Db.query(ObjectStorageTables.BUCKET_CAPABILITY))).isEmpty();
assertThat(Db.asSystem(() -> Db.first(ObjectStorageTables.CONNECTION, "name", "PRIMARY")).getValue("lastTestedAt")).isNull();
}
/*******************************************************************************
** Clock skew uses the same threshold as the record action.
*******************************************************************************/
@Test
void clockSkew() throws Exception
{
skew = CapabilityProbe.CLOCK_SKEW_THRESHOLD_SECONDS - 2;
assertClean(run(), HealthStatus.UP);
skew = CapabilityProbe.CLOCK_SKEW_THRESHOLD_SECONDS + 10;
HealthCheckOutcome outcome = run();
assertClean(outcome, HealthStatus.DEGRADED);
assertThat((Long) outcome.getDetails().getValues().get("clockSkewSeconds")).isGreaterThan(900);
}
/*******************************************************************************
** Expected failures carry only a fixed kind even when errors contain secrets.
*******************************************************************************/
@ParameterizedTest
@ValueSource(strings = { "auth", "tls", "dns", "clock", "connection" })
void downAndNoSecrets(String kind) throws Exception
{
failure = switch(kind)
{
case "auth" -> (S3Exception) S3Exception.builder().statusCode(403).message(SECRET).build();
case "tls" -> new IllegalStateException(SECRET, new SSLHandshakeException(SECRET));
case "dns" -> new IllegalStateException(SECRET, new UnknownHostException(SECRET));
case "clock" -> (S3Exception) S3Exception.builder().statusCode(403).message(SECRET)
.awsErrorDetails(AwsErrorDetails.builder().errorCode("RequestTimeTooSkewed").build()).build();
default -> new IllegalStateException(SECRET);
};
skew = 1000;
HealthCheckOutcome outcome = run();
assertClean(outcome, HealthStatus.DOWN);
assertThat(outcome.getDetails().getValues()).containsEntry("problemKind", kind);
assertThat(Db.asSystem(() -> Db.query(ObjectStorageTables.CONNECTION_CAPABILITY))).isEmpty();
}
/*******************************************************************************
** An empty configuration cannot establish connectivity.
*******************************************************************************/
@Test
void noBuckets() throws Exception
{
config.getBuckets().clear();
assertClean(run(), HealthStatus.UNKNOWN);
assertThat(calls).isEmpty();
}
/*******************************************************************************
** Client initialization errors also cannot expose poisoned configuration.
*******************************************************************************/
@Test
@SuppressWarnings("unchecked")
void clientInitializationFailure() throws Exception
{
Field connections = ObjectStorageRuntime.class.getDeclaredField("CONNECTIONS");
connections.setAccessible(true);
((Map<String, ConnectionRuntime>) connections.get(null)).put("PRIMARY", new ConnectionRuntime(config.getConnections().getFirst())
{
/*******************************************************************************
** Simulate a credential or endpoint failure before any request is sent.
*******************************************************************************/
@Override
public synchronized S3Client client()
{
throw (new IllegalStateException(SECRET));
}
});
assertClean(run(), HealthStatus.DOWN);
assertThat(calls).isEmpty();
}
/*******************************************************************************
** Expired checks perform no I/O; a deadline reached mid-probe stops SDK calls.
*******************************************************************************/
@Test
void deadlines() throws Exception
{
assertClean(check().getCheckInstance().run(input().withDeadline(Instant.now().minusSeconds(1))), HealthStatus.DOWN);
assertThat(calls).isEmpty();
expireAfterList = true;
deadline = Instant.now().plusMillis(500);
HealthCheckOutcome outcome = check().getCheckInstance().run(input().withDeadline(deadline));
assertClean(outcome, HealthStatus.DOWN);
assertThat(outcome.getDetails().getValues()).containsEntry("problemKind", "timeout");
assertThat(calls).containsExactly("listObjectsV2");
}
/*******************************************************************************
** Rate limiting also respects the budget, both for slots and depleted tokens.
*******************************************************************************/
@Test
void rateLimiterDeadline() throws Exception
{
RateLimiter limiter = ObjectStorageRuntime.connection("PRIMARY").getRateLimiter();
try(var held = limiter.acquire())
{
assertClean(check().getCheckInstance().run(input().withDeadline(Instant.now().plusMillis(50))), HealthStatus.DOWN);
assertThat(calls).isEmpty();
}
RateLimiter onePerSecond = new RateLimiter(1, 1);
onePerSecond.acquire().close();
assertThatThrownBy(() -> onePerSecond.acquire(Instant.now().plusMillis(20))).isInstanceOf(IllegalStateException.class);
try(var permit = onePerSecond.acquire(Instant.now().plusSeconds(2)))
{
assertThat(permit).isNotNull();
}
}
/*******************************************************************************
** Fetch checks through the registry, exercising the supplier code reference.
*******************************************************************************/
private HealthCheckMetaData check() throws Exception
{
return (HealthChecksMetaData.ofOrWithNew(instance).resolveAll(instance).getFirst());
}
/*******************************************************************************
** Default bounded input.
*******************************************************************************/
private HealthCheckInput input()
{
return (new HealthCheckInput().withQInstance(instance).withDeadline(Instant.now().plusSeconds(20)));
}
/*******************************************************************************
** Run the registered check.
*******************************************************************************/
private HealthCheckOutcome run() throws Exception
{
return (check().getCheckInstance().run(input()));
}
/*******************************************************************************
** HealthRunner.sanitize is package-private; enforce cleanliness on raw output.
*******************************************************************************/
private void assertClean(HealthCheckOutcome outcome, HealthStatus expected)
{
assertThat(outcome.getStatus()).isEqualTo(expected);
assertThat(outcome.getMessage() + outcome.getDetails().toJson()).doesNotContain(SECRET, "private.example", "private-bucket");
assertThat(outcome.getMessage()).doesNotContain("\n", "\r");
}
}
@@ -473,6 +473,28 @@ class MinioIndexTest
@Test
void keyFolderReferencesCoverEveryObjectOfTheSet() throws Exception
{
rawPut(target.indexBucket, "set/one/original.jpg", 10);
rawPut(target.indexBucket, "set/one/thumbnail.jpg", 2);
rawPut(target.indexBucket, "set/two/original.jpg", 10);
ObjectIndex.scan("indexed");
StorageTestSupport.setUp(StorageTestSupport.config(target, com.kingsrook.qbits.objectstorage.config.ChecksumMode.WHEN_REQUIRED)
.withReferenceProvider(FieldReferenceProvider.keyFolder("indexed", "media", "storageKey")), instance ->
instance.addTable(new QTableMetaData().withName("media").withBackendName(StorageTestSupport.MEMORY).withPrimaryKeyField("id")
.withField(new QFieldMetaData("id", QFieldType.INTEGER)).withField(new QFieldMetaData("storageKey", QFieldType.STRING))));
ObjectIndex.scan("indexed");
StorageTestSupport.insert("media", new QRecord().withValue("storageKey", "set/one/original.jpg"), new QRecord().withValue("storageKey", "bare-name.jpg"));
ReferenceScan.Result result = ReferenceScan.run("indexed");
assertThat(result.references()).as("a key with no folder is not a reference").isEqualTo(1);
assertThat(rows("indexed").get("set/one/original.jpg").getValueInteger("referenceCount")).isEqualTo(1);
assertThat(rows("indexed").get("set/one/thumbnail.jpg").getValueInteger("referenceCount")).as("the rendition is covered").isEqualTo(1);
assertThat(rows("indexed").get("set/two/original.jpg").getValueInteger("referenceCount")).isZero();
}
@Test
void backupBucketsAreNeverReferenceScanned() throws Exception
{
@@ -552,4 +574,40 @@ class MinioIndexTest
.contains("Usage trend").contains("collecting since")
.contains("Connections").contains("Never tested");
}
/*******************************************************************************
** Every link the home emits is a /app/<table>[...] route that the Next frontend
** resolves, never an /<appName>/<table> path, with data and without.
*******************************************************************************/
@Test
void everyHomeLinkUsesTheAppRoute() throws Exception
{
String prefix = com.kingsrook.qbits.objectstorage.app.StorageWidgets.tablePathPrefix();
assertThat(prefix).isEqualTo("/app");
java.util.List<String> hrefs = new java.util.ArrayList<>(hrefs(com.kingsrook.qbits.objectstorage.app.StorageHome.render(prefix, Instant.now())));
rawPut(target.indexBucket, "media/1/a.jpg", 2048);
rawPut(target.indexBucket, "media/9/orphan.jpg", 1024);
ObjectIndex.scan("indexed");
ObjectIndex.headBacklog("indexed", 10);
ReferenceScan.run("indexed");
ObjectStorage.bucket("indexed").put(PutRequest.of("media/1/c.jpg", new byte[3]));
hrefs.addAll(hrefs(com.kingsrook.qbits.objectstorage.app.StorageHome.render(prefix, Instant.now())));
assertThat(hrefs).isNotEmpty().allSatisfy(href -> assertThat(href).startsWith("/app/"));
assertThat(hrefs).noneMatch(href -> href.contains("objectStorageAdmin"));
}
private static java.util.List<String> hrefs(String html)
{
java.util.List<String> hrefs = new java.util.ArrayList<>();
java.util.regex.Matcher m = java.util.regex.Pattern.compile("href=\"([^\"]*)\"").matcher(html);
while(m.find())
{
hrefs.add(m.group(1).replace("&amp;", "&"));
}
return (hrefs);
}
}
@@ -23,10 +23,14 @@ package com.kingsrook.qbits.objectstorage.index;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig;
import com.kingsrook.qbits.objectstorage.api.FieldReferenceProvider;
import com.kingsrook.qbits.objectstorage.api.ObjectStorage;
@@ -46,6 +50,11 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
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.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
@@ -270,4 +279,203 @@ class PostgresIndexTest
.withSecurityKeyValue(ObjectStorageTables.KEY_TYPE_BUCKET, ConfigSync.bucketId("indexed")));
assertThat(new QueryAction().execute(new QueryInput(ObjectStorageTables.OBJECT)).getRecords()).extracting(r -> r.getValueString("objectKey")).containsExactly("d/ref.txt");
}
/** Relations (child.column -> parent) that are deliberately not database foreign keys,
** each with its reason. Empty: every join and table possible value is enforced. */
private static final Map<String, String> FK_EXEMPTIONS = Map.of();
/*******************************************************************************
** The guardrail: every join and table possible-value field between the qbit's
** tables is a foreign key in the generated schema (or exempt, with a reason),
** with the declared ON DELETE rule, and every foreign key column leads an index.
** A new relation without a foreign key fails here.
*******************************************************************************/
@Test
void everyRelationIsAForeignKeyWithAnIndex() throws Exception
{
Set<String> expected = new TreeSet<>();
for(QJoinMetaData join : QContext.getQInstance().getJoins().values())
{
if(ObjectStorageTables.ALL.contains(join.getLeftTable()) && ObjectStorageTables.ALL.contains(join.getRightTable()) && join.getType() == JoinType.ONE_TO_MANY)
{
for(JoinOn on : join.getJoinOns())
{
expected.add(join.getRightTable() + "." + ObjectStorageSchemaColumns.of(on.getRightField()) + " -> " + join.getLeftTable());
}
}
}
for(String tableName : ObjectStorageTables.ALL)
{
for(QFieldMetaData field : QContext.getQInstance().getTable(tableName).getFields().values())
{
QPossibleValueSource pvs = field.getPossibleValueSourceName() == null ? null : QContext.getQInstance().getPossibleValueSource(field.getPossibleValueSourceName());
if(pvs != null && pvs.getType() == QPossibleValueSourceType.TABLE && ObjectStorageTables.ALL.contains(pvs.getTableName()))
{
expected.add(tableName + "." + ObjectStorageSchemaColumns.of(field.getName()) + " -> " + pvs.getTableName());
}
}
}
assertThat(expected).as("the qbit declares relations").hasSizeGreaterThan(10);
Map<String, String> actual = new TreeMap<>();
List<String> noIndex = new ArrayList<>();
try(Connection c = DriverManager.getConnection(postgres.getJdbcUrl(), "qqq", "qqq"); Statement st = c.createStatement();
ResultSet rs = st.executeQuery("""
SELECT cl.relname AS child, a.attname AS col, pcl.relname AS parent, con.confdeltype AS rule,
EXISTS (SELECT 1 FROM pg_index i WHERE i.indrelid = con.conrelid AND i.indkey[0] = con.conkey[1]) AS indexed
FROM pg_constraint con
JOIN pg_class cl ON cl.oid = con.conrelid
JOIN pg_class pcl ON pcl.oid = con.confrelid
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = con.conkey[1]
WHERE con.contype = 'f'"""))
{
while(rs.next())
{
String key = rs.getString("child") + "." + rs.getString("col") + " -> " + rs.getString("parent");
actual.put(key, rs.getString("rule"));
if(!rs.getBoolean("indexed"))
{
noIndex.add(key);
}
}
}
List<String> missing = new ArrayList<>();
for(String relation : expected)
{
if(!actual.containsKey(relation) && !FK_EXEMPTIONS.containsKey(relation))
{
missing.add(relation);
}
}
assertThat(missing).as("relations without a database foreign key (add one to ObjectStorageSchema, or an exemption with a reason)").isEmpty();
assertThat(noIndex).as("foreign key columns that do not lead an index").isEmpty();
Map<String, String> rules = Map.of("NO ACTION", "a", "RESTRICT", "r", "CASCADE", "c", "SET NULL", "n");
for(ObjectStorageSchema.ForeignKey fk : ObjectStorageSchema.foreignKeys())
{
String key = fk.table() + "." + ObjectStorageSchemaColumns.of(fk.field()) + " -> " + fk.parent();
assertThat(actual.get(key)).as(fk.name() + " ON DELETE").isEqualTo(rules.get(fk.onDelete()));
assertThat(fk.name().length()).as(fk.name()).isLessThanOrEqualTo(63);
}
}
/*******************************************************************************
** Upgrading a schema created before the foreign keys: orphans are recorded in
** qqq_fk_quarantine and cleared (nullable) or deleted (required), the keys are
** added, and running the statements again changes nothing.
*******************************************************************************/
@Test
void foreignKeyUpgradeQuarantinesOrphansAndIsIdempotent() throws Exception
{
Integer bucketId = ConfigSync.bucketId("indexed");
try(Connection c = DriverManager.getConnection(postgres.getJdbcUrl(), "qqq", "qqq"); Statement st = c.createStatement())
{
for(ObjectStorageSchema.ForeignKey fk : ObjectStorageSchema.foreignKeys())
{
st.execute("ALTER TABLE " + fk.table() + " DROP CONSTRAINT " + fk.name());
}
st.execute("INSERT INTO object_storage_object (bucket_id, object_key, folder_id) VALUES (" + bucketId + ", 'kept.txt', 424242)");
st.execute("INSERT INTO object_storage_protection_rule (bucket_id, prefix_glob, mode) VALUES (434343, 'x/*', 'NO_DELETE')");
st.execute("INSERT INTO object_storage_protection_rule (bucket_id, prefix_glob, mode) VALUES (" + bucketId + ", 'y/*', 'NO_DELETE')");
ObjectStorageQBitConfig forSchema = new ObjectStorageQBitConfig().withBackendName("rdbms");
for(int pass = 0; pass < 2; pass++)
{
for(String sql : ObjectStorageSchema.postgresStatements(forSchema))
{
st.execute(sql);
}
}
try(ResultSet rs = st.executeQuery("SELECT table_name, column_name, missing_parent_id, action, row_data FROM qqq_fk_quarantine ORDER BY id"))
{
List<String> rows = new ArrayList<>();
while(rs.next())
{
rows.add(rs.getString(1) + "." + rs.getString(2) + "=" + rs.getString(3) + " " + rs.getString(4) + (rs.getString(5).contains("x/*") ? " with row" : ""));
}
assertThat(rows).as("recorded once, although the statements ran twice")
.containsExactly("object_storage_protection_rule.bucket_id=434343 DELETED with row", "object_storage_object.folder_id=424242 NULLED");
}
try(ResultSet rs = st.executeQuery("SELECT folder_id FROM object_storage_object WHERE object_key = 'kept.txt'"))
{
assertThat(rs.next()).isTrue();
assertThat(rs.getObject(1)).isNull();
}
try(ResultSet rs = st.executeQuery("SELECT prefix_glob FROM object_storage_protection_rule ORDER BY id"))
{
assertThat(rs.next()).isTrue();
assertThat(rs.getString(1)).isEqualTo("y/*");
assertThat(rs.next()).isFalse();
}
try(ResultSet rs = st.executeQuery("SELECT COUNT(*) FROM pg_constraint WHERE contype = 'f' AND conname LIKE 'fk_object_storage_%'"))
{
rs.next();
assertThat(rs.getInt(1)).isEqualTo(ObjectStorageSchema.foreignKeys().size());
}
}
}
/*******************************************************************************
** The delete paths the qbit uses still work with the keys: removing an object
** row (scan removal, hard-delete write-through) takes its versions and leaves
** its references pointing nowhere, and a bucket row with an audit history or
** rules cannot be deleted.
*******************************************************************************/
@Test
void deletesFollowTheForeignKeyRules() throws Exception
{
rawPut("f/one.txt", 3);
ObjectIndex.scan("indexed");
Integer bucketId = ConfigSync.bucketId("indexed");
Integer objectId = Db.first(ObjectStorageTables.OBJECT, "objectKey", "f/one.txt").getValueInteger("id");
try(Connection c = DriverManager.getConnection(postgres.getJdbcUrl(), "qqq", "qqq"); Statement st = c.createStatement())
{
st.execute("INSERT INTO object_storage_object_version (bucket_id, object_id, object_key) VALUES (" + bucketId + ", " + objectId + ", 'f/one.txt')");
}
Db.insert("media", List.of(new QRecord().withValue("storageKey", "f/one.txt")));
ReferenceScan.run("indexed");
assertThat(Db.first(ObjectStorageTables.OBJECT_REFERENCE, "keyOrPrefix", "f/one.txt").getValueInteger("objectId")).isEqualTo(objectId);
try(S3Client raw = target.rawClient())
{
raw.deleteObject(DeleteObjectRequest.builder().bucket(target.indexBucket).key("f/one.txt").build());
}
assertThat(ObjectIndex.scan("indexed").status()).isEqualTo("OK");
assertThat(Db.first(ObjectStorageTables.OBJECT, "objectKey", "f/one.txt")).isNull();
assertThat(Db.query(ObjectStorageTables.OBJECT_VERSION, "objectId", objectId)).as("versions cascade").isEmpty();
assertThat(Db.first(ObjectStorageTables.OBJECT_REFERENCE, "keyOrPrefix", "f/one.txt").getValueInteger("objectId")).as("reference set null").isNull();
try(Connection c = DriverManager.getConnection(postgres.getJdbcUrl(), "qqq", "qqq"); Statement st = c.createStatement())
{
st.execute("INSERT INTO object_storage_operation (bucket_id, bucket_alias, action) VALUES (" + bucketId + ", 'indexed', 'PUT')");
org.assertj.core.api.Assertions.assertThatThrownBy(() -> st.execute("DELETE FROM object_storage_bucket WHERE id = " + bucketId))
.hasMessageContaining("fk_object_storage_operation_bucket_id");
}
}
/*******************************************************************************
** Column names as the schema derives them.
*******************************************************************************/
private static final class ObjectStorageSchemaColumns
{
/*******************************************************************************
** The snake_case column of a field.
*******************************************************************************/
static String of(String field)
{
return (com.kingsrook.qqq.backend.core.instances.QInstanceEnricher.inferBackendName(field));
}
}
}
@@ -0,0 +1,146 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.objectstorage.internal;
import java.net.URL;
import java.time.Duration;
import java.util.List;
import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig;
import com.kingsrook.qbits.objectstorage.api.ObjectStorage;
import com.kingsrook.qbits.objectstorage.api.PresignGet;
import com.kingsrook.qbits.objectstorage.api.PresignPut;
import com.kingsrook.qbits.objectstorage.config.BucketRole;
import com.kingsrook.qbits.objectstorage.config.ProviderType;
import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig;
import com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig;
import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables;
import com.kingsrook.qbits.objectstorage.processes.ActionSupport;
import com.kingsrook.qbits.objectstorage.testsupport.StorageTestSupport;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The presign limits on the bucket row (Settings on object_storage_bucket) are
** read at runtime, the way ConfigSync.withOverrides reads a connection row:
** editing the row changes how long presigned links last, and the identity
** fields stay owned by code.
*******************************************************************************/
class BucketRowOverridesTest
{
/*******************************************************************************
** Tear down.
*******************************************************************************/
@AfterEach
void tearDown()
{
StorageTestSupport.tearDown();
}
/*******************************************************************************
** A config whose buckets never touch a real store (presigning is offline).
*******************************************************************************/
private static ObjectStorageQBitConfig config()
{
System.setProperty("os.unit.ak", "ak");
System.setProperty("os.unit.sk", "sk");
return (new ObjectStorageQBitConfig().withBackendName(StorageTestSupport.MEMORY).withBucketScoping(false).withOpenWhenAnonymous(true).withAdminApp(true)
.withAllowedSecretRefPatterns(List.of("prop.os.unit.*"))
.withConnection(new StorageConnectionConfig("main").withProviderType(ProviderType.MINIO).withEndpoint("http://localhost:1").withRegion("us-east-1")
.withAccessKeyRef("${prop.os.unit.ak}").withSecretKeyRef("${prop.os.unit.sk}"))
.withBucket(new StorageBucketConfig("media").withConnection("main").withBucketName("media-bucket").withRole(BucketRole.PRIVATE).withIndexed(false)
.withPublicBaseUrl("https://cdn.example.com/media/")));
}
/*******************************************************************************
** The presigned URL's lifetime in seconds.
*******************************************************************************/
private static int expires(URL url)
{
return (Integer.parseInt(url.getQuery().replaceAll(".*X-Amz-Expires=(\\d+).*", "$1")));
}
/*******************************************************************************
** The row can only LOWER the presign limits: the effective limit is the
** smaller of the code config and the row. A larger, zero or null row value
** leaves the code limit, the shared config object is never modified, and the
** download-link action and the preview URL read it too.
*******************************************************************************/
@Test
void rowCanOnlyLowerPresignLimits() throws Exception
{
StorageTestSupport.setUp(config());
Integer bucketId = ConfigSync.bucketId("media");
Duration half = Duration.ofMinutes(30);
assertThat(expires(ObjectStorage.bucket("media").presignGet(PresignGet.of("a.txt").ttl(half)))).as("code limit is an hour").isEqualTo(1800);
setRow(bucketId, 60, 120);
assertThat(expires(ObjectStorage.bucket("media").presignGet(PresignGet.of("a.txt").ttl(half)))).as("the row lowers the cap").isEqualTo(60);
assertThat(expires(ObjectStorage.bucket("media").presignPut(PresignPut.of("a.txt").ttl(Duration.ofMinutes(10))))).isEqualTo(120);
assertThat(ConfigSync.effectiveMaxPresignGetSeconds(ActionSupport.config("media"))).as("the download-link action reads it too").isEqualTo(60);
assertThat(expires(ObjectStorage.bucket("media").previewUrl("a.txt"))).as("previews are capped as well").isEqualTo(60);
assertThat(ObjectStorageRuntime.bucketConfig("media").getMaxPresignGetSeconds()).as("the shared config is not mutated").isEqualTo(3600);
setRow(bucketId, 999_999, 999_999);
assertThat(expires(ObjectStorage.bucket("media").presignGet(PresignGet.of("a.txt").ttl(Duration.ofHours(2))))).as("the row cannot raise the code limit").isEqualTo(3600);
setRow(bucketId, 0, null);
assertThat(expires(ObjectStorage.bucket("media").presignGet(PresignGet.of("a.txt").ttl(half)))).as("zero: the code value applies again").isEqualTo(1800);
assertThat(ConfigSync.effectiveMaxPresignPutSeconds(ActionSupport.config("media"))).as("null: the code value applies again").isEqualTo(900);
}
/*******************************************************************************
** Set the row limits and drop the short-lived cache.
*******************************************************************************/
private static void setRow(Integer bucketId, Integer get, Integer put) throws Exception
{
Db.update(ObjectStorageTables.BUCKET, List.of(new QRecord().withValue("id", bucketId).withValue("maxPresignGetSeconds", get).withValue("maxPresignPutSeconds", put)));
ConfigSync.clearRowLimits();
}
/*******************************************************************************
** Identity fields stay code-owned: a tampered public base URL on the row is
** rewritten from code and never read back as an override.
*******************************************************************************/
@Test
void identityStaysWithCode() throws Exception
{
StorageTestSupport.setUp(config());
Integer bucketId = ConfigSync.bucketId("media");
Db.update(ObjectStorageTables.BUCKET, List.of(new QRecord().withValue("id", bucketId).withValue("publicBaseUrl", "https://evil.example.com/").withValue("trashPrefix", "x/")));
assertThat(ObjectStorageRuntime.bucketConfig("media").getPublicBaseUrl()).isEqualTo("https://cdn.example.com/media/");
ConfigSync.sync();
assertThat(Db.first(ObjectStorageTables.BUCKET, "alias", "media").getValueString("publicBaseUrl")).isEqualTo("https://cdn.example.com/media/");
}
}
@@ -0,0 +1,334 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.objectstorage.internal;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.objectstorage.config.AddressingStyle;
import com.kingsrook.qbits.objectstorage.config.ChecksumMode;
import com.kingsrook.qbits.objectstorage.config.CredentialsMode;
import com.kingsrook.qbits.objectstorage.config.ProviderType;
import com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig;
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3BackendMetaData;
import com.kingsrook.qqq.backend.module.filesystem.s3.model.metadata.S3ChecksumMode;
import com.sun.net.httpserver.Headers;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.core.checksums.RequestChecksumCalculation;
import software.amazon.awssdk.core.checksums.ResponseChecksumValidation;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Mapping and lifecycle checks plus real SDK signing against a loopback server.
*******************************************************************************/
class ConnectionRuntimeTest
{
/*******************************************************************************
** Core reads ${file.PATH} only inside its allowed roots; reference() points
** them at each test's temporary directory.
*******************************************************************************/
@AfterEach
void afterEach()
{
System.clearProperty("qqq.fileReference.roots");
}
/*******************************************************************************
** Every core-supported setting is mapped after resolving references. Session
** tokens and presign settings are exercised below.
*******************************************************************************/
@Test
void testBackendMetaDataCarriesEverySetting(@TempDir Path dir) throws Exception
{
StorageConnectionConfig config = new StorageConnectionConfig("mapping")
.withEndpoint(reference(dir, "endpoint", "https://storage.example.test"))
.withRegion(reference(dir, "region", "eu-west-1"))
.withAddressingStyle(AddressingStyle.VIRTUAL_HOST)
.withChecksumMode(ChecksumMode.WHEN_SUPPORTED)
.withTlsVerify(false)
.withCaBundleRef(reference(dir, "ca", "test-pem-bundle"))
.withCredentialsMode(CredentialsMode.STATIC_REFS)
.withAccessKeyRef(reference(dir, "access", "test-access"))
.withSecretKeyRef(reference(dir, "secret", "test-secret"))
.withConnectTimeoutMs(1234)
.withReadTimeoutMs(5678);
S3BackendMetaData backend = new ConnectionRuntime(config).backendMetaData();
assertThat(backend.getEndpoint()).isEqualTo("https://storage.example.test");
assertThat(backend.getRegion()).isEqualTo("eu-west-1");
assertThat(backend.getPathStyleAccess()).isFalse();
assertThat(backend.getChecksumMode()).isEqualTo(S3ChecksumMode.WHEN_SUPPORTED);
assertThat(backend.getTlsVerify()).isFalse();
assertThat(backend.getCaCertificatesPem()).isEqualTo("test-pem-bundle");
assertThat(backend.getAccessKey()).isEqualTo("test-access");
assertThat(backend.getSecretKey()).isEqualTo("test-secret");
assertThat(backend.getConnectTimeoutMillis()).isEqualTo(1234);
assertThat(backend.getSocketTimeoutMillis()).isEqualTo(5678);
assertThat(backend.getBucketName()).isNull();
}
/*******************************************************************************
** Provider defaults remain the qbit's defaults, including its 5s/60s timeouts.
*******************************************************************************/
@Test
void testBackendDefaults()
{
S3BackendMetaData compatible = new ConnectionRuntime(new StorageConnectionConfig("compatible")
.withEndpoint("http://localhost:9000")).backendMetaData();
assertThat(compatible.getRegion()).isEqualTo("us-east-1");
assertThat(compatible.getPathStyleAccess()).isTrue();
assertThat(compatible.getChecksumMode()).isEqualTo(S3ChecksumMode.WHEN_REQUIRED);
assertThat(compatible.getTlsVerify()).isTrue();
assertThat(compatible.getCaCertificatesPem()).isNull();
assertThat(compatible.getConnectTimeoutMillis()).isEqualTo(5000);
assertThat(compatible.getSocketTimeoutMillis()).isEqualTo(60000);
S3BackendMetaData aws = new ConnectionRuntime(new StorageConnectionConfig("aws").withProviderType(ProviderType.AWS_S3)).backendMetaData();
assertThat(aws.getEndpoint()).isNull();
assertThat(aws.getRegion()).isNull();
assertThat(aws.getPathStyleAccess()).isFalse();
assertThat(aws.getChecksumMode()).isEqualTo(S3ChecksumMode.WHEN_SUPPORTED);
}
/*******************************************************************************
** Default-chain and web-identity modes must ignore configured static refs.
*******************************************************************************/
@Test
void testChainCredentialsIgnoreStaticRefs(@TempDir Path dir) throws Exception
{
for(CredentialsMode mode : List.of(CredentialsMode.DEFAULT_CHAIN, CredentialsMode.WEB_IDENTITY))
{
ConnectionRuntime runtime = new ConnectionRuntime(config(dir).withCredentialsMode(mode)
.withSessionTokenRef(reference(dir, "token", "test-session")));
try
{
assertThat(runtime.backendMetaData().getAccessKey()).isNull();
assertThat(runtime.backendMetaData().getSecretKey()).isNull();
assertThat(runtime.client().serviceClientConfiguration().credentialsProvider()).isInstanceOf(DefaultCredentialsProvider.class);
}
finally
{
runtime.close();
}
}
}
/*******************************************************************************
** Missing static refs preserve the prior fallback to the SDK default chain.
*******************************************************************************/
@Test
void testMissingStaticCredentialsFallBack(@TempDir Path dir) throws Exception
{
ConnectionRuntime runtime = new ConnectionRuntime(config(dir).withSecretKeyRef(null));
try
{
assertThat(runtime.client().serviceClientConfiguration().credentialsProvider()).isInstanceOf(DefaultCredentialsProvider.class);
}
finally
{
runtime.close();
}
}
/*******************************************************************************
** Clients belong to a runtime, never core's shared cache; close rebuilds from
** changed settings and does not affect another connection with equal settings.
*******************************************************************************/
@Test
void testClientOwnershipAndRebuild(@TempDir Path dir) throws Exception
{
StorageConnectionConfig config = config(dir);
ConnectionRuntime runtime = new ConnectionRuntime(config);
ConnectionRuntime sibling = new ConnectionRuntime(config);
try
{
S3Client first = runtime.client();
assertThat(runtime.client()).isSameAs(first);
assertThat(sibling.client()).isNotSameAs(first);
assertThat(first.serviceClientConfiguration().endpointOverride()).contains(URI.create("http://localhost:9000"));
assertThat(first.serviceClientConfiguration().region()).isEqualTo(Region.US_EAST_1);
assertThat(((AwsCredentialsProvider) first.serviceClientConfiguration().credentialsProvider()).resolveCredentials().accessKeyId()).isEqualTo("test-access");
assertThat(first.serviceClientConfiguration().requestChecksumCalculation()).isEqualTo(RequestChecksumCalculation.WHEN_REQUIRED);
assertThat(first.serviceClientConfiguration().responseChecksumValidation()).isEqualTo(ResponseChecksumValidation.WHEN_REQUIRED);
runtime.close();
config.withRegion("eu-west-1").withChecksumMode(ChecksumMode.WHEN_SUPPORTED);
assertThat(runtime.client()).isNotSameAs(first);
assertThat(runtime.client().serviceClientConfiguration().region()).isEqualTo(Region.EU_WEST_1);
assertThat(runtime.client().serviceClientConfiguration().requestChecksumCalculation()).isEqualTo(RequestChecksumCalculation.WHEN_SUPPORTED);
assertThat(sibling.client().serviceClientConfiguration().region()).isEqualTo(Region.US_EAST_1);
}
finally
{
runtime.close();
sibling.close();
}
}
/*******************************************************************************
** Session credentials (core sessionToken) sign streaming reads/writes and
** paginator requests; request overrides are untouched.
*******************************************************************************/
@Test
void testSessionSigningAndRequestOverrides(@TempDir Path dir) throws Exception
{
List<Headers> requests = new ArrayList<>();
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", exchange ->
{
requests.add(exchange.getRequestHeaders());
exchange.getRequestBody().readAllBytes();
byte[] response = (exchange.getRequestURI().getQuery() != null && exchange.getRequestURI().getQuery().contains("list-type")
? "<ListBucketResult><IsTruncated>false</IsTruncated></ListBucketResult>" : "data").getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, response.length);
exchange.getResponseBody().write(response);
exchange.close();
});
server.start();
ConnectionRuntime runtime = new ConnectionRuntime(config(dir)
.withEndpoint("http://127.0.0.1:" + server.getAddress().getPort())
.withSessionTokenRef(reference(dir, "token", "test-session")));
try
{
GetObjectRequest request = GetObjectRequest.builder().bucket("bucket").key("key")
.overrideConfiguration(o -> o.putHeader("x-test-preserved", "yes")).build();
assertThat(runtime.client().getObjectAsBytes(request).asUtf8String()).isEqualTo("data");
runtime.client().putObject(o -> o.bucket("bucket").key("key"), RequestBody.fromString("upload"));
assertThat(runtime.client().listObjectsV2Paginator(ListObjectsV2Request.builder().bucket("bucket").build()).stream().count()).isEqualTo(1);
assertThat(requests).hasSize(3);
for(Headers headers : requests)
{
assertThat(headers.getFirst("x-amz-security-token")).isEqualTo("test-session");
assertThat(headers.getFirst("Authorization")).contains("Credential=test-access/");
}
assertThat(requests.getFirst().getFirst("x-test-preserved")).isEqualTo("yes");
assertThat(request.overrideConfiguration().orElseThrow().credentialsProvider()).isEmpty();
}
finally
{
runtime.close();
server.stop(0);
}
}
/*******************************************************************************
** Presigning keeps its separate public endpoint/key and session-token support.
*******************************************************************************/
@Test
void testPresignerRemainsLocal(@TempDir Path dir) throws Exception
{
ConnectionRuntime runtime = new ConnectionRuntime(config(dir)
.withPresignEndpoint("https://public.example.test")
.withPresignAccessKeyRef(reference(dir, "presign-access", "presign-access"))
.withPresignSecretKeyRef(reference(dir, "presign-secret", "presign-secret"))
.withSessionTokenRef(reference(dir, "token", "test-session")));
try
{
assertThat(runtime.presigner()).isSameAs(runtime.presigner());
String url = runtime.presigner().presignGetObject(p -> p.signatureDuration(Duration.ofMinutes(5))
.getObjectRequest(g -> g.bucket("bucket").key("key"))).url().toString();
assertThat(url).startsWith("https://public.example.test/bucket/key?")
.contains("X-Amz-Credential=presign-access%2F", "X-Amz-Security-Token=test-session");
assertThat(runtime.presignCredentials()).containsExactly("presign-access", "presign-secret", "us-east-1", "https://public.example.test");
}
finally
{
runtime.close();
}
}
/*******************************************************************************
** Core handles invalid CA bundles and the explicit TLS-verification opt-out.
*******************************************************************************/
@Test
void testTlsSettingsReachCore(@TempDir Path dir) throws Exception
{
StorageConnectionConfig config = config(dir).withCaBundleRef(reference(dir, "ca", "invalid-pem"));
ConnectionRuntime runtime = new ConnectionRuntime(config);
assertThatThrownBy(runtime::client).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("caCertificatesPem");
config.withTlsVerify(false);
try
{
assertThat(runtime.client()).isNotNull();
}
finally
{
runtime.close();
}
}
/*******************************************************************************
** Deterministic test connection without any external credentials or service.
*******************************************************************************/
private static StorageConnectionConfig config(Path dir) throws Exception
{
return (new StorageConnectionConfig("test").withEndpoint("http://localhost:9000")
.withAccessKeyRef(reference(dir, "access", "test-access"))
.withSecretKeyRef(reference(dir, "secret", "test-secret")));
}
/*******************************************************************************
** Mounted-file reference for test data, scoped to JUnit's temporary directory.
*******************************************************************************/
private static String reference(Path dir, String name, String value) throws Exception
{
System.setProperty("qqq.fileReference.roots", dir.toString());
Path file = dir.resolve(name);
Files.writeString(file, value);
return ("${file." + file + "}");
}
}
@@ -0,0 +1,233 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.objectstorage.internal;
import java.io.Serializable;
import java.util.List;
import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig;
import com.kingsrook.qbits.objectstorage.api.StorageCaller;
import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables;
import com.kingsrook.qbits.objectstorage.permissions.ObjectStoragePermissions;
import com.kingsrook.qbits.objectstorage.testsupport.StorageTestSupport;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Db.asSystem runs work as the core system user. Token-based authentication
** modules (OAuth2, Auth0) accept a QSystemUserSession without a token and
** reject any other tokenless session, so the class matters, not just the
** permissions. The system session is not what lets the service write its own
** tables, though: each Db write carries core's per-call permission bypass, and
** a write that does not is refused to every session.
*******************************************************************************/
class DbSystemSessionTest
{
private static final String REFUSAL = "This storage table changes only through storage actions.";
/*******************************************************************************
** Reset the runtime and context after each test.
*******************************************************************************/
@AfterEach
void tearDown()
{
StorageTestSupport.tearDown();
}
/*******************************************************************************
** The work sees a QSystemUserSession with every all-access key, and the
** caller's session is restored afterwards.
*******************************************************************************/
@Test
void testAsSystemUsesCoreSystemUserSession() throws Exception
{
ObjectStorageQBitConfig config = new ObjectStorageQBitConfig().withBackendName(StorageTestSupport.MEMORY).withBucketScoping(false);
StorageTestSupport.setUp(config, instance -> instance.addSecurityKeyType(new QSecurityKeyType().withName("tenant").withAllAccessKeyName("tenantAllAccess")));
QSession caller = QContext.getQSession();
QSession inside = Db.asSystem(QContext::getQSession);
assertThat(inside).isInstanceOf(QSystemUserSession.class);
assertThat(inside.hasPermission("anything")).isTrue();
assertThat(inside.hasSecurityKeyValue("tenantAllAccess", true)).isTrue();
assertThat(QContext.getQSession()).isSameAs(caller);
}
/*******************************************************************************
** A host that enforces permissions inside the core actions
** (QInstance.enforcePermissionsInCoreActions) still gets its operation log
** row, and the row names the person who acted: the caller is captured before
** the write moves to the system user. The other tables the service keeps
** (here the object index) are written, changed and removed the same way.
*******************************************************************************/
@Test
void testServiceWritesItsOwnTablesWhenCoreActionsEnforcePermissions() throws Exception
{
enforcingInstance();
QContext.setQSession(new QSession().withUser(new QUser().withIdReference("pat@example.com").withFullName("Pat Example")).withPermissions(ObjectStoragePermissions.WRITE));
new OperationLog.Entry(StorageCaller.user(), "PUT", "files").key("stored/by/the/service").write();
assertThat(operations()).as("the operation log row of a stored object").singleElement().satisfies(row ->
{
assertThat(row.getValueString("objectKey")).isEqualTo("stored/by/the/service");
assertThat(row.getValueString("actor")).as("the person who acted, not the system user that wrote the row").isEqualTo("pat@example.com");
});
assertThat(OperationLog.failedWriteCount()).isZero();
Serializable id = Db.asSystem(() -> Db.insert(ObjectStorageTables.OBJECT, new QRecord().withValue("objectKey", "a/b.txt").withValue("size", 1L))).getValue("id");
Db.asSystem(() -> Db.update(ObjectStorageTables.OBJECT, List.of(new QRecord().withValue("id", id).withValue("size", 2L))));
assertThat(Db.asSystem(() -> Db.first(ObjectStorageTables.OBJECT, "id", id)).getValueLong("size")).isEqualTo(2L);
Db.asSystem(() ->
{
Db.delete(ObjectStorageTables.OBJECT, List.of(id));
return (null);
});
assertThat(Db.asSystem(() -> Db.query(ObjectStorageTables.OBJECT))).isEmpty();
}
/*******************************************************************************
** Being the system user is not enough to write the operation log. Server code
** outside the service's Db (a host, another qbit) that runs as the core system
** user is refused the insert, the update and the delete, also when it borrows
** Db.asSystem for its session.
*******************************************************************************/
@Test
void testSystemSessionOutsideTheServiceCannotWriteTheOperationLog() throws Exception
{
QInstance instance = enforcingInstance();
new OperationLog.Entry(StorageCaller.user(), "PUT", "files").key("the/audited/key").write();
Serializable id = operations().get(0).getValue("id");
QContext.setQSession(new QSystemUserSession(instance));
assertRefused("insert as the system user", () -> new InsertAction().execute(new InsertInput(ObjectStorageTables.OPERATION).withRecords(List.of(new QRecord().withValue("action", "PUT")))));
assertRefused("update as the system user", () -> new UpdateAction().execute(new UpdateInput(ObjectStorageTables.OPERATION).withRecords(List.of(new QRecord().withValue("id", id).withValue("actor", "nobody")))));
assertRefused("delete as the system user", () -> new DeleteAction().execute(new DeleteInput(ObjectStorageTables.OPERATION).withPrimaryKey(id)));
QContext.setQSession(new QSession());
assertRefused("insert inside Db.asSystem, not through Db", () -> Db.asSystem(() -> new InsertAction().execute(new InsertInput(ObjectStorageTables.OPERATION).withRecords(List.of(new QRecord().withValue("action", "PUT"))))));
assertRefused("update inside Db.asSystem, not through Db", () -> Db.asSystem(() -> new UpdateAction().execute(new UpdateInput(ObjectStorageTables.OPERATION).withRecords(List.of(new QRecord().withValue("id", id).withValue("actor", "nobody"))))));
assertRefused("delete inside Db.asSystem, not through Db", () -> Db.asSystem(() -> new DeleteAction().execute(new DeleteInput(ObjectStorageTables.OPERATION).withPrimaryKey(id))));
assertUnchanged();
}
/*******************************************************************************
** An operation row is not updated or deleted by an update or delete action
** that does not carry the bypass, whoever asks: a person, the storage
** administrator or the system user, by key or by filter.
*******************************************************************************/
@Test
void testOperationRowIsNeverUpdatedOrDeletedWithoutTheBypass() throws Exception
{
QInstance instance = enforcingInstance();
new OperationLog.Entry(StorageCaller.user(), "PUT", "files").key("the/audited/key").write();
Serializable id = operations().get(0).getValue("id");
List<QSession> sessions = List.of(new QSession(), new QSession().withPermissions(ObjectStoragePermissions.ADMIN), new QSystemUserSession(instance));
for(QSession session : sessions)
{
QContext.setQSession(session);
String who = session.getClass().getSimpleName() + (session.hasPermission(ObjectStoragePermissions.ADMIN) ? " (admin)" : "");
assertRefused("update by " + who, () -> new UpdateAction().execute(new UpdateInput(ObjectStorageTables.OPERATION).withRecords(List.of(new QRecord().withValue("id", id).withValue("result", "FAILED")))));
assertRefused("delete by key by " + who, () -> new DeleteAction().execute(new DeleteInput(ObjectStorageTables.OPERATION).withPrimaryKey(id)));
assertRefused("delete by filter by " + who, () -> new DeleteAction().execute(new DeleteInput(ObjectStorageTables.OPERATION).withQueryFilter(new QQueryFilter(Db.eq("action", "PUT")))));
}
assertUnchanged();
}
/*******************************************************************************
** An instance that enforces permissions inside the core actions.
*******************************************************************************/
private static QInstance enforcingInstance() throws QException
{
ObjectStorageQBitConfig config = new ObjectStorageQBitConfig().withBackendName(StorageTestSupport.MEMORY).withBucketScoping(false);
return (StorageTestSupport.setUp(config, instance -> instance.setEnforcePermissionsInCoreActions(true)));
}
/*******************************************************************************
** The operation log, read by the service.
*******************************************************************************/
private static List<QRecord> operations() throws QException
{
return (Db.asSystem(() -> Db.query(ObjectStorageTables.OPERATION)));
}
/*******************************************************************************
** The qbit's checker refused the write.
*******************************************************************************/
private static void assertRefused(String what, ThrowingCallable write)
{
assertThatThrownBy(write).as(what).isInstanceOf(QPermissionDeniedException.class).hasMessage(REFUSAL);
}
/*******************************************************************************
** The one row the service wrote is still there, as it was written.
*******************************************************************************/
private static void assertUnchanged() throws QException
{
assertThat(operations()).singleElement().satisfies(row ->
{
assertThat(row.getValueString("objectKey")).isEqualTo("the/audited/key");
assertThat(row.getValueString("result")).isEqualTo("OK");
assertThat(row.getValueString("actor")).isEqualTo("system (no user)");
});
}
}
@@ -0,0 +1,287 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.objectstorage.internal;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig;
import com.kingsrook.qbits.objectstorage.api.ObjectHeaders;
import com.kingsrook.qbits.objectstorage.config.BucketRole;
import com.kingsrook.qbits.objectstorage.config.ProviderType;
import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig;
import com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig;
import com.kingsrook.qbits.objectstorage.model.ObjectStorageSchema;
import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables;
import com.kingsrook.qbits.objectstorage.testsupport.StorageTestSupport;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import liquibase.Contexts;
import liquibase.Liquibase;
import liquibase.database.Database;
import liquibase.database.DatabaseFactory;
import liquibase.database.jvm.JdbcConnection;
import liquibase.resource.ClassLoaderResourceAccessor;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The shipped starter defaults (db/liquibase/qbit-object-storage/
** object-storage-defaults.changelog.sql), run through real Liquibase on H2 in
** PostgreSQL mode against the schema ObjectStorageSchema emits: they apply,
** a second run inserts nothing and keeps an administrator's edits, a host that
** already has the rule or lacks the table is skipped (MARK_RAN), and enabling
** the seeded rule changes the headers the policy gives.
*******************************************************************************/
public class DefaultsChangelogTest
{
static final String CHANGELOG = "/db/liquibase/qbit-object-storage/object-storage-defaults.changelog.sql";
/** The tables the changelog seeds. */
public static final Set<String> SEEDED_TABLES = Set.of(ObjectStorageTables.HEADER_RULE);
/*******************************************************************************
** Tear down.
*******************************************************************************/
@AfterEach
void tearDown()
{
StorageTestSupport.tearDown();
}
/*******************************************************************************
** Fresh schema, seed twice: the same single disabled rule, and an edit survives.
*******************************************************************************/
@Test
void seedsAFreshSchemaInsertOnly() throws Exception
{
try(Connection connection = freshSchema(); Statement statement = connection.createStatement())
{
runChangelog(connection);
assertThat(scalar(connection, "SELECT COUNT(*) FROM object_storage_header_rule")).isEqualTo("1");
assertThat(scalar(connection, "SELECT name || '|' || key_glob || '|' || cache_control || '|' || priority || '|' || enabled FROM object_storage_header_rule"))
.isEqualTo("default-mutable|**|public, max-age=3600|1000|FALSE");
assertThat(scalar(connection, "SELECT bucket_id FROM object_storage_header_rule")).as("generic: not tied to a bucket").isNull();
statement.execute("UPDATE object_storage_header_rule SET cache_control = 'no-cache', enabled = TRUE WHERE name = 'default-mutable'");
runChangelog(connection);
assertThat(scalar(connection, "SELECT COUNT(*) FROM object_storage_header_rule")).isEqualTo("1");
assertThat(scalar(connection, "SELECT cache_control || '|' || enabled FROM object_storage_header_rule")).as("the edit survives").isEqualTo("no-cache|TRUE");
assertThat(ranChangesets(connection)).containsEntry("defaults-1", "EXECUTED");
}
}
/*******************************************************************************
** Rolling the seed back never deletes data: a header rule the host owns
** survives (the rollback is empty).
*******************************************************************************/
@Test
void rollbackKeepsHostOwnedRules() throws Exception
{
try(Connection connection = freshSchema(); Statement statement = connection.createStatement())
{
statement.execute("INSERT INTO object_storage_header_rule (name, key_glob, cache_control, priority, enabled, create_date, modify_date) "
+ "VALUES ('host-rule', '*.css', 'immutable', 5, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)");
runChangelog(connection);
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
new Liquibase(CHANGELOG.substring(1), new ClassLoaderResourceAccessor(), database).rollback(1, "");
assertThat(scalar(connection, "SELECT COUNT(*) FROM object_storage_header_rule WHERE name = 'host-rule' AND enabled = TRUE")).isEqualTo("1");
assertThat(changelogText()).contains("-- rollback empty").doesNotContain("DELETE FROM");
}
}
/*******************************************************************************
** A host that already has a rule of that name keeps it untouched, and the
** changeset is marked ran. A host without the table is skipped, not failed.
*******************************************************************************/
@Test
void existingRuleAndMissingTableAreSkipped() throws Exception
{
try(Connection connection = freshSchema(); Statement statement = connection.createStatement())
{
statement.execute("INSERT INTO object_storage_header_rule (name, key_glob, cache_control, priority, enabled, create_date, modify_date) "
+ "VALUES ('default-mutable', '*.css', 'immutable', 5, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)");
runChangelog(connection);
assertThat(scalar(connection, "SELECT COUNT(*) FROM object_storage_header_rule")).isEqualTo("1");
assertThat(scalar(connection, "SELECT key_glob || '|' || enabled FROM object_storage_header_rule")).isEqualTo("*.css|TRUE");
assertThat(ranChangesets(connection)).containsEntry("defaults-1", "MARK_RAN");
}
try(Connection connection = freshSchema(); Statement statement = connection.createStatement())
{
statement.execute("DROP TABLE object_storage_header_rule");
runChangelog(connection);
assertThat(ranChangesets(connection)).containsEntry("defaults-1", "MARK_RAN");
}
}
/*******************************************************************************
** Every changeset guards itself, uses only the sql-check precondition form,
** and inserts disabled rows only.
*******************************************************************************/
@Test
void everyChangesetIsGuarded() throws Exception
{
String text = changelogText();
String[] changesets = text.split("(?m)^-- changeset ");
assertThat(changesets).hasSize(2);
assertThat(changesets[1]).contains("-- preconditions onFail:MARK_RAN onError:MARK_RAN").contains("-- precondition-sql-check expectedResult:0 ")
.contains("-- rollback empty").contains("NOT EXISTS").contains("FALSE, CURRENT_TIMESTAMP");
assertThat(text).doesNotContain("precondition-table-exists");
for(String table : SEEDED_TABLES)
{
assertThat(text).contains("INSERT INTO " + table + " ");
}
}
/*******************************************************************************
** The seeded rule is inert until enabled (an existing host's behavior does not
** change on deploy), and enabling it gives every object the header.
*******************************************************************************/
@Test
void enablingTheSeededRuleChangesHeaders() throws Exception
{
ObjectStorageQBitConfig config = config();
StorageTestSupport.setUp(config);
StorageTestSupport.insert(ObjectStorageTables.HEADER_RULE, new QRecord().withValue("name", "default-mutable").withValue("keyGlob", "**")
.withValue("cacheControl", "public, max-age=3600").withValue("priority", 1000).withValue("enabled", false));
DefaultObjectHeaderPolicy.clearCache();
StorageBucketConfig media = config.findBucket("media");
assertThat(new DefaultObjectHeaderPolicy().headersFor(media, "any/key.bin", "application/octet-stream").isEmpty()).as("disabled: no behavior change").isTrue();
Db.update(ObjectStorageTables.HEADER_RULE, List.of(new QRecord().withValue("id", Db.first(ObjectStorageTables.HEADER_RULE, "name", "default-mutable").getValue("id")).withValue("enabled", true)));
DefaultObjectHeaderPolicy.clearCache();
ObjectHeaders headers = new DefaultObjectHeaderPolicy().headersFor(media, "any/key.bin", "application/octet-stream");
assertThat(headers.getCacheControl()).isEqualTo("public, max-age=3600");
}
/*******************************************************************************
** A small config.
*******************************************************************************/
private static ObjectStorageQBitConfig config()
{
return (new ObjectStorageQBitConfig().withBackendName(StorageTestSupport.MEMORY).withBucketScoping(false).withOpenWhenAnonymous(true).withAdminApp(true)
.withConnection(new StorageConnectionConfig("main").withProviderType(ProviderType.MINIO).withEndpoint("http://localhost:1").withRegion("us-east-1"))
.withBucket(new StorageBucketConfig("media").withConnection("main").withBucketName("media-bucket").withRole(BucketRole.PRIVATE).withIndexed(false)));
}
/*******************************************************************************
** A fresh H2 (PostgreSQL mode) with the schema the qbit emits.
*******************************************************************************/
static Connection freshSchema() throws Exception
{
Connection connection = DriverManager.getConnection("jdbc:h2:mem:osdefaults" + System.nanoTime() + ";MODE=PostgreSQL;DATABASE_TO_UPPER=FALSE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DEFAULT_NULL_ORDERING=HIGH");
try(Statement statement = connection.createStatement())
{
for(String sql : ObjectStorageSchema.postgresStatements(config()))
{
if(sql.startsWith("DO $$"))
{
// the foreign-key blocks are PL/pgSQL (Postgres only); PostgresIndexTest runs them on real Postgres
continue;
}
statement.execute(sql);
}
}
return (connection);
}
/*******************************************************************************
** Run the changelog through Liquibase, as a host does.
*******************************************************************************/
static void runChangelog(Connection connection) throws Exception
{
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
new Liquibase(CHANGELOG.substring(1), new ClassLoaderResourceAccessor(), database).update(new Contexts());
}
/*******************************************************************************
** changeset id to EXECTYPE (EXECUTED, MARK_RAN, ...).
*******************************************************************************/
static Map<String, String> ranChangesets(Connection connection) throws Exception
{
Map<String, String> rs = new HashMap<>();
try(Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery("SELECT id, exectype FROM databasechangelog"))
{
while(result.next())
{
rs.put(result.getString(1), result.getString(2));
}
}
return (rs);
}
/*******************************************************************************
** The first column of the first row, as text (null when none).
*******************************************************************************/
static String scalar(Connection connection, String sql) throws Exception
{
try(Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery(sql))
{
return (result.next() ? result.getString(1) : null);
}
}
/*******************************************************************************
** The changelog's text.
*******************************************************************************/
public static String changelogText() throws Exception
{
try(InputStream stream = DefaultsChangelogTest.class.getResourceAsStream(CHANGELOG))
{
assertThat(stream).as(CHANGELOG + " is on the classpath").isNotNull();
return (new String(stream.readAllBytes(), StandardCharsets.UTF_8));
}
}
}
@@ -83,10 +83,23 @@ class InternalsTest
assertThat(SecretRefs.resolve(null)).isNull();
assertThat(SecretRefs.resolveSetting("")).isNull();
//////////////////////////////////////////////////////////////////////
// core reads ${file.PATH} only inside its allowed roots (by default //
// /var/run/secrets and /run/secrets); point them at the temp dir. //
//////////////////////////////////////////////////////////////////////
Path file = dir.resolve("key");
Files.writeString(file, " from-file\n");
assertThat(SecretRefs.resolve("${file." + file + "}")).isEqualTo("from-file");
assertThat(SecretRefs.resolve("${file." + dir.resolve("missing") + "}")).isNull();
assertThat(SecretRefs.resolve("${file." + file + "}")).as("outside the allowed roots").isNull();
System.setProperty("qqq.fileReference.roots", dir.toString());
try
{
assertThat(SecretRefs.resolve("${file." + file + "}")).isEqualTo("from-file");
assertThat(SecretRefs.resolve("${file." + dir.resolve("missing") + "}")).isNull();
}
finally
{
System.clearProperty("qqq.fileReference.roots");
}
}
@@ -0,0 +1,240 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.objectstorage.internal;
import java.util.EnumSet;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;
import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig;
import com.kingsrook.qbits.objectstorage.api.Capability;
import com.kingsrook.qbits.objectstorage.api.CapabilityStatus;
import com.kingsrook.qbits.objectstorage.config.BucketRole;
import com.kingsrook.qbits.objectstorage.config.ProviderType;
import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig;
import com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig;
import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables;
import com.kingsrook.qbits.objectstorage.testsupport.StorageTestSupport;
import com.kingsrook.qqq.backend.core.cluster.ClusterBus;
import com.kingsrook.qqq.backend.core.cluster.ClusterBusInterface;
import com.kingsrook.qqq.backend.core.cluster.ClusterBusMetaData;
import com.kingsrook.qqq.backend.core.cluster.ClusterCacheAudit;
import com.kingsrook.qqq.backend.core.cluster.ClusterEvent;
import com.kingsrook.qqq.backend.core.cluster.QNodeRoles;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The Object Storage caches are correct on several nodes: every static cache
** is registered with ClusterCaches, a probe result recorded on one node clears
** that capability on the others, and an edited bucket row or header rule
** clears its cache on every node.
*******************************************************************************/
class ObjectStorageClusterCachesTest
{
/** the "wire": events every node's transport sees */
static final List<Consumer<ClusterEvent>> WIRE = new CopyOnWriteArrayList<>();
/*******************************************************************************
** A transport over the static WIRE, so a "remote" publish reaches us.
*******************************************************************************/
public static class WireBus implements ClusterBusInterface
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public void publish(ClusterEvent event)
{
WIRE.forEach(listener -> listener.accept(event));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public AutoCloseable subscribe(Consumer<ClusterEvent> listener)
{
WIRE.add(listener);
return (() -> WIRE.remove(listener));
}
}
/*******************************************************************************
** A synced instance whose buckets never touch a real store, on the wire as
** node-a.
*******************************************************************************/
@BeforeEach
void setUp() throws Exception
{
System.setProperty("os.unit.ak", "ak");
System.setProperty("os.unit.sk", "sk");
StorageTestSupport.setUp(new ObjectStorageQBitConfig().withBackendName(StorageTestSupport.MEMORY).withBucketScoping(false).withOpenWhenAnonymous(true).withAdminApp(true)
.withAllowedSecretRefPatterns(List.of("prop.os.unit.*"))
.withConnection(new StorageConnectionConfig("main").withProviderType(ProviderType.MINIO).withEndpoint("http://localhost:1").withRegion("us-east-1")
.withAccessKeyRef("${prop.os.unit.ak}").withSecretKeyRef("${prop.os.unit.sk}"))
.withBucket(new StorageBucketConfig("media").withConnection("main").withBucketName("media-bucket").withRole(BucketRole.PRIVATE).withIndexed(false)),
instance -> instance.withSupplementalMetaData(new ClusterBusMetaData().withBus(new QCodeReference(WireBus.class))));
QNodeRoles.set(new QNodeRoles.Settings(EnumSet.allOf(QNodeRoles.Role.class), true, true, true, null, "node-a"));
ClusterBus.connect(QContext.getQInstance());
}
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void tearDown()
{
ClusterBus.close(QContext.getQInstance());
WIRE.clear();
QNodeRoles.set(null);
StorageTestSupport.tearDown();
}
/*******************************************************************************
** Every static Memoization in the QBit is registered (shared or node-local).
*******************************************************************************/
@Test
void testNoUnregisteredStaticCaches() throws Exception
{
assertThat(ClusterCacheAudit.unregisteredStaticCaches("com.kingsrook.qbits.objectstorage")).isEmpty();
}
/*******************************************************************************
** Another node's probe result clears the capability here; the next read
** reloads it from the capability row.
*******************************************************************************/
@Test
void testRemoteProbeResultClearsCapability() throws Exception
{
StorageBucketConfig bucket = ObjectStorageRuntime.bucketConfig("media");
CapabilityStore.record(bucket, Capability.VERSIONING, CapabilityStatus.UNSUPPORTED, "probe here");
assertThat(CapabilityStore.isCached(bucket, Capability.VERSIONING)).isTrue();
////////////////////////////////////////////////////////////
// node-b probes again and writes SUPPORTED to the row //
////////////////////////////////////////////////////////////
Integer bucketId = ConfigSync.bucketId("media");
Db.asSystem(() ->
{
QRecord row = Db.first(ObjectStorageTables.BUCKET_CAPABILITY, "bucketId", bucketId, "capability", Capability.VERSIONING.name());
Db.update(ObjectStorageTables.BUCKET_CAPABILITY, List.of(new QRecord().withValue("id", row.getValue("id")).withValue("status", CapabilityStatus.SUPPORTED.name())));
return (null);
});
ClusterEvent remote = new ClusterEvent(ClusterEvent.TYPE_MEMO_INVALIDATE, CapabilityStore.CACHE_NAME, "b:media:" + Capability.VERSIONING);
remote.setOriginNodeId("node-b");
WIRE.forEach(listener -> listener.accept(remote));
assertThat(CapabilityStore.isCached(bucket, Capability.VERSIONING)).isFalse();
assertThat(CapabilityStore.status("media", Capability.VERSIONING)).isEqualTo(CapabilityStatus.SUPPORTED);
}
/*******************************************************************************
** A probe result recorded here tells the other nodes, and stays cached here.
*******************************************************************************/
@Test
void testProbeResultPublishesInvalidation()
{
List<ClusterEvent> sent = sentEvents();
StorageBucketConfig bucket = ObjectStorageRuntime.bucketConfig("media");
CapabilityStore.record(bucket, Capability.VERSIONING, CapabilityStatus.SUPPORTED, "probe here");
assertThat(CapabilityStore.status("media", Capability.VERSIONING)).isEqualTo(CapabilityStatus.SUPPORTED);
assertThat(sent).anySatisfy(event -> assertInvalidation(event, CapabilityStore.CACHE_NAME, "b:media:VERSIONING"));
}
/*******************************************************************************
** An edited bucket row takes effect at once and tells the other nodes.
*******************************************************************************/
@Test
void testBucketRowEditInvalidatesRowLimitsEverywhere() throws Exception
{
StorageBucketConfig bucket = ObjectStorageRuntime.bucketConfig("media");
assertThat(ConfigSync.effectiveMaxPresignGetSeconds(bucket)).isEqualTo(3600);
List<ClusterEvent> sent = sentEvents();
Db.update(ObjectStorageTables.BUCKET, List.of(new QRecord().withValue("id", ConfigSync.bucketId("media")).withValue("maxPresignGetSeconds", 60)));
assertThat(ConfigSync.effectiveMaxPresignGetSeconds(bucket)).as("no wait for the 15 s cache").isEqualTo(60);
assertThat(sent).anySatisfy(event -> assertInvalidation(event, ConfigSync.ROW_LIMITS_CACHE_NAME, null));
}
/*******************************************************************************
** An edited header rule tells the other nodes.
*******************************************************************************/
@Test
void testHeaderRuleEditInvalidatesRulesEverywhere() throws Exception
{
List<ClusterEvent> sent = sentEvents();
StorageTestSupport.insert(ObjectStorageTables.HEADER_RULE, new QRecord().withValue("name", "all").withValue("keyGlob", "**").withValue("cacheControl", "no-store"));
assertThat(sent).anySatisfy(event -> assertInvalidation(event, DefaultObjectHeaderPolicy.CACHE_NAME, null));
}
/*******************************************************************************
** Collect what this node puts on the wire.
*******************************************************************************/
private static List<ClusterEvent> sentEvents()
{
List<ClusterEvent> sent = new CopyOnWriteArrayList<>();
WIRE.add(sent::add);
return (sent);
}
/*******************************************************************************
** A memo invalidation from node-a for a cache (and key).
*******************************************************************************/
private static void assertInvalidation(ClusterEvent event, String name, String key)
{
assertThat(event.getType()).isEqualTo(ClusterEvent.TYPE_MEMO_INVALIDATE);
assertThat(event.getName()).isEqualTo(name);
assertThat(event.getKey()).isEqualTo(key);
assertThat(event.getOriginNodeId()).isEqualTo("node-a");
}
}
@@ -0,0 +1,125 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.objectstorage.internal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig;
import com.kingsrook.qbits.objectstorage.api.StorageAlertSink;
import com.kingsrook.qbits.objectstorage.api.StorageCaller;
import com.kingsrook.qbits.objectstorage.model.ObjectStorageTables;
import com.kingsrook.qbits.objectstorage.testsupport.StorageTestSupport;
import com.kingsrook.qqq.backend.core.logging.CollectedLogMessage;
import com.kingsrook.qqq.backend.core.logging.QCollectingLogger;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import org.apache.logging.log4j.Level;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** An operation log row that cannot be written does not fail the operation it
** describes, so an operator has to be able to see it: an ERROR log line, the
** storage.operation_log.failed alert and OperationLog.failedWriteCount().
*******************************************************************************/
class OperationLogTest
{
private final List<String> alerts = new ArrayList<>();
/*******************************************************************************
** Reset the runtime, the context and the logger after each test.
*******************************************************************************/
@AfterEach
void tearDown()
{
QLogger.deactivateCollectingLoggerForClass(OperationLog.class);
StorageTestSupport.tearDown();
}
/*******************************************************************************
** A row the insert action refuses (it hands the record back with an error, it
** does not throw) and a row whose insert throws are both counted, logged at
** ERROR and raised as an alert; the caller is not failed. The next row written
** for the bucket resolves the alert and the count stays.
*******************************************************************************/
@Test
void testFailedRowIsAnErrorAnAlertAndACount() throws Exception
{
QInstance instance = StorageTestSupport.setUp(new ObjectStorageQBitConfig().withBackendName(StorageTestSupport.MEMORY).withBucketScoping(false));
ObjectStorageRuntime.setAlertSink(new StorageAlertSink()
{
/*******************************************************************************
** Record the alert.
*******************************************************************************/
@Override
public void raise(String alertType, String dedupeKey, String severity, String message, Map<String, Object> details)
{
alerts.add("raise " + alertType + " " + dedupeKey + " " + severity + " count=" + details.get("count") + " action=" + details.get("action"));
}
/*******************************************************************************
** Record the resolution.
*******************************************************************************/
@Override
public void resolve(String alertType, String dedupeKey)
{
alerts.add("resolve " + alertType + " " + dedupeKey);
}
});
QCollectingLogger logger = QLogger.activateCollectingLoggerForClass(OperationLog.class);
new OperationLog.Entry(StorageCaller.user(), "PUT", "files").key("a").write();
assertThat(OperationLog.failedWriteCount()).isZero();
assertThat(alerts).isEmpty();
instance.getTable(ObjectStorageTables.OPERATION).getField("detail").setIsRequired(true);
new OperationLog.Entry(StorageCaller.user(), "PUT", "files").key("refused").write();
assertThat(OperationLog.failedWriteCount()).isEqualTo(1);
assertThat(alerts).containsExactly("raise storage.operation_log.failed bucket:files error count=1 action=PUT");
instance.getTable(ObjectStorageTables.OPERATION).getField("detail").setIsRequired(false);
String backendName = instance.getTable(ObjectStorageTables.OPERATION).getBackendName();
instance.getTable(ObjectStorageTables.OPERATION).setBackendName("no-such-backend");
new OperationLog.Entry(StorageCaller.user(), "DELETE", "files").key("thrown").write();
assertThat(OperationLog.failedWriteCount()).isEqualTo(2);
assertThat(alerts).last().isEqualTo("raise storage.operation_log.failed bucket:files error count=2 action=DELETE");
List<CollectedLogMessage> errors = logger.getCollectedMessages().stream().filter(message -> Level.ERROR.equals(message.getLevel())).toList();
assertThat(errors).hasSize(2).allSatisfy(message -> assertThat(message.getMessage()).contains("Could not write a storage operation log row"));
instance.getTable(ObjectStorageTables.OPERATION).setBackendName(backendName);
new OperationLog.Entry(StorageCaller.user(), "PUT", "files").key("b").write();
new OperationLog.Entry(StorageCaller.user(), "PUT", "files").key("c").write();
assertThat(alerts).hasSize(3).last().isEqualTo("resolve storage.operation_log.failed bucket:files");
assertThat(OperationLog.failedWriteCount()).as("the count is of this node since it started, a later row does not lower it").isEqualTo(2);
assertThat(Db.asSystem(() -> Db.query(ObjectStorageTables.OPERATION))).extracting(row -> row.getValueString("objectKey")).containsExactly("a", "b", "c");
}
}
@@ -129,6 +129,25 @@ class TablesAndConfigTest
/*******************************************************************************
** The record-page child lists filter on the parent id alone (folder ->
** objects, object -> versions and references), so the generated schema
** gives each of those columns its own index, appended so existing generated
** index names keep their numbers.
*******************************************************************************/
@Test
void testChildListColumnsAreIndexed()
{
String schema = String.join("\n", com.kingsrook.qbits.objectstorage.model.ObjectStorageSchema.postgresStatements(new ObjectStorageQBitConfig().withBackendName("rdbms")));
assertThat(schema)
.contains("ix_object_storage_object_1 ON object_storage_object (bucket_id, folder_id)")
.contains("ix_object_storage_object_7 ON object_storage_object (folder_id)")
.contains("ix_object_storage_object_version_2 ON object_storage_object_version (object_id)")
.contains("ix_object_storage_object_reference_3 ON object_storage_object_reference (object_id)");
}
/*******************************************************************************
** Owner naming rules: every table is lowercase snake_case with the full qbit
** name as prefix; no table has a field that could hold a secret value; the nav
@@ -186,7 +205,7 @@ class TablesAndConfigTest
new com.kingsrook.qbits.objectstorage.ObjectStorageQBitProducer().withQBitConfig(config).produce(instance);
assertThat(instance.getApp("objectStorageAdmin").getParentAppName()).isEqualTo("admin");
assertThat(instance.getApp("admin").getChildren()).extracting(c -> c.getName()).contains("objectStorageAdmin");
assertThat(com.kingsrook.qbits.objectstorage.app.StorageWidgets.tablePathPrefix()).isEqualTo("/admin/objectStorageAdmin");
assertThat(com.kingsrook.qbits.objectstorage.app.StorageWidgets.tablePathPrefix()).isEqualTo("/app");
}