Compare commits

..
Author SHA1 Message Date
james.maesandCodex 3d969c201f ci: use shared orb pipelines and source scanning
Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 10:19:04 -05:00
222 changed files with 16 additions and 35627 deletions

No files matched your search

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

Before

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

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