From 85f4ca0a6f1e527fd2025782e181d4f5834327f7 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 16:49:20 -0500 Subject: [PATCH 1/3] feat: guided empty states, setup card, Create default plan, scheduled verify process Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + README.md | 4 + UPGRADE.md | 7 + .../qbits/backups/BackupsQBitProducer.java | 1 + .../qbits/backups/app/BackupsHomeView.java | 45 +++ .../qbits/backups/app/BackupsSetupStatus.java | 169 ++++++++++ .../qbits/backups/model/BackupsHelp.java | 126 +++++++ .../qbits/backups/model/BackupsTables.java | 16 +- .../plan/BackupPlanTableCustomizer.java | 2 +- .../backups/plan/BackupPlanValidator.java | 4 + .../backups/processes/BackupsProcesses.java | 1 + .../backups/processes/DefaultPlanProcess.java | 309 ++++++++++++++++++ .../backups/processes/LifecycleProcesses.java | 1 + .../BackupScheduledVerifyProcess.java | 136 ++++++++ src/main/resources/help/qbit-backups.yaml | 58 ++++ .../backups/app/BackupsSetupStatusTest.java | 132 ++++++++ .../tables/BackupsTablesGuardrailTest.java | 175 ++++++++++ .../tables/DefaultPlanProcessTest.java | 186 +++++++++++ ...ailableSettingsAndScheduledVerifyTest.java | 188 +++++++++++ 19 files changed, 1557 insertions(+), 7 deletions(-) create mode 100644 UPGRADE.md create mode 100644 src/main/java/com/kingsrook/qbits/backups/app/BackupsSetupStatus.java create mode 100644 src/main/java/com/kingsrook/qbits/backups/model/BackupsHelp.java create mode 100644 src/main/java/com/kingsrook/qbits/backups/processes/DefaultPlanProcess.java create mode 100644 src/main/java/com/kingsrook/qbits/backups/schedule/BackupScheduledVerifyProcess.java create mode 100644 src/main/resources/help/qbit-backups.yaml create mode 100644 src/test/java/com/kingsrook/qbits/backups/app/BackupsSetupStatusTest.java create mode 100644 src/test/java/com/kingsrook/qbits/backups/tables/BackupsTablesGuardrailTest.java create mode 100644 src/test/java/com/kingsrook/qbits/backups/tables/DefaultPlanProcessTest.java create mode 100644 src/test/java/com/kingsrook/qbits/backups/tables/UnavailableSettingsAndScheduledVerifyTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 697db61..a34cf27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index bae86d2..5d98ea3 100644 --- a/README.md +++ b/README.md @@ -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_` (lowercase snake_case, backend name equal to the table name). Permissions are `backups.`. diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..caa6dd2 --- /dev/null +++ b/UPGRADE.md @@ -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. diff --git a/src/main/java/com/kingsrook/qbits/backups/BackupsQBitProducer.java b/src/main/java/com/kingsrook/qbits/backups/BackupsQBitProducer.java index 66e703a..5483a24 100644 --- a/src/main/java/com/kingsrook/qbits/backups/BackupsQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/backups/BackupsQBitProducer.java @@ -160,6 +160,7 @@ public class BackupsQBitProducer implements QBitMetaDataProducer"); planStatus(html, data, pathPrefix); + setup(html, data, pathPrefix); html.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 items = BackupsSetupStatus.load(data.plans()); + open(html, "Setup"); + long todo = items.stream().filter(i -> i.state() == BackupsSetupStatus.State.TODO).count(); + html.append("

") + .append(todo == 0 ? "Everything backups need is set up." : esc(todo + (todo == 1 ? " item needs" : " items need") + " attention.")) + .append("

    "); + 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("
  • ") + .append("").append("").append(esc(item.label())).append("") + .append("").append(esc(label)).append("") + .append("
    ").append(esc(item.detail())).append("
    "); + if(item.nextStep() != null) + { + html.append("
    Next: ").append(esc(item.nextStep())).append("
    "); + } + html.append("
  • "); + } + html.append("
"); + close(html); + } + + + /******************************************************************************* ** Needs attention. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/backups/app/BackupsSetupStatus.java b/src/main/java/com/kingsrook/qbits/backups/app/BackupsSetupStatus.java new file mode 100644 index 0000000..ae17324 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/backups/app/BackupsSetupStatus.java @@ -0,0 +1,169 @@ +/* + * 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.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 load(List plans) + { + List items = new ArrayList<>(); + List active = plans.stream().filter(p -> !Boolean.FALSE.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 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 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 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(); + items.add(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.", null)); + + ////////////////// + // verification // + ////////////////// + long verified = active.stream().filter(p -> hasContent(p.getValueString("verifyCronExpression"))).count(); + items.add(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()); + } +} diff --git a/src/main/java/com/kingsrook/qbits/backups/model/BackupsHelp.java b/src/main/java/com/kingsrook/qbits/backups/model/BackupsHelp.java new file mode 100644 index 0000000..bd79490 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/backups/model/BackupsHelp.java @@ -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 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 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 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())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/backups/model/BackupsTables.java b/src/main/java/com/kingsrook/qbits/backups/model/BackupsTables.java index a0a0453..f509571 100644 --- a/src/main/java/com/kingsrook/qbits/backups/model/BackupsTables.java +++ b/src/main/java/com/kingsrook/qbits/backups/model/BackupsTables.java @@ -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)) diff --git a/src/main/java/com/kingsrook/qbits/backups/plan/BackupPlanTableCustomizer.java b/src/main/java/com/kingsrook/qbits/backups/plan/BackupPlanTableCustomizer.java index ef18ae0..c4e32b0 100644 --- a/src/main/java/com/kingsrook/qbits/backups/plan/BackupPlanTableCustomizer.java +++ b/src/main/java/com/kingsrook/qbits/backups/plan/BackupPlanTableCustomizer.java @@ -48,7 +48,7 @@ import com.kingsrook.qqq.backend.core.model.statusmessages.QWarningMessage; public class BackupPlanTableCustomizer implements TableCustomizerInterface { private static final List 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"); diff --git a/src/main/java/com/kingsrook/qbits/backups/plan/BackupPlanValidator.java b/src/main/java/com/kingsrook/qbits/backups/plan/BackupPlanValidator.java index b43bc67..5a7c281 100644 --- a/src/main/java/com/kingsrook/qbits/backups/plan/BackupPlanValidator.java +++ b/src/main/java/com/kingsrook/qbits/backups/plan/BackupPlanValidator.java @@ -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."); + } } diff --git a/src/main/java/com/kingsrook/qbits/backups/processes/BackupsProcesses.java b/src/main/java/com/kingsrook/qbits/backups/processes/BackupsProcesses.java index 4d91237..c7e8360 100644 --- a/src/main/java/com/kingsrook/qbits/backups/processes/BackupsProcesses.java +++ b/src/main/java/com/kingsrook/qbits/backups/processes/BackupsProcesses.java @@ -72,6 +72,7 @@ public final class BackupsProcesses List 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()); diff --git a/src/main/java/com/kingsrook/qbits/backups/processes/DefaultPlanProcess.java b/src/main/java/com/kingsrook/qbits/backups/processes/DefaultPlanProcess.java new file mode 100644 index 0000000..45217f5 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/backups/processes/DefaultPlanProcess.java @@ -0,0 +1,309 @@ +/* + * 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 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 planValues(String alias) + { + Map 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 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()); + if(result.ok()) + { + create.incrementCountAndAddPrimaryKey(record.getValueString("planKey")); + output.getRecords().add(record); + } + else + { + invalid.incrementCount(); + problems.addAll(result.errors()); + } + } + } + + + + /******************************************************************************* + ** {@inheritDoc} + *******************************************************************************/ + @Override + public ArrayList getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen) + { + ArrayList 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 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 getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen) + { + ArrayList lines = new ArrayList<>(); + created.addSelfToListIfAnyCount(lines); + failed.setBulletsOfText(new ArrayList<>(problems)); + failed.addSelfToListIfAnyCount(lines); + return (lines); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/backups/processes/LifecycleProcesses.java b/src/main/java/com/kingsrook/qbits/backups/processes/LifecycleProcesses.java index a8f92bd..c4d17c3 100644 --- a/src/main/java/com/kingsrook/qbits/backups/processes/LifecycleProcesses.java +++ b/src/main/java/com/kingsrook/qbits/backups/processes/LifecycleProcesses.java @@ -53,6 +53,7 @@ public final class LifecycleProcesses { List 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()); diff --git a/src/main/java/com/kingsrook/qbits/backups/schedule/BackupScheduledVerifyProcess.java b/src/main/java/com/kingsrook/qbits/backups/schedule/BackupScheduledVerifyProcess.java new file mode 100644 index 0000000..6693b0d --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/backups/schedule/BackupScheduledVerifyProcess.java @@ -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 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 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())); + } + } +} diff --git a/src/main/resources/help/qbit-backups.yaml b/src/main/resources/help/qbit-backups.yaml new file mode 100644 index 0000000..5029231 --- /dev/null +++ b/src/main/resources/help/qbit-backups.yaml @@ -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. diff --git a/src/test/java/com/kingsrook/qbits/backups/app/BackupsSetupStatusTest.java b/src/test/java/com/kingsrook/qbits/backups/app/BackupsSetupStatusTest.java new file mode 100644 index 0000000..7439368 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/backups/app/BackupsSetupStatusTest.java @@ -0,0 +1,132 @@ +/* + * 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 items, String label) + { + return (items.stream().filter(i -> i.label().equals(label)).findFirst().orElseThrow()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static QRecord plan(Map 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 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 items = BackupsSetupStatus.load(List.of(plan(Map.of()))); + 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 items = BackupsSetupStatus.load(List.of(plan(Map.of("cronExpression", DefaultPlanProcess.CRON, "verifyCronExpression", DefaultPlanProcess.VERIFY_CRON, "pruneBucketAlias", "backups")))); + assertThat(items).extracting(Item::state).doesNotContain(State.TODO, State.DEFAULT); + assertThat(items).extracting(Item::tableName).contains(BackupsTables.PLAN, BackupsTables.PLAN_SCOPE, BackupsTables.VERIFICATION); + } + + + + /******************************************************************************* + ** An alias that does not resolve, an inactive plan and a scoped plan are called out. + *******************************************************************************/ + @Test + void problemsAreCalledOut() + { + List items = BackupsSetupStatus.load(List.of(plan(Map.of("storageBucketAlias", "nowhere", "isActive", false, "scopeType", "TABLES")))); + 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"); + } +} diff --git a/src/test/java/com/kingsrook/qbits/backups/tables/BackupsTablesGuardrailTest.java b/src/test/java/com/kingsrook/qbits/backups/tables/BackupsTablesGuardrailTest.java new file mode 100644 index 0000000..075b40a --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/backups/tables/BackupsTablesGuardrailTest.java @@ -0,0 +1,175 @@ +/* + * 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 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 runtimeSource = new ArrayList<>(); + try(Stream 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 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"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private List backupsTables() + { + List 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)); + } +} diff --git a/src/test/java/com/kingsrook/qbits/backups/tables/DefaultPlanProcessTest.java b/src/test/java/com/kingsrook/qbits/backups/tables/DefaultPlanProcessTest.java new file mode 100644 index 0000000..1bc0ee9 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/backups/tables/DefaultPlanProcessTest.java @@ -0,0 +1,186 @@ +/* + * 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 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) output.getValues().get(StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY)); + } + input.setStartAfterStep("review"); + output = new RunProcessAction().execute(input); + return ((List) 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 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 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 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 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(); + } +} diff --git a/src/test/java/com/kingsrook/qbits/backups/tables/UnavailableSettingsAndScheduledVerifyTest.java b/src/test/java/com/kingsrook/qbits/backups/tables/UnavailableSettingsAndScheduledVerifyTest.java new file mode 100644 index 0000000..5b91833 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/backups/tables/UnavailableSettingsAndScheduledVerifyTest.java @@ -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 overrides) throws QException + { + Map 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> 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> 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 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)); + } +} -- 2.54.0 From 89f218937aaf13bdd9ae44952d216539088109db Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 17:07:54 -0500 Subject: [PATCH 2/3] fix: setup card reflects the scheduler, review checks the plan name, host help test Co-Authored-By: Claude Opus 5.5 --- .../qbits/backups/app/BackupsSetupStatus.java | 26 +++++++++++++++--- .../backups/processes/DefaultPlanProcess.java | 7 ++++- .../backups/app/BackupsSetupStatusTest.java | 27 ++++++++++++++++--- .../tables/BackupsTablesGuardrailTest.java | 17 ++++++++++++ .../tables/DefaultPlanProcessTest.java | 19 +++++++++++++ 5 files changed, 89 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/kingsrook/qbits/backups/app/BackupsSetupStatus.java b/src/main/java/com/kingsrook/qbits/backups/app/BackupsSetupStatus.java index ae17324..981deba 100644 --- a/src/main/java/com/kingsrook/qbits/backups/app/BackupsSetupStatus.java +++ b/src/main/java/com/kingsrook/qbits/backups/app/BackupsSetupStatus.java @@ -21,6 +21,7 @@ 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; @@ -69,9 +70,21 @@ public final class BackupsSetupStatus ** Build the items from the plans. *******************************************************************************/ public static List load(List plans) + { + BackupsQBitConfig config = BackupsRuntime.isInstalled() ? BackupsRuntime.getConfig() : null; + return (load(plans, config != null && hasContent(config.getSchedulerName()))); + } + + + + /******************************************************************************* + ** 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 load(List plans, boolean schedulerConfigured) { List items = new ArrayList<>(); - List active = plans.stream().filter(p -> !Boolean.FALSE.equals(p.getValueBoolean("isActive"))).toList(); + List active = plans.stream().filter(p -> Boolean.TRUE.equals(p.getValueBoolean("isActive"))).toList(); /////////// // plans // @@ -133,7 +146,11 @@ public final class BackupsSetupStatus // schedule: an active plan has a cron // ///////////////////////////////////////// long scheduled = active.stream().filter(p -> hasContent(p.getValueString("cronExpression"))).count(); - items.add(scheduled == 0 + String noScheduler = "No scheduler is configured (schedulerName on the qbit config), so plan crons are saved but never fire"; + items.add(!schedulerConfigured + ? 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.", null)); @@ -141,7 +158,10 @@ public final class BackupsSetupStatus // verification // ////////////////// long verified = active.stream().filter(p -> hasContent(p.getValueString("verifyCronExpression"))).count(); - items.add(verified == 0 + items.add(!schedulerConfigured + ? 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)); diff --git a/src/main/java/com/kingsrook/qbits/backups/processes/DefaultPlanProcess.java b/src/main/java/com/kingsrook/qbits/backups/processes/DefaultPlanProcess.java index 45217f5..740bfbb 100644 --- a/src/main/java/com/kingsrook/qbits/backups/processes/DefaultPlanProcess.java +++ b/src/main/java/com/kingsrook/qbits/backups/processes/DefaultPlanProcess.java @@ -221,7 +221,12 @@ public final class DefaultPlanProcess { sawRecord = true; BackupPlanValidator.Result result = BackupPlanValidator.validate(PlanSettings.of(record), null, false, List.of()); - if(result.ok()) + 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); diff --git a/src/test/java/com/kingsrook/qbits/backups/app/BackupsSetupStatusTest.java b/src/test/java/com/kingsrook/qbits/backups/app/BackupsSetupStatusTest.java index 7439368..f70cc58 100644 --- a/src/test/java/com/kingsrook/qbits/backups/app/BackupsSetupStatusTest.java +++ b/src/test/java/com/kingsrook/qbits/backups/app/BackupsSetupStatusTest.java @@ -94,7 +94,7 @@ class BackupsSetupStatusTest @Test void aBarePlanNeedsASchedule() { - List items = BackupsSetupStatus.load(List.of(plan(Map.of()))); + List 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); @@ -110,20 +110,41 @@ class BackupsSetupStatusTest @Test void aCompletePlanIsSetUp() { - List items = BackupsSetupStatus.load(List.of(plan(Map.of("cronExpression", DefaultPlanProcess.CRON, "verifyCronExpression", DefaultPlanProcess.VERIFY_CRON, "pruneBucketAlias", "backups")))); + List 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 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); + } + + + /******************************************************************************* ** An alias that does not resolve, an inactive plan and a scoped plan are called out. *******************************************************************************/ @Test void problemsAreCalledOut() { - List items = BackupsSetupStatus.load(List.of(plan(Map.of("storageBucketAlias", "nowhere", "isActive", false, "scopeType", "TABLES")))); + List 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"); diff --git a/src/test/java/com/kingsrook/qbits/backups/tables/BackupsTablesGuardrailTest.java b/src/test/java/com/kingsrook/qbits/backups/tables/BackupsTablesGuardrailTest.java index 075b40a..ed04702 100644 --- a/src/test/java/com/kingsrook/qbits/backups/tables/BackupsTablesGuardrailTest.java +++ b/src/test/java/com/kingsrook/qbits/backups/tables/BackupsTablesGuardrailTest.java @@ -146,6 +146,23 @@ class BackupsTablesGuardrailTest + /******************************************************************************* + ** 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"); + } + + + /******************************************************************************* ** *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/backups/tables/DefaultPlanProcessTest.java b/src/test/java/com/kingsrook/qbits/backups/tables/DefaultPlanProcessTest.java index 1bc0ee9..1a370e1 100644 --- a/src/test/java/com/kingsrook/qbits/backups/tables/DefaultPlanProcessTest.java +++ b/src/test/java/com/kingsrook/qbits/backups/tables/DefaultPlanProcessTest.java @@ -183,4 +183,23 @@ class DefaultPlanProcessTest 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 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"); + }); + } } -- 2.54.0 From 6dea78881d15d14d6ea3edb50ae06ff2df1c8df5 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 17:10:21 -0500 Subject: [PATCH 3/3] feat: host-scheduler config so a host that ticks plans itself is not shown a false needs-setup Co-Authored-By: Claude Opus 5.5 --- .../qbits/backups/BackupsQBitConfig.java | 47 +++++++++++++++++++ .../qbits/backups/app/BackupsSetupStatus.java | 26 ++++++++-- .../backups/app/BackupsSetupStatusTest.java | 19 ++++++++ 3 files changed, 88 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/kingsrook/qbits/backups/BackupsQBitConfig.java b/src/main/java/com/kingsrook/qbits/backups/BackupsQBitConfig.java index f0fb776..0353f74 100644 --- a/src/main/java/com/kingsrook/qbits/backups/BackupsQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/backups/BackupsQBitConfig.java @@ -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) *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/backups/app/BackupsSetupStatus.java b/src/main/java/com/kingsrook/qbits/backups/app/BackupsSetupStatus.java index 981deba..62e142f 100644 --- a/src/main/java/com/kingsrook/qbits/backups/app/BackupsSetupStatus.java +++ b/src/main/java/com/kingsrook/qbits/backups/app/BackupsSetupStatus.java @@ -72,7 +72,8 @@ public final class BackupsSetupStatus public static List load(List plans) { BackupsQBitConfig config = BackupsRuntime.isInstalled() ? BackupsRuntime.getConfig() : null; - return (load(plans, config != null && hasContent(config.getSchedulerName()))); + return (load(plans, config != null && hasContent(config.getSchedulerName()), config == null ? null : config.getHostSchedulerDescription(), + config != null && Boolean.TRUE.equals(config.getHostSchedulerRunsVerification()))); } @@ -83,6 +84,19 @@ public final class BackupsSetupStatus *******************************************************************************/ public static List load(List 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 load(List plans, boolean schedulerConfigured, String hostScheduler, boolean hostRunsVerification) + { + boolean host = hasContent(hostScheduler); List items = new ArrayList<>(); List active = plans.stream().filter(p -> Boolean.TRUE.equals(p.getValueBoolean("isActive"))).toList(); @@ -147,18 +161,22 @@ public final class BackupsSetupStatus ///////////////////////////////////////// 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 + 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.", null)); + : 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(!schedulerConfigured + 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 diff --git a/src/test/java/com/kingsrook/qbits/backups/app/BackupsSetupStatusTest.java b/src/test/java/com/kingsrook/qbits/backups/app/BackupsSetupStatusTest.java index f70cc58..c8e5028 100644 --- a/src/test/java/com/kingsrook/qbits/backups/app/BackupsSetupStatusTest.java +++ b/src/test/java/com/kingsrook/qbits/backups/app/BackupsSetupStatusTest.java @@ -138,6 +138,25 @@ class BackupsSetupStatusTest + /******************************************************************************* + ** 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 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. *******************************************************************************/ -- 2.54.0