Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ef8a6293f
|
||
|
|
c58324bccf
|
||
|
|
06b766d516
|
||
|
|
5c32e3c5f3
|
||
|
|
1de785328e
|
||
|
|
dbcfd85da8
|
||
|
|
ff76412d80
|
||
|
|
76ac13c14f
|
||
|
|
7ba2740283
|
||
|
|
c82c847b35
|
||
|
|
dc69ffe245
|
||
|
|
66fd64e1c4
|
||
|
|
895727acbb | ||
|
|
886e76b333 | ||
|
|
603f0c9d82 | ||
|
|
bdf79eae09 | ||
|
|
ec5fef94bc | ||
|
|
b21db17a9f | ||
|
|
7fa71bbaa6 | ||
|
|
84f030d048 | ||
|
|
9cb78190d8
|
||
|
|
91d86b016a | ||
|
|
0c28ea6263
|
||
|
|
825944fab4 | ||
|
|
b25f4ff244
|
||
|
|
b58c0edbd5 | ||
|
|
17aad52cdf
|
||
|
|
1b37582233 | ||
|
|
2f1964ddd1
|
||
|
|
e57ab62193
|
||
|
|
e263f3bc8b | ||
|
|
ab969bf99b
|
||
|
|
b17664db8c | ||
|
|
eb413b6e15 | ||
|
|
1e17dada29
|
||
|
|
991f310ccc
|
||
|
|
0e798e3079 | ||
|
|
9a2a1dd88e
|
||
|
|
20dbcc223c
|
||
|
|
ab4c578ba8 | ||
|
|
acf51b6749
|
||
|
|
a825360ba1 | ||
|
|
b06c1fd8e4
|
||
|
|
01db11b123 | ||
|
|
6dea78881d
|
||
|
|
89f218937a
|
||
|
|
85f4ca0a6f
|
||
|
|
e2d07b275a
|
||
|
|
f9767ede83
|
||
|
|
8d56613f7d
|
||
|
|
0f9178dc14
|
||
|
|
48e7320228 | ||
|
|
a5297e709d | ||
|
|
f2af36ec5f | ||
|
|
054f6d90d7
|
||
|
|
5b7106b981
|
||
|
|
32034e5762 | ||
|
|
bc97d59b96
|
||
|
|
506741bd90 | ||
|
|
95b971a5fb
|
||
|
|
e56cc1b021 | ||
|
|
664f467d11 | ||
|
|
4fb182e824 | ||
|
|
1de38f03ae
|
||
|
|
cf2d027f9a | ||
|
|
714ef906fe
|
||
|
|
26c174f7bf
|
||
|
|
284f0cec8c | ||
|
|
2f16bd6515
|
||
|
|
d4ad39f19d
|
||
|
|
ba3959e7dc
|
||
|
|
a67e4c4a57 | ||
|
|
4022fa1eee
|
||
|
|
6017f3ed2d
|
||
|
|
01b60708f8
|
||
|
|
06c87afa20 | ||
|
|
b928920989
|
||
|
|
758ef90cc1 | ||
|
|
eb481a475a
|
||
|
|
3d7343c394 | ||
|
|
eea9f63878
|
||
|
|
2f520e9f15 | ||
|
|
7ef0849a02 |
No files matched your search
@@ -1,14 +1,14 @@
|
||||
version: 2.1
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.7.13
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
workflows:
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: 0.7.13
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /v.*/
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
@@ -0,0 +1,7 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
jobs:
|
||||
maven:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,74 @@
|
||||
name: Release candidate
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original Circle publisher build number (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
checks: read
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
|
||||
== 'workflow_dispatch' && inputs.finalize) }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|
||||
|| inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|
||||
|| inputs.bump_sha || '' }}
|
||||
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|
||||
|| inputs.branch || '' }}
|
||||
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|
||||
|| inputs.version || '' }}
|
||||
secrets: inherit
|
||||
@@ -1,16 +1,16 @@
|
||||
profile: qbit
|
||||
orb_version: 0.7.13
|
||||
orb_version: '0.8'
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: v-prefix
|
||||
test_branch_policy: non-release-no-hotfix
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
hotfix: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
@@ -35,7 +35,7 @@ This file provides guidance to Codex when working with code in this repository.
|
||||
## Dependencies
|
||||
|
||||
- Java 21.
|
||||
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.4` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
|
||||
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.6` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
|
||||
- Tests: JUnit 5, AssertJ.
|
||||
|
||||
## Release
|
||||
|
||||
@@ -5,4 +5,33 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
## [0.1.0] - Unreleased
|
||||
|
||||
### Added
|
||||
- Central `backups.sla` and per-active-plan `backups.destination.<planId>` health checks, with bounded read-only destination diagnostics and secret-free status details.
|
||||
- Always-durable backup, resume, scheduled backup, restore and verify processes with conservative `NONE` idempotency and stable backup identities on explicit job retry.
|
||||
- Weighted backup phases, table/file counts, stored bytes, safe per-table/artifact logs, warnings, failures and resume notices through QQQ `JobProgress`.
|
||||
- Cooperative job cancellation during backup pages, media copying, restore pages and verification scans; local media-reference spooling keeps phase separation from growing heap usage across tables.
|
||||
- Complete host-neutral in-app help bundle with live references and a help coverage gate. Hosts can override entries with a HOST bundle.
|
||||
- Guided empty states: help bundle `help/qbit-backups.yaml` (app and all nine tables, with "How rows get created") applied by `BackupsHelp`, and a Setup card on the Backups home (plan, scope rules, storage, schedule, verification, prune bucket).
|
||||
- "Create default plan" table action on Backup plans: adds "Nightly full" for a storage bucket alias; insert-only.
|
||||
- `backupVerifyPlan`: the scheduled verification process. A plan verify cron created a scheduled job for a process that did not exist, so scheduled verification never ran.
|
||||
- Repository scaffold: `BackupsQBitConfig` and `BackupsQBitProducer` (table prefix `backups_`, permissions `backups.*`), build, CI and repository standard files.
|
||||
|
||||
### Changed
|
||||
- Tables whose backend module has no query interface (storage-only modules such as qbit-edi `edi_preview_download`) are classified EXTERNAL and always left out of a backup with the reason `NOT_QUERYABLE`, even when selected by name or role; the Run backup review lists them. Before, such a table was read as DATA and the whole run FAILED with "Query is not implemented in this module".
|
||||
- Failed runs persist an actionable `error_message` ("Backup failed: Class: message") and fill `error_detail` with the exception class, the cause chain and the top root-cause frames (bounded to 8 KB). Both go through `FailureText.sanitize`, which redacts URL user-info, presigned query parameters, key=value secrets, Authorization/Bearer values, AWS access key ids, PEM blocks and Postgres row values while keeping object keys and bucket names.
|
||||
- Multi-node deployments: the cached Prometheus text (`BackupsMetrics`) is registered with QQQ `ClusterCaches` as `qbit-backups.metrics` and cleared on every node when a run finishes or a plan is inserted, updated or deleted; the 60 s TTL remains the safety net. `SlaChecker`'s manifest-head and bucket-audit throttles and `BackupsRuntime`'s process-permission map are registered as node-local, with reasons. `BackupsClusterCachesTest` audits the package for unregistered static caches and covers a remote invalidation.
|
||||
- Builds against QQQ `4.1.0-SNAPSHOT` (`qqq.snapshot.version`) for `ClusterCaches` until the next QQQ RC ships; a release must pin that RC.
|
||||
- Read the QBit metadata version from the Maven artifact version so release builds report their released version.
|
||||
- Target QQQ `4.1.0-RC.8` (job progress).
|
||||
- Persist controlled backup failure messages instead of untrusted provider exception text.
|
||||
- Pin QQQ to released 4.1.0-RC.7 and replace the temporary help loader with core HelpBundles registration.
|
||||
- Saving a plan with Use read replica on is refused (it was never read); the not-yet-available settings (read replica, native dump, incremental, PARQUET, ZSTD) say so in their field help; scheduledJobId is documented as unused.
|
||||
- Build against the released QQQ BOM 4.1.0-RC.5; replace reflective alerts binding with core QAlerts and real sink integration tests.
|
||||
- Register backup alert types for every instance while preserving custom backup sinks and no-op behavior without a configured core sink.
|
||||
- Use RC.5-valid alert names `backups.run.missing-in-storage` and `backups.plan.owner-invalid` (and matching deduplication keys). Hosts using the old underscore names must update routing rules.
|
||||
- Target QQQ 4.1.0-RC.6 and replace the fingerprint, keyset pager and reflective restore bridges with typed core APIs.
|
||||
- New manifests identify `qqq-table-schema-v1` and carry core schema descriptors; restore and content verification still accept pre-RC.6 manifests with the original fingerprint algorithm.
|
||||
- Preserve primary-key ordering, exclusive resume cursors, bounded pages and caller-owned transactions; reject non-orderable primary keys and scope filters with skip/limit.
|
||||
- Require the core `backups.restore` permission during Review when using a core restore writer, including anonymous-mode hosts; preserve supplied timestamps through `RestoreWriteFlag`.
|
||||
|
||||
### Removed
|
||||
- The legacy `BackupsHealthIndicator` and its optional `qqq-middleware-health` dependency; central health registration replaces manual indicator configuration.
|
||||
@@ -10,7 +10,7 @@
|
||||
## Requirements
|
||||
|
||||
- Java 21.
|
||||
- QQQ 4.1 (built against `4.1.0-RC.4`).
|
||||
- QQQ `4.1.0-RC.8` or later.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -22,6 +22,10 @@ new BackupsQBitProducer()
|
||||
.produce(qInstance);
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
This qbit ships no DDL and seeds nothing from a changelog: its one setup value, the storage bucket alias, belongs to your application. On a fresh install open Backups, read the Setup card, and run **Create default plan** on Backup plans (it asks only for the alias of a bucket your storage resolver provides). It creates the insert-only "Nightly full" plan: whole instance, data and audit tables, JSONL and gzip, 02:00 UTC, verified Sundays 04:00 UTC. Edit any setting afterwards. Help for every table, including how its rows get created, ships in `help/qbit-backups.yaml`.
|
||||
|
||||
## Naming
|
||||
|
||||
Tables are `backups_<name>` (lowercase snake_case, backend name equal to the table name). Permissions are `backups.<action>`.
|
||||
@@ -36,8 +40,83 @@ Follow the QQQ [adoption checklist](https://github.com/QRun-IO/qqq/blob/develop/
|
||||
./mvnw verify
|
||||
```
|
||||
|
||||
See [the RC.6 migration notes](docs/RC6-MIGRATION.md) for fingerprint compatibility,
|
||||
paging guarantees and restore permissions.
|
||||
|
||||
Health checks use the core health API released in QQQ `4.1.0-RC.8`.
|
||||
The producer registers `backups.sla` and a read-only destination check for each
|
||||
active plan (`backups.destination.<planId>`). With dependencies cached locally,
|
||||
run `./mvnw -o verify`; no enforcer skip flags are required.
|
||||
|
||||
## In-app help
|
||||
|
||||
The producer registers [help/qbit-backups.yaml](src/main/resources/help/qbit-backups.yaml)
|
||||
as a QBIT bundle. Hosts can register a `layer: HOST` bundle with matching entry keys
|
||||
and roles to override its text. Core's help startup sync applies the bundle when the
|
||||
host installs the help content table; registration also works without that table.
|
||||
|
||||
`BackupPlanScheduleTest` checks coverage with `HelpBundleAssertions`, including
|
||||
live references and optional scheduler processes. `BackupsTablesGuardrailTest`
|
||||
checks empty-state, widget and possible-value-source coverage. Run it with
|
||||
`./mvnw verify`. Keep the bundle's literal version aligned with the POM version.
|
||||
|
||||
Always use the Maven wrapper (Maven 3.9.11); Maven 3.10 cannot read the parent POM.
|
||||
|
||||
## Durable jobs and progress
|
||||
|
||||
Run backup now, Resume, scheduled backups, both Restore actions, and Verify backup
|
||||
are always durable jobs. Hosts must configure QQQ durable jobs and its worker.
|
||||
Review remains a preview; execution runs in the background and reports through
|
||||
the job page and jobs tray.
|
||||
|
||||
Backups report Resolve scope, Back up tables, Copy media files, and Write catalog
|
||||
and manifest. Separate verification jobs report a single Verify phase.
|
||||
The job includes table/file counts, stored bytes, per-table
|
||||
rows and duration, artifact names, warnings and failures. Logs exclude row values,
|
||||
source file keys, storage credentials and endpoints. Provider errors use controlled
|
||||
failure messages; detailed exceptions stay in server diagnostics.
|
||||
|
||||
These processes use `NONE` idempotency: a worker interruption requires review before
|
||||
retry because object writes and catalog updates are not atomic. Explicitly retrying
|
||||
a backup job reuses its run and resumes completed parts; it does not create another
|
||||
backup. Snapshot runs that cannot resume must be started as a new backup. See
|
||||
[the progress and retry contract](docs/DESIGN.md#job-progress-implementation) for limits and cancellation.
|
||||
|
||||
## License
|
||||
|
||||
Apache License 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
||||
|
||||
### Alerts integration
|
||||
|
||||
Backups binds directly to core QAlerts in QQQ 4.1.0-RC.5 and declares its nine
|
||||
alert types on each instance. Hosts install an `AlertSinkInterface` through
|
||||
`AlertsInstanceMetaData.withSink(QCodeReference)`. Without a configured sink,
|
||||
backup alert calls remain no-ops (they do not use core's logging fallback).
|
||||
Custom `BackupAlertSink` bindings are preserved. Core handles dispatch failures;
|
||||
adapter failures are caught and logged once, so alerting cannot fail a backup.
|
||||
Raise and resolve follow core's transaction-aware dispatch; heartbeats run immediately.
|
||||
The backup SLA checker still owns expected intervals and grace periods.
|
||||
|
||||
RC.5 requires hyphenated alert names: `backups.run.missing-in-storage` and
|
||||
`backups.plan.owner-invalid` replace the former underscore spellings, including
|
||||
their deduplication keys. Update host routing rules that used those spellings.
|
||||
|
||||
## Health checks
|
||||
|
||||
The producer registers these central checks under component `backups`, category `STORAGE`:
|
||||
|
||||
| Name | Kind | Interval | Timeout |
|
||||
| --- | --- | --- | --- |
|
||||
| `backups.sla` | DEPENDENCY | 900s (15 minutes) | 5000ms |
|
||||
| `backups.destination.<planId>` | DEPENDENCY | 86400s (24 hours) | 30000ms |
|
||||
|
||||
The SLA check reads persisted `slaStatus`: critical active plans that are LATE,
|
||||
FAILING or MISSING_IN_STORAGE yield DOWN; non-critical failures yield DEGRADED.
|
||||
Only UNVERIFIED/UNKNOWN plans yield UNKNOWN; no active plans yield UP. Details
|
||||
contain counts per SLA status. The scheduled `backupCheckSla` still computes SLA state.
|
||||
|
||||
Destination checks are supplied for active plans and reuse `DestinationProbe` in
|
||||
read-only list/head mode, including on unlocked buckets. They never write, delete,
|
||||
or resolve prune credentials. The Test destination action retains its write tests.
|
||||
Storage calls are bounded by the central runner, with deadline checks between calls.
|
||||
Override intervals in `health_check` and disable checks through `health_check.enabled`.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Upgrading
|
||||
|
||||
## Unreleased
|
||||
|
||||
- No schema change and no new include line. Hosts get the help text and the Setup card automatically.
|
||||
- Plans with `use_read_replica` true are now refused on the next save (the setting was never used). Turn it off.
|
||||
- Hosts that set a plan `verifyCronExpression` and a scheduler now get working scheduled verification: the `backupVerifyPlan` process exists. It verifies the plan's latest successful run through the plan's `verifyLevel` (CONTENT when unset). Expect verification rows and, on failure, alerts.
|
||||
@@ -1,3 +1,18 @@
|
||||
# QBit Backups: design
|
||||
|
||||
The design for this qbit is tracked in [QRun-IO/qqq#1091](https://github.com/QRun-IO/qqq/pull/1091). This repository implements it; the implementation is in progress.
|
||||
|
||||
|
||||
## Job progress implementation
|
||||
|
||||
Backup, resume, scheduled backup, restore, and verify execution use always-durable
|
||||
jobs with `NONE` idempotency against the QQQ job-progress API targeted for RC.8.
|
||||
Backup phases are `prepare`, `tables`, `media`, and `upload`, weighted 2, 75, 18,
|
||||
and 5. Verification runs separately with a single `verify` phase.
|
||||
|
||||
Progress reports table/file counts, stored bytes, and safe artifact names without
|
||||
row data, source keys, credentials, or endpoints. Cancellation is cooperative at
|
||||
page and media-copy boundaries. Media references spool locally between phases.
|
||||
Object writes and catalog updates are not atomic, so worker interruptions require
|
||||
review before retry. Explicit backup retries reuse the run and resume completed
|
||||
parts; snapshot runs that cannot resume require a new backup.
|
||||
@@ -0,0 +1,100 @@
|
||||
# RC.6 core migration (issue #19, F3)
|
||||
|
||||
The POM targets `4.1.0-RC.6`. Until that release is available, local verification
|
||||
uses `./mvnw -B verify -Dqqq.snapshot.version=4.1.0-ARCHRC6-SNAPSHOT` against the
|
||||
installed build from `qqq-wt-archrc6`. No core source changes are needed.
|
||||
|
||||
## Fingerprint compatibility
|
||||
|
||||
The algorithms are **not byte-compatible**, even for identical table metadata:
|
||||
|
||||
| Descriptor property | Pre-RC.6 backup algorithm | Core algorithm |
|
||||
| --- | --- | --- |
|
||||
| Table and key | `name`, `primaryKey` | `table`, `primaryKeyField` |
|
||||
| Fields | Map keyed by name | Sorted list with explicit `name` |
|
||||
| Field flags | `required`, `pvs`, `heavy`, `sensitive` | `isRequired`, `possibleValueSource`, `isHeavy`, `isSensitive` |
|
||||
| Unique keys | Sorted `+`-joined strings | Sorted lists of field names |
|
||||
| Associations | Sorted `name->table` strings | Objects with name, associatedTable and joinName |
|
||||
| Null backend type | JSON null | Empty string |
|
||||
| Escaping | Historical hand-written escaping | Jackson JSON escaping |
|
||||
|
||||
New table entries contain `fingerprintAlgorithm: "qqq-table-schema-v1"`, the core
|
||||
`schema` descriptor, and `fingerprint: "sha256:" + core.getHash()`. The outer
|
||||
manifest remains version 1; row parts, checksums, compression and storage paths
|
||||
are unchanged. The instance/catalog hash still combines the ordered table
|
||||
fingerprint strings, so its value changes too. New manifests require this RC.6
|
||||
reader; older readers do not understand the new descriptor shape.
|
||||
|
||||
An absent algorithm identifies the old `qbit-backups-table-v1` algorithm. The
|
||||
reader also accepts that explicit identifier. `LegacyTableFingerprint` freezes
|
||||
the original descriptor and hash implementation solely for comparison against
|
||||
existing backups; no new backup uses it. Restore and content verification select
|
||||
the declared algorithm. Unknown identifiers never match a known fingerprint.
|
||||
Restore normalizes either field shape for its existing compatibility analysis
|
||||
when a table has changed. Fingerprint equality is not required for a compatible
|
||||
schema migration, as before.
|
||||
|
||||
`src/test/resources/manifests/legacy-schema.json` freezes descriptors and hashes
|
||||
produced by the original stand-in for the blog fixture. Tests place these into a
|
||||
real backup manifest without the algorithm field, verify its content, mutate the
|
||||
live data, then restore and compare the complete content hash including dates.
|
||||
They also cover optional additions and incompatible type changes in old schemas.
|
||||
|
||||
## Paging and consistency
|
||||
|
||||
`TableReader.Keyset` delegates to core `KeysetPager`. Every query uses
|
||||
`(scope filter) AND pk > lastKey ORDER BY pk ASC LIMIT pageSize`, with no offset.
|
||||
The resume key is exclusive. Page size is a row bound, **not a JDBC fetch-size
|
||||
setting**. Core's normal bounded QueryAction path retains backend driver defaults;
|
||||
this adapter does not opt into the large-result streaming query hint or change
|
||||
JDBC fetch size. Heavy/hidden/password fields and untranslated stored values keep
|
||||
the same query settings and existing backup permission boundary.
|
||||
|
||||
Every page receives the caller's transaction. The pager does not open, commit,
|
||||
close, or replace that transaction. A test opens a real core memory snapshot,
|
||||
changes a later page's data, and proves every page still sees the original state.
|
||||
The existing BackupEngine continues to report BEST_EFFORT and reject
|
||||
SNAPSHOT_REQUIRED: adopting the consistent-read opening API at engine level is
|
||||
separate work. This migration does not claim snapshot guarantees for ordinary
|
||||
backup runs.
|
||||
|
||||
Core can stream non-orderable keys without deterministic key order; backups
|
||||
explicitly reject those keys instead. Core rejects scope filters carrying skip
|
||||
or limit, and replaces user ordering with ascending primary-key ordering. Tests
|
||||
cover OR filters, resume, full and partial pages, empty scans and invalid inputs.
|
||||
|
||||
## Restore writes
|
||||
|
||||
`StandardRestoreWriter` now constructs `RestoreWriteFlag` directly and calls
|
||||
`QBackendModuleInterface.resyncKeySequences`, consuming typed `SequenceResyncResult`
|
||||
values. Only RESYNCED results are reported as sequence changes; NOT_NEEDED and
|
||||
NOT_APPLICABLE remain normal outcomes. There is no reflection or unavailable-core
|
||||
fallback. The core flag preserves supplied dates and carries the requested
|
||||
customizer, automation, audit and event settings.
|
||||
|
||||
Core always requires an actual session permission `backups.restore` for its
|
||||
restore flag, including hosts with general core enforcement disabled. The qbit's
|
||||
anonymous-mode convenience setting does not grant that permission. Review now
|
||||
reports this requirement before executing a restore; test sessions explicitly
|
||||
hold it. Custom writers that do not use the core flag keep their existing qbit
|
||||
permission checks.
|
||||
|
||||
## HTTP operations and wire changes
|
||||
|
||||
No HTTP operations are added, removed or modified: method, path and operationId
|
||||
are unchanged (no operation list entries for this migration). The RC.6 UI API
|
||||
extension documentation was reviewed; this task introduces no Javalin route or
|
||||
middleware extension. Wire changes are confined to the manifest fields and
|
||||
fingerprint values described above, plus an earlier restore permission error in
|
||||
Review for anonymous sessions lacking `backups.restore`.
|
||||
|
||||
## Local verification (2026-10-13)
|
||||
|
||||
- `./mvnw -B clean verify -Dqqq.snapshot.version=4.1.0-ARCHRC6-SNAPSHOT`:
|
||||
BUILD SUCCESS; 158 tests, zero failures/errors/skips; both JaCoCo gates passed.
|
||||
Existing backup, restore and MinIO integration tests ran.
|
||||
- Separate Checkstyle 14.3.0 audit of all 16 changed Java files (main and test):
|
||||
zero violations, using the shared Kingsrook-derived rules with this repository's
|
||||
Apache file header. This repository has no Checkstyle execution configured in
|
||||
its POM; audit configuration and results are local under `.cache/style/`.
|
||||
- `git diff --check`: passed. No commit, push or core source modification.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Backups home and record screenshots
|
||||
|
||||
Taken 2026-10-12 against `BackupsDemoServer` (src/test/.../demo, memory backend, real engine history) and the Next UI 1.0.0-RC.14 jar.
|
||||
Light and dark at 390, 600, 768, 1024, 1440, 1920 px (home) and 390, 1440 px (records).
|
||||
|
||||
- `.` : this branch (foundation base): home, plan, run, failed run, lists.
|
||||
- `with-restore-verify/` : a scratch merge of PRs #5 (verify), #6 (restore), #7 (prune/schedule) onto this branch, so verifications, a restore run and retention classes exist. Not part of this PR.
|
||||
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 66 KiB |
@@ -18,6 +18,9 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<properties>
|
||||
<qqq.version>4.1.0-RC.26</qqq.version> <!-- feeds qbit-build-parent's qqq-bom-pom import -->
|
||||
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
|
||||
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
|
||||
<revision>0.1.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
@@ -27,7 +30,7 @@
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.2.1</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -72,6 +75,42 @@
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- optional: ESB events (apps without qqq-esb never load it) -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-esb</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- Optional: the production BackupStorage adapter. Apps that install this qbit -->
|
||||
<!-- with object storage already have it; others use their own BackupStorage. -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-object-storage</artifactId>
|
||||
<version>0.1.0-RC.10</version>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers</artifactId>
|
||||
<version>2.0.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Demo server only (src/test/.../demo): never shipped. -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-javalin</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.38</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
@@ -86,7 +125,40 @@
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<resources>
|
||||
<!-- help bundles carry the artifact version (QQQ help bundle provenance) -->
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
<includes>
|
||||
<include>help/*.yaml</include>
|
||||
<include>qbit-backups.properties</include>
|
||||
</includes>
|
||||
</resource>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
<excludes>
|
||||
<exclude>help/*.yaml</exclude>
|
||||
<exclude>qbit-backups.properties</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
</resources>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<systemPropertyVariables combine.children="append">
|
||||
<qbit.artifactVersion>${project.version}</qbit.artifactVersion>
|
||||
</systemPropertyVariables>
|
||||
<!-- the MinIO test container default credentials, resolved through ${env.X} refs like production -->
|
||||
<environmentVariables>
|
||||
<STORAGE_TEST_ACCESS_KEY>minioadmin</STORAGE_TEST_ACCESS_KEY>
|
||||
<STORAGE_TEST_SECRET_KEY>minioadmin</STORAGE_TEST_SECRET_KEY>
|
||||
</environmentVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
@@ -138,14 +210,14 @@
|
||||
<profiles>
|
||||
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
|
||||
<!-- imports the released 4.1 BOM, this profile is active by default and -->
|
||||
<!-- imports qqq-bom-pom 4.1.0-RC.4 ahead of the parent's 4.0.0 BOM. -->
|
||||
<!-- imports qqq-bom-pom 4.1.0-RC.8 ahead of the parent's 4.0.0 BOM. -->
|
||||
<profile>
|
||||
<id>qqq-snapshot</id>
|
||||
<activation>
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.4</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -18,16 +18,23 @@ package com.kingsrook.qbits.backups;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
import com.kingsrook.qbits.backups.codec.ArtifactTransform;
|
||||
import com.kingsrook.qbits.backups.verify.ScratchTarget;
|
||||
import com.kingsrook.qbits.backups.storage.BackupStorage;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Host-facing configuration for the Backups QBit.
|
||||
** Configuration of the Backups qbit.
|
||||
**
|
||||
** Tables are named backups_<name> (lowercase snake_case, backend name equal
|
||||
** to the table name); permissions are backups.<action>.
|
||||
** Storage is resolved through a function from a bucket alias (the value of
|
||||
** backups_plan.storageBucketAlias) to a BackupStorage. The production resolver
|
||||
** is backed by qbit-object-storage; tests and local development use the
|
||||
** in-memory implementation. No credential is ever held here or in a table.
|
||||
*******************************************************************************/
|
||||
public class BackupsQBitConfig implements QBitConfig
|
||||
{
|
||||
@@ -35,6 +42,27 @@ public class BackupsQBitConfig implements QBitConfig
|
||||
public static final String PERMISSION_BASE_NAME = "backups";
|
||||
|
||||
private String backendName;
|
||||
private String appName = "backups";
|
||||
private String appLabel = "Backups";
|
||||
private String parentAppName;
|
||||
private String tablePathPrefix;
|
||||
private String schedulerName;
|
||||
private String hostSchedulerDescription;
|
||||
private Boolean hostSchedulerRunsVerification = false;
|
||||
private String instanceName;
|
||||
|
||||
private Integer maxConcurrentRuns = 1;
|
||||
private Integer slaCheckIntervalSeconds = 300;
|
||||
private Boolean openWhenAnonymous = false;
|
||||
private Boolean prometheusEndpoint = false;
|
||||
|
||||
private Function<String, BackupStorage> storageResolver;
|
||||
|
||||
private Integer fileReverifyDays = 30;
|
||||
private Function<String, ArtifactTransform> artifactTransformResolver;
|
||||
private ScratchTarget scratchTarget;
|
||||
private Predicate<String> planOwnerValidator;
|
||||
private String esbDestinationName;
|
||||
|
||||
|
||||
|
||||
@@ -89,4 +117,386 @@ public class BackupsQBitConfig implements QBitConfig
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for appName
|
||||
*******************************************************************************/
|
||||
public String getAppName()
|
||||
{
|
||||
return (appName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for appName
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withAppName(String appName)
|
||||
{
|
||||
this.appName = appName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for appLabel
|
||||
*******************************************************************************/
|
||||
public String getAppLabel()
|
||||
{
|
||||
return (appLabel);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for appLabel
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withAppLabel(String appLabel)
|
||||
{
|
||||
this.appLabel = appLabel;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for parentAppName (for example "admin", so this is Admin, Backups).
|
||||
*******************************************************************************/
|
||||
public String getParentAppName()
|
||||
{
|
||||
return (parentAppName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for parentAppName
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withParentAppName(String parentAppName)
|
||||
{
|
||||
this.parentAppName = parentAppName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tablePathPrefix (the front of the links the home makes to tables and
|
||||
** records, "/app" for the Next dashboard, which routes /app/{table}/{id}).
|
||||
*******************************************************************************/
|
||||
public String getTablePathPrefix()
|
||||
{
|
||||
return (tablePathPrefix == null ? "/app" : tablePathPrefix);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tablePathPrefix
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withTablePathPrefix(String tablePathPrefix)
|
||||
{
|
||||
this.tablePathPrefix = tablePathPrefix;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for hostSchedulerDescription: when the application runs plan crons with
|
||||
** its own tick instead of a QQQ scheduler (schedulerName), a short name for it.
|
||||
** The Setup card then treats plan crons as firing.
|
||||
*******************************************************************************/
|
||||
public String getHostSchedulerDescription()
|
||||
{
|
||||
return (hostSchedulerDescription);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for hostSchedulerDescription.
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withHostSchedulerDescription(String hostSchedulerDescription)
|
||||
{
|
||||
this.hostSchedulerDescription = hostSchedulerDescription;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for hostSchedulerRunsVerification: whether the host's own tick also runs
|
||||
** the verify cron (by calling the backupVerifyPlan process). False by default.
|
||||
*******************************************************************************/
|
||||
public Boolean getHostSchedulerRunsVerification()
|
||||
{
|
||||
return (hostSchedulerRunsVerification);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for hostSchedulerRunsVerification.
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withHostSchedulerRunsVerification(Boolean hostSchedulerRunsVerification)
|
||||
{
|
||||
this.hostSchedulerRunsVerification = hostSchedulerRunsVerification;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for schedulerName (the QQQ scheduler used for plan jobs and the SLA checker)
|
||||
*******************************************************************************/
|
||||
public String getSchedulerName()
|
||||
{
|
||||
return (schedulerName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for schedulerName
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withSchedulerName(String schedulerName)
|
||||
{
|
||||
this.schedulerName = schedulerName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for instanceName (used in object keys); defaults to the QInstance name
|
||||
*******************************************************************************/
|
||||
public String getInstanceName()
|
||||
{
|
||||
return (instanceName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for instanceName
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withInstanceName(String instanceName)
|
||||
{
|
||||
this.instanceName = instanceName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for maxConcurrentRuns
|
||||
*******************************************************************************/
|
||||
public Integer getMaxConcurrentRuns()
|
||||
{
|
||||
return (maxConcurrentRuns);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for maxConcurrentRuns
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withMaxConcurrentRuns(Integer maxConcurrentRuns)
|
||||
{
|
||||
this.maxConcurrentRuns = maxConcurrentRuns;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for slaCheckIntervalSeconds
|
||||
*******************************************************************************/
|
||||
public Integer getSlaCheckIntervalSeconds()
|
||||
{
|
||||
return (slaCheckIntervalSeconds);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for slaCheckIntervalSeconds
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withSlaCheckIntervalSeconds(Integer slaCheckIntervalSeconds)
|
||||
{
|
||||
this.slaCheckIntervalSeconds = slaCheckIntervalSeconds;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for openWhenAnonymous (local development only)
|
||||
*******************************************************************************/
|
||||
public Boolean getOpenWhenAnonymous()
|
||||
{
|
||||
return (openWhenAnonymous);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for openWhenAnonymous
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withOpenWhenAnonymous(Boolean openWhenAnonymous)
|
||||
{
|
||||
this.openWhenAnonymous = openWhenAnonymous;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for prometheusEndpoint
|
||||
*******************************************************************************/
|
||||
public Boolean getPrometheusEndpoint()
|
||||
{
|
||||
return (prometheusEndpoint);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for prometheusEndpoint
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withPrometheusEndpoint(Boolean prometheusEndpoint)
|
||||
{
|
||||
this.prometheusEndpoint = prometheusEndpoint;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for storageResolver
|
||||
*******************************************************************************/
|
||||
public Function<String, BackupStorage> getStorageResolver()
|
||||
{
|
||||
return (storageResolver);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for storageResolver: bucket alias to storage
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withStorageResolver(Function<String, BackupStorage> storageResolver)
|
||||
{
|
||||
this.storageResolver = storageResolver;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fileReverifyDays: CONTENT verification skips file-store objects
|
||||
** verified more recently than this.
|
||||
*******************************************************************************/
|
||||
public Integer getFileReverifyDays()
|
||||
{
|
||||
return (fileReverifyDays);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for fileReverifyDays
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withFileReverifyDays(Integer fileReverifyDays)
|
||||
{
|
||||
this.fileReverifyDays = fileReverifyDays;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for artifactTransformResolver (the decrypt hook for verify)
|
||||
*******************************************************************************/
|
||||
public Function<String, ArtifactTransform> getArtifactTransformResolver()
|
||||
{
|
||||
return (artifactTransformResolver);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for artifactTransformResolver: encryption key reference (the name
|
||||
** of a key in a key provider) to the transform that decrypts what that key wrote.
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withArtifactTransformResolver(Function<String, ArtifactTransform> artifactTransformResolver)
|
||||
{
|
||||
this.artifactTransformResolver = artifactTransformResolver;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for scratchTarget (where TEST_RESTORE restores to; null means MEMORY)
|
||||
*******************************************************************************/
|
||||
public ScratchTarget getScratchTarget()
|
||||
{
|
||||
return (scratchTarget);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for scratchTarget
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withScratchTarget(ScratchTarget scratchTarget)
|
||||
{
|
||||
this.scratchTarget = scratchTarget;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for planOwnerValidator (true when the owner still exists and holds backups.admin)
|
||||
*******************************************************************************/
|
||||
public Predicate<String> getPlanOwnerValidator()
|
||||
{
|
||||
return (planOwnerValidator);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for planOwnerValidator: with none, plan owners are not checked.
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withPlanOwnerValidator(Predicate<String> planOwnerValidator)
|
||||
{
|
||||
this.planOwnerValidator = planOwnerValidator;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for esbDestinationName (ESB events are published here, when set)
|
||||
*******************************************************************************/
|
||||
public String getEsbDestinationName()
|
||||
{
|
||||
return (esbDestinationName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for esbDestinationName
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withEsbDestinationName(String esbDestinationName)
|
||||
{
|
||||
this.esbDestinationName = esbDestinationName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,6 +17,18 @@
|
||||
package com.kingsrook.qbits.backups;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Properties;
|
||||
import com.kingsrook.qbits.backups.health.BackupsDestinationHealthChecks;
|
||||
import com.kingsrook.qbits.backups.health.BackupsSlaHealthCheck;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCategory;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckSupplierMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
|
||||
|
||||
@@ -29,13 +41,41 @@ public class BackupsQBitProducer implements QBitMetaDataProducer<BackupsQBitConf
|
||||
{
|
||||
public static final String GROUP_ID = "com.kingsrook.qbits";
|
||||
public static final String ARTIFACT_ID = "qbit-backups";
|
||||
public static final String VERSION = "0.1.0";
|
||||
public static final String VERSION = loadVersion();
|
||||
|
||||
private BackupsQBitConfig qBitConfig;
|
||||
private String namespace;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the artifact version from the Maven-filtered resource.
|
||||
*******************************************************************************/
|
||||
private static String loadVersion()
|
||||
{
|
||||
try(InputStream inputStream = BackupsQBitProducer.class.getClassLoader().getResourceAsStream("qbit-backups.properties"))
|
||||
{
|
||||
if(inputStream == null)
|
||||
{
|
||||
throw (new IllegalStateException("Missing qbit-backups.properties; build with Maven to generate the artifact version"));
|
||||
}
|
||||
Properties properties = new Properties();
|
||||
properties.load(inputStream);
|
||||
String version = properties.getProperty("version");
|
||||
if(version == null || version.isBlank() || version.contains("${"))
|
||||
{
|
||||
throw (new IllegalStateException("Missing or unfiltered version in qbit-backups.properties; build with Maven"));
|
||||
}
|
||||
return (version);
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw (new IllegalStateException("Could not read artifact version from qbit-backups.properties", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for the config.
|
||||
*******************************************************************************/
|
||||
@@ -100,4 +140,83 @@ public class BackupsQBitProducer implements QBitMetaDataProducer<BackupsQBitConf
|
||||
return (qBitConfig != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build everything and add it to the instance: possible-value sources, joins,
|
||||
** tables, processes, widgets and the app.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput produce(com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance) throws com.kingsrook.qqq.backend.core.exceptions.QException
|
||||
{
|
||||
java.util.List<String> errors = new java.util.ArrayList<>();
|
||||
qBitConfig.validate(qInstance, errors);
|
||||
if(!errors.isEmpty())
|
||||
{
|
||||
throw (new com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfigValidationException(qBitConfig, errors));
|
||||
}
|
||||
QBitMetaData qBit = getQBitMetaData();
|
||||
qInstance.addQBit(qBit);
|
||||
BackupsRuntime.install(qBitConfig);
|
||||
|
||||
com.kingsrook.qbits.backups.alerts.AlertsQBitAlertSink.bindIfAbsent();
|
||||
new com.kingsrook.qbits.backups.alerts.AlertsQBitAlertSink().registerAlertTypes(qInstance);
|
||||
|
||||
com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput output = new com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput();
|
||||
for(var possibleValueSource : com.kingsrook.qbits.backups.model.BackupsTables.possibleValueSources())
|
||||
{
|
||||
output.add(possibleValueSource);
|
||||
}
|
||||
for(var join : com.kingsrook.qbits.backups.model.BackupsTables.joins())
|
||||
{
|
||||
output.add(join);
|
||||
}
|
||||
java.util.List<com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData> tables = new java.util.ArrayList<>();
|
||||
for(var table : com.kingsrook.qbits.backups.model.BackupsTables.tables(qBitConfig))
|
||||
{
|
||||
var customized = qBitConfig.getTableMetaDataCustomizer() == null ? table : qBitConfig.getTableMetaDataCustomizer().customizeMetaData(qInstance, table);
|
||||
customized.setSourceQBitName(qBit.getName());
|
||||
tables.add(customized);
|
||||
output.add(customized);
|
||||
}
|
||||
for(var process : com.kingsrook.qbits.backups.processes.BackupsProcesses.processes())
|
||||
{
|
||||
process.setSourceQBitName(qBit.getName());
|
||||
output.add(process);
|
||||
}
|
||||
for(var process : com.kingsrook.qbits.backups.processes.ExportTableProcess.exportProcesses(qInstance))
|
||||
{
|
||||
process.setSourceQBitName(qBit.getName());
|
||||
output.add(process);
|
||||
}
|
||||
for(var widget : com.kingsrook.qbits.backups.app.BackupsApp.widgets())
|
||||
{
|
||||
output.add(widget);
|
||||
}
|
||||
for(var widget : com.kingsrook.qbits.backups.model.BackupsTables.widgets())
|
||||
{
|
||||
output.add(widget);
|
||||
}
|
||||
com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData app = com.kingsrook.qbits.backups.app.BackupsApp.app(qBitConfig, tables);
|
||||
output.add(app);
|
||||
output.addSelfToInstance(qInstance);
|
||||
// Registration is metadata-only; core guards startup sync when helpContent is absent.
|
||||
HelpBundles.register(qInstance, "help/qbit-backups.yaml");
|
||||
|
||||
HealthChecksMetaData.ofOrWithNew(qInstance)
|
||||
.withCheck(new HealthCheckMetaData().withName(BackupsSlaHealthCheck.NAME).withLabel("Backup SLA")
|
||||
.withComponent("backups").withKind(HealthCheckKind.DEPENDENCY).withCategory(HealthCategory.STORAGE)
|
||||
.withIntervalSeconds(900).withCheck(new QCodeReference(BackupsSlaHealthCheck.class)))
|
||||
.withSupplier(new HealthCheckSupplierMetaData().withNamePrefix(BackupsDestinationHealthChecks.PREFIX)
|
||||
.withComponent("backups").withSupplier(new QCodeReference(BackupsDestinationHealthChecks.class)));
|
||||
|
||||
// A parent app only lists the apps that are its children, so attach ourselves (for example Admin, then Backups).
|
||||
com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData parent = qBitConfig.getParentAppName() == null ? null : qInstance.getApp(qBitConfig.getParentAppName());
|
||||
if(parent != null && (parent.getChildren() == null || parent.getChildren().stream().noneMatch(child -> app.getName().equals(child.getName()))))
|
||||
{
|
||||
parent.withChild(app);
|
||||
}
|
||||
return (new com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups;
|
||||
|
||||
|
||||
import java.time.Clock;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Function;
|
||||
import com.kingsrook.qbits.backups.alerts.BackupAlertSink;
|
||||
import com.kingsrook.qbits.backups.alerts.NoOpBackupAlertSink;
|
||||
import com.kingsrook.qbits.backups.storage.BackupStorage;
|
||||
import com.kingsrook.qbits.backups.storage.BackupStorageException;
|
||||
import com.kingsrook.qqq.backend.core.cluster.ClusterCaches;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Process-wide state of the installed qbit: the config, the clock, the alert
|
||||
** sink and the per-process permission map. Static because QQQ instantiates
|
||||
** steps and permission checkers by class reference.
|
||||
*******************************************************************************/
|
||||
public final class BackupsRuntime
|
||||
{
|
||||
private static final Map<String, String> PROCESS_PERMISSIONS = ClusterCaches.nodeLocal(new ConcurrentHashMap<>(),
|
||||
"built from the qbit config at startup, the same on every node");
|
||||
|
||||
private static volatile BackupsQBitConfig config;
|
||||
private static volatile Clock clock = Clock.systemUTC();
|
||||
private static volatile BackupAlertSink alertSink = new NoOpBackupAlertSink();
|
||||
private static volatile Function<String, String> environment = System::getenv;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private BackupsRuntime()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Install the config (called by the producer).
|
||||
*******************************************************************************/
|
||||
public static void install(BackupsQBitConfig newConfig)
|
||||
{
|
||||
config = newConfig;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Forget everything (tests).
|
||||
*******************************************************************************/
|
||||
public static void reset()
|
||||
{
|
||||
config = null;
|
||||
clock = Clock.systemUTC();
|
||||
alertSink = new NoOpBackupAlertSink();
|
||||
environment = System::getenv;
|
||||
PROCESS_PERMISSIONS.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The installed config, or null.
|
||||
*******************************************************************************/
|
||||
public static BackupsQBitConfig getConfig()
|
||||
{
|
||||
return (config);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the qbit is installed.
|
||||
*******************************************************************************/
|
||||
public static boolean isInstalled()
|
||||
{
|
||||
return (config != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether permissions are open on fully anonymous instances (local development).
|
||||
*******************************************************************************/
|
||||
public static boolean isOpenWhenAnonymous()
|
||||
{
|
||||
return (config != null && Boolean.TRUE.equals(config.getOpenWhenAnonymous()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The clock (replaceable in tests).
|
||||
*******************************************************************************/
|
||||
public static Clock getClock()
|
||||
{
|
||||
return (clock);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Replace the clock.
|
||||
*******************************************************************************/
|
||||
public static void setClock(Clock newClock)
|
||||
{
|
||||
clock = newClock;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The alert sink.
|
||||
*******************************************************************************/
|
||||
public static BackupAlertSink getAlertSink()
|
||||
{
|
||||
return (alertSink);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind an alert sink (the Alerts qbit adapter, or a test double).
|
||||
*******************************************************************************/
|
||||
public static void setAlertSink(BackupAlertSink newSink)
|
||||
{
|
||||
alertSink = newSink == null ? new NoOpBackupAlertSink() : newSink;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Environment lookup (replaceable in tests).
|
||||
*******************************************************************************/
|
||||
public static Function<String, String> getEnvironment()
|
||||
{
|
||||
return (environment);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Replace the environment lookup.
|
||||
*******************************************************************************/
|
||||
public static void setEnvironment(Function<String, String> newEnvironment)
|
||||
{
|
||||
environment = newEnvironment;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The permission each qbit process needs, by process name.
|
||||
*******************************************************************************/
|
||||
public static Map<String, String> processPermissions()
|
||||
{
|
||||
return (PROCESS_PERMISSIONS);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the storage for a bucket alias.
|
||||
*******************************************************************************/
|
||||
public static BackupStorage storageFor(String bucketAlias)
|
||||
{
|
||||
if(config == null || config.getStorageResolver() == null)
|
||||
{
|
||||
throw (new BackupStorageException(BackupStorageException.Reason.UNSUPPORTED, "No storage resolver is configured for the Backups qbit"));
|
||||
}
|
||||
BackupStorage storage = config.getStorageResolver().apply(bucketAlias);
|
||||
if(storage == null)
|
||||
{
|
||||
throw (new BackupStorageException(BackupStorageException.Reason.NOT_FOUND, "No storage bucket is configured with the alias: " + bucketAlias));
|
||||
}
|
||||
return (storage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.alerts;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Direct adapter to core QAlerts. Calls are no-ops without a configured alerts
|
||||
** sink, including when only the backup alert types have been registered.
|
||||
** QAlerts catches and logs dispatch failures; adapter failures are logged once.
|
||||
** Heartbeat intervals remain the responsibility of the backup SLA checker.
|
||||
*******************************************************************************/
|
||||
public class AlertsQBitAlertSink implements BackupAlertSink
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AlertsQBitAlertSink.class);
|
||||
|
||||
private boolean warned = false;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind the core adapter unless the host already supplied a backup sink.
|
||||
*******************************************************************************/
|
||||
public static boolean bindIfAbsent()
|
||||
{
|
||||
if(!(BackupsRuntime.getAlertSink() instanceof NoOpBackupAlertSink))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
BackupsRuntime.setAlertSink(new AlertsQBitAlertSink());
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare missing backup alert types on each instance, independently of sink
|
||||
** installation order. Returns how many were added.
|
||||
*******************************************************************************/
|
||||
public int registerAlertTypes(QInstance qInstance)
|
||||
{
|
||||
int added = 0;
|
||||
for(BackupAlerts.AlertType alertType : BackupAlerts.alertTypes())
|
||||
{
|
||||
try
|
||||
{
|
||||
if(QAlertTypeMetaData.of(qInstance, alertType.name()) == null)
|
||||
{
|
||||
new QAlertTypeMetaData()
|
||||
.withName(alertType.name())
|
||||
.withLabel(alertType.label())
|
||||
.withGroupName("Backups")
|
||||
.withHowToFix(alertType.howToFix())
|
||||
.withDefaultSeverity(AlertSeverity.valueOf(alertType.defaultSeverity().name()))
|
||||
.addSelfToInstance(qInstance);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
catch(RuntimeException | LinkageError e)
|
||||
{
|
||||
warnOnce("Could not declare the alert type " + alertType.name(), e);
|
||||
}
|
||||
}
|
||||
return (added);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void raise(String type, String dedupeKey, Severity severity, String title, Map<String, String> detail)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(!isAlertsQBitInstalled())
|
||||
{
|
||||
return;
|
||||
}
|
||||
AlertSignal signal = new AlertSignal(type)
|
||||
.withDedupKey(dedupeKey)
|
||||
.withSeverity(AlertSeverity.valueOf(severity.name()))
|
||||
.withTitle(title);
|
||||
if(detail != null && !detail.isEmpty())
|
||||
{
|
||||
signal.withSummary(detail.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()).collect(Collectors.joining(", ")));
|
||||
detail.forEach(signal::withAttribute);
|
||||
}
|
||||
QAlerts.raise(signal);
|
||||
}
|
||||
catch(RuntimeException | LinkageError e)
|
||||
{
|
||||
warnOnce("Could not raise an alert", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void resolve(String type, String dedupeKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(isAlertsQBitInstalled())
|
||||
{
|
||||
QAlerts.resolve(type, dedupeKey, "Cleared by the Backups qbit");
|
||||
}
|
||||
}
|
||||
catch(RuntimeException | LinkageError e)
|
||||
{
|
||||
warnOnce("Could not resolve an alert", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void heartbeat(String monitorKey, long expectedEverySeconds, long graceSeconds)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(isAlertsQBitInstalled())
|
||||
{
|
||||
QAlerts.heartbeat(monitorKey);
|
||||
}
|
||||
}
|
||||
catch(RuntimeException | LinkageError e)
|
||||
{
|
||||
warnOnce("Could not check in a heartbeat", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Check on every call so installing the alerts sink after this qbit works.
|
||||
** Type declarations alone do not mean an alerts implementation is installed.
|
||||
*******************************************************************************/
|
||||
boolean isAlertsQBitInstalled()
|
||||
{
|
||||
AlertsInstanceMetaData metaData = AlertsInstanceMetaData.of(QContext.getQInstance());
|
||||
return (metaData != null && metaData.getSink() != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Log adapter failures once; dispatch failures are already logged by core.
|
||||
*******************************************************************************/
|
||||
private synchronized void warnOnce(String message, Throwable t)
|
||||
{
|
||||
if(!warned)
|
||||
{
|
||||
warned = true;
|
||||
LOG.warn(message + " (the Alerts qbit adapter will keep failing quietly)", t);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.alerts;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Where backups raises and resolves alerts. The adapter for the Alerts qbit
|
||||
** producer SPI binds here when that qbit is installed; otherwise the no-op
|
||||
** sink is used and every condition is still visible on the Backups home and in
|
||||
** the tables. An alert has a stable dedupe key; counts and links only, never
|
||||
** values, keys or presigned URLs.
|
||||
*******************************************************************************/
|
||||
public interface BackupAlertSink
|
||||
{
|
||||
|
||||
/***************************************************************************
|
||||
** Raise (or refresh) an alert.
|
||||
**
|
||||
** @param type alert type, e.g. backups.plan.missed
|
||||
** @param dedupeKey stable key, type plus plan or run
|
||||
** @param severity severity
|
||||
** @param title short title
|
||||
** @param detail counts and context only
|
||||
***************************************************************************/
|
||||
void raise(String type, String dedupeKey, Severity severity, String title, Map<String, String> detail);
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Resolve an alert that was raised under the dedupe key.
|
||||
***************************************************************************/
|
||||
void resolve(String type, String dedupeKey);
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Check in a heartbeat monitor: the plan ran successfully.
|
||||
***************************************************************************/
|
||||
void heartbeat(String monitorKey, long expectedEverySeconds, long graceSeconds);
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Severities.
|
||||
***************************************************************************/
|
||||
enum Severity
|
||||
{
|
||||
INFO, WARNING, ERROR, CRITICAL
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.alerts;
|
||||
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every alert the Backups qbit raises (design spec section 10.4), with stable
|
||||
** dedupe keys, and helpers that send them through the bound BackupAlertSink.
|
||||
**
|
||||
** A dedupe key is the alert type plus a plan key (or a run key, or a plan key
|
||||
** and a verify level), so a repeated condition refreshes one alert and a cleared
|
||||
** condition resolves exactly that alert. Details carry counts and names only:
|
||||
** never row values, object keys with secrets in them, or presigned URLs.
|
||||
*******************************************************************************/
|
||||
public final class BackupAlerts
|
||||
{
|
||||
public static final String PLAN_MISSED = "backups.plan.missed";
|
||||
public static final String RUN_FAILED = "backups.run.failed";
|
||||
public static final String RUN_MISSING = "backups.run.missing-in-storage";
|
||||
public static final String FILES_MISSING = "backups.files.missing";
|
||||
public static final String VERIFY_FAILED = "backups.verify.failed";
|
||||
public static final String VERIFY_OVERDUE = "backups.verify.overdue";
|
||||
public static final String PLAN_OWNER_INVALID = "backups.plan.owner-invalid";
|
||||
public static final String STORAGE_DEBRIS = "backups.storage.debris";
|
||||
public static final String RESTORE_COMPLETED = "backups.restore.completed";
|
||||
|
||||
/** The prefix of the heartbeat monitor key of a plan. */
|
||||
public static final String HEARTBEAT_PREFIX = "backups.plan:";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A declared alert type, for the Alerts qbit's type registry.
|
||||
**
|
||||
** @param name the alert type name
|
||||
** @param label a short label
|
||||
** @param defaultSeverity the severity when a signal does not say
|
||||
** @param howToFix one sentence for the person looking at the alert
|
||||
***************************************************************************/
|
||||
public record AlertType(String name, String label, BackupAlertSink.Severity defaultSeverity, String howToFix)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private BackupAlerts()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The alert types of the spec table (section 10.4), in table order.
|
||||
*******************************************************************************/
|
||||
public static List<AlertType> alertTypes()
|
||||
{
|
||||
return (List.of(
|
||||
new AlertType(PLAN_MISSED, "Backup did not run", BackupAlertSink.Severity.ERROR,
|
||||
"Open the plan and its latest runs, check the schedule and the scheduler, then run the backup by hand."),
|
||||
new AlertType(RUN_FAILED, "Backup run failed", BackupAlertSink.Severity.WARNING,
|
||||
"Open the failed run for the error, fix the cause, and run the plan again."),
|
||||
new AlertType(RUN_MISSING, "Backup is missing from storage", BackupAlertSink.Severity.CRITICAL,
|
||||
"The catalog says the run succeeded but its manifest is gone from the bucket. Check the bucket, its lifecycle rules and the credentials, then run a new backup."),
|
||||
new AlertType(FILES_MISSING, "Backed up files were missing at the source", BackupAlertSink.Severity.WARNING,
|
||||
"Open the run's file index: records point at files that no longer exist."),
|
||||
new AlertType(VERIFY_FAILED, "Backup verification failed", BackupAlertSink.Severity.ERROR,
|
||||
"Open the verification for the mismatches. Treat the run as unusable and take a new backup."),
|
||||
new AlertType(VERIFY_OVERDUE, "Backup verification is overdue", BackupAlertSink.Severity.WARNING,
|
||||
"Run Verify backup on a recent run of the plan, or set a verify schedule on the plan."),
|
||||
new AlertType(PLAN_OWNER_INVALID, "Backup plan owner is not valid", BackupAlertSink.Severity.WARNING,
|
||||
"Set an owner who exists and holds backups.admin on the plan."),
|
||||
new AlertType(STORAGE_DEBRIS, "Backup storage has debris or orphans", BackupAlertSink.Severity.INFO,
|
||||
"Prune clears debris. Orphan prefixes can be imported into the catalog or deleted."),
|
||||
new AlertType(RESTORE_COMPLETED, "A restore finished", BackupAlertSink.Severity.INFO,
|
||||
"Nothing to fix unless the restore failed. Open the restore run.")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The stable dedupe key for a type and an id (plan key, run key...).
|
||||
*******************************************************************************/
|
||||
public static String key(String type, String id)
|
||||
{
|
||||
return (type + ":" + id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The dedupe key of a verify failure: it includes the level so a pass at a
|
||||
** higher level clears the lower failures, and a cheap pass never hides a
|
||||
** content failure.
|
||||
*******************************************************************************/
|
||||
public static String verifyKey(String planKey, String level)
|
||||
{
|
||||
return (VERIFY_FAILED + ":" + planKey + ":" + level);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Raise (or refresh) an alert. Never throws: an alerting problem must not fail
|
||||
** a backup or a check.
|
||||
*******************************************************************************/
|
||||
public static void raise(String type, String id, BackupAlertSink.Severity severity, String title, String... detailPairs)
|
||||
{
|
||||
try
|
||||
{
|
||||
Map<String, String> detail = new LinkedHashMap<>();
|
||||
for(int i = 0; i + 1 < detailPairs.length; i += 2)
|
||||
{
|
||||
detail.put(detailPairs[i], detailPairs[i + 1]);
|
||||
}
|
||||
BackupsRuntime.getAlertSink().raise(type, key(type, id), severity, title, detail);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
// fire and forget
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Raise with an explicit dedupe key.
|
||||
*******************************************************************************/
|
||||
public static void raiseWithKey(String type, String dedupeKey, BackupAlertSink.Severity severity, String title, Map<String, String> detail)
|
||||
{
|
||||
try
|
||||
{
|
||||
BackupsRuntime.getAlertSink().raise(type, dedupeKey, severity, title, detail);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
// fire and forget
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the alert of a type and id. Never throws.
|
||||
*******************************************************************************/
|
||||
public static void resolve(String type, String id)
|
||||
{
|
||||
resolveKey(type, key(type, id));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve an alert by its dedupe key. Never throws.
|
||||
*******************************************************************************/
|
||||
public static void resolveKey(String type, String dedupeKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
BackupsRuntime.getAlertSink().resolve(type, dedupeKey);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
// fire and forget
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A restore finished: an info alert, so restores are never silent (error when
|
||||
** it failed). The Alerts qbit clears it after 7 days; nothing resolves it here.
|
||||
*******************************************************************************/
|
||||
public static void restoreCompleted(String restoreRunKey, String status, String planKey, long rowsRestored)
|
||||
{
|
||||
boolean failed = "FAILED".equals(status);
|
||||
raise(RESTORE_COMPLETED, restoreRunKey, failed ? BackupAlertSink.Severity.ERROR : BackupAlertSink.Severity.INFO,
|
||||
failed ? "A restore failed" : "A restore finished",
|
||||
"restoreRun", restoreRunKey, "status", String.valueOf(status), "plan", String.valueOf(planKey), "rows", String.valueOf(rowsRestored));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.alerts;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The sink used when the Alerts qbit is not installed.
|
||||
*******************************************************************************/
|
||||
public class NoOpBackupAlertSink implements BackupAlertSink
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void raise(String type, String dedupeKey, Severity severity, String title, Map<String, String> detail)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void resolve(String type, String dedupeKey)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void heartbeat(String monitorKey, long expectedEverySeconds, long graceSeconds)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.app;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.backups.BackupsQBitConfig;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Backups app (a sub-app under Admin when configured) and its widgets. The
|
||||
** home is the single owner of backup data: every card is built from the qbit
|
||||
** tables, and each has an honest empty state. The nav lists the main tables;
|
||||
** sub-tables appear as child lists on their parents.
|
||||
*******************************************************************************/
|
||||
public final class BackupsApp
|
||||
{
|
||||
public static final String HOME_WIDGET = "backupsHome";
|
||||
public static final String HEALTH_CARD_WIDGET = "backupsHealthCard";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private BackupsApp()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The widgets.
|
||||
*******************************************************************************/
|
||||
public static List<QWidgetMetaData> widgets()
|
||||
{
|
||||
return (List.of(
|
||||
new QWidgetMetaData().withName(HOME_WIDGET).withLabel("Overview").withType(WidgetType.HTML.getType())
|
||||
.withCodeReference(new QCodeReference(HomeRenderer.class)).withGridColumns(12).withIsCard(false).withPermissionRules(BackupsTables.permissionRules()),
|
||||
new QWidgetMetaData().withName(HEALTH_CARD_WIDGET).withLabel("Backups").withType(WidgetType.HTML.getType())
|
||||
.withCodeReference(new QCodeReference(HealthCardRenderer.class)).withGridColumns(12).withIsCard(true).withPermissionRules(BackupsTables.permissionRules())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The app: the main tables only; sub-tables are relations on their parents.
|
||||
*******************************************************************************/
|
||||
public static QAppMetaData app(BackupsQBitConfig config, List<QTableMetaData> tables)
|
||||
{
|
||||
QAppMetaData app = new QAppMetaData()
|
||||
.withName(config.getAppName())
|
||||
.withLabel(config.getAppLabel())
|
||||
.withIcon(new QIcon("backup"))
|
||||
.withPermissionRules(BackupsTables.permissionRules())
|
||||
.withWidgets(List.of(HOME_WIDGET));
|
||||
if(config.getParentAppName() != null)
|
||||
{
|
||||
app.setParentAppName(config.getParentAppName());
|
||||
}
|
||||
for(QTableMetaData table : tables)
|
||||
{
|
||||
if(BackupsTables.NAV_TABLES.contains(table.getName()))
|
||||
{
|
||||
app.withChild(table);
|
||||
}
|
||||
}
|
||||
return (app);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The home.
|
||||
*******************************************************************************/
|
||||
public static class HomeRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Render.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
return (new RenderWidgetOutput(new RawHTML(null, BackupsHomeView.render(BackupsHomeData.load(), pathPrefix()))));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The status card for Admin, Health.
|
||||
*******************************************************************************/
|
||||
public static class HealthCardRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Render.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
return (new RenderWidgetOutput(new RawHTML("Backups", BackupsHomeView.renderHealthCard(BackupsHomeData.load(), pathPrefix()))));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The path prefix of table and record links.
|
||||
*******************************************************************************/
|
||||
static String pathPrefix()
|
||||
{
|
||||
var config = com.kingsrook.qbits.backups.BackupsRuntime.getConfig();
|
||||
return (config == null ? "/app" : config.getTablePathPrefix());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.app;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The data behind the Backups home: plan status, what needs attention, recent
|
||||
** activity and the size trend. Everything is read from the qbit tables; nothing
|
||||
** is invented. Home cards read the denormalized plan fields (slaStatus, last
|
||||
** success) that the processes and the SLA checker maintain.
|
||||
*******************************************************************************/
|
||||
public class BackupsHomeData
|
||||
{
|
||||
/** Plan statuses that need attention (the plan or its backups have a problem). */
|
||||
public static final List<String> ATTENTION = List.of("LATE", "FAILING", "MISSING_IN_STORAGE", "UNVERIFIED", "ABANDONED", "OWNER_INVALID");
|
||||
|
||||
private final List<QRecord> plans;
|
||||
private final List<QRecord> recentRuns;
|
||||
private final List<QRecord> failedRuns;
|
||||
private final List<QRecord> verifications;
|
||||
private final List<QRecord> restores;
|
||||
private final Instant now;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
BackupsHomeData(List<QRecord> plans, List<QRecord> recentRuns, List<QRecord> failedRuns, List<QRecord> verifications, List<QRecord> restores, Instant now)
|
||||
{
|
||||
this.plans = plans;
|
||||
this.recentRuns = recentRuns;
|
||||
this.failedRuns = failedRuns;
|
||||
this.verifications = verifications;
|
||||
this.restores = restores;
|
||||
this.now = now;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Load from the tables.
|
||||
*******************************************************************************/
|
||||
public static BackupsHomeData load() throws QException
|
||||
{
|
||||
BackupCatalog catalog = new BackupCatalog();
|
||||
Instant now = BackupsRuntime.getClock().instant();
|
||||
List<QRecord> plans = catalog.query(BackupsTables.PLAN, new QQueryFilter().withOrderBy(new QFilterOrderBy("name", true)));
|
||||
|
||||
QQueryFilter recent = new QQueryFilter().withOrderBy(new QFilterOrderBy("id", false));
|
||||
recent.setLimit(200);
|
||||
List<QRecord> runs = catalog.query(BackupsTables.RUN, recent);
|
||||
List<QRecord> failed = new ArrayList<>();
|
||||
Instant weekAgo = now.minus(Duration.ofDays(7));
|
||||
for(QRecord run : runs)
|
||||
{
|
||||
Instant start = run.getValueInstant("startDate");
|
||||
if(List.of("FAILED", "TIMED_OUT", "ABANDONED").contains(run.getValueString("status")) && (start == null || start.isAfter(weekAgo)))
|
||||
{
|
||||
failed.add(run);
|
||||
}
|
||||
}
|
||||
QQueryFilter verificationFilter = new QQueryFilter().withOrderBy(new QFilterOrderBy("id", false));
|
||||
verificationFilter.setLimit(100);
|
||||
QQueryFilter restoreFilter = new QQueryFilter().withOrderBy(new QFilterOrderBy("id", false));
|
||||
restoreFilter.setLimit(20);
|
||||
return (new BackupsHomeData(plans, runs, failed, catalog.query(BackupsTables.VERIFICATION, verificationFilter),
|
||||
catalog.query(BackupsTables.RESTORE_RUN, restoreFilter), now));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Plans.
|
||||
*******************************************************************************/
|
||||
public List<QRecord> plans()
|
||||
{
|
||||
return (plans);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recent runs (newest first).
|
||||
*******************************************************************************/
|
||||
public List<QRecord> recentRuns()
|
||||
{
|
||||
return (recentRuns);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Runs that failed in the last 7 days.
|
||||
*******************************************************************************/
|
||||
public List<QRecord> failedRuns()
|
||||
{
|
||||
return (failedRuns);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Verifications (newest first).
|
||||
*******************************************************************************/
|
||||
public List<QRecord> verifications()
|
||||
{
|
||||
return (verifications);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Restores (newest first).
|
||||
*******************************************************************************/
|
||||
public List<QRecord> restores()
|
||||
{
|
||||
return (restores);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The time the data was loaded.
|
||||
*******************************************************************************/
|
||||
public Instant now()
|
||||
{
|
||||
return (now);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The status to show for a plan: the stored slaStatus when it is a problem state,
|
||||
** otherwise LATE when the age of the last success is past the SLA (the SLA
|
||||
** checker may not have run yet), otherwise the stored status (UNKNOWN when none).
|
||||
*******************************************************************************/
|
||||
public String effectiveStatus(QRecord plan)
|
||||
{
|
||||
String stored = plan.getValueString("slaStatus");
|
||||
if(stored != null && ATTENTION.contains(stored))
|
||||
{
|
||||
return (stored);
|
||||
}
|
||||
Long age = ageHours(plan);
|
||||
Integer sla = plan.getValueInteger("slaHours");
|
||||
if(age != null && sla != null && age > sla)
|
||||
{
|
||||
return ("LATE");
|
||||
}
|
||||
return (stored == null ? "UNKNOWN" : stored);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Plans that need attention.
|
||||
*******************************************************************************/
|
||||
public List<QRecord> plansNeedingAttention()
|
||||
{
|
||||
return (plans.stream().filter(p -> Boolean.TRUE.equals(p.getValueBoolean("isActive")) && ATTENTION.contains(effectiveStatus(p))).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The last run of a plan (from the recent runs), or null.
|
||||
*******************************************************************************/
|
||||
public QRecord lastRun(QRecord plan)
|
||||
{
|
||||
Integer lastRunId = plan.getValueInteger("lastRunId");
|
||||
for(QRecord run : recentRuns)
|
||||
{
|
||||
if(lastRunId != null && lastRunId.equals(run.getValueInteger("id")))
|
||||
{
|
||||
return (run);
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The overall state: NO_PLANS, ATTENTION or OK.
|
||||
*******************************************************************************/
|
||||
public String overallState()
|
||||
{
|
||||
if(plans.isEmpty())
|
||||
{
|
||||
return ("NO_PLANS");
|
||||
}
|
||||
return (plansNeedingAttention().isEmpty() && failedRuns.isEmpty() ? "OK" : "ATTENTION");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Hours since the last successful backup of a plan, or null when it has none.
|
||||
*******************************************************************************/
|
||||
public Long ageHours(QRecord plan)
|
||||
{
|
||||
Instant last = plan.getValueInstant("lastSuccessDate");
|
||||
return (last == null ? null : Duration.between(last, now).toHours());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The latest test restore of a plan, or null.
|
||||
*******************************************************************************/
|
||||
public QRecord latestTestRestore(Integer planId)
|
||||
{
|
||||
for(QRecord verification : verifications)
|
||||
{
|
||||
if("TEST_RESTORE".equals(verification.getValueString("level")))
|
||||
{
|
||||
Integer runId = verification.getValueInteger("backupRunId");
|
||||
for(QRecord run : recentRuns)
|
||||
{
|
||||
if(runId != null && planId != null && runId.equals(run.getValueInteger("id")) && planId.equals(run.getValueInteger("backupPlanId")))
|
||||
{
|
||||
return (verification);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Successful runs of a plan, oldest first, for the trend (bytes stored, rows, duration).
|
||||
*******************************************************************************/
|
||||
public List<QRecord> successfulRuns(Integer planId)
|
||||
{
|
||||
List<QRecord> rs = new ArrayList<>();
|
||||
if(planId == null)
|
||||
{
|
||||
return (rs);
|
||||
}
|
||||
for(QRecord run : recentRuns)
|
||||
{
|
||||
if(planId.equals(run.getValueInteger("backupPlanId")) && run.getValueString("status") != null && run.getValueString("status").startsWith("SUCCEEDED"))
|
||||
{
|
||||
rs.add(0, run);
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Runs kept per retention class for a plan (as classified by the last prune).
|
||||
*******************************************************************************/
|
||||
public Map<String, Integer> retentionCounts(Integer planId)
|
||||
{
|
||||
Map<String, Integer> counts = new LinkedHashMap<>();
|
||||
if(planId == null)
|
||||
{
|
||||
return (counts);
|
||||
}
|
||||
for(QRecord run : recentRuns)
|
||||
{
|
||||
if(planId.equals(run.getValueInteger("backupPlanId")) && run.getValueString("status") != null && run.getValueString("status").startsWith("SUCCEEDED")
|
||||
&& run.getValue("prunedDate") == null)
|
||||
{
|
||||
counts.merge(run.getValueString("retentionClass") == null ? "UNCLASSIFIED" : run.getValueString("retentionClass"), 1, Integer::sum);
|
||||
}
|
||||
}
|
||||
return (counts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.app;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renders the Backups home: a plan status row per plan across the full width,
|
||||
** then cards for what needs attention, the latest test restore, recent activity,
|
||||
** the size trend and retention. Every card has an honest empty state. Layout
|
||||
** is flow and grid only (no tables, no fixed widths, no inner scrolling): plan
|
||||
** rows wrap their cells and the cards stack to one column on narrow screens,
|
||||
** long names wrap, and colors are inherited (only the status dot is colored), so
|
||||
** it reads at every width in light and dark mode.
|
||||
*******************************************************************************/
|
||||
public final class BackupsHomeView
|
||||
{
|
||||
private static final DateTimeFormatter WHEN = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm 'UTC'").withZone(ZoneOffset.UTC);
|
||||
|
||||
private static final String ROOT = "display:grid;gap:1rem;min-width:0;";
|
||||
private static final String GRID = "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,24rem),1fr));gap:1rem;align-items:start;min-width:0;";
|
||||
private static final String CARD = "border:1px solid rgba(127,127,127,.3);border-radius:8px;padding:1rem;min-width:0;overflow-wrap:anywhere;";
|
||||
private static final String ROW = "border:1px solid rgba(127,127,127,.25);border-radius:6px;padding:.75rem;margin:0 0 .75rem;min-width:0;";
|
||||
private static final String CELLS = "display:grid;grid-template-columns:repeat(auto-fit,minmax(9.5rem,1fr));gap:.6rem 1rem;margin:.6rem 0 0;font-size:.9rem;";
|
||||
private static final String LIST = "display:grid;grid-template-columns:minmax(7rem,auto) 1fr;gap:.25rem .75rem;margin:0 0 .75rem;font-size:.9rem;";
|
||||
private static final String ITEMS = "margin:0 0 .75rem;padding-left:1.25rem;list-style:disc;font-size:.9rem;display:grid;gap:.35rem;";
|
||||
private static final String MUTED = "opacity:.75;";
|
||||
private static final String LINK = "color:inherit;text-decoration:underline;text-underline-offset:2px;";
|
||||
private static final String CHIP = "display:inline-flex;align-items:center;gap:.4rem;padding:.05rem .6rem;border-radius:999px;border:1px solid rgba(127,127,127,.4);font-size:.8rem;white-space:nowrap;";
|
||||
|
||||
private static final int TREND_BARS = 30;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private BackupsHomeView()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The whole home; pathPrefix is the prefix of table and record links.
|
||||
*******************************************************************************/
|
||||
public static String render(BackupsHomeData data, String pathPrefix)
|
||||
{
|
||||
StringBuilder html = new StringBuilder("<div class=\"backups-home\" style=\"" + ROOT + "\">");
|
||||
List<BackupsSetupStatus.Item> setupItems = BackupsSetupStatus.load(data.plans());
|
||||
planStatus(html, data, pathPrefix);
|
||||
setup(html, setupItems, pathPrefix);
|
||||
html.append("<div style=\"").append(GRID).append("\">");
|
||||
needsAttention(html, data, setupItems, pathPrefix);
|
||||
latestTestRestore(html, data, pathPrefix);
|
||||
recentActivity(html, data, pathPrefix);
|
||||
trend(html, data);
|
||||
retention(html, data);
|
||||
return (html.append("</div></div>").toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The one status card for Admin, Health.
|
||||
*******************************************************************************/
|
||||
public static String renderHealthCard(BackupsHomeData data, String pathPrefix)
|
||||
{
|
||||
String text = switch(data.overallState())
|
||||
{
|
||||
case "NO_PLANS" -> "No backup plans yet.";
|
||||
case "ATTENTION" -> "Needs attention: see the Backups home.";
|
||||
default -> "All backups are within their SLA.";
|
||||
};
|
||||
return ("<div style=\"" + CARD + "\"><strong>Backups</strong><p>" + esc(text) + "</p><a style=\"" + LINK + "\" href=\"" + esc(pathPrefix) + "\">Open the Backups home</a></div>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Plan status: one row per plan, the cells wrap to the width.
|
||||
*******************************************************************************/
|
||||
private static void planStatus(StringBuilder html, BackupsHomeData data, String prefix)
|
||||
{
|
||||
open(html, "Plan status");
|
||||
if(data.plans().isEmpty())
|
||||
{
|
||||
empty(html, "No backup plans yet. Create one from Backup plans, New.");
|
||||
}
|
||||
for(QRecord plan : data.plans())
|
||||
{
|
||||
Long age = data.ageHours(plan);
|
||||
Integer sla = plan.getValueInteger("slaHours");
|
||||
String status = data.effectiveStatus(plan);
|
||||
QRecord last = data.lastRun(plan);
|
||||
|
||||
html.append("<div style=\"").append(ROW).append("\"><div style=\"display:flex;flex-wrap:wrap;align-items:center;gap:.5rem .75rem;\">");
|
||||
html.append("<strong>").append(recordLink(prefix, BackupsTables.PLAN, plan.getValue("id"), plan.getValueString("name"))).append("</strong>");
|
||||
html.append(chip(status));
|
||||
if(!Boolean.TRUE.equals(plan.getValueBoolean("isActive")))
|
||||
{
|
||||
html.append("<span style=\"").append(MUTED).append("\">Not active</span>");
|
||||
}
|
||||
html.append("</div><div style=\"").append(CELLS).append("\">");
|
||||
cell(html, "Last backup", plan.getValueInstant("lastSuccessDate") == null ? "No successful backup yet" : WHEN.format(plan.getValueInstant("lastSuccessDate")));
|
||||
cell(html, "Age against SLA", age != null && sla != null ? age + " h of " + sla + " h" : (sla == null ? "No SLA set" : "Not run yet"));
|
||||
cell(html, "Last run", last == null ? "None yet" : label(last.getValueString("status")) + (last.getValueInstant("startDate") == null ? "" : ", " + WHEN.format(last.getValueInstant("startDate"))));
|
||||
cell(html, "Schedule", plan.getValueString("cronExpression") == null ? "Manual only" : plan.getValueString("cronExpression"));
|
||||
cell(html, "Last verified", plan.getValueInstant("lastVerifiedDate") == null ? "Not verified yet" : WHEN.format(plan.getValueInstant("lastVerifiedDate")));
|
||||
if(plan.getValueString("objectLockMode") != null && !"NONE".equals(plan.getValueString("objectLockMode")))
|
||||
{
|
||||
cell(html, "Object lock", plan.getValueString("objectLockMode") + (plan.getValueInteger("objectLockDays") == null ? "" : ", " + plan.getValueInteger("objectLockDays") + " days"));
|
||||
}
|
||||
html.append("</div></div>");
|
||||
}
|
||||
link(html, prefix, BackupsTables.PLAN, "Backup plans");
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card: what a working backup needs, whether each part is in place,
|
||||
** and the next step, linked to its table.
|
||||
*******************************************************************************/
|
||||
private static void setup(StringBuilder html, List<BackupsSetupStatus.Item> items, String prefix)
|
||||
{
|
||||
open(html, "Setup");
|
||||
long todo = items.stream().filter(i -> i.state() == BackupsSetupStatus.State.TODO).count();
|
||||
html.append("<p style=\"margin:0 0 .6rem;font-size:.9rem;\">")
|
||||
.append(todo == 0 ? "Everything backups need is set up." : esc(todo + (todo == 1 ? " item needs" : " items need") + " attention."))
|
||||
.append("</p><ul style=\"list-style:none;margin:0;padding:0;\">");
|
||||
for(BackupsSetupStatus.Item item : items)
|
||||
{
|
||||
String color = switch(item.state())
|
||||
{
|
||||
case OK -> "#16a34a";
|
||||
case TODO -> "#dc2626";
|
||||
case DEFAULT -> "#d97706";
|
||||
case OPTIONAL -> "#9ca3af";
|
||||
};
|
||||
String label = switch(item.state())
|
||||
{
|
||||
case OK -> "Set up";
|
||||
case TODO -> "Needs setup";
|
||||
case DEFAULT -> "Using defaults";
|
||||
case OPTIONAL -> "Optional";
|
||||
};
|
||||
html.append("<li style=\"").append(ROW).append("\"><div style=\"display:flex;flex-wrap:wrap;align-items:center;gap:.5rem .75rem;\">")
|
||||
.append("<strong>").append("<a style=\"").append(LINK).append("\" href=\"").append(esc(prefix + "/" + item.tableName())).append("\">").append(esc(item.label())).append("</a></strong>")
|
||||
.append("<span style=\"").append(CHIP).append("\"><span aria-hidden=\"true\" style=\"width:.55rem;height:.55rem;border-radius:50%;background:").append(color).append(";display:inline-block;\"></span>").append(esc(label)).append("</span>")
|
||||
.append("</div><div style=\"font-size:.9rem;margin-top:.35rem;\">").append(esc(item.detail())).append("</div>");
|
||||
if(item.nextStep() != null)
|
||||
{
|
||||
html.append("<div style=\"").append(MUTED).append("font-size:.9rem;margin-top:.2rem;\">Next: ").append(esc(item.nextStep())).append("</div>");
|
||||
}
|
||||
html.append("</li>");
|
||||
}
|
||||
html.append("</ul>");
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Needs attention: every Setup item that needs setup (the same list the Setup
|
||||
** card counts), plus plans in a problem state and recent failed runs, so the two
|
||||
** cards never disagree.
|
||||
*******************************************************************************/
|
||||
private static void needsAttention(StringBuilder html, BackupsHomeData data, List<BackupsSetupStatus.Item> setupItems, String prefix)
|
||||
{
|
||||
open(html, "Needs attention");
|
||||
List<BackupsSetupStatus.Item> setupTodo = setupItems.stream().filter(i -> i.state() == BackupsSetupStatus.State.TODO).toList();
|
||||
List<QRecord> plans = data.plansNeedingAttention();
|
||||
if(setupTodo.isEmpty() && plans.isEmpty() && data.failedRuns().isEmpty())
|
||||
{
|
||||
empty(html, data.plans().isEmpty() ? "Nothing to check yet: no plans." : "Nothing needs attention.");
|
||||
}
|
||||
else
|
||||
{
|
||||
html.append("<ul style=\"").append(ITEMS).append("\">");
|
||||
for(BackupsSetupStatus.Item item : setupTodo)
|
||||
{
|
||||
html.append("<li>Setup: <a style=\"").append(LINK).append("\" href=\"").append(esc(prefix + "/" + item.tableName())).append("\">").append(esc(item.label())).append("</a>: ")
|
||||
.append(esc(item.detail())).append("</li>");
|
||||
}
|
||||
for(QRecord plan : plans)
|
||||
{
|
||||
html.append("<li>").append(recordLink(prefix, BackupsTables.PLAN, plan.getValue("id"), plan.getValueString("name")))
|
||||
.append(": ").append(esc(label(data.effectiveStatus(plan)))).append("</li>");
|
||||
}
|
||||
for(QRecord run : data.failedRuns())
|
||||
{
|
||||
html.append("<li>Run ").append(recordLink(prefix, BackupsTables.RUN, run.getValue("id"), run.getValueString("runKey")))
|
||||
.append(": ").append(esc(label(run.getValueString("status")))).append("</li>");
|
||||
}
|
||||
html.append("</ul>");
|
||||
}
|
||||
link(html, prefix, BackupsTables.RUN, "All runs");
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Latest test restore per plan.
|
||||
*******************************************************************************/
|
||||
private static void latestTestRestore(StringBuilder html, BackupsHomeData data, String prefix)
|
||||
{
|
||||
open(html, "Latest test restore");
|
||||
boolean any = false;
|
||||
for(QRecord plan : data.plans())
|
||||
{
|
||||
QRecord v = data.latestTestRestore(plan.getValueInteger("id"));
|
||||
if(v == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
any = true;
|
||||
html.append("<dl style=\"").append(LIST).append("\">");
|
||||
row(html, "Plan", plan.getValueString("name"));
|
||||
row(html, "Result", label(v.getValueString("status")));
|
||||
row(html, "Compared", number(v.getValueLong("rowsChecked")) + " rows in " + number(toLong(v.getValueInteger("tablesChecked"))) + " tables, "
|
||||
+ number(toLong(v.getValueInteger("mismatches"))) + " mismatches");
|
||||
row(html, "Scratch target", v.getValueString("scratchTarget") == null ? "Not recorded" : v.getValueString("scratchTarget"));
|
||||
html.append("</dl>");
|
||||
}
|
||||
if(!any)
|
||||
{
|
||||
empty(html, "No test restore yet. Run Verify backup, Test restore on a run, or set a verify schedule on the plan.");
|
||||
}
|
||||
link(html, prefix, BackupsTables.VERIFICATION, "All verifications");
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Size trend per plan: bars for the stored size of each successful run (oldest
|
||||
** left), then the numbers.
|
||||
*******************************************************************************/
|
||||
private static void trend(StringBuilder html, BackupsHomeData data)
|
||||
{
|
||||
open(html, "Size and duration trend");
|
||||
boolean any = false;
|
||||
for(QRecord plan : data.plans())
|
||||
{
|
||||
List<QRecord> runs = data.successfulRuns(plan.getValueInteger("id"));
|
||||
if(runs.isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
any = true;
|
||||
QRecord first = runs.get(0);
|
||||
QRecord last = runs.get(runs.size() - 1);
|
||||
html.append("<div style=\"margin:0 0 .75rem;\"><strong style=\"font-size:.9rem;\">").append(esc(plan.getValueString("name"))).append("</strong>");
|
||||
bars(html, runs);
|
||||
html.append("<dl style=\"").append(LIST).append("\">");
|
||||
row(html, "Successful runs", String.valueOf(runs.size()));
|
||||
row(html, "Stored", bytes(first.getValueLong("bytesStored")) + " at first, " + bytes(last.getValueLong("bytesStored")) + " latest");
|
||||
row(html, "Rows", number(first.getValueLong("rowsTotal")) + " at first, " + number(last.getValueLong("rowsTotal")) + " latest");
|
||||
row(html, "Duration", last.getValueLong("durationMs") == null ? "Not recorded" : duration(last.getValueLong("durationMs")) + " latest");
|
||||
html.append("</dl></div>");
|
||||
}
|
||||
if(!any)
|
||||
{
|
||||
empty(html, "Trends appear after the first successful run.");
|
||||
}
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A row of bars, one per run, tall in proportion to the stored bytes.
|
||||
*******************************************************************************/
|
||||
private static void bars(StringBuilder html, List<QRecord> runs)
|
||||
{
|
||||
List<QRecord> shown = runs.size() > TREND_BARS ? runs.subList(runs.size() - TREND_BARS, runs.size()) : runs;
|
||||
long max = 1;
|
||||
for(QRecord run : shown)
|
||||
{
|
||||
max = Math.max(max, run.getValueLong("bytesStored") == null ? 0 : run.getValueLong("bytesStored"));
|
||||
}
|
||||
html.append("<div role=\"img\" aria-label=\"Stored size of the last ").append(shown.size()).append(" successful runs\" style=\"display:flex;align-items:flex-end;gap:2px;height:2.5rem;margin:.4rem 0 .6rem;\">");
|
||||
for(QRecord run : shown)
|
||||
{
|
||||
long value = run.getValueLong("bytesStored") == null ? 0 : run.getValueLong("bytesStored");
|
||||
long pct = Math.max(4, Math.round(100.0 * value / max));
|
||||
String title = (run.getValueInstant("startDate") == null ? "" : WHEN.format(run.getValueInstant("startDate")) + ": ") + bytes(run.getValueLong("bytesStored"));
|
||||
html.append("<span title=\"").append(esc(title)).append("\" style=\"flex:1 1 0;min-width:2px;max-width:1.5rem;height:").append(pct).append("%;background:currentColor;opacity:.55;border-radius:2px 2px 0 0;\"></span>");
|
||||
}
|
||||
html.append("</div>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recent activity: the last runs, restores and verifications.
|
||||
*******************************************************************************/
|
||||
private static void recentActivity(StringBuilder html, BackupsHomeData data, String prefix)
|
||||
{
|
||||
open(html, "Recent activity");
|
||||
if(data.recentRuns().isEmpty() && data.restores().isEmpty() && data.verifications().isEmpty())
|
||||
{
|
||||
empty(html, "Nothing has run yet.");
|
||||
}
|
||||
else
|
||||
{
|
||||
html.append("<ul style=\"").append(ITEMS).append("\">");
|
||||
data.recentRuns().stream().limit(6).forEach(run ->
|
||||
{
|
||||
Instant start = run.getValueInstant("startDate");
|
||||
html.append("<li>Backup ").append(recordLink(prefix, BackupsTables.RUN, run.getValue("id"), run.getValueString("runKey")))
|
||||
.append(": ").append(esc(label(run.getValueString("status")))).append(start == null ? "" : ", " + esc(WHEN.format(start))).append("</li>");
|
||||
});
|
||||
data.restores().stream().limit(2).forEach(restore -> html.append("<li>Restore ").append(recordLink(prefix, BackupsTables.RESTORE_RUN, restore.getValue("id"), "#" + restore.getValue("id")))
|
||||
.append(": ").append(esc(label(restore.getValueString("status")))).append("</li>"));
|
||||
data.verifications().stream().limit(2).forEach(v -> html.append("<li>Verify ").append(recordLink(prefix, BackupsTables.VERIFICATION, v.getValue("id"), label(v.getValueString("level"))))
|
||||
.append(": ").append(esc(label(v.getValueString("status")))).append("</li>"));
|
||||
html.append("</ul>");
|
||||
}
|
||||
link(html, prefix, BackupsTables.RUN, "All runs");
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Retention: runs kept per class and the oldest restorable point.
|
||||
*******************************************************************************/
|
||||
private static void retention(StringBuilder html, BackupsHomeData data)
|
||||
{
|
||||
open(html, "Retention");
|
||||
boolean any = false;
|
||||
for(QRecord plan : data.plans())
|
||||
{
|
||||
Map<String, Integer> counts = data.retentionCounts(plan.getValueInteger("id"));
|
||||
if(counts.isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
any = true;
|
||||
html.append("<dl style=\"").append(LIST).append("\">");
|
||||
row(html, "Plan", plan.getValueString("name"));
|
||||
row(html, "Kept", counts.entrySet().stream().map(e -> e.getValue() + " " + ("UNCLASSIFIED".equals(e.getKey()) ? "not classified yet (no prune has run)" : label(e.getKey()).toLowerCase()))
|
||||
.reduce((a, b) -> a + ", " + b).orElse(""));
|
||||
List<QRecord> runs = data.successfulRuns(plan.getValueInteger("id"));
|
||||
Instant oldest = runs.isEmpty() ? null : runs.get(0).getValueInstant("startDate");
|
||||
row(html, "Oldest restorable", oldest == null ? "Not known" : WHEN.format(oldest));
|
||||
html.append("</dl>");
|
||||
}
|
||||
if(!any)
|
||||
{
|
||||
empty(html, "Retention starts once runs exist.");
|
||||
}
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Open a card.
|
||||
*******************************************************************************/
|
||||
private static void open(StringBuilder html, String title)
|
||||
{
|
||||
html.append("<section style=\"").append(CARD).append("\"><h3 style=\"margin:0 0 .75rem;font-size:1rem;font-weight:600;\">").append(esc(title)).append("</h3>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Close a card.
|
||||
*******************************************************************************/
|
||||
private static void close(StringBuilder html)
|
||||
{
|
||||
html.append("</section>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A muted empty-state line.
|
||||
*******************************************************************************/
|
||||
private static void empty(StringBuilder html, String text)
|
||||
{
|
||||
html.append("<p style=\"").append(MUTED).append("margin:0 0 .75rem;font-size:.9rem;\">").append(esc(text)).append("</p>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A name and value pair in a definition list.
|
||||
*******************************************************************************/
|
||||
private static void row(StringBuilder html, String name, String value)
|
||||
{
|
||||
html.append("<dt style=\"").append(MUTED).append("\">").append(esc(name)).append("</dt><dd style=\"margin:0;\">").append(esc(value == null ? "" : value)).append("</dd>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A small label over its value (a plan row cell).
|
||||
*******************************************************************************/
|
||||
private static void cell(StringBuilder html, String name, String value)
|
||||
{
|
||||
html.append("<div style=\"min-width:0;\"><div style=\"").append(MUTED).append("font-size:.75rem;\">").append(esc(name)).append("</div><div>").append(esc(value)).append("</div></div>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A status chip: a colored dot (the only color) and the status label.
|
||||
*******************************************************************************/
|
||||
private static String chip(String status)
|
||||
{
|
||||
String color = switch(status == null ? "UNKNOWN" : status)
|
||||
{
|
||||
case "OK" -> "#16a34a";
|
||||
case "LATE", "UNVERIFIED", "UNKNOWN" -> "#d97706";
|
||||
default -> "#dc2626";
|
||||
};
|
||||
return ("<span style=\"" + CHIP + "\"><span aria-hidden=\"true\" style=\"width:.55rem;height:.55rem;border-radius:50%;background:" + color + ";display:inline-block;\"></span>" + esc(label(status)) + "</span>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A link to the table list at the end of a card.
|
||||
*******************************************************************************/
|
||||
private static void link(StringBuilder html, String prefix, String table, String text)
|
||||
{
|
||||
html.append("<a style=\"").append(LINK).append("font-size:.9rem;\" href=\"").append(esc(prefix + "/" + table)).append("\">").append(esc(text)).append("</a>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A link to a record.
|
||||
*******************************************************************************/
|
||||
private static String recordLink(String prefix, String table, Object id, String text)
|
||||
{
|
||||
return ("<a style=\"" + LINK + "\" href=\"" + esc(prefix + "/" + table + "/" + id) + "\">" + esc(text == null ? String.valueOf(id) : text) + "</a>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SOME_STATUS to Some status (OK stays OK).
|
||||
*******************************************************************************/
|
||||
static String label(String status)
|
||||
{
|
||||
if(status == null || status.isEmpty())
|
||||
{
|
||||
return ("Unknown");
|
||||
}
|
||||
if("OK".equals(status))
|
||||
{
|
||||
return ("OK");
|
||||
}
|
||||
String s = status.replace('_', ' ').toLowerCase();
|
||||
return (Character.toUpperCase(s.charAt(0)) + s.substring(1));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An Integer as a Long, null-safe.
|
||||
*******************************************************************************/
|
||||
private static Long toLong(Integer value)
|
||||
{
|
||||
return (value == null ? null : value.longValue());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Milliseconds as a short duration.
|
||||
*******************************************************************************/
|
||||
static String duration(long millis)
|
||||
{
|
||||
if(millis < 1000)
|
||||
{
|
||||
return (millis + " ms");
|
||||
}
|
||||
long seconds = millis / 1000;
|
||||
return (seconds < 60 ? seconds + " s" : seconds / 60 + " min " + seconds % 60 + " s");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bytes as a short size.
|
||||
*******************************************************************************/
|
||||
static String bytes(Long value)
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return ("Not recorded");
|
||||
}
|
||||
double v = value;
|
||||
String[] units = { "B", "KB", "MB", "GB", "TB" };
|
||||
int i = 0;
|
||||
while(v >= 1024 && i < units.length - 1)
|
||||
{
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return (i == 0 ? String.format("%d B", value) : String.format("%.1f %s", v, units[i]));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A number with separators.
|
||||
*******************************************************************************/
|
||||
static String number(Long value)
|
||||
{
|
||||
return (value == null ? "Not recorded" : String.format("%,d", value));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** HTML-escape.
|
||||
*******************************************************************************/
|
||||
static String esc(String s)
|
||||
{
|
||||
return (s == null ? "" : s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.app;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.backups.BackupsQBitConfig;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.processes.DefaultPlanProcess;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card on the Backups home: what a working backup needs (a plan, a
|
||||
** storage bucket that resolves, a schedule, verification, a separate prune
|
||||
** bucket), whether each is in place, and the next step. Computed from the plans
|
||||
** the home already loaded and the runtime's storage resolver; it reads nothing
|
||||
** else.
|
||||
*******************************************************************************/
|
||||
public final class BackupsSetupStatus
|
||||
{
|
||||
/*******************************************************************************
|
||||
** OK: in place. TODO: backups need it. DEFAULT: working, with a default worth a
|
||||
** look. OPTIONAL: a feature that is off until set up.
|
||||
*******************************************************************************/
|
||||
public enum State
|
||||
{
|
||||
OK, TODO, DEFAULT, OPTIONAL
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One line of the card.
|
||||
*******************************************************************************/
|
||||
public record Item(String label, String tableName, State state, String detail, String nextStep)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private BackupsSetupStatus()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the items from the plans.
|
||||
*******************************************************************************/
|
||||
public static List<Item> load(List<QRecord> plans)
|
||||
{
|
||||
BackupsQBitConfig config = BackupsRuntime.isInstalled() ? BackupsRuntime.getConfig() : null;
|
||||
return (load(plans, config != null && hasContent(config.getSchedulerName()), config == null ? null : config.getHostSchedulerDescription(),
|
||||
config != null && Boolean.TRUE.equals(config.getHostSchedulerRunsVerification())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the items from the plans, given whether a scheduler is configured (a plan
|
||||
** cron only fires through the scheduler the qbit config names).
|
||||
*******************************************************************************/
|
||||
public static List<Item> load(List<QRecord> plans, boolean schedulerConfigured)
|
||||
{
|
||||
return (load(plans, schedulerConfigured, null, false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the items from the plans. A host that runs plan crons with its own tick
|
||||
** (hostScheduler names it) has crons that fire; hostRunsVerification says whether
|
||||
** that tick also runs the verify cron.
|
||||
*******************************************************************************/
|
||||
public static List<Item> load(List<QRecord> plans, boolean schedulerConfigured, String hostScheduler, boolean hostRunsVerification)
|
||||
{
|
||||
boolean host = hasContent(hostScheduler);
|
||||
List<Item> items = new ArrayList<>();
|
||||
List<QRecord> active = plans.stream().filter(p -> Boolean.TRUE.equals(p.getValueBoolean("isActive"))).toList();
|
||||
|
||||
///////////
|
||||
// plans //
|
||||
///////////
|
||||
if(plans.isEmpty())
|
||||
{
|
||||
items.add(new Item("Backup plans", BackupsTables.PLAN, State.TODO, "No plan: nothing is backed up.",
|
||||
"Run Create default plan on Backup plans (it asks only for the storage bucket alias), or add a plan with New."));
|
||||
items.add(new Item("Plan scope rules", BackupsTables.PLAN_SCOPE, State.OPTIONAL, "None needed yet.",
|
||||
"A plan that backs up everything (scope Instance) needs no rules. Add them on a plan that is limited to apps, qbits or tables."));
|
||||
return (items);
|
||||
}
|
||||
items.add(active.isEmpty()
|
||||
? new Item("Backup plans", BackupsTables.PLAN, State.TODO, plans.size() + (plans.size() == 1 ? " plan, but it is" : " plans, but none is") + " active: nothing runs on its own.", "Turn a plan on (Active).")
|
||||
: new Item("Backup plans", BackupsTables.PLAN, State.OK, active.size() + " active plan" + (active.size() == 1 ? "" : "s") + " of " + plans.size() + ".", null));
|
||||
|
||||
/////////////////////////////////////////
|
||||
// scope rules: needed unless INSTANCE //
|
||||
/////////////////////////////////////////
|
||||
List<String> limited = plans.stream().filter(p -> p.getValueString("scopeType") != null && !"INSTANCE".equals(p.getValueString("scopeType"))).map(p -> p.getValueString("name")).toList();
|
||||
items.add(limited.isEmpty()
|
||||
? new Item("Plan scope rules", BackupsTables.PLAN_SCOPE, State.OPTIONAL, "Every plan backs up the whole instance, so no rules are needed.",
|
||||
"Add rules to a plan only to include or leave out apps, qbits, tables or roles.")
|
||||
: new Item("Plan scope rules", BackupsTables.PLAN_SCOPE, State.OK, limited.size() + (limited.size() == 1 ? " plan is" : " plans are") + " limited by scope rules: " + String.join(", ", limited) + ".",
|
||||
"Check each plan's Scope rules to see what it covers."));
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// storage: every alias a plan names resolves //
|
||||
///////////////////////////////////////////////
|
||||
Set<String> aliases = new LinkedHashSet<>();
|
||||
for(QRecord plan : plans)
|
||||
{
|
||||
for(String field : List.of("storageBucketAlias", "pruneBucketAlias"))
|
||||
{
|
||||
if(plan.getValueString(field) != null)
|
||||
{
|
||||
aliases.add(plan.getValueString(field));
|
||||
}
|
||||
}
|
||||
}
|
||||
List<String> broken = new ArrayList<>();
|
||||
for(String alias : aliases)
|
||||
{
|
||||
try
|
||||
{
|
||||
BackupsRuntime.storageFor(alias);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
broken.add(alias);
|
||||
}
|
||||
}
|
||||
items.add(broken.isEmpty()
|
||||
? new Item("Storage", BackupsTables.PLAN, State.OK, aliases.size() + " storage bucket alias" + (aliases.size() == 1 ? "" : "es") + " in use, and each resolves.", "Run Test destination on a plan to prove it can write.")
|
||||
: new Item("Storage", BackupsTables.PLAN, State.TODO, "These aliases do not resolve to a bucket: " + String.join(", ", broken) + ".",
|
||||
"Fix the alias on the plan, or add the bucket to the application's storage resolver."));
|
||||
|
||||
/////////////////////////////////////////
|
||||
// schedule: an active plan has a cron //
|
||||
/////////////////////////////////////////
|
||||
long scheduled = active.stream().filter(p -> hasContent(p.getValueString("cronExpression"))).count();
|
||||
String noScheduler = "No scheduler is configured (schedulerName on the qbit config), so plan crons are saved but never fire";
|
||||
items.add(!schedulerConfigured && !host
|
||||
? new Item("Schedule", BackupsTables.PLAN, State.TODO, noScheduler + ". Backups run only when someone clicks Run backup now.",
|
||||
"Set schedulerName on the qbit config (or run your own tick if the application does), then give each plan a cron expression.")
|
||||
: scheduled == 0
|
||||
? new Item("Schedule", BackupsTables.PLAN, State.TODO, "No active plan has a schedule: backups run only when someone clicks Run backup now.", "Set a Cron expression on a plan (for example " + DefaultPlanProcess.CRON + ").")
|
||||
: new Item("Schedule", BackupsTables.PLAN, State.OK, scheduled + " active plan" + (scheduled == 1 ? " runs" : "s run") + " on a schedule"
|
||||
+ (host && !schedulerConfigured ? ", run by " + hostScheduler + " (the application scheduler)." : "."), null));
|
||||
|
||||
//////////////////
|
||||
// verification //
|
||||
//////////////////
|
||||
long verified = active.stream().filter(p -> hasContent(p.getValueString("verifyCronExpression"))).count();
|
||||
items.add(host && !schedulerConfigured && !hostRunsVerification
|
||||
? new Item("Verification", BackupsTables.VERIFICATION, State.OPTIONAL, "Not run by " + hostScheduler + ": verify cron expressions are saved but nothing fires them.",
|
||||
"Run Verify backup on a run by hand, or have the application scheduler run the backupVerifyPlan process for each plan.")
|
||||
: !schedulerConfigured && !host
|
||||
? new Item("Verification", BackupsTables.VERIFICATION, State.TODO, noScheduler + ", so no verification is scheduled.",
|
||||
"Set schedulerName on the qbit config, then a verify cron expression on a plan; or run Verify backup on a run by hand.")
|
||||
: verified == 0
|
||||
? new Item("Verification", BackupsTables.VERIFICATION, State.OPTIONAL, "No plan verifies its backups on a schedule.",
|
||||
"Set a Verify cron expression on a plan, or run Verify backup on a run. A backup nobody has restored is a guess.")
|
||||
: new Item("Verification", BackupsTables.VERIFICATION, State.OK, verified + " active plan" + (verified == 1 ? " verifies" : "s verify") + " its latest backup on a schedule.", null));
|
||||
|
||||
////////////////////////////////////////////
|
||||
// prune bucket: separate delete authority //
|
||||
////////////////////////////////////////////
|
||||
long noPrune = plans.stream().filter(p -> !hasContent(p.getValueString("pruneBucketAlias"))).count();
|
||||
items.add(noPrune == 0
|
||||
? new Item("Prune bucket", BackupsTables.PLAN, State.OK, "Every plan prunes through its own delete-capable bucket alias.", null)
|
||||
: new Item("Prune bucket", BackupsTables.PLAN, State.DEFAULT, noPrune + (noPrune == 1 ? " plan has" : " plans have") + " no prune bucket: Prune uses the backup bucket, so one key can both write and delete backups.",
|
||||
"Give the plan a separate delete-capable Prune bucket alias."));
|
||||
return (items);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static boolean hasContent(String value)
|
||||
{
|
||||
return (value != null && !value.isBlank());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.codec;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A reversible transform applied to the bytes of an artifact after compression,
|
||||
** before they reach storage. The only implementation is client-side encryption
|
||||
** (the streaming cipher of the QQQ crypto package); IDENTITY is the default.
|
||||
*******************************************************************************/
|
||||
public interface ArtifactTransform
|
||||
{
|
||||
/** No transform. */
|
||||
ArtifactTransform IDENTITY = new ArtifactTransform()
|
||||
{
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public OutputStream wrap(OutputStream out)
|
||||
{
|
||||
return (out);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public InputStream unwrap(InputStream in)
|
||||
{
|
||||
return (in);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Wrap the stream going to storage.
|
||||
***************************************************************************/
|
||||
OutputStream wrap(OutputStream out) throws IOException;
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Wrap a stream read from storage, to undo the transform.
|
||||
***************************************************************************/
|
||||
InputStream unwrap(InputStream in) throws IOException;
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether this transform encrypts.
|
||||
***************************************************************************/
|
||||
default boolean encrypts()
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** File name suffix added to artifact keys, e.g. ".tink".
|
||||
***************************************************************************/
|
||||
default String keySuffix()
|
||||
{
|
||||
return ("");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.codec;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The CSV row format (spec 8.3): RFC 4180, UTF-8 without a BOM, a header of
|
||||
** field names, and the same canonical value text as JSONL. Null is written as
|
||||
** \N (unquoted) so it can be told from the empty string; a string that is
|
||||
** literally \N is quoted. A multi-line value is one record in a quoted field,
|
||||
** so the codec works on records (a record may span lines): encode() returns a
|
||||
** text that may contain embedded newlines, and the part writer appends one
|
||||
** newline after each.
|
||||
*******************************************************************************/
|
||||
public class CsvRowCodec implements RowCodec
|
||||
{
|
||||
public static final String NULL_TOKEN = "\\N";
|
||||
|
||||
private final QTableMetaData table;
|
||||
private final List<String> fieldNames;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public CsvRowCodec(QTableMetaData table, java.util.Collection<String> excludedFields)
|
||||
{
|
||||
this.table = table;
|
||||
TreeSet<String> names = new TreeSet<>(table.getFields().keySet());
|
||||
if(excludedFields != null)
|
||||
{
|
||||
names.removeAll(excludedFields);
|
||||
}
|
||||
this.fieldNames = new ArrayList<>(names);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<String> getFieldNames()
|
||||
{
|
||||
return (fieldNames);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String header()
|
||||
{
|
||||
return (String.join(",", fieldNames.stream().map(CsvRowCodec::quoteIfNeeded).toList()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String encode(QRecord record)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for(int i = 0; i < fieldNames.size(); i++)
|
||||
{
|
||||
if(i > 0)
|
||||
{
|
||||
sb.append(',');
|
||||
}
|
||||
String name = fieldNames.get(i);
|
||||
Serializable value = record.getValue(name);
|
||||
if(value == null)
|
||||
{
|
||||
sb.append(NULL_TOKEN);
|
||||
}
|
||||
else
|
||||
{
|
||||
String text = FieldValueCodec.toCanonicalText(table.getField(name).getType(), value);
|
||||
sb.append(NULL_TOKEN.equals(text) ? "\"" + text + "\"" : quoteIfNeeded(text));
|
||||
}
|
||||
}
|
||||
return (sb.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse one CSV record (as returned by CsvRecordReader, header excluded) to a record.
|
||||
** The line may contain embedded newlines inside quoted fields.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QRecord decode(String line) throws QException
|
||||
{
|
||||
List<String> cells = splitCells(line);
|
||||
return (toRecord(cells, fieldNames));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build a record from already-split cells and the header names.
|
||||
*******************************************************************************/
|
||||
public QRecord toRecord(List<String> cells, List<String> header) throws QException
|
||||
{
|
||||
if(cells.size() != header.size())
|
||||
{
|
||||
throw (new QException("CSV row has " + cells.size() + " cells but the header has " + header.size() + " for table " + table.getName()));
|
||||
}
|
||||
Map<String, Serializable> values = new LinkedHashMap<>();
|
||||
for(int i = 0; i < header.size(); i++)
|
||||
{
|
||||
String cell = cells.get(i);
|
||||
QFieldMetaData field = table.getFields().get(header.get(i));
|
||||
if(cell == null)
|
||||
{
|
||||
values.put(header.get(i), null);
|
||||
}
|
||||
else if(field == null)
|
||||
{
|
||||
values.put(header.get(i), cell);
|
||||
}
|
||||
else
|
||||
{
|
||||
values.put(header.get(i), FieldValueCodec.fromCanonicalText(field.getType(), cell));
|
||||
}
|
||||
}
|
||||
QRecord record = new QRecord();
|
||||
record.setTableName(table.getName());
|
||||
record.setValues(values);
|
||||
return (record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Quote a cell when it contains a comma, quote, CR or LF (or is empty after
|
||||
** being a header, which never happens), doubling embedded quotes.
|
||||
*******************************************************************************/
|
||||
static String quoteIfNeeded(String s)
|
||||
{
|
||||
if(s.indexOf(',') >= 0 || s.indexOf('"') >= 0 || s.indexOf('\n') >= 0 || s.indexOf('\r') >= 0)
|
||||
{
|
||||
return ("\"" + s.replace("\"", "\"\"") + "\"");
|
||||
}
|
||||
return (s);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Split one CSV record into cells. Returns null for an unquoted \N cell (a null).
|
||||
*******************************************************************************/
|
||||
public static List<String> splitCells(String line)
|
||||
{
|
||||
List<String> cells = new ArrayList<>();
|
||||
StringBuilder cell = new StringBuilder();
|
||||
boolean quoted = false;
|
||||
boolean wasQuoted = false;
|
||||
for(int i = 0; i < line.length(); i++)
|
||||
{
|
||||
char c = line.charAt(i);
|
||||
if(quoted)
|
||||
{
|
||||
if(c == '"')
|
||||
{
|
||||
if(i + 1 < line.length() && line.charAt(i + 1) == '"')
|
||||
{
|
||||
cell.append('"');
|
||||
i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
quoted = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
cell.append(c);
|
||||
}
|
||||
}
|
||||
else if(c == '"')
|
||||
{
|
||||
quoted = true;
|
||||
wasQuoted = true;
|
||||
}
|
||||
else if(c == ',')
|
||||
{
|
||||
cells.add(finishCell(cell, wasQuoted));
|
||||
cell = new StringBuilder();
|
||||
wasQuoted = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
cell.append(c);
|
||||
}
|
||||
}
|
||||
cells.add(finishCell(cell, wasQuoted));
|
||||
return (cells);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String finishCell(StringBuilder cell, boolean wasQuoted)
|
||||
{
|
||||
String s = cell.toString();
|
||||
return (!wasQuoted && NULL_TOKEN.equals(s) ? null : s);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Split a whole CSV text into records, honoring newlines inside quoted fields.
|
||||
*******************************************************************************/
|
||||
public static List<String> splitRecords(String text)
|
||||
{
|
||||
List<String> records = new ArrayList<>();
|
||||
StringBuilder current = new StringBuilder();
|
||||
boolean quoted = false;
|
||||
for(int i = 0; i < text.length(); i++)
|
||||
{
|
||||
char c = text.charAt(i);
|
||||
if(c == '"')
|
||||
{
|
||||
quoted = !quoted;
|
||||
current.append(c);
|
||||
}
|
||||
else if(c == '\n' && !quoted)
|
||||
{
|
||||
records.add(current.toString());
|
||||
current = new StringBuilder();
|
||||
}
|
||||
else
|
||||
{
|
||||
current.append(c);
|
||||
}
|
||||
}
|
||||
if(current.length() > 0)
|
||||
{
|
||||
records.add(current.toString());
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.codec;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Base64;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The canonical, lossless text encoding of a field value, by QFieldType
|
||||
** (spec 8.2). The same canonical text is used by JSONL and CSV, so a value
|
||||
** means the same thing in both, and by the content hash.
|
||||
**
|
||||
** STRING, TEXT, HTML, PASSWORD: string
|
||||
** INTEGER, LONG: number
|
||||
** DECIMAL: string (BigDecimal.toPlainString), so there is no float rounding
|
||||
** BOOLEAN: true or false
|
||||
** DATE: yyyy-MM-dd
|
||||
** TIME: HH:mm:ss, with .SSSSSS only when there are fractional seconds
|
||||
** DATE_TIME: an ISO-8601 instant in UTC, with .SSSSSS only when there are fractional seconds
|
||||
** BLOB: base64
|
||||
*******************************************************************************/
|
||||
public final class FieldValueCodec
|
||||
{
|
||||
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm:ss");
|
||||
private static final DateTimeFormatter TIME_FORMAT_MICROS = DateTimeFormatter.ofPattern("HH:mm:ss.SSSSSS");
|
||||
private static final DateTimeFormatter INSTANT_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'").withZone(ZoneOffset.UTC);
|
||||
private static final DateTimeFormatter INSTANT_FORMAT_MICRO = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'").withZone(ZoneOffset.UTC);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private FieldValueCodec()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the canonical JSON form of a type is a bare number or boolean (not quoted).
|
||||
*******************************************************************************/
|
||||
public static boolean isBareJson(QFieldType type)
|
||||
{
|
||||
return (type == QFieldType.INTEGER || type == QFieldType.LONG || type == QFieldType.BOOLEAN);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The canonical text of a non-null value.
|
||||
*******************************************************************************/
|
||||
public static String toCanonicalText(QFieldType type, Serializable value)
|
||||
{
|
||||
switch(type)
|
||||
{
|
||||
case INTEGER, LONG:
|
||||
return (String.valueOf(ValueUtils.getValueAsLong(value)));
|
||||
case DECIMAL:
|
||||
return (ValueUtils.getValueAsBigDecimal(value).toPlainString());
|
||||
case BOOLEAN:
|
||||
return (String.valueOf(ValueUtils.getValueAsBoolean(value)));
|
||||
case DATE:
|
||||
return (ValueUtils.getValueAsLocalDate(value).toString());
|
||||
case TIME:
|
||||
{
|
||||
LocalTime time = ValueUtils.getValueAsLocalTime(value);
|
||||
return (time.getNano() == 0 ? TIME_FORMAT.format(time) : TIME_FORMAT_MICROS.format(time));
|
||||
}
|
||||
case DATE_TIME:
|
||||
{
|
||||
Instant instant = ValueUtils.getValueAsInstant(value);
|
||||
return (instant.getNano() == 0 ? INSTANT_FORMAT.format(instant) : INSTANT_FORMAT_MICRO.format(instant));
|
||||
}
|
||||
case BLOB:
|
||||
return (Base64.getEncoder().encodeToString(blobBytes(value)));
|
||||
default:
|
||||
return (String.valueOf(value));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static byte[] blobBytes(Serializable value)
|
||||
{
|
||||
if(value instanceof byte[] bytes)
|
||||
{
|
||||
return (bytes);
|
||||
}
|
||||
return (String.valueOf(value).getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse canonical text back to a Java value.
|
||||
*******************************************************************************/
|
||||
public static Serializable fromCanonicalText(QFieldType type, String text)
|
||||
{
|
||||
if(text == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
switch(type)
|
||||
{
|
||||
case INTEGER:
|
||||
return (Integer.valueOf(text));
|
||||
case LONG:
|
||||
return (Long.valueOf(text));
|
||||
case DECIMAL:
|
||||
return (new BigDecimal(text));
|
||||
case BOOLEAN:
|
||||
return (Boolean.valueOf(text));
|
||||
case DATE:
|
||||
return (LocalDate.parse(text));
|
||||
case TIME:
|
||||
return (LocalTime.parse(text));
|
||||
case DATE_TIME:
|
||||
return (Instant.parse(text));
|
||||
case BLOB:
|
||||
return (Base64.getDecoder().decode(text));
|
||||
default:
|
||||
return (text);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Convert a parsed JSON value to a Java value.
|
||||
*******************************************************************************/
|
||||
public static Serializable fromJson(QFieldType type, JsonNode node)
|
||||
{
|
||||
if(node == null || node.isNull())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
if(type == QFieldType.BOOLEAN)
|
||||
{
|
||||
return (node.asBoolean());
|
||||
}
|
||||
return (fromCanonicalText(type, node.isNumber() ? node.asText() : node.textValue()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
}
|
||||