Compare commits

...
Author SHA1 Message Date
james.maesandClaude Sonnet 5.5 eea9f63878 feat: backups foundation - tables, scope, codecs, manifest, backup engine, file copy, home
Tables (spec 4), permissions, scope resolver and table roles, canonical JSONL
and CSV codecs, per-part SHA-256 and rowsHash, manifest written last, the backup
engine with referenced-file copy into the deduplicated store, a narrow storage
interface with an in-memory implementation, Run backup now (streamed ETL), and the
Backups app home. Stand-ins for unreleased core hooks are marked in the code.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 16:14:32 -05:00
42 changed files with 8459 additions and 3 deletions

No files matched your search

@@ -18,16 +18,20 @@ package com.kingsrook.qbits.backups;
import java.util.List;
import java.util.function.Function;
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 +39,18 @@ 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;
@@ -89,4 +105,214 @@ 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);
}
}
@@ -100,4 +100,54 @@ 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);
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,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,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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;"));
}
}
@@ -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,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,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,572 @@
/*
* 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("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,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.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));
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,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,768 @@
/*
* 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.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();
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,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,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,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");
}
}