Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
664f467d11 | ||
|
|
4fb182e824 | ||
|
|
1de38f03ae
|
||
|
|
cf2d027f9a | ||
|
|
714ef906fe
|
||
|
|
26c174f7bf
|
||
|
|
284f0cec8c | ||
|
|
2f16bd6515
|
||
|
|
d4ad39f19d
|
||
|
|
ba3959e7dc
|
||
|
|
a67e4c4a57 | ||
|
|
4022fa1eee
|
||
|
|
6017f3ed2d
|
||
|
|
01b60708f8
|
||
|
|
06c87afa20 | ||
|
|
b928920989
|
||
|
|
758ef90cc1 | ||
|
|
eb481a475a
|
||
|
|
3d7343c394 | ||
|
|
2f520e9f15 | ||
|
|
7ef0849a02 |
No files matched your search
@@ -57,7 +57,7 @@ workflows:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/v.*/]
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
|
||||
publish_hotfix_release:
|
||||
jobs:
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Backups home and record screenshots
|
||||
|
||||
Taken 2026-10-12 against `BackupsDemoServer` (src/test/.../demo, memory backend, real engine history) and the Next UI 1.0.0-RC.14 jar.
|
||||
Light and dark at 390, 600, 768, 1024, 1440, 1920 px (home) and 390, 1440 px (records).
|
||||
|
||||
- `.` : this branch (foundation base): home, plan, run, failed run, lists.
|
||||
- `with-restore-verify/` : a scratch merge of PRs #5 (verify), #6 (restore), #7 (prune/schedule) onto this branch, so verifications, a restore run and retention classes exist. Not part of this PR.
|
||||
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 66 KiB |
@@ -72,6 +72,49 @@
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- optional: the BackupsHealthIndicator (apps without qqq-middleware-health never load it) -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-health</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- optional: ESB events (apps without qqq-esb never load it) -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-esb</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- Optional: the production BackupStorage adapter. Apps that install this qbit -->
|
||||
<!-- with object storage already have it; others use their own BackupStorage. -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-object-storage</artifactId>
|
||||
<version>0.1.0-RC.2</version>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers</artifactId>
|
||||
<version>2.0.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Demo server only (src/test/.../demo): never shipped. -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-javalin</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.14</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
@@ -87,6 +130,17 @@
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<!-- the MinIO test container default credentials, resolved through ${env.X} refs like production -->
|
||||
<environmentVariables>
|
||||
<STORAGE_TEST_ACCESS_KEY>minioadmin</STORAGE_TEST_ACCESS_KEY>
|
||||
<STORAGE_TEST_SECRET_KEY>minioadmin</STORAGE_TEST_SECRET_KEY>
|
||||
</environmentVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
|
||||
@@ -19,6 +19,9 @@ package com.kingsrook.qbits.backups;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
import com.kingsrook.qbits.backups.codec.ArtifactTransform;
|
||||
import com.kingsrook.qbits.backups.verify.ScratchTarget;
|
||||
import com.kingsrook.qbits.backups.storage.BackupStorage;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
@@ -42,6 +45,7 @@ public class BackupsQBitConfig implements QBitConfig
|
||||
private String appName = "backups";
|
||||
private String appLabel = "Backups";
|
||||
private String parentAppName;
|
||||
private String tablePathPrefix;
|
||||
private String schedulerName;
|
||||
private String instanceName;
|
||||
|
||||
@@ -52,6 +56,12 @@ public class BackupsQBitConfig implements QBitConfig
|
||||
|
||||
private Function<String, BackupStorage> storageResolver;
|
||||
|
||||
private Integer fileReverifyDays = 30;
|
||||
private Function<String, ArtifactTransform> artifactTransformResolver;
|
||||
private ScratchTarget scratchTarget;
|
||||
private Predicate<String> planOwnerValidator;
|
||||
private String esbDestinationName;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -170,6 +180,28 @@ public class BackupsQBitConfig implements QBitConfig
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tablePathPrefix (the front of the links the home makes to tables and
|
||||
** records, "/app" for the Next dashboard, which routes /app/{table}/{id}).
|
||||
*******************************************************************************/
|
||||
public String getTablePathPrefix()
|
||||
{
|
||||
return (tablePathPrefix == null ? "/app" : tablePathPrefix);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tablePathPrefix
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withTablePathPrefix(String tablePathPrefix)
|
||||
{
|
||||
this.tablePathPrefix = tablePathPrefix;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for schedulerName (the QQQ scheduler used for plan jobs and the SLA checker)
|
||||
*******************************************************************************/
|
||||
@@ -315,4 +347,109 @@ public class BackupsQBitConfig implements QBitConfig
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fileReverifyDays: CONTENT verification skips file-store objects
|
||||
** verified more recently than this.
|
||||
*******************************************************************************/
|
||||
public Integer getFileReverifyDays()
|
||||
{
|
||||
return (fileReverifyDays);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for fileReverifyDays
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withFileReverifyDays(Integer fileReverifyDays)
|
||||
{
|
||||
this.fileReverifyDays = fileReverifyDays;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for artifactTransformResolver (the decrypt hook for verify)
|
||||
*******************************************************************************/
|
||||
public Function<String, ArtifactTransform> getArtifactTransformResolver()
|
||||
{
|
||||
return (artifactTransformResolver);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for artifactTransformResolver: encryption key reference (the name
|
||||
** of a key in a key provider) to the transform that decrypts what that key wrote.
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withArtifactTransformResolver(Function<String, ArtifactTransform> artifactTransformResolver)
|
||||
{
|
||||
this.artifactTransformResolver = artifactTransformResolver;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for scratchTarget (where TEST_RESTORE restores to; null means MEMORY)
|
||||
*******************************************************************************/
|
||||
public ScratchTarget getScratchTarget()
|
||||
{
|
||||
return (scratchTarget);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for scratchTarget
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withScratchTarget(ScratchTarget scratchTarget)
|
||||
{
|
||||
this.scratchTarget = scratchTarget;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for planOwnerValidator (true when the owner still exists and holds backups.admin)
|
||||
*******************************************************************************/
|
||||
public Predicate<String> getPlanOwnerValidator()
|
||||
{
|
||||
return (planOwnerValidator);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for planOwnerValidator: with none, plan owners are not checked.
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withPlanOwnerValidator(Predicate<String> planOwnerValidator)
|
||||
{
|
||||
this.planOwnerValidator = planOwnerValidator;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for esbDestinationName (ESB events are published here, when set)
|
||||
*******************************************************************************/
|
||||
public String getEsbDestinationName()
|
||||
{
|
||||
return (esbDestinationName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for esbDestinationName
|
||||
*******************************************************************************/
|
||||
public BackupsQBitConfig withEsbDestinationName(String esbDestinationName)
|
||||
{
|
||||
this.esbDestinationName = esbDestinationName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -119,6 +119,14 @@ public class BackupsQBitProducer implements QBitMetaDataProducer<BackupsQBitConf
|
||||
qInstance.addQBit(qBit);
|
||||
BackupsRuntime.install(qBitConfig);
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// bind the Alerts qbit producer SPI when it is on the class path (optional) //
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
if(com.kingsrook.qbits.backups.alerts.AlertsQBitAlertSink.bindIfPresent())
|
||||
{
|
||||
new com.kingsrook.qbits.backups.alerts.AlertsQBitAlertSink().registerAlertTypes(qInstance);
|
||||
}
|
||||
|
||||
com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput output = new com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput();
|
||||
for(var possibleValueSource : com.kingsrook.qbits.backups.model.BackupsTables.possibleValueSources())
|
||||
{
|
||||
@@ -141,12 +149,29 @@ public class BackupsQBitProducer implements QBitMetaDataProducer<BackupsQBitConf
|
||||
process.setSourceQBitName(qBit.getName());
|
||||
output.add(process);
|
||||
}
|
||||
for(var process : com.kingsrook.qbits.backups.processes.ExportTableProcess.exportProcesses(qInstance))
|
||||
{
|
||||
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));
|
||||
for(var widget : com.kingsrook.qbits.backups.model.BackupsTables.widgets())
|
||||
{
|
||||
output.add(widget);
|
||||
}
|
||||
com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData app = com.kingsrook.qbits.backups.app.BackupsApp.app(qBitConfig, tables);
|
||||
output.add(app);
|
||||
output.addSelfToInstance(qInstance);
|
||||
|
||||
// A parent app only lists the apps that are its children, so attach ourselves (for example Admin, then Backups).
|
||||
com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData parent = qBitConfig.getParentAppName() == null ? null : qInstance.getApp(qBitConfig.getParentAppName());
|
||||
if(parent != null && (parent.getChildren() == null || parent.getChildren().stream().noneMatch(child -> app.getName().equals(child.getName()))))
|
||||
{
|
||||
parent.withChild(app);
|
||||
}
|
||||
return (new com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.alerts;
|
||||
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The BackupAlertSink adapter for the Alerts qbit producer SPI (QRun-IO/qqq
|
||||
** PR #1108, com.kingsrook.qqq.backend.core.alerts: QAlerts, AlertSignal,
|
||||
** AlertResolution, AlertSeverity, QAlertTypeMetaData; design spec
|
||||
** docs/specs/2026-10-12-qbit-alerts-design.md).
|
||||
**
|
||||
** STAND-IN: the SPI is an open core PR and is not in QQQ 4.1.0-RC.4, so this
|
||||
** adapter is bound REFLECTIVELY and optionally. It compiles against nothing in
|
||||
** the SPI; it looks the classes up by name, and every call is guarded:
|
||||
** <ul>
|
||||
** <li>no SPI classes on the class path: {@link #bindIfPresent()} binds nothing
|
||||
** and the sink stays the no-op;</li>
|
||||
** <li>the SPI is present but the instance has no alerts meta-data (the Alerts
|
||||
** qbit is not installed): every call is a no-op;</li>
|
||||
** <li>any reflective or SPI failure is logged once and swallowed, because an
|
||||
** alerting problem must never fail a backup.</li>
|
||||
** </ul>
|
||||
** Replace with a direct binding (QAlerts.raise and friends) when RC.5 ships.
|
||||
**
|
||||
** Mapping: raise to QAlerts.raise(AlertSignal), resolve to QAlerts.resolve(type,
|
||||
** dedupKey, note), heartbeat to QAlerts.heartbeat(key). The SPI has no per-plan
|
||||
** heartbeat registration (monitors are declared on the alert type), so the
|
||||
** expected interval and grace are not passed on; the SLA checker raises
|
||||
** backups.plan.missed itself, independent of any heartbeat (spec 10.4).
|
||||
*******************************************************************************/
|
||||
public class AlertsQBitAlertSink implements BackupAlertSink
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AlertsQBitAlertSink.class);
|
||||
|
||||
/** The package of the Alerts producer SPI in qqq-backend-core. */
|
||||
public static final String DEFAULT_API_PACKAGE = "com.kingsrook.qqq.backend.core.alerts";
|
||||
|
||||
private final String apiPackage;
|
||||
|
||||
private boolean warned = false;
|
||||
|
||||
private Class<?> qAlerts;
|
||||
private Class<?> alertSignal;
|
||||
private Class<?> alertSeverity;
|
||||
private Class<?> instanceMetaData;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor for the real SPI package.
|
||||
*******************************************************************************/
|
||||
public AlertsQBitAlertSink()
|
||||
{
|
||||
this(DEFAULT_API_PACKAGE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor for another package (a test double of the SPI shape).
|
||||
*******************************************************************************/
|
||||
public AlertsQBitAlertSink(String apiPackage)
|
||||
{
|
||||
this.apiPackage = apiPackage;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the SPI classes are on the class path.
|
||||
*******************************************************************************/
|
||||
public static boolean isPresent(String apiPackage)
|
||||
{
|
||||
try
|
||||
{
|
||||
Class.forName(apiPackage + ".QAlerts");
|
||||
Class.forName(apiPackage + ".AlertSignal");
|
||||
return (true);
|
||||
}
|
||||
catch(ClassNotFoundException | LinkageError e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind this adapter as the runtime's alert sink when the SPI is present and no
|
||||
** other sink was bound already. Returns whether it bound.
|
||||
*******************************************************************************/
|
||||
public static boolean bindIfPresent()
|
||||
{
|
||||
if(!(BackupsRuntime.getAlertSink() instanceof NoOpBackupAlertSink) || !isPresent(DEFAULT_API_PACKAGE))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
BackupsRuntime.setAlertSink(new AlertsQBitAlertSink());
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare the backup alert types with the Alerts qbit (QAlertTypeMetaData), when
|
||||
** the SPI is present. Returns how many were added.
|
||||
*******************************************************************************/
|
||||
public int registerAlertTypes(QInstance qInstance)
|
||||
{
|
||||
int added = 0;
|
||||
try
|
||||
{
|
||||
Class<?> typeClass = Class.forName(apiPackage + ".QAlertTypeMetaData");
|
||||
load();
|
||||
for(BackupAlerts.AlertType alertType : BackupAlerts.alertTypes())
|
||||
{
|
||||
try
|
||||
{
|
||||
Object type = typeClass.getConstructor().newInstance();
|
||||
typeClass.getMethod("withName", String.class).invoke(type, alertType.name());
|
||||
typeClass.getMethod("withLabel", String.class).invoke(type, alertType.label());
|
||||
typeClass.getMethod("withGroupName", String.class).invoke(type, "Backups");
|
||||
typeClass.getMethod("withHowToFix", String.class).invoke(type, alertType.howToFix());
|
||||
typeClass.getMethod("withDefaultSeverity", alertSeverity).invoke(type, severity(alertType.defaultSeverity()));
|
||||
typeClass.getMethod("addSelfToInstance", QInstance.class).invoke(type, qInstance);
|
||||
added++;
|
||||
}
|
||||
catch(ReflectiveOperationException | RuntimeException e)
|
||||
{
|
||||
warnOnce("Could not declare the alert type " + alertType.name(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(ReflectiveOperationException | LinkageError e)
|
||||
{
|
||||
warnOnce("The alerts SPI is not available", e);
|
||||
}
|
||||
return (added);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void raise(String type, String dedupeKey, Severity severity, String title, Map<String, String> detail)
|
||||
{
|
||||
if(!isAlertsQBitInstalled())
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
Object signal = alertSignal.getConstructor(String.class).newInstance(type);
|
||||
alertSignal.getMethod("setDedupKey", String.class).invoke(signal, dedupeKey);
|
||||
alertSignal.getMethod("setSeverity", alertSeverity).invoke(signal, severity(severity));
|
||||
alertSignal.getMethod("setTitle", String.class).invoke(signal, title);
|
||||
if(detail != null && !detail.isEmpty())
|
||||
{
|
||||
alertSignal.getMethod("setSummary", String.class).invoke(signal, detail.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()).collect(Collectors.joining(", ")));
|
||||
Method withAttribute = alertSignal.getMethod("withAttribute", String.class, String.class);
|
||||
for(Map.Entry<String, String> entry : detail.entrySet())
|
||||
{
|
||||
withAttribute.invoke(signal, entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
qAlerts.getMethod("raise", alertSignal).invoke(null, signal);
|
||||
}
|
||||
catch(ReflectiveOperationException | RuntimeException e)
|
||||
{
|
||||
warnOnce("Could not raise an alert", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void resolve(String type, String dedupeKey)
|
||||
{
|
||||
if(!isAlertsQBitInstalled())
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
qAlerts.getMethod("resolve", String.class, String.class, String.class).invoke(null, type, dedupeKey, "Cleared by the Backups qbit");
|
||||
}
|
||||
catch(ReflectiveOperationException | RuntimeException e)
|
||||
{
|
||||
warnOnce("Could not resolve an alert", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void heartbeat(String monitorKey, long expectedEverySeconds, long graceSeconds)
|
||||
{
|
||||
if(!isAlertsQBitInstalled())
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
qAlerts.getMethod("heartbeat", String.class).invoke(null, monitorKey);
|
||||
}
|
||||
catch(ReflectiveOperationException | RuntimeException e)
|
||||
{
|
||||
warnOnce("Could not check in a heartbeat", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The SPI classes load, and the instance has alerts meta-data (the Alerts qbit
|
||||
** is installed). Checked on every call so the order qbits are produced in
|
||||
** does not matter.
|
||||
*******************************************************************************/
|
||||
boolean isAlertsQBitInstalled()
|
||||
{
|
||||
try
|
||||
{
|
||||
load();
|
||||
QInstance qInstance = QContext.getQInstance();
|
||||
if(qInstance == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (instanceMetaData.getMethod("of", QInstance.class).invoke(null, qInstance) != null);
|
||||
}
|
||||
catch(ReflectiveOperationException | RuntimeException | LinkageError e)
|
||||
{
|
||||
warnOnce("The alerts SPI is not usable", e);
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private synchronized void load() throws ClassNotFoundException
|
||||
{
|
||||
if(qAlerts == null)
|
||||
{
|
||||
qAlerts = Class.forName(apiPackage + ".QAlerts");
|
||||
alertSignal = Class.forName(apiPackage + ".AlertSignal");
|
||||
alertSeverity = Class.forName(apiPackage + ".AlertSeverity");
|
||||
instanceMetaData = Class.forName(apiPackage + ".AlertsInstanceMetaData");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Map our severity to the SPI's enum constant of the same name.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings({ "unchecked", "rawtypes" })
|
||||
private Object severity(Severity severity)
|
||||
{
|
||||
return (Enum.valueOf((Class<Enum>) alertSeverity, severity.name()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void warnOnce(String message, Throwable t)
|
||||
{
|
||||
if(!warned)
|
||||
{
|
||||
warned = true;
|
||||
LOG.warn(message + " (the Alerts qbit adapter will keep failing quietly)", t, logPair("apiPackage", apiPackage));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.alerts;
|
||||
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every alert the Backups qbit raises (design spec section 10.4), with stable
|
||||
** dedupe keys, and helpers that send them through the bound BackupAlertSink.
|
||||
**
|
||||
** A dedupe key is the alert type plus a plan key (or a run key, or a plan key
|
||||
** and a verify level), so a repeated condition refreshes one alert and a cleared
|
||||
** condition resolves exactly that alert. Details carry counts and names only:
|
||||
** never row values, object keys with secrets in them, or presigned URLs.
|
||||
*******************************************************************************/
|
||||
public final class BackupAlerts
|
||||
{
|
||||
public static final String PLAN_MISSED = "backups.plan.missed";
|
||||
public static final String RUN_FAILED = "backups.run.failed";
|
||||
public static final String RUN_MISSING = "backups.run.missing_in_storage";
|
||||
public static final String FILES_MISSING = "backups.files.missing";
|
||||
public static final String VERIFY_FAILED = "backups.verify.failed";
|
||||
public static final String VERIFY_OVERDUE = "backups.verify.overdue";
|
||||
public static final String PLAN_OWNER_INVALID = "backups.plan.owner_invalid";
|
||||
public static final String STORAGE_DEBRIS = "backups.storage.debris";
|
||||
public static final String RESTORE_COMPLETED = "backups.restore.completed";
|
||||
|
||||
/** The prefix of the heartbeat monitor key of a plan. */
|
||||
public static final String HEARTBEAT_PREFIX = "backups.plan:";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A declared alert type, for the Alerts qbit's type registry.
|
||||
**
|
||||
** @param name the alert type name
|
||||
** @param label a short label
|
||||
** @param defaultSeverity the severity when a signal does not say
|
||||
** @param howToFix one sentence for the person looking at the alert
|
||||
***************************************************************************/
|
||||
public record AlertType(String name, String label, BackupAlertSink.Severity defaultSeverity, String howToFix)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private BackupAlerts()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The alert types of the spec table (section 10.4), in table order.
|
||||
*******************************************************************************/
|
||||
public static List<AlertType> alertTypes()
|
||||
{
|
||||
return (List.of(
|
||||
new AlertType(PLAN_MISSED, "Backup did not run", BackupAlertSink.Severity.ERROR,
|
||||
"Open the plan and its latest runs, check the schedule and the scheduler, then run the backup by hand."),
|
||||
new AlertType(RUN_FAILED, "Backup run failed", BackupAlertSink.Severity.WARNING,
|
||||
"Open the failed run for the error, fix the cause, and run the plan again."),
|
||||
new AlertType(RUN_MISSING, "Backup is missing from storage", BackupAlertSink.Severity.CRITICAL,
|
||||
"The catalog says the run succeeded but its manifest is gone from the bucket. Check the bucket, its lifecycle rules and the credentials, then run a new backup."),
|
||||
new AlertType(FILES_MISSING, "Backed up files were missing at the source", BackupAlertSink.Severity.WARNING,
|
||||
"Open the run's file index: records point at files that no longer exist."),
|
||||
new AlertType(VERIFY_FAILED, "Backup verification failed", BackupAlertSink.Severity.ERROR,
|
||||
"Open the verification for the mismatches. Treat the run as unusable and take a new backup."),
|
||||
new AlertType(VERIFY_OVERDUE, "Backup verification is overdue", BackupAlertSink.Severity.WARNING,
|
||||
"Run Verify backup on a recent run of the plan, or set a verify schedule on the plan."),
|
||||
new AlertType(PLAN_OWNER_INVALID, "Backup plan owner is not valid", BackupAlertSink.Severity.WARNING,
|
||||
"Set an owner who exists and holds backups.admin on the plan."),
|
||||
new AlertType(STORAGE_DEBRIS, "Backup storage has debris or orphans", BackupAlertSink.Severity.INFO,
|
||||
"Prune clears debris. Orphan prefixes can be imported into the catalog or deleted."),
|
||||
new AlertType(RESTORE_COMPLETED, "A restore finished", BackupAlertSink.Severity.INFO,
|
||||
"Nothing to fix unless the restore failed. Open the restore run.")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The stable dedupe key for a type and an id (plan key, run key...).
|
||||
*******************************************************************************/
|
||||
public static String key(String type, String id)
|
||||
{
|
||||
return (type + ":" + id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The dedupe key of a verify failure: it includes the level so a pass at a
|
||||
** higher level clears the lower failures, and a cheap pass never hides a
|
||||
** content failure.
|
||||
*******************************************************************************/
|
||||
public static String verifyKey(String planKey, String level)
|
||||
{
|
||||
return (VERIFY_FAILED + ":" + planKey + ":" + level);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Raise (or refresh) an alert. Never throws: an alerting problem must not fail
|
||||
** a backup or a check.
|
||||
*******************************************************************************/
|
||||
public static void raise(String type, String id, BackupAlertSink.Severity severity, String title, String... detailPairs)
|
||||
{
|
||||
try
|
||||
{
|
||||
Map<String, String> detail = new LinkedHashMap<>();
|
||||
for(int i = 0; i + 1 < detailPairs.length; i += 2)
|
||||
{
|
||||
detail.put(detailPairs[i], detailPairs[i + 1]);
|
||||
}
|
||||
BackupsRuntime.getAlertSink().raise(type, key(type, id), severity, title, detail);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
// fire and forget
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Raise with an explicit dedupe key.
|
||||
*******************************************************************************/
|
||||
public static void raiseWithKey(String type, String dedupeKey, BackupAlertSink.Severity severity, String title, Map<String, String> detail)
|
||||
{
|
||||
try
|
||||
{
|
||||
BackupsRuntime.getAlertSink().raise(type, dedupeKey, severity, title, detail);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
// fire and forget
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the alert of a type and id. Never throws.
|
||||
*******************************************************************************/
|
||||
public static void resolve(String type, String id)
|
||||
{
|
||||
resolveKey(type, key(type, id));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve an alert by its dedupe key. Never throws.
|
||||
*******************************************************************************/
|
||||
public static void resolveKey(String type, String dedupeKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
BackupsRuntime.getAlertSink().resolve(type, dedupeKey);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
// fire and forget
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A restore finished: an info alert, so restores are never silent (error when
|
||||
** it failed). The Alerts qbit clears it after 7 days; nothing resolves it here.
|
||||
*******************************************************************************/
|
||||
public static void restoreCompleted(String restoreRunKey, String status, String planKey, long rowsRestored)
|
||||
{
|
||||
boolean failed = "FAILED".equals(status);
|
||||
raise(RESTORE_COMPLETED, restoreRunKey, failed ? BackupAlertSink.Severity.ERROR : BackupAlertSink.Severity.INFO,
|
||||
failed ? "A restore failed" : "A restore finished",
|
||||
"restoreRun", restoreRunKey, "status", String.valueOf(status), "plan", String.valueOf(planKey), "rows", String.valueOf(rowsRestored));
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,6 @@ 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;
|
||||
|
||||
@@ -62,7 +61,7 @@ public final class BackupsApp
|
||||
public static List<QWidgetMetaData> widgets()
|
||||
{
|
||||
return (List.of(
|
||||
new QWidgetMetaData().withName(HOME_WIDGET).withLabel("Backups").withType(WidgetType.HTML.getType())
|
||||
new QWidgetMetaData().withName(HOME_WIDGET).withLabel("Overview").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())));
|
||||
@@ -80,13 +79,7 @@ public final class BackupsApp
|
||||
.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)));
|
||||
.withWidgets(List.of(HOME_WIDGET));
|
||||
if(config.getParentAppName() != null)
|
||||
{
|
||||
app.setParentAppName(config.getParentAppName());
|
||||
@@ -143,10 +136,6 @@ public final class BackupsApp
|
||||
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());
|
||||
return (config == null ? "/app" : config.getTablePathPrefix());
|
||||
}
|
||||
}
|
||||
@@ -159,12 +159,53 @@ public class BackupsHomeData
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The status to show for a plan: the stored slaStatus when it is a problem state,
|
||||
** otherwise LATE when the age of the last success is past the SLA (the SLA
|
||||
** checker may not have run yet), otherwise the stored status (UNKNOWN when none).
|
||||
*******************************************************************************/
|
||||
public String effectiveStatus(QRecord plan)
|
||||
{
|
||||
String stored = plan.getValueString("slaStatus");
|
||||
if(stored != null && ATTENTION.contains(stored))
|
||||
{
|
||||
return (stored);
|
||||
}
|
||||
Long age = ageHours(plan);
|
||||
Integer sla = plan.getValueInteger("slaHours");
|
||||
if(age != null && sla != null && age > sla)
|
||||
{
|
||||
return ("LATE");
|
||||
}
|
||||
return (stored == null ? "UNKNOWN" : stored);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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());
|
||||
return (plans.stream().filter(p -> Boolean.TRUE.equals(p.getValueBoolean("isActive")) && ATTENTION.contains(effectiveStatus(p))).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The last run of a plan (from the recent runs), or null.
|
||||
*******************************************************************************/
|
||||
public QRecord lastRun(QRecord plan)
|
||||
{
|
||||
Integer lastRunId = plan.getValueInteger("lastRunId");
|
||||
for(QRecord run : recentRuns)
|
||||
{
|
||||
if(lastRunId != null && lastRunId.equals(run.getValueInteger("id")))
|
||||
{
|
||||
return (run);
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
@@ -206,7 +247,7 @@ public class BackupsHomeData
|
||||
Integer runId = verification.getValueInteger("backupRunId");
|
||||
for(QRecord run : recentRuns)
|
||||
{
|
||||
if(runId != null && runId.equals(run.getValueInteger("id")) && planId.equals(run.getValueInteger("backupPlanId")))
|
||||
if(runId != null && planId != null && runId.equals(run.getValueInteger("id")) && planId.equals(run.getValueInteger("backupPlanId")))
|
||||
{
|
||||
return (verification);
|
||||
}
|
||||
@@ -224,6 +265,10 @@ public class BackupsHomeData
|
||||
public List<QRecord> successfulRuns(Integer planId)
|
||||
{
|
||||
List<QRecord> rs = new ArrayList<>();
|
||||
if(planId == null)
|
||||
{
|
||||
return (rs);
|
||||
}
|
||||
for(QRecord run : recentRuns)
|
||||
{
|
||||
if(planId.equals(run.getValueInteger("backupPlanId")) && run.getValueString("status") != null && run.getValueString("status").startsWith("SUCCEEDED"))
|
||||
@@ -242,6 +287,10 @@ public class BackupsHomeData
|
||||
public Map<String, Integer> retentionCounts(Integer planId)
|
||||
{
|
||||
Map<String, Integer> counts = new LinkedHashMap<>();
|
||||
if(planId == null)
|
||||
{
|
||||
return (counts);
|
||||
}
|
||||
for(QRecord run : recentRuns)
|
||||
{
|
||||
if(planId.equals(run.getValueInteger("backupPlanId")) && run.getValueString("status") != null && run.getValueString("status").startsWith("SUCCEEDED")
|
||||
|
||||
@@ -27,21 +27,30 @@ 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).
|
||||
** Renders the Backups home: a plan status row per plan across the full width,
|
||||
** then cards for what needs attention, the latest test restore, recent activity,
|
||||
** the size trend and retention. Every card has an honest empty state. Layout
|
||||
** is flow and grid only (no tables, no fixed widths, no inner scrolling): plan
|
||||
** rows wrap their cells and the cards stack to one column on narrow screens,
|
||||
** long names wrap, and colors are inherited (only the status dot is colored), so
|
||||
** it reads at every width in light and dark mode.
|
||||
*******************************************************************************/
|
||||
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 ROOT = "display:grid;gap:1rem;min-width:0;";
|
||||
private static final String GRID = "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,24rem),1fr));gap:1rem;align-items:start;min-width:0;";
|
||||
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 ROW = "border:1px solid rgba(127,127,127,.25);border-radius:6px;padding:.75rem;margin:0 0 .75rem;min-width:0;";
|
||||
private static final String CELLS = "display:grid;grid-template-columns:repeat(auto-fit,minmax(9.5rem,1fr));gap:.6rem 1rem;margin:.6rem 0 0;font-size:.9rem;";
|
||||
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 ITEMS = "margin:0 0 .75rem;padding-left:1.25rem;list-style:disc;font-size:.9rem;display:grid;gap:.35rem;";
|
||||
private static final String MUTED = "opacity:.75;";
|
||||
private static final String LINK = "color:inherit;text-decoration:underline;text-underline-offset:2px;";
|
||||
private static final String CHIP = "display:inline-flex;align-items:center;gap:.4rem;padding:.05rem .6rem;border-radius:999px;border:1px solid rgba(127,127,127,.4);font-size:.8rem;white-space:nowrap;";
|
||||
|
||||
private static final int TREND_BARS = 30;
|
||||
|
||||
|
||||
|
||||
@@ -59,14 +68,15 @@ public final class BackupsHomeView
|
||||
*******************************************************************************/
|
||||
public static String render(BackupsHomeData data, String pathPrefix)
|
||||
{
|
||||
StringBuilder html = new StringBuilder("<div class=\"backups-home\" style=\"" + GRID + "\">");
|
||||
StringBuilder html = new StringBuilder("<div class=\"backups-home\" style=\"" + ROOT + "\">");
|
||||
planStatus(html, data, pathPrefix);
|
||||
html.append("<div style=\"").append(GRID).append("\">");
|
||||
needsAttention(html, data, pathPrefix);
|
||||
latestTestRestore(html, data, pathPrefix);
|
||||
trend(html, data);
|
||||
recentActivity(html, data, pathPrefix);
|
||||
trend(html, data);
|
||||
retention(html, data);
|
||||
return (html.append("</div>").toString());
|
||||
return (html.append("</div></div>").toString());
|
||||
}
|
||||
|
||||
|
||||
@@ -82,13 +92,13 @@ public final class BackupsHomeView
|
||||
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>");
|
||||
return ("<div style=\"" + CARD + "\"><strong>Backups</strong><p>" + esc(text) + "</p><a style=\"" + LINK + "\" href=\"" + esc(pathPrefix) + "\">Open the Backups home</a></div>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Plan status: last successful backup, age against the SLA, last run, verification.
|
||||
** Plan status: one row per plan, the cells wrap to the width.
|
||||
*******************************************************************************/
|
||||
private static void planStatus(StringBuilder html, BackupsHomeData data, String prefix)
|
||||
{
|
||||
@@ -99,24 +109,29 @@ public final class BackupsHomeView
|
||||
}
|
||||
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)
|
||||
Long age = data.ageHours(plan);
|
||||
Integer sla = plan.getValueInteger("slaHours");
|
||||
String status = data.effectiveStatus(plan);
|
||||
QRecord last = data.lastRun(plan);
|
||||
|
||||
html.append("<div style=\"").append(ROW).append("\"><div style=\"display:flex;flex-wrap:wrap;align-items:center;gap:.5rem .75rem;\">");
|
||||
html.append("<strong>").append(recordLink(prefix, BackupsTables.PLAN, plan.getValue("id"), plan.getValueString("name"))).append("</strong>");
|
||||
html.append(chip(status));
|
||||
if(!Boolean.TRUE.equals(plan.getValueBoolean("isActive")))
|
||||
{
|
||||
row(html, "Age against SLA", age + " h of " + sla + " h" + (age > sla ? " - late" : ""));
|
||||
html.append("<span style=\"").append(MUTED).append("\">Not active</span>");
|
||||
}
|
||||
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"));
|
||||
html.append("</div><div style=\"").append(CELLS).append("\">");
|
||||
cell(html, "Last backup", plan.getValueInstant("lastSuccessDate") == null ? "No successful backup yet" : WHEN.format(plan.getValueInstant("lastSuccessDate")));
|
||||
cell(html, "Age against SLA", age != null && sla != null ? age + " h of " + sla + " h" : (sla == null ? "No SLA set" : "Not run yet"));
|
||||
cell(html, "Last run", last == null ? "None yet" : label(last.getValueString("status")) + (last.getValueInstant("startDate") == null ? "" : ", " + WHEN.format(last.getValueInstant("startDate"))));
|
||||
cell(html, "Schedule", plan.getValueString("cronExpression") == null ? "Manual only" : plan.getValueString("cronExpression"));
|
||||
cell(html, "Last verified", plan.getValueInstant("lastVerifiedDate") == null ? "Not verified yet" : WHEN.format(plan.getValueInstant("lastVerifiedDate")));
|
||||
if(plan.getValueString("objectLockMode") != null && !"NONE".equals(plan.getValueString("objectLockMode")))
|
||||
{
|
||||
row(html, "Object lock", plan.getValueString("objectLockMode") + ", " + plan.getValueInteger("objectLockDays") + " days");
|
||||
cell(html, "Object lock", plan.getValueString("objectLockMode") + (plan.getValueInteger("objectLockDays") == null ? "" : ", " + plan.getValueInteger("objectLockDays") + " days"));
|
||||
}
|
||||
html.append("</dl>");
|
||||
html.append("</div></div>");
|
||||
}
|
||||
link(html, prefix, BackupsTables.PLAN, "Backup plans");
|
||||
close(html);
|
||||
@@ -144,13 +159,13 @@ public final class BackupsHomeView
|
||||
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>");
|
||||
html.append("<li>").append(recordLink(prefix, BackupsTables.PLAN, plan.getValue("id"), plan.getValueString("name")))
|
||||
.append(": ").append(esc(label(data.effectiveStatus(plan)))).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("<li>Run ").append(recordLink(prefix, BackupsTables.RUN, run.getValue("id"), run.getValueString("runKey")))
|
||||
.append(": ").append(esc(label(run.getValueString("status")))).append("</li>");
|
||||
}
|
||||
html.append("</ul>");
|
||||
}
|
||||
@@ -178,7 +193,8 @@ public final class BackupsHomeView
|
||||
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, "Compared", number(v.getValueLong("rowsChecked")) + " rows in " + number(toLong(v.getValueInteger("tablesChecked"))) + " tables, "
|
||||
+ number(toLong(v.getValueInteger("mismatches"))) + " mismatches");
|
||||
row(html, "Scratch target", v.getValueString("scratchTarget") == null ? "Not recorded" : v.getValueString("scratchTarget"));
|
||||
html.append("</dl>");
|
||||
}
|
||||
@@ -193,7 +209,8 @@ public final class BackupsHomeView
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Size and duration trend per plan, as numbers next to each other.
|
||||
** Size trend per plan: bars for the stored size of each successful run (oldest
|
||||
** left), then the numbers.
|
||||
*******************************************************************************/
|
||||
private static void trend(StringBuilder html, BackupsHomeData data)
|
||||
{
|
||||
@@ -209,13 +226,14 @@ public final class BackupsHomeView
|
||||
any = true;
|
||||
QRecord first = runs.get(0);
|
||||
QRecord last = runs.get(runs.size() - 1);
|
||||
html.append("<div style=\"margin:0 0 .75rem;\"><strong style=\"font-size:.9rem;\">").append(esc(plan.getValueString("name"))).append("</strong>");
|
||||
bars(html, runs);
|
||||
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>");
|
||||
row(html, "Stored", bytes(first.getValueLong("bytesStored")) + " at first, " + bytes(last.getValueLong("bytesStored")) + " latest");
|
||||
row(html, "Rows", number(first.getValueLong("rowsTotal")) + " at first, " + number(last.getValueLong("rowsTotal")) + " latest");
|
||||
row(html, "Duration", last.getValueLong("durationMs") == null ? "Not recorded" : duration(last.getValueLong("durationMs")) + " latest");
|
||||
html.append("</dl></div>");
|
||||
}
|
||||
if(!any)
|
||||
{
|
||||
@@ -227,7 +245,31 @@ public final class BackupsHomeView
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recent activity: the last runs and restores.
|
||||
** A row of bars, one per run, tall in proportion to the stored bytes.
|
||||
*******************************************************************************/
|
||||
private static void bars(StringBuilder html, List<QRecord> runs)
|
||||
{
|
||||
List<QRecord> shown = runs.size() > TREND_BARS ? runs.subList(runs.size() - TREND_BARS, runs.size()) : runs;
|
||||
long max = 1;
|
||||
for(QRecord run : shown)
|
||||
{
|
||||
max = Math.max(max, run.getValueLong("bytesStored") == null ? 0 : run.getValueLong("bytesStored"));
|
||||
}
|
||||
html.append("<div role=\"img\" aria-label=\"Stored size of the last ").append(shown.size()).append(" successful runs\" style=\"display:flex;align-items:flex-end;gap:2px;height:2.5rem;margin:.4rem 0 .6rem;\">");
|
||||
for(QRecord run : shown)
|
||||
{
|
||||
long value = run.getValueLong("bytesStored") == null ? 0 : run.getValueLong("bytesStored");
|
||||
long pct = Math.max(4, Math.round(100.0 * value / max));
|
||||
String title = (run.getValueInstant("startDate") == null ? "" : WHEN.format(run.getValueInstant("startDate")) + ": ") + bytes(run.getValueLong("bytesStored"));
|
||||
html.append("<span title=\"").append(esc(title)).append("\" style=\"flex:1 1 0;min-width:2px;max-width:1.5rem;height:").append(pct).append("%;background:currentColor;opacity:.55;border-radius:2px 2px 0 0;\"></span>");
|
||||
}
|
||||
html.append("</div>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recent activity: the last runs, restores and verifications.
|
||||
*******************************************************************************/
|
||||
private static void recentActivity(StringBuilder html, BackupsHomeData data, String prefix)
|
||||
{
|
||||
@@ -242,13 +284,13 @@ public final class BackupsHomeView
|
||||
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>");
|
||||
html.append("<li>Backup ").append(recordLink(prefix, BackupsTables.RUN, run.getValue("id"), run.getValueString("runKey")))
|
||||
.append(": ").append(esc(label(run.getValueString("status")))).append(start == null ? "" : ", " + esc(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>"));
|
||||
data.restores().stream().limit(2).forEach(restore -> html.append("<li>Restore ").append(recordLink(prefix, BackupsTables.RESTORE_RUN, restore.getValue("id"), "#" + restore.getValue("id")))
|
||||
.append(": ").append(esc(label(restore.getValueString("status")))).append("</li>"));
|
||||
data.verifications().stream().limit(2).forEach(v -> html.append("<li>Verify ").append(recordLink(prefix, BackupsTables.VERIFICATION, v.getValue("id"), label(v.getValueString("level"))))
|
||||
.append(": ").append(esc(label(v.getValueString("status")))).append("</li>"));
|
||||
html.append("</ul>");
|
||||
}
|
||||
link(html, prefix, BackupsTables.RUN, "All runs");
|
||||
@@ -274,9 +316,10 @@ public final class BackupsHomeView
|
||||
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, "Kept", counts.entrySet().stream().map(e -> e.getValue() + " " + ("UNCLASSIFIED".equals(e.getKey()) ? "not classified yet (no prune has run)" : 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>");
|
||||
}
|
||||
@@ -290,17 +333,17 @@ public final class BackupsHomeView
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
** Open a card.
|
||||
*******************************************************************************/
|
||||
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>");
|
||||
html.append("<section style=\"").append(CARD).append("\"><h3 style=\"margin:0 0 .75rem;font-size:1rem;font-weight:600;\">").append(esc(title)).append("</h3>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
** Close a card.
|
||||
*******************************************************************************/
|
||||
private static void close(StringBuilder html)
|
||||
{
|
||||
@@ -310,17 +353,17 @@ public final class BackupsHomeView
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
** A muted empty-state line.
|
||||
*******************************************************************************/
|
||||
private static void empty(StringBuilder html, String text)
|
||||
{
|
||||
html.append("<p style=\"").append(MUTED).append("margin:0 0 .75rem;\">").append(esc(text)).append("</p>");
|
||||
html.append("<p style=\"").append(MUTED).append("margin:0 0 .75rem;font-size:.9rem;\">").append(esc(text)).append("</p>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
** A name and value pair in a definition list.
|
||||
*******************************************************************************/
|
||||
private static void row(StringBuilder html, String name, String value)
|
||||
{
|
||||
@@ -330,17 +373,53 @@ public final class BackupsHomeView
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
** A small label over its value (a plan row cell).
|
||||
*******************************************************************************/
|
||||
private static void link(StringBuilder html, String prefix, String table, String text)
|
||||
private static void cell(StringBuilder html, String name, String value)
|
||||
{
|
||||
html.append("<a href=\"").append(esc(prefix + "/" + table)).append("\">").append(esc(text)).append("</a>");
|
||||
html.append("<div style=\"min-width:0;\"><div style=\"").append(MUTED).append("font-size:.75rem;\">").append(esc(name)).append("</div><div>").append(esc(value)).append("</div></div>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SOME_STATUS to Some status.
|
||||
** A status chip: a colored dot (the only color) and the status label.
|
||||
*******************************************************************************/
|
||||
private static String chip(String status)
|
||||
{
|
||||
String color = switch(status == null ? "UNKNOWN" : status)
|
||||
{
|
||||
case "OK" -> "#16a34a";
|
||||
case "LATE", "UNVERIFIED", "UNKNOWN" -> "#d97706";
|
||||
default -> "#dc2626";
|
||||
};
|
||||
return ("<span style=\"" + CHIP + "\"><span aria-hidden=\"true\" style=\"width:.55rem;height:.55rem;border-radius:50%;background:" + color + ";display:inline-block;\"></span>" + esc(label(status)) + "</span>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A link to the table list at the end of a card.
|
||||
*******************************************************************************/
|
||||
private static void link(StringBuilder html, String prefix, String table, String text)
|
||||
{
|
||||
html.append("<a style=\"").append(LINK).append("font-size:.9rem;\" href=\"").append(esc(prefix + "/" + table)).append("\">").append(esc(text)).append("</a>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A link to a record.
|
||||
*******************************************************************************/
|
||||
private static String recordLink(String prefix, String table, Object id, String text)
|
||||
{
|
||||
return ("<a style=\"" + LINK + "\" href=\"" + esc(prefix + "/" + table + "/" + id) + "\">" + esc(text == null ? String.valueOf(id) : text) + "</a>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SOME_STATUS to Some status (OK stays OK).
|
||||
*******************************************************************************/
|
||||
static String label(String status)
|
||||
{
|
||||
@@ -348,6 +427,10 @@ public final class BackupsHomeView
|
||||
{
|
||||
return ("Unknown");
|
||||
}
|
||||
if("OK".equals(status))
|
||||
{
|
||||
return ("OK");
|
||||
}
|
||||
String s = status.replace('_', ' ').toLowerCase();
|
||||
return (Character.toUpperCase(s.charAt(0)) + s.substring(1));
|
||||
}
|
||||
@@ -355,7 +438,32 @@ public final class BackupsHomeView
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
** An Integer as a Long, null-safe.
|
||||
*******************************************************************************/
|
||||
private static Long toLong(Integer value)
|
||||
{
|
||||
return (value == null ? null : value.longValue());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Milliseconds as a short duration.
|
||||
*******************************************************************************/
|
||||
static String duration(long millis)
|
||||
{
|
||||
if(millis < 1000)
|
||||
{
|
||||
return (millis + " ms");
|
||||
}
|
||||
long seconds = millis / 1000;
|
||||
return (seconds < 60 ? seconds + " s" : seconds / 60 + " min " + seconds % 60 + " s");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bytes as a short size.
|
||||
*******************************************************************************/
|
||||
static String bytes(Long value)
|
||||
{
|
||||
@@ -363,7 +471,7 @@ public final class BackupsHomeView
|
||||
{
|
||||
return ("Not recorded");
|
||||
}
|
||||
double v = value;
|
||||
double v = value;
|
||||
String[] units = { "B", "KB", "MB", "GB", "TB" };
|
||||
int i = 0;
|
||||
while(v >= 1024 && i < units.length - 1)
|
||||
@@ -377,7 +485,7 @@ public final class BackupsHomeView
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
** A number with separators.
|
||||
*******************************************************************************/
|
||||
static String number(Long value)
|
||||
{
|
||||
@@ -387,7 +495,7 @@ public final class BackupsHomeView
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
** HTML-escape.
|
||||
*******************************************************************************/
|
||||
static String esc(String s)
|
||||
{
|
||||
|
||||
@@ -22,9 +22,11 @@ import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
import com.kingsrook.qbits.backups.storage.BackupStorage;
|
||||
import com.kingsrook.qbits.backups.storage.WriteOptions;
|
||||
@@ -65,6 +67,10 @@ public class TableArtifactWriter implements Closeable
|
||||
private String firstKeyOfTable;
|
||||
private String lastKeyOfTable;
|
||||
|
||||
private Consumer<PartResult> partListener;
|
||||
private String objectLockMode;
|
||||
private Instant objectLockUntil;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
@@ -122,6 +128,63 @@ public class TableArtifactWriter implements Closeable
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Be told about each part as it is committed to storage (so the catalog records
|
||||
** a part the moment it is complete, which is what a resume starts from).
|
||||
*******************************************************************************/
|
||||
public TableArtifactWriter withPartListener(Consumer<PartResult> partListener)
|
||||
{
|
||||
this.partListener = partListener;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply an object lock to every part written.
|
||||
*******************************************************************************/
|
||||
public TableArtifactWriter withObjectLock(String mode, Instant until)
|
||||
{
|
||||
this.objectLockMode = mode;
|
||||
this.objectLockUntil = until;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Continue after parts that were already written (resume): numbering carries on
|
||||
** after them and the table totals include them. The caller then feeds the rows
|
||||
** hash with seedRowsHash.
|
||||
*******************************************************************************/
|
||||
public void resumeFrom(List<PartResult> priorParts)
|
||||
{
|
||||
if(priorParts.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
parts.addAll(priorParts);
|
||||
partNumber = priorParts.get(priorParts.size() - 1).partNumber();
|
||||
for(PartResult part : priorParts)
|
||||
{
|
||||
totalRows += part.rows();
|
||||
}
|
||||
firstKeyOfTable = priorParts.get(0).firstKey();
|
||||
lastKeyOfTable = priorParts.get(priorParts.size() - 1).lastKey();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Feed already-written canonical JSONL bytes into the rows hash (resume).
|
||||
*******************************************************************************/
|
||||
public void seedRowsHash(byte[] bytes, int offset, int length)
|
||||
{
|
||||
rowsDigest.update(bytes, offset, length);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Write one record.
|
||||
*******************************************************************************/
|
||||
@@ -245,6 +308,10 @@ public class TableArtifactWriter implements Closeable
|
||||
WriteOptions options = WriteOptions.create().withContentType("application/octet-stream")
|
||||
.withMetadata("qqq-backup-run", runKey)
|
||||
.withMetadata("qqq-backup-table", table.getName());
|
||||
if(objectLockMode != null)
|
||||
{
|
||||
options.withObjectLock(objectLockMode, objectLockUntil);
|
||||
}
|
||||
OutputStream toStorage = storage.openWrite(current.key, options);
|
||||
current.stored = new HashingOutputStream(toStorage);
|
||||
OutputStream transformed = transform.wrap(current.stored);
|
||||
@@ -266,9 +333,14 @@ public class TableArtifactWriter implements Closeable
|
||||
private void closePart() throws IOException
|
||||
{
|
||||
current.raw.close();
|
||||
parts.add(new PartResult(partNumber, current.key, current.rows, current.raw.getCount(), current.stored.getCount(),
|
||||
PartResult result = 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"));
|
||||
csv ? "CSV" : "JSONL", gzip ? "GZIP" : "NONE");
|
||||
parts.add(result);
|
||||
current = null;
|
||||
if(partListener != null)
|
||||
{
|
||||
partListener.accept(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.events;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.BiConsumer;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** ESB events of the Backups qbit (design spec section 10.4): qqq.backups.run.succeeded,
|
||||
** qqq.backups.run.failed and qqq.backups.restore.completed. They are for
|
||||
** integrations, not alerting, and are FIRE AND FORGET: a failed publish is logged
|
||||
** at most once and never fails a backup or a restore.
|
||||
**
|
||||
** Events go out only when BackupsQBitConfig.esbDestinationName is set and the ESB
|
||||
** (qqq-esb, an optional dependency) is on the class path; otherwise publish() does
|
||||
** nothing. A test, or an app with its own bus, can bind a publisher with
|
||||
** {@link #setPublisher}.
|
||||
*******************************************************************************/
|
||||
public final class BackupEvents
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(BackupEvents.class);
|
||||
|
||||
public static final String RUN_SUCCEEDED = "qqq.backups.run.succeeded";
|
||||
public static final String RUN_FAILED = "qqq.backups.run.failed";
|
||||
public static final String RESTORE_COMPLETED = "qqq.backups.restore.completed";
|
||||
|
||||
private static volatile BiConsumer<String, Map<String, Serializable>> publisher;
|
||||
private static volatile boolean warned = false;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private BackupEvents()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind a publisher (event type, data), or null to go back to the ESB default.
|
||||
*******************************************************************************/
|
||||
public static void setPublisher(BiConsumer<String, Map<String, Serializable>> newPublisher)
|
||||
{
|
||||
publisher = newPublisher;
|
||||
warned = false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A run finished. Succeeded and warnings publish run.succeeded; failed, timed
|
||||
** out and abandoned publish run.failed; cancelled publishes nothing.
|
||||
*******************************************************************************/
|
||||
public static void runFinished(String planKey, String runKey, String status, long rows, long bytesStored)
|
||||
{
|
||||
String type;
|
||||
if("SUCCEEDED".equals(status) || "SUCCEEDED_WITH_WARNINGS".equals(status))
|
||||
{
|
||||
type = RUN_SUCCEEDED;
|
||||
}
|
||||
else if("CANCELLED".equals(status))
|
||||
{
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
type = RUN_FAILED;
|
||||
}
|
||||
Map<String, Serializable> data = new LinkedHashMap<>();
|
||||
data.put("plan", planKey);
|
||||
data.put("run", runKey);
|
||||
data.put("status", status);
|
||||
data.put("rows", rows);
|
||||
data.put("bytesStored", bytesStored);
|
||||
publish(type, data);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A restore finished (any status).
|
||||
*******************************************************************************/
|
||||
public static void restoreCompleted(String restoreRunKey, String status, long rowsRestored)
|
||||
{
|
||||
Map<String, Serializable> data = new LinkedHashMap<>();
|
||||
data.put("restoreRun", restoreRunKey);
|
||||
data.put("status", status);
|
||||
data.put("rows", rowsRestored);
|
||||
publish(RESTORE_COMPLETED, data);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Publish an event. Never throws.
|
||||
*******************************************************************************/
|
||||
public static void publish(String type, Map<String, Serializable> data)
|
||||
{
|
||||
try
|
||||
{
|
||||
BiConsumer<String, Map<String, Serializable>> bound = publisher;
|
||||
if(bound != null)
|
||||
{
|
||||
bound.accept(type, data);
|
||||
return;
|
||||
}
|
||||
|
||||
String destination = BackupsRuntime.getConfig() == null ? null : BackupsRuntime.getConfig().getEsbDestinationName();
|
||||
if(destination == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
EsbBackupEventPublisher.publish(destination, type, data);
|
||||
}
|
||||
catch(Exception | LinkageError e)
|
||||
{
|
||||
if(!warned)
|
||||
{
|
||||
warned = true;
|
||||
LOG.warn("A backup event could not be published (events are best effort)", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.events;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.esb.publish.EsbPublishAction;
|
||||
import com.kingsrook.qqq.esb.publish.EsbPublishInput;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Publishes a backup event on the QQQ ESB. This is the only class that touches
|
||||
** qqq-esb (an optional dependency); BackupEvents calls it inside a guard that
|
||||
** swallows a missing class (NoClassDefFoundError), so apps without the ESB never
|
||||
** notice.
|
||||
*******************************************************************************/
|
||||
final class EsbBackupEventPublisher
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private EsbBackupEventPublisher()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Publish one event to a destination.
|
||||
*******************************************************************************/
|
||||
static void publish(String destinationName, String type, Map<String, Serializable> data)
|
||||
{
|
||||
new EsbPublishAction().execute(new EsbPublishInput()
|
||||
.withDestinationName(destinationName)
|
||||
.withType(type)
|
||||
.withSourcePath("/backups")
|
||||
.withData(data));
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,8 @@ public class FileCopier
|
||||
private final FileStoreRegistry registry;
|
||||
private final boolean referenceOnly;
|
||||
private final String runKey;
|
||||
private String objectLockMode;
|
||||
private java.time.Instant objectLockUntil;
|
||||
|
||||
|
||||
|
||||
@@ -119,6 +121,19 @@ public class FileCopier
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Lock every object this copier writes (a server-side copy cannot set a lock,
|
||||
** so with a lock the bytes are streamed instead).
|
||||
*******************************************************************************/
|
||||
public FileCopier withObjectLock(String mode, java.time.Instant until)
|
||||
{
|
||||
this.objectLockMode = mode;
|
||||
this.objectLockUntil = until;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The content-addressed key for a hash: prefix/ab/cd/sha256.
|
||||
*******************************************************************************/
|
||||
@@ -177,7 +192,7 @@ public class FileCopier
|
||||
*******************************************************************************/
|
||||
private String copy(BackupStorage source, FileReference ref, String key) throws IOException
|
||||
{
|
||||
if(source == destination && !transform.encrypts())
|
||||
if(source == destination && !transform.encrypts() && objectLockMode == null)
|
||||
{
|
||||
destination.copy(ref.key(), key);
|
||||
return (null);
|
||||
@@ -185,6 +200,10 @@ public class FileCopier
|
||||
|
||||
WriteOptions options = WriteOptions.create().withContentType(ref.contentType() == null ? "application/octet-stream" : ref.contentType())
|
||||
.withMetadata("qqq-backup-run", runKey);
|
||||
if(objectLockMode != null)
|
||||
{
|
||||
options.withObjectLock(objectLockMode, objectLockUntil);
|
||||
}
|
||||
try(InputStream in = source.openRead(ref.key());
|
||||
HashingOutputStream plain = new HashingOutputStream(transform.wrap(destination.openWrite(key, options))))
|
||||
{
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.health;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.middleware.health.HealthIndicator;
|
||||
import com.kingsrook.qqq.middleware.health.model.metadata.HealthCheckResult;
|
||||
import com.kingsrook.qqq.middleware.health.model.metadata.HealthStatus;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Backups health indicator (design spec section 10.4), for
|
||||
** qqq-middleware-health (an optional dependency: an app that does not have that
|
||||
** module never loads this class). It reports DOWN if any active plan marked
|
||||
** critical is LATE, FAILING or MISSING_IN_STORAGE, and UP with details otherwise.
|
||||
** It reads the slaStatus the SLA checker maintains; it never touches storage, so
|
||||
** a health call is cheap.
|
||||
**
|
||||
** It is OFF the liveness path by design. Add it to the indicators of the
|
||||
** instance's health check (the readiness and monitoring view), and do not point a
|
||||
** Kubernetes liveness probe at a check that includes it: a backup problem must
|
||||
** never restart pods.
|
||||
*******************************************************************************/
|
||||
public class BackupsHealthIndicator implements HealthIndicator
|
||||
{
|
||||
public static final String NAME = "backups";
|
||||
|
||||
private static final Set<String> CRITICAL_STATUSES = Set.of("LATE", "FAILING", "MISSING_IN_STORAGE");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
return (NAME);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public HealthCheckResult check(QInstance qInstance) throws QException
|
||||
{
|
||||
long started = System.currentTimeMillis();
|
||||
boolean ownsContext = QContext.getQInstance() == null;
|
||||
if(ownsContext)
|
||||
{
|
||||
QContext.init(qInstance, new QSession());
|
||||
}
|
||||
try
|
||||
{
|
||||
List<QRecord> plans = new BackupCatalog().query(BackupsTables.PLAN, new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true)));
|
||||
|
||||
List<String> criticalProblems = new ArrayList<>();
|
||||
Map<String, Integer> byStatus = new LinkedHashMap<>();
|
||||
for(QRecord plan : plans)
|
||||
{
|
||||
String status = plan.getValueString("slaStatus") == null ? "UNKNOWN" : plan.getValueString("slaStatus");
|
||||
byStatus.merge(status, 1, Integer::sum);
|
||||
if(Boolean.TRUE.equals(plan.getValueBoolean("critical")) && CRITICAL_STATUSES.contains(status))
|
||||
{
|
||||
criticalProblems.add(plan.getValueString("planKey") + ": " + status);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> details = new LinkedHashMap<>();
|
||||
details.put("activePlans", plans.size());
|
||||
details.put("plansByStatus", byStatus);
|
||||
if(!criticalProblems.isEmpty())
|
||||
{
|
||||
details.put("criticalPlansWithProblems", criticalProblems);
|
||||
}
|
||||
return (new HealthCheckResult()
|
||||
.withStatus(criticalProblems.isEmpty() ? HealthStatus.UP : HealthStatus.DOWN)
|
||||
.withDurationMs(System.currentTimeMillis() - started)
|
||||
.withDetails(details));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(ownsContext)
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.metrics;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.backups.BackupsQBitConfig;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The optional Prometheus text for operators who already run Alertmanager (design
|
||||
** spec section 10.4), computed from the tables and cached for 60 seconds. Gauges,
|
||||
** all labeled {instance_name, plan}:
|
||||
** <ul>
|
||||
** <li>qqq_backup_last_success_timestamp_seconds</li>
|
||||
** <li>qqq_backup_last_run_failed (1 when the latest finished run failed)</li>
|
||||
** <li>qqq_backup_sla_seconds</li>
|
||||
** <li>qqq_backup_last_verify_success_timestamp_seconds</li>
|
||||
** <li>qqq_backup_last_success_bytes</li>
|
||||
** </ul>
|
||||
** The rule worth having even alongside the Alerts qbit is
|
||||
** absent_over_time(qqq_backup_last_success_timestamp_seconds[30m]): the one signal
|
||||
** that still fires when the whole app, and so the Alerts qbit with it, is down.
|
||||
**
|
||||
** ENDPOINT STUB: this class renders the text. Serving it at GET /metrics/backups
|
||||
** (when BackupsQBitConfig.withPrometheusEndpoint(true)) is a route in
|
||||
** qqq-middleware-javalin, which this qbit does not depend on; the app's Javalin
|
||||
** route provider calls {@link #renderCached()}. {@link #isEnabled()} says whether
|
||||
** the config asks for the endpoint.
|
||||
*******************************************************************************/
|
||||
public final class BackupsMetrics
|
||||
{
|
||||
public static final String ENDPOINT_PATH = "/metrics/backups";
|
||||
|
||||
private static final Duration CACHE = Duration.ofSeconds(60);
|
||||
|
||||
private static String cachedText;
|
||||
private static Instant cachedAt;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private BackupsMetrics()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the config asks for the endpoint.
|
||||
*******************************************************************************/
|
||||
public static boolean isEnabled()
|
||||
{
|
||||
BackupsQBitConfig config = BackupsRuntime.getConfig();
|
||||
return (config != null && Boolean.TRUE.equals(config.getPrometheusEndpoint()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Forget the cached text (tests).
|
||||
*******************************************************************************/
|
||||
public static synchronized void resetCache()
|
||||
{
|
||||
cachedText = null;
|
||||
cachedAt = null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The text, from the cache when it is under 60 seconds old.
|
||||
*******************************************************************************/
|
||||
public static synchronized String renderCached() throws QException
|
||||
{
|
||||
Instant now = BackupsRuntime.getClock().instant();
|
||||
if(cachedText == null || cachedAt == null || cachedAt.plus(CACHE).isBefore(now))
|
||||
{
|
||||
cachedText = render();
|
||||
cachedAt = now;
|
||||
}
|
||||
return (cachedText);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Render the text format now.
|
||||
*******************************************************************************/
|
||||
public static String render() throws QException
|
||||
{
|
||||
BackupCatalog catalog = new BackupCatalog();
|
||||
BackupsQBitConfig config = BackupsRuntime.getConfig();
|
||||
String instance = config != null && config.getInstanceName() != null ? config.getInstanceName() : "qqq";
|
||||
|
||||
StringBuilder lastSuccess = header("qqq_backup_last_success_timestamp_seconds", "Unix time of the last successful backup run");
|
||||
StringBuilder lastFailed = header("qqq_backup_last_run_failed", "1 when the latest finished run of the plan failed");
|
||||
StringBuilder sla = header("qqq_backup_sla_seconds", "Maximum age of the last successful run before the plan is late");
|
||||
StringBuilder lastVerify = header("qqq_backup_last_verify_success_timestamp_seconds", "Unix time of the last passed content or test-restore verification");
|
||||
StringBuilder lastBytes = header("qqq_backup_last_success_bytes", "Bytes stored by the last successful run");
|
||||
|
||||
List<QRecord> plans = catalog.query(BackupsTables.PLAN, new QQueryFilter(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true)));
|
||||
for(QRecord plan : plans)
|
||||
{
|
||||
String labels = "{instance_name=\"" + escape(instance) + "\",plan=\"" + escape(plan.getValueString("planKey")) + "\"}";
|
||||
|
||||
if(plan.getValueInstant("lastSuccessDate") != null)
|
||||
{
|
||||
lastSuccess.append("qqq_backup_last_success_timestamp_seconds").append(labels).append(' ').append(plan.getValueInstant("lastSuccessDate").getEpochSecond()).append('\n');
|
||||
}
|
||||
List<QRecord> runs = catalog.where(BackupsTables.RUN, "backupPlanId", plan.getValueInteger("id"));
|
||||
QRecord latest = runs.stream().filter(r -> List.of("SUCCEEDED", "SUCCEEDED_WITH_WARNINGS", "FAILED", "TIMED_OUT", "ABANDONED").contains(r.getValueString("status"))).findFirst().orElse(null);
|
||||
if(latest != null)
|
||||
{
|
||||
boolean failed = List.of("FAILED", "TIMED_OUT", "ABANDONED").contains(latest.getValueString("status"));
|
||||
lastFailed.append("qqq_backup_last_run_failed").append(labels).append(' ').append(failed ? 1 : 0).append('\n');
|
||||
}
|
||||
Integer slaHours = plan.getValueInteger("slaHours");
|
||||
if(slaHours != null)
|
||||
{
|
||||
sla.append("qqq_backup_sla_seconds").append(labels).append(' ').append(slaHours * 3600L).append('\n');
|
||||
}
|
||||
if(plan.getValueInstant("lastVerifiedDate") != null)
|
||||
{
|
||||
lastVerify.append("qqq_backup_last_verify_success_timestamp_seconds").append(labels).append(' ').append(plan.getValueInstant("lastVerifiedDate").getEpochSecond()).append('\n');
|
||||
}
|
||||
if(plan.getValueInteger("lastSuccessRunId") != null)
|
||||
{
|
||||
QRecord run = catalog.get(BackupsTables.RUN, plan.getValueInteger("lastSuccessRunId")).orElse(null);
|
||||
if(run != null && run.getValueLong("bytesStored") != null)
|
||||
{
|
||||
lastBytes.append("qqq_backup_last_success_bytes").append(labels).append(' ').append(run.getValueLong("bytesStored")).append('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
return (lastSuccess.toString() + lastFailed + sla + lastVerify + lastBytes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static StringBuilder header(String name, String help)
|
||||
{
|
||||
return (new StringBuilder("# HELP ").append(name).append(' ').append(help).append('\n').append("# TYPE ").append(name).append(" gauge\n"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Escape a label value for the text format.
|
||||
*******************************************************************************/
|
||||
static String escape(String value)
|
||||
{
|
||||
return (value == null ? "" : value.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n"));
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,20 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.BackupsQBitConfig;
|
||||
import com.kingsrook.qbits.backups.permissions.BackupsPermissionChecker;
|
||||
import com.kingsrook.qbits.backups.plan.BackupPlanTableCustomizer;
|
||||
import com.kingsrook.qbits.backups.schedule.BackupPlanSyncToScheduledJobProcess;
|
||||
import com.kingsrook.qbits.backups.schedule.BackupPlanVerifySyncToScheduledJobProcess;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.MultiCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
|
||||
import com.kingsrook.qqq.backend.core.scheduler.processes.BaseSyncToScheduledJobTableCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
|
||||
@@ -63,7 +75,7 @@ public final class BackupsTables
|
||||
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> NAV_TABLES = List.of(PLAN, RUN, RESTORE_RUN, VERIFICATION);
|
||||
|
||||
public static final List<String> ALL = List.of(PLAN, PLAN_SCOPE, RUN, ARTIFACT, VERIFICATION, RESTORE_RUN, RESTORE_RUN_TABLE, FILE_OBJECT, ID_MAP);
|
||||
|
||||
@@ -91,14 +103,14 @@ public final class BackupsTables
|
||||
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("run_status", List.of("QUEUED", "RUNNING", "SUCCEEDED", "SUCCEEDED_WITH_WARNINGS", "FAILED", "TIMED_OUT", "CANCELLING", "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_status", List.of("PREVIEWED", "QUEUED", "RUNNING", "SUCCEEDED", "SUCCEEDED_WITH_WARNINGS", "FAILED", "CANCELLING", "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"));
|
||||
@@ -203,7 +215,111 @@ public final class BackupsTables
|
||||
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)));
|
||||
restoreRun(config), restoreRunTable(config), fileObject(config), idMap(config)).stream().map(BackupsTables::withSections).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Group a table's fields into record-screen sections (the identity and status
|
||||
** first, then one section per concern, then the system fields), so a record reads
|
||||
** as a few short groups instead of one long list. Fields not named here land in
|
||||
** the last section.
|
||||
*******************************************************************************/
|
||||
static QTableMetaData withSections(QTableMetaData table)
|
||||
{
|
||||
List<Object[]> layout = switch(table.getName())
|
||||
{
|
||||
case PLAN -> List.of(
|
||||
new Object[] { "plan", "Plan", "backup", Tier.T1, "name", "description", "planKey", "isActive", "scopeType", "slaStatus", "lastSuccessDate", "lastVerifiedDate" },
|
||||
new Object[] { "schedule", "Schedule and SLA", "schedule", Tier.T2, "cronExpression", "cronTimeZoneId", "scheduledJobId", "slaHours", "verifySlaDays", "verifyCronExpression", "verifyLevel" },
|
||||
new Object[] { "where", "Scope and storage", "storage", Tier.T2, "includeRoles", "storageBucketAlias", "pruneBucketAlias", "prefix", "critical", "consistency", "useReadReplica", "incrementalMode", "nativeDump" },
|
||||
new Object[] { "format", "Format and safety", "shield", Tier.T2, "format", "compression", "encryptionMode", "encryptionKeyRef", "sensitiveFieldPolicy", "heavyFieldPolicy", "fileObjectPolicy", "fileMissingPolicy" },
|
||||
new Object[] { "retention", "Retention and lock", "lock_clock", Tier.T2, "retainDaily", "retainWeekly", "retainMonthly", "retainYearly", "retainMinimum", "objectLockMode", "objectLockDays" },
|
||||
new Object[] { "system", "Limits and history", "history", Tier.T3, "maxDurationMinutes", "pageSize", "targetPartBytes", "ownerUserId", "lastRunId", "lastSuccessRunId", "lastVerifiedRunId", "id", "createDate", "modifyDate" });
|
||||
case RUN -> List.of(
|
||||
new Object[] { "run", "Run", "history", Tier.T1, "runKey", "backupPlanId", "status", "trigger", "startDate", "endDate", "durationMs", "errorMessage" },
|
||||
new Object[] { "volume", "Rows and size", "data_usage", Tier.T2, "tablesPlanned", "tablesDone", "rowsTotal", "bytesRaw", "bytesStored", "consistencyAchieved", "warningCount" },
|
||||
new Object[] { "files", "Files", "attach_file", Tier.T2, "filesReferenced", "filesCopied", "filesDeduplicated", "filesMissing", "fileBytesCopied", "fileBytesReferenced" },
|
||||
new Object[] { "storage", "Storage and integrity", "storage", Tier.T2, "storageBucketAlias", "runPrefix", "manifestKey", "manifestSha256", "snapshotId", "schemaFingerprint", "securityScope", "encryptionKeyRef", "encryptionKeyFingerprint", "verificationStatus" },
|
||||
new Object[] { "retention", "Retention", "lock_clock", Tier.T2, "retentionClass", "lockedUntil", "prunedDate", "prunedByUserId" },
|
||||
new Object[] { "system", "System", "settings", Tier.T3, "requestedByUserId", "heartbeatDate", "qqqVersion", "appVersion", "instanceName", "errorDetail", "id", "createDate", "modifyDate" });
|
||||
case VERIFICATION -> List.of(
|
||||
new Object[] { "result", "Result", "verified", Tier.T1, "backupRunId", "level", "status", "tablesChecked", "rowsChecked", "mismatches", "scratchTarget", "startDate", "endDate" },
|
||||
new Object[] { "system", "Detail", "settings", Tier.T3, "trigger", "requestedByUserId", "detailJson", "id", "createDate", "modifyDate" });
|
||||
case RESTORE_RUN -> List.of(
|
||||
new Object[] { "restore", "Restore", "restore", Tier.T1, "sourceBackupRunId", "status", "mode", "idStrategy", "matchOn", "targetInstanceName", "startDate", "endDate", "errorMessage" },
|
||||
new Object[] { "rows", "Rows", "table_rows", Tier.T2, "rowsInserted", "rowsUpdated", "rowsUnchanged", "rowsDeleted", "rowsSkipped", "rowsErrored", "sequencesResynced" },
|
||||
new Object[] { "files", "Files", "attach_file", Tier.T2, "filesRestored", "filesAlreadyPresent", "filesConflicting", "filesMissingFromBackup", "fileBytesRestored" },
|
||||
new Object[] { "system", "Safety and system", "settings", Tier.T3, "preRestoreBackupRunId", "scopeSummary", "automationsSuppressed", "esbSuppressed", "confirmationText", "requestedByUserId", "id", "createDate", "modifyDate" });
|
||||
default -> List.of();
|
||||
};
|
||||
if(layout.isEmpty())
|
||||
{
|
||||
return (table);
|
||||
}
|
||||
java.util.Set<String> placed = new java.util.HashSet<>();
|
||||
for(int i = 0; i < layout.size(); i++)
|
||||
{
|
||||
Object[] spec = layout.get(i);
|
||||
List<String> fields = new ArrayList<>();
|
||||
for(int f = 4; f < spec.length; f++)
|
||||
{
|
||||
fields.add((String) spec[f]);
|
||||
}
|
||||
if(i == layout.size() - 1)
|
||||
{
|
||||
table.getFields().keySet().stream().filter(name -> !placed.contains(name) && !fields.contains(name)).forEach(fields::add);
|
||||
}
|
||||
placed.addAll(fields);
|
||||
table.withSection(new QFieldSection((String) spec[0], (String) spec[1], new QIcon((String) spec[2]), (Tier) spec[3], fields));
|
||||
}
|
||||
for(ChildList childList : CHILD_LISTS)
|
||||
{
|
||||
if(childList.parent().equals(table.getName()))
|
||||
{
|
||||
table.withSection(new QFieldSection(childList.key(), new QIcon(childList.icon()), Tier.T2).withLabel(childList.label()).withWidgetName(childList.joinName()));
|
||||
}
|
||||
}
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** A child list on a parent record: its join, the columns shown, and the order. */
|
||||
record ChildList(String parent, String joinName, String key, String label, String icon, int maxRows, boolean canAdd, String orderField, List<String> columns)
|
||||
{
|
||||
}
|
||||
|
||||
/** The relations shown on the record screens (sub-tables are child lists here, not in the nav). */
|
||||
static final List<ChildList> CHILD_LISTS = List.of(
|
||||
new ChildList(PLAN, "backupsPlanScopeJoinPlan", "scopeRules", "Scope rules", "rule", 50, true, "id", List.of("action", "selectorType", "selectorValue", "includeAssociatedChildren", "fieldExclusions")),
|
||||
new ChildList(PLAN, "backupsRunJoinPlan", "runs", "Runs", "history", 10, false, "id", List.of("runKey", "status", "startDate", "durationMs", "rowsTotal", "bytesStored")),
|
||||
new ChildList(RUN, "backupsArtifactJoinRun", "artifacts", "Artifacts", "inventory_2", 100, false, "id", List.of("tableName", "artifactType", "partNumber", "rowCount", "bytesStored", "status")),
|
||||
new ChildList(RUN, "backupsVerificationJoinRun", "verifications", "Verifications", "verified", 20, false, "id", List.of("level", "status", "tablesChecked", "rowsChecked", "mismatches", "startDate")),
|
||||
new ChildList(RUN, "backupsRestoreRunJoinRun", "restores", "Restores", "restore", 20, false, "id", List.of("status", "mode", "startDate", "rowsInserted", "rowsUpdated", "rowsErrored")),
|
||||
new ChildList(RESTORE_RUN, "backupsRestoreRunTableJoinRestoreRun", "restoreTables", "Tables restored", "table_rows", 100, false, "id",
|
||||
List.of("tableName", "targetTableName", "rowsInserted", "rowsUpdated", "rowsUnchanged", "rowsErrored", "schemaStatus")));
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The child-list widgets for the record screens (see CHILD_LISTS). Each shows a
|
||||
** few readable columns, newest first, and never scrolls sideways.
|
||||
*******************************************************************************/
|
||||
public static List<QWidgetMetaData> widgets()
|
||||
{
|
||||
Map<String, QJoinMetaData> byName = new LinkedHashMap<>();
|
||||
joins().forEach(join -> byName.put(join.getName(), join));
|
||||
List<QWidgetMetaData> rs = new ArrayList<>();
|
||||
for(ChildList childList : CHILD_LISTS)
|
||||
{
|
||||
rs.add(ChildRecordListRenderer.widgetMetaDataBuilder(byName.get(childList.joinName())).withLabel(childList.label()).withMaxRows(childList.maxRows())
|
||||
.withCanAddChildRecord(childList.canAdd()).withOnlyIncludeFieldNames(childList.columns())
|
||||
.withOrderBys(List.of(new QFilterOrderBy(childList.orderField(), !PLAN.equals(childList.parent()) || childList.canAdd())))
|
||||
.getWidgetMetaData().withPermissionRules(permissionRules()));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
@@ -286,6 +402,34 @@ public final class BackupsTables
|
||||
** backups_plan: what, how, where, when and how long (spec 4.1).
|
||||
*******************************************************************************/
|
||||
static QTableMetaData plan(BackupsQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = planBase(config);
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
// save validation (spec 4.1, 11.1), and the plan -> scheduled job sync when the qbit //
|
||||
// has a scheduler (spec 9.1): one job per active plan, and one for the verify cron //
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
QCodeReference validation = new QCodeReference(BackupPlanTableCustomizer.class);
|
||||
table.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, validation);
|
||||
table.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, validation);
|
||||
if(StringUtils.hasContent(config.getSchedulerName()))
|
||||
{
|
||||
QCodeReferenceWithProperties sync = MultiCustomizer.of(
|
||||
BaseSyncToScheduledJobTableCustomizer.makeCodeReference(table, new BackupPlanSyncToScheduledJobProcess()),
|
||||
BaseSyncToScheduledJobTableCustomizer.makeCodeReference(table, new BackupPlanVerifySyncToScheduledJobProcess()));
|
||||
table.withCustomizer(TableCustomizers.POST_INSERT_RECORD, sync);
|
||||
table.withCustomizer(TableCustomizers.POST_UPDATE_RECORD, sync);
|
||||
table.withCustomizer(TableCustomizers.POST_DELETE_RECORD, sync);
|
||||
}
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The plan table before the customizers.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData planBase(BackupsQBitConfig config)
|
||||
{
|
||||
return (base(config, PLAN, "Backup Plan", "backup")
|
||||
.withRecordLabelFormatAndFields("%s", "name")
|
||||
@@ -319,6 +463,7 @@ public final class BackupsTables
|
||||
.withField(roInt("scheduledJobId"))
|
||||
.withField(new QFieldMetaData("slaHours", QFieldType.INTEGER).withDefaultValue(26))
|
||||
.withField(new QFieldMetaData("verifySlaDays", QFieldType.INTEGER).withDefaultValue(8))
|
||||
.withField(new QFieldMetaData("verifyFileSamplePercent", QFieldType.INTEGER).withDefaultValue(100))
|
||||
.withField(new QFieldMetaData("verifyCronExpression", QFieldType.STRING).withMaxLength(100))
|
||||
.withField(enumField("verifyLevel", "verify_level", "CONTENT"))
|
||||
.withField(new QFieldMetaData("retainDaily", QFieldType.INTEGER).withDefaultValue(7))
|
||||
@@ -368,8 +513,6 @@ public final class BackupsTables
|
||||
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"))
|
||||
@@ -475,7 +618,6 @@ public final class BackupsTables
|
||||
{
|
||||
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))
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* 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.plan;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qbits.backups.run.BackupRunFactory;
|
||||
import com.kingsrook.qbits.backups.scope.PlanSettings;
|
||||
import com.kingsrook.qbits.backups.scope.ScopeResolver;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.QWarningMessage;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Plan save validation (pre insert and pre update of backups_plan). See
|
||||
** BackupPlanValidator for the rules. An update that touches none of the plan
|
||||
** configuration (the processes write the last run and SLA fields) is not
|
||||
** validated again.
|
||||
**
|
||||
** The saving user becomes the plan owner when none is given.
|
||||
*******************************************************************************/
|
||||
public class BackupPlanTableCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
private static final List<String> CONFIGURATION_FIELDS = List.of("planKey", "isActive", "scopeType", "includeRoles", "storageBucketAlias", "pruneBucketAlias",
|
||||
"format", "compression", "encryptionMode", "encryptionKeyRef", "sensitiveFieldPolicy", "incrementalMode", "nativeDump", "cronExpression", "cronTimeZoneId",
|
||||
"verifyCronExpression", "retainDaily", "retainWeekly", "retainMonthly", "retainYearly", "retainMinimum", "objectLockMode", "objectLockDays",
|
||||
"slaHours", "pageSize", "ownerUserId");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
Map<Serializable, QRecord> oldById = oldRecordList.isPresent() ? oldRecordListToMap("id", oldRecordList).orElse(Map.of()) : Map.of();
|
||||
BackupCatalog catalog = new BackupCatalog();
|
||||
|
||||
for(QRecord record : records)
|
||||
{
|
||||
QRecord old = oldRecordList.isPresent() ? oldById.get(record.getValue("id")) : null;
|
||||
if(old != null && !touchesConfiguration(record))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
QRecord merged = new QRecord();
|
||||
if(old != null)
|
||||
{
|
||||
merged.getValues().putAll(old.getValues());
|
||||
}
|
||||
merged.getValues().putAll(record.getValues());
|
||||
|
||||
if(merged.getValue("ownerUserId") == null && Boolean.TRUE.equals(merged.getValueBoolean("isActive")))
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
String owner = session == null || session.getUser() == null ? null : session.getUser().getIdReference();
|
||||
if(owner != null)
|
||||
{
|
||||
record.setValue("ownerUserId", owner);
|
||||
merged.setValue("ownerUserId", owner);
|
||||
}
|
||||
}
|
||||
|
||||
boolean hasRun = old != null && !catalog.where(BackupsTables.RUN, "backupPlanId", old.getValueInteger("id")).isEmpty();
|
||||
BackupPlanValidator.Result result = BackupPlanValidator.validate(PlanSettings.of(merged), old == null ? null : old.getValueString("planKey"), hasRun, scopeRules(record, old));
|
||||
for(String error : result.errors())
|
||||
{
|
||||
record.addError(new BadInputStatusMessage(error));
|
||||
}
|
||||
for(String warning : result.warnings())
|
||||
{
|
||||
record.addWarning(new QWarningMessage(warning));
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static boolean touchesConfiguration(QRecord record)
|
||||
{
|
||||
for(String field : CONFIGURATION_FIELDS)
|
||||
{
|
||||
if(record.getValues().containsKey(field))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
return (false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The scope rules to resolve: the ones sent with the plan (associated rows), or
|
||||
** the saved ones.
|
||||
*******************************************************************************/
|
||||
private static List<ScopeResolver.ScopeRule> scopeRules(QRecord record, QRecord old) throws QException
|
||||
{
|
||||
List<QRecord> sent = record.getAssociatedRecords() == null ? null : record.getAssociatedRecords().get("scopes");
|
||||
if(sent != null)
|
||||
{
|
||||
List<ScopeResolver.ScopeRule> rules = new ArrayList<>();
|
||||
for(QRecord row : sent)
|
||||
{
|
||||
rules.add(new ScopeResolver.ScopeRule(!"EXCLUDE".equals(row.getValueString("action")), row.getValueString("selectorType"), row.getValueString("selectorValue"),
|
||||
row.getValueString("filterJson"), !Boolean.FALSE.equals(row.getValueBoolean("includeAssociatedChildren")), row.getValueString("fieldExclusions")));
|
||||
}
|
||||
return (rules);
|
||||
}
|
||||
return (old == null ? List.of() : BackupRunFactory.scopeRules(old.getValueInteger("id")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
/*
|
||||
* 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.plan;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.permissions.BackupsPermissionChecker;
|
||||
import com.kingsrook.qbits.backups.permissions.BackupsPermissions;
|
||||
import com.kingsrook.qbits.backups.scope.PlanSettings;
|
||||
import com.kingsrook.qbits.backups.scope.ScopeResolver;
|
||||
import com.kingsrook.qbits.backups.storage.BackupStorage;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The rules a backup plan must satisfy when it is saved or activated (spec 4.1,
|
||||
** 6.2, 11.1, 11.3): the refusals are clear messages, not run-time failures.
|
||||
**
|
||||
** - the saving user has backups.admin and may read every table the plan resolves
|
||||
** to (a scheduled run is full-fidelity, so whoever activates the plan must be
|
||||
** allowed to read all of it)
|
||||
** - an object lock mode is checked against what the storage bucket can do
|
||||
** - INCLUDE_ENCRYPTED_ONLY needs encryption
|
||||
** - formats, compressions and options that are not built yet are refused
|
||||
** - the plan key is a slug, and cannot change after the first run (it is in every
|
||||
** object key)
|
||||
*******************************************************************************/
|
||||
public final class BackupPlanValidator
|
||||
{
|
||||
private static final Pattern PLAN_KEY = Pattern.compile("[a-z0-9][a-z0-9_-]{0,59}");
|
||||
|
||||
private static final Set<String> FORMATS = Set.of("JSONL", "CSV", "JSONL_AND_CSV");
|
||||
private static final Set<String> COMPRESSIONS = Set.of("GZIP", "NONE");
|
||||
private static final Set<String> LOCK_MODES = Set.of("NONE", "GOVERNANCE", "COMPLIANCE");
|
||||
|
||||
/** Replace with the real check when client-side encryption ships (spec 11.2). */
|
||||
private static volatile boolean encryptionAvailable = false;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private BackupPlanValidator()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tell the validator that client-side encryption is available (the encryption
|
||||
** work calls this when it installs a cipher).
|
||||
*******************************************************************************/
|
||||
public static void setEncryptionAvailable(boolean available)
|
||||
{
|
||||
encryptionAvailable = available;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The outcome of a validation.
|
||||
*******************************************************************************/
|
||||
public record Result(List<String> errors, List<String> warnings)
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public boolean ok()
|
||||
{
|
||||
return (errors.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate the complete settings of a plan.
|
||||
**
|
||||
** @param settings the plan values as they will be after the save
|
||||
** @param oldPlanKey the plan key before the save, null for a new plan
|
||||
** @param hasRun whether the plan already has a run
|
||||
** @param rules the scope rules of the plan
|
||||
*******************************************************************************/
|
||||
public static Result validate(PlanSettings settings, String oldPlanKey, boolean hasRun, List<ScopeResolver.ScopeRule> rules)
|
||||
{
|
||||
List<String> errors = new ArrayList<>();
|
||||
List<String> warnings = new ArrayList<>();
|
||||
|
||||
validateKey(settings, oldPlanKey, hasRun, errors);
|
||||
validateFormats(settings, errors);
|
||||
validateEncryption(settings, errors);
|
||||
validateStorage(settings, errors, warnings);
|
||||
validateSchedule(settings, errors);
|
||||
validateNumbers(settings, errors);
|
||||
|
||||
if(settings.bool("isActive", true))
|
||||
{
|
||||
validateOwner(settings, rules, errors);
|
||||
}
|
||||
|
||||
return (new Result(errors, warnings));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void validateKey(PlanSettings settings, String oldPlanKey, boolean hasRun, List<String> errors)
|
||||
{
|
||||
String planKey = settings.string("planKey", null);
|
||||
if(planKey == null || !PLAN_KEY.matcher(planKey).matches())
|
||||
{
|
||||
errors.add("The plan key must be lowercase letters, digits, dash or underscore (up to 60 characters): it is part of every object key.");
|
||||
}
|
||||
if(oldPlanKey != null && planKey != null && !oldPlanKey.equals(planKey) && hasRun)
|
||||
{
|
||||
errors.add("The plan key cannot change after the plan has run: it is part of the object keys of its backups. Create a new plan instead.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void validateFormats(PlanSettings settings, List<String> errors)
|
||||
{
|
||||
String format = settings.string("format", "JSONL");
|
||||
if("PARQUET".equals(format))
|
||||
{
|
||||
errors.add("The PARQUET format is not available yet. Use JSONL, CSV or JSONL_AND_CSV.");
|
||||
}
|
||||
else if(!FORMATS.contains(format))
|
||||
{
|
||||
errors.add("Unknown format " + format + ". Use JSONL, CSV or JSONL_AND_CSV.");
|
||||
}
|
||||
|
||||
String compression = settings.string("compression", "GZIP");
|
||||
if("ZSTD".equals(compression))
|
||||
{
|
||||
errors.add("ZSTD compression is not available yet. Use GZIP or NONE.");
|
||||
}
|
||||
else if(!COMPRESSIONS.contains(compression))
|
||||
{
|
||||
errors.add("Unknown compression " + compression + ". Use GZIP or NONE.");
|
||||
}
|
||||
|
||||
if("CHANGED_SINCE_LAST".equals(settings.string("incrementalMode", "FULL")))
|
||||
{
|
||||
errors.add("Incremental backups are not available yet. Use FULL.");
|
||||
}
|
||||
if(!"NONE".equals(settings.string("nativeDump", "NONE")))
|
||||
{
|
||||
errors.add("Native database dumps are not available yet. Use NONE.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void validateEncryption(PlanSettings settings, List<String> errors)
|
||||
{
|
||||
String mode = settings.string("encryptionMode", "NONE");
|
||||
String policy = settings.string("sensitiveFieldPolicy", "EXCLUDE");
|
||||
if("CLIENT_AES256_GCM".equals(mode))
|
||||
{
|
||||
if(!encryptionAvailable)
|
||||
{
|
||||
errors.add("Client-side encryption is not available in this version. Use NONE (and keep the sensitive field policy at EXCLUDE).");
|
||||
}
|
||||
else if(settings.string("encryptionKeyRef", null) == null)
|
||||
{
|
||||
errors.add("Client-side encryption needs an encryption key reference (the name of the key, never the key itself).");
|
||||
}
|
||||
}
|
||||
if("INCLUDE_ENCRYPTED_ONLY".equals(policy) && !"CLIENT_AES256_GCM".equals(mode))
|
||||
{
|
||||
errors.add("The sensitive field policy INCLUDE_ENCRYPTED_ONLY requires client-side encryption (CLIENT_AES256_GCM).");
|
||||
}
|
||||
if("INCLUDE".equals(policy) && !BackupsPermissionChecker.has(BackupsPermissions.ADMIN))
|
||||
{
|
||||
errors.add("Including sensitive fields in the clear needs the backups.admin permission.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The destination: the bucket exists, and an object lock is something the bucket can do.
|
||||
*******************************************************************************/
|
||||
private static void validateStorage(PlanSettings settings, List<String> errors, List<String> warnings)
|
||||
{
|
||||
String alias = settings.string("storageBucketAlias", null);
|
||||
if(alias == null)
|
||||
{
|
||||
errors.add("Choose a storage bucket for the backups.");
|
||||
return;
|
||||
}
|
||||
|
||||
BackupStorage storage = null;
|
||||
try
|
||||
{
|
||||
storage = BackupsRuntime.storageFor(alias);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
errors.add("The storage bucket " + alias + " cannot be used: " + e.getMessage());
|
||||
}
|
||||
|
||||
String lockMode = settings.string("objectLockMode", "NONE");
|
||||
if(!LOCK_MODES.contains(lockMode))
|
||||
{
|
||||
errors.add("Unknown object lock mode " + lockMode + ". Use NONE, GOVERNANCE or COMPLIANCE.");
|
||||
}
|
||||
else if(!"NONE".equals(lockMode))
|
||||
{
|
||||
Integer days = settings.integer("objectLockDays", null);
|
||||
if(days == null || days < 1)
|
||||
{
|
||||
errors.add("Object lock needs a number of days to keep each object locked (objectLockDays).");
|
||||
}
|
||||
if(storage != null && !storage.capabilities().contains(BackupStorage.StorageCapability.OBJECT_LOCK))
|
||||
{
|
||||
errors.add("Object lock " + lockMode + " was asked for, but the storage bucket " + alias + " does not support object lock (its capabilities: "
|
||||
+ storage.capabilities() + "). Choose NONE, or a bucket whose Test connection found object lock supported.");
|
||||
}
|
||||
}
|
||||
|
||||
String pruneAlias = settings.string("pruneBucketAlias", null);
|
||||
if(pruneAlias == null)
|
||||
{
|
||||
warnings.add("No prune bucket is set: Prune will use the backup bucket, so one key can both write and delete backups. Give the plan a separate delete-capable alias.");
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
BackupsRuntime.storageFor(pruneAlias);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
errors.add("The prune bucket " + pruneAlias + " cannot be used: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void validateSchedule(PlanSettings settings, List<String> errors)
|
||||
{
|
||||
for(String field : List.of("cronExpression", "verifyCronExpression"))
|
||||
{
|
||||
String cron = settings.string(field, null);
|
||||
if(cron != null && !org.quartz.CronExpression.isValidExpression(cron))
|
||||
{
|
||||
errors.add("The " + field + " is not a valid cron expression: " + cron);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void validateNumbers(PlanSettings settings, List<String> errors)
|
||||
{
|
||||
for(String field : List.of("retainDaily", "retainWeekly", "retainMonthly", "retainYearly", "retainMinimum"))
|
||||
{
|
||||
Integer value = settings.integer(field, 0);
|
||||
if(value < 0)
|
||||
{
|
||||
errors.add(field + " cannot be negative.");
|
||||
}
|
||||
}
|
||||
if(settings.integer("slaHours", 26) < 1)
|
||||
{
|
||||
errors.add("slaHours must be at least 1.");
|
||||
}
|
||||
if(settings.integer("pageSize", 5000) < 1)
|
||||
{
|
||||
errors.add("pageSize must be at least 1.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Saving or activating a plan: the saving user must have backups.admin and read
|
||||
** access to every table the plan resolves to (spec 11.1).
|
||||
*******************************************************************************/
|
||||
private static void validateOwner(PlanSettings settings, List<ScopeResolver.ScopeRule> rules, List<String> errors)
|
||||
{
|
||||
if(!BackupsPermissionChecker.has(BackupsPermissions.ADMIN))
|
||||
{
|
||||
errors.add("Saving an active plan needs the backups.admin permission: the plan runs on its own with full access.");
|
||||
return;
|
||||
}
|
||||
|
||||
ScopeResolver.ResolvedScope scope;
|
||||
try
|
||||
{
|
||||
scope = new ScopeResolver(QContext.getQInstance()).resolve(settings, rules);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
errors.add("The plan scope cannot be resolved: " + e.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> unreadable = new ArrayList<>();
|
||||
for(ScopeResolver.ResolvedTable table : scope.tables())
|
||||
{
|
||||
if(!PermissionsHelper.hasTablePermission(new QueryInput(table.name()), table.name(), TablePermissionSubType.READ))
|
||||
{
|
||||
unreadable.add(table.name());
|
||||
}
|
||||
}
|
||||
if(!unreadable.isEmpty())
|
||||
{
|
||||
errors.add("The plan covers tables that its owner is not allowed to read, so it could not back them up in full: "
|
||||
+ String.join(", ", unreadable.stream().limit(10).toList()) + (unreadable.size() > 10 ? " and " + (unreadable.size() - 10) + " more" : "")
|
||||
+ ". Remove them from the scope, or save the plan as a user who can read them.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* 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.plan;
|
||||
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.prune.BackupPruner;
|
||||
import com.kingsrook.qbits.backups.storage.BackupStorage;
|
||||
import com.kingsrook.qbits.backups.storage.StoredObjectInfo;
|
||||
import com.kingsrook.qbits.backups.storage.WriteOptions;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test destination (spec 9): proves the plan destination works before the first
|
||||
** night depends on it. Writes a small probe object, reads it back, lists it, and
|
||||
** deletes it through the prune alias (so the delete credential is exercised too).
|
||||
**
|
||||
** On a bucket that is object-locked the probe is head only: a probe object could
|
||||
** not be deleted again, so nothing is written; the prefix is listed, and the
|
||||
** newest manifest is looked at with a head. A capability report always comes with
|
||||
** it.
|
||||
*******************************************************************************/
|
||||
public final class DestinationProbe
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private DestinationProbe()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One thing that was checked.
|
||||
*******************************************************************************/
|
||||
public record Check(String name, boolean ok, String detail)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The whole report.
|
||||
*******************************************************************************/
|
||||
public record Report(boolean headOnly, List<Check> checks, List<String> capabilities, List<String> warnings)
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public boolean ok()
|
||||
{
|
||||
return (checks.stream().allMatch(Check::ok));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Probe the destination of a plan.
|
||||
*******************************************************************************/
|
||||
public static Report probe(QRecord plan)
|
||||
{
|
||||
List<Check> checks = new ArrayList<>();
|
||||
List<String> warnings = new ArrayList<>();
|
||||
|
||||
String alias = plan.getValueString("storageBucketAlias");
|
||||
BackupStorage storage;
|
||||
try
|
||||
{
|
||||
storage = BackupsRuntime.storageFor(alias);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
checks.add(new Check("Open the backup bucket " + alias, false, e.getMessage()));
|
||||
return (new Report(false, checks, List.of(), warnings));
|
||||
}
|
||||
checks.add(new Check("Open the backup bucket " + alias, true, "found"));
|
||||
|
||||
BackupStorage deleteSide = storage;
|
||||
String pruneAlias = plan.getValueString("pruneBucketAlias");
|
||||
if(pruneAlias == null || pruneAlias.isBlank())
|
||||
{
|
||||
warnings.add("No prune bucket is set, so one key both writes and deletes backups.");
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
deleteSide = BackupsRuntime.storageFor(pruneAlias);
|
||||
checks.add(new Check("Open the prune bucket " + pruneAlias, true, "found"));
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
checks.add(new Check("Open the prune bucket " + pruneAlias, false, e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
List<String> capabilities = new ArrayList<>();
|
||||
for(BackupStorage.StorageCapability capability : BackupStorage.StorageCapability.values())
|
||||
{
|
||||
capabilities.add(capability.name() + ": " + (storage.capabilities().contains(capability) ? "supported" : "not supported"));
|
||||
}
|
||||
|
||||
String lockMode = plan.getValueString("objectLockMode");
|
||||
boolean locked = lockMode != null && !"NONE".equals(lockMode);
|
||||
if(locked && !storage.capabilities().contains(BackupStorage.StorageCapability.OBJECT_LOCK))
|
||||
{
|
||||
checks.add(new Check("Object lock " + lockMode, false, "the plan asks for object lock, but this bucket does not support it"));
|
||||
}
|
||||
|
||||
String prefix = BackupPruner.planPrefix(plan);
|
||||
if(locked)
|
||||
{
|
||||
headOnly(plan, storage, prefix, checks);
|
||||
}
|
||||
else
|
||||
{
|
||||
writeReadListDelete(storage, deleteSide, prefix + ".qqq-probe/", checks);
|
||||
}
|
||||
return (new Report(locked, checks, capabilities, warnings));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The probe for a locked bucket: list the prefix, and head the newest manifest.
|
||||
*******************************************************************************/
|
||||
private static void headOnly(QRecord plan, BackupStorage storage, String prefix, List<Check> checks)
|
||||
{
|
||||
checks.add(attempt("Write and delete a probe object", () -> "skipped: object lock is on, a probe object could not be deleted again"));
|
||||
checks.add(attempt("List the plan prefix", () ->
|
||||
{
|
||||
List<StoredObjectInfo> objects = storage.list(prefix);
|
||||
return (objects.size() + " objects under " + prefix);
|
||||
}));
|
||||
|
||||
Integer lastSuccess = plan.getValueInteger("lastSuccessRunId");
|
||||
if(lastSuccess == null)
|
||||
{
|
||||
checks.add(new Check("Head the newest manifest", true, "there is no successful run yet"));
|
||||
return;
|
||||
}
|
||||
checks.add(attempt("Head the newest manifest", () ->
|
||||
{
|
||||
QRecord run = new com.kingsrook.qbits.backups.run.BackupCatalog().get(com.kingsrook.qbits.backups.model.BackupsTables.RUN, lastSuccess).orElseThrow();
|
||||
String key = run.getValueString("manifestKey");
|
||||
if(key == null || storage.head(key).isEmpty())
|
||||
{
|
||||
throw (new IllegalStateException("the manifest of the newest successful run is not in the bucket"));
|
||||
}
|
||||
return ("found " + key);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Write, read back, list and delete a probe object.
|
||||
*******************************************************************************/
|
||||
private static void writeReadListDelete(BackupStorage storage, BackupStorage deleteSide, String probePrefix, List<Check> checks)
|
||||
{
|
||||
String key = probePrefix + UUID.randomUUID();
|
||||
byte[] content = ("qqq backups probe " + UUID.randomUUID()).getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
Check write = attempt("Write a probe object", () ->
|
||||
{
|
||||
try(OutputStream out = storage.openWrite(key, WriteOptions.create().withContentType("text/plain")))
|
||||
{
|
||||
out.write(content);
|
||||
}
|
||||
return ("wrote " + content.length + " bytes");
|
||||
});
|
||||
checks.add(write);
|
||||
if(!write.ok())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
checks.add(attempt("Read it back", () ->
|
||||
{
|
||||
try(InputStream in = storage.openRead(key))
|
||||
{
|
||||
if(!java.util.Arrays.equals(content, in.readAllBytes()))
|
||||
{
|
||||
throw (new IllegalStateException("the bytes read back are not the bytes written"));
|
||||
}
|
||||
}
|
||||
return ("the same bytes came back");
|
||||
}));
|
||||
|
||||
checks.add(attempt("List it", () ->
|
||||
{
|
||||
if(storage.list(probePrefix).stream().noneMatch(o -> o.key().equals(key)))
|
||||
{
|
||||
throw (new IllegalStateException("the probe object is not in the listing"));
|
||||
}
|
||||
return ("found in the listing");
|
||||
}));
|
||||
|
||||
checks.add(attempt("Delete it", () ->
|
||||
{
|
||||
BackupStorage.DeleteResult result = deleteSide.deletePermanent(key);
|
||||
if(result != BackupStorage.DeleteResult.DELETED)
|
||||
{
|
||||
throw (new IllegalStateException("the delete returned " + result));
|
||||
}
|
||||
return ("deleted");
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@FunctionalInterface
|
||||
private interface Attempt
|
||||
{
|
||||
String run() throws Exception;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Check attempt(String name, Attempt attempt)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (new Check(name, true, attempt.run()));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return (new Check(name, false, e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,9 +25,8 @@ 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.BackupRunCoordinator;
|
||||
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;
|
||||
@@ -73,6 +72,10 @@ public final class BackupsProcesses
|
||||
List<QProcessMetaData> processes = new ArrayList<>();
|
||||
processes.add(permission(etl(RUN_PLAN, "Run backup now", BackupsTables.PLAN, RunPlanTransform.class, RunPlanLoad.class,
|
||||
"This is a preview of what the backup will include. Nothing is written until you continue."), BackupsPermissions.RUN));
|
||||
processes.addAll(VerifyProcesses.processes());
|
||||
processes.addAll(RestoreProcesses.processes());
|
||||
processes.addAll(CatalogProcesses.processes());
|
||||
processes.addAll(LifecycleProcesses.processes());
|
||||
return (processes);
|
||||
}
|
||||
|
||||
@@ -209,6 +212,7 @@ public final class BackupsProcesses
|
||||
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 skipped = line(Status.WARNING, "backup did not start", "backups did not start", "backup did not start", "backups did not start");
|
||||
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");
|
||||
@@ -224,13 +228,17 @@ public final class BackupsProcesses
|
||||
{
|
||||
for(QRecord plan : input.getRecords())
|
||||
{
|
||||
BackupRunRequest request = BackupRunFactory.forPlan(plan, "MANUAL");
|
||||
BackupRunResult result = new BackupEngine().execute(request);
|
||||
BackupRunResult result = new BackupRunCoordinator().runPlan(plan, "MANUAL");
|
||||
results.add(result);
|
||||
switch(result.getStatus())
|
||||
{
|
||||
case "SUCCEEDED" -> succeeded.incrementCountAndAddPrimaryKey(result.getRunId());
|
||||
case "SUCCEEDED_WITH_WARNINGS" -> warnings.incrementCountAndAddPrimaryKey(result.getRunId());
|
||||
case BackupRunCoordinator.SKIPPED_CONCURRENT, BackupRunCoordinator.QUEUED ->
|
||||
{
|
||||
skipped.incrementCountAndAddPrimaryKey(result.getRunId());
|
||||
skipped.setMessageSuffix(result.getErrorMessage() == null ? null : ": " + result.getErrorMessage());
|
||||
}
|
||||
default ->
|
||||
{
|
||||
failed.incrementCountAndAddPrimaryKey(result.getRunId());
|
||||
@@ -255,6 +263,7 @@ public final class BackupsProcesses
|
||||
succeeded.addSelfToListIfAnyCount(lines);
|
||||
warnings.addSelfToListIfAnyCount(lines);
|
||||
failed.addSelfToListIfAnyCount(lines);
|
||||
skipped.addSelfToListIfAnyCount(lines);
|
||||
rows.addSelfToListIfAnyCount(lines);
|
||||
files.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.processes;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.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.permissions.BackupsPermissions;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qbits.backups.run.BackupRunCoordinator;
|
||||
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.ProcessSummaryProviderInterface;
|
||||
import static com.kingsrook.qbits.backups.processes.BackupsProcesses.line;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cancel (spec 9): a record action on a running backup run, and on a running
|
||||
** restore run. The cancel is cooperative: it sets the run row to CANCELLING, and
|
||||
** the run, which looks at its own row at every page, stops at the next page and
|
||||
** ends CANCELLED. A queued run is cancelled at once, and a run that has lost its
|
||||
** heartbeat (nothing is running it) is closed straight away.
|
||||
*******************************************************************************/
|
||||
public final class CancelRunProcess
|
||||
{
|
||||
public static final String NAME = "backupCancelRun";
|
||||
public static final String NAME_RESTORE = "backupCancelRestoreRun";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CancelRunProcess()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The two processes.
|
||||
*******************************************************************************/
|
||||
public static List<QProcessMetaData> processes()
|
||||
{
|
||||
return (List.of(
|
||||
BackupsProcesses.permission(BackupsProcesses.etl(NAME, "Cancel", BackupsTables.RUN, Transform.class, Load.class,
|
||||
"This asks the selected runs to stop. A run stops at its next page."), BackupsPermissions.RUN),
|
||||
BackupsProcesses.permission(BackupsProcesses.etl(NAME_RESTORE, "Cancel", BackupsTables.RESTORE_RUN, Transform.class, Load.class,
|
||||
"This asks the selected restores to stop. A restore stops at its next page."), BackupsPermissions.RESTORE)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What should happen to a run, by its row.
|
||||
*******************************************************************************/
|
||||
enum Action
|
||||
{
|
||||
ASK_TO_STOP, CANCEL_NOW, CLOSE_STALE, NOT_CANCELLABLE
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static Action actionFor(QRecord run)
|
||||
{
|
||||
String status = run.getValueString("status");
|
||||
if("QUEUED".equals(status))
|
||||
{
|
||||
return (Action.CANCEL_NOW);
|
||||
}
|
||||
if("RUNNING".equals(status))
|
||||
{
|
||||
Instant heartbeat = run.getValueInstant("heartbeatDate");
|
||||
boolean live = heartbeat != null && heartbeat.isAfter(BackupsRuntime.getClock().instant().minus(BackupRunCoordinator.HEARTBEAT_STALE));
|
||||
return (live ? Action.ASK_TO_STOP : Action.CLOSE_STALE);
|
||||
}
|
||||
return (Action.NOT_CANCELLABLE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review.
|
||||
*******************************************************************************/
|
||||
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine stop = line(Status.OK, "running run will be asked to stop at its next page", "running runs will be asked to stop at their next page",
|
||||
"running run was asked to stop at its next page", "running runs were asked to stop at their next page");
|
||||
private final ProcessSummaryLine queued = line(Status.OK, "queued run will be cancelled", "queued runs will be cancelled", "queued run was cancelled", "queued runs were cancelled");
|
||||
private final ProcessSummaryLine stale = line(Status.WARNING, "run has lost its heartbeat (nothing is running it) and will be closed as cancelled",
|
||||
"runs have lost their heartbeat (nothing is running them) and will be closed as cancelled", "run had lost its heartbeat and was closed as cancelled",
|
||||
"runs had lost their heartbeat and were closed as cancelled");
|
||||
private final ProcessSummaryLine no = line(Status.ERROR, "run cannot be cancelled: it is not running", "runs cannot be cancelled: they are not running",
|
||||
"run could not be cancelled: it is not running", "runs could not be cancelled: they are not running");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord run : input.getRecords())
|
||||
{
|
||||
switch(actionFor(run))
|
||||
{
|
||||
case ASK_TO_STOP -> stop.incrementCountAndAddPrimaryKey(run.getValue("id"));
|
||||
case CANCEL_NOW -> queued.incrementCountAndAddPrimaryKey(run.getValue("id"));
|
||||
case CLOSE_STALE -> stale.incrementCountAndAddPrimaryKey(run.getValue("id"));
|
||||
default -> no.incrementCountAndAddPrimaryKey(run.getValue("id"));
|
||||
}
|
||||
output.getRecords().add(run);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
stop.addSelfToListIfAnyCount(lines);
|
||||
queued.addSelfToListIfAnyCount(lines);
|
||||
stale.addSelfToListIfAnyCount(lines);
|
||||
no.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Result.
|
||||
*******************************************************************************/
|
||||
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine stop = line(Status.OK, "running run was asked to stop at its next page", "running runs were asked to stop at their next page",
|
||||
"running run was asked to stop at its next page", "running runs were asked to stop at their next page");
|
||||
private final ProcessSummaryLine queued = line(Status.OK, "queued run was cancelled", "queued runs were cancelled", "queued run was cancelled", "queued runs were cancelled");
|
||||
private final ProcessSummaryLine stale = line(Status.WARNING, "run that had lost its heartbeat was closed as cancelled", "runs that had lost their heartbeat were closed as cancelled",
|
||||
"run that had lost its heartbeat was closed as cancelled", "runs that had lost their heartbeat were closed as cancelled");
|
||||
private final ProcessSummaryLine no = line(Status.ERROR, "run was not cancelled: it is not running", "runs were not cancelled: they are not running",
|
||||
"run was not cancelled: it is not running", "runs were not cancelled: they are not running");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cancel each run.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
BackupCatalog catalog = new BackupCatalog();
|
||||
String table = input.getTableName();
|
||||
for(QRecord selected : input.getRecords())
|
||||
{
|
||||
QRecord run = catalog.get(table, selected.getValueInteger("id")).orElse(selected);
|
||||
switch(actionFor(run))
|
||||
{
|
||||
case ASK_TO_STOP ->
|
||||
{
|
||||
catalog.update(table, run.getValueInteger("id"), Map.of("status", "CANCELLING"));
|
||||
stop.incrementCountAndAddPrimaryKey(run.getValue("id"));
|
||||
}
|
||||
case CANCEL_NOW ->
|
||||
{
|
||||
catalog.update(table, run.getValueInteger("id"), closed("Cancelled before it started."));
|
||||
queued.incrementCountAndAddPrimaryKey(run.getValue("id"));
|
||||
}
|
||||
case CLOSE_STALE ->
|
||||
{
|
||||
catalog.update(table, run.getValueInteger("id"), closed("Cancelled: the run had stopped sending heartbeats."));
|
||||
stale.incrementCountAndAddPrimaryKey(run.getValue("id"));
|
||||
}
|
||||
default -> no.incrementCountAndAddPrimaryKey(run.getValue("id"));
|
||||
}
|
||||
output.getRecords().add(run);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Map<String, Serializable> closed(String message)
|
||||
{
|
||||
Map<String, Serializable> values = new LinkedHashMap<>();
|
||||
values.put("status", "CANCELLED");
|
||||
values.put("endDate", BackupsRuntime.getClock().instant());
|
||||
values.put("errorMessage", message);
|
||||
return (values);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
stop.addSelfToListIfAnyCount(lines);
|
||||
queued.addSelfToListIfAnyCount(lines);
|
||||
stale.addSelfToListIfAnyCount(lines);
|
||||
no.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.processes;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.permissions.BackupsPermissions;
|
||||
import com.kingsrook.qbits.backups.restore.CatalogImporter;
|
||||
import com.kingsrook.qbits.backups.restore.RestoreSummaries;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qbits.backups.storage.BackupStorage;
|
||||
import com.kingsrook.qqq.backend.core.actions.audits.AuditAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractExtractStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The catalog and download processes (spec 9.6 and 9.7): Import backups from
|
||||
** storage (a table action on backups_run) and Download (a record action on a run
|
||||
** or an artifact). Standard streamed ETL, Review and Result summary lines.
|
||||
*******************************************************************************/
|
||||
public final class CatalogProcesses
|
||||
{
|
||||
public static final String IMPORT_CATALOG = "backupImportCatalog";
|
||||
public static final String DOWNLOAD = "backupDownload";
|
||||
public static final String DOWNLOAD_ARTIFACT = "backupDownloadArtifact";
|
||||
|
||||
public static final Duration LINK_TTL = Duration.ofMinutes(15);
|
||||
|
||||
private static final QLogger LOG = QLogger.getLogger(CatalogProcesses.class);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CatalogProcesses()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The processes defined here.
|
||||
*******************************************************************************/
|
||||
public static List<QProcessMetaData> processes()
|
||||
{
|
||||
List<QProcessMetaData> processes = new ArrayList<>();
|
||||
|
||||
QProcessMetaData importProcess = definition(IMPORT_CATALOG, "Import backups from storage", BackupsTables.RUN, ImportExtract.class, ImportTransform.class, ImportLoad.class,
|
||||
"These are the backups found in storage that are not in the catalog yet. Nothing is written until you continue.");
|
||||
importProcess.withStep(0, new QFrontendStepMetaData()
|
||||
.withName("input")
|
||||
.withLabel("Where are the backups?")
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM))
|
||||
.withFormField(new QFieldMetaData("bucketAlias", QFieldType.STRING).withLabel("Backup bucket alias").withIsRequired(true))
|
||||
.withFormField(new QFieldMetaData("prefix", QFieldType.STRING).withLabel("Folder (prefix)").withDefaultValue("qqq-backups/")));
|
||||
importProcess.setMinInputRecords(0);
|
||||
processes.add(BackupsProcesses.permission(importProcess, BackupsPermissions.ADMIN));
|
||||
|
||||
processes.add(BackupsProcesses.permission(definition(DOWNLOAD, "Download", BackupsTables.RUN, ExtractViaQueryStep.class, DownloadTransform.class, DownloadLoad.class,
|
||||
"These are the files you will get download links for. Nothing is changed."), BackupsPermissions.RESTORE));
|
||||
processes.add(BackupsProcesses.permission(definition(DOWNLOAD_ARTIFACT, "Download", BackupsTables.ARTIFACT, ExtractViaQueryStep.class, DownloadTransform.class, DownloadLoad.class,
|
||||
"These are the files you will get download links for. Nothing is changed."), BackupsPermissions.RESTORE));
|
||||
return (processes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QProcessMetaData definition(String name, String label, String table, Class<? extends AbstractExtractStep> extract,
|
||||
Class<? extends AbstractTransformStep> transform, Class<? extends AbstractLoadStep> load, String preview)
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_TRANSACTION_LEVEL, StreamedETLWithFrontendProcess.TRANSACTION_LEVEL_AUTO_COMMIT);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, preview);
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(extract, transform, load, values);
|
||||
process.setName(name);
|
||||
process.setLabel(label);
|
||||
process.setTableName(table);
|
||||
process.setPermissionRules(BackupsTables.permissionRules());
|
||||
return (process);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Lists the manifests under the bucket alias and prefix; one record per manifest.
|
||||
*******************************************************************************/
|
||||
public static class ImportExtract extends AbstractExtractStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
String alias = input.getValueString("bucketAlias");
|
||||
if(alias == null || alias.isBlank())
|
||||
{
|
||||
throw (new QUserFacingException("Choose the backup bucket alias to import from."));
|
||||
}
|
||||
BackupStorage storage = BackupsRuntime.storageFor(alias);
|
||||
for(String key : new CatalogImporter().findManifestKeys(storage, input.getValueString("prefix")))
|
||||
{
|
||||
getRecordPipe().addRecord(new QRecord().withTableName(BackupsTables.RUN).withValue("manifestKey", key).withValue("storageBucketAlias", alias));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review: what each manifest is (new, already in the catalog, or damaged).
|
||||
*******************************************************************************/
|
||||
public static class ImportTransform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine found = RestoreSummaries.line(Status.INFO, 0, "backup was found in storage", "backups were found in storage", "backup was found in storage", "backups were found in storage");
|
||||
private final ProcessSummaryLine newRuns = RestoreSummaries.line(Status.OK, 0, "backup will be added to the catalog", "backups will be added to the catalog", "backup was added to the catalog", "backups were added to the catalog");
|
||||
private final ProcessSummaryLine existing = RestoreSummaries.line(Status.INFO, 0, "backup is already in the catalog (skipped)", "backups are already in the catalog (skipped)", "backup was already in the catalog", "backups were already in the catalog");
|
||||
private final ProcessSummaryLine invalid = RestoreSummaries.line(Status.ERROR, 0, "backup has a manifest or checksum problem and will be skipped", "backups have a manifest or checksum problem and will be skipped",
|
||||
"backup had a manifest or checksum problem and was skipped", "backups had a manifest or checksum problem and were skipped");
|
||||
private final ArrayList<String> problems = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
BackupStorage storage = BackupsRuntime.storageFor(record.getValueString("storageBucketAlias"));
|
||||
CatalogImporter.Inspection inspection = new CatalogImporter().inspect(storage, record.getValueString("manifestKey"));
|
||||
found.incrementCount();
|
||||
switch(inspection.status())
|
||||
{
|
||||
case CatalogImporter.Inspection.NEW ->
|
||||
{
|
||||
newRuns.incrementCount();
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
case CatalogImporter.Inspection.EXISTS -> existing.incrementCount();
|
||||
default ->
|
||||
{
|
||||
invalid.incrementCount();
|
||||
for(String problem : inspection.problems())
|
||||
{
|
||||
problems.add(inspection.manifestKey() + ": " + problem);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
found.addSelfToListIfAnyCount(lines);
|
||||
newRuns.addSelfToListIfAnyCount(lines);
|
||||
existing.addSelfToListIfAnyCount(lines);
|
||||
invalid.setBulletsOfText(problems);
|
||||
invalid.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute: create the run and artifact rows from each new manifest.
|
||||
*******************************************************************************/
|
||||
public static class ImportLoad extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine imported = RestoreSummaries.line(Status.OK, 0, "backup will be added to the catalog", "backups will be added to the catalog", "backup was added to the catalog", "backups were added to the catalog");
|
||||
private final ProcessSummaryLine skipped = RestoreSummaries.line(Status.WARNING, 0, "backup was not added", "backups were not added", "backup was not added", "backups were not added");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
CatalogImporter importer = new CatalogImporter();
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
String alias = record.getValueString("storageBucketAlias");
|
||||
BackupStorage storage = BackupsRuntime.storageFor(alias);
|
||||
CatalogImporter.Inspection inspection = importer.inspect(storage, record.getValueString("manifestKey"));
|
||||
if(CatalogImporter.Inspection.NEW.equals(inspection.status()))
|
||||
{
|
||||
imported.incrementCountAndAddPrimaryKey(importer.importRun(alias, storage, inspection));
|
||||
}
|
||||
else
|
||||
{
|
||||
skipped.incrementCount();
|
||||
}
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
imported.addSelfToListIfAnyCount(lines);
|
||||
skipped.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One object to link to.
|
||||
*******************************************************************************/
|
||||
record DownloadItem(String auditTable, Integer auditId, BackupStorage storage, String key, String downloadName, boolean encrypted, long size)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The objects the selected records ask for: for a run, all of its artifacts; for
|
||||
** an artifact, itself plus the manifest and checksums of its run.
|
||||
*******************************************************************************/
|
||||
static List<DownloadItem> items(List<QRecord> records) throws QException
|
||||
{
|
||||
BackupCatalog catalog = new BackupCatalog();
|
||||
Map<String, DownloadItem> items = new LinkedHashMap<>();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
boolean isArtifact = BackupsTables.ARTIFACT.equals(record.getTableName()) || record.getValue("artifactType") != null;
|
||||
Integer runId = isArtifact ? record.getValueInteger("backupRunId") : record.getValueInteger("id");
|
||||
QRecord run = catalog.get(BackupsTables.RUN, runId).orElseThrow(() -> new QUserFacingException("The backup run was not found."));
|
||||
String table = isArtifact ? BackupsTables.ARTIFACT : BackupsTables.RUN;
|
||||
BackupStorage storage = BackupsRuntime.storageFor(run.getValueString("storageBucketAlias"));
|
||||
|
||||
List<QRecord> artifacts = new ArrayList<>();
|
||||
if(isArtifact)
|
||||
{
|
||||
artifacts.add(record);
|
||||
for(QRecord other : catalog.where(BackupsTables.ARTIFACT, "backupRunId", runId))
|
||||
{
|
||||
if("MANIFEST".equals(other.getValueString("artifactType")) || "CHECKSUMS".equals(other.getValueString("artifactType")))
|
||||
{
|
||||
artifacts.add(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
artifacts.addAll(catalog.where(BackupsTables.ARTIFACT, "backupRunId", runId));
|
||||
}
|
||||
|
||||
for(QRecord artifact : artifacts)
|
||||
{
|
||||
if(!"COMPLETE".equals(artifact.getValueString("status")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String key = run.getValueString("runPrefix") + artifact.getValueString("objectKey");
|
||||
String name = run.getValueString("runKey") + "-" + artifact.getValueString("objectKey").replace('/', '-');
|
||||
items.putIfAbsent(key, new DownloadItem(table, isArtifact && artifact == record ? artifact.getValueInteger("id") : runId, storage, key, name,
|
||||
Boolean.TRUE.equals(artifact.getValueBoolean("encrypted")), artifact.getValueLong("bytesStored") == null ? 0 : artifact.getValueLong("bytesStored")));
|
||||
}
|
||||
}
|
||||
return (new ArrayList<>(items.values()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review: what will be linked.
|
||||
*******************************************************************************/
|
||||
public static class DownloadTransform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine objects = RestoreSummaries.line(Status.OK, 0, "file will get a download link", "files will get download links", "file got a download link", "files got download links");
|
||||
private final ProcessSummaryLine encrypted = RestoreSummaries.line(Status.INFO, 0, "file is encrypted: you get the ciphertext only, and decrypt it with the command line tool",
|
||||
"files are encrypted: you get the ciphertext only, and decrypt them with the command line tool", "file is encrypted (ciphertext only)", "files are encrypted (ciphertext only)");
|
||||
private long bytes;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(DownloadItem item : items(input.getRecords()))
|
||||
{
|
||||
objects.incrementCount();
|
||||
bytes += item.size();
|
||||
if(item.encrypted())
|
||||
{
|
||||
encrypted.incrementCount();
|
||||
}
|
||||
}
|
||||
output.getRecords().addAll(input.getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
objects.addSelfToListIfAnyCount(lines);
|
||||
encrypted.addSelfToListIfAnyCount(lines);
|
||||
if(objects.getCount() != null && objects.getCount() > 0)
|
||||
{
|
||||
lines.add(RestoreSummaries.message(Status.INFO, "The links last " + LINK_TTL.toMinutes() + " minutes and the download is about " + bytes + " bytes. Each link is recorded in the audit."));
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute: issue the presigned links (ciphertext only), each one audited.
|
||||
*******************************************************************************/
|
||||
public static class DownloadLoad extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine issued = RestoreSummaries.line(Status.OK, 0, "download link was issued", "download links were issued", "download link was issued", "download links were issued");
|
||||
private final ArrayList<String> links = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
String user = QContext.getQSession() == null || QContext.getQSession().getUser() == null ? "system" : QContext.getQSession().getUser().getIdReference();
|
||||
for(DownloadItem item : items(input.getRecords()))
|
||||
{
|
||||
Instant expires = BackupsRuntime.getClock().instant().plus(LINK_TTL);
|
||||
String url = item.storage().presignGet(item.key(), LINK_TTL, item.downloadName());
|
||||
links.add(item.downloadName() + (item.encrypted() ? " (encrypted)" : "") + ": " + url);
|
||||
issued.incrementCount();
|
||||
LOG.info("Issued a backup download link", logPair("object", item.key()), logPair("user", user), logPair("expires", expires));
|
||||
try
|
||||
{
|
||||
AuditAction.execute(item.auditTable(), item.auditId(), Map.of(), "Download link issued for " + item.key() + " to " + user + ", expires " + expires);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
LOG.warn("Could not audit a download link", e, logPair("object", item.key()));
|
||||
}
|
||||
}
|
||||
output.getRecords().addAll(input.getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
issued.setBulletsOfText(links);
|
||||
issued.addSelfToListIfAnyCount(lines);
|
||||
if(issued.getCount() != null && issued.getCount() > 0)
|
||||
{
|
||||
lines.add(RestoreSummaries.message(Status.INFO, "The links expire in " + LINK_TTL.toMinutes() + " minutes. Encrypted files download as ciphertext: use the command line tool to decrypt them."));
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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.processes;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.backups.BackupsQBitConfig;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.permissions.BackupsPermissionChecker;
|
||||
import com.kingsrook.qbits.backups.permissions.BackupsPermissions;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
import com.kingsrook.qbits.backups.run.BackupRunCoordinator;
|
||||
import com.kingsrook.qbits.backups.run.BackupRunRequest;
|
||||
import com.kingsrook.qbits.backups.run.BackupRunResult;
|
||||
import com.kingsrook.qbits.backups.run.TableReader;
|
||||
import com.kingsrook.qbits.backups.scope.PlanSettings;
|
||||
import com.kingsrook.qbits.backups.scope.ScopeResolver;
|
||||
import com.kingsrook.qbits.backups.scope.TableRole;
|
||||
import com.kingsrook.qbits.backups.scope.TableRoles;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.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.actions.permissions.TablePermissionSubType;
|
||||
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;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import static com.kingsrook.qbits.backups.processes.BackupsProcesses.line;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Back up these records (spec 9, 11.3): a table and bulk action on any table the
|
||||
** user can read. It writes an ad-hoc backups_run (no plan) that restores like any
|
||||
** other, with referenced files copied the same way.
|
||||
**
|
||||
** - The records are whatever the table action selected (the chosen records or the
|
||||
** current filter), read as the user, so record locks apply and the manifest says
|
||||
** securityScope USER.
|
||||
** - Sensitive fields are left out unless the user has backups.admin.
|
||||
** - The destination is the bucket given in the process input (storageBucketAlias),
|
||||
** else the bucket of the newest active plan.
|
||||
**
|
||||
** The records are held in memory until the export runs (an export of selected
|
||||
** records is bounded by what a person selected); a whole table is what a plan is for.
|
||||
**
|
||||
** The process is added to each table that can be backed up, named
|
||||
** table.backupExportTable (see exportProcesses).
|
||||
*******************************************************************************/
|
||||
public final class ExportTableProcess
|
||||
{
|
||||
public static final String NAME_SUFFIX = "backupExportTable";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private ExportTableProcess()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The export process for one table.
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData processFor(String tableName)
|
||||
{
|
||||
return (BackupsProcesses.permission(BackupsProcesses.etl(tableName + "." + NAME_SUFFIX, "Back up these records", tableName, Transform.class, Load.class,
|
||||
"This is a preview of what will be backed up. Nothing is written until you continue."), BackupsPermissions.RUN));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The export process of every table of the instance that can be backed up (not the
|
||||
** qbit own tables, and not secrets or sessions).
|
||||
*******************************************************************************/
|
||||
public static List<QProcessMetaData> exportProcesses(QInstance qInstance)
|
||||
{
|
||||
List<QProcessMetaData> processes = new ArrayList<>();
|
||||
for(QTableMetaData table : qInstance.getTables().values())
|
||||
{
|
||||
if(table.getName().startsWith(BackupsQBitConfig.TABLE_NAME_PREFIX))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
TableRole role = TableRoles.roleOf(qInstance, table);
|
||||
if(role == TableRole.DATA || role == TableRole.AUDIT || role == TableRole.DERIVED)
|
||||
{
|
||||
processes.add(processFor(table.getName()));
|
||||
}
|
||||
}
|
||||
return (processes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The sensitive field policy for the user.
|
||||
*******************************************************************************/
|
||||
static String sensitivePolicy()
|
||||
{
|
||||
return (BackupsPermissionChecker.has(BackupsPermissions.ADMIN) ? "INCLUDE" : "EXCLUDE");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bucket the export goes to, or null when there is none.
|
||||
*******************************************************************************/
|
||||
static String bucketAlias(RunBackendStepInput input) throws QException
|
||||
{
|
||||
String given = input.getValueString("storageBucketAlias");
|
||||
if(given != null && !given.isBlank())
|
||||
{
|
||||
return (given);
|
||||
}
|
||||
List<QRecord> plans = new BackupCatalog().query(BackupsTables.PLAN, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter(
|
||||
new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria("isActive", com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.EQUALS, true))
|
||||
.withOrderBy(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy("id", false)));
|
||||
return (plans.isEmpty() ? null : plans.get(0).getValueString("storageBucketAlias"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review: what is selected and what will be left out.
|
||||
*******************************************************************************/
|
||||
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine records = line(Status.OK, "record will be backed up", "records will be backed up", "record was backed up", "records were backed up");
|
||||
private final ProcessSummaryLine sensitive = line(Status.INFO, "field is sensitive and will be left out of the backup (including it needs backups.admin)",
|
||||
"fields are sensitive and will be left out of the backup (including them needs backups.admin)", "field was left out as sensitive", "fields were left out as sensitive");
|
||||
private final ProcessSummaryLine included = line(Status.WARNING, "field is sensitive and will be written in the clear, because you have backups.admin",
|
||||
"fields are sensitive and will be written in the clear, because you have backups.admin", "field was written in the clear", "fields were written in the clear");
|
||||
private final ProcessSummaryLine noBucket = line(Status.ERROR, "export has no destination: there is no active backup plan to take its bucket from",
|
||||
"exports have no destination: there is no active backup plan to take their bucket from", "export had no destination", "exports had no destination");
|
||||
private final ProcessSummaryLine denied = line(Status.ERROR, "table cannot be read by you, so it cannot be backed up", "tables cannot be read by you, so they cannot be backed up",
|
||||
"table could not be read by you", "tables could not be read by you");
|
||||
private boolean described = false;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
String table = input.getTableName();
|
||||
if(!described)
|
||||
{
|
||||
described = true;
|
||||
if(!PermissionsHelper.hasTablePermission(input, table, TablePermissionSubType.READ))
|
||||
{
|
||||
denied.incrementCount();
|
||||
}
|
||||
if(bucketAlias(input) == null)
|
||||
{
|
||||
noBucket.incrementCount();
|
||||
}
|
||||
QTableMetaData meta = QContext.getQInstance().getTable(table);
|
||||
int count = 0;
|
||||
for(QFieldMetaData field : meta.getFields().values())
|
||||
{
|
||||
if(!field.getName().equals(meta.getPrimaryKeyField()) && (field.getIsSensitive() || field.getType() == QFieldType.PASSWORD))
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
(BackupsPermissionChecker.has(BackupsPermissions.ADMIN) ? included : sensitive).incrementCount(count);
|
||||
}
|
||||
if(denied.getCount() > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
records.incrementCount(input.getRecords().size());
|
||||
output.getRecords().addAll(input.getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
records.addSelfToListIfAnyCount(lines);
|
||||
sensitive.addSelfToListIfAnyCount(lines);
|
||||
included.addSelfToListIfAnyCount(lines);
|
||||
noBucket.addSelfToListIfAnyCount(lines);
|
||||
denied.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Result: collect the selected records, then run the export through the engine when
|
||||
** the Result lines are asked for (after the last page, before post run).
|
||||
*******************************************************************************/
|
||||
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine succeeded = line(Status.OK, "export succeeded", "exports succeeded", "export succeeded", "exports succeeded");
|
||||
private final ProcessSummaryLine warnings = line(Status.WARNING, "export finished with warnings", "exports finished with warnings", "export finished with warnings", "exports finished with warnings");
|
||||
private final ProcessSummaryLine failed = line(Status.ERROR, "export failed", "exports failed", "export failed", "exports failed");
|
||||
private final ProcessSummaryLine rows = line(Status.INFO, "record was written", "records were written", "record was written", "records 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<QRecord> buffered = new ArrayList<>();
|
||||
|
||||
private String tableName;
|
||||
private String bucket;
|
||||
private boolean exported = false;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Remember what is being exported.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void preRun(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
super.preRun(input, output);
|
||||
tableName = input.getTableName();
|
||||
bucket = bucketAlias(input);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
buffered.addAll(input.getRecords());
|
||||
output.getRecords().addAll(input.getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All records are in (the summary is asked for once the last page is loaded, and
|
||||
** before post run): back them up, once.
|
||||
*******************************************************************************/
|
||||
private void export() throws QException
|
||||
{
|
||||
if(exported)
|
||||
{
|
||||
return;
|
||||
}
|
||||
exported = true;
|
||||
if(bucket == null)
|
||||
{
|
||||
failed.incrementCount();
|
||||
failed.setMessageSuffix(": there is no active backup plan to take a bucket from");
|
||||
return;
|
||||
}
|
||||
BackupRunResult result = new BackupRunCoordinator().runAdHoc(request(tableName, bucket, buffered));
|
||||
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());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
try
|
||||
{
|
||||
export();
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
failed.incrementCount();
|
||||
failed.setMessageSuffix(": " + e.getMessage());
|
||||
}
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
succeeded.addSelfToListIfAnyCount(lines);
|
||||
warnings.addSelfToListIfAnyCount(lines);
|
||||
failed.addSelfToListIfAnyCount(lines);
|
||||
rows.addSelfToListIfAnyCount(lines);
|
||||
files.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The ad-hoc request for the selected records of a table.
|
||||
*******************************************************************************/
|
||||
public static BackupRunRequest request(String tableName, String bucketAlias, List<QRecord> selected)
|
||||
{
|
||||
QInstance qInstance = QContext.getQInstance();
|
||||
QTableMetaData table = qInstance.getTable(tableName);
|
||||
String pk = table.getPrimaryKeyField();
|
||||
QSession session = QContext.getQSession();
|
||||
String user = session == null || session.getUser() == null ? "system" : session.getUser().getIdReference();
|
||||
BackupsQBitConfig config = BackupsRuntime.getConfig();
|
||||
|
||||
Map<String, QRecord> unique = new LinkedHashMap<>();
|
||||
for(QRecord record : selected)
|
||||
{
|
||||
unique.put(String.valueOf(record.getValue(pk)), record);
|
||||
}
|
||||
List<QRecord> inPkOrder = new ArrayList<>(unique.values());
|
||||
inPkOrder.sort(Comparator.comparing(r -> (Comparable) r.getValue(pk)));
|
||||
|
||||
PlanSettings settings = PlanSettings.adHoc().with("planKey", "adhoc").with("storageBucketAlias", bucketAlias).with("sensitiveFieldPolicy", sensitivePolicy());
|
||||
return (new BackupRunRequest()
|
||||
.withSettings(settings)
|
||||
.withScope(new ScopeResolver.ResolvedScope(List.of(new ScopeResolver.ResolvedTable(tableName, TableRoles.roleOf(qInstance, table), null, List.of())), List.of(), List.of()))
|
||||
.withTrigger("MANUAL")
|
||||
.withRequestedBy(user)
|
||||
.withSecurityScope("USER")
|
||||
.withBucketAlias(bucketAlias)
|
||||
.withStorage(BackupsRuntime.storageFor(bucketAlias))
|
||||
.withSourceResolver(BackupsRuntime::storageFor)
|
||||
.withInstanceName(config.getInstanceName() != null ? config.getInstanceName() : "qqq")
|
||||
.withPlanName("Export of " + table.getLabel())
|
||||
.withReader(new BufferedReader(inPkOrder))
|
||||
.withClock(BackupsRuntime.getClock()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Serves the selected records to the engine, in primary key order.
|
||||
*******************************************************************************/
|
||||
static class BufferedReader implements TableReader
|
||||
{
|
||||
private final List<QRecord> records;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
BufferedReader(List<QRecord> records)
|
||||
{
|
||||
this.records = records;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void read(QTableMetaData table, String filterJson, Serializable startAfterKey, int pageSize, QBackendTransaction transaction, java.util.function.Consumer<QRecord> sink)
|
||||
{
|
||||
records.forEach(sink);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.processes;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.backups.BackupsQBitConfig;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.schedule.BackupPlanSyncToScheduledJobProcess;
|
||||
import com.kingsrook.qbits.backups.schedule.BackupPlanVerifySyncToScheduledJobProcess;
|
||||
import com.kingsrook.qbits.backups.schedule.BackupScheduledRunProcess;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The processes of the run lifecycle (spec 9): scheduled runs, cancel, resume,
|
||||
** test destination and prune, and the plan to scheduled job sync. Added to the
|
||||
** qbit by BackupsProcesses.processes(). The per-table export process
|
||||
** (exportProcesses) needs the instance tables, so the producer adds it.
|
||||
*******************************************************************************/
|
||||
public final class LifecycleProcesses
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private LifecycleProcesses()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The processes.
|
||||
*******************************************************************************/
|
||||
public static List<QProcessMetaData> processes()
|
||||
{
|
||||
List<QProcessMetaData> processes = new ArrayList<>();
|
||||
processes.add(BackupScheduledRunProcess.processMetaData());
|
||||
processes.addAll(CancelRunProcess.processes());
|
||||
processes.add(ResumeRunProcess.process());
|
||||
processes.add(TestDestinationProcess.process());
|
||||
processes.addAll(PruneProcess.processes());
|
||||
|
||||
BackupsQBitConfig config = BackupsRuntime.getConfig();
|
||||
if(config != null && StringUtils.hasContent(config.getSchedulerName()))
|
||||
{
|
||||
try
|
||||
{
|
||||
processes.add(new BackupPlanSyncToScheduledJobProcess().produce(null));
|
||||
processes.add(new BackupPlanVerifySyncToScheduledJobProcess().produce(null));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new IllegalStateException("Could not define the plan schedule sync processes: " + e.getMessage(), e));
|
||||
}
|
||||
}
|
||||
return (processes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
/*
|
||||
* 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.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.backups.BackupsQBitConfig;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.permissions.BackupsPermissions;
|
||||
import com.kingsrook.qbits.backups.prune.BackupPruner;
|
||||
import com.kingsrook.qbits.backups.prune.PruneCalculator;
|
||||
import com.kingsrook.qbits.backups.run.BackupCatalog;
|
||||
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.model.metadata.scheduleing.QScheduleMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import static com.kingsrook.qbits.backups.processes.BackupsProcesses.line;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Prune per retention (spec 9, 10.3). A record action on backups_plan, and a
|
||||
** table action on backups_run (which prunes the plans of the selected runs); the
|
||||
** plan one also runs daily when the qbit has a scheduler.
|
||||
**
|
||||
** Review shows exactly what Result will do: the runs to delete, the runs kept by
|
||||
** class, the runs kept because an object is still locked (with the date), the
|
||||
** debris prefixes, the content-addressed objects to sweep, and the bytes freed.
|
||||
** Review writes nothing.
|
||||
*******************************************************************************/
|
||||
public final class PruneProcess
|
||||
{
|
||||
public static final String NAME = "backupPrune";
|
||||
public static final String NAME_RUNS = "backupPruneRuns";
|
||||
|
||||
private static final long MIB = 1024L * 1024L;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private PruneProcess()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The two processes.
|
||||
*******************************************************************************/
|
||||
public static List<QProcessMetaData> processes()
|
||||
{
|
||||
QProcessMetaData onPlans = BackupsProcesses.permission(BackupsProcesses.etl(NAME, "Prune per retention", BackupsTables.PLAN, PlanTransform.class, Load.class,
|
||||
"This is a preview of what Prune will delete. Nothing is deleted until you continue."), BackupsPermissions.ADMIN);
|
||||
|
||||
BackupsQBitConfig config = BackupsRuntime.getConfig();
|
||||
if(config != null && config.getSchedulerName() != null && !config.getSchedulerName().isBlank())
|
||||
{
|
||||
onPlans.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()).withRepeatSeconds(24 * 3600).withInitialDelaySeconds(3600));
|
||||
}
|
||||
|
||||
QProcessMetaData onRuns = BackupsProcesses.permission(BackupsProcesses.etl(NAME_RUNS, "Prune per retention", BackupsTables.RUN, RunTransform.class, Load.class,
|
||||
"This is a preview of what Prune will delete for the plans of the selected runs. Nothing is deleted until you continue."), BackupsPermissions.ADMIN);
|
||||
return (List.of(onPlans, onRuns));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The review lines, shared by the two transforms.
|
||||
*******************************************************************************/
|
||||
abstract static class BaseTransform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine plans = line(Status.OK, "plan will be pruned", "plans will be pruned", "plan was pruned", "plans were pruned");
|
||||
private final ProcessSummaryLine delete = line(Status.WARNING, "run will be deleted", "runs will be deleted", "run was deleted", "runs were deleted");
|
||||
private final ProcessSummaryLine daily = line(Status.OK, "run is kept as a daily backup", "runs are kept as daily backups", "run was kept as a daily backup", "runs were kept as daily backups");
|
||||
private final ProcessSummaryLine weekly = line(Status.OK, "run is kept as a weekly backup", "runs are kept as weekly backups", "run was kept as a weekly backup", "runs were kept as weekly backups");
|
||||
private final ProcessSummaryLine monthly = line(Status.OK, "run is kept as a monthly backup", "runs are kept as monthly backups", "run was kept as a monthly backup", "runs were kept as monthly backups");
|
||||
private final ProcessSummaryLine yearly = line(Status.OK, "run is kept as a yearly backup", "runs are kept as yearly backups", "run was kept as a yearly backup", "runs were kept as yearly backups");
|
||||
private final ProcessSummaryLine guarded = line(Status.OK, "run is kept by a guard (the minimum, the newest verified run, or a recent restore)",
|
||||
"runs are kept by a guard (the minimum, the newest verified run, or a recent restore)", "run was kept by a guard", "runs were kept by a guard");
|
||||
private final ProcessSummaryLine locked = line(Status.WARNING, "run is kept: its objects are locked", "runs are kept: their objects are locked",
|
||||
"run was kept: its objects are locked", "runs were kept: their objects are locked");
|
||||
private final ProcessSummaryLine debris = line(Status.INFO, "unfinished backup (older than 7 days) will be deleted", "unfinished backups (older than 7 days) will be deleted",
|
||||
"unfinished backup was deleted", "unfinished backups were deleted");
|
||||
private final ProcessSummaryLine sweep = line(Status.INFO, "stored file that no retained backup references will be deleted", "stored files that no retained backup references will be deleted",
|
||||
"stored file was deleted", "stored files were deleted");
|
||||
private final ProcessSummaryLine sweepLocked = line(Status.WARNING, "stored file is kept: it is locked", "stored files are kept: they are locked", "stored file was kept: it is locked", "stored files were kept: they are locked");
|
||||
private final ProcessSummaryLine deferred = line(Status.WARNING, "plan's file store sweep is skipped", "plans' file store sweeps are skipped", "plan's file store sweep was skipped", "plans' file store sweeps were skipped");
|
||||
private final ProcessSummaryLine freed = line(Status.INFO, "MiB of storage will be freed", "MiB of storage will be freed", "MiB of storage was freed", "MiB of storage was freed");
|
||||
private final ProcessSummaryLine failed = line(Status.ERROR, "plan cannot be pruned", "plans cannot be pruned", "plan could not be pruned", "plans could not be pruned");
|
||||
|
||||
private final Set<Integer> seenPlans = new HashSet<>();
|
||||
private long bytes = 0;
|
||||
private Instant latestLock;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The plan records this step looks at.
|
||||
*******************************************************************************/
|
||||
abstract List<QRecord> plansFor(RunBackendStepInput input) throws QException;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Plan each selected plan, writing nothing.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord plan : plansFor(input))
|
||||
{
|
||||
if(!seenPlans.add(plan.getValueInteger("id")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
BackupPruner.PrunePlan prunePlan = new BackupPruner().plan(plan);
|
||||
plans.incrementCountAndAddPrimaryKey(plan.getValue("id"));
|
||||
delete.incrementCount(prunePlan.toDelete().size());
|
||||
var kept = prunePlan.keptByClass();
|
||||
daily.incrementCount(kept.getOrDefault(PruneCalculator.RetentionClass.DAILY, 0));
|
||||
weekly.incrementCount(kept.getOrDefault(PruneCalculator.RetentionClass.WEEKLY, 0));
|
||||
monthly.incrementCount(kept.getOrDefault(PruneCalculator.RetentionClass.MONTHLY, 0));
|
||||
yearly.incrementCount(kept.getOrDefault(PruneCalculator.RetentionClass.YEARLY, 0));
|
||||
guarded.incrementCount(kept.getOrDefault(PruneCalculator.RetentionClass.NONE, 0));
|
||||
locked.incrementCount(prunePlan.lockedNotKept().size());
|
||||
for(BackupPruner.RunPlan run : prunePlan.lockedNotKept())
|
||||
{
|
||||
latestLock = latestLock == null || run.lockedUntil().isAfter(latestLock) ? run.lockedUntil() : latestLock;
|
||||
}
|
||||
debris.incrementCount(prunePlan.debris().stream().filter(d -> d.lockedUntil() == null).toList().size());
|
||||
sweep.incrementCount(prunePlan.sweep().deletable().size());
|
||||
sweepLocked.incrementCount(prunePlan.sweep().locked().size());
|
||||
if(prunePlan.sweep().deferredReason() != null)
|
||||
{
|
||||
deferred.incrementCount();
|
||||
deferred.setMessageSuffix(": " + prunePlan.sweep().deferredReason());
|
||||
}
|
||||
bytes += prunePlan.bytesToFree();
|
||||
output.getRecords().add(plan);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
failed.incrementCountAndAddPrimaryKey(plan.getValue("id"));
|
||||
failed.setMessageSuffix(": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The lines.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
if(latestLock != null)
|
||||
{
|
||||
locked.setMessageSuffix(" (the latest lock ends " + latestLock + ")");
|
||||
}
|
||||
freed.setCount((int) Math.min(Integer.MAX_VALUE, (bytes + MIB - 1) / MIB));
|
||||
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
plans.addSelfToListIfAnyCount(lines);
|
||||
delete.addSelfToListIfAnyCount(lines);
|
||||
daily.addSelfToListIfAnyCount(lines);
|
||||
weekly.addSelfToListIfAnyCount(lines);
|
||||
monthly.addSelfToListIfAnyCount(lines);
|
||||
yearly.addSelfToListIfAnyCount(lines);
|
||||
guarded.addSelfToListIfAnyCount(lines);
|
||||
locked.addSelfToListIfAnyCount(lines);
|
||||
debris.addSelfToListIfAnyCount(lines);
|
||||
sweep.addSelfToListIfAnyCount(lines);
|
||||
sweepLocked.addSelfToListIfAnyCount(lines);
|
||||
deferred.addSelfToListIfAnyCount(lines);
|
||||
freed.addSelfToListIfAnyCount(lines);
|
||||
failed.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review for plans.
|
||||
*******************************************************************************/
|
||||
public static class PlanTransform extends BaseTransform
|
||||
{
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
List<QRecord> plansFor(RunBackendStepInput input)
|
||||
{
|
||||
return (input.getRecords());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review for runs: the plans of the selected runs.
|
||||
*******************************************************************************/
|
||||
public static class RunTransform extends BaseTransform
|
||||
{
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
List<QRecord> plansFor(RunBackendStepInput input) throws QException
|
||||
{
|
||||
List<QRecord> plans = new ArrayList<>();
|
||||
BackupCatalog catalog = new BackupCatalog();
|
||||
for(QRecord run : input.getRecords())
|
||||
{
|
||||
if(run.getValueInteger("backupPlanId") != null)
|
||||
{
|
||||
catalog.get(BackupsTables.PLAN, run.getValueInteger("backupPlanId")).ifPresent(plans::add);
|
||||
}
|
||||
}
|
||||
return (plans);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Result: do it, and report what was done.
|
||||
*******************************************************************************/
|
||||
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine plans = line(Status.OK, "plan will be pruned", "plans will be pruned", "plan was pruned", "plans were pruned");
|
||||
private final ProcessSummaryLine deleted = line(Status.OK, "run will be deleted", "runs will be deleted", "run was deleted", "runs were deleted");
|
||||
private final ProcessSummaryLine locked = line(Status.WARNING, "run was kept: locked until a later date", "runs were kept: locked until a later date",
|
||||
"run was kept: locked until a later date", "runs were kept: locked until a later date");
|
||||
private final ProcessSummaryLine debris = line(Status.OK, "unfinished backup will be deleted", "unfinished backups will be deleted", "unfinished backup was deleted", "unfinished backups were deleted");
|
||||
private final ProcessSummaryLine files = line(Status.OK, "stored file will be deleted", "stored files will be deleted", "stored file was deleted", "stored files were deleted");
|
||||
private final ProcessSummaryLine filesKept = line(Status.WARNING, "stored file was kept: it is locked", "stored files were kept: they are locked", "stored file was kept: it is locked", "stored files were kept: they are locked");
|
||||
private final ProcessSummaryLine notes = line(Status.INFO, "note", "notes", "note", "notes");
|
||||
private final ProcessSummaryLine freed = line(Status.INFO, "MiB of storage was freed", "MiB of storage was freed", "MiB of storage was freed", "MiB of storage was freed");
|
||||
private final ProcessSummaryLine failed = line(Status.ERROR, "plan could not be pruned", "plans could not be pruned", "plan could not be pruned", "plans could not be pruned");
|
||||
private long bytes = 0;
|
||||
private final List<String> messages = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Prune each plan.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
com.kingsrook.qqq.backend.core.model.session.QSession session = com.kingsrook.qqq.backend.core.context.QContext.getQSession();
|
||||
String user = session == null || session.getUser() == null ? "system:scheduler" : session.getUser().getIdReference();
|
||||
for(QRecord plan : input.getRecords())
|
||||
{
|
||||
try
|
||||
{
|
||||
BackupPruner.Outcome outcome = new BackupPruner().prune(plan, user);
|
||||
plans.incrementCountAndAddPrimaryKey(plan.getValue("id"));
|
||||
deleted.incrementCount(outcome.runsDeleted());
|
||||
locked.incrementCount(outcome.runsLocked());
|
||||
debris.incrementCount(outcome.debrisPrefixesDeleted());
|
||||
files.incrementCount(outcome.filesSwept());
|
||||
filesKept.incrementCount(outcome.filesLocked());
|
||||
bytes += outcome.bytesFreed();
|
||||
messages.addAll(outcome.messages());
|
||||
output.getRecords().add(plan);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
failed.incrementCountAndAddPrimaryKey(plan.getValue("id"));
|
||||
failed.setMessageSuffix(": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Result lines: what was deleted, and what was kept because it is locked.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
freed.setCount((int) Math.min(Integer.MAX_VALUE, (bytes + MIB - 1) / MIB));
|
||||
notes.setCount(messages.size());
|
||||
if(!messages.isEmpty())
|
||||
{
|
||||
notes.setMessageSuffix(": " + String.join("; ", messages.stream().limit(5).toList()) + (messages.size() > 5 ? "; and " + (messages.size() - 5) + " more" : ""));
|
||||
}
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
plans.addSelfToListIfAnyCount(lines);
|
||||
deleted.addSelfToListIfAnyCount(lines);
|
||||
locked.addSelfToListIfAnyCount(lines);
|
||||
debris.addSelfToListIfAnyCount(lines);
|
||||
files.addSelfToListIfAnyCount(lines);
|
||||
filesKept.addSelfToListIfAnyCount(lines);
|
||||
freed.addSelfToListIfAnyCount(lines);
|
||||
notes.addSelfToListIfAnyCount(lines);
|
||||
failed.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.processes;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.permissions.BackupsPermissions;
|
||||
import com.kingsrook.qbits.backups.restore.RestoreEngine;
|
||||
import com.kingsrook.qbits.backups.restore.RestoreOutcome;
|
||||
import com.kingsrook.qbits.backups.restore.RestoreRequest;
|
||||
import com.kingsrook.qbits.backups.restore.RestoreSummaries;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The restore processes (spec 9.3): Restore... on a backup run, and Restore this
|
||||
** table on a backup artifact. Both are the standard streamed ETL. The first
|
||||
** screen takes the inputs; Review is a real dry run of the whole restore with the
|
||||
** per-table summary lines and a masked sample diff; Execute is the restore, and
|
||||
** Result shows what was done. Neither is in the nav.
|
||||
*******************************************************************************/
|
||||
public final class RestoreProcesses
|
||||
{
|
||||
public static final String RESTORE = "backupRestore";
|
||||
public static final String RESTORE_TABLE = "backupRestoreTable";
|
||||
|
||||
public static final String STEP_INPUT = "input";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private RestoreProcesses()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The processes defined here.
|
||||
*******************************************************************************/
|
||||
public static List<QProcessMetaData> processes()
|
||||
{
|
||||
List<QProcessMetaData> processes = new ArrayList<>();
|
||||
processes.add(BackupsProcesses.permission(restore(RESTORE, "Restore...", BackupsTables.RUN), BackupsPermissions.RESTORE));
|
||||
processes.add(BackupsProcesses.permission(restore(RESTORE_TABLE, "Restore this table", BackupsTables.ARTIFACT), BackupsPermissions.RESTORE));
|
||||
return (processes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One restore process, sourced from the table whose records it is run on.
|
||||
*******************************************************************************/
|
||||
private static QProcessMetaData restore(String name, String label, String table)
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_TRANSACTION_LEVEL, StreamedETLWithFrontendProcess.TRANSACTION_LEVEL_AUTO_COMMIT);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, "This is what the restore will do. Nothing is written until you continue.");
|
||||
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, RestoreTransform.class, RestoreLoad.class, values);
|
||||
process.withStep(0, inputStep());
|
||||
process.setName(name);
|
||||
process.setLabel(label);
|
||||
process.setTableName(table);
|
||||
process.setPermissionRules(BackupsTables.permissionRules());
|
||||
return (process);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The first screen: what to restore and how.
|
||||
*******************************************************************************/
|
||||
private static QFrontendStepMetaData inputStep()
|
||||
{
|
||||
return (new QFrontendStepMetaData()
|
||||
.withName(STEP_INPUT)
|
||||
.withLabel("Restore options")
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM))
|
||||
.withFormField(new QFieldMetaData("tables", QFieldType.STRING).withLabel("Tables (blank for every table in the backup)"))
|
||||
.withFormField(new QFieldMetaData("tableFilters", QFieldType.TEXT).withLabel("Only these records (JSON: table name to filter, optional)"))
|
||||
.withFormField(new QFieldMetaData("targetTable", QFieldType.STRING).withLabel("Restore into another table (optional, one table only)"))
|
||||
.withFormField(new QFieldMetaData("mode", QFieldType.STRING).withLabel("Mode").withIsRequired(true).withDefaultValue(RestoreRequest.MODE_INSERT_NEW)
|
||||
.withPossibleValueSourceName(BackupsTables.pvs("restore_mode")))
|
||||
.withFormField(new QFieldMetaData("matchOn", QFieldType.STRING).withLabel("Match records on (PRIMARY_KEY, or a unique key's fields)").withDefaultValue(RestoreRequest.MATCH_PRIMARY_KEY))
|
||||
.withFormField(new QFieldMetaData("idStrategy", QFieldType.STRING).withLabel("Ids").withIsRequired(true).withDefaultValue(RestoreRequest.IDS_PRESERVE)
|
||||
.withPossibleValueSourceName(BackupsTables.pvs("id_strategy")))
|
||||
.withFormField(new QFieldMetaData("suppressAutomations", QFieldType.BOOLEAN).withLabel("Do not trigger automations").withDefaultValue(true))
|
||||
.withFormField(new QFieldMetaData("publishEvents", QFieldType.BOOLEAN).withLabel("Publish change events").withDefaultValue(false))
|
||||
.withFormField(new QFieldMetaData("runCustomizers", QFieldType.BOOLEAN).withLabel("Run table customizers").withDefaultValue(false))
|
||||
.withFormField(new QFieldMetaData("perRecordAudit", QFieldType.BOOLEAN).withLabel("Audit each record (otherwise one summary per table)").withDefaultValue(false))
|
||||
.withFormField(new QFieldMetaData("restoreFiles", QFieldType.BOOLEAN).withLabel("Restore referenced files").withDefaultValue(true))
|
||||
.withFormField(new QFieldMetaData("fileTargetBucketAlias", QFieldType.STRING).withLabel("Restore files to another bucket alias (optional)"))
|
||||
.withFormField(new QFieldMetaData("fileTargetPrefix", QFieldType.STRING).withLabel("Restore files under this prefix (optional)"))
|
||||
.withFormField(new QFieldMetaData("preRestoreBackup", QFieldType.BOOLEAN).withLabel("Back up the target first").withDefaultValue(true))
|
||||
.withFormField(new QFieldMetaData("confirmationText", QFieldType.STRING).withLabel("Type the table name (or REPLACE <n> TABLES) to confirm a REPLACE")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The backup runs the selected records belong to, with the tables chosen for each
|
||||
** (empty means the tables of the form). Artifact records choose their own table.
|
||||
*******************************************************************************/
|
||||
static Map<Integer, Set<String>> sources(List<QRecord> records) throws QException
|
||||
{
|
||||
Map<Integer, Set<String>> sources = new LinkedHashMap<>();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
boolean artifact = BackupsTables.ARTIFACT.equals(record.getTableName()) || record.getValue("artifactType") != null;
|
||||
if(artifact)
|
||||
{
|
||||
String table = record.getValueString("tableName");
|
||||
if(table == null)
|
||||
{
|
||||
throw (new QUserFacingException("Only the data of a table can be restored: choose a table data artifact."));
|
||||
}
|
||||
sources.computeIfAbsent(record.getValueInteger("backupRunId"), k -> new LinkedHashSet<>()).add(table);
|
||||
}
|
||||
else
|
||||
{
|
||||
sources.computeIfAbsent(record.getValueInteger("id"), k -> new LinkedHashSet<>());
|
||||
}
|
||||
}
|
||||
return (sources);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review (preview, validate) is the dry run; execute only re-checks the rails.
|
||||
*******************************************************************************/
|
||||
public static class RestoreTransform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final List<RestoreOutcome> outcomes = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
boolean execute = StreamedETLWithFrontendProcess.STEP_NAME_EXECUTE.equals(input.getStepName());
|
||||
RestoreEngine engine = new RestoreEngine();
|
||||
for(Map.Entry<Integer, Set<String>> source : sources(input.getRecords()).entrySet())
|
||||
{
|
||||
RestoreRequest request = RestoreRequest.fromInput(input, source.getKey(), new ArrayList<>(source.getValue()));
|
||||
if(execute)
|
||||
{
|
||||
RestoreEngine.Plan plan = engine.plan(request);
|
||||
if(!plan.errors().isEmpty())
|
||||
{
|
||||
throw (new QUserFacingException(String.join(" ", plan.errors())));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
outcomes.add(engine.review(request));
|
||||
}
|
||||
}
|
||||
output.getRecords().addAll(input.getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
for(RestoreOutcome outcome : outcomes)
|
||||
{
|
||||
lines.addAll(RestoreSummaries.lines(outcome, false));
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute: the restore itself, one backup run at a time.
|
||||
*******************************************************************************/
|
||||
public static class RestoreLoad extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final List<RestoreOutcome> outcomes = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
RestoreEngine engine = new RestoreEngine();
|
||||
for(Map.Entry<Integer, Set<String>> source : sources(input.getRecords()).entrySet())
|
||||
{
|
||||
RestoreRequest request = RestoreRequest.fromInput(input, source.getKey(), new ArrayList<>(source.getValue()));
|
||||
outcomes.add(engine.execute(request));
|
||||
}
|
||||
output.getRecords().addAll(input.getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
for(RestoreOutcome outcome : outcomes)
|
||||
{
|
||||
lines.addAll(RestoreSummaries.lines(outcome, true));
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
}
|
||||