Compare commits

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

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

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

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

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

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

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

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

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

No files matched your search

+1 -1
View File
@@ -57,7 +57,7 @@ workflows:
branches:
ignore: /.*/
tags:
only: [/v.*/]
only: [/^v\d+\.\d+\.\d+$/]
publish_hotfix_release:
jobs:
+7
View File
@@ -0,0 +1,7 @@
# Backups home and record screenshots
Taken 2026-10-12 against `BackupsDemoServer` (src/test/.../demo, memory backend, real engine history) and the Next UI 1.0.0-RC.14 jar.
Light and dark at 390, 600, 768, 1024, 1440, 1920 px (home) and 390, 1440 px (records).
- `.` : this branch (foundation base): home, plan, run, failed run, lists.
- `with-restore-verify/` : a scratch merge of PRs #5 (verify), #6 (restore), #7 (prune/schedule) onto this branch, so verifications, a restore run and retention classes exist. Not part of this PR.
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

+54
View File
@@ -72,6 +72,49 @@
<artifactId>qqq-backend-core</artifactId>
</dependency>
<!-- optional: the BackupsHealthIndicator (apps without qqq-middleware-health never load it) -->
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-health</artifactId>
<optional>true</optional>
</dependency>
<!-- optional: ESB events (apps without qqq-esb never load it) -->
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-esb</artifactId>
<optional>true</optional>
</dependency>
<!-- Optional: the production BackupStorage adapter. Apps that install this qbit -->
<!-- with object storage already have it; others use their own BackupStorage. -->
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-object-storage</artifactId>
<version>0.1.0-RC.2</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>2.0.3</version>
<scope>test</scope>
</dependency>
<!-- Demo server only (src/test/.../demo): never shipped. -->
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-javalin</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>1.0.0-RC.14</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
@@ -87,6 +130,17 @@
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<!-- 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>
@@ -19,6 +19,9 @@ 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;
@@ -42,6 +45,7 @@ public class BackupsQBitConfig implements QBitConfig
private String appName = "backups";
private String appLabel = "Backups";
private String parentAppName;
private String tablePathPrefix;
private String schedulerName;
private String instanceName;
@@ -52,6 +56,12 @@ public class BackupsQBitConfig implements QBitConfig
private Function<String, BackupStorage> storageResolver;
private Integer fileReverifyDays = 30;
private Function<String, ArtifactTransform> artifactTransformResolver;
private ScratchTarget scratchTarget;
private Predicate<String> planOwnerValidator;
private String esbDestinationName;
/*******************************************************************************
@@ -170,6 +180,28 @@ public class BackupsQBitConfig implements QBitConfig
/*******************************************************************************
** 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 schedulerName (the QQQ scheduler used for plan jobs and the SLA checker)
*******************************************************************************/
@@ -315,4 +347,109 @@ public class BackupsQBitConfig implements QBitConfig
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);
}
}
@@ -119,6 +119,14 @@ public class BackupsQBitProducer implements QBitMetaDataProducer<BackupsQBitConf
qInstance.addQBit(qBit);
BackupsRuntime.install(qBitConfig);
///////////////////////////////////////////////////////////////////////////////
// bind the Alerts qbit producer SPI when it is on the class path (optional) //
///////////////////////////////////////////////////////////////////////////////
if(com.kingsrook.qbits.backups.alerts.AlertsQBitAlertSink.bindIfPresent())
{
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())
{
@@ -141,12 +149,29 @@ public class BackupsQBitProducer implements QBitMetaDataProducer<BackupsQBitConf
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);
}
output.add(com.kingsrook.qbits.backups.app.BackupsApp.app(qBitConfig, tables));
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);
// 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,310 @@
/*
* 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.lang.reflect.Method;
import java.util.Map;
import java.util.stream.Collectors;
import com.kingsrook.qbits.backups.BackupsRuntime;
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;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** The BackupAlertSink adapter for the Alerts qbit producer SPI (QRun-IO/qqq
** PR #1108, com.kingsrook.qqq.backend.core.alerts: QAlerts, AlertSignal,
** AlertResolution, AlertSeverity, QAlertTypeMetaData; design spec
** docs/specs/2026-10-12-qbit-alerts-design.md).
**
** STAND-IN: the SPI is an open core PR and is not in QQQ 4.1.0-RC.4, so this
** adapter is bound REFLECTIVELY and optionally. It compiles against nothing in
** the SPI; it looks the classes up by name, and every call is guarded:
** <ul>
** <li>no SPI classes on the class path: {@link #bindIfPresent()} binds nothing
** and the sink stays the no-op;</li>
** <li>the SPI is present but the instance has no alerts meta-data (the Alerts
** qbit is not installed): every call is a no-op;</li>
** <li>any reflective or SPI failure is logged once and swallowed, because an
** alerting problem must never fail a backup.</li>
** </ul>
** Replace with a direct binding (QAlerts.raise and friends) when RC.5 ships.
**
** Mapping: raise to QAlerts.raise(AlertSignal), resolve to QAlerts.resolve(type,
** dedupKey, note), heartbeat to QAlerts.heartbeat(key). The SPI has no per-plan
** heartbeat registration (monitors are declared on the alert type), so the
** expected interval and grace are not passed on; the SLA checker raises
** backups.plan.missed itself, independent of any heartbeat (spec 10.4).
*******************************************************************************/
public class AlertsQBitAlertSink implements BackupAlertSink
{
private static final QLogger LOG = QLogger.getLogger(AlertsQBitAlertSink.class);
/** The package of the Alerts producer SPI in qqq-backend-core. */
public static final String DEFAULT_API_PACKAGE = "com.kingsrook.qqq.backend.core.alerts";
private final String apiPackage;
private boolean warned = false;
private Class<?> qAlerts;
private Class<?> alertSignal;
private Class<?> alertSeverity;
private Class<?> instanceMetaData;
/*******************************************************************************
** Constructor for the real SPI package.
*******************************************************************************/
public AlertsQBitAlertSink()
{
this(DEFAULT_API_PACKAGE);
}
/*******************************************************************************
** Constructor for another package (a test double of the SPI shape).
*******************************************************************************/
public AlertsQBitAlertSink(String apiPackage)
{
this.apiPackage = apiPackage;
}
/*******************************************************************************
** Whether the SPI classes are on the class path.
*******************************************************************************/
public static boolean isPresent(String apiPackage)
{
try
{
Class.forName(apiPackage + ".QAlerts");
Class.forName(apiPackage + ".AlertSignal");
return (true);
}
catch(ClassNotFoundException | LinkageError e)
{
return (false);
}
}
/*******************************************************************************
** Bind this adapter as the runtime's alert sink when the SPI is present and no
** other sink was bound already. Returns whether it bound.
*******************************************************************************/
public static boolean bindIfPresent()
{
if(!(BackupsRuntime.getAlertSink() instanceof NoOpBackupAlertSink) || !isPresent(DEFAULT_API_PACKAGE))
{
return (false);
}
BackupsRuntime.setAlertSink(new AlertsQBitAlertSink());
return (true);
}
/*******************************************************************************
** Declare the backup alert types with the Alerts qbit (QAlertTypeMetaData), when
** the SPI is present. Returns how many were added.
*******************************************************************************/
public int registerAlertTypes(QInstance qInstance)
{
int added = 0;
try
{
Class<?> typeClass = Class.forName(apiPackage + ".QAlertTypeMetaData");
load();
for(BackupAlerts.AlertType alertType : BackupAlerts.alertTypes())
{
try
{
Object type = typeClass.getConstructor().newInstance();
typeClass.getMethod("withName", String.class).invoke(type, alertType.name());
typeClass.getMethod("withLabel", String.class).invoke(type, alertType.label());
typeClass.getMethod("withGroupName", String.class).invoke(type, "Backups");
typeClass.getMethod("withHowToFix", String.class).invoke(type, alertType.howToFix());
typeClass.getMethod("withDefaultSeverity", alertSeverity).invoke(type, severity(alertType.defaultSeverity()));
typeClass.getMethod("addSelfToInstance", QInstance.class).invoke(type, qInstance);
added++;
}
catch(ReflectiveOperationException | RuntimeException e)
{
warnOnce("Could not declare the alert type " + alertType.name(), e);
}
}
}
catch(ReflectiveOperationException | LinkageError e)
{
warnOnce("The alerts SPI is not available", e);
}
return (added);
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public void raise(String type, String dedupeKey, Severity severity, String title, Map<String, String> detail)
{
if(!isAlertsQBitInstalled())
{
return;
}
try
{
Object signal = alertSignal.getConstructor(String.class).newInstance(type);
alertSignal.getMethod("setDedupKey", String.class).invoke(signal, dedupeKey);
alertSignal.getMethod("setSeverity", alertSeverity).invoke(signal, severity(severity));
alertSignal.getMethod("setTitle", String.class).invoke(signal, title);
if(detail != null && !detail.isEmpty())
{
alertSignal.getMethod("setSummary", String.class).invoke(signal, detail.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()).collect(Collectors.joining(", ")));
Method withAttribute = alertSignal.getMethod("withAttribute", String.class, String.class);
for(Map.Entry<String, String> entry : detail.entrySet())
{
withAttribute.invoke(signal, entry.getKey(), entry.getValue());
}
}
qAlerts.getMethod("raise", alertSignal).invoke(null, signal);
}
catch(ReflectiveOperationException | RuntimeException e)
{
warnOnce("Could not raise an alert", e);
}
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public void resolve(String type, String dedupeKey)
{
if(!isAlertsQBitInstalled())
{
return;
}
try
{
qAlerts.getMethod("resolve", String.class, String.class, String.class).invoke(null, type, dedupeKey, "Cleared by the Backups qbit");
}
catch(ReflectiveOperationException | RuntimeException e)
{
warnOnce("Could not resolve an alert", e);
}
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public void heartbeat(String monitorKey, long expectedEverySeconds, long graceSeconds)
{
if(!isAlertsQBitInstalled())
{
return;
}
try
{
qAlerts.getMethod("heartbeat", String.class).invoke(null, monitorKey);
}
catch(ReflectiveOperationException | RuntimeException e)
{
warnOnce("Could not check in a heartbeat", e);
}
}
/*******************************************************************************
** The SPI classes load, and the instance has alerts meta-data (the Alerts qbit
** is installed). Checked on every call so the order qbits are produced in
** does not matter.
*******************************************************************************/
boolean isAlertsQBitInstalled()
{
try
{
load();
QInstance qInstance = QContext.getQInstance();
if(qInstance == null)
{
return (false);
}
return (instanceMetaData.getMethod("of", QInstance.class).invoke(null, qInstance) != null);
}
catch(ReflectiveOperationException | RuntimeException | LinkageError e)
{
warnOnce("The alerts SPI is not usable", e);
return (false);
}
}
/*******************************************************************************
**
*******************************************************************************/
private synchronized void load() throws ClassNotFoundException
{
if(qAlerts == null)
{
qAlerts = Class.forName(apiPackage + ".QAlerts");
alertSignal = Class.forName(apiPackage + ".AlertSignal");
alertSeverity = Class.forName(apiPackage + ".AlertSeverity");
instanceMetaData = Class.forName(apiPackage + ".AlertsInstanceMetaData");
}
}
/*******************************************************************************
** Map our severity to the SPI's enum constant of the same name.
*******************************************************************************/
@SuppressWarnings({ "unchecked", "rawtypes" })
private Object severity(Severity severity)
{
return (Enum.valueOf((Class<Enum>) alertSeverity, severity.name()));
}
/*******************************************************************************
**
*******************************************************************************/
private void warnOnce(String message, Throwable t)
{
if(!warned)
{
warned = true;
LOG.warn(message + " (the Alerts qbit adapter will keep failing quietly)", t, logPair("apiPackage", apiPackage));
}
}
}
@@ -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));
}
}
@@ -29,7 +29,6 @@ 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.QAppSection;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
@@ -62,7 +61,7 @@ public final class BackupsApp
public static List<QWidgetMetaData> widgets()
{
return (List.of(
new QWidgetMetaData().withName(HOME_WIDGET).withLabel("Backups").withType(WidgetType.HTML.getType())
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())));
@@ -80,13 +79,7 @@ public final class BackupsApp
.withLabel(config.getAppLabel())
.withIcon(new QIcon("backup"))
.withPermissionRules(BackupsTables.permissionRules())
.withWidgets(List.of(HOME_WIDGET))
.withSection(new QAppSection().withName("backups").withLabel("Backups").withIcon(new QIcon("backup"))
.withTables(List.of(BackupsTables.PLAN, BackupsTables.RUN)))
.withSection(new QAppSection().withName("recovery").withLabel("Restore and verify").withIcon(new QIcon("restore"))
.withTables(List.of(BackupsTables.RESTORE_RUN, BackupsTables.VERIFICATION)))
.withSection(new QAppSection().withName("files").withLabel("Files").withIcon(new QIcon("attach_file"))
.withTables(List.of(BackupsTables.FILE_OBJECT)));
.withWidgets(List.of(HOME_WIDGET));
if(config.getParentAppName() != null)
{
app.setParentAppName(config.getParentAppName());
@@ -143,10 +136,6 @@ public final class BackupsApp
static String pathPrefix()
{
var config = com.kingsrook.qbits.backups.BackupsRuntime.getConfig();
if(config == null)
{
return ("/backups");
}
return (config.getParentAppName() != null ? "/" + config.getParentAppName() + "/" + config.getAppName() : "/" + config.getAppName());
return (config == null ? "/app" : config.getTablePathPrefix());
}
}
@@ -159,12 +159,53 @@ public class BackupsHomeData
/*******************************************************************************
** 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(p.getValueString("slaStatus"))).toList());
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);
}
@@ -206,7 +247,7 @@ public class BackupsHomeData
Integer runId = verification.getValueInteger("backupRunId");
for(QRecord run : recentRuns)
{
if(runId != null && runId.equals(run.getValueInteger("id")) && planId.equals(run.getValueInteger("backupPlanId")))
if(runId != null && planId != null && runId.equals(run.getValueInteger("id")) && planId.equals(run.getValueInteger("backupPlanId")))
{
return (verification);
}
@@ -224,6 +265,10 @@ public class BackupsHomeData
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"))
@@ -242,6 +287,10 @@ public class BackupsHomeData
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")
@@ -27,21 +27,30 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Renders the Backups home: one card per concern, each with an honest empty
** state. Cards stack to one column on narrow screens, long names wrap, and
** there are no tables and no inner horizontal scroll (definition lists and
** lists only), so it reads at every width in light and dark mode (colors are
** inherited, not set).
** 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 GRID = "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,22rem),1fr));gap:1rem;";
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;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;
@@ -59,14 +68,15 @@ public final class BackupsHomeView
*******************************************************************************/
public static String render(BackupsHomeData data, String pathPrefix)
{
StringBuilder html = new StringBuilder("<div class=\"backups-home\" style=\"" + GRID + "\">");
StringBuilder html = new StringBuilder("<div class=\"backups-home\" style=\"" + ROOT + "\">");
planStatus(html, data, pathPrefix);
html.append("<div style=\"").append(GRID).append("\">");
needsAttention(html, data, pathPrefix);
latestTestRestore(html, data, pathPrefix);
trend(html, data);
recentActivity(html, data, pathPrefix);
trend(html, data);
retention(html, data);
return (html.append("</div>").toString());
return (html.append("</div></div>").toString());
}
@@ -82,13 +92,13 @@ public final class BackupsHomeView
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 href=\"" + esc(pathPrefix) + "\">Open the Backups home</a></div>");
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: last successful backup, age against the SLA, last run, verification.
** Plan status: one row per plan, the cells wrap to the width.
*******************************************************************************/
private static void planStatus(StringBuilder html, BackupsHomeData data, String prefix)
{
@@ -99,24 +109,29 @@ public final class BackupsHomeView
}
for(QRecord plan : data.plans())
{
Long age = data.ageHours(plan);
Integer sla = plan.getValueInteger("slaHours");
html.append("<dl style=\"").append(LIST).append("\">");
html.append("<dt><strong>Plan</strong></dt><dd><a href=\"").append(esc(prefix + "/" + BackupsTables.PLAN + "/" + plan.getValue("id"))).append("\">")
.append(esc(plan.getValueString("name"))).append("</a>").append(Boolean.TRUE.equals(plan.getValueBoolean("isActive")) ? "" : " (not active)").append("</dd>");
row(html, "Status", label(plan.getValueString("slaStatus")));
row(html, "Last backup", plan.getValueInstant("lastSuccessDate") == null ? "No successful backup yet" : WHEN.format(plan.getValueInstant("lastSuccessDate")));
if(age != null && sla != null)
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")))
{
row(html, "Age against SLA", age + " h of " + sla + " h" + (age > sla ? " - late" : ""));
html.append("<span style=\"").append(MUTED).append("\">Not active</span>");
}
row(html, "Last verified", plan.getValueInstant("lastVerifiedDate") == null ? "Not verified yet" : WHEN.format(plan.getValueInstant("lastVerifiedDate")));
row(html, "Schedule", plan.getValueString("cronExpression") == null ? "Manual only" : plan.getValueString("cronExpression"));
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")))
{
row(html, "Object lock", plan.getValueString("objectLockMode") + ", " + plan.getValueInteger("objectLockDays") + " days");
cell(html, "Object lock", plan.getValueString("objectLockMode") + (plan.getValueInteger("objectLockDays") == null ? "" : ", " + plan.getValueInteger("objectLockDays") + " days"));
}
html.append("</dl>");
html.append("</div></div>");
}
link(html, prefix, BackupsTables.PLAN, "Backup plans");
close(html);
@@ -144,13 +159,13 @@ public final class BackupsHomeView
html.append("<ul style=\"").append(ITEMS).append("\">");
for(QRecord plan : plans)
{
html.append("<li><a href=\"").append(esc(prefix + "/" + BackupsTables.PLAN + "/" + plan.getValue("id"))).append("\">").append(esc(plan.getValueString("name")))
.append("</a>: ").append(esc(label(plan.getValueString("slaStatus")))).append("</li>");
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><a href=\"").append(esc(prefix + "/" + BackupsTables.RUN + "/" + run.getValue("id"))).append("\">Run ").append(esc(run.getValueString("runKey")))
.append("</a> ").append(esc(label(run.getValueString("status")))).append("</li>");
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>");
}
@@ -178,7 +193,8 @@ public final class BackupsHomeView
html.append("<dl style=\"").append(LIST).append("\">");
row(html, "Plan", plan.getValueString("name"));
row(html, "Result", label(v.getValueString("status")));
row(html, "Compared", v.getValueInteger("tablesChecked") + " tables, " + v.getValueLong("rowsChecked") + " rows, " + v.getValueInteger("mismatches") + " mismatches");
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>");
}
@@ -193,7 +209,8 @@ public final class BackupsHomeView
/*******************************************************************************
** Size and duration trend per plan, as numbers next to each other.
** 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)
{
@@ -209,13 +226,14 @@ public final class BackupsHomeView
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, "Plan", plan.getValueString("name"));
row(html, "Successful runs", String.valueOf(runs.size()));
row(html, "Stored (first, latest)", bytes(first.getValueLong("bytesStored")) + ", " + bytes(last.getValueLong("bytesStored")));
row(html, "Rows (first, latest)", number(first.getValueLong("rowsTotal")) + ", " + number(last.getValueLong("rowsTotal")));
row(html, "Duration (latest)", last.getValueLong("durationMs") == null ? "Not recorded" : last.getValueLong("durationMs") / 1000 + " s");
html.append("</dl>");
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)
{
@@ -227,7 +245,31 @@ public final class BackupsHomeView
/*******************************************************************************
** Recent activity: the last runs and restores.
** 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)
{
@@ -242,13 +284,13 @@ public final class BackupsHomeView
data.recentRuns().stream().limit(6).forEach(run ->
{
Instant start = run.getValueInstant("startDate");
html.append("<li>Backup <a href=\"").append(esc(prefix + "/" + BackupsTables.RUN + "/" + run.getValue("id"))).append("\">").append(esc(run.getValueString("runKey")))
.append("</a>: ").append(esc(label(run.getValueString("status")))).append(start == null ? "" : ", " + WHEN.format(start)).append("</li>");
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 <a href=\"").append(esc(prefix + "/" + BackupsTables.RESTORE_RUN + "/" + restore.getValue("id")))
.append("\">#").append(esc(String.valueOf(restore.getValue("id")))).append("</a>: ").append(esc(label(restore.getValueString("status")))).append("</li>"));
data.verifications().stream().limit(2).forEach(v -> html.append("<li>Verify <a href=\"").append(esc(prefix + "/" + BackupsTables.VERIFICATION + "/" + v.getValue("id")))
.append("\">").append(esc(label(v.getValueString("level")))).append("</a>: ").append(esc(label(v.getValueString("status")))).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");
@@ -274,9 +316,10 @@ public final class BackupsHomeView
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() + " " + 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, "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>");
}
@@ -290,17 +333,17 @@ public final class BackupsHomeView
/*******************************************************************************
**
** 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;\">").append(esc(title)).append("</h3>");
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)
{
@@ -310,17 +353,17 @@ public final class BackupsHomeView
/*******************************************************************************
**
** A muted empty-state line.
*******************************************************************************/
private static void empty(StringBuilder html, String text)
{
html.append("<p style=\"").append(MUTED).append("margin:0 0 .75rem;\">").append(esc(text)).append("</p>");
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)
{
@@ -330,17 +373,53 @@ public final class BackupsHomeView
/*******************************************************************************
**
** A small label over its value (a plan row cell).
*******************************************************************************/
private static void link(StringBuilder html, String prefix, String table, String text)
private static void cell(StringBuilder html, String name, String value)
{
html.append("<a href=\"").append(esc(prefix + "/" + table)).append("\">").append(esc(text)).append("</a>");
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>");
}
/*******************************************************************************
** SOME_STATUS to Some status.
** 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)
{
@@ -348,6 +427,10 @@ public final class BackupsHomeView
{
return ("Unknown");
}
if("OK".equals(status))
{
return ("OK");
}
String s = status.replace('_', ' ').toLowerCase();
return (Character.toUpperCase(s.charAt(0)) + s.substring(1));
}
@@ -355,7 +438,32 @@ public final class BackupsHomeView
/*******************************************************************************
**
** 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)
{
@@ -363,7 +471,7 @@ public final class BackupsHomeView
{
return ("Not recorded");
}
double v = value;
double v = value;
String[] units = { "B", "KB", "MB", "GB", "TB" };
int i = 0;
while(v >= 1024 && i < units.length - 1)
@@ -377,7 +485,7 @@ public final class BackupsHomeView
/*******************************************************************************
**
** A number with separators.
*******************************************************************************/
static String number(Long value)
{
@@ -387,7 +495,7 @@ public final class BackupsHomeView
/*******************************************************************************
**
** HTML-escape.
*******************************************************************************/
static String esc(String s)
{
@@ -22,9 +22,11 @@ import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.function.Consumer;
import java.util.zip.GZIPOutputStream;
import com.kingsrook.qbits.backups.storage.BackupStorage;
import com.kingsrook.qbits.backups.storage.WriteOptions;
@@ -65,6 +67,10 @@ public class TableArtifactWriter implements Closeable
private String firstKeyOfTable;
private String lastKeyOfTable;
private Consumer<PartResult> partListener;
private String objectLockMode;
private Instant objectLockUntil;
/***************************************************************************
@@ -122,6 +128,63 @@ public class TableArtifactWriter implements Closeable
/*******************************************************************************
** Be told about each part as it is committed to storage (so the catalog records
** a part the moment it is complete, which is what a resume starts from).
*******************************************************************************/
public TableArtifactWriter withPartListener(Consumer<PartResult> partListener)
{
this.partListener = partListener;
return (this);
}
/*******************************************************************************
** Apply an object lock to every part written.
*******************************************************************************/
public TableArtifactWriter withObjectLock(String mode, Instant until)
{
this.objectLockMode = mode;
this.objectLockUntil = until;
return (this);
}
/*******************************************************************************
** Continue after parts that were already written (resume): numbering carries on
** after them and the table totals include them. The caller then feeds the rows
** hash with seedRowsHash.
*******************************************************************************/
public void resumeFrom(List<PartResult> priorParts)
{
if(priorParts.isEmpty())
{
return;
}
parts.addAll(priorParts);
partNumber = priorParts.get(priorParts.size() - 1).partNumber();
for(PartResult part : priorParts)
{
totalRows += part.rows();
}
firstKeyOfTable = priorParts.get(0).firstKey();
lastKeyOfTable = priorParts.get(priorParts.size() - 1).lastKey();
}
/*******************************************************************************
** Feed already-written canonical JSONL bytes into the rows hash (resume).
*******************************************************************************/
public void seedRowsHash(byte[] bytes, int offset, int length)
{
rowsDigest.update(bytes, offset, length);
}
/*******************************************************************************
** Write one record.
*******************************************************************************/
@@ -245,6 +308,10 @@ public class TableArtifactWriter implements Closeable
WriteOptions options = WriteOptions.create().withContentType("application/octet-stream")
.withMetadata("qqq-backup-run", runKey)
.withMetadata("qqq-backup-table", table.getName());
if(objectLockMode != null)
{
options.withObjectLock(objectLockMode, objectLockUntil);
}
OutputStream toStorage = storage.openWrite(current.key, options);
current.stored = new HashingOutputStream(toStorage);
OutputStream transformed = transform.wrap(current.stored);
@@ -266,9 +333,14 @@ public class TableArtifactWriter implements Closeable
private void closePart() throws IOException
{
current.raw.close();
parts.add(new PartResult(partNumber, current.key, current.rows, current.raw.getCount(), current.stored.getCount(),
PartResult result = new PartResult(partNumber, current.key, current.rows, current.raw.getCount(), current.stored.getCount(),
current.raw.getSha256(), current.stored.getSha256(), current.firstKey, current.lastKey, transform.encrypts(),
csv ? "CSV" : "JSONL", gzip ? "GZIP" : "NONE"));
csv ? "CSV" : "JSONL", gzip ? "GZIP" : "NONE");
parts.add(result);
current = null;
if(partListener != null)
{
partListener.accept(result);
}
}
}
@@ -0,0 +1,146 @@
/*
* 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.events;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.BiConsumer;
import com.kingsrook.qbits.backups.BackupsRuntime;
import com.kingsrook.qqq.backend.core.logging.QLogger;
/*******************************************************************************
** ESB events of the Backups qbit (design spec section 10.4): qqq.backups.run.succeeded,
** qqq.backups.run.failed and qqq.backups.restore.completed. They are for
** integrations, not alerting, and are FIRE AND FORGET: a failed publish is logged
** at most once and never fails a backup or a restore.
**
** Events go out only when BackupsQBitConfig.esbDestinationName is set and the ESB
** (qqq-esb, an optional dependency) is on the class path; otherwise publish() does
** nothing. A test, or an app with its own bus, can bind a publisher with
** {@link #setPublisher}.
*******************************************************************************/
public final class BackupEvents
{
private static final QLogger LOG = QLogger.getLogger(BackupEvents.class);
public static final String RUN_SUCCEEDED = "qqq.backups.run.succeeded";
public static final String RUN_FAILED = "qqq.backups.run.failed";
public static final String RESTORE_COMPLETED = "qqq.backups.restore.completed";
private static volatile BiConsumer<String, Map<String, Serializable>> publisher;
private static volatile boolean warned = false;
/*******************************************************************************
** Utility class.
*******************************************************************************/
private BackupEvents()
{
}
/*******************************************************************************
** Bind a publisher (event type, data), or null to go back to the ESB default.
*******************************************************************************/
public static void setPublisher(BiConsumer<String, Map<String, Serializable>> newPublisher)
{
publisher = newPublisher;
warned = false;
}
/*******************************************************************************
** A run finished. Succeeded and warnings publish run.succeeded; failed, timed
** out and abandoned publish run.failed; cancelled publishes nothing.
*******************************************************************************/
public static void runFinished(String planKey, String runKey, String status, long rows, long bytesStored)
{
String type;
if("SUCCEEDED".equals(status) || "SUCCEEDED_WITH_WARNINGS".equals(status))
{
type = RUN_SUCCEEDED;
}
else if("CANCELLED".equals(status))
{
return;
}
else
{
type = RUN_FAILED;
}
Map<String, Serializable> data = new LinkedHashMap<>();
data.put("plan", planKey);
data.put("run", runKey);
data.put("status", status);
data.put("rows", rows);
data.put("bytesStored", bytesStored);
publish(type, data);
}
/*******************************************************************************
** A restore finished (any status).
*******************************************************************************/
public static void restoreCompleted(String restoreRunKey, String status, long rowsRestored)
{
Map<String, Serializable> data = new LinkedHashMap<>();
data.put("restoreRun", restoreRunKey);
data.put("status", status);
data.put("rows", rowsRestored);
publish(RESTORE_COMPLETED, data);
}
/*******************************************************************************
** Publish an event. Never throws.
*******************************************************************************/
public static void publish(String type, Map<String, Serializable> data)
{
try
{
BiConsumer<String, Map<String, Serializable>> bound = publisher;
if(bound != null)
{
bound.accept(type, data);
return;
}
String destination = BackupsRuntime.getConfig() == null ? null : BackupsRuntime.getConfig().getEsbDestinationName();
if(destination == null)
{
return;
}
EsbBackupEventPublisher.publish(destination, type, data);
}
catch(Exception | LinkageError e)
{
if(!warned)
{
warned = true;
LOG.warn("A backup event could not be published (events are best effort)", e);
}
}
}
}
@@ -0,0 +1,54 @@
/*
* 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.events;
import java.io.Serializable;
import java.util.Map;
import com.kingsrook.qqq.esb.publish.EsbPublishAction;
import com.kingsrook.qqq.esb.publish.EsbPublishInput;
/*******************************************************************************
** Publishes a backup event on the QQQ ESB. This is the only class that touches
** qqq-esb (an optional dependency); BackupEvents calls it inside a guard that
** swallows a missing class (NoClassDefFoundError), so apps without the ESB never
** notice.
*******************************************************************************/
final class EsbBackupEventPublisher
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private EsbBackupEventPublisher()
{
}
/*******************************************************************************
** Publish one event to a destination.
*******************************************************************************/
static void publish(String destinationName, String type, Map<String, Serializable> data)
{
new EsbPublishAction().execute(new EsbPublishInput()
.withDestinationName(destinationName)
.withType(type)
.withSourcePath("/backups")
.withData(data));
}
}
@@ -57,6 +57,8 @@ public class FileCopier
private final FileStoreRegistry registry;
private final boolean referenceOnly;
private final String runKey;
private String objectLockMode;
private java.time.Instant objectLockUntil;
@@ -119,6 +121,19 @@ public class FileCopier
/*******************************************************************************
** Lock every object this copier writes (a server-side copy cannot set a lock,
** so with a lock the bytes are streamed instead).
*******************************************************************************/
public FileCopier withObjectLock(String mode, java.time.Instant until)
{
this.objectLockMode = mode;
this.objectLockUntil = until;
return (this);
}
/*******************************************************************************
** The content-addressed key for a hash: prefix/ab/cd/sha256.
*******************************************************************************/
@@ -177,7 +192,7 @@ public class FileCopier
*******************************************************************************/
private String copy(BackupStorage source, FileReference ref, String key) throws IOException
{
if(source == destination && !transform.encrypts())
if(source == destination && !transform.encrypts() && objectLockMode == null)
{
destination.copy(ref.key(), key);
return (null);
@@ -185,6 +200,10 @@ public class FileCopier
WriteOptions options = WriteOptions.create().withContentType(ref.contentType() == null ? "application/octet-stream" : ref.contentType())
.withMetadata("qqq-backup-run", runKey);
if(objectLockMode != null)
{
options.withObjectLock(objectLockMode, objectLockUntil);
}
try(InputStream in = source.openRead(ref.key());
HashingOutputStream plain = new HashingOutputStream(transform.wrap(destination.openWrite(key, options))))
{
@@ -0,0 +1,120 @@
/*
* 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.health;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.backups.model.BackupsTables;
import com.kingsrook.qbits.backups.run.BackupCatalog;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.middleware.health.HealthIndicator;
import com.kingsrook.qqq.middleware.health.model.metadata.HealthCheckResult;
import com.kingsrook.qqq.middleware.health.model.metadata.HealthStatus;
/*******************************************************************************
** The Backups health indicator (design spec section 10.4), for
** qqq-middleware-health (an optional dependency: an app that does not have that
** module never loads this class). It reports DOWN if any active plan marked
** critical is LATE, FAILING or MISSING_IN_STORAGE, and UP with details otherwise.
** It reads the slaStatus the SLA checker maintains; it never touches storage, so
** a health call is cheap.
**
** It is OFF the liveness path by design. Add it to the indicators of the
** instance's health check (the readiness and monitoring view), and do not point a
** Kubernetes liveness probe at a check that includes it: a backup problem must
** never restart pods.
*******************************************************************************/
public class BackupsHealthIndicator implements HealthIndicator
{
public static final String NAME = "backups";
private static final Set<String> CRITICAL_STATUSES = Set.of("LATE", "FAILING", "MISSING_IN_STORAGE");
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public String getName()
{
return (NAME);
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public HealthCheckResult check(QInstance qInstance) throws QException
{
long started = System.currentTimeMillis();
boolean ownsContext = QContext.getQInstance() == null;
if(ownsContext)
{
QContext.init(qInstance, new QSession());
}
try
{
List<QRecord> plans = new BackupCatalog().query(BackupsTables.PLAN, new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true)));
List<String> criticalProblems = new ArrayList<>();
Map<String, Integer> byStatus = new LinkedHashMap<>();
for(QRecord plan : plans)
{
String status = plan.getValueString("slaStatus") == null ? "UNKNOWN" : plan.getValueString("slaStatus");
byStatus.merge(status, 1, Integer::sum);
if(Boolean.TRUE.equals(plan.getValueBoolean("critical")) && CRITICAL_STATUSES.contains(status))
{
criticalProblems.add(plan.getValueString("planKey") + ": " + status);
}
}
Map<String, Object> details = new LinkedHashMap<>();
details.put("activePlans", plans.size());
details.put("plansByStatus", byStatus);
if(!criticalProblems.isEmpty())
{
details.put("criticalPlansWithProblems", criticalProblems);
}
return (new HealthCheckResult()
.withStatus(criticalProblems.isEmpty() ? HealthStatus.UP : HealthStatus.DOWN)
.withDurationMs(System.currentTimeMillis() - started)
.withDetails(details));
}
finally
{
if(ownsContext)
{
QContext.clear();
}
}
}
}
@@ -0,0 +1,184 @@
/*
* 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.metrics;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.backups.BackupsQBitConfig;
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.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** The optional Prometheus text for operators who already run Alertmanager (design
** spec section 10.4), computed from the tables and cached for 60 seconds. Gauges,
** all labeled {instance_name, plan}:
** <ul>
** <li>qqq_backup_last_success_timestamp_seconds</li>
** <li>qqq_backup_last_run_failed (1 when the latest finished run failed)</li>
** <li>qqq_backup_sla_seconds</li>
** <li>qqq_backup_last_verify_success_timestamp_seconds</li>
** <li>qqq_backup_last_success_bytes</li>
** </ul>
** The rule worth having even alongside the Alerts qbit is
** absent_over_time(qqq_backup_last_success_timestamp_seconds[30m]): the one signal
** that still fires when the whole app, and so the Alerts qbit with it, is down.
**
** ENDPOINT STUB: this class renders the text. Serving it at GET /metrics/backups
** (when BackupsQBitConfig.withPrometheusEndpoint(true)) is a route in
** qqq-middleware-javalin, which this qbit does not depend on; the app's Javalin
** route provider calls {@link #renderCached()}. {@link #isEnabled()} says whether
** the config asks for the endpoint.
*******************************************************************************/
public final class BackupsMetrics
{
public static final String ENDPOINT_PATH = "/metrics/backups";
private static final Duration CACHE = Duration.ofSeconds(60);
private static String cachedText;
private static Instant cachedAt;
/*******************************************************************************
** Utility class.
*******************************************************************************/
private BackupsMetrics()
{
}
/*******************************************************************************
** Whether the config asks for the endpoint.
*******************************************************************************/
public static boolean isEnabled()
{
BackupsQBitConfig config = BackupsRuntime.getConfig();
return (config != null && Boolean.TRUE.equals(config.getPrometheusEndpoint()));
}
/*******************************************************************************
** Forget the cached text (tests).
*******************************************************************************/
public static synchronized void resetCache()
{
cachedText = null;
cachedAt = null;
}
/*******************************************************************************
** The text, from the cache when it is under 60 seconds old.
*******************************************************************************/
public static synchronized String renderCached() throws QException
{
Instant now = BackupsRuntime.getClock().instant();
if(cachedText == null || cachedAt == null || cachedAt.plus(CACHE).isBefore(now))
{
cachedText = render();
cachedAt = now;
}
return (cachedText);
}
/*******************************************************************************
** Render the text format now.
*******************************************************************************/
public static String render() throws QException
{
BackupCatalog catalog = new BackupCatalog();
BackupsQBitConfig config = BackupsRuntime.getConfig();
String instance = config != null && config.getInstanceName() != null ? config.getInstanceName() : "qqq";
StringBuilder lastSuccess = header("qqq_backup_last_success_timestamp_seconds", "Unix time of the last successful backup run");
StringBuilder lastFailed = header("qqq_backup_last_run_failed", "1 when the latest finished run of the plan failed");
StringBuilder sla = header("qqq_backup_sla_seconds", "Maximum age of the last successful run before the plan is late");
StringBuilder lastVerify = header("qqq_backup_last_verify_success_timestamp_seconds", "Unix time of the last passed content or test-restore verification");
StringBuilder lastBytes = header("qqq_backup_last_success_bytes", "Bytes stored by the last successful run");
List<QRecord> plans = catalog.query(BackupsTables.PLAN, new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true)));
for(QRecord plan : plans)
{
String labels = "{instance_name=\"" + escape(instance) + "\",plan=\"" + escape(plan.getValueString("planKey")) + "\"}";
if(plan.getValueInstant("lastSuccessDate") != null)
{
lastSuccess.append("qqq_backup_last_success_timestamp_seconds").append(labels).append(' ').append(plan.getValueInstant("lastSuccessDate").getEpochSecond()).append('\n');
}
List<QRecord> runs = catalog.where(BackupsTables.RUN, "backupPlanId", plan.getValueInteger("id"));
QRecord latest = runs.stream().filter(r -> List.of("SUCCEEDED", "SUCCEEDED_WITH_WARNINGS", "FAILED", "TIMED_OUT", "ABANDONED").contains(r.getValueString("status"))).findFirst().orElse(null);
if(latest != null)
{
boolean failed = List.of("FAILED", "TIMED_OUT", "ABANDONED").contains(latest.getValueString("status"));
lastFailed.append("qqq_backup_last_run_failed").append(labels).append(' ').append(failed ? 1 : 0).append('\n');
}
Integer slaHours = plan.getValueInteger("slaHours");
if(slaHours != null)
{
sla.append("qqq_backup_sla_seconds").append(labels).append(' ').append(slaHours * 3600L).append('\n');
}
if(plan.getValueInstant("lastVerifiedDate") != null)
{
lastVerify.append("qqq_backup_last_verify_success_timestamp_seconds").append(labels).append(' ').append(plan.getValueInstant("lastVerifiedDate").getEpochSecond()).append('\n');
}
if(plan.getValueInteger("lastSuccessRunId") != null)
{
QRecord run = catalog.get(BackupsTables.RUN, plan.getValueInteger("lastSuccessRunId")).orElse(null);
if(run != null && run.getValueLong("bytesStored") != null)
{
lastBytes.append("qqq_backup_last_success_bytes").append(labels).append(' ').append(run.getValueLong("bytesStored")).append('\n');
}
}
}
return (lastSuccess.toString() + lastFailed + sla + lastVerify + lastBytes);
}
/*******************************************************************************
**
*******************************************************************************/
private static StringBuilder header(String name, String help)
{
return (new StringBuilder("# HELP ").append(name).append(' ').append(help).append('\n').append("# TYPE ").append(name).append(" gauge\n"));
}
/*******************************************************************************
** Escape a label value for the text format.
*******************************************************************************/
static String escape(String value)
{
return (value == null ? "" : value.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n"));
}
}
@@ -23,8 +23,20 @@ import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.backups.BackupsQBitConfig;
import com.kingsrook.qbits.backups.permissions.BackupsPermissionChecker;
import com.kingsrook.qbits.backups.plan.BackupPlanTableCustomizer;
import com.kingsrook.qbits.backups.schedule.BackupPlanSyncToScheduledJobProcess;
import com.kingsrook.qbits.backups.schedule.BackupPlanVerifySyncToScheduledJobProcess;
import com.kingsrook.qqq.backend.core.actions.customizers.MultiCustomizer;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
import com.kingsrook.qqq.backend.core.scheduler.processes.BaseSyncToScheduledJobTableCustomizer;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
@@ -63,7 +75,7 @@ public final class BackupsTables
public static final List<String> PROCESS_WRITTEN = List.of(RUN, ARTIFACT, VERIFICATION, RESTORE_RUN, RESTORE_RUN_TABLE, FILE_OBJECT, ID_MAP);
/** The tables shown in the nav (sub-tables appear as relations on their parents). */
public static final List<String> NAV_TABLES = List.of(PLAN, RUN, RESTORE_RUN, VERIFICATION, FILE_OBJECT);
public static final List<String> NAV_TABLES = List.of(PLAN, RUN, RESTORE_RUN, VERIFICATION);
public static final List<String> ALL = List.of(PLAN, PLAN_SCOPE, RUN, ARTIFACT, VERIFICATION, RESTORE_RUN, RESTORE_RUN_TABLE, FILE_OBJECT, ID_MAP);
@@ -91,14 +103,14 @@ public final class BackupsTables
ENUMS.put("lock_mode", List.of("NONE", "GOVERNANCE", "COMPLIANCE"));
ENUMS.put("sla_status", List.of("OK", "LATE", "FAILING", "MISSING_IN_STORAGE", "UNVERIFIED", "ABANDONED", "OWNER_INVALID", "UNKNOWN"));
ENUMS.put("run_trigger", List.of("SCHEDULED", "MANUAL", "PRE_RESTORE", "RESUME", "IMPORTED"));
ENUMS.put("run_status", List.of("QUEUED", "RUNNING", "SUCCEEDED", "SUCCEEDED_WITH_WARNINGS", "FAILED", "TIMED_OUT", "CANCELLED", "ABANDONED"));
ENUMS.put("run_status", List.of("QUEUED", "RUNNING", "SUCCEEDED", "SUCCEEDED_WITH_WARNINGS", "FAILED", "TIMED_OUT", "CANCELLING", "CANCELLED", "ABANDONED"));
ENUMS.put("retention_class", List.of("DAILY", "WEEKLY", "MONTHLY", "YEARLY", "NONE"));
ENUMS.put("verification_status", List.of("UNVERIFIED", "STORAGE_OK", "CONTENT_OK", "TEST_RESTORE_OK", "FAILED"));
ENUMS.put("artifact_type", List.of("TABLE_DATA", "TABLE_BLOBS", "FILE_OBJECTS_INDEX", "NATIVE_DUMP", "MANIFEST", "CHECKSUMS"));
ENUMS.put("artifact_status", List.of("WRITING", "COMPLETE", "FAILED"));
ENUMS.put("verify_level", List.of("STORAGE", "CONTENT", "TEST_RESTORE"));
ENUMS.put("verify_result", List.of("PASSED", "PASSED_WITH_WARNINGS", "FAILED", "RUNNING", "ERROR"));
ENUMS.put("restore_status", List.of("PREVIEWED", "QUEUED", "RUNNING", "SUCCEEDED", "SUCCEEDED_WITH_WARNINGS", "FAILED", "CANCELLED"));
ENUMS.put("restore_status", List.of("PREVIEWED", "QUEUED", "RUNNING", "SUCCEEDED", "SUCCEEDED_WITH_WARNINGS", "FAILED", "CANCELLING", "CANCELLED"));
ENUMS.put("restore_mode", List.of("INSERT_NEW", "UPSERT", "REPLACE"));
ENUMS.put("id_strategy", List.of("PRESERVE", "REMAP_NEW_IDS"));
ENUMS.put("schema_status", List.of("MATCH", "COMPATIBLE", "MISMATCH"));
@@ -203,7 +215,111 @@ public final class BackupsTables
public static List<QTableMetaData> tables(BackupsQBitConfig config)
{
return (List.of(plan(config), planScope(config), run(config), artifact(config), verification(config),
restoreRun(config), restoreRunTable(config), fileObject(config), idMap(config)));
restoreRun(config), restoreRunTable(config), fileObject(config), idMap(config)).stream().map(BackupsTables::withSections).toList());
}
/*******************************************************************************
** Group a table's fields into record-screen sections (the identity and status
** first, then one section per concern, then the system fields), so a record reads
** as a few short groups instead of one long list. Fields not named here land in
** the last section.
*******************************************************************************/
static QTableMetaData withSections(QTableMetaData table)
{
List<Object[]> layout = switch(table.getName())
{
case PLAN -> List.of(
new Object[] { "plan", "Plan", "backup", Tier.T1, "name", "description", "planKey", "isActive", "scopeType", "slaStatus", "lastSuccessDate", "lastVerifiedDate" },
new Object[] { "schedule", "Schedule and SLA", "schedule", Tier.T2, "cronExpression", "cronTimeZoneId", "scheduledJobId", "slaHours", "verifySlaDays", "verifyCronExpression", "verifyLevel" },
new Object[] { "where", "Scope and storage", "storage", Tier.T2, "includeRoles", "storageBucketAlias", "pruneBucketAlias", "prefix", "critical", "consistency", "useReadReplica", "incrementalMode", "nativeDump" },
new Object[] { "format", "Format and safety", "shield", Tier.T2, "format", "compression", "encryptionMode", "encryptionKeyRef", "sensitiveFieldPolicy", "heavyFieldPolicy", "fileObjectPolicy", "fileMissingPolicy" },
new Object[] { "retention", "Retention and lock", "lock_clock", Tier.T2, "retainDaily", "retainWeekly", "retainMonthly", "retainYearly", "retainMinimum", "objectLockMode", "objectLockDays" },
new Object[] { "system", "Limits and history", "history", Tier.T3, "maxDurationMinutes", "pageSize", "targetPartBytes", "ownerUserId", "lastRunId", "lastSuccessRunId", "lastVerifiedRunId", "id", "createDate", "modifyDate" });
case RUN -> List.of(
new Object[] { "run", "Run", "history", Tier.T1, "runKey", "backupPlanId", "status", "trigger", "startDate", "endDate", "durationMs", "errorMessage" },
new Object[] { "volume", "Rows and size", "data_usage", Tier.T2, "tablesPlanned", "tablesDone", "rowsTotal", "bytesRaw", "bytesStored", "consistencyAchieved", "warningCount" },
new Object[] { "files", "Files", "attach_file", Tier.T2, "filesReferenced", "filesCopied", "filesDeduplicated", "filesMissing", "fileBytesCopied", "fileBytesReferenced" },
new Object[] { "storage", "Storage and integrity", "storage", Tier.T2, "storageBucketAlias", "runPrefix", "manifestKey", "manifestSha256", "snapshotId", "schemaFingerprint", "securityScope", "encryptionKeyRef", "encryptionKeyFingerprint", "verificationStatus" },
new Object[] { "retention", "Retention", "lock_clock", Tier.T2, "retentionClass", "lockedUntil", "prunedDate", "prunedByUserId" },
new Object[] { "system", "System", "settings", Tier.T3, "requestedByUserId", "heartbeatDate", "qqqVersion", "appVersion", "instanceName", "errorDetail", "id", "createDate", "modifyDate" });
case VERIFICATION -> List.of(
new Object[] { "result", "Result", "verified", Tier.T1, "backupRunId", "level", "status", "tablesChecked", "rowsChecked", "mismatches", "scratchTarget", "startDate", "endDate" },
new Object[] { "system", "Detail", "settings", Tier.T3, "trigger", "requestedByUserId", "detailJson", "id", "createDate", "modifyDate" });
case RESTORE_RUN -> List.of(
new Object[] { "restore", "Restore", "restore", Tier.T1, "sourceBackupRunId", "status", "mode", "idStrategy", "matchOn", "targetInstanceName", "startDate", "endDate", "errorMessage" },
new Object[] { "rows", "Rows", "table_rows", Tier.T2, "rowsInserted", "rowsUpdated", "rowsUnchanged", "rowsDeleted", "rowsSkipped", "rowsErrored", "sequencesResynced" },
new Object[] { "files", "Files", "attach_file", Tier.T2, "filesRestored", "filesAlreadyPresent", "filesConflicting", "filesMissingFromBackup", "fileBytesRestored" },
new Object[] { "system", "Safety and system", "settings", Tier.T3, "preRestoreBackupRunId", "scopeSummary", "automationsSuppressed", "esbSuppressed", "confirmationText", "requestedByUserId", "id", "createDate", "modifyDate" });
default -> List.of();
};
if(layout.isEmpty())
{
return (table);
}
java.util.Set<String> placed = new java.util.HashSet<>();
for(int i = 0; i < layout.size(); i++)
{
Object[] spec = layout.get(i);
List<String> fields = new ArrayList<>();
for(int f = 4; f < spec.length; f++)
{
fields.add((String) spec[f]);
}
if(i == layout.size() - 1)
{
table.getFields().keySet().stream().filter(name -> !placed.contains(name) && !fields.contains(name)).forEach(fields::add);
}
placed.addAll(fields);
table.withSection(new QFieldSection((String) spec[0], (String) spec[1], new QIcon((String) spec[2]), (Tier) spec[3], fields));
}
for(ChildList childList : CHILD_LISTS)
{
if(childList.parent().equals(table.getName()))
{
table.withSection(new QFieldSection(childList.key(), new QIcon(childList.icon()), Tier.T2).withLabel(childList.label()).withWidgetName(childList.joinName()));
}
}
return (table);
}
/** A child list on a parent record: its join, the columns shown, and the order. */
record ChildList(String parent, String joinName, String key, String label, String icon, int maxRows, boolean canAdd, String orderField, List<String> columns)
{
}
/** The relations shown on the record screens (sub-tables are child lists here, not in the nav). */
static final List<ChildList> CHILD_LISTS = List.of(
new ChildList(PLAN, "backupsPlanScopeJoinPlan", "scopeRules", "Scope rules", "rule", 50, true, "id", List.of("action", "selectorType", "selectorValue", "includeAssociatedChildren", "fieldExclusions")),
new ChildList(PLAN, "backupsRunJoinPlan", "runs", "Runs", "history", 10, false, "id", List.of("runKey", "status", "startDate", "durationMs", "rowsTotal", "bytesStored")),
new ChildList(RUN, "backupsArtifactJoinRun", "artifacts", "Artifacts", "inventory_2", 100, false, "id", List.of("tableName", "artifactType", "partNumber", "rowCount", "bytesStored", "status")),
new ChildList(RUN, "backupsVerificationJoinRun", "verifications", "Verifications", "verified", 20, false, "id", List.of("level", "status", "tablesChecked", "rowsChecked", "mismatches", "startDate")),
new ChildList(RUN, "backupsRestoreRunJoinRun", "restores", "Restores", "restore", 20, false, "id", List.of("status", "mode", "startDate", "rowsInserted", "rowsUpdated", "rowsErrored")),
new ChildList(RESTORE_RUN, "backupsRestoreRunTableJoinRestoreRun", "restoreTables", "Tables restored", "table_rows", 100, false, "id",
List.of("tableName", "targetTableName", "rowsInserted", "rowsUpdated", "rowsUnchanged", "rowsErrored", "schemaStatus")));
/*******************************************************************************
** The child-list widgets for the record screens (see CHILD_LISTS). Each shows a
** few readable columns, newest first, and never scrolls sideways.
*******************************************************************************/
public static List<QWidgetMetaData> widgets()
{
Map<String, QJoinMetaData> byName = new LinkedHashMap<>();
joins().forEach(join -> byName.put(join.getName(), join));
List<QWidgetMetaData> rs = new ArrayList<>();
for(ChildList childList : CHILD_LISTS)
{
rs.add(ChildRecordListRenderer.widgetMetaDataBuilder(byName.get(childList.joinName())).withLabel(childList.label()).withMaxRows(childList.maxRows())
.withCanAddChildRecord(childList.canAdd()).withOnlyIncludeFieldNames(childList.columns())
.withOrderBys(List.of(new QFilterOrderBy(childList.orderField(), !PLAN.equals(childList.parent()) || childList.canAdd())))
.getWidgetMetaData().withPermissionRules(permissionRules()));
}
return (rs);
}
@@ -286,6 +402,34 @@ public final class BackupsTables
** backups_plan: what, how, where, when and how long (spec 4.1).
*******************************************************************************/
static QTableMetaData plan(BackupsQBitConfig config)
{
QTableMetaData table = planBase(config);
////////////////////////////////////////////////////////////////////////////////////////
// save validation (spec 4.1, 11.1), and the plan -> scheduled job sync when the qbit //
// has a scheduler (spec 9.1): one job per active plan, and one for the verify cron //
////////////////////////////////////////////////////////////////////////////////////////
QCodeReference validation = new QCodeReference(BackupPlanTableCustomizer.class);
table.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, validation);
table.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, validation);
if(StringUtils.hasContent(config.getSchedulerName()))
{
QCodeReferenceWithProperties sync = MultiCustomizer.of(
BaseSyncToScheduledJobTableCustomizer.makeCodeReference(table, new BackupPlanSyncToScheduledJobProcess()),
BaseSyncToScheduledJobTableCustomizer.makeCodeReference(table, new BackupPlanVerifySyncToScheduledJobProcess()));
table.withCustomizer(TableCustomizers.POST_INSERT_RECORD, sync);
table.withCustomizer(TableCustomizers.POST_UPDATE_RECORD, sync);
table.withCustomizer(TableCustomizers.POST_DELETE_RECORD, sync);
}
return (table);
}
/*******************************************************************************
** The plan table before the customizers.
*******************************************************************************/
private static QTableMetaData planBase(BackupsQBitConfig config)
{
return (base(config, PLAN, "Backup Plan", "backup")
.withRecordLabelFormatAndFields("%s", "name")
@@ -319,6 +463,7 @@ public final class BackupsTables
.withField(roInt("scheduledJobId"))
.withField(new QFieldMetaData("slaHours", QFieldType.INTEGER).withDefaultValue(26))
.withField(new QFieldMetaData("verifySlaDays", QFieldType.INTEGER).withDefaultValue(8))
.withField(new QFieldMetaData("verifyFileSamplePercent", QFieldType.INTEGER).withDefaultValue(100))
.withField(new QFieldMetaData("verifyCronExpression", QFieldType.STRING).withMaxLength(100))
.withField(enumField("verifyLevel", "verify_level", "CONTENT"))
.withField(new QFieldMetaData("retainDaily", QFieldType.INTEGER).withDefaultValue(7))
@@ -368,8 +513,6 @@ public final class BackupsTables
return (base(config, RUN, "Backup Run", "history")
.withRecordLabelFormatAndFields("%s", "runKey")
.withUniqueKey(new UniqueKey("runKey"))
.withAssociation(new Association().withName("artifacts").withAssociatedTableName(ARTIFACT).withJoinName("backupsArtifactJoinRun"))
.withAssociation(new Association().withName("verifications").withAssociatedTableName(VERIFICATION).withJoinName("backupsVerificationJoinRun"))
.withField(ro("backupPlanId", QFieldType.INTEGER).withPossibleValueSourceName(PLAN))
.withField(ro("runKey", QFieldType.STRING))
.withField(roEnum("trigger", "run_trigger"))
@@ -475,7 +618,6 @@ public final class BackupsTables
{
return (base(config, RESTORE_RUN, "Backup Restore", "restore")
.withRecordLabelFormatAndFields("Restore %s", "id")
.withAssociation(new Association().withName("tables").withAssociatedTableName(RESTORE_RUN_TABLE).withJoinName("backupsRestoreRunTableJoinRestoreRun"))
.withField(ro("sourceBackupRunId", QFieldType.INTEGER).withPossibleValueSourceName(RUN))
.withField(ro("requestedByUserId", QFieldType.STRING))
.withField(ro("startDate", QFieldType.DATE_TIME))
@@ -0,0 +1,144 @@
/*
* 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.plan;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import com.kingsrook.qbits.backups.model.BackupsTables;
import com.kingsrook.qbits.backups.run.BackupCatalog;
import com.kingsrook.qbits.backups.run.BackupRunFactory;
import com.kingsrook.qbits.backups.scope.PlanSettings;
import com.kingsrook.qbits.backups.scope.ScopeResolver;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
import com.kingsrook.qqq.backend.core.model.statusmessages.QWarningMessage;
/*******************************************************************************
** Plan save validation (pre insert and pre update of backups_plan). See
** BackupPlanValidator for the rules. An update that touches none of the plan
** configuration (the processes write the last run and SLA fields) is not
** validated again.
**
** The saving user becomes the plan owner when none is given.
*******************************************************************************/
public class BackupPlanTableCustomizer implements TableCustomizerInterface
{
private static final List<String> CONFIGURATION_FIELDS = List.of("planKey", "isActive", "scopeType", "includeRoles", "storageBucketAlias", "pruneBucketAlias",
"format", "compression", "encryptionMode", "encryptionKeyRef", "sensitiveFieldPolicy", "incrementalMode", "nativeDump", "cronExpression", "cronTimeZoneId",
"verifyCronExpression", "retainDaily", "retainWeekly", "retainMonthly", "retainYearly", "retainMinimum", "objectLockMode", "objectLockDays",
"slaHours", "pageSize", "ownerUserId");
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
{
Map<Serializable, QRecord> oldById = oldRecordList.isPresent() ? oldRecordListToMap("id", oldRecordList).orElse(Map.of()) : Map.of();
BackupCatalog catalog = new BackupCatalog();
for(QRecord record : records)
{
QRecord old = oldRecordList.isPresent() ? oldById.get(record.getValue("id")) : null;
if(old != null && !touchesConfiguration(record))
{
continue;
}
QRecord merged = new QRecord();
if(old != null)
{
merged.getValues().putAll(old.getValues());
}
merged.getValues().putAll(record.getValues());
if(merged.getValue("ownerUserId") == null && Boolean.TRUE.equals(merged.getValueBoolean("isActive")))
{
QSession session = QContext.getQSession();
String owner = session == null || session.getUser() == null ? null : session.getUser().getIdReference();
if(owner != null)
{
record.setValue("ownerUserId", owner);
merged.setValue("ownerUserId", owner);
}
}
boolean hasRun = old != null && !catalog.where(BackupsTables.RUN, "backupPlanId", old.getValueInteger("id")).isEmpty();
BackupPlanValidator.Result result = BackupPlanValidator.validate(PlanSettings.of(merged), old == null ? null : old.getValueString("planKey"), hasRun, scopeRules(record, old));
for(String error : result.errors())
{
record.addError(new BadInputStatusMessage(error));
}
for(String warning : result.warnings())
{
record.addWarning(new QWarningMessage(warning));
}
}
return (records);
}
/*******************************************************************************
**
*******************************************************************************/
private static boolean touchesConfiguration(QRecord record)
{
for(String field : CONFIGURATION_FIELDS)
{
if(record.getValues().containsKey(field))
{
return (true);
}
}
return (false);
}
/*******************************************************************************
** The scope rules to resolve: the ones sent with the plan (associated rows), or
** the saved ones.
*******************************************************************************/
private static List<ScopeResolver.ScopeRule> scopeRules(QRecord record, QRecord old) throws QException
{
List<QRecord> sent = record.getAssociatedRecords() == null ? null : record.getAssociatedRecords().get("scopes");
if(sent != null)
{
List<ScopeResolver.ScopeRule> rules = new ArrayList<>();
for(QRecord row : sent)
{
rules.add(new ScopeResolver.ScopeRule(!"EXCLUDE".equals(row.getValueString("action")), row.getValueString("selectorType"), row.getValueString("selectorValue"),
row.getValueString("filterJson"), !Boolean.FALSE.equals(row.getValueBoolean("includeAssociatedChildren")), row.getValueString("fieldExclusions")));
}
return (rules);
}
return (old == null ? List.of() : BackupRunFactory.scopeRules(old.getValueInteger("id")));
}
}
@@ -0,0 +1,354 @@
/*
* 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.plan;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
import com.kingsrook.qbits.backups.BackupsRuntime;
import com.kingsrook.qbits.backups.permissions.BackupsPermissionChecker;
import com.kingsrook.qbits.backups.permissions.BackupsPermissions;
import com.kingsrook.qbits.backups.scope.PlanSettings;
import com.kingsrook.qbits.backups.scope.ScopeResolver;
import com.kingsrook.qbits.backups.storage.BackupStorage;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
/*******************************************************************************
** The rules a backup plan must satisfy when it is saved or activated (spec 4.1,
** 6.2, 11.1, 11.3): the refusals are clear messages, not run-time failures.
**
** - the saving user has backups.admin and may read every table the plan resolves
** to (a scheduled run is full-fidelity, so whoever activates the plan must be
** allowed to read all of it)
** - an object lock mode is checked against what the storage bucket can do
** - INCLUDE_ENCRYPTED_ONLY needs encryption
** - formats, compressions and options that are not built yet are refused
** - the plan key is a slug, and cannot change after the first run (it is in every
** object key)
*******************************************************************************/
public final class BackupPlanValidator
{
private static final Pattern PLAN_KEY = Pattern.compile("[a-z0-9][a-z0-9_-]{0,59}");
private static final Set<String> FORMATS = Set.of("JSONL", "CSV", "JSONL_AND_CSV");
private static final Set<String> COMPRESSIONS = Set.of("GZIP", "NONE");
private static final Set<String> LOCK_MODES = Set.of("NONE", "GOVERNANCE", "COMPLIANCE");
/** Replace with the real check when client-side encryption ships (spec 11.2). */
private static volatile boolean encryptionAvailable = false;
/*******************************************************************************
** Utility class.
*******************************************************************************/
private BackupPlanValidator()
{
}
/*******************************************************************************
** Tell the validator that client-side encryption is available (the encryption
** work calls this when it installs a cipher).
*******************************************************************************/
public static void setEncryptionAvailable(boolean available)
{
encryptionAvailable = available;
}
/*******************************************************************************
** The outcome of a validation.
*******************************************************************************/
public record Result(List<String> errors, List<String> warnings)
{
/*******************************************************************************
**
*******************************************************************************/
public boolean ok()
{
return (errors.isEmpty());
}
}
/*******************************************************************************
** Validate the complete settings of a plan.
**
** @param settings the plan values as they will be after the save
** @param oldPlanKey the plan key before the save, null for a new plan
** @param hasRun whether the plan already has a run
** @param rules the scope rules of the plan
*******************************************************************************/
public static Result validate(PlanSettings settings, String oldPlanKey, boolean hasRun, List<ScopeResolver.ScopeRule> rules)
{
List<String> errors = new ArrayList<>();
List<String> warnings = new ArrayList<>();
validateKey(settings, oldPlanKey, hasRun, errors);
validateFormats(settings, errors);
validateEncryption(settings, errors);
validateStorage(settings, errors, warnings);
validateSchedule(settings, errors);
validateNumbers(settings, errors);
if(settings.bool("isActive", true))
{
validateOwner(settings, rules, errors);
}
return (new Result(errors, warnings));
}
/*******************************************************************************
**
*******************************************************************************/
private static void validateKey(PlanSettings settings, String oldPlanKey, boolean hasRun, List<String> errors)
{
String planKey = settings.string("planKey", null);
if(planKey == null || !PLAN_KEY.matcher(planKey).matches())
{
errors.add("The plan key must be lowercase letters, digits, dash or underscore (up to 60 characters): it is part of every object key.");
}
if(oldPlanKey != null && planKey != null && !oldPlanKey.equals(planKey) && hasRun)
{
errors.add("The plan key cannot change after the plan has run: it is part of the object keys of its backups. Create a new plan instead.");
}
}
/*******************************************************************************
**
*******************************************************************************/
private static void validateFormats(PlanSettings settings, List<String> errors)
{
String format = settings.string("format", "JSONL");
if("PARQUET".equals(format))
{
errors.add("The PARQUET format is not available yet. Use JSONL, CSV or JSONL_AND_CSV.");
}
else if(!FORMATS.contains(format))
{
errors.add("Unknown format " + format + ". Use JSONL, CSV or JSONL_AND_CSV.");
}
String compression = settings.string("compression", "GZIP");
if("ZSTD".equals(compression))
{
errors.add("ZSTD compression is not available yet. Use GZIP or NONE.");
}
else if(!COMPRESSIONS.contains(compression))
{
errors.add("Unknown compression " + compression + ". Use GZIP or NONE.");
}
if("CHANGED_SINCE_LAST".equals(settings.string("incrementalMode", "FULL")))
{
errors.add("Incremental backups are not available yet. Use FULL.");
}
if(!"NONE".equals(settings.string("nativeDump", "NONE")))
{
errors.add("Native database dumps are not available yet. Use NONE.");
}
}
/*******************************************************************************
**
*******************************************************************************/
private static void validateEncryption(PlanSettings settings, List<String> errors)
{
String mode = settings.string("encryptionMode", "NONE");
String policy = settings.string("sensitiveFieldPolicy", "EXCLUDE");
if("CLIENT_AES256_GCM".equals(mode))
{
if(!encryptionAvailable)
{
errors.add("Client-side encryption is not available in this version. Use NONE (and keep the sensitive field policy at EXCLUDE).");
}
else if(settings.string("encryptionKeyRef", null) == null)
{
errors.add("Client-side encryption needs an encryption key reference (the name of the key, never the key itself).");
}
}
if("INCLUDE_ENCRYPTED_ONLY".equals(policy) && !"CLIENT_AES256_GCM".equals(mode))
{
errors.add("The sensitive field policy INCLUDE_ENCRYPTED_ONLY requires client-side encryption (CLIENT_AES256_GCM).");
}
if("INCLUDE".equals(policy) && !BackupsPermissionChecker.has(BackupsPermissions.ADMIN))
{
errors.add("Including sensitive fields in the clear needs the backups.admin permission.");
}
}
/*******************************************************************************
** The destination: the bucket exists, and an object lock is something the bucket can do.
*******************************************************************************/
private static void validateStorage(PlanSettings settings, List<String> errors, List<String> warnings)
{
String alias = settings.string("storageBucketAlias", null);
if(alias == null)
{
errors.add("Choose a storage bucket for the backups.");
return;
}
BackupStorage storage = null;
try
{
storage = BackupsRuntime.storageFor(alias);
}
catch(RuntimeException e)
{
errors.add("The storage bucket " + alias + " cannot be used: " + e.getMessage());
}
String lockMode = settings.string("objectLockMode", "NONE");
if(!LOCK_MODES.contains(lockMode))
{
errors.add("Unknown object lock mode " + lockMode + ". Use NONE, GOVERNANCE or COMPLIANCE.");
}
else if(!"NONE".equals(lockMode))
{
Integer days = settings.integer("objectLockDays", null);
if(days == null || days < 1)
{
errors.add("Object lock needs a number of days to keep each object locked (objectLockDays).");
}
if(storage != null && !storage.capabilities().contains(BackupStorage.StorageCapability.OBJECT_LOCK))
{
errors.add("Object lock " + lockMode + " was asked for, but the storage bucket " + alias + " does not support object lock (its capabilities: "
+ storage.capabilities() + "). Choose NONE, or a bucket whose Test connection found object lock supported.");
}
}
String pruneAlias = settings.string("pruneBucketAlias", null);
if(pruneAlias == null)
{
warnings.add("No prune bucket is set: Prune will use the backup bucket, so one key can both write and delete backups. Give the plan a separate delete-capable alias.");
}
else
{
try
{
BackupsRuntime.storageFor(pruneAlias);
}
catch(RuntimeException e)
{
errors.add("The prune bucket " + pruneAlias + " cannot be used: " + e.getMessage());
}
}
}
/*******************************************************************************
**
*******************************************************************************/
private static void validateSchedule(PlanSettings settings, List<String> errors)
{
for(String field : List.of("cronExpression", "verifyCronExpression"))
{
String cron = settings.string(field, null);
if(cron != null && !org.quartz.CronExpression.isValidExpression(cron))
{
errors.add("The " + field + " is not a valid cron expression: " + cron);
}
}
}
/*******************************************************************************
**
*******************************************************************************/
private static void validateNumbers(PlanSettings settings, List<String> errors)
{
for(String field : List.of("retainDaily", "retainWeekly", "retainMonthly", "retainYearly", "retainMinimum"))
{
Integer value = settings.integer(field, 0);
if(value < 0)
{
errors.add(field + " cannot be negative.");
}
}
if(settings.integer("slaHours", 26) < 1)
{
errors.add("slaHours must be at least 1.");
}
if(settings.integer("pageSize", 5000) < 1)
{
errors.add("pageSize must be at least 1.");
}
}
/*******************************************************************************
** Saving or activating a plan: the saving user must have backups.admin and read
** access to every table the plan resolves to (spec 11.1).
*******************************************************************************/
private static void validateOwner(PlanSettings settings, List<ScopeResolver.ScopeRule> rules, List<String> errors)
{
if(!BackupsPermissionChecker.has(BackupsPermissions.ADMIN))
{
errors.add("Saving an active plan needs the backups.admin permission: the plan runs on its own with full access.");
return;
}
ScopeResolver.ResolvedScope scope;
try
{
scope = new ScopeResolver(QContext.getQInstance()).resolve(settings, rules);
}
catch(RuntimeException e)
{
errors.add("The plan scope cannot be resolved: " + e.getMessage());
return;
}
List<String> unreadable = new ArrayList<>();
for(ScopeResolver.ResolvedTable table : scope.tables())
{
if(!PermissionsHelper.hasTablePermission(new QueryInput(table.name()), table.name(), TablePermissionSubType.READ))
{
unreadable.add(table.name());
}
}
if(!unreadable.isEmpty())
{
errors.add("The plan covers tables that its owner is not allowed to read, so it could not back them up in full: "
+ String.join(", ", unreadable.stream().limit(10).toList()) + (unreadable.size() > 10 ? " and " + (unreadable.size() - 10) + " more" : "")
+ ". Remove them from the scope, or save the plan as a user who can read them.");
}
}
}
@@ -0,0 +1,260 @@
/*
* 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.plan;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import com.kingsrook.qbits.backups.BackupsRuntime;
import com.kingsrook.qbits.backups.prune.BackupPruner;
import com.kingsrook.qbits.backups.storage.BackupStorage;
import com.kingsrook.qbits.backups.storage.StoredObjectInfo;
import com.kingsrook.qbits.backups.storage.WriteOptions;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Test destination (spec 9): proves the plan destination works before the first
** night depends on it. Writes a small probe object, reads it back, lists it, and
** deletes it through the prune alias (so the delete credential is exercised too).
**
** On a bucket that is object-locked the probe is head only: a probe object could
** not be deleted again, so nothing is written; the prefix is listed, and the
** newest manifest is looked at with a head. A capability report always comes with
** it.
*******************************************************************************/
public final class DestinationProbe
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private DestinationProbe()
{
}
/*******************************************************************************
** One thing that was checked.
*******************************************************************************/
public record Check(String name, boolean ok, String detail)
{
}
/*******************************************************************************
** The whole report.
*******************************************************************************/
public record Report(boolean headOnly, List<Check> checks, List<String> capabilities, List<String> warnings)
{
/*******************************************************************************
**
*******************************************************************************/
public boolean ok()
{
return (checks.stream().allMatch(Check::ok));
}
}
/*******************************************************************************
** Probe the destination of a plan.
*******************************************************************************/
public static Report probe(QRecord plan)
{
List<Check> checks = new ArrayList<>();
List<String> warnings = new ArrayList<>();
String alias = plan.getValueString("storageBucketAlias");
BackupStorage storage;
try
{
storage = BackupsRuntime.storageFor(alias);
}
catch(RuntimeException e)
{
checks.add(new Check("Open the backup bucket " + alias, false, e.getMessage()));
return (new Report(false, checks, List.of(), warnings));
}
checks.add(new Check("Open the backup bucket " + alias, true, "found"));
BackupStorage deleteSide = storage;
String pruneAlias = plan.getValueString("pruneBucketAlias");
if(pruneAlias == null || pruneAlias.isBlank())
{
warnings.add("No prune bucket is set, so one key both writes and deletes backups.");
}
else
{
try
{
deleteSide = BackupsRuntime.storageFor(pruneAlias);
checks.add(new Check("Open the prune bucket " + pruneAlias, true, "found"));
}
catch(RuntimeException e)
{
checks.add(new Check("Open the prune bucket " + pruneAlias, false, e.getMessage()));
}
}
List<String> capabilities = new ArrayList<>();
for(BackupStorage.StorageCapability capability : BackupStorage.StorageCapability.values())
{
capabilities.add(capability.name() + ": " + (storage.capabilities().contains(capability) ? "supported" : "not supported"));
}
String lockMode = plan.getValueString("objectLockMode");
boolean locked = lockMode != null && !"NONE".equals(lockMode);
if(locked && !storage.capabilities().contains(BackupStorage.StorageCapability.OBJECT_LOCK))
{
checks.add(new Check("Object lock " + lockMode, false, "the plan asks for object lock, but this bucket does not support it"));
}
String prefix = BackupPruner.planPrefix(plan);
if(locked)
{
headOnly(plan, storage, prefix, checks);
}
else
{
writeReadListDelete(storage, deleteSide, prefix + ".qqq-probe/", checks);
}
return (new Report(locked, checks, capabilities, warnings));
}
/*******************************************************************************
** The probe for a locked bucket: list the prefix, and head the newest manifest.
*******************************************************************************/
private static void headOnly(QRecord plan, BackupStorage storage, String prefix, List<Check> checks)
{
checks.add(attempt("Write and delete a probe object", () -> "skipped: object lock is on, a probe object could not be deleted again"));
checks.add(attempt("List the plan prefix", () ->
{
List<StoredObjectInfo> objects = storage.list(prefix);
return (objects.size() + " objects under " + prefix);
}));
Integer lastSuccess = plan.getValueInteger("lastSuccessRunId");
if(lastSuccess == null)
{
checks.add(new Check("Head the newest manifest", true, "there is no successful run yet"));
return;
}
checks.add(attempt("Head the newest manifest", () ->
{
QRecord run = new com.kingsrook.qbits.backups.run.BackupCatalog().get(com.kingsrook.qbits.backups.model.BackupsTables.RUN, lastSuccess).orElseThrow();
String key = run.getValueString("manifestKey");
if(key == null || storage.head(key).isEmpty())
{
throw (new IllegalStateException("the manifest of the newest successful run is not in the bucket"));
}
return ("found " + key);
}));
}
/*******************************************************************************
** Write, read back, list and delete a probe object.
*******************************************************************************/
private static void writeReadListDelete(BackupStorage storage, BackupStorage deleteSide, String probePrefix, List<Check> checks)
{
String key = probePrefix + UUID.randomUUID();
byte[] content = ("qqq backups probe " + UUID.randomUUID()).getBytes(StandardCharsets.UTF_8);
Check write = attempt("Write a probe object", () ->
{
try(OutputStream out = storage.openWrite(key, WriteOptions.create().withContentType("text/plain")))
{
out.write(content);
}
return ("wrote " + content.length + " bytes");
});
checks.add(write);
if(!write.ok())
{
return;
}
checks.add(attempt("Read it back", () ->
{
try(InputStream in = storage.openRead(key))
{
if(!java.util.Arrays.equals(content, in.readAllBytes()))
{
throw (new IllegalStateException("the bytes read back are not the bytes written"));
}
}
return ("the same bytes came back");
}));
checks.add(attempt("List it", () ->
{
if(storage.list(probePrefix).stream().noneMatch(o -> o.key().equals(key)))
{
throw (new IllegalStateException("the probe object is not in the listing"));
}
return ("found in the listing");
}));
checks.add(attempt("Delete it", () ->
{
BackupStorage.DeleteResult result = deleteSide.deletePermanent(key);
if(result != BackupStorage.DeleteResult.DELETED)
{
throw (new IllegalStateException("the delete returned " + result));
}
return ("deleted");
}));
}
/***************************************************************************
**
***************************************************************************/
@FunctionalInterface
private interface Attempt
{
String run() throws Exception;
}
/*******************************************************************************
**
*******************************************************************************/
private static Check attempt(String name, Attempt attempt)
{
try
{
return (new Check(name, true, attempt.run()));
}
catch(Exception e)
{
return (new Check(name, false, e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
}
}
}
@@ -25,9 +25,8 @@ import java.util.Map;
import com.kingsrook.qbits.backups.BackupsRuntime;
import com.kingsrook.qbits.backups.model.BackupsTables;
import com.kingsrook.qbits.backups.permissions.BackupsPermissions;
import com.kingsrook.qbits.backups.run.BackupEngine;
import com.kingsrook.qbits.backups.run.BackupRunCoordinator;
import com.kingsrook.qbits.backups.run.BackupRunFactory;
import com.kingsrook.qbits.backups.run.BackupRunRequest;
import com.kingsrook.qbits.backups.run.BackupRunResult;
import com.kingsrook.qbits.backups.scope.ScopeResolver;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -73,6 +72,10 @@ public final class BackupsProcesses
List<QProcessMetaData> processes = new ArrayList<>();
processes.add(permission(etl(RUN_PLAN, "Run backup now", BackupsTables.PLAN, RunPlanTransform.class, RunPlanLoad.class,
"This is a preview of what the backup will include. Nothing is written until you continue."), BackupsPermissions.RUN));
processes.addAll(VerifyProcesses.processes());
processes.addAll(RestoreProcesses.processes());
processes.addAll(CatalogProcesses.processes());
processes.addAll(LifecycleProcesses.processes());
return (processes);
}
@@ -209,6 +212,7 @@ public final class BackupsProcesses
private final ProcessSummaryLine warnings = line(Status.WARNING, "backup finished with warnings", "backups finished with warnings",
"backup finished with warnings", "backups finished with warnings");
private final ProcessSummaryLine failed = line(Status.ERROR, "backup failed", "backups failed", "backup failed", "backups failed");
private final ProcessSummaryLine skipped = line(Status.WARNING, "backup did not start", "backups did not start", "backup did not start", "backups did not start");
private final ProcessSummaryLine rows = line(Status.INFO, "row was written", "rows were written", "row was written", "rows were written");
private final ProcessSummaryLine files = line(Status.INFO, "referenced file was copied or already stored", "referenced files were copied or already stored",
"referenced file was copied or already stored", "referenced files were copied or already stored");
@@ -224,13 +228,17 @@ public final class BackupsProcesses
{
for(QRecord plan : input.getRecords())
{
BackupRunRequest request = BackupRunFactory.forPlan(plan, "MANUAL");
BackupRunResult result = new BackupEngine().execute(request);
BackupRunResult result = new BackupRunCoordinator().runPlan(plan, "MANUAL");
results.add(result);
switch(result.getStatus())
{
case "SUCCEEDED" -> succeeded.incrementCountAndAddPrimaryKey(result.getRunId());
case "SUCCEEDED_WITH_WARNINGS" -> warnings.incrementCountAndAddPrimaryKey(result.getRunId());
case BackupRunCoordinator.SKIPPED_CONCURRENT, BackupRunCoordinator.QUEUED ->
{
skipped.incrementCountAndAddPrimaryKey(result.getRunId());
skipped.setMessageSuffix(result.getErrorMessage() == null ? null : ": " + result.getErrorMessage());
}
default ->
{
failed.incrementCountAndAddPrimaryKey(result.getRunId());
@@ -255,6 +263,7 @@ public final class BackupsProcesses
succeeded.addSelfToListIfAnyCount(lines);
warnings.addSelfToListIfAnyCount(lines);
failed.addSelfToListIfAnyCount(lines);
skipped.addSelfToListIfAnyCount(lines);
rows.addSelfToListIfAnyCount(lines);
files.addSelfToListIfAnyCount(lines);
return (lines);
@@ -0,0 +1,246 @@
/*
* 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.processes;
import java.io.Serializable;
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.permissions.BackupsPermissions;
import com.kingsrook.qbits.backups.run.BackupCatalog;
import com.kingsrook.qbits.backups.run.BackupRunCoordinator;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
import static com.kingsrook.qbits.backups.processes.BackupsProcesses.line;
/*******************************************************************************
** Cancel (spec 9): a record action on a running backup run, and on a running
** restore run. The cancel is cooperative: it sets the run row to CANCELLING, and
** the run, which looks at its own row at every page, stops at the next page and
** ends CANCELLED. A queued run is cancelled at once, and a run that has lost its
** heartbeat (nothing is running it) is closed straight away.
*******************************************************************************/
public final class CancelRunProcess
{
public static final String NAME = "backupCancelRun";
public static final String NAME_RESTORE = "backupCancelRestoreRun";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private CancelRunProcess()
{
}
/*******************************************************************************
** The two processes.
*******************************************************************************/
public static List<QProcessMetaData> processes()
{
return (List.of(
BackupsProcesses.permission(BackupsProcesses.etl(NAME, "Cancel", BackupsTables.RUN, Transform.class, Load.class,
"This asks the selected runs to stop. A run stops at its next page."), BackupsPermissions.RUN),
BackupsProcesses.permission(BackupsProcesses.etl(NAME_RESTORE, "Cancel", BackupsTables.RESTORE_RUN, Transform.class, Load.class,
"This asks the selected restores to stop. A restore stops at its next page."), BackupsPermissions.RESTORE)));
}
/*******************************************************************************
** What should happen to a run, by its row.
*******************************************************************************/
enum Action
{
ASK_TO_STOP, CANCEL_NOW, CLOSE_STALE, NOT_CANCELLABLE
}
/*******************************************************************************
**
*******************************************************************************/
static Action actionFor(QRecord run)
{
String status = run.getValueString("status");
if("QUEUED".equals(status))
{
return (Action.CANCEL_NOW);
}
if("RUNNING".equals(status))
{
Instant heartbeat = run.getValueInstant("heartbeatDate");
boolean live = heartbeat != null && heartbeat.isAfter(BackupsRuntime.getClock().instant().minus(BackupRunCoordinator.HEARTBEAT_STALE));
return (live ? Action.ASK_TO_STOP : Action.CLOSE_STALE);
}
return (Action.NOT_CANCELLABLE);
}
/*******************************************************************************
** Review.
*******************************************************************************/
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine stop = line(Status.OK, "running run will be asked to stop at its next page", "running runs will be asked to stop at their next page",
"running run was asked to stop at its next page", "running runs were asked to stop at their next page");
private final ProcessSummaryLine queued = line(Status.OK, "queued run will be cancelled", "queued runs will be cancelled", "queued run was cancelled", "queued runs were cancelled");
private final ProcessSummaryLine stale = line(Status.WARNING, "run has lost its heartbeat (nothing is running it) and will be closed as cancelled",
"runs have lost their heartbeat (nothing is running them) and will be closed as cancelled", "run had lost its heartbeat and was closed as cancelled",
"runs had lost their heartbeat and were closed as cancelled");
private final ProcessSummaryLine no = line(Status.ERROR, "run cannot be cancelled: it is not running", "runs cannot be cancelled: they are not running",
"run could not be cancelled: it is not running", "runs could not be cancelled: they are not running");
/*******************************************************************************
**
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord run : input.getRecords())
{
switch(actionFor(run))
{
case ASK_TO_STOP -> stop.incrementCountAndAddPrimaryKey(run.getValue("id"));
case CANCEL_NOW -> queued.incrementCountAndAddPrimaryKey(run.getValue("id"));
case CLOSE_STALE -> stale.incrementCountAndAddPrimaryKey(run.getValue("id"));
default -> no.incrementCountAndAddPrimaryKey(run.getValue("id"));
}
output.getRecords().add(run);
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
stop.addSelfToListIfAnyCount(lines);
queued.addSelfToListIfAnyCount(lines);
stale.addSelfToListIfAnyCount(lines);
no.addSelfToListIfAnyCount(lines);
return (lines);
}
}
/*******************************************************************************
** Result.
*******************************************************************************/
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine stop = line(Status.OK, "running run was asked to stop at its next page", "running runs were asked to stop at their next page",
"running run was asked to stop at its next page", "running runs were asked to stop at their next page");
private final ProcessSummaryLine queued = line(Status.OK, "queued run was cancelled", "queued runs were cancelled", "queued run was cancelled", "queued runs were cancelled");
private final ProcessSummaryLine stale = line(Status.WARNING, "run that had lost its heartbeat was closed as cancelled", "runs that had lost their heartbeat were closed as cancelled",
"run that had lost its heartbeat was closed as cancelled", "runs that had lost their heartbeat were closed as cancelled");
private final ProcessSummaryLine no = line(Status.ERROR, "run was not cancelled: it is not running", "runs were not cancelled: they are not running",
"run was not cancelled: it is not running", "runs were not cancelled: they are not running");
/*******************************************************************************
** Cancel each run.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
BackupCatalog catalog = new BackupCatalog();
String table = input.getTableName();
for(QRecord selected : input.getRecords())
{
QRecord run = catalog.get(table, selected.getValueInteger("id")).orElse(selected);
switch(actionFor(run))
{
case ASK_TO_STOP ->
{
catalog.update(table, run.getValueInteger("id"), Map.of("status", "CANCELLING"));
stop.incrementCountAndAddPrimaryKey(run.getValue("id"));
}
case CANCEL_NOW ->
{
catalog.update(table, run.getValueInteger("id"), closed("Cancelled before it started."));
queued.incrementCountAndAddPrimaryKey(run.getValue("id"));
}
case CLOSE_STALE ->
{
catalog.update(table, run.getValueInteger("id"), closed("Cancelled: the run had stopped sending heartbeats."));
stale.incrementCountAndAddPrimaryKey(run.getValue("id"));
}
default -> no.incrementCountAndAddPrimaryKey(run.getValue("id"));
}
output.getRecords().add(run);
}
}
/*******************************************************************************
**
*******************************************************************************/
private static Map<String, Serializable> closed(String message)
{
Map<String, Serializable> values = new LinkedHashMap<>();
values.put("status", "CANCELLED");
values.put("endDate", BackupsRuntime.getClock().instant());
values.put("errorMessage", message);
return (values);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
stop.addSelfToListIfAnyCount(lines);
queued.addSelfToListIfAnyCount(lines);
stale.addSelfToListIfAnyCount(lines);
no.addSelfToListIfAnyCount(lines);
return (lines);
}
}
}
@@ -0,0 +1,443 @@
/*
* 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.processes;
import java.io.Serializable;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
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.permissions.BackupsPermissions;
import com.kingsrook.qbits.backups.restore.CatalogImporter;
import com.kingsrook.qbits.backups.restore.RestoreSummaries;
import com.kingsrook.qbits.backups.run.BackupCatalog;
import com.kingsrook.qbits.backups.storage.BackupStorage;
import com.kingsrook.qqq.backend.core.actions.audits.AuditAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractExtractStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** The catalog and download processes (spec 9.6 and 9.7): Import backups from
** storage (a table action on backups_run) and Download (a record action on a run
** or an artifact). Standard streamed ETL, Review and Result summary lines.
*******************************************************************************/
public final class CatalogProcesses
{
public static final String IMPORT_CATALOG = "backupImportCatalog";
public static final String DOWNLOAD = "backupDownload";
public static final String DOWNLOAD_ARTIFACT = "backupDownloadArtifact";
public static final Duration LINK_TTL = Duration.ofMinutes(15);
private static final QLogger LOG = QLogger.getLogger(CatalogProcesses.class);
/*******************************************************************************
** Utility class.
*******************************************************************************/
private CatalogProcesses()
{
}
/*******************************************************************************
** The processes defined here.
*******************************************************************************/
public static List<QProcessMetaData> processes()
{
List<QProcessMetaData> processes = new ArrayList<>();
QProcessMetaData importProcess = definition(IMPORT_CATALOG, "Import backups from storage", BackupsTables.RUN, ImportExtract.class, ImportTransform.class, ImportLoad.class,
"These are the backups found in storage that are not in the catalog yet. Nothing is written until you continue.");
importProcess.withStep(0, new QFrontendStepMetaData()
.withName("input")
.withLabel("Where are the backups?")
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM))
.withFormField(new QFieldMetaData("bucketAlias", QFieldType.STRING).withLabel("Backup bucket alias").withIsRequired(true))
.withFormField(new QFieldMetaData("prefix", QFieldType.STRING).withLabel("Folder (prefix)").withDefaultValue("qqq-backups/")));
importProcess.setMinInputRecords(0);
processes.add(BackupsProcesses.permission(importProcess, BackupsPermissions.ADMIN));
processes.add(BackupsProcesses.permission(definition(DOWNLOAD, "Download", BackupsTables.RUN, ExtractViaQueryStep.class, DownloadTransform.class, DownloadLoad.class,
"These are the files you will get download links for. Nothing is changed."), BackupsPermissions.RESTORE));
processes.add(BackupsProcesses.permission(definition(DOWNLOAD_ARTIFACT, "Download", BackupsTables.ARTIFACT, ExtractViaQueryStep.class, DownloadTransform.class, DownloadLoad.class,
"These are the files you will get download links for. Nothing is changed."), BackupsPermissions.RESTORE));
return (processes);
}
/*******************************************************************************
**
*******************************************************************************/
private static QProcessMetaData definition(String name, String label, String table, Class<? extends AbstractExtractStep> extract,
Class<? extends AbstractTransformStep> transform, Class<? extends AbstractLoadStep> load, String preview)
{
Map<String, Serializable> values = new HashMap<>();
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table);
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table);
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_TRANSACTION_LEVEL, StreamedETLWithFrontendProcess.TRANSACTION_LEVEL_AUTO_COMMIT);
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, preview);
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(extract, transform, load, values);
process.setName(name);
process.setLabel(label);
process.setTableName(table);
process.setPermissionRules(BackupsTables.permissionRules());
return (process);
}
/*******************************************************************************
** Lists the manifests under the bucket alias and prefix; one record per manifest.
*******************************************************************************/
public static class ImportExtract extends AbstractExtractStep
{
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
String alias = input.getValueString("bucketAlias");
if(alias == null || alias.isBlank())
{
throw (new QUserFacingException("Choose the backup bucket alias to import from."));
}
BackupStorage storage = BackupsRuntime.storageFor(alias);
for(String key : new CatalogImporter().findManifestKeys(storage, input.getValueString("prefix")))
{
getRecordPipe().addRecord(new QRecord().withTableName(BackupsTables.RUN).withValue("manifestKey", key).withValue("storageBucketAlias", alias));
}
}
}
/*******************************************************************************
** Review: what each manifest is (new, already in the catalog, or damaged).
*******************************************************************************/
public static class ImportTransform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine found = RestoreSummaries.line(Status.INFO, 0, "backup was found in storage", "backups were found in storage", "backup was found in storage", "backups were found in storage");
private final ProcessSummaryLine newRuns = RestoreSummaries.line(Status.OK, 0, "backup will be added to the catalog", "backups will be added to the catalog", "backup was added to the catalog", "backups were added to the catalog");
private final ProcessSummaryLine existing = RestoreSummaries.line(Status.INFO, 0, "backup is already in the catalog (skipped)", "backups are already in the catalog (skipped)", "backup was already in the catalog", "backups were already in the catalog");
private final ProcessSummaryLine invalid = RestoreSummaries.line(Status.ERROR, 0, "backup has a manifest or checksum problem and will be skipped", "backups have a manifest or checksum problem and will be skipped",
"backup had a manifest or checksum problem and was skipped", "backups had a manifest or checksum problem and were skipped");
private final ArrayList<String> problems = new ArrayList<>();
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord record : input.getRecords())
{
BackupStorage storage = BackupsRuntime.storageFor(record.getValueString("storageBucketAlias"));
CatalogImporter.Inspection inspection = new CatalogImporter().inspect(storage, record.getValueString("manifestKey"));
found.incrementCount();
switch(inspection.status())
{
case CatalogImporter.Inspection.NEW ->
{
newRuns.incrementCount();
output.getRecords().add(record);
}
case CatalogImporter.Inspection.EXISTS -> existing.incrementCount();
default ->
{
invalid.incrementCount();
for(String problem : inspection.problems())
{
problems.add(inspection.manifestKey() + ": " + problem);
}
}
}
}
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
found.addSelfToListIfAnyCount(lines);
newRuns.addSelfToListIfAnyCount(lines);
existing.addSelfToListIfAnyCount(lines);
invalid.setBulletsOfText(problems);
invalid.addSelfToListIfAnyCount(lines);
return (lines);
}
}
/*******************************************************************************
** Execute: create the run and artifact rows from each new manifest.
*******************************************************************************/
public static class ImportLoad extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine imported = RestoreSummaries.line(Status.OK, 0, "backup will be added to the catalog", "backups will be added to the catalog", "backup was added to the catalog", "backups were added to the catalog");
private final ProcessSummaryLine skipped = RestoreSummaries.line(Status.WARNING, 0, "backup was not added", "backups were not added", "backup was not added", "backups were not added");
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
CatalogImporter importer = new CatalogImporter();
for(QRecord record : input.getRecords())
{
String alias = record.getValueString("storageBucketAlias");
BackupStorage storage = BackupsRuntime.storageFor(alias);
CatalogImporter.Inspection inspection = importer.inspect(storage, record.getValueString("manifestKey"));
if(CatalogImporter.Inspection.NEW.equals(inspection.status()))
{
imported.incrementCountAndAddPrimaryKey(importer.importRun(alias, storage, inspection));
}
else
{
skipped.incrementCount();
}
output.getRecords().add(record);
}
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
imported.addSelfToListIfAnyCount(lines);
skipped.addSelfToListIfAnyCount(lines);
return (lines);
}
}
/*******************************************************************************
** One object to link to.
*******************************************************************************/
record DownloadItem(String auditTable, Integer auditId, BackupStorage storage, String key, String downloadName, boolean encrypted, long size)
{
}
/*******************************************************************************
** The objects the selected records ask for: for a run, all of its artifacts; for
** an artifact, itself plus the manifest and checksums of its run.
*******************************************************************************/
static List<DownloadItem> items(List<QRecord> records) throws QException
{
BackupCatalog catalog = new BackupCatalog();
Map<String, DownloadItem> items = new LinkedHashMap<>();
for(QRecord record : records)
{
boolean isArtifact = BackupsTables.ARTIFACT.equals(record.getTableName()) || record.getValue("artifactType") != null;
Integer runId = isArtifact ? record.getValueInteger("backupRunId") : record.getValueInteger("id");
QRecord run = catalog.get(BackupsTables.RUN, runId).orElseThrow(() -> new QUserFacingException("The backup run was not found."));
String table = isArtifact ? BackupsTables.ARTIFACT : BackupsTables.RUN;
BackupStorage storage = BackupsRuntime.storageFor(run.getValueString("storageBucketAlias"));
List<QRecord> artifacts = new ArrayList<>();
if(isArtifact)
{
artifacts.add(record);
for(QRecord other : catalog.where(BackupsTables.ARTIFACT, "backupRunId", runId))
{
if("MANIFEST".equals(other.getValueString("artifactType")) || "CHECKSUMS".equals(other.getValueString("artifactType")))
{
artifacts.add(other);
}
}
}
else
{
artifacts.addAll(catalog.where(BackupsTables.ARTIFACT, "backupRunId", runId));
}
for(QRecord artifact : artifacts)
{
if(!"COMPLETE".equals(artifact.getValueString("status")))
{
continue;
}
String key = run.getValueString("runPrefix") + artifact.getValueString("objectKey");
String name = run.getValueString("runKey") + "-" + artifact.getValueString("objectKey").replace('/', '-');
items.putIfAbsent(key, new DownloadItem(table, isArtifact && artifact == record ? artifact.getValueInteger("id") : runId, storage, key, name,
Boolean.TRUE.equals(artifact.getValueBoolean("encrypted")), artifact.getValueLong("bytesStored") == null ? 0 : artifact.getValueLong("bytesStored")));
}
}
return (new ArrayList<>(items.values()));
}
/*******************************************************************************
** Review: what will be linked.
*******************************************************************************/
public static class DownloadTransform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine objects = RestoreSummaries.line(Status.OK, 0, "file will get a download link", "files will get download links", "file got a download link", "files got download links");
private final ProcessSummaryLine encrypted = RestoreSummaries.line(Status.INFO, 0, "file is encrypted: you get the ciphertext only, and decrypt it with the command line tool",
"files are encrypted: you get the ciphertext only, and decrypt them with the command line tool", "file is encrypted (ciphertext only)", "files are encrypted (ciphertext only)");
private long bytes;
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(DownloadItem item : items(input.getRecords()))
{
objects.incrementCount();
bytes += item.size();
if(item.encrypted())
{
encrypted.incrementCount();
}
}
output.getRecords().addAll(input.getRecords());
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
objects.addSelfToListIfAnyCount(lines);
encrypted.addSelfToListIfAnyCount(lines);
if(objects.getCount() != null && objects.getCount() > 0)
{
lines.add(RestoreSummaries.message(Status.INFO, "The links last " + LINK_TTL.toMinutes() + " minutes and the download is about " + bytes + " bytes. Each link is recorded in the audit."));
}
return (lines);
}
}
/*******************************************************************************
** Execute: issue the presigned links (ciphertext only), each one audited.
*******************************************************************************/
public static class DownloadLoad extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine issued = RestoreSummaries.line(Status.OK, 0, "download link was issued", "download links were issued", "download link was issued", "download links were issued");
private final ArrayList<String> links = new ArrayList<>();
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
String user = QContext.getQSession() == null || QContext.getQSession().getUser() == null ? "system" : QContext.getQSession().getUser().getIdReference();
for(DownloadItem item : items(input.getRecords()))
{
Instant expires = BackupsRuntime.getClock().instant().plus(LINK_TTL);
String url = item.storage().presignGet(item.key(), LINK_TTL, item.downloadName());
links.add(item.downloadName() + (item.encrypted() ? " (encrypted)" : "") + ": " + url);
issued.incrementCount();
LOG.info("Issued a backup download link", logPair("object", item.key()), logPair("user", user), logPair("expires", expires));
try
{
AuditAction.execute(item.auditTable(), item.auditId(), Map.of(), "Download link issued for " + item.key() + " to " + user + ", expires " + expires);
}
catch(RuntimeException e)
{
LOG.warn("Could not audit a download link", e, logPair("object", item.key()));
}
}
output.getRecords().addAll(input.getRecords());
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
issued.setBulletsOfText(links);
issued.addSelfToListIfAnyCount(lines);
if(issued.getCount() != null && issued.getCount() > 0)
{
lines.add(RestoreSummaries.message(Status.INFO, "The links expire in " + LINK_TTL.toMinutes() + " minutes. Encrypted files download as ciphertext: use the command line tool to decrypt them."));
}
return (lines);
}
}
}
@@ -0,0 +1,407 @@
/*
* 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.processes;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.backups.BackupsQBitConfig;
import com.kingsrook.qbits.backups.BackupsRuntime;
import com.kingsrook.qbits.backups.model.BackupsTables;
import com.kingsrook.qbits.backups.permissions.BackupsPermissionChecker;
import com.kingsrook.qbits.backups.permissions.BackupsPermissions;
import com.kingsrook.qbits.backups.run.BackupCatalog;
import com.kingsrook.qbits.backups.run.BackupRunCoordinator;
import com.kingsrook.qbits.backups.run.BackupRunRequest;
import com.kingsrook.qbits.backups.run.BackupRunResult;
import com.kingsrook.qbits.backups.run.TableReader;
import com.kingsrook.qbits.backups.scope.PlanSettings;
import com.kingsrook.qbits.backups.scope.ScopeResolver;
import com.kingsrook.qbits.backups.scope.TableRole;
import com.kingsrook.qbits.backups.scope.TableRoles;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
import static com.kingsrook.qbits.backups.processes.BackupsProcesses.line;
/*******************************************************************************
** Back up these records (spec 9, 11.3): a table and bulk action on any table the
** user can read. It writes an ad-hoc backups_run (no plan) that restores like any
** other, with referenced files copied the same way.
**
** - The records are whatever the table action selected (the chosen records or the
** current filter), read as the user, so record locks apply and the manifest says
** securityScope USER.
** - Sensitive fields are left out unless the user has backups.admin.
** - The destination is the bucket given in the process input (storageBucketAlias),
** else the bucket of the newest active plan.
**
** The records are held in memory until the export runs (an export of selected
** records is bounded by what a person selected); a whole table is what a plan is for.
**
** The process is added to each table that can be backed up, named
** table.backupExportTable (see exportProcesses).
*******************************************************************************/
public final class ExportTableProcess
{
public static final String NAME_SUFFIX = "backupExportTable";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private ExportTableProcess()
{
}
/*******************************************************************************
** The export process for one table.
*******************************************************************************/
public static QProcessMetaData processFor(String tableName)
{
return (BackupsProcesses.permission(BackupsProcesses.etl(tableName + "." + NAME_SUFFIX, "Back up these records", tableName, Transform.class, Load.class,
"This is a preview of what will be backed up. Nothing is written until you continue."), BackupsPermissions.RUN));
}
/*******************************************************************************
** The export process of every table of the instance that can be backed up (not the
** qbit own tables, and not secrets or sessions).
*******************************************************************************/
public static List<QProcessMetaData> exportProcesses(QInstance qInstance)
{
List<QProcessMetaData> processes = new ArrayList<>();
for(QTableMetaData table : qInstance.getTables().values())
{
if(table.getName().startsWith(BackupsQBitConfig.TABLE_NAME_PREFIX))
{
continue;
}
TableRole role = TableRoles.roleOf(qInstance, table);
if(role == TableRole.DATA || role == TableRole.AUDIT || role == TableRole.DERIVED)
{
processes.add(processFor(table.getName()));
}
}
return (processes);
}
/*******************************************************************************
** The sensitive field policy for the user.
*******************************************************************************/
static String sensitivePolicy()
{
return (BackupsPermissionChecker.has(BackupsPermissions.ADMIN) ? "INCLUDE" : "EXCLUDE");
}
/*******************************************************************************
** The bucket the export goes to, or null when there is none.
*******************************************************************************/
static String bucketAlias(RunBackendStepInput input) throws QException
{
String given = input.getValueString("storageBucketAlias");
if(given != null && !given.isBlank())
{
return (given);
}
List<QRecord> plans = new BackupCatalog().query(BackupsTables.PLAN, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter(
new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria("isActive", com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.EQUALS, true))
.withOrderBy(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy("id", false)));
return (plans.isEmpty() ? null : plans.get(0).getValueString("storageBucketAlias"));
}
/*******************************************************************************
** Review: what is selected and what will be left out.
*******************************************************************************/
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine records = line(Status.OK, "record will be backed up", "records will be backed up", "record was backed up", "records were backed up");
private final ProcessSummaryLine sensitive = line(Status.INFO, "field is sensitive and will be left out of the backup (including it needs backups.admin)",
"fields are sensitive and will be left out of the backup (including them needs backups.admin)", "field was left out as sensitive", "fields were left out as sensitive");
private final ProcessSummaryLine included = line(Status.WARNING, "field is sensitive and will be written in the clear, because you have backups.admin",
"fields are sensitive and will be written in the clear, because you have backups.admin", "field was written in the clear", "fields were written in the clear");
private final ProcessSummaryLine noBucket = line(Status.ERROR, "export has no destination: there is no active backup plan to take its bucket from",
"exports have no destination: there is no active backup plan to take their bucket from", "export had no destination", "exports had no destination");
private final ProcessSummaryLine denied = line(Status.ERROR, "table cannot be read by you, so it cannot be backed up", "tables cannot be read by you, so they cannot be backed up",
"table could not be read by you", "tables could not be read by you");
private boolean described = false;
/*******************************************************************************
**
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
String table = input.getTableName();
if(!described)
{
described = true;
if(!PermissionsHelper.hasTablePermission(input, table, TablePermissionSubType.READ))
{
denied.incrementCount();
}
if(bucketAlias(input) == null)
{
noBucket.incrementCount();
}
QTableMetaData meta = QContext.getQInstance().getTable(table);
int count = 0;
for(QFieldMetaData field : meta.getFields().values())
{
if(!field.getName().equals(meta.getPrimaryKeyField()) && (field.getIsSensitive() || field.getType() == QFieldType.PASSWORD))
{
count++;
}
}
(BackupsPermissionChecker.has(BackupsPermissions.ADMIN) ? included : sensitive).incrementCount(count);
}
if(denied.getCount() > 0)
{
return;
}
records.incrementCount(input.getRecords().size());
output.getRecords().addAll(input.getRecords());
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
records.addSelfToListIfAnyCount(lines);
sensitive.addSelfToListIfAnyCount(lines);
included.addSelfToListIfAnyCount(lines);
noBucket.addSelfToListIfAnyCount(lines);
denied.addSelfToListIfAnyCount(lines);
return (lines);
}
}
/*******************************************************************************
** Result: collect the selected records, then run the export through the engine when
** the Result lines are asked for (after the last page, before post run).
*******************************************************************************/
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine succeeded = line(Status.OK, "export succeeded", "exports succeeded", "export succeeded", "exports succeeded");
private final ProcessSummaryLine warnings = line(Status.WARNING, "export finished with warnings", "exports finished with warnings", "export finished with warnings", "exports finished with warnings");
private final ProcessSummaryLine failed = line(Status.ERROR, "export failed", "exports failed", "export failed", "exports failed");
private final ProcessSummaryLine rows = line(Status.INFO, "record was written", "records were written", "record was written", "records were written");
private final ProcessSummaryLine files = line(Status.INFO, "referenced file was copied or already stored", "referenced files were copied or already stored",
"referenced file was copied or already stored", "referenced files were copied or already stored");
private final List<QRecord> buffered = new ArrayList<>();
private String tableName;
private String bucket;
private boolean exported = false;
/*******************************************************************************
** Remember what is being exported.
*******************************************************************************/
@Override
public void preRun(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
super.preRun(input, output);
tableName = input.getTableName();
bucket = bucketAlias(input);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
buffered.addAll(input.getRecords());
output.getRecords().addAll(input.getRecords());
}
/*******************************************************************************
** All records are in (the summary is asked for once the last page is loaded, and
** before post run): back them up, once.
*******************************************************************************/
private void export() throws QException
{
if(exported)
{
return;
}
exported = true;
if(bucket == null)
{
failed.incrementCount();
failed.setMessageSuffix(": there is no active backup plan to take a bucket from");
return;
}
BackupRunResult result = new BackupRunCoordinator().runAdHoc(request(tableName, bucket, buffered));
switch(result.getStatus())
{
case "SUCCEEDED" -> succeeded.incrementCountAndAddPrimaryKey(result.getRunId());
case "SUCCEEDED_WITH_WARNINGS" -> warnings.incrementCountAndAddPrimaryKey(result.getRunId());
default ->
{
failed.incrementCountAndAddPrimaryKey(result.getRunId());
failed.setMessageSuffix(result.getErrorMessage() == null ? null : ": " + result.getErrorMessage());
}
}
rows.incrementCount((int) Math.min(Integer.MAX_VALUE, result.getRows()));
files.incrementCount(result.getFilesCopied() + result.getFilesDeduplicated());
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
try
{
export();
}
catch(QException e)
{
failed.incrementCount();
failed.setMessageSuffix(": " + e.getMessage());
}
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
succeeded.addSelfToListIfAnyCount(lines);
warnings.addSelfToListIfAnyCount(lines);
failed.addSelfToListIfAnyCount(lines);
rows.addSelfToListIfAnyCount(lines);
files.addSelfToListIfAnyCount(lines);
return (lines);
}
}
/*******************************************************************************
** The ad-hoc request for the selected records of a table.
*******************************************************************************/
public static BackupRunRequest request(String tableName, String bucketAlias, List<QRecord> selected)
{
QInstance qInstance = QContext.getQInstance();
QTableMetaData table = qInstance.getTable(tableName);
String pk = table.getPrimaryKeyField();
QSession session = QContext.getQSession();
String user = session == null || session.getUser() == null ? "system" : session.getUser().getIdReference();
BackupsQBitConfig config = BackupsRuntime.getConfig();
Map<String, QRecord> unique = new LinkedHashMap<>();
for(QRecord record : selected)
{
unique.put(String.valueOf(record.getValue(pk)), record);
}
List<QRecord> inPkOrder = new ArrayList<>(unique.values());
inPkOrder.sort(Comparator.comparing(r -> (Comparable) r.getValue(pk)));
PlanSettings settings = PlanSettings.adHoc().with("planKey", "adhoc").with("storageBucketAlias", bucketAlias).with("sensitiveFieldPolicy", sensitivePolicy());
return (new BackupRunRequest()
.withSettings(settings)
.withScope(new ScopeResolver.ResolvedScope(List.of(new ScopeResolver.ResolvedTable(tableName, TableRoles.roleOf(qInstance, table), null, List.of())), List.of(), List.of()))
.withTrigger("MANUAL")
.withRequestedBy(user)
.withSecurityScope("USER")
.withBucketAlias(bucketAlias)
.withStorage(BackupsRuntime.storageFor(bucketAlias))
.withSourceResolver(BackupsRuntime::storageFor)
.withInstanceName(config.getInstanceName() != null ? config.getInstanceName() : "qqq")
.withPlanName("Export of " + table.getLabel())
.withReader(new BufferedReader(inPkOrder))
.withClock(BackupsRuntime.getClock()));
}
/*******************************************************************************
** Serves the selected records to the engine, in primary key order.
*******************************************************************************/
static class BufferedReader implements TableReader
{
private final List<QRecord> records;
/*******************************************************************************
** Constructor.
*******************************************************************************/
BufferedReader(List<QRecord> records)
{
this.records = records;
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public void read(QTableMetaData table, String filterJson, Serializable startAfterKey, int pageSize, QBackendTransaction transaction, java.util.function.Consumer<QRecord> sink)
{
records.forEach(sink);
}
}
}
@@ -0,0 +1,76 @@
/*
* 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.processes;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.backups.BackupsQBitConfig;
import com.kingsrook.qbits.backups.BackupsRuntime;
import com.kingsrook.qbits.backups.schedule.BackupPlanSyncToScheduledJobProcess;
import com.kingsrook.qbits.backups.schedule.BackupPlanVerifySyncToScheduledJobProcess;
import com.kingsrook.qbits.backups.schedule.BackupScheduledRunProcess;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** The processes of the run lifecycle (spec 9): scheduled runs, cancel, resume,
** test destination and prune, and the plan to scheduled job sync. Added to the
** qbit by BackupsProcesses.processes(). The per-table export process
** (exportProcesses) needs the instance tables, so the producer adds it.
*******************************************************************************/
public final class LifecycleProcesses
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private LifecycleProcesses()
{
}
/*******************************************************************************
** The processes.
*******************************************************************************/
public static List<QProcessMetaData> processes()
{
List<QProcessMetaData> processes = new ArrayList<>();
processes.add(BackupScheduledRunProcess.processMetaData());
processes.addAll(CancelRunProcess.processes());
processes.add(ResumeRunProcess.process());
processes.add(TestDestinationProcess.process());
processes.addAll(PruneProcess.processes());
BackupsQBitConfig config = BackupsRuntime.getConfig();
if(config != null && StringUtils.hasContent(config.getSchedulerName()))
{
try
{
processes.add(new BackupPlanSyncToScheduledJobProcess().produce(null));
processes.add(new BackupPlanVerifySyncToScheduledJobProcess().produce(null));
}
catch(QException e)
{
throw (new IllegalStateException("Could not define the plan schedule sync processes: " + e.getMessage(), e));
}
}
return (processes);
}
}
@@ -0,0 +1,336 @@
/*
* 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.processes;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashSet;
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.permissions.BackupsPermissions;
import com.kingsrook.qbits.backups.prune.BackupPruner;
import com.kingsrook.qbits.backups.prune.PruneCalculator;
import com.kingsrook.qbits.backups.run.BackupCatalog;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
import static com.kingsrook.qbits.backups.processes.BackupsProcesses.line;
/*******************************************************************************
** Prune per retention (spec 9, 10.3). A record action on backups_plan, and a
** table action on backups_run (which prunes the plans of the selected runs); the
** plan one also runs daily when the qbit has a scheduler.
**
** Review shows exactly what Result will do: the runs to delete, the runs kept by
** class, the runs kept because an object is still locked (with the date), the
** debris prefixes, the content-addressed objects to sweep, and the bytes freed.
** Review writes nothing.
*******************************************************************************/
public final class PruneProcess
{
public static final String NAME = "backupPrune";
public static final String NAME_RUNS = "backupPruneRuns";
private static final long MIB = 1024L * 1024L;
/*******************************************************************************
** Utility class.
*******************************************************************************/
private PruneProcess()
{
}
/*******************************************************************************
** The two processes.
*******************************************************************************/
public static List<QProcessMetaData> processes()
{
QProcessMetaData onPlans = BackupsProcesses.permission(BackupsProcesses.etl(NAME, "Prune per retention", BackupsTables.PLAN, PlanTransform.class, Load.class,
"This is a preview of what Prune will delete. Nothing is deleted until you continue."), BackupsPermissions.ADMIN);
BackupsQBitConfig config = BackupsRuntime.getConfig();
if(config != null && config.getSchedulerName() != null && !config.getSchedulerName().isBlank())
{
onPlans.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()).withRepeatSeconds(24 * 3600).withInitialDelaySeconds(3600));
}
QProcessMetaData onRuns = BackupsProcesses.permission(BackupsProcesses.etl(NAME_RUNS, "Prune per retention", BackupsTables.RUN, RunTransform.class, Load.class,
"This is a preview of what Prune will delete for the plans of the selected runs. Nothing is deleted until you continue."), BackupsPermissions.ADMIN);
return (List.of(onPlans, onRuns));
}
/*******************************************************************************
** The review lines, shared by the two transforms.
*******************************************************************************/
abstract static class BaseTransform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine plans = line(Status.OK, "plan will be pruned", "plans will be pruned", "plan was pruned", "plans were pruned");
private final ProcessSummaryLine delete = line(Status.WARNING, "run will be deleted", "runs will be deleted", "run was deleted", "runs were deleted");
private final ProcessSummaryLine daily = line(Status.OK, "run is kept as a daily backup", "runs are kept as daily backups", "run was kept as a daily backup", "runs were kept as daily backups");
private final ProcessSummaryLine weekly = line(Status.OK, "run is kept as a weekly backup", "runs are kept as weekly backups", "run was kept as a weekly backup", "runs were kept as weekly backups");
private final ProcessSummaryLine monthly = line(Status.OK, "run is kept as a monthly backup", "runs are kept as monthly backups", "run was kept as a monthly backup", "runs were kept as monthly backups");
private final ProcessSummaryLine yearly = line(Status.OK, "run is kept as a yearly backup", "runs are kept as yearly backups", "run was kept as a yearly backup", "runs were kept as yearly backups");
private final ProcessSummaryLine guarded = line(Status.OK, "run is kept by a guard (the minimum, the newest verified run, or a recent restore)",
"runs are kept by a guard (the minimum, the newest verified run, or a recent restore)", "run was kept by a guard", "runs were kept by a guard");
private final ProcessSummaryLine locked = line(Status.WARNING, "run is kept: its objects are locked", "runs are kept: their objects are locked",
"run was kept: its objects are locked", "runs were kept: their objects are locked");
private final ProcessSummaryLine debris = line(Status.INFO, "unfinished backup (older than 7 days) will be deleted", "unfinished backups (older than 7 days) will be deleted",
"unfinished backup was deleted", "unfinished backups were deleted");
private final ProcessSummaryLine sweep = line(Status.INFO, "stored file that no retained backup references will be deleted", "stored files that no retained backup references will be deleted",
"stored file was deleted", "stored files were deleted");
private final ProcessSummaryLine sweepLocked = line(Status.WARNING, "stored file is kept: it is locked", "stored files are kept: they are locked", "stored file was kept: it is locked", "stored files were kept: they are locked");
private final ProcessSummaryLine deferred = line(Status.WARNING, "plan's file store sweep is skipped", "plans' file store sweeps are skipped", "plan's file store sweep was skipped", "plans' file store sweeps were skipped");
private final ProcessSummaryLine freed = line(Status.INFO, "MiB of storage will be freed", "MiB of storage will be freed", "MiB of storage was freed", "MiB of storage was freed");
private final ProcessSummaryLine failed = line(Status.ERROR, "plan cannot be pruned", "plans cannot be pruned", "plan could not be pruned", "plans could not be pruned");
private final Set<Integer> seenPlans = new HashSet<>();
private long bytes = 0;
private Instant latestLock;
/*******************************************************************************
** The plan records this step looks at.
*******************************************************************************/
abstract List<QRecord> plansFor(RunBackendStepInput input) throws QException;
/*******************************************************************************
** Plan each selected plan, writing nothing.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord plan : plansFor(input))
{
if(!seenPlans.add(plan.getValueInteger("id")))
{
continue;
}
try
{
BackupPruner.PrunePlan prunePlan = new BackupPruner().plan(plan);
plans.incrementCountAndAddPrimaryKey(plan.getValue("id"));
delete.incrementCount(prunePlan.toDelete().size());
var kept = prunePlan.keptByClass();
daily.incrementCount(kept.getOrDefault(PruneCalculator.RetentionClass.DAILY, 0));
weekly.incrementCount(kept.getOrDefault(PruneCalculator.RetentionClass.WEEKLY, 0));
monthly.incrementCount(kept.getOrDefault(PruneCalculator.RetentionClass.MONTHLY, 0));
yearly.incrementCount(kept.getOrDefault(PruneCalculator.RetentionClass.YEARLY, 0));
guarded.incrementCount(kept.getOrDefault(PruneCalculator.RetentionClass.NONE, 0));
locked.incrementCount(prunePlan.lockedNotKept().size());
for(BackupPruner.RunPlan run : prunePlan.lockedNotKept())
{
latestLock = latestLock == null || run.lockedUntil().isAfter(latestLock) ? run.lockedUntil() : latestLock;
}
debris.incrementCount(prunePlan.debris().stream().filter(d -> d.lockedUntil() == null).toList().size());
sweep.incrementCount(prunePlan.sweep().deletable().size());
sweepLocked.incrementCount(prunePlan.sweep().locked().size());
if(prunePlan.sweep().deferredReason() != null)
{
deferred.incrementCount();
deferred.setMessageSuffix(": " + prunePlan.sweep().deferredReason());
}
bytes += prunePlan.bytesToFree();
output.getRecords().add(plan);
}
catch(Exception e)
{
failed.incrementCountAndAddPrimaryKey(plan.getValue("id"));
failed.setMessageSuffix(": " + e.getMessage());
}
}
}
/*******************************************************************************
** The lines.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
if(latestLock != null)
{
locked.setMessageSuffix(" (the latest lock ends " + latestLock + ")");
}
freed.setCount((int) Math.min(Integer.MAX_VALUE, (bytes + MIB - 1) / MIB));
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
plans.addSelfToListIfAnyCount(lines);
delete.addSelfToListIfAnyCount(lines);
daily.addSelfToListIfAnyCount(lines);
weekly.addSelfToListIfAnyCount(lines);
monthly.addSelfToListIfAnyCount(lines);
yearly.addSelfToListIfAnyCount(lines);
guarded.addSelfToListIfAnyCount(lines);
locked.addSelfToListIfAnyCount(lines);
debris.addSelfToListIfAnyCount(lines);
sweep.addSelfToListIfAnyCount(lines);
sweepLocked.addSelfToListIfAnyCount(lines);
deferred.addSelfToListIfAnyCount(lines);
freed.addSelfToListIfAnyCount(lines);
failed.addSelfToListIfAnyCount(lines);
return (lines);
}
}
/*******************************************************************************
** Review for plans.
*******************************************************************************/
public static class PlanTransform extends BaseTransform
{
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
List<QRecord> plansFor(RunBackendStepInput input)
{
return (input.getRecords());
}
}
/*******************************************************************************
** Review for runs: the plans of the selected runs.
*******************************************************************************/
public static class RunTransform extends BaseTransform
{
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
List<QRecord> plansFor(RunBackendStepInput input) throws QException
{
List<QRecord> plans = new ArrayList<>();
BackupCatalog catalog = new BackupCatalog();
for(QRecord run : input.getRecords())
{
if(run.getValueInteger("backupPlanId") != null)
{
catalog.get(BackupsTables.PLAN, run.getValueInteger("backupPlanId")).ifPresent(plans::add);
}
}
return (plans);
}
}
/*******************************************************************************
** Result: do it, and report what was done.
*******************************************************************************/
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine plans = line(Status.OK, "plan will be pruned", "plans will be pruned", "plan was pruned", "plans were pruned");
private final ProcessSummaryLine deleted = line(Status.OK, "run will be deleted", "runs will be deleted", "run was deleted", "runs were deleted");
private final ProcessSummaryLine locked = line(Status.WARNING, "run was kept: locked until a later date", "runs were kept: locked until a later date",
"run was kept: locked until a later date", "runs were kept: locked until a later date");
private final ProcessSummaryLine debris = line(Status.OK, "unfinished backup will be deleted", "unfinished backups will be deleted", "unfinished backup was deleted", "unfinished backups were deleted");
private final ProcessSummaryLine files = line(Status.OK, "stored file will be deleted", "stored files will be deleted", "stored file was deleted", "stored files were deleted");
private final ProcessSummaryLine filesKept = line(Status.WARNING, "stored file was kept: it is locked", "stored files were kept: they are locked", "stored file was kept: it is locked", "stored files were kept: they are locked");
private final ProcessSummaryLine notes = line(Status.INFO, "note", "notes", "note", "notes");
private final ProcessSummaryLine freed = line(Status.INFO, "MiB of storage was freed", "MiB of storage was freed", "MiB of storage was freed", "MiB of storage was freed");
private final ProcessSummaryLine failed = line(Status.ERROR, "plan could not be pruned", "plans could not be pruned", "plan could not be pruned", "plans could not be pruned");
private long bytes = 0;
private final List<String> messages = new ArrayList<>();
/*******************************************************************************
** Prune each plan.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
com.kingsrook.qqq.backend.core.model.session.QSession session = com.kingsrook.qqq.backend.core.context.QContext.getQSession();
String user = session == null || session.getUser() == null ? "system:scheduler" : session.getUser().getIdReference();
for(QRecord plan : input.getRecords())
{
try
{
BackupPruner.Outcome outcome = new BackupPruner().prune(plan, user);
plans.incrementCountAndAddPrimaryKey(plan.getValue("id"));
deleted.incrementCount(outcome.runsDeleted());
locked.incrementCount(outcome.runsLocked());
debris.incrementCount(outcome.debrisPrefixesDeleted());
files.incrementCount(outcome.filesSwept());
filesKept.incrementCount(outcome.filesLocked());
bytes += outcome.bytesFreed();
messages.addAll(outcome.messages());
output.getRecords().add(plan);
}
catch(Exception e)
{
failed.incrementCountAndAddPrimaryKey(plan.getValue("id"));
failed.setMessageSuffix(": " + e.getMessage());
}
}
}
/*******************************************************************************
** The Result lines: what was deleted, and what was kept because it is locked.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
freed.setCount((int) Math.min(Integer.MAX_VALUE, (bytes + MIB - 1) / MIB));
notes.setCount(messages.size());
if(!messages.isEmpty())
{
notes.setMessageSuffix(": " + String.join("; ", messages.stream().limit(5).toList()) + (messages.size() > 5 ? "; and " + (messages.size() - 5) + " more" : ""));
}
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
plans.addSelfToListIfAnyCount(lines);
deleted.addSelfToListIfAnyCount(lines);
locked.addSelfToListIfAnyCount(lines);
debris.addSelfToListIfAnyCount(lines);
files.addSelfToListIfAnyCount(lines);
filesKept.addSelfToListIfAnyCount(lines);
freed.addSelfToListIfAnyCount(lines);
notes.addSelfToListIfAnyCount(lines);
failed.addSelfToListIfAnyCount(lines);
return (lines);
}
}
}
@@ -0,0 +1,269 @@
/*
* 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.processes;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.backups.model.BackupsTables;
import com.kingsrook.qbits.backups.permissions.BackupsPermissions;
import com.kingsrook.qbits.backups.restore.RestoreEngine;
import com.kingsrook.qbits.backups.restore.RestoreOutcome;
import com.kingsrook.qbits.backups.restore.RestoreRequest;
import com.kingsrook.qbits.backups.restore.RestoreSummaries;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
/*******************************************************************************
** The restore processes (spec 9.3): Restore... on a backup run, and Restore this
** table on a backup artifact. Both are the standard streamed ETL. The first
** screen takes the inputs; Review is a real dry run of the whole restore with the
** per-table summary lines and a masked sample diff; Execute is the restore, and
** Result shows what was done. Neither is in the nav.
*******************************************************************************/
public final class RestoreProcesses
{
public static final String RESTORE = "backupRestore";
public static final String RESTORE_TABLE = "backupRestoreTable";
public static final String STEP_INPUT = "input";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private RestoreProcesses()
{
}
/*******************************************************************************
** The processes defined here.
*******************************************************************************/
public static List<QProcessMetaData> processes()
{
List<QProcessMetaData> processes = new ArrayList<>();
processes.add(BackupsProcesses.permission(restore(RESTORE, "Restore...", BackupsTables.RUN), BackupsPermissions.RESTORE));
processes.add(BackupsProcesses.permission(restore(RESTORE_TABLE, "Restore this table", BackupsTables.ARTIFACT), BackupsPermissions.RESTORE));
return (processes);
}
/*******************************************************************************
** One restore process, sourced from the table whose records it is run on.
*******************************************************************************/
private static QProcessMetaData restore(String name, String label, String table)
{
Map<String, Serializable> values = new HashMap<>();
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table);
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table);
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_TRANSACTION_LEVEL, StreamedETLWithFrontendProcess.TRANSACTION_LEVEL_AUTO_COMMIT);
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, "This is what the restore will do. Nothing is written until you continue.");
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, RestoreTransform.class, RestoreLoad.class, values);
process.withStep(0, inputStep());
process.setName(name);
process.setLabel(label);
process.setTableName(table);
process.setPermissionRules(BackupsTables.permissionRules());
return (process);
}
/*******************************************************************************
** The first screen: what to restore and how.
*******************************************************************************/
private static QFrontendStepMetaData inputStep()
{
return (new QFrontendStepMetaData()
.withName(STEP_INPUT)
.withLabel("Restore options")
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM))
.withFormField(new QFieldMetaData("tables", QFieldType.STRING).withLabel("Tables (blank for every table in the backup)"))
.withFormField(new QFieldMetaData("tableFilters", QFieldType.TEXT).withLabel("Only these records (JSON: table name to filter, optional)"))
.withFormField(new QFieldMetaData("targetTable", QFieldType.STRING).withLabel("Restore into another table (optional, one table only)"))
.withFormField(new QFieldMetaData("mode", QFieldType.STRING).withLabel("Mode").withIsRequired(true).withDefaultValue(RestoreRequest.MODE_INSERT_NEW)
.withPossibleValueSourceName(BackupsTables.pvs("restore_mode")))
.withFormField(new QFieldMetaData("matchOn", QFieldType.STRING).withLabel("Match records on (PRIMARY_KEY, or a unique key's fields)").withDefaultValue(RestoreRequest.MATCH_PRIMARY_KEY))
.withFormField(new QFieldMetaData("idStrategy", QFieldType.STRING).withLabel("Ids").withIsRequired(true).withDefaultValue(RestoreRequest.IDS_PRESERVE)
.withPossibleValueSourceName(BackupsTables.pvs("id_strategy")))
.withFormField(new QFieldMetaData("suppressAutomations", QFieldType.BOOLEAN).withLabel("Do not trigger automations").withDefaultValue(true))
.withFormField(new QFieldMetaData("publishEvents", QFieldType.BOOLEAN).withLabel("Publish change events").withDefaultValue(false))
.withFormField(new QFieldMetaData("runCustomizers", QFieldType.BOOLEAN).withLabel("Run table customizers").withDefaultValue(false))
.withFormField(new QFieldMetaData("perRecordAudit", QFieldType.BOOLEAN).withLabel("Audit each record (otherwise one summary per table)").withDefaultValue(false))
.withFormField(new QFieldMetaData("restoreFiles", QFieldType.BOOLEAN).withLabel("Restore referenced files").withDefaultValue(true))
.withFormField(new QFieldMetaData("fileTargetBucketAlias", QFieldType.STRING).withLabel("Restore files to another bucket alias (optional)"))
.withFormField(new QFieldMetaData("fileTargetPrefix", QFieldType.STRING).withLabel("Restore files under this prefix (optional)"))
.withFormField(new QFieldMetaData("preRestoreBackup", QFieldType.BOOLEAN).withLabel("Back up the target first").withDefaultValue(true))
.withFormField(new QFieldMetaData("confirmationText", QFieldType.STRING).withLabel("Type the table name (or REPLACE <n> TABLES) to confirm a REPLACE")));
}
/*******************************************************************************
** The backup runs the selected records belong to, with the tables chosen for each
** (empty means the tables of the form). Artifact records choose their own table.
*******************************************************************************/
static Map<Integer, Set<String>> sources(List<QRecord> records) throws QException
{
Map<Integer, Set<String>> sources = new LinkedHashMap<>();
for(QRecord record : records)
{
boolean artifact = BackupsTables.ARTIFACT.equals(record.getTableName()) || record.getValue("artifactType") != null;
if(artifact)
{
String table = record.getValueString("tableName");
if(table == null)
{
throw (new QUserFacingException("Only the data of a table can be restored: choose a table data artifact."));
}
sources.computeIfAbsent(record.getValueInteger("backupRunId"), k -> new LinkedHashSet<>()).add(table);
}
else
{
sources.computeIfAbsent(record.getValueInteger("id"), k -> new LinkedHashSet<>());
}
}
return (sources);
}
/*******************************************************************************
** Review (preview, validate) is the dry run; execute only re-checks the rails.
*******************************************************************************/
public static class RestoreTransform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final List<RestoreOutcome> outcomes = new ArrayList<>();
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
boolean execute = StreamedETLWithFrontendProcess.STEP_NAME_EXECUTE.equals(input.getStepName());
RestoreEngine engine = new RestoreEngine();
for(Map.Entry<Integer, Set<String>> source : sources(input.getRecords()).entrySet())
{
RestoreRequest request = RestoreRequest.fromInput(input, source.getKey(), new ArrayList<>(source.getValue()));
if(execute)
{
RestoreEngine.Plan plan = engine.plan(request);
if(!plan.errors().isEmpty())
{
throw (new QUserFacingException(String.join(" ", plan.errors())));
}
}
else
{
outcomes.add(engine.review(request));
}
}
output.getRecords().addAll(input.getRecords());
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
for(RestoreOutcome outcome : outcomes)
{
lines.addAll(RestoreSummaries.lines(outcome, false));
}
return (lines);
}
}
/*******************************************************************************
** Execute: the restore itself, one backup run at a time.
*******************************************************************************/
public static class RestoreLoad extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final List<RestoreOutcome> outcomes = new ArrayList<>();
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
RestoreEngine engine = new RestoreEngine();
for(Map.Entry<Integer, Set<String>> source : sources(input.getRecords()).entrySet())
{
RestoreRequest request = RestoreRequest.fromInput(input, source.getKey(), new ArrayList<>(source.getValue()));
outcomes.add(engine.execute(request));
}
output.getRecords().addAll(input.getRecords());
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
for(RestoreOutcome outcome : outcomes)
{
lines.addAll(RestoreSummaries.lines(outcome, true));
}
return (lines);
}
}
}
Loaded 100 of 168 files, more files were not shown because too many files have changed in this diff. Show more