Compare commits
7
Commits
main
...
feature/restore
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b928920989
|
||
|
|
758ef90cc1 | ||
|
|
eb481a475a
|
||
|
|
3d7343c394 | ||
|
|
eea9f63878
|
||
|
|
2f520e9f15 | ||
|
|
7ef0849a02 |
No files matched your search
@@ -57,7 +57,7 @@ workflows:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/v.*/]
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
|
||||
publish_hotfix_release:
|
||||
jobs:
|
||||
|
||||
@@ -72,6 +72,20 @@
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- optional: the BackupsHealthIndicator (apps without qqq-middleware-health never load it) -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-health</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- optional: ESB events (apps without qqq-esb never load it) -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-esb</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
|
||||
@@ -18,16 +18,23 @@ package com.kingsrook.qbits.backups;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
import com.kingsrook.qbits.backups.codec.ArtifactTransform;
|
||||
import com.kingsrook.qbits.backups.verify.ScratchTarget;
|
||||
import com.kingsrook.qbits.backups.storage.BackupStorage;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Host-facing configuration for the Backups QBit.
|
||||
** Configuration of the Backups qbit.
|
||||
**
|
||||
** Tables are named backups_<name> (lowercase snake_case, backend name equal
|
||||
** to the table name); permissions are backups.<action>.
|
||||
** Storage is resolved through a function from a bucket alias (the value of
|
||||
** backups_plan.storageBucketAlias) to a BackupStorage. The production resolver
|
||||
** is backed by qbit-object-storage; tests and local development use the
|
||||
** in-memory implementation. No credential is ever held here or in a table.
|
||||
*******************************************************************************/
|
||||
public class BackupsQBitConfig implements QBitConfig
|
||||
{
|
||||
@@ -35,6 +42,24 @@ public class BackupsQBitConfig implements QBitConfig
|
||||
public static final String PERMISSION_BASE_NAME = "backups";
|
||||
|
||||
private String backendName;
|
||||
private String appName = "backups";
|
||||
private String appLabel = "Backups";
|
||||
private String parentAppName;
|
||||
private String schedulerName;
|
||||
private String instanceName;
|
||||
|
||||
private Integer maxConcurrentRuns = 1;
|
||||
private Integer slaCheckIntervalSeconds = 300;
|
||||
private Boolean openWhenAnonymous = false;
|
||||
private Boolean prometheusEndpoint = false;
|
||||
|
||||
private Function<String, BackupStorage> storageResolver;
|
||||
|
||||
private Integer fileReverifyDays = 30;
|
||||
private Function<String, ArtifactTransform> artifactTransformResolver;
|
||||
private ScratchTarget scratchTarget;
|
||||
private Predicate<String> planOwnerValidator;
|
||||
private String esbDestinationName;
|
||||
|
||||
|
||||
|
||||
@@ -89,4 +114,319 @@ public class BackupsQBitConfig implements QBitConfig
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for appName
|
||||
*******************************************************************************/
|
||||
public String getAppName()
|
||||
{
|
||||
return (appName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for appName
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withAppName(String appName)
|
||||
{
|
||||
this.appName = appName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for appLabel
|
||||
*******************************************************************************/
|
||||
public String getAppLabel()
|
||||
{
|
||||
return (appLabel);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for appLabel
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withAppLabel(String appLabel)
|
||||
{
|
||||
this.appLabel = appLabel;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for parentAppName (for example "admin", so this is Admin, Backups).
|
||||
*******************************************************************************/
|
||||
public String getParentAppName()
|
||||
{
|
||||
return (parentAppName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for parentAppName
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withParentAppName(String parentAppName)
|
||||
{
|
||||
this.parentAppName = parentAppName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for schedulerName (the QQQ scheduler used for plan jobs and the SLA checker)
|
||||
*******************************************************************************/
|
||||
public String getSchedulerName()
|
||||
{
|
||||
return (schedulerName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for schedulerName
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withSchedulerName(String schedulerName)
|
||||
{
|
||||
this.schedulerName = schedulerName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for instanceName (used in object keys); defaults to the QInstance name
|
||||
*******************************************************************************/
|
||||
public String getInstanceName()
|
||||
{
|
||||
return (instanceName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for instanceName
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withInstanceName(String instanceName)
|
||||
{
|
||||
this.instanceName = instanceName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for maxConcurrentRuns
|
||||
*******************************************************************************/
|
||||
public Integer getMaxConcurrentRuns()
|
||||
{
|
||||
return (maxConcurrentRuns);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for maxConcurrentRuns
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withMaxConcurrentRuns(Integer maxConcurrentRuns)
|
||||
{
|
||||
this.maxConcurrentRuns = maxConcurrentRuns;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for slaCheckIntervalSeconds
|
||||
*******************************************************************************/
|
||||
public Integer getSlaCheckIntervalSeconds()
|
||||
{
|
||||
return (slaCheckIntervalSeconds);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for slaCheckIntervalSeconds
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withSlaCheckIntervalSeconds(Integer slaCheckIntervalSeconds)
|
||||
{
|
||||
this.slaCheckIntervalSeconds = slaCheckIntervalSeconds;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for openWhenAnonymous (local development only)
|
||||
*******************************************************************************/
|
||||
public Boolean getOpenWhenAnonymous()
|
||||
{
|
||||
return (openWhenAnonymous);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for openWhenAnonymous
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withOpenWhenAnonymous(Boolean openWhenAnonymous)
|
||||
{
|
||||
this.openWhenAnonymous = openWhenAnonymous;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for prometheusEndpoint
|
||||
*******************************************************************************/
|
||||
public Boolean getPrometheusEndpoint()
|
||||
{
|
||||
return (prometheusEndpoint);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for prometheusEndpoint
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withPrometheusEndpoint(Boolean prometheusEndpoint)
|
||||
{
|
||||
this.prometheusEndpoint = prometheusEndpoint;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for storageResolver
|
||||
*******************************************************************************/
|
||||
public Function<String, BackupStorage> getStorageResolver()
|
||||
{
|
||||
return (storageResolver);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for storageResolver: bucket alias to storage
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withStorageResolver(Function<String, BackupStorage> storageResolver)
|
||||
{
|
||||
this.storageResolver = storageResolver;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fileReverifyDays: CONTENT verification skips file-store objects
|
||||
** verified more recently than this.
|
||||
*******************************************************************************/
|
||||
public Integer getFileReverifyDays()
|
||||
{
|
||||
return (fileReverifyDays);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for fileReverifyDays
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withFileReverifyDays(Integer fileReverifyDays)
|
||||
{
|
||||
this.fileReverifyDays = fileReverifyDays;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for artifactTransformResolver (the decrypt hook for verify)
|
||||
*******************************************************************************/
|
||||
public Function<String, ArtifactTransform> getArtifactTransformResolver()
|
||||
{
|
||||
return (artifactTransformResolver);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for artifactTransformResolver: encryption key reference (the name
|
||||
** of a key in a key provider) to the transform that decrypts what that key wrote.
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withArtifactTransformResolver(Function<String, ArtifactTransform> artifactTransformResolver)
|
||||
{
|
||||
this.artifactTransformResolver = artifactTransformResolver;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for scratchTarget (where TEST_RESTORE restores to; null means MEMORY)
|
||||
*******************************************************************************/
|
||||
public ScratchTarget getScratchTarget()
|
||||
{
|
||||
return (scratchTarget);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for scratchTarget
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withScratchTarget(ScratchTarget scratchTarget)
|
||||
{
|
||||
this.scratchTarget = scratchTarget;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for planOwnerValidator (true when the owner still exists and holds backups.admin)
|
||||
*******************************************************************************/
|
||||
public Predicate<String> getPlanOwnerValidator()
|
||||
{
|
||||
return (planOwnerValidator);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for planOwnerValidator: with none, plan owners are not checked.
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withPlanOwnerValidator(Predicate<String> planOwnerValidator)
|
||||
{
|
||||
this.planOwnerValidator = planOwnerValidator;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for esbDestinationName (ESB events are published here, when set)
|
||||
*******************************************************************************/
|
||||
public String getEsbDestinationName()
|
||||
{
|
||||
return (esbDestinationName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for esbDestinationName
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withEsbDestinationName(String esbDestinationName)
|
||||
{
|
||||
this.esbDestinationName = esbDestinationName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -100,4 +100,62 @@ public class BackupsQBitProducer implements QBitMetaDataProducer<BackupsQBitConf
|
||||
return (qBitConfig != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build everything and add it to the instance: possible-value sources, joins,
|
||||
** tables, processes, widgets and the app.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput produce(com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance) throws com.kingsrook.qqq.backend.core.exceptions.QException
|
||||
{
|
||||
java.util.List<String> errors = new java.util.ArrayList<>();
|
||||
qBitConfig.validate(qInstance, errors);
|
||||
if(!errors.isEmpty())
|
||||
{
|
||||
throw (new com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfigValidationException(qBitConfig, errors));
|
||||
}
|
||||
QBitMetaData qBit = getQBitMetaData();
|
||||
qInstance.addQBit(qBit);
|
||||
BackupsRuntime.install(qBitConfig);
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// bind the Alerts qbit producer SPI when it is on the class path (optional) //
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
if(com.kingsrook.qbits.backups.alerts.AlertsQBitAlertSink.bindIfPresent())
|
||||
{
|
||||
new com.kingsrook.qbits.backups.alerts.AlertsQBitAlertSink().registerAlertTypes(qInstance);
|
||||
}
|
||||
|
||||
com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput output = new com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput();
|
||||
for(var possibleValueSource : com.kingsrook.qbits.backups.model.BackupsTables.possibleValueSources())
|
||||
{
|
||||
output.add(possibleValueSource);
|
||||
}
|
||||
for(var join : com.kingsrook.qbits.backups.model.BackupsTables.joins())
|
||||
{
|
||||
output.add(join);
|
||||
}
|
||||
java.util.List<com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData> tables = new java.util.ArrayList<>();
|
||||
for(var table : com.kingsrook.qbits.backups.model.BackupsTables.tables(qBitConfig))
|
||||
{
|
||||
var customized = qBitConfig.getTableMetaDataCustomizer() == null ? table : qBitConfig.getTableMetaDataCustomizer().customizeMetaData(qInstance, table);
|
||||
customized.setSourceQBitName(qBit.getName());
|
||||
tables.add(customized);
|
||||
output.add(customized);
|
||||
}
|
||||
for(var process : com.kingsrook.qbits.backups.processes.BackupsProcesses.processes())
|
||||
{
|
||||
process.setSourceQBitName(qBit.getName());
|
||||
output.add(process);
|
||||
}
|
||||
for(var widget : com.kingsrook.qbits.backups.app.BackupsApp.widgets())
|
||||
{
|
||||
output.add(widget);
|
||||
}
|
||||
output.add(com.kingsrook.qbits.backups.app.BackupsApp.app(qBitConfig, tables));
|
||||
output.addSelfToInstance(qInstance);
|
||||
return (new com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
|
||||
import java.time.Clock;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Function;
|
||||
import com.kingsrook.qbits.backups.alerts.BackupAlertSink;
|
||||
import com.kingsrook.qbits.backups.alerts.NoOpBackupAlertSink;
|
||||
import com.kingsrook.qbits.backups.storage.BackupStorage;
|
||||
import com.kingsrook.qbits.backups.storage.BackupStorageException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Process-wide state of the installed qbit: the config, the clock, the alert
|
||||
** sink and the per-process permission map. Static because QQQ instantiates
|
||||
** steps and permission checkers by class reference.
|
||||
*******************************************************************************/
|
||||
public final class BackupsRuntime
|
||||
{
|
||||
private static final Map<String, String> PROCESS_PERMISSIONS = new ConcurrentHashMap<>();
|
||||
|
||||
private static volatile BackupsQBitConfig config;
|
||||
private static volatile Clock clock = Clock.systemUTC();
|
||||
private static volatile BackupAlertSink alertSink = new NoOpBackupAlertSink();
|
||||
private static volatile Function<String, String> environment = System::getenv;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private BackupsRuntime()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Install the config (called by the producer).
|
||||
*******************************************************************************/
|
||||
public static void install(BackupsQBitConfig newConfig)
|
||||
{
|
||||
config = newConfig;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Forget everything (tests).
|
||||
*******************************************************************************/
|
||||
public static void reset()
|
||||
{
|
||||
config = null;
|
||||
clock = Clock.systemUTC();
|
||||
alertSink = new NoOpBackupAlertSink();
|
||||
environment = System::getenv;
|
||||
PROCESS_PERMISSIONS.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The installed config, or null.
|
||||
*******************************************************************************/
|
||||
public static BackupsQBitConfig getConfig()
|
||||
{
|
||||
return (config);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the qbit is installed.
|
||||
*******************************************************************************/
|
||||
public static boolean isInstalled()
|
||||
{
|
||||
return (config != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether permissions are open on fully anonymous instances (local development).
|
||||
*******************************************************************************/
|
||||
public static boolean isOpenWhenAnonymous()
|
||||
{
|
||||
return (config != null && Boolean.TRUE.equals(config.getOpenWhenAnonymous()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The clock (replaceable in tests).
|
||||
*******************************************************************************/
|
||||
public static Clock getClock()
|
||||
{
|
||||
return (clock);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Replace the clock.
|
||||
*******************************************************************************/
|
||||
public static void setClock(Clock newClock)
|
||||
{
|
||||
clock = newClock;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The alert sink.
|
||||
*******************************************************************************/
|
||||
public static BackupAlertSink getAlertSink()
|
||||
{
|
||||
return (alertSink);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind an alert sink (the Alerts qbit adapter, or a test double).
|
||||
*******************************************************************************/
|
||||
public static void setAlertSink(BackupAlertSink newSink)
|
||||
{
|
||||
alertSink = newSink == null ? new NoOpBackupAlertSink() : newSink;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Environment lookup (replaceable in tests).
|
||||
*******************************************************************************/
|
||||
public static Function<String, String> getEnvironment()
|
||||
{
|
||||
return (environment);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Replace the environment lookup.
|
||||
*******************************************************************************/
|
||||
public static void setEnvironment(Function<String, String> newEnvironment)
|
||||
{
|
||||
environment = newEnvironment;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The permission each qbit process needs, by process name.
|
||||
*******************************************************************************/
|
||||
public static Map<String, String> processPermissions()
|
||||
{
|
||||
return (PROCESS_PERMISSIONS);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the storage for a bucket alias.
|
||||
*******************************************************************************/
|
||||
public static BackupStorage storageFor(String bucketAlias)
|
||||
{
|
||||
if(config == null || config.getStorageResolver() == null)
|
||||
{
|
||||
throw (new BackupStorageException(BackupStorageException.Reason.UNSUPPORTED, "No storage resolver is configured for the Backups qbit"));
|
||||
}
|
||||
BackupStorage storage = config.getStorageResolver().apply(bucketAlias);
|
||||
if(storage == null)
|
||||
{
|
||||
throw (new BackupStorageException(BackupStorageException.Reason.NOT_FOUND, "No storage bucket is configured with the alias: " + bucketAlias));
|
||||
}
|
||||
return (storage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.alerts;
|
||||
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The BackupAlertSink adapter for the Alerts qbit producer SPI (QRun-IO/qqq
|
||||
** PR #1108, com.kingsrook.qqq.backend.core.alerts: QAlerts, AlertSignal,
|
||||
** AlertResolution, AlertSeverity, QAlertTypeMetaData; design spec
|
||||
** docs/specs/2026-10-12-qbit-alerts-design.md).
|
||||
**
|
||||
** STAND-IN: the SPI is an open core PR and is not in QQQ 4.1.0-RC.4, so this
|
||||
** adapter is bound REFLECTIVELY and optionally. It compiles against nothing in
|
||||
** the SPI; it looks the classes up by name, and every call is guarded:
|
||||
** <ul>
|
||||
** <li>no SPI classes on the class path: {@link #bindIfPresent()} binds nothing
|
||||
** and the sink stays the no-op;</li>
|
||||
** <li>the SPI is present but the instance has no alerts meta-data (the Alerts
|
||||
** qbit is not installed): every call is a no-op;</li>
|
||||
** <li>any reflective or SPI failure is logged once and swallowed, because an
|
||||
** alerting problem must never fail a backup.</li>
|
||||
** </ul>
|
||||
** Replace with a direct binding (QAlerts.raise and friends) when RC.5 ships.
|
||||
**
|
||||
** Mapping: raise to QAlerts.raise(AlertSignal), resolve to QAlerts.resolve(type,
|
||||
** dedupKey, note), heartbeat to QAlerts.heartbeat(key). The SPI has no per-plan
|
||||
** heartbeat registration (monitors are declared on the alert type), so the
|
||||
** expected interval and grace are not passed on; the SLA checker raises
|
||||
** backups.plan.missed itself, independent of any heartbeat (spec 10.4).
|
||||
*******************************************************************************/
|
||||
public class AlertsQBitAlertSink implements BackupAlertSink
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AlertsQBitAlertSink.class);
|
||||
|
||||
/** The package of the Alerts producer SPI in qqq-backend-core. */
|
||||
public static final String DEFAULT_API_PACKAGE = "com.kingsrook.qqq.backend.core.alerts";
|
||||
|
||||
private final String apiPackage;
|
||||
|
||||
private boolean warned = false;
|
||||
|
||||
private Class<?> qAlerts;
|
||||
private Class<?> alertSignal;
|
||||
private Class<?> alertSeverity;
|
||||
private Class<?> instanceMetaData;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor for the real SPI package.
|
||||
*******************************************************************************/
|
||||
public AlertsQBitAlertSink()
|
||||
{
|
||||
this(DEFAULT_API_PACKAGE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor for another package (a test double of the SPI shape).
|
||||
*******************************************************************************/
|
||||
public AlertsQBitAlertSink(String apiPackage)
|
||||
{
|
||||
this.apiPackage = apiPackage;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the SPI classes are on the class path.
|
||||
*******************************************************************************/
|
||||
public static boolean isPresent(String apiPackage)
|
||||
{
|
||||
try
|
||||
{
|
||||
Class.forName(apiPackage + ".QAlerts");
|
||||
Class.forName(apiPackage + ".AlertSignal");
|
||||
return (true);
|
||||
}
|
||||
catch(ClassNotFoundException | LinkageError e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind this adapter as the runtime's alert sink when the SPI is present and no
|
||||
** other sink was bound already. Returns whether it bound.
|
||||
*******************************************************************************/
|
||||
public static boolean bindIfPresent()
|
||||
{
|
||||
if(!(BackupsRuntime.getAlertSink() instanceof NoOpBackupAlertSink) || !isPresent(DEFAULT_API_PACKAGE))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
BackupsRuntime.setAlertSink(new AlertsQBitAlertSink());
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare the backup alert types with the Alerts qbit (QAlertTypeMetaData), when
|
||||
** the SPI is present. Returns how many were added.
|
||||
*******************************************************************************/
|
||||
public int registerAlertTypes(QInstance qInstance)
|
||||
{
|
||||
int added = 0;
|
||||
try
|
||||
{
|
||||
Class<?> typeClass = Class.forName(apiPackage + ".QAlertTypeMetaData");
|
||||
load();
|
||||
for(BackupAlerts.AlertType alertType : BackupAlerts.alertTypes())
|
||||
{
|
||||
try
|
||||
{
|
||||
Object type = typeClass.getConstructor().newInstance();
|
||||
typeClass.getMethod("withName", String.class).invoke(type, alertType.name());
|
||||
typeClass.getMethod("withLabel", String.class).invoke(type, alertType.label());
|
||||
typeClass.getMethod("withGroupName", String.class).invoke(type, "Backups");
|
||||
typeClass.getMethod("withHowToFix", String.class).invoke(type, alertType.howToFix());
|
||||
typeClass.getMethod("withDefaultSeverity", alertSeverity).invoke(type, severity(alertType.defaultSeverity()));
|
||||
typeClass.getMethod("addSelfToInstance", QInstance.class).invoke(type, qInstance);
|
||||
added++;
|
||||
}
|
||||
catch(ReflectiveOperationException | RuntimeException e)
|
||||
{
|
||||
warnOnce("Could not declare the alert type " + alertType.name(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(ReflectiveOperationException | LinkageError e)
|
||||
{
|
||||
warnOnce("The alerts SPI is not available", e);
|
||||
}
|
||||
return (added);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void raise(String type, String dedupeKey, Severity severity, String title, Map<String, String> detail)
|
||||
{
|
||||
if(!isAlertsQBitInstalled())
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
Object signal = alertSignal.getConstructor(String.class).newInstance(type);
|
||||
alertSignal.getMethod("setDedupKey", String.class).invoke(signal, dedupeKey);
|
||||
alertSignal.getMethod("setSeverity", alertSeverity).invoke(signal, severity(severity));
|
||||
alertSignal.getMethod("setTitle", String.class).invoke(signal, title);
|
||||
if(detail != null && !detail.isEmpty())
|
||||
{
|
||||
alertSignal.getMethod("setSummary", String.class).invoke(signal, detail.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()).collect(Collectors.joining(", ")));
|
||||
Method withAttribute = alertSignal.getMethod("withAttribute", String.class, String.class);
|
||||
for(Map.Entry<String, String> entry : detail.entrySet())
|
||||
{
|
||||
withAttribute.invoke(signal, entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
qAlerts.getMethod("raise", alertSignal).invoke(null, signal);
|
||||
}
|
||||
catch(ReflectiveOperationException | RuntimeException e)
|
||||
{
|
||||
warnOnce("Could not raise an alert", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void resolve(String type, String dedupeKey)
|
||||
{
|
||||
if(!isAlertsQBitInstalled())
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
qAlerts.getMethod("resolve", String.class, String.class, String.class).invoke(null, type, dedupeKey, "Cleared by the Backups qbit");
|
||||
}
|
||||
catch(ReflectiveOperationException | RuntimeException e)
|
||||
{
|
||||
warnOnce("Could not resolve an alert", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void heartbeat(String monitorKey, long expectedEverySeconds, long graceSeconds)
|
||||
{
|
||||
if(!isAlertsQBitInstalled())
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
qAlerts.getMethod("heartbeat", String.class).invoke(null, monitorKey);
|
||||
}
|
||||
catch(ReflectiveOperationException | RuntimeException e)
|
||||
{
|
||||
warnOnce("Could not check in a heartbeat", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The SPI classes load, and the instance has alerts meta-data (the Alerts qbit
|
||||
** is installed). Checked on every call so the order qbits are produced in
|
||||
** does not matter.
|
||||
*******************************************************************************/
|
||||
boolean isAlertsQBitInstalled()
|
||||
{
|
||||
try
|
||||
{
|
||||
load();
|
||||
QInstance qInstance = QContext.getQInstance();
|
||||
if(qInstance == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (instanceMetaData.getMethod("of", QInstance.class).invoke(null, qInstance) != null);
|
||||
}
|
||||
catch(ReflectiveOperationException | RuntimeException | LinkageError e)
|
||||
{
|
||||
warnOnce("The alerts SPI is not usable", e);
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private synchronized void load() throws ClassNotFoundException
|
||||
{
|
||||
if(qAlerts == null)
|
||||
{
|
||||
qAlerts = Class.forName(apiPackage + ".QAlerts");
|
||||
alertSignal = Class.forName(apiPackage + ".AlertSignal");
|
||||
alertSeverity = Class.forName(apiPackage + ".AlertSeverity");
|
||||
instanceMetaData = Class.forName(apiPackage + ".AlertsInstanceMetaData");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Map our severity to the SPI's enum constant of the same name.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings({ "unchecked", "rawtypes" })
|
||||
private Object severity(Severity severity)
|
||||
{
|
||||
return (Enum.valueOf((Class<Enum>) alertSeverity, severity.name()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void warnOnce(String message, Throwable t)
|
||||
{
|
||||
if(!warned)
|
||||
{
|
||||
warned = true;
|
||||
LOG.warn(message + " (the Alerts qbit adapter will keep failing quietly)", t, logPair("apiPackage", apiPackage));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.alerts;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Where backups raises and resolves alerts. The adapter for the Alerts qbit
|
||||
** producer SPI binds here when that qbit is installed; otherwise the no-op
|
||||
** sink is used and every condition is still visible on the Backups home and in
|
||||
** the tables. An alert has a stable dedupe key; counts and links only, never
|
||||
** values, keys or presigned URLs.
|
||||
*******************************************************************************/
|
||||
public interface BackupAlertSink
|
||||
{
|
||||
|
||||
/***************************************************************************
|
||||
** Raise (or refresh) an alert.
|
||||
**
|
||||
** @param type alert type, e.g. backups.plan.missed
|
||||
** @param dedupeKey stable key, type plus plan or run
|
||||
** @param severity severity
|
||||
** @param title short title
|
||||
** @param detail counts and context only
|
||||
***************************************************************************/
|
||||
void raise(String type, String dedupeKey, Severity severity, String title, Map<String, String> detail);
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Resolve an alert that was raised under the dedupe key.
|
||||
***************************************************************************/
|
||||
void resolve(String type, String dedupeKey);
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Check in a heartbeat monitor: the plan ran successfully.
|
||||
***************************************************************************/
|
||||
void heartbeat(String monitorKey, long expectedEverySeconds, long graceSeconds);
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Severities.
|
||||
***************************************************************************/
|
||||
enum Severity
|
||||
{
|
||||
INFO, WARNING, ERROR, CRITICAL
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.alerts;
|
||||
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every alert the Backups qbit raises (design spec section 10.4), with stable
|
||||
** dedupe keys, and helpers that send them through the bound BackupAlertSink.
|
||||
**
|
||||
** A dedupe key is the alert type plus a plan key (or a run key, or a plan key
|
||||
** and a verify level), so a repeated condition refreshes one alert and a cleared
|
||||
** condition resolves exactly that alert. Details carry counts and names only:
|
||||
** never row values, object keys with secrets in them, or presigned URLs.
|
||||
*******************************************************************************/
|
||||
public final class BackupAlerts
|
||||
{
|
||||
public static final String PLAN_MISSED = "backups.plan.missed";
|
||||
public static final String RUN_FAILED = "backups.run.failed";
|
||||
public static final String RUN_MISSING = "backups.run.missing_in_storage";
|
||||
public static final String FILES_MISSING = "backups.files.missing";
|
||||
public static final String VERIFY_FAILED = "backups.verify.failed";
|
||||
public static final String VERIFY_OVERDUE = "backups.verify.overdue";
|
||||
public static final String PLAN_OWNER_INVALID = "backups.plan.owner_invalid";
|
||||
public static final String STORAGE_DEBRIS = "backups.storage.debris";
|
||||
public static final String RESTORE_COMPLETED = "backups.restore.completed";
|
||||
|
||||
/** The prefix of the heartbeat monitor key of a plan. */
|
||||
public static final String HEARTBEAT_PREFIX = "backups.plan:";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A declared alert type, for the Alerts qbit's type registry.
|
||||
**
|
||||
** @param name the alert type name
|
||||
** @param label a short label
|
||||
** @param defaultSeverity the severity when a signal does not say
|
||||
** @param howToFix one sentence for the person looking at the alert
|
||||
***************************************************************************/
|
||||
public record AlertType(String name, String label, BackupAlertSink.Severity defaultSeverity, String howToFix)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private BackupAlerts()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The alert types of the spec table (section 10.4), in table order.
|
||||
*******************************************************************************/
|
||||
public static List<AlertType> alertTypes()
|
||||
{
|
||||
return (List.of(
|
||||
new AlertType(PLAN_MISSED, "Backup did not run", BackupAlertSink.Severity.ERROR,
|
||||
"Open the plan and its latest runs, check the schedule and the scheduler, then run the backup by hand."),
|
||||
new AlertType(RUN_FAILED, "Backup run failed", BackupAlertSink.Severity.WARNING,
|
||||
"Open the failed run for the error, fix the cause, and run the plan again."),
|
||||
new AlertType(RUN_MISSING, "Backup is missing from storage", BackupAlertSink.Severity.CRITICAL,
|
||||
"The catalog says the run succeeded but its manifest is gone from the bucket. Check the bucket, its lifecycle rules and the credentials, then run a new backup."),
|
||||
new AlertType(FILES_MISSING, "Backed up files were missing at the source", BackupAlertSink.Severity.WARNING,
|
||||
"Open the run's file index: records point at files that no longer exist."),
|
||||
new AlertType(VERIFY_FAILED, "Backup verification failed", BackupAlertSink.Severity.ERROR,
|
||||
"Open the verification for the mismatches. Treat the run as unusable and take a new backup."),
|
||||
new AlertType(VERIFY_OVERDUE, "Backup verification is overdue", BackupAlertSink.Severity.WARNING,
|
||||
"Run Verify backup on a recent run of the plan, or set a verify schedule on the plan."),
|
||||
new AlertType(PLAN_OWNER_INVALID, "Backup plan owner is not valid", BackupAlertSink.Severity.WARNING,
|
||||
"Set an owner who exists and holds backups.admin on the plan."),
|
||||
new AlertType(STORAGE_DEBRIS, "Backup storage has debris or orphans", BackupAlertSink.Severity.INFO,
|
||||
"Prune clears debris. Orphan prefixes can be imported into the catalog or deleted."),
|
||||
new AlertType(RESTORE_COMPLETED, "A restore finished", BackupAlertSink.Severity.INFO,
|
||||
"Nothing to fix unless the restore failed. Open the restore run.")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The stable dedupe key for a type and an id (plan key, run key...).
|
||||
*******************************************************************************/
|
||||
public static String key(String type, String id)
|
||||
{
|
||||
return (type + ":" + id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The dedupe key of a verify failure: it includes the level so a pass at a
|
||||
** higher level clears the lower failures, and a cheap pass never hides a
|
||||
** content failure.
|
||||
*******************************************************************************/
|
||||
public static String verifyKey(String planKey, String level)
|
||||
{
|
||||
return (VERIFY_FAILED + ":" + planKey + ":" + level);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Raise (or refresh) an alert. Never throws: an alerting problem must not fail
|
||||
** a backup or a check.
|
||||
*******************************************************************************/
|
||||
public static void raise(String type, String id, BackupAlertSink.Severity severity, String title, String... detailPairs)
|
||||
{
|
||||
try
|
||||
{
|
||||
Map<String, String> detail = new LinkedHashMap<>();
|
||||
for(int i = 0; i + 1 < detailPairs.length; i += 2)
|
||||
{
|
||||
detail.put(detailPairs[i], detailPairs[i + 1]);
|
||||
}
|
||||
BackupsRuntime.getAlertSink().raise(type, key(type, id), severity, title, detail);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
// fire and forget
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Raise with an explicit dedupe key.
|
||||
*******************************************************************************/
|
||||
public static void raiseWithKey(String type, String dedupeKey, BackupAlertSink.Severity severity, String title, Map<String, String> detail)
|
||||
{
|
||||
try
|
||||
{
|
||||
BackupsRuntime.getAlertSink().raise(type, dedupeKey, severity, title, detail);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
// fire and forget
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the alert of a type and id. Never throws.
|
||||
*******************************************************************************/
|
||||
public static void resolve(String type, String id)
|
||||
{
|
||||
resolveKey(type, key(type, id));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve an alert by its dedupe key. Never throws.
|
||||
*******************************************************************************/
|
||||
public static void resolveKey(String type, String dedupeKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
BackupsRuntime.getAlertSink().resolve(type, dedupeKey);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
// fire and forget
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A restore finished: an info alert, so restores are never silent (error when
|
||||
** it failed). The Alerts qbit clears it after 7 days; nothing resolves it here.
|
||||
*******************************************************************************/
|
||||
public static void restoreCompleted(String restoreRunKey, String status, String planKey, long rowsRestored)
|
||||
{
|
||||
boolean failed = "FAILED".equals(status);
|
||||
raise(RESTORE_COMPLETED, restoreRunKey, failed ? BackupAlertSink.Severity.ERROR : BackupAlertSink.Severity.INFO,
|
||||
failed ? "A restore failed" : "A restore finished",
|
||||
"restoreRun", restoreRunKey, "status", String.valueOf(status), "plan", String.valueOf(planKey), "rows", String.valueOf(rowsRestored));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.alerts;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The sink used when the Alerts qbit is not installed.
|
||||
*******************************************************************************/
|
||||
public class NoOpBackupAlertSink implements BackupAlertSink
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void raise(String type, String dedupeKey, Severity severity, String title, Map<String, String> detail)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void resolve(String type, String dedupeKey)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void heartbeat(String monitorKey, long expectedEverySeconds, long graceSeconds)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import com.kingsrook.qbits.backups.BackupsQBitConfig;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Backups app (a sub-app under Admin when configured) and its widgets. The
|
||||
** home is the single owner of backup data: every card is built from the qbit
|
||||
** tables, and each has an honest empty state. The nav lists the main tables;
|
||||
** sub-tables appear as child lists on their parents.
|
||||
*******************************************************************************/
|
||||
public final class BackupsApp
|
||||
{
|
||||
public static final String HOME_WIDGET = "backupsHome";
|
||||
public static final String HEALTH_CARD_WIDGET = "backupsHealthCard";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private BackupsApp()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The widgets.
|
||||
*******************************************************************************/
|
||||
public static List<QWidgetMetaData> widgets()
|
||||
{
|
||||
return (List.of(
|
||||
new QWidgetMetaData().withName(HOME_WIDGET).withLabel("Backups").withType(WidgetType.HTML.getType())
|
||||
.withCodeReference(new QCodeReference(HomeRenderer.class)).withGridColumns(12).withIsCard(false).withPermissionRules(BackupsTables.permissionRules()),
|
||||
new QWidgetMetaData().withName(HEALTH_CARD_WIDGET).withLabel("Backups").withType(WidgetType.HTML.getType())
|
||||
.withCodeReference(new QCodeReference(HealthCardRenderer.class)).withGridColumns(12).withIsCard(true).withPermissionRules(BackupsTables.permissionRules())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The app: the main tables only; sub-tables are relations on their parents.
|
||||
*******************************************************************************/
|
||||
public static QAppMetaData app(BackupsQBitConfig config, List<QTableMetaData> tables)
|
||||
{
|
||||
QAppMetaData app = new QAppMetaData()
|
||||
.withName(config.getAppName())
|
||||
.withLabel(config.getAppLabel())
|
||||
.withIcon(new QIcon("backup"))
|
||||
.withPermissionRules(BackupsTables.permissionRules())
|
||||
.withWidgets(List.of(HOME_WIDGET))
|
||||
.withSection(new QAppSection().withName("backups").withLabel("Backups").withIcon(new QIcon("backup"))
|
||||
.withTables(List.of(BackupsTables.PLAN, BackupsTables.RUN)))
|
||||
.withSection(new QAppSection().withName("recovery").withLabel("Restore and verify").withIcon(new QIcon("restore"))
|
||||
.withTables(List.of(BackupsTables.RESTORE_RUN, BackupsTables.VERIFICATION)))
|
||||
.withSection(new QAppSection().withName("files").withLabel("Files").withIcon(new QIcon("attach_file"))
|
||||
.withTables(List.of(BackupsTables.FILE_OBJECT)));
|
||||
if(config.getParentAppName() != null)
|
||||
{
|
||||
app.setParentAppName(config.getParentAppName());
|
||||
}
|
||||
for(QTableMetaData table : tables)
|
||||
{
|
||||
if(BackupsTables.NAV_TABLES.contains(table.getName()))
|
||||
{
|
||||
app.withChild(table);
|
||||
}
|
||||
}
|
||||
return (app);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The home.
|
||||
*******************************************************************************/
|
||||
public static class HomeRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Render.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
return (new RenderWidgetOutput(new RawHTML(null, BackupsHomeView.render(BackupsHomeData.load(), pathPrefix()))));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The status card for Admin, Health.
|
||||
*******************************************************************************/
|
||||
public static class HealthCardRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Render.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
return (new RenderWidgetOutput(new RawHTML("Backups", BackupsHomeView.renderHealthCard(BackupsHomeData.load(), pathPrefix()))));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The path prefix of table and record links.
|
||||
*******************************************************************************/
|
||||
static String pathPrefix()
|
||||
{
|
||||
var config = com.kingsrook.qbits.backups.BackupsRuntime.getConfig();
|
||||
if(config == null)
|
||||
{
|
||||
return ("/backups");
|
||||
}
|
||||
return (config.getParentAppName() != null ? "/" + config.getParentAppName() + "/" + config.getAppName() : "/" + config.getAppName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/*
|
||||
* 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.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The data behind the Backups home: plan status, what needs attention, recent
|
||||
** activity and the size trend. Everything is read from the qbit tables; nothing
|
||||
** is invented. Home cards read the denormalized plan fields (slaStatus, last
|
||||
** success) that the processes and the SLA checker maintain.
|
||||
*******************************************************************************/
|
||||
public class BackupsHomeData
|
||||
{
|
||||
/** Plan statuses that need attention (the plan or its backups have a problem). */
|
||||
public static final List<String> ATTENTION = List.of("LATE", "FAILING", "MISSING_IN_STORAGE", "UNVERIFIED", "ABANDONED", "OWNER_INVALID");
|
||||
|
||||
private final List<QRecord> plans;
|
||||
private final List<QRecord> recentRuns;
|
||||
private final List<QRecord> failedRuns;
|
||||
private final List<QRecord> verifications;
|
||||
private final List<QRecord> restores;
|
||||
private final Instant now;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
BackupsHomeData(List<QRecord> plans, List<QRecord> recentRuns, List<QRecord> failedRuns, List<QRecord> verifications, List<QRecord> restores, Instant now)
|
||||
{
|
||||
this.plans = plans;
|
||||
this.recentRuns = recentRuns;
|
||||
this.failedRuns = failedRuns;
|
||||
this.verifications = verifications;
|
||||
this.restores = restores;
|
||||
this.now = now;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Load from the tables.
|
||||
*******************************************************************************/
|
||||
public static BackupsHomeData load() throws QException
|
||||
{
|
||||
BackupCatalog catalog = new BackupCatalog();
|
||||
Instant now = BackupsRuntime.getClock().instant();
|
||||
List<QRecord> plans = catalog.query(BackupsTables.PLAN, new QQueryFilter().withOrderBy(new QFilterOrderBy("name", true)));
|
||||
|
||||
QQueryFilter recent = new QQueryFilter().withOrderBy(new QFilterOrderBy("id", false));
|
||||
recent.setLimit(200);
|
||||
List<QRecord> runs = catalog.query(BackupsTables.RUN, recent);
|
||||
List<QRecord> failed = new ArrayList<>();
|
||||
Instant weekAgo = now.minus(Duration.ofDays(7));
|
||||
for(QRecord run : runs)
|
||||
{
|
||||
Instant start = run.getValueInstant("startDate");
|
||||
if(List.of("FAILED", "TIMED_OUT", "ABANDONED").contains(run.getValueString("status")) && (start == null || start.isAfter(weekAgo)))
|
||||
{
|
||||
failed.add(run);
|
||||
}
|
||||
}
|
||||
QQueryFilter verificationFilter = new QQueryFilter().withOrderBy(new QFilterOrderBy("id", false));
|
||||
verificationFilter.setLimit(100);
|
||||
QQueryFilter restoreFilter = new QQueryFilter().withOrderBy(new QFilterOrderBy("id", false));
|
||||
restoreFilter.setLimit(20);
|
||||
return (new BackupsHomeData(plans, runs, failed, catalog.query(BackupsTables.VERIFICATION, verificationFilter),
|
||||
catalog.query(BackupsTables.RESTORE_RUN, restoreFilter), now));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Plans.
|
||||
*******************************************************************************/
|
||||
public List<QRecord> plans()
|
||||
{
|
||||
return (plans);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recent runs (newest first).
|
||||
*******************************************************************************/
|
||||
public List<QRecord> recentRuns()
|
||||
{
|
||||
return (recentRuns);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Runs that failed in the last 7 days.
|
||||
*******************************************************************************/
|
||||
public List<QRecord> failedRuns()
|
||||
{
|
||||
return (failedRuns);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Verifications (newest first).
|
||||
*******************************************************************************/
|
||||
public List<QRecord> verifications()
|
||||
{
|
||||
return (verifications);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Restores (newest first).
|
||||
*******************************************************************************/
|
||||
public List<QRecord> restores()
|
||||
{
|
||||
return (restores);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The time the data was loaded.
|
||||
*******************************************************************************/
|
||||
public Instant now()
|
||||
{
|
||||
return (now);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Plans that need attention.
|
||||
*******************************************************************************/
|
||||
public List<QRecord> plansNeedingAttention()
|
||||
{
|
||||
return (plans.stream().filter(p -> Boolean.TRUE.equals(p.getValueBoolean("isActive")) && ATTENTION.contains(p.getValueString("slaStatus"))).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The overall state: NO_PLANS, ATTENTION or OK.
|
||||
*******************************************************************************/
|
||||
public String overallState()
|
||||
{
|
||||
if(plans.isEmpty())
|
||||
{
|
||||
return ("NO_PLANS");
|
||||
}
|
||||
return (plansNeedingAttention().isEmpty() && failedRuns.isEmpty() ? "OK" : "ATTENTION");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Hours since the last successful backup of a plan, or null when it has none.
|
||||
*******************************************************************************/
|
||||
public Long ageHours(QRecord plan)
|
||||
{
|
||||
Instant last = plan.getValueInstant("lastSuccessDate");
|
||||
return (last == null ? null : Duration.between(last, now).toHours());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The latest test restore of a plan, or null.
|
||||
*******************************************************************************/
|
||||
public QRecord latestTestRestore(Integer planId)
|
||||
{
|
||||
for(QRecord verification : verifications)
|
||||
{
|
||||
if("TEST_RESTORE".equals(verification.getValueString("level")))
|
||||
{
|
||||
Integer runId = verification.getValueInteger("backupRunId");
|
||||
for(QRecord run : recentRuns)
|
||||
{
|
||||
if(runId != null && runId.equals(run.getValueInteger("id")) && planId.equals(run.getValueInteger("backupPlanId")))
|
||||
{
|
||||
return (verification);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Successful runs of a plan, oldest first, for the trend (bytes stored, rows, duration).
|
||||
*******************************************************************************/
|
||||
public List<QRecord> successfulRuns(Integer planId)
|
||||
{
|
||||
List<QRecord> rs = new ArrayList<>();
|
||||
for(QRecord run : recentRuns)
|
||||
{
|
||||
if(planId.equals(run.getValueInteger("backupPlanId")) && run.getValueString("status") != null && run.getValueString("status").startsWith("SUCCEEDED"))
|
||||
{
|
||||
rs.add(0, run);
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Runs kept per retention class for a plan (as classified by the last prune).
|
||||
*******************************************************************************/
|
||||
public Map<String, Integer> retentionCounts(Integer planId)
|
||||
{
|
||||
Map<String, Integer> counts = new LinkedHashMap<>();
|
||||
for(QRecord run : recentRuns)
|
||||
{
|
||||
if(planId.equals(run.getValueInteger("backupPlanId")) && run.getValueString("status") != null && run.getValueString("status").startsWith("SUCCEEDED")
|
||||
&& run.getValue("prunedDate") == null)
|
||||
{
|
||||
counts.merge(run.getValueString("retentionClass") == null ? "UNCLASSIFIED" : run.getValueString("retentionClass"), 1, Integer::sum);
|
||||
}
|
||||
}
|
||||
return (counts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
/*
|
||||
* 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.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renders the Backups home: one card per concern, each with an honest empty
|
||||
** state. Cards stack to one column on narrow screens, long names wrap, and
|
||||
** there are no tables and no inner horizontal scroll (definition lists and
|
||||
** lists only), so it reads at every width in light and dark mode (colors are
|
||||
** inherited, not set).
|
||||
*******************************************************************************/
|
||||
public final class BackupsHomeView
|
||||
{
|
||||
private static final DateTimeFormatter WHEN = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm 'UTC'").withZone(ZoneOffset.UTC);
|
||||
|
||||
private static final String GRID = "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,22rem),1fr));gap:1rem;";
|
||||
private static final String CARD = "border:1px solid rgba(127,127,127,.3);border-radius:8px;padding:1rem;min-width:0;overflow-wrap:anywhere;";
|
||||
private static final String LIST = "display:grid;grid-template-columns:minmax(7rem,auto) 1fr;gap:.25rem .75rem;margin:0 0 .75rem;font-size:.9rem;";
|
||||
private static final String ITEMS = "margin:0 0 .75rem;padding-left:1.25rem;font-size:.9rem;";
|
||||
private static final String MUTED = "opacity:.75;";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private BackupsHomeView()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The whole home; pathPrefix is the prefix of table and record links.
|
||||
*******************************************************************************/
|
||||
public static String render(BackupsHomeData data, String pathPrefix)
|
||||
{
|
||||
StringBuilder html = new StringBuilder("<div class=\"backups-home\" style=\"" + GRID + "\">");
|
||||
planStatus(html, data, pathPrefix);
|
||||
needsAttention(html, data, pathPrefix);
|
||||
latestTestRestore(html, data, pathPrefix);
|
||||
trend(html, data);
|
||||
recentActivity(html, data, pathPrefix);
|
||||
retention(html, data);
|
||||
return (html.append("</div>").toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The one status card for Admin, Health.
|
||||
*******************************************************************************/
|
||||
public static String renderHealthCard(BackupsHomeData data, String pathPrefix)
|
||||
{
|
||||
String text = switch(data.overallState())
|
||||
{
|
||||
case "NO_PLANS" -> "No backup plans yet.";
|
||||
case "ATTENTION" -> "Needs attention: see the Backups home.";
|
||||
default -> "All backups are within their SLA.";
|
||||
};
|
||||
return ("<div style=\"" + CARD + "\"><strong>Backups</strong><p>" + esc(text) + "</p><a href=\"" + esc(pathPrefix) + "\">Open the Backups home</a></div>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Plan status: last successful backup, age against the SLA, last run, verification.
|
||||
*******************************************************************************/
|
||||
private static void planStatus(StringBuilder html, BackupsHomeData data, String prefix)
|
||||
{
|
||||
open(html, "Plan status");
|
||||
if(data.plans().isEmpty())
|
||||
{
|
||||
empty(html, "No backup plans yet. Create one from Backup plans, New.");
|
||||
}
|
||||
for(QRecord plan : data.plans())
|
||||
{
|
||||
Long age = data.ageHours(plan);
|
||||
Integer sla = plan.getValueInteger("slaHours");
|
||||
html.append("<dl style=\"").append(LIST).append("\">");
|
||||
html.append("<dt><strong>Plan</strong></dt><dd><a href=\"").append(esc(prefix + "/" + BackupsTables.PLAN + "/" + plan.getValue("id"))).append("\">")
|
||||
.append(esc(plan.getValueString("name"))).append("</a>").append(Boolean.TRUE.equals(plan.getValueBoolean("isActive")) ? "" : " (not active)").append("</dd>");
|
||||
row(html, "Status", label(plan.getValueString("slaStatus")));
|
||||
row(html, "Last backup", plan.getValueInstant("lastSuccessDate") == null ? "No successful backup yet" : WHEN.format(plan.getValueInstant("lastSuccessDate")));
|
||||
if(age != null && sla != null)
|
||||
{
|
||||
row(html, "Age against SLA", age + " h of " + sla + " h" + (age > sla ? " - late" : ""));
|
||||
}
|
||||
row(html, "Last verified", plan.getValueInstant("lastVerifiedDate") == null ? "Not verified yet" : WHEN.format(plan.getValueInstant("lastVerifiedDate")));
|
||||
row(html, "Schedule", plan.getValueString("cronExpression") == null ? "Manual only" : plan.getValueString("cronExpression"));
|
||||
if(plan.getValueString("objectLockMode") != null && !"NONE".equals(plan.getValueString("objectLockMode")))
|
||||
{
|
||||
row(html, "Object lock", plan.getValueString("objectLockMode") + ", " + plan.getValueInteger("objectLockDays") + " days");
|
||||
}
|
||||
html.append("</dl>");
|
||||
}
|
||||
link(html, prefix, BackupsTables.PLAN, "Backup plans");
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Needs attention.
|
||||
*******************************************************************************/
|
||||
private static void needsAttention(StringBuilder html, BackupsHomeData data, String prefix)
|
||||
{
|
||||
open(html, "Needs attention");
|
||||
List<QRecord> plans = data.plansNeedingAttention();
|
||||
if(data.plans().isEmpty())
|
||||
{
|
||||
empty(html, "Nothing to check yet: no plans.");
|
||||
}
|
||||
else if(plans.isEmpty() && data.failedRuns().isEmpty())
|
||||
{
|
||||
empty(html, "Nothing needs attention.");
|
||||
}
|
||||
else
|
||||
{
|
||||
html.append("<ul style=\"").append(ITEMS).append("\">");
|
||||
for(QRecord plan : plans)
|
||||
{
|
||||
html.append("<li><a href=\"").append(esc(prefix + "/" + BackupsTables.PLAN + "/" + plan.getValue("id"))).append("\">").append(esc(plan.getValueString("name")))
|
||||
.append("</a>: ").append(esc(label(plan.getValueString("slaStatus")))).append("</li>");
|
||||
}
|
||||
for(QRecord run : data.failedRuns())
|
||||
{
|
||||
html.append("<li><a href=\"").append(esc(prefix + "/" + BackupsTables.RUN + "/" + run.getValue("id"))).append("\">Run ").append(esc(run.getValueString("runKey")))
|
||||
.append("</a> ").append(esc(label(run.getValueString("status")))).append("</li>");
|
||||
}
|
||||
html.append("</ul>");
|
||||
}
|
||||
link(html, prefix, BackupsTables.RUN, "All runs");
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Latest test restore per plan.
|
||||
*******************************************************************************/
|
||||
private static void latestTestRestore(StringBuilder html, BackupsHomeData data, String prefix)
|
||||
{
|
||||
open(html, "Latest test restore");
|
||||
boolean any = false;
|
||||
for(QRecord plan : data.plans())
|
||||
{
|
||||
QRecord v = data.latestTestRestore(plan.getValueInteger("id"));
|
||||
if(v == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
any = true;
|
||||
html.append("<dl style=\"").append(LIST).append("\">");
|
||||
row(html, "Plan", plan.getValueString("name"));
|
||||
row(html, "Result", label(v.getValueString("status")));
|
||||
row(html, "Compared", v.getValueInteger("tablesChecked") + " tables, " + v.getValueLong("rowsChecked") + " rows, " + v.getValueInteger("mismatches") + " mismatches");
|
||||
row(html, "Scratch target", v.getValueString("scratchTarget") == null ? "Not recorded" : v.getValueString("scratchTarget"));
|
||||
html.append("</dl>");
|
||||
}
|
||||
if(!any)
|
||||
{
|
||||
empty(html, "No test restore yet. Run Verify backup, Test restore on a run, or set a verify schedule on the plan.");
|
||||
}
|
||||
link(html, prefix, BackupsTables.VERIFICATION, "All verifications");
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Size and duration trend per plan, as numbers next to each other.
|
||||
*******************************************************************************/
|
||||
private static void trend(StringBuilder html, BackupsHomeData data)
|
||||
{
|
||||
open(html, "Size and duration trend");
|
||||
boolean any = false;
|
||||
for(QRecord plan : data.plans())
|
||||
{
|
||||
List<QRecord> runs = data.successfulRuns(plan.getValueInteger("id"));
|
||||
if(runs.isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
any = true;
|
||||
QRecord first = runs.get(0);
|
||||
QRecord last = runs.get(runs.size() - 1);
|
||||
html.append("<dl style=\"").append(LIST).append("\">");
|
||||
row(html, "Plan", plan.getValueString("name"));
|
||||
row(html, "Successful runs", String.valueOf(runs.size()));
|
||||
row(html, "Stored (first, latest)", bytes(first.getValueLong("bytesStored")) + ", " + bytes(last.getValueLong("bytesStored")));
|
||||
row(html, "Rows (first, latest)", number(first.getValueLong("rowsTotal")) + ", " + number(last.getValueLong("rowsTotal")));
|
||||
row(html, "Duration (latest)", last.getValueLong("durationMs") == null ? "Not recorded" : last.getValueLong("durationMs") / 1000 + " s");
|
||||
html.append("</dl>");
|
||||
}
|
||||
if(!any)
|
||||
{
|
||||
empty(html, "Trends appear after the first successful run.");
|
||||
}
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recent activity: the last runs and restores.
|
||||
*******************************************************************************/
|
||||
private static void recentActivity(StringBuilder html, BackupsHomeData data, String prefix)
|
||||
{
|
||||
open(html, "Recent activity");
|
||||
if(data.recentRuns().isEmpty() && data.restores().isEmpty() && data.verifications().isEmpty())
|
||||
{
|
||||
empty(html, "Nothing has run yet.");
|
||||
}
|
||||
else
|
||||
{
|
||||
html.append("<ul style=\"").append(ITEMS).append("\">");
|
||||
data.recentRuns().stream().limit(6).forEach(run ->
|
||||
{
|
||||
Instant start = run.getValueInstant("startDate");
|
||||
html.append("<li>Backup <a href=\"").append(esc(prefix + "/" + BackupsTables.RUN + "/" + run.getValue("id"))).append("\">").append(esc(run.getValueString("runKey")))
|
||||
.append("</a>: ").append(esc(label(run.getValueString("status")))).append(start == null ? "" : ", " + WHEN.format(start)).append("</li>");
|
||||
});
|
||||
data.restores().stream().limit(2).forEach(restore -> html.append("<li>Restore <a href=\"").append(esc(prefix + "/" + BackupsTables.RESTORE_RUN + "/" + restore.getValue("id")))
|
||||
.append("\">#").append(esc(String.valueOf(restore.getValue("id")))).append("</a>: ").append(esc(label(restore.getValueString("status")))).append("</li>"));
|
||||
data.verifications().stream().limit(2).forEach(v -> html.append("<li>Verify <a href=\"").append(esc(prefix + "/" + BackupsTables.VERIFICATION + "/" + v.getValue("id")))
|
||||
.append("\">").append(esc(label(v.getValueString("level")))).append("</a>: ").append(esc(label(v.getValueString("status")))).append("</li>"));
|
||||
html.append("</ul>");
|
||||
}
|
||||
link(html, prefix, BackupsTables.RUN, "All runs");
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Retention: runs kept per class and the oldest restorable point.
|
||||
*******************************************************************************/
|
||||
private static void retention(StringBuilder html, BackupsHomeData data)
|
||||
{
|
||||
open(html, "Retention");
|
||||
boolean any = false;
|
||||
for(QRecord plan : data.plans())
|
||||
{
|
||||
Map<String, Integer> counts = data.retentionCounts(plan.getValueInteger("id"));
|
||||
if(counts.isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
any = true;
|
||||
html.append("<dl style=\"").append(LIST).append("\">");
|
||||
row(html, "Plan", plan.getValueString("name"));
|
||||
row(html, "Kept", counts.entrySet().stream().map(e -> e.getValue() + " " + label(e.getKey()).toLowerCase()).reduce((a, b) -> a + ", " + b).orElse(""));
|
||||
List<QRecord> runs = data.successfulRuns(plan.getValueInteger("id"));
|
||||
Instant oldest = runs.isEmpty() ? null : runs.get(0).getValueInstant("startDate");
|
||||
row(html, "Oldest restorable", oldest == null ? "Not known" : WHEN.format(oldest));
|
||||
html.append("</dl>");
|
||||
}
|
||||
if(!any)
|
||||
{
|
||||
empty(html, "Retention starts once runs exist.");
|
||||
}
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void open(StringBuilder html, String title)
|
||||
{
|
||||
html.append("<section style=\"").append(CARD).append("\"><h3 style=\"margin:0 0 .75rem;font-size:1rem;\">").append(esc(title)).append("</h3>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void close(StringBuilder html)
|
||||
{
|
||||
html.append("</section>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void empty(StringBuilder html, String text)
|
||||
{
|
||||
html.append("<p style=\"").append(MUTED).append("margin:0 0 .75rem;\">").append(esc(text)).append("</p>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void row(StringBuilder html, String name, String value)
|
||||
{
|
||||
html.append("<dt style=\"").append(MUTED).append("\">").append(esc(name)).append("</dt><dd style=\"margin:0;\">").append(esc(value == null ? "" : value)).append("</dd>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void link(StringBuilder html, String prefix, String table, String text)
|
||||
{
|
||||
html.append("<a href=\"").append(esc(prefix + "/" + table)).append("\">").append(esc(text)).append("</a>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SOME_STATUS to Some status.
|
||||
*******************************************************************************/
|
||||
static String label(String status)
|
||||
{
|
||||
if(status == null || status.isEmpty())
|
||||
{
|
||||
return ("Unknown");
|
||||
}
|
||||
String s = status.replace('_', ' ').toLowerCase();
|
||||
return (Character.toUpperCase(s.charAt(0)) + s.substring(1));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static String bytes(Long value)
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return ("Not recorded");
|
||||
}
|
||||
double v = value;
|
||||
String[] units = { "B", "KB", "MB", "GB", "TB" };
|
||||
int i = 0;
|
||||
while(v >= 1024 && i < units.length - 1)
|
||||
{
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return (i == 0 ? String.format("%d B", value) : String.format("%.1f %s", v, units[i]));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static String number(Long value)
|
||||
{
|
||||
return (value == null ? "Not recorded" : String.format("%,d", value));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static String esc(String s)
|
||||
{
|
||||
return (s == null ? "" : s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* 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.codec;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A reversible transform applied to the bytes of an artifact after compression,
|
||||
** before they reach storage. The only implementation is client-side encryption
|
||||
** (the streaming cipher of the QQQ crypto package); IDENTITY is the default.
|
||||
*******************************************************************************/
|
||||
public interface ArtifactTransform
|
||||
{
|
||||
/** No transform. */
|
||||
ArtifactTransform IDENTITY = new ArtifactTransform()
|
||||
{
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public OutputStream wrap(OutputStream out)
|
||||
{
|
||||
return (out);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public InputStream unwrap(InputStream in)
|
||||
{
|
||||
return (in);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Wrap the stream going to storage.
|
||||
***************************************************************************/
|
||||
OutputStream wrap(OutputStream out) throws IOException;
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Wrap a stream read from storage, to undo the transform.
|
||||
***************************************************************************/
|
||||
InputStream unwrap(InputStream in) throws IOException;
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether this transform encrypts.
|
||||
***************************************************************************/
|
||||
default boolean encrypts()
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** File name suffix added to artifact keys, e.g. ".tink".
|
||||
***************************************************************************/
|
||||
default String keySuffix()
|
||||
{
|
||||
return ("");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
/*
|
||||
* 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.codec;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
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.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The CSV row format (spec 8.3): RFC 4180, UTF-8 without a BOM, a header of
|
||||
** field names, and the same canonical value text as JSONL. Null is written as
|
||||
** \N (unquoted) so it can be told from the empty string; a string that is
|
||||
** literally \N is quoted. A multi-line value is one record in a quoted field,
|
||||
** so the codec works on records (a record may span lines): encode() returns a
|
||||
** text that may contain embedded newlines, and the part writer appends one
|
||||
** newline after each.
|
||||
*******************************************************************************/
|
||||
public class CsvRowCodec implements RowCodec
|
||||
{
|
||||
public static final String NULL_TOKEN = "\\N";
|
||||
|
||||
private final QTableMetaData table;
|
||||
private final List<String> fieldNames;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public CsvRowCodec(QTableMetaData table, java.util.Collection<String> excludedFields)
|
||||
{
|
||||
this.table = table;
|
||||
TreeSet<String> names = new TreeSet<>(table.getFields().keySet());
|
||||
if(excludedFields != null)
|
||||
{
|
||||
names.removeAll(excludedFields);
|
||||
}
|
||||
this.fieldNames = new ArrayList<>(names);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<String> getFieldNames()
|
||||
{
|
||||
return (fieldNames);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String header()
|
||||
{
|
||||
return (String.join(",", fieldNames.stream().map(CsvRowCodec::quoteIfNeeded).toList()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String encode(QRecord record)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for(int i = 0; i < fieldNames.size(); i++)
|
||||
{
|
||||
if(i > 0)
|
||||
{
|
||||
sb.append(',');
|
||||
}
|
||||
String name = fieldNames.get(i);
|
||||
Serializable value = record.getValue(name);
|
||||
if(value == null)
|
||||
{
|
||||
sb.append(NULL_TOKEN);
|
||||
}
|
||||
else
|
||||
{
|
||||
String text = FieldValueCodec.toCanonicalText(table.getField(name).getType(), value);
|
||||
sb.append(NULL_TOKEN.equals(text) ? "\"" + text + "\"" : quoteIfNeeded(text));
|
||||
}
|
||||
}
|
||||
return (sb.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse one CSV record (as returned by CsvRecordReader, header excluded) to a record.
|
||||
** The line may contain embedded newlines inside quoted fields.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QRecord decode(String line) throws QException
|
||||
{
|
||||
List<String> cells = splitCells(line);
|
||||
return (toRecord(cells, fieldNames));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build a record from already-split cells and the header names.
|
||||
*******************************************************************************/
|
||||
public QRecord toRecord(List<String> cells, List<String> header) throws QException
|
||||
{
|
||||
if(cells.size() != header.size())
|
||||
{
|
||||
throw (new QException("CSV row has " + cells.size() + " cells but the header has " + header.size() + " for table " + table.getName()));
|
||||
}
|
||||
Map<String, Serializable> values = new LinkedHashMap<>();
|
||||
for(int i = 0; i < header.size(); i++)
|
||||
{
|
||||
String cell = cells.get(i);
|
||||
QFieldMetaData field = table.getFields().get(header.get(i));
|
||||
if(cell == null)
|
||||
{
|
||||
values.put(header.get(i), null);
|
||||
}
|
||||
else if(field == null)
|
||||
{
|
||||
values.put(header.get(i), cell);
|
||||
}
|
||||
else
|
||||
{
|
||||
values.put(header.get(i), FieldValueCodec.fromCanonicalText(field.getType(), cell));
|
||||
}
|
||||
}
|
||||
QRecord record = new QRecord();
|
||||
record.setTableName(table.getName());
|
||||
record.setValues(values);
|
||||
return (record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Quote a cell when it contains a comma, quote, CR or LF (or is empty after
|
||||
** being a header, which never happens), doubling embedded quotes.
|
||||
*******************************************************************************/
|
||||
static String quoteIfNeeded(String s)
|
||||
{
|
||||
if(s.indexOf(',') >= 0 || s.indexOf('"') >= 0 || s.indexOf('\n') >= 0 || s.indexOf('\r') >= 0)
|
||||
{
|
||||
return ("\"" + s.replace("\"", "\"\"") + "\"");
|
||||
}
|
||||
return (s);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Split one CSV record into cells. Returns null for an unquoted \N cell (a null).
|
||||
*******************************************************************************/
|
||||
public static List<String> splitCells(String line)
|
||||
{
|
||||
List<String> cells = new ArrayList<>();
|
||||
StringBuilder cell = new StringBuilder();
|
||||
boolean quoted = false;
|
||||
boolean wasQuoted = false;
|
||||
for(int i = 0; i < line.length(); i++)
|
||||
{
|
||||
char c = line.charAt(i);
|
||||
if(quoted)
|
||||
{
|
||||
if(c == '"')
|
||||
{
|
||||
if(i + 1 < line.length() && line.charAt(i + 1) == '"')
|
||||
{
|
||||
cell.append('"');
|
||||
i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
quoted = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
cell.append(c);
|
||||
}
|
||||
}
|
||||
else if(c == '"')
|
||||
{
|
||||
quoted = true;
|
||||
wasQuoted = true;
|
||||
}
|
||||
else if(c == ',')
|
||||
{
|
||||
cells.add(finishCell(cell, wasQuoted));
|
||||
cell = new StringBuilder();
|
||||
wasQuoted = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
cell.append(c);
|
||||
}
|
||||
}
|
||||
cells.add(finishCell(cell, wasQuoted));
|
||||
return (cells);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String finishCell(StringBuilder cell, boolean wasQuoted)
|
||||
{
|
||||
String s = cell.toString();
|
||||
return (!wasQuoted && NULL_TOKEN.equals(s) ? null : s);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Split a whole CSV text into records, honoring newlines inside quoted fields.
|
||||
*******************************************************************************/
|
||||
public static List<String> splitRecords(String text)
|
||||
{
|
||||
List<String> records = new ArrayList<>();
|
||||
StringBuilder current = new StringBuilder();
|
||||
boolean quoted = false;
|
||||
for(int i = 0; i < text.length(); i++)
|
||||
{
|
||||
char c = text.charAt(i);
|
||||
if(c == '"')
|
||||
{
|
||||
quoted = !quoted;
|
||||
current.append(c);
|
||||
}
|
||||
else if(c == '\n' && !quoted)
|
||||
{
|
||||
records.add(current.toString());
|
||||
current = new StringBuilder();
|
||||
}
|
||||
else
|
||||
{
|
||||
current.append(c);
|
||||
}
|
||||
}
|
||||
if(current.length() > 0)
|
||||
{
|
||||
records.add(current.toString());
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.codec;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Base64;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The canonical, lossless text encoding of a field value, by QFieldType
|
||||
** (spec 8.2). The same canonical text is used by JSONL and CSV, so a value
|
||||
** means the same thing in both, and by the content hash.
|
||||
**
|
||||
** STRING, TEXT, HTML, PASSWORD: string
|
||||
** INTEGER, LONG: number
|
||||
** DECIMAL: string (BigDecimal.toPlainString), so there is no float rounding
|
||||
** BOOLEAN: true or false
|
||||
** DATE: yyyy-MM-dd
|
||||
** TIME: HH:mm:ss, with .SSSSSS only when there are fractional seconds
|
||||
** DATE_TIME: an ISO-8601 instant in UTC, with .SSSSSS only when there are fractional seconds
|
||||
** BLOB: base64
|
||||
*******************************************************************************/
|
||||
public final class FieldValueCodec
|
||||
{
|
||||
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm:ss");
|
||||
private static final DateTimeFormatter TIME_FORMAT_MICROS = DateTimeFormatter.ofPattern("HH:mm:ss.SSSSSS");
|
||||
private static final DateTimeFormatter INSTANT_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'").withZone(ZoneOffset.UTC);
|
||||
private static final DateTimeFormatter INSTANT_FORMAT_MICRO = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'").withZone(ZoneOffset.UTC);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private FieldValueCodec()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the canonical JSON form of a type is a bare number or boolean (not quoted).
|
||||
*******************************************************************************/
|
||||
public static boolean isBareJson(QFieldType type)
|
||||
{
|
||||
return (type == QFieldType.INTEGER || type == QFieldType.LONG || type == QFieldType.BOOLEAN);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The canonical text of a non-null value.
|
||||
*******************************************************************************/
|
||||
public static String toCanonicalText(QFieldType type, Serializable value)
|
||||
{
|
||||
switch(type)
|
||||
{
|
||||
case INTEGER, LONG:
|
||||
return (String.valueOf(ValueUtils.getValueAsLong(value)));
|
||||
case DECIMAL:
|
||||
return (ValueUtils.getValueAsBigDecimal(value).toPlainString());
|
||||
case BOOLEAN:
|
||||
return (String.valueOf(ValueUtils.getValueAsBoolean(value)));
|
||||
case DATE:
|
||||
return (ValueUtils.getValueAsLocalDate(value).toString());
|
||||
case TIME:
|
||||
{
|
||||
LocalTime time = ValueUtils.getValueAsLocalTime(value);
|
||||
return (time.getNano() == 0 ? TIME_FORMAT.format(time) : TIME_FORMAT_MICROS.format(time));
|
||||
}
|
||||
case DATE_TIME:
|
||||
{
|
||||
Instant instant = ValueUtils.getValueAsInstant(value);
|
||||
return (instant.getNano() == 0 ? INSTANT_FORMAT.format(instant) : INSTANT_FORMAT_MICRO.format(instant));
|
||||
}
|
||||
case BLOB:
|
||||
return (Base64.getEncoder().encodeToString(blobBytes(value)));
|
||||
default:
|
||||
return (String.valueOf(value));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static byte[] blobBytes(Serializable value)
|
||||
{
|
||||
if(value instanceof byte[] bytes)
|
||||
{
|
||||
return (bytes);
|
||||
}
|
||||
return (String.valueOf(value).getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse canonical text back to a Java value.
|
||||
*******************************************************************************/
|
||||
public static Serializable fromCanonicalText(QFieldType type, String text)
|
||||
{
|
||||
if(text == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
switch(type)
|
||||
{
|
||||
case INTEGER:
|
||||
return (Integer.valueOf(text));
|
||||
case LONG:
|
||||
return (Long.valueOf(text));
|
||||
case DECIMAL:
|
||||
return (new BigDecimal(text));
|
||||
case BOOLEAN:
|
||||
return (Boolean.valueOf(text));
|
||||
case DATE:
|
||||
return (LocalDate.parse(text));
|
||||
case TIME:
|
||||
return (LocalTime.parse(text));
|
||||
case DATE_TIME:
|
||||
return (Instant.parse(text));
|
||||
case BLOB:
|
||||
return (Base64.getDecoder().decode(text));
|
||||
default:
|
||||
return (text);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Convert a parsed JSON value to a Java value.
|
||||
*******************************************************************************/
|
||||
public static Serializable fromJson(QFieldType type, JsonNode node)
|
||||
{
|
||||
if(node == null || node.isNull())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
if(type == QFieldType.BOOLEAN)
|
||||
{
|
||||
return (node.asBoolean());
|
||||
}
|
||||
return (fromCanonicalText(type, node.isNumber() ? node.asText() : node.textValue()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* 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.codec;
|
||||
|
||||
|
||||
import java.io.FilterOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An output stream that counts and SHA-256 hashes the bytes passing through.
|
||||
** Closing it closes the stream underneath. The hash is available after close()
|
||||
** (or any time, as the hash of the bytes so far).
|
||||
*******************************************************************************/
|
||||
public class HashingOutputStream extends FilterOutputStream
|
||||
{
|
||||
private final MessageDigest digest;
|
||||
private long count = 0;
|
||||
private String hex;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public HashingOutputStream(OutputStream out)
|
||||
{
|
||||
super(out);
|
||||
this.digest = newSha256();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A new SHA-256 digest.
|
||||
*******************************************************************************/
|
||||
public static MessageDigest newSha256()
|
||||
{
|
||||
try
|
||||
{
|
||||
return (MessageDigest.getInstance("SHA-256"));
|
||||
}
|
||||
catch(NoSuchAlgorithmException e)
|
||||
{
|
||||
throw (new IllegalStateException("SHA-256 is not available", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Hex of a digest result.
|
||||
*******************************************************************************/
|
||||
public static String hex(byte[] bytes)
|
||||
{
|
||||
return (HexFormat.of().formatHex(bytes));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SHA-256 of a byte array, as hex.
|
||||
*******************************************************************************/
|
||||
public static String sha256Hex(byte[] bytes)
|
||||
{
|
||||
return (hex(newSha256().digest(bytes)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void write(int b) throws IOException
|
||||
{
|
||||
out.write(b);
|
||||
digest.update((byte) b);
|
||||
count++;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void write(byte[] b, int off, int len) throws IOException
|
||||
{
|
||||
out.write(b, off, len);
|
||||
digest.update(b, off, len);
|
||||
count += len;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void close() throws IOException
|
||||
{
|
||||
super.close();
|
||||
if(hex == null)
|
||||
{
|
||||
hex = hex(digest.digest());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The SHA-256 (hex) of everything written; valid after close().
|
||||
*******************************************************************************/
|
||||
public String getSha256()
|
||||
{
|
||||
return (hex);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The number of bytes written.
|
||||
*******************************************************************************/
|
||||
public long getCount()
|
||||
{
|
||||
return (count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.codec;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeSet;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
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.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The JSON Lines row format (spec 8.2): one JSON object per line, keys are field
|
||||
** names (sorted), values are the raw canonical encodings, never display values.
|
||||
** The text of a line is canonical, so the same rows always give the same bytes
|
||||
** (which is what the rowsHash is computed over).
|
||||
*******************************************************************************/
|
||||
public class JsonlRowCodec implements RowCodec
|
||||
{
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private final QTableMetaData table;
|
||||
private final List<String> fieldNames;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor. The fields are the table fields, minus any excluded, sorted by name.
|
||||
*******************************************************************************/
|
||||
public JsonlRowCodec(QTableMetaData table, java.util.Collection<String> excludedFields)
|
||||
{
|
||||
this.table = table;
|
||||
TreeSet<String> names = new TreeSet<>(table.getFields().keySet());
|
||||
if(excludedFields != null)
|
||||
{
|
||||
names.removeAll(excludedFields);
|
||||
}
|
||||
this.fieldNames = new ArrayList<>(names);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<String> getFieldNames()
|
||||
{
|
||||
return (fieldNames);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String header()
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The canonical line (no trailing newline) for a record.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String encode(QRecord record)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder("{");
|
||||
boolean first = true;
|
||||
for(String name : fieldNames)
|
||||
{
|
||||
QFieldType type = table.getField(name).getType();
|
||||
Serializable value = record.getValue(name);
|
||||
if(!first)
|
||||
{
|
||||
sb.append(',');
|
||||
}
|
||||
first = false;
|
||||
appendString(sb, name);
|
||||
sb.append(':');
|
||||
if(value == null)
|
||||
{
|
||||
sb.append("null");
|
||||
}
|
||||
else
|
||||
{
|
||||
String text = FieldValueCodec.toCanonicalText(type, value);
|
||||
if(FieldValueCodec.isBareJson(type))
|
||||
{
|
||||
sb.append(text);
|
||||
}
|
||||
else
|
||||
{
|
||||
appendString(sb, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (sb.append('}').toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse one line into a record. Unknown keys are kept as strings so that
|
||||
** restore can report them as unmapped.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QRecord decode(String line) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
JsonNode node = MAPPER.readTree(line);
|
||||
Map<String, Serializable> values = new LinkedHashMap<>();
|
||||
for(var it = node.fields(); it.hasNext(); )
|
||||
{
|
||||
var entry = it.next();
|
||||
QFieldMetaData field = table.getFields().get(entry.getKey());
|
||||
if(field == null)
|
||||
{
|
||||
values.put(entry.getKey(), entry.getValue().isNull() ? null : entry.getValue().asText());
|
||||
}
|
||||
else
|
||||
{
|
||||
values.put(entry.getKey(), FieldValueCodec.fromJson(field.getType(), entry.getValue()));
|
||||
}
|
||||
}
|
||||
QRecord record = new QRecord();
|
||||
record.setTableName(table.getName());
|
||||
record.setValues(values);
|
||||
return (record);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new QException("Could not read a backup row for table " + table.getName() + ": " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Append a JSON string with escapes. Control characters, quotes and backslashes
|
||||
** are escaped; everything else (including non-ASCII) is written as is, in UTF-8.
|
||||
*******************************************************************************/
|
||||
static void appendString(StringBuilder sb, String s)
|
||||
{
|
||||
sb.append('"');
|
||||
for(int i = 0; i < s.length(); i++)
|
||||
{
|
||||
char c = s.charAt(i);
|
||||
switch(c)
|
||||
{
|
||||
case '"' -> sb.append("\\\"");
|
||||
case '\\' -> sb.append("\\\\");
|
||||
case '\n' -> sb.append("\\n");
|
||||
case '\r' -> sb.append("\\r");
|
||||
case '\t' -> sb.append("\\t");
|
||||
case '\b' -> sb.append("\\b");
|
||||
case '\f' -> sb.append("\\f");
|
||||
default ->
|
||||
{
|
||||
if(c < 0x20)
|
||||
{
|
||||
sb.append(String.format("\\u%04x", (int) c));
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.append('"');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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.codec;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Encodes a record to one line of text, and decodes it back.
|
||||
*******************************************************************************/
|
||||
public interface RowCodec
|
||||
{
|
||||
|
||||
/***************************************************************************
|
||||
** The field names written, in order.
|
||||
***************************************************************************/
|
||||
List<String> getFieldNames();
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A header line to write at the start of each part, or null.
|
||||
***************************************************************************/
|
||||
String header();
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One line, without the trailing newline.
|
||||
***************************************************************************/
|
||||
String encode(QRecord record);
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Read one line back into a record.
|
||||
***************************************************************************/
|
||||
QRecord decode(String line) throws QException;
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/*
|
||||
* 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.codec;
|
||||
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
import com.kingsrook.qbits.backups.storage.BackupStorage;
|
||||
import com.kingsrook.qbits.backups.storage.WriteOptions;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Streams the rows of one table into numbered parts in storage. Each row goes
|
||||
** through the row codec, then (per part) into a hash of the raw bytes, gzip,
|
||||
** the artifact transform (encryption) and a hash of the stored bytes, and out
|
||||
** through BackupStorage.openWrite. Memory is O(row); nothing touches a temp disk.
|
||||
**
|
||||
** Whatever the output format, a canonical JSONL hash of every row (in the order
|
||||
** written, which is primary key order) is kept: that is the rowsHash a verifier
|
||||
** recomputes (spec 8.2), independent of compression, encryption and part sizes.
|
||||
*******************************************************************************/
|
||||
public class TableArtifactWriter implements Closeable
|
||||
{
|
||||
private final BackupStorage storage;
|
||||
private final String keyPrefix;
|
||||
private final QTableMetaData table;
|
||||
private final RowCodec codec;
|
||||
private final JsonlRowCodec canonical;
|
||||
private final boolean csv;
|
||||
private final boolean gzip;
|
||||
private final long targetPartBytes;
|
||||
private final long maxRowsPerPart;
|
||||
private final ArtifactTransform transform;
|
||||
private final String runKey;
|
||||
|
||||
private final MessageDigest rowsDigest = HashingOutputStream.newSha256();
|
||||
private final List<PartResult> parts = new ArrayList<>();
|
||||
|
||||
private int partNumber = 0;
|
||||
private PartState current;
|
||||
private long totalRows = 0;
|
||||
private String firstKeyOfTable;
|
||||
private String lastKeyOfTable;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The outcome of one written part.
|
||||
***************************************************************************/
|
||||
public record PartResult(int partNumber, String key, long rows, long bytesRaw, long bytesStored, String sha256Raw, String sha256Stored,
|
||||
String firstKey, String lastKey, boolean encrypted, String format, String compression)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
private static class PartState
|
||||
{
|
||||
String key;
|
||||
HashingOutputStream raw;
|
||||
HashingOutputStream stored;
|
||||
long rows;
|
||||
String firstKey;
|
||||
String lastKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
**
|
||||
** @param keyPrefix the key prefix of the table directory, ending in a slash
|
||||
** @param format JSONL or CSV
|
||||
** @param compression GZIP or NONE
|
||||
*******************************************************************************/
|
||||
public TableArtifactWriter(BackupStorage storage, String keyPrefix, QTableMetaData table, Collection<String> excludedFields, String format,
|
||||
String compression, long targetPartBytes, long maxRowsPerPart, ArtifactTransform transform, String runKey)
|
||||
{
|
||||
this.storage = storage;
|
||||
this.keyPrefix = keyPrefix;
|
||||
this.table = table;
|
||||
this.csv = "CSV".equals(format);
|
||||
this.canonical = new JsonlRowCodec(table, excludedFields);
|
||||
this.codec = csv ? new CsvRowCodec(table, excludedFields) : canonical;
|
||||
this.gzip = "GZIP".equals(compression);
|
||||
this.targetPartBytes = targetPartBytes;
|
||||
this.maxRowsPerPart = maxRowsPerPart;
|
||||
this.transform = transform == null ? ArtifactTransform.IDENTITY : transform;
|
||||
this.runKey = runKey;
|
||||
|
||||
if(!gzip && !"NONE".equals(compression))
|
||||
{
|
||||
throw (new IllegalArgumentException("Unsupported compression: " + compression));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Write one record.
|
||||
*******************************************************************************/
|
||||
public void write(QRecord record) throws IOException
|
||||
{
|
||||
if(current == null)
|
||||
{
|
||||
openPart();
|
||||
}
|
||||
|
||||
String pk = String.valueOf(record.getValue(table.getPrimaryKeyField()));
|
||||
byte[] line = (codec.encode(record) + "\n").getBytes(StandardCharsets.UTF_8);
|
||||
current.raw.write(line);
|
||||
if(current.firstKey == null)
|
||||
{
|
||||
current.firstKey = pk;
|
||||
}
|
||||
current.lastKey = pk;
|
||||
current.rows++;
|
||||
totalRows++;
|
||||
if(firstKeyOfTable == null)
|
||||
{
|
||||
firstKeyOfTable = pk;
|
||||
}
|
||||
lastKeyOfTable = pk;
|
||||
|
||||
rowsDigest.update((canonical.encode(record) + "\n").getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
if(current.raw.getCount() >= targetPartBytes || current.rows >= maxRowsPerPart)
|
||||
{
|
||||
closePart();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Finish: close the last part (writing an empty part if there were no rows at all).
|
||||
*******************************************************************************/
|
||||
public List<PartResult> finish() throws IOException
|
||||
{
|
||||
if(current == null && parts.isEmpty())
|
||||
{
|
||||
openPart();
|
||||
}
|
||||
if(current != null)
|
||||
{
|
||||
closePart();
|
||||
}
|
||||
return (parts);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The canonical content hash of all rows written, as sha256:hex. Valid after finish().
|
||||
*******************************************************************************/
|
||||
public String rowsHash()
|
||||
{
|
||||
return ("sha256:" + HashingOutputStream.hex(rowsDigest.digest()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Total rows written.
|
||||
*******************************************************************************/
|
||||
public long getTotalRows()
|
||||
{
|
||||
return (totalRows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** First primary key written.
|
||||
*******************************************************************************/
|
||||
public String getFirstKey()
|
||||
{
|
||||
return (firstKeyOfTable);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Last primary key written.
|
||||
*******************************************************************************/
|
||||
public String getLastKey()
|
||||
{
|
||||
return (lastKeyOfTable);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void close() throws IOException
|
||||
{
|
||||
if(current != null)
|
||||
{
|
||||
// an abandoned part is never committed to storage
|
||||
current.raw.close();
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void openPart() throws IOException
|
||||
{
|
||||
partNumber++;
|
||||
current = new PartState();
|
||||
String extension = csv ? "csv" : "jsonl";
|
||||
current.key = keyPrefix + String.format("part-%05d.%s%s%s", partNumber, extension, gzip ? ".gz" : "", transform.keySuffix());
|
||||
|
||||
WriteOptions options = WriteOptions.create().withContentType("application/octet-stream")
|
||||
.withMetadata("qqq-backup-run", runKey)
|
||||
.withMetadata("qqq-backup-table", table.getName());
|
||||
OutputStream toStorage = storage.openWrite(current.key, options);
|
||||
current.stored = new HashingOutputStream(toStorage);
|
||||
OutputStream transformed = transform.wrap(current.stored);
|
||||
OutputStream compressed = gzip ? new GZIPOutputStream(transformed) : transformed;
|
||||
current.raw = new HashingOutputStream(compressed);
|
||||
|
||||
String header = codec.header();
|
||||
if(header != null)
|
||||
{
|
||||
current.raw.write((header + "\n").getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void closePart() throws IOException
|
||||
{
|
||||
current.raw.close();
|
||||
parts.add(new PartResult(partNumber, current.key, current.rows, current.raw.getCount(), current.stored.getCount(),
|
||||
current.raw.getSha256(), current.stored.getSha256(), current.firstKey, current.lastKey, transform.encrypts(),
|
||||
csv ? "CSV" : "JSONL", gzip ? "GZIP" : "NONE"));
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.events;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.BiConsumer;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** ESB events of the Backups qbit (design spec section 10.4): qqq.backups.run.succeeded,
|
||||
** qqq.backups.run.failed and qqq.backups.restore.completed. They are for
|
||||
** integrations, not alerting, and are FIRE AND FORGET: a failed publish is logged
|
||||
** at most once and never fails a backup or a restore.
|
||||
**
|
||||
** Events go out only when BackupsQBitConfig.esbDestinationName is set and the ESB
|
||||
** (qqq-esb, an optional dependency) is on the class path; otherwise publish() does
|
||||
** nothing. A test, or an app with its own bus, can bind a publisher with
|
||||
** {@link #setPublisher}.
|
||||
*******************************************************************************/
|
||||
public final class BackupEvents
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(BackupEvents.class);
|
||||
|
||||
public static final String RUN_SUCCEEDED = "qqq.backups.run.succeeded";
|
||||
public static final String RUN_FAILED = "qqq.backups.run.failed";
|
||||
public static final String RESTORE_COMPLETED = "qqq.backups.restore.completed";
|
||||
|
||||
private static volatile BiConsumer<String, Map<String, Serializable>> publisher;
|
||||
private static volatile boolean warned = false;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private BackupEvents()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind a publisher (event type, data), or null to go back to the ESB default.
|
||||
*******************************************************************************/
|
||||
public static void setPublisher(BiConsumer<String, Map<String, Serializable>> newPublisher)
|
||||
{
|
||||
publisher = newPublisher;
|
||||
warned = false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A run finished. Succeeded and warnings publish run.succeeded; failed, timed
|
||||
** out and abandoned publish run.failed; cancelled publishes nothing.
|
||||
*******************************************************************************/
|
||||
public static void runFinished(String planKey, String runKey, String status, long rows, long bytesStored)
|
||||
{
|
||||
String type;
|
||||
if("SUCCEEDED".equals(status) || "SUCCEEDED_WITH_WARNINGS".equals(status))
|
||||
{
|
||||
type = RUN_SUCCEEDED;
|
||||
}
|
||||
else if("CANCELLED".equals(status))
|
||||
{
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
type = RUN_FAILED;
|
||||
}
|
||||
Map<String, Serializable> data = new LinkedHashMap<>();
|
||||
data.put("plan", planKey);
|
||||
data.put("run", runKey);
|
||||
data.put("status", status);
|
||||
data.put("rows", rows);
|
||||
data.put("bytesStored", bytesStored);
|
||||
publish(type, data);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A restore finished (any status).
|
||||
*******************************************************************************/
|
||||
public static void restoreCompleted(String restoreRunKey, String status, long rowsRestored)
|
||||
{
|
||||
Map<String, Serializable> data = new LinkedHashMap<>();
|
||||
data.put("restoreRun", restoreRunKey);
|
||||
data.put("status", status);
|
||||
data.put("rows", rowsRestored);
|
||||
publish(RESTORE_COMPLETED, data);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Publish an event. Never throws.
|
||||
*******************************************************************************/
|
||||
public static void publish(String type, Map<String, Serializable> data)
|
||||
{
|
||||
try
|
||||
{
|
||||
BiConsumer<String, Map<String, Serializable>> bound = publisher;
|
||||
if(bound != null)
|
||||
{
|
||||
bound.accept(type, data);
|
||||
return;
|
||||
}
|
||||
|
||||
String destination = BackupsRuntime.getConfig() == null ? null : BackupsRuntime.getConfig().getEsbDestinationName();
|
||||
if(destination == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
EsbBackupEventPublisher.publish(destination, type, data);
|
||||
}
|
||||
catch(Exception | LinkageError e)
|
||||
{
|
||||
if(!warned)
|
||||
{
|
||||
warned = true;
|
||||
LOG.warn("A backup event could not be published (events are best effort)", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.events;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.esb.publish.EsbPublishAction;
|
||||
import com.kingsrook.qqq.esb.publish.EsbPublishInput;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Publishes a backup event on the QQQ ESB. This is the only class that touches
|
||||
** qqq-esb (an optional dependency); BackupEvents calls it inside a guard that
|
||||
** swallows a missing class (NoClassDefFoundError), so apps without the ESB never
|
||||
** notice.
|
||||
*******************************************************************************/
|
||||
final class EsbBackupEventPublisher
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private EsbBackupEventPublisher()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Publish one event to a destination.
|
||||
*******************************************************************************/
|
||||
static void publish(String destinationName, String type, Map<String, Serializable> data)
|
||||
{
|
||||
new EsbPublishAction().execute(new EsbPublishInput()
|
||||
.withDestinationName(destinationName)
|
||||
.withType(type)
|
||||
.withSourcePath("/backups")
|
||||
.withData(data));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* 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.files;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import com.kingsrook.qbits.backups.codec.ArtifactTransform;
|
||||
import com.kingsrook.qbits.backups.codec.HashingOutputStream;
|
||||
import com.kingsrook.qbits.backups.storage.BackupStorage;
|
||||
import com.kingsrook.qbits.backups.storage.StoredObjectInfo;
|
||||
import com.kingsrook.qbits.backups.storage.WriteOptions;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Makes sure each file that a record references is in the content-addressed
|
||||
** store (spec 8.5), and says what it did.
|
||||
**
|
||||
** For each reference: head the source (missing is reported, never silently
|
||||
** skipped); find its SHA-256 (known from the reference provider, else the object
|
||||
** is read once and hashed); if the store already has that hash the file is
|
||||
** DEDUPLICATED and nothing is copied; otherwise it is copied (server-side when
|
||||
** the source and the destination are the same storage and the plan does not
|
||||
** encrypt, else streamed through the transform) and the hash observed while
|
||||
** copying must equal the hash the key was derived from. If the source changed
|
||||
** in between, the file is hashed again and copied once more.
|
||||
*******************************************************************************/
|
||||
public class FileCopier
|
||||
{
|
||||
public static final String COPIED = "COPIED";
|
||||
public static final String DEDUPLICATED = "DEDUPLICATED";
|
||||
public static final String MISSING = "MISSING";
|
||||
public static final String REFERENCE_ONLY = "REFERENCE_ONLY";
|
||||
|
||||
private final Function<String, BackupStorage> sourceResolver;
|
||||
private final String destinationAlias;
|
||||
private final BackupStorage destination;
|
||||
private final String casPrefix;
|
||||
private final ArtifactTransform transform;
|
||||
private final FileStoreRegistry registry;
|
||||
private final boolean referenceOnly;
|
||||
private final String runKey;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The catalog side of the file store: which hashes are already stored.
|
||||
***************************************************************************/
|
||||
public interface FileStoreRegistry
|
||||
{
|
||||
/***************************************************************************
|
||||
** Whether the store knows this hash (and the object is expected to be there).
|
||||
***************************************************************************/
|
||||
boolean isKnown(String sha256);
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Record a newly stored file.
|
||||
***************************************************************************/
|
||||
void register(String sha256, long sizeBytes, String contentType, String casKey, boolean encrypted);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One line of the per-run file index.
|
||||
**
|
||||
** @param tableName the referencing table
|
||||
** @param recordKey the referencing record primary key
|
||||
** @param field the referencing field
|
||||
** @param sourceBucketAlias where the file was
|
||||
** @param sourceKey the source key
|
||||
** @param sha256 content hash (null when missing or reference-only without a known hash)
|
||||
** @param size size in bytes (-1 when unknown)
|
||||
** @param status COPIED, DEDUPLICATED, MISSING or REFERENCE_ONLY
|
||||
** @param casKey the content-addressed key in the destination, if stored
|
||||
***************************************************************************/
|
||||
public record IndexEntry(String tableName, String recordKey, String field, String sourceBucketAlias, String sourceKey,
|
||||
String sha256, long size, String status, String casKey)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
**
|
||||
** @param casPrefix prefix of the store, ending in a slash, e.g. qqq-backups/app/_files/sha256/
|
||||
*******************************************************************************/
|
||||
public FileCopier(Function<String, BackupStorage> sourceResolver, String destinationAlias, BackupStorage destination, String casPrefix,
|
||||
ArtifactTransform transform, FileStoreRegistry registry, boolean referenceOnly, String runKey)
|
||||
{
|
||||
this.sourceResolver = sourceResolver;
|
||||
this.destinationAlias = destinationAlias;
|
||||
this.destination = destination;
|
||||
this.casPrefix = casPrefix;
|
||||
this.transform = transform == null ? ArtifactTransform.IDENTITY : transform;
|
||||
this.registry = registry;
|
||||
this.referenceOnly = referenceOnly;
|
||||
this.runKey = runKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The content-addressed key for a hash: prefix/ab/cd/sha256.
|
||||
*******************************************************************************/
|
||||
public static String casKey(String casPrefix, String sha256)
|
||||
{
|
||||
return (casPrefix + sha256.substring(0, 2) + "/" + sha256.substring(2, 4) + "/" + sha256);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Process one reference.
|
||||
*******************************************************************************/
|
||||
public IndexEntry process(FileReference ref) throws IOException
|
||||
{
|
||||
BackupStorage source = sourceResolver.apply(ref.bucketAlias());
|
||||
Optional<StoredObjectInfo> head = source == null ? Optional.empty() : source.head(ref.key());
|
||||
if(head.isEmpty())
|
||||
{
|
||||
return (entry(ref, null, -1, MISSING, null));
|
||||
}
|
||||
long size = head.get().size();
|
||||
|
||||
if(referenceOnly)
|
||||
{
|
||||
return (entry(ref, ref.knownSha256(), size, REFERENCE_ONLY, null));
|
||||
}
|
||||
|
||||
String sha = ref.knownSha256() != null ? ref.knownSha256() : hashOf(source, ref.key());
|
||||
for(int attempt = 0; attempt < 2; attempt++)
|
||||
{
|
||||
String key = casKey(casPrefix, sha);
|
||||
if(registry.isKnown(sha) && destination.head(key).isPresent())
|
||||
{
|
||||
return (entry(ref, sha, size, DEDUPLICATED, key));
|
||||
}
|
||||
|
||||
String observed = copy(source, ref, key);
|
||||
if(observed == null || observed.equals(sha))
|
||||
{
|
||||
registry.register(sha, size, ref.contentType(), key, transform.encrypts());
|
||||
return (entry(ref, sha, size, COPIED, key));
|
||||
}
|
||||
// the source changed while it was being copied: the object we wrote is under the wrong key
|
||||
destination.deletePermanent(key);
|
||||
sha = observed;
|
||||
}
|
||||
throw (new IOException("The file " + ref.key() + " kept changing while it was being copied"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy the file to the store; returns the hash observed while streaming, or null
|
||||
** for a server-side copy (which cannot observe the bytes).
|
||||
*******************************************************************************/
|
||||
private String copy(BackupStorage source, FileReference ref, String key) throws IOException
|
||||
{
|
||||
if(source == destination && !transform.encrypts())
|
||||
{
|
||||
destination.copy(ref.key(), key);
|
||||
return (null);
|
||||
}
|
||||
|
||||
WriteOptions options = WriteOptions.create().withContentType(ref.contentType() == null ? "application/octet-stream" : ref.contentType())
|
||||
.withMetadata("qqq-backup-run", runKey);
|
||||
try(InputStream in = source.openRead(ref.key());
|
||||
HashingOutputStream plain = new HashingOutputStream(transform.wrap(destination.openWrite(key, options))))
|
||||
{
|
||||
in.transferTo(plain);
|
||||
plain.flush();
|
||||
plain.close();
|
||||
return (plain.getSha256());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read an object once, to find its SHA-256.
|
||||
*******************************************************************************/
|
||||
private String hashOf(BackupStorage source, String key) throws IOException
|
||||
{
|
||||
try(InputStream in = source.openRead(key);
|
||||
HashingOutputStream sink = new HashingOutputStream(OutputStream.nullOutputStream()))
|
||||
{
|
||||
in.transferTo(sink);
|
||||
sink.close();
|
||||
return (sink.getSha256());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private IndexEntry entry(FileReference ref, String sha, long size, String status, String casKey)
|
||||
{
|
||||
return (new IndexEntry(ref.tableName(), ref.recordKey(), ref.field(), ref.bucketAlias(), ref.key(), sha, size, status, casKey));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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.files;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A reference from a record field to a file in object storage.
|
||||
**
|
||||
** @param tableName the referencing table
|
||||
** @param recordKey the referencing record primary key (as text)
|
||||
** @param field the referencing field
|
||||
** @param bucketAlias the storage bucket alias the file lives in
|
||||
** @param key the object key
|
||||
** @param knownSha256 the content hash when the reference provider knows it (a content-addressed key or a stored checksum), else null
|
||||
** @param contentType the content type when known, else null
|
||||
*******************************************************************************/
|
||||
public record FileReference(String tableName, String recordKey, String field, String bucketAlias, String key, String knownSha256, String contentType)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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.files;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.backups.scope.BackupTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Finds the files a record references. Apps declare their file references once
|
||||
** (the object-storage qbit StorageReferenceProvider registrations, which an
|
||||
** adapter exposes here); the default provider reads the fileReferenceFields of
|
||||
** the table BackupTableMetaData.
|
||||
*******************************************************************************/
|
||||
public interface FileReferenceProvider
|
||||
{
|
||||
|
||||
/***************************************************************************
|
||||
** The files this record references (empty when none).
|
||||
***************************************************************************/
|
||||
List<FileReference> referencesIn(QTableMetaData table, QRecord record);
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Reads BackupTableMetaData.fileReferenceFields: each named field holds a key
|
||||
** in the table fileBucketAlias bucket.
|
||||
***************************************************************************/
|
||||
class FromTableMetaData implements FileReferenceProvider
|
||||
{
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<FileReference> referencesIn(QTableMetaData table, QRecord record)
|
||||
{
|
||||
BackupTableMetaData meta = BackupTableMetaData.of(table);
|
||||
if(meta == null || meta.getFileReferenceFields().isEmpty())
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
List<FileReference> rs = new ArrayList<>();
|
||||
for(String field : meta.getFileReferenceFields())
|
||||
{
|
||||
String key = record.getValueString(field);
|
||||
if(key != null && !key.isBlank())
|
||||
{
|
||||
rs.add(new FileReference(table.getName(), String.valueOf(record.getValue(table.getPrimaryKeyField())), field, meta.getFileBucketAlias(), key, null, null));
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.health;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.middleware.health.HealthIndicator;
|
||||
import com.kingsrook.qqq.middleware.health.model.metadata.HealthCheckResult;
|
||||
import com.kingsrook.qqq.middleware.health.model.metadata.HealthStatus;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Backups health indicator (design spec section 10.4), for
|
||||
** qqq-middleware-health (an optional dependency: an app that does not have that
|
||||
** module never loads this class). It reports DOWN if any active plan marked
|
||||
** critical is LATE, FAILING or MISSING_IN_STORAGE, and UP with details otherwise.
|
||||
** It reads the slaStatus the SLA checker maintains; it never touches storage, so
|
||||
** a health call is cheap.
|
||||
**
|
||||
** It is OFF the liveness path by design. Add it to the indicators of the
|
||||
** instance's health check (the readiness and monitoring view), and do not point a
|
||||
** Kubernetes liveness probe at a check that includes it: a backup problem must
|
||||
** never restart pods.
|
||||
*******************************************************************************/
|
||||
public class BackupsHealthIndicator implements HealthIndicator
|
||||
{
|
||||
public static final String NAME = "backups";
|
||||
|
||||
private static final Set<String> CRITICAL_STATUSES = Set.of("LATE", "FAILING", "MISSING_IN_STORAGE");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
return (NAME);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public HealthCheckResult check(QInstance qInstance) throws QException
|
||||
{
|
||||
long started = System.currentTimeMillis();
|
||||
boolean ownsContext = QContext.getQInstance() == null;
|
||||
if(ownsContext)
|
||||
{
|
||||
QContext.init(qInstance, new QSession());
|
||||
}
|
||||
try
|
||||
{
|
||||
List<QRecord> plans = new BackupCatalog().query(BackupsTables.PLAN, new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true)));
|
||||
|
||||
List<String> criticalProblems = new ArrayList<>();
|
||||
Map<String, Integer> byStatus = new LinkedHashMap<>();
|
||||
for(QRecord plan : plans)
|
||||
{
|
||||
String status = plan.getValueString("slaStatus") == null ? "UNKNOWN" : plan.getValueString("slaStatus");
|
||||
byStatus.merge(status, 1, Integer::sum);
|
||||
if(Boolean.TRUE.equals(plan.getValueBoolean("critical")) && CRITICAL_STATUSES.contains(status))
|
||||
{
|
||||
criticalProblems.add(plan.getValueString("planKey") + ": " + status);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> details = new LinkedHashMap<>();
|
||||
details.put("activePlans", plans.size());
|
||||
details.put("plansByStatus", byStatus);
|
||||
if(!criticalProblems.isEmpty())
|
||||
{
|
||||
details.put("criticalPlansWithProblems", criticalProblems);
|
||||
}
|
||||
return (new HealthCheckResult()
|
||||
.withStatus(criticalProblems.isEmpty() ? HealthStatus.UP : HealthStatus.DOWN)
|
||||
.withDurationMs(System.currentTimeMillis() - started)
|
||||
.withDetails(details));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(ownsContext)
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/*
|
||||
* 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.manifest;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The self-describing manifest of a run (spec 8.7), written LAST: it is the
|
||||
** commit marker. A run without a manifest did not complete. It holds no
|
||||
** secrets: only key references and fingerprints, never key material.
|
||||
**
|
||||
** Unknown properties are ignored when reading, so a newer manifest is still
|
||||
** readable by an older qbit (and by the offline tools).
|
||||
*******************************************************************************/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record BackupManifest(
|
||||
int manifestVersion,
|
||||
String runKey,
|
||||
PlanInfo plan,
|
||||
InstanceInfo instance,
|
||||
String startedAt,
|
||||
String finishedAt,
|
||||
String requestedBy,
|
||||
String securityScope,
|
||||
ConsistencyInfo consistency,
|
||||
EncryptionInfo encryption,
|
||||
List<TableEntry> tables,
|
||||
List<String> restoreOrder,
|
||||
FilesInfo files,
|
||||
List<String> warnings,
|
||||
Totals totals)
|
||||
{
|
||||
public static final int CURRENT_VERSION = 1;
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record PlanInfo(String planKey, String name, Map<String, java.io.Serializable> settings)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record InstanceInfo(String name, String appVersion, String qqqVersion, List<Map<String, String>> qbits)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record ConsistencyInfo(String achieved, List<Map<String, String>> perBackend)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record EncryptionInfo(String mode, String scheme, String keyRef, String keyFingerprint)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record ExcludedField(String name, String reason)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record Part(String key, long rows, long bytesRaw, long bytesStored, String sha256Stored, String sha256Raw, String format, String compression, boolean encrypted)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record TableEntry(
|
||||
String name,
|
||||
String role,
|
||||
String backend,
|
||||
String primaryKey,
|
||||
String fingerprint,
|
||||
Map<String, Object> schema,
|
||||
String filter,
|
||||
List<ExcludedField> excludedFields,
|
||||
long rowCount,
|
||||
String rowsHash,
|
||||
Map<String, String> keyRange,
|
||||
List<Part> parts,
|
||||
String consistency)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record FilesInfo(String policy, List<String> indexKeys, long objects, long bytes, long missing)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record Totals(int tables, long rows, long bytesStored)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The manifest as pretty JSON bytes.
|
||||
*******************************************************************************/
|
||||
public byte[] toJsonBytes()
|
||||
{
|
||||
try
|
||||
{
|
||||
return (MAPPER.writeValueAsBytes(this));
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw (new IllegalStateException("Could not write the manifest", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read a manifest.
|
||||
*******************************************************************************/
|
||||
public static BackupManifest read(InputStream in) throws IOException
|
||||
{
|
||||
return (MAPPER.readValue(in, BackupManifest.class));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read a manifest from bytes.
|
||||
*******************************************************************************/
|
||||
public static BackupManifest read(byte[] bytes) throws IOException
|
||||
{
|
||||
return (MAPPER.readValue(bytes, BackupManifest.class));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The table entry with a name, or null.
|
||||
*******************************************************************************/
|
||||
public TableEntry table(String name)
|
||||
{
|
||||
return (tables == null ? null : tables.stream().filter(t -> t.name().equals(name)).findFirst().orElse(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.manifest;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import com.kingsrook.qbits.backups.codec.HashingOutputStream;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Association;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A stable schema descriptor and SHA-256 fingerprint of a table (spec 5.5).
|
||||
**
|
||||
** This is an internal stand-in for the QQQ core TableSchemaFingerprint (core PR
|
||||
** feature/schema-fingerprint). When that ships in the next 4.1.x RC, this class
|
||||
** delegates to it and the descriptor below is kept only for reading old manifests.
|
||||
** Labels, adornments and help text are excluded, so cosmetic changes do not count.
|
||||
*******************************************************************************/
|
||||
public final class TableFingerprints
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private TableFingerprints()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The descriptor of a table (sorted fields; plain maps and lists, so it is also
|
||||
** what goes in the manifest schema).
|
||||
*******************************************************************************/
|
||||
public static Map<String, Object> descriptor(QTableMetaData table, String backendType)
|
||||
{
|
||||
Map<String, Object> d = new LinkedHashMap<>();
|
||||
d.put("name", table.getName());
|
||||
d.put("primaryKey", table.getPrimaryKeyField());
|
||||
d.put("backendType", backendType);
|
||||
|
||||
Map<String, Object> fields = new TreeMap<>();
|
||||
for(QFieldMetaData field : new TreeMap<>(table.getFields()).values())
|
||||
{
|
||||
Map<String, Object> f = new LinkedHashMap<>();
|
||||
f.put("type", field.getType().name());
|
||||
f.put("required", field.getIsRequired());
|
||||
f.put("maxLength", field.getMaxLength());
|
||||
f.put("pvs", field.getPossibleValueSourceName());
|
||||
f.put("heavy", field.getIsHeavy());
|
||||
f.put("sensitive", field.getIsSensitive());
|
||||
fields.put(field.getName(), f);
|
||||
}
|
||||
d.put("fields", fields);
|
||||
|
||||
List<String> uniqueKeys = new ArrayList<>();
|
||||
if(table.getUniqueKeys() != null)
|
||||
{
|
||||
for(UniqueKey key : table.getUniqueKeys())
|
||||
{
|
||||
uniqueKeys.add(String.join("+", key.getFieldNames().stream().sorted().toList()));
|
||||
}
|
||||
}
|
||||
uniqueKeys.sort(String::compareTo);
|
||||
d.put("uniqueKeys", uniqueKeys);
|
||||
|
||||
List<String> associations = new ArrayList<>();
|
||||
if(table.getAssociations() != null)
|
||||
{
|
||||
for(Association association : table.getAssociations())
|
||||
{
|
||||
associations.add(association.getName() + "->" + association.getAssociatedTableName());
|
||||
}
|
||||
}
|
||||
associations.sort(String::compareTo);
|
||||
d.put("associations", associations);
|
||||
return (d);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The fingerprint of a table: sha256:hex of the canonical descriptor.
|
||||
*******************************************************************************/
|
||||
public static String of(QTableMetaData table, String backendType)
|
||||
{
|
||||
return (fingerprintOf(descriptor(table, backendType)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The fingerprint of a descriptor (maps sorted by key at every level, no whitespace).
|
||||
*******************************************************************************/
|
||||
public static String fingerprintOf(Map<String, Object> descriptor)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
canonical(descriptor, sb);
|
||||
return ("sha256:" + HashingOutputStream.sha256Hex(sb.toString().getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The fingerprint of a whole instance's tables: a hash of the table fingerprints.
|
||||
*******************************************************************************/
|
||||
public static String combine(List<String> tableFingerprints)
|
||||
{
|
||||
return ("sha256:" + HashingOutputStream.sha256Hex(String.join("\n", tableFingerprints).getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void canonical(Object o, StringBuilder sb)
|
||||
{
|
||||
if(o == null)
|
||||
{
|
||||
sb.append("null");
|
||||
}
|
||||
else if(o instanceof Map<?, ?> map)
|
||||
{
|
||||
sb.append('{');
|
||||
boolean first = true;
|
||||
for(Map.Entry<String, Object> e : new TreeMap<>((Map<String, Object>) map).entrySet())
|
||||
{
|
||||
if(!first)
|
||||
{
|
||||
sb.append(',');
|
||||
}
|
||||
first = false;
|
||||
sb.append('"').append(e.getKey()).append("\":");
|
||||
canonical(e.getValue(), sb);
|
||||
}
|
||||
sb.append('}');
|
||||
}
|
||||
else if(o instanceof List<?> list)
|
||||
{
|
||||
sb.append('[');
|
||||
for(int i = 0; i < list.size(); i++)
|
||||
{
|
||||
if(i > 0)
|
||||
{
|
||||
sb.append(',');
|
||||
}
|
||||
canonical(list.get(i), sb);
|
||||
}
|
||||
sb.append(']');
|
||||
}
|
||||
else if(o instanceof Number || o instanceof Boolean)
|
||||
{
|
||||
sb.append(o);
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.append('"').append(String.valueOf(o).replace("\\", "\\\\").replace("\"", "\\\"")).append('"');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.metrics;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.backups.BackupsQBitConfig;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The optional Prometheus text for operators who already run Alertmanager (design
|
||||
** spec section 10.4), computed from the tables and cached for 60 seconds. Gauges,
|
||||
** all labeled {instance_name, plan}:
|
||||
** <ul>
|
||||
** <li>qqq_backup_last_success_timestamp_seconds</li>
|
||||
** <li>qqq_backup_last_run_failed (1 when the latest finished run failed)</li>
|
||||
** <li>qqq_backup_sla_seconds</li>
|
||||
** <li>qqq_backup_last_verify_success_timestamp_seconds</li>
|
||||
** <li>qqq_backup_last_success_bytes</li>
|
||||
** </ul>
|
||||
** The rule worth having even alongside the Alerts qbit is
|
||||
** absent_over_time(qqq_backup_last_success_timestamp_seconds[30m]): the one signal
|
||||
** that still fires when the whole app, and so the Alerts qbit with it, is down.
|
||||
**
|
||||
** ENDPOINT STUB: this class renders the text. Serving it at GET /metrics/backups
|
||||
** (when BackupsQBitConfig.withPrometheusEndpoint(true)) is a route in
|
||||
** qqq-middleware-javalin, which this qbit does not depend on; the app's Javalin
|
||||
** route provider calls {@link #renderCached()}. {@link #isEnabled()} says whether
|
||||
** the config asks for the endpoint.
|
||||
*******************************************************************************/
|
||||
public final class BackupsMetrics
|
||||
{
|
||||
public static final String ENDPOINT_PATH = "/metrics/backups";
|
||||
|
||||
private static final Duration CACHE = Duration.ofSeconds(60);
|
||||
|
||||
private static String cachedText;
|
||||
private static Instant cachedAt;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private BackupsMetrics()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the config asks for the endpoint.
|
||||
*******************************************************************************/
|
||||
public static boolean isEnabled()
|
||||
{
|
||||
BackupsQBitConfig config = BackupsRuntime.getConfig();
|
||||
return (config != null && Boolean.TRUE.equals(config.getPrometheusEndpoint()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Forget the cached text (tests).
|
||||
*******************************************************************************/
|
||||
public static synchronized void resetCache()
|
||||
{
|
||||
cachedText = null;
|
||||
cachedAt = null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The text, from the cache when it is under 60 seconds old.
|
||||
*******************************************************************************/
|
||||
public static synchronized String renderCached() throws QException
|
||||
{
|
||||
Instant now = BackupsRuntime.getClock().instant();
|
||||
if(cachedText == null || cachedAt == null || cachedAt.plus(CACHE).isBefore(now))
|
||||
{
|
||||
cachedText = render();
|
||||
cachedAt = now;
|
||||
}
|
||||
return (cachedText);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Render the text format now.
|
||||
*******************************************************************************/
|
||||
public static String render() throws QException
|
||||
{
|
||||
BackupCatalog catalog = new BackupCatalog();
|
||||
BackupsQBitConfig config = BackupsRuntime.getConfig();
|
||||
String instance = config != null && config.getInstanceName() != null ? config.getInstanceName() : "qqq";
|
||||
|
||||
StringBuilder lastSuccess = header("qqq_backup_last_success_timestamp_seconds", "Unix time of the last successful backup run");
|
||||
StringBuilder lastFailed = header("qqq_backup_last_run_failed", "1 when the latest finished run of the plan failed");
|
||||
StringBuilder sla = header("qqq_backup_sla_seconds", "Maximum age of the last successful run before the plan is late");
|
||||
StringBuilder lastVerify = header("qqq_backup_last_verify_success_timestamp_seconds", "Unix time of the last passed content or test-restore verification");
|
||||
StringBuilder lastBytes = header("qqq_backup_last_success_bytes", "Bytes stored by the last successful run");
|
||||
|
||||
List<QRecord> plans = catalog.query(BackupsTables.PLAN, new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true)));
|
||||
for(QRecord plan : plans)
|
||||
{
|
||||
String labels = "{instance_name=\"" + escape(instance) + "\",plan=\"" + escape(plan.getValueString("planKey")) + "\"}";
|
||||
|
||||
if(plan.getValueInstant("lastSuccessDate") != null)
|
||||
{
|
||||
lastSuccess.append("qqq_backup_last_success_timestamp_seconds").append(labels).append(' ').append(plan.getValueInstant("lastSuccessDate").getEpochSecond()).append('\n');
|
||||
}
|
||||
List<QRecord> runs = catalog.where(BackupsTables.RUN, "backupPlanId", plan.getValueInteger("id"));
|
||||
QRecord latest = runs.stream().filter(r -> List.of("SUCCEEDED", "SUCCEEDED_WITH_WARNINGS", "FAILED", "TIMED_OUT", "ABANDONED").contains(r.getValueString("status"))).findFirst().orElse(null);
|
||||
if(latest != null)
|
||||
{
|
||||
boolean failed = List.of("FAILED", "TIMED_OUT", "ABANDONED").contains(latest.getValueString("status"));
|
||||
lastFailed.append("qqq_backup_last_run_failed").append(labels).append(' ').append(failed ? 1 : 0).append('\n');
|
||||
}
|
||||
Integer slaHours = plan.getValueInteger("slaHours");
|
||||
if(slaHours != null)
|
||||
{
|
||||
sla.append("qqq_backup_sla_seconds").append(labels).append(' ').append(slaHours * 3600L).append('\n');
|
||||
}
|
||||
if(plan.getValueInstant("lastVerifiedDate") != null)
|
||||
{
|
||||
lastVerify.append("qqq_backup_last_verify_success_timestamp_seconds").append(labels).append(' ').append(plan.getValueInstant("lastVerifiedDate").getEpochSecond()).append('\n');
|
||||
}
|
||||
if(plan.getValueInteger("lastSuccessRunId") != null)
|
||||
{
|
||||
QRecord run = catalog.get(BackupsTables.RUN, plan.getValueInteger("lastSuccessRunId")).orElse(null);
|
||||
if(run != null && run.getValueLong("bytesStored") != null)
|
||||
{
|
||||
lastBytes.append("qqq_backup_last_success_bytes").append(labels).append(' ').append(run.getValueLong("bytesStored")).append('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
return (lastSuccess.toString() + lastFailed + sla + lastVerify + lastBytes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static StringBuilder header(String name, String help)
|
||||
{
|
||||
return (new StringBuilder("# HELP ").append(name).append(' ').append(help).append('\n').append("# TYPE ").append(name).append(" gauge\n"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Escape a label value for the text format.
|
||||
*******************************************************************************/
|
||||
static String escape(String value)
|
||||
{
|
||||
return (value == null ? "" : value.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
/*
|
||||
* 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.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.BackupsQBitConfig;
|
||||
import com.kingsrook.qbits.backups.permissions.BackupsPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
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.joins.JoinOn;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Association;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The qbit tables (design spec section 4). All are standard visible QQQ
|
||||
** tables, prefixed backups_, in lowercase snake_case. Tables written by
|
||||
** processes are read-only in the UI. No table ever holds a key, password or
|
||||
** credential: only references such as a key name or a bucket alias.
|
||||
*******************************************************************************/
|
||||
public final class BackupsTables
|
||||
{
|
||||
public static final String PLAN = "backups_plan";
|
||||
public static final String PLAN_SCOPE = "backups_plan_scope";
|
||||
public static final String RUN = "backups_run";
|
||||
public static final String ARTIFACT = "backups_artifact";
|
||||
public static final String VERIFICATION = "backups_verification";
|
||||
public static final String RESTORE_RUN = "backups_restore_run";
|
||||
public static final String RESTORE_RUN_TABLE = "backups_restore_run_table";
|
||||
public static final String FILE_OBJECT = "backups_file_object";
|
||||
public static final String ID_MAP = "backups_id_map";
|
||||
|
||||
/** Tables that only processes write (read-only in the UI; no delete). */
|
||||
public static final List<String> PROCESS_WRITTEN = List.of(RUN, ARTIFACT, VERIFICATION, RESTORE_RUN, RESTORE_RUN_TABLE, FILE_OBJECT, ID_MAP);
|
||||
|
||||
/** The tables shown in the nav (sub-tables appear as relations on their parents). */
|
||||
public static final List<String> NAV_TABLES = List.of(PLAN, RUN, RESTORE_RUN, VERIFICATION, FILE_OBJECT);
|
||||
|
||||
public static final List<String> ALL = List.of(PLAN, PLAN_SCOPE, RUN, ARTIFACT, VERIFICATION, RESTORE_RUN, RESTORE_RUN_TABLE, FILE_OBJECT, ID_MAP);
|
||||
|
||||
public static final String PVS_PREFIX = "backups_";
|
||||
|
||||
private static final Map<String, List<String>> ENUMS = new LinkedHashMap<>();
|
||||
|
||||
static
|
||||
{
|
||||
ENUMS.put("scope_type", List.of("INSTANCE", "APPS", "QBITS", "TABLES"));
|
||||
ENUMS.put("table_role", List.of("DATA", "AUDIT", "DERIVED", "EPHEMERAL", "SECRET", "BACKUPS", "EXTERNAL"));
|
||||
ENUMS.put("scope_action", List.of("INCLUDE", "EXCLUDE"));
|
||||
ENUMS.put("selector_type", List.of("TABLE", "APP", "QBIT", "ROLE"));
|
||||
ENUMS.put("format", List.of("JSONL", "CSV", "JSONL_AND_CSV", "PARQUET"));
|
||||
ENUMS.put("compression", List.of("GZIP", "ZSTD", "NONE"));
|
||||
ENUMS.put("encryption_mode", List.of("NONE", "CLIENT_AES256_GCM"));
|
||||
ENUMS.put("sensitive_policy", List.of("EXCLUDE", "INCLUDE_ENCRYPTED_ONLY", "INCLUDE"));
|
||||
ENUMS.put("heavy_policy", List.of("INLINE", "SEPARATE_OBJECTS", "EXCLUDE"));
|
||||
ENUMS.put("file_policy", List.of("COPY", "REFERENCE_ONLY"));
|
||||
ENUMS.put("file_missing_policy", List.of("WARN", "FAIL"));
|
||||
ENUMS.put("consistency", List.of("SNAPSHOT_REQUIRED", "SNAPSHOT_PREFERRED", "PER_TABLE"));
|
||||
ENUMS.put("consistency_achieved", List.of("INSTANCE_SNAPSHOT", "BACKEND_SNAPSHOT", "PER_TABLE", "BEST_EFFORT"));
|
||||
ENUMS.put("incremental_mode", List.of("FULL", "CHANGED_SINCE_LAST"));
|
||||
ENUMS.put("native_dump", List.of("NONE", "PG_DUMP_CUSTOM"));
|
||||
ENUMS.put("lock_mode", List.of("NONE", "GOVERNANCE", "COMPLIANCE"));
|
||||
ENUMS.put("sla_status", List.of("OK", "LATE", "FAILING", "MISSING_IN_STORAGE", "UNVERIFIED", "ABANDONED", "OWNER_INVALID", "UNKNOWN"));
|
||||
ENUMS.put("run_trigger", List.of("SCHEDULED", "MANUAL", "PRE_RESTORE", "RESUME", "IMPORTED"));
|
||||
ENUMS.put("run_status", List.of("QUEUED", "RUNNING", "SUCCEEDED", "SUCCEEDED_WITH_WARNINGS", "FAILED", "TIMED_OUT", "CANCELLED", "ABANDONED"));
|
||||
ENUMS.put("retention_class", List.of("DAILY", "WEEKLY", "MONTHLY", "YEARLY", "NONE"));
|
||||
ENUMS.put("verification_status", List.of("UNVERIFIED", "STORAGE_OK", "CONTENT_OK", "TEST_RESTORE_OK", "FAILED"));
|
||||
ENUMS.put("artifact_type", List.of("TABLE_DATA", "TABLE_BLOBS", "FILE_OBJECTS_INDEX", "NATIVE_DUMP", "MANIFEST", "CHECKSUMS"));
|
||||
ENUMS.put("artifact_status", List.of("WRITING", "COMPLETE", "FAILED"));
|
||||
ENUMS.put("verify_level", List.of("STORAGE", "CONTENT", "TEST_RESTORE"));
|
||||
ENUMS.put("verify_result", List.of("PASSED", "PASSED_WITH_WARNINGS", "FAILED", "RUNNING", "ERROR"));
|
||||
ENUMS.put("restore_status", List.of("PREVIEWED", "QUEUED", "RUNNING", "SUCCEEDED", "SUCCEEDED_WITH_WARNINGS", "FAILED", "CANCELLED"));
|
||||
ENUMS.put("restore_mode", List.of("INSERT_NEW", "UPSERT", "REPLACE"));
|
||||
ENUMS.put("id_strategy", List.of("PRESERVE", "REMAP_NEW_IDS"));
|
||||
ENUMS.put("schema_status", List.of("MATCH", "COMPATIBLE", "MISMATCH"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private BackupsTables()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The name of the possible value source for an enum key.
|
||||
*******************************************************************************/
|
||||
public static String pvs(String enumKey)
|
||||
{
|
||||
return (PVS_PREFIX + enumKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The values of an enum, by key.
|
||||
*******************************************************************************/
|
||||
public static List<String> enumValues(String enumKey)
|
||||
{
|
||||
return (ENUMS.get(enumKey));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Permission rules shared by every qbit table, app, widget and process.
|
||||
*******************************************************************************/
|
||||
public static QPermissionRules permissionRules()
|
||||
{
|
||||
return (new QPermissionRules()
|
||||
.withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
|
||||
.withPermissionBaseName(BackupsQBitConfig.PERMISSION_BASE_NAME)
|
||||
.withCustomPermissionChecker(new QCodeReference(BackupsPermissionChecker.class)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The possible-value sources: enums and table references.
|
||||
*******************************************************************************/
|
||||
public static List<QPossibleValueSource> possibleValueSources()
|
||||
{
|
||||
List<QPossibleValueSource> rs = new ArrayList<>();
|
||||
for(Map.Entry<String, List<String>> entry : ENUMS.entrySet())
|
||||
{
|
||||
List<QPossibleValue<?>> values = new ArrayList<>();
|
||||
for(String value : entry.getValue())
|
||||
{
|
||||
values.add(new QPossibleValue<>(value, StringUtils.allCapsToMixedCase(value)));
|
||||
}
|
||||
rs.add(new QPossibleValueSource().withName(pvs(entry.getKey())).withType(QPossibleValueSourceType.ENUM).withEnumValues(values));
|
||||
}
|
||||
rs.add(QPossibleValueSource.newForTable(PLAN));
|
||||
rs.add(QPossibleValueSource.newForTable(RUN));
|
||||
rs.add(QPossibleValueSource.newForTable(RESTORE_RUN));
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The joins that back the parent-to-child associations (child lists on parents).
|
||||
*******************************************************************************/
|
||||
public static List<QJoinMetaData> joins()
|
||||
{
|
||||
return (List.of(
|
||||
oneToMany("backupsPlanScopeJoinPlan", PLAN, PLAN_SCOPE, "backupPlanId"),
|
||||
oneToMany("backupsArtifactJoinRun", RUN, ARTIFACT, "backupRunId"),
|
||||
oneToMany("backupsVerificationJoinRun", RUN, VERIFICATION, "backupRunId"),
|
||||
oneToMany("backupsRestoreRunJoinRun", RUN, RESTORE_RUN, "sourceBackupRunId"),
|
||||
oneToMany("backupsRunJoinPlan", PLAN, RUN, "backupPlanId"),
|
||||
oneToMany("backupsRestoreRunTableJoinRestoreRun", RESTORE_RUN, RESTORE_RUN_TABLE, "backupRestoreRunId")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QJoinMetaData oneToMany(String name, String parent, String child, String childField)
|
||||
{
|
||||
return (new QJoinMetaData().withName(name).withType(JoinType.ONE_TO_MANY).withLeftTable(parent).withRightTable(child).withJoinOn(new JoinOn("id", childField)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All tables.
|
||||
*******************************************************************************/
|
||||
public static List<QTableMetaData> tables(BackupsQBitConfig config)
|
||||
{
|
||||
return (List.of(plan(config), planScope(config), run(config), artifact(config), verification(config),
|
||||
restoreRun(config), restoreRunTable(config), fileObject(config), idMap(config)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A table with the common id/createDate/modifyDate fields.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData base(BackupsQBitConfig config, String name, String label, String icon)
|
||||
{
|
||||
return (new QTableMetaData()
|
||||
.withName(name)
|
||||
.withLabel(label)
|
||||
.withIcon(new QIcon(icon))
|
||||
.withBackendName(config.getBackendName())
|
||||
.withPrimaryKeyField("id")
|
||||
.withPermissionRules(permissionRules())
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
|
||||
.withField(new QFieldMetaData("createDate", QFieldType.DATE_TIME).withIsEditable(false))
|
||||
.withField(new QFieldMetaData("modifyDate", QFieldType.DATE_TIME).withIsEditable(false)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A read-only field.
|
||||
*******************************************************************************/
|
||||
private static QFieldMetaData ro(String name, QFieldType type)
|
||||
{
|
||||
return (new QFieldMetaData(name, type).withIsEditable(false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A read-only field backed by an enum possible value source.
|
||||
*******************************************************************************/
|
||||
private static QFieldMetaData roEnum(String name, String enumKey)
|
||||
{
|
||||
return (ro(name, QFieldType.STRING).withPossibleValueSourceName(pvs(enumKey)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An editable enum field with a default.
|
||||
*******************************************************************************/
|
||||
private static QFieldMetaData enumField(String name, String enumKey, String defaultValue)
|
||||
{
|
||||
QFieldMetaData field = new QFieldMetaData(name, QFieldType.STRING).withPossibleValueSourceName(pvs(enumKey));
|
||||
if(defaultValue != null)
|
||||
{
|
||||
field.withDefaultValue(defaultValue);
|
||||
}
|
||||
return (field);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A read-only integer count.
|
||||
*******************************************************************************/
|
||||
private static QFieldMetaData roInt(String name)
|
||||
{
|
||||
return (ro(name, QFieldType.INTEGER));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A read-only long (byte counts, millis).
|
||||
*******************************************************************************/
|
||||
private static QFieldMetaData roLong(String name)
|
||||
{
|
||||
return (ro(name, QFieldType.LONG));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** backups_plan: what, how, where, when and how long (spec 4.1).
|
||||
*******************************************************************************/
|
||||
static QTableMetaData plan(BackupsQBitConfig config)
|
||||
{
|
||||
return (base(config, PLAN, "Backup Plan", "backup")
|
||||
.withRecordLabelFormatAndFields("%s", "name")
|
||||
.withUniqueKey(new UniqueKey("name"))
|
||||
.withUniqueKey(new UniqueKey("planKey"))
|
||||
.withAssociation(new Association().withName("scopes").withAssociatedTableName(PLAN_SCOPE).withJoinName("backupsPlanScopeJoinPlan"))
|
||||
.withField(new QFieldMetaData("name", QFieldType.STRING).withIsRequired(true).withMaxLength(100))
|
||||
.withField(new QFieldMetaData("description", QFieldType.TEXT))
|
||||
.withField(new QFieldMetaData("planKey", QFieldType.STRING).withIsRequired(true).withMaxLength(60))
|
||||
.withField(new QFieldMetaData("isActive", QFieldType.BOOLEAN).withDefaultValue(true))
|
||||
.withField(enumField("scopeType", "scope_type", "INSTANCE").withIsRequired(true))
|
||||
.withField(new QFieldMetaData("includeRoles", QFieldType.STRING).withDefaultValue("DATA,AUDIT").withMaxLength(100))
|
||||
.withField(new QFieldMetaData("storageBucketAlias", QFieldType.STRING).withIsRequired(true).withMaxLength(100))
|
||||
.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("encryptionMode", "encryption_mode", "NONE"))
|
||||
.withField(new QFieldMetaData("encryptionKeyRef", QFieldType.STRING).withMaxLength(100))
|
||||
.withField(enumField("sensitiveFieldPolicy", "sensitive_policy", "EXCLUDE"))
|
||||
.withField(enumField("heavyFieldPolicy", "heavy_policy", "INLINE"))
|
||||
.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("cronExpression", QFieldType.STRING).withMaxLength(100))
|
||||
.withField(new QFieldMetaData("cronTimeZoneId", QFieldType.STRING).withMaxLength(60))
|
||||
.withField(roInt("scheduledJobId"))
|
||||
.withField(new QFieldMetaData("slaHours", QFieldType.INTEGER).withDefaultValue(26))
|
||||
.withField(new QFieldMetaData("verifySlaDays", QFieldType.INTEGER).withDefaultValue(8))
|
||||
.withField(new QFieldMetaData("verifyFileSamplePercent", QFieldType.INTEGER).withDefaultValue(100))
|
||||
.withField(new QFieldMetaData("verifyCronExpression", QFieldType.STRING).withMaxLength(100))
|
||||
.withField(enumField("verifyLevel", "verify_level", "CONTENT"))
|
||||
.withField(new QFieldMetaData("retainDaily", QFieldType.INTEGER).withDefaultValue(7))
|
||||
.withField(new QFieldMetaData("retainWeekly", QFieldType.INTEGER).withDefaultValue(4))
|
||||
.withField(new QFieldMetaData("retainMonthly", QFieldType.INTEGER).withDefaultValue(12))
|
||||
.withField(new QFieldMetaData("retainYearly", QFieldType.INTEGER).withDefaultValue(0))
|
||||
.withField(new QFieldMetaData("retainMinimum", QFieldType.INTEGER).withDefaultValue(3))
|
||||
.withField(enumField("objectLockMode", "lock_mode", "NONE"))
|
||||
.withField(new QFieldMetaData("objectLockDays", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("maxDurationMinutes", QFieldType.INTEGER).withDefaultValue(240))
|
||||
.withField(new QFieldMetaData("pageSize", QFieldType.INTEGER).withDefaultValue(5000))
|
||||
.withField(new QFieldMetaData("targetPartBytes", QFieldType.LONG).withDefaultValue(268435456L))
|
||||
.withField(new QFieldMetaData("ownerUserId", QFieldType.STRING).withMaxLength(100))
|
||||
.withField(ro("lastRunId", QFieldType.INTEGER).withPossibleValueSourceName(RUN))
|
||||
.withField(ro("lastSuccessRunId", QFieldType.INTEGER).withPossibleValueSourceName(RUN))
|
||||
.withField(ro("lastSuccessDate", QFieldType.DATE_TIME))
|
||||
.withField(ro("lastVerifiedRunId", QFieldType.INTEGER).withPossibleValueSourceName(RUN))
|
||||
.withField(ro("lastVerifiedDate", QFieldType.DATE_TIME))
|
||||
.withField(roEnum("slaStatus", "sla_status")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** backups_plan_scope: one row per inclusion or exclusion (spec 4.2).
|
||||
*******************************************************************************/
|
||||
static QTableMetaData planScope(BackupsQBitConfig config)
|
||||
{
|
||||
return (base(config, PLAN_SCOPE, "Backup Plan Scope", "filter_list")
|
||||
.withRecordLabelFormatAndFields("%s %s %s", "action", "selectorType", "selectorValue")
|
||||
.withField(new QFieldMetaData("backupPlanId", QFieldType.INTEGER).withIsRequired(true).withPossibleValueSourceName(PLAN))
|
||||
.withField(enumField("action", "scope_action", "INCLUDE").withIsRequired(true))
|
||||
.withField(enumField("selectorType", "selector_type", "TABLE").withIsRequired(true))
|
||||
.withField(new QFieldMetaData("selectorValue", QFieldType.STRING).withIsRequired(true).withMaxLength(200))
|
||||
.withField(new QFieldMetaData("filterJson", QFieldType.TEXT))
|
||||
.withField(new QFieldMetaData("includeAssociatedChildren", QFieldType.BOOLEAN).withDefaultValue(true))
|
||||
.withField(new QFieldMetaData("fieldExclusions", QFieldType.STRING).withMaxLength(1000)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** backups_run: one execution of a plan, or an ad-hoc export (spec 4.3).
|
||||
*******************************************************************************/
|
||||
static QTableMetaData run(BackupsQBitConfig config)
|
||||
{
|
||||
return (base(config, RUN, "Backup Run", "history")
|
||||
.withRecordLabelFormatAndFields("%s", "runKey")
|
||||
.withUniqueKey(new UniqueKey("runKey"))
|
||||
.withAssociation(new Association().withName("artifacts").withAssociatedTableName(ARTIFACT).withJoinName("backupsArtifactJoinRun"))
|
||||
.withAssociation(new Association().withName("verifications").withAssociatedTableName(VERIFICATION).withJoinName("backupsVerificationJoinRun"))
|
||||
.withField(ro("backupPlanId", QFieldType.INTEGER).withPossibleValueSourceName(PLAN))
|
||||
.withField(ro("runKey", QFieldType.STRING))
|
||||
.withField(roEnum("trigger", "run_trigger"))
|
||||
.withField(ro("requestedByUserId", QFieldType.STRING))
|
||||
.withField(roEnum("status", "run_status"))
|
||||
.withField(ro("startDate", QFieldType.DATE_TIME))
|
||||
.withField(ro("endDate", QFieldType.DATE_TIME))
|
||||
.withField(roLong("durationMs"))
|
||||
.withField(ro("heartbeatDate", QFieldType.DATE_TIME))
|
||||
.withField(roInt("tablesPlanned"))
|
||||
.withField(roInt("tablesDone"))
|
||||
.withField(roLong("rowsTotal"))
|
||||
.withField(roLong("bytesRaw"))
|
||||
.withField(roLong("bytesStored"))
|
||||
.withField(roInt("filesReferenced"))
|
||||
.withField(roInt("filesCopied"))
|
||||
.withField(roInt("filesDeduplicated"))
|
||||
.withField(roInt("filesMissing"))
|
||||
.withField(roLong("fileBytesCopied"))
|
||||
.withField(roLong("fileBytesReferenced"))
|
||||
.withField(roEnum("consistencyAchieved", "consistency_achieved"))
|
||||
.withField(ro("snapshotId", QFieldType.STRING))
|
||||
.withField(ro("storageBucketAlias", QFieldType.STRING))
|
||||
.withField(ro("runPrefix", QFieldType.STRING))
|
||||
.withField(ro("manifestKey", QFieldType.STRING))
|
||||
.withField(ro("manifestSha256", QFieldType.STRING))
|
||||
.withField(ro("qqqVersion", QFieldType.STRING))
|
||||
.withField(ro("appVersion", QFieldType.STRING))
|
||||
.withField(ro("instanceName", QFieldType.STRING))
|
||||
.withField(ro("schemaFingerprint", QFieldType.STRING))
|
||||
.withField(ro("securityScope", QFieldType.STRING))
|
||||
.withField(ro("encryptionKeyRef", QFieldType.STRING))
|
||||
.withField(ro("encryptionKeyFingerprint", QFieldType.STRING))
|
||||
.withField(roEnum("retentionClass", "retention_class"))
|
||||
.withField(ro("lockedUntil", QFieldType.DATE_TIME))
|
||||
.withField(ro("prunedDate", QFieldType.DATE_TIME))
|
||||
.withField(ro("prunedByUserId", QFieldType.STRING))
|
||||
.withField(roEnum("verificationStatus", "verification_status"))
|
||||
.withField(roInt("warningCount"))
|
||||
.withField(ro("errorMessage", QFieldType.TEXT))
|
||||
.withField(ro("errorDetail", QFieldType.TEXT)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** backups_artifact: one stored object or part (spec 4.4).
|
||||
*******************************************************************************/
|
||||
static QTableMetaData artifact(BackupsQBitConfig config)
|
||||
{
|
||||
return (base(config, ARTIFACT, "Backup Artifact", "inventory_2")
|
||||
.withRecordLabelFormatAndFields("%s", "objectKey")
|
||||
.withField(ro("backupRunId", QFieldType.INTEGER).withPossibleValueSourceName(RUN))
|
||||
.withField(roEnum("artifactType", "artifact_type"))
|
||||
.withField(ro("tableName", QFieldType.STRING))
|
||||
.withField(roInt("partNumber"))
|
||||
.withField(ro("objectKey", QFieldType.STRING))
|
||||
.withField(ro("format", QFieldType.STRING))
|
||||
.withField(ro("compression", QFieldType.STRING))
|
||||
.withField(ro("encrypted", QFieldType.BOOLEAN))
|
||||
.withField(roLong("rowCount"))
|
||||
.withField(roLong("bytesRaw"))
|
||||
.withField(roLong("bytesStored"))
|
||||
.withField(ro("sha256Stored", QFieldType.STRING))
|
||||
.withField(ro("sha256Raw", QFieldType.STRING))
|
||||
.withField(ro("rowsHash", QFieldType.STRING))
|
||||
.withField(ro("firstKey", QFieldType.STRING))
|
||||
.withField(ro("lastKey", QFieldType.STRING))
|
||||
.withField(ro("tableFingerprint", QFieldType.STRING))
|
||||
.withField(roEnum("status", "artifact_status"))
|
||||
.withField(ro("storageVersionId", QFieldType.STRING)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** backups_verification: the result of a verify (spec 4.5).
|
||||
*******************************************************************************/
|
||||
static QTableMetaData verification(BackupsQBitConfig config)
|
||||
{
|
||||
return (base(config, VERIFICATION, "Backup Verification", "verified")
|
||||
.withRecordLabelFormatAndFields("%s %s", "level", "status")
|
||||
.withField(ro("backupRunId", QFieldType.INTEGER).withPossibleValueSourceName(RUN))
|
||||
.withField(roEnum("level", "verify_level"))
|
||||
.withField(roEnum("trigger", "run_trigger"))
|
||||
.withField(ro("requestedByUserId", QFieldType.STRING))
|
||||
.withField(ro("startDate", QFieldType.DATE_TIME))
|
||||
.withField(ro("endDate", QFieldType.DATE_TIME))
|
||||
.withField(roEnum("status", "verify_result"))
|
||||
.withField(roInt("tablesChecked"))
|
||||
.withField(roLong("rowsChecked"))
|
||||
.withField(roInt("mismatches"))
|
||||
.withField(ro("scratchTarget", QFieldType.STRING))
|
||||
.withField(ro("detailJson", QFieldType.TEXT)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** backups_restore_run: one restore (spec 4.6).
|
||||
*******************************************************************************/
|
||||
static QTableMetaData restoreRun(BackupsQBitConfig config)
|
||||
{
|
||||
return (base(config, RESTORE_RUN, "Backup Restore", "restore")
|
||||
.withRecordLabelFormatAndFields("Restore %s", "id")
|
||||
.withAssociation(new Association().withName("tables").withAssociatedTableName(RESTORE_RUN_TABLE).withJoinName("backupsRestoreRunTableJoinRestoreRun"))
|
||||
.withField(ro("sourceBackupRunId", QFieldType.INTEGER).withPossibleValueSourceName(RUN))
|
||||
.withField(ro("requestedByUserId", QFieldType.STRING))
|
||||
.withField(ro("startDate", QFieldType.DATE_TIME))
|
||||
.withField(ro("endDate", QFieldType.DATE_TIME))
|
||||
.withField(roEnum("status", "restore_status"))
|
||||
.withField(roEnum("mode", "restore_mode"))
|
||||
.withField(roEnum("idStrategy", "id_strategy"))
|
||||
.withField(ro("matchOn", QFieldType.STRING))
|
||||
.withField(ro("targetInstanceName", QFieldType.STRING))
|
||||
.withField(ro("scopeSummary", QFieldType.TEXT))
|
||||
.withField(ro("preRestoreBackupRunId", QFieldType.INTEGER).withPossibleValueSourceName(RUN))
|
||||
.withField(roLong("rowsInserted"))
|
||||
.withField(roLong("rowsUpdated"))
|
||||
.withField(roLong("rowsUnchanged"))
|
||||
.withField(roLong("rowsDeleted"))
|
||||
.withField(roLong("rowsSkipped"))
|
||||
.withField(roLong("rowsErrored"))
|
||||
.withField(roInt("filesRestored"))
|
||||
.withField(roInt("filesAlreadyPresent"))
|
||||
.withField(roInt("filesConflicting"))
|
||||
.withField(roInt("filesMissingFromBackup"))
|
||||
.withField(roLong("fileBytesRestored"))
|
||||
.withField(roInt("sequencesResynced"))
|
||||
.withField(ro("automationsSuppressed", QFieldType.BOOLEAN))
|
||||
.withField(ro("esbSuppressed", QFieldType.BOOLEAN))
|
||||
.withField(ro("confirmationText", QFieldType.STRING))
|
||||
.withField(ro("errorMessage", QFieldType.TEXT)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** backups_restore_run_table: per-table restore results (spec 4.7).
|
||||
*******************************************************************************/
|
||||
static QTableMetaData restoreRunTable(BackupsQBitConfig config)
|
||||
{
|
||||
return (base(config, RESTORE_RUN_TABLE, "Backup Restore Table", "table_rows")
|
||||
.withRecordLabelFormatAndFields("%s", "tableName")
|
||||
.withField(ro("backupRestoreRunId", QFieldType.INTEGER).withPossibleValueSourceName(RESTORE_RUN))
|
||||
.withField(ro("tableName", QFieldType.STRING))
|
||||
.withField(ro("targetTableName", QFieldType.STRING))
|
||||
.withField(roLong("rowsInserted"))
|
||||
.withField(roLong("rowsUpdated"))
|
||||
.withField(roLong("rowsUnchanged"))
|
||||
.withField(roLong("rowsDeleted"))
|
||||
.withField(roLong("rowsSkipped"))
|
||||
.withField(roLong("rowsErrored"))
|
||||
.withField(roEnum("schemaStatus", "schema_status"))
|
||||
.withField(ro("unmappedFields", QFieldType.STRING))
|
||||
.withField(ro("sequenceResynced", QFieldType.BOOLEAN))
|
||||
.withField(ro("status", QFieldType.STRING)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** backups_file_object: the content-addressed file store (spec 4.8).
|
||||
*******************************************************************************/
|
||||
static QTableMetaData fileObject(BackupsQBitConfig config)
|
||||
{
|
||||
return (base(config, FILE_OBJECT, "Backup File", "attach_file")
|
||||
.withRecordLabelFormatAndFields("%s", "sha256")
|
||||
.withUniqueKey(new UniqueKey("sha256"))
|
||||
.withField(ro("sha256", QFieldType.STRING))
|
||||
.withField(roLong("sizeBytes"))
|
||||
.withField(ro("contentType", QFieldType.STRING))
|
||||
.withField(ro("storageBucketAlias", QFieldType.STRING))
|
||||
.withField(ro("objectKey", QFieldType.STRING))
|
||||
.withField(ro("encrypted", QFieldType.BOOLEAN))
|
||||
.withField(ro("encryptionKeyFingerprint", QFieldType.STRING))
|
||||
.withField(ro("firstRunId", QFieldType.INTEGER).withPossibleValueSourceName(RUN))
|
||||
.withField(ro("lastReferencedRunId", QFieldType.INTEGER).withPossibleValueSourceName(RUN))
|
||||
.withField(roInt("referenceCount"))
|
||||
.withField(ro("lastVerifiedDate", QFieldType.DATE_TIME))
|
||||
.withField(ro("lastVerifyStatus", QFieldType.STRING))
|
||||
.withField(ro("lockedUntil", QFieldType.DATE_TIME))
|
||||
.withField(ro("deletedDate", QFieldType.DATE_TIME)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** backups_id_map: transient old-to-new ids for REMAP_NEW_IDS restores (spec 4.9).
|
||||
*******************************************************************************/
|
||||
static QTableMetaData idMap(BackupsQBitConfig config)
|
||||
{
|
||||
return (base(config, ID_MAP, "Backup ID Map", "swap_horiz")
|
||||
.withRecordLabelFormatAndFields("%s %s", "tableName", "oldId")
|
||||
.withField(ro("restoreRunId", QFieldType.INTEGER).withPossibleValueSourceName(RESTORE_RUN))
|
||||
.withField(ro("tableName", QFieldType.STRING))
|
||||
.withField(ro("oldId", QFieldType.STRING))
|
||||
.withField(ro("newId", QFieldType.STRING)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* 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.permissions;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Permission rules for every qbit table, app, widget and process (spec 11.4):
|
||||
** view reads everything; plans are edited with backups.admin; the tables that
|
||||
** processes write are read-only in the UI; each process names its permission.
|
||||
*******************************************************************************/
|
||||
public class BackupsPermissionChecker implements CustomPermissionChecker
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Check, throwing when denied.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput actionInput, MetaDataWithPermissionRules metaData) throws QPermissionDeniedException
|
||||
{
|
||||
String needed = required(actionInput, metaData);
|
||||
if(needed == null)
|
||||
{
|
||||
throw (new QPermissionDeniedException("This Backups table is written only by the Backups qbit."));
|
||||
}
|
||||
if(!has(needed))
|
||||
{
|
||||
throw (new QPermissionDeniedException("This needs the " + needed + " permission."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The permission an action needs, or null when nobody may do it.
|
||||
*******************************************************************************/
|
||||
static String required(AbstractActionInput actionInput, MetaDataWithPermissionRules metaData)
|
||||
{
|
||||
boolean write = actionInput instanceof InsertInput || actionInput instanceof UpdateInput || actionInput instanceof DeleteInput;
|
||||
if(metaData instanceof QTableMetaData table)
|
||||
{
|
||||
if(!write)
|
||||
{
|
||||
return (BackupsPermissions.VIEW);
|
||||
}
|
||||
return (BackupsTables.PROCESS_WRITTEN.contains(table.getName()) ? null : BackupsPermissions.ADMIN);
|
||||
}
|
||||
if(metaData instanceof QProcessMetaData process)
|
||||
{
|
||||
Map<String, String> byProcess = BackupsRuntime.processPermissions();
|
||||
return (byProcess.getOrDefault(process.getName(), BackupsPermissions.ADMIN));
|
||||
}
|
||||
return (BackupsPermissions.VIEW);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the current session holds a permission (or the instance is fully
|
||||
** anonymous and the config allows that, for local development).
|
||||
*******************************************************************************/
|
||||
public static boolean has(String permission)
|
||||
{
|
||||
QInstance instance = QContext.getQInstance();
|
||||
QSession session = QContext.getQSession();
|
||||
if(session != null && session.hasPermission(permission))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
return (BackupsRuntime.isOpenWhenAnonymous() && instance != null && instance.getAuthentication() != null
|
||||
&& QAuthenticationType.FULLY_ANONYMOUS.equals(instance.getAuthentication().getType()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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.permissions;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Permission names (design spec section 11.4).
|
||||
*******************************************************************************/
|
||||
public final class BackupsPermissions
|
||||
{
|
||||
/** The app, every backups table (read-only), the home. */
|
||||
public static final String VIEW = "backups.view";
|
||||
/** Run backup now, back up records, verify, cancel and resume. */
|
||||
public static final String RUN = "backups.run";
|
||||
/** Restore (insert-new and upsert), download, cancel restore. */
|
||||
public static final String RESTORE = "backups.restore";
|
||||
/** Edit plans, prune, replace restores, import catalog, test destination. */
|
||||
public static final String ADMIN = "backups.admin";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private BackupsPermissions()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.permissions.BackupsPermissions;
|
||||
import com.kingsrook.qbits.backups.run.BackupEngine;
|
||||
import com.kingsrook.qbits.backups.run.BackupRunFactory;
|
||||
import com.kingsrook.qbits.backups.run.BackupRunRequest;
|
||||
import com.kingsrook.qbits.backups.run.BackupRunResult;
|
||||
import com.kingsrook.qbits.backups.scope.ScopeResolver;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The qbit processes (spec 9). Each is a standard streamed ETL with the standard
|
||||
** Review and Result summary lines; none is in the nav; labels are plain. They
|
||||
** appear as record actions on the table that has the records they act on.
|
||||
*******************************************************************************/
|
||||
public final class BackupsProcesses
|
||||
{
|
||||
public static final String RUN_PLAN = "backupRunPlan";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private BackupsProcesses()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All processes.
|
||||
*******************************************************************************/
|
||||
public static List<QProcessMetaData> processes()
|
||||
{
|
||||
List<QProcessMetaData> processes = new ArrayList<>();
|
||||
processes.add(permission(etl(RUN_PLAN, "Run backup now", BackupsTables.PLAN, RunPlanTransform.class, RunPlanLoad.class,
|
||||
"This is a preview of what the backup will include. Nothing is written until you continue."), BackupsPermissions.RUN));
|
||||
processes.addAll(VerifyProcesses.processes());
|
||||
processes.addAll(RestoreProcesses.processes());
|
||||
processes.addAll(CatalogProcesses.processes());
|
||||
return (processes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record the permission a process needs.
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData permission(QProcessMetaData process, String permission)
|
||||
{
|
||||
BackupsRuntime.processPermissions().put(process.getName(), permission);
|
||||
return (process);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A standard streamed ETL process with full validation (Review is the dry run).
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData etl(String name, String label, String table, Class<? extends AbstractTransformStep> transform,
|
||||
Class<? extends AbstractLoadStep> load, String preview)
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, preview);
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, transform, load, values);
|
||||
process.setName(name);
|
||||
process.setLabel(label);
|
||||
process.setTableName(table);
|
||||
process.setPermissionRules(BackupsTables.permissionRules());
|
||||
return (process);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A summary line with future (Review) and past (Result) wording.
|
||||
*******************************************************************************/
|
||||
public static ProcessSummaryLine line(Status status, String singularFuture, String pluralFuture, String singularPast, String pluralPast)
|
||||
{
|
||||
ProcessSummaryLine line = new ProcessSummaryLine(status, 0, null);
|
||||
line.setSingularFutureMessage(singularFuture);
|
||||
line.setPluralFutureMessage(pluralFuture);
|
||||
line.setSingularPastMessage(singularPast);
|
||||
line.setPluralPastMessage(pluralPast);
|
||||
return (line);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run backup now, review: what each selected plan will back up.
|
||||
*******************************************************************************/
|
||||
public static class RunPlanTransform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine plans = line(Status.OK, "plan will run", "plans will run", "plan was run", "plans were run");
|
||||
private final ProcessSummaryLine tables = line(Status.INFO, "table will be backed up", "tables will be backed up", "table was backed up", "tables were backed up");
|
||||
private final ProcessSummaryLine excluded = line(Status.INFO, "table is left out by its role or scope", "tables are left out by their role or scope",
|
||||
"table was left out by its role or scope", "tables were left out by their role or scope");
|
||||
private final ProcessSummaryLine inactive = line(Status.WARNING, "plan is not active (it can still be run by hand)", "plans are not active (they can still be run by hand)",
|
||||
"plan was not active", "plans were not active");
|
||||
private final ProcessSummaryLine invalid = line(Status.ERROR, "plan cannot run", "plans cannot run", "plan could not run", "plans could not run");
|
||||
private final List<String> samples = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve each plan scope without writing anything.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord plan : input.getRecords())
|
||||
{
|
||||
try
|
||||
{
|
||||
String alias = plan.getValueString("storageBucketAlias");
|
||||
if(alias == null)
|
||||
{
|
||||
throw (new QException("no storage bucket alias"));
|
||||
}
|
||||
BackupsRuntime.storageFor(alias);
|
||||
ScopeResolver.ResolvedScope scope = BackupRunFactory.resolveScope(plan);
|
||||
plans.incrementCountAndAddPrimaryKey(plan.getValue("id"));
|
||||
tables.incrementCount(scope.tables().size());
|
||||
excluded.incrementCount(scope.excluded().size());
|
||||
if(!Boolean.TRUE.equals(plan.getValueBoolean("isActive")))
|
||||
{
|
||||
inactive.incrementCountAndAddPrimaryKey(plan.getValue("id"));
|
||||
}
|
||||
for(ScopeResolver.ResolvedTable table : scope.tables().stream().limit(5).toList())
|
||||
{
|
||||
samples.add(table.name());
|
||||
}
|
||||
output.getRecords().add(plan);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
invalid.incrementCountAndAddPrimaryKey(plan.getValue("id"));
|
||||
invalid.setMessageSuffix(": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review lines.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
plans.addSelfToListIfAnyCount(lines);
|
||||
tables.addSelfToListIfAnyCount(lines);
|
||||
excluded.addSelfToListIfAnyCount(lines);
|
||||
inactive.addSelfToListIfAnyCount(lines);
|
||||
invalid.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run backup now, execute: run the engine for each plan.
|
||||
*******************************************************************************/
|
||||
public static class RunPlanLoad extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine succeeded = line(Status.OK, "backup will succeed", "backups succeeded", "backup succeeded", "backups succeeded");
|
||||
private final ProcessSummaryLine warnings = line(Status.WARNING, "backup finished with warnings", "backups finished with warnings",
|
||||
"backup finished with warnings", "backups finished with warnings");
|
||||
private final ProcessSummaryLine failed = line(Status.ERROR, "backup failed", "backups failed", "backup failed", "backups failed");
|
||||
private final ProcessSummaryLine rows = line(Status.INFO, "row was written", "rows were written", "row was written", "rows were written");
|
||||
private final ProcessSummaryLine files = line(Status.INFO, "referenced file was copied or already stored", "referenced files were copied or already stored",
|
||||
"referenced file was copied or already stored", "referenced files were copied or already stored");
|
||||
private final List<BackupRunResult> results = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the plans.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord plan : input.getRecords())
|
||||
{
|
||||
BackupRunRequest request = BackupRunFactory.forPlan(plan, "MANUAL");
|
||||
BackupRunResult result = new BackupEngine().execute(request);
|
||||
results.add(result);
|
||||
switch(result.getStatus())
|
||||
{
|
||||
case "SUCCEEDED" -> succeeded.incrementCountAndAddPrimaryKey(result.getRunId());
|
||||
case "SUCCEEDED_WITH_WARNINGS" -> warnings.incrementCountAndAddPrimaryKey(result.getRunId());
|
||||
default ->
|
||||
{
|
||||
failed.incrementCountAndAddPrimaryKey(result.getRunId());
|
||||
failed.setMessageSuffix(result.getErrorMessage() == null ? null : ": " + result.getErrorMessage());
|
||||
}
|
||||
}
|
||||
rows.incrementCount((int) Math.min(Integer.MAX_VALUE, result.getRows()));
|
||||
files.incrementCount(result.getFilesCopied() + result.getFilesDeduplicated());
|
||||
output.getRecords().add(plan);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Result lines, with the run in the primary keys so it links to the run.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
succeeded.addSelfToListIfAnyCount(lines);
|
||||
warnings.addSelfToListIfAnyCount(lines);
|
||||
failed.addSelfToListIfAnyCount(lines);
|
||||
rows.addSelfToListIfAnyCount(lines);
|
||||
files.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.processes;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.permissions.BackupsPermissions;
|
||||
import com.kingsrook.qbits.backups.restore.CatalogImporter;
|
||||
import com.kingsrook.qbits.backups.restore.RestoreSummaries;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qbits.backups.storage.BackupStorage;
|
||||
import com.kingsrook.qqq.backend.core.actions.audits.AuditAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractExtractStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The catalog and download processes (spec 9.6 and 9.7): Import backups from
|
||||
** storage (a table action on backups_run) and Download (a record action on a run
|
||||
** or an artifact). Standard streamed ETL, Review and Result summary lines.
|
||||
*******************************************************************************/
|
||||
public final class CatalogProcesses
|
||||
{
|
||||
public static final String IMPORT_CATALOG = "backupImportCatalog";
|
||||
public static final String DOWNLOAD = "backupDownload";
|
||||
public static final String DOWNLOAD_ARTIFACT = "backupDownloadArtifact";
|
||||
|
||||
public static final Duration LINK_TTL = Duration.ofMinutes(15);
|
||||
|
||||
private static final QLogger LOG = QLogger.getLogger(CatalogProcesses.class);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CatalogProcesses()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The processes defined here.
|
||||
*******************************************************************************/
|
||||
public static List<QProcessMetaData> processes()
|
||||
{
|
||||
List<QProcessMetaData> processes = new ArrayList<>();
|
||||
|
||||
QProcessMetaData importProcess = definition(IMPORT_CATALOG, "Import backups from storage", BackupsTables.RUN, ImportExtract.class, ImportTransform.class, ImportLoad.class,
|
||||
"These are the backups found in storage that are not in the catalog yet. Nothing is written until you continue.");
|
||||
importProcess.withStep(0, new QFrontendStepMetaData()
|
||||
.withName("input")
|
||||
.withLabel("Where are the backups?")
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM))
|
||||
.withFormField(new QFieldMetaData("bucketAlias", QFieldType.STRING).withLabel("Backup bucket alias").withIsRequired(true))
|
||||
.withFormField(new QFieldMetaData("prefix", QFieldType.STRING).withLabel("Folder (prefix)").withDefaultValue("qqq-backups/")));
|
||||
importProcess.setMinInputRecords(0);
|
||||
processes.add(BackupsProcesses.permission(importProcess, BackupsPermissions.ADMIN));
|
||||
|
||||
processes.add(BackupsProcesses.permission(definition(DOWNLOAD, "Download", BackupsTables.RUN, ExtractViaQueryStep.class, DownloadTransform.class, DownloadLoad.class,
|
||||
"These are the files you will get download links for. Nothing is changed."), BackupsPermissions.RESTORE));
|
||||
processes.add(BackupsProcesses.permission(definition(DOWNLOAD_ARTIFACT, "Download", BackupsTables.ARTIFACT, ExtractViaQueryStep.class, DownloadTransform.class, DownloadLoad.class,
|
||||
"These are the files you will get download links for. Nothing is changed."), BackupsPermissions.RESTORE));
|
||||
return (processes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QProcessMetaData definition(String name, String label, String table, Class<? extends AbstractExtractStep> extract,
|
||||
Class<? extends AbstractTransformStep> transform, Class<? extends AbstractLoadStep> load, String preview)
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_TRANSACTION_LEVEL, StreamedETLWithFrontendProcess.TRANSACTION_LEVEL_AUTO_COMMIT);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, preview);
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(extract, transform, load, values);
|
||||
process.setName(name);
|
||||
process.setLabel(label);
|
||||
process.setTableName(table);
|
||||
process.setPermissionRules(BackupsTables.permissionRules());
|
||||
return (process);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Lists the manifests under the bucket alias and prefix; one record per manifest.
|
||||
*******************************************************************************/
|
||||
public static class ImportExtract extends AbstractExtractStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
String alias = input.getValueString("bucketAlias");
|
||||
if(alias == null || alias.isBlank())
|
||||
{
|
||||
throw (new QUserFacingException("Choose the backup bucket alias to import from."));
|
||||
}
|
||||
BackupStorage storage = BackupsRuntime.storageFor(alias);
|
||||
for(String key : new CatalogImporter().findManifestKeys(storage, input.getValueString("prefix")))
|
||||
{
|
||||
getRecordPipe().addRecord(new QRecord().withTableName(BackupsTables.RUN).withValue("manifestKey", key).withValue("storageBucketAlias", alias));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review: what each manifest is (new, already in the catalog, or damaged).
|
||||
*******************************************************************************/
|
||||
public static class ImportTransform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine found = RestoreSummaries.line(Status.INFO, 0, "backup was found in storage", "backups were found in storage", "backup was found in storage", "backups were found in storage");
|
||||
private final ProcessSummaryLine newRuns = RestoreSummaries.line(Status.OK, 0, "backup will be added to the catalog", "backups will be added to the catalog", "backup was added to the catalog", "backups were added to the catalog");
|
||||
private final ProcessSummaryLine existing = RestoreSummaries.line(Status.INFO, 0, "backup is already in the catalog (skipped)", "backups are already in the catalog (skipped)", "backup was already in the catalog", "backups were already in the catalog");
|
||||
private final ProcessSummaryLine invalid = RestoreSummaries.line(Status.ERROR, 0, "backup has a manifest or checksum problem and will be skipped", "backups have a manifest or checksum problem and will be skipped",
|
||||
"backup had a manifest or checksum problem and was skipped", "backups had a manifest or checksum problem and were skipped");
|
||||
private final ArrayList<String> problems = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
BackupStorage storage = BackupsRuntime.storageFor(record.getValueString("storageBucketAlias"));
|
||||
CatalogImporter.Inspection inspection = new CatalogImporter().inspect(storage, record.getValueString("manifestKey"));
|
||||
found.incrementCount();
|
||||
switch(inspection.status())
|
||||
{
|
||||
case CatalogImporter.Inspection.NEW ->
|
||||
{
|
||||
newRuns.incrementCount();
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
case CatalogImporter.Inspection.EXISTS -> existing.incrementCount();
|
||||
default ->
|
||||
{
|
||||
invalid.incrementCount();
|
||||
for(String problem : inspection.problems())
|
||||
{
|
||||
problems.add(inspection.manifestKey() + ": " + problem);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
found.addSelfToListIfAnyCount(lines);
|
||||
newRuns.addSelfToListIfAnyCount(lines);
|
||||
existing.addSelfToListIfAnyCount(lines);
|
||||
invalid.setBulletsOfText(problems);
|
||||
invalid.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute: create the run and artifact rows from each new manifest.
|
||||
*******************************************************************************/
|
||||
public static class ImportLoad extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine imported = RestoreSummaries.line(Status.OK, 0, "backup will be added to the catalog", "backups will be added to the catalog", "backup was added to the catalog", "backups were added to the catalog");
|
||||
private final ProcessSummaryLine skipped = RestoreSummaries.line(Status.WARNING, 0, "backup was not added", "backups were not added", "backup was not added", "backups were not added");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
CatalogImporter importer = new CatalogImporter();
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
String alias = record.getValueString("storageBucketAlias");
|
||||
BackupStorage storage = BackupsRuntime.storageFor(alias);
|
||||
CatalogImporter.Inspection inspection = importer.inspect(storage, record.getValueString("manifestKey"));
|
||||
if(CatalogImporter.Inspection.NEW.equals(inspection.status()))
|
||||
{
|
||||
imported.incrementCountAndAddPrimaryKey(importer.importRun(alias, storage, inspection));
|
||||
}
|
||||
else
|
||||
{
|
||||
skipped.incrementCount();
|
||||
}
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
imported.addSelfToListIfAnyCount(lines);
|
||||
skipped.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One object to link to.
|
||||
*******************************************************************************/
|
||||
record DownloadItem(String auditTable, Integer auditId, BackupStorage storage, String key, String downloadName, boolean encrypted, long size)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The objects the selected records ask for: for a run, all of its artifacts; for
|
||||
** an artifact, itself plus the manifest and checksums of its run.
|
||||
*******************************************************************************/
|
||||
static List<DownloadItem> items(List<QRecord> records) throws QException
|
||||
{
|
||||
BackupCatalog catalog = new BackupCatalog();
|
||||
Map<String, DownloadItem> items = new LinkedHashMap<>();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
boolean isArtifact = BackupsTables.ARTIFACT.equals(record.getTableName()) || record.getValue("artifactType") != null;
|
||||
Integer runId = isArtifact ? record.getValueInteger("backupRunId") : record.getValueInteger("id");
|
||||
QRecord run = catalog.get(BackupsTables.RUN, runId).orElseThrow(() -> new QUserFacingException("The backup run was not found."));
|
||||
String table = isArtifact ? BackupsTables.ARTIFACT : BackupsTables.RUN;
|
||||
BackupStorage storage = BackupsRuntime.storageFor(run.getValueString("storageBucketAlias"));
|
||||
|
||||
List<QRecord> artifacts = new ArrayList<>();
|
||||
if(isArtifact)
|
||||
{
|
||||
artifacts.add(record);
|
||||
for(QRecord other : catalog.where(BackupsTables.ARTIFACT, "backupRunId", runId))
|
||||
{
|
||||
if("MANIFEST".equals(other.getValueString("artifactType")) || "CHECKSUMS".equals(other.getValueString("artifactType")))
|
||||
{
|
||||
artifacts.add(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
artifacts.addAll(catalog.where(BackupsTables.ARTIFACT, "backupRunId", runId));
|
||||
}
|
||||
|
||||
for(QRecord artifact : artifacts)
|
||||
{
|
||||
if(!"COMPLETE".equals(artifact.getValueString("status")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String key = run.getValueString("runPrefix") + artifact.getValueString("objectKey");
|
||||
String name = run.getValueString("runKey") + "-" + artifact.getValueString("objectKey").replace('/', '-');
|
||||
items.putIfAbsent(key, new DownloadItem(table, isArtifact && artifact == record ? artifact.getValueInteger("id") : runId, storage, key, name,
|
||||
Boolean.TRUE.equals(artifact.getValueBoolean("encrypted")), artifact.getValueLong("bytesStored") == null ? 0 : artifact.getValueLong("bytesStored")));
|
||||
}
|
||||
}
|
||||
return (new ArrayList<>(items.values()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review: what will be linked.
|
||||
*******************************************************************************/
|
||||
public static class DownloadTransform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine objects = RestoreSummaries.line(Status.OK, 0, "file will get a download link", "files will get download links", "file got a download link", "files got download links");
|
||||
private final ProcessSummaryLine encrypted = RestoreSummaries.line(Status.INFO, 0, "file is encrypted: you get the ciphertext only, and decrypt it with the command line tool",
|
||||
"files are encrypted: you get the ciphertext only, and decrypt them with the command line tool", "file is encrypted (ciphertext only)", "files are encrypted (ciphertext only)");
|
||||
private long bytes;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(DownloadItem item : items(input.getRecords()))
|
||||
{
|
||||
objects.incrementCount();
|
||||
bytes += item.size();
|
||||
if(item.encrypted())
|
||||
{
|
||||
encrypted.incrementCount();
|
||||
}
|
||||
}
|
||||
output.getRecords().addAll(input.getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
objects.addSelfToListIfAnyCount(lines);
|
||||
encrypted.addSelfToListIfAnyCount(lines);
|
||||
if(objects.getCount() != null && objects.getCount() > 0)
|
||||
{
|
||||
lines.add(RestoreSummaries.message(Status.INFO, "The links last " + LINK_TTL.toMinutes() + " minutes and the download is about " + bytes + " bytes. Each link is recorded in the audit."));
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute: issue the presigned links (ciphertext only), each one audited.
|
||||
*******************************************************************************/
|
||||
public static class DownloadLoad extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine issued = RestoreSummaries.line(Status.OK, 0, "download link was issued", "download links were issued", "download link was issued", "download links were issued");
|
||||
private final ArrayList<String> links = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
String user = QContext.getQSession() == null || QContext.getQSession().getUser() == null ? "system" : QContext.getQSession().getUser().getIdReference();
|
||||
for(DownloadItem item : items(input.getRecords()))
|
||||
{
|
||||
Instant expires = BackupsRuntime.getClock().instant().plus(LINK_TTL);
|
||||
String url = item.storage().presignGet(item.key(), LINK_TTL, item.downloadName());
|
||||
links.add(item.downloadName() + (item.encrypted() ? " (encrypted)" : "") + ": " + url);
|
||||
issued.incrementCount();
|
||||
LOG.info("Issued a backup download link", logPair("object", item.key()), logPair("user", user), logPair("expires", expires));
|
||||
try
|
||||
{
|
||||
AuditAction.execute(item.auditTable(), item.auditId(), Map.of(), "Download link issued for " + item.key() + " to " + user + ", expires " + expires);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
LOG.warn("Could not audit a download link", e, logPair("object", item.key()));
|
||||
}
|
||||
}
|
||||
output.getRecords().addAll(input.getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
issued.setBulletsOfText(links);
|
||||
issued.addSelfToListIfAnyCount(lines);
|
||||
if(issued.getCount() != null && issued.getCount() > 0)
|
||||
{
|
||||
lines.add(RestoreSummaries.message(Status.INFO, "The links expire in " + LINK_TTL.toMinutes() + " minutes. Encrypted files download as ciphertext: use the command line tool to decrypt them."));
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.processes;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.permissions.BackupsPermissions;
|
||||
import com.kingsrook.qbits.backups.restore.RestoreEngine;
|
||||
import com.kingsrook.qbits.backups.restore.RestoreOutcome;
|
||||
import com.kingsrook.qbits.backups.restore.RestoreRequest;
|
||||
import com.kingsrook.qbits.backups.restore.RestoreSummaries;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The restore processes (spec 9.3): Restore... on a backup run, and Restore this
|
||||
** table on a backup artifact. Both are the standard streamed ETL. The first
|
||||
** screen takes the inputs; Review is a real dry run of the whole restore with the
|
||||
** per-table summary lines and a masked sample diff; Execute is the restore, and
|
||||
** Result shows what was done. Neither is in the nav.
|
||||
*******************************************************************************/
|
||||
public final class RestoreProcesses
|
||||
{
|
||||
public static final String RESTORE = "backupRestore";
|
||||
public static final String RESTORE_TABLE = "backupRestoreTable";
|
||||
|
||||
public static final String STEP_INPUT = "input";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private RestoreProcesses()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The processes defined here.
|
||||
*******************************************************************************/
|
||||
public static List<QProcessMetaData> processes()
|
||||
{
|
||||
List<QProcessMetaData> processes = new ArrayList<>();
|
||||
processes.add(BackupsProcesses.permission(restore(RESTORE, "Restore...", BackupsTables.RUN), BackupsPermissions.RESTORE));
|
||||
processes.add(BackupsProcesses.permission(restore(RESTORE_TABLE, "Restore this table", BackupsTables.ARTIFACT), BackupsPermissions.RESTORE));
|
||||
return (processes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One restore process, sourced from the table whose records it is run on.
|
||||
*******************************************************************************/
|
||||
private static QProcessMetaData restore(String name, String label, String table)
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_TRANSACTION_LEVEL, StreamedETLWithFrontendProcess.TRANSACTION_LEVEL_AUTO_COMMIT);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, "This is what the restore will do. Nothing is written until you continue.");
|
||||
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, RestoreTransform.class, RestoreLoad.class, values);
|
||||
process.withStep(0, inputStep());
|
||||
process.setName(name);
|
||||
process.setLabel(label);
|
||||
process.setTableName(table);
|
||||
process.setPermissionRules(BackupsTables.permissionRules());
|
||||
return (process);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The first screen: what to restore and how.
|
||||
*******************************************************************************/
|
||||
private static QFrontendStepMetaData inputStep()
|
||||
{
|
||||
return (new QFrontendStepMetaData()
|
||||
.withName(STEP_INPUT)
|
||||
.withLabel("Restore options")
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM))
|
||||
.withFormField(new QFieldMetaData("tables", QFieldType.STRING).withLabel("Tables (blank for every table in the backup)"))
|
||||
.withFormField(new QFieldMetaData("tableFilters", QFieldType.TEXT).withLabel("Only these records (JSON: table name to filter, optional)"))
|
||||
.withFormField(new QFieldMetaData("targetTable", QFieldType.STRING).withLabel("Restore into another table (optional, one table only)"))
|
||||
.withFormField(new QFieldMetaData("mode", QFieldType.STRING).withLabel("Mode").withIsRequired(true).withDefaultValue(RestoreRequest.MODE_INSERT_NEW)
|
||||
.withPossibleValueSourceName(BackupsTables.pvs("restore_mode")))
|
||||
.withFormField(new QFieldMetaData("matchOn", QFieldType.STRING).withLabel("Match records on (PRIMARY_KEY, or a unique key's fields)").withDefaultValue(RestoreRequest.MATCH_PRIMARY_KEY))
|
||||
.withFormField(new QFieldMetaData("idStrategy", QFieldType.STRING).withLabel("Ids").withIsRequired(true).withDefaultValue(RestoreRequest.IDS_PRESERVE)
|
||||
.withPossibleValueSourceName(BackupsTables.pvs("id_strategy")))
|
||||
.withFormField(new QFieldMetaData("suppressAutomations", QFieldType.BOOLEAN).withLabel("Do not trigger automations").withDefaultValue(true))
|
||||
.withFormField(new QFieldMetaData("publishEvents", QFieldType.BOOLEAN).withLabel("Publish change events").withDefaultValue(false))
|
||||
.withFormField(new QFieldMetaData("runCustomizers", QFieldType.BOOLEAN).withLabel("Run table customizers").withDefaultValue(false))
|
||||
.withFormField(new QFieldMetaData("perRecordAudit", QFieldType.BOOLEAN).withLabel("Audit each record (otherwise one summary per table)").withDefaultValue(false))
|
||||
.withFormField(new QFieldMetaData("restoreFiles", QFieldType.BOOLEAN).withLabel("Restore referenced files").withDefaultValue(true))
|
||||
.withFormField(new QFieldMetaData("fileTargetBucketAlias", QFieldType.STRING).withLabel("Restore files to another bucket alias (optional)"))
|
||||
.withFormField(new QFieldMetaData("fileTargetPrefix", QFieldType.STRING).withLabel("Restore files under this prefix (optional)"))
|
||||
.withFormField(new QFieldMetaData("preRestoreBackup", QFieldType.BOOLEAN).withLabel("Back up the target first").withDefaultValue(true))
|
||||
.withFormField(new QFieldMetaData("confirmationText", QFieldType.STRING).withLabel("Type the table name (or REPLACE <n> TABLES) to confirm a REPLACE")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The backup runs the selected records belong to, with the tables chosen for each
|
||||
** (empty means the tables of the form). Artifact records choose their own table.
|
||||
*******************************************************************************/
|
||||
static Map<Integer, Set<String>> sources(List<QRecord> records) throws QException
|
||||
{
|
||||
Map<Integer, Set<String>> sources = new LinkedHashMap<>();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
boolean artifact = BackupsTables.ARTIFACT.equals(record.getTableName()) || record.getValue("artifactType") != null;
|
||||
if(artifact)
|
||||
{
|
||||
String table = record.getValueString("tableName");
|
||||
if(table == null)
|
||||
{
|
||||
throw (new QUserFacingException("Only the data of a table can be restored: choose a table data artifact."));
|
||||
}
|
||||
sources.computeIfAbsent(record.getValueInteger("backupRunId"), k -> new LinkedHashSet<>()).add(table);
|
||||
}
|
||||
else
|
||||
{
|
||||
sources.computeIfAbsent(record.getValueInteger("id"), k -> new LinkedHashSet<>());
|
||||
}
|
||||
}
|
||||
return (sources);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review (preview, validate) is the dry run; execute only re-checks the rails.
|
||||
*******************************************************************************/
|
||||
public static class RestoreTransform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final List<RestoreOutcome> outcomes = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
boolean execute = StreamedETLWithFrontendProcess.STEP_NAME_EXECUTE.equals(input.getStepName());
|
||||
RestoreEngine engine = new RestoreEngine();
|
||||
for(Map.Entry<Integer, Set<String>> source : sources(input.getRecords()).entrySet())
|
||||
{
|
||||
RestoreRequest request = RestoreRequest.fromInput(input, source.getKey(), new ArrayList<>(source.getValue()));
|
||||
if(execute)
|
||||
{
|
||||
RestoreEngine.Plan plan = engine.plan(request);
|
||||
if(!plan.errors().isEmpty())
|
||||
{
|
||||
throw (new QUserFacingException(String.join(" ", plan.errors())));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
outcomes.add(engine.review(request));
|
||||
}
|
||||
}
|
||||
output.getRecords().addAll(input.getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
for(RestoreOutcome outcome : outcomes)
|
||||
{
|
||||
lines.addAll(RestoreSummaries.lines(outcome, false));
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute: the restore itself, one backup run at a time.
|
||||
*******************************************************************************/
|
||||
public static class RestoreLoad extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final List<RestoreOutcome> outcomes = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
RestoreEngine engine = new RestoreEngine();
|
||||
for(Map.Entry<Integer, Set<String>> source : sources(input.getRecords()).entrySet())
|
||||
{
|
||||
RestoreRequest request = RestoreRequest.fromInput(input, source.getKey(), new ArrayList<>(source.getValue()));
|
||||
outcomes.add(engine.execute(request));
|
||||
}
|
||||
output.getRecords().addAll(input.getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
for(RestoreOutcome outcome : outcomes)
|
||||
{
|
||||
lines.addAll(RestoreSummaries.lines(outcome, true));
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.processes;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.backups.BackupsQBitConfig;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.permissions.BackupsPermissions;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qbits.backups.sla.SlaChecker;
|
||||
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.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import static com.kingsrook.qbits.backups.processes.BackupsProcesses.line;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The verification and SLA processes (design spec section 10):
|
||||
** <ul>
|
||||
** <li>backupVerify: the record action "Verify backup" on backups_run, a standard
|
||||
** streamed ETL. Review lists what will be checked (nothing is read); the run
|
||||
** does the STORAGE level and then, if that passes, the CONTENT level. The input
|
||||
** value verifyLevel (STORAGE, CONTENT or TEST_RESTORE) picks how far to go;
|
||||
** CONTENT is the default and TEST_RESTORE is for the API and schedules.</li>
|
||||
** <li>backupCheckSla: the scheduled SLA check, every slaCheckIntervalSeconds on
|
||||
** the configured scheduler, independent of the backup trigger. It is not in the
|
||||
** nav.</li>
|
||||
** </ul>
|
||||
*******************************************************************************/
|
||||
public final class VerifyProcesses
|
||||
{
|
||||
public static final String VERIFY = "backupVerify";
|
||||
public static final String CHECK_SLA = "backupCheckSla";
|
||||
|
||||
public static final String FIELD_VERIFY_LEVEL = "verifyLevel";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private VerifyProcesses()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The processes of this area.
|
||||
*******************************************************************************/
|
||||
public static List<QProcessMetaData> processes()
|
||||
{
|
||||
List<QProcessMetaData> processes = new ArrayList<>();
|
||||
processes.add(BackupsProcesses.permission(BackupsProcesses.etl(VERIFY, "Verify backup", BackupsTables.RUN, VerifyTransform.class, VerifyLoad.class,
|
||||
"This checks that the backup's objects are all in storage, then reads every byte back, decrypts and decodes it, and compares counts and hashes with the manifest. Nothing is changed in the backup."),
|
||||
BackupsPermissions.RUN));
|
||||
processes.add(checkSlaProcess(BackupsRuntime.getConfig()));
|
||||
return (processes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The SLA check process. With a scheduler configured it is scheduled every
|
||||
** slaCheckIntervalSeconds.
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData checkSlaProcess(BackupsQBitConfig config)
|
||||
{
|
||||
QProcessMetaData process = new QProcessMetaData()
|
||||
.withName(CHECK_SLA)
|
||||
.withLabel("Check backup SLAs")
|
||||
.withStepList(List.<QStepMetaData>of(new QBackendStepMetaData().withName("check").withCode(new QCodeReference(CheckSlaStep.class))));
|
||||
process.setPermissionRules(BackupsTables.permissionRules());
|
||||
|
||||
if(config != null && config.getSchedulerName() != null && config.getSlaCheckIntervalSeconds() != null)
|
||||
{
|
||||
process.setSchedule(new QScheduleMetaData()
|
||||
.withSchedulerName(config.getSchedulerName())
|
||||
.withRepeatSeconds(config.getSlaCheckIntervalSeconds())
|
||||
.withInitialDelaySeconds(30)
|
||||
.withDescription("Backup SLA check (independent of the backup trigger)"));
|
||||
}
|
||||
return (BackupsProcesses.permission(process, BackupsPermissions.RUN));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The scheduled SLA check.
|
||||
*******************************************************************************/
|
||||
public static class CheckSlaStep implements BackendStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Check every active plan.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
List<SlaChecker.PlanResult> results = new SlaChecker().checkAll();
|
||||
long attention = results.stream().filter(r -> !SlaChecker.OK.equals(r.status()) && !SlaChecker.UNKNOWN.equals(r.status())).count();
|
||||
output.addValue("plansChecked", results.size());
|
||||
output.addValue("plansNeedingAttention", attention);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Verify backup, review: which runs can be verified and how much will be read.
|
||||
*******************************************************************************/
|
||||
public static class VerifyTransform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine runs = line(Status.OK, "backup will be verified", "backups will be verified", "backup was verified", "backups were verified");
|
||||
private final ProcessSummaryLine objects = line(Status.INFO, "stored object will be checked", "stored objects will be checked", "stored object was checked", "stored objects were checked");
|
||||
private final ProcessSummaryLine files = line(Status.INFO, "referenced file will be checked", "referenced files will be checked", "referenced file was checked", "referenced files were checked");
|
||||
private final ProcessSummaryLine noManifest = line(Status.ERROR, "backup has no manifest, so it is not usable and cannot be verified", "backups have no manifest, so they are not usable and cannot be verified",
|
||||
"backup had no manifest", "backups had no manifest");
|
||||
private final ProcessSummaryLine pruned = line(Status.ERROR, "backup was pruned, so its objects are gone", "backups were pruned, so their objects are gone", "backup was pruned", "backups were pruned");
|
||||
private final BackupCatalog catalog = new BackupCatalog();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Look at each run without reading any backup bytes.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord run : input.getRecords())
|
||||
{
|
||||
java.io.Serializable id = run.getValue("id");
|
||||
if(run.getValueInstant("prunedDate") != null)
|
||||
{
|
||||
pruned.incrementCountAndAddPrimaryKey(id);
|
||||
}
|
||||
else if(run.getValueString("manifestKey") == null)
|
||||
{
|
||||
noManifest.incrementCountAndAddPrimaryKey(id);
|
||||
}
|
||||
else
|
||||
{
|
||||
runs.incrementCountAndAddPrimaryKey(id);
|
||||
objects.incrementCount(catalog.where(BackupsTables.ARTIFACT, "backupRunId", run.getValueInteger("id")).size());
|
||||
files.incrementCount(run.getValueInteger("filesReferenced") == null ? 0 : run.getValueInteger("filesReferenced"));
|
||||
output.getRecords().add(run);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review lines.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
runs.addSelfToListIfAnyCount(lines);
|
||||
objects.addSelfToListIfAnyCount(lines);
|
||||
files.addSelfToListIfAnyCount(lines);
|
||||
noManifest.addSelfToListIfAnyCount(lines);
|
||||
pruned.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Verify backup, execute: the storage level, then the content level.
|
||||
*******************************************************************************/
|
||||
public static class VerifyLoad extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine passed = line(Status.OK, "backup will pass verification", "backups passed verification", "backup passed verification", "backups passed verification");
|
||||
private final ProcessSummaryLine warnings = line(Status.WARNING, "backup passed with warnings", "backups passed with warnings", "backup passed with warnings", "backups passed with warnings");
|
||||
private final ProcessSummaryLine failed = line(Status.ERROR, "backup failed verification", "backups failed verification", "backup failed verification", "backups failed verification");
|
||||
private final ProcessSummaryLine errored = line(Status.ERROR, "backup could not be verified", "backups could not be verified", "backup could not be verified", "backups could not be verified");
|
||||
private final ProcessSummaryLine rows = line(Status.INFO, "row was decoded and checked", "rows were decoded and checked", "row was decoded and checked", "rows were decoded and checked");
|
||||
private final ProcessSummaryLine files = line(Status.INFO, "file was checked", "files were checked", "file was checked", "files were checked");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Verify each run.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
String level = input.getValueString(FIELD_VERIFY_LEVEL) == null ? BackupVerifier.CONTENT : input.getValueString(FIELD_VERIFY_LEVEL);
|
||||
String user = QContext.getQSession() == null || QContext.getQSession().getUser() == null ? "system" : QContext.getQSession().getUser().getIdReference();
|
||||
for(QRecord run : input.getRecords())
|
||||
{
|
||||
List<VerificationOutcome> outcomes = new BackupVerifier().verifyThrough(run.getValueInteger("id"), level, "MANUAL", user);
|
||||
VerificationOutcome last = outcomes.get(outcomes.size() - 1);
|
||||
switch(last.status())
|
||||
{
|
||||
case "PASSED" -> passed.incrementCountAndAddPrimaryKey(run.getValue("id"));
|
||||
case "PASSED_WITH_WARNINGS" -> warnings.incrementCountAndAddPrimaryKey(run.getValue("id"));
|
||||
case "ERROR" ->
|
||||
{
|
||||
errored.incrementCountAndAddPrimaryKey(run.getValue("id"));
|
||||
errored.setMessageSuffix(last.messages().isEmpty() ? null : ": " + last.messages().get(0));
|
||||
}
|
||||
default ->
|
||||
{
|
||||
failed.incrementCountAndAddPrimaryKey(run.getValue("id"));
|
||||
failed.setMessageSuffix(last.messages().isEmpty() ? null : ": " + last.messages().get(0));
|
||||
}
|
||||
}
|
||||
for(VerificationOutcome outcome : outcomes)
|
||||
{
|
||||
rows.incrementCount((int) Math.min(Integer.MAX_VALUE, outcome.rowsChecked()));
|
||||
files.incrementCount(outcome.filesChecked());
|
||||
}
|
||||
output.getRecords().add(run);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Result lines.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
passed.addSelfToListIfAnyCount(lines);
|
||||
warnings.addSelfToListIfAnyCount(lines);
|
||||
failed.addSelfToListIfAnyCount(lines);
|
||||
errored.addSelfToListIfAnyCount(lines);
|
||||
rows.addSelfToListIfAnyCount(lines);
|
||||
files.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
/*
|
||||
* 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.restore;
|
||||
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.DigestInputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.codec.ArtifactTransform;
|
||||
import com.kingsrook.qbits.backups.codec.CsvRowCodec;
|
||||
import com.kingsrook.qbits.backups.codec.HashingOutputStream;
|
||||
import com.kingsrook.qbits.backups.codec.JsonlRowCodec;
|
||||
import com.kingsrook.qbits.backups.manifest.BackupManifest;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qbits.backups.storage.BackupStorage;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A backup run opened for restore: the catalog row, its manifest (checked against
|
||||
** the manifest hash in the catalog), its storage, and readers for the rows and the
|
||||
** file index. Every part is checked against the stored SHA-256 in the manifest as
|
||||
** it is read, so a damaged part fails the restore instead of restoring bad rows.
|
||||
*******************************************************************************/
|
||||
public final class BackupSource
|
||||
{
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////
|
||||
// Supplies the transform that decrypts the parts of an encrypted run. The //
|
||||
// default knows no keys, so restoring an encrypted run fails with a clear //
|
||||
// message until the crypto package (QQQ core PR 1107) provider is wired in. //
|
||||
/////////////////////////////////////////////////////////////////////////////////
|
||||
private static volatile Function<BackupManifest.EncryptionInfo, ArtifactTransform> transformResolver = null;
|
||||
|
||||
private final QRecord run;
|
||||
private final BackupStorage storage;
|
||||
private final String runPrefix;
|
||||
private final BackupManifest manifest;
|
||||
private final ArtifactTransform transform;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private BackupSource(QRecord run, BackupStorage storage, String runPrefix, BackupManifest manifest, ArtifactTransform transform)
|
||||
{
|
||||
this.run = run;
|
||||
this.storage = storage;
|
||||
this.runPrefix = runPrefix;
|
||||
this.manifest = manifest;
|
||||
this.transform = transform;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Install the resolver for the decrypting transform (null removes it).
|
||||
*******************************************************************************/
|
||||
public static void setTransformResolver(Function<BackupManifest.EncryptionInfo, ArtifactTransform> resolver)
|
||||
{
|
||||
transformResolver = resolver;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Open a run for restore.
|
||||
*******************************************************************************/
|
||||
public static BackupSource open(Integer runId) throws QException
|
||||
{
|
||||
QRecord run = new BackupCatalog().get(BackupsTables.RUN, runId).orElseThrow(() -> new QException("The backup run " + runId + " was not found"));
|
||||
String status = run.getValueString("status");
|
||||
if(!"SUCCEEDED".equals(status) && !"SUCCEEDED_WITH_WARNINGS".equals(status))
|
||||
{
|
||||
throw (new QException("The backup run " + run.getValueString("runKey") + " did not complete (status " + status + "), so it cannot be restored"));
|
||||
}
|
||||
String manifestKey = run.getValueString("manifestKey");
|
||||
if(manifestKey == null)
|
||||
{
|
||||
throw (new QException("The backup run " + run.getValueString("runKey") + " has no manifest"));
|
||||
}
|
||||
|
||||
BackupStorage storage = BackupsRuntime.storageFor(run.getValueString("storageBucketAlias"));
|
||||
try
|
||||
{
|
||||
byte[] bytes = readAll(storage.openRead(manifestKey));
|
||||
String expected = run.getValueString("manifestSha256");
|
||||
if(expected != null && !expected.equals(HashingOutputStream.sha256Hex(bytes)))
|
||||
{
|
||||
throw (new QException("The manifest of backup run " + run.getValueString("runKey") + " does not match its recorded checksum"));
|
||||
}
|
||||
BackupManifest manifest = BackupManifest.read(bytes);
|
||||
String runPrefix = run.getValueString("runPrefix");
|
||||
if(runPrefix == null)
|
||||
{
|
||||
runPrefix = manifestKey.substring(0, manifestKey.length() - "manifest.json".length());
|
||||
}
|
||||
return (new BackupSource(run, storage, runPrefix, manifest, transformFor(manifest)));
|
||||
}
|
||||
catch(IOException | RuntimeException e)
|
||||
{
|
||||
throw (new QException("Could not read the manifest of backup run " + run.getValueString("runKey") + ": " + e.getMessage(), e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static ArtifactTransform transformFor(BackupManifest manifest) throws QException
|
||||
{
|
||||
BackupManifest.EncryptionInfo encryption = manifest.encryption();
|
||||
if(encryption == null || encryption.mode() == null || "NONE".equals(encryption.mode()))
|
||||
{
|
||||
return (ArtifactTransform.IDENTITY);
|
||||
}
|
||||
Function<BackupManifest.EncryptionInfo, ArtifactTransform> resolver = transformResolver;
|
||||
ArtifactTransform resolved = resolver == null ? null : resolver.apply(encryption);
|
||||
if(resolved == null)
|
||||
{
|
||||
throw (new QException("This backup is encrypted (key " + encryption.keyRef() + "), and no key provider is available to restore it"));
|
||||
}
|
||||
return (resolved);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static byte[] readAll(InputStream in) throws IOException
|
||||
{
|
||||
try(in)
|
||||
{
|
||||
return (in.readAllBytes());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The catalog row of the run.
|
||||
*******************************************************************************/
|
||||
public QRecord getRun()
|
||||
{
|
||||
return (run);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The manifest.
|
||||
*******************************************************************************/
|
||||
public BackupManifest getManifest()
|
||||
{
|
||||
return (manifest);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The storage the backup lives in.
|
||||
*******************************************************************************/
|
||||
public BackupStorage getStorage()
|
||||
{
|
||||
return (storage);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The transform that decrypts the stored parts (identity when not encrypted).
|
||||
*******************************************************************************/
|
||||
public ArtifactTransform getTransform()
|
||||
{
|
||||
return (transform);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The prefix of the run's objects.
|
||||
*******************************************************************************/
|
||||
public String getRunPrefix()
|
||||
{
|
||||
return (runPrefix);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stream the rows of one table, decoding each with the target table's field types.
|
||||
** The sink may throw an unchecked exception to stop.
|
||||
*******************************************************************************/
|
||||
public void readRows(BackupManifest.TableEntry entry, QTableMetaData decodeTable, Consumer<QRecord> sink) throws QException
|
||||
{
|
||||
List<BackupManifest.Part> parts = new ArrayList<>();
|
||||
for(BackupManifest.Part part : entry.parts())
|
||||
{
|
||||
if("JSONL".equals(part.format()))
|
||||
{
|
||||
parts.add(part);
|
||||
}
|
||||
}
|
||||
if(parts.isEmpty())
|
||||
{
|
||||
parts.addAll(entry.parts());
|
||||
}
|
||||
|
||||
for(BackupManifest.Part part : parts)
|
||||
{
|
||||
try
|
||||
{
|
||||
readPart(part, decodeTable, sink);
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw (new QException("Could not read " + part.key() + " of the backup: " + e.getMessage(), e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void readPart(BackupManifest.Part part, QTableMetaData decodeTable, Consumer<QRecord> sink) throws IOException, QException
|
||||
{
|
||||
MessageDigest digest = HashingOutputStream.newSha256();
|
||||
try(DigestInputStream stored = new DigestInputStream(storage.openRead(runPrefix + part.key()), digest))
|
||||
{
|
||||
InputStream plain = transform.unwrap(stored);
|
||||
InputStream text = "GZIP".equals(part.compression()) ? new GZIPInputStream(plain) : plain;
|
||||
|
||||
if("CSV".equals(part.format()))
|
||||
{
|
||||
String all = new String(text.readAllBytes(), StandardCharsets.UTF_8);
|
||||
List<String> records = CsvRowCodec.splitRecords(all);
|
||||
if(!records.isEmpty())
|
||||
{
|
||||
CsvRowCodec codec = new CsvRowCodec(decodeTable, null);
|
||||
List<String> header = CsvRowCodec.splitCells(records.get(0));
|
||||
for(int i = 1; i < records.size(); i++)
|
||||
{
|
||||
sink.accept(codec.toRecord(CsvRowCodec.splitCells(records.get(i)), header));
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
JsonlRowCodec codec = new JsonlRowCodec(decodeTable, null);
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(text, StandardCharsets.UTF_8));
|
||||
String line;
|
||||
while((line = reader.readLine()) != null)
|
||||
{
|
||||
if(!line.isEmpty())
|
||||
{
|
||||
sink.accept(codec.decode(line));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stored.transferTo(OutputStream.nullOutputStream());
|
||||
}
|
||||
|
||||
String actual = HashingOutputStream.hex(digest.digest());
|
||||
if(part.sha256Stored() != null && !part.sha256Stored().equals(actual))
|
||||
{
|
||||
throw (new IOException("checksum mismatch (the stored part is damaged or was changed)"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The file index entries of the given tables, keyed by table, record key and field.
|
||||
*******************************************************************************/
|
||||
public Map<String, JsonNode> fileIndex(Set<String> tables) throws QException
|
||||
{
|
||||
Map<String, JsonNode> index = new HashMap<>();
|
||||
if(manifest.files() == null || manifest.files().indexKeys() == null)
|
||||
{
|
||||
return (index);
|
||||
}
|
||||
for(String indexKey : manifest.files().indexKeys())
|
||||
{
|
||||
try(InputStream raw = storage.openRead(runPrefix + indexKey);
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(new GZIPInputStream(transform.unwrap(raw)), StandardCharsets.UTF_8)))
|
||||
{
|
||||
String line;
|
||||
while((line = reader.readLine()) != null)
|
||||
{
|
||||
if(line.isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
JsonNode node = MAPPER.readTree(new ByteArrayInputStream(line.getBytes(StandardCharsets.UTF_8)));
|
||||
if(tables.contains(node.path("table").asText()))
|
||||
{
|
||||
index.put(indexKeyOf(node.path("table").asText(), node.path("recordKey").asText(), node.path("field").asText()), node);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw (new QException("Could not read the file index " + indexKey + ": " + e.getMessage(), e));
|
||||
}
|
||||
}
|
||||
return (index);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The lookup key of a file index entry.
|
||||
*******************************************************************************/
|
||||
public static String indexKeyOf(String table, String recordKey, String field)
|
||||
{
|
||||
return (table + "\u0001" + recordKey + "\u0001" + field);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
/*
|
||||
* 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.restore;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Serializable;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import com.kingsrook.qbits.backups.codec.HashingOutputStream;
|
||||
import com.kingsrook.qbits.backups.manifest.BackupManifest;
|
||||
import com.kingsrook.qbits.backups.manifest.TableFingerprints;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qbits.backups.storage.BackupStorage;
|
||||
import com.kingsrook.qbits.backups.storage.StoredObjectInfo;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rebuilds the catalog from storage (spec 9.6): every manifest is
|
||||
** self-describing, so after the database with the catalog is lost, the manifests
|
||||
** under a bucket alias and prefix are listed, validated, and turned back into
|
||||
** backups_run and backups_artifact rows (trigger IMPORTED). The content
|
||||
** addressed file store is re-registered in backups_file_object so dedup keeps
|
||||
** working. Importing is idempotent: a run already in the catalog is skipped.
|
||||
**
|
||||
** Validation: the manifest must be readable and of a known version, its run key
|
||||
** must match its folder, checksums.sha256 must exist and agree with the manifest
|
||||
** for every part, and every part must exist with the recorded size. The checksum
|
||||
** of the manifest itself is computed and stored on the run, so a later restore
|
||||
** detects a changed manifest.
|
||||
*******************************************************************************/
|
||||
public class CatalogImporter
|
||||
{
|
||||
private static final Pattern PART_NUMBER = Pattern.compile("part-(\\d+)");
|
||||
private static final Pattern SHA_KEY = Pattern.compile("^[0-9a-f]{64}$");
|
||||
|
||||
private final BackupCatalog catalog = new BackupCatalog();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What was found for one manifest.
|
||||
*******************************************************************************/
|
||||
public record Inspection(String manifestKey, String status, List<String> problems, BackupManifest manifest, String runPrefix, String manifestSha256)
|
||||
{
|
||||
public static final String NEW = "NEW";
|
||||
public static final String EXISTS = "EXISTS";
|
||||
public static final String INVALID = "INVALID";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The keys of every manifest under a prefix.
|
||||
*******************************************************************************/
|
||||
public List<String> findManifestKeys(BackupStorage storage, String prefix)
|
||||
{
|
||||
List<String> keys = new ArrayList<>();
|
||||
for(StoredObjectInfo info : storage.list(prefix == null ? "" : prefix))
|
||||
{
|
||||
if(info.key().endsWith("/manifest.json"))
|
||||
{
|
||||
keys.add(info.key());
|
||||
}
|
||||
}
|
||||
return (keys);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read and validate one manifest, and say whether the catalog already has it.
|
||||
*******************************************************************************/
|
||||
public Inspection inspect(BackupStorage storage, String manifestKey) throws QException
|
||||
{
|
||||
String runPrefix = manifestKey.substring(0, manifestKey.length() - "manifest.json".length());
|
||||
byte[] bytes;
|
||||
BackupManifest manifest;
|
||||
try
|
||||
{
|
||||
bytes = BackupSource.readAll(storage.openRead(manifestKey));
|
||||
manifest = BackupManifest.read(bytes);
|
||||
}
|
||||
catch(IOException | RuntimeException e)
|
||||
{
|
||||
return (invalid(manifestKey, runPrefix, "The manifest cannot be read: " + e.getMessage()));
|
||||
}
|
||||
|
||||
String sha = HashingOutputStream.sha256Hex(bytes);
|
||||
List<String> problems = new ArrayList<>();
|
||||
if(manifest.manifestVersion() > BackupManifest.CURRENT_VERSION)
|
||||
{
|
||||
problems.add("The manifest is version " + manifest.manifestVersion() + ", newer than this qbit understands");
|
||||
}
|
||||
String[] segments = runPrefix.split("/");
|
||||
if(manifest.runKey() == null || segments.length == 0 || !manifest.runKey().equals(segments[segments.length - 1]))
|
||||
{
|
||||
problems.add("The manifest's run key does not match its folder");
|
||||
}
|
||||
|
||||
if(!problems.isEmpty())
|
||||
{
|
||||
return (new Inspection(manifestKey, Inspection.INVALID, problems, manifest, runPrefix, sha));
|
||||
}
|
||||
|
||||
if(!catalog.where(BackupsTables.RUN, "runKey", manifest.runKey()).isEmpty())
|
||||
{
|
||||
return (new Inspection(manifestKey, Inspection.EXISTS, List.of(), manifest, runPrefix, sha));
|
||||
}
|
||||
|
||||
checkParts(storage, manifest, runPrefix, problems);
|
||||
return (new Inspection(manifestKey, problems.isEmpty() ? Inspection.NEW : Inspection.INVALID, problems, manifest, runPrefix, sha));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private Inspection invalid(String manifestKey, String runPrefix, String problem)
|
||||
{
|
||||
return (new Inspection(manifestKey, Inspection.INVALID, List.of(problem), null, runPrefix, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Check checksums.sha256 against the manifest, and that each part exists.
|
||||
*******************************************************************************/
|
||||
private void checkParts(BackupStorage storage, BackupManifest manifest, String runPrefix, List<String> problems)
|
||||
{
|
||||
Map<String, String> checksums = readChecksums(storage, runPrefix);
|
||||
if(checksums == null)
|
||||
{
|
||||
problems.add("checksums.sha256 is missing or unreadable");
|
||||
return;
|
||||
}
|
||||
|
||||
for(BackupManifest.TableEntry table : manifest.tables() == null ? List.<BackupManifest.TableEntry>of() : manifest.tables())
|
||||
{
|
||||
for(BackupManifest.Part part : table.parts())
|
||||
{
|
||||
String listed = checksums.get(part.key());
|
||||
if(listed == null || !listed.equals(part.sha256Stored()))
|
||||
{
|
||||
problems.add("The checksum of " + part.key() + " does not match the manifest");
|
||||
}
|
||||
else if(storage.head(runPrefix + part.key()).map(StoredObjectInfo::size).orElse(-1L) != part.bytesStored())
|
||||
{
|
||||
problems.add("The part " + part.key() + " is missing or has the wrong size");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create the catalog rows of one valid manifest. Returns the new run id.
|
||||
*******************************************************************************/
|
||||
public Integer importRun(String bucketAlias, BackupStorage storage, Inspection inspection) throws QException
|
||||
{
|
||||
BackupManifest manifest = inspection.manifest();
|
||||
String runPrefix = inspection.runPrefix();
|
||||
List<BackupManifest.TableEntry> tables = manifest.tables() == null ? List.of() : manifest.tables();
|
||||
|
||||
Instant started = parse(manifest.startedAt());
|
||||
Instant ended = parse(manifest.finishedAt());
|
||||
Integer planId = null;
|
||||
if(manifest.plan() != null && manifest.plan().planKey() != null)
|
||||
{
|
||||
List<QRecord> plans = catalog.where(BackupsTables.PLAN, "planKey", manifest.plan().planKey());
|
||||
planId = plans.isEmpty() ? null : plans.get(0).getValueInteger("id");
|
||||
}
|
||||
|
||||
Map<String, Serializable> row = new LinkedHashMap<>();
|
||||
row.put("backupPlanId", planId);
|
||||
row.put("runKey", manifest.runKey());
|
||||
row.put("trigger", "IMPORTED");
|
||||
row.put("requestedByUserId", manifest.requestedBy());
|
||||
row.put("status", manifest.warnings() == null || manifest.warnings().isEmpty() ? "SUCCEEDED" : "SUCCEEDED_WITH_WARNINGS");
|
||||
row.put("startDate", started);
|
||||
row.put("endDate", ended);
|
||||
row.put("durationMs", started != null && ended != null ? ended.toEpochMilli() - started.toEpochMilli() : null);
|
||||
row.put("tablesPlanned", tables.size());
|
||||
row.put("tablesDone", tables.size());
|
||||
row.put("rowsTotal", manifest.totals() == null ? null : manifest.totals().rows());
|
||||
row.put("bytesStored", manifest.totals() == null ? null : manifest.totals().bytesStored());
|
||||
row.put("consistencyAchieved", manifest.consistency() == null ? null : manifest.consistency().achieved());
|
||||
row.put("storageBucketAlias", bucketAlias);
|
||||
row.put("runPrefix", runPrefix);
|
||||
row.put("manifestKey", inspection.manifestKey());
|
||||
row.put("manifestSha256", inspection.manifestSha256());
|
||||
row.put("securityScope", manifest.securityScope());
|
||||
row.put("qqqVersion", manifest.instance() == null ? null : manifest.instance().qqqVersion());
|
||||
row.put("appVersion", manifest.instance() == null ? null : manifest.instance().appVersion());
|
||||
row.put("instanceName", manifest.instance() == null ? null : manifest.instance().name());
|
||||
row.put("schemaFingerprint", TableFingerprints.combine(tables.stream().map(BackupManifest.TableEntry::fingerprint).toList()));
|
||||
boolean encrypted = manifest.encryption() != null && manifest.encryption().mode() != null && !"NONE".equals(manifest.encryption().mode());
|
||||
row.put("encryptionKeyRef", manifest.encryption() == null ? null : manifest.encryption().keyRef());
|
||||
row.put("encryptionKeyFingerprint", manifest.encryption() == null ? null : manifest.encryption().keyFingerprint());
|
||||
row.put("verificationStatus", "UNVERIFIED");
|
||||
row.put("warningCount", manifest.warnings() == null ? 0 : manifest.warnings().size());
|
||||
if(manifest.files() != null)
|
||||
{
|
||||
row.put("filesReferenced", (int) manifest.files().objects());
|
||||
row.put("filesMissing", (int) manifest.files().missing());
|
||||
row.put("fileBytesReferenced", manifest.files().bytes());
|
||||
}
|
||||
Integer runId = catalog.insert(BackupsTables.RUN, row);
|
||||
|
||||
Map<String, String> checksums = readChecksums(storage, runPrefix);
|
||||
if(checksums == null)
|
||||
{
|
||||
checksums = new HashMap<>();
|
||||
}
|
||||
for(BackupManifest.TableEntry table : tables)
|
||||
{
|
||||
for(int i = 0; i < table.parts().size(); i++)
|
||||
{
|
||||
BackupManifest.Part part = table.parts().get(i);
|
||||
Matcher matcher = PART_NUMBER.matcher(part.key());
|
||||
int number = matcher.find() ? Integer.parseInt(matcher.group(1)) : i + 1;
|
||||
boolean last = i == table.parts().size() - 1;
|
||||
catalog.insertArtifact(runId, "TABLE_DATA", table.name(), number, part.key(), part.format(), part.compression(), part.encrypted(), part.rows(),
|
||||
part.bytesRaw(), part.bytesStored(), part.sha256Stored(), part.sha256Raw(), last ? table.rowsHash() : null, null, null, table.fingerprint());
|
||||
}
|
||||
}
|
||||
if(manifest.files() != null && manifest.files().indexKeys() != null)
|
||||
{
|
||||
int number = 1;
|
||||
for(String key : manifest.files().indexKeys())
|
||||
{
|
||||
long size = storage.head(runPrefix + key).map(StoredObjectInfo::size).orElse(0L);
|
||||
catalog.insertArtifact(runId, "FILE_OBJECTS_INDEX", null, number++, key, "JSONL", "GZIP", encrypted, 0, 0, size, checksums.get(key), null, null, null, null, null);
|
||||
}
|
||||
}
|
||||
artifactOfKey(storage, runPrefix, "checksums.sha256", "CHECKSUMS", "TEXT", runId, null);
|
||||
artifactOfKey(storage, runPrefix, "manifest.json", "MANIFEST", "JSON", runId, inspection.manifestSha256());
|
||||
|
||||
registerFileStore(bucketAlias, storage, runPrefix, runId, manifest, encrypted);
|
||||
return (runId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void artifactOfKey(BackupStorage storage, String runPrefix, String key, String type, String format, Integer runId, String knownSha) throws QException
|
||||
{
|
||||
long size = storage.head(runPrefix + key).map(StoredObjectInfo::size).orElse(0L);
|
||||
String sha = knownSha;
|
||||
if(sha == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
sha = HashingOutputStream.sha256Hex(BackupSource.readAll(storage.openRead(runPrefix + key)));
|
||||
}
|
||||
catch(IOException | RuntimeException e)
|
||||
{
|
||||
sha = null;
|
||||
}
|
||||
}
|
||||
catalog.insertArtifact(runId, type, null, 1, key, format, "NONE", false, 0, size, size, sha, sha, null, null, null, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private Map<String, String> readChecksums(BackupStorage storage, String runPrefix)
|
||||
{
|
||||
Map<String, String> checksums = new HashMap<>();
|
||||
try
|
||||
{
|
||||
for(String line : new String(BackupSource.readAll(storage.openRead(runPrefix + "checksums.sha256")), StandardCharsets.UTF_8).split("\n"))
|
||||
{
|
||||
String[] parts = line.split(" ", 2);
|
||||
if(parts.length == 2)
|
||||
{
|
||||
checksums.put(parts[1].trim(), parts[0].trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(IOException | RuntimeException e)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
return (checksums);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Re-register the content-addressed files of the instance, so the next backup
|
||||
** deduplicates against them again.
|
||||
*******************************************************************************/
|
||||
private void registerFileStore(String bucketAlias, BackupStorage storage, String runPrefix, Integer runId, BackupManifest manifest, boolean encrypted)
|
||||
{
|
||||
String[] segments = runPrefix.split("/");
|
||||
if(segments.length < 6)
|
||||
{
|
||||
return;
|
||||
}
|
||||
String instancePrefix = String.join("/", java.util.Arrays.copyOf(segments, segments.length - 5)) + "/";
|
||||
String fingerprint = manifest.encryption() == null ? null : manifest.encryption().keyFingerprint();
|
||||
var registry = catalog.fileStoreRegistry(bucketAlias, runId, fingerprint);
|
||||
for(StoredObjectInfo info : storage.list(instancePrefix + "_files/sha256/"))
|
||||
{
|
||||
String name = info.key().substring(info.key().lastIndexOf('/') + 1);
|
||||
int dot = name.indexOf('.');
|
||||
String sha = dot < 0 ? name : name.substring(0, dot);
|
||||
if(SHA_KEY.matcher(sha).matches() && !registry.isKnown(sha))
|
||||
{
|
||||
registry.register(sha, info.size(), null, info.key(), encrypted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Instant parse(String text)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (text == null ? null : Instant.parse(text));
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,651 @@
|
||||
/*
|
||||
* 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.restore;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.backups.BackupsQBitConfig;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.codec.HashingOutputStream;
|
||||
import com.kingsrook.qbits.backups.manifest.BackupManifest;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.permissions.BackupsPermissionChecker;
|
||||
import com.kingsrook.qbits.backups.permissions.BackupsPermissions;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qbits.backups.run.BackupEngine;
|
||||
import com.kingsrook.qbits.backups.run.BackupRunFactory;
|
||||
import com.kingsrook.qbits.backups.run.BackupRunRequest;
|
||||
import com.kingsrook.qbits.backups.run.BackupRunResult;
|
||||
import com.kingsrook.qbits.backups.scope.PlanSettings;
|
||||
import com.kingsrook.qbits.backups.scope.ScopeResolver;
|
||||
import com.kingsrook.qbits.backups.scope.TableRole;
|
||||
import com.kingsrook.qbits.backups.scope.TableRoles;
|
||||
import com.kingsrook.qbits.backups.storage.BackupStorage;
|
||||
import com.kingsrook.qbits.backups.storage.WriteOptions;
|
||||
import com.kingsrook.qqq.backend.core.actions.audits.AuditAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Restores from a backup run (spec 9.3 to 9.5). Two entry points share the same
|
||||
** table pass: review() reads and compares without writing anything, and
|
||||
** execute() takes the pre-restore backup, writes in restore order, resyncs key
|
||||
** sequences, brings files back (re-hashing each against the index), writes one
|
||||
** summary audit per table, and records backups_restore_run and
|
||||
** backups_restore_run_table rows.
|
||||
**
|
||||
** Safety rails (spec 9.4) are enforced here, not only in the screens: the caller
|
||||
** needs backups.restore (REPLACE also backups.admin and the typed confirmation),
|
||||
** SECRET and BACKUPS tables are never restored, EPHEMERAL tables only when named,
|
||||
** and nothing restores while a backup of the same tables is running.
|
||||
*******************************************************************************/
|
||||
public class RestoreEngine
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(RestoreEngine.class);
|
||||
|
||||
private static final long RUNNING_HEARTBEAT_MINUTES = 10;
|
||||
|
||||
private final BackupCatalog catalog = new BackupCatalog();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What a request resolves to: the source, the tables in restore order and the
|
||||
** table each is written to, with every rail violation as an error.
|
||||
*******************************************************************************/
|
||||
public record Plan(BackupSource source, List<String> order, Map<String, String> targets, List<String> errors, List<String> warnings, List<String> notes)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve a request and check every safety rail, without touching any data.
|
||||
*******************************************************************************/
|
||||
public Plan plan(RestoreRequest request)
|
||||
{
|
||||
List<String> errors = new ArrayList<>();
|
||||
List<String> warnings = new ArrayList<>();
|
||||
List<String> notes = new ArrayList<>();
|
||||
List<String> order = new ArrayList<>();
|
||||
Map<String, String> targets = new LinkedHashMap<>();
|
||||
QInstance instance = QContext.getQInstance();
|
||||
|
||||
if(!BackupsPermissionChecker.has(BackupsPermissions.RESTORE))
|
||||
{
|
||||
errors.add("Restoring needs the " + BackupsPermissions.RESTORE + " permission.");
|
||||
}
|
||||
boolean replace = RestoreRequest.MODE_REPLACE.equals(request.getMode());
|
||||
if(!List.of(RestoreRequest.MODE_INSERT_NEW, RestoreRequest.MODE_UPSERT, RestoreRequest.MODE_REPLACE).contains(request.getMode()))
|
||||
{
|
||||
errors.add("Unknown restore mode " + request.getMode() + ".");
|
||||
}
|
||||
if(!List.of(RestoreRequest.IDS_PRESERVE, RestoreRequest.IDS_REMAP).contains(request.getIdStrategy()))
|
||||
{
|
||||
errors.add("Unknown id strategy " + request.getIdStrategy() + ".");
|
||||
}
|
||||
if(replace && !BackupsPermissionChecker.has(BackupsPermissions.ADMIN))
|
||||
{
|
||||
errors.add("A REPLACE restore needs the " + BackupsPermissions.ADMIN + " permission as well.");
|
||||
}
|
||||
if(replace && RestoreRequest.IDS_REMAP.equals(request.getIdStrategy()))
|
||||
{
|
||||
errors.add("REPLACE cannot be combined with new ids: use PRESERVE ids.");
|
||||
}
|
||||
|
||||
BackupSource source = null;
|
||||
try
|
||||
{
|
||||
source = BackupSource.open(request.getSourceRunId());
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
errors.add(e.getMessage());
|
||||
return (new Plan(null, order, targets, errors, warnings, notes));
|
||||
}
|
||||
|
||||
boolean explicit = !request.getTables().isEmpty();
|
||||
List<String> names = new ArrayList<>();
|
||||
if(explicit)
|
||||
{
|
||||
for(String name : request.getTables())
|
||||
{
|
||||
if(source.getManifest().table(name) == null)
|
||||
{
|
||||
errors.add("The table " + name + " is not in this backup.");
|
||||
}
|
||||
else
|
||||
{
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
source.getManifest().tables().forEach(t -> names.add(t.name()));
|
||||
}
|
||||
|
||||
if(request.getTargetTable() != null && names.size() != 1)
|
||||
{
|
||||
errors.add("A target table can only be chosen when exactly one table is restored.");
|
||||
}
|
||||
|
||||
for(String name : names)
|
||||
{
|
||||
String targetName = request.getTargetTable() != null && names.size() == 1 ? request.getTargetTable() : name;
|
||||
QTableMetaData target = instance.getTable(targetName);
|
||||
if(target == null)
|
||||
{
|
||||
errors.add("The table " + targetName + " does not exist in this instance.");
|
||||
continue;
|
||||
}
|
||||
TableRole role = TableRoles.roleOf(instance, target);
|
||||
if(role == TableRole.SECRET || role == TableRole.BACKUPS)
|
||||
{
|
||||
errors.add("The table " + targetName + " is a " + role + " table, and restores never touch those.");
|
||||
continue;
|
||||
}
|
||||
if(role == TableRole.EPHEMERAL)
|
||||
{
|
||||
if(!explicit)
|
||||
{
|
||||
notes.add("The table " + targetName + " holds temporary data and is left out unless you choose it.");
|
||||
continue;
|
||||
}
|
||||
warnings.add("The table " + targetName + " holds temporary data; you chose to restore it anyway.");
|
||||
}
|
||||
targets.put(name, targetName);
|
||||
}
|
||||
|
||||
List<String> manifestOrder = source.getManifest().restoreOrder() == null ? List.of() : source.getManifest().restoreOrder();
|
||||
for(String name : manifestOrder)
|
||||
{
|
||||
if(targets.containsKey(name))
|
||||
{
|
||||
order.add(name);
|
||||
}
|
||||
}
|
||||
for(String name : targets.keySet())
|
||||
{
|
||||
if(!order.contains(name))
|
||||
{
|
||||
order.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
if(order.isEmpty() && errors.isEmpty())
|
||||
{
|
||||
errors.add("There are no tables to restore.");
|
||||
}
|
||||
|
||||
if(replace && errors.isEmpty())
|
||||
{
|
||||
String expected = RestoreRequest.expectedConfirmation(order);
|
||||
if(request.getConfirmationText() == null || !expected.equals(request.getConfirmationText().trim()))
|
||||
{
|
||||
errors.add("To confirm a REPLACE, type " + expected + ".");
|
||||
}
|
||||
}
|
||||
|
||||
String running = runningBackupFor(order);
|
||||
if(running != null)
|
||||
{
|
||||
errors.add(running);
|
||||
}
|
||||
|
||||
return (new Plan(source, order, targets, errors, warnings, notes));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A message when a backup that covers any of these tables is running, else null.
|
||||
*******************************************************************************/
|
||||
private String runningBackupFor(List<String> tables)
|
||||
{
|
||||
try
|
||||
{
|
||||
Instant cutoff = BackupsRuntime.getClock().instant().minusSeconds(RUNNING_HEARTBEAT_MINUTES * 60);
|
||||
for(QRecord run : catalog.where(BackupsTables.RUN, "status", "RUNNING"))
|
||||
{
|
||||
Instant heartbeat = run.getValueInstant("heartbeatDate");
|
||||
if(heartbeat == null || !heartbeat.isAfter(cutoff))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(coversAny(run, tables))
|
||||
{
|
||||
return ("A backup of these tables is running (" + run.getValueString("runKey") + "). Wait for it to finish, then restore.");
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
return ("Could not check for running backups: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a running backup covers any of the tables (a run without a plan is
|
||||
** assumed to).
|
||||
*******************************************************************************/
|
||||
private boolean coversAny(QRecord run, List<String> tables) throws QException
|
||||
{
|
||||
Integer planId = run.getValueInteger("backupPlanId");
|
||||
if(planId == null)
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
QRecord plan = catalog.get(BackupsTables.PLAN, planId).orElse(null);
|
||||
if(plan == null)
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
Set<String> covered = new HashSet<>(BackupRunFactory.resolveScope(plan).names());
|
||||
return (tables.stream().anyMatch(covered::contains));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review: everything the restore would do, with nothing written.
|
||||
*******************************************************************************/
|
||||
public RestoreOutcome review(RestoreRequest request) throws QException
|
||||
{
|
||||
Plan plan = plan(request);
|
||||
if(!plan.errors().isEmpty())
|
||||
{
|
||||
RestoreOutcome outcome = new RestoreOutcome();
|
||||
outcome.getErrors().addAll(plan.errors());
|
||||
return (outcome);
|
||||
}
|
||||
|
||||
RestoreOutcome outcome = new RestorePass(request, plan.source(), true, null, plan.order(), plan.targets()).run();
|
||||
outcome.getWarnings().addAll(plan.warnings());
|
||||
outcome.getNotes().addAll(plan.notes());
|
||||
if(!request.getPreRestoreBackup())
|
||||
{
|
||||
outcome.getWarnings().add("The safety backup before the restore is turned off.");
|
||||
}
|
||||
if(RestoreRequest.IDS_PRESERVE.equals(request.getIdStrategy()) && outcome.sum(t -> t.getSequenceNeeded() ? 1 : 0) > 0 && !request.getWriter().canResyncSequences())
|
||||
{
|
||||
outcome.getWarnings().add("Key sequences cannot be resynced with this version of QQQ (it needs the sequence resync hook), so on databases with generated keys the next generated id may collide. Resync them after the restore.");
|
||||
}
|
||||
if(!request.getWriter().usesCoreRestoreFlag())
|
||||
{
|
||||
outcome.getNotes().add("This version of QQQ has no restore mode for writes, so rows are written with the standard actions: record security and validation apply, and modified dates may be set to now.");
|
||||
}
|
||||
return (outcome);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the restore. Throws when a rail refuses it; otherwise returns the
|
||||
** outcome (also recorded in the restore run rows).
|
||||
*******************************************************************************/
|
||||
public RestoreOutcome execute(RestoreRequest request) throws QException
|
||||
{
|
||||
Plan plan = plan(request);
|
||||
if(!plan.errors().isEmpty())
|
||||
{
|
||||
throw (new QException(String.join(" ", plan.errors())));
|
||||
}
|
||||
|
||||
BackupsQBitConfig config = BackupsRuntime.getConfig();
|
||||
Instant start = BackupsRuntime.getClock().instant();
|
||||
|
||||
Map<String, Serializable> row = new LinkedHashMap<>();
|
||||
row.put("sourceBackupRunId", request.getSourceRunId());
|
||||
row.put("requestedByUserId", request.getRequestedBy());
|
||||
row.put("startDate", start);
|
||||
row.put("status", "RUNNING");
|
||||
row.put("mode", request.getMode());
|
||||
row.put("idStrategy", request.getIdStrategy());
|
||||
row.put("matchOn", request.getMatchOn());
|
||||
row.put("targetInstanceName", config.getInstanceName() != null ? config.getInstanceName() : "qqq");
|
||||
row.put("scopeSummary", scopeSummary(request, plan));
|
||||
row.put("automationsSuppressed", request.getSuppressAutomations());
|
||||
row.put("esbSuppressed", !request.getPublishEvents());
|
||||
row.put("confirmationText", RestoreRequest.MODE_REPLACE.equals(request.getMode()) ? request.getConfirmationText() : null);
|
||||
Integer restoreRunId = catalog.insert(BackupsTables.RESTORE_RUN, row);
|
||||
|
||||
RestoreOutcome outcome = null;
|
||||
try
|
||||
{
|
||||
Integer preRestoreRunId = null;
|
||||
if(request.getPreRestoreBackup())
|
||||
{
|
||||
preRestoreRunId = preRestoreBackup(request, plan);
|
||||
catalog.update(BackupsTables.RESTORE_RUN, restoreRunId, Map.of("preRestoreBackupRunId", preRestoreRunId));
|
||||
}
|
||||
|
||||
RestorePass pass = new RestorePass(request, plan.source(), false, restoreRunId, plan.order(), plan.targets());
|
||||
outcome = pass.run();
|
||||
outcome.setRestoreRunId(restoreRunId);
|
||||
outcome.setPreRestoreRunId(preRestoreRunId);
|
||||
outcome.getWarnings().addAll(plan.warnings());
|
||||
outcome.getNotes().addAll(plan.notes());
|
||||
|
||||
resyncSequences(request, plan, outcome);
|
||||
restoreFiles(request, plan, pass, outcome, restoreRunId);
|
||||
recordResults(request, plan, outcome, restoreRunId, start);
|
||||
deleteIdMap(restoreRunId);
|
||||
return (outcome);
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
LOG.warn("Restore failed", e, logPair("restoreRunId", restoreRunId));
|
||||
String message = e.getClass().getSimpleName() + ": " + e.getMessage();
|
||||
try
|
||||
{
|
||||
Map<String, Serializable> failed = new LinkedHashMap<>();
|
||||
failed.put("status", "FAILED");
|
||||
failed.put("endDate", BackupsRuntime.getClock().instant());
|
||||
failed.put("errorMessage", message.length() > 2000 ? message.substring(0, 2000) : message);
|
||||
catalog.update(BackupsTables.RESTORE_RUN, restoreRunId, failed);
|
||||
}
|
||||
catch(QException inner)
|
||||
{
|
||||
LOG.warn("Could not record the failed restore", inner);
|
||||
}
|
||||
throw (e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private String scopeSummary(RestoreRequest request, Plan plan)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder(String.join(", ", plan.order()));
|
||||
if(!request.getFilters().isEmpty())
|
||||
{
|
||||
sb.append(" | filters on ").append(String.join(", ", request.getFilters().keySet()));
|
||||
}
|
||||
if(request.getTargetTable() != null)
|
||||
{
|
||||
sb.append(" | into ").append(request.getTargetTable());
|
||||
}
|
||||
if(!request.getPreRestoreBackup())
|
||||
{
|
||||
sb.append(" | no pre-restore backup");
|
||||
}
|
||||
return (sb.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Back up the target scope (with its files) before writing anything.
|
||||
*******************************************************************************/
|
||||
private Integer preRestoreBackup(RestoreRequest request, Plan plan) throws QException
|
||||
{
|
||||
BackupSource source = plan.source();
|
||||
BackupsQBitConfig config = BackupsRuntime.getConfig();
|
||||
String alias = source.getRun().getValueString("storageBucketAlias");
|
||||
|
||||
PlanSettings settings = PlanSettings.adHoc().with("scopeType", "TABLES").with("planKey", "pre-restore");
|
||||
Map<String, Serializable> original = source.getManifest().plan() == null ? Map.of() : source.getManifest().plan().settings();
|
||||
for(String key : List.of("prefix", "format", "compression", "pageSize", "targetPartBytes", "fileObjectPolicy", "heavyFieldPolicy", "consistency"))
|
||||
{
|
||||
if(original != null && original.get(key) != null)
|
||||
{
|
||||
settings.with(key, original.get(key));
|
||||
}
|
||||
}
|
||||
boolean encrypts = source.getTransform().encrypts();
|
||||
if(encrypts)
|
||||
{
|
||||
settings.with("encryptionMode", "CLIENT_AES256_GCM").with("sensitiveFieldPolicy", "INCLUDE_ENCRYPTED_ONLY");
|
||||
}
|
||||
|
||||
List<ScopeResolver.ScopeRule> rules = new ArrayList<>();
|
||||
for(String name : plan.order())
|
||||
{
|
||||
rules.add(new ScopeResolver.ScopeRule(true, "TABLE", plan.targets().get(name), request.filterFor(name), false, null));
|
||||
}
|
||||
|
||||
BackupRunRequest backup = new BackupRunRequest()
|
||||
.withSettings(settings)
|
||||
.withScope(new ScopeResolver(QContext.getQInstance()).resolve(settings, rules))
|
||||
.withTrigger("PRE_RESTORE")
|
||||
.withRequestedBy(request.getRequestedBy())
|
||||
.withBucketAlias(alias)
|
||||
.withStorage(source.getStorage())
|
||||
.withSourceResolver(BackupsRuntime::storageFor)
|
||||
.withInstanceName(config.getInstanceName() != null ? config.getInstanceName() : "qqq")
|
||||
.withPlanName("Pre-restore backup")
|
||||
.withClock(BackupsRuntime.getClock());
|
||||
BackupManifest.EncryptionInfo encryption = source.getManifest().encryption();
|
||||
backup.withTransform(source.getTransform(), encrypts && encryption != null ? encryption.keyRef() : null, encrypts && encryption != null ? encryption.keyFingerprint() : null);
|
||||
|
||||
BackupRunResult result = new BackupEngine().execute(backup);
|
||||
if(!result.isUsable())
|
||||
{
|
||||
throw (new QException("The backup taken before the restore failed, so nothing was restored: " + result.getErrorMessage()));
|
||||
}
|
||||
return (result.getRunId());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Advance the key sequences of the tables written with their original ids.
|
||||
*******************************************************************************/
|
||||
private void resyncSequences(RestoreRequest request, Plan plan, RestoreOutcome outcome) throws QException
|
||||
{
|
||||
List<QTableMetaData> tables = new ArrayList<>();
|
||||
for(RestoreOutcome.TableOutcome table : outcome.getTables().values())
|
||||
{
|
||||
if(table.getSequenceNeeded())
|
||||
{
|
||||
tables.add(QContext.getQInstance().getTable(table.getTargetName()));
|
||||
}
|
||||
}
|
||||
if(tables.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(!request.getWriter().canResyncSequences())
|
||||
{
|
||||
outcome.getWarnings().add("Key sequences were not resynced: this version of QQQ has no resync hook.");
|
||||
return;
|
||||
}
|
||||
List<String> resynced = request.getWriter().resyncSequences(tables);
|
||||
outcome.setSequencesResynced(resynced.size());
|
||||
for(RestoreOutcome.TableOutcome table : outcome.getTables().values())
|
||||
{
|
||||
table.setSequenceResynced(resynced.contains(table.getTargetName()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy files back from the content-addressed store, re-hashing each against the
|
||||
** index. A conflicting file (different content at the target) is only replaced
|
||||
** by a REPLACE restore.
|
||||
*******************************************************************************/
|
||||
private void restoreFiles(RestoreRequest request, Plan plan, RestorePass pass, RestoreOutcome outcome, Integer restoreRunId)
|
||||
{
|
||||
for(RestorePass.FileWork work : pass.getFileWork())
|
||||
{
|
||||
if(work.conflict() && !RestoreRequest.MODE_REPLACE.equals(request.getMode()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
RestoreOutcome.TableOutcome table = outcome.getTables().get(work.table());
|
||||
try
|
||||
{
|
||||
copyFileBack(plan.source(), work, restoreRunId);
|
||||
table.addFilesRestored(1);
|
||||
table.addFileBytesRestored(Math.max(0, work.size()));
|
||||
}
|
||||
catch(IOException | RuntimeException e)
|
||||
{
|
||||
outcome.addFilesFailed(1);
|
||||
table.getProblems().add("The file " + work.targetKey() + " was not restored: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Write one file to a temporary key, check its SHA-256 against the index, and
|
||||
** only then put it in place.
|
||||
*******************************************************************************/
|
||||
private void copyFileBack(BackupSource source, RestorePass.FileWork work, Integer restoreRunId) throws IOException
|
||||
{
|
||||
BackupStorage target = BackupsRuntime.storageFor(work.targetAlias());
|
||||
String temp = work.targetKey() + ".qqq-restore-" + restoreRunId;
|
||||
|
||||
String observed;
|
||||
try(InputStream in = source.getTransform().unwrap(source.getStorage().openRead(work.casKey()));
|
||||
HashingOutputStream out = new HashingOutputStream(target.openWrite(temp, WriteOptions.create().withMetadata("qqq-restore-run", String.valueOf(restoreRunId)))))
|
||||
{
|
||||
in.transferTo(out);
|
||||
out.close();
|
||||
observed = out.getSha256();
|
||||
}
|
||||
|
||||
if(!work.sha256().equals(observed))
|
||||
{
|
||||
target.deletePermanent(temp);
|
||||
throw (new IOException("checksum mismatch after reading the stored copy"));
|
||||
}
|
||||
target.copy(temp, work.targetKey());
|
||||
target.deletePermanent(temp);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One summary audit per table, the per-table rows, and the totals on the run.
|
||||
*******************************************************************************/
|
||||
private void recordResults(RestoreRequest request, Plan plan, RestoreOutcome outcome, Integer restoreRunId, Instant start) throws QException
|
||||
{
|
||||
String runKey = plan.source().getRun().getValueString("runKey");
|
||||
for(RestoreOutcome.TableOutcome t : outcome.getTables().values())
|
||||
{
|
||||
Map<String, Serializable> row = new LinkedHashMap<>();
|
||||
row.put("backupRestoreRunId", restoreRunId);
|
||||
row.put("tableName", t.getName());
|
||||
row.put("targetTableName", t.getTargetName());
|
||||
row.put("rowsInserted", t.getInserted());
|
||||
row.put("rowsUpdated", t.getUpdated());
|
||||
row.put("rowsUnchanged", t.getUnchanged());
|
||||
row.put("rowsDeleted", t.getDeleted());
|
||||
row.put("rowsSkipped", t.getSkipped() + t.getConflicts());
|
||||
row.put("rowsErrored", t.getErrored());
|
||||
row.put("schemaStatus", t.getSchemaStatus());
|
||||
row.put("unmappedFields", String.join(", ", t.getUnmappedFields()));
|
||||
row.put("sequenceResynced", t.getSequenceResynced());
|
||||
row.put("status", t.getStatus());
|
||||
catalog.insert(BackupsTables.RESTORE_RUN_TABLE, row);
|
||||
|
||||
if(!"SKIPPED".equals(t.getStatus()))
|
||||
{
|
||||
summaryAudit(t, runKey, restoreRunId);
|
||||
}
|
||||
}
|
||||
|
||||
boolean warnings = outcome.sum(t -> t.getErrored()) > 0 || outcome.getFilesFailed() > 0 || !outcome.getWarnings().isEmpty()
|
||||
|| outcome.getTables().values().stream().anyMatch(t -> "SKIPPED".equals(t.getStatus()));
|
||||
|
||||
Map<String, Serializable> totals = new LinkedHashMap<>();
|
||||
totals.put("status", warnings ? "SUCCEEDED_WITH_WARNINGS" : "SUCCEEDED");
|
||||
totals.put("endDate", BackupsRuntime.getClock().instant());
|
||||
totals.put("rowsInserted", outcome.sum(RestoreOutcome.TableOutcome::getInserted));
|
||||
totals.put("rowsUpdated", outcome.sum(RestoreOutcome.TableOutcome::getUpdated));
|
||||
totals.put("rowsUnchanged", outcome.sum(RestoreOutcome.TableOutcome::getUnchanged));
|
||||
totals.put("rowsDeleted", outcome.sum(RestoreOutcome.TableOutcome::getDeleted));
|
||||
totals.put("rowsSkipped", outcome.sum(t -> t.getSkipped() + t.getConflicts()));
|
||||
totals.put("rowsErrored", outcome.sum(RestoreOutcome.TableOutcome::getErrored));
|
||||
totals.put("filesRestored", (int) outcome.sum(t -> t.getFilesRestored()));
|
||||
totals.put("filesAlreadyPresent", (int) outcome.sum(t -> t.getFilesPresent()));
|
||||
totals.put("filesConflicting", (int) outcome.sum(t -> t.getFilesConflicting()));
|
||||
totals.put("filesMissingFromBackup", (int) outcome.sum(t -> t.getFilesMissing()));
|
||||
totals.put("fileBytesRestored", outcome.sum(RestoreOutcome.TableOutcome::getFileBytesRestored));
|
||||
totals.put("sequencesResynced", outcome.getSequencesResynced());
|
||||
catalog.update(BackupsTables.RESTORE_RUN, restoreRunId, totals);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The one audit entry for a table in a restore (replaces per-record audit).
|
||||
*******************************************************************************/
|
||||
private void summaryAudit(RestoreOutcome.TableOutcome t, String runKey, Integer restoreRunId)
|
||||
{
|
||||
try
|
||||
{
|
||||
AuditAction.execute(t.getTargetName(), null, Map.of(), "Restore " + restoreRunId + " from backup " + runKey + ": "
|
||||
+ t.getInserted() + " inserted, " + t.getUpdated() + " updated, " + t.getDeleted() + " deleted, " + t.getUnchanged() + " unchanged");
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
LOG.warn("Could not write the restore summary audit", e, logPair("table", t.getTargetName()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The id map rows are only needed while the restore runs.
|
||||
*******************************************************************************/
|
||||
private void deleteIdMap(Integer restoreRunId)
|
||||
{
|
||||
try
|
||||
{
|
||||
DeleteInput input = new DeleteInput(BackupsTables.ID_MAP);
|
||||
input.setQueryFilter(new QQueryFilter(new QFilterCriteria("restoreRunId", QCriteriaOperator.EQUALS, restoreRunId)));
|
||||
input.withPermissionBypass("backups restore id map");
|
||||
new DeleteAction().execute(input);
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
LOG.warn("Could not clear the id map of a restore", e, logPair("restoreRunId", restoreRunId));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
/*
|
||||
* 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.restore;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What a restore pass found (Review) or did (Execute): the per-table counts, the
|
||||
** schema comparison, the sample diffs and the file counts. Review and Execute
|
||||
** share one algorithm, so the Review numbers are the numbers the restore produces.
|
||||
** Nothing here ever holds a sensitive value: sample diffs are masked when built.
|
||||
*******************************************************************************/
|
||||
public class RestoreOutcome
|
||||
{
|
||||
private final Map<String, TableOutcome> tables = new LinkedHashMap<>();
|
||||
private final List<String> warnings = new ArrayList<>();
|
||||
private final List<String> errors = new ArrayList<>();
|
||||
private final List<String> notes = new ArrayList<>();
|
||||
|
||||
private Integer preRestoreRunId;
|
||||
private Integer restoreRunId;
|
||||
private int sequencesResynced;
|
||||
private int filesFailed;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One changed record, as masked "field: old -> new" texts.
|
||||
*******************************************************************************/
|
||||
public record Sample(String key, List<String> changes)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The result for one table.
|
||||
*******************************************************************************/
|
||||
public static class TableOutcome
|
||||
{
|
||||
private final String name;
|
||||
private final String targetName;
|
||||
|
||||
private String status = "PENDING";
|
||||
private String schemaStatus = "MATCH";
|
||||
private final List<String> schemaNotes = new ArrayList<>();
|
||||
private final List<String> unmappedFields = new ArrayList<>();
|
||||
private final List<String> unmappedReferences = new ArrayList<>();
|
||||
private final List<String> problems = new ArrayList<>();
|
||||
private final List<Sample> samples = new ArrayList<>();
|
||||
private long rows;
|
||||
private long inserted;
|
||||
private long updated;
|
||||
private long unchanged;
|
||||
private long skipped;
|
||||
private long deleted;
|
||||
private long conflicts;
|
||||
private long orphans;
|
||||
private long errored;
|
||||
private boolean sequenceNeeded;
|
||||
private boolean sequenceResynced;
|
||||
private long updatedSampleTotal;
|
||||
private int filesToRestore;
|
||||
private int filesPresent;
|
||||
private int filesConflicting;
|
||||
private int filesMissing;
|
||||
private int filesRestored;
|
||||
private long fileBytesRestored;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public TableOutcome(String name, String targetName)
|
||||
{
|
||||
this.name = name;
|
||||
this.targetName = targetName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for name (the table in the backup)
|
||||
*******************************************************************************/
|
||||
public String getName()
|
||||
{
|
||||
return (name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for targetName (the table written)
|
||||
*******************************************************************************/
|
||||
public String getTargetName()
|
||||
{
|
||||
return (targetName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for status
|
||||
*******************************************************************************/
|
||||
public String getStatus()
|
||||
{
|
||||
return (status);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for status
|
||||
*******************************************************************************/
|
||||
public void setStatus(String status)
|
||||
{
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for schemaStatus
|
||||
*******************************************************************************/
|
||||
public String getSchemaStatus()
|
||||
{
|
||||
return (schemaStatus);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for schemaStatus
|
||||
*******************************************************************************/
|
||||
public void setSchemaStatus(String schemaStatus)
|
||||
{
|
||||
this.schemaStatus = schemaStatus;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for schemaNotes
|
||||
*******************************************************************************/
|
||||
public List<String> getSchemaNotes()
|
||||
{
|
||||
return (schemaNotes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for unmappedFields (in the backup, not in the target)
|
||||
*******************************************************************************/
|
||||
public List<String> getUnmappedFields()
|
||||
{
|
||||
return (unmappedFields);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for unmappedReferences (id fields that metadata cannot trace)
|
||||
*******************************************************************************/
|
||||
public List<String> getUnmappedReferences()
|
||||
{
|
||||
return (unmappedReferences);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for problems (error messages, never row values)
|
||||
*******************************************************************************/
|
||||
public List<String> getProblems()
|
||||
{
|
||||
return (problems);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for samples
|
||||
*******************************************************************************/
|
||||
public List<Sample> getSamples()
|
||||
{
|
||||
return (samples);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for rows
|
||||
*******************************************************************************/
|
||||
public long getRows()
|
||||
{
|
||||
return (rows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add rows read from the backup.
|
||||
*******************************************************************************/
|
||||
public void addRows(long n)
|
||||
{
|
||||
rows += n;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for inserted
|
||||
*******************************************************************************/
|
||||
public long getInserted()
|
||||
{
|
||||
return (inserted);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add inserted
|
||||
*******************************************************************************/
|
||||
public void addInserted(long n)
|
||||
{
|
||||
inserted += n;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for updated
|
||||
*******************************************************************************/
|
||||
public long getUpdated()
|
||||
{
|
||||
return (updated);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add updated
|
||||
*******************************************************************************/
|
||||
public void addUpdated(long n)
|
||||
{
|
||||
updated += n;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for unchanged
|
||||
*******************************************************************************/
|
||||
public long getUnchanged()
|
||||
{
|
||||
return (unchanged);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add unchanged
|
||||
*******************************************************************************/
|
||||
public void addUnchanged(long n)
|
||||
{
|
||||
unchanged += n;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for skipped (INSERT_NEW rows that exist with different content)
|
||||
*******************************************************************************/
|
||||
public long getSkipped()
|
||||
{
|
||||
return (skipped);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add skipped
|
||||
*******************************************************************************/
|
||||
public void addSkipped(long n)
|
||||
{
|
||||
skipped += n;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for deleted
|
||||
*******************************************************************************/
|
||||
public long getDeleted()
|
||||
{
|
||||
return (deleted);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add deleted
|
||||
*******************************************************************************/
|
||||
public void addDeleted(long n)
|
||||
{
|
||||
deleted += n;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for conflicts (rows that collide on a unique key)
|
||||
*******************************************************************************/
|
||||
public long getConflicts()
|
||||
{
|
||||
return (conflicts);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add conflicts
|
||||
*******************************************************************************/
|
||||
public void addConflicts(long n)
|
||||
{
|
||||
conflicts += n;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for orphans (rows whose parent is in neither the backup nor the target)
|
||||
*******************************************************************************/
|
||||
public long getOrphans()
|
||||
{
|
||||
return (orphans);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add orphans
|
||||
*******************************************************************************/
|
||||
public void addOrphans(long n)
|
||||
{
|
||||
orphans += n;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for errored
|
||||
*******************************************************************************/
|
||||
public long getErrored()
|
||||
{
|
||||
return (errored);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add errored
|
||||
*******************************************************************************/
|
||||
public void addErrored(long n)
|
||||
{
|
||||
errored += n;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sequenceNeeded
|
||||
*******************************************************************************/
|
||||
public boolean getSequenceNeeded()
|
||||
{
|
||||
return (sequenceNeeded);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sequenceNeeded
|
||||
*******************************************************************************/
|
||||
public void setSequenceNeeded(boolean sequenceNeeded)
|
||||
{
|
||||
this.sequenceNeeded = sequenceNeeded;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sequenceResynced
|
||||
*******************************************************************************/
|
||||
public boolean getSequenceResynced()
|
||||
{
|
||||
return (sequenceResynced);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sequenceResynced
|
||||
*******************************************************************************/
|
||||
public void setSequenceResynced(boolean sequenceResynced)
|
||||
{
|
||||
this.sequenceResynced = sequenceResynced;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for the number of updated records seen (the sample list is capped)
|
||||
*******************************************************************************/
|
||||
public long getUpdatedSampleTotal()
|
||||
{
|
||||
return (updatedSampleTotal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Count one more changed record (sampled or not).
|
||||
*******************************************************************************/
|
||||
public void countChangedRecord()
|
||||
{
|
||||
updatedSampleTotal++;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for filesToRestore
|
||||
*******************************************************************************/
|
||||
public int getFilesToRestore()
|
||||
{
|
||||
return (filesToRestore);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add filesToRestore
|
||||
*******************************************************************************/
|
||||
public void addFilesToRestore(int n)
|
||||
{
|
||||
filesToRestore += n;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for filesPresent
|
||||
*******************************************************************************/
|
||||
public int getFilesPresent()
|
||||
{
|
||||
return (filesPresent);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add filesPresent
|
||||
*******************************************************************************/
|
||||
public void addFilesPresent(int n)
|
||||
{
|
||||
filesPresent += n;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for filesConflicting
|
||||
*******************************************************************************/
|
||||
public int getFilesConflicting()
|
||||
{
|
||||
return (filesConflicting);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add filesConflicting
|
||||
*******************************************************************************/
|
||||
public void addFilesConflicting(int n)
|
||||
{
|
||||
filesConflicting += n;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for filesMissing (missing from the backup)
|
||||
*******************************************************************************/
|
||||
public int getFilesMissing()
|
||||
{
|
||||
return (filesMissing);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add filesMissing
|
||||
*******************************************************************************/
|
||||
public void addFilesMissing(int n)
|
||||
{
|
||||
filesMissing += n;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for filesRestored
|
||||
*******************************************************************************/
|
||||
public int getFilesRestored()
|
||||
{
|
||||
return (filesRestored);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add filesRestored
|
||||
*******************************************************************************/
|
||||
public void addFilesRestored(int n)
|
||||
{
|
||||
filesRestored += n;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fileBytesRestored
|
||||
*******************************************************************************/
|
||||
public long getFileBytesRestored()
|
||||
{
|
||||
return (fileBytesRestored);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add fileBytesRestored
|
||||
*******************************************************************************/
|
||||
public void addFileBytesRestored(long n)
|
||||
{
|
||||
fileBytesRestored += n;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The outcome of one table, created on first use.
|
||||
*******************************************************************************/
|
||||
public TableOutcome table(String name, String targetName)
|
||||
{
|
||||
return (tables.computeIfAbsent(name, k -> new TableOutcome(name, targetName)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The tables, in restore order.
|
||||
*******************************************************************************/
|
||||
public Map<String, TableOutcome> getTables()
|
||||
{
|
||||
return (tables);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run-level warnings.
|
||||
*******************************************************************************/
|
||||
public List<String> getWarnings()
|
||||
{
|
||||
return (warnings);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run-level errors (the restore does not run when there are any).
|
||||
*******************************************************************************/
|
||||
public List<String> getErrors()
|
||||
{
|
||||
return (errors);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run-level informational notes.
|
||||
*******************************************************************************/
|
||||
public List<String> getNotes()
|
||||
{
|
||||
return (notes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for preRestoreRunId
|
||||
*******************************************************************************/
|
||||
public Integer getPreRestoreRunId()
|
||||
{
|
||||
return (preRestoreRunId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for preRestoreRunId
|
||||
*******************************************************************************/
|
||||
public void setPreRestoreRunId(Integer preRestoreRunId)
|
||||
{
|
||||
this.preRestoreRunId = preRestoreRunId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for restoreRunId
|
||||
*******************************************************************************/
|
||||
public Integer getRestoreRunId()
|
||||
{
|
||||
return (restoreRunId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for restoreRunId
|
||||
*******************************************************************************/
|
||||
public void setRestoreRunId(Integer restoreRunId)
|
||||
{
|
||||
this.restoreRunId = restoreRunId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sequencesResynced
|
||||
*******************************************************************************/
|
||||
public int getSequencesResynced()
|
||||
{
|
||||
return (sequencesResynced);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sequencesResynced
|
||||
*******************************************************************************/
|
||||
public void setSequencesResynced(int sequencesResynced)
|
||||
{
|
||||
this.sequencesResynced = sequencesResynced;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for filesFailed
|
||||
*******************************************************************************/
|
||||
public int getFilesFailed()
|
||||
{
|
||||
return (filesFailed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add filesFailed
|
||||
*******************************************************************************/
|
||||
public void addFilesFailed(int n)
|
||||
{
|
||||
filesFailed += n;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A total over the tables.
|
||||
*******************************************************************************/
|
||||
public long sum(java.util.function.ToLongFunction<TableOutcome> getter)
|
||||
{
|
||||
return (tables.values().stream().mapToLong(getter).sum());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,612 @@
|
||||
/*
|
||||
* 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.restore;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.files.FileReferenceProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Everything a restore needs to know (spec 9.3): the source run, the tables and
|
||||
** per-table filters, the target, the mode, how rows are matched, what happens to
|
||||
** ids, and the options. Built from the first screen of the process.
|
||||
*******************************************************************************/
|
||||
public class RestoreRequest
|
||||
{
|
||||
public static final String MODE_INSERT_NEW = "INSERT_NEW";
|
||||
public static final String MODE_UPSERT = "UPSERT";
|
||||
public static final String MODE_REPLACE = "REPLACE";
|
||||
|
||||
public static final String IDS_PRESERVE = "PRESERVE";
|
||||
public static final String IDS_REMAP = "REMAP_NEW_IDS";
|
||||
|
||||
public static final String MATCH_PRIMARY_KEY = "PRIMARY_KEY";
|
||||
|
||||
private Integer sourceRunId;
|
||||
private List<String> tables = new ArrayList<>();
|
||||
private Map<String, String> filters = new LinkedHashMap<>();
|
||||
private String targetTable;
|
||||
private String mode = MODE_INSERT_NEW;
|
||||
private String matchOn = MATCH_PRIMARY_KEY;
|
||||
private String idStrategy = IDS_PRESERVE;
|
||||
private boolean suppressAutomations = true;
|
||||
private boolean publishEvents = false;
|
||||
private boolean runCustomizers = false;
|
||||
private boolean perRecordAudit = false;
|
||||
private boolean restoreFiles = true;
|
||||
private boolean preRestoreBackup = true;
|
||||
private String fileTargetBucketAlias;
|
||||
private String fileTargetPrefix;
|
||||
private String confirmationText;
|
||||
private String requestedBy = "system";
|
||||
private int pageSize = 1000;
|
||||
private List<FileReferenceProvider> fileProviders = new ArrayList<>(List.of(new FileReferenceProvider.FromTableMetaData()));
|
||||
private RestoreWriter writer = new StandardRestoreWriter();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build from the values of the first screen of the process. The tables value is
|
||||
** a comma separated list (blank means every table in the backup); the filters
|
||||
** value is a JSON object of table name to query filter.
|
||||
*******************************************************************************/
|
||||
public static RestoreRequest fromInput(RunBackendStepInput input, Integer runId, List<String> forcedTables) throws com.kingsrook.qqq.backend.core.exceptions.QException
|
||||
{
|
||||
RestoreRequest request = new RestoreRequest();
|
||||
request.sourceRunId = runId;
|
||||
if(forcedTables != null && !forcedTables.isEmpty())
|
||||
{
|
||||
request.tables = new ArrayList<>(forcedTables);
|
||||
}
|
||||
else
|
||||
{
|
||||
request.tables = splitList(input.getValueString("tables"));
|
||||
}
|
||||
request.filters = parseFilters(input.getValueString("tableFilters"));
|
||||
request.targetTable = blankToNull(input.getValueString("targetTable"));
|
||||
request.mode = orDefault(input.getValueString("mode"), MODE_INSERT_NEW);
|
||||
request.matchOn = orDefault(input.getValueString("matchOn"), MATCH_PRIMARY_KEY);
|
||||
request.idStrategy = orDefault(input.getValueString("idStrategy"), IDS_PRESERVE);
|
||||
request.suppressAutomations = bool(input, "suppressAutomations", true);
|
||||
request.publishEvents = bool(input, "publishEvents", false);
|
||||
request.runCustomizers = bool(input, "runCustomizers", false);
|
||||
request.perRecordAudit = bool(input, "perRecordAudit", false);
|
||||
request.restoreFiles = bool(input, "restoreFiles", true);
|
||||
request.preRestoreBackup = bool(input, "preRestoreBackup", true);
|
||||
request.fileTargetBucketAlias = blankToNull(input.getValueString("fileTargetBucketAlias"));
|
||||
request.fileTargetPrefix = blankToNull(input.getValueString("fileTargetPrefix"));
|
||||
request.confirmationText = input.getValueString("confirmationText");
|
||||
com.kingsrook.qqq.backend.core.model.session.QSession session = com.kingsrook.qqq.backend.core.context.QContext.getQSession();
|
||||
request.requestedBy = session == null || session.getUser() == null ? "system" : session.getUser().getIdReference();
|
||||
return (request);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The text the user must type to confirm a REPLACE of these tables.
|
||||
*******************************************************************************/
|
||||
public static String expectedConfirmation(List<String> tables)
|
||||
{
|
||||
return (tables.size() == 1 ? tables.get(0) : "REPLACE " + tables.size() + " TABLES");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The filter json for a table, or null.
|
||||
*******************************************************************************/
|
||||
public String filterFor(String table)
|
||||
{
|
||||
return (filters.get(table));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static List<String> splitList(String text)
|
||||
{
|
||||
List<String> rs = new ArrayList<>();
|
||||
if(StringUtils.hasContent(text))
|
||||
{
|
||||
for(String part : text.split(","))
|
||||
{
|
||||
if(!part.isBlank())
|
||||
{
|
||||
rs.add(part.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Map<String, String> parseFilters(String json) throws com.kingsrook.qqq.backend.core.exceptions.QException
|
||||
{
|
||||
Map<String, String> rs = new LinkedHashMap<>();
|
||||
if(!StringUtils.hasContent(json))
|
||||
{
|
||||
return (rs);
|
||||
}
|
||||
try
|
||||
{
|
||||
com.fasterxml.jackson.databind.JsonNode node = new com.fasterxml.jackson.databind.ObjectMapper().readTree(json);
|
||||
for(var it = node.fields(); it.hasNext(); )
|
||||
{
|
||||
var entry = it.next();
|
||||
rs.put(entry.getKey(), entry.getValue().isTextual() ? entry.getValue().asText() : entry.getValue().toString());
|
||||
}
|
||||
}
|
||||
catch(java.io.IOException e)
|
||||
{
|
||||
throw (new com.kingsrook.qqq.backend.core.exceptions.QException("The per-table filters are not valid JSON: " + e.getMessage()));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static boolean bool(RunBackendStepInput input, String name, boolean defaultValue)
|
||||
{
|
||||
Boolean value = input.getValueBoolean(name);
|
||||
return (value == null ? defaultValue : value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String orDefault(String value, String defaultValue)
|
||||
{
|
||||
return (StringUtils.hasContent(value) ? value : defaultValue);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String blankToNull(String value)
|
||||
{
|
||||
return (StringUtils.hasContent(value) ? value : null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sourceRunId
|
||||
*******************************************************************************/
|
||||
public Integer getSourceRunId()
|
||||
{
|
||||
return (sourceRunId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sourceRunId
|
||||
*******************************************************************************/
|
||||
public RestoreRequest withSourceRunId(Integer sourceRunId)
|
||||
{
|
||||
this.sourceRunId = sourceRunId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tables (empty means every table in the backup)
|
||||
*******************************************************************************/
|
||||
public List<String> getTables()
|
||||
{
|
||||
return (tables);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tables
|
||||
*******************************************************************************/
|
||||
public RestoreRequest withTables(List<String> tables)
|
||||
{
|
||||
this.tables = new ArrayList<>(tables);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for filters
|
||||
*******************************************************************************/
|
||||
public Map<String, String> getFilters()
|
||||
{
|
||||
return (filters);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for one table filter (json of a QQueryFilter)
|
||||
*******************************************************************************/
|
||||
public RestoreRequest withFilter(String table, String filterJson)
|
||||
{
|
||||
this.filters.put(table, filterJson);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for targetTable
|
||||
*******************************************************************************/
|
||||
public String getTargetTable()
|
||||
{
|
||||
return (targetTable);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for targetTable
|
||||
*******************************************************************************/
|
||||
public RestoreRequest withTargetTable(String targetTable)
|
||||
{
|
||||
this.targetTable = targetTable;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for mode
|
||||
*******************************************************************************/
|
||||
public String getMode()
|
||||
{
|
||||
return (mode);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for mode
|
||||
*******************************************************************************/
|
||||
public RestoreRequest withMode(String mode)
|
||||
{
|
||||
this.mode = mode;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for matchOn
|
||||
*******************************************************************************/
|
||||
public String getMatchOn()
|
||||
{
|
||||
return (matchOn);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for matchOn
|
||||
*******************************************************************************/
|
||||
public RestoreRequest withMatchOn(String matchOn)
|
||||
{
|
||||
this.matchOn = matchOn;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for idStrategy
|
||||
*******************************************************************************/
|
||||
public String getIdStrategy()
|
||||
{
|
||||
return (idStrategy);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for idStrategy
|
||||
*******************************************************************************/
|
||||
public RestoreRequest withIdStrategy(String idStrategy)
|
||||
{
|
||||
this.idStrategy = idStrategy;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for suppressAutomations
|
||||
*******************************************************************************/
|
||||
public boolean getSuppressAutomations()
|
||||
{
|
||||
return (suppressAutomations);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for suppressAutomations
|
||||
*******************************************************************************/
|
||||
public RestoreRequest withSuppressAutomations(boolean suppressAutomations)
|
||||
{
|
||||
this.suppressAutomations = suppressAutomations;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for publishEvents
|
||||
*******************************************************************************/
|
||||
public boolean getPublishEvents()
|
||||
{
|
||||
return (publishEvents);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for publishEvents
|
||||
*******************************************************************************/
|
||||
public RestoreRequest withPublishEvents(boolean publishEvents)
|
||||
{
|
||||
this.publishEvents = publishEvents;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for runCustomizers
|
||||
*******************************************************************************/
|
||||
public boolean getRunCustomizers()
|
||||
{
|
||||
return (runCustomizers);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for runCustomizers
|
||||
*******************************************************************************/
|
||||
public RestoreRequest withRunCustomizers(boolean runCustomizers)
|
||||
{
|
||||
this.runCustomizers = runCustomizers;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for perRecordAudit
|
||||
*******************************************************************************/
|
||||
public boolean getPerRecordAudit()
|
||||
{
|
||||
return (perRecordAudit);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for perRecordAudit
|
||||
*******************************************************************************/
|
||||
public RestoreRequest withPerRecordAudit(boolean perRecordAudit)
|
||||
{
|
||||
this.perRecordAudit = perRecordAudit;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for restoreFiles
|
||||
*******************************************************************************/
|
||||
public boolean getRestoreFiles()
|
||||
{
|
||||
return (restoreFiles);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for restoreFiles
|
||||
*******************************************************************************/
|
||||
public RestoreRequest withRestoreFiles(boolean restoreFiles)
|
||||
{
|
||||
this.restoreFiles = restoreFiles;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for preRestoreBackup
|
||||
*******************************************************************************/
|
||||
public boolean getPreRestoreBackup()
|
||||
{
|
||||
return (preRestoreBackup);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for preRestoreBackup
|
||||
*******************************************************************************/
|
||||
public RestoreRequest withPreRestoreBackup(boolean preRestoreBackup)
|
||||
{
|
||||
this.preRestoreBackup = preRestoreBackup;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fileTargetBucketAlias
|
||||
*******************************************************************************/
|
||||
public String getFileTargetBucketAlias()
|
||||
{
|
||||
return (fileTargetBucketAlias);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for fileTargetBucketAlias
|
||||
*******************************************************************************/
|
||||
public RestoreRequest withFileTargetBucketAlias(String fileTargetBucketAlias)
|
||||
{
|
||||
this.fileTargetBucketAlias = fileTargetBucketAlias;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fileTargetPrefix
|
||||
*******************************************************************************/
|
||||
public String getFileTargetPrefix()
|
||||
{
|
||||
return (fileTargetPrefix);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for fileTargetPrefix
|
||||
*******************************************************************************/
|
||||
public RestoreRequest withFileTargetPrefix(String fileTargetPrefix)
|
||||
{
|
||||
this.fileTargetPrefix = fileTargetPrefix;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for confirmationText
|
||||
*******************************************************************************/
|
||||
public String getConfirmationText()
|
||||
{
|
||||
return (confirmationText);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for confirmationText
|
||||
*******************************************************************************/
|
||||
public RestoreRequest withConfirmationText(String confirmationText)
|
||||
{
|
||||
this.confirmationText = confirmationText;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for requestedBy
|
||||
*******************************************************************************/
|
||||
public String getRequestedBy()
|
||||
{
|
||||
return (requestedBy);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for requestedBy
|
||||
*******************************************************************************/
|
||||
public RestoreRequest withRequestedBy(String requestedBy)
|
||||
{
|
||||
this.requestedBy = requestedBy;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for pageSize
|
||||
*******************************************************************************/
|
||||
public int getPageSize()
|
||||
{
|
||||
return (pageSize);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for pageSize
|
||||
*******************************************************************************/
|
||||
public RestoreRequest withPageSize(int pageSize)
|
||||
{
|
||||
this.pageSize = pageSize;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fileProviders
|
||||
*******************************************************************************/
|
||||
public List<FileReferenceProvider> getFileProviders()
|
||||
{
|
||||
return (fileProviders);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for writer
|
||||
*******************************************************************************/
|
||||
public RestoreWriter getWriter()
|
||||
{
|
||||
return (writer);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for writer (tests, or a backend specific writer)
|
||||
*******************************************************************************/
|
||||
public RestoreRequest withWriter(RestoreWriter writer)
|
||||
{
|
||||
this.writer = writer;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
* 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.restore;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
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.Status;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Turns a RestoreOutcome into the standard Review and Result summary lines
|
||||
** (spec 9.3). One group of lines per table, in restore order. Every line has
|
||||
** its future wording (Review) and its past wording (Result); sample diffs are
|
||||
** bullets on one line per table, with sensitive values already masked.
|
||||
*******************************************************************************/
|
||||
public final class RestoreSummaries
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private RestoreSummaries()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A line with all four wordings.
|
||||
*******************************************************************************/
|
||||
public static ProcessSummaryLine line(Status status, long count, String singularFuture, String pluralFuture, String singularPast, String pluralPast)
|
||||
{
|
||||
ProcessSummaryLine line = new ProcessSummaryLine(status, (int) Math.min(Integer.MAX_VALUE, count), null);
|
||||
line.setSingularFutureMessage(singularFuture);
|
||||
line.setPluralFutureMessage(pluralFuture);
|
||||
line.setSingularPastMessage(singularPast);
|
||||
line.setPluralPastMessage(pluralPast);
|
||||
return (line);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A one-off message line (the same text before and after).
|
||||
*******************************************************************************/
|
||||
public static ProcessSummaryLine message(Status status, String text)
|
||||
{
|
||||
return (line(status, 1, text, text, text, text));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The lines of an outcome.
|
||||
*******************************************************************************/
|
||||
public static List<ProcessSummaryLineInterface> lines(RestoreOutcome outcome, boolean isResult)
|
||||
{
|
||||
List<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
|
||||
for(String error : outcome.getErrors())
|
||||
{
|
||||
lines.add(message(Status.ERROR, error));
|
||||
}
|
||||
|
||||
for(RestoreOutcome.TableOutcome t : outcome.getTables().values())
|
||||
{
|
||||
tableLines(lines, t, isResult);
|
||||
}
|
||||
|
||||
long sequences = outcome.sum(t -> t.getSequenceNeeded() ? 1 : 0);
|
||||
if(sequences > 0 && !isResult)
|
||||
{
|
||||
lines.add(line(Status.INFO, sequences, "table will have its key sequence resynced", "tables will have their key sequences resynced",
|
||||
"table had its key sequence resynced", "tables had their key sequences resynced"));
|
||||
}
|
||||
if(isResult && outcome.getSequencesResynced() > 0)
|
||||
{
|
||||
lines.add(line(Status.OK, outcome.getSequencesResynced(), "key sequence was resynced", "key sequences were resynced",
|
||||
"key sequence was resynced", "key sequences were resynced"));
|
||||
}
|
||||
if(isResult && outcome.getPreRestoreRunId() != null)
|
||||
{
|
||||
lines.add(message(Status.INFO, "A backup of the target tables was taken first (backup run " + outcome.getPreRestoreRunId() + "), so this restore can be undone."));
|
||||
}
|
||||
if(isResult && outcome.getFilesFailed() > 0)
|
||||
{
|
||||
lines.add(line(Status.ERROR, outcome.getFilesFailed(), "file could not be restored", "files could not be restored", "file could not be restored", "files could not be restored"));
|
||||
}
|
||||
|
||||
for(String warning : outcome.getWarnings())
|
||||
{
|
||||
lines.add(message(Status.WARNING, warning));
|
||||
}
|
||||
for(String note : outcome.getNotes())
|
||||
{
|
||||
lines.add(message(Status.INFO, note));
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void tableLines(List<ProcessSummaryLineInterface> lines, RestoreOutcome.TableOutcome t, boolean isResult)
|
||||
{
|
||||
String in = " " + t.getTargetName();
|
||||
|
||||
if("SKIPPED".equals(t.getStatus()))
|
||||
{
|
||||
lines.add(message(Status.ERROR, "The table " + t.getName() + " will not be restored: its schema does not fit the backup"));
|
||||
}
|
||||
for(String note : t.getSchemaNotes())
|
||||
{
|
||||
lines.add(message("MISMATCH".equals(t.getSchemaStatus()) ? Status.ERROR : Status.WARNING, "Schema of " + t.getName() + ": " + note));
|
||||
}
|
||||
for(String reference : t.getUnmappedReferences())
|
||||
{
|
||||
lines.add(message(Status.WARNING, "The field " + reference + " looks like a reference but cannot be traced, so it is not rewritten to the new ids"));
|
||||
}
|
||||
|
||||
add(lines, line(Status.OK, t.getInserted(), "record will be inserted into" + in, "records will be inserted into" + in,
|
||||
"record was inserted into" + in, "records were inserted into" + in));
|
||||
add(lines, line(Status.OK, t.getUpdated(), "record will be updated in" + in, "records will be updated in" + in,
|
||||
"record was updated in" + in, "records were updated in" + in));
|
||||
add(lines, line(Status.INFO, t.getUnchanged(), "record is unchanged in" + in, "records are unchanged in" + in,
|
||||
"record was already the same in" + in, "records were already the same in" + in));
|
||||
add(lines, line(Status.INFO, t.getSkipped(), "record exists with different content and will be left alone in" + in,
|
||||
"records exist with different content and will be left alone in" + in,
|
||||
"record existed with different content and was left alone in" + in, "records existed with different content and were left alone in" + in));
|
||||
add(lines, line(Status.WARNING, t.getDeleted(), "record will be deleted from" + in + " (REPLACE)", "records will be deleted from" + in + " (REPLACE)",
|
||||
"record was deleted from" + in + " (REPLACE)", "records were deleted from" + in + " (REPLACE)"));
|
||||
add(lines, line(Status.WARNING, t.getConflicts(), "record conflicts on a unique key in" + in + " and will be skipped",
|
||||
"records conflict on a unique key in" + in + " and will be skipped",
|
||||
"record conflicted on a unique key in" + in + " and was skipped", "records conflicted on a unique key in" + in + " and were skipped"));
|
||||
add(lines, line(Status.WARNING, t.getOrphans(), "record in" + in + " references a parent that is missing from the backup and the target",
|
||||
"records in" + in + " reference a parent that is missing from the backup and the target",
|
||||
"record in" + in + " referenced a parent that is missing from the backup and the target",
|
||||
"records in" + in + " referenced a parent that is missing from the backup and the target"));
|
||||
if(isResult)
|
||||
{
|
||||
add(lines, line(Status.ERROR, t.getErrored(), "record could not be written to" + in, "records could not be written to" + in,
|
||||
"record could not be written to" + in, "records could not be written to" + in));
|
||||
for(String problem : t.getProblems())
|
||||
{
|
||||
lines.add(message(Status.ERROR, problem));
|
||||
}
|
||||
}
|
||||
|
||||
fileLines(lines, t, isResult);
|
||||
|
||||
if(!t.getSamples().isEmpty() && !isResult)
|
||||
{
|
||||
ProcessSummaryLine sample = line(Status.INFO, t.getSamples().size(),
|
||||
"changed record in" + in + " (field: now -> from the backup; sensitive values are hidden)",
|
||||
"changed records in" + in + " (field: now -> from the backup; sensitive values are hidden)", "", "");
|
||||
ArrayList<String> bullets = new ArrayList<>();
|
||||
for(RestoreOutcome.Sample s : t.getSamples())
|
||||
{
|
||||
bullets.add("#" + s.key() + " " + String.join("; ", s.changes()));
|
||||
}
|
||||
sample.setBulletsOfText(bullets);
|
||||
lines.add(sample);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void fileLines(List<ProcessSummaryLineInterface> lines, RestoreOutcome.TableOutcome t, boolean isResult)
|
||||
{
|
||||
String in = " for" + " " + t.getTargetName();
|
||||
if(isResult)
|
||||
{
|
||||
add(lines, line(Status.OK, t.getFilesRestored(), "file was restored" + in, "files were restored" + in, "file was restored" + in, "files were restored" + in));
|
||||
}
|
||||
else
|
||||
{
|
||||
add(lines, line(Status.OK, t.getFilesToRestore(), "file will be restored" + in, "files will be restored" + in, "file was restored" + in, "files were restored" + in));
|
||||
}
|
||||
add(lines, line(Status.INFO, t.getFilesPresent(), "file is already there with the same checksum" + in + " and is skipped",
|
||||
"files are already there with the same checksum" + in + " and are skipped",
|
||||
"file was already there with the same checksum" + in, "files were already there with the same checksum" + in));
|
||||
add(lines, line(Status.WARNING, t.getFilesConflicting(), "file exists with different content" + in + " (kept, unless the mode is REPLACE)",
|
||||
"files exist with different content" + in + " (kept, unless the mode is REPLACE)",
|
||||
"file existed with different content" + in, "files existed with different content" + in));
|
||||
add(lines, line(Status.WARNING, t.getFilesMissing(), "file is missing from the backup" + in, "files are missing from the backup" + in,
|
||||
"file was missing from the backup" + in, "files were missing from the backup" + in));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void add(List<ProcessSummaryLineInterface> lines, ProcessSummaryLine line)
|
||||
{
|
||||
if(line.getCount() != null && line.getCount() > 0)
|
||||
{
|
||||
lines.add(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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.restore;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** How a restore writes (spec 5.3 and 5.4). The default implementation uses the
|
||||
** plain Insert, Update and Delete actions, and selects the core RestoreWriteFlag
|
||||
** (QRun-IO/qqq#1111) and the core key-sequence resync (#1112) reflectively when the
|
||||
** running QQQ has them. A backend specific writer can be supplied on the request.
|
||||
**
|
||||
** Whatever the implementation, record security, field security, unique keys and
|
||||
** required-field validation still apply to every write.
|
||||
*******************************************************************************/
|
||||
public interface RestoreWriter
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Insert records (restore mode). Returns the output records, with any errors.
|
||||
*******************************************************************************/
|
||||
List<QRecord> insert(QTableMetaData table, List<QRecord> records, RestoreRequest options, QBackendTransaction transaction) throws QException;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Update records (restore mode). Returns the output records, with any errors.
|
||||
*******************************************************************************/
|
||||
List<QRecord> update(QTableMetaData table, List<QRecord> records, RestoreRequest options, QBackendTransaction transaction) throws QException;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete records by primary key (REPLACE). Returns the number deleted.
|
||||
*******************************************************************************/
|
||||
long delete(QTableMetaData table, List<Serializable> primaryKeys, RestoreRequest options, QBackendTransaction transaction) throws QException;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the writes honor the core restore flag (so created and modified dates
|
||||
** are kept as supplied).
|
||||
*******************************************************************************/
|
||||
boolean usesCoreRestoreFlag();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether key sequences can be resynced on this QQQ (the core hook is present).
|
||||
*******************************************************************************/
|
||||
boolean canResyncSequences();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resync key sequences after explicit-id inserts. Returns the names of the tables
|
||||
** whose sequence was advanced.
|
||||
*******************************************************************************/
|
||||
List<String> resyncSequences(Collection<QTableMetaData> tables) throws QException;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
/*
|
||||
* 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.restore;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.ActionFlag;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleDispatcher;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleInterface;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The default RestoreWriter: the standard Insert, Update and Delete actions.
|
||||
**
|
||||
** Stand-ins (replace with the core API when QQQ 4.1.0-RC.5 ships):
|
||||
** <ul>
|
||||
** <li>RestoreWriteFlag (QRun-IO/qqq#1111) is created reflectively when the class is
|
||||
** present. Without it, the DML audit is omitted (a summary audit is written per
|
||||
** table instead), but created and modified dates may be set by the normal defaults.</li>
|
||||
** <li>resyncKeySequences (QRun-IO/qqq#1112) is called reflectively on the backend
|
||||
** module when it exists; otherwise it is a no-op that the Review reports.</li>
|
||||
** </ul>
|
||||
*******************************************************************************/
|
||||
public class StandardRestoreWriter implements RestoreWriter
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(StandardRestoreWriter.class);
|
||||
|
||||
private static final String FLAG_CLASS = "com.kingsrook.qqq.backend.core.model.actions.tables.RestoreWriteFlag";
|
||||
|
||||
private static final Class<?> FLAG = lookup(FLAG_CLASS);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Class<?> lookup(String name)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Class.forName(name));
|
||||
}
|
||||
catch(ClassNotFoundException | LinkageError e)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> insert(QTableMetaData table, List<QRecord> records, RestoreRequest options, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
InsertInput input = new InsertInput(table.getName());
|
||||
input.setRecords(records);
|
||||
input.setOmitDmlAudit(!options.getPerRecordAudit());
|
||||
if(transaction != null)
|
||||
{
|
||||
input.setTransaction(transaction);
|
||||
}
|
||||
addFlag(options, input::withFlag);
|
||||
return (new InsertAction().execute(input).getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> update(QTableMetaData table, List<QRecord> records, RestoreRequest options, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
UpdateInput input = new UpdateInput(table.getName());
|
||||
input.setRecords(records);
|
||||
input.setOmitDmlAudit(!options.getPerRecordAudit());
|
||||
if(transaction != null)
|
||||
{
|
||||
input.setTransaction(transaction);
|
||||
}
|
||||
addFlag(options, input::withFlag);
|
||||
return (new UpdateAction().execute(input).getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public long delete(QTableMetaData table, List<Serializable> primaryKeys, RestoreRequest options, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
DeleteInput input = new DeleteInput(table.getName());
|
||||
input.setPrimaryKeys(primaryKeys);
|
||||
input.setOmitDmlAudit(!options.getPerRecordAudit());
|
||||
if(transaction != null)
|
||||
{
|
||||
input.setTransaction(transaction);
|
||||
}
|
||||
addFlag(options, input::withFlag);
|
||||
DeleteOutput output = new DeleteAction().execute(input);
|
||||
if(output.getRecordsWithErrors() != null && !output.getRecordsWithErrors().isEmpty())
|
||||
{
|
||||
QRecord first = output.getRecordsWithErrors().get(0);
|
||||
throw (new QException("Could not delete " + output.getRecordsWithErrors().size() + " records of " + table.getName() + ": "
|
||||
+ (first.getErrors().isEmpty() ? "unknown error" : first.getErrors().get(0).getMessage())));
|
||||
}
|
||||
return (output.getDeletedRecordCount());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create the core flag (when the running QQQ has it) and hand it to the input.
|
||||
*******************************************************************************/
|
||||
private void addFlag(RestoreRequest options, java.util.function.Consumer<ActionFlag> sink) throws QException
|
||||
{
|
||||
if(FLAG == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
Object flag = FLAG.getDeclaredConstructor().newInstance();
|
||||
FLAG.getMethod("withSuppressAutomations", boolean.class).invoke(flag, options.getSuppressAutomations());
|
||||
FLAG.getMethod("withRunCustomizers", boolean.class).invoke(flag, options.getRunCustomizers());
|
||||
FLAG.getMethod("withPerRecordAudit", boolean.class).invoke(flag, options.getPerRecordAudit());
|
||||
FLAG.getMethod("withPublishEvents", boolean.class).invoke(flag, options.getPublishEvents());
|
||||
sink.accept((ActionFlag) flag);
|
||||
}
|
||||
catch(ReflectiveOperationException | ClassCastException e)
|
||||
{
|
||||
throw (new QException("Could not use the core restore flag: " + e.getMessage(), e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean usesCoreRestoreFlag()
|
||||
{
|
||||
return (FLAG != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean canResyncSequences()
|
||||
{
|
||||
return (resyncMethod() != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Method resyncMethod()
|
||||
{
|
||||
try
|
||||
{
|
||||
return (QBackendModuleInterface.class.getMethod("resyncKeySequences", QBackendMetaData.class, Collection.class));
|
||||
}
|
||||
catch(NoSuchMethodException | LinkageError e)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<String> resyncSequences(Collection<QTableMetaData> tables) throws QException
|
||||
{
|
||||
Method method = resyncMethod();
|
||||
if(method == null)
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
|
||||
Map<String, List<QTableMetaData>> byBackend = new LinkedHashMap<>();
|
||||
for(QTableMetaData table : tables)
|
||||
{
|
||||
byBackend.computeIfAbsent(table.getBackendName(), k -> new ArrayList<>()).add(table);
|
||||
}
|
||||
|
||||
List<String> resynced = new ArrayList<>();
|
||||
for(Map.Entry<String, List<QTableMetaData>> entry : byBackend.entrySet())
|
||||
{
|
||||
QBackendMetaData backend = QContext.getQInstance().getBackend(entry.getKey());
|
||||
try
|
||||
{
|
||||
QBackendModuleInterface module = new QBackendModuleDispatcher().getQBackendModule(backend);
|
||||
for(Object result : (List<?>) method.invoke(module, backend, entry.getValue()))
|
||||
{
|
||||
Object status = result.getClass().getMethod("getStatus").invoke(result);
|
||||
if("RESYNCED".equals(String.valueOf(status)))
|
||||
{
|
||||
resynced.add(String.valueOf(result.getClass().getMethod("getTableName").invoke(result)));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(ReflectiveOperationException e)
|
||||
{
|
||||
LOG.warn("Could not resync key sequences", e, logPair("backend", entry.getKey()));
|
||||
throw (new QException("Could not resync key sequences on backend " + entry.getKey() + ": " + e.getMessage(), e));
|
||||
}
|
||||
}
|
||||
return (resynced);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/*
|
||||
* 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.run;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.backups.files.FileCopier;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reads and writes the qbit own tables (the catalog) through the standard QQQ
|
||||
** actions. Every table is a normal, visible QQQ table; this class only wraps
|
||||
** the calls the processes make, under a permission bypass (the callers have
|
||||
** already checked backups.* permissions).
|
||||
*******************************************************************************/
|
||||
public class BackupCatalog
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert one row, returning its id.
|
||||
*******************************************************************************/
|
||||
public Integer insert(String table, Map<String, Serializable> values) throws QException
|
||||
{
|
||||
QRecord record = new QRecord();
|
||||
record.setValues(new LinkedHashMap<>(values));
|
||||
InsertInput input = new InsertInput(table);
|
||||
input.setRecords(List.of(record));
|
||||
input.withPermissionBypass("backups catalog");
|
||||
List<QRecord> rs = new InsertAction().execute(input).getRecords();
|
||||
if(rs.get(0).getErrors() != null && !rs.get(0).getErrors().isEmpty())
|
||||
{
|
||||
throw (new QException("Could not write to " + table + ": " + rs.get(0).getErrors().get(0).getMessage()));
|
||||
}
|
||||
return (rs.get(0).getValueInteger("id"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Update some fields of one row.
|
||||
*******************************************************************************/
|
||||
public void update(String table, Integer id, Map<String, Serializable> values) throws QException
|
||||
{
|
||||
Map<String, Serializable> all = new LinkedHashMap<>(values);
|
||||
all.put("id", id);
|
||||
QRecord record = new QRecord();
|
||||
record.setValues(all);
|
||||
UpdateInput input = new UpdateInput(table);
|
||||
input.setRecords(List.of(record));
|
||||
input.withPermissionBypass("backups catalog");
|
||||
List<QRecord> rs = new UpdateAction().execute(input).getRecords();
|
||||
if(rs.get(0).getErrors() != null && !rs.get(0).getErrors().isEmpty())
|
||||
{
|
||||
throw (new QException("Could not update " + table + " " + id + ": " + rs.get(0).getErrors().get(0).getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One row by id.
|
||||
*******************************************************************************/
|
||||
public Optional<QRecord> get(String table, Integer id) throws QException
|
||||
{
|
||||
return (query(table, new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, id))).stream().findFirst());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Query a table (catalog tables are small; no paging).
|
||||
*******************************************************************************/
|
||||
public List<QRecord> query(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput(table);
|
||||
input.setFilter(filter);
|
||||
input.withPermissionBypass("backups catalog");
|
||||
return (new QueryAction().execute(input).getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Query by one equals criterion, newest first by id.
|
||||
*******************************************************************************/
|
||||
public List<QRecord> where(String table, String field, Serializable value) throws QException
|
||||
{
|
||||
return (query(table, new QQueryFilter(new QFilterCriteria(field, QCriteriaOperator.EQUALS, value)).withOrderBy(new QFilterOrderBy("id", false))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert one artifact row.
|
||||
*******************************************************************************/
|
||||
public Integer insertArtifact(Integer runId, String type, String tableName, Integer partNumber, String objectKey, String format, String compression,
|
||||
boolean encrypted, long rows, long bytesRaw, long bytesStored, String sha256Stored, String sha256Raw, String rowsHash,
|
||||
String firstKey, String lastKey, String tableFingerprint) throws QException
|
||||
{
|
||||
Map<String, Serializable> v = new LinkedHashMap<>();
|
||||
v.put("backupRunId", runId);
|
||||
v.put("artifactType", type);
|
||||
v.put("tableName", tableName);
|
||||
v.put("partNumber", partNumber);
|
||||
v.put("objectKey", objectKey);
|
||||
v.put("format", format);
|
||||
v.put("compression", compression);
|
||||
v.put("encrypted", encrypted);
|
||||
v.put("rowCount", rows);
|
||||
v.put("bytesRaw", bytesRaw);
|
||||
v.put("bytesStored", bytesStored);
|
||||
v.put("sha256Stored", sha256Stored);
|
||||
v.put("sha256Raw", sha256Raw);
|
||||
v.put("rowsHash", rowsHash);
|
||||
v.put("firstKey", firstKey);
|
||||
v.put("lastKey", lastKey);
|
||||
v.put("tableFingerprint", tableFingerprint);
|
||||
v.put("status", "COMPLETE");
|
||||
return (insert(BackupsTables.ARTIFACT, v));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The file store registry over backups_file_object: known hashes are those with
|
||||
** a live (not deleted) row.
|
||||
*******************************************************************************/
|
||||
public FileCopier.FileStoreRegistry fileStoreRegistry(String bucketAlias, Integer runId, String keyFingerprint)
|
||||
{
|
||||
return (new FileCopier.FileStoreRegistry()
|
||||
{
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isKnown(String sha256)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (existing(sha256).isPresent());
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new IllegalStateException("Could not look up a stored file: " + e.getMessage(), e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void register(String sha256, long sizeBytes, String contentType, String casKey, boolean encrypted)
|
||||
{
|
||||
try
|
||||
{
|
||||
Optional<QRecord> existing = existing(sha256);
|
||||
if(existing.isPresent())
|
||||
{
|
||||
return;
|
||||
}
|
||||
List<QRecord> deleted = where(BackupsTables.FILE_OBJECT, "sha256", sha256);
|
||||
Map<String, Serializable> v = new LinkedHashMap<>();
|
||||
v.put("sha256", sha256);
|
||||
v.put("sizeBytes", sizeBytes);
|
||||
v.put("contentType", contentType);
|
||||
v.put("storageBucketAlias", bucketAlias);
|
||||
v.put("objectKey", casKey);
|
||||
v.put("encrypted", encrypted);
|
||||
v.put("encryptionKeyFingerprint", keyFingerprint);
|
||||
v.put("firstRunId", runId);
|
||||
v.put("lastReferencedRunId", runId);
|
||||
v.put("referenceCount", 0);
|
||||
v.put("deletedDate", null);
|
||||
if(deleted.isEmpty())
|
||||
{
|
||||
insert(BackupsTables.FILE_OBJECT, v);
|
||||
}
|
||||
else
|
||||
{
|
||||
update(BackupsTables.FILE_OBJECT, deleted.get(0).getValueInteger("id"), v);
|
||||
}
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new IllegalStateException("Could not record a stored file: " + e.getMessage(), e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private Optional<QRecord> existing(String sha256) throws QException
|
||||
{
|
||||
return (where(BackupsTables.FILE_OBJECT, "sha256", sha256).stream().filter(r -> r.getValue("deletedDate") == null).findFirst());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The runs that are RUNNING for a plan, with a heartbeat newer than the cutoff.
|
||||
*******************************************************************************/
|
||||
public List<QRecord> liveRunsForPlan(Integer planId, Instant heartbeatCutoff) throws QException
|
||||
{
|
||||
List<QRecord> live = new ArrayList<>();
|
||||
for(QRecord run : where(BackupsTables.RUN, "backupPlanId", planId))
|
||||
{
|
||||
Instant heartbeat = run.getValueInstant("heartbeatDate");
|
||||
if("RUNNING".equals(run.getValueString("status")) && heartbeat != null && heartbeat.isAfter(heartbeatCutoff))
|
||||
{
|
||||
live.add(run);
|
||||
}
|
||||
}
|
||||
return (live);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
/*
|
||||
* 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.run;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.io.Serializable;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.alerts.BackupAlertSink;
|
||||
import com.kingsrook.qbits.backups.codec.HashingOutputStream;
|
||||
import com.kingsrook.qbits.backups.events.BackupEvents;
|
||||
import com.kingsrook.qbits.backups.codec.TableArtifactWriter;
|
||||
import com.kingsrook.qbits.backups.files.FileCopier;
|
||||
import com.kingsrook.qbits.backups.files.FileReference;
|
||||
import com.kingsrook.qbits.backups.files.FileReferenceProvider;
|
||||
import com.kingsrook.qbits.backups.manifest.BackupManifest;
|
||||
import com.kingsrook.qbits.backups.manifest.TableFingerprints;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.scope.ScopeResolver;
|
||||
import com.kingsrook.qbits.backups.storage.BackupStorage;
|
||||
import com.kingsrook.qbits.backups.storage.WriteOptions;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Executes one backup run (spec 8 and 9.2).
|
||||
**
|
||||
** The order is the contract: the run row is created RUNNING; every table is
|
||||
** streamed in primary key order into numbered parts (with their SHA-256 and the
|
||||
** rowsHash); the files the rows reference are copied into the deduplicated
|
||||
** content-addressed store; checksums.sha256 is written; and the manifest is
|
||||
** written LAST, then checked with a head. A run with no manifest did not
|
||||
** complete. The run row is updated every page (heartbeat), and a failure marks
|
||||
** the run FAILED with a scrubbed message (never row values).
|
||||
*******************************************************************************/
|
||||
public class BackupEngine
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(BackupEngine.class);
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private static final DateTimeFormatter RUN_KEY_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").withZone(ZoneOffset.UTC);
|
||||
private static final DateTimeFormatter DATE_PATH = DateTimeFormatter.ofPattern("yyyy/MM/dd").withZone(ZoneOffset.UTC);
|
||||
|
||||
private static final long MAX_ROWS_PER_PART = 1_000_000L;
|
||||
|
||||
private final BackupCatalog catalog = new BackupCatalog();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run a backup. Never throws for a backup failure: the result carries the
|
||||
** status and message (and the run row is FAILED).
|
||||
*******************************************************************************/
|
||||
public BackupRunResult execute(BackupRunRequest request)
|
||||
{
|
||||
BackupRunResult result = new BackupRunResult();
|
||||
Instant started = request.getClock().instant();
|
||||
String runKey = RUN_KEY_FORMAT.format(started) + "-" + UUID.randomUUID().toString().substring(0, 4);
|
||||
String planPrefix = request.getSettings().string("prefix", "qqq-backups");
|
||||
String instancePrefix = trimSlashes(planPrefix) + "/" + request.getInstanceName() + "/";
|
||||
String runPrefix = instancePrefix + request.getSettings().planKey() + "/" + DATE_PATH.format(started) + "/" + runKey + "/";
|
||||
result.setRunKey(runKey);
|
||||
result.setRunPrefix(runPrefix);
|
||||
|
||||
try
|
||||
{
|
||||
Map<String, Serializable> runValues = new LinkedHashMap<>();
|
||||
runValues.put("backupPlanId", request.getSettings().getPlanId());
|
||||
runValues.put("runKey", runKey);
|
||||
runValues.put("trigger", request.getTrigger());
|
||||
runValues.put("requestedByUserId", request.getRequestedBy());
|
||||
runValues.put("status", "RUNNING");
|
||||
runValues.put("startDate", started);
|
||||
runValues.put("heartbeatDate", started);
|
||||
runValues.put("tablesPlanned", request.getScope().tables().size());
|
||||
runValues.put("storageBucketAlias", request.getBucketAlias());
|
||||
runValues.put("runPrefix", runPrefix);
|
||||
runValues.put("instanceName", request.getInstanceName());
|
||||
runValues.put("appVersion", request.getAppVersion());
|
||||
runValues.put("securityScope", request.getSecurityScope());
|
||||
runValues.put("encryptionKeyRef", request.getEncryptionKeyRef());
|
||||
runValues.put("encryptionKeyFingerprint", request.getEncryptionKeyFingerprint());
|
||||
runValues.put("verificationStatus", "UNVERIFIED");
|
||||
result.setRunId(catalog.insert(BackupsTables.RUN, runValues));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
result.setStatus("FAILED");
|
||||
result.setErrorMessage("Could not create the run record: " + e.getMessage());
|
||||
return (result);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
doRun(request, result, runPrefix, instancePrefix, started);
|
||||
}
|
||||
catch(CancelledException e)
|
||||
{
|
||||
finish(request, result, "CANCELLED", "Cancelled", started);
|
||||
}
|
||||
catch(TimedOutException e)
|
||||
{
|
||||
finish(request, result, "TIMED_OUT", e.getMessage(), started);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Backup run failed", e, logPair("runKey", runKey));
|
||||
finish(request, result, "FAILED", scrub(e), started);
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void doRun(BackupRunRequest request, BackupRunResult result, String runPrefix, String instancePrefix, Instant started) throws Exception
|
||||
{
|
||||
QInstance qInstance = QContext.getQInstance();
|
||||
BackupStorage storage = request.getStorage();
|
||||
var settings = request.getSettings();
|
||||
String format = settings.format();
|
||||
String compression = settings.compression();
|
||||
boolean copyFiles = "COPY".equals(settings.fileObjectPolicy());
|
||||
long maxMillis = settings.integer("maxDurationMinutes", 240) * 60_000L;
|
||||
|
||||
if("ZSTD".equals(compression) || "PARQUET".equals(format))
|
||||
{
|
||||
throw (new IllegalStateException("The " + ("ZSTD".equals(compression) ? "ZSTD compression" : "PARQUET format") + " is not available yet; use GZIP and JSONL or CSV."));
|
||||
}
|
||||
String sensitivePolicy = settings.string("sensitiveFieldPolicy", "EXCLUDE");
|
||||
if("INCLUDE_ENCRYPTED_ONLY".equals(sensitivePolicy) && !request.getTransform().encrypts())
|
||||
{
|
||||
throw (new IllegalStateException("The sensitive field policy INCLUDE_ENCRYPTED_ONLY requires client-side encryption."));
|
||||
}
|
||||
String consistency = consistencyFor(settings.string("consistency", "SNAPSHOT_PREFERRED"), result);
|
||||
|
||||
Function<String, BackupStorage> sourceResolver = request.getSourceResolver() != null ? request.getSourceResolver() : alias -> storage;
|
||||
FileCopier copier = new FileCopier(sourceResolver, request.getBucketAlias(), storage, instancePrefix + "_files/sha256/", request.getTransform(),
|
||||
catalog.fileStoreRegistry(request.getBucketAlias(), result.getRunId(), request.getEncryptionKeyFingerprint()), !copyFiles, result.getRunKey());
|
||||
|
||||
List<BackupManifest.TableEntry> manifestTables = new ArrayList<>();
|
||||
List<String> checksums = new ArrayList<>();
|
||||
List<String> fingerprints = new ArrayList<>();
|
||||
FileIndexWriter indexWriter = null;
|
||||
long rows = 0;
|
||||
long bytesStored = 0;
|
||||
long bytesRaw = 0;
|
||||
int done = 0;
|
||||
int referenced = 0;
|
||||
int copied = 0;
|
||||
int deduplicated = 0;
|
||||
int missing = 0;
|
||||
long fileBytes = 0;
|
||||
long fileBytesRef = 0;
|
||||
|
||||
try
|
||||
{
|
||||
for(ScopeResolver.ResolvedTable resolved : request.getScope().tables())
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable(resolved.name());
|
||||
String backendType = qInstance.getBackend(table.getBackendName()) == null ? null : qInstance.getBackend(table.getBackendName()).getBackendType();
|
||||
|
||||
List<BackupManifest.ExcludedField> excluded = excludedFields(table, resolved, sensitivePolicy, settings.string("heavyFieldPolicy", "INLINE"));
|
||||
List<String> excludedNames = excluded.stream().map(BackupManifest.ExcludedField::name).toList();
|
||||
|
||||
List<TableArtifactWriter> writers = new ArrayList<>();
|
||||
String tablePrefix = runPrefix + "tables/" + table.getName() + "/";
|
||||
for(String f : "JSONL_AND_CSV".equals(format) ? List.of("JSONL", "CSV") : List.of("CSV".equals(format) ? "CSV" : "JSONL"))
|
||||
{
|
||||
writers.add(new TableArtifactWriter(storage, tablePrefix, table, excludedNames, f, compression, settings.targetPartBytes(), MAX_ROWS_PER_PART,
|
||||
request.getTransform(), result.getRunKey()));
|
||||
}
|
||||
|
||||
List<FileReference> references = new ArrayList<>();
|
||||
long[] counter = { 0 };
|
||||
Instant deadline = started.plusMillis(maxMillis);
|
||||
Throwable[] writeError = { null };
|
||||
|
||||
request.getReader().read(table, resolved.filterJson(), null, settings.pageSize(), null, record ->
|
||||
{
|
||||
try
|
||||
{
|
||||
for(TableArtifactWriter writer : writers)
|
||||
{
|
||||
writer.write(record);
|
||||
}
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw (new IllegalStateException("Could not write a row of " + table.getName() + ": " + e.getMessage(), e));
|
||||
}
|
||||
if(copyFiles || "REFERENCE_ONLY".equals(settings.fileObjectPolicy()))
|
||||
{
|
||||
for(FileReferenceProvider provider : request.getFileProviders())
|
||||
{
|
||||
references.addAll(provider.referencesIn(table, record));
|
||||
}
|
||||
}
|
||||
counter[0]++;
|
||||
if(counter[0] % settings.pageSize() == 0)
|
||||
{
|
||||
heartbeat(request, result, deadline, table.getName(), counter[0]);
|
||||
}
|
||||
});
|
||||
|
||||
String rowsHash = null;
|
||||
List<BackupManifest.Part> parts = new ArrayList<>();
|
||||
String fingerprint = TableFingerprints.of(table, backendType);
|
||||
fingerprints.add(fingerprint);
|
||||
for(TableArtifactWriter writer : writers)
|
||||
{
|
||||
List<TableArtifactWriter.PartResult> results = writer.finish();
|
||||
rowsHash = writer.rowsHash();
|
||||
for(TableArtifactWriter.PartResult part : results)
|
||||
{
|
||||
String relative = part.key().substring(runPrefix.length());
|
||||
parts.add(new BackupManifest.Part(relative, part.rows(), part.bytesRaw(), part.bytesStored(), part.sha256Stored(), part.sha256Raw(), part.format(), part.compression(), part.encrypted()));
|
||||
checksums.add(part.sha256Stored() + " " + relative);
|
||||
bytesStored += part.bytesStored();
|
||||
bytesRaw += part.bytesRaw();
|
||||
catalog.insertArtifact(result.getRunId(), "TABLE_DATA", table.getName(), part.partNumber(), relative, part.format(), part.compression(),
|
||||
part.encrypted(), part.rows(), part.bytesRaw(), part.bytesStored(), part.sha256Stored(), part.sha256Raw(),
|
||||
part.partNumber() == results.size() ? rowsHash : null, part.firstKey(), part.lastKey(), fingerprint);
|
||||
}
|
||||
}
|
||||
rows += counter[0];
|
||||
|
||||
for(FileReference ref : references)
|
||||
{
|
||||
if(indexWriter == null)
|
||||
{
|
||||
indexWriter = new FileIndexWriter(storage, runPrefix + "files/index-00001.jsonl.gz", result.getRunKey());
|
||||
}
|
||||
FileCopier.IndexEntry entry = copier.process(ref);
|
||||
indexWriter.write(entry);
|
||||
referenced++;
|
||||
switch(entry.status())
|
||||
{
|
||||
case FileCopier.COPIED ->
|
||||
{
|
||||
copied++;
|
||||
fileBytes += entry.size();
|
||||
}
|
||||
case FileCopier.DEDUPLICATED -> deduplicated++;
|
||||
case FileCopier.MISSING -> missing++;
|
||||
default -> fileBytesRef += Math.max(0, entry.size());
|
||||
}
|
||||
if(request.getCancelRequested().getAsBoolean())
|
||||
{
|
||||
throw (new CancelledException());
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, String> keyRange = new LinkedHashMap<>();
|
||||
keyRange.put("min", writers.get(0).getFirstKey());
|
||||
keyRange.put("max", writers.get(0).getLastKey());
|
||||
manifestTables.add(new BackupManifest.TableEntry(table.getName(), resolved.role().name(), table.getBackendName(), table.getPrimaryKeyField(),
|
||||
fingerprint, TableFingerprints.descriptor(table, backendType), resolved.filterJson(), excluded, counter[0], rowsHash, keyRange, parts, consistency));
|
||||
done++;
|
||||
catalogProgress(result, done, rows, bytesRaw, bytesStored);
|
||||
if(request.getCancelRequested().getAsBoolean())
|
||||
{
|
||||
throw (new CancelledException());
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(indexWriter != null)
|
||||
{
|
||||
indexWriter.close();
|
||||
}
|
||||
}
|
||||
|
||||
List<String> indexKeys = new ArrayList<>();
|
||||
if(indexWriter != null)
|
||||
{
|
||||
indexKeys.add("files/index-00001.jsonl.gz");
|
||||
checksums.add(indexWriter.sha256Stored() + " files/index-00001.jsonl.gz");
|
||||
catalog.insertArtifact(result.getRunId(), "FILE_OBJECTS_INDEX", null, 1, "files/index-00001.jsonl.gz", "JSONL", "GZIP", request.getTransform().encrypts(),
|
||||
indexWriter.rows(), indexWriter.bytesRaw(), indexWriter.bytesStored(), indexWriter.sha256Stored(), indexWriter.sha256Raw(), null, null, null, null);
|
||||
bytesStored += indexWriter.bytesStored();
|
||||
}
|
||||
|
||||
if(missing > 0)
|
||||
{
|
||||
if("FAIL".equals(settings.string("fileMissingPolicy", "WARN")))
|
||||
{
|
||||
throw (new IllegalStateException(missing + " referenced files are missing at the source"));
|
||||
}
|
||||
result.getWarnings().add(missing + " referenced files were missing at the source");
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////
|
||||
// checksums, then the manifest LAST (the commit mark) //
|
||||
/////////////////////////////////////////////////////////
|
||||
byte[] checksumBytes = (String.join("\n", checksums) + "\n").getBytes(StandardCharsets.UTF_8);
|
||||
putBytes(storage, runPrefix + "checksums.sha256", checksumBytes, result.getRunKey());
|
||||
catalog.insertArtifact(result.getRunId(), "CHECKSUMS", null, 1, "checksums.sha256", "TEXT", "NONE", false, checksums.size(), checksumBytes.length,
|
||||
checksumBytes.length, HashingOutputStream.sha256Hex(checksumBytes), HashingOutputStream.sha256Hex(checksumBytes), null, null, null, null);
|
||||
|
||||
Instant finished = request.getClock().instant();
|
||||
List<String> order = request.getScope().names();
|
||||
BackupManifest manifest = new BackupManifest(BackupManifest.CURRENT_VERSION, result.getRunKey(),
|
||||
new BackupManifest.PlanInfo(settings.planKey(), request.getPlanName(), settings.manifestSettings()),
|
||||
new BackupManifest.InstanceInfo(request.getInstanceName(), request.getAppVersion(), qqqVersion(), List.of()),
|
||||
started.toString(), finished.toString(), request.getRequestedBy(), request.getSecurityScope(),
|
||||
new BackupManifest.ConsistencyInfo(result.getConsistencyAchieved(), List.of()),
|
||||
request.getTransform().encrypts()
|
||||
? new BackupManifest.EncryptionInfo("CLIENT_AES256_GCM", "tink-streaming-aead-AES256_GCM_HKDF_1MB", request.getEncryptionKeyRef(), request.getEncryptionKeyFingerprint())
|
||||
: new BackupManifest.EncryptionInfo("NONE", null, null, null),
|
||||
manifestTables, order,
|
||||
new BackupManifest.FilesInfo(settings.fileObjectPolicy(), indexKeys, copied + deduplicated, fileBytes + fileBytesRef, missing),
|
||||
result.getWarnings(),
|
||||
new BackupManifest.Totals(manifestTables.size(), rows, bytesStored));
|
||||
byte[] manifestBytes = manifest.toJsonBytes();
|
||||
String manifestKey = runPrefix + "manifest.json";
|
||||
putBytes(storage, manifestKey, manifestBytes, result.getRunKey());
|
||||
if(storage.head(manifestKey).isEmpty())
|
||||
{
|
||||
throw (new IllegalStateException("The manifest was written but is not visible in the bucket"));
|
||||
}
|
||||
catalog.insertArtifact(result.getRunId(), "MANIFEST", null, 1, "manifest.json", "JSON", "NONE", false, manifestTables.size(), manifestBytes.length,
|
||||
manifestBytes.length, HashingOutputStream.sha256Hex(manifestBytes), HashingOutputStream.sha256Hex(manifestBytes), null, null, null, null);
|
||||
|
||||
result.setManifestKey(manifestKey);
|
||||
result.setManifestSha256(HashingOutputStream.sha256Hex(manifestBytes));
|
||||
result.setTables(manifestTables.size());
|
||||
result.setRows(rows);
|
||||
result.setBytesRaw(bytesRaw);
|
||||
result.setBytesStored(bytesStored);
|
||||
result.setFilesReferenced(referenced);
|
||||
result.setFilesCopied(copied);
|
||||
result.setFilesDeduplicated(deduplicated);
|
||||
result.setFilesMissing(missing);
|
||||
result.setFileBytesCopied(fileBytes);
|
||||
|
||||
Map<String, Serializable> extra = new LinkedHashMap<>();
|
||||
extra.put("manifestKey", manifestKey);
|
||||
extra.put("manifestSha256", result.getManifestSha256());
|
||||
extra.put("schemaFingerprint", TableFingerprints.combine(fingerprints));
|
||||
extra.put("qqqVersion", qqqVersion());
|
||||
extra.put("consistencyAchieved", result.getConsistencyAchieved());
|
||||
extra.put("filesReferenced", referenced);
|
||||
extra.put("filesCopied", copied);
|
||||
extra.put("filesDeduplicated", deduplicated);
|
||||
extra.put("filesMissing", missing);
|
||||
extra.put("fileBytesCopied", fileBytes);
|
||||
extra.put("fileBytesReferenced", fileBytesRef);
|
||||
extra.put("tablesDone", manifestTables.size());
|
||||
extra.put("rowsTotal", rows);
|
||||
extra.put("bytesRaw", bytesRaw);
|
||||
extra.put("bytesStored", bytesStored);
|
||||
extra.put("warningCount", result.getWarnings().size());
|
||||
finishWith(request, result, result.getWarnings().isEmpty() ? "SUCCEEDED" : "SUCCEEDED_WITH_WARNINGS", null, started, extra);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Work out the consistency achieved. Until the core consistent-read hook ships
|
||||
** (QQQ core PR feature/consistent-read-transactions, next 4.1.x RC), no backend
|
||||
** reports a snapshot, so: SNAPSHOT_REQUIRED fails before writing anything,
|
||||
** SNAPSHOT_PREFERRED records BEST_EFFORT with a warning, PER_TABLE records PER_TABLE.
|
||||
*******************************************************************************/
|
||||
private String consistencyFor(String requested, BackupRunResult result)
|
||||
{
|
||||
switch(requested)
|
||||
{
|
||||
case "SNAPSHOT_REQUIRED":
|
||||
throw (new IllegalStateException("The plan requires a consistent snapshot, which this version of QQQ cannot open yet. Use SNAPSHOT_PREFERRED or PER_TABLE."));
|
||||
case "PER_TABLE":
|
||||
result.setConsistencyAchieved("PER_TABLE");
|
||||
return ("PER_TABLE");
|
||||
default:
|
||||
result.setConsistencyAchieved("BEST_EFFORT");
|
||||
result.getWarnings().add("No consistent snapshot was available: every table was read on its own (BEST_EFFORT)");
|
||||
return ("BEST_EFFORT");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The fields left out of a table, by policy. The primary key is never left out.
|
||||
*******************************************************************************/
|
||||
private List<BackupManifest.ExcludedField> excludedFields(QTableMetaData table, ScopeResolver.ResolvedTable resolved, String sensitivePolicy, String heavyPolicy)
|
||||
{
|
||||
Map<String, String> out = new LinkedHashMap<>();
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
if(field.getName().equals(table.getPrimaryKeyField()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if("EXCLUDE".equals(sensitivePolicy) && (field.getIsSensitive() || field.getType() == QFieldType.PASSWORD))
|
||||
{
|
||||
out.put(field.getName(), "SENSITIVE_POLICY");
|
||||
}
|
||||
else if("EXCLUDE".equals(heavyPolicy) && field.getIsHeavy())
|
||||
{
|
||||
out.put(field.getName(), "HEAVY_POLICY");
|
||||
}
|
||||
}
|
||||
for(String name : resolved.excludedFields())
|
||||
{
|
||||
if(table.getFields().containsKey(name) && !name.equals(table.getPrimaryKeyField()))
|
||||
{
|
||||
out.put(name, "SCOPE");
|
||||
}
|
||||
}
|
||||
return (out.entrySet().stream().map(e -> new BackupManifest.ExcludedField(e.getKey(), e.getValue())).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void heartbeat(BackupRunRequest request, BackupRunResult result, Instant deadline, String tableName, long rowsSoFar)
|
||||
{
|
||||
Instant now = request.getClock().instant();
|
||||
if(request.getCancelRequested().getAsBoolean())
|
||||
{
|
||||
throw (new CancelledException());
|
||||
}
|
||||
if(now.isAfter(deadline))
|
||||
{
|
||||
throw (new TimedOutException("The run exceeded its maximum duration"));
|
||||
}
|
||||
try
|
||||
{
|
||||
catalog.update(BackupsTables.RUN, result.getRunId(), Map.of("heartbeatDate", now));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not record a heartbeat", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void catalogProgress(BackupRunResult result, int done, long rows, long bytesRaw, long bytesStored)
|
||||
{
|
||||
try
|
||||
{
|
||||
Map<String, Serializable> v = new LinkedHashMap<>();
|
||||
v.put("tablesDone", done);
|
||||
v.put("rowsTotal", rows);
|
||||
v.put("bytesRaw", bytesRaw);
|
||||
v.put("bytesStored", bytesStored);
|
||||
catalog.update(BackupsTables.RUN, result.getRunId(), v);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not record progress", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void finish(BackupRunRequest request, BackupRunResult result, String status, String message, Instant started)
|
||||
{
|
||||
try
|
||||
{
|
||||
finishWith(request, result, status, message, started, new LinkedHashMap<>());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not finish the run record", e);
|
||||
result.setStatus(status);
|
||||
result.setErrorMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void finishWith(BackupRunRequest request, BackupRunResult result, String status, String message, Instant started, Map<String, Serializable> extra) throws Exception
|
||||
{
|
||||
Instant end = request.getClock().instant();
|
||||
Map<String, Serializable> v = new LinkedHashMap<>(extra);
|
||||
v.put("status", status);
|
||||
v.put("endDate", end);
|
||||
v.put("durationMs", end.toEpochMilli() - started.toEpochMilli());
|
||||
v.put("heartbeatDate", end);
|
||||
v.put("errorMessage", message);
|
||||
v.put("warningCount", result.getWarnings().size());
|
||||
catalog.update(BackupsTables.RUN, result.getRunId(), v);
|
||||
result.setStatus(status);
|
||||
result.setErrorMessage(message);
|
||||
signal(request, result, status, message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Update the plan denormalized fields, check in the heartbeat, and raise or resolve alerts.
|
||||
*******************************************************************************/
|
||||
private void signal(BackupRunRequest request, BackupRunResult result, String status, String message) throws Exception
|
||||
{
|
||||
Integer planId = request.getSettings().getPlanId();
|
||||
if(planId == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
boolean ok = "SUCCEEDED".equals(status) || "SUCCEEDED_WITH_WARNINGS".equals(status);
|
||||
Map<String, Serializable> plan = new LinkedHashMap<>();
|
||||
plan.put("lastRunId", result.getRunId());
|
||||
if(ok)
|
||||
{
|
||||
plan.put("lastSuccessRunId", result.getRunId());
|
||||
plan.put("lastSuccessDate", request.getClock().instant());
|
||||
plan.put("slaStatus", "OK");
|
||||
}
|
||||
catalog.update(BackupsTables.PLAN, planId, plan);
|
||||
|
||||
BackupAlertSink sink = BackupsRuntime.getAlertSink();
|
||||
String planKey = request.getSettings().planKey();
|
||||
BackupEvents.runFinished(planKey, result.getRunKey(), status, result.getRows(), result.getBytesStored());
|
||||
if(ok)
|
||||
{
|
||||
sink.resolve("backups.run.failed", "backups.run.failed:" + planKey);
|
||||
sink.heartbeat("backups.plan:" + planKey, request.getSettings().integer("slaHours", 26) * 3600L, 0L);
|
||||
if(result.getFilesMissing() > 0)
|
||||
{
|
||||
sink.raise("backups.files.missing", "backups.files.missing:" + planKey, BackupAlertSink.Severity.WARNING, "Referenced files are missing",
|
||||
Map.of("plan", planKey, "missing", String.valueOf(result.getFilesMissing()), "run", result.getRunKey()));
|
||||
}
|
||||
else
|
||||
{
|
||||
sink.resolve("backups.files.missing", "backups.files.missing:" + planKey);
|
||||
}
|
||||
}
|
||||
else if(!"CANCELLED".equals(status))
|
||||
{
|
||||
sink.raise("backups.run.failed", "backups.run.failed:" + planKey, BackupAlertSink.Severity.WARNING, "A backup run failed",
|
||||
Map.of("plan", planKey, "status", status, "run", result.getRunKey()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A message safe to store: the exception type and message, never row values (the
|
||||
** engine never puts values in messages), truncated.
|
||||
*******************************************************************************/
|
||||
private static String scrub(Exception e)
|
||||
{
|
||||
String message = e.getClass().getSimpleName() + ": " + e.getMessage();
|
||||
return (message.length() > 2000 ? message.substring(0, 2000) : message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void putBytes(BackupStorage storage, String key, byte[] bytes, String runKey) throws IOException
|
||||
{
|
||||
try(OutputStream out = storage.openWrite(key, WriteOptions.create().withMetadata("qqq-backup-run", runKey)))
|
||||
{
|
||||
out.write(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String trimSlashes(String s)
|
||||
{
|
||||
String r = s;
|
||||
while(r.startsWith("/"))
|
||||
{
|
||||
r = r.substring(1);
|
||||
}
|
||||
while(r.endsWith("/"))
|
||||
{
|
||||
r = r.substring(0, r.length() - 1);
|
||||
}
|
||||
return (r);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static String qqqVersion()
|
||||
{
|
||||
String v = QInstance.class.getPackage().getImplementationVersion();
|
||||
return (v == null ? "unknown" : v);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
static class CancelledException extends RuntimeException
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
static class TimedOutException extends RuntimeException
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
TimedOutException(String message)
|
||||
{
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The per-run file index, written as it goes: one JSON line per reference.
|
||||
***************************************************************************/
|
||||
static class FileIndexWriter
|
||||
{
|
||||
private final HashingOutputStream raw;
|
||||
private final HashingOutputStream stored;
|
||||
private long rows = 0;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
FileIndexWriter(BackupStorage storage, String key, String runKey) throws IOException
|
||||
{
|
||||
stored = new HashingOutputStream(storage.openWrite(key, WriteOptions.create().withMetadata("qqq-backup-run", runKey)));
|
||||
raw = new HashingOutputStream(new GZIPOutputStream(stored));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Write one entry.
|
||||
*******************************************************************************/
|
||||
void write(FileCopier.IndexEntry entry) throws IOException
|
||||
{
|
||||
Map<String, Object> line = new LinkedHashMap<>();
|
||||
line.put("table", entry.tableName());
|
||||
line.put("recordKey", entry.recordKey());
|
||||
line.put("field", entry.field());
|
||||
line.put("sourceBucket", entry.sourceBucketAlias());
|
||||
line.put("sourceKey", entry.sourceKey());
|
||||
line.put("sha256", entry.sha256());
|
||||
line.put("size", entry.size());
|
||||
line.put("status", entry.status());
|
||||
line.put("casKey", entry.casKey());
|
||||
raw.write((MAPPER.writeValueAsString(line) + "\n").getBytes(StandardCharsets.UTF_8));
|
||||
rows++;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Close the stream.
|
||||
*******************************************************************************/
|
||||
void close() throws IOException
|
||||
{
|
||||
raw.close();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rows written.
|
||||
*******************************************************************************/
|
||||
long rows()
|
||||
{
|
||||
return (rows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Raw byte count.
|
||||
*******************************************************************************/
|
||||
long bytesRaw()
|
||||
{
|
||||
return (raw.getCount());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stored byte count.
|
||||
*******************************************************************************/
|
||||
long bytesStored()
|
||||
{
|
||||
return (stored.getCount());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Raw hash.
|
||||
*******************************************************************************/
|
||||
String sha256Raw()
|
||||
{
|
||||
return (raw.getSha256());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stored hash.
|
||||
*******************************************************************************/
|
||||
String sha256Stored()
|
||||
{
|
||||
return (stored.getSha256());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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.run;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.backups.BackupsQBitConfig;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.scope.PlanSettings;
|
||||
import com.kingsrook.qbits.backups.scope.ScopeResolver;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Builds the BackupRunRequest for a plan record: loads the scope rows, resolves
|
||||
** the tables, and picks the storage by the plan bucket alias.
|
||||
*******************************************************************************/
|
||||
public final class BackupRunFactory
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private BackupRunFactory()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The scope rules of a plan.
|
||||
*******************************************************************************/
|
||||
public static List<ScopeResolver.ScopeRule> scopeRules(Integer planId) throws QException
|
||||
{
|
||||
List<ScopeResolver.ScopeRule> rules = new ArrayList<>();
|
||||
for(QRecord row : new BackupCatalog().where(BackupsTables.PLAN_SCOPE, "backupPlanId", planId))
|
||||
{
|
||||
rules.add(new ScopeResolver.ScopeRule("INCLUDE".equals(row.getValueString("action")), row.getValueString("selectorType"), row.getValueString("selectorValue"),
|
||||
row.getValueString("filterJson"), !Boolean.FALSE.equals(row.getValueBoolean("includeAssociatedChildren")), row.getValueString("fieldExclusions")));
|
||||
}
|
||||
return (rules);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the scope of a plan record.
|
||||
*******************************************************************************/
|
||||
public static ScopeResolver.ResolvedScope resolveScope(QRecord plan) throws QException
|
||||
{
|
||||
return (new ScopeResolver(QContext.getQInstance()).resolve(PlanSettings.of(plan), scopeRules(plan.getValueInteger("id"))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The request to run a plan.
|
||||
*******************************************************************************/
|
||||
public static BackupRunRequest forPlan(QRecord plan, String trigger) throws QException
|
||||
{
|
||||
BackupsQBitConfig config = BackupsRuntime.getConfig();
|
||||
PlanSettings settings = PlanSettings.of(plan);
|
||||
QSession session = QContext.getQSession();
|
||||
String user = session == null || session.getUser() == null ? "system:scheduler" : session.getUser().getIdReference();
|
||||
|
||||
return (new BackupRunRequest()
|
||||
.withSettings(settings)
|
||||
.withScope(resolveScope(plan))
|
||||
.withTrigger(trigger)
|
||||
.withRequestedBy("SCHEDULED".equals(trigger) ? "system:scheduler" : user)
|
||||
.withSecurityScope("SCHEDULED".equals(trigger) ? "FULL" : "FULL")
|
||||
.withBucketAlias(settings.string("storageBucketAlias", null))
|
||||
.withStorage(BackupsRuntime.storageFor(settings.string("storageBucketAlias", null)))
|
||||
.withSourceResolver(BackupsRuntime::storageFor)
|
||||
.withInstanceName(config.getInstanceName() != null ? config.getInstanceName() : "qqq")
|
||||
.withPlanName(plan.getValueString("name"))
|
||||
.withClock(BackupsRuntime.getClock()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
/*
|
||||
* 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.run;
|
||||
|
||||
|
||||
import java.time.Clock;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import com.kingsrook.qbits.backups.codec.ArtifactTransform;
|
||||
import com.kingsrook.qbits.backups.files.FileReferenceProvider;
|
||||
import com.kingsrook.qbits.backups.scope.PlanSettings;
|
||||
import com.kingsrook.qbits.backups.scope.ScopeResolver;
|
||||
import com.kingsrook.qbits.backups.storage.BackupStorage;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Everything a backup run needs, resolved by the caller (the process step, the
|
||||
** scheduler or a test).
|
||||
*******************************************************************************/
|
||||
public class BackupRunRequest
|
||||
{
|
||||
private PlanSettings settings;
|
||||
private ScopeResolver.ResolvedScope scope;
|
||||
private String trigger = "MANUAL";
|
||||
private String requestedBy = "system";
|
||||
private String securityScope = "FULL";
|
||||
private BackupStorage storage;
|
||||
private String bucketAlias;
|
||||
private String instanceName = "qqq";
|
||||
private String appVersion;
|
||||
private String planName;
|
||||
private ArtifactTransform transform = ArtifactTransform.IDENTITY;
|
||||
private String encryptionKeyRef;
|
||||
private String encryptionKeyFingerprint;
|
||||
private List<FileReferenceProvider> fileProviders = new ArrayList<>(List.of(new FileReferenceProvider.FromTableMetaData()));
|
||||
private TableReader reader = new TableReader.Keyset();
|
||||
private Clock clock = Clock.systemUTC();
|
||||
private BooleanSupplier cancelRequested = () -> false;
|
||||
private java.util.function.Function<String, BackupStorage> sourceResolver;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for settings
|
||||
*******************************************************************************/
|
||||
public PlanSettings getSettings()
|
||||
{
|
||||
return (settings);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for settings
|
||||
*******************************************************************************/
|
||||
public BackupRunRequest withSettings(PlanSettings settings)
|
||||
{
|
||||
this.settings = settings;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for scope
|
||||
*******************************************************************************/
|
||||
public ScopeResolver.ResolvedScope getScope()
|
||||
{
|
||||
return (scope);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for scope
|
||||
*******************************************************************************/
|
||||
public BackupRunRequest withScope(ScopeResolver.ResolvedScope scope)
|
||||
{
|
||||
this.scope = scope;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for trigger
|
||||
*******************************************************************************/
|
||||
public String getTrigger()
|
||||
{
|
||||
return (trigger);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for trigger
|
||||
*******************************************************************************/
|
||||
public BackupRunRequest withTrigger(String trigger)
|
||||
{
|
||||
this.trigger = trigger;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for requestedBy
|
||||
*******************************************************************************/
|
||||
public String getRequestedBy()
|
||||
{
|
||||
return (requestedBy);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for requestedBy
|
||||
*******************************************************************************/
|
||||
public BackupRunRequest withRequestedBy(String requestedBy)
|
||||
{
|
||||
this.requestedBy = requestedBy;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for securityScope (FULL, or USER when the run saw only what a user may see)
|
||||
*******************************************************************************/
|
||||
public String getSecurityScope()
|
||||
{
|
||||
return (securityScope);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for securityScope
|
||||
*******************************************************************************/
|
||||
public BackupRunRequest withSecurityScope(String securityScope)
|
||||
{
|
||||
this.securityScope = securityScope;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for storage
|
||||
*******************************************************************************/
|
||||
public BackupStorage getStorage()
|
||||
{
|
||||
return (storage);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for storage
|
||||
*******************************************************************************/
|
||||
public BackupRunRequest withStorage(BackupStorage storage)
|
||||
{
|
||||
this.storage = storage;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for bucketAlias
|
||||
*******************************************************************************/
|
||||
public String getBucketAlias()
|
||||
{
|
||||
return (bucketAlias);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for bucketAlias
|
||||
*******************************************************************************/
|
||||
public BackupRunRequest withBucketAlias(String bucketAlias)
|
||||
{
|
||||
this.bucketAlias = bucketAlias;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for instanceName
|
||||
*******************************************************************************/
|
||||
public String getInstanceName()
|
||||
{
|
||||
return (instanceName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for instanceName
|
||||
*******************************************************************************/
|
||||
public BackupRunRequest withInstanceName(String instanceName)
|
||||
{
|
||||
this.instanceName = instanceName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for appVersion
|
||||
*******************************************************************************/
|
||||
public String getAppVersion()
|
||||
{
|
||||
return (appVersion);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for appVersion
|
||||
*******************************************************************************/
|
||||
public BackupRunRequest withAppVersion(String appVersion)
|
||||
{
|
||||
this.appVersion = appVersion;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for planName
|
||||
*******************************************************************************/
|
||||
public String getPlanName()
|
||||
{
|
||||
return (planName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for planName
|
||||
*******************************************************************************/
|
||||
public BackupRunRequest withPlanName(String planName)
|
||||
{
|
||||
this.planName = planName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for transform
|
||||
*******************************************************************************/
|
||||
public ArtifactTransform getTransform()
|
||||
{
|
||||
return (transform);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set the artifact transform (encryption) with the key reference and fingerprint to record.
|
||||
*******************************************************************************/
|
||||
public BackupRunRequest withTransform(ArtifactTransform transform, String keyRef, String keyFingerprint)
|
||||
{
|
||||
this.transform = transform;
|
||||
this.encryptionKeyRef = keyRef;
|
||||
this.encryptionKeyFingerprint = keyFingerprint;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for encryptionKeyRef
|
||||
*******************************************************************************/
|
||||
public String getEncryptionKeyRef()
|
||||
{
|
||||
return (encryptionKeyRef);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for encryptionKeyFingerprint
|
||||
*******************************************************************************/
|
||||
public String getEncryptionKeyFingerprint()
|
||||
{
|
||||
return (encryptionKeyFingerprint);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fileProviders
|
||||
*******************************************************************************/
|
||||
public List<FileReferenceProvider> getFileProviders()
|
||||
{
|
||||
return (fileProviders);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add a file reference provider.
|
||||
*******************************************************************************/
|
||||
public BackupRunRequest withFileProvider(FileReferenceProvider provider)
|
||||
{
|
||||
this.fileProviders.add(provider);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for reader
|
||||
*******************************************************************************/
|
||||
public TableReader getReader()
|
||||
{
|
||||
return (reader);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for reader
|
||||
*******************************************************************************/
|
||||
public BackupRunRequest withReader(TableReader reader)
|
||||
{
|
||||
this.reader = reader;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for clock
|
||||
*******************************************************************************/
|
||||
public Clock getClock()
|
||||
{
|
||||
return (clock);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for clock
|
||||
*******************************************************************************/
|
||||
public BackupRunRequest withClock(Clock clock)
|
||||
{
|
||||
this.clock = clock;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for cancelRequested
|
||||
*******************************************************************************/
|
||||
public BooleanSupplier getCancelRequested()
|
||||
{
|
||||
return (cancelRequested);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for cancelRequested
|
||||
*******************************************************************************/
|
||||
public BackupRunRequest withCancelRequested(BooleanSupplier cancelRequested)
|
||||
{
|
||||
this.cancelRequested = cancelRequested;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sourceResolver (bucket alias of the referencing files to storage); defaults to the plan storage
|
||||
*******************************************************************************/
|
||||
public java.util.function.Function<String, BackupStorage> getSourceResolver()
|
||||
{
|
||||
return (sourceResolver);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sourceResolver
|
||||
*******************************************************************************/
|
||||
public BackupRunRequest withSourceResolver(java.util.function.Function<String, BackupStorage> sourceResolver)
|
||||
{
|
||||
this.sourceResolver = sourceResolver;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.run;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What a backup run did.
|
||||
*******************************************************************************/
|
||||
public class BackupRunResult
|
||||
{
|
||||
private Integer runId;
|
||||
private String runKey;
|
||||
private String runPrefix;
|
||||
private String status;
|
||||
private String manifestKey;
|
||||
private String manifestSha256;
|
||||
private String consistencyAchieved;
|
||||
private int tables;
|
||||
private long rows;
|
||||
private long bytesStored;
|
||||
private long bytesRaw;
|
||||
private int filesReferenced;
|
||||
private int filesCopied;
|
||||
private int filesDeduplicated;
|
||||
private int filesMissing;
|
||||
private long fileBytesCopied;
|
||||
private String errorMessage;
|
||||
private List<String> warnings = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the run produced a usable backup (has a manifest).
|
||||
*******************************************************************************/
|
||||
public boolean isUsable()
|
||||
{
|
||||
return (manifestKey != null && ("SUCCEEDED".equals(status) || "SUCCEEDED_WITH_WARNINGS".equals(status)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for runId
|
||||
*******************************************************************************/
|
||||
public Integer getRunId()
|
||||
{
|
||||
return (runId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for runId
|
||||
*******************************************************************************/
|
||||
public void setRunId(Integer runId)
|
||||
{
|
||||
this.runId = runId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for runKey
|
||||
*******************************************************************************/
|
||||
public String getRunKey()
|
||||
{
|
||||
return (runKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for runKey
|
||||
*******************************************************************************/
|
||||
public void setRunKey(String runKey)
|
||||
{
|
||||
this.runKey = runKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for runPrefix
|
||||
*******************************************************************************/
|
||||
public String getRunPrefix()
|
||||
{
|
||||
return (runPrefix);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for runPrefix
|
||||
*******************************************************************************/
|
||||
public void setRunPrefix(String runPrefix)
|
||||
{
|
||||
this.runPrefix = runPrefix;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for status
|
||||
*******************************************************************************/
|
||||
public String getStatus()
|
||||
{
|
||||
return (status);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for status
|
||||
*******************************************************************************/
|
||||
public void setStatus(String status)
|
||||
{
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for manifestKey
|
||||
*******************************************************************************/
|
||||
public String getManifestKey()
|
||||
{
|
||||
return (manifestKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for manifestKey
|
||||
*******************************************************************************/
|
||||
public void setManifestKey(String manifestKey)
|
||||
{
|
||||
this.manifestKey = manifestKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for manifestSha256
|
||||
*******************************************************************************/
|
||||
public String getManifestSha256()
|
||||
{
|
||||
return (manifestSha256);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for manifestSha256
|
||||
*******************************************************************************/
|
||||
public void setManifestSha256(String manifestSha256)
|
||||
{
|
||||
this.manifestSha256 = manifestSha256;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for consistencyAchieved
|
||||
*******************************************************************************/
|
||||
public String getConsistencyAchieved()
|
||||
{
|
||||
return (consistencyAchieved);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for consistencyAchieved
|
||||
*******************************************************************************/
|
||||
public void setConsistencyAchieved(String consistencyAchieved)
|
||||
{
|
||||
this.consistencyAchieved = consistencyAchieved;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tables
|
||||
*******************************************************************************/
|
||||
public int getTables()
|
||||
{
|
||||
return (tables);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for tables
|
||||
*******************************************************************************/
|
||||
public void setTables(int tables)
|
||||
{
|
||||
this.tables = tables;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for rows
|
||||
*******************************************************************************/
|
||||
public long getRows()
|
||||
{
|
||||
return (rows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for rows
|
||||
*******************************************************************************/
|
||||
public void setRows(long rows)
|
||||
{
|
||||
this.rows = rows;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for bytesStored
|
||||
*******************************************************************************/
|
||||
public long getBytesStored()
|
||||
{
|
||||
return (bytesStored);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for bytesStored
|
||||
*******************************************************************************/
|
||||
public void setBytesStored(long bytesStored)
|
||||
{
|
||||
this.bytesStored = bytesStored;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for bytesRaw
|
||||
*******************************************************************************/
|
||||
public long getBytesRaw()
|
||||
{
|
||||
return (bytesRaw);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for bytesRaw
|
||||
*******************************************************************************/
|
||||
public void setBytesRaw(long bytesRaw)
|
||||
{
|
||||
this.bytesRaw = bytesRaw;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for filesReferenced
|
||||
*******************************************************************************/
|
||||
public int getFilesReferenced()
|
||||
{
|
||||
return (filesReferenced);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for filesReferenced
|
||||
*******************************************************************************/
|
||||
public void setFilesReferenced(int filesReferenced)
|
||||
{
|
||||
this.filesReferenced = filesReferenced;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for filesCopied
|
||||
*******************************************************************************/
|
||||
public int getFilesCopied()
|
||||
{
|
||||
return (filesCopied);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for filesCopied
|
||||
*******************************************************************************/
|
||||
public void setFilesCopied(int filesCopied)
|
||||
{
|
||||
this.filesCopied = filesCopied;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for filesDeduplicated
|
||||
*******************************************************************************/
|
||||
public int getFilesDeduplicated()
|
||||
{
|
||||
return (filesDeduplicated);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for filesDeduplicated
|
||||
*******************************************************************************/
|
||||
public void setFilesDeduplicated(int filesDeduplicated)
|
||||
{
|
||||
this.filesDeduplicated = filesDeduplicated;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for filesMissing
|
||||
*******************************************************************************/
|
||||
public int getFilesMissing()
|
||||
{
|
||||
return (filesMissing);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for filesMissing
|
||||
*******************************************************************************/
|
||||
public void setFilesMissing(int filesMissing)
|
||||
{
|
||||
this.filesMissing = filesMissing;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fileBytesCopied
|
||||
*******************************************************************************/
|
||||
public long getFileBytesCopied()
|
||||
{
|
||||
return (fileBytesCopied);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for fileBytesCopied
|
||||
*******************************************************************************/
|
||||
public void setFileBytesCopied(long fileBytesCopied)
|
||||
{
|
||||
this.fileBytesCopied = fileBytesCopied;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for errorMessage
|
||||
*******************************************************************************/
|
||||
public String getErrorMessage()
|
||||
{
|
||||
return (errorMessage);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for errorMessage
|
||||
*******************************************************************************/
|
||||
public void setErrorMessage(String errorMessage)
|
||||
{
|
||||
this.errorMessage = errorMessage;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for warnings
|
||||
*******************************************************************************/
|
||||
public List<String> getWarnings()
|
||||
{
|
||||
return (warnings);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* 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.run;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reads the rows of one table in primary key order, in keyset pages (WHERE pk >
|
||||
** lastKey ORDER BY pk LIMIT n), so the cost stays flat with depth and a read can
|
||||
** resume from a key. A read raw: hidden fields, passwords and heavy fields are
|
||||
** returned as stored, and possible values are not translated.
|
||||
**
|
||||
** This is an internal stand-in for the QQQ core KeysetPager and QueryInput
|
||||
** withRawRead (core PR feature/keyset-pager-raw-read); the run engine only
|
||||
** depends on this interface, so it is swapped when that ships in the next RC.
|
||||
*******************************************************************************/
|
||||
public interface TableReader
|
||||
{
|
||||
|
||||
/***************************************************************************
|
||||
** Read every row of the table matching the optional filter, after startAfterKey
|
||||
** (exclusive, null for the beginning), calling the sink for each in key order.
|
||||
***************************************************************************/
|
||||
void read(QTableMetaData table, String filterJson, Serializable startAfterKey, int pageSize, QBackendTransaction transaction, Consumer<QRecord> sink) throws QException;
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The default implementation over QueryAction.
|
||||
***************************************************************************/
|
||||
class Keyset implements TableReader
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QQueryFilter parseFilter(String filterJson) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
return (filterJson == null || filterJson.isBlank() ? null : JsonUtils.toObject(filterJson, QQueryFilter.class));
|
||||
}
|
||||
catch(java.io.IOException e)
|
||||
{
|
||||
throw (new QException("Could not read a scope filter: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void read(QTableMetaData table, String filterJson, Serializable startAfterKey, int pageSize, QBackendTransaction transaction, Consumer<QRecord> sink) throws QException
|
||||
{
|
||||
String pk = table.getPrimaryKeyField();
|
||||
Serializable lastKey = startAfterKey;
|
||||
QQueryFilter user = parseFilter(filterJson);
|
||||
|
||||
while(true)
|
||||
{
|
||||
QQueryFilter filter = new QQueryFilter();
|
||||
if(lastKey != null)
|
||||
{
|
||||
filter.addCriteria(new QFilterCriteria(pk, QCriteriaOperator.GREATER_THAN, List.of(lastKey)));
|
||||
}
|
||||
if(user != null)
|
||||
{
|
||||
filter.addSubFilter(user);
|
||||
}
|
||||
filter.withOrderBy(new QFilterOrderBy(pk, true));
|
||||
filter.setLimit(pageSize);
|
||||
|
||||
QueryInput queryInput = new QueryInput(table.getName());
|
||||
queryInput.setFilter(filter);
|
||||
queryInput.setShouldOmitHiddenFields(false);
|
||||
queryInput.setShouldMaskPasswords(false);
|
||||
queryInput.setShouldFetchHeavyFields(true);
|
||||
queryInput.setShouldTranslatePossibleValues(false);
|
||||
queryInput.setShouldGenerateDisplayValues(false);
|
||||
if(transaction != null)
|
||||
{
|
||||
queryInput.setTransaction(transaction);
|
||||
}
|
||||
queryInput.withPermissionBypass("backups run");
|
||||
|
||||
List<QRecord> page = new QueryAction().execute(queryInput).getRecords();
|
||||
for(QRecord record : page)
|
||||
{
|
||||
sink.accept(record);
|
||||
lastKey = record.getValue(pk);
|
||||
}
|
||||
if(page.size() < pageSize)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* 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.scope;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QSupplementalTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Supplemental table meta-data: the backup role of a table, and the fields
|
||||
** that reference files in object storage (when the app does not register them
|
||||
** through the object-storage reference providers).
|
||||
*******************************************************************************/
|
||||
public class BackupTableMetaData extends QSupplementalTableMetaData
|
||||
{
|
||||
public static final String TYPE = "backups";
|
||||
|
||||
private TableRole role;
|
||||
private List<String> fileReferenceFields = new ArrayList<>();
|
||||
private String fileBucketAlias;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The BackupTableMetaData on a table, or null.
|
||||
*******************************************************************************/
|
||||
public static BackupTableMetaData of(QTableMetaData table)
|
||||
{
|
||||
return (table == null ? null : QSupplementalTableMetaData.of(table, TYPE));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The BackupTableMetaData on a table, adding an empty one if needed.
|
||||
*******************************************************************************/
|
||||
public static BackupTableMetaData ofOrWithNew(QTableMetaData table)
|
||||
{
|
||||
return (QSupplementalTableMetaData.ofOrWithNew(table, TYPE, BackupTableMetaData::new));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return (TYPE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected QSupplementalTableMetaData finishClone(QSupplementalTableMetaData abstractClone)
|
||||
{
|
||||
BackupTableMetaData clone = (BackupTableMetaData) abstractClone;
|
||||
clone.role = this.role;
|
||||
clone.fileReferenceFields = new ArrayList<>(this.fileReferenceFields);
|
||||
clone.fileBucketAlias = this.fileBucketAlias;
|
||||
return (clone);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for role
|
||||
*******************************************************************************/
|
||||
public TableRole getRole()
|
||||
{
|
||||
return (role);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for role
|
||||
*******************************************************************************/
|
||||
public BackupTableMetaData withRole(TableRole role)
|
||||
{
|
||||
this.role = role;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fileReferenceFields (fields holding a storage key in the source bucket)
|
||||
*******************************************************************************/
|
||||
public List<String> getFileReferenceFields()
|
||||
{
|
||||
return (fileReferenceFields);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for fileReferenceFields
|
||||
*******************************************************************************/
|
||||
public BackupTableMetaData withFileReferenceFields(List<String> fileReferenceFields)
|
||||
{
|
||||
this.fileReferenceFields = fileReferenceFields;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fileBucketAlias (the bucket the fileReferenceFields keys live in)
|
||||
*******************************************************************************/
|
||||
public String getFileBucketAlias()
|
||||
{
|
||||
return (fileBucketAlias);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for fileBucketAlias
|
||||
*******************************************************************************/
|
||||
public BackupTableMetaData withFileBucketAlias(String fileBucketAlias)
|
||||
{
|
||||
this.fileBucketAlias = fileBucketAlias;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/*
|
||||
* 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.scope;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The settings of a backup plan (a backups_plan record), with the defaults of
|
||||
** spec 4.1 applied for anything not set. Ad-hoc exports (no plan row) build one
|
||||
** with the static factory and the with() method.
|
||||
*******************************************************************************/
|
||||
public class PlanSettings
|
||||
{
|
||||
private final Map<String, Serializable> values = new LinkedHashMap<>();
|
||||
|
||||
private Integer planId;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** From a plan record.
|
||||
*******************************************************************************/
|
||||
public static PlanSettings of(QRecord plan)
|
||||
{
|
||||
PlanSettings settings = new PlanSettings();
|
||||
if(plan != null)
|
||||
{
|
||||
settings.planId = plan.getValueInteger("id");
|
||||
settings.values.putAll(plan.getValues());
|
||||
}
|
||||
return (settings);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An ad-hoc (no plan row) settings object with all defaults.
|
||||
*******************************************************************************/
|
||||
public static PlanSettings adHoc()
|
||||
{
|
||||
return (new PlanSettings());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set one value (fluent).
|
||||
*******************************************************************************/
|
||||
public PlanSettings with(String name, Serializable value)
|
||||
{
|
||||
values.put(name, value);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The plan id, or null for an ad-hoc run.
|
||||
*******************************************************************************/
|
||||
public Integer getPlanId()
|
||||
{
|
||||
return (planId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A string setting.
|
||||
*******************************************************************************/
|
||||
public String string(String name, String defaultValue)
|
||||
{
|
||||
String value = ValueUtils.getValueAsString(values.get(name));
|
||||
return (value == null || value.isEmpty() ? defaultValue : value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An integer setting.
|
||||
*******************************************************************************/
|
||||
public Integer integer(String name, Integer defaultValue)
|
||||
{
|
||||
Integer value = ValueUtils.getValueAsInteger(values.get(name));
|
||||
return (value == null ? defaultValue : value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A long setting.
|
||||
*******************************************************************************/
|
||||
public Long longValue(String name, Long defaultValue)
|
||||
{
|
||||
Long value = ValueUtils.getValueAsLong(values.get(name));
|
||||
return (value == null ? defaultValue : value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A boolean setting.
|
||||
*******************************************************************************/
|
||||
public boolean bool(String name, boolean defaultValue)
|
||||
{
|
||||
Boolean value = ValueUtils.getValueAsBoolean(values.get(name));
|
||||
return (value == null ? defaultValue : value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The roles a plan includes by default (comma separated).
|
||||
*******************************************************************************/
|
||||
public List<TableRole> includeRoles()
|
||||
{
|
||||
String csv = string("includeRoles", "DATA,AUDIT");
|
||||
return (java.util.Arrays.stream(csv.split(",")).map(String::trim).filter(s -> !s.isEmpty()).map(TableRole::valueOf).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The plan key (slug used in object keys).
|
||||
*******************************************************************************/
|
||||
public String planKey()
|
||||
{
|
||||
return (string("planKey", "adhoc"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The scope type.
|
||||
*******************************************************************************/
|
||||
public String scopeType()
|
||||
{
|
||||
return (string("scopeType", "INSTANCE"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The format.
|
||||
*******************************************************************************/
|
||||
public String format()
|
||||
{
|
||||
return (string("format", "JSONL"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The compression.
|
||||
*******************************************************************************/
|
||||
public String compression()
|
||||
{
|
||||
return (string("compression", "GZIP"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The file object policy: COPY (default) or REFERENCE_ONLY.
|
||||
*******************************************************************************/
|
||||
public String fileObjectPolicy()
|
||||
{
|
||||
return (string("fileObjectPolicy", "COPY"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The page size.
|
||||
*******************************************************************************/
|
||||
public int pageSize()
|
||||
{
|
||||
return (integer("pageSize", 5000));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Target part size in bytes.
|
||||
*******************************************************************************/
|
||||
public long targetPartBytes()
|
||||
{
|
||||
return (longValue("targetPartBytes", 268435456L));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The settings as a map (for the manifest; contains no secrets by design).
|
||||
*******************************************************************************/
|
||||
public Map<String, Serializable> manifestSettings()
|
||||
{
|
||||
Map<String, Serializable> map = new LinkedHashMap<>();
|
||||
for(String key : List.of("scopeType", "includeRoles", "format", "compression", "encryptionMode", "sensitiveFieldPolicy", "heavyFieldPolicy",
|
||||
"fileObjectPolicy", "fileMissingPolicy", "consistency", "incrementalMode", "pageSize", "targetPartBytes"))
|
||||
{
|
||||
if(values.get(key) != null)
|
||||
{
|
||||
map.put(key, values.get(key));
|
||||
}
|
||||
}
|
||||
return (map);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
/*
|
||||
* 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.scope;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppChildMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Association;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Turns a plan (scope type, included roles, scope rows) into the ordered list of
|
||||
** tables a run will back up (spec 4.2).
|
||||
**
|
||||
** Resolution: start from the scope type and roles, apply the INCLUDE rows, then
|
||||
** the EXCLUDE rows, add associated children, and drop SECRET and BACKUPS tables.
|
||||
** Tables are ordered parents first, using associations, joins and table-backed
|
||||
** possible values. Every table that was left out is reported with its reason.
|
||||
*******************************************************************************/
|
||||
public class ScopeResolver
|
||||
{
|
||||
private final QInstance qInstance;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public ScopeResolver(QInstance qInstance)
|
||||
{
|
||||
this.qInstance = qInstance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One scope row.
|
||||
**
|
||||
** @param include true for INCLUDE, false for EXCLUDE
|
||||
** @param selectorType TABLE, APP, QBIT or ROLE
|
||||
** @param selectorValue the table, app, qbit or role
|
||||
** @param filterJson optional QQueryFilter json (TABLE only)
|
||||
** @param includeAssociatedChildren also include tables reachable through associations
|
||||
** @param fieldExclusions comma separated field names to leave out
|
||||
***************************************************************************/
|
||||
public record ScopeRule(boolean include, String selectorType, String selectorValue, String filterJson, boolean includeAssociatedChildren, String fieldExclusions)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One table that will be backed up.
|
||||
***************************************************************************/
|
||||
public record ResolvedTable(String name, TableRole role, String filterJson, List<String> excludedFields)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A table that was left out, and why.
|
||||
***************************************************************************/
|
||||
public record ExcludedTable(String name, String reason)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The outcome of resolving a scope.
|
||||
**
|
||||
** @param tables the tables, parents first
|
||||
** @param excluded tables left out, by reason
|
||||
** @param cyclic tables that could not be ordered (reference cycle), in name order at the end
|
||||
***************************************************************************/
|
||||
public record ResolvedScope(List<ResolvedTable> tables, List<ExcludedTable> excluded, List<String> cyclic)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The table names, in restore order.
|
||||
*******************************************************************************/
|
||||
public List<String> names()
|
||||
{
|
||||
return (tables.stream().map(ResolvedTable::name).toList());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve a scope.
|
||||
*******************************************************************************/
|
||||
public ResolvedScope resolve(PlanSettings settings, List<ScopeRule> rules)
|
||||
{
|
||||
List<TableRole> roles = settings.includeRoles();
|
||||
|
||||
Set<String> selected = new LinkedHashSet<>();
|
||||
Set<String> explicit = new LinkedHashSet<>();
|
||||
Map<String, String> filters = new LinkedHashMap<>();
|
||||
Map<String, String> fieldsOut = new LinkedHashMap<>();
|
||||
Set<String> expandChildrenFor = new LinkedHashSet<>();
|
||||
|
||||
if("INSTANCE".equals(settings.scopeType()))
|
||||
{
|
||||
for(QTableMetaData table : sortedTables())
|
||||
{
|
||||
if(roles.contains(TableRoles.roleOf(qInstance, table)))
|
||||
{
|
||||
selected.add(table.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for(ScopeRule rule : rules)
|
||||
{
|
||||
if(!rule.include())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for(String name : tablesFor(rule, roles))
|
||||
{
|
||||
selected.add(name);
|
||||
if("TABLE".equals(rule.selectorType()))
|
||||
{
|
||||
explicit.add(name);
|
||||
if(rule.filterJson() != null && !rule.filterJson().isBlank())
|
||||
{
|
||||
filters.put(name, rule.filterJson());
|
||||
}
|
||||
if(rule.fieldExclusions() != null && !rule.fieldExclusions().isBlank())
|
||||
{
|
||||
fieldsOut.put(name, rule.fieldExclusions());
|
||||
}
|
||||
}
|
||||
if(rule.includeAssociatedChildren())
|
||||
{
|
||||
expandChildrenFor.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for(ScopeRule rule : rules)
|
||||
{
|
||||
if(rule.include())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for(String name : tablesFor(rule, List.of(TableRole.values())))
|
||||
{
|
||||
selected.remove(name);
|
||||
explicit.remove(name);
|
||||
}
|
||||
}
|
||||
|
||||
for(String name : new ArrayList<>(expandChildrenFor))
|
||||
{
|
||||
addAssociatedChildren(name, selected);
|
||||
}
|
||||
|
||||
List<ExcludedTable> excluded = new ArrayList<>();
|
||||
List<String> kept = new ArrayList<>();
|
||||
for(String name : selected)
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable(name);
|
||||
if(table == null)
|
||||
{
|
||||
excluded.add(new ExcludedTable(name, "NOT_FOUND"));
|
||||
continue;
|
||||
}
|
||||
TableRole role = TableRoles.roleOf(qInstance, table);
|
||||
if(role == TableRole.SECRET)
|
||||
{
|
||||
excluded.add(new ExcludedTable(name, "SECRET"));
|
||||
}
|
||||
else if(role == TableRole.BACKUPS)
|
||||
{
|
||||
excluded.add(new ExcludedTable(name, "BACKUPS"));
|
||||
}
|
||||
else if((role == TableRole.EPHEMERAL || role == TableRole.DERIVED || role == TableRole.EXTERNAL) && !explicit.contains(name) && !roles.contains(role))
|
||||
{
|
||||
excluded.add(new ExcludedTable(name, role.name()));
|
||||
}
|
||||
else
|
||||
{
|
||||
kept.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
List<String> cyclic = new ArrayList<>();
|
||||
List<String> ordered = order(kept, cyclic);
|
||||
|
||||
List<ResolvedTable> tables = new ArrayList<>();
|
||||
for(String name : ordered)
|
||||
{
|
||||
List<String> out = new ArrayList<>();
|
||||
if(fieldsOut.get(name) != null)
|
||||
{
|
||||
for(String field : fieldsOut.get(name).split(","))
|
||||
{
|
||||
if(!field.isBlank())
|
||||
{
|
||||
out.add(field.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
tables.add(new ResolvedTable(name, TableRoles.roleOf(qInstance, qInstance.getTable(name)), filters.get(name), out));
|
||||
}
|
||||
return (new ResolvedScope(tables, excluded, cyclic));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The tables a rule selects.
|
||||
*******************************************************************************/
|
||||
private Collection<String> tablesFor(ScopeRule rule, List<TableRole> rolesForExpansion)
|
||||
{
|
||||
Set<String> names = new LinkedHashSet<>();
|
||||
switch(rule.selectorType())
|
||||
{
|
||||
case "TABLE" -> names.add(rule.selectorValue());
|
||||
case "ROLE" ->
|
||||
{
|
||||
TableRole role = TableRole.valueOf(rule.selectorValue());
|
||||
for(QTableMetaData table : sortedTables())
|
||||
{
|
||||
if(TableRoles.roleOf(qInstance, table) == role)
|
||||
{
|
||||
names.add(table.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
case "QBIT" ->
|
||||
{
|
||||
for(QTableMetaData table : sortedTables())
|
||||
{
|
||||
if(rule.selectorValue().equals(table.getSourceQBitName()) && rolesForExpansion.contains(TableRoles.roleOf(qInstance, table)))
|
||||
{
|
||||
names.add(table.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
case "APP" ->
|
||||
{
|
||||
QAppMetaData app = qInstance.getApp(rule.selectorValue());
|
||||
if(app != null)
|
||||
{
|
||||
Set<String> inApp = new LinkedHashSet<>();
|
||||
collectAppTables(app, inApp, new LinkedHashSet<>());
|
||||
for(String name : inApp)
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable(name);
|
||||
if(table != null && rolesForExpansion.contains(TableRoles.roleOf(qInstance, table)))
|
||||
{
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
default ->
|
||||
{
|
||||
}
|
||||
}
|
||||
return (names);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void collectAppTables(QAppMetaData app, Set<String> tables, Set<String> seenApps)
|
||||
{
|
||||
if(!seenApps.add(app.getName()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
for(QAppChildMetaData child : app.getChildren() == null ? List.<QAppChildMetaData>of() : app.getChildren())
|
||||
{
|
||||
if(child instanceof QAppMetaData childApp)
|
||||
{
|
||||
collectAppTables(childApp, tables, seenApps);
|
||||
}
|
||||
else if(child instanceof QTableMetaData table)
|
||||
{
|
||||
tables.add(table.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void addAssociatedChildren(String name, Set<String> selected)
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable(name);
|
||||
if(table == null || table.getAssociations() == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for(Association association : table.getAssociations())
|
||||
{
|
||||
if(selected.add(association.getAssociatedTableName()))
|
||||
{
|
||||
addAssociatedChildren(association.getAssociatedTableName(), selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<QTableMetaData> sortedTables()
|
||||
{
|
||||
return (new ArrayList<>(new TreeMap<>(qInstance.getTables()).values()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The tables that must be restored before the given table (its parents).
|
||||
*******************************************************************************/
|
||||
Set<String> parentsOf(String name, Set<String> universe)
|
||||
{
|
||||
Set<String> parents = new LinkedHashSet<>();
|
||||
QTableMetaData table = qInstance.getTable(name);
|
||||
|
||||
for(QTableMetaData other : qInstance.getTables().values())
|
||||
{
|
||||
if(other.getAssociations() != null)
|
||||
{
|
||||
for(Association association : other.getAssociations())
|
||||
{
|
||||
if(name.equals(association.getAssociatedTableName()))
|
||||
{
|
||||
parents.add(other.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for(QJoinMetaData join : qInstance.getJoins().values())
|
||||
{
|
||||
if(join.getType() == JoinType.MANY_TO_ONE && name.equals(join.getLeftTable()))
|
||||
{
|
||||
parents.add(join.getRightTable());
|
||||
}
|
||||
else if(join.getType() == JoinType.ONE_TO_MANY && name.equals(join.getRightTable()))
|
||||
{
|
||||
parents.add(join.getLeftTable());
|
||||
}
|
||||
}
|
||||
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
if(field.getPossibleValueSourceName() != null)
|
||||
{
|
||||
QPossibleValueSource pvs = qInstance.getPossibleValueSource(field.getPossibleValueSourceName());
|
||||
if(pvs != null && pvs.getType() == QPossibleValueSourceType.TABLE && pvs.getTableName() != null)
|
||||
{
|
||||
parents.add(pvs.getTableName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parents.remove(name);
|
||||
parents.retainAll(universe);
|
||||
return (parents);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Topological order, parents first, stable by name. Tables in a reference cycle
|
||||
** are appended in name order and reported through the cyclic list.
|
||||
*******************************************************************************/
|
||||
List<String> order(List<String> names, List<String> cyclic)
|
||||
{
|
||||
Set<String> universe = new LinkedHashSet<>(names);
|
||||
Map<String, Set<String>> remaining = new TreeMap<>();
|
||||
for(String name : names)
|
||||
{
|
||||
remaining.put(name, parentsOf(name, universe));
|
||||
}
|
||||
|
||||
List<String> ordered = new ArrayList<>();
|
||||
while(!remaining.isEmpty())
|
||||
{
|
||||
List<String> ready = new ArrayList<>();
|
||||
for(Map.Entry<String, Set<String>> entry : remaining.entrySet())
|
||||
{
|
||||
if(entry.getValue().isEmpty())
|
||||
{
|
||||
ready.add(entry.getKey());
|
||||
}
|
||||
}
|
||||
if(ready.isEmpty())
|
||||
{
|
||||
cyclic.addAll(remaining.keySet());
|
||||
ordered.addAll(remaining.keySet());
|
||||
break;
|
||||
}
|
||||
for(String name : ready)
|
||||
{
|
||||
ordered.add(name);
|
||||
remaining.remove(name);
|
||||
}
|
||||
for(Set<String> parents : remaining.values())
|
||||
{
|
||||
parents.removeAll(ready);
|
||||
}
|
||||
}
|
||||
return (ordered);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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.scope;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What a table is for, as far as backups are concerned (spec 3.2). Plans select
|
||||
** tables by role.
|
||||
*******************************************************************************/
|
||||
public enum TableRole
|
||||
{
|
||||
/** Business data (included by default). */
|
||||
DATA,
|
||||
/** Audit tables (included by default, configurable). */
|
||||
AUDIT,
|
||||
/** Rebuildable from other data: search indexes, rollups, caches. Excluded by default. */
|
||||
DERIVED,
|
||||
/** Sessions, idempotency keys, scheduler internals. Excluded by default. */
|
||||
EPHEMERAL,
|
||||
/** Tokens, salts, credentials. NEVER backed up, even when selected explicitly. */
|
||||
SECRET,
|
||||
/** The tables of this qbit: the catalog is rebuilt from manifests. */
|
||||
BACKUPS,
|
||||
/** Tables backed by another system (API). Excluded by default; selectable. */
|
||||
EXTERNAL
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* 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.scope;
|
||||
|
||||
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.backups.BackupsQBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Works out the backup role of a table: the declared role when there is one,
|
||||
** else inferred by the rules of spec 3.2.
|
||||
*******************************************************************************/
|
||||
public final class TableRoles
|
||||
{
|
||||
private static final Set<String> AUDIT_TABLES = Set.of("audit", "auditTable", "auditUser", "auditDetail");
|
||||
|
||||
private static final String[] SECRET_NAME_HINTS = { "refreshToken", "RefreshToken", "oauthToken", "OAuthToken", "apiKey" };
|
||||
|
||||
private static final String[] EPHEMERAL_NAME_PREFIXES = { "qrtz_" };
|
||||
private static final String[] EPHEMERAL_NAME_PARTS = { "Session", "Idempotency", "RedirectState", "AsyncJob" };
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private TableRoles()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The role of a table.
|
||||
*******************************************************************************/
|
||||
public static TableRole roleOf(QInstance qInstance, QTableMetaData table)
|
||||
{
|
||||
BackupTableMetaData declared = BackupTableMetaData.of(table);
|
||||
if(declared != null && declared.getRole() != null)
|
||||
{
|
||||
return (declared.getRole());
|
||||
}
|
||||
|
||||
String name = table.getName();
|
||||
if(name.startsWith(BackupsQBitConfig.TABLE_NAME_PREFIX))
|
||||
{
|
||||
return (TableRole.BACKUPS);
|
||||
}
|
||||
for(String hint : SECRET_NAME_HINTS)
|
||||
{
|
||||
if(name.contains(hint))
|
||||
{
|
||||
return (TableRole.SECRET);
|
||||
}
|
||||
}
|
||||
if(isHiddenWithSecretFields(table))
|
||||
{
|
||||
return (TableRole.SECRET);
|
||||
}
|
||||
if(AUDIT_TABLES.contains(name))
|
||||
{
|
||||
return (TableRole.AUDIT);
|
||||
}
|
||||
if(table.getCacheOf() != null)
|
||||
{
|
||||
return (TableRole.DERIVED);
|
||||
}
|
||||
for(String prefix : EPHEMERAL_NAME_PREFIXES)
|
||||
{
|
||||
if(name.startsWith(prefix))
|
||||
{
|
||||
return (TableRole.EPHEMERAL);
|
||||
}
|
||||
}
|
||||
for(String part : EPHEMERAL_NAME_PARTS)
|
||||
{
|
||||
if(name.contains(part) || name.toLowerCase().contains(part.toLowerCase()))
|
||||
{
|
||||
return (TableRole.EPHEMERAL);
|
||||
}
|
||||
}
|
||||
if(qInstance != null && table.getBackendName() != null && qInstance.getBackend(table.getBackendName()) != null
|
||||
&& "api".equalsIgnoreCase(qInstance.getBackend(table.getBackendName()).getBackendType()))
|
||||
{
|
||||
return (TableRole.EXTERNAL);
|
||||
}
|
||||
return (TableRole.DATA);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A table that is hidden and has a password or sensitive field (other than the
|
||||
** keys and dates) is treated as a table of secrets.
|
||||
*******************************************************************************/
|
||||
static boolean isHiddenWithSecretFields(QTableMetaData table)
|
||||
{
|
||||
if(!table.getIsHidden())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
if(field.getType() == QFieldType.PASSWORD || field.getIsSensitive())
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
/*
|
||||
* 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.sla;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import com.kingsrook.qbits.backups.BackupsQBitConfig;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.alerts.BackupAlertSink;
|
||||
import com.kingsrook.qbits.backups.alerts.BackupAlerts;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qbits.backups.storage.BackupStorage;
|
||||
import com.kingsrook.qbits.backups.storage.StoredObjectInfo;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The SLA checker (design spec section 10.2): backupCheckSla. A scheduled process
|
||||
** that is independent of the backup trigger, so a broken trigger cannot also
|
||||
** silence the check. For each active plan it computes slaStatus:
|
||||
** <ul>
|
||||
** <li>MISSING_IN_STORAGE: the catalog says success but the manifest is gone
|
||||
** (a head on manifestKey, a positive answer cached for an hour);</li>
|
||||
** <li>FAILING: the last two finished runs failed;</li>
|
||||
** <li>ABANDONED: a RUNNING run has a heartbeat older than ten minutes (the run
|
||||
** is marked ABANDONED);</li>
|
||||
** <li>LATE: no success within slaHours;</li>
|
||||
** <li>OWNER_INVALID: the plan owner no longer validates (the configured
|
||||
** planOwnerValidator);</li>
|
||||
** <li>UNVERIFIED: no passed CONTENT or TEST_RESTORE verification within
|
||||
** verifySlaDays;</li>
|
||||
** <li>OK otherwise (UNKNOWN for a new plan that has not had time to run yet).</li>
|
||||
** </ul>
|
||||
** When several apply, the status shown is the first in that order; the alerts are
|
||||
** independent, one per condition. Once a day it also lists each plan's prefix in
|
||||
** the bucket and reports orphans (run prefixes that are not in the catalog) and
|
||||
** debris (prefixes with no manifest older than 24 hours): look at the bucket, not
|
||||
** at the job.
|
||||
*******************************************************************************/
|
||||
public class SlaChecker
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(SlaChecker.class);
|
||||
|
||||
public static final String OK = "OK";
|
||||
public static final String LATE = "LATE";
|
||||
public static final String FAILING = "FAILING";
|
||||
public static final String MISSING_IN_STORAGE = "MISSING_IN_STORAGE";
|
||||
public static final String UNVERIFIED = "UNVERIFIED";
|
||||
public static final String ABANDONED = "ABANDONED";
|
||||
public static final String OWNER_INVALID = "OWNER_INVALID";
|
||||
public static final String UNKNOWN = "UNKNOWN";
|
||||
|
||||
/** A RUNNING run whose heartbeat is older than this is abandoned. */
|
||||
public static final Duration STALE_HEARTBEAT = Duration.ofMinutes(10);
|
||||
|
||||
/** How long a successful manifest head is trusted. */
|
||||
public static final Duration MANIFEST_CACHE = Duration.ofHours(1);
|
||||
|
||||
/** How often the bucket is listed for orphans and debris. */
|
||||
public static final Duration BUCKET_AUDIT_INTERVAL = Duration.ofHours(24);
|
||||
|
||||
/** A prefix with no manifest older than this is debris. */
|
||||
public static final Duration DEBRIS_AGE = Duration.ofHours(24);
|
||||
|
||||
private static final Set<String> FAILED_STATUSES = Set.of("FAILED", "TIMED_OUT", "ABANDONED");
|
||||
private static final Set<String> FINISHED_STATUSES = Set.of("SUCCEEDED", "SUCCEEDED_WITH_WARNINGS", "FAILED", "TIMED_OUT", "ABANDONED");
|
||||
|
||||
private static final Map<String, Instant> MANIFEST_OK_AT = new ConcurrentHashMap<>();
|
||||
private static final Map<String, Instant> LAST_AUDIT_AT = new ConcurrentHashMap<>();
|
||||
|
||||
private final BackupCatalog catalog = new BackupCatalog();
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The outcome of checking one plan.
|
||||
**
|
||||
** @param planId the plan id
|
||||
** @param planKey the plan key
|
||||
** @param previousStatus slaStatus before the check
|
||||
** @param status slaStatus now
|
||||
** @param abandonedRuns runs marked ABANDONED by this check
|
||||
** @param orphans orphan run prefixes found by the bucket audit (-1 when the audit did not run)
|
||||
** @param debris debris prefixes found by the bucket audit (-1 when the audit did not run)
|
||||
***************************************************************************/
|
||||
public record PlanResult(Integer planId, String planKey, String previousStatus, String status, int abandonedRuns, int orphans, int debris)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Forget the cached manifest heads and bucket audit times (tests).
|
||||
*******************************************************************************/
|
||||
public static void resetCaches()
|
||||
{
|
||||
MANIFEST_OK_AT.clear();
|
||||
LAST_AUDIT_AT.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Check every active plan.
|
||||
*******************************************************************************/
|
||||
public List<PlanResult> checkAll() throws QException
|
||||
{
|
||||
List<PlanResult> results = new ArrayList<>();
|
||||
for(QRecord plan : catalog.query(BackupsTables.PLAN, new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true))))
|
||||
{
|
||||
try
|
||||
{
|
||||
results.add(check(plan));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("The SLA check of a plan failed", e, logPair("plan", plan.getValueString("planKey")));
|
||||
}
|
||||
}
|
||||
return (results);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Check one plan: mark stale runs, compute the status, update the plan, raise and
|
||||
** resolve alerts, and (once a day) audit its prefix in the bucket.
|
||||
*******************************************************************************/
|
||||
public PlanResult check(QRecord plan) throws QException
|
||||
{
|
||||
Instant now = BackupsRuntime.getClock().instant();
|
||||
Integer planId = plan.getValueInteger("id");
|
||||
String planKey = plan.getValueString("planKey");
|
||||
boolean critical = Boolean.TRUE.equals(plan.getValueBoolean("critical"));
|
||||
|
||||
List<QRecord> runs = catalog.where(BackupsTables.RUN, "backupPlanId", planId);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// a RUNNING run that stopped reporting is abandoned (pod died etc) //
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
int abandonedNow = 0;
|
||||
for(QRecord run : runs)
|
||||
{
|
||||
if("RUNNING".equals(run.getValueString("status")))
|
||||
{
|
||||
Instant lastSeen = run.getValueInstant("heartbeatDate") != null ? run.getValueInstant("heartbeatDate") : run.getValueInstant("startDate");
|
||||
if(lastSeen != null && lastSeen.isBefore(now.minus(STALE_HEARTBEAT)))
|
||||
{
|
||||
Map<String, Serializable> update = new LinkedHashMap<>();
|
||||
update.put("status", "ABANDONED");
|
||||
update.put("endDate", now);
|
||||
update.put("errorMessage", "The run stopped reporting a heartbeat (the process or pod probably died); resume it or run the plan again");
|
||||
catalog.update(BackupsTables.RUN, run.getValueInteger("id"), update);
|
||||
abandonedNow++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(abandonedNow > 0)
|
||||
{
|
||||
runs = catalog.where(BackupsTables.RUN, "backupPlanId", planId);
|
||||
}
|
||||
|
||||
List<QRecord> finished = runs.stream().filter(r -> FINISHED_STATUSES.contains(r.getValueString("status"))).toList();
|
||||
boolean latestFailed = !finished.isEmpty() && FAILED_STATUSES.contains(finished.get(0).getValueString("status"));
|
||||
boolean failing = finished.size() >= 2 && latestFailed && FAILED_STATUSES.contains(finished.get(1).getValueString("status"));
|
||||
boolean abandoned = abandonedNow > 0 || (!finished.isEmpty() && "ABANDONED".equals(finished.get(0).getValueString("status")));
|
||||
|
||||
boolean missing = isManifestMissing(plan, now);
|
||||
boolean late = isLate(plan, now);
|
||||
boolean ownerBad = isOwnerInvalid(plan);
|
||||
boolean unverified = isUnverified(plan, now);
|
||||
|
||||
String status;
|
||||
if(missing)
|
||||
{
|
||||
status = MISSING_IN_STORAGE;
|
||||
}
|
||||
else if(failing)
|
||||
{
|
||||
status = FAILING;
|
||||
}
|
||||
else if(abandoned)
|
||||
{
|
||||
status = ABANDONED;
|
||||
}
|
||||
else if(late)
|
||||
{
|
||||
status = LATE;
|
||||
}
|
||||
else if(ownerBad)
|
||||
{
|
||||
status = OWNER_INVALID;
|
||||
}
|
||||
else if(unverified)
|
||||
{
|
||||
status = UNVERIFIED;
|
||||
}
|
||||
else if(plan.getValueInstant("lastSuccessDate") == null)
|
||||
{
|
||||
status = UNKNOWN;
|
||||
}
|
||||
else
|
||||
{
|
||||
status = OK;
|
||||
}
|
||||
|
||||
String previous = plan.getValueString("slaStatus");
|
||||
if(!status.equals(previous))
|
||||
{
|
||||
catalog.update(BackupsTables.PLAN, planId, Map.of("slaStatus", status));
|
||||
}
|
||||
|
||||
signal(plan, planKey, critical, missing, late, ownerBad, unverified, latestFailed, failing, abandoned, finished.isEmpty() ? null : finished.get(0));
|
||||
|
||||
int[] audit = auditBucket(plan, planKey, runs, now);
|
||||
return (new PlanResult(planId, planKey, previous, status, abandonedNow, audit[0], audit[1]));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Raise and resolve the plan's alerts, one per condition.
|
||||
*******************************************************************************/
|
||||
private void signal(QRecord plan, String planKey, boolean critical, boolean missing, boolean late, boolean ownerBad, boolean unverified,
|
||||
boolean latestFailed, boolean failing, boolean abandoned, QRecord latestFinished)
|
||||
{
|
||||
String name = plan.getValueString("name") == null ? planKey : plan.getValueString("name");
|
||||
|
||||
if(missing)
|
||||
{
|
||||
BackupAlerts.raise(BackupAlerts.RUN_MISSING, planKey, BackupAlertSink.Severity.CRITICAL, "The latest backup is missing from storage",
|
||||
"plan", planKey, "planName", name);
|
||||
}
|
||||
else
|
||||
{
|
||||
BackupAlerts.resolve(BackupAlerts.RUN_MISSING, planKey);
|
||||
}
|
||||
|
||||
if(late)
|
||||
{
|
||||
BackupAlerts.raise(BackupAlerts.PLAN_MISSED, planKey, critical ? BackupAlertSink.Severity.CRITICAL : BackupAlertSink.Severity.ERROR,
|
||||
"A backup did not run in time", "plan", planKey, "planName", name, "slaHours", String.valueOf(slaHours(plan)));
|
||||
}
|
||||
else
|
||||
{
|
||||
BackupAlerts.resolve(BackupAlerts.PLAN_MISSED, planKey);
|
||||
}
|
||||
|
||||
if(latestFailed || abandoned)
|
||||
{
|
||||
BackupAlerts.raise(BackupAlerts.RUN_FAILED, planKey, failing ? BackupAlertSink.Severity.ERROR : BackupAlertSink.Severity.WARNING,
|
||||
failing ? "Backups have failed twice in a row" : "A backup run failed", "plan", planKey, "planName", name);
|
||||
}
|
||||
else if(latestFinished != null)
|
||||
{
|
||||
BackupAlerts.resolve(BackupAlerts.RUN_FAILED, planKey);
|
||||
}
|
||||
|
||||
if(ownerBad)
|
||||
{
|
||||
BackupAlerts.raise(BackupAlerts.PLAN_OWNER_INVALID, planKey, BackupAlertSink.Severity.WARNING, "The backup plan owner is not valid",
|
||||
"plan", planKey, "planName", name);
|
||||
}
|
||||
else
|
||||
{
|
||||
BackupAlerts.resolve(BackupAlerts.PLAN_OWNER_INVALID, planKey);
|
||||
}
|
||||
|
||||
if(unverified)
|
||||
{
|
||||
BackupAlerts.raise(BackupAlerts.VERIFY_OVERDUE, planKey, BackupAlertSink.Severity.WARNING, "No backup has been verified recently",
|
||||
"plan", planKey, "planName", name, "verifySlaDays", String.valueOf(plan.getValueInteger("verifySlaDays")));
|
||||
}
|
||||
else
|
||||
{
|
||||
BackupAlerts.resolve(BackupAlerts.VERIFY_OVERDUE, planKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The catalog says the last success exists; is its manifest still in the bucket?
|
||||
** A positive answer is trusted for an hour; a negative one is always re-checked.
|
||||
** A storage that cannot be reached does not mean the manifest is gone.
|
||||
*******************************************************************************/
|
||||
private boolean isManifestMissing(QRecord plan, Instant now) throws QException
|
||||
{
|
||||
Integer successId = plan.getValueInteger("lastSuccessRunId");
|
||||
if(successId == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
QRecord run = catalog.get(BackupsTables.RUN, successId).orElse(null);
|
||||
if(run == null || run.getValueString("manifestKey") == null || run.getValueString("storageBucketAlias") == null || run.getValueInstant("prunedDate") != null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
|
||||
String cacheKey = run.getValueString("storageBucketAlias") + "|" + run.getValueString("manifestKey");
|
||||
Instant okAt = MANIFEST_OK_AT.get(cacheKey);
|
||||
if(okAt != null && okAt.plus(MANIFEST_CACHE).isAfter(now))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
BackupStorage storage = BackupsRuntime.storageFor(run.getValueString("storageBucketAlias"));
|
||||
if(storage.head(run.getValueString("manifestKey")).isPresent())
|
||||
{
|
||||
MANIFEST_OK_AT.put(cacheKey, now);
|
||||
return (false);
|
||||
}
|
||||
MANIFEST_OK_AT.remove(cacheKey);
|
||||
return (true);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
LOG.warn("Could not check the manifest of the last backup", e, logPair("plan", plan.getValueString("planKey")));
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No success within slaHours. A manual-only plan (no cron) with no slaHours has
|
||||
** no SLA. A new plan gets slaHours to run its first backup.
|
||||
*******************************************************************************/
|
||||
private boolean isLate(QRecord plan, Instant now)
|
||||
{
|
||||
Integer hours = slaHours(plan);
|
||||
if(hours == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
Instant lastSuccess = plan.getValueInstant("lastSuccessDate");
|
||||
Instant reference = lastSuccess != null ? lastSuccess : plan.getValueInstant("createDate");
|
||||
return (reference != null && reference.plus(Duration.ofHours(hours)).isBefore(now));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** slaHours of a plan: its value, else 26 for a scheduled plan, else none.
|
||||
*******************************************************************************/
|
||||
private Integer slaHours(QRecord plan)
|
||||
{
|
||||
if(plan.getValueInteger("slaHours") != null)
|
||||
{
|
||||
return (plan.getValueInteger("slaHours"));
|
||||
}
|
||||
String cron = plan.getValueString("cronExpression");
|
||||
return (cron != null && !cron.isBlank() ? 26 : null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The plan has a successful backup but no passed CONTENT or TEST_RESTORE
|
||||
** verification within verifySlaDays.
|
||||
*******************************************************************************/
|
||||
private boolean isUnverified(QRecord plan, Instant now)
|
||||
{
|
||||
Integer days = plan.getValueInteger("verifySlaDays");
|
||||
if(days == null || plan.getValueInstant("lastSuccessDate") == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
Instant reference = plan.getValueInstant("lastVerifiedDate") != null ? plan.getValueInstant("lastVerifiedDate") : plan.getValueInstant("createDate");
|
||||
return (reference != null && reference.plus(Duration.ofDays(days)).isBefore(now));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The plan owner no longer validates.
|
||||
*******************************************************************************/
|
||||
private boolean isOwnerInvalid(QRecord plan)
|
||||
{
|
||||
BackupsQBitConfig config = BackupsRuntime.getConfig();
|
||||
String owner = plan.getValueString("ownerUserId");
|
||||
if(config == null || config.getPlanOwnerValidator() == null || owner == null || owner.isBlank())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
try
|
||||
{
|
||||
return (!config.getPlanOwnerValidator().test(owner));
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
LOG.warn("The plan owner validator failed", e, logPair("plan", plan.getValueString("planKey")));
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Once a day, list the plan's prefix in the bucket: orphans (run prefixes not in
|
||||
** the catalog) and debris (no manifest, older than 24 hours). Returns
|
||||
* {orphans, debris}, or {-1, -1} when it did not run.
|
||||
*******************************************************************************/
|
||||
private int[] auditBucket(QRecord plan, String planKey, List<QRecord> runs, Instant now)
|
||||
{
|
||||
String alias = plan.getValueString("storageBucketAlias");
|
||||
if(alias == null)
|
||||
{
|
||||
return (new int[] { -1, -1 });
|
||||
}
|
||||
String auditKey = alias + "|" + planKey;
|
||||
Instant last = LAST_AUDIT_AT.get(auditKey);
|
||||
if(last != null && last.plus(BUCKET_AUDIT_INTERVAL).isAfter(now))
|
||||
{
|
||||
return (new int[] { -1, -1 });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
BackupStorage storage = BackupsRuntime.storageFor(alias);
|
||||
String planPrefix = planPrefix(plan, planKey);
|
||||
|
||||
Set<String> known = new HashSet<>();
|
||||
Set<String> active = new HashSet<>();
|
||||
for(QRecord run : runs)
|
||||
{
|
||||
known.add(run.getValueString("runPrefix"));
|
||||
if("RUNNING".equals(run.getValueString("status")) || "QUEUED".equals(run.getValueString("status")))
|
||||
{
|
||||
active.add(run.getValueString("runPrefix"));
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Boolean> hasManifest = new LinkedHashMap<>();
|
||||
Map<String, Instant> newest = new LinkedHashMap<>();
|
||||
for(StoredObjectInfo object : storage.list(planPrefix))
|
||||
{
|
||||
String[] parts = object.key().substring(planPrefix.length()).split("/");
|
||||
if(parts.length < 5)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String runPrefix = planPrefix + parts[0] + "/" + parts[1] + "/" + parts[2] + "/" + parts[3] + "/";
|
||||
hasManifest.merge(runPrefix, "manifest.json".equals(parts[4]), Boolean::logicalOr);
|
||||
if(object.lastModified() != null)
|
||||
{
|
||||
newest.merge(runPrefix, object.lastModified(), (a, b) -> a.isAfter(b) ? a : b);
|
||||
}
|
||||
}
|
||||
|
||||
int orphans = 0;
|
||||
int debris = 0;
|
||||
for(Map.Entry<String, Boolean> entry : hasManifest.entrySet())
|
||||
{
|
||||
String runPrefix = entry.getKey();
|
||||
if(active.contains(runPrefix))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(!known.contains(runPrefix))
|
||||
{
|
||||
orphans++;
|
||||
}
|
||||
Instant modified = newest.get(runPrefix);
|
||||
if(!entry.getValue() && modified != null && modified.plus(DEBRIS_AGE).isBefore(now))
|
||||
{
|
||||
debris++;
|
||||
}
|
||||
}
|
||||
|
||||
LAST_AUDIT_AT.put(auditKey, now);
|
||||
if(orphans + debris > 0)
|
||||
{
|
||||
BackupAlerts.raise(BackupAlerts.STORAGE_DEBRIS, planKey, BackupAlertSink.Severity.INFO, "Backup storage has orphans or debris",
|
||||
"plan", planKey, "orphans", String.valueOf(orphans), "debris", String.valueOf(debris));
|
||||
}
|
||||
else
|
||||
{
|
||||
BackupAlerts.resolve(BackupAlerts.STORAGE_DEBRIS, planKey);
|
||||
}
|
||||
return (new int[] { orphans, debris });
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
LOG.warn("The bucket audit failed", e, logPair("plan", planKey));
|
||||
return (new int[] { -1, -1 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The prefix of a plan's runs in its bucket, the same way the backup engine
|
||||
** builds it: (plan prefix)/(instance name)/(planKey)/.
|
||||
*******************************************************************************/
|
||||
static String planPrefix(QRecord plan, String planKey)
|
||||
{
|
||||
BackupsQBitConfig config = BackupsRuntime.getConfig();
|
||||
String instance = config != null && config.getInstanceName() != null ? config.getInstanceName() : "qqq";
|
||||
String prefix = plan.getValueString("prefix") != null ? plan.getValueString("prefix") : "qqq-backups";
|
||||
while(prefix.startsWith("/"))
|
||||
{
|
||||
prefix = prefix.substring(1);
|
||||
}
|
||||
while(prefix.endsWith("/"))
|
||||
{
|
||||
prefix = prefix.substring(0, prefix.length() - 1);
|
||||
}
|
||||
return (prefix + "/" + instance + "/" + planKey + "/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* 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.storage;
|
||||
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The narrow storage API that the Backups qbit needs. It mirrors the
|
||||
** qbit-object-storage bucket API (put, get, head, list, copy, delete, presign),
|
||||
** so the production implementation is a thin adapter over a StorageBucket, and
|
||||
** tests use InMemoryBackupStorage. Keys are relative to the bucket.
|
||||
**
|
||||
** All methods throw BackupStorageException (a RuntimeException) on failure.
|
||||
*******************************************************************************/
|
||||
public interface BackupStorage
|
||||
{
|
||||
|
||||
/***************************************************************************
|
||||
** Open a stream to write an object of unknown length. The object becomes
|
||||
** visible when the stream is closed successfully.
|
||||
***************************************************************************/
|
||||
OutputStream openWrite(String key, WriteOptions options);
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Open a stream over an object.
|
||||
***************************************************************************/
|
||||
InputStream openRead(String key);
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Existence and size.
|
||||
***************************************************************************/
|
||||
Optional<StoredObjectInfo> head(String key);
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** All objects with the given key prefix (recursive, in key order).
|
||||
***************************************************************************/
|
||||
List<StoredObjectInfo> list(String prefix);
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Server-side copy within the same bucket (or connection).
|
||||
***************************************************************************/
|
||||
void copy(String sourceKey, String destinationKey);
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Permanently delete an object, every version. Object-locked objects are
|
||||
** not deleted (DeleteResult.LOCKED); a missing object is NOT_FOUND.
|
||||
***************************************************************************/
|
||||
DeleteResult deletePermanent(String key);
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A time-limited download link.
|
||||
***************************************************************************/
|
||||
String presignGet(String key, Duration ttl, String downloadName);
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** What the bucket can do (object lock, server-side copy, ...).
|
||||
***************************************************************************/
|
||||
Set<StorageCapability> capabilities();
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Result of a permanent delete.
|
||||
***************************************************************************/
|
||||
enum DeleteResult
|
||||
{
|
||||
DELETED, NOT_FOUND, LOCKED
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Capabilities, as probed by the storage qbit.
|
||||
***************************************************************************/
|
||||
enum StorageCapability
|
||||
{
|
||||
OBJECT_LOCK, VERSIONING, SERVER_SIDE_COPY, MULTIPART
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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.storage;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A storage failure, with a reason that mirrors the StorageException reasons
|
||||
** of the object-storage qbit.
|
||||
*******************************************************************************/
|
||||
public class BackupStorageException extends RuntimeException
|
||||
{
|
||||
private final Reason reason;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public BackupStorageException(Reason reason, String message)
|
||||
{
|
||||
super(message);
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor with a cause.
|
||||
*******************************************************************************/
|
||||
public BackupStorageException(Reason reason, String message, Throwable cause)
|
||||
{
|
||||
super(message, cause);
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for reason
|
||||
*******************************************************************************/
|
||||
public Reason getReason()
|
||||
{
|
||||
return (reason);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Failure reasons.
|
||||
***************************************************************************/
|
||||
public enum Reason
|
||||
{
|
||||
NOT_FOUND, ACCESS_DENIED, UNSUPPORTED, PRECONDITION_FAILED, THROTTLED, PROVIDER_ERROR
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/*
|
||||
* 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.storage;
|
||||
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A BackupStorage held in memory. It is the test implementation and a
|
||||
** stand-in for local development. It supports object lock, and has hooks for
|
||||
** failure injection (a predicate over keys that makes a write fail).
|
||||
*******************************************************************************/
|
||||
public class InMemoryBackupStorage implements BackupStorage
|
||||
{
|
||||
private final TreeMap<String, Entry> objects = new TreeMap<>();
|
||||
|
||||
private Clock clock = Clock.systemUTC();
|
||||
private Predicate<String> failWritesFor;
|
||||
private Set<StorageCapability> capabilities = EnumSet.of(StorageCapability.OBJECT_LOCK, StorageCapability.SERVER_SIDE_COPY, StorageCapability.MULTIPART);
|
||||
|
||||
private record Entry(byte[] bytes, Map<String, String> metadata, Instant lastModified, Instant lockedUntil)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use a fixed clock (for object lock and last modified in tests).
|
||||
*******************************************************************************/
|
||||
public InMemoryBackupStorage withClock(Clock clock)
|
||||
{
|
||||
this.clock = clock;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Make writes of matching keys fail with a provider error (failure injection).
|
||||
*******************************************************************************/
|
||||
public InMemoryBackupStorage withFailWritesFor(Predicate<String> failWritesFor)
|
||||
{
|
||||
this.failWritesFor = failWritesFor;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set the capabilities this storage reports.
|
||||
*******************************************************************************/
|
||||
public InMemoryBackupStorage withCapabilities(Set<StorageCapability> capabilities)
|
||||
{
|
||||
this.capabilities = capabilities;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test helper: remove everything.
|
||||
*******************************************************************************/
|
||||
public synchronized void clear()
|
||||
{
|
||||
objects.clear();
|
||||
failWritesFor = null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test helper: put bytes directly.
|
||||
*******************************************************************************/
|
||||
public synchronized void putBytes(String key, byte[] bytes)
|
||||
{
|
||||
objects.put(key, new Entry(bytes, new LinkedHashMap<>(), clock.instant(), null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test helper: read bytes directly.
|
||||
*******************************************************************************/
|
||||
public synchronized byte[] getBytes(String key)
|
||||
{
|
||||
Entry entry = objects.get(key);
|
||||
return (entry == null ? null : entry.bytes());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test helper: remove an object ignoring any lock (simulating an out-of-band delete).
|
||||
*******************************************************************************/
|
||||
public synchronized void removeIgnoringLock(String key)
|
||||
{
|
||||
objects.remove(key);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test helper: replace an object bytes (simulating corruption).
|
||||
*******************************************************************************/
|
||||
public synchronized void corrupt(String key, byte[] bytes)
|
||||
{
|
||||
Entry old = objects.get(key);
|
||||
objects.put(key, new Entry(bytes, old == null ? new LinkedHashMap<>() : old.metadata(), clock.instant(), old == null ? null : old.lockedUntil()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public OutputStream openWrite(String key, WriteOptions options)
|
||||
{
|
||||
if(failWritesFor != null && failWritesFor.test(key))
|
||||
{
|
||||
throw (new BackupStorageException(BackupStorageException.Reason.PROVIDER_ERROR, "injected write failure for " + key));
|
||||
}
|
||||
if(options.getObjectLockMode() != null && !capabilities.contains(StorageCapability.OBJECT_LOCK))
|
||||
{
|
||||
throw (new BackupStorageException(BackupStorageException.Reason.UNSUPPORTED, "object lock is not supported"));
|
||||
}
|
||||
|
||||
return (new ByteArrayOutputStream()
|
||||
{
|
||||
private boolean closed = false;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** commit on close
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void close() throws IOException
|
||||
{
|
||||
super.close();
|
||||
if(!closed)
|
||||
{
|
||||
closed = true;
|
||||
synchronized(InMemoryBackupStorage.this)
|
||||
{
|
||||
objects.put(key, new Entry(toByteArray(), new LinkedHashMap<>(options.getMetadata()), clock.instant(), options.getRetainUntil()));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized InputStream openRead(String key)
|
||||
{
|
||||
Entry entry = objects.get(key);
|
||||
if(entry == null)
|
||||
{
|
||||
throw (new BackupStorageException(BackupStorageException.Reason.NOT_FOUND, "no such object: " + key));
|
||||
}
|
||||
return (new ByteArrayInputStream(entry.bytes()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized Optional<StoredObjectInfo> head(String key)
|
||||
{
|
||||
Entry entry = objects.get(key);
|
||||
return (entry == null ? Optional.empty() : Optional.of(info(key, entry)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized List<StoredObjectInfo> list(String prefix)
|
||||
{
|
||||
List<StoredObjectInfo> rs = new ArrayList<>();
|
||||
for(Map.Entry<String, Entry> e : objects.tailMap(prefix == null ? "" : prefix).entrySet())
|
||||
{
|
||||
if(prefix != null && !e.getKey().startsWith(prefix))
|
||||
{
|
||||
break;
|
||||
}
|
||||
rs.add(info(e.getKey(), e.getValue()));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized void copy(String sourceKey, String destinationKey)
|
||||
{
|
||||
Entry entry = objects.get(sourceKey);
|
||||
if(entry == null)
|
||||
{
|
||||
throw (new BackupStorageException(BackupStorageException.Reason.NOT_FOUND, "no such object: " + sourceKey));
|
||||
}
|
||||
objects.put(destinationKey, new Entry(entry.bytes(), entry.metadata(), clock.instant(), null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized DeleteResult deletePermanent(String key)
|
||||
{
|
||||
Entry entry = objects.get(key);
|
||||
if(entry == null)
|
||||
{
|
||||
return (DeleteResult.NOT_FOUND);
|
||||
}
|
||||
if(entry.lockedUntil() != null && entry.lockedUntil().isAfter(clock.instant()))
|
||||
{
|
||||
return (DeleteResult.LOCKED);
|
||||
}
|
||||
objects.remove(key);
|
||||
return (DeleteResult.DELETED);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String presignGet(String key, Duration ttl, String downloadName)
|
||||
{
|
||||
return ("memory://" + key + "?ttl=" + ttl.toSeconds());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Set<StorageCapability> capabilities()
|
||||
{
|
||||
return (capabilities);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private StoredObjectInfo info(String key, Entry entry)
|
||||
{
|
||||
return (new StoredObjectInfo(key, entry.bytes().length, entry.lastModified(), entry.metadata(), entry.lockedUntil()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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.storage;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What the bucket says about one object.
|
||||
**
|
||||
** @param key key relative to the bucket
|
||||
** @param size size in bytes
|
||||
** @param lastModified last modified instant (may be null)
|
||||
** @param metadata user metadata
|
||||
** @param lockedUntil object-lock retain-until, or null
|
||||
*******************************************************************************/
|
||||
public record StoredObjectInfo(String key, long size, Instant lastModified, Map<String, String> metadata, Instant lockedUntil)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* 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.storage;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Options for writing one object.
|
||||
*******************************************************************************/
|
||||
public class WriteOptions
|
||||
{
|
||||
private String contentType = "application/octet-stream";
|
||||
private Map<String, String> metadata = new LinkedHashMap<>();
|
||||
private String objectLockMode;
|
||||
private Instant retainUntil;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Start with defaults.
|
||||
*******************************************************************************/
|
||||
public static WriteOptions create()
|
||||
{
|
||||
return (new WriteOptions());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for contentType
|
||||
*******************************************************************************/
|
||||
public String getContentType()
|
||||
{
|
||||
return (contentType);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for contentType
|
||||
*******************************************************************************/
|
||||
public WriteOptions withContentType(String contentType)
|
||||
{
|
||||
this.contentType = contentType;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for metadata (user metadata, which describes the object even without the catalog)
|
||||
*******************************************************************************/
|
||||
public Map<String, String> getMetadata()
|
||||
{
|
||||
return (metadata);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add one metadata entry.
|
||||
*******************************************************************************/
|
||||
public WriteOptions withMetadata(String key, String value)
|
||||
{
|
||||
this.metadata.put(key, value);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for objectLockMode (GOVERNANCE or COMPLIANCE), null for none
|
||||
*******************************************************************************/
|
||||
public String getObjectLockMode()
|
||||
{
|
||||
return (objectLockMode);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for retainUntil
|
||||
*******************************************************************************/
|
||||
public Instant getRetainUntil()
|
||||
{
|
||||
return (retainUntil);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Request an object lock on the written object.
|
||||
*******************************************************************************/
|
||||
public WriteOptions withObjectLock(String mode, Instant retainUntil)
|
||||
{
|
||||
this.objectLockMode = mode;
|
||||
this.retainUntil = retainUntil;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
/*
|
||||
* 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.verify;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.io.Serializable;
|
||||
import java.security.DigestInputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.backups.BackupsQBitConfig;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.alerts.BackupAlerts;
|
||||
import com.kingsrook.qbits.backups.alerts.BackupAlertSink;
|
||||
import com.kingsrook.qbits.backups.codec.ArtifactTransform;
|
||||
import com.kingsrook.qbits.backups.codec.HashingOutputStream;
|
||||
import com.kingsrook.qbits.backups.codec.JsonlRowCodec;
|
||||
import com.kingsrook.qbits.backups.manifest.BackupManifest;
|
||||
import com.kingsrook.qbits.backups.manifest.TableFingerprints;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qbits.backups.storage.BackupStorage;
|
||||
import com.kingsrook.qbits.backups.storage.BackupStorageException;
|
||||
import com.kingsrook.qbits.backups.storage.StoredObjectInfo;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Verifies backup runs (design spec section 10.1). Three levels, each a row in
|
||||
** backups_verification:
|
||||
** <ul>
|
||||
** <li>STORAGE: head every object of the run, including every file-store object
|
||||
** the run's file index references; sizes (and a provider checksum, where the
|
||||
** provider stores one) must match the manifest.</li>
|
||||
** <li>CONTENT: read every byte. Each part is decrypted (the decrypt hook,
|
||||
** ArtifactTransform.unwrap), gunzipped and parsed row by row with the codec;
|
||||
** row counts, sha256Stored, sha256Raw and the table rowsHash are recomputed and
|
||||
** compared with the manifest. Every referenced file-store object is decrypted
|
||||
** and re-hashed against its SHA-256 key, except those verified within
|
||||
** fileReverifyDays (backups_file_object.lastVerifiedDate).</li>
|
||||
** <li>TEST_RESTORE: see {@link TestRestoreVerifier}.</li>
|
||||
** </ul>
|
||||
** The result updates backups_run.verificationStatus and backups_plan.lastVerified*,
|
||||
** and a failure raises backups.verify.failed. A mismatch is a FAILED verification;
|
||||
** a verification that could not run (storage unreachable, no key) is an ERROR.
|
||||
*******************************************************************************/
|
||||
public class BackupVerifier
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(BackupVerifier.class);
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
public static final String STORAGE = "STORAGE";
|
||||
public static final String CONTENT = "CONTENT";
|
||||
public static final String TEST_RESTORE = "TEST_RESTORE";
|
||||
|
||||
/** The levels in order; each includes the ones before it when run through. */
|
||||
public static final List<String> LEVELS = List.of(STORAGE, CONTENT, TEST_RESTORE);
|
||||
|
||||
private static final List<String> STATUS_RANK = List.of("UNVERIFIED", "STORAGE_OK", "CONTENT_OK", "TEST_RESTORE_OK");
|
||||
|
||||
private final BackupCatalog catalog = new BackupCatalog();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Verify a run at each level up to and including the given one, stopping at the
|
||||
** first level that does not pass. Returns one outcome per level that ran.
|
||||
*******************************************************************************/
|
||||
public List<VerificationOutcome> verifyThrough(Integer runId, String maxLevel, String trigger, String requestedBy) throws QException
|
||||
{
|
||||
List<VerificationOutcome> outcomes = new ArrayList<>();
|
||||
for(String level : LEVELS.subList(0, LEVELS.indexOf(maxLevel) + 1))
|
||||
{
|
||||
VerificationOutcome outcome = verify(runId, level, trigger, requestedBy);
|
||||
outcomes.add(outcome);
|
||||
if(!outcome.passed())
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
return (outcomes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Verify a run at one level. Never throws for a backup problem: the outcome
|
||||
** carries the status and the first messages.
|
||||
*******************************************************************************/
|
||||
public VerificationOutcome verify(Integer runId, String level, String trigger, String requestedBy) throws QException
|
||||
{
|
||||
if(!LEVELS.contains(level))
|
||||
{
|
||||
throw (new QException("Unknown verification level: " + level));
|
||||
}
|
||||
QRecord run = catalog.get(BackupsTables.RUN, runId).orElseThrow(() -> new QException("No backup run with id " + runId));
|
||||
QRecord plan = run.getValueInteger("backupPlanId") == null ? null : catalog.get(BackupsTables.PLAN, run.getValueInteger("backupPlanId")).orElse(null);
|
||||
|
||||
Instant started = BackupsRuntime.getClock().instant();
|
||||
Map<String, Serializable> row = new LinkedHashMap<>();
|
||||
row.put("backupRunId", runId);
|
||||
row.put("level", level);
|
||||
row.put("trigger", trigger);
|
||||
row.put("requestedByUserId", requestedBy);
|
||||
row.put("startDate", started);
|
||||
row.put("status", "RUNNING");
|
||||
Integer verificationId = catalog.insert(BackupsTables.VERIFICATION, row);
|
||||
|
||||
VerifyFindings findings = new VerifyFindings();
|
||||
String status;
|
||||
String scratch = null;
|
||||
try
|
||||
{
|
||||
RunBundle bundle = RunBundle.load(run, plan, findings);
|
||||
if(bundle != null)
|
||||
{
|
||||
switch(level)
|
||||
{
|
||||
case STORAGE -> storageLevel(bundle, findings);
|
||||
case CONTENT -> contentLevel(bundle, findings);
|
||||
default -> scratch = new TestRestoreVerifier().verify(bundle, findings);
|
||||
}
|
||||
}
|
||||
status = findings.status();
|
||||
}
|
||||
catch(VerifyException | BackupStorageException | QException e)
|
||||
{
|
||||
status = "ERROR";
|
||||
findings.mismatch("The verification could not be carried out: " + e.getMessage());
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
LOG.warn("Verification crashed", e, logPair("runId", runId), logPair("level", level));
|
||||
status = "ERROR";
|
||||
findings.mismatch("The verification crashed: " + e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||
}
|
||||
|
||||
Instant ended = BackupsRuntime.getClock().instant();
|
||||
VerificationOutcome outcome = new VerificationOutcome(verificationId, runId, level, status, findings.tablesChecked, findings.rowsChecked, findings.mismatches,
|
||||
findings.objectsChecked, findings.filesChecked, findings.filesSkipped, findings.messages(), scratch);
|
||||
|
||||
Map<String, Serializable> done = new LinkedHashMap<>();
|
||||
done.put("endDate", ended);
|
||||
done.put("status", status);
|
||||
done.put("tablesChecked", findings.tablesChecked);
|
||||
done.put("rowsChecked", findings.rowsChecked);
|
||||
done.put("mismatches", findings.mismatches);
|
||||
done.put("scratchTarget", scratch);
|
||||
done.put("detailJson", detailJson(findings));
|
||||
catalog.update(BackupsTables.VERIFICATION, verificationId, done);
|
||||
|
||||
applyOutcome(run, plan, outcome, ended);
|
||||
return (outcome);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STORAGE level: head every object, including every file-store object.
|
||||
*******************************************************************************/
|
||||
void storageLevel(RunBundle bundle, VerifyFindings findings)
|
||||
{
|
||||
BackupManifest manifest = bundle.manifest();
|
||||
BackupStorage storage = bundle.storage();
|
||||
findings.tablesChecked = manifest.tables().size();
|
||||
findings.objectsChecked++;
|
||||
|
||||
headOrMismatch(storage, bundle.runPrefix() + "checksums.sha256", "checksums.sha256", findings);
|
||||
|
||||
for(BackupManifest.TableEntry table : manifest.tables())
|
||||
{
|
||||
for(BackupManifest.Part part : table.parts() == null ? List.<BackupManifest.Part>of() : table.parts())
|
||||
{
|
||||
findings.objectsChecked++;
|
||||
Optional<StoredObjectInfo> head = storage.head(bundle.runPrefix() + part.key());
|
||||
if(head.isEmpty())
|
||||
{
|
||||
findings.mismatch("Missing object: " + part.key() + " (table " + table.name() + ")");
|
||||
continue;
|
||||
}
|
||||
if(head.get().size() != part.bytesStored())
|
||||
{
|
||||
findings.mismatch("Size mismatch for " + part.key() + ": the manifest says " + part.bytesStored() + " bytes, the bucket has " + head.get().size());
|
||||
}
|
||||
String providerSha = head.get().metadata() == null ? null : head.get().metadata().get("sha256");
|
||||
if(providerSha != null && !providerSha.equalsIgnoreCase(part.sha256Stored()))
|
||||
{
|
||||
findings.mismatch("Checksum mismatch for " + part.key() + ": the provider's stored SHA-256 differs from the manifest");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(manifest.files() != null && manifest.files().indexKeys() != null)
|
||||
{
|
||||
for(String indexKey : manifest.files().indexKeys())
|
||||
{
|
||||
headOrMismatch(storage, bundle.runPrefix() + indexKey, indexKey, findings);
|
||||
}
|
||||
}
|
||||
|
||||
int missingFiles = 0;
|
||||
for(RunBundle.FileIndexEntry entry : bundle.distinctStoredFiles(findings))
|
||||
{
|
||||
findings.objectsChecked++;
|
||||
findings.filesChecked++;
|
||||
Optional<StoredObjectInfo> head = storage.head(entry.casKey());
|
||||
if(head.isEmpty())
|
||||
{
|
||||
missingFiles++;
|
||||
findings.mismatch("File store object missing: sha256 " + entry.sha256() + " (referenced by " + entry.table() + ")");
|
||||
}
|
||||
else if(!bundle.isEncrypted() && entry.size() >= 0 && head.get().size() != entry.size())
|
||||
{
|
||||
findings.mismatch("File store object has the wrong size: sha256 " + entry.sha256() + " should be " + entry.size() + " bytes, the bucket has " + head.get().size());
|
||||
}
|
||||
}
|
||||
findings.extra.put("filesMissingFromStore", missingFiles);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** CONTENT level: decrypt, gunzip, parse, recount and re-hash everything.
|
||||
*******************************************************************************/
|
||||
void contentLevel(RunBundle bundle, VerifyFindings findings) throws QException
|
||||
{
|
||||
BackupManifest manifest = bundle.manifest();
|
||||
QInstance qInstance = QContext.getQInstance();
|
||||
findings.tablesChecked = manifest.tables().size();
|
||||
|
||||
checkChecksumsFile(bundle, findings);
|
||||
|
||||
for(BackupManifest.TableEntry entry : manifest.tables())
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable(entry.name());
|
||||
JsonlRowCodec codec = null;
|
||||
boolean roundTrip = false;
|
||||
if(table == null)
|
||||
{
|
||||
findings.warn("Table " + entry.name() + " no longer exists in this instance; its rows were checked as JSON, not with the codec");
|
||||
}
|
||||
else
|
||||
{
|
||||
List<String> excluded = entry.excludedFields() == null ? List.of() : entry.excludedFields().stream().map(BackupManifest.ExcludedField::name).toList();
|
||||
codec = new JsonlRowCodec(table, excluded);
|
||||
String backendType = qInstance.getBackend(table.getBackendName()) == null ? null : qInstance.getBackend(table.getBackendName()).getBackendType();
|
||||
roundTrip = TableFingerprints.of(table, backendType).equals(entry.fingerprint());
|
||||
if(!roundTrip)
|
||||
{
|
||||
findings.warn("Table " + entry.name() + " has changed since this backup; rows were parsed but not checked for an exact round trip");
|
||||
}
|
||||
}
|
||||
verifyTable(bundle, entry, codec, roundTrip, findings);
|
||||
}
|
||||
|
||||
verifyFiles(bundle, findings);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All parts of one table.
|
||||
*******************************************************************************/
|
||||
private void verifyTable(RunBundle bundle, BackupManifest.TableEntry entry, JsonlRowCodec codec, boolean roundTrip, VerifyFindings findings)
|
||||
{
|
||||
MessageDigest rowsDigest = HashingOutputStream.newSha256();
|
||||
long rows = 0;
|
||||
boolean hasJsonl = false;
|
||||
int partCount = 0;
|
||||
int before = findings.mismatches;
|
||||
|
||||
for(BackupManifest.Part part : entry.parts() == null ? List.<BackupManifest.Part>of() : entry.parts())
|
||||
{
|
||||
partCount++;
|
||||
findings.objectsChecked++;
|
||||
boolean jsonl = !"CSV".equals(part.format());
|
||||
hasJsonl |= jsonl;
|
||||
long[] parsedRows = { 0 };
|
||||
try
|
||||
{
|
||||
PartScanner.Scan scan = PartScanner.scan(bundle, part, jsonl ? line ->
|
||||
{
|
||||
parsedRows[0]++;
|
||||
rowsDigest.update((line + "\n").getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
checkRow(entry.name(), part.key(), parsedRows[0], line, codec, roundTrip, findings);
|
||||
} : null);
|
||||
|
||||
if(!scan.sha256Stored().equals(part.sha256Stored()))
|
||||
{
|
||||
findings.mismatch("Stored checksum mismatch for " + part.key() + ": the bytes in the bucket are not the bytes that were written (corrupted or replaced)");
|
||||
}
|
||||
if(!scan.sha256Raw().equals(part.sha256Raw()))
|
||||
{
|
||||
findings.mismatch("Content checksum mismatch for " + part.key() + ": the decoded content differs from what was written");
|
||||
}
|
||||
if(jsonl && scan.lines() != part.rows())
|
||||
{
|
||||
findings.mismatch("Row count mismatch for " + part.key() + ": the manifest says " + part.rows() + " rows, the part has " + scan.lines());
|
||||
}
|
||||
if(jsonl)
|
||||
{
|
||||
rows += scan.lines();
|
||||
}
|
||||
}
|
||||
catch(IOException | RuntimeException e)
|
||||
{
|
||||
findings.mismatch("The part " + part.key() + " could not be read, decrypted or decompressed (" + e.getClass().getSimpleName() + (e.getMessage() == null ? "" : ": " + e.getMessage())
|
||||
+ "); it is corrupted, or the key is wrong");
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
findings.mismatch("The part " + part.key() + " could not be read: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
String actualHash = hasJsonl ? "sha256:" + HashingOutputStream.hex(rowsDigest.digest()) : null;
|
||||
if(hasJsonl)
|
||||
{
|
||||
findings.rowsChecked += rows;
|
||||
if(rows != entry.rowCount())
|
||||
{
|
||||
findings.mismatch("Row count mismatch for table " + entry.name() + ": the manifest says " + entry.rowCount() + ", the parts hold " + rows);
|
||||
}
|
||||
if(entry.rowsHash() != null && !entry.rowsHash().equals(actualHash))
|
||||
{
|
||||
findings.mismatch("Row content hash mismatch for table " + entry.name() + ": the rows are not the rows that were backed up");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
findings.warn("Table " + entry.name() + " has only CSV parts; their bytes were hashed but rows were not recounted");
|
||||
}
|
||||
|
||||
Map<String, Object> detail = new LinkedHashMap<>();
|
||||
detail.put("table", entry.name());
|
||||
detail.put("parts", partCount);
|
||||
detail.put("expectedRows", entry.rowCount());
|
||||
detail.put("actualRows", hasJsonl ? rows : null);
|
||||
detail.put("expectedRowsHash", entry.rowsHash());
|
||||
detail.put("actualRowsHash", actualHash);
|
||||
detail.put("ok", findings.mismatches == before);
|
||||
findings.tables.add(detail);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse one row with the codec (and check the exact round trip when the table
|
||||
** is unchanged). Without a table, check only that it is a JSON object.
|
||||
*******************************************************************************/
|
||||
private void checkRow(String table, String partKey, long rowNumber, String line, JsonlRowCodec codec, boolean roundTrip, VerifyFindings findings) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
if(codec == null)
|
||||
{
|
||||
if(!MAPPER.readTree(line).isObject())
|
||||
{
|
||||
throw (new QException("not a JSON object"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
QRecord record = codec.decode(line);
|
||||
if(roundTrip && !codec.encode(record).equals(line))
|
||||
{
|
||||
findings.mismatch("Row " + rowNumber + " of " + partKey + " does not survive a decode and encode round trip (table " + table + ")");
|
||||
}
|
||||
}
|
||||
catch(IOException | QException e)
|
||||
{
|
||||
findings.mismatch("Row " + rowNumber + " of " + partKey + " cannot be parsed (table " + table + ")");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The checksums.sha256 file must list every part with its stored SHA-256.
|
||||
*******************************************************************************/
|
||||
private void checkChecksumsFile(RunBundle bundle, VerifyFindings findings)
|
||||
{
|
||||
String key = bundle.runPrefix() + "checksums.sha256";
|
||||
findings.objectsChecked++;
|
||||
try(InputStream in = bundle.storage().openRead(key))
|
||||
{
|
||||
String text = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
|
||||
for(BackupManifest.TableEntry table : bundle.manifest().tables())
|
||||
{
|
||||
for(BackupManifest.Part part : table.parts() == null ? List.<BackupManifest.Part>of() : table.parts())
|
||||
{
|
||||
if(!text.contains(part.sha256Stored() + " " + part.key()))
|
||||
{
|
||||
findings.mismatch("checksums.sha256 does not list " + part.key() + " with the manifest's checksum");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(BackupStorageException e)
|
||||
{
|
||||
findings.mismatch("checksums.sha256 is missing from the bucket");
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
findings.mismatch("checksums.sha256 cannot be read: " + e.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Re-hash every referenced file-store object against its SHA-256 key, skipping
|
||||
** those verified within fileReverifyDays.
|
||||
*******************************************************************************/
|
||||
private void verifyFiles(RunBundle bundle, VerifyFindings findings) throws QException
|
||||
{
|
||||
BackupsQBitConfig config = BackupsRuntime.getConfig();
|
||||
int days = config == null || config.getFileReverifyDays() == null ? 30 : config.getFileReverifyDays();
|
||||
Instant now = BackupsRuntime.getClock().instant();
|
||||
Instant cutoff = now.minus(Duration.ofDays(days));
|
||||
int failed = 0;
|
||||
|
||||
for(RunBundle.FileIndexEntry entry : bundle.distinctStoredFiles(findings))
|
||||
{
|
||||
QRecord fileObject = catalog.where(BackupsTables.FILE_OBJECT, "sha256", entry.sha256()).stream().filter(r -> r.getValue("deletedDate") == null).findFirst().orElse(null);
|
||||
if(fileObject != null && "PASSED".equals(fileObject.getValueString("lastVerifyStatus"))
|
||||
&& fileObject.getValueInstant("lastVerifiedDate") != null && fileObject.getValueInstant("lastVerifiedDate").isAfter(cutoff))
|
||||
{
|
||||
findings.filesSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
findings.filesChecked++;
|
||||
findings.objectsChecked++;
|
||||
boolean encrypted = fileObject != null ? Boolean.TRUE.equals(fileObject.getValueBoolean("encrypted")) : bundle.isEncrypted();
|
||||
boolean ok = false;
|
||||
try
|
||||
{
|
||||
String actual = hashStoredFile(bundle, entry.casKey(), encrypted);
|
||||
ok = actual.equals(entry.sha256());
|
||||
if(!ok)
|
||||
{
|
||||
findings.mismatch("File store object sha256 " + entry.sha256() + " has the wrong content: re-hashing it gives " + actual);
|
||||
}
|
||||
}
|
||||
catch(BackupStorageException e)
|
||||
{
|
||||
findings.mismatch("File store object missing: sha256 " + entry.sha256() + " (referenced by " + entry.table() + ")");
|
||||
}
|
||||
catch(IOException | RuntimeException e)
|
||||
{
|
||||
findings.mismatch("File store object sha256 " + entry.sha256() + " could not be read or decrypted (" + e.getClass().getSimpleName() + ")");
|
||||
}
|
||||
|
||||
if(!ok)
|
||||
{
|
||||
failed++;
|
||||
}
|
||||
if(fileObject != null)
|
||||
{
|
||||
Map<String, Serializable> update = new LinkedHashMap<>();
|
||||
update.put("lastVerifiedDate", now);
|
||||
update.put("lastVerifyStatus", ok ? "PASSED" : "FAILED");
|
||||
catalog.update(BackupsTables.FILE_OBJECT, fileObject.getValueInteger("id"), update);
|
||||
}
|
||||
}
|
||||
findings.extra.put("filesFailed", failed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SHA-256 of a stored file's plaintext (decrypted when it was encrypted).
|
||||
*******************************************************************************/
|
||||
static String hashStoredFile(RunBundle bundle, String key, boolean encrypted) throws IOException
|
||||
{
|
||||
return (hashObject(bundle.storage(), key, encrypted ? bundle.transform() : ArtifactTransform.IDENTITY));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SHA-256 of an object read through a transform.
|
||||
*******************************************************************************/
|
||||
static String hashObject(BackupStorage storage, String key, ArtifactTransform transform) throws IOException
|
||||
{
|
||||
MessageDigest digest = HashingOutputStream.newSha256();
|
||||
try(InputStream raw = storage.openRead(key);
|
||||
DigestInputStream plain = new DigestInputStream(transform.unwrap(raw), digest))
|
||||
{
|
||||
plain.transferTo(OutputStream.nullOutputStream());
|
||||
}
|
||||
return (HashingOutputStream.hex(digest.digest()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Head an object, recording a mismatch when it is missing.
|
||||
*******************************************************************************/
|
||||
private void headOrMismatch(BackupStorage storage, String key, String label, VerifyFindings findings)
|
||||
{
|
||||
findings.objectsChecked++;
|
||||
if(storage.head(key).isEmpty())
|
||||
{
|
||||
findings.mismatch("Missing object: " + label);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The per-table detail and the messages, as JSON text.
|
||||
*******************************************************************************/
|
||||
private String detailJson(VerifyFindings findings)
|
||||
{
|
||||
Map<String, Object> detail = new LinkedHashMap<>();
|
||||
detail.put("tables", findings.tables);
|
||||
detail.put("mismatches", findings.mismatchMessages);
|
||||
detail.put("warnings", findings.warnings);
|
||||
detail.put("objectsChecked", findings.objectsChecked);
|
||||
detail.put("filesChecked", findings.filesChecked);
|
||||
detail.put("filesSkipped", findings.filesSkipped);
|
||||
detail.putAll(findings.extra);
|
||||
try
|
||||
{
|
||||
return (MAPPER.writeValueAsString(detail));
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
return ("{}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Update the run, the plan and the alerts from an outcome.
|
||||
*******************************************************************************/
|
||||
private void applyOutcome(QRecord run, QRecord plan, VerificationOutcome outcome, Instant when) throws QException
|
||||
{
|
||||
Integer runId = run.getValueInteger("id");
|
||||
String current = run.getValueString("verificationStatus");
|
||||
String newState = null;
|
||||
if(outcome.passed())
|
||||
{
|
||||
String reached = switch(outcome.level())
|
||||
{
|
||||
case STORAGE -> "STORAGE_OK";
|
||||
case CONTENT -> "CONTENT_OK";
|
||||
default -> "TEST_RESTORE_OK";
|
||||
};
|
||||
boolean currentFailed = "FAILED".equals(current);
|
||||
// a cheap pass never clears a failure found by a deeper level
|
||||
if(!currentFailed || !STORAGE.equals(outcome.level()))
|
||||
{
|
||||
newState = !currentFailed && STATUS_RANK.indexOf(current) > STATUS_RANK.indexOf(reached) ? current : reached;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
newState = "FAILED";
|
||||
}
|
||||
if(newState != null && !newState.equals(current))
|
||||
{
|
||||
catalog.update(BackupsTables.RUN, runId, Map.of("verificationStatus", newState));
|
||||
}
|
||||
|
||||
if(plan == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
String planKey = plan.getValueString("planKey");
|
||||
int rank = LEVELS.indexOf(outcome.level());
|
||||
if(outcome.passed())
|
||||
{
|
||||
for(int i = 0; i <= rank; i++)
|
||||
{
|
||||
BackupAlerts.resolveKey(BackupAlerts.VERIFY_FAILED, BackupAlerts.verifyKey(planKey, LEVELS.get(i)));
|
||||
}
|
||||
if(rank >= LEVELS.indexOf(CONTENT))
|
||||
{
|
||||
Map<String, Serializable> planUpdate = new LinkedHashMap<>();
|
||||
planUpdate.put("lastVerifiedRunId", runId);
|
||||
planUpdate.put("lastVerifiedDate", when);
|
||||
catalog.update(BackupsTables.PLAN, plan.getValueInteger("id"), planUpdate);
|
||||
BackupAlerts.resolve(BackupAlerts.VERIFY_OVERDUE, planKey);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Map<String, String> detail = new LinkedHashMap<>();
|
||||
detail.put("plan", planKey);
|
||||
detail.put("run", run.getValueString("runKey"));
|
||||
detail.put("level", outcome.level());
|
||||
detail.put("mismatches", String.valueOf(outcome.mismatches()));
|
||||
BackupAlerts.raiseWithKey(BackupAlerts.VERIFY_FAILED, BackupAlerts.verifyKey(planKey, outcome.level()), BackupAlertSink.Severity.ERROR,
|
||||
"Backup verification failed (" + outcome.level() + ")", detail);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* 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.verify;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The MEMORY scratch target (design spec section 10.1): QQQ's memory backend. It
|
||||
** proves the files and the codecs, not the database. Each source table is cloned
|
||||
** into a temporary in-memory QTableMetaData (a different name, the memory backend,
|
||||
** no customizers, automation, behaviors, unique keys or associations) that is
|
||||
** registered in the instance for the length of the verification and removed by
|
||||
** drop().
|
||||
*******************************************************************************/
|
||||
public class MemoryScratchTarget implements ScratchTarget
|
||||
{
|
||||
public static final String BACKEND_NAME = "qqq_verify_memory";
|
||||
public static final String TYPE = "MEMORY";
|
||||
|
||||
private final Map<String, List<String>> tablesByRun = new LinkedHashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String type()
|
||||
{
|
||||
return (TYPE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String describe(String runKey)
|
||||
{
|
||||
return ("memory:" + prefix(runKey));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized ScratchTable create(QTableMetaData source, String runKey) throws QException
|
||||
{
|
||||
QInstance qInstance = QContext.getQInstance();
|
||||
if(qInstance.getBackend(BACKEND_NAME) == null)
|
||||
{
|
||||
qInstance.addBackend(new QBackendMetaData().withName(BACKEND_NAME).withBackendType(MemoryBackendModule.class));
|
||||
}
|
||||
|
||||
String scratchName = prefix(runKey) + "_" + source.getName();
|
||||
QTableMetaData clone = source.clone();
|
||||
clone.setName(scratchName);
|
||||
clone.setBackendName(BACKEND_NAME);
|
||||
clone.setBackendDetails(null);
|
||||
clone.setAutomationDetails(null);
|
||||
clone.setCustomizers(null);
|
||||
clone.setUniqueKeys(null);
|
||||
clone.setAssociations(new ArrayList<>());
|
||||
clone.setRecordSecurityLocks(new ArrayList<>());
|
||||
clone.setExposedJoins(new ArrayList<>());
|
||||
clone.setSupplementalMetaData(null);
|
||||
clone.setSourceQBitName(null);
|
||||
clone.getFields().values().forEach(field -> field.setBehaviors(null));
|
||||
qInstance.getTables().put(scratchName, clone);
|
||||
tablesByRun.computeIfAbsent(runKey, k -> new ArrayList<>()).add(scratchName);
|
||||
|
||||
return (new ScratchTable()
|
||||
{
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void insert(List<QRecord> records) throws QException
|
||||
{
|
||||
InsertInput input = new InsertInput(scratchName);
|
||||
input.setRecords(records);
|
||||
input.withPermissionBypass("backups verify scratch");
|
||||
List<QRecord> inserted = new InsertAction().execute(input).getRecords();
|
||||
for(QRecord record : inserted)
|
||||
{
|
||||
if(record.getErrors() != null && !record.getErrors().isEmpty())
|
||||
{
|
||||
throw (new QException("A row could not be restored into the scratch table for " + source.getName() + ": " + record.getErrors().get(0).getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void readInPrimaryKeyOrder(int pageSize, Consumer<QRecord> consumer) throws QException
|
||||
{
|
||||
int skip = 0;
|
||||
while(true)
|
||||
{
|
||||
QueryInput input = new QueryInput(scratchName);
|
||||
input.setFilter(new QQueryFilter().withOrderBy(new QFilterOrderBy(source.getPrimaryKeyField(), true)).withSkip(skip).withLimit(pageSize));
|
||||
input.withPermissionBypass("backups verify scratch");
|
||||
List<QRecord> page = new QueryAction().execute(input).getRecords();
|
||||
page.forEach(consumer);
|
||||
if(page.size() < pageSize)
|
||||
{
|
||||
return;
|
||||
}
|
||||
skip += pageSize;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized void drop(String runKey) throws QException
|
||||
{
|
||||
List<String> names = tablesByRun.remove(runKey);
|
||||
if(names == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for(String name : names)
|
||||
{
|
||||
DeleteInput input = new DeleteInput(name);
|
||||
input.setQueryFilter(new QQueryFilter());
|
||||
input.withPermissionBypass("backups verify scratch");
|
||||
new DeleteAction().execute(input);
|
||||
QContext.getQInstance().getTables().remove(name);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The scratch name prefix of a run: qqq_verify_ plus the run key made safe.
|
||||
*******************************************************************************/
|
||||
static String prefix(String runKey)
|
||||
{
|
||||
return ("qqq_verify_" + runKey.replaceAll("[^A-Za-z0-9]", "_").toLowerCase());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* 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.verify;
|
||||
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.DigestInputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
import com.kingsrook.qbits.backups.codec.HashingOutputStream;
|
||||
import com.kingsrook.qbits.backups.manifest.BackupManifest;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reads one stored part end to end the way a restore would: from storage,
|
||||
** through the decrypt hook, through gunzip, and hands each line to a consumer,
|
||||
** while hashing the stored bytes and the decoded bytes.
|
||||
*******************************************************************************/
|
||||
final class PartScanner
|
||||
{
|
||||
/***************************************************************************
|
||||
** What a scan saw.
|
||||
**
|
||||
** @param lines the number of lines handed to the consumer (0 when none was given)
|
||||
** @param sha256Stored SHA-256 of the bytes in the bucket
|
||||
** @param sha256Raw SHA-256 of the bytes after decrypting and decompressing
|
||||
***************************************************************************/
|
||||
record Scan(long lines, String sha256Stored, String sha256Raw)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Receives each line of a part (no trailing newline).
|
||||
***************************************************************************/
|
||||
@FunctionalInterface
|
||||
interface LineConsumer
|
||||
{
|
||||
/***************************************************************************
|
||||
** One line.
|
||||
***************************************************************************/
|
||||
void accept(String line) throws QException;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private PartScanner()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scan a part. With no consumer the part is read to its end and hashed only (a
|
||||
** CSV part, where lines are not rows).
|
||||
*******************************************************************************/
|
||||
static Scan scan(RunBundle bundle, BackupManifest.Part part, LineConsumer consumer) throws IOException, QException
|
||||
{
|
||||
MessageDigest storedDigest = HashingOutputStream.newSha256();
|
||||
MessageDigest rawDigest = HashingOutputStream.newSha256();
|
||||
long lines = 0;
|
||||
|
||||
try(InputStream fromStorage = bundle.storage().openRead(bundle.runPrefix() + part.key());
|
||||
DigestInputStream stored = new DigestInputStream(fromStorage, storedDigest))
|
||||
{
|
||||
InputStream plain = part.encrypted() ? bundle.transform().unwrap(stored) : stored;
|
||||
InputStream decompressed = "GZIP".equals(part.compression()) ? new GZIPInputStream(plain) : plain;
|
||||
|
||||
try(DigestInputStream raw = new DigestInputStream(decompressed, rawDigest))
|
||||
{
|
||||
if(consumer == null)
|
||||
{
|
||||
raw.transferTo(OutputStream.nullOutputStream());
|
||||
}
|
||||
else
|
||||
{
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(raw, StandardCharsets.UTF_8));
|
||||
String line;
|
||||
while((line = reader.readLine()) != null)
|
||||
{
|
||||
lines++;
|
||||
consumer.accept(line);
|
||||
}
|
||||
}
|
||||
// anything after the decoded stream (a gzip trailer, padding) still counts toward the stored hash
|
||||
stored.transferTo(OutputStream.nullOutputStream());
|
||||
}
|
||||
}
|
||||
|
||||
return (new Scan(lines, HashingOutputStream.hex(storedDigest.digest()), HashingOutputStream.hex(rawDigest.digest())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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.verify;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The RDBMS_SCHEMA scratch target (design spec section 10.1): a configured scratch
|
||||
** backend whose dedicated database user may CREATE SCHEMA and DROP SCHEMA only for
|
||||
** qqq_verify_*. The qbit creates qqq_verify_(runKey), builds each table there
|
||||
** from a DDL-only copy generated from QQQ metadata (columns by type, primary key,
|
||||
** unique keys), restores through a temporary in-memory QTableMetaData clone that
|
||||
** points at the scratch schema, and drops the schema.
|
||||
**
|
||||
** STUB / TODO: this needs the restore agent's writer (the restore-mode insert,
|
||||
** QRun-IO/qqq PR #1111 RestoreWriteFlag, and the table writer built on it) and the
|
||||
** schema DDL generator. Until those land, create() refuses with a clear message,
|
||||
** so a plan that selects this target fails its TEST_RESTORE verification with an
|
||||
** ERROR instead of silently passing. Replace the body with the real
|
||||
** implementation when the restore writer is available.
|
||||
*******************************************************************************/
|
||||
public class RdbmsSchemaScratchTarget implements ScratchTarget
|
||||
{
|
||||
public static final String TYPE = "RDBMS_SCHEMA";
|
||||
|
||||
private final String backendName;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
**
|
||||
** @param backendName the configured scratch RDBMS backend
|
||||
*******************************************************************************/
|
||||
public RdbmsSchemaScratchTarget(String backendName)
|
||||
{
|
||||
this.backendName = backendName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String type()
|
||||
{
|
||||
return (TYPE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String describe(String runKey)
|
||||
{
|
||||
return (backendName + ":schema=qqq_verify_" + runKey.replaceAll("[^A-Za-z0-9]", "_").toLowerCase());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ScratchTable create(QTableMetaData source, String runKey) throws QException
|
||||
{
|
||||
// TODO: CREATE SCHEMA, generate the DDL-only table copy, and return a ScratchTable that restores
|
||||
// through the restore writer. Needs the restore agent's writer.
|
||||
throw (new QException("The RDBMS_SCHEMA scratch target is not implemented yet (it needs the restore writer); use the MEMORY scratch target."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void drop(String runKey) throws QException
|
||||
{
|
||||
// TODO: DROP SCHEMA qqq_verify_(runKey) once create() is implemented.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
/*
|
||||
* 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.verify;
|
||||
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.BufferedReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.codec.ArtifactTransform;
|
||||
import com.kingsrook.qbits.backups.codec.HashingOutputStream;
|
||||
import com.kingsrook.qbits.backups.manifest.BackupManifest;
|
||||
import com.kingsrook.qbits.backups.storage.BackupStorage;
|
||||
import com.kingsrook.qbits.backups.storage.StoredObjectInfo;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Everything a verification needs to know about one run, loaded once: the run
|
||||
** row, its storage, its manifest (read and hashed), the transform that undoes
|
||||
** the plan's encryption, and (lazily) the file index.
|
||||
*******************************************************************************/
|
||||
class RunBundle
|
||||
{
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private final QRecord run;
|
||||
private final QRecord plan;
|
||||
private final BackupStorage storage;
|
||||
private final String runPrefix;
|
||||
private final BackupManifest manifest;
|
||||
private final ArtifactTransform transform;
|
||||
private final boolean encrypted;
|
||||
|
||||
private List<FileIndexEntry> fileIndex;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One line of the per-run file index.
|
||||
**
|
||||
** @param table the referencing table
|
||||
** @param recordKey the referencing record's primary key
|
||||
** @param field the referencing field
|
||||
** @param sha256 the content hash, null when not known
|
||||
** @param size the size in bytes, -1 when not known
|
||||
** @param status COPIED, DEDUPLICATED, MISSING or REFERENCE_ONLY
|
||||
** @param casKey the content-addressed key in the bucket, null when not stored
|
||||
***************************************************************************/
|
||||
record FileIndexEntry(String table, String recordKey, String field, String sha256, long size, String status, String casKey)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Whether the file is in the file store (so it can be checked).
|
||||
*******************************************************************************/
|
||||
boolean isStored()
|
||||
{
|
||||
return (casKey != null && sha256 != null && ("COPIED".equals(status) || "DEDUPLICATED".equals(status)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
private RunBundle(QRecord run, QRecord plan, BackupStorage storage, String runPrefix, BackupManifest manifest, ArtifactTransform transform, boolean encrypted)
|
||||
{
|
||||
this.run = run;
|
||||
this.plan = plan;
|
||||
this.storage = storage;
|
||||
this.runPrefix = runPrefix;
|
||||
this.manifest = manifest;
|
||||
this.transform = transform;
|
||||
this.encrypted = encrypted;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Load a run. Returns null, after recording a mismatch, when the run has no
|
||||
** readable manifest (so it is not usable and there is nothing more to check).
|
||||
** Throws VerifyException when the run cannot be examined at all.
|
||||
*******************************************************************************/
|
||||
static RunBundle load(QRecord run, QRecord plan, VerifyFindings findings)
|
||||
{
|
||||
String alias = run.getValueString("storageBucketAlias");
|
||||
String runPrefix = run.getValueString("runPrefix");
|
||||
if(alias == null || runPrefix == null)
|
||||
{
|
||||
throw (new VerifyException("The run has no storage bucket or run prefix recorded"));
|
||||
}
|
||||
BackupStorage storage = BackupsRuntime.storageFor(alias);
|
||||
|
||||
String manifestKey = run.getValueString("manifestKey") != null ? run.getValueString("manifestKey") : runPrefix + "manifest.json";
|
||||
Optional<StoredObjectInfo> head = storage.head(manifestKey);
|
||||
if(head.isEmpty())
|
||||
{
|
||||
findings.mismatch("The manifest is missing from the bucket, so this backup is not usable");
|
||||
return (null);
|
||||
}
|
||||
|
||||
byte[] bytes;
|
||||
try(InputStream in = storage.openRead(manifestKey))
|
||||
{
|
||||
bytes = in.readAllBytes();
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw (new VerifyException("The manifest could not be read: " + e.getMessage(), e));
|
||||
}
|
||||
|
||||
String sha = HashingOutputStream.sha256Hex(bytes);
|
||||
if(run.getValueString("manifestSha256") != null && !run.getValueString("manifestSha256").equals(sha))
|
||||
{
|
||||
findings.mismatch("The manifest changed after it was written (its SHA-256 no longer matches the catalog)");
|
||||
}
|
||||
|
||||
BackupManifest manifest;
|
||||
try
|
||||
{
|
||||
manifest = BackupManifest.read(bytes);
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
findings.mismatch("The manifest is not valid JSON: " + e.getMessage());
|
||||
return (null);
|
||||
}
|
||||
if(manifest.tables() == null)
|
||||
{
|
||||
findings.mismatch("The manifest lists no tables section");
|
||||
return (null);
|
||||
}
|
||||
|
||||
boolean encrypted = manifest.encryption() != null && manifest.encryption().mode() != null && !"NONE".equals(manifest.encryption().mode());
|
||||
ArtifactTransform transform = ArtifactTransform.IDENTITY;
|
||||
if(encrypted)
|
||||
{
|
||||
String keyRef = manifest.encryption().keyRef();
|
||||
var config = BackupsRuntime.getConfig();
|
||||
if(config == null || config.getArtifactTransformResolver() == null)
|
||||
{
|
||||
throw (new VerifyException("This backup is encrypted but no decrypt hook is configured (BackupsQBitConfig.withArtifactTransformResolver), so it cannot be verified"));
|
||||
}
|
||||
transform = config.getArtifactTransformResolver().apply(keyRef);
|
||||
if(transform == null)
|
||||
{
|
||||
throw (new VerifyException("No key is available for the key reference " + keyRef + ", so this backup cannot be decrypted"));
|
||||
}
|
||||
}
|
||||
return (new RunBundle(run, plan, storage, runPrefix, manifest, transform, encrypted));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The distinct stored files the run's file index references, in first-seen order
|
||||
** (one entry per hash). Reads the index once.
|
||||
*******************************************************************************/
|
||||
synchronized List<FileIndexEntry> distinctStoredFiles(VerifyFindings findings)
|
||||
{
|
||||
if(fileIndex == null)
|
||||
{
|
||||
fileIndex = readIndex(findings);
|
||||
}
|
||||
Map<String, FileIndexEntry> bySha = new LinkedHashMap<>();
|
||||
for(FileIndexEntry entry : fileIndex)
|
||||
{
|
||||
if(entry.isStored())
|
||||
{
|
||||
bySha.putIfAbsent(entry.sha256(), entry);
|
||||
}
|
||||
}
|
||||
return (new ArrayList<>(bySha.values()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every index entry, stored or not.
|
||||
*******************************************************************************/
|
||||
synchronized List<FileIndexEntry> allFileEntries(VerifyFindings findings)
|
||||
{
|
||||
if(fileIndex == null)
|
||||
{
|
||||
fileIndex = readIndex(findings);
|
||||
}
|
||||
return (fileIndex);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the file index objects. The index is gzip JSON Lines; it is read as plain
|
||||
** gzip first and, if that is not readable and the plan encrypts, through the
|
||||
** decrypt hook.
|
||||
*******************************************************************************/
|
||||
private List<FileIndexEntry> readIndex(VerifyFindings findings)
|
||||
{
|
||||
List<FileIndexEntry> entries = new ArrayList<>();
|
||||
if(manifest.files() == null || manifest.files().indexKeys() == null)
|
||||
{
|
||||
return (entries);
|
||||
}
|
||||
for(String relative : manifest.files().indexKeys())
|
||||
{
|
||||
String key = runPrefix + relative;
|
||||
if(storage.head(key).isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
byte[] bytes;
|
||||
try(InputStream in = storage.openRead(key))
|
||||
{
|
||||
bytes = in.readAllBytes();
|
||||
}
|
||||
try
|
||||
{
|
||||
parseIndex(new GZIPInputStream(new ByteArrayInputStream(bytes)), entries);
|
||||
}
|
||||
catch(IOException plainFailed)
|
||||
{
|
||||
if(!encrypted)
|
||||
{
|
||||
throw (plainFailed);
|
||||
}
|
||||
entries.clear();
|
||||
parseIndex(new GZIPInputStream(transform.unwrap(new ByteArrayInputStream(bytes))), entries);
|
||||
}
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
findings.mismatch("The file index " + relative + " cannot be read: " + e.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
return (entries);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void parseIndex(InputStream in, List<FileIndexEntry> entries) throws IOException
|
||||
{
|
||||
try(BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8)))
|
||||
{
|
||||
String line;
|
||||
while((line = reader.readLine()) != null)
|
||||
{
|
||||
if(line.isBlank())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
JsonNode node = MAPPER.readTree(line);
|
||||
entries.add(new FileIndexEntry(text(node, "table"), text(node, "recordKey"), text(node, "field"), text(node, "sha256"),
|
||||
node.hasNonNull("size") ? node.get("size").asLong() : -1L, text(node, "status"), text(node, "casKey")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String text(JsonNode node, String name)
|
||||
{
|
||||
return (node.hasNonNull(name) ? node.get(name).asText() : null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for run
|
||||
*******************************************************************************/
|
||||
QRecord run()
|
||||
{
|
||||
return (run);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for plan (null for an ad-hoc run)
|
||||
*******************************************************************************/
|
||||
QRecord plan()
|
||||
{
|
||||
return (plan);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for storage
|
||||
*******************************************************************************/
|
||||
BackupStorage storage()
|
||||
{
|
||||
return (storage);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for runPrefix
|
||||
*******************************************************************************/
|
||||
String runPrefix()
|
||||
{
|
||||
return (runPrefix);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for manifest
|
||||
*******************************************************************************/
|
||||
BackupManifest manifest()
|
||||
{
|
||||
return (manifest);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for transform (the decrypt hook, or the identity)
|
||||
*******************************************************************************/
|
||||
ArtifactTransform transform()
|
||||
{
|
||||
return (transform);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the run's artifacts are encrypted.
|
||||
*******************************************************************************/
|
||||
boolean isEncrypted()
|
||||
{
|
||||
return (encrypted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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.verify;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One restored table inside a scratch target.
|
||||
*******************************************************************************/
|
||||
public interface ScratchTable
|
||||
{
|
||||
/***************************************************************************
|
||||
** Restore rows into the scratch table, preserving their primary keys.
|
||||
***************************************************************************/
|
||||
void insert(List<QRecord> records) throws QException;
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Read every row back in primary key order, a page at a time.
|
||||
***************************************************************************/
|
||||
void readInPrimaryKeyOrder(int pageSize, Consumer<QRecord> consumer) throws QException;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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.verify;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Where a TEST_RESTORE verification restores to (design spec section 10.1). The
|
||||
** target builds a scratch copy of each table from the source table's metadata,
|
||||
** takes the restored rows, reads them back in primary key order, and is dropped
|
||||
** afterwards. Nothing a scratch target does may touch the real tables.
|
||||
**
|
||||
** Implementations: MEMORY (MemoryScratchTarget, small apps and CI) and RDBMS_SCHEMA
|
||||
** (RdbmsSchemaScratchTarget, a stub until the restore agent's writer ships).
|
||||
*******************************************************************************/
|
||||
public interface ScratchTarget
|
||||
{
|
||||
/***************************************************************************
|
||||
** The target type: MEMORY, RDBMS_SCHEMA or SEPARATE_DATABASE.
|
||||
***************************************************************************/
|
||||
String type();
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A description for backups_verification.scratchTarget, for example
|
||||
** "memory:qqq_verify_20261012t020000z_7f3a".
|
||||
***************************************************************************/
|
||||
String describe(String runKey);
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Create the scratch copy of a table for a run.
|
||||
***************************************************************************/
|
||||
ScratchTable create(QTableMetaData source, String runKey) throws QException;
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Drop everything created for the run. Safe to call more than once.
|
||||
***************************************************************************/
|
||||
void drop(String runKey) throws QException;
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
/*
|
||||
* 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.verify;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.codec.ArtifactTransform;
|
||||
import com.kingsrook.qbits.backups.codec.HashingOutputStream;
|
||||
import com.kingsrook.qbits.backups.codec.JsonlRowCodec;
|
||||
import com.kingsrook.qbits.backups.manifest.BackupManifest;
|
||||
import com.kingsrook.qbits.backups.storage.BackupStorage;
|
||||
import com.kingsrook.qbits.backups.storage.WriteOptions;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** TEST_RESTORE verification (design spec section 10.1): restore every table of the
|
||||
** run into a scratch target (MEMORY by default), read it back in primary key order,
|
||||
** and compare each table's row count and rowsHash with the manifest; restore the
|
||||
** run's files into the scratch prefix qqq-verify/(runKey)/ in the backup bucket,
|
||||
** re-hash each one, and check that every file reference of the index resolves to a
|
||||
** restored file. Then drop the scratch tables and delete the scratch files, even
|
||||
** when something fails.
|
||||
**
|
||||
** The scratch target is BackupsQBitConfig.getScratchTarget(); the MEMORY target
|
||||
** proves the files and the codecs, not the database. RDBMS_SCHEMA is a stub until
|
||||
** the restore agent's writer ships (see RdbmsSchemaScratchTarget). Sequence resync
|
||||
** is part of the real restore (spec 5.4) and not exercised by the MEMORY target.
|
||||
*******************************************************************************/
|
||||
public class TestRestoreVerifier
|
||||
{
|
||||
private static final int PAGE_SIZE = 1000;
|
||||
|
||||
public static final String SCRATCH_FILE_PREFIX = "qqq-verify/";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the test restore, recording what it finds. Returns the scratch target
|
||||
** description for backups_verification.scratchTarget.
|
||||
*******************************************************************************/
|
||||
String verify(RunBundle bundle, VerifyFindings findings) throws QException
|
||||
{
|
||||
ScratchTarget target = BackupsRuntime.getConfig() != null && BackupsRuntime.getConfig().getScratchTarget() != null
|
||||
? BackupsRuntime.getConfig().getScratchTarget() : new MemoryScratchTarget();
|
||||
String runKey = bundle.manifest().runKey();
|
||||
String scratch = target.describe(runKey);
|
||||
|
||||
List<String> scratchFileKeys = new ArrayList<>();
|
||||
try
|
||||
{
|
||||
findings.tablesChecked = bundle.manifest().tables().size();
|
||||
restoreTables(bundle, target, runKey, findings);
|
||||
restoreFiles(bundle, runKey, scratchFileKeys, findings);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
target.drop(runKey);
|
||||
}
|
||||
finally
|
||||
{
|
||||
for(String key : scratchFileKeys)
|
||||
{
|
||||
bundle.storage().deletePermanent(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (scratch);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Restore each table into the scratch target and compare it with the manifest.
|
||||
*******************************************************************************/
|
||||
private void restoreTables(RunBundle bundle, ScratchTarget target, String runKey, VerifyFindings findings) throws QException
|
||||
{
|
||||
BackupManifest manifest = bundle.manifest();
|
||||
List<String> order = new ArrayList<>(manifest.restoreOrder() == null ? List.of() : manifest.restoreOrder());
|
||||
for(BackupManifest.TableEntry entry : manifest.tables())
|
||||
{
|
||||
if(!order.contains(entry.name()))
|
||||
{
|
||||
order.add(entry.name());
|
||||
}
|
||||
}
|
||||
|
||||
for(String name : order)
|
||||
{
|
||||
BackupManifest.TableEntry entry = manifest.table(name);
|
||||
if(entry == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
QTableMetaData table = QContext.getQInstance().getTable(name);
|
||||
if(table == null)
|
||||
{
|
||||
findings.mismatch("Table " + name + " no longer exists in this instance, so it cannot be test-restored");
|
||||
continue;
|
||||
}
|
||||
boolean hasJsonl = entry.parts() != null && entry.parts().stream().anyMatch(p -> !"CSV".equals(p.format()));
|
||||
if(!hasJsonl)
|
||||
{
|
||||
findings.warn("Table " + name + " has only CSV parts and was not test-restored");
|
||||
continue;
|
||||
}
|
||||
|
||||
List<String> excluded = entry.excludedFields() == null ? List.of() : entry.excludedFields().stream().map(BackupManifest.ExcludedField::name).toList();
|
||||
JsonlRowCodec codec = new JsonlRowCodec(table, excluded);
|
||||
ScratchTable scratchTable = target.create(table, runKey);
|
||||
|
||||
int before = findings.mismatches;
|
||||
restoreRows(bundle, entry, codec, scratchTable, findings);
|
||||
|
||||
MessageDigest digest = HashingOutputStream.newSha256();
|
||||
long[] count = { 0 };
|
||||
scratchTable.readInPrimaryKeyOrder(PAGE_SIZE, record ->
|
||||
{
|
||||
count[0]++;
|
||||
digest.update((codec.encode(record) + "\n").getBytes(StandardCharsets.UTF_8));
|
||||
});
|
||||
String actualHash = "sha256:" + HashingOutputStream.hex(digest.digest());
|
||||
|
||||
if(count[0] != entry.rowCount())
|
||||
{
|
||||
findings.mismatch("Test restore of table " + name + " has " + count[0] + " rows, the manifest says " + entry.rowCount());
|
||||
}
|
||||
if(entry.rowsHash() != null && !entry.rowsHash().equals(actualHash))
|
||||
{
|
||||
findings.mismatch("Test restore of table " + name + " does not reproduce the backed up rows (row content hash differs)");
|
||||
}
|
||||
findings.rowsChecked += count[0];
|
||||
|
||||
Map<String, Object> detail = new LinkedHashMap<>();
|
||||
detail.put("table", name);
|
||||
detail.put("expectedRows", entry.rowCount());
|
||||
detail.put("actualRows", count[0]);
|
||||
detail.put("expectedRowsHash", entry.rowsHash());
|
||||
detail.put("actualRowsHash", actualHash);
|
||||
detail.put("ok", findings.mismatches == before);
|
||||
findings.tables.add(detail);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Decode the JSONL parts of one table and insert them into the scratch table.
|
||||
*******************************************************************************/
|
||||
private void restoreRows(RunBundle bundle, BackupManifest.TableEntry entry, JsonlRowCodec codec, ScratchTable scratchTable, VerifyFindings findings) throws QException
|
||||
{
|
||||
for(BackupManifest.Part part : entry.parts())
|
||||
{
|
||||
if("CSV".equals(part.format()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
List<QRecord> batch = new ArrayList<>();
|
||||
try
|
||||
{
|
||||
PartScanner.scan(bundle, part, line ->
|
||||
{
|
||||
batch.add(codec.decode(line));
|
||||
if(batch.size() >= PAGE_SIZE)
|
||||
{
|
||||
scratchTable.insert(new ArrayList<>(batch));
|
||||
batch.clear();
|
||||
}
|
||||
});
|
||||
if(!batch.isEmpty())
|
||||
{
|
||||
scratchTable.insert(batch);
|
||||
}
|
||||
}
|
||||
catch(IOException | RuntimeException e)
|
||||
{
|
||||
findings.mismatch("The part " + part.key() + " could not be restored (" + e.getClass().getSimpleName() + "); it is corrupted, or the key is wrong");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Restore the run's files into the scratch prefix, re-hash each, and check that
|
||||
** every index reference resolves to a restored file. verifyFileSamplePercent
|
||||
** (on the plan, default 100) limits how many files are restored.
|
||||
*******************************************************************************/
|
||||
private void restoreFiles(RunBundle bundle, String runKey, List<String> scratchFileKeys, VerifyFindings findings)
|
||||
{
|
||||
int percent = 100;
|
||||
if(bundle.plan() != null && bundle.plan().getValueInteger("verifyFileSamplePercent") != null)
|
||||
{
|
||||
percent = Math.max(0, Math.min(100, bundle.plan().getValueInteger("verifyFileSamplePercent")));
|
||||
}
|
||||
|
||||
BackupStorage storage = bundle.storage();
|
||||
String prefix = SCRATCH_FILE_PREFIX + runKey + "/";
|
||||
Set<String> restored = new LinkedHashSet<>();
|
||||
int sampled = 0;
|
||||
for(RunBundle.FileIndexEntry entry : bundle.distinctStoredFiles(findings))
|
||||
{
|
||||
if(Math.floorMod(entry.sha256().hashCode(), 100) >= percent)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
sampled++;
|
||||
String scratchKey = prefix + entry.sha256();
|
||||
scratchFileKeys.add(scratchKey);
|
||||
try
|
||||
{
|
||||
ArtifactTransform transform = bundle.isEncrypted() ? bundle.transform() : ArtifactTransform.IDENTITY;
|
||||
String written;
|
||||
try(InputStream raw = storage.openRead(entry.casKey());
|
||||
InputStream plain = transform.unwrap(raw);
|
||||
HashingOutputStream out = new HashingOutputStream(storage.openWrite(scratchKey, WriteOptions.create())))
|
||||
{
|
||||
plain.transferTo(out);
|
||||
out.close();
|
||||
written = out.getSha256();
|
||||
}
|
||||
String reread = BackupVerifier.hashObject(storage, scratchKey, ArtifactTransform.IDENTITY);
|
||||
if(written.equals(entry.sha256()) && reread.equals(entry.sha256()))
|
||||
{
|
||||
restored.add(entry.sha256());
|
||||
}
|
||||
else
|
||||
{
|
||||
findings.mismatch("Test restore of file sha256 " + entry.sha256() + " does not reproduce its content");
|
||||
}
|
||||
}
|
||||
catch(IOException | RuntimeException e)
|
||||
{
|
||||
findings.mismatch("Test restore of file sha256 " + entry.sha256() + " failed (" + e.getClass().getSimpleName() + ")");
|
||||
}
|
||||
}
|
||||
|
||||
int unresolved = 0;
|
||||
for(RunBundle.FileIndexEntry entry : bundle.allFileEntries(findings))
|
||||
{
|
||||
if(entry.isStored() && Math.floorMod(entry.sha256().hashCode(), 100) < percent && !restored.contains(entry.sha256()))
|
||||
{
|
||||
unresolved++;
|
||||
}
|
||||
}
|
||||
if(unresolved > 0)
|
||||
{
|
||||
findings.mismatch(unresolved + " file references in the index do not resolve to a restored file");
|
||||
}
|
||||
findings.filesChecked += sampled;
|
||||
findings.extra.put("filesRestored", restored.size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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.verify;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The result of verifying one run at one level; also what was written to the
|
||||
** backups_verification row.
|
||||
**
|
||||
** @param verificationId the backups_verification id
|
||||
** @param runId the backups_run id
|
||||
** @param level STORAGE, CONTENT or TEST_RESTORE
|
||||
** @param status PASSED, PASSED_WITH_WARNINGS, FAILED or ERROR
|
||||
** @param tablesChecked tables checked
|
||||
** @param rowsChecked rows decoded and counted
|
||||
** @param mismatches how many things did not match (the messages list is capped)
|
||||
** @param objectsChecked storage objects examined
|
||||
** @param filesChecked file-store objects examined (head, or read and re-hashed)
|
||||
** @param filesSkipped file-store objects skipped because they were verified recently
|
||||
** @param messages the first mismatches and warnings, in plain words
|
||||
** @param scratchTarget for TEST_RESTORE, the scratch target used
|
||||
*******************************************************************************/
|
||||
public record VerificationOutcome(Integer verificationId, Integer runId, String level, String status, int tablesChecked, long rowsChecked, int mismatches,
|
||||
int objectsChecked, int filesChecked, int filesSkipped, List<String> messages, String scratchTarget)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Whether the verification passed (with or without warnings).
|
||||
*******************************************************************************/
|
||||
public boolean passed()
|
||||
{
|
||||
return ("PASSED".equals(status) || "PASSED_WITH_WARNINGS".equals(status));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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.verify;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A verification could not be carried out (as opposed to carried out and found
|
||||
** a mismatch): the storage is unreachable, there is no key to decrypt with. The
|
||||
** verification ends with status ERROR.
|
||||
*******************************************************************************/
|
||||
public class VerifyException extends RuntimeException
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public VerifyException(String message)
|
||||
{
|
||||
super(message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public VerifyException(String message, Throwable cause)
|
||||
{
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 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.verify;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What one verification found: counts, the first mismatches and warnings (capped,
|
||||
** so a ruined bucket does not write megabytes of messages), and per-table detail
|
||||
** for backups_verification.detailJson. Messages never contain row values.
|
||||
*******************************************************************************/
|
||||
class VerifyFindings
|
||||
{
|
||||
static final int MAX_MESSAGES = 100;
|
||||
|
||||
final List<String> mismatchMessages = new ArrayList<>();
|
||||
final List<String> warnings = new ArrayList<>();
|
||||
final List<Map<String, Object>> tables = new ArrayList<>();
|
||||
final Map<String, Object> extra = new LinkedHashMap<>();
|
||||
|
||||
int mismatches;
|
||||
int tablesChecked;
|
||||
long rowsChecked;
|
||||
int objectsChecked;
|
||||
int filesChecked;
|
||||
int filesSkipped;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Something did not match.
|
||||
*******************************************************************************/
|
||||
void mismatch(String message)
|
||||
{
|
||||
mismatches++;
|
||||
if(mismatchMessages.size() < MAX_MESSAGES)
|
||||
{
|
||||
mismatchMessages.add(message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Something worth knowing that is not a failure.
|
||||
*******************************************************************************/
|
||||
void warn(String message)
|
||||
{
|
||||
if(warnings.size() < MAX_MESSAGES)
|
||||
{
|
||||
warnings.add(message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The mismatches, then the warnings.
|
||||
*******************************************************************************/
|
||||
List<String> messages()
|
||||
{
|
||||
List<String> all = new ArrayList<>(mismatchMessages);
|
||||
all.addAll(warnings);
|
||||
return (all);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** PASSED, PASSED_WITH_WARNINGS or FAILED.
|
||||
*******************************************************************************/
|
||||
String status()
|
||||
{
|
||||
if(mismatches > 0)
|
||||
{
|
||||
return ("FAILED");
|
||||
}
|
||||
return (warnings.isEmpty() ? "PASSED" : "PASSED_WITH_WARNINGS");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.scope.BackupTableMetaData;
|
||||
import com.kingsrook.qbits.backups.storage.BackupStorage;
|
||||
import com.kingsrook.qbits.backups.storage.InMemoryBackupStorage;
|
||||
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.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test fixtures: an instance on the memory backend with the Backups qbit and a
|
||||
** small blog app (authors, posts with a media file key, an ephemeral table).
|
||||
*******************************************************************************/
|
||||
public final class BackupsTestUtils
|
||||
{
|
||||
public static final String BACKEND = "memory";
|
||||
|
||||
public static final InMemoryBackupStorage BACKUPS = new InMemoryBackupStorage();
|
||||
public static final InMemoryBackupStorage MEDIA = new InMemoryBackupStorage();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private BackupsTestUtils()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the instance, install the qbit, and initialize the context.
|
||||
*******************************************************************************/
|
||||
public static QInstance init() throws Exception
|
||||
{
|
||||
BackupsRuntime.reset();
|
||||
MemoryRecordStore.getInstance().reset();
|
||||
BACKUPS.clear();
|
||||
MEDIA.clear();
|
||||
|
||||
QInstance qInstance = new QInstance();
|
||||
qInstance.withInstanceDefaultAuthentication(new com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData().withType(com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType.FULLY_ANONYMOUS).withName("anon"));
|
||||
qInstance.addBackend(new QBackendMetaData().withName(BACKEND).withBackendType(MemoryBackendModule.class));
|
||||
|
||||
qInstance.addTable(new QTableMetaData().withName("author").withBackendName(BACKEND).withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("name", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("bio", QFieldType.TEXT)));
|
||||
|
||||
qInstance.addTable(new QTableMetaData().withName("blogPost").withBackendName(BACKEND).withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("authorId", QFieldType.INTEGER).withPossibleValueSourceName("author"))
|
||||
.withField(new QFieldMetaData("title", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("body", QFieldType.TEXT))
|
||||
.withField(new QFieldMetaData("price", QFieldType.DECIMAL))
|
||||
.withField(new QFieldMetaData("published", QFieldType.BOOLEAN))
|
||||
.withField(new QFieldMetaData("publishedOn", QFieldType.DATE))
|
||||
.withField(new QFieldMetaData("createDate", QFieldType.DATE_TIME))
|
||||
.withField(new QFieldMetaData("mediaKey", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("secretNote", QFieldType.STRING).withIsSensitive(true))
|
||||
.withSupplementalMetaData(new BackupTableMetaData().withFileReferenceFields(List.of("mediaKey")).withFileBucketAlias("media")));
|
||||
qInstance.addPossibleValueSource(com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource.newForTable("author"));
|
||||
|
||||
qInstance.addTable(new QTableMetaData().withName("userSession").withBackendName(BACKEND).withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("token", QFieldType.STRING)));
|
||||
|
||||
Map<String, BackupStorage> buckets = Map.of("backups", BACKUPS, "media", MEDIA);
|
||||
BackupsQBitConfig config = new BackupsQBitConfig().withBackendName(BACKEND).withInstanceName("testapp").withStorageResolver(buckets::get).withOpenWhenAnonymous(true);
|
||||
new BackupsQBitProducer().withQBitConfig(config).withNamespace("backups").produce(qInstance);
|
||||
|
||||
new QInstanceValidator().validate(qInstance);
|
||||
QContext.init(qInstance, new QSession());
|
||||
return (qInstance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert rows into a table.
|
||||
*******************************************************************************/
|
||||
public static void insert(String table, List<Map<String, Serializable>> rows) throws QException
|
||||
{
|
||||
InsertInput input = new InsertInput(table);
|
||||
input.setRecords(rows.stream().map(r ->
|
||||
{
|
||||
QRecord record = new QRecord();
|
||||
record.setValues(new LinkedHashMap<>(r));
|
||||
return (record);
|
||||
}).toList());
|
||||
new InsertAction().execute(input);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The standard blog data: 3 authors, and the posts, some with media.
|
||||
*******************************************************************************/
|
||||
public static void loadBlog(int posts) throws Exception
|
||||
{
|
||||
insert("author", List.of(
|
||||
Map.of("id", 1, "name", "Ada", "bio", "Line one\nLine two, with \"quotes\" and unicode é中"),
|
||||
Map.of("id", 2, "name", "Grace", "bio", ""),
|
||||
Map.of("id", 3, "name", "Linus")));
|
||||
List<Map<String, Serializable>> rows = new java.util.ArrayList<>();
|
||||
for(int i = 1; i <= posts; i++)
|
||||
{
|
||||
Map<String, Serializable> row = new LinkedHashMap<>();
|
||||
row.put("id", i);
|
||||
row.put("authorId", 1 + (i % 3));
|
||||
row.put("title", "Post " + i);
|
||||
row.put("body", "Body of " + i + ", with a comma");
|
||||
row.put("price", new BigDecimal("12.3400").add(BigDecimal.valueOf(i)));
|
||||
row.put("published", i % 2 == 0);
|
||||
row.put("publishedOn", java.time.LocalDate.of(2026, 1, 1).plusDays(i));
|
||||
row.put("createDate", Instant.parse("2026-10-12T02:00:00.123456Z").plusSeconds(i));
|
||||
row.put("secretNote", "do-not-leak-" + i);
|
||||
if(i % 2 == 1)
|
||||
{
|
||||
String key = "media/photo-" + (i % 3) + ".jpg";
|
||||
row.put("mediaKey", key);
|
||||
if(MEDIA.getBytes(key) == null)
|
||||
{
|
||||
MEDIA.putBytes(key, ("photo bytes " + (i % 3)).getBytes());
|
||||
}
|
||||
}
|
||||
rows.add(row);
|
||||
}
|
||||
insert("blogPost", rows);
|
||||
insert("userSession", List.of(Map.of("id", 1, "token", "tok")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A synthetic clock for tests: it stands still until it is advanced, so SLA
|
||||
** windows can be crossed without waiting.
|
||||
*******************************************************************************/
|
||||
public class MutableClock extends Clock
|
||||
{
|
||||
private Instant now;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public MutableClock(Instant start)
|
||||
{
|
||||
this.now = start;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Move the clock forward.
|
||||
*******************************************************************************/
|
||||
public synchronized MutableClock advance(Duration duration)
|
||||
{
|
||||
now = now.plus(duration);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ZoneId getZone()
|
||||
{
|
||||
return (ZoneOffset.UTC);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Clock withZone(ZoneId zone)
|
||||
{
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized Instant instant()
|
||||
{
|
||||
return (now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.alerts.BackupAlertSink;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A test double for the alert sink: remembers every call, and which alerts are
|
||||
** currently open (raised and not resolved), by dedupe key.
|
||||
*******************************************************************************/
|
||||
public class RecordingAlertSink implements BackupAlertSink
|
||||
{
|
||||
/** One raise call. */
|
||||
public record Raised(String type, String dedupeKey, Severity severity, String title, Map<String, String> detail)
|
||||
{
|
||||
}
|
||||
|
||||
public final List<Raised> raises = new ArrayList<>();
|
||||
public final List<String> resolves = new ArrayList<>();
|
||||
public final List<String> heartbeats = new ArrayList<>();
|
||||
public final Map<String, Raised> open = new LinkedHashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void raise(String type, String dedupeKey, Severity severity, String title, Map<String, String> detail)
|
||||
{
|
||||
Raised raised = new Raised(type, dedupeKey, severity, title, detail);
|
||||
raises.add(raised);
|
||||
open.put(dedupeKey, raised);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void resolve(String type, String dedupeKey)
|
||||
{
|
||||
resolves.add(dedupeKey);
|
||||
open.remove(dedupeKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void heartbeat(String monitorKey, long expectedEverySeconds, long graceSeconds)
|
||||
{
|
||||
heartbeats.add(monitorKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether an alert is open.
|
||||
*******************************************************************************/
|
||||
public boolean isOpen(String dedupeKey)
|
||||
{
|
||||
return (open.containsKey(dedupeKey));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The open alert, or null.
|
||||
*******************************************************************************/
|
||||
public Raised openAlert(String dedupeKey)
|
||||
{
|
||||
return (open.get(dedupeKey));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
|
||||
import java.io.FilterInputStream;
|
||||
import java.io.FilterOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.codec.ArtifactTransform;
|
||||
import com.kingsrook.qbits.backups.events.BackupEvents;
|
||||
import com.kingsrook.qbits.backups.metrics.BackupsMetrics;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qbits.backups.run.BackupEngine;
|
||||
import com.kingsrook.qbits.backups.run.BackupRunFactory;
|
||||
import com.kingsrook.qbits.backups.run.BackupRunResult;
|
||||
import com.kingsrook.qbits.backups.sla.SlaChecker;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared setup for the verification, SLA, alert, health and event tests: the
|
||||
** blog instance, a synthetic clock, a recording alert sink, plans and runs.
|
||||
*******************************************************************************/
|
||||
public final class VerifyTestSupport
|
||||
{
|
||||
public static MutableClock clock;
|
||||
public static RecordingAlertSink sink;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private VerifyTestSupport()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fresh instance with posts, a synthetic clock starting now, and a recording sink.
|
||||
*******************************************************************************/
|
||||
public static void setUp(int posts) throws Exception
|
||||
{
|
||||
BackupsTestUtils.init();
|
||||
BackupsTestUtils.loadBlog(posts);
|
||||
SlaChecker.resetCaches();
|
||||
BackupsMetrics.resetCache();
|
||||
BackupEvents.setPublisher(null);
|
||||
clock = new MutableClock(Instant.now());
|
||||
sink = new RecordingAlertSink();
|
||||
BackupsRuntime.setClock(clock);
|
||||
BackupsRuntime.setAlertSink(sink);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert a nightly plan; overrides replace the defaults.
|
||||
*******************************************************************************/
|
||||
public static QRecord plan(Map<String, Serializable> overrides) throws Exception
|
||||
{
|
||||
Map<String, Serializable> values = new LinkedHashMap<>();
|
||||
values.put("name", "Nightly");
|
||||
values.put("planKey", "nightly");
|
||||
values.put("isActive", true);
|
||||
values.put("scopeType", "INSTANCE");
|
||||
values.put("storageBucketAlias", "backups");
|
||||
values.put("slaHours", 26);
|
||||
values.put("verifySlaDays", 8);
|
||||
values.putAll(overrides);
|
||||
Integer id = new BackupCatalog().insert(BackupsTables.PLAN, values);
|
||||
return (new BackupCatalog().get(BackupsTables.PLAN, id).orElseThrow());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The plan as it is now.
|
||||
*******************************************************************************/
|
||||
public static QRecord reload(QRecord plan) throws Exception
|
||||
{
|
||||
return (new BackupCatalog().get(BackupsTables.PLAN, plan.getValueInteger("id")).orElseThrow());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The run as it is now.
|
||||
*******************************************************************************/
|
||||
public static QRecord run(Integer runId) throws Exception
|
||||
{
|
||||
return (new BackupCatalog().get(BackupsTables.RUN, runId).orElseThrow());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the plan's backup.
|
||||
*******************************************************************************/
|
||||
public static BackupRunResult backup(QRecord plan) throws Exception
|
||||
{
|
||||
return (new BackupEngine().execute(BackupRunFactory.forPlan(plan, "MANUAL")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the plan's backup encrypted with a transform.
|
||||
*******************************************************************************/
|
||||
public static BackupRunResult backupEncrypted(QRecord plan, ArtifactTransform transform, String keyRef) throws Exception
|
||||
{
|
||||
return (new BackupEngine().execute(BackupRunFactory.forPlan(plan, "MANUAL").withTransform(transform, keyRef, "fingerprint-" + keyRef)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The part keys (relative to the run prefix) of a table in a run's manifest.
|
||||
*******************************************************************************/
|
||||
public static String firstPartKey(BackupRunResult result, String table) throws Exception
|
||||
{
|
||||
var manifest = com.kingsrook.qbits.backups.manifest.BackupManifest.read(BackupsTestUtils.BACKUPS.getBytes(result.getManifestKey()));
|
||||
return (result.getRunPrefix() + manifest.table(table).parts().get(0).key());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A toy "cipher" for tests: every byte is XORed with a key byte. Different
|
||||
** key bytes stand in for "the wrong key".
|
||||
***************************************************************************/
|
||||
public static class XorTransform implements ArtifactTransform
|
||||
{
|
||||
private final int key;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public XorTransform(int key)
|
||||
{
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public OutputStream wrap(OutputStream out)
|
||||
{
|
||||
return (new FilterOutputStream(out)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void write(int b) throws IOException
|
||||
{
|
||||
out.write((b ^ key) & 0xFF);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void write(byte[] b, int off, int len) throws IOException
|
||||
{
|
||||
for(int i = 0; i < len; i++)
|
||||
{
|
||||
write(b[off + i]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public InputStream unwrap(InputStream in)
|
||||
{
|
||||
return (new FilterInputStream(in)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int read() throws IOException
|
||||
{
|
||||
int b = super.read();
|
||||
return (b < 0 ? b : (b ^ key) & 0xFF);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int read(byte[] b, int off, int len) throws IOException
|
||||
{
|
||||
int n = super.read(b, off, len);
|
||||
for(int i = 0; i < n; i++)
|
||||
{
|
||||
b[off + i] = (byte) ((b[off + i] ^ key) & 0xFF);
|
||||
}
|
||||
return (n);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean encrypts()
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String keySuffix()
|
||||
{
|
||||
return (".xor");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.alerts;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.VerifyTestSupport;
|
||||
import com.kingsrook.qbits.backups.alerts.fakeapi.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qbits.backups.alerts.fakeapi.QAlertTypeMetaData;
|
||||
import com.kingsrook.qbits.backups.alerts.fakeapi.QAlerts;
|
||||
import com.kingsrook.qbits.backups.sla.SlaChecker;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Alerts qbit adapter, bound reflectively. The SPI is not in QQQ 4.1.0-RC.4,
|
||||
** so these tests run it against a test double with the same shape as the real
|
||||
** com.kingsrook.qqq.backend.core.alerts package (QRun-IO/qqq PR #1108).
|
||||
*******************************************************************************/
|
||||
class AlertsQBitAlertSinkTest
|
||||
{
|
||||
private static final String FAKE_API = "com.kingsrook.qbits.backups.alerts.fakeapi";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
VerifyTestSupport.setUp(3);
|
||||
QAlerts.reset();
|
||||
QAlertTypeMetaData.ADDED.clear();
|
||||
AlertsInstanceMetaData.installed = true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
AlertsInstanceMetaData.installed = false;
|
||||
QAlerts.reset();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Raise, resolve and heartbeat reach the SPI with the stable dedupe key, the
|
||||
** severity mapped by name, and the detail as the summary and attributes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRaiseResolveHeartbeat()
|
||||
{
|
||||
AlertsQBitAlertSink sink = new AlertsQBitAlertSink(FAKE_API);
|
||||
Map<String, String> detail = new LinkedHashMap<>();
|
||||
detail.put("plan", "nightly");
|
||||
detail.put("slaHours", "26");
|
||||
|
||||
sink.raise("backups.plan.missed", "backups.plan.missed:nightly", BackupAlertSink.Severity.CRITICAL, "A backup did not run in time", detail);
|
||||
sink.resolve("backups.plan.missed", "backups.plan.missed:nightly");
|
||||
sink.heartbeat("backups.plan:nightly", 93600, 0);
|
||||
|
||||
assertThat(QAlerts.RAISED).hasSize(1);
|
||||
var signal = QAlerts.RAISED.get(0);
|
||||
assertThat(signal.alertTypeName).isEqualTo("backups.plan.missed");
|
||||
assertThat(signal.dedupKey).isEqualTo("backups.plan.missed:nightly");
|
||||
assertThat(signal.severity.name()).isEqualTo("CRITICAL");
|
||||
assertThat(signal.title).isEqualTo("A backup did not run in time");
|
||||
assertThat(signal.summary).isEqualTo("plan=nightly, slaHours=26");
|
||||
assertThat(signal.attributes).containsEntry("plan", "nightly").containsEntry("slaHours", "26");
|
||||
assertThat(QAlerts.RESOLVED).containsExactly("backups.plan.missed|backups.plan.missed:nightly|Cleared by the Backups qbit");
|
||||
assertThat(QAlerts.HEARTBEATS).containsExactly("backups.plan:nightly");
|
||||
|
||||
sink.raise("backups.storage.debris", "k", BackupAlertSink.Severity.INFO, "no detail", null);
|
||||
assertThat(QAlerts.RAISED.get(1).summary).isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Without the Alerts qbit installed (no alerts meta-data on the instance) every
|
||||
** call is a no-op.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testNoOpWithoutTheAlertsQBit()
|
||||
{
|
||||
AlertsInstanceMetaData.installed = false;
|
||||
AlertsQBitAlertSink sink = new AlertsQBitAlertSink(FAKE_API);
|
||||
sink.raise("t", "k", BackupAlertSink.Severity.INFO, "x", Map.of());
|
||||
sink.resolve("t", "k");
|
||||
sink.heartbeat("m", 1, 1);
|
||||
assertThat(QAlerts.RAISED).isEmpty();
|
||||
assertThat(QAlerts.RESOLVED).isEmpty();
|
||||
assertThat(QAlerts.HEARTBEATS).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A broken or missing SPI never throws into the backup.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testFailuresAreSwallowed()
|
||||
{
|
||||
AlertsQBitAlertSink missing = new AlertsQBitAlertSink("no.such.package");
|
||||
missing.raise("t", "k", BackupAlertSink.Severity.INFO, "x", Map.of());
|
||||
missing.resolve("t", "k");
|
||||
missing.heartbeat("m", 1, 1);
|
||||
assertThat(missing.registerAlertTypes(QContext.getQInstance())).isZero();
|
||||
|
||||
QAlerts.failWith = new IllegalStateException("alerts down");
|
||||
AlertsQBitAlertSink broken = new AlertsQBitAlertSink(FAKE_API);
|
||||
broken.raise("t", "k", BackupAlertSink.Severity.INFO, "x", Map.of());
|
||||
broken.resolve("t", "k");
|
||||
broken.heartbeat("m", 1, 1);
|
||||
assertThat(QAlerts.RAISED).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The backup alert types are declared with the Alerts qbit.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRegisterAlertTypes()
|
||||
{
|
||||
int added = new AlertsQBitAlertSink(FAKE_API).registerAlertTypes(QContext.getQInstance());
|
||||
assertThat(added).isEqualTo(BackupAlerts.alertTypes().size()).isEqualTo(9);
|
||||
assertThat(QAlertTypeMetaData.ADDED).extracting(t -> t.name).containsExactly(
|
||||
"backups.plan.missed", "backups.run.failed", "backups.run.missing_in_storage", "backups.files.missing", "backups.verify.failed",
|
||||
"backups.verify.overdue", "backups.plan.owner_invalid", "backups.storage.debris", "backups.restore.completed");
|
||||
assertThat(QAlertTypeMetaData.ADDED).allSatisfy(t ->
|
||||
{
|
||||
assertThat(t.groupName).isEqualTo("Backups");
|
||||
assertThat(t.howToFix).isNotBlank();
|
||||
assertThat(t.label).isNotBlank();
|
||||
});
|
||||
assertThat(QAlertTypeMetaData.ADDED.get(2).defaultSeverity.name()).isEqualTo("CRITICAL");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Detection: the SPI is present or not by class name; the real SPI is not in
|
||||
** RC.4, so nothing is bound by default there.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testDetectionAndBinding()
|
||||
{
|
||||
assertThat(AlertsQBitAlertSink.isPresent(FAKE_API)).isTrue();
|
||||
assertThat(AlertsQBitAlertSink.isPresent("no.such.package")).isFalse();
|
||||
|
||||
boolean realSpiPresent = AlertsQBitAlertSink.isPresent(AlertsQBitAlertSink.DEFAULT_API_PACKAGE);
|
||||
assertThat(AlertsQBitAlertSink.bindIfPresent()).isEqualTo(realSpiPresent);
|
||||
if(realSpiPresent)
|
||||
{
|
||||
assertThat(BackupsRuntime.getAlertSink()).isInstanceOf(AlertsQBitAlertSink.class);
|
||||
assertThat(AlertsQBitAlertSink.bindIfPresent()).as("an already bound sink is not replaced").isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** End to end: the SLA checker's alerts reach the Alerts qbit SPI and resolve.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSlaAlertsThroughTheAdapter() throws Exception
|
||||
{
|
||||
BackupsRuntime.setAlertSink(new AlertsQBitAlertSink(FAKE_API));
|
||||
QRecord plan = VerifyTestSupport.plan(Map.of());
|
||||
VerifyTestSupport.backup(plan);
|
||||
assertThat(QAlerts.HEARTBEATS).contains("backups.plan:nightly");
|
||||
|
||||
VerifyTestSupport.clock.advance(Duration.ofHours(30));
|
||||
new SlaChecker().check(VerifyTestSupport.reload(plan));
|
||||
assertThat(QAlerts.RAISED).anySatisfy(s ->
|
||||
{
|
||||
assertThat(s.alertTypeName).isEqualTo("backups.plan.missed");
|
||||
assertThat(s.dedupKey).isEqualTo("backups.plan.missed:nightly");
|
||||
assertThat(s.severity.name()).isEqualTo("ERROR");
|
||||
});
|
||||
|
||||
VerifyTestSupport.backup(plan);
|
||||
new SlaChecker().check(VerifyTestSupport.reload(plan));
|
||||
assertThat(QAlerts.RESOLVED).anyMatch(r -> r.startsWith("backups.plan.missed|backups.plan.missed:nightly|"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.alerts;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.RecordingAlertSink;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The alert catalog and its helpers: the spec table, stable dedupe keys, and
|
||||
** helpers that never throw into the caller.
|
||||
*******************************************************************************/
|
||||
class BackupAlertsTest
|
||||
{
|
||||
private RecordingAlertSink sink;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
BackupsRuntime.reset();
|
||||
sink = new RecordingAlertSink();
|
||||
BackupsRuntime.setAlertSink(sink);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
BackupsRuntime.reset();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every alert of the spec table (section 10.4) is declared, once, with a default
|
||||
** severity and a way to fix it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testTheSpecTable()
|
||||
{
|
||||
Set<String> names = BackupAlerts.alertTypes().stream().map(BackupAlerts.AlertType::name).collect(Collectors.toSet());
|
||||
assertThat(names).containsExactlyInAnyOrder("backups.plan.missed", "backups.run.failed", "backups.run.missing_in_storage", "backups.files.missing",
|
||||
"backups.verify.failed", "backups.verify.overdue", "backups.plan.owner_invalid", "backups.storage.debris", "backups.restore.completed");
|
||||
assertThat(BackupAlerts.alertTypes()).allSatisfy(t -> assertThat(t.howToFix()).isNotBlank());
|
||||
assertThat(BackupAlerts.alertTypes().stream().filter(t -> t.name().equals(BackupAlerts.RUN_MISSING)).findFirst().orElseThrow().defaultSeverity())
|
||||
.isEqualTo(BackupAlertSink.Severity.CRITICAL);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Dedupe keys are the type plus the plan (or plan and level), never a value.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testStableKeys()
|
||||
{
|
||||
assertThat(BackupAlerts.key(BackupAlerts.PLAN_MISSED, "nightly")).isEqualTo("backups.plan.missed:nightly");
|
||||
assertThat(BackupAlerts.verifyKey("nightly", "CONTENT")).isEqualTo("backups.verify.failed:nightly:CONTENT");
|
||||
|
||||
BackupAlerts.raise(BackupAlerts.FILES_MISSING, "nightly", BackupAlertSink.Severity.WARNING, "t", "missing", "3", "odd");
|
||||
BackupAlerts.raise(BackupAlerts.FILES_MISSING, "nightly", BackupAlertSink.Severity.WARNING, "t", "missing", "4");
|
||||
assertThat(sink.raises).hasSize(2).allSatisfy(r -> assertThat(r.dedupeKey()).isEqualTo("backups.files.missing:nightly"));
|
||||
assertThat(sink.raises.get(0).detail()).containsExactly(Map.entry("missing", "3"));
|
||||
assertThat(sink.open).hasSize(1);
|
||||
|
||||
BackupAlerts.resolve(BackupAlerts.FILES_MISSING, "nightly");
|
||||
assertThat(sink.open).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A restore is never silent: info, or error when it failed.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRestoreCompleted()
|
||||
{
|
||||
BackupAlerts.restoreCompleted("20261012T030000Z-r1", "SUCCEEDED", "nightly", 42);
|
||||
BackupAlerts.restoreCompleted("20261012T030000Z-r2", "FAILED", null, 0);
|
||||
assertThat(sink.raises).extracting(RecordingAlertSink.Raised::severity).containsExactly(BackupAlertSink.Severity.INFO, BackupAlertSink.Severity.ERROR);
|
||||
assertThat(sink.raises.get(0).detail()).containsEntry("rows", "42").containsEntry("plan", "nightly");
|
||||
assertThat(sink.raises.get(0).type()).isEqualTo("backups.restore.completed");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A sink that throws never fails the caller.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testASinkThatThrowsIsSwallowed()
|
||||
{
|
||||
BackupsRuntime.setAlertSink(new NoOpBackupAlertSink()
|
||||
{
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void raise(String type, String dedupeKey, Severity severity, String title, Map<String, String> detail)
|
||||
{
|
||||
throw (new IllegalStateException("down"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void resolve(String type, String dedupeKey)
|
||||
{
|
||||
throw (new IllegalStateException("down"));
|
||||
}
|
||||
});
|
||||
BackupAlerts.raise("t", "p", BackupAlertSink.Severity.INFO, "x");
|
||||
BackupAlerts.raiseWithKey("t", "k", BackupAlertSink.Severity.INFO, "x", Map.of());
|
||||
BackupAlerts.resolve("t", "p");
|
||||
BackupAlerts.resolveKey("t", "k");
|
||||
|
||||
new NoOpBackupAlertSink().raise("t", "k", BackupAlertSink.Severity.INFO, "x", Map.of());
|
||||
new NoOpBackupAlertSink().resolve("t", "k");
|
||||
new NoOpBackupAlertSink().heartbeat("m", 1, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.alerts.fakeapi;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test double of the Alerts producer SPI (QRun-IO/qqq PR #1108): the same shape as
|
||||
** com.kingsrook.qqq.backend.core.alerts.AlertSeverity.
|
||||
*******************************************************************************/
|
||||
public enum AlertSeverity
|
||||
{
|
||||
INFO,
|
||||
WARNING,
|
||||
ERROR,
|
||||
CRITICAL
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.alerts.fakeapi;
|
||||
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test double of the Alerts producer SPI AlertSignal (only what the adapter uses).
|
||||
*******************************************************************************/
|
||||
public class AlertSignal
|
||||
{
|
||||
public final String alertTypeName;
|
||||
public String dedupKey;
|
||||
public AlertSeverity severity;
|
||||
public String title;
|
||||
public String summary;
|
||||
public final Map<String, String> attributes = new LinkedHashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public AlertSignal(String alertTypeName)
|
||||
{
|
||||
this.alertTypeName = alertTypeName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter.
|
||||
*******************************************************************************/
|
||||
public void setDedupKey(String dedupKey)
|
||||
{
|
||||
this.dedupKey = dedupKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter.
|
||||
*******************************************************************************/
|
||||
public void setSeverity(AlertSeverity severity)
|
||||
{
|
||||
this.severity = severity;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter.
|
||||
*******************************************************************************/
|
||||
public void setTitle(String title)
|
||||
{
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter.
|
||||
*******************************************************************************/
|
||||
public void setSummary(String summary)
|
||||
{
|
||||
this.summary = summary;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent attribute.
|
||||
*******************************************************************************/
|
||||
public AlertSignal withAttribute(String key, String value)
|
||||
{
|
||||
attributes.put(key, value);
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.alerts.fakeapi;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test double of the Alerts producer SPI AlertsInstanceMetaData: of(instance) is
|
||||
** non-null only when a test says the Alerts qbit is installed.
|
||||
*******************************************************************************/
|
||||
public class AlertsInstanceMetaData
|
||||
{
|
||||
public static boolean installed = false;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The alerts meta-data of an instance, or null.
|
||||
*******************************************************************************/
|
||||
public static AlertsInstanceMetaData of(QInstance qInstance)
|
||||
{
|
||||
return (installed ? new AlertsInstanceMetaData() : null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.alerts.fakeapi;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test double of the Alerts producer SPI QAlertTypeMetaData: records the types
|
||||
** added to an instance.
|
||||
*******************************************************************************/
|
||||
public class QAlertTypeMetaData
|
||||
{
|
||||
public static final List<QAlertTypeMetaData> ADDED = new ArrayList<>();
|
||||
|
||||
public String name;
|
||||
public String label;
|
||||
public String groupName;
|
||||
public String howToFix;
|
||||
public AlertSeverity defaultSeverity;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent.
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent.
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withLabel(String label)
|
||||
{
|
||||
this.label = label;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent.
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withGroupName(String groupName)
|
||||
{
|
||||
this.groupName = groupName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent.
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withHowToFix(String howToFix)
|
||||
{
|
||||
this.howToFix = howToFix;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent.
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withDefaultSeverity(AlertSeverity defaultSeverity)
|
||||
{
|
||||
this.defaultSeverity = defaultSeverity;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add to the instance (recorded).
|
||||
*******************************************************************************/
|
||||
public void addSelfToInstance(QInstance qInstance)
|
||||
{
|
||||
ADDED.add(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.alerts.fakeapi;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test double of the Alerts producer SPI QAlerts: static calls that record what
|
||||
** they were given.
|
||||
*******************************************************************************/
|
||||
public final class QAlerts
|
||||
{
|
||||
public static final List<AlertSignal> RAISED = new ArrayList<>();
|
||||
public static final List<String> RESOLVED = new ArrayList<>();
|
||||
public static final List<String> HEARTBEATS = new ArrayList<>();
|
||||
|
||||
/** When set, every call throws it (the SPI is meant not to, but the adapter must survive). */
|
||||
public static RuntimeException failWith;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private QAlerts()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Forget everything.
|
||||
*******************************************************************************/
|
||||
public static void reset()
|
||||
{
|
||||
RAISED.clear();
|
||||
RESOLVED.clear();
|
||||
HEARTBEATS.clear();
|
||||
failWith = null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Raise.
|
||||
*******************************************************************************/
|
||||
public static void raise(AlertSignal signal)
|
||||
{
|
||||
maybeFail();
|
||||
RAISED.add(signal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve.
|
||||
*******************************************************************************/
|
||||
public static void resolve(String alertTypeName, String dedupKey, String note)
|
||||
{
|
||||
maybeFail();
|
||||
RESOLVED.add(alertTypeName + "|" + dedupKey + "|" + note);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Heartbeat.
|
||||
*******************************************************************************/
|
||||
public static void heartbeat(String heartbeatKey)
|
||||
{
|
||||
maybeFail();
|
||||
HEARTBEATS.add(heartbeatKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void maybeFail()
|
||||
{
|
||||
if(failWith != null)
|
||||
{
|
||||
throw (failWith);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.events;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.BackupsTestUtils;
|
||||
import com.kingsrook.qbits.backups.VerifyTestSupport;
|
||||
import com.kingsrook.qbits.backups.run.BackupRunResult;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** ESB events are fire and forget: published for run outcomes, never allowed to
|
||||
** fail a backup, and silent when no destination is configured.
|
||||
*******************************************************************************/
|
||||
class BackupEventsTest
|
||||
{
|
||||
private final List<String> types = new ArrayList<>();
|
||||
private final List<Map<String, Serializable>> datas = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
VerifyTestSupport.setUp(3);
|
||||
BackupEvents.setPublisher((type, data) ->
|
||||
{
|
||||
types.add(type);
|
||||
datas.add(data);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
BackupEvents.setPublisher(null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A successful run publishes run.succeeded; a failed one run.failed.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRunEvents() throws Exception
|
||||
{
|
||||
QRecord plan = VerifyTestSupport.plan(Map.of());
|
||||
BackupRunResult result = VerifyTestSupport.backup(plan);
|
||||
assertThat(types).containsExactly("qqq.backups.run.succeeded");
|
||||
assertThat(datas.get(0)).containsEntry("plan", "nightly").containsEntry("run", result.getRunKey()).containsEntry("rows", result.getRows());
|
||||
|
||||
BackupsTestUtils.BACKUPS.withFailWritesFor(key -> key.contains("/tables/"));
|
||||
VerifyTestSupport.backup(plan);
|
||||
assertThat(types).containsExactly("qqq.backups.run.succeeded", "qqq.backups.run.failed");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cancelled runs publish nothing; restores publish restore.completed.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testCancelledAndRestore()
|
||||
{
|
||||
BackupEvents.runFinished("nightly", "r1", "CANCELLED", 0, 0);
|
||||
BackupEvents.runFinished("nightly", "r2", "TIMED_OUT", 0, 0);
|
||||
BackupEvents.restoreCompleted("rr1", "SUCCEEDED", 12);
|
||||
assertThat(types).containsExactly("qqq.backups.run.failed", "qqq.backups.restore.completed");
|
||||
assertThat(datas.get(1)).containsEntry("restoreRun", "rr1").containsEntry("rows", 12L);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A publisher that throws never fails the run.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testPublishFailureNeverFailsTheBackup() throws Exception
|
||||
{
|
||||
BackupEvents.setPublisher((type, data) ->
|
||||
{
|
||||
throw (new IllegalStateException("bus down"));
|
||||
});
|
||||
BackupRunResult result = VerifyTestSupport.backup(VerifyTestSupport.plan(Map.of()));
|
||||
assertThat(result.getStatus()).startsWith("SUCCEEDED");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With no destination configured nothing is published; with one, the ESB is
|
||||
** tried and a missing or unconfigured ESB is swallowed.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEsbDefault() throws Exception
|
||||
{
|
||||
BackupEvents.setPublisher(null);
|
||||
BackupEvents.publish("qqq.backups.run.succeeded", Map.of());
|
||||
|
||||
BackupsRuntime.getConfig().withEsbDestinationName("backups-events");
|
||||
BackupEvents.publish("qqq.backups.run.succeeded", Map.of("plan", "nightly"));
|
||||
BackupEvents.publish("qqq.backups.run.succeeded", Map.of("plan", "nightly"));
|
||||
assertThat(VerifyTestSupport.backup(VerifyTestSupport.plan(Map.of())).getStatus()).startsWith("SUCCEEDED");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.health;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.VerifyTestSupport;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.middleware.health.model.metadata.HealthCheckResult;
|
||||
import com.kingsrook.qqq.middleware.health.model.metadata.HealthStatus;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The health indicator: DOWN only when a critical, active plan is LATE, FAILING or
|
||||
** MISSING_IN_STORAGE.
|
||||
*******************************************************************************/
|
||||
class BackupsHealthIndicatorTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
VerifyTestSupport.setUp(3);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void setStatus(QRecord plan, String status) throws Exception
|
||||
{
|
||||
new BackupCatalog().update(BackupsTables.PLAN, plan.getValueInteger("id"), Map.of("slaStatus", status));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** UP with details when nothing critical is wrong, including a non-critical plan
|
||||
** that is LATE; DOWN when a critical plan is LATE, FAILING or MISSING_IN_STORAGE.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testUpAndDown() throws Exception
|
||||
{
|
||||
BackupsHealthIndicator indicator = new BackupsHealthIndicator();
|
||||
assertThat(indicator.getName()).isEqualTo("backups");
|
||||
|
||||
HealthCheckResult empty = indicator.check(QContext.getQInstance());
|
||||
assertThat(empty.getStatus()).isEqualTo(HealthStatus.UP);
|
||||
assertThat(empty.getDetails()).containsEntry("activePlans", 0);
|
||||
|
||||
QRecord critical = VerifyTestSupport.plan(Map.of("critical", true));
|
||||
QRecord ordinary = VerifyTestSupport.plan(Map.of("name", "Other", "planKey", "other"));
|
||||
VerifyTestSupport.plan(Map.of("name", "Off", "planKey", "off", "isActive", false, "critical", true));
|
||||
|
||||
setStatus(critical, "OK");
|
||||
setStatus(ordinary, "LATE");
|
||||
HealthCheckResult up = indicator.check(QContext.getQInstance());
|
||||
assertThat(up.getStatus()).isEqualTo(HealthStatus.UP);
|
||||
assertThat(up.getDetails()).containsEntry("activePlans", 2).doesNotContainKey("criticalPlansWithProblems");
|
||||
assertThat(up.getDurationMs()).isNotNull();
|
||||
|
||||
for(String status : new String[] { "LATE", "FAILING", "MISSING_IN_STORAGE" })
|
||||
{
|
||||
setStatus(critical, status);
|
||||
HealthCheckResult down = indicator.check(QContext.getQInstance());
|
||||
assertThat(down.getStatus()).as(status).isEqualTo(HealthStatus.DOWN);
|
||||
assertThat(down.getDetails().get("criticalPlansWithProblems").toString()).contains("nightly: " + status);
|
||||
}
|
||||
|
||||
setStatus(critical, "UNVERIFIED");
|
||||
assertThat(indicator.check(QContext.getQInstance()).getStatus()).as("not verified is not down").isEqualTo(HealthStatus.UP);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** On a thread with no QQQ context (a health request) the indicator makes its own
|
||||
** and cleans up.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testWorksWithoutAContext() throws Exception
|
||||
{
|
||||
QInstance qInstance = QContext.getQInstance();
|
||||
QContext.clear();
|
||||
try
|
||||
{
|
||||
assertThat(new BackupsHealthIndicator().check(qInstance).getStatus()).isEqualTo(HealthStatus.UP);
|
||||
assertThat(QContext.getQInstance()).isNull();
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.init(qInstance, new com.kingsrook.qqq.backend.core.model.session.QSession());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.metrics;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.BackupsTestUtils;
|
||||
import com.kingsrook.qbits.backups.VerifyTestSupport;
|
||||
import com.kingsrook.qbits.backups.run.BackupRunResult;
|
||||
import com.kingsrook.qbits.backups.verify.BackupVerifier;
|
||||
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 Prometheus text, computed from the tables.
|
||||
*******************************************************************************/
|
||||
class BackupsMetricsTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
VerifyTestSupport.setUp(3);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The five gauges, labeled with the instance and the plan, from real runs.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRender() throws Exception
|
||||
{
|
||||
QRecord plan = VerifyTestSupport.plan(Map.of());
|
||||
BackupRunResult result = VerifyTestSupport.backup(plan);
|
||||
new BackupVerifier().verify(result.getRunId(), BackupVerifier.CONTENT, "MANUAL", "t");
|
||||
|
||||
String text = BackupsMetrics.render();
|
||||
String labels = "{instance_name=\"testapp\",plan=\"nightly\"}";
|
||||
long now = VerifyTestSupport.clock.instant().getEpochSecond();
|
||||
assertThat(text).contains("# TYPE qqq_backup_last_success_timestamp_seconds gauge")
|
||||
.contains("qqq_backup_last_success_timestamp_seconds" + labels + " " + now + "\n")
|
||||
.contains("qqq_backup_last_run_failed" + labels + " 0\n")
|
||||
.contains("qqq_backup_sla_seconds" + labels + " 93600\n")
|
||||
.contains("qqq_backup_last_verify_success_timestamp_seconds" + labels + " " + now + "\n")
|
||||
.contains("qqq_backup_last_success_bytes" + labels + " ");
|
||||
|
||||
BackupsTestUtils.BACKUPS.withFailWritesFor(key -> key.contains("/tables/"));
|
||||
VerifyTestSupport.backup(plan);
|
||||
assertThat(BackupsMetrics.render()).contains("qqq_backup_last_run_failed" + labels + " 1\n");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cached for 60 seconds; label values are escaped; the config flag is read.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testCacheEscapeAndFlag() throws Exception
|
||||
{
|
||||
assertThat(BackupsMetrics.isEnabled()).isFalse();
|
||||
BackupsRuntime.getConfig().withPrometheusEndpoint(true);
|
||||
assertThat(BackupsMetrics.isEnabled()).isTrue();
|
||||
assertThat(BackupsMetrics.ENDPOINT_PATH).isEqualTo("/metrics/backups");
|
||||
|
||||
String empty = BackupsMetrics.renderCached();
|
||||
assertThat(empty).doesNotContain("{instance_name");
|
||||
|
||||
VerifyTestSupport.backup(VerifyTestSupport.plan(Map.of()));
|
||||
VerifyTestSupport.clock.advance(Duration.ofSeconds(30));
|
||||
assertThat(BackupsMetrics.renderCached()).as("cached").isEqualTo(empty);
|
||||
VerifyTestSupport.clock.advance(Duration.ofSeconds(31));
|
||||
assertThat(BackupsMetrics.renderCached()).contains("plan=\"nightly\"");
|
||||
|
||||
assertThat(BackupsMetrics.escape("a\"b\\c\nd")).isEqualTo("a\\\"b\\\\c\\nd");
|
||||
assertThat(BackupsMetrics.escape(null)).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.processes;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.BackupsQBitConfig;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.BackupsTestUtils;
|
||||
import com.kingsrook.qbits.backups.VerifyTestSupport;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.permissions.BackupsPermissions;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qbits.backups.run.BackupRunResult;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
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.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
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 Verify backup record action (a streamed ETL with Review and Result lines)
|
||||
** and the scheduled SLA check.
|
||||
*******************************************************************************/
|
||||
class VerifyProcessesTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
VerifyTestSupport.setUp(7);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Both processes are defined on the instance with the right permissions; neither
|
||||
** is in the nav, and the label is plain.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testDefinitions()
|
||||
{
|
||||
QProcessMetaData verify = QContext.getQInstance().getProcess(VerifyProcesses.VERIFY);
|
||||
assertThat(verify).isNotNull();
|
||||
assertThat(verify.getLabel()).isEqualTo("Verify backup");
|
||||
assertThat(verify.getTableName()).isEqualTo(BackupsTables.RUN);
|
||||
assertThat(QContext.getQInstance().getProcess(VerifyProcesses.CHECK_SLA)).isNotNull();
|
||||
assertThat(BackupsRuntime.processPermissions()).containsEntry("backupVerify", BackupsPermissions.RUN).containsEntry("backupCheckSla", BackupsPermissions.RUN);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The SLA check is scheduled every slaCheckIntervalSeconds when a scheduler is
|
||||
** configured, independent of any backup trigger; otherwise it is unscheduled.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSlaCheckSchedule()
|
||||
{
|
||||
assertThat(VerifyProcesses.checkSlaProcess(BackupsRuntime.getConfig()).getSchedule()).isNull();
|
||||
assertThat(VerifyProcesses.checkSlaProcess(null).getSchedule()).isNull();
|
||||
|
||||
BackupsQBitConfig scheduled = new BackupsQBitConfig().withBackendName("memory").withSchedulerName("quartz").withSlaCheckIntervalSeconds(120);
|
||||
QProcessMetaData process = VerifyProcesses.checkSlaProcess(scheduled);
|
||||
assertThat(process.getSchedule().getSchedulerName()).isEqualTo("quartz");
|
||||
assertThat(process.getSchedule().getRepeatSeconds()).isEqualTo(120);
|
||||
assertThat(process.getSchedule().getCronExpression()).isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The scheduled step checks every active plan.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testCheckSlaStep() throws Exception
|
||||
{
|
||||
VerifyTestSupport.backup(VerifyTestSupport.plan(Map.of()));
|
||||
VerifyTestSupport.plan(Map.of("name", "Late", "planKey", "late", "storageBucketAlias", "backups"));
|
||||
VerifyTestSupport.clock.advance(java.time.Duration.ofHours(30));
|
||||
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName(VerifyProcesses.CHECK_SLA);
|
||||
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
|
||||
RunProcessOutput output = new RunProcessAction().execute(input);
|
||||
assertThat(output.getException()).isEmpty();
|
||||
assertThat(output.getValues()).containsEntry("plansChecked", 2).containsEntry("plansNeedingAttention", 2L);
|
||||
assertThat(new BackupCatalog().where(BackupsTables.PLAN, "planKey", "late").get(0).getValueString("slaStatus")).isEqualTo("LATE");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Verify backup on a good run: Review lists what will be checked without reading
|
||||
** anything; the run passes, and the Result lines link back to the run.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testVerifyGoodRun() throws Exception
|
||||
{
|
||||
BackupRunResult result = VerifyTestSupport.backup(VerifyTestSupport.plan(Map.of()));
|
||||
assertThat(new BackupCatalog().where(BackupsTables.VERIFICATION, "backupRunId", result.getRunId())).isEmpty();
|
||||
|
||||
RunProcessInput input = input(result.getRunId());
|
||||
RunProcessOutput output = new RunProcessAction().execute(input);
|
||||
List<ProcessSummaryLineInterface> review = lines(output, StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY);
|
||||
assertThat(review).extracting(l -> ((ProcessSummaryLine) l).getStatus()).contains(Status.OK, Status.INFO);
|
||||
assertThat(new BackupCatalog().where(BackupsTables.VERIFICATION, "backupRunId", result.getRunId())).as("Review reads nothing and writes nothing").isEmpty();
|
||||
|
||||
input.setStartAfterStep("review");
|
||||
output = new RunProcessAction().execute(input);
|
||||
assertThat(output.getException()).isEmpty();
|
||||
List<ProcessSummaryLineInterface> summary = lines(output, StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY);
|
||||
ProcessSummaryLine ok = (ProcessSummaryLine) summary.stream().filter(l -> ((ProcessSummaryLine) l).getStatus() == Status.OK).findFirst().orElseThrow();
|
||||
assertThat(ok.getCount()).isEqualTo(1);
|
||||
assertThat(ok.getPrimaryKeys()).containsExactly(result.getRunId());
|
||||
assertThat(new BackupCatalog().where(BackupsTables.VERIFICATION, "backupRunId", result.getRunId())).hasSize(2);
|
||||
assertThat(VerifyTestSupport.run(result.getRunId()).getValueString("verificationStatus")).isEqualTo("CONTENT_OK");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A corrupted run fails with the first mismatch in the Result line; a run with no
|
||||
** manifest is refused at Review.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testVerifyBadRunsAndRefusals() throws Exception
|
||||
{
|
||||
BackupRunResult result = VerifyTestSupport.backup(VerifyTestSupport.plan(Map.of()));
|
||||
String key = VerifyTestSupport.firstPartKey(result, "blogPost");
|
||||
byte[] bytes = BackupsTestUtils.BACKUPS.getBytes(key).clone();
|
||||
bytes[bytes.length / 2] ^= 0x55;
|
||||
BackupsTestUtils.BACKUPS.corrupt(key, bytes);
|
||||
|
||||
Integer noManifest = new BackupCatalog().insert(BackupsTables.RUN, new java.util.LinkedHashMap<>(Map.<String, Serializable>of("runKey", "x1", "status", "FAILED")));
|
||||
Integer pruned = new BackupCatalog().insert(BackupsTables.RUN, new java.util.LinkedHashMap<>(Map.<String, Serializable>of("runKey", "x2", "status", "SUCCEEDED", "manifestKey", "m", "prunedDate", java.time.Instant.now())));
|
||||
|
||||
RunProcessInput input = input(result.getRunId(), noManifest, pruned);
|
||||
input.addValue(VerifyProcesses.FIELD_VERIFY_LEVEL, "STORAGE");
|
||||
RunProcessOutput output = new RunProcessAction().execute(input);
|
||||
List<ProcessSummaryLineInterface> review = lines(output, StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY);
|
||||
assertThat(review.stream().filter(l -> ((ProcessSummaryLine) l).getStatus() == Status.ERROR)).hasSize(2);
|
||||
|
||||
input.addValue(VerifyProcesses.FIELD_VERIFY_LEVEL, "CONTENT");
|
||||
input.setStartAfterStep("review");
|
||||
output = new RunProcessAction().execute(input);
|
||||
List<ProcessSummaryLineInterface> summary = lines(output, StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY);
|
||||
ProcessSummaryLine failed = (ProcessSummaryLine) summary.stream().filter(l -> ((ProcessSummaryLine) l).getStatus() == Status.ERROR).findFirst().orElseThrow();
|
||||
assertThat(failed.getCount()).isEqualTo(1);
|
||||
assertThat(failed.getMessageSuffix()).contains("corrupted");
|
||||
assertThat(VerifyTestSupport.run(result.getRunId()).getValueString("verificationStatus")).isEqualTo("FAILED");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private RunProcessInput input(Integer... runIds)
|
||||
{
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName(VerifyProcesses.VERIFY);
|
||||
input.addValue(StreamedETLWithFrontendProcess.FIELD_DEFAULT_QUERY_FILTER, new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.IN, List.of((Serializable[]) runIds))));
|
||||
return (input);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<ProcessSummaryLineInterface> lines(RunProcessOutput output, String field)
|
||||
{
|
||||
return ((List<ProcessSummaryLineInterface>) output.getValues().get(field));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
/*
|
||||
* 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.restore;
|
||||
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.BackupsTestUtils;
|
||||
import com.kingsrook.qbits.backups.codec.HashingOutputStream;
|
||||
import com.kingsrook.qbits.backups.manifest.BackupManifest;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qbits.backups.run.BackupRunResult;
|
||||
import com.kingsrook.qbits.backups.storage.StoredObjectInfo;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
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.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.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;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Import backups from storage (spec 9.6): the database with the catalog is gone,
|
||||
** a fresh instance imports the catalog from the manifests in storage and restores
|
||||
** from it, files included.
|
||||
*******************************************************************************/
|
||||
class CatalogImportTest
|
||||
{
|
||||
private String originalPostsHash;
|
||||
private BackupManifest originalManifest;
|
||||
private Map<String, byte[]> snapshot;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Back up an instance, keep what is in the backup bucket, then start over: a fresh
|
||||
** instance (empty tables, empty catalog, empty media) that only has the bucket.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
BackupsTestUtils.init();
|
||||
BackupsTestUtils.loadBlog(30);
|
||||
BackupRunResult run = RestoreTestSupport.backup(Map.of());
|
||||
originalPostsHash = RestoreTestSupport.contentHash("blogPost");
|
||||
originalManifest = RestoreTestSupport.manifest(run);
|
||||
|
||||
snapshot = new LinkedHashMap<>();
|
||||
for(StoredObjectInfo info : BackupsTestUtils.BACKUPS.list(""))
|
||||
{
|
||||
snapshot.put(info.key(), BackupsTestUtils.BACKUPS.getBytes(info.key()));
|
||||
}
|
||||
|
||||
BackupsTestUtils.init();
|
||||
snapshot.forEach(BackupsTestUtils.BACKUPS::putBytes);
|
||||
assertThat(new BackupCatalog().query(BackupsTables.RUN, new QQueryFilter())).as("a fresh catalog").isEmpty();
|
||||
assertThat(RestoreTestSupport.rows("blogPost")).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The manifests are found, validated and imported (trigger IMPORTED), the content
|
||||
** addressed files are registered again, and importing twice changes nothing.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testImportRebuildsTheCatalog() throws Exception
|
||||
{
|
||||
CatalogImporter importer = new CatalogImporter();
|
||||
List<String> keys = importer.findManifestKeys(BackupsTestUtils.BACKUPS, "qqq-backups/");
|
||||
assertThat(keys).hasSize(1);
|
||||
|
||||
CatalogImporter.Inspection inspection = importer.inspect(BackupsTestUtils.BACKUPS, keys.get(0));
|
||||
assertThat(inspection.status()).isEqualTo(CatalogImporter.Inspection.NEW);
|
||||
assertThat(inspection.manifestSha256()).isEqualTo(HashingOutputStream.sha256Hex(BackupsTestUtils.BACKUPS.getBytes(keys.get(0))));
|
||||
|
||||
Integer runId = importer.importRun("backups", BackupsTestUtils.BACKUPS, inspection);
|
||||
QRecord run = new BackupCatalog().get(BackupsTables.RUN, runId).orElseThrow();
|
||||
assertThat(run.getValueString("trigger")).isEqualTo("IMPORTED");
|
||||
assertThat(run.getValueString("runKey")).isEqualTo(originalManifest.runKey());
|
||||
assertThat(run.getValueString("status")).isEqualTo("SUCCEEDED_WITH_WARNINGS");
|
||||
assertThat(run.getValueString("manifestKey")).isEqualTo(keys.get(0));
|
||||
assertThat(run.getValueString("manifestSha256")).isEqualTo(inspection.manifestSha256());
|
||||
assertThat(run.getValueString("storageBucketAlias")).isEqualTo("backups");
|
||||
assertThat(run.getValueString("instanceName")).isEqualTo("testapp");
|
||||
assertThat(run.getValueLong("rowsTotal")).isEqualTo(originalManifest.totals().rows());
|
||||
assertThat(run.getValueInteger("tablesPlanned")).isEqualTo(originalManifest.tables().size());
|
||||
assertThat(run.getValueString("verificationStatus")).isEqualTo("UNVERIFIED");
|
||||
|
||||
List<QRecord> artifacts = new BackupCatalog().where(BackupsTables.ARTIFACT, "backupRunId", runId);
|
||||
long parts = originalManifest.tables().stream().mapToLong(t -> t.parts().size()).sum();
|
||||
assertThat(artifacts).hasSize((int) parts + 3);
|
||||
assertThat(artifacts).extracting(a -> a.getValueString("artifactType")).contains("MANIFEST", "CHECKSUMS", "FILE_OBJECTS_INDEX", "TABLE_DATA");
|
||||
QRecord posts = artifacts.stream().filter(a -> "blogPost".equals(a.getValueString("tableName"))).findFirst().orElseThrow();
|
||||
assertThat(posts.getValueString("sha256Stored")).isEqualTo(originalManifest.table("blogPost").parts().get(0).sha256Stored());
|
||||
assertThat(posts.getValueString("rowsHash")).isEqualTo(originalManifest.table("blogPost").rowsHash());
|
||||
|
||||
assertThat(new BackupCatalog().query(BackupsTables.FILE_OBJECT, new QQueryFilter())).as("the file store is registered again").hasSize(3);
|
||||
|
||||
assertThat(importer.inspect(BackupsTestUtils.BACKUPS, keys.get(0)).status()).isEqualTo(CatalogImporter.Inspection.EXISTS);
|
||||
assertThat(new BackupCatalog().query(BackupsTables.RUN, new QQueryFilter())).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The point of the catalog import: restore from the imported run into the fresh
|
||||
** instance and get the original rows (same hash) and the files (same checksum).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRestoreFromTheImportedCatalog() throws Exception
|
||||
{
|
||||
CatalogImporter importer = new CatalogImporter();
|
||||
CatalogImporter.Inspection inspection = importer.inspect(BackupsTestUtils.BACKUPS, importer.findManifestKeys(BackupsTestUtils.BACKUPS, "qqq-backups/").get(0));
|
||||
Integer runId = importer.importRun("backups", BackupsTestUtils.BACKUPS, inspection);
|
||||
|
||||
RestoreRequest request = new RestoreRequest().withSourceRunId(runId).withMode(RestoreRequest.MODE_INSERT_NEW).withRequestedBy("tester");
|
||||
RestoreOutcome review = new RestoreEngine().review(request);
|
||||
assertThat(review.getErrors()).isEmpty();
|
||||
assertThat(review.getTables().get("author").getInserted()).isEqualTo(3);
|
||||
assertThat(review.getTables().get("blogPost").getInserted()).isEqualTo(30);
|
||||
assertThat(review.getTables().get("blogPost").getFilesToRestore()).isEqualTo(3);
|
||||
|
||||
RestoreOutcome outcome = new RestoreEngine().execute(request);
|
||||
assertThat(outcome.getTables().get("blogPost").getInserted()).isEqualTo(30);
|
||||
assertThat(outcome.getTables().get("blogPost").getFilesRestored()).isEqualTo(3);
|
||||
assertThat(outcome.getFilesFailed()).isZero();
|
||||
|
||||
assertThat(RestoreTestSupport.rows("author")).hasSize(3);
|
||||
assertThat(RestoreTestSupport.contentHash("blogPost")).isEqualTo(originalPostsHash);
|
||||
for(int i = 0; i < 3; i++)
|
||||
{
|
||||
assertThat(new String(BackupsTestUtils.MEDIA.getBytes("media/photo-" + i + ".jpg"))).isEqualTo("photo bytes " + i);
|
||||
}
|
||||
assertThat(new BackupCatalog().where(BackupsTables.RESTORE_RUN, "sourceBackupRunId", runId)).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A manifest whose parts are damaged is reported, not imported.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testDamagedBackupsAreNotImported() throws Exception
|
||||
{
|
||||
CatalogImporter importer = new CatalogImporter();
|
||||
String key = importer.findManifestKeys(BackupsTestUtils.BACKUPS, "qqq-backups/").get(0);
|
||||
String partKey = key.substring(0, key.length() - "manifest.json".length()) + originalManifest.table("blogPost").parts().get(0).key();
|
||||
BackupsTestUtils.BACKUPS.corrupt(partKey, new byte[] { 1, 2, 3 });
|
||||
|
||||
CatalogImporter.Inspection inspection = importer.inspect(BackupsTestUtils.BACKUPS, key);
|
||||
assertThat(inspection.status()).isEqualTo(CatalogImporter.Inspection.INVALID);
|
||||
assertThat(inspection.problems()).anyMatch(p -> p.contains("wrong size"));
|
||||
assertThat(new BackupCatalog().query(BackupsTables.RUN, new QQueryFilter())).isEmpty();
|
||||
|
||||
BackupsTestUtils.BACKUPS.putBytes(key, "not a manifest".getBytes());
|
||||
assertThat(importer.inspect(BackupsTestUtils.BACKUPS, key).problems()).anyMatch(p -> p.contains("cannot be read"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Restore refuses a part that does not match its stored checksum.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRestoreRefusesADamagedPart() throws Exception
|
||||
{
|
||||
CatalogImporter importer = new CatalogImporter();
|
||||
String key = importer.findManifestKeys(BackupsTestUtils.BACKUPS, "qqq-backups/").get(0);
|
||||
Integer runId = importer.importRun("backups", BackupsTestUtils.BACKUPS, importer.inspect(BackupsTestUtils.BACKUPS, key));
|
||||
|
||||
String partKey = key.substring(0, key.length() - "manifest.json".length()) + originalManifest.table("author").parts().get(0).key();
|
||||
byte[] same = BackupsTestUtils.BACKUPS.getBytes(partKey).clone();
|
||||
same[same.length / 2] ^= 0x55;
|
||||
BackupsTestUtils.BACKUPS.corrupt(partKey, same);
|
||||
|
||||
RestoreRequest request = new RestoreRequest().withSourceRunId(runId).withTables(List.of("author")).withMode(RestoreRequest.MODE_INSERT_NEW).withPreRestoreBackup(false);
|
||||
assertThatThrownBy(() -> new RestoreEngine().execute(request)).hasMessageContaining("Could not read");
|
||||
assertThat(RestoreTestSupport.rows("author")).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The import process: Review lists what will be added, Result says what was.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testImportProcess() throws Exception
|
||||
{
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName("backupImportCatalog");
|
||||
input.addValue("bucketAlias", "backups");
|
||||
input.addValue("prefix", "qqq-backups/");
|
||||
|
||||
RunProcessOutput output = new RunProcessAction().execute(input);
|
||||
assertThat(output.getException()).isEmpty();
|
||||
assertThat(output.getProcessState().getNextStepName()).isPresent().get().isEqualTo("input");
|
||||
input.addValue("processUUID", output.getProcessUUID());
|
||||
|
||||
input.setStartAfterStep("input");
|
||||
output = new RunProcessAction().execute(input);
|
||||
assertThat(output.getException()).isEmpty();
|
||||
List<ProcessSummaryLineInterface> review = RestoreProcessTest.lines(output, StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY);
|
||||
assertThat(RestoreTestSupport.line(review, false, "backup will be added to the catalog").getCount()).isEqualTo(1);
|
||||
assertThat(new BackupCatalog().query(BackupsTables.RUN, new QQueryFilter())).as("Review writes nothing").isEmpty();
|
||||
|
||||
input.setStartAfterStep("review");
|
||||
output = new RunProcessAction().execute(input);
|
||||
assertThat(output.getException()).isEmpty();
|
||||
List<ProcessSummaryLineInterface> result = RestoreProcessTest.lines(output, StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY);
|
||||
ProcessSummaryLine imported = RestoreTestSupport.line(result, true, "backup was added to the catalog");
|
||||
assertThat(imported.getCount()).isEqualTo(1);
|
||||
assertThat(imported.getPrimaryKeys()).hasSize(1);
|
||||
assertThat(new BackupCatalog().query(BackupsTables.RUN, new QQueryFilter())).hasSize(1);
|
||||
|
||||
RunProcessInput again = new RunProcessInput();
|
||||
again.setProcessName("backupImportCatalog");
|
||||
again.addValue("bucketAlias", "backups");
|
||||
again.addValue("prefix", "qqq-backups/");
|
||||
output = new RunProcessAction().execute(again);
|
||||
again.addValue("processUUID", output.getProcessUUID());
|
||||
again.setStartAfterStep("input");
|
||||
output = new RunProcessAction().execute(again);
|
||||
List<ProcessSummaryLineInterface> second = RestoreProcessTest.lines(output, StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY);
|
||||
assertThat(RestoreTestSupport.line(second, false, "already in the catalog").getCount()).isEqualTo(1);
|
||||
assertThat(RestoreTestSupport.line(second, false, "will be added")).isNull();
|
||||
assertThat(second.stream().map(l -> ((ProcessSummaryLine) l).getStatus())).doesNotContain(Status.ERROR);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
/*
|
||||
* 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.restore;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.BackupsTestUtils;
|
||||
import com.kingsrook.qbits.backups.codec.HashingOutputStream;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qbits.backups.run.BackupRunResult;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
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.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** End to end tests of restore (spec 13): back up 30 posts, then delete 10, edit
|
||||
** 10 and add 10; Review each mode and check the summary lines; restore and check
|
||||
** counts, hashes and files.
|
||||
*******************************************************************************/
|
||||
class RestoreModesTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
BackupsTestUtils.init();
|
||||
BackupsTestUtils.loadBlog(30);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review of each mode reports the counts the restore will produce, in the standard
|
||||
** summary lines, and writes nothing.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testReviewEachMode() throws Exception
|
||||
{
|
||||
BackupRunResult run = RestoreTestSupport.backup(Map.of());
|
||||
RestoreTestSupport.mutate();
|
||||
assertThat(RestoreTestSupport.rows("blogPost")).hasSize(30);
|
||||
|
||||
RestoreOutcome insertNew = new RestoreEngine().review(RestoreTestSupport.request(run, RestoreRequest.MODE_INSERT_NEW, "blogPost"));
|
||||
RestoreOutcome.TableOutcome t = insertNew.getTables().get("blogPost");
|
||||
assertThat(insertNew.getErrors()).isEmpty();
|
||||
assertThat(t.getRows()).isEqualTo(30);
|
||||
assertThat(t.getInserted()).isEqualTo(10);
|
||||
assertThat(t.getUpdated()).isZero();
|
||||
assertThat(t.getUnchanged()).isEqualTo(10);
|
||||
assertThat(t.getSkipped()).isEqualTo(10);
|
||||
assertThat(t.getDeleted()).isZero();
|
||||
assertThat(t.getSchemaStatus()).isEqualTo("MATCH");
|
||||
List<ProcessSummaryLineInterface> lines = RestoreSummaries.lines(insertNew, false);
|
||||
assertThat(RestoreTestSupport.line(lines, false, "records will be inserted into blogPost").getCount()).isEqualTo(10);
|
||||
assertThat(RestoreTestSupport.line(lines, false, "records are unchanged in blogPost").getCount()).isEqualTo(10);
|
||||
assertThat(RestoreTestSupport.line(lines, false, "left alone in blogPost").getCount()).isEqualTo(10);
|
||||
assertThat(RestoreTestSupport.line(lines, false, "key sequence")).isNotNull();
|
||||
|
||||
RestoreOutcome upsert = new RestoreEngine().review(RestoreTestSupport.request(run, RestoreRequest.MODE_UPSERT, "blogPost"));
|
||||
t = upsert.getTables().get("blogPost");
|
||||
assertThat(t.getInserted()).isEqualTo(10);
|
||||
assertThat(t.getUpdated()).isEqualTo(10);
|
||||
assertThat(t.getUnchanged()).isEqualTo(10);
|
||||
assertThat(t.getSkipped()).isZero();
|
||||
assertThat(t.getDeleted()).isZero();
|
||||
lines = RestoreSummaries.lines(upsert, false);
|
||||
assertThat(RestoreTestSupport.line(lines, false, "records will be updated in blogPost").getCount()).isEqualTo(10);
|
||||
assertThat(t.getSamples()).hasSize(10);
|
||||
assertThat(t.getSamples().get(0).changes()).contains("title: Edited 11 -> Post 11");
|
||||
|
||||
RestoreOutcome replace = new RestoreEngine().review(RestoreTestSupport.request(run, RestoreRequest.MODE_REPLACE, "blogPost").withConfirmationText("blogPost"));
|
||||
assertThat(replace.getErrors()).isEmpty();
|
||||
t = replace.getTables().get("blogPost");
|
||||
assertThat(t.getDeleted()).isEqualTo(10);
|
||||
assertThat(t.getInserted()).isEqualTo(10);
|
||||
assertThat(t.getUpdated()).isEqualTo(10);
|
||||
assertThat(t.getUnchanged()).isEqualTo(10);
|
||||
ProcessSummaryLine deleted = RestoreTestSupport.line(RestoreSummaries.lines(replace, false), false, "will be deleted from blogPost (REPLACE)");
|
||||
assertThat(deleted.getCount()).isEqualTo(10);
|
||||
|
||||
assertThat(RestoreTestSupport.rows("blogPost")).as("Review writes nothing").hasSize(30);
|
||||
assertThat(new BackupCatalog().query(BackupsTables.RESTORE_RUN, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter())).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** INSERT_NEW brings back the deleted rows and leaves edits and additions alone.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testExecuteInsertNew() throws Exception
|
||||
{
|
||||
BackupRunResult run = RestoreTestSupport.backup(Map.of());
|
||||
RestoreTestSupport.mutate();
|
||||
|
||||
RestoreOutcome outcome = new RestoreEngine().execute(RestoreTestSupport.request(run, RestoreRequest.MODE_INSERT_NEW, "blogPost"));
|
||||
assertThat(outcome.getTables().get("blogPost").getInserted()).isEqualTo(10);
|
||||
|
||||
List<QRecord> rows = RestoreTestSupport.rows("blogPost");
|
||||
assertThat(rows).hasSize(40);
|
||||
assertThat(rows.get(0).getValueString("title")).isEqualTo("Post 1");
|
||||
assertThat(rows.stream().filter(r -> r.getValueInteger("id") == 15).findFirst().orElseThrow().getValueString("title")).isEqualTo("Edited 15");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** UPSERT rolls edits back and restores deleted rows, keeps additions; the file that
|
||||
** was removed comes back with the right checksum, and a file with different content
|
||||
** is kept (a conflict).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testExecuteUpsertWithFiles() throws Exception
|
||||
{
|
||||
BackupRunResult run = RestoreTestSupport.backup(Map.of());
|
||||
RestoreTestSupport.mutate();
|
||||
String original1 = "photo bytes 1";
|
||||
BackupsTestUtils.MEDIA.removeIgnoringLock("media/photo-1.jpg");
|
||||
BackupsTestUtils.MEDIA.putBytes("media/photo-2.jpg", "tampered".getBytes());
|
||||
|
||||
RestoreOutcome review = new RestoreEngine().review(RestoreTestSupport.request(run, RestoreRequest.MODE_UPSERT, "blogPost"));
|
||||
RestoreOutcome.TableOutcome rt = review.getTables().get("blogPost");
|
||||
assertThat(rt.getFilesToRestore()).isEqualTo(1);
|
||||
assertThat(rt.getFilesPresent()).isEqualTo(1);
|
||||
assertThat(rt.getFilesConflicting()).isEqualTo(1);
|
||||
assertThat(rt.getFilesMissing()).isZero();
|
||||
assertThat(RestoreTestSupport.line(RestoreSummaries.lines(review, false), false, "different content for blogPost")).isNotNull();
|
||||
|
||||
RestoreOutcome outcome = new RestoreEngine().execute(RestoreTestSupport.request(run, RestoreRequest.MODE_UPSERT, "blogPost"));
|
||||
RestoreOutcome.TableOutcome t = outcome.getTables().get("blogPost");
|
||||
assertThat(t.getInserted()).isEqualTo(10);
|
||||
assertThat(t.getUpdated()).isEqualTo(10);
|
||||
assertThat(t.getFilesRestored()).isEqualTo(1);
|
||||
assertThat(outcome.getFilesFailed()).isZero();
|
||||
|
||||
List<QRecord> rows = RestoreTestSupport.rows("blogPost");
|
||||
assertThat(rows).hasSize(40);
|
||||
assertThat(rows.stream().filter(r -> r.getValueInteger("id") == 15).findFirst().orElseThrow().getValueString("title")).isEqualTo("Post 15");
|
||||
|
||||
byte[] restored = BackupsTestUtils.MEDIA.getBytes("media/photo-1.jpg");
|
||||
assertThat(new String(restored)).isEqualTo(original1);
|
||||
assertThat(HashingOutputStream.sha256Hex(restored)).isEqualTo(HashingOutputStream.sha256Hex(original1.getBytes()));
|
||||
assertThat(new String(BackupsTestUtils.MEDIA.getBytes("media/photo-2.jpg"))).as("a conflicting file is kept").isEqualTo("tampered");
|
||||
assertThat(BackupsTestUtils.MEDIA.list("media/").stream().filter(o -> o.key().contains("qqq-restore"))).as("no temporary object is left").isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** REPLACE needs the typed confirmation, deletes what is not in the backup, takes a
|
||||
** pre-restore backup first, brings the table back to exactly the backed up rows
|
||||
** (same hash), overwrites a conflicting file, and records the run rows.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testExecuteReplace() throws Exception
|
||||
{
|
||||
BackupRunResult run = RestoreTestSupport.backup(Map.of());
|
||||
String originalHash = RestoreTestSupport.contentHash("blogPost");
|
||||
RestoreTestSupport.mutate();
|
||||
assertThat(RestoreTestSupport.contentHash("blogPost")).isNotEqualTo(originalHash);
|
||||
BackupsTestUtils.MEDIA.putBytes("media/photo-2.jpg", "tampered".getBytes());
|
||||
|
||||
RestoreRequest request = RestoreTestSupport.request(run, RestoreRequest.MODE_REPLACE, "blogPost");
|
||||
assertThatThrownBy(() -> new RestoreEngine().execute(request)).hasMessageContaining("type blogPost");
|
||||
assertThat(RestoreTestSupport.rows("blogPost")).hasSize(30);
|
||||
|
||||
RestoreOutcome outcome = new RestoreEngine().execute(request.withConfirmationText("blogPost"));
|
||||
RestoreOutcome.TableOutcome t = outcome.getTables().get("blogPost");
|
||||
assertThat(t.getDeleted()).isEqualTo(10);
|
||||
assertThat(t.getInserted()).isEqualTo(10);
|
||||
assertThat(t.getUpdated()).isEqualTo(10);
|
||||
|
||||
assertThat(RestoreTestSupport.rows("blogPost")).hasSize(30);
|
||||
assertThat(RestoreTestSupport.contentHash("blogPost")).as("the rows are exactly the backed up rows again").isEqualTo(originalHash);
|
||||
assertThat(new String(BackupsTestUtils.MEDIA.getBytes("media/photo-2.jpg"))).as("REPLACE overwrites a conflicting file").isEqualTo("photo bytes 2");
|
||||
|
||||
QRecord restoreRun = new BackupCatalog().get(BackupsTables.RESTORE_RUN, outcome.getRestoreRunId()).orElseThrow();
|
||||
assertThat(restoreRun.getValueString("status")).isIn("SUCCEEDED", "SUCCEEDED_WITH_WARNINGS");
|
||||
assertThat(restoreRun.getValueString("mode")).isEqualTo("REPLACE");
|
||||
assertThat(restoreRun.getValueLong("rowsDeleted")).isEqualTo(10L);
|
||||
assertThat(restoreRun.getValueLong("rowsInserted")).isEqualTo(10L);
|
||||
assertThat(restoreRun.getValueInteger("filesRestored")).isEqualTo(1);
|
||||
assertThat(restoreRun.getValueString("confirmationText")).isEqualTo("blogPost");
|
||||
assertThat(restoreRun.getValueInteger("preRestoreBackupRunId")).isNotNull();
|
||||
QRecord pre = new BackupCatalog().get(BackupsTables.RUN, restoreRun.getValueInteger("preRestoreBackupRunId")).orElseThrow();
|
||||
assertThat(pre.getValueString("trigger")).isEqualTo("PRE_RESTORE");
|
||||
assertThat(pre.getValueString("status")).startsWith("SUCCEEDED");
|
||||
List<QRecord> tableRows = new BackupCatalog().where(BackupsTables.RESTORE_RUN_TABLE, "backupRestoreRunId", outcome.getRestoreRunId());
|
||||
assertThat(tableRows).hasSize(1);
|
||||
assertThat(tableRows.get(0).getValueString("tableName")).isEqualTo("blogPost");
|
||||
assertThat(tableRows.get(0).getValueLong("rowsDeleted")).isEqualTo(10L);
|
||||
assertThat(tableRows.get(0).getValueString("schemaStatus")).isEqualTo("MATCH");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Turning the pre-restore backup off is allowed and is recorded on the restore run.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testPreRestoreBackupCanBeTurnedOff() throws Exception
|
||||
{
|
||||
BackupRunResult run = RestoreTestSupport.backup(Map.of());
|
||||
RestoreTestSupport.mutate();
|
||||
RestoreOutcome outcome = new RestoreEngine().execute(RestoreTestSupport.request(run, RestoreRequest.MODE_INSERT_NEW, "blogPost").withPreRestoreBackup(false));
|
||||
QRecord restoreRun = new BackupCatalog().get(BackupsTables.RESTORE_RUN, outcome.getRestoreRunId()).orElseThrow();
|
||||
assertThat(restoreRun.getValueInteger("preRestoreBackupRunId")).isNull();
|
||||
assertThat(restoreRun.getValueString("scopeSummary")).contains("no pre-restore backup");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** REMAP_NEW_IDS lets the target assign ids and rewrites the references of later
|
||||
** tables through the map; the id map is cleared when the restore ends.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRemapNewIds() throws Exception
|
||||
{
|
||||
BackupRunResult run = RestoreTestSupport.backup(Map.of());
|
||||
|
||||
RestoreRequest request = RestoreTestSupport.request(run, RestoreRequest.MODE_INSERT_NEW, "author", "blogPost").withIdStrategy(RestoreRequest.IDS_REMAP);
|
||||
RestoreOutcome review = new RestoreEngine().review(request);
|
||||
assertThat(review.getTables().get("author").getInserted()).isEqualTo(3);
|
||||
assertThat(review.getTables().get("blogPost").getInserted()).isEqualTo(30);
|
||||
assertThat(review.getTables().get("blogPost").getOrphans()).as("the parents are in the backup").isZero();
|
||||
|
||||
RestoreOutcome outcome = new RestoreEngine().execute(request);
|
||||
assertThat(outcome.getTables().get("author").getInserted()).isEqualTo(3);
|
||||
|
||||
List<QRecord> authors = RestoreTestSupport.rows("author");
|
||||
assertThat(authors).hasSize(6);
|
||||
List<QRecord> posts = RestoreTestSupport.rows("blogPost");
|
||||
assertThat(posts).hasSize(60);
|
||||
QRecord newPost1 = posts.stream().filter(p -> p.getValueInteger("id") > 30 && "Post 1".equals(p.getValueString("title"))).findFirst().orElseThrow();
|
||||
QRecord newAuthor = authors.stream().filter(a -> a.getValueInteger("id") == newPost1.getValueInteger("authorId")).findFirst().orElseThrow();
|
||||
assertThat(newAuthor.getValueInteger("id")).as("the post points at a new author").isGreaterThan(3);
|
||||
assertThat(newAuthor.getValueString("name")).as("old author 2 was Grace").isEqualTo("Grace");
|
||||
assertThat(new BackupCatalog().query(BackupsTables.ID_MAP, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter())).isEmpty();
|
||||
assertThat(new BackupCatalog().get(BackupsTables.RESTORE_RUN, outcome.getRestoreRunId()).orElseThrow().getValueString("idStrategy")).isEqualTo("REMAP_NEW_IDS");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** REMAP with a unique key reuses rows that already exist (the target ids differ),
|
||||
** and the later tables point at the existing parents.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRemapMatchingOnAUniqueKey() throws Exception
|
||||
{
|
||||
RestoreTestSupport.uniqueKey("author", "name");
|
||||
BackupRunResult run = RestoreTestSupport.backup(Map.of());
|
||||
|
||||
com.kingsrook.qqq.backend.core.actions.tables.DeleteAction delete = new com.kingsrook.qqq.backend.core.actions.tables.DeleteAction();
|
||||
for(String table : List.of("blogPost", "author"))
|
||||
{
|
||||
com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput input = new com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput(table);
|
||||
List<Serializable> ids = new ArrayList<>();
|
||||
for(QRecord row : RestoreTestSupport.rows(table))
|
||||
{
|
||||
ids.add(row.getValue("id"));
|
||||
}
|
||||
input.setPrimaryKeys(ids);
|
||||
delete.execute(input);
|
||||
}
|
||||
BackupsTestUtils.insert("author", List.of(Map.of("id", 10, "name", "Linus"), Map.of("id", 20, "name", "Ada", "bio", "other bio"), Map.of("id", 30, "name", "Grace", "bio", "")));
|
||||
|
||||
RestoreRequest request = RestoreTestSupport.request(run, RestoreRequest.MODE_UPSERT, "author", "blogPost").withIdStrategy(RestoreRequest.IDS_REMAP).withMatchOn("name");
|
||||
RestoreOutcome review = new RestoreEngine().review(request);
|
||||
assertThat(review.getTables().get("author").getInserted()).isZero();
|
||||
assertThat(review.getTables().get("author").getUpdated()).as("only Ada's bio differs").isEqualTo(1);
|
||||
assertThat(review.getTables().get("author").getUnchanged()).isEqualTo(2);
|
||||
|
||||
new RestoreEngine().execute(request);
|
||||
assertThat(RestoreTestSupport.rows("author")).hasSize(3);
|
||||
assertThat(RestoreTestSupport.rows("author").stream().filter(a -> a.getValueInteger("id") == 20).findFirst().orElseThrow().getValueString("bio"))
|
||||
.startsWith("Line one");
|
||||
List<QRecord> posts = RestoreTestSupport.rows("blogPost");
|
||||
assertThat(posts).hasSize(30);
|
||||
QRecord post1 = posts.stream().filter(p -> "Post 1".equals(p.getValueString("title"))).findFirst().orElseThrow();
|
||||
assertThat(post1.getValueInteger("authorId")).as("Post 1 belonged to Grace, who is now id 30").isEqualTo(30);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unique-key conflicts and orphaned references are counted in Review.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testConflictsAndOrphans() throws Exception
|
||||
{
|
||||
RestoreTestSupport.uniqueKey("author", "name");
|
||||
BackupRunResult run = RestoreTestSupport.backup(Map.of());
|
||||
|
||||
com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput input = new com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput("author");
|
||||
input.setPrimaryKeys(List.of(1, 2));
|
||||
new com.kingsrook.qqq.backend.core.actions.tables.DeleteAction().execute(input);
|
||||
BackupsTestUtils.insert("author", List.of(Map.of("id", 5, "name", "Ada")));
|
||||
|
||||
RestoreOutcome authors = new RestoreEngine().review(RestoreTestSupport.request(run, RestoreRequest.MODE_INSERT_NEW, "author"));
|
||||
RestoreOutcome.TableOutcome a = authors.getTables().get("author");
|
||||
assertThat(a.getConflicts()).as("Ada id 1 collides with the existing Ada id 5").isEqualTo(1);
|
||||
assertThat(a.getInserted()).as("Grace id 2 can come back").isEqualTo(1);
|
||||
assertThat(a.getUnchanged()).isEqualTo(1);
|
||||
assertThat(RestoreTestSupport.line(RestoreSummaries.lines(authors, false), false, "conflicts on a unique key")).isNotNull();
|
||||
|
||||
RestoreOutcome posts = new RestoreEngine().review(RestoreTestSupport.request(run, RestoreRequest.MODE_INSERT_NEW, "blogPost"));
|
||||
assertThat(posts.getTables().get("blogPost").getOrphans()).as("posts of author 1 and 2: those ids are in neither the backup selection nor the target").isEqualTo(20);
|
||||
assertThat(RestoreTestSupport.line(RestoreSummaries.lines(posts, false), false, "reference a parent that is missing")).isNotNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The schema is compared with the backup: extra and removed fields are compatible
|
||||
** (the removed ones are skipped); a changed type blocks that table.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSchemaComparison() throws Exception
|
||||
{
|
||||
BackupRunResult run = RestoreTestSupport.backup(Map.of());
|
||||
RestoreTestSupport.mutate();
|
||||
|
||||
com.kingsrook.qqq.backend.core.context.QContext.getQInstance().getTable("author").getFields().remove("bio");
|
||||
com.kingsrook.qqq.backend.core.context.QContext.getQInstance().getTable("blogPost").getField("price").setType(QFieldType.STRING);
|
||||
|
||||
RestoreOutcome review = new RestoreEngine().review(RestoreTestSupport.request(run, RestoreRequest.MODE_UPSERT, "author", "blogPost"));
|
||||
RestoreOutcome.TableOutcome author = review.getTables().get("author");
|
||||
assertThat(author.getSchemaStatus()).isEqualTo("COMPATIBLE");
|
||||
assertThat(author.getUnmappedFields()).containsExactly("bio");
|
||||
assertThat(RestoreTestSupport.line(RestoreSummaries.lines(review, false), false, "field bio is in the backup but not in the target and will be skipped")).isNotNull();
|
||||
|
||||
RestoreOutcome.TableOutcome posts = review.getTables().get("blogPost");
|
||||
assertThat(posts.getSchemaStatus()).isEqualTo("MISMATCH");
|
||||
assertThat(posts.getStatus()).isEqualTo("SKIPPED");
|
||||
assertThat(posts.getInserted()).isZero();
|
||||
assertThat(RestoreTestSupport.line(RestoreSummaries.lines(review, false), false, "changed type from DECIMAL to STRING").getStatus())
|
||||
.isEqualTo(com.kingsrook.qqq.backend.core.model.actions.processes.Status.ERROR);
|
||||
|
||||
RestoreOutcome outcome = new RestoreEngine().execute(RestoreTestSupport.request(run, RestoreRequest.MODE_UPSERT, "author", "blogPost"));
|
||||
assertThat(outcome.getTables().get("blogPost").getStatus()).isEqualTo("SKIPPED");
|
||||
assertThat(RestoreTestSupport.rows("blogPost")).as("the incompatible table is left alone").hasSize(30);
|
||||
List<QRecord> tableRows = new BackupCatalog().where(BackupsTables.RESTORE_RUN_TABLE, "backupRestoreRunId", outcome.getRestoreRunId());
|
||||
assertThat(tableRows).extracting(r -> r.getValueString("schemaStatus")).containsExactlyInAnyOrder("COMPATIBLE", "MISMATCH");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Sensitive values are masked in the sample diff, even when they are in the backup.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSensitiveValuesAreNeverShown() throws Exception
|
||||
{
|
||||
BackupRunResult run = RestoreTestSupport.backup(Map.of("sensitiveFieldPolicy", "INCLUDE"));
|
||||
UpdateInput update = new UpdateInput("blogPost");
|
||||
update.setRecords(List.of(new QRecord().withValue("id", 1).withValue("secretNote", "brand-new-secret")));
|
||||
new UpdateAction().execute(update);
|
||||
|
||||
RestoreOutcome review = new RestoreEngine().review(RestoreTestSupport.request(run, RestoreRequest.MODE_UPSERT, "blogPost"));
|
||||
RestoreOutcome.TableOutcome t = review.getTables().get("blogPost");
|
||||
assertThat(t.getUpdated()).isEqualTo(1);
|
||||
assertThat(t.getSamples()).hasSize(1);
|
||||
assertThat(t.getSamples().get(0).changes()).containsExactly("secretNote: ******** -> ********");
|
||||
|
||||
String everything = RestoreSummaries.lines(review, false).stream().map(l -> String.valueOf(((ProcessSummaryLine) l).getBulletsOfText())).reduce("", String::concat);
|
||||
assertThat(everything).doesNotContain("brand-new-secret").doesNotContain("do-not-leak");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Per table filters restrict the restore to selected records.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testFilteredRestore() throws Exception
|
||||
{
|
||||
BackupRunResult run = RestoreTestSupport.backup(Map.of());
|
||||
RestoreTestSupport.mutate();
|
||||
|
||||
RestoreRequest request = RestoreTestSupport.request(run, RestoreRequest.MODE_UPSERT, "blogPost")
|
||||
.withFilter("blogPost", "{\"criteria\":[{\"fieldName\":\"id\",\"operator\":\"IN\",\"values\":[2,3,12]}]}");
|
||||
RestoreOutcome outcome = new RestoreEngine().execute(request);
|
||||
RestoreOutcome.TableOutcome t = outcome.getTables().get("blogPost");
|
||||
assertThat(t.getRows()).isEqualTo(3);
|
||||
assertThat(t.getInserted()).isEqualTo(2);
|
||||
assertThat(t.getUpdated()).isEqualTo(1);
|
||||
assertThat(RestoreTestSupport.rows("blogPost")).hasSize(32);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Writes go through the RestoreWriter in page transactions.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testWritesUseThePluggableWriterInPageTransactions() throws Exception
|
||||
{
|
||||
BackupRunResult run = RestoreTestSupport.backup(Map.of());
|
||||
RestoreTestSupport.mutate();
|
||||
|
||||
List<String> calls = new ArrayList<>();
|
||||
StandardRestoreWriter standard = new StandardRestoreWriter();
|
||||
RestoreWriter recording = new RestoreWriter()
|
||||
{
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> insert(com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData table, List<QRecord> records, RestoreRequest options, QBackendTransaction tx)
|
||||
throws com.kingsrook.qqq.backend.core.exceptions.QException
|
||||
{
|
||||
calls.add("insert:" + records.size() + ":" + (tx != null));
|
||||
return (standard.insert(table, records, options, tx));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> update(com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData table, List<QRecord> records, RestoreRequest options, QBackendTransaction tx)
|
||||
throws com.kingsrook.qqq.backend.core.exceptions.QException
|
||||
{
|
||||
calls.add("update:" + records.size() + ":" + (tx != null));
|
||||
return (standard.update(table, records, options, tx));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public long delete(com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData table, List<Serializable> primaryKeys, RestoreRequest options, QBackendTransaction tx)
|
||||
throws com.kingsrook.qqq.backend.core.exceptions.QException
|
||||
{
|
||||
return (standard.delete(table, primaryKeys, options, tx));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean usesCoreRestoreFlag()
|
||||
{
|
||||
return (standard.usesCoreRestoreFlag());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean canResyncSequences()
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<String> resyncSequences(java.util.Collection<com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData> tables)
|
||||
throws com.kingsrook.qqq.backend.core.exceptions.QException
|
||||
{
|
||||
tables.forEach(t -> calls.add("resync:" + t.getName()));
|
||||
return (tables.stream().map(com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData::getName).toList());
|
||||
}
|
||||
};
|
||||
|
||||
RestoreOutcome outcome = new RestoreEngine().execute(RestoreTestSupport.request(run, RestoreRequest.MODE_UPSERT, "blogPost").withWriter(recording).withPageSize(8));
|
||||
assertThat(calls).contains("insert:8:true", "insert:2:true", "update:6:true", "update:4:true");
|
||||
assertThat(calls).filteredOn(c -> !c.startsWith("resync")).allMatch(c -> c.endsWith(":true"));
|
||||
assertThat(calls).as("the key sequence of the table that got its original ids is resynced, after the writes").endsWith("resync:blogPost");
|
||||
assertThat(outcome.getSequencesResynced()).isEqualTo(1);
|
||||
assertThat(new BackupCatalog().get(BackupsTables.RESTORE_RUN, outcome.getRestoreRunId()).orElseThrow().getValueInteger("sequencesResynced")).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
* 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.restore;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
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.run.BackupCatalog;
|
||||
import com.kingsrook.qbits.backups.run.BackupRunResult;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
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.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.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;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The restore and download processes, run the way the frontend runs them: the
|
||||
** first screen, Review (the validation summary), Execute and Result.
|
||||
*******************************************************************************/
|
||||
class RestoreProcessTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
BackupsTestUtils.init();
|
||||
BackupsTestUtils.loadBlog(30);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
static List<ProcessSummaryLineInterface> lines(RunProcessOutput output, String field)
|
||||
{
|
||||
return (new ArrayList<>((List<ProcessSummaryLineInterface>) output.getValues().get(field)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Restore... on a backup run: Review shows the per-table lines and a sample diff,
|
||||
** Execute restores, Result shows what was done.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRestoreProcessEndToEnd() throws Exception
|
||||
{
|
||||
BackupRunResult run = RestoreTestSupport.backup(Map.of());
|
||||
RestoreTestSupport.mutate();
|
||||
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName("backupRestore");
|
||||
input.addValue(StreamedETLWithFrontendProcess.FIELD_DEFAULT_QUERY_FILTER, new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, run.getRunId())));
|
||||
input.addValue("tables", "blogPost");
|
||||
input.addValue("mode", "UPSERT");
|
||||
|
||||
RunProcessOutput output = new RunProcessAction().execute(input);
|
||||
assertThat(output.getException()).isEmpty();
|
||||
assertThat(output.getProcessState().getNextStepName()).as("the first screen asks for the options").isPresent().get().isEqualTo("input");
|
||||
input.addValue("processUUID", output.getProcessUUID());
|
||||
|
||||
input.setStartAfterStep("input");
|
||||
output = new RunProcessAction().execute(input);
|
||||
assertThat(output.getException()).isEmpty();
|
||||
assertThat(output.getProcessState().getNextStepName()).isPresent().get().isEqualTo("review");
|
||||
|
||||
List<ProcessSummaryLineInterface> review = lines(output, StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY);
|
||||
assertThat(RestoreTestSupport.line(review, false, "records will be inserted into blogPost").getCount()).isEqualTo(10);
|
||||
assertThat(RestoreTestSupport.line(review, false, "records will be updated in blogPost").getCount()).isEqualTo(10);
|
||||
assertThat(RestoreTestSupport.line(review, false, "records are unchanged in blogPost").getCount()).isEqualTo(10);
|
||||
ProcessSummaryLine sample = RestoreTestSupport.line(review, false, "changed records in blogPost");
|
||||
assertThat(sample.getBulletsOfText()).hasSize(10);
|
||||
assertThat(sample.getBulletsOfText().get(0)).contains("title: Edited 11 -> Post 11");
|
||||
assertThat(RestoreTestSupport.rows("blogPost")).as("Review writes nothing").hasSize(30);
|
||||
|
||||
input.setStartAfterStep("review");
|
||||
output = new RunProcessAction().execute(input);
|
||||
assertThat(output.getException()).isEmpty();
|
||||
assertThat(output.getProcessState().getNextStepName()).isPresent().get().isEqualTo("result");
|
||||
|
||||
List<ProcessSummaryLineInterface> result = lines(output, StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY);
|
||||
assertThat(RestoreTestSupport.line(result, true, "records were inserted into blogPost").getCount()).isEqualTo(10);
|
||||
assertThat(RestoreTestSupport.line(result, true, "records were updated in blogPost").getCount()).isEqualTo(10);
|
||||
assertThat(RestoreTestSupport.line(result, true, "A backup of the target tables was taken first")).isNotNull();
|
||||
assertThat(result.stream().map(l -> ((ProcessSummaryLine) l).getStatus())).doesNotContain(Status.ERROR);
|
||||
|
||||
assertThat(RestoreTestSupport.rows("blogPost")).hasSize(40);
|
||||
assertThat(new BackupCatalog().where(BackupsTables.RESTORE_RUN, "sourceBackupRunId", run.getRunId())).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Restore this table, on a table's data artifact, restores just that table.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRestoreThisTableOnAnArtifact() throws Exception
|
||||
{
|
||||
BackupRunResult run = RestoreTestSupport.backup(Map.of());
|
||||
RestoreTestSupport.mutate();
|
||||
QRecord artifact = new BackupCatalog().where(BackupsTables.ARTIFACT, "backupRunId", run.getRunId()).stream()
|
||||
.filter(a -> "blogPost".equals(a.getValueString("tableName"))).findFirst().orElseThrow();
|
||||
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName("backupRestoreTable");
|
||||
input.addValue(StreamedETLWithFrontendProcess.FIELD_DEFAULT_QUERY_FILTER, new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, artifact.getValueInteger("id"))));
|
||||
input.addValue("mode", "INSERT_NEW");
|
||||
|
||||
RunProcessOutput output = new RunProcessAction().execute(input);
|
||||
input.addValue("processUUID", output.getProcessUUID());
|
||||
input.setStartAfterStep("input");
|
||||
output = new RunProcessAction().execute(input);
|
||||
assertThat(output.getException()).isEmpty();
|
||||
assertThat(RestoreTestSupport.line(lines(output, StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY), false, "records will be inserted into blogPost").getCount()).isEqualTo(10);
|
||||
assertThat(RestoreTestSupport.line(lines(output, StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY), false, "author")).as("only that table").isNull();
|
||||
|
||||
input.setStartAfterStep("review");
|
||||
new RunProcessAction().execute(input);
|
||||
assertThat(RestoreTestSupport.rows("blogPost")).hasSize(40);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Review of a REPLACE without the typed confirmation is an error line, and
|
||||
** Execute refuses.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testReplaceWithoutConfirmationIsRefused() throws Exception
|
||||
{
|
||||
BackupRunResult run = RestoreTestSupport.backup(Map.of());
|
||||
RestoreTestSupport.mutate();
|
||||
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName("backupRestore");
|
||||
input.addValue(StreamedETLWithFrontendProcess.FIELD_DEFAULT_QUERY_FILTER, new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, run.getRunId())));
|
||||
input.addValue("tables", "blogPost");
|
||||
input.addValue("mode", "REPLACE");
|
||||
|
||||
RunProcessOutput output = new RunProcessAction().execute(input);
|
||||
input.addValue("processUUID", output.getProcessUUID());
|
||||
input.setStartAfterStep("input");
|
||||
output = new RunProcessAction().execute(input);
|
||||
ProcessSummaryLine error = RestoreTestSupport.line(lines(output, StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY), false, "To confirm a REPLACE, type blogPost");
|
||||
assertThat(error).isNotNull();
|
||||
assertThat(error.getStatus()).isEqualTo(Status.ERROR);
|
||||
|
||||
input.setStartAfterStep("review");
|
||||
assertThatThrownBy(() -> new RunProcessAction().execute(input)).hasMessageContaining("To confirm a REPLACE, type blogPost");
|
||||
assertThat(RestoreTestSupport.rows("blogPost")).hasSize(30);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Download: presigned links for the run's objects (ciphertext as stored), each one
|
||||
** a separate link, with the manifest and checksums among them.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testDownloadIssuesLinks() throws Exception
|
||||
{
|
||||
BackupRunResult run = RestoreTestSupport.backup(Map.of());
|
||||
int artifacts = new BackupCatalog().where(BackupsTables.ARTIFACT, "backupRunId", run.getRunId()).size();
|
||||
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName("backupDownload");
|
||||
input.addValue(StreamedETLWithFrontendProcess.FIELD_DEFAULT_QUERY_FILTER, new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, run.getRunId())));
|
||||
|
||||
RunProcessOutput output = new RunProcessAction().execute(input);
|
||||
assertThat(output.getException()).isEmpty();
|
||||
input.addValue("processUUID", output.getProcessUUID());
|
||||
if(output.getProcessState().getNextStepName().orElse("").equals("review"))
|
||||
{
|
||||
input.setStartAfterStep("review");
|
||||
}
|
||||
else
|
||||
{
|
||||
input.setStartAfterStep(output.getProcessState().getNextStepName().orElse(null));
|
||||
}
|
||||
|
||||
List<ProcessSummaryLineInterface> review = lines(output, StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY);
|
||||
assertThat(RestoreTestSupport.line(review, false, "files will get download links").getCount()).isEqualTo(artifacts);
|
||||
|
||||
output = new RunProcessAction().execute(input);
|
||||
assertThat(output.getException()).isEmpty();
|
||||
ProcessSummaryLine issued = RestoreTestSupport.line(lines(output, StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY), true, "download links were issued");
|
||||
assertThat(issued.getCount()).isEqualTo(artifacts);
|
||||
assertThat(issued.getBulletsOfText()).hasSize(artifacts);
|
||||
assertThat(issued.getBulletsOfText()).anyMatch(b -> b.contains("manifest.json") && b.contains("memory://") && b.contains("ttl=900"));
|
||||
assertThat(issued.getBulletsOfText()).anyMatch(b -> b.contains("checksums.sha256"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Download on one artifact links that artifact plus the manifest and checksums.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testDownloadOfOneArtifact() throws Exception
|
||||
{
|
||||
BackupRunResult run = RestoreTestSupport.backup(Map.of());
|
||||
QRecord artifact = new BackupCatalog().where(BackupsTables.ARTIFACT, "backupRunId", run.getRunId()).stream()
|
||||
.filter(a -> "blogPost".equals(a.getValueString("tableName"))).findFirst().orElseThrow();
|
||||
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName("backupDownloadArtifact");
|
||||
input.addValue(StreamedETLWithFrontendProcess.FIELD_DEFAULT_QUERY_FILTER, new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, artifact.getValueInteger("id"))));
|
||||
RunProcessOutput output = new RunProcessAction().execute(input);
|
||||
input.addValue("processUUID", output.getProcessUUID());
|
||||
input.setStartAfterStep("review");
|
||||
output = new RunProcessAction().execute(input);
|
||||
|
||||
ProcessSummaryLine issued = RestoreTestSupport.line(lines(output, StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY), true, "download links were issued");
|
||||
assertThat(issued.getCount()).isEqualTo(3);
|
||||
assertThat(issued.getBulletsOfText()).anyMatch(b -> b.contains("blogPost-part-00001"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* 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.restore;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.BackupsTestUtils;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qbits.backups.run.BackupRunResult;
|
||||
import com.kingsrook.qbits.backups.scope.BackupTableMetaData;
|
||||
import com.kingsrook.qbits.backups.scope.TableRole;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The safety rails of restore (spec 9.4) and who may run it (spec 11).
|
||||
*******************************************************************************/
|
||||
class RestoreSafetyTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
BackupsTestUtils.init();
|
||||
BackupsTestUtils.loadBlog(12);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A restore is refused while a backup that covers the same tables is running.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRefusesWhileABackupOfTheSameTablesIsRunning() throws Exception
|
||||
{
|
||||
BackupRunResult run = RestoreTestSupport.backup(Map.of());
|
||||
Integer planId = new BackupCatalog().where(BackupsTables.PLAN, "planKey", "nightly").get(0).getValueInteger("id");
|
||||
|
||||
Map<String, Serializable> live = new LinkedHashMap<>();
|
||||
live.put("backupPlanId", planId);
|
||||
live.put("runKey", "live-one");
|
||||
live.put("status", "RUNNING");
|
||||
live.put("startDate", BackupsRuntime.getClock().instant());
|
||||
live.put("heartbeatDate", BackupsRuntime.getClock().instant());
|
||||
Integer liveId = new BackupCatalog().insert(BackupsTables.RUN, live);
|
||||
|
||||
RestoreRequest request = RestoreTestSupport.request(run, RestoreRequest.MODE_INSERT_NEW, "blogPost");
|
||||
assertThat(new RestoreEngine().plan(request).errors()).anyMatch(e -> e.contains("is running"));
|
||||
assertThatThrownBy(() -> new RestoreEngine().execute(request)).hasMessageContaining("is running");
|
||||
assertThat(new BackupCatalog().where(BackupsTables.RESTORE_RUN, "sourceBackupRunId", run.getRunId())).as("nothing was started").isEmpty();
|
||||
|
||||
new BackupCatalog().update(BackupsTables.RUN, liveId, Map.of("heartbeatDate", BackupsRuntime.getClock().instant().minusSeconds(3600)));
|
||||
assertThat(new RestoreEngine().plan(request).errors()).as("a run with an old heartbeat is not running").isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** REPLACE needs the typed confirmation: the table name, or REPLACE n TABLES.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testReplaceNeedsTheTypedConfirmation() throws Exception
|
||||
{
|
||||
BackupRunResult run = RestoreTestSupport.backup(Map.of());
|
||||
|
||||
RestoreRequest one = RestoreTestSupport.request(run, RestoreRequest.MODE_REPLACE, "blogPost");
|
||||
assertThat(new RestoreEngine().plan(one).errors()).anyMatch(e -> e.contains("type blogPost"));
|
||||
assertThat(new RestoreEngine().plan(one.withConfirmationText("blogpost")).errors()).as("case matters").isNotEmpty();
|
||||
assertThat(new RestoreEngine().plan(one.withConfirmationText("blogPost")).errors()).isEmpty();
|
||||
|
||||
RestoreRequest two = RestoreTestSupport.request(run, RestoreRequest.MODE_REPLACE, "author", "blogPost");
|
||||
assertThat(new RestoreEngine().plan(two).errors()).anyMatch(e -> e.contains("REPLACE 2 TABLES"));
|
||||
assertThat(new RestoreEngine().plan(two.withConfirmationText("REPLACE 2 TABLES")).errors()).isEmpty();
|
||||
|
||||
assertThat(new RestoreEngine().plan(RestoreTestSupport.request(run, RestoreRequest.MODE_REPLACE, "blogPost").withConfirmationText("blogPost")
|
||||
.withIdStrategy(RestoreRequest.IDS_REMAP)).errors()).anyMatch(e -> e.contains("REPLACE cannot be combined with new ids"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** REPLACE also needs backups.admin, not just backups.restore.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testReplaceNeedsAdmin() throws Exception
|
||||
{
|
||||
BackupRunResult run = RestoreTestSupport.backup(Map.of());
|
||||
BackupsRuntime.getConfig().withOpenWhenAnonymous(false);
|
||||
RestoreRequest replace = RestoreTestSupport.request(run, RestoreRequest.MODE_REPLACE, "blogPost").withConfirmationText("blogPost");
|
||||
|
||||
QContext.setQSession(new QSession().withPermissions("backups.view", "backups.restore"));
|
||||
assertThat(new RestoreEngine().plan(RestoreTestSupport.request(run, RestoreRequest.MODE_UPSERT, "blogPost")).errors()).isEmpty();
|
||||
assertThat(new RestoreEngine().plan(replace).errors()).anyMatch(e -> e.contains("backups.admin"));
|
||||
|
||||
QContext.setQSession(new QSession().withPermissions("backups.view", "backups.restore", "backups.admin"));
|
||||
assertThat(new RestoreEngine().plan(replace).errors()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Restores never touch SECRET or backups tables, and temporary (EPHEMERAL) tables
|
||||
** only when they are named.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testNeverRestoresSecretBackupsOrUnnamedEphemeralTables() throws Exception
|
||||
{
|
||||
BackupRunResult run = RestoreTestSupport.backup(Map.of("includeRoles", "DATA,AUDIT,EPHEMERAL"));
|
||||
assertThat(RestoreTestSupport.manifest(run).table("userSession")).isNotNull();
|
||||
|
||||
RestoreEngine.Plan all = new RestoreEngine().plan(new RestoreRequest().withSourceRunId(run.getRunId()));
|
||||
assertThat(all.errors()).isEmpty();
|
||||
assertThat(all.order()).containsExactly("author", "blogPost");
|
||||
assertThat(all.notes()).anyMatch(n -> n.contains("userSession") && n.contains("temporary"));
|
||||
|
||||
RestoreEngine.Plan named = new RestoreEngine().plan(new RestoreRequest().withSourceRunId(run.getRunId()).withTables(List.of("userSession")));
|
||||
assertThat(named.order()).containsExactly("userSession");
|
||||
assertThat(named.warnings()).anyMatch(w -> w.contains("temporary data"));
|
||||
|
||||
assertThat(new RestoreEngine().plan(new RestoreRequest().withSourceRunId(run.getRunId()).withTables(List.of("backups_plan"))).errors())
|
||||
.anyMatch(e -> e.contains("not in this backup"));
|
||||
|
||||
BackupTableMetaData.ofOrWithNew(QContext.getQInstance().getTable("author")).withRole(TableRole.SECRET);
|
||||
RestoreEngine.Plan secret = new RestoreEngine().plan(new RestoreRequest().withSourceRunId(run.getRunId()).withTables(List.of("author")));
|
||||
assertThat(secret.errors()).anyMatch(e -> e.contains("SECRET") && e.contains("never touch"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Security: without backups.restore nobody can run a restore, through the process
|
||||
** (the core permission check) or by calling the engine. With the permission they can.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRestoreNeedsThePermission() throws Exception
|
||||
{
|
||||
BackupRunResult run = RestoreTestSupport.backup(Map.of());
|
||||
RestoreTestSupport.mutate();
|
||||
int rowsBefore = RestoreTestSupport.rows("blogPost").size();
|
||||
|
||||
BackupsRuntime.getConfig().withOpenWhenAnonymous(false);
|
||||
QContext.getQInstance().setEnforcePermissionsInCoreActions(true);
|
||||
QContext.setQSession(new QSession().withPermissions("backups.view", "backups.run"));
|
||||
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName("backupRestore");
|
||||
input.addValue("tables", "blogPost");
|
||||
input.addValue("mode", "UPSERT");
|
||||
assertThatThrownBy(() -> new RunProcessAction().execute(input)).isInstanceOf(QPermissionDeniedException.class).hasMessageContaining("backups.restore");
|
||||
|
||||
RestoreRequest request = RestoreTestSupport.request(run, RestoreRequest.MODE_UPSERT, "blogPost");
|
||||
assertThatThrownBy(() -> new RestoreEngine().execute(request)).hasMessageContaining("backups.restore");
|
||||
assertThat(new RestoreEngine().review(request).getErrors()).anyMatch(e -> e.contains("backups.restore"));
|
||||
|
||||
QContext.setQSession(new QSession());
|
||||
assertThat(RestoreTestSupport.rows("blogPost")).as("nothing was written").hasSize(rowsBefore);
|
||||
|
||||
QContext.setQSession(new QSession().withPermissions("backups.view", "backups.restore"));
|
||||
assertThat(new RestoreEngine().plan(request).errors()).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
* 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.restore;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.BackupsTestUtils;
|
||||
import com.kingsrook.qbits.backups.codec.HashingOutputStream;
|
||||
import com.kingsrook.qbits.backups.codec.JsonlRowCodec;
|
||||
import com.kingsrook.qbits.backups.manifest.BackupManifest;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qbits.backups.run.BackupEngine;
|
||||
import com.kingsrook.qbits.backups.run.BackupRunFactory;
|
||||
import com.kingsrook.qbits.backups.run.BackupRunResult;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Helpers shared by the restore tests: take a backup, mutate the data the way
|
||||
** the spec's acceptance does (delete 10, edit 10, add 10), and read tables back.
|
||||
*******************************************************************************/
|
||||
final class RestoreTestSupport
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private RestoreTestSupport()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create the nightly plan (with overrides) and run it.
|
||||
*******************************************************************************/
|
||||
static BackupRunResult backup(Map<String, Serializable> overrides) throws Exception
|
||||
{
|
||||
Map<String, Serializable> values = new LinkedHashMap<>(Map.of("name", "Nightly", "planKey", "nightly", "isActive", true,
|
||||
"scopeType", "INSTANCE", "storageBucketAlias", "backups", "slaHours", 26));
|
||||
values.putAll(overrides);
|
||||
BackupCatalog catalog = new BackupCatalog();
|
||||
Integer planId = catalog.insert(BackupsTables.PLAN, values);
|
||||
BackupRunResult result = new BackupEngine().execute(BackupRunFactory.forPlan(catalog.get(BackupsTables.PLAN, planId).orElseThrow(), "MANUAL"));
|
||||
assertThat(result.isUsable()).as(result.getErrorMessage()).isTrue();
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Back up again with the existing nightly plan.
|
||||
*******************************************************************************/
|
||||
static BackupRunResult backupAgain() throws Exception
|
||||
{
|
||||
QRecord plan = new BackupCatalog().where(BackupsTables.PLAN, "planKey", "nightly").get(0);
|
||||
BackupRunResult result = new BackupEngine().execute(BackupRunFactory.forPlan(plan, "MANUAL"));
|
||||
assertThat(result.isUsable()).as(result.getErrorMessage()).isTrue();
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The manifest of a run.
|
||||
*******************************************************************************/
|
||||
static BackupManifest manifest(BackupRunResult result) throws Exception
|
||||
{
|
||||
return (BackupManifest.read(BackupsTestUtils.BACKUPS.getBytes(result.getManifestKey())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete posts 1..10, edit posts 11..20 (title), add posts 31..40.
|
||||
*******************************************************************************/
|
||||
static void mutate() throws Exception
|
||||
{
|
||||
DeleteInput delete = new DeleteInput("blogPost");
|
||||
List<Serializable> ids = new ArrayList<>();
|
||||
for(int i = 1; i <= 10; i++)
|
||||
{
|
||||
ids.add(i);
|
||||
}
|
||||
delete.setPrimaryKeys(ids);
|
||||
new DeleteAction().execute(delete);
|
||||
|
||||
List<QRecord> edits = new ArrayList<>();
|
||||
for(int i = 11; i <= 20; i++)
|
||||
{
|
||||
edits.add(new QRecord().withValue("id", i).withValue("title", "Edited " + i));
|
||||
}
|
||||
UpdateInput update = new UpdateInput("blogPost");
|
||||
update.setRecords(edits);
|
||||
new UpdateAction().execute(update);
|
||||
|
||||
List<Map<String, Serializable>> added = new ArrayList<>();
|
||||
for(int i = 31; i <= 40; i++)
|
||||
{
|
||||
Map<String, Serializable> row = new LinkedHashMap<>();
|
||||
row.put("id", i);
|
||||
row.put("authorId", 1);
|
||||
row.put("title", "Added " + i);
|
||||
row.put("createDate", Instant.parse("2026-10-13T00:00:00Z"));
|
||||
added.add(row);
|
||||
}
|
||||
BackupsTestUtils.insert("blogPost", added);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All the rows of a table, by id.
|
||||
*******************************************************************************/
|
||||
static List<QRecord> rows(String table) throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput(table);
|
||||
input.setFilter(new QQueryFilter().withOrderBy(new QFilterOrderBy("id", true)));
|
||||
return (new QueryAction().execute(input).getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A SHA-256 over the canonical rows of a table (by id), leaving out the sensitive
|
||||
** fields (they are not backed up) and, until the core restore flag ships, the created
|
||||
** and modified dates (without the flag the standard actions set them to now).
|
||||
*******************************************************************************/
|
||||
static String contentHash(String table) throws Exception
|
||||
{
|
||||
QTableMetaData meta = QContext.getQInstance().getTable(table);
|
||||
List<String> left = new ArrayList<>();
|
||||
meta.getFields().values().stream().filter(f -> f.getIsSensitive()).forEach(f -> left.add(f.getName()));
|
||||
if(!new StandardRestoreWriter().usesCoreRestoreFlag())
|
||||
{
|
||||
left.addAll(List.of("createDate", "modifyDate"));
|
||||
}
|
||||
JsonlRowCodec codec = new JsonlRowCodec(meta, left);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for(QRecord row : rows(table))
|
||||
{
|
||||
sb.append(codec.encode(row)).append('\n');
|
||||
}
|
||||
return (HashingOutputStream.sha256Hex(sb.toString().getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A request over some tables, restoring from a run.
|
||||
*******************************************************************************/
|
||||
static RestoreRequest request(BackupRunResult run, String mode, String... tables)
|
||||
{
|
||||
return (new RestoreRequest().withSourceRunId(run.getRunId()).withMode(mode).withTables(List.of(tables)).withRequestedBy("tester"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The summary line whose message contains the text.
|
||||
*******************************************************************************/
|
||||
static ProcessSummaryLine line(List<ProcessSummaryLineInterface> lines, boolean result, String text)
|
||||
{
|
||||
for(ProcessSummaryLineInterface line : lines)
|
||||
{
|
||||
ProcessSummaryLine candidate = (ProcessSummaryLine) line;
|
||||
candidate.pickMessage(result);
|
||||
if(candidate.getMessage() != null && candidate.getMessage().contains(text))
|
||||
{
|
||||
return (candidate);
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare a unique key on a table of the fixture.
|
||||
*******************************************************************************/
|
||||
static void uniqueKey(String table, String... fields)
|
||||
{
|
||||
QContext.getQInstance().getTable(table).withUniqueKey(new UniqueKey(fields));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
/*
|
||||
* 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.run;
|
||||
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
import com.kingsrook.qbits.backups.BackupsTestUtils;
|
||||
import com.kingsrook.qbits.backups.manifest.BackupManifest;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.scope.PlanSettings;
|
||||
import com.kingsrook.qbits.backups.scope.ScopeResolver;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** End to end tests of a backup run on the memory backend: layout, manifest last,
|
||||
** canonical rows, policies, file copy and deduplication, failure handling.
|
||||
*******************************************************************************/
|
||||
class BackupEngineTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
BackupsTestUtils.init();
|
||||
BackupsTestUtils.loadBlog(7);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private QRecord plan(Map<String, java.io.Serializable> overrides) throws Exception
|
||||
{
|
||||
Map<String, java.io.Serializable> values = new java.util.LinkedHashMap<>(Map.of("name", "Nightly", "planKey", "nightly", "isActive", true,
|
||||
"scopeType", "INSTANCE", "storageBucketAlias", "backups", "slaHours", 26));
|
||||
values.putAll(overrides);
|
||||
Integer id = new BackupCatalog().insert(BackupsTables.PLAN, values);
|
||||
return (new BackupCatalog().get(BackupsTables.PLAN, id).orElseThrow());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private BackupRunResult run(QRecord plan) throws Exception
|
||||
{
|
||||
return (new BackupEngine().execute(BackupRunFactory.forPlan(plan, "MANUAL")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static String gunzip(byte[] bytes) throws IOException
|
||||
{
|
||||
return (new String(new GZIPInputStream(new ByteArrayInputStream(bytes)).readAllBytes(), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A nightly run writes parts, checksums and the manifest LAST, and the manifest
|
||||
** matches the rows and the stored bytes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRunWritesManifestAndParts() throws Exception
|
||||
{
|
||||
BackupRunResult result = run(plan(Map.of()));
|
||||
assertThat(result.getStatus()).isEqualTo("SUCCEEDED_WITH_WARNINGS");
|
||||
assertThat(result.getWarnings()).anyMatch(w -> w.contains("BEST_EFFORT"));
|
||||
assertThat(result.isUsable()).isTrue();
|
||||
|
||||
BackupManifest manifest = BackupManifest.read(BackupsTestUtils.BACKUPS.getBytes(result.getManifestKey()));
|
||||
assertThat(manifest.runKey()).isEqualTo(result.getRunKey());
|
||||
assertThat(manifest.restoreOrder()).containsSubsequence("author", "blogPost");
|
||||
assertThat(manifest.table("userSession")).as("ephemeral tables are not backed up").isNull();
|
||||
assertThat(manifest.table("backups_plan")).as("the qbit own tables are not backed up").isNull();
|
||||
|
||||
BackupManifest.TableEntry posts = manifest.table("blogPost");
|
||||
assertThat(posts.rowCount()).isEqualTo(7);
|
||||
assertThat(posts.excludedFields()).extracting(BackupManifest.ExcludedField::name).containsExactly("secretNote");
|
||||
assertThat(posts.fingerprint()).startsWith("sha256:");
|
||||
|
||||
BackupManifest.Part part = posts.parts().get(0);
|
||||
byte[] stored = BackupsTestUtils.BACKUPS.getBytes(result.getRunPrefix() + part.key());
|
||||
assertThat(com.kingsrook.qbits.backups.codec.HashingOutputStream.sha256Hex(stored)).isEqualTo(part.sha256Stored());
|
||||
String text = gunzip(stored);
|
||||
assertThat(text.split("\n")).hasSize(7);
|
||||
assertThat(text).doesNotContain("do-not-leak").doesNotContain("secretNote");
|
||||
assertThat(text.split("\n")[0]).startsWith("{\"authorId\":2,\"body\":\"Body of 1, with a comma\",\"createDate\":\"20").contains(",\"id\":1,\"mediaKey\":\"media/photo-1.jpg\",\"price\":\"13.3400\"");
|
||||
assertThat(text).contains("\"price\":\"13.3400\"").contains("\"publishedOn\":\"2026-01-02\"");
|
||||
assertThat(com.kingsrook.qbits.backups.codec.HashingOutputStream.sha256Hex(text.getBytes(StandardCharsets.UTF_8))).isEqualTo(part.sha256Raw());
|
||||
|
||||
String checksums = new String(BackupsTestUtils.BACKUPS.getBytes(result.getRunPrefix() + "checksums.sha256"), StandardCharsets.UTF_8);
|
||||
assertThat(checksums).contains(part.sha256Stored() + " " + part.key());
|
||||
|
||||
QRecord run = new BackupCatalog().get(BackupsTables.RUN, result.getRunId()).orElseThrow();
|
||||
assertThat(run.getValueString("status")).isEqualTo("SUCCEEDED_WITH_WARNINGS");
|
||||
assertThat(run.getValueString("manifestKey")).isEqualTo(result.getManifestKey());
|
||||
assertThat(run.getValueLong("rowsTotal")).isEqualTo(10L);
|
||||
assertThat(new BackupCatalog().where(BackupsTables.ARTIFACT, "backupRunId", result.getRunId())).hasSizeGreaterThanOrEqualTo(4);
|
||||
|
||||
QRecord plan = new BackupCatalog().where(BackupsTables.PLAN, "planKey", "nightly").get(0);
|
||||
assertThat(plan.getValueInteger("lastSuccessRunId")).isEqualTo(result.getRunId());
|
||||
assertThat(plan.getValueString("slaStatus")).isEqualTo("OK");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Referenced files are copied into the content-addressed store; the second run
|
||||
** copies nothing new (deduplicated), and rows referencing the same bytes share one object.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testFilesAreCopiedThenDeduplicated() throws Exception
|
||||
{
|
||||
QRecord plan = plan(Map.of());
|
||||
BackupRunResult first = run(plan);
|
||||
assertThat(first.getFilesReferenced()).isEqualTo(4);
|
||||
assertThat(first.getFilesCopied()).isEqualTo(3);
|
||||
assertThat(first.getFilesDeduplicated()).isEqualTo(1);
|
||||
assertThat(first.getFilesMissing()).isZero();
|
||||
|
||||
List<QRecord> fileObjects = new BackupCatalog().query(BackupsTables.FILE_OBJECT, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter());
|
||||
assertThat(fileObjects).hasSize(3);
|
||||
for(QRecord fileObject : fileObjects)
|
||||
{
|
||||
byte[] stored = BackupsTestUtils.BACKUPS.getBytes(fileObject.getValueString("objectKey"));
|
||||
assertThat(com.kingsrook.qbits.backups.codec.HashingOutputStream.sha256Hex(stored)).isEqualTo(fileObject.getValueString("sha256"));
|
||||
assertThat(fileObject.getValueString("objectKey")).contains("/_files/sha256/" + fileObject.getValueString("sha256").substring(0, 2) + "/");
|
||||
}
|
||||
|
||||
BackupRunResult second = run(plan);
|
||||
assertThat(second.getFilesCopied()).isZero();
|
||||
assertThat(second.getFilesDeduplicated()).isEqualTo(4);
|
||||
assertThat(new BackupCatalog().query(BackupsTables.FILE_OBJECT, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter())).hasSize(3);
|
||||
|
||||
String index = gunzip(BackupsTestUtils.BACKUPS.getBytes(first.getRunPrefix() + "files/index-00001.jsonl.gz"));
|
||||
assertThat(index.split("\n")).hasSize(4);
|
||||
assertThat(index).contains("\"status\":\"COPIED\"").contains("\"status\":\"DEDUPLICATED\"");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** REFERENCE_ONLY records the key and size without copying; a missing source is
|
||||
** listed and (under WARN) the run ends with warnings.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testReferenceOnlyAndMissingFiles() throws Exception
|
||||
{
|
||||
BackupRunResult refOnly = run(plan(Map.of("fileObjectPolicy", "REFERENCE_ONLY")));
|
||||
assertThat(refOnly.getFilesCopied()).isZero();
|
||||
assertThat(new BackupCatalog().query(BackupsTables.FILE_OBJECT, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter())).isEmpty();
|
||||
assertThat(gunzip(BackupsTestUtils.BACKUPS.getBytes(refOnly.getRunPrefix() + "files/index-00001.jsonl.gz"))).contains("REFERENCE_ONLY");
|
||||
|
||||
BackupsTestUtils.MEDIA.removeIgnoringLock("media/photo-1.jpg");
|
||||
BackupRunResult missing = run(new BackupCatalog().where(BackupsTables.PLAN, "planKey", "nightly").get(0));
|
||||
assertThat(missing.getFilesMissing()).isGreaterThan(0);
|
||||
assertThat(missing.getWarnings()).anyMatch(w -> w.contains("missing"));
|
||||
assertThat(missing.getStatus()).isEqualTo("SUCCEEDED_WITH_WARNINGS");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A storage failure mid-run leaves the run FAILED with no manifest (so it is
|
||||
** not usable and the checker treats the prefix as debris).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testStorageFailureLeavesNoManifest() throws Exception
|
||||
{
|
||||
BackupsTestUtils.BACKUPS.withFailWritesFor(key -> key.contains("/tables/blogPost/"));
|
||||
try
|
||||
{
|
||||
BackupRunResult result = run(plan(Map.of()));
|
||||
assertThat(result.getStatus()).isEqualTo("FAILED");
|
||||
assertThat(result.isUsable()).isFalse();
|
||||
assertThat(result.getErrorMessage()).contains("injected write failure");
|
||||
assertThat(BackupsTestUtils.BACKUPS.list(result.getRunPrefix() + "manifest.json")).isEmpty();
|
||||
assertThat(new BackupCatalog().get(BackupsTables.RUN, result.getRunId()).orElseThrow().getValueString("status")).isEqualTo("FAILED");
|
||||
}
|
||||
finally
|
||||
{
|
||||
BackupsTestUtils.BACKUPS.withFailWritesFor(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Required snapshots fail before anything is written (no backend can open one yet).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSnapshotRequiredFailsBeforeWriting() throws Exception
|
||||
{
|
||||
BackupRunResult result = run(plan(Map.of("consistency", "SNAPSHOT_REQUIRED")));
|
||||
assertThat(result.getStatus()).isEqualTo("FAILED");
|
||||
assertThat(BackupsTestUtils.BACKUPS.list("")).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** CSV (and gzip) output, a page size smaller than the table (keyset paging), part
|
||||
** rotation by rows, and the rowsHash staying the same whatever the format.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testCsvAndPaging() throws Exception
|
||||
{
|
||||
BackupRunResult jsonl = run(plan(Map.of("pageSize", 2)));
|
||||
BackupRunResult csv = run(plan(Map.of("name", "Csv", "planKey", "csv", "format", "CSV", "pageSize", 3)));
|
||||
|
||||
BackupManifest jm = BackupManifest.read(BackupsTestUtils.BACKUPS.getBytes(jsonl.getManifestKey()));
|
||||
BackupManifest cm = BackupManifest.read(BackupsTestUtils.BACKUPS.getBytes(csv.getManifestKey()));
|
||||
assertThat(cm.table("blogPost").rowsHash()).isEqualTo(jm.table("blogPost").rowsHash());
|
||||
assertThat(cm.table("blogPost").parts().get(0).key()).endsWith(".csv.gz");
|
||||
|
||||
String text = gunzip(BackupsTestUtils.BACKUPS.getBytes(csv.getRunPrefix() + cm.table("blogPost").parts().get(0).key()));
|
||||
List<String> records = com.kingsrook.qbits.backups.codec.CsvRowCodec.splitRecords(text);
|
||||
assertThat(records).hasSize(8);
|
||||
assertThat(records.get(0)).startsWith("authorId,body,createDate");
|
||||
String authors = gunzip(BackupsTestUtils.BACKUPS.getBytes(csv.getRunPrefix() + cm.table("author").parts().get(0).key()));
|
||||
assertThat(com.kingsrook.qbits.backups.codec.CsvRowCodec.splitRecords(authors)).hasSize(4);
|
||||
assertThat(authors).contains("\\N").contains("\"Line one\nLine two, with \"\"quotes\"\"");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scope: explicit tables with a filter and field exclusions, and SECRET never included.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testScopeRulesFilterAndExclusions() throws Exception
|
||||
{
|
||||
QRecord plan = plan(Map.of("scopeType", "TABLES"));
|
||||
new BackupCatalog().insert(BackupsTables.PLAN_SCOPE, Map.of("backupPlanId", plan.getValueInteger("id"), "action", "INCLUDE", "selectorType", "TABLE",
|
||||
"selectorValue", "blogPost", "filterJson", "{\"criteria\":[{\"fieldName\":\"published\",\"operator\":\"EQUALS\",\"values\":[true]}]}",
|
||||
"includeAssociatedChildren", false, "fieldExclusions", "body"));
|
||||
BackupRunResult result = run(plan);
|
||||
BackupManifest m = BackupManifest.read(BackupsTestUtils.BACKUPS.getBytes(result.getManifestKey()));
|
||||
assertThat(m.tables()).extracting(BackupManifest.TableEntry::name).containsExactly("blogPost");
|
||||
assertThat(m.table("blogPost").rowCount()).isEqualTo(3);
|
||||
assertThat(m.table("blogPost").excludedFields()).extracting(BackupManifest.ExcludedField::name).contains("body", "secretNote");
|
||||
assertThat(gunzip(BackupsTestUtils.BACKUPS.getBytes(result.getRunPrefix() + m.table("blogPost").parts().get(0).key()))).doesNotContain("\"body\"");
|
||||
|
||||
ScopeResolver.ResolvedScope scope = new ScopeResolver(QContext.getQInstance()).resolve(PlanSettings.adHoc().with("scopeType", "TABLES"),
|
||||
List.of(new ScopeResolver.ScopeRule(true, "TABLE", "backups_plan", null, false, null)));
|
||||
assertThat(scope.tables()).isEmpty();
|
||||
assertThat(scope.excluded()).extracting(ScopeResolver.ExcludedTable::reason).containsExactly("BACKUPS");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
/*
|
||||
* 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.sla;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.BackupsTestUtils;
|
||||
import com.kingsrook.qbits.backups.VerifyTestSupport;
|
||||
import com.kingsrook.qbits.backups.alerts.BackupAlertSink;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qbits.backups.run.BackupRunResult;
|
||||
import com.kingsrook.qbits.backups.verify.BackupVerifier;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static com.kingsrook.qbits.backups.VerifyTestSupport.backup;
|
||||
import static com.kingsrook.qbits.backups.VerifyTestSupport.clock;
|
||||
import static com.kingsrook.qbits.backups.VerifyTestSupport.plan;
|
||||
import static com.kingsrook.qbits.backups.VerifyTestSupport.reload;
|
||||
import static com.kingsrook.qbits.backups.VerifyTestSupport.sink;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every SLA state of the checker, driven by a synthetic clock, with the alerts it
|
||||
** raises and resolves recorded through a recording sink.
|
||||
*******************************************************************************/
|
||||
class SlaCheckerTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
VerifyTestSupport.setUp(7);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private SlaChecker.PlanResult check(QRecord plan) throws Exception
|
||||
{
|
||||
return (new SlaChecker().check(reload(plan)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A new plan that has not run yet is UNKNOWN, then LATE once slaHours pass; a
|
||||
** manual-only plan with no slaHours never is.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testNewPlanThenLate() throws Exception
|
||||
{
|
||||
QRecord plan = plan(Map.of("critical", true));
|
||||
assertThat(check(plan).status()).isEqualTo(SlaChecker.UNKNOWN);
|
||||
assertThat(sink.open).isEmpty();
|
||||
|
||||
clock.advance(Duration.ofHours(27));
|
||||
SlaChecker.PlanResult late = check(plan);
|
||||
assertThat(late.status()).isEqualTo(SlaChecker.LATE);
|
||||
assertThat(late.previousStatus()).isEqualTo(SlaChecker.UNKNOWN);
|
||||
assertThat(reload(plan).getValueString("slaStatus")).isEqualTo("LATE");
|
||||
BackupAlertsAssert.assertOpen("backups.plan.missed:nightly", BackupAlertSink.Severity.CRITICAL);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The plan.missed alert is error for an ordinary plan, critical for a critical
|
||||
** one, and resolves when the next run succeeds.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testLateRaisesAndResolves() throws Exception
|
||||
{
|
||||
QRecord plan = plan(Map.of());
|
||||
backup(plan);
|
||||
assertThat(check(plan).status()).isEqualTo(SlaChecker.OK);
|
||||
assertThat(sink.open).isEmpty();
|
||||
|
||||
clock.advance(Duration.ofHours(27));
|
||||
assertThat(check(plan).status()).isEqualTo(SlaChecker.LATE);
|
||||
BackupAlertsAssert.assertOpen("backups.plan.missed:nightly", BackupAlertSink.Severity.ERROR);
|
||||
assertThat(sink.openAlert("backups.plan.missed:nightly").detail()).containsEntry("plan", "nightly").containsEntry("slaHours", "26");
|
||||
|
||||
backup(plan);
|
||||
assertThat(check(plan).status()).isEqualTo(SlaChecker.OK);
|
||||
assertThat(sink.isOpen("backups.plan.missed:nightly")).isFalse();
|
||||
assertThat(sink.heartbeats).contains("backups.plan:nightly");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A manual-only plan with no slaHours has no SLA; a scheduled one defaults to 26.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testManualPlanHasNoSlaUnlessScheduled() throws Exception
|
||||
{
|
||||
QRecord manual = plan(Map.of("name", "Manual", "planKey", "manual"));
|
||||
new BackupCatalog().update(BackupsTables.PLAN, manual.getValueInteger("id"), java.util.Collections.singletonMap("slaHours", null));
|
||||
clock.advance(Duration.ofDays(3));
|
||||
assertThat(check(manual).status()).isEqualTo(SlaChecker.UNKNOWN);
|
||||
|
||||
QRecord scheduled = plan(Map.of("name", "Sched", "planKey", "sched", "cronExpression", "0 0 2 * * ?"));
|
||||
new BackupCatalog().update(BackupsTables.PLAN, scheduled.getValueInteger("id"), java.util.Collections.singletonMap("slaHours", null));
|
||||
assertThat(check(scheduled).status()).isEqualTo(SlaChecker.LATE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Two failed runs in a row are FAILING (error); one is only a warning; a later
|
||||
** success clears it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testFailingAfterTwoFailures() throws Exception
|
||||
{
|
||||
QRecord plan = plan(Map.of());
|
||||
BackupsTestUtils.BACKUPS.withFailWritesFor(key -> key.contains("/tables/"));
|
||||
|
||||
assertThat(backup(plan).getStatus()).isEqualTo("FAILED");
|
||||
assertThat(check(plan).status()).isEqualTo(SlaChecker.UNKNOWN);
|
||||
BackupAlertsAssert.assertOpen("backups.run.failed:nightly", BackupAlertSink.Severity.WARNING);
|
||||
|
||||
assertThat(backup(plan).getStatus()).isEqualTo("FAILED");
|
||||
assertThat(check(plan).status()).isEqualTo(SlaChecker.FAILING);
|
||||
BackupAlertsAssert.assertOpen("backups.run.failed:nightly", BackupAlertSink.Severity.ERROR);
|
||||
|
||||
BackupsTestUtils.BACKUPS.withFailWritesFor(null);
|
||||
assertThat(backup(plan).getStatus()).startsWith("SUCCEEDED");
|
||||
assertThat(check(plan).status()).isEqualTo(SlaChecker.OK);
|
||||
assertThat(sink.isOpen("backups.run.failed:nightly")).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A deleted manifest is MISSING_IN_STORAGE with a critical alert (a head, cached
|
||||
** for an hour); putting it back clears it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testDeletedManifestIsMissingInStorage() throws Exception
|
||||
{
|
||||
QRecord plan = plan(Map.of());
|
||||
BackupRunResult result = backup(plan);
|
||||
byte[] bytes = BackupsTestUtils.BACKUPS.getBytes(result.getManifestKey());
|
||||
|
||||
assertThat(check(plan).status()).isEqualTo(SlaChecker.OK);
|
||||
BackupsTestUtils.BACKUPS.removeIgnoringLock(result.getManifestKey());
|
||||
|
||||
clock.advance(Duration.ofMinutes(10));
|
||||
assertThat(check(plan).status()).as("the earlier positive head is trusted for an hour").isEqualTo(SlaChecker.OK);
|
||||
|
||||
clock.advance(Duration.ofMinutes(55));
|
||||
assertThat(check(plan).status()).isEqualTo(SlaChecker.MISSING_IN_STORAGE);
|
||||
BackupAlertsAssert.assertOpen("backups.run.missing_in_storage:nightly", BackupAlertSink.Severity.CRITICAL);
|
||||
assertThat(check(plan).status()).as("a negative answer is never cached").isEqualTo(SlaChecker.MISSING_IN_STORAGE);
|
||||
|
||||
BackupsTestUtils.BACKUPS.putBytes(result.getManifestKey(), bytes);
|
||||
assertThat(check(plan).status()).isEqualTo(SlaChecker.OK);
|
||||
assertThat(sink.isOpen("backups.run.missing_in_storage:nightly")).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A RUNNING run whose heartbeat is older than ten minutes is marked ABANDONED
|
||||
** and the plan is ABANDONED; a fresh heartbeat is left alone.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testStaleHeartbeatIsAbandoned() throws Exception
|
||||
{
|
||||
QRecord plan = plan(Map.of());
|
||||
backup(plan);
|
||||
Integer stale = runRow(plan, "20261012T000000Z-aaaa", "RUNNING", clock.instant());
|
||||
clock.advance(Duration.ofMinutes(5));
|
||||
Integer fresh = runRow(plan, "20261012T000500Z-bbbb", "RUNNING", clock.instant());
|
||||
|
||||
clock.advance(Duration.ofMinutes(6));
|
||||
SlaChecker.PlanResult result = check(plan);
|
||||
assertThat(result.abandonedRuns()).isEqualTo(1);
|
||||
assertThat(new BackupCatalog().get(BackupsTables.RUN, stale).orElseThrow().getValueString("status")).isEqualTo("ABANDONED");
|
||||
assertThat(new BackupCatalog().get(BackupsTables.RUN, stale).orElseThrow().getValueString("errorMessage")).contains("heartbeat");
|
||||
assertThat(new BackupCatalog().get(BackupsTables.RUN, fresh).orElseThrow().getValueString("status")).isEqualTo("RUNNING");
|
||||
|
||||
assertThat(result.status()).isEqualTo(SlaChecker.ABANDONED);
|
||||
BackupAlertsAssert.assertOpen("backups.run.failed:nightly", BackupAlertSink.Severity.WARNING);
|
||||
|
||||
new BackupCatalog().update(BackupsTables.RUN, fresh, java.util.Collections.singletonMap("status", "SUCCEEDED"));
|
||||
backup(plan);
|
||||
assertThat(check(plan).status()).isEqualTo(SlaChecker.OK);
|
||||
assertThat(sink.isOpen("backups.run.failed:nightly")).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** UNVERIFIED when no content verification has passed within verifySlaDays; a
|
||||
** passed verification clears it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testUnverifiedThenVerified() throws Exception
|
||||
{
|
||||
QRecord plan = plan(Map.of("slaHours", 24 * 30));
|
||||
backup(plan);
|
||||
assertThat(check(plan).status()).isEqualTo(SlaChecker.OK);
|
||||
|
||||
clock.advance(Duration.ofDays(9));
|
||||
BackupRunResult second = backup(plan);
|
||||
assertThat(check(plan).status()).isEqualTo(SlaChecker.UNVERIFIED);
|
||||
BackupAlertsAssert.assertOpen("backups.verify.overdue:nightly", BackupAlertSink.Severity.WARNING);
|
||||
|
||||
new BackupVerifier().verifyThrough(second.getRunId(), BackupVerifier.CONTENT, "MANUAL", "t");
|
||||
assertThat(sink.isOpen("backups.verify.overdue:nightly")).isFalse();
|
||||
assertThat(check(plan).status()).isEqualTo(SlaChecker.OK);
|
||||
|
||||
clock.advance(Duration.ofDays(9));
|
||||
backup(plan);
|
||||
assertThat(check(plan).status()).isEqualTo(SlaChecker.UNVERIFIED);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** OWNER_INVALID when the configured validator rejects the plan owner.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testOwnerInvalid() throws Exception
|
||||
{
|
||||
QRecord plan = plan(Map.of("ownerUserId", "bob"));
|
||||
backup(plan);
|
||||
assertThat(check(plan).status()).isEqualTo(SlaChecker.OK);
|
||||
|
||||
BackupsRuntime.getConfig().withPlanOwnerValidator(owner -> false);
|
||||
assertThat(check(plan).status()).isEqualTo(SlaChecker.OWNER_INVALID);
|
||||
BackupAlertsAssert.assertOpen("backups.plan.owner_invalid:nightly", BackupAlertSink.Severity.WARNING);
|
||||
|
||||
BackupsRuntime.getConfig().withPlanOwnerValidator(owner -> "bob".equals(owner));
|
||||
assertThat(check(plan).status()).isEqualTo(SlaChecker.OK);
|
||||
assertThat(sink.isOpen("backups.plan.owner_invalid:nightly")).isFalse();
|
||||
|
||||
BackupsRuntime.getConfig().withPlanOwnerValidator(owner ->
|
||||
{
|
||||
throw (new IllegalStateException("directory down"));
|
||||
});
|
||||
assertThat(check(plan).status()).as("a validator that fails is not a verdict").isEqualTo(SlaChecker.OK);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Worst state first: MISSING_IN_STORAGE beats LATE.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testPrecedence() throws Exception
|
||||
{
|
||||
QRecord plan = plan(Map.of());
|
||||
BackupRunResult result = backup(plan);
|
||||
BackupsTestUtils.BACKUPS.removeIgnoringLock(result.getManifestKey());
|
||||
clock.advance(Duration.ofDays(2));
|
||||
assertThat(check(plan).status()).isEqualTo(SlaChecker.MISSING_IN_STORAGE);
|
||||
assertThat(sink.isOpen("backups.plan.missed:nightly")).as("the alerts are independent of the status shown").isTrue();
|
||||
assertThat(sink.isOpen("backups.run.missing_in_storage:nightly")).isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The daily bucket listing reports orphans and debris (no manifest older than 24h)
|
||||
** once a day, raises an info alert, and resolves it when the bucket is clean.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testBucketAuditFindsOrphansAndDebris() throws Exception
|
||||
{
|
||||
QRecord plan = plan(Map.of());
|
||||
backup(plan);
|
||||
|
||||
String debris = "qqq-backups/testapp/nightly/2026/09/01/20260901T020000Z-dead/tables/author/part-00001.jsonl.gz";
|
||||
String orphan = "qqq-backups/testapp/nightly/2026/09/02/20260902T020000Z-beef/manifest.json";
|
||||
BackupsTestUtils.BACKUPS.withClock(Clock.fixed(clock.instant().minus(Duration.ofDays(3)), java.time.ZoneOffset.UTC));
|
||||
BackupsTestUtils.BACKUPS.putBytes(debris, new byte[] { 1 });
|
||||
BackupsTestUtils.BACKUPS.putBytes(orphan, new byte[] { 2 });
|
||||
BackupsTestUtils.BACKUPS.withClock(Clock.systemUTC());
|
||||
|
||||
SlaChecker.PlanResult first = check(plan);
|
||||
assertThat(first.orphans()).isEqualTo(2);
|
||||
assertThat(first.debris()).isEqualTo(1);
|
||||
BackupAlertsAssert.assertOpen("backups.storage.debris:nightly", BackupAlertSink.Severity.INFO);
|
||||
assertThat(sink.openAlert("backups.storage.debris:nightly").detail()).containsEntry("orphans", "2").containsEntry("debris", "1");
|
||||
|
||||
assertThat(check(plan).orphans()).as("listed once a day").isEqualTo(-1);
|
||||
|
||||
BackupsTestUtils.BACKUPS.removeIgnoringLock(debris);
|
||||
BackupsTestUtils.BACKUPS.removeIgnoringLock(orphan);
|
||||
clock.advance(Duration.ofHours(25));
|
||||
SlaChecker.PlanResult clean = check(plan);
|
||||
assertThat(clean.orphans()).isZero();
|
||||
assertThat(clean.debris()).isZero();
|
||||
assertThat(sink.isOpen("backups.storage.debris:nightly")).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A run that is still going (RUNNING with a fresh heartbeat) is not debris, even
|
||||
** though its prefix has no manifest yet.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRunningPrefixIsNotDebris() throws Exception
|
||||
{
|
||||
QRecord plan = plan(Map.of());
|
||||
backup(plan);
|
||||
String prefix = "qqq-backups/testapp/nightly/2026/10/12/20261012T020000Z-cafe/";
|
||||
BackupsTestUtils.BACKUPS.withClock(Clock.fixed(clock.instant().minus(Duration.ofDays(2)), java.time.ZoneOffset.UTC));
|
||||
BackupsTestUtils.BACKUPS.putBytes(prefix + "tables/author/part-00001.jsonl.gz", new byte[] { 1 });
|
||||
BackupsTestUtils.BACKUPS.withClock(Clock.systemUTC());
|
||||
Map<String, Serializable> values = new LinkedHashMap<>();
|
||||
values.put("backupPlanId", plan.getValueInteger("id"));
|
||||
values.put("runKey", "20261012T020000Z-cafe");
|
||||
values.put("status", "RUNNING");
|
||||
values.put("startDate", clock.instant());
|
||||
values.put("heartbeatDate", clock.instant());
|
||||
values.put("runPrefix", prefix);
|
||||
new BackupCatalog().insert(BackupsTables.RUN, values);
|
||||
|
||||
SlaChecker.PlanResult result = check(plan);
|
||||
assertThat(result.debris()).isZero();
|
||||
assertThat(result.orphans()).isZero();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** checkAll covers only active plans, and survives a plan whose bucket is unknown.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testCheckAllSkipsInactivePlansAndSurvivesBadBucket() throws Exception
|
||||
{
|
||||
plan(Map.of());
|
||||
plan(Map.of("name", "Off", "planKey", "off", "isActive", false));
|
||||
plan(Map.of("name", "Bad", "planKey", "bad", "storageBucketAlias", "no-such-bucket"));
|
||||
clock.advance(Duration.ofHours(30));
|
||||
|
||||
List<SlaChecker.PlanResult> results = new SlaChecker().checkAll();
|
||||
assertThat(results).extracting(SlaChecker.PlanResult::planKey).containsExactlyInAnyOrder("nightly", "bad");
|
||||
assertThat(results).extracting(SlaChecker.PlanResult::status).containsOnly(SlaChecker.LATE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Without a recorded success or storage the checks degrade quietly: a success
|
||||
** run with no manifest key, and a pruned run, are not "missing".
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testPrunedOrKeylessSuccessIsNotMissing() throws Exception
|
||||
{
|
||||
QRecord plan = plan(Map.of());
|
||||
BackupRunResult result = backup(plan);
|
||||
new BackupCatalog().update(BackupsTables.RUN, result.getRunId(), java.util.Collections.singletonMap("prunedDate", Instant.now()));
|
||||
BackupsTestUtils.BACKUPS.removeIgnoringLock(result.getManifestKey());
|
||||
assertThat(check(plan).status()).isEqualTo(SlaChecker.OK);
|
||||
|
||||
new BackupCatalog().update(BackupsTables.RUN, result.getRunId(), java.util.Collections.singletonMap("manifestKey", null));
|
||||
assertThat(check(plan).status()).isEqualTo(SlaChecker.OK);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private Integer runRow(QRecord plan, String runKey, String status, Instant heartbeat) throws Exception
|
||||
{
|
||||
Map<String, Serializable> values = new LinkedHashMap<>();
|
||||
values.put("backupPlanId", plan.getValueInteger("id"));
|
||||
values.put("runKey", runKey);
|
||||
values.put("status", status);
|
||||
values.put("startDate", heartbeat);
|
||||
values.put("heartbeatDate", heartbeat);
|
||||
values.put("runPrefix", "qqq-backups/testapp/nightly/2026/10/12/" + runKey + "/");
|
||||
return (new BackupCatalog().insert(BackupsTables.RUN, values));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Alert assertions over the recording sink.
|
||||
***************************************************************************/
|
||||
private static final class BackupAlertsAssert
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The alert is open at that severity.
|
||||
*******************************************************************************/
|
||||
static void assertOpen(String dedupeKey, BackupAlertSink.Severity severity)
|
||||
{
|
||||
assertThat(sink.isOpen(dedupeKey)).as("alert " + dedupeKey + " is open").isTrue();
|
||||
assertThat(sink.openAlert(dedupeKey).severity()).isEqualTo(severity);
|
||||
assertThat(sink.openAlert(dedupeKey).dedupeKey()).isEqualTo(dedupeKey);
|
||||
assertThat(sink.openAlert(dedupeKey).type()).isEqualTo(dedupeKey.substring(0, dedupeKey.indexOf(':')));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
/*
|
||||
* 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.verify;
|
||||
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.BackupsTestUtils;
|
||||
import com.kingsrook.qbits.backups.VerifyTestSupport;
|
||||
import com.kingsrook.qbits.backups.alerts.BackupAlerts;
|
||||
import com.kingsrook.qbits.backups.manifest.BackupManifest;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qbits.backups.run.BackupRunResult;
|
||||
import com.kingsrook.qbits.backups.storage.WriteOptions;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static com.kingsrook.qbits.backups.VerifyTestSupport.backup;
|
||||
import static com.kingsrook.qbits.backups.VerifyTestSupport.plan;
|
||||
import static com.kingsrook.qbits.backups.VerifyTestSupport.reload;
|
||||
import static com.kingsrook.qbits.backups.VerifyTestSupport.run;
|
||||
import static com.kingsrook.qbits.backups.VerifyTestSupport.sink;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Verification at every level against the memory backend, including the failure
|
||||
** injection cases: a corrupted part, a deleted file-store object, a wrong
|
||||
** checksum, a missing manifest, and a wrong key.
|
||||
*******************************************************************************/
|
||||
class BackupVerifierTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
VerifyTestSupport.setUp(7);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A good run passes STORAGE then CONTENT; every table, row and file is checked,
|
||||
** the verification rows, the run status and the plan are updated.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testStorageAndContentPass() throws Exception
|
||||
{
|
||||
QRecord plan = plan(Map.of());
|
||||
BackupRunResult result = backup(plan);
|
||||
|
||||
List<VerificationOutcome> outcomes = new BackupVerifier().verifyThrough(result.getRunId(), BackupVerifier.CONTENT, "MANUAL", "tester");
|
||||
assertThat(outcomes).extracting(VerificationOutcome::level).containsExactly("STORAGE", "CONTENT");
|
||||
assertThat(outcomes).extracting(VerificationOutcome::status).containsExactly("PASSED", "PASSED");
|
||||
|
||||
VerificationOutcome content = outcomes.get(1);
|
||||
assertThat(content.tablesChecked()).isEqualTo(2);
|
||||
assertThat(content.rowsChecked()).isEqualTo(10);
|
||||
assertThat(content.filesChecked()).isEqualTo(3);
|
||||
assertThat(content.mismatches()).isZero();
|
||||
|
||||
List<QRecord> rows = new BackupCatalog().where(BackupsTables.VERIFICATION, "backupRunId", result.getRunId());
|
||||
assertThat(rows).hasSize(2);
|
||||
QRecord contentRow = rows.stream().filter(r -> "CONTENT".equals(r.getValueString("level"))).findFirst().orElseThrow();
|
||||
assertThat(contentRow.getValueString("status")).isEqualTo("PASSED");
|
||||
assertThat(contentRow.getValueLong("rowsChecked")).isEqualTo(10L);
|
||||
assertThat(contentRow.getValueString("detailJson")).contains("\"table\":\"blogPost\"").contains("\"expectedRows\":7").contains("\"actualRows\":7").contains("\"ok\":true");
|
||||
|
||||
assertThat(run(result.getRunId()).getValueString("verificationStatus")).isEqualTo("CONTENT_OK");
|
||||
QRecord reloaded = reload(plan);
|
||||
assertThat(reloaded.getValueInteger("lastVerifiedRunId")).isEqualTo(result.getRunId());
|
||||
assertThat(reloaded.getValueInstant("lastVerifiedDate")).isEqualTo(VerifyTestSupport.clock.instant());
|
||||
|
||||
assertThat(new BackupCatalog().query(BackupsTables.FILE_OBJECT, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter()))
|
||||
.allSatisfy(f -> assertThat(f.getValueString("lastVerifyStatus")).isEqualTo("PASSED"));
|
||||
assertThat(sink.open).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An unknown level is refused; an unknown run is refused.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testBadArguments() throws Exception
|
||||
{
|
||||
BackupRunResult result = backup(plan(Map.of()));
|
||||
assertThatThrownBy(() -> new BackupVerifier().verify(result.getRunId(), "WHATEVER", "MANUAL", "t")).hasMessageContaining("Unknown verification level");
|
||||
assertThatThrownBy(() -> new BackupVerifier().verify(99999, "STORAGE", "MANUAL", "t")).hasMessageContaining("No backup run");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A flipped byte in a part is caught by CONTENT with a clear message, the run is
|
||||
** marked FAILED, and an alert is raised; STORAGE (sizes only) still passes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testCorruptedPartFailsContent() throws Exception
|
||||
{
|
||||
QRecord plan = plan(Map.of());
|
||||
BackupRunResult result = backup(plan);
|
||||
String key = VerifyTestSupport.firstPartKey(result, "blogPost");
|
||||
byte[] bytes = BackupsTestUtils.BACKUPS.getBytes(key).clone();
|
||||
bytes[bytes.length / 2] ^= 0x55;
|
||||
BackupsTestUtils.BACKUPS.corrupt(key, bytes);
|
||||
|
||||
List<VerificationOutcome> outcomes = new BackupVerifier().verifyThrough(result.getRunId(), BackupVerifier.CONTENT, "MANUAL", "tester");
|
||||
assertThat(outcomes).extracting(VerificationOutcome::status).containsExactly("PASSED", "FAILED");
|
||||
assertThat(outcomes.get(1).messages()).anyMatch(m -> m.contains("tables/blogPost/part-00001.jsonl.gz") && (m.contains("corrupted") || m.contains("checksum mismatch")));
|
||||
assertThat(run(result.getRunId()).getValueString("verificationStatus")).isEqualTo("FAILED");
|
||||
assertThat(reload(plan).getValueInstant("lastVerifiedDate")).isNull();
|
||||
|
||||
String key2 = BackupAlerts.verifyKey("nightly", "CONTENT");
|
||||
assertThat(sink.isOpen(key2)).isTrue();
|
||||
assertThat(sink.openAlert(key2).severity()).isEqualTo(com.kingsrook.qbits.backups.alerts.BackupAlertSink.Severity.ERROR);
|
||||
assertThat(sink.openAlert(key2).type()).isEqualTo("backups.verify.failed");
|
||||
assertThat(sink.openAlert(key2).detail()).containsEntry("plan", "nightly").containsEntry("level", "CONTENT").doesNotContainKey("key");
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// a later verification of a good run of the same plan resolves the alert //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
BackupRunResult good = backup(plan);
|
||||
assertThat(new BackupVerifier().verifyThrough(good.getRunId(), BackupVerifier.CONTENT, "MANUAL", "tester")).extracting(VerificationOutcome::status).containsExactly("PASSED", "PASSED");
|
||||
assertThat(sink.isOpen(key2)).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A part that is valid gzip but whose bytes differ from what was written fails
|
||||
** the stored and content checksums, and the row content hash.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testReplacedPartContentFails() throws Exception
|
||||
{
|
||||
BackupRunResult result = backup(plan(Map.of()));
|
||||
String key = VerifyTestSupport.firstPartKey(result, "blogPost");
|
||||
String text = gunzip(BackupsTestUtils.BACKUPS.getBytes(key)).replace("Post 1", "Post X");
|
||||
BackupsTestUtils.BACKUPS.putBytes(key, gzip(text));
|
||||
|
||||
VerificationOutcome outcome = new BackupVerifier().verify(result.getRunId(), BackupVerifier.CONTENT, "MANUAL", "t");
|
||||
assertThat(outcome.status()).isEqualTo("FAILED");
|
||||
assertThat(outcome.messages()).anyMatch(m -> m.startsWith("Stored checksum mismatch")).anyMatch(m -> m.startsWith("Content checksum mismatch")).anyMatch(m -> m.startsWith("Row content hash mismatch"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A truncated part fails the STORAGE size check.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testTruncatedPartFailsStorage() throws Exception
|
||||
{
|
||||
BackupRunResult result = backup(plan(Map.of()));
|
||||
String key = VerifyTestSupport.firstPartKey(result, "blogPost");
|
||||
byte[] bytes = BackupsTestUtils.BACKUPS.getBytes(key);
|
||||
BackupsTestUtils.BACKUPS.corrupt(key, java.util.Arrays.copyOf(bytes, bytes.length - 5));
|
||||
|
||||
List<VerificationOutcome> outcomes = new BackupVerifier().verifyThrough(result.getRunId(), BackupVerifier.CONTENT, "MANUAL", "t");
|
||||
assertThat(outcomes).hasSize(1);
|
||||
assertThat(outcomes.get(0).status()).isEqualTo("FAILED");
|
||||
assertThat(outcomes.get(0).messages()).anyMatch(m -> m.startsWith("Size mismatch for") && m.contains("blogPost"));
|
||||
assertThat(sink.isOpen(BackupAlerts.verifyKey("nightly", "STORAGE"))).isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A deleted part is a missing object at STORAGE level.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testDeletedPartFailsStorage() throws Exception
|
||||
{
|
||||
BackupRunResult result = backup(plan(Map.of()));
|
||||
BackupsTestUtils.BACKUPS.removeIgnoringLock(VerifyTestSupport.firstPartKey(result, "author"));
|
||||
|
||||
VerificationOutcome outcome = new BackupVerifier().verify(result.getRunId(), BackupVerifier.STORAGE, "MANUAL", "t");
|
||||
assertThat(outcome.status()).isEqualTo("FAILED");
|
||||
assertThat(outcome.messages()).anyMatch(m -> m.startsWith("Missing object:") && m.contains("table author"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A provider that stores SHA-256 checksums: a stored value that differs from the
|
||||
** manifest is a STORAGE mismatch.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testProviderChecksumMismatchFailsStorage() throws Exception
|
||||
{
|
||||
BackupRunResult result = backup(plan(Map.of()));
|
||||
String key = VerifyTestSupport.firstPartKey(result, "author");
|
||||
byte[] bytes = BackupsTestUtils.BACKUPS.getBytes(key);
|
||||
try(var out = BackupsTestUtils.BACKUPS.openWrite(key, WriteOptions.create().withMetadata("sha256", "0000")))
|
||||
{
|
||||
out.write(bytes);
|
||||
}
|
||||
VerificationOutcome outcome = new BackupVerifier().verify(result.getRunId(), BackupVerifier.STORAGE, "MANUAL", "t");
|
||||
assertThat(outcome.status()).isEqualTo("FAILED");
|
||||
assertThat(outcome.messages()).anyMatch(m -> m.startsWith("Checksum mismatch for"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A file-store object that was deleted fails STORAGE (head) and CONTENT.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testDeletedFileObjectFails() throws Exception
|
||||
{
|
||||
BackupRunResult result = backup(plan(Map.of()));
|
||||
QRecord file = new BackupCatalog().query(BackupsTables.FILE_OBJECT, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter()).get(0);
|
||||
BackupsTestUtils.BACKUPS.removeIgnoringLock(file.getValueString("objectKey"));
|
||||
|
||||
VerificationOutcome storage = new BackupVerifier().verify(result.getRunId(), BackupVerifier.STORAGE, "MANUAL", "t");
|
||||
assertThat(storage.status()).isEqualTo("FAILED");
|
||||
assertThat(storage.messages()).anyMatch(m -> m.startsWith("File store object missing: sha256 " + file.getValueString("sha256")));
|
||||
|
||||
VerificationOutcome content = new BackupVerifier().verify(result.getRunId(), BackupVerifier.CONTENT, "MANUAL", "t");
|
||||
assertThat(content.status()).isEqualTo("FAILED");
|
||||
assertThat(content.messages()).anyMatch(m -> m.startsWith("File store object missing"));
|
||||
assertThat(new BackupCatalog().get(BackupsTables.FILE_OBJECT, file.getValueInteger("id")).orElseThrow().getValueString("lastVerifyStatus")).isEqualTo("FAILED");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A file-store object with the right size but the wrong bytes (a wrong checksum)
|
||||
** fails CONTENT only.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testWrongFileChecksumFailsContent() throws Exception
|
||||
{
|
||||
BackupRunResult result = backup(plan(Map.of()));
|
||||
QRecord file = new BackupCatalog().query(BackupsTables.FILE_OBJECT, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter()).get(0);
|
||||
byte[] bytes = BackupsTestUtils.BACKUPS.getBytes(file.getValueString("objectKey")).clone();
|
||||
bytes[0] ^= 0x01;
|
||||
BackupsTestUtils.BACKUPS.corrupt(file.getValueString("objectKey"), bytes);
|
||||
|
||||
assertThat(new BackupVerifier().verify(result.getRunId(), BackupVerifier.STORAGE, "MANUAL", "t").status()).isEqualTo("PASSED");
|
||||
VerificationOutcome content = new BackupVerifier().verify(result.getRunId(), BackupVerifier.CONTENT, "MANUAL", "t");
|
||||
assertThat(content.status()).isEqualTo("FAILED");
|
||||
assertThat(content.messages()).anyMatch(m -> m.contains("has the wrong content") && m.contains(file.getValueString("sha256")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A manifest that is gone: the run is not usable.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testMissingManifest() throws Exception
|
||||
{
|
||||
BackupRunResult result = backup(plan(Map.of()));
|
||||
BackupsTestUtils.BACKUPS.removeIgnoringLock(result.getManifestKey());
|
||||
|
||||
VerificationOutcome outcome = new BackupVerifier().verify(result.getRunId(), BackupVerifier.STORAGE, "MANUAL", "t");
|
||||
assertThat(outcome.status()).isEqualTo("FAILED");
|
||||
assertThat(outcome.messages()).containsExactly("The manifest is missing from the bucket, so this backup is not usable");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A manifest that was changed after it was written (its hash no longer matches
|
||||
** the catalog) and one that is not JSON.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testTamperedAndUnreadableManifest() throws Exception
|
||||
{
|
||||
BackupRunResult result = backup(plan(Map.of()));
|
||||
byte[] bytes = BackupsTestUtils.BACKUPS.getBytes(result.getManifestKey());
|
||||
BackupsTestUtils.BACKUPS.putBytes(result.getManifestKey(), (new String(bytes, StandardCharsets.UTF_8) + " ").getBytes(StandardCharsets.UTF_8));
|
||||
VerificationOutcome tampered = new BackupVerifier().verify(result.getRunId(), BackupVerifier.STORAGE, "MANUAL", "t");
|
||||
assertThat(tampered.status()).isEqualTo("FAILED");
|
||||
assertThat(tampered.messages()).anyMatch(m -> m.contains("manifest changed after it was written"));
|
||||
|
||||
BackupsTestUtils.BACKUPS.putBytes(result.getManifestKey(), "not json".getBytes(StandardCharsets.UTF_8));
|
||||
VerificationOutcome garbage = new BackupVerifier().verify(result.getRunId(), BackupVerifier.STORAGE, "MANUAL", "t");
|
||||
assertThat(garbage.status()).isEqualTo("FAILED");
|
||||
assertThat(garbage.messages()).anyMatch(m -> m.contains("not valid JSON"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A run with no recorded bucket cannot be examined at all: ERROR, not FAILED.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testUnexaminableRunIsError() throws Exception
|
||||
{
|
||||
BackupRunResult result = backup(plan(Map.of()));
|
||||
new BackupCatalog().update(BackupsTables.RUN, result.getRunId(), java.util.Collections.singletonMap("storageBucketAlias", null));
|
||||
VerificationOutcome outcome = new BackupVerifier().verify(result.getRunId(), BackupVerifier.STORAGE, "MANUAL", "t");
|
||||
assertThat(outcome.status()).isEqualTo("ERROR");
|
||||
assertThat(outcome.messages()).anyMatch(m -> m.contains("could not be carried out"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Encrypted runs verify through the decrypt hook; a wrong key fails clearly; no
|
||||
** hook at all is an ERROR that says what to configure.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEncryptedRun() throws Exception
|
||||
{
|
||||
QRecord plan = plan(Map.of());
|
||||
BackupRunResult result = VerifyTestSupport.backupEncrypted(plan, new VerifyTestSupport.XorTransform(0x5A), "kek-1");
|
||||
|
||||
assertThat(new BackupVerifier().verify(result.getRunId(), BackupVerifier.CONTENT, "MANUAL", "t").status()).isEqualTo("ERROR");
|
||||
assertThat(new BackupVerifier().verify(result.getRunId(), BackupVerifier.CONTENT, "MANUAL", "t").messages()).anyMatch(m -> m.contains("no decrypt hook is configured"));
|
||||
|
||||
BackupsRuntime.getConfig().withArtifactTransformResolver(ref -> null);
|
||||
assertThat(new BackupVerifier().verify(result.getRunId(), BackupVerifier.CONTENT, "MANUAL", "t").messages()).anyMatch(m -> m.contains("No key is available for the key reference kek-1"));
|
||||
|
||||
BackupsRuntime.getConfig().withArtifactTransformResolver(ref -> new VerifyTestSupport.XorTransform(0x11));
|
||||
VerificationOutcome wrongKey = new BackupVerifier().verify(result.getRunId(), BackupVerifier.CONTENT, "MANUAL", "t");
|
||||
assertThat(wrongKey.status()).isEqualTo("FAILED");
|
||||
assertThat(wrongKey.messages()).anyMatch(m -> m.contains("corrupted, or the key is wrong"));
|
||||
|
||||
BackupsRuntime.getConfig().withArtifactTransformResolver(ref -> new VerifyTestSupport.XorTransform(0x5A));
|
||||
List<VerificationOutcome> good = new BackupVerifier().verifyThrough(result.getRunId(), BackupVerifier.TEST_RESTORE, "MANUAL", "t");
|
||||
assertThat(good).extracting(VerificationOutcome::status).containsExactly("PASSED", "PASSED", "PASSED");
|
||||
assertThat(good.get(1).filesChecked()).isEqualTo(3);
|
||||
assertThat(run(result.getRunId()).getValueString("verificationStatus")).isEqualTo("TEST_RESTORE_OK");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Files verified within fileReverifyDays are skipped; later they are checked again.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testFileReverifyDaysBoundsTheWork() throws Exception
|
||||
{
|
||||
BackupRunResult result = backup(plan(Map.of()));
|
||||
assertThat(new BackupVerifier().verify(result.getRunId(), BackupVerifier.CONTENT, "MANUAL", "t").filesChecked()).isEqualTo(3);
|
||||
|
||||
VerificationOutcome again = new BackupVerifier().verify(result.getRunId(), BackupVerifier.CONTENT, "MANUAL", "t");
|
||||
assertThat(again.filesChecked()).isZero();
|
||||
assertThat(again.filesSkipped()).isEqualTo(3);
|
||||
|
||||
VerifyTestSupport.clock.advance(Duration.ofDays(31));
|
||||
VerificationOutcome later = new BackupVerifier().verify(result.getRunId(), BackupVerifier.CONTENT, "MANUAL", "t");
|
||||
assertThat(later.filesChecked()).isEqualTo(3);
|
||||
assertThat(later.filesSkipped()).isZero();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A cheap STORAGE pass does not clear a FAILED found by CONTENT, and never
|
||||
** downgrades a CONTENT_OK; a later CONTENT pass does clear the failure.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testVerificationStatusRules() throws Exception
|
||||
{
|
||||
BackupRunResult result = backup(plan(Map.of()));
|
||||
new BackupVerifier().verify(result.getRunId(), BackupVerifier.CONTENT, "MANUAL", "t");
|
||||
assertThat(run(result.getRunId()).getValueString("verificationStatus")).isEqualTo("CONTENT_OK");
|
||||
new BackupVerifier().verify(result.getRunId(), BackupVerifier.STORAGE, "MANUAL", "t");
|
||||
assertThat(run(result.getRunId()).getValueString("verificationStatus")).isEqualTo("CONTENT_OK");
|
||||
|
||||
String key = VerifyTestSupport.firstPartKey(result, "blogPost");
|
||||
byte[] bytes = BackupsTestUtils.BACKUPS.getBytes(key).clone();
|
||||
bytes[bytes.length / 2] ^= 0x55;
|
||||
BackupsTestUtils.BACKUPS.corrupt(key, bytes);
|
||||
new BackupVerifier().verify(result.getRunId(), BackupVerifier.CONTENT, "MANUAL", "t");
|
||||
assertThat(run(result.getRunId()).getValueString("verificationStatus")).isEqualTo("FAILED");
|
||||
new BackupVerifier().verify(result.getRunId(), BackupVerifier.STORAGE, "MANUAL", "t");
|
||||
assertThat(run(result.getRunId()).getValueString("verificationStatus")).isEqualTo("FAILED");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An ad-hoc run (no plan) verifies without touching a plan or raising plan alerts.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testAdHocRunHasNoPlan() throws Exception
|
||||
{
|
||||
BackupRunResult result = backup(plan(Map.of()));
|
||||
new BackupCatalog().update(BackupsTables.RUN, result.getRunId(), java.util.Collections.singletonMap("backupPlanId", null));
|
||||
assertThat(new BackupVerifier().verify(result.getRunId(), BackupVerifier.CONTENT, "MANUAL", "t").status()).isEqualTo("PASSED");
|
||||
assertThat(sink.raises).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A table that is no longer in the instance is checked as JSON with a warning; a
|
||||
** table whose schema changed is parsed without the exact round trip.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSchemaDriftIsAWarning() throws Exception
|
||||
{
|
||||
BackupRunResult result = backup(plan(Map.of()));
|
||||
QContext.getQInstance().getTable("blogPost").addField(new com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData("added", com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.STRING));
|
||||
QContext.getQInstance().getTables().remove("author");
|
||||
|
||||
VerificationOutcome outcome = new BackupVerifier().verify(result.getRunId(), BackupVerifier.CONTENT, "MANUAL", "t");
|
||||
assertThat(outcome.status()).isEqualTo("PASSED_WITH_WARNINGS");
|
||||
assertThat(outcome.messages()).anyMatch(m -> m.contains("Table author no longer exists")).anyMatch(m -> m.contains("Table blogPost has changed since this backup"));
|
||||
assertThat(outcome.passed()).isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** CSV parts are hashed (stored and decoded bytes) but rows are not recounted.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testCsvOnlyPlanIsHashedWithAWarning() throws Exception
|
||||
{
|
||||
BackupRunResult result = backup(plan(Map.of("format", "CSV")));
|
||||
assertThat(result.getStatus()).startsWith("SUCCEEDED");
|
||||
VerificationOutcome outcome = new BackupVerifier().verify(result.getRunId(), BackupVerifier.CONTENT, "MANUAL", "t");
|
||||
assertThat(outcome.status()).isEqualTo("PASSED_WITH_WARNINGS");
|
||||
assertThat(outcome.messages()).anyMatch(m -> m.contains("only CSV parts"));
|
||||
|
||||
BackupsTestUtils.BACKUPS.corrupt(VerifyTestSupport.firstPartKey(result, "author"), new byte[] { 1, 2, 3 });
|
||||
assertThat(new BackupVerifier().verify(result.getRunId(), BackupVerifier.CONTENT, "MANUAL", "t").status()).isEqualTo("FAILED");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A good run test-restores into the memory scratch target: every table matches,
|
||||
** every file is restored and re-hashed, and the scratch tables and files are gone.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testTestRestoreMemory() throws Exception
|
||||
{
|
||||
QRecord plan = plan(Map.of());
|
||||
BackupRunResult result = backup(plan);
|
||||
|
||||
VerificationOutcome outcome = new BackupVerifier().verify(result.getRunId(), BackupVerifier.TEST_RESTORE, "MANUAL", "t");
|
||||
assertThat(outcome.messages()).isEmpty();
|
||||
assertThat(outcome.status()).isEqualTo("PASSED");
|
||||
assertThat(outcome.rowsChecked()).isEqualTo(10);
|
||||
assertThat(outcome.filesChecked()).isEqualTo(3);
|
||||
assertThat(outcome.scratchTarget()).startsWith("memory:qqq_verify_");
|
||||
|
||||
assertThat(QContext.getQInstance().getTables().keySet()).noneMatch(n -> n.startsWith("qqq_verify_"));
|
||||
assertThat(BackupsTestUtils.BACKUPS.list(TestRestoreVerifier.SCRATCH_FILE_PREFIX)).isEmpty();
|
||||
QRecord row = new BackupCatalog().where(BackupsTables.VERIFICATION, "level", "TEST_RESTORE").get(0);
|
||||
assertThat(row.getValueString("scratchTarget")).startsWith("memory:");
|
||||
assertThat(row.getValueString("detailJson")).contains("\"filesRestored\":3");
|
||||
assertThat(run(result.getRunId()).getValueString("verificationStatus")).isEqualTo("TEST_RESTORE_OK");
|
||||
assertThat(reload(plan).getValueInteger("lastVerifiedRunId")).isEqualTo(result.getRunId());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test restore catches rows that differ from the backed up rows, a missing
|
||||
** table, an unreadable part and a stored file that is not its hash.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testTestRestoreCatchesProblems() throws Exception
|
||||
{
|
||||
BackupRunResult result = backup(plan(Map.of()));
|
||||
String key = VerifyTestSupport.firstPartKey(result, "blogPost");
|
||||
byte[] good = BackupsTestUtils.BACKUPS.getBytes(key);
|
||||
|
||||
BackupsTestUtils.BACKUPS.putBytes(key, gzip(gunzip(good).replace("Post 1", "Post X")));
|
||||
VerificationOutcome changed = new BackupVerifier().verify(result.getRunId(), BackupVerifier.TEST_RESTORE, "MANUAL", "t");
|
||||
assertThat(changed.status()).isEqualTo("FAILED");
|
||||
assertThat(changed.messages()).anyMatch(m -> m.contains("does not reproduce the backed up rows"));
|
||||
assertThat(QContext.getQInstance().getTables().keySet()).noneMatch(n -> n.startsWith("qqq_verify_"));
|
||||
|
||||
BackupsTestUtils.BACKUPS.putBytes(key, new byte[] { 1, 2, 3 });
|
||||
VerificationOutcome unreadable = new BackupVerifier().verify(result.getRunId(), BackupVerifier.TEST_RESTORE, "MANUAL", "t");
|
||||
assertThat(unreadable.status()).isEqualTo("FAILED");
|
||||
assertThat(unreadable.messages()).anyMatch(m -> m.contains("could not be restored"));
|
||||
BackupsTestUtils.BACKUPS.putBytes(key, good);
|
||||
|
||||
QRecord file = new BackupCatalog().query(BackupsTables.FILE_OBJECT, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter()).get(0);
|
||||
BackupsTestUtils.BACKUPS.corrupt(file.getValueString("objectKey"), "different bytes".getBytes(StandardCharsets.UTF_8));
|
||||
VerificationOutcome badFile = new BackupVerifier().verify(result.getRunId(), BackupVerifier.TEST_RESTORE, "MANUAL", "t");
|
||||
assertThat(badFile.status()).isEqualTo("FAILED");
|
||||
assertThat(badFile.messages()).anyMatch(m -> m.startsWith("Test restore of file sha256")).anyMatch(m -> m.contains("do not resolve to a restored file"));
|
||||
assertThat(BackupsTestUtils.BACKUPS.list(TestRestoreVerifier.SCRATCH_FILE_PREFIX)).isEmpty();
|
||||
|
||||
QContext.getQInstance().getTables().remove("author");
|
||||
assertThat(new BackupVerifier().verify(result.getRunId(), BackupVerifier.TEST_RESTORE, "MANUAL", "t").messages()).anyMatch(m -> m.contains("Table author no longer exists"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** verifyFileSamplePercent 0 restores no files; CSV-only tables are skipped.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testTestRestoreFileSampleAndCsv() throws Exception
|
||||
{
|
||||
BackupRunResult result = backup(plan(Map.of("verifyFileSamplePercent", 0)));
|
||||
VerificationOutcome outcome = new BackupVerifier().verify(result.getRunId(), BackupVerifier.TEST_RESTORE, "MANUAL", "t");
|
||||
assertThat(outcome.status()).isEqualTo("PASSED");
|
||||
assertThat(outcome.filesChecked()).isZero();
|
||||
|
||||
BackupRunResult csv = backup(VerifyTestSupport.plan(Map.of("name", "Csv", "planKey", "csv", "format", "CSV")));
|
||||
VerificationOutcome csvOutcome = new BackupVerifier().verify(csv.getRunId(), BackupVerifier.TEST_RESTORE, "MANUAL", "t");
|
||||
assertThat(csvOutcome.status()).isEqualTo("PASSED_WITH_WARNINGS");
|
||||
assertThat(csvOutcome.messages()).anyMatch(m -> m.contains("only CSV parts and was not test-restored"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The RDBMS_SCHEMA scratch target is a stub: it ends the verification with an
|
||||
** ERROR that says so, rather than passing.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRdbmsSchemaScratchTargetIsAStub() throws Exception
|
||||
{
|
||||
BackupsRuntime.getConfig().withScratchTarget(new RdbmsSchemaScratchTarget("scratchdb"));
|
||||
BackupRunResult result = backup(plan(Map.of()));
|
||||
|
||||
VerificationOutcome outcome = new BackupVerifier().verify(result.getRunId(), BackupVerifier.TEST_RESTORE, "MANUAL", "t");
|
||||
assertThat(outcome.status()).isEqualTo("ERROR");
|
||||
assertThat(outcome.messages()).anyMatch(m -> m.contains("RDBMS_SCHEMA scratch target is not implemented yet"));
|
||||
|
||||
ScratchTarget target = BackupsRuntime.getConfig().getScratchTarget();
|
||||
assertThat(target.type()).isEqualTo("RDBMS_SCHEMA");
|
||||
assertThat(target.describe("2026-A")).isEqualTo("scratchdb:schema=qqq_verify_2026_a");
|
||||
target.drop("2026-A");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The outcome of a verification that has not passed or failed reads sensibly.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testManifestReadableByTheVerifier() throws Exception
|
||||
{
|
||||
BackupRunResult result = backup(plan(Map.of()));
|
||||
BackupManifest manifest = BackupManifest.read(BackupsTestUtils.BACKUPS.getBytes(result.getManifestKey()));
|
||||
assertThat(manifest.tables()).hasSize(2);
|
||||
assertThat(new VerificationOutcome(1, 2, "STORAGE", "ERROR", 0, 0, 1, 0, 0, 0, List.of(), null).passed()).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String gunzip(byte[] bytes) throws Exception
|
||||
{
|
||||
return (new String(new java.util.zip.GZIPInputStream(new java.io.ByteArrayInputStream(bytes)).readAllBytes(), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static byte[] gzip(String text) throws Exception
|
||||
{
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
try(GZIPOutputStream out = new GZIPOutputStream(bytes))
|
||||
{
|
||||
out.write(text.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
return (bytes.toByteArray());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user