feat: populated defaults, guided empty states, scheduled verify #25
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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>`.
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
+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