Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acf51b6749
|
||
|
|
a825360ba1 | ||
|
|
01db11b123 | ||
|
|
6dea78881d
|
||
|
|
89f218937a
|
||
|
|
85f4ca0a6f
|
No files matched your search
@@ -5,9 +5,15 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
## [0.1.0] - Unreleased
|
||||
|
||||
### Added
|
||||
- Complete host-neutral in-app help bundle with live references and a help coverage gate. Hosts can override entries with a HOST bundle.
|
||||
- Guided empty states: help bundle `help/qbit-backups.yaml` (app and all nine tables, with "How rows get created") applied by `BackupsHelp`, and a Setup card on the Backups home (plan, scope rules, storage, schedule, verification, prune bucket).
|
||||
- "Create default plan" table action on Backup plans: adds "Nightly full" for a storage bucket alias; insert-only.
|
||||
- `backupVerifyPlan`: the scheduled verification process. A plan verify cron created a scheduled job for a process that did not exist, so scheduled verification never ran.
|
||||
- Repository scaffold: `BackupsQBitConfig` and `BackupsQBitProducer` (table prefix `backups_`, permissions `backups.*`), build, CI and repository standard files.
|
||||
|
||||
### Changed
|
||||
- Pin QQQ to released 4.1.0-RC.7 and replace the temporary help loader with core HelpBundles registration.
|
||||
- Saving a plan with Use read replica on is refused (it was never read); the not-yet-available settings (read replica, native dump, incremental, PARQUET, ZSTD) say so in their field help; scheduledJobId is documented as unused.
|
||||
- Build against the released QQQ BOM 4.1.0-RC.5; replace reflective alerts binding with core QAlerts and real sink integration tests.
|
||||
- Register backup alert types for every instance while preserving custom backup sinks and no-op behavior without a configured core sink.
|
||||
- Use RC.5-valid alert names `backups.run.missing-in-storage` and `backups.plan.owner-invalid` (and matching deduplication keys). Hosts using the old underscore names must update routing rules.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
## Requirements
|
||||
|
||||
- Java 21.
|
||||
- QQQ 4.1 (targets `4.1.0-RC.6`, not yet released).
|
||||
- QQQ `4.1.0-RC.7` or later.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -22,6 +22,10 @@ new BackupsQBitProducer()
|
||||
.produce(qInstance);
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
This qbit ships no DDL and seeds nothing from a changelog: its one setup value, the storage bucket alias, belongs to your application. On a fresh install open Backups, read the Setup card, and run **Create default plan** on Backup plans (it asks only for the alias of a bucket your storage resolver provides). It creates the insert-only "Nightly full" plan: whole instance, data and audit tables, JSONL and gzip, 02:00 UTC, verified Sundays 04:00 UTC. Edit any setting afterwards. Help for every table, including how its rows get created, ships in `help/qbit-backups.yaml`.
|
||||
|
||||
## Naming
|
||||
|
||||
Tables are `backups_<name>` (lowercase snake_case, backend name equal to the table name). Permissions are `backups.<action>`.
|
||||
@@ -36,14 +40,20 @@ Follow the QQQ [adoption checklist](https://github.com/QRun-IO/qqq/blob/develop/
|
||||
./mvnw verify
|
||||
```
|
||||
|
||||
Before RC.6 is released, verify against the locally installed core build only:
|
||||
See [the RC.6 migration notes](docs/RC6-MIGRATION.md) for fingerprint compatibility,
|
||||
paging guarantees and restore permissions.
|
||||
|
||||
```bash
|
||||
./mvnw -B verify -Dqqq.snapshot.version=4.1.0-ARCHRC6-SNAPSHOT
|
||||
```
|
||||
## In-app help
|
||||
|
||||
The POM stays on RC.6. See [the RC.6 migration notes](docs/RC6-MIGRATION.md) for
|
||||
fingerprint compatibility, paging guarantees and restore permissions.
|
||||
The producer registers [help/qbit-backups.yaml](src/main/resources/help/qbit-backups.yaml)
|
||||
as a QBIT bundle. Hosts can register a `layer: HOST` bundle with matching entry keys
|
||||
and roles to override its text. Core's help startup sync applies the bundle when the
|
||||
host installs the help content table; registration also works without that table.
|
||||
|
||||
`BackupPlanScheduleTest` checks coverage with `HelpBundleAssertions`, including
|
||||
live references and optional scheduler processes. `BackupsTablesGuardrailTest`
|
||||
checks empty-state, widget and possible-value-source coverage. Run it with
|
||||
`./mvnw verify`. Keep the bundle's literal version aligned with the POM version.
|
||||
|
||||
Always use the Maven wrapper (Maven 3.9.11); Maven 3.10 cannot read the parent POM.
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Upgrading
|
||||
|
||||
## Unreleased
|
||||
|
||||
- No schema change and no new include line. Hosts get the help text and the Setup card automatically.
|
||||
- Plans with `use_read_replica` true are now refused on the next save (the setting was never used). Turn it off.
|
||||
- Hosts that set a plan `verifyCronExpression` and a scheduler now get working scheduled verification: the `backupVerifyPlan` process exists. It verifies the plan's latest successful run through the plan's `verifyLevel` (CONTENT when unset). Expect verification rows and, on failure, alerts.
|
||||
@@ -194,14 +194,14 @@
|
||||
<profiles>
|
||||
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
|
||||
<!-- imports the released 4.1 BOM, this profile is active by default and -->
|
||||
<!-- imports qqq-bom-pom 4.1.0-RC.6 ahead of the parent's 4.0.0 BOM. -->
|
||||
<!-- imports qqq-bom-pom 4.1.0-RC.7 ahead of the parent's 4.0.0 BOM. -->
|
||||
<profile>
|
||||
<id>qqq-snapshot</id>
|
||||
<activation>
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -47,6 +47,8 @@ public class BackupsQBitConfig implements QBitConfig
|
||||
private String parentAppName;
|
||||
private String tablePathPrefix;
|
||||
private String schedulerName;
|
||||
private String hostSchedulerDescription;
|
||||
private Boolean hostSchedulerRunsVerification = false;
|
||||
private String instanceName;
|
||||
|
||||
private Integer maxConcurrentRuns = 1;
|
||||
@@ -202,6 +204,51 @@ public class BackupsQBitConfig implements QBitConfig
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for hostSchedulerDescription: when the application runs plan crons with
|
||||
** its own tick instead of a QQQ scheduler (schedulerName), a short name for it.
|
||||
** The Setup card then treats plan crons as firing.
|
||||
*******************************************************************************/
|
||||
public String getHostSchedulerDescription()
|
||||
{
|
||||
return (hostSchedulerDescription);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for hostSchedulerDescription.
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withHostSchedulerDescription(String hostSchedulerDescription)
|
||||
{
|
||||
this.hostSchedulerDescription = hostSchedulerDescription;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for hostSchedulerRunsVerification: whether the host's own tick also runs
|
||||
** the verify cron (by calling the backupVerifyPlan process). False by default.
|
||||
*******************************************************************************/
|
||||
public Boolean getHostSchedulerRunsVerification()
|
||||
{
|
||||
return (hostSchedulerRunsVerification);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for hostSchedulerRunsVerification.
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withHostSchedulerRunsVerification(Boolean hostSchedulerRunsVerification)
|
||||
{
|
||||
this.hostSchedulerRunsVerification = hostSchedulerRunsVerification;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for schedulerName (the QQQ scheduler used for plan jobs and the SLA checker)
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package com.kingsrook.qbits.backups;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
|
||||
|
||||
@@ -160,6 +161,8 @@ public class BackupsQBitProducer implements QBitMetaDataProducer<BackupsQBitConf
|
||||
com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData app = com.kingsrook.qbits.backups.app.BackupsApp.app(qBitConfig, tables);
|
||||
output.add(app);
|
||||
output.addSelfToInstance(qInstance);
|
||||
// Registration is metadata-only; core guards startup sync when helpContent is absent.
|
||||
HelpBundles.register(qInstance, "help/qbit-backups.yaml");
|
||||
|
||||
// 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());
|
||||
|
||||
@@ -70,6 +70,7 @@ public final class BackupsHomeView
|
||||
{
|
||||
StringBuilder html = new StringBuilder("<div class=\"backups-home\" style=\"" + ROOT + "\">");
|
||||
planStatus(html, data, pathPrefix);
|
||||
setup(html, data, pathPrefix);
|
||||
html.append("<div style=\"").append(GRID).append("\">");
|
||||
needsAttention(html, data, pathPrefix);
|
||||
latestTestRestore(html, data, pathPrefix);
|
||||
@@ -139,6 +140,50 @@ public final class BackupsHomeView
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card: what a working backup needs, whether each part is in place,
|
||||
** and the next step, linked to its table.
|
||||
*******************************************************************************/
|
||||
private static void setup(StringBuilder html, BackupsHomeData data, String prefix)
|
||||
{
|
||||
List<BackupsSetupStatus.Item> items = BackupsSetupStatus.load(data.plans());
|
||||
open(html, "Setup");
|
||||
long todo = items.stream().filter(i -> i.state() == BackupsSetupStatus.State.TODO).count();
|
||||
html.append("<p style=\"margin:0 0 .6rem;font-size:.9rem;\">")
|
||||
.append(todo == 0 ? "Everything backups need is set up." : esc(todo + (todo == 1 ? " item needs" : " items need") + " attention."))
|
||||
.append("</p><ul style=\"list-style:none;margin:0;padding:0;\">");
|
||||
for(BackupsSetupStatus.Item item : items)
|
||||
{
|
||||
String color = switch(item.state())
|
||||
{
|
||||
case OK -> "#16a34a";
|
||||
case TODO -> "#dc2626";
|
||||
case DEFAULT -> "#d97706";
|
||||
case OPTIONAL -> "#9ca3af";
|
||||
};
|
||||
String label = switch(item.state())
|
||||
{
|
||||
case OK -> "Set up";
|
||||
case TODO -> "Needs setup";
|
||||
case DEFAULT -> "Using defaults";
|
||||
case OPTIONAL -> "Optional";
|
||||
};
|
||||
html.append("<li style=\"").append(ROW).append("\"><div style=\"display:flex;flex-wrap:wrap;align-items:center;gap:.5rem .75rem;\">")
|
||||
.append("<strong>").append("<a style=\"").append(LINK).append("\" href=\"").append(esc(prefix + "/" + item.tableName())).append("\">").append(esc(item.label())).append("</a></strong>")
|
||||
.append("<span style=\"").append(CHIP).append("\"><span aria-hidden=\"true\" style=\"width:.55rem;height:.55rem;border-radius:50%;background:").append(color).append(";display:inline-block;\"></span>").append(esc(label)).append("</span>")
|
||||
.append("</div><div style=\"font-size:.9rem;margin-top:.35rem;\">").append(esc(item.detail())).append("</div>");
|
||||
if(item.nextStep() != null)
|
||||
{
|
||||
html.append("<div style=\"").append(MUTED).append("font-size:.9rem;margin-top:.2rem;\">Next: ").append(esc(item.nextStep())).append("</div>");
|
||||
}
|
||||
html.append("</li>");
|
||||
}
|
||||
html.append("</ul>");
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Needs attention.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.app;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.backups.BackupsQBitConfig;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.processes.DefaultPlanProcess;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card on the Backups home: what a working backup needs (a plan, a
|
||||
** storage bucket that resolves, a schedule, verification, a separate prune
|
||||
** bucket), whether each is in place, and the next step. Computed from the plans
|
||||
** the home already loaded and the runtime's storage resolver; it reads nothing
|
||||
** else.
|
||||
*******************************************************************************/
|
||||
public final class BackupsSetupStatus
|
||||
{
|
||||
/*******************************************************************************
|
||||
** OK: in place. TODO: backups need it. DEFAULT: working, with a default worth a
|
||||
** look. OPTIONAL: a feature that is off until set up.
|
||||
*******************************************************************************/
|
||||
public enum State
|
||||
{
|
||||
OK, TODO, DEFAULT, OPTIONAL
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One line of the card.
|
||||
*******************************************************************************/
|
||||
public record Item(String label, String tableName, State state, String detail, String nextStep)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private BackupsSetupStatus()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the items from the plans.
|
||||
*******************************************************************************/
|
||||
public static List<Item> load(List<QRecord> plans)
|
||||
{
|
||||
BackupsQBitConfig config = BackupsRuntime.isInstalled() ? BackupsRuntime.getConfig() : null;
|
||||
return (load(plans, config != null && hasContent(config.getSchedulerName()), config == null ? null : config.getHostSchedulerDescription(),
|
||||
config != null && Boolean.TRUE.equals(config.getHostSchedulerRunsVerification())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the items from the plans, given whether a scheduler is configured (a plan
|
||||
** cron only fires through the scheduler the qbit config names).
|
||||
*******************************************************************************/
|
||||
public static List<Item> load(List<QRecord> plans, boolean schedulerConfigured)
|
||||
{
|
||||
return (load(plans, schedulerConfigured, null, false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the items from the plans. A host that runs plan crons with its own tick
|
||||
** (hostScheduler names it) has crons that fire; hostRunsVerification says whether
|
||||
** that tick also runs the verify cron.
|
||||
*******************************************************************************/
|
||||
public static List<Item> load(List<QRecord> plans, boolean schedulerConfigured, String hostScheduler, boolean hostRunsVerification)
|
||||
{
|
||||
boolean host = hasContent(hostScheduler);
|
||||
List<Item> items = new ArrayList<>();
|
||||
List<QRecord> active = plans.stream().filter(p -> Boolean.TRUE.equals(p.getValueBoolean("isActive"))).toList();
|
||||
|
||||
///////////
|
||||
// plans //
|
||||
///////////
|
||||
if(plans.isEmpty())
|
||||
{
|
||||
items.add(new Item("Backup plans", BackupsTables.PLAN, State.TODO, "No plan: nothing is backed up.",
|
||||
"Run Create default plan on Backup plans (it asks only for the storage bucket alias), or add a plan with New."));
|
||||
items.add(new Item("Plan scope rules", BackupsTables.PLAN_SCOPE, State.OPTIONAL, "None needed yet.",
|
||||
"A plan that backs up everything (scope Instance) needs no rules. Add them on a plan that is limited to apps, qbits or tables."));
|
||||
return (items);
|
||||
}
|
||||
items.add(active.isEmpty()
|
||||
? new Item("Backup plans", BackupsTables.PLAN, State.TODO, plans.size() + (plans.size() == 1 ? " plan, but it is" : " plans, but none is") + " active: nothing runs on its own.", "Turn a plan on (Active).")
|
||||
: new Item("Backup plans", BackupsTables.PLAN, State.OK, active.size() + " active plan" + (active.size() == 1 ? "" : "s") + " of " + plans.size() + ".", null));
|
||||
|
||||
/////////////////////////////////////////
|
||||
// scope rules: needed unless INSTANCE //
|
||||
/////////////////////////////////////////
|
||||
List<String> limited = plans.stream().filter(p -> p.getValueString("scopeType") != null && !"INSTANCE".equals(p.getValueString("scopeType"))).map(p -> p.getValueString("name")).toList();
|
||||
items.add(limited.isEmpty()
|
||||
? new Item("Plan scope rules", BackupsTables.PLAN_SCOPE, State.OPTIONAL, "Every plan backs up the whole instance, so no rules are needed.",
|
||||
"Add rules to a plan only to include or leave out apps, qbits, tables or roles.")
|
||||
: new Item("Plan scope rules", BackupsTables.PLAN_SCOPE, State.OK, limited.size() + (limited.size() == 1 ? " plan is" : " plans are") + " limited by scope rules: " + String.join(", ", limited) + ".",
|
||||
"Check each plan's Scope rules to see what it covers."));
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// storage: every alias a plan names resolves //
|
||||
///////////////////////////////////////////////
|
||||
Set<String> aliases = new LinkedHashSet<>();
|
||||
for(QRecord plan : plans)
|
||||
{
|
||||
for(String field : List.of("storageBucketAlias", "pruneBucketAlias"))
|
||||
{
|
||||
if(plan.getValueString(field) != null)
|
||||
{
|
||||
aliases.add(plan.getValueString(field));
|
||||
}
|
||||
}
|
||||
}
|
||||
List<String> broken = new ArrayList<>();
|
||||
for(String alias : aliases)
|
||||
{
|
||||
try
|
||||
{
|
||||
BackupsRuntime.storageFor(alias);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
broken.add(alias);
|
||||
}
|
||||
}
|
||||
items.add(broken.isEmpty()
|
||||
? new Item("Storage", BackupsTables.PLAN, State.OK, aliases.size() + " storage bucket alias" + (aliases.size() == 1 ? "" : "es") + " in use, and each resolves.", "Run Test destination on a plan to prove it can write.")
|
||||
: new Item("Storage", BackupsTables.PLAN, State.TODO, "These aliases do not resolve to a bucket: " + String.join(", ", broken) + ".",
|
||||
"Fix the alias on the plan, or add the bucket to the application's storage resolver."));
|
||||
|
||||
/////////////////////////////////////////
|
||||
// schedule: an active plan has a cron //
|
||||
/////////////////////////////////////////
|
||||
long scheduled = active.stream().filter(p -> hasContent(p.getValueString("cronExpression"))).count();
|
||||
String noScheduler = "No scheduler is configured (schedulerName on the qbit config), so plan crons are saved but never fire";
|
||||
items.add(!schedulerConfigured && !host
|
||||
? new Item("Schedule", BackupsTables.PLAN, State.TODO, noScheduler + ". Backups run only when someone clicks Run backup now.",
|
||||
"Set schedulerName on the qbit config (or run your own tick if the application does), then give each plan a cron expression.")
|
||||
: scheduled == 0
|
||||
? new Item("Schedule", BackupsTables.PLAN, State.TODO, "No active plan has a schedule: backups run only when someone clicks Run backup now.", "Set a Cron expression on a plan (for example " + DefaultPlanProcess.CRON + ").")
|
||||
: new Item("Schedule", BackupsTables.PLAN, State.OK, scheduled + " active plan" + (scheduled == 1 ? " runs" : "s run") + " on a schedule"
|
||||
+ (host && !schedulerConfigured ? ", run by " + hostScheduler + " (the application scheduler)." : "."), null));
|
||||
|
||||
//////////////////
|
||||
// verification //
|
||||
//////////////////
|
||||
long verified = active.stream().filter(p -> hasContent(p.getValueString("verifyCronExpression"))).count();
|
||||
items.add(host && !schedulerConfigured && !hostRunsVerification
|
||||
? new Item("Verification", BackupsTables.VERIFICATION, State.OPTIONAL, "Not run by " + hostScheduler + ": verify cron expressions are saved but nothing fires them.",
|
||||
"Run Verify backup on a run by hand, or have the application scheduler run the backupVerifyPlan process for each plan.")
|
||||
: !schedulerConfigured && !host
|
||||
? new Item("Verification", BackupsTables.VERIFICATION, State.TODO, noScheduler + ", so no verification is scheduled.",
|
||||
"Set schedulerName on the qbit config, then a verify cron expression on a plan; or run Verify backup on a run by hand.")
|
||||
: verified == 0
|
||||
? new Item("Verification", BackupsTables.VERIFICATION, State.OPTIONAL, "No plan verifies its backups on a schedule.",
|
||||
"Set a Verify cron expression on a plan, or run Verify backup on a run. A backup nobody has restored is a guess.")
|
||||
: new Item("Verification", BackupsTables.VERIFICATION, State.OK, verified + " active plan" + (verified == 1 ? " verifies" : "s verify") + " its latest backup on a schedule.", null));
|
||||
|
||||
////////////////////////////////////////////
|
||||
// prune bucket: separate delete authority //
|
||||
////////////////////////////////////////////
|
||||
long noPrune = plans.stream().filter(p -> !hasContent(p.getValueString("pruneBucketAlias"))).count();
|
||||
items.add(noPrune == 0
|
||||
? new Item("Prune bucket", BackupsTables.PLAN, State.OK, "Every plan prunes through its own delete-capable bucket alias.", null)
|
||||
: new Item("Prune bucket", BackupsTables.PLAN, State.DEFAULT, noPrune + (noPrune == 1 ? " plan has" : " plans have") + " no prune bucket: Prune uses the backup bucket, so one key can both write and delete backups.",
|
||||
"Give the plan a separate delete-capable Prune bucket alias."));
|
||||
return (items);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static boolean hasContent(String value)
|
||||
{
|
||||
return (value != null && !value.isBlank());
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ 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.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
@@ -446,8 +447,8 @@ public final class BackupsTables
|
||||
.withField(new QFieldMetaData("pruneBucketAlias", QFieldType.STRING).withMaxLength(100))
|
||||
.withField(new QFieldMetaData("prefix", QFieldType.STRING).withMaxLength(255))
|
||||
.withField(new QFieldMetaData("critical", QFieldType.BOOLEAN).withDefaultValue(false))
|
||||
.withField(enumField("format", "format", "JSONL"))
|
||||
.withField(enumField("compression", "compression", "GZIP"))
|
||||
.withField(enumField("format", "format", "JSONL").withHelpContent(new QHelpContent("JSONL, CSV and JSONL_AND_CSV are available. PARQUET is not available yet and is refused when the plan is saved.")))
|
||||
.withField(enumField("compression", "compression", "GZIP").withHelpContent(new QHelpContent("GZIP and NONE are available. ZSTD is not available yet and is refused when the plan is saved.")))
|
||||
.withField(enumField("encryptionMode", "encryption_mode", "NONE"))
|
||||
.withField(new QFieldMetaData("encryptionKeyRef", QFieldType.STRING).withMaxLength(100))
|
||||
.withField(enumField("sensitiveFieldPolicy", "sensitive_policy", "EXCLUDE"))
|
||||
@@ -455,12 +456,15 @@ public final class BackupsTables
|
||||
.withField(enumField("fileObjectPolicy", "file_policy", "COPY"))
|
||||
.withField(enumField("fileMissingPolicy", "file_missing_policy", "WARN"))
|
||||
.withField(enumField("consistency", "consistency", "SNAPSHOT_PREFERRED"))
|
||||
.withField(new QFieldMetaData("useReadReplica", QFieldType.BOOLEAN).withDefaultValue(false))
|
||||
.withField(enumField("incrementalMode", "incremental_mode", "FULL"))
|
||||
.withField(enumField("nativeDump", "native_dump", "NONE"))
|
||||
.withField(new QFieldMetaData("useReadReplica", QFieldType.BOOLEAN).withDefaultValue(false)
|
||||
.withHelpContent(new QHelpContent("Not available yet: backups always read from the application's own backend. Leave this off; turning it on is refused when the plan is saved.")))
|
||||
.withField(enumField("incrementalMode", "incremental_mode", "FULL")
|
||||
.withHelpContent(new QHelpContent("Only FULL is available yet. CHANGED_SINCE_LAST is not available yet and is refused when the plan is saved.")))
|
||||
.withField(enumField("nativeDump", "native_dump", "NONE")
|
||||
.withHelpContent(new QHelpContent("Only NONE is available yet. Native database dumps (PG_DUMP_CUSTOM) are not available yet and are refused when the plan is saved.")))
|
||||
.withField(new QFieldMetaData("cronExpression", QFieldType.STRING).withMaxLength(100))
|
||||
.withField(new QFieldMetaData("cronTimeZoneId", QFieldType.STRING).withMaxLength(60))
|
||||
.withField(roInt("scheduledJobId"))
|
||||
.withField(roInt("scheduledJobId").withHelpContent(new QHelpContent("Not used: a plan's schedules are scheduled jobs found by their foreign key (backups_plan for the backup, backups_plan_verify for the verification), so look there.")))
|
||||
.withField(new QFieldMetaData("slaHours", QFieldType.INTEGER).withDefaultValue(26))
|
||||
.withField(new QFieldMetaData("verifySlaDays", QFieldType.INTEGER).withDefaultValue(8))
|
||||
.withField(new QFieldMetaData("verifyFileSamplePercent", QFieldType.INTEGER).withDefaultValue(100))
|
||||
|
||||
@@ -48,7 +48,7 @@ import com.kingsrook.qqq.backend.core.model.statusmessages.QWarningMessage;
|
||||
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",
|
||||
"format", "compression", "encryptionMode", "encryptionKeyRef", "sensitiveFieldPolicy", "incrementalMode", "nativeDump", "useReadReplica", "cronExpression", "cronTimeZoneId",
|
||||
"verifyCronExpression", "retainDaily", "retainWeekly", "retainMonthly", "retainYearly", "retainMinimum", "objectLockMode", "objectLockDays",
|
||||
"slaHours", "pageSize", "ownerUserId");
|
||||
|
||||
|
||||
@@ -176,6 +176,10 @@ public final class BackupPlanValidator
|
||||
{
|
||||
errors.add("Native database dumps are not available yet. Use NONE.");
|
||||
}
|
||||
if(settings.bool("useReadReplica", false))
|
||||
{
|
||||
errors.add("Reading from a replica is not available yet: backups read from the application's own backend. Turn Use read replica off.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ 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.add(DefaultPlanProcess.process());
|
||||
processes.addAll(VerifyProcesses.processes());
|
||||
processes.addAll(RestoreProcesses.processes());
|
||||
processes.addAll(CatalogProcesses.processes());
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.permissions.BackupsPermissions;
|
||||
import com.kingsrook.qbits.backups.plan.BackupPlanValidator;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qbits.backups.scope.PlanSettings;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
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.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.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
|
||||
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.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
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.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "Create default plan": a table action on backups_plan that adds one working
|
||||
** plan, "Nightly full", so a fresh install has something to run. The only thing
|
||||
** it asks for is the storage bucket alias, because that is the one value the qbit
|
||||
** cannot know (it names a bucket the host's storage resolver provides). Review
|
||||
** shows the plan that will be created and checks it against the same rules a
|
||||
** hand-made plan must pass; Result links the new plan.
|
||||
**
|
||||
** Insert-only: when a plan with the key nightly-full exists (edited or not), nothing
|
||||
** is created or changed. The plan goes through the normal insert, so the owner is
|
||||
** the user running the action and the schedule syncs like any other plan.
|
||||
*******************************************************************************/
|
||||
public final class DefaultPlanProcess
|
||||
{
|
||||
public static final String NAME = "backupsPlanCreateDefault";
|
||||
public static final String FIELD_ALIAS = "storageBucketAlias";
|
||||
public static final String PLAN_KEY = "nightly-full";
|
||||
public static final String PLAN_NAME = "Nightly full";
|
||||
public static final String CRON = "0 0 2 * * ?";
|
||||
public static final String VERIFY_CRON = "0 0 4 ? * SUN";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private DefaultPlanProcess()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The process: a table-level action with one question (the bucket alias).
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData process()
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, BackupsTables.PLAN);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, BackupsTables.PLAN);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, "This plan will be created. Nothing is written until you continue. You can edit every setting afterwards.");
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(Extract.class, Transform.class, Load.class, values);
|
||||
process.setName(NAME);
|
||||
process.setLabel("Create default plan");
|
||||
process.setIcon(new QIcon("playlist_add"));
|
||||
process.setTableName(BackupsTables.PLAN);
|
||||
process.withStep(0, new QFrontendStepMetaData()
|
||||
.withName("input")
|
||||
.withLabel("Where should the backups go?")
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM))
|
||||
.withFormField(new QFieldMetaData(FIELD_ALIAS, QFieldType.STRING).withLabel("Storage bucket alias").withIsRequired(true)
|
||||
.withHelpContent(new QHelpContent("The alias of a bucket your application provides to Backups (never a key or an endpoint). It becomes the plan's Storage bucket alias."))));
|
||||
process.setMinInputRecords(0);
|
||||
process.setPermissionRules(BackupsTables.permissionRules());
|
||||
return (BackupsProcesses.permission(process, BackupsPermissions.ADMIN));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The values of the default plan for a bucket alias. Every setting is explicit
|
||||
** so the plan does not depend on the backend applying field defaults.
|
||||
*******************************************************************************/
|
||||
public static Map<String, Serializable> planValues(String alias)
|
||||
{
|
||||
Map<String, Serializable> plan = new LinkedHashMap<>();
|
||||
plan.put("name", PLAN_NAME);
|
||||
plan.put("planKey", PLAN_KEY);
|
||||
plan.put("description", "Every night: all data and audit tables of the application, and the files they reference, to the storage bucket. Edit any setting on this plan.");
|
||||
plan.put("isActive", true);
|
||||
plan.put("scopeType", "INSTANCE");
|
||||
plan.put("includeRoles", "DATA,AUDIT");
|
||||
plan.put("storageBucketAlias", alias);
|
||||
plan.put("critical", true);
|
||||
plan.put("format", "JSONL");
|
||||
plan.put("compression", "GZIP");
|
||||
plan.put("encryptionMode", "NONE");
|
||||
plan.put("sensitiveFieldPolicy", "EXCLUDE");
|
||||
plan.put("heavyFieldPolicy", "INLINE");
|
||||
plan.put("fileObjectPolicy", "COPY");
|
||||
plan.put("fileMissingPolicy", "WARN");
|
||||
plan.put("consistency", "SNAPSHOT_PREFERRED");
|
||||
plan.put("useReadReplica", false);
|
||||
plan.put("incrementalMode", "FULL");
|
||||
plan.put("nativeDump", "NONE");
|
||||
plan.put("cronExpression", CRON);
|
||||
plan.put("cronTimeZoneId", "UTC");
|
||||
plan.put("slaHours", 26);
|
||||
plan.put("verifySlaDays", 8);
|
||||
plan.put("verifyFileSamplePercent", 100);
|
||||
plan.put("verifyCronExpression", VERIFY_CRON);
|
||||
plan.put("verifyLevel", "CONTENT");
|
||||
plan.put("retainDaily", 7);
|
||||
plan.put("retainWeekly", 4);
|
||||
plan.put("retainMonthly", 12);
|
||||
plan.put("retainYearly", 0);
|
||||
plan.put("retainMinimum", 3);
|
||||
plan.put("objectLockMode", "NONE");
|
||||
plan.put("maxDurationMinutes", 240);
|
||||
plan.put("pageSize", 5000);
|
||||
plan.put("targetPartBytes", 268435456L);
|
||||
return (plan);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the default plan already exists.
|
||||
*******************************************************************************/
|
||||
private static boolean exists() throws QException
|
||||
{
|
||||
return (!new BackupCatalog().where(BackupsTables.PLAN, "planKey", PLAN_KEY).isEmpty());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Extract: the plan to create, or nothing when it exists already.
|
||||
*******************************************************************************/
|
||||
public static class Extract extends AbstractExtractStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
String alias = input.getValueString(FIELD_ALIAS);
|
||||
if(alias == null || alias.isBlank())
|
||||
{
|
||||
throw (new QUserFacingException("Enter the storage bucket alias the backups should go to."));
|
||||
}
|
||||
if(!exists())
|
||||
{
|
||||
QRecord record = new QRecord().withTableName(BackupsTables.PLAN);
|
||||
record.setValues(new LinkedHashMap<>(planValues(alias.strip())));
|
||||
getRecordPipe().addRecord(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review: the plan, checked against the rules a plan must pass.
|
||||
*******************************************************************************/
|
||||
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine create = BackupsProcesses.line(Status.OK, "plan will be created", "plans will be created", "plan was created", "plans were created");
|
||||
private final ProcessSummaryLine invalid = BackupsProcesses.line(Status.ERROR, "plan cannot be created", "plans cannot be created", "plan could not be created", "plans could not be created");
|
||||
private final List<String> problems = new ArrayList<>();
|
||||
private boolean sawRecord;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
sawRecord = true;
|
||||
BackupPlanValidator.Result result = BackupPlanValidator.validate(PlanSettings.of(record), null, false, List.of());
|
||||
boolean nameTaken = !new BackupCatalog().where(BackupsTables.PLAN, "name", record.getValueString("name")).isEmpty();
|
||||
if(nameTaken)
|
||||
{
|
||||
problems.add("A plan named " + record.getValueString("name") + " already exists. Rename it or edit that plan.");
|
||||
}
|
||||
if(result.ok() && !nameTaken)
|
||||
{
|
||||
create.incrementCountAndAddPrimaryKey(record.getValueString("planKey"));
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
else
|
||||
{
|
||||
invalid.incrementCount();
|
||||
problems.addAll(result.errors());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
create.addSelfToListIfAnyCount(lines);
|
||||
invalid.setBulletsOfText(new ArrayList<>(problems));
|
||||
invalid.addSelfToListIfAnyCount(lines);
|
||||
if(!sawRecord)
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, null, "A plan with the key " + PLAN_KEY + " already exists, so nothing will be created. Edit that plan instead."));
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Load: insert through the normal insert (validation, owner, schedule).
|
||||
*******************************************************************************/
|
||||
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine created = BackupsProcesses.line(Status.OK, "plan will be created", "plans will be created", "plan was created", "plans were created");
|
||||
private final ProcessSummaryLine failed = BackupsProcesses.line(Status.ERROR, "plan could not be created", "plans could not be created", "plan could not be created", "plans could not be created");
|
||||
private final List<String> problems = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
InsertInput insert = new InsertInput(BackupsTables.PLAN);
|
||||
insert.setRecords(input.getRecords());
|
||||
InsertOutput inserted = new InsertAction().execute(insert);
|
||||
for(QRecord record : inserted.getRecords())
|
||||
{
|
||||
if(record.getErrors() == null || record.getErrors().isEmpty())
|
||||
{
|
||||
created.incrementCountAndAddPrimaryKey(record.getValue("id"));
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
else
|
||||
{
|
||||
failed.incrementCount();
|
||||
record.getErrors().forEach(error -> problems.add(error.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
created.addSelfToListIfAnyCount(lines);
|
||||
failed.setBulletsOfText(new ArrayList<>(problems));
|
||||
failed.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ public final class LifecycleProcesses
|
||||
{
|
||||
List<QProcessMetaData> processes = new ArrayList<>();
|
||||
processes.add(BackupScheduledRunProcess.processMetaData());
|
||||
processes.add(com.kingsrook.qbits.backups.schedule.BackupScheduledVerifyProcess.processMetaData());
|
||||
processes.addAll(CancelRunProcess.processes());
|
||||
processes.add(ResumeRunProcess.process());
|
||||
processes.add(TestDestinationProcess.process());
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* 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.schedule;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.permissions.BackupsPermissions;
|
||||
import com.kingsrook.qbits.backups.processes.BackupsProcesses;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qbits.backups.verify.BackupVerifier;
|
||||
import com.kingsrook.qbits.backups.verify.VerificationOutcome;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
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.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.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QRecordListMetaData;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The scheduled verify entry point: the process the verify scheduled job of a
|
||||
** plan (backups_plan.verifyCronExpression) runs. Until this class existed the
|
||||
** verify job was created but pointed at a process nobody had registered, so a
|
||||
** verify cron never verified anything.
|
||||
**
|
||||
** It verifies the plan's latest successful run, through the level the plan names
|
||||
** (verifyLevel, CONTENT when unset), stopping at the first level that does not
|
||||
** pass. Findings, alerts and the plan's last-verified fields are written by the
|
||||
** verifier, exactly as for Verify backup on a run. A plan that has no
|
||||
** successful run yet has nothing to verify, and says so.
|
||||
*******************************************************************************/
|
||||
public class BackupScheduledVerifyProcess implements BackendStep
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(BackupScheduledVerifyProcess.class);
|
||||
|
||||
public static final String NAME = BackupPlanVerifySyncToScheduledJobProcess.VERIFY_PROCESS_NAME;
|
||||
|
||||
public static final String SCHEDULER_USER = "system:scheduler";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The process metadata: not in the nav, a record process on backups_plan.
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData processMetaData()
|
||||
{
|
||||
QProcessMetaData process = new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Run scheduled verification")
|
||||
.withTableName(BackupsTables.PLAN)
|
||||
.withIcon(new QIcon().withName("verified"))
|
||||
.withStep(new QBackendStepMetaData()
|
||||
.withName("execute")
|
||||
.withInputData(new QFunctionInputMetaData().withRecordListMetaData(new QRecordListMetaData().withTableName(BackupsTables.PLAN)))
|
||||
.withCode(new QCodeReference(BackupScheduledVerifyProcess.class)));
|
||||
process.setPermissionRules(BackupsTables.permissionRules());
|
||||
return (BackupsProcesses.permission(process, BackupsPermissions.RUN));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The level a plan verifies to: its verifyLevel, CONTENT when unset or unknown.
|
||||
*******************************************************************************/
|
||||
public static String levelOf(QRecord plan)
|
||||
{
|
||||
String level = plan.getValueString("verifyLevel");
|
||||
return (level != null && BackupVerifier.LEVELS.contains(level) ? level : BackupVerifier.CONTENT);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Verify the latest successful run of the plan.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
List<QRecord> records = input.getRecords();
|
||||
if(records == null || records.isEmpty())
|
||||
{
|
||||
throw (new QUserFacingException("No backup plan was selected or found."));
|
||||
}
|
||||
QRecord plan = new BackupCatalog().get(BackupsTables.PLAN, records.get(0).getValueInteger("id"))
|
||||
.orElseThrow(() -> new QUserFacingException("The backup plan no longer exists."));
|
||||
|
||||
if(!Boolean.TRUE.equals(plan.getValueBoolean("isActive")))
|
||||
{
|
||||
LOG.info("A scheduled verification fired for a plan that is not active; nothing was verified", logPair("plan", plan.getValueString("planKey")));
|
||||
output.addValue("status", "INACTIVE");
|
||||
return;
|
||||
}
|
||||
|
||||
Integer runId = plan.getValueInteger("lastSuccessRunId");
|
||||
if(runId == null)
|
||||
{
|
||||
LOG.info("A scheduled verification fired for a plan with no successful backup yet; nothing was verified", logPair("plan", plan.getValueString("planKey")));
|
||||
output.addValue("status", "NO_BACKUP");
|
||||
return;
|
||||
}
|
||||
|
||||
String level = levelOf(plan);
|
||||
List<VerificationOutcome> outcomes = new BackupVerifier().verifyThrough(runId, level, "SCHEDULED", SCHEDULER_USER);
|
||||
VerificationOutcome last = outcomes.get(outcomes.size() - 1);
|
||||
output.addValue("status", last.status());
|
||||
output.addValue("runId", runId);
|
||||
output.addValue("level", level);
|
||||
if(!last.passed())
|
||||
{
|
||||
LOG.info("A scheduled verification did not pass", logPair("plan", plan.getValueString("planKey")), logPair("runId", runId), logPair("status", last.status()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,782 @@
|
||||
bundle: qbit-backups
|
||||
version: 0.1.0-SNAPSHOT
|
||||
layer: QBIT
|
||||
entries:
|
||||
- key: table:backups_plan;field:name
|
||||
summary: 'The display name of the backup plan.'
|
||||
- key: table:backups_plan;field:description
|
||||
summary: 'Your notes about the purpose and coverage of this plan.'
|
||||
- key: table:backups_plan;field:planKey
|
||||
summary: 'A stable short name used in the storage paths of this plan’s backups.'
|
||||
content: |
|
||||
Choose a stable lowercase name using letters, digits, dashes or underscores, within {{maxLength:backups_plan.planKey}} characters. This name becomes part of stored object paths. It cannot change after the plan has run. If you need a different storage identity, create another plan and keep the existing plan and its run history available for recovery.
|
||||
- key: table:backups_plan;field:isActive
|
||||
summary: 'Whether this plan is active for scheduled execution.'
|
||||
- key: table:backups_plan;field:scopeType
|
||||
summary: 'How this plan selects the data to back up.'
|
||||
content: |
|
||||
Scope and table roles control which data a plan selects. Use **Backup Plan Scope** rules to include or exclude particular targets and narrow records when needed. Review the resolved selection with **Run backup now** before continuing, especially after application metadata changes. Excluded data will not be available from this backup even if the run itself completes successfully.
|
||||
- key: table:backups_plan;field:includeRoles
|
||||
summary: 'The comma-separated table roles included by the plan.'
|
||||
content: |
|
||||
Scope and table roles control which data a plan selects. Use **Backup Plan Scope** rules to include or exclude particular targets and narrow records when needed. Review the resolved selection with **Run backup now** before continuing, especially after application metadata changes. Excluded data will not be available from this backup even if the run itself completes successfully.
|
||||
- key: table:backups_plan;field:pruneBucketAlias
|
||||
summary: 'The storage connection alias used when removing expired backups.'
|
||||
content: |
|
||||
This alias selects the configured storage access used for pruning. It can separate deletion access from the destination used to write backups. Enter a storage alias rather than connection credentials, and check it before requesting retention cleanup. **Prune per retention** previews proposed deletions; locked objects and retained backups remain subject to their protection rules.
|
||||
- key: table:backups_plan;field:prefix
|
||||
summary: 'The storage path prefix under which this plan writes backups.'
|
||||
- key: table:backups_plan;field:critical
|
||||
summary: 'Whether this plan is marked as critical for backup monitoring.'
|
||||
content: |
|
||||
Monitoring compares successful backup and verification history with the plan’s expected ages. Use the health result to decide which **Backup Run** or **Verification** to inspect. A recent run can still need verification, and a failed run can leave an older successful backup in place. Adjust expectations to your recovery needs rather than treating a changed threshold as a repair.
|
||||
- key: table:backups_plan;field:format
|
||||
summary: 'The file format used for exported table records.'
|
||||
content: |
|
||||
The default is {{default:backups_plan.format}} and the metadata choices are {{values:backups_format}}. JSONL supports structured record content, while CSV provides a tabular representation; both can be requested together. Parquet is listed but is not implemented in this version and is rejected by validation. Review the backup’s artifacts afterward to see the files actually produced.
|
||||
- key: table:backups_plan;field:compression
|
||||
summary: 'The compression applied to exported backup data.'
|
||||
content: |
|
||||
The default is {{default:backups_plan.compression}} and the metadata choices are {{values:backups_compression}}. Use GZIP to compress exported data or NONE to leave it uncompressed. ZSTD is listed but is not implemented in this version. Compare raw and stored byte counts on **Backup Run** or **Artifacts** when assessing the resulting storage size.
|
||||
- key: table:backups_plan;field:encryptionMode
|
||||
summary: 'The encryption mode requested for backup content.'
|
||||
content: |
|
||||
Review these settings together before including sensitive data. Encryption support depends on the configured backup implementation; unsupported client encryption is rejected. A key reference names a key and must never contain its value. Encrypted-only inclusion requires client encryption, while including sensitive fields in the clear requires backup administrator permission. Check the preview and validation messages before running the plan.
|
||||
- key: table:backups_plan;field:encryptionKeyRef
|
||||
summary: 'The name of the encryption key to resolve, never the key value.'
|
||||
content: |
|
||||
Review these settings together before including sensitive data. Encryption support depends on the configured backup implementation; unsupported client encryption is rejected. A key reference names a key and must never contain its value. Encrypted-only inclusion requires client encryption, while including sensitive fields in the clear requires backup administrator permission. Check the preview and validation messages before running the plan.
|
||||
- key: table:backups_plan;field:sensitiveFieldPolicy
|
||||
summary: 'How fields marked sensitive are handled in this backup.'
|
||||
content: |
|
||||
Review these settings together before including sensitive data. Encryption support depends on the configured backup implementation; unsupported client encryption is rejected. A key reference names a key and must never contain its value. Encrypted-only inclusion requires client encryption, while including sensitive fields in the clear requires backup administrator permission. Check the preview and validation messages before running the plan.
|
||||
- key: table:backups_plan;field:heavyFieldPolicy
|
||||
summary: 'How large field values are included or omitted in this backup.'
|
||||
content: |
|
||||
Choose how large field values should be represented in the backup. The default is {{default:backups_plan.heavyFieldPolicy}} and the configured choices are {{values:backups_heavy_policy}}. Including values inline, placing them in separate objects and excluding them have different recovery implications. Review the resulting artifacts and verify a backup before relying on it to recover records with large content.
|
||||
- key: table:backups_plan;field:fileObjectPolicy
|
||||
summary: 'Whether referenced files are copied or only referenced in the backup.'
|
||||
content: |
|
||||
File handling is separate from exporting database records. Copying referenced files keeps their content with the backup; reference-only handling does not make an independent copy. The missing-file policy determines whether an unavailable source file produces a warning or fails the run. Check copied and missing file counts on **Backup Run**, and verify media recovery as well as record recovery.
|
||||
- key: table:backups_plan;field:fileMissingPolicy
|
||||
summary: 'Whether a missing referenced file warns or fails the backup.'
|
||||
content: |
|
||||
File handling is separate from exporting database records. Copying referenced files keeps their content with the backup; reference-only handling does not make an independent copy. The missing-file policy determines whether an unavailable source file produces a warning or fails the run. Check copied and missing file counts on **Backup Run**, and verify media recovery as well as record recovery.
|
||||
- key: table:backups_plan;field:consistency
|
||||
summary: 'The requested consistency of reads across the backed-up data.'
|
||||
content: |
|
||||
The default is {{default:backups_plan.consistency}} and the available requests are {{values:backups_consistency}}. A shared snapshot helps keep related records consistent while the application changes. Support depends on the source backend. After a run, inspect **{{label:backups_run.consistencyAchieved}}** rather than assuming the requested level was achieved. Review warnings before relying on the backup for recovery.
|
||||
- key: table:backups_plan;field:useReadReplica
|
||||
summary: 'Read replica requests are unavailable and are rejected when you save the plan.'
|
||||
- key: table:backups_plan;field:incrementalMode
|
||||
summary: 'Whether the plan requests a full backup or changed records only.'
|
||||
content: |
|
||||
The default is {{default:backups_plan.incrementalMode}}. This version supports full backups; requesting only records changed since the previous backup is rejected by validation. Keep the plan on the supported full mode and use scope rules when you need to limit selected data. Review **Backup Run** and its artifacts to confirm the resulting coverage.
|
||||
- key: table:backups_plan;field:nativeDump
|
||||
summary: 'The native database dump mode requested in addition to logical data.'
|
||||
content: |
|
||||
The default is {{default:backups_plan.nativeDump}}. Native database dumps are not implemented in this version, so selecting a native dump mode is rejected. Use the supported logical backup formats to save table records and referenced files. Those backups have their own manifests, verification and restore workflow; do not assume they contain a database-engine dump.
|
||||
- key: table:backups_plan;field:cronTimeZoneId
|
||||
summary: 'The time zone used to interpret this plan’s schedule.'
|
||||
- key: table:backups_plan;field:scheduledJobId
|
||||
summary: 'An unused legacy scheduler reference; you do not need to set it.'
|
||||
- key: table:backups_plan;field:slaHours
|
||||
summary: 'The maximum expected age of a successful backup, in hours.'
|
||||
content: |
|
||||
Monitoring compares successful backup and verification history with the plan’s expected ages. Use the health result to decide which **Backup Run** or **Verification** to inspect. A recent run can still need verification, and a failed run can leave an older successful backup in place. Adjust expectations to your recovery needs rather than treating a changed threshold as a repair.
|
||||
- key: table:backups_plan;field:verifySlaDays
|
||||
summary: 'The maximum expected age of a verified backup, in days.'
|
||||
content: |
|
||||
Monitoring compares successful backup and verification history with the plan’s expected ages. Use the health result to decide which **Backup Run** or **Verification** to inspect. A recent run can still need verification, and a failed run can leave an older successful backup in place. Adjust expectations to your recovery needs rather than treating a changed threshold as a repair.
|
||||
- key: table:backups_plan;field:verifyFileSamplePercent
|
||||
summary: 'The percentage of referenced files selected for verification.'
|
||||
content: |
|
||||
Verification has its own depth, file selection and schedule. Checking stored objects is different from comparing decoded content or testing a restore. Review **Verification** to see which level actually ran and what it checked. Scheduling depends on the host integration; a saved expression alone is not evidence of a completed check. Use **Verify backup** for a direct content check.
|
||||
- key: table:backups_plan;field:verifyCronExpression
|
||||
summary: 'The schedule expression for automatic verification.'
|
||||
content: |
|
||||
Verification has its own depth, file selection and schedule. Checking stored objects is different from comparing decoded content or testing a restore. Review **Verification** to see which level actually ran and what it checked. Scheduling depends on the host integration; a saved expression alone is not evidence of a completed check. Use **Verify backup** for a direct content check.
|
||||
- key: table:backups_plan;field:verifyLevel
|
||||
summary: 'The depth of checking requested for automatic verification.'
|
||||
content: |
|
||||
Verification has its own depth, file selection and schedule. Checking stored objects is different from comparing decoded content or testing a restore. Review **Verification** to see which level actually ran and what it checked. Scheduling depends on the host integration; a saved expression alone is not evidence of a completed check. Use **Verify backup** for a direct content check.
|
||||
- key: table:backups_plan;field:retainDaily
|
||||
summary: 'The number of daily backup periods retained by pruning.'
|
||||
content: |
|
||||
Retention selects backups to keep across daily, weekly, monthly and yearly periods, with a minimum number protected from pruning. Changing these settings does not itself prove which objects will be deleted. Use **Prune per retention** and review its proposed deletions before continuing. Storage locks can prevent deletion even when a backup falls outside the requested retention periods.
|
||||
- key: table:backups_plan;field:retainWeekly
|
||||
summary: 'The number of weekly backup periods retained by pruning.'
|
||||
content: |
|
||||
Retention selects backups to keep across daily, weekly, monthly and yearly periods, with a minimum number protected from pruning. Changing these settings does not itself prove which objects will be deleted. Use **Prune per retention** and review its proposed deletions before continuing. Storage locks can prevent deletion even when a backup falls outside the requested retention periods.
|
||||
- key: table:backups_plan;field:retainMonthly
|
||||
summary: 'The number of monthly backup periods retained by pruning.'
|
||||
content: |
|
||||
Retention selects backups to keep across daily, weekly, monthly and yearly periods, with a minimum number protected from pruning. Changing these settings does not itself prove which objects will be deleted. Use **Prune per retention** and review its proposed deletions before continuing. Storage locks can prevent deletion even when a backup falls outside the requested retention periods.
|
||||
- key: table:backups_plan;field:retainYearly
|
||||
summary: 'The number of yearly backup periods retained by pruning.'
|
||||
content: |
|
||||
Retention selects backups to keep across daily, weekly, monthly and yearly periods, with a minimum number protected from pruning. Changing these settings does not itself prove which objects will be deleted. Use **Prune per retention** and review its proposed deletions before continuing. Storage locks can prevent deletion even when a backup falls outside the requested retention periods.
|
||||
- key: table:backups_plan;field:retainMinimum
|
||||
summary: 'The minimum number of successful backups protected from pruning.'
|
||||
content: |
|
||||
Retention selects backups to keep across daily, weekly, monthly and yearly periods, with a minimum number protected from pruning. Changing these settings does not itself prove which objects will be deleted. Use **Prune per retention** and review its proposed deletions before continuing. Storage locks can prevent deletion even when a backup falls outside the requested retention periods.
|
||||
- key: table:backups_plan;field:objectLockMode
|
||||
summary: 'The storage retention-lock mode requested for backup objects.'
|
||||
content: |
|
||||
Object locking asks the storage service to protect backup objects from deletion for the specified period. The selected destination must support locking, and a non-disabled mode requires a duration. These protections can prevent pruning from removing objects. Use **Test destination** to inspect capability information and review the retention requirements before choosing a locked destination.
|
||||
- key: table:backups_plan;field:objectLockDays
|
||||
summary: 'How many days backup objects should remain locked in storage.'
|
||||
content: |
|
||||
Object locking asks the storage service to protect backup objects from deletion for the specified period. The selected destination must support locking, and a non-disabled mode requires a duration. These protections can prevent pruning from removing objects. Use **Test destination** to inspect capability information and review the retention requirements before choosing a locked destination.
|
||||
- key: table:backups_plan;field:maxDurationMinutes
|
||||
summary: 'The configured backup duration limit in minutes.'
|
||||
- key: table:backups_plan;field:pageSize
|
||||
summary: 'The number of records requested per backup read page.'
|
||||
- key: table:backups_plan;field:targetPartBytes
|
||||
summary: 'The target size in bytes for each exported data part.'
|
||||
- key: table:backups_plan;field:ownerUserId
|
||||
summary: 'The user whose access is checked for this plan’s scheduled backups.'
|
||||
content: |
|
||||
Scheduled backups check that the plan owner can read the selected tables. An active plan can run with broad data access, so saving an active plan requires backup administrator permission. Keep the owner and scope aligned as access changes. If scheduling reports an invalid owner, correct the owner or permissions and inspect **Backup Run** for subsequent results.
|
||||
- key: table:backups_plan;field:lastRunId
|
||||
summary: 'The latest backup run associated with this plan.'
|
||||
- key: table:backups_plan;field:lastSuccessRunId
|
||||
summary: 'The latest successful backup run for this plan.'
|
||||
- key: table:backups_plan;field:lastSuccessDate
|
||||
summary: 'When this plan last completed a successful backup.'
|
||||
- key: table:backups_plan;field:lastVerifiedRunId
|
||||
summary: 'The most recently verified backup run for this plan.'
|
||||
- key: table:backups_plan;field:lastVerifiedDate
|
||||
summary: 'When a backup for this plan was last verified.'
|
||||
- key: table:backups_plan;field:slaStatus
|
||||
summary: 'The current assessment of this plan’s backup health and timeliness.'
|
||||
content: |
|
||||
Monitoring compares successful backup and verification history with the plan’s expected ages. Use the health result to decide which **Backup Run** or **Verification** to inspect. A recent run can still need verification, and a failed run can leave an older successful backup in place. Adjust expectations to your recovery needs rather than treating a changed threshold as a repair.
|
||||
- key: table:backups_plan_scope;field:backupPlanId
|
||||
summary: 'The backup plan this scope rule belongs to.'
|
||||
- key: table:backups_plan_scope;field:action
|
||||
summary: 'Whether matching data is included in or excluded from the plan.'
|
||||
- key: table:backups_plan_scope;field:selectorType
|
||||
summary: 'Whether the selector identifies a table, application, component or table role.'
|
||||
content: |
|
||||
Use these settings to describe the data matched by this scope rule. The selector identifies a target; a structured filter can narrow its records, child inclusion can expand related records, and exclusions omit fields. Review the resolved result through **Run backup now** before continuing. A successful backup cannot recover records or fields that its scope left out.
|
||||
- key: table:backups_plan_scope;field:selectorValue
|
||||
summary: 'The name matched by the chosen selector type.'
|
||||
content: |
|
||||
Use these settings to describe the data matched by this scope rule. The selector identifies a target; a structured filter can narrow its records, child inclusion can expand related records, and exclusions omit fields. Review the resolved result through **Run backup now** before continuing. A successful backup cannot recover records or fields that its scope left out.
|
||||
- key: table:backups_plan_scope;field:filterJson
|
||||
summary: 'A structured record filter that narrows the selected data.'
|
||||
content: |
|
||||
Use these settings to describe the data matched by this scope rule. The selector identifies a target; a structured filter can narrow its records, child inclusion can expand related records, and exclusions omit fields. Review the resolved result through **Run backup now** before continuing. A successful backup cannot recover records or fields that its scope left out.
|
||||
- key: table:backups_plan_scope;field:includeAssociatedChildren
|
||||
summary: 'Whether the selection also follows associated child records.'
|
||||
content: |
|
||||
Use these settings to describe the data matched by this scope rule. The selector identifies a target; a structured filter can narrow its records, child inclusion can expand related records, and exclusions omit fields. Review the resolved result through **Run backup now** before continuing. A successful backup cannot recover records or fields that its scope left out.
|
||||
- key: table:backups_plan_scope;field:fieldExclusions
|
||||
summary: 'The fields to omit from data selected by this scope rule.'
|
||||
content: |
|
||||
Use these settings to describe the data matched by this scope rule. The selector identifies a target; a structured filter can narrow its records, child inclusion can expand related records, and exclusions omit fields. Review the resolved result through **Run backup now** before continuing. A successful backup cannot recover records or fields that its scope left out.
|
||||
- key: table:backups_run;field:backupPlanId
|
||||
summary: 'The plan that produced this backup.'
|
||||
- key: table:backups_run;field:runKey
|
||||
summary: 'The unique storage reference for this backup execution.'
|
||||
- key: table:backups_run;field:trigger
|
||||
summary: 'What caused this backup to run.'
|
||||
- key: table:backups_run;field:requestedByUserId
|
||||
summary: 'The user who requested this operation.'
|
||||
- key: table:backups_run;field:status
|
||||
summary: 'The recorded progress or final state of this operation.'
|
||||
content: |
|
||||
The run states are {{values:backups_run_status}}. Check the state alongside completed table counts, warnings and errors. A run that succeeded with warnings needs review, especially if referenced files were missing. Completion and verification are separate: inspect **Verification** before relying on the saved content. A cancellation request can take time to reach a final state.
|
||||
- key: table:backups_run;field:startDate
|
||||
summary: 'When this operation started.'
|
||||
- key: table:backups_run;field:endDate
|
||||
summary: 'When this operation ended.'
|
||||
- key: table:backups_run;field:durationMs
|
||||
summary: 'The elapsed backup time in milliseconds.'
|
||||
- key: table:backups_run;field:heartbeatDate
|
||||
summary: 'When the running backup last reported activity.'
|
||||
- key: table:backups_run;field:tablesPlanned
|
||||
summary: 'The number of tables selected for this backup.'
|
||||
- key: table:backups_run;field:tablesDone
|
||||
summary: 'The number of tables completed by this backup.'
|
||||
- key: table:backups_run;field:rowsTotal
|
||||
summary: 'The total number of records exported.'
|
||||
- key: table:backups_run;field:bytesRaw
|
||||
summary: 'The data size in bytes before storage encoding.'
|
||||
- key: table:backups_run;field:bytesStored
|
||||
summary: 'The data size in bytes as stored.'
|
||||
- key: table:backups_run;field:filesReferenced
|
||||
summary: 'The number of referenced files encountered during the backup.'
|
||||
- key: table:backups_run;field:filesCopied
|
||||
summary: 'The number of files copied into backup storage.'
|
||||
- key: table:backups_run;field:filesDeduplicated
|
||||
summary: 'The number of files whose existing backup copies were reused.'
|
||||
- key: table:backups_run;field:filesMissing
|
||||
summary: 'The number of referenced files that could not be found.'
|
||||
- key: table:backups_run;field:fileBytesCopied
|
||||
summary: 'The total bytes of file content copied into backup storage.'
|
||||
- key: table:backups_run;field:fileBytesReferenced
|
||||
summary: 'The total bytes of file content referenced by this backup.'
|
||||
- key: table:backups_run;field:consistencyAchieved
|
||||
summary: 'The read consistency actually achieved by this run.'
|
||||
content: |
|
||||
These values describe the saved backup rather than the current plan. The manifest records content and integrity information, while consistency and security scope describe how records were read. Use them when comparing backup coverage or investigating **Verification** failures. A user-selected export can contain fewer records than a broader scheduled plan because the user’s record access still applies.
|
||||
- key: table:backups_run;field:snapshotId
|
||||
summary: 'The database snapshot identifier recorded for this backup.'
|
||||
- key: table:backups_run;field:storageBucketAlias
|
||||
summary: 'The configured storage alias holding this backup content.'
|
||||
- key: table:backups_run;field:runPrefix
|
||||
summary: 'The storage path prefix for this run’s objects.'
|
||||
- key: table:backups_run;field:manifestKey
|
||||
summary: 'The storage path of the manifest describing this backup.'
|
||||
content: |
|
||||
These values describe the saved backup rather than the current plan. The manifest records content and integrity information, while consistency and security scope describe how records were read. Use them when comparing backup coverage or investigating **Verification** failures. A user-selected export can contain fewer records than a broader scheduled plan because the user’s record access still applies.
|
||||
- key: table:backups_run;field:manifestSha256
|
||||
summary: 'The checksum used to identify and check the backup manifest.'
|
||||
content: |
|
||||
These values describe the saved backup rather than the current plan. The manifest records content and integrity information, while consistency and security scope describe how records were read. Use them when comparing backup coverage or investigating **Verification** failures. A user-selected export can contain fewer records than a broader scheduled plan because the user’s record access still applies.
|
||||
- key: table:backups_run;field:qqqVersion
|
||||
summary: 'The framework version recorded when this backup was made.'
|
||||
- key: table:backups_run;field:appVersion
|
||||
summary: 'The application version recorded when this backup was made.'
|
||||
- key: table:backups_run;field:instanceName
|
||||
summary: 'The application instance from which the backup was made.'
|
||||
- key: table:backups_run;field:schemaFingerprint
|
||||
summary: 'A fingerprint of the data structure recorded in the backup.'
|
||||
content: |
|
||||
These values describe the saved backup rather than the current plan. The manifest records content and integrity information, while consistency and security scope describe how records were read. Use them when comparing backup coverage or investigating **Verification** failures. A user-selected export can contain fewer records than a broader scheduled plan because the user’s record access still applies.
|
||||
- key: table:backups_run;field:securityScope
|
||||
summary: 'Whether the backup reflects user-limited access or a broader plan execution.'
|
||||
content: |
|
||||
These values describe the saved backup rather than the current plan. The manifest records content and integrity information, while consistency and security scope describe how records were read. Use them when comparing backup coverage or investigating **Verification** failures. A user-selected export can contain fewer records than a broader scheduled plan because the user’s record access still applies.
|
||||
- key: table:backups_run;field:encryptionKeyRef
|
||||
summary: 'The key reference recorded for the backup, without the key value.'
|
||||
- key: table:backups_run;field:encryptionKeyFingerprint
|
||||
summary: 'A fingerprint identifying the encryption key without exposing it.'
|
||||
- key: table:backups_run;field:retentionClass
|
||||
summary: 'The retention period category assigned to this backup.'
|
||||
- key: table:backups_run;field:lockedUntil
|
||||
summary: 'The time until which storage retention protects this content from deletion.'
|
||||
- key: table:backups_run;field:prunedDate
|
||||
summary: 'When this backup was pruned from storage.'
|
||||
- key: table:backups_run;field:prunedByUserId
|
||||
summary: 'The user recorded as pruning this backup.'
|
||||
- key: table:backups_run;field:verificationStatus
|
||||
summary: 'The latest recorded level and outcome of verification for this backup.'
|
||||
content: |
|
||||
The recorded states are {{values:backups_verification_status}}. Storage checks, content checks and test restores provide different evidence; a storage-only result does not establish that every record can be restored. Open the related **Verification** for its level, counts and diagnostics. This status describes checking after backup creation and is separate from the backup run’s own completion status.
|
||||
- key: table:backups_run;field:warningCount
|
||||
summary: 'The number of warnings recorded during the backup.'
|
||||
- key: table:backups_run;field:errorMessage
|
||||
summary: 'The error message recorded for this operation.'
|
||||
- key: table:backups_run;field:errorDetail
|
||||
summary: 'Additional error information recorded for the failed backup.'
|
||||
- key: table:backups_artifact;field:backupRunId
|
||||
summary: 'The backup run this record belongs to.'
|
||||
- key: table:backups_artifact;field:artifactType
|
||||
summary: 'The kind of backup content stored in this artifact.'
|
||||
- key: table:backups_artifact;field:tableName
|
||||
summary: 'The source table described by this record.'
|
||||
- key: table:backups_artifact;field:partNumber
|
||||
summary: 'The sequence number of this part within the exported table data.'
|
||||
- key: table:backups_artifact;field:objectKey
|
||||
summary: 'The path identifying the object within its storage bucket.'
|
||||
- key: table:backups_artifact;field:format
|
||||
summary: 'The format recorded for this artifact’s content.'
|
||||
- key: table:backups_artifact;field:compression
|
||||
summary: 'The compression recorded for this artifact’s content.'
|
||||
- key: table:backups_artifact;field:encrypted
|
||||
summary: 'Whether this stored object is recorded as encrypted.'
|
||||
- key: table:backups_artifact;field:rowCount
|
||||
summary: 'The number of records written to this artifact.'
|
||||
- key: table:backups_artifact;field:bytesRaw
|
||||
summary: 'The data size in bytes before storage encoding.'
|
||||
- key: table:backups_artifact;field:bytesStored
|
||||
summary: 'The data size in bytes as stored.'
|
||||
- key: table:backups_artifact;field:sha256Stored
|
||||
summary: 'The checksum of the bytes as stored in the bucket.'
|
||||
- key: table:backups_artifact;field:sha256Raw
|
||||
summary: 'The checksum of the content before storage encoding.'
|
||||
- key: table:backups_artifact;field:rowsHash
|
||||
summary: 'The checksum representing the exported records.'
|
||||
- key: table:backups_artifact;field:firstKey
|
||||
summary: 'The first record key recorded for this data part.'
|
||||
- key: table:backups_artifact;field:lastKey
|
||||
summary: 'The last record key recorded for this data part.'
|
||||
- key: table:backups_artifact;field:tableFingerprint
|
||||
summary: 'A fingerprint of the table structure used by this artifact.'
|
||||
- key: table:backups_artifact;field:status
|
||||
summary: 'The recorded progress or final state of this operation.'
|
||||
- key: table:backups_artifact;field:storageVersionId
|
||||
summary: 'The storage service’s version identifier for the saved object.'
|
||||
- key: table:backups_verification;field:backupRunId
|
||||
summary: 'The backup run this record belongs to.'
|
||||
- key: table:backups_verification;field:level
|
||||
summary: 'The depth of verification performed on this backup.'
|
||||
content: |
|
||||
The levels are {{values:backups_verify_level}}. Storage verification checks saved objects, content verification reads and compares backup data, and a test restore exercises recovery in a temporary destination. Review the result and detail to see what actually passed. A deeper check can provide more recovery evidence, but the level alone does not mean the check succeeded.
|
||||
- key: table:backups_verification;field:trigger
|
||||
summary: 'What caused this verification to run.'
|
||||
- key: table:backups_verification;field:requestedByUserId
|
||||
summary: 'The user who requested this operation.'
|
||||
- key: table:backups_verification;field:startDate
|
||||
summary: 'When this operation started.'
|
||||
- key: table:backups_verification;field:endDate
|
||||
summary: 'When this operation ended.'
|
||||
- key: table:backups_verification;field:status
|
||||
summary: 'The recorded progress or final state of this operation.'
|
||||
content: |
|
||||
The possible results are {{values:backups_verify_result}}. Review the level, mismatch count and details together with the result. A check can pass with warnings or fail before completing all planned work. Use the linked **Backup Run** and its artifacts to investigate; changing a plan’s settings does not repair the stored content of an earlier backup.
|
||||
- key: table:backups_verification;field:tablesChecked
|
||||
summary: 'The number of tables examined during verification.'
|
||||
- key: table:backups_verification;field:rowsChecked
|
||||
summary: 'The number of records examined during verification.'
|
||||
- key: table:backups_verification;field:mismatches
|
||||
summary: 'The number of discrepancies found during verification.'
|
||||
- key: table:backups_verification;field:scratchTarget
|
||||
summary: 'The temporary destination recorded for a test restore.'
|
||||
- key: table:backups_verification;field:detailJson
|
||||
summary: 'The structured results and diagnostics from verification.'
|
||||
- key: table:backups_restore_run;field:sourceBackupRunId
|
||||
summary: 'The backup run used as the source for recovery.'
|
||||
- key: table:backups_restore_run;field:requestedByUserId
|
||||
summary: 'The user who requested this operation.'
|
||||
- key: table:backups_restore_run;field:startDate
|
||||
summary: 'When this operation started.'
|
||||
- key: table:backups_restore_run;field:endDate
|
||||
summary: 'When this operation ended.'
|
||||
- key: table:backups_restore_run;field:status
|
||||
summary: 'The recorded progress or final state of this operation.'
|
||||
- key: table:backups_restore_run;field:mode
|
||||
summary: 'How the restore handles new and existing destination records.'
|
||||
content: |
|
||||
Restore mode controls how destination records are handled, while matching and identifier strategy control how saved records connect to them. Review these choices before execution: replacement can delete existing records and requires explicit confirmation. This row records the choices made for that run. Inspect the table results and any pre-restore backup before responding to an unexpected recovery outcome.
|
||||
- key: table:backups_restore_run;field:idStrategy
|
||||
summary: 'Whether original record identifiers are preserved or replaced during recovery.'
|
||||
content: |
|
||||
Restore mode controls how destination records are handled, while matching and identifier strategy control how saved records connect to them. Review these choices before execution: replacement can delete existing records and requires explicit confirmation. This row records the choices made for that run. Inspect the table results and any pre-restore backup before responding to an unexpected recovery outcome.
|
||||
- key: table:backups_restore_run;field:matchOn
|
||||
summary: 'The key used to match saved records to destination records.'
|
||||
content: |
|
||||
Restore mode controls how destination records are handled, while matching and identifier strategy control how saved records connect to them. Review these choices before execution: replacement can delete existing records and requires explicit confirmation. This row records the choices made for that run. Inspect the table results and any pre-restore backup before responding to an unexpected recovery outcome.
|
||||
- key: table:backups_restore_run;field:targetInstanceName
|
||||
summary: 'The application instance receiving the restored data.'
|
||||
- key: table:backups_restore_run;field:scopeSummary
|
||||
summary: 'A description of the tables and records selected for recovery.'
|
||||
- key: table:backups_restore_run;field:preRestoreBackupRunId
|
||||
summary: 'The backup taken of the destination before this restore.'
|
||||
- key: table:backups_restore_run;field:rowsInserted
|
||||
summary: 'The number of records inserted by the restore.'
|
||||
- key: table:backups_restore_run;field:rowsUpdated
|
||||
summary: 'The number of existing records updated by the restore.'
|
||||
- key: table:backups_restore_run;field:rowsUnchanged
|
||||
summary: 'The number of matched records that needed no changes.'
|
||||
- key: table:backups_restore_run;field:rowsDeleted
|
||||
summary: 'The number of records deleted by the restore.'
|
||||
- key: table:backups_restore_run;field:rowsSkipped
|
||||
summary: 'The number of records skipped by the restore.'
|
||||
- key: table:backups_restore_run;field:rowsErrored
|
||||
summary: 'The number of records that encountered restore errors.'
|
||||
- key: table:backups_restore_run;field:filesRestored
|
||||
summary: 'The number of referenced files restored from backup storage.'
|
||||
- key: table:backups_restore_run;field:filesAlreadyPresent
|
||||
summary: 'The number of files already present at the destination.'
|
||||
- key: table:backups_restore_run;field:filesConflicting
|
||||
summary: 'The number of files that conflict with existing destination content.'
|
||||
- key: table:backups_restore_run;field:filesMissingFromBackup
|
||||
summary: 'The number of required files absent from the backup.'
|
||||
- key: table:backups_restore_run;field:fileBytesRestored
|
||||
summary: 'The total bytes of file content restored.'
|
||||
- key: table:backups_restore_run;field:sequencesResynced
|
||||
summary: 'The number of automatic record-number sequences adjusted after recovery.'
|
||||
- key: table:backups_restore_run;field:automationsSuppressed
|
||||
summary: 'Whether record automations were suppressed during this restore.'
|
||||
content: |
|
||||
These recorded flags explain whether restoring records was allowed to trigger automations or publish change events. Restore options normally suppress automations and do not publish events, but the selected options determine the actual run. Use these values when investigating missing or unexpected downstream activity, and inspect the **Restore Run** results before manually repeating any follow-up work.
|
||||
- key: table:backups_restore_run;field:esbSuppressed
|
||||
summary: 'Whether change-event publication was suppressed during this restore.'
|
||||
content: |
|
||||
These recorded flags explain whether restoring records was allowed to trigger automations or publish change events. Restore options normally suppress automations and do not publish events, but the selected options determine the actual run. Use these values when investigating missing or unexpected downstream activity, and inspect the **Restore Run** results before manually repeating any follow-up work.
|
||||
- key: table:backups_restore_run;field:confirmationText
|
||||
summary: 'The confirmation entered for a replacement restore.'
|
||||
content: |
|
||||
Restore mode controls how destination records are handled, while matching and identifier strategy control how saved records connect to them. Review these choices before execution: replacement can delete existing records and requires explicit confirmation. This row records the choices made for that run. Inspect the table results and any pre-restore backup before responding to an unexpected recovery outcome.
|
||||
- key: table:backups_restore_run;field:errorMessage
|
||||
summary: 'The error message recorded for this operation.'
|
||||
- key: table:backups_restore_run_table;field:backupRestoreRunId
|
||||
summary: 'The restore run this table result belongs to.'
|
||||
- key: table:backups_restore_run_table;field:tableName
|
||||
summary: 'The source table described by this record.'
|
||||
- key: table:backups_restore_run_table;field:targetTableName
|
||||
summary: 'The destination table that received the restored records.'
|
||||
- key: table:backups_restore_run_table;field:rowsInserted
|
||||
summary: 'The number of records inserted by the restore.'
|
||||
- key: table:backups_restore_run_table;field:rowsUpdated
|
||||
summary: 'The number of existing records updated by the restore.'
|
||||
- key: table:backups_restore_run_table;field:rowsUnchanged
|
||||
summary: 'The number of matched records that needed no changes.'
|
||||
- key: table:backups_restore_run_table;field:rowsDeleted
|
||||
summary: 'The number of records deleted by the restore.'
|
||||
- key: table:backups_restore_run_table;field:rowsSkipped
|
||||
summary: 'The number of records skipped by the restore.'
|
||||
- key: table:backups_restore_run_table;field:rowsErrored
|
||||
summary: 'The number of records that encountered restore errors.'
|
||||
- key: table:backups_restore_run_table;field:schemaStatus
|
||||
summary: 'How the saved table structure compares with the destination structure.'
|
||||
content: |
|
||||
The comparison states are {{values:backups_schema_status}}. Differences can arise when the application’s fields change after a backup was taken. Review **{{label:backups_restore_run_table.unmappedFields}}** and the restore’s record counts before assuming all saved data was recovered. The recorded comparison concerns this table; inspect the parent **Restore Run** for the overall outcome and any errors.
|
||||
- key: table:backups_restore_run_table;field:unmappedFields
|
||||
summary: 'The saved fields that could not be mapped to destination fields.'
|
||||
- key: table:backups_restore_run_table;field:sequenceResynced
|
||||
summary: 'Whether automatic record numbering was adjusted for this table.'
|
||||
- key: table:backups_restore_run_table;field:status
|
||||
summary: 'The recorded progress or final state of this operation.'
|
||||
- key: table:backups_file_object;field:sha256
|
||||
summary: 'The checksum identifying this saved file’s content.'
|
||||
- key: table:backups_file_object;field:sizeBytes
|
||||
summary: 'The file’s recorded size in bytes.'
|
||||
- key: table:backups_file_object;field:contentType
|
||||
summary: 'The media type recorded for this file.'
|
||||
- key: table:backups_file_object;field:storageBucketAlias
|
||||
summary: 'The configured storage alias holding this backup content.'
|
||||
- key: table:backups_file_object;field:objectKey
|
||||
summary: 'The path identifying the object within its storage bucket.'
|
||||
- key: table:backups_file_object;field:encrypted
|
||||
summary: 'Whether this stored object is recorded as encrypted.'
|
||||
- key: table:backups_file_object;field:encryptionKeyFingerprint
|
||||
summary: 'A fingerprint identifying the encryption key without exposing it.'
|
||||
- key: table:backups_file_object;field:firstRunId
|
||||
summary: 'The first backup run recorded as using this file.'
|
||||
- key: table:backups_file_object;field:lastReferencedRunId
|
||||
summary: 'The latest backup run recorded as using this file.'
|
||||
- key: table:backups_file_object;field:referenceCount
|
||||
summary: 'The number of backup references recorded for this file.'
|
||||
- key: table:backups_file_object;field:lastVerifiedDate
|
||||
summary: 'When this file was last verified.'
|
||||
- key: table:backups_file_object;field:lastVerifyStatus
|
||||
summary: 'The latest recorded verification outcome for this file.'
|
||||
- key: table:backups_file_object;field:lockedUntil
|
||||
summary: 'The time until which storage retention protects this content from deletion.'
|
||||
- key: table:backups_file_object;field:deletedDate
|
||||
summary: 'When this file’s deletion was recorded.'
|
||||
- key: table:backups_id_map;field:restoreRunId
|
||||
summary: 'The restore run that assigned the new identifier.'
|
||||
- key: table:backups_id_map;field:tableName
|
||||
summary: 'The table containing the remapped record.'
|
||||
- key: table:backups_id_map;field:oldId
|
||||
summary: 'The record identifier in the source backup.'
|
||||
- key: table:backups_id_map;field:newId
|
||||
summary: 'The record identifier assigned at the destination.'
|
||||
- key: process:backupRunPlan;slot:help
|
||||
summary: 'Preview a backup plan’s scope and run it when you continue.'
|
||||
content: |
|
||||
Use **Run backup now** on a **Backup Plan** when you need a fresh copy before a change or want to check the plan. Review the resolved scope and any warnings first; nothing is written until you continue. The run writes its output to the configured destination and records progress in **Backup Run**. Inspect its result and verify the saved content before relying on it for recovery.
|
||||
- key: process:backupVerify;slot:help
|
||||
summary: 'Check a backup’s stored content against its manifest.'
|
||||
content: |
|
||||
Use **Verify backup** on a **Backup Run** to check that its objects are present and their contents match the recorded backup. The content check reads stored bytes, decodes them and compares counts and checksums with the manifest. It records a **Verification** result without changing the backup. Review mismatches and warnings before using that run as a recovery source.
|
||||
- key: process:backupCheckSla;slot:help
|
||||
summary: 'Check whether backup plans meet their freshness and verification expectations.'
|
||||
content: |
|
||||
This monitoring action checks backup health against each plan’s expected backup and verification ages. Use **Backup Plan** and the Backups home page to inspect its assessment, then open the latest **Backup Run** or **Verification** for the underlying evidence. A health problem may require a new run, a storage repair or investigation of a failed check rather than simply changing the monitoring threshold.
|
||||
- key: process:backupRestore;slot:help
|
||||
summary: 'Preview and restore data from a saved backup run.'
|
||||
content: |
|
||||
Use **Restore...** on a **Backup Run** to recover its data. Choose the tables, matching rule, record handling and file options, then review the proposed changes. Nothing is written until you continue. Replacement mode can delete destination records and requires confirmation. Review the option to back up the target first. After execution, inspect **Restore Run** and its table results for skipped records, warnings or failures.
|
||||
- key: process:backupRestoreTable;slot:help
|
||||
summary: 'Preview and restore table data from a selected backup artifact.'
|
||||
content: |
|
||||
Use the table restore action when you need data from a particular backup artifact rather than the entire run. Check the source table, destination, matching rule and restore mode in the options and review screen. Nothing is written until you continue. Replacement can remove destination data. Inspect **Restore Run** and its table results afterward to confirm which records and files were recovered.
|
||||
- key: process:backupImportCatalog;slot:help
|
||||
summary: 'Find saved backups in storage and add their metadata to the local catalog.'
|
||||
content: |
|
||||
Use **Import backups from storage** when backup objects exist in storage but their run records are absent from this application. Enter the backup bucket alias and folder prefix, then review the discovered backups before continuing. This imports catalog information; it does not restore application records. Once imported, inspect **Backup Run**, verify the saved content and choose a restore action when needed.
|
||||
- key: process:backupDownload;slot:help
|
||||
summary: 'Create download links for the stored artifacts of selected backup runs.'
|
||||
content: |
|
||||
Use **Download** when you need the files belonging to a backup run outside the application. Review the files offered before continuing to obtain their links. The action does not restore records or change the saved backup. Treat the downloaded content according to the data it contains, and use **Artifacts** or **Verification** when you need to inspect completeness and integrity.
|
||||
- key: process:backupDownloadArtifact;slot:help
|
||||
summary: 'Create a download link for a selected backup artifact.'
|
||||
content: |
|
||||
Use the artifact download action when you need a particular stored part of a backup. Review the selected artifact and its related **Backup Run** so you know which table or supporting file you are retrieving. The action returns a download link and does not change application records. A single part may not contain everything needed for a complete restore.
|
||||
- key: process:backupRunScheduledPlan;slot:help
|
||||
summary: 'Execute a backup plan on behalf of its configured schedule.'
|
||||
content: |
|
||||
This background action runs a plan when invoked by scheduling. It checks the plan owner before starting the backup and records the resulting **Backup Run**. Use the plan’s latest run and error information to investigate a missed or failed execution. For an interactive preview and manual start, use **Run backup now** on the plan instead.
|
||||
- key: process:backupCancelRun;slot:help
|
||||
summary: 'Request cancellation of a backup run that is still in progress.'
|
||||
content: |
|
||||
Use **Cancel** on a running backup when it should stop. Review the selected runs before continuing. Cancellation is a request to the running process, so check **Backup Run** afterward for its final state. It does not turn partial output into a complete backup or undo a previous successful run. Use a successfully completed and verified backup for recovery.
|
||||
- key: process:backupCancelRestoreRun;slot:help
|
||||
summary: 'Request cancellation of a restore that is still in progress.'
|
||||
content: |
|
||||
Use **Cancel** on a restore when further recovery work should stop. Review the selection and then check **Restore Run** for its final status and table results. Cancellation does not imply that records already changed have been rolled back. Inspect the counts and any target backup before deciding how to recover from a partially completed restore.
|
||||
- key: process:backupResumeRun;slot:help
|
||||
summary: 'Preview where an interrupted backup can continue and resume eligible work.'
|
||||
content: |
|
||||
Use **Resume** on an interrupted backup when you want to continue eligible work from its recorded progress. Review the proposed continuation and any reasons the run cannot resume before proceeding. Nothing is written during the preview. After execution, inspect **Backup Run** and its artifacts, then verify the completed backup before treating it as a recovery source.
|
||||
- key: process:backupTestDestination;slot:help
|
||||
summary: 'Check whether a plan’s configured backup storage can be used.'
|
||||
content: |
|
||||
Use **Test destination** after configuring a **Backup Plan** destination. After review, the test writes a small probe object, reads it back, lists it and deletes it. For a plan using object locking, it only reads and writes no probe. Review each check and the capability report. This tests destination access, not the integrity of an existing backup; use **Verify backup** for that.
|
||||
- key: process:backupPrune;slot:help
|
||||
summary: 'Preview and delete backups that a plan’s retention rules no longer keep.'
|
||||
content: |
|
||||
Use **Prune per retention** on a **Backup Plan** to remove expired backup content. Review exactly what will be deleted before continuing; the preview deletes nothing. Retention periods, minimum retained backups and storage locks affect eligibility. Pruning removes recovery material, so review the retained backups and their verification results before proceeding. Inspect the run catalog afterward for the recorded pruning result.
|
||||
- key: process:backupPruneRuns;slot:help
|
||||
summary: 'Apply retention pruning to the plans associated with selected backup runs.'
|
||||
content: |
|
||||
Use **Prune per retention** on selected runs to evaluate the retention rules of their plans. The action is not simply a command to delete each selected row. Review the proposed deletions before continuing. Minimum retention and storage locks can protect backups from deletion. Pruning removes saved recovery content, so inspect the backups that will remain and their verification results first.
|
||||
- key: widget:backupsHome;slot:label
|
||||
summary: 'Backup plan health, recent runs and verification information.'
|
||||
- key: widget:backupsHealthCard;slot:label
|
||||
summary: 'A summary of backup health and plans needing attention.'
|
||||
- key: widget:backupsPlanScopeJoinPlan;slot:label
|
||||
summary: 'Inclusion and exclusion rules for this backup plan.'
|
||||
- key: widget:backupsRunJoinPlan;slot:label
|
||||
summary: 'Backup runs produced by this plan.'
|
||||
- key: widget:backupsArtifactJoinRun;slot:label
|
||||
summary: 'Stored artifacts belonging to this backup run.'
|
||||
- key: widget:backupsVerificationJoinRun;slot:label
|
||||
summary: 'Verification checks performed on this backup run.'
|
||||
- key: widget:backupsRestoreRunJoinRun;slot:label
|
||||
summary: 'Restore runs using this backup as their source.'
|
||||
- key: widget:backupsRestoreRunTableJoinRestoreRun;slot:label
|
||||
summary: 'Per-table results for this restore run.'
|
||||
|
||||
- key: app:backups;slot:help
|
||||
summary: "Back up, verify and restore the application's data to S3-compatible storage."
|
||||
content: |
|
||||
Use the **Setup** card on the home to review what a working backup needs (a plan, a storage bucket, a schedule, verification) with the next step for each. A new installation has no plan: run Create default plan on Backup plans, which asks only for the storage bucket alias, or add a plan with New. Everything else (runs, artifacts, verifications, restores) is written by the backups themselves.
|
||||
|
||||
- key: table:backups_plan;slot:help
|
||||
summary: "A backup plan: what is backed up, where it goes, when it runs, how long it is kept and how it is verified."
|
||||
content: |
|
||||
**How rows get created:** add a plan with **New**, or run **Create default plan**, which adds "Nightly full" (the whole instance, data and audit tables, JSONL and gzip, every night at 02:00 UTC, verified on Sundays) and asks only for the storage bucket alias. The alias names a bucket your application provides to Backups; no key or endpoint is ever stored here. With the scheduler integration installed, an active plan with a cron expression runs on schedule; Run backup now runs it by hand. A few settings (PARQUET, ZSTD, incremental, native dump, read replica) are not available yet and are refused when the plan is saved.
|
||||
|
||||
- key: table:backups_plan_scope;slot:help
|
||||
summary: "One include or exclude rule of a plan: an app, a qbit, a table or a table role."
|
||||
content: |
|
||||
**How rows get created:** add **Scope rules** on a plan record. A plan whose scope is Instance backs up everything allowed by its roles and needs no rules; a plan limited to apps, qbits or tables is made of these rules, and an Exclude rule always wins over an Include.
|
||||
|
||||
- key: table:backups_run;slot:help
|
||||
summary: "One execution of a plan, or an ad-hoc export: its status, size, rows, files and storage location."
|
||||
content: |
|
||||
**How rows get created:** only the backups write it. Run backup now, a plan's schedule, a pre-restore safety backup, Resume and Import backups from storage each add a row. Nothing is added by hand; Verify backup, Download, Restore and Prune are actions on a run.
|
||||
|
||||
- key: table:backups_artifact;slot:help
|
||||
summary: "One stored object of a run: a table part, its manifest, checksums or a file index, with its hash and size."
|
||||
content: |
|
||||
**How rows get created:** only the backups write it, one row per object a run stores. Open **Artifacts** on a run record to use **Restore this table** or **Download**.
|
||||
|
||||
- key: table:backups_verification;slot:help
|
||||
summary: "The result of checking a backup: that it is in storage, that it decodes, or that it restores into scratch."
|
||||
content: |
|
||||
**How rows get created:** only verification writes it. Run Verify backup on a run (storage, content or test restore), or give a plan a verify cron expression so it verifies its latest backup on a schedule. Inspect the newest result on the plan and the home.
|
||||
|
||||
- key: table:backups_restore_run;slot:help
|
||||
summary: "One restore of a backup into the application: mode, id strategy, row and file counts and its safety backup."
|
||||
content: |
|
||||
**How rows get created:** only Restore writes it. Run Restore on a backup run, or Restore this table on an artifact: Review previews what would change before anything is written. A restore that replaces data takes a safety backup first and links it here.
|
||||
|
||||
- key: table:backups_restore_run_table;slot:help
|
||||
summary: "Per-table results of a restore: rows inserted, updated and skipped and how the schema matched."
|
||||
content: |
|
||||
**How rows get created:** only Restore writes it, one row per table restored. Open **Tables restored** on a restore record to inspect these results.
|
||||
|
||||
- key: table:backups_file_object;slot:help
|
||||
summary: "The content-addressed store of copied files, shared by runs, with reference counts and verification."
|
||||
content: |
|
||||
**How rows get created:** only backups write it. When a plan copies the files its rows reference, each distinct file (by SHA-256) is stored once and every run that uses it adds a reference; Prune lowers the counts and removes a file when no run needs it.
|
||||
|
||||
- key: table:backups_id_map;slot:help
|
||||
summary: "Old id to new id pairs recorded while restoring with new ids, so related rows can be rewritten."
|
||||
content: |
|
||||
**How rows get created:** only Restore writes it, and only when a restore uses the Remap new ids strategy. The rows are working data for that restore and are cleared when it finishes, so you will usually find this table empty.
|
||||
|
||||
- key: table:backups_plan;field:storageBucketAlias
|
||||
summary: The configured storage alias where you want this plan to write backups.
|
||||
content: |
|
||||
Enter the alias of a bucket your application provides to Backups, never credentials or an endpoint. Use **Test destination** to check access before running the plan. You can select a separate **Prune bucket alias** for deletion access.
|
||||
- key: table:backups_plan;field:cronExpression
|
||||
summary: The schedule expression for running this plan automatically.
|
||||
content: |
|
||||
Set the schedule and **{{label:backups_plan.cronTimeZoneId}}** together. Your application must install the scheduler integration, and the plan must be active. Leave the expression blank for manual runs. Use **Run backup now** to preview and start a backup yourself.
|
||||
- key: process:backupsPlanCreateDefault;slot:help
|
||||
summary: Create a starter backup plan for a storage bucket you choose.
|
||||
content: |
|
||||
Use **Create default plan** on **Backup plans** and enter a **Storage bucket alias** provided by your application. Review the proposed plan before continuing. The action adds **Nightly full** for data and audit tables with JSONL, gzip and copied files, scheduled at 02:00 UTC with content verification on Sundays at 04:00 UTC.
|
||||
|
||||
You can edit the settings afterward. The action creates a plan only when its plan key does not already exist; it does not change an existing plan. Scheduled execution requires your application's scheduler integration.
|
||||
- key: process:backupVerifyPlan;slot:help
|
||||
summary: Verify the latest successful backup of a scheduled plan.
|
||||
content: |
|
||||
Use the plan's verification schedule and level to control automatic checks. This background action selects its latest successful backup and records the check in **Verification**. If no successful backup exists, there is nothing to verify. Inspect the plan and its latest verification result when investigating missed or failed checks.
|
||||
- key: process:*.backupExportTable;slot:help
|
||||
summary: Preview and back up selected table records using your current access.
|
||||
content: |
|
||||
Use **Back up these records** to export records from a table. Review the selected scope before continuing; the preview writes nothing. Your record access applies to this export. The destination comes from the supplied storage alias or an active plan. Inspect the resulting **Backup Run** and verify it before relying on it for recovery.
|
||||
|
||||
- key: possibleValueSource:backups_scope_type;slot:help
|
||||
summary: The available scope type choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_scope_type}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_table_role;slot:help
|
||||
summary: The available table role choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_table_role}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_scope_action;slot:help
|
||||
summary: The available scope action choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_scope_action}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_selector_type;slot:help
|
||||
summary: The available selector type choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_selector_type}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_format;slot:help
|
||||
summary: The available format choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_format}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_compression;slot:help
|
||||
summary: The available compression choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_compression}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_encryption_mode;slot:help
|
||||
summary: The available encryption mode choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_encryption_mode}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_sensitive_policy;slot:help
|
||||
summary: The available sensitive policy choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_sensitive_policy}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_heavy_policy;slot:help
|
||||
summary: The available heavy policy choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_heavy_policy}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_file_policy;slot:help
|
||||
summary: The available file policy choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_file_policy}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_file_missing_policy;slot:help
|
||||
summary: The available file missing policy choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_file_missing_policy}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_consistency;slot:help
|
||||
summary: The available consistency choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_consistency}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_consistency_achieved;slot:help
|
||||
summary: The available consistency achieved choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_consistency_achieved}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_incremental_mode;slot:help
|
||||
summary: The available incremental mode choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_incremental_mode}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_native_dump;slot:help
|
||||
summary: The available native dump choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_native_dump}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_lock_mode;slot:help
|
||||
summary: The available lock mode choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_lock_mode}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_sla_status;slot:help
|
||||
summary: The available sla status choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_sla_status}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_run_trigger;slot:help
|
||||
summary: The available run trigger choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_run_trigger}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_run_status;slot:help
|
||||
summary: The available run status choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_run_status}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_retention_class;slot:help
|
||||
summary: The available retention class choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_retention_class}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_verification_status;slot:help
|
||||
summary: The available verification status choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_verification_status}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_artifact_type;slot:help
|
||||
summary: The available artifact type choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_artifact_type}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_artifact_status;slot:help
|
||||
summary: The available artifact status choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_artifact_status}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_verify_level;slot:help
|
||||
summary: The available verify level choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_verify_level}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_verify_result;slot:help
|
||||
summary: The available verify result choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_verify_result}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_restore_status;slot:help
|
||||
summary: The available restore status choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_restore_status}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_restore_mode;slot:help
|
||||
summary: The available restore mode choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_restore_mode}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_id_strategy;slot:help
|
||||
summary: The available id strategy choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_id_strategy}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_schema_status;slot:help
|
||||
summary: The available schema status choices for backups.
|
||||
content: |
|
||||
You can see these choices: {{values:backups_schema_status}}. Check the related field help for supported settings and how the choice affects your backup or restore.
|
||||
|
||||
- key: possibleValueSource:backups_plan;slot:help
|
||||
summary: The backup plans available for linking related records.
|
||||
|
||||
- key: possibleValueSource:backups_run;slot:help
|
||||
summary: The backup runs available for linking related records.
|
||||
|
||||
- key: possibleValueSource:backups_restore_run;slot:help
|
||||
summary: The restore runs available for linking related records.
|
||||
|
||||
- key: process:BackupPlanSyncToScheduledJobProcess;slot:help
|
||||
summary: Keep scheduled backup jobs aligned with your plan settings.
|
||||
content: |
|
||||
Set the plan's schedule, time zone and active status to control its scheduled backup job. This background integration synchronizes those settings when your application configures a scheduler. It does not start a backup immediately. Use **Run backup now** for a manual backup and inspect the plan's latest run to confirm execution.
|
||||
- key: process:BackupPlanVerifySyncToScheduledJobProcess;slot:help
|
||||
summary: Keep scheduled verification jobs aligned with your plan settings.
|
||||
content: |
|
||||
Set the plan's verification schedule to request automatic checks when your application configures a scheduler. This background integration keeps the verification job aligned with the plan. Inspect **Verification** for completed checks; synchronizing a schedule does not verify a backup. Use **Verify backup** on a saved run for a manual check.
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.app;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.BackupsTestUtils;
|
||||
import com.kingsrook.qbits.backups.app.BackupsSetupStatus.Item;
|
||||
import com.kingsrook.qbits.backups.app.BackupsSetupStatus.State;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.processes.DefaultPlanProcess;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card of the Backups home: with no plan it tells the user to run Create
|
||||
** default plan; with a plan it checks the storage alias, the schedule, the
|
||||
** verification and the prune bucket, and links each to its table.
|
||||
*******************************************************************************/
|
||||
class BackupsSetupStatusTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
BackupsTestUtils.init();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Item item(List<Item> items, String label)
|
||||
{
|
||||
return (items.stream().filter(i -> i.label().equals(label)).findFirst().orElseThrow());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QRecord plan(Map<String, Object> values)
|
||||
{
|
||||
QRecord plan = new QRecord().withValue("name", "Nightly").withValue("isActive", true).withValue("scopeType", "INSTANCE").withValue("storageBucketAlias", "backups");
|
||||
values.forEach((k, v) -> plan.setValue(k, (java.io.Serializable) v));
|
||||
return (plan);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No plans: the first step is the Create default plan action.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void noPlansPointsAtTheDefaultAction()
|
||||
{
|
||||
List<Item> items = BackupsSetupStatus.load(List.of());
|
||||
assertThat(item(items, "Backup plans").state()).isEqualTo(State.TODO);
|
||||
assertThat(item(items, "Backup plans").nextStep()).contains("Create default plan");
|
||||
assertThat(item(items, "Plan scope rules").state()).isEqualTo(State.OPTIONAL);
|
||||
|
||||
String html = BackupsHomeView.render(new BackupsHomeData(List.of(), List.of(), List.of(), List.of(), List.of(), Instant.now()), "/app");
|
||||
assertThat(html).contains("Setup").contains("1 item needs attention").contains("Create default plan").contains("href=\"/app/backups_plan\"");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A bare plan: storage resolves, but no schedule, no verification and no prune bucket.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void aBarePlanNeedsASchedule()
|
||||
{
|
||||
List<Item> items = BackupsSetupStatus.load(List.of(plan(Map.of())), true);
|
||||
assertThat(item(items, "Backup plans").state()).isEqualTo(State.OK);
|
||||
assertThat(item(items, "Storage").state()).isEqualTo(State.OK);
|
||||
assertThat(item(items, "Schedule").state()).isEqualTo(State.TODO);
|
||||
assertThat(item(items, "Verification").state()).isEqualTo(State.OPTIONAL);
|
||||
assertThat(item(items, "Prune bucket").state()).isEqualTo(State.DEFAULT);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A complete plan: everything is in place.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void aCompletePlanIsSetUp()
|
||||
{
|
||||
List<Item> items = BackupsSetupStatus.load(List.of(plan(Map.of("cronExpression", DefaultPlanProcess.CRON, "verifyCronExpression", DefaultPlanProcess.VERIFY_CRON, "pruneBucketAlias", "backups"))), true);
|
||||
assertThat(items).extracting(Item::state).doesNotContain(State.TODO, State.DEFAULT);
|
||||
assertThat(items).extracting(Item::tableName).contains(BackupsTables.PLAN, BackupsTables.PLAN_SCOPE, BackupsTables.VERIFICATION);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Without a scheduler the crons never fire: Schedule and Verification are TODO, and a
|
||||
** plan whose isActive is null is not counted active (as at runtime).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void noSchedulerMeansCronsNeverFire()
|
||||
{
|
||||
QRecord complete = plan(Map.of("cronExpression", DefaultPlanProcess.CRON, "verifyCronExpression", DefaultPlanProcess.VERIFY_CRON, "pruneBucketAlias", "backups"));
|
||||
List<Item> items = BackupsSetupStatus.load(List.of(complete), false);
|
||||
assertThat(item(items, "Schedule").state()).isEqualTo(State.TODO);
|
||||
assertThat(item(items, "Schedule").detail()).contains("No scheduler is configured");
|
||||
assertThat(item(items, "Verification").state()).isEqualTo(State.TODO);
|
||||
assertThat(item(items, "Verification").nextStep()).contains("schedulerName");
|
||||
|
||||
QRecord unset = new QRecord().withValue("name", "N").withValue("storageBucketAlias", "backups");
|
||||
assertThat(item(BackupsSetupStatus.load(List.of(unset), true), "Backup plans").state()).isEqualTo(State.TODO);
|
||||
assertThat(item(BackupsSetupStatus.load(List.of(complete)), "Schedule").state()).as("the test instance has no scheduler").isEqualTo(State.TODO);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host that ticks plans itself is not told it needs a scheduler; a verify cron its
|
||||
** tick does not run is called out, and one it does run is fine.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void hostSchedulerCountsButUnrunVerifyIsCalledOut()
|
||||
{
|
||||
QRecord complete = plan(Map.of("cronExpression", DefaultPlanProcess.CRON, "verifyCronExpression", DefaultPlanProcess.VERIFY_CRON, "pruneBucketAlias", "backups"));
|
||||
List<Item> items = BackupsSetupStatus.load(List.of(complete), false, "the website ticker", false);
|
||||
assertThat(item(items, "Schedule").state()).isEqualTo(State.OK);
|
||||
assertThat(item(items, "Schedule").detail()).contains("the website ticker");
|
||||
assertThat(item(items, "Verification").state()).isEqualTo(State.OPTIONAL);
|
||||
assertThat(item(items, "Verification").detail()).contains("Not run by the website ticker");
|
||||
|
||||
assertThat(item(BackupsSetupStatus.load(List.of(complete), false, "the website ticker", true), "Verification").state()).isEqualTo(State.OK);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An alias that does not resolve, an inactive plan and a scoped plan are called out.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void problemsAreCalledOut()
|
||||
{
|
||||
List<Item> items = BackupsSetupStatus.load(List.of(plan(Map.of("storageBucketAlias", "nowhere", "isActive", false, "scopeType", "TABLES"))), true);
|
||||
assertThat(item(items, "Backup plans").state()).isEqualTo(State.TODO);
|
||||
assertThat(item(items, "Storage").state()).isEqualTo(State.TODO);
|
||||
assertThat(item(items, "Storage").detail()).contains("nowhere");
|
||||
assertThat(item(items, "Plan scope rules").detail()).contains("limited by scope rules");
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
|
||||
import com.kingsrook.qqq.backend.core.scheduler.QScheduleManager;
|
||||
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -320,4 +321,22 @@ class BackupPlanScheduleTest
|
||||
assertThat(inactive.getValues().get("status")).isEqualTo("INACTIVE");
|
||||
assertThat(catalog.where(BackupsTables.RUN, "backupPlanId", planId)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cover every qbit target, including optional scheduler integration processes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void helpBundleIsComplete()
|
||||
{
|
||||
QInstance qInstance = QContext.getQInstance();
|
||||
HelpBundleAssertions.forInstance(qInstance)
|
||||
.bundles("help/qbit-backups.yaml")
|
||||
// Core owns the generated bulk CRUD actions; this qbit owns its export actions on host tables.
|
||||
.owning(target -> target.owner() instanceof com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData process
|
||||
? qInstance.getQBits().containsKey(process.getSourceQBitName())
|
||||
: target.name().startsWith("backups"))
|
||||
.assertComplete();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* 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.tables;
|
||||
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qbits.backups.BackupsTestUtils;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.processes.DefaultPlanProcess;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guardrail (owner requirement 2026-10-14: "all tables should be populated and
|
||||
** the qbit should be fully using them"). For every backups_* table:
|
||||
** - runtime code (not just its model, metadata or home screen) reads or writes it,
|
||||
** so no table exists only on paper;
|
||||
** - it is listed here with why it starts empty and, where a user sets it up, how
|
||||
** (this qbit ships no DDL and its one setup value, the storage bucket alias, is
|
||||
** the host's, so nothing is seeded by a changelog);
|
||||
** - it has bundle help that says what it is for and how its rows get created
|
||||
** (shown on the table screen and when it is empty).
|
||||
** A new table fails this test until it is wired and explained.
|
||||
*******************************************************************************/
|
||||
class BackupsTablesGuardrailTest
|
||||
{
|
||||
private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "backups");
|
||||
|
||||
/*******************************************************************************
|
||||
** Why each table legitimately starts empty, and where its rows come from.
|
||||
*******************************************************************************/
|
||||
static final Map<String, String> STARTS_EMPTY = Map.ofEntries(
|
||||
Map.entry(BackupsTables.PLAN, "host-specific: its storage bucket alias names a bucket the host provides (the Create default plan action adds Nightly full for an alias)"),
|
||||
Map.entry(BackupsTables.PLAN_SCOPE, "user content: include/exclude rules; an INSTANCE plan needs none"),
|
||||
Map.entry(BackupsTables.RUN, "runtime: a plan runs, or Import backups from storage"),
|
||||
Map.entry(BackupsTables.ARTIFACT, "runtime: every object a run stores"),
|
||||
Map.entry(BackupsTables.VERIFICATION, "runtime: Verify backup, or a plan verify schedule"),
|
||||
Map.entry(BackupsTables.RESTORE_RUN, "runtime: a restore"),
|
||||
Map.entry(BackupsTables.RESTORE_RUN_TABLE, "runtime: each table of a restore"),
|
||||
Map.entry(BackupsTables.FILE_OBJECT, "runtime: each distinct file a plan copies"),
|
||||
Map.entry(BackupsTables.ID_MAP, "runtime: working data of a restore with new ids, cleared when it ends"));
|
||||
|
||||
private QInstance qInstance;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
qInstance = BackupsTestUtils.init();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is used by runtime code.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsUsedAtRuntime() throws Exception
|
||||
{
|
||||
List<String> runtimeSource = new ArrayList<>();
|
||||
try(Stream<Path> files = Files.walk(MAIN))
|
||||
{
|
||||
for(Path file : files.filter(p -> p.toString().endsWith(".java")).filter(p -> !p.startsWith(MAIN.resolve("model")) && !p.startsWith(MAIN.resolve("app"))).toList())
|
||||
{
|
||||
runtimeSource.add(Files.readString(file, StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
for(String table : backupsTables())
|
||||
{
|
||||
String constant = constantFor(table);
|
||||
Pattern use = Pattern.compile("\\bBackupsTables\\." + constant + "\\b");
|
||||
assertThat(runtimeSource.stream().anyMatch(source -> use.matcher(source).find()))
|
||||
.as(table + " (BackupsTables." + constant + ") must be read or written by runtime code, not only declared").isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is explained: where its rows come from.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsExplained()
|
||||
{
|
||||
assertThat(backupsTables()).containsExactlyInAnyOrderElementsOf(STARTS_EMPTY.keySet());
|
||||
assertThat(BackupsTables.ALL).containsExactlyInAnyOrderElementsOf(STARTS_EMPTY.keySet());
|
||||
assertThat(STARTS_EMPTY.values()).allMatch(reason -> !reason.isBlank());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table and the app have bundle help that says how rows get created, and
|
||||
** the plan help names the action that sets it up.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableHasEmptyStateHelp() throws Exception
|
||||
{
|
||||
var bundle = HelpBundles.of(qInstance).getBundles().get(0);
|
||||
for(String table : backupsTables())
|
||||
{
|
||||
assertThat(bundle.getEntries()).filteredOn(entry -> entry.getKey().equals("table:" + table + ";slot:help"))
|
||||
.singleElement().satisfies(entry -> assertThat(entry.getContent()).contains("How rows get created"));
|
||||
}
|
||||
assertThat(bundle.getEntries()).filteredOn(entry -> entry.getKey().equals("app:backups;slot:help")).hasSize(1);
|
||||
assertThat(bundle.getEntries()).filteredOn(entry -> entry.getKey().equals("table:backups_plan;slot:help"))
|
||||
.singleElement().satisfies(entry -> assertThat(entry.getContent()).contains("Create default plan"));
|
||||
// RC.7 validates supplied widget/value-source entries but does not require their coverage.
|
||||
for(String name : qInstance.getWidgets().keySet().stream().filter(name -> name.startsWith("backups")).toList())
|
||||
{
|
||||
assertThat(bundle.getEntries()).anyMatch(entry -> entry.getKey().startsWith("widget:" + name + ";slot:"));
|
||||
}
|
||||
for(String name : qInstance.getPossibleValueSources().keySet().stream().filter(name -> name.startsWith("backups_")).toList())
|
||||
{
|
||||
assertThat(bundle.getEntries()).anyMatch(entry -> entry.getKey().startsWith("possibleValueSource:" + name + ";slot:"));
|
||||
}
|
||||
assertThat(qInstance.getProcess(DefaultPlanProcess.NAME)).as("the action the plan help names").isNotNull();
|
||||
assertThat(qInstance.getProcess(DefaultPlanProcess.NAME).getLabel()).isEqualTo("Create default plan");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host's own help in the slot is never replaced by the bundle.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void hostHelpIsKept() throws Exception
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable(BackupsTables.RUN);
|
||||
table.withHelpContent("help", new QHelpContent("Host help for runs."));
|
||||
qInstance.getApp("backups").withHelpContent("help", new QHelpContent("Host help for the app."));
|
||||
HelpBundles.register(qInstance, "help/qbit-backups.yaml");
|
||||
assertThat(qInstance.getTable("helpContent")).isNull();
|
||||
HelpBundles.syncOnStartup(qInstance);
|
||||
assertThat(table.getHelpContent().get("help")).singleElement().satisfies(h -> assertThat(h.getContent()).isEqualTo("Host help for runs."));
|
||||
assertThat(qInstance.getApp("backups").getHelpContent().get("help")).singleElement().satisfies(h -> assertThat(h.getContent()).isEqualTo("Host help for the app."));
|
||||
assertThat(HelpBundles.of(qInstance).getBundles()).isNotEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<String> backupsTables()
|
||||
{
|
||||
List<String> tables = qInstance.getTables().values().stream().map(QTableMetaData::getName).filter(n -> n.startsWith("backups_")).sorted().toList();
|
||||
assertThat(tables).hasSize(9);
|
||||
return (tables);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The BackupsTables constant whose value is the table name.
|
||||
*******************************************************************************/
|
||||
private static String constantFor(String table) throws Exception
|
||||
{
|
||||
for(Field field : BackupsTables.class.getFields())
|
||||
{
|
||||
if(field.getType() == String.class && table.equals(field.get(null)) && !field.getName().equals("PVS_PREFIX"))
|
||||
{
|
||||
return (field.getName());
|
||||
}
|
||||
}
|
||||
throw (new IllegalStateException("no constant for " + table));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* 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.tables;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.BackupsTestUtils;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.permissions.BackupsPermissions;
|
||||
import com.kingsrook.qbits.backups.plan.BackupValidatorReset;
|
||||
import com.kingsrook.qbits.backups.processes.DefaultPlanProcess;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
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.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The "Create default plan" table action: it asks for a storage bucket alias,
|
||||
** creates the Nightly full plan through the normal insert, refuses an alias that
|
||||
** does not resolve, and is insert-only (a second run changes nothing, an edited
|
||||
** plan survives).
|
||||
*******************************************************************************/
|
||||
class DefaultPlanProcessTest
|
||||
{
|
||||
private final BackupCatalog catalog = new BackupCatalog();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
BackupsTestUtils.init();
|
||||
BackupValidatorReset.reset();
|
||||
QContext.getQSession().withPermissions(BackupsPermissions.ADMIN);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the action through Review and then Result.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<ProcessSummaryLine> run(String alias, boolean execute) throws QException
|
||||
{
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName(DefaultPlanProcess.NAME);
|
||||
input.addValue(DefaultPlanProcess.FIELD_ALIAS, alias);
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// the first call stops at the input form; the next runs Review (full //
|
||||
// validation), the last executes and gives the Result //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
RunProcessOutput output = new RunProcessAction().execute(input);
|
||||
input.addValue("processUUID", output.getProcessUUID());
|
||||
input.setStartAfterStep("input");
|
||||
output = new RunProcessAction().execute(input);
|
||||
if(!execute)
|
||||
{
|
||||
return ((List<ProcessSummaryLine>) output.getValues().get(StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY));
|
||||
}
|
||||
input.setStartAfterStep("review");
|
||||
output = new RunProcessAction().execute(input);
|
||||
return ((List<ProcessSummaryLine>) output.getValues().get(StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The action is a table action that needs no selected records and backups.admin.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void isATableActionForAdmins() throws Exception
|
||||
{
|
||||
var process = QContext.getQInstance().getProcess(DefaultPlanProcess.NAME);
|
||||
assertThat(process.getTableName()).isEqualTo(BackupsTables.PLAN);
|
||||
assertThat(process.getMinInputRecords()).isEqualTo(0);
|
||||
assertThat(((com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData) process.getStep("input")).getFormFields().stream().map(f -> f.getName())).containsExactly(DefaultPlanProcess.FIELD_ALIAS);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review previews without writing; Result creates the plan, and the plan works.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void createsTheNightlyFullPlan() throws Exception
|
||||
{
|
||||
List<ProcessSummaryLine> review = run("backups", false);
|
||||
assertThat(review).anySatisfy(line -> assertThat(line.getPluralFutureMessage()).contains("will be created"));
|
||||
assertThat(catalog.query(BackupsTables.PLAN, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter())).as("Review writes nothing").isEmpty();
|
||||
|
||||
List<ProcessSummaryLine> result = run("backups", true);
|
||||
assertThat(result).singleElement().satisfies(line -> assertThat(line.getStatus().name()).isEqualTo("OK"));
|
||||
|
||||
QRecord plan = catalog.where(BackupsTables.PLAN, "planKey", DefaultPlanProcess.PLAN_KEY).get(0);
|
||||
assertThat(plan.getValueString("name")).isEqualTo("Nightly full");
|
||||
assertThat(plan.getValueString("storageBucketAlias")).isEqualTo("backups");
|
||||
assertThat(plan.getValueBoolean("isActive")).isTrue();
|
||||
assertThat(plan.getValueString("scopeType")).isEqualTo("INSTANCE");
|
||||
assertThat(plan.getValueString("cronExpression")).isEqualTo(DefaultPlanProcess.CRON);
|
||||
assertThat(plan.getValueString("verifyCronExpression")).isEqualTo(DefaultPlanProcess.VERIFY_CRON);
|
||||
assertThat(plan.getValueInteger("slaHours")).isEqualTo(26);
|
||||
assertThat(plan.getValueString("format")).isEqualTo("JSONL");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A plan made by the action is a working plan: it runs and backs up the instance.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void theCreatedPlanRuns() throws Exception
|
||||
{
|
||||
BackupsTestUtils.loadBlog(3);
|
||||
run("backups", true);
|
||||
QRecord plan = catalog.where(BackupsTables.PLAN, "planKey", DefaultPlanProcess.PLAN_KEY).get(0);
|
||||
var result = new com.kingsrook.qbits.backups.run.BackupRunCoordinator().runPlan(plan, "MANUAL");
|
||||
assertThat(result.getStatus()).isIn("SUCCEEDED", "SUCCEEDED_WITH_WARNINGS");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert-only: a second run creates nothing, and an edited plan keeps its edit.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void isInsertOnly() throws Exception
|
||||
{
|
||||
run("backups", true);
|
||||
Integer id = catalog.where(BackupsTables.PLAN, "planKey", DefaultPlanProcess.PLAN_KEY).get(0).getValueInteger("id");
|
||||
catalog.update(BackupsTables.PLAN, id, Map.of("slaHours", 48, "name", "Our nightly"));
|
||||
|
||||
List<ProcessSummaryLine> review = run("backups", false);
|
||||
assertThat(review).singleElement().satisfies(line -> assertThat(line.getMessage()).contains("already exists"));
|
||||
run("backups", true);
|
||||
|
||||
assertThat(catalog.query(BackupsTables.PLAN, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter())).hasSize(1);
|
||||
QRecord plan = catalog.get(BackupsTables.PLAN, id).orElseThrow();
|
||||
assertThat(plan.getValueInteger("slaHours")).isEqualTo(48);
|
||||
assertThat(plan.getValueString("name")).isEqualTo("Our nightly");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An alias the host does not provide is refused in Review, and nothing is created.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void refusesAnUnknownAlias() throws Exception
|
||||
{
|
||||
List<ProcessSummaryLine> review = run("no-such-bucket", false);
|
||||
assertThat(review).anySatisfy(line ->
|
||||
{
|
||||
assertThat(line.getStatus().name()).isEqualTo("ERROR");
|
||||
assertThat(String.join(" ", line.getBulletsOfText())).contains("no-such-bucket");
|
||||
});
|
||||
run("no-such-bucket", true);
|
||||
assertThat(catalog.query(BackupsTables.PLAN, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter())).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A plan that already has the name Nightly full (under another key) is refused in
|
||||
** Review, never "will be created".
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void reviewRefusesATakenName() throws Exception
|
||||
{
|
||||
catalog.insert(BackupsTables.PLAN, new java.util.LinkedHashMap<>(Map.of("name", DefaultPlanProcess.PLAN_NAME, "planKey", "other-key", "isActive", false, "scopeType", "INSTANCE", "storageBucketAlias", "backups")));
|
||||
List<ProcessSummaryLine> review = run("backups", false);
|
||||
assertThat(review).noneSatisfy(line -> assertThat(line.getPluralFutureMessage()).contains("will be created"));
|
||||
assertThat(review).anySatisfy(line ->
|
||||
{
|
||||
assertThat(line.getStatus().name()).isEqualTo("ERROR");
|
||||
assertThat(String.join(" ", line.getBulletsOfText())).contains("already exists");
|
||||
});
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* 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.tables;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.BackupsTestUtils;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.plan.BackupValidatorReset;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qbits.backups.run.BackupRunCoordinator;
|
||||
import com.kingsrook.qbits.backups.schedule.BackupPlanVerifySyncToScheduledJobProcess;
|
||||
import com.kingsrook.qbits.backups.schedule.BackupScheduledVerifyProcess;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
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.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Two half-wired settings of backups_plan, made honest or made to work:
|
||||
** - the settings that are not available yet (read replica, native dump,
|
||||
** incremental, PARQUET, ZSTD) are refused at save with a clear message and say so
|
||||
** in their field help;
|
||||
** - verifyCronExpression creates a scheduled job that runs the process
|
||||
** backupVerifyPlan; that process now exists, and verifies the plan's latest
|
||||
** successful backup through the plan's verifyLevel.
|
||||
*******************************************************************************/
|
||||
class UnavailableSettingsAndScheduledVerifyTest
|
||||
{
|
||||
private final BackupCatalog catalog = new BackupCatalog();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
BackupsTestUtils.init();
|
||||
BackupsTestUtils.loadBlog(3);
|
||||
BackupValidatorReset.reset();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Save a plan through the table (validation runs), returning the record.
|
||||
*******************************************************************************/
|
||||
private QRecord save(Map<String, Serializable> overrides) throws QException
|
||||
{
|
||||
Map<String, Serializable> values = new LinkedHashMap<>(Map.of("name", "P" + System.nanoTime(), "planKey", "p" + System.nanoTime(), "isActive", true, "scopeType", "INSTANCE", "storageBucketAlias", "backups"));
|
||||
values.putAll(overrides);
|
||||
QRecord record = new QRecord();
|
||||
record.setValues(values);
|
||||
InsertInput input = new InsertInput(BackupsTables.PLAN);
|
||||
input.setRecords(List.of(record));
|
||||
input.withPermissionBypass("test");
|
||||
return (new InsertAction().execute(input).getRecords().get(0));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every setting that is not available yet is refused, and the field help says so.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void unavailableSettingsAreRefusedAndSayWhy() throws Exception
|
||||
{
|
||||
assertThat(save(Map.of()).getErrors()).as("the defaults are fine").isEmpty();
|
||||
|
||||
Map<String, Map<String, Serializable>> refused = Map.of(
|
||||
"Reading from a replica is not available yet", Map.of("useReadReplica", true),
|
||||
"Native database dumps are not available yet", Map.of("nativeDump", "PG_DUMP_CUSTOM"),
|
||||
"Incremental backups are not available yet", Map.of("incrementalMode", "CHANGED_SINCE_LAST"),
|
||||
"PARQUET format is not available yet", Map.of("format", "PARQUET"),
|
||||
"ZSTD compression is not available yet", Map.of("compression", "ZSTD"));
|
||||
for(Map.Entry<String, Map<String, Serializable>> entry : refused.entrySet())
|
||||
{
|
||||
QRecord record = save(entry.getValue());
|
||||
assertThat(record.getErrors()).as(entry.getValue().toString()).isNotEmpty();
|
||||
assertThat(record.getErrors().toString()).contains(entry.getKey());
|
||||
}
|
||||
|
||||
var table = QContext.getQInstance().getTable(BackupsTables.PLAN);
|
||||
for(String field : List.of("useReadReplica", "nativeDump", "incrementalMode", "format", "compression", "scheduledJobId"))
|
||||
{
|
||||
assertThat(table.getField(field).getHelpContents()).as(field + " help").isNotEmpty();
|
||||
assertThat(table.getField(field).getHelpContents().get(0).getContent()).as(field + " help").matches("(?is).*(not available yet|not used).*");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The process a verify cron schedules exists, and verifies the plan's latest
|
||||
** successful run, as the scheduler, through the plan's verifyLevel.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void theVerifyCronRunsARealProcess() throws Exception
|
||||
{
|
||||
assertThat(QContext.getQInstance().getProcess(BackupPlanVerifySyncToScheduledJobProcess.VERIFY_PROCESS_NAME)).as("the process the scheduled verify job names").isNotNull();
|
||||
|
||||
Integer planId = catalog.insert(BackupsTables.PLAN, new LinkedHashMap<>(Map.of("name", "Nightly", "planKey", "nightly", "isActive", true, "scopeType", "INSTANCE",
|
||||
"storageBucketAlias", "backups", "verifyLevel", "STORAGE")));
|
||||
assertThat(run(planId).getValues().get("status")).as("nothing to verify before the first backup").isEqualTo("NO_BACKUP");
|
||||
|
||||
QRecord plan = catalog.get(BackupsTables.PLAN, planId).orElseThrow();
|
||||
var result = new BackupRunCoordinator().runPlan(plan, "SCHEDULED");
|
||||
assertThat(result.getStatus()).isIn("SUCCEEDED", "SUCCEEDED_WITH_WARNINGS");
|
||||
|
||||
RunProcessOutput output = run(planId);
|
||||
assertThat(output.getException()).isEmpty();
|
||||
assertThat(output.getValues().get("status")).isEqualTo("PASSED");
|
||||
assertThat(output.getValues().get("level")).isEqualTo("STORAGE");
|
||||
|
||||
List<QRecord> verifications = catalog.where(BackupsTables.VERIFICATION, "backupRunId", result.getRunId());
|
||||
assertThat(verifications).singleElement().satisfies(v ->
|
||||
{
|
||||
assertThat(v.getValueString("level")).isEqualTo("STORAGE");
|
||||
assertThat(v.getValueString("trigger")).isEqualTo("SCHEDULED");
|
||||
assertThat(v.getValueString("requestedByUserId")).isEqualTo(BackupScheduledVerifyProcess.SCHEDULER_USER);
|
||||
});
|
||||
assertThat(catalog.get(BackupsTables.PLAN, planId).orElseThrow().getValue("lastVerifiedDate")).as("a storage check alone does not count as verified").isNull();
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// the plan's level decides how far it goes; an inactive plan is skipped //
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
catalog.update(BackupsTables.PLAN, planId, Map.of("verifyLevel", "CONTENT"));
|
||||
assertThat(run(planId).getValues().get("level")).isEqualTo("CONTENT");
|
||||
assertThat(catalog.get(BackupsTables.PLAN, planId).orElseThrow().getValue("lastVerifiedDate")).isNotNull();
|
||||
catalog.update(BackupsTables.PLAN, planId, Map.of("isActive", false));
|
||||
assertThat(run(planId).getValues().get("status")).isEqualTo("INACTIVE");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An unset or unknown level falls back to CONTENT.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void levelFallsBackToContent()
|
||||
{
|
||||
assertThat(BackupScheduledVerifyProcess.levelOf(new QRecord())).isEqualTo("CONTENT");
|
||||
assertThat(BackupScheduledVerifyProcess.levelOf(new QRecord().withValue("verifyLevel", "BOGUS"))).isEqualTo("CONTENT");
|
||||
assertThat(BackupScheduledVerifyProcess.levelOf(new QRecord().withValue("verifyLevel", "TEST_RESTORE"))).isEqualTo("TEST_RESTORE");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the scheduled verify entry point for a plan, as the scheduler would.
|
||||
*******************************************************************************/
|
||||
private RunProcessOutput run(Integer planId) throws QException
|
||||
{
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName(BackupScheduledVerifyProcess.NAME);
|
||||
input.setCallback(QProcessCallbackFactory.forPrimaryKey("id", planId));
|
||||
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
|
||||
return (new RunProcessAction().execute(input));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user