feat: populated defaults, guided empty states, scheduled verify #25

Merged
KofTwentyTwo merged 3 commits from feature/tables-populated-defaults into develop 2026-10-14 22:13:32 +00:00
20 changed files with 1723 additions and 7 deletions

No files matched your search

+4
View File
@@ -5,9 +5,13 @@ All notable changes to this project are documented here. The format follows Keep
## [0.1.0] - Unreleased
### Added
- 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
- 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.
+4
View File
@@ -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>`.
+7
View File
@@ -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.
@@ -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)
*******************************************************************************/
@@ -160,6 +160,7 @@ 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);
com.kingsrook.qbits.backups.model.BackupsHelp.apply(qInstance, app.getName());
// 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());
}
}
@@ -0,0 +1,126 @@
/*
* 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.model;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Applies help/qbit-backups.yaml (the QQQ help-bundle format) to the Backups app
** and its backups_* tables: what each table is for and how its rows get created,
** shown on the table screen and when it is empty.
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.8): replace with HelpBundles.register once the pinned QQQ
** release includes it; the bundle file stays the same.
*******************************************************************************/
public final class BackupsHelp
{
public static final String RESOURCE = "/help/qbit-backups.yaml";
public static final String SLOT = "help";
public static final String APP_KEY = "app:backups;slot:help";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private BackupsHelp()
{
}
/*******************************************************************************
** The bundle's entries by key, validated (fails startup if malformed).
*******************************************************************************/
public static Map<String, JsonNode> entries() throws QException
{
try(InputStream stream = BackupsHelp.class.getResourceAsStream(RESOURCE))
{
if(stream == null)
{
throw (new IOException("missing resource"));
}
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
if(!"qbit-backups".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText()))
{
throw (new IOException("invalid bundle header"));
}
Map<String, JsonNode> entries = new LinkedHashMap<>();
for(JsonNode entry : bundle.path("entries"))
{
String key = entry.path("key").asText();
String summary = entry.path("summary").asText();
if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
{
throw (new IOException("invalid or duplicate entry: " + key));
}
}
return (entries);
}
catch(IOException | RuntimeException e)
{
throw (new QException("Could not load " + RESOURCE, e));
}
}
/*******************************************************************************
** Apply the help to the app and every backups_* table in the instance. A host's
** own help in the slot is kept (the bundle only fills it).
*******************************************************************************/
public static void apply(QInstance qInstance, String appName) throws QException
{
Map<String, JsonNode> entries = entries();
for(QTableMetaData table : qInstance.getTables().values())
{
JsonNode entry = entries.get("table:" + table.getName() + ";slot:" + SLOT);
if(entry != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
{
table.withHelpContent(SLOT, content(entry));
}
}
QAppMetaData app = qInstance.getApp(appName);
JsonNode appEntry = entries.get(APP_KEY);
if(app != null && appEntry != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
{
app.withHelpContent(SLOT, content(appEntry));
}
}
/*******************************************************************************
** Summary as the first paragraph, then the content, as markdown.
*******************************************************************************/
static QHelpContent content(JsonNode entry)
{
return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip()));
}
}
@@ -38,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()));
}
}
}
+58
View File
@@ -0,0 +1,58 @@
bundle: qbit-backups
version: 1
layer: QBIT
# Help for the Backups app and every backups_* table, in the QQQ help-bundle format
# (qqq#1198). Each table entry says what the table is for and how its rows get
# created, so an empty table explains itself. BackupsHelp applies it until the core
# HelpBundles registry ships in a QQQ release (STAND-IN(core=#1198)).
# Summaries stay under 120 characters (BackupsTablesGuardrailTest).
entries:
- key: app:backups;slot:help
summary: "Back up, verify and restore the application's data to S3-compatible storage."
content: |
The Setup card on the home lists 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:** an administrator adds one with New, or runs 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. A plan with a cron expression runs on its own once it is active; 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:** an administrator adds them as 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. They appear as Artifacts on a run record, and Restore this table and Download work from here.
- 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. The newest result also shows 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. They appear as Tables restored on a restore record.
- 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 this table is usually empty.
@@ -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");
}
}
@@ -0,0 +1,192 @@
/*
* 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.BackupsHelp;
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.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
{
assertThat(BackupsHelp.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120));
for(String table : backupsTables())
{
List<QHelpContent> help = qInstance.getTable(table).getHelpContent() == null ? null : qInstance.getTable(table).getHelpContent().get(BackupsHelp.SLOT);
assertThat(help).as(table + " table help").isNotEmpty();
assertThat(help.get(0).getContent()).as(table + " help says how rows get created").contains("How rows get created");
}
assertThat(qInstance.getApp("backups").getHelpContent()).containsKey(BackupsHelp.SLOT);
assertThat(BackupsHelp.entries().keySet()).allMatch(key -> !key.startsWith("table:") || qInstance.getTable(key.substring(6, key.indexOf(';'))) != null,
"help names only real tables");
assertThat(qInstance.getTable(BackupsTables.PLAN).getHelpContent().get(BackupsHelp.SLOT).get(0).getContent()).contains("Create default plan");
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(BackupsHelp.SLOT, new QHelpContent("Host help for runs."));
qInstance.getApp("backups").withHelpContent(BackupsHelp.SLOT, new QHelpContent("Host help for the app."));
BackupsHelp.apply(qInstance, "backups");
assertThat(table.getHelpContent().get(BackupsHelp.SLOT)).singleElement().satisfies(h -> assertThat(h.getContent()).isEqualTo("Host help for runs."));
assertThat(qInstance.getApp("backups").getHelpContent().get(BackupsHelp.SLOT)).singleElement().satisfies(h -> assertThat(h.getContent()).isEqualTo("Host help for the app."));
assertThat(qInstance.getTable(BackupsTables.PLAN).getHelpContent().get(BackupsHelp.SLOT).get(0).getContent()).contains("How rows get created");
}
/*******************************************************************************
**
*******************************************************************************/
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");
});
}
}
@@ -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));
}
}