Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29499ea6b6
|
||
|
|
ec5fef94bc | ||
|
|
b21db17a9f | ||
|
|
7fa71bbaa6 | ||
|
|
84f030d048 | ||
|
|
9cb78190d8
|
||
|
|
91d86b016a | ||
|
|
0c28ea6263
|
||
|
|
825944fab4 |
No files matched your search
+11
-57
@@ -1,60 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.9
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.7.12
|
||||
workflows:
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only: [develop]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only: [/release\/.*/]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: 0.7.12
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -1,26 +1,74 @@
|
||||
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
|
||||
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
|
||||
# the release line and the release/* push runs CircleCI publish_release_candidate.
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original Circle publisher build number (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
actions: read
|
||||
checks: read
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
secrets: inherit
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
|
||||
== 'workflow_dispatch' && inputs.finalize) }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|
||||
|| inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|
||||
|| inputs.bump_sha || '' }}
|
||||
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|
||||
|| inputs.branch || '' }}
|
||||
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|
||||
|| inputs.version || '' }}
|
||||
secrets: inherit
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
profile: qbit
|
||||
orb_version: 0.7.12
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
@@ -16,6 +16,8 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
- Repository scaffold: `BackupsQBitConfig` and `BackupsQBitProducer` (table prefix `backups_`, permissions `backups.*`), build, CI and repository standard files.
|
||||
|
||||
### Changed
|
||||
- Tables whose backend module has no query interface (storage-only modules such as qbit-edi `edi_preview_download`) are classified EXTERNAL and always left out of a backup with the reason `NOT_QUERYABLE`, even when selected by name or role; the Run backup review lists them. Before, such a table was read as DATA and the whole run FAILED with "Query is not implemented in this module".
|
||||
- Failed runs persist an actionable `error_message` ("Backup failed: Class: message") and fill `error_detail` with the exception class, the cause chain and the top root-cause frames (bounded to 8 KB). Both go through `FailureText.sanitize`, which redacts URL user-info, presigned query parameters, key=value secrets, Authorization/Bearer values, AWS access key ids, PEM blocks and Postgres row values while keeping object keys and bucket names.
|
||||
- Multi-node deployments: the cached Prometheus text (`BackupsMetrics`) is registered with QQQ `ClusterCaches` as `qbit-backups.metrics` and cleared on every node when a run finishes or a plan is inserted, updated or deleted; the 60 s TTL remains the safety net. `SlaChecker`'s manifest-head and bucket-audit throttles and `BackupsRuntime`'s process-permission map are registered as node-local, with reasons. `BackupsClusterCachesTest` audits the package for unregistered static caches and covers a remote invalidation.
|
||||
- Builds against QQQ `4.1.0-SNAPSHOT` (`qqq.snapshot.version`) for `ClusterCaches` until the next QQQ RC ships; a release must pin that RC.
|
||||
- Read the QBit metadata version from the Maven artifact version so release builds report their released version.
|
||||
|
||||
@@ -22,6 +22,8 @@ import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qbits.backups.BackupsRuntime;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.permissions.BackupsPermissions;
|
||||
@@ -178,6 +180,10 @@ public final class BackupsProcesses
|
||||
private final ProcessSummaryLine tables = line(Status.INFO, "table will be backed up", "tables will be backed up", "table was backed up", "tables were backed up");
|
||||
private final ProcessSummaryLine excluded = line(Status.INFO, "table is left out by its role or scope", "tables are left out by their role or scope",
|
||||
"table was left out by its role or scope", "tables were left out by their role or scope");
|
||||
private final ProcessSummaryLine unreadable = line(Status.INFO, "table is left out because its backend cannot be queried (storage only)",
|
||||
"tables are left out because their backend cannot be queried (storage only)", "table was left out because its backend cannot be queried (storage only)",
|
||||
"tables were left out because their backend cannot be queried (storage only)");
|
||||
private final Set<String> unreadableNames = new TreeSet<>();
|
||||
private final ProcessSummaryLine inactive = line(Status.WARNING, "plan is not active (it can still be run by hand)", "plans are not active (they can still be run by hand)",
|
||||
"plan was not active", "plans were not active");
|
||||
private final ProcessSummaryLine invalid = line(Status.ERROR, "plan cannot run", "plans cannot run", "plan could not run", "plans could not run");
|
||||
@@ -205,6 +211,14 @@ public final class BackupsProcesses
|
||||
plans.incrementCountAndAddPrimaryKey(plan.getValue("id"));
|
||||
tables.incrementCount(scope.tables().size());
|
||||
excluded.incrementCount(scope.excluded().size());
|
||||
for(ScopeResolver.ExcludedTable out : scope.excluded())
|
||||
{
|
||||
if(ScopeResolver.NOT_QUERYABLE.equals(out.reason()) && unreadableNames.add(out.name()))
|
||||
{
|
||||
unreadable.incrementCount();
|
||||
unreadable.setMessageSuffix(": " + String.join(", ", unreadableNames));
|
||||
}
|
||||
}
|
||||
if(!Boolean.TRUE.equals(plan.getValueBoolean("isActive")))
|
||||
{
|
||||
inactive.incrementCountAndAddPrimaryKey(plan.getValue("id"));
|
||||
@@ -235,6 +249,7 @@ public final class BackupsProcesses
|
||||
plans.addSelfToListIfAnyCount(lines);
|
||||
tables.addSelfToListIfAnyCount(lines);
|
||||
excluded.addSelfToListIfAnyCount(lines);
|
||||
unreadable.addSelfToListIfAnyCount(lines);
|
||||
inactive.addSelfToListIfAnyCount(lines);
|
||||
invalid.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
|
||||
@@ -118,7 +118,7 @@ public final class ExportTableProcess
|
||||
continue;
|
||||
}
|
||||
TableRole role = TableRoles.roleOf(qInstance, table);
|
||||
if(role == TableRole.DATA || role == TableRole.AUDIT || role == TableRole.DERIVED)
|
||||
if((role == TableRole.DATA || role == TableRole.AUDIT || role == TableRole.DERIVED) && TableRoles.isQueryable(qInstance, table))
|
||||
{
|
||||
processes.add(processFor(table.getName()));
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
** content-addressed store; checksums.sha256 is written; and the manifest is
|
||||
** written LAST, then checked with a head. A run with no manifest did not
|
||||
** complete. The run row is updated every page (heartbeat), and a failure marks
|
||||
** the run FAILED with a scrubbed message (never row values).
|
||||
** the run FAILED with a sanitized message and detail (no secrets, no row values).
|
||||
*******************************************************************************/
|
||||
public class BackupEngine
|
||||
{
|
||||
@@ -142,6 +142,7 @@ public class BackupEngine
|
||||
runValues.remove("startDate");
|
||||
runValues.put("endDate", null);
|
||||
runValues.put("errorMessage", null);
|
||||
runValues.put("errorDetail", null);
|
||||
}
|
||||
if(request.getExistingRunId() != null)
|
||||
{
|
||||
@@ -155,9 +156,12 @@ public class BackupEngine
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not create the backup run record", e, logPair("runKey", runKey));
|
||||
String message = FailureText.summary("Could not create the run record", e);
|
||||
result.setStatus("FAILED");
|
||||
result.setErrorMessage("Could not create the run record (" + e.getClass().getSimpleName() + ")");
|
||||
failProgress(progress, "Could not create the run record (" + e.getClass().getSimpleName() + ")");
|
||||
result.setErrorMessage(message);
|
||||
result.setErrorDetail(FailureText.detail(e));
|
||||
failProgress(progress, message);
|
||||
return (result);
|
||||
}
|
||||
|
||||
@@ -174,12 +178,12 @@ public class BackupEngine
|
||||
}
|
||||
catch(CancelledException e)
|
||||
{
|
||||
finish(request, result, "CANCELLED", "Cancelled", started);
|
||||
finish(request, result, "CANCELLED", "Cancelled", null, started);
|
||||
progress.info("Backup cancelled");
|
||||
}
|
||||
catch(TimedOutException e)
|
||||
{
|
||||
finish(request, result, "TIMED_OUT", e.getMessage(), started);
|
||||
finish(request, result, "TIMED_OUT", e.getMessage(), null, started);
|
||||
failProgress(progress, e.getMessage());
|
||||
}
|
||||
catch(Exception e)
|
||||
@@ -189,14 +193,14 @@ public class BackupEngine
|
||||
{
|
||||
if(interrupted.getReason() != JobInterruptedException.Reason.LEASE_LOST)
|
||||
{
|
||||
finish(request, result, interrupted.getReason() == JobInterruptedException.Reason.CANCEL ? "CANCELLED" : "ABANDONED", "Job interrupted", started);
|
||||
finish(request, result, interrupted.getReason() == JobInterruptedException.Reason.CANCEL ? "CANCELLED" : "ABANDONED", "Job interrupted", null, started);
|
||||
}
|
||||
throw (interrupted);
|
||||
}
|
||||
LOG.warn("Backup run failed", e, logPair("runKey", runKey));
|
||||
// Provider and reader messages can contain credentials or row values.
|
||||
failProgress(progress, scrub(e));
|
||||
finish(request, result, "FAILED", scrub(e), started);
|
||||
String message = scrub(e);
|
||||
failProgress(progress, message);
|
||||
finish(request, result, "FAILED", message, FailureText.detail(e), started);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -573,7 +577,7 @@ public class BackupEngine
|
||||
extra.put("bytesRaw", bytesRaw);
|
||||
extra.put("bytesStored", bytesStored);
|
||||
extra.put("warningCount", result.getWarnings().size());
|
||||
finishWith(request, result, result.getWarnings().isEmpty() ? "SUCCEEDED" : "SUCCEEDED_WITH_WARNINGS", null, started, extra);
|
||||
finishWith(request, result, result.getWarnings().isEmpty() ? "SUCCEEDED" : "SUCCEEDED_WITH_WARNINGS", null, null, started, extra);
|
||||
}
|
||||
|
||||
|
||||
@@ -741,11 +745,11 @@ public class BackupEngine
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void finish(BackupRunRequest request, BackupRunResult result, String status, String message, Instant started)
|
||||
private void finish(BackupRunRequest request, BackupRunResult result, String status, String message, String detail, Instant started)
|
||||
{
|
||||
try
|
||||
{
|
||||
finishWith(request, result, status, message, started, new LinkedHashMap<>());
|
||||
finishWith(request, result, status, message, detail, started, new LinkedHashMap<>());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
@@ -753,6 +757,7 @@ public class BackupEngine
|
||||
request.getProgress().warn("Could not finish the run record");
|
||||
result.setStatus(status);
|
||||
result.setErrorMessage(message);
|
||||
result.setErrorDetail(detail);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -761,7 +766,7 @@ public class BackupEngine
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void finishWith(BackupRunRequest request, BackupRunResult result, String status, String message, Instant started, Map<String, Serializable> extra) throws Exception
|
||||
private void finishWith(BackupRunRequest request, BackupRunResult result, String status, String message, String detail, Instant started, Map<String, Serializable> extra) throws Exception
|
||||
{
|
||||
Instant end = request.getClock().instant();
|
||||
Map<String, Serializable> v = new LinkedHashMap<>(extra);
|
||||
@@ -770,10 +775,12 @@ public class BackupEngine
|
||||
v.put("durationMs", end.toEpochMilli() - started.toEpochMilli());
|
||||
v.put("heartbeatDate", end);
|
||||
v.put("errorMessage", message);
|
||||
v.put("errorDetail", detail);
|
||||
v.put("warningCount", result.getWarnings().size());
|
||||
catalog.update(BackupsTables.RUN, result.getRunId(), v);
|
||||
result.setStatus(status);
|
||||
result.setErrorMessage(message);
|
||||
result.setErrorDetail(detail);
|
||||
signal(request, result, status, message);
|
||||
BackupsMetrics.invalidate();
|
||||
}
|
||||
@@ -828,12 +835,17 @@ public class BackupEngine
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider exceptions may contain credentials or row values. Persist only the
|
||||
** failure type; detailed diagnostics stay in the server log.
|
||||
** The error_message of a failed run: "Backup failed: SimpleClass: message".
|
||||
** Provider, driver and reader messages can contain credentials or row values,
|
||||
** so the message goes through FailureText.sanitize (URL user-info, presigned
|
||||
** query parameters, key=value secrets, Authorization values, AWS key ids, PEM
|
||||
** blocks and Postgres row-value fragments are redacted; object keys and bucket
|
||||
** names are kept). The class, the cause chain and the root cause frames go to
|
||||
** error_detail (FailureText.detail); the full trace stays in the server log.
|
||||
*******************************************************************************/
|
||||
private static String scrub(Exception e)
|
||||
{
|
||||
return ("Backup failed (" + e.getClass().getSimpleName() + "); see server logs for details.");
|
||||
return (FailureText.summary(e));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ public class BackupRunResult
|
||||
private int filesMissing;
|
||||
private long fileBytesCopied;
|
||||
private String errorMessage;
|
||||
private String errorDetail;
|
||||
private List<String> warnings = new ArrayList<>();
|
||||
|
||||
|
||||
@@ -397,6 +398,26 @@ public class BackupRunResult
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for errorDetail (sanitized class, message, cause chain and root frames)
|
||||
*******************************************************************************/
|
||||
public String getErrorDetail()
|
||||
{
|
||||
return (errorDetail);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for errorDetail
|
||||
*******************************************************************************/
|
||||
public void setErrorDetail(String errorDetail)
|
||||
{
|
||||
this.errorDetail = errorDetail;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for warnings
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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.run;
|
||||
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Safe text for a failure: what is persisted on a run row (error_message and
|
||||
** error_detail) and shown in job output.
|
||||
**
|
||||
** Exception messages from storage providers, drivers and readers can carry
|
||||
** credentials (URL user-info, presigned query strings, key=value secrets,
|
||||
** Authorization headers, AWS access key ids, PEM blocks) and row values (the
|
||||
** Postgres "Key (col)=(value)" and "Detail:" fragments). sanitize() redacts
|
||||
** those and keeps the rest (class names, object keys, bucket names), which is
|
||||
** what an operator needs to act on a failure without reading the server log.
|
||||
*******************************************************************************/
|
||||
public final class FailureText
|
||||
{
|
||||
/** Redaction marker. */
|
||||
public static final String REDACTED = "[REDACTED]";
|
||||
|
||||
/** The longest error_detail written. */
|
||||
public static final int MAX_DETAIL_CHARS = 8192;
|
||||
|
||||
/** The longest exception message kept in error_message. */
|
||||
public static final int MAX_SUMMARY_MESSAGE_CHARS = 500;
|
||||
|
||||
/** Stack frames of the root cause kept in error_detail. */
|
||||
public static final int ROOT_CAUSE_FRAMES = 8;
|
||||
|
||||
private static final int MAX_CAUSES = 20;
|
||||
|
||||
private static final String SECRET_NAME = "[\\w.-]*(?:password|passwd|pwd|secret|token|credential|signature|api[_-]?key|access[_-]?key|private[_-]?key)[\\w.-]*";
|
||||
|
||||
private static final Pattern PEM_BLOCK = Pattern.compile("-----BEGIN [A-Z0-9 ]+-----.*?(?:-----END [A-Z0-9 ]+-----|$)", Pattern.DOTALL);
|
||||
private static final Pattern URL_USERINFO = Pattern.compile("(?i)\\b([a-z][a-z0-9+.-]*://)[^/\\s@?#]+@");
|
||||
private static final Pattern QUERY_PARAM = Pattern.compile("(?i)([?&;](?:X-Amz-[\\w-]+|Signature|Credential|AWSAccessKeyId|Policy|sig|key|" + SECRET_NAME + ")=)[^&\\s\"'<>]*");
|
||||
private static final Pattern PG_KEY_VALUE = Pattern.compile("(?i)\\b(Key \\([^)]*\\)=\\().*?(\\))(?=\\s|$|[.,;:])");
|
||||
private static final Pattern PG_DETAIL = Pattern.compile("(?i)\\b(Detail:|Failing row contains)[^\\r\\n]*");
|
||||
private static final Pattern AUTHORIZATION = Pattern.compile("(?i)\\b((?:Proxy-)?Authorization\"?\\s*[:=]\\s*)(\"[^\"]*\"|[^\\r\\n,;]+)");
|
||||
private static final Pattern BEARER = Pattern.compile("(?i)\\b(Bearer|Basic)\\s+[A-Za-z0-9._~+/=-]{6,}");
|
||||
private static final Pattern SECRET_JSON = Pattern.compile("(?i)(\"" + SECRET_NAME + "\"\\s*:\\s*)\"[^\"]*\"");
|
||||
private static final Pattern SECRET_ASSIGN = Pattern.compile("(?i)(?<![\\w.-])((?:" + SECRET_NAME + "|key)\\s*=\\s*)(?!\\[REDACTED)(\"[^\"]*\"|'[^']*'|[^\\s&,;)\\]}]+)");
|
||||
private static final Pattern AWS_KEY_ID = Pattern.compile("\\b(?:AKIA|ASIA|AGPA|AIDA|AROA|ANPA|ANVA|AIPA)[A-Z0-9]{16}\\b");
|
||||
|
||||
private static final String R = Matcher.quoteReplacement(REDACTED);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private FailureText()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Redact secrets and row values from a message. Null stays null.
|
||||
*******************************************************************************/
|
||||
public static String sanitize(String text)
|
||||
{
|
||||
if(text == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
String s = PEM_BLOCK.matcher(text).replaceAll(Matcher.quoteReplacement("[REDACTED PEM]"));
|
||||
s = URL_USERINFO.matcher(s).replaceAll("$1" + R + "@");
|
||||
s = QUERY_PARAM.matcher(s).replaceAll("$1" + R);
|
||||
s = PG_KEY_VALUE.matcher(s).replaceAll("$1" + R + "$2");
|
||||
s = PG_DETAIL.matcher(s).replaceAll("$1 " + R);
|
||||
s = AUTHORIZATION.matcher(s).replaceAll("$1" + R);
|
||||
s = BEARER.matcher(s).replaceAll("$1 " + R);
|
||||
s = SECRET_JSON.matcher(s).replaceAll("$1\"" + R + "\"");
|
||||
s = SECRET_ASSIGN.matcher(s).replaceAll("$1" + R);
|
||||
s = AWS_KEY_ID.matcher(s).replaceAll(R);
|
||||
return (s);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The short error_message: "Backup failed: SimpleClass: sanitized message".
|
||||
*******************************************************************************/
|
||||
public static String summary(Throwable t)
|
||||
{
|
||||
return (summary("Backup failed", t));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A short message: "prefix: SimpleClass: sanitized message" (bounded).
|
||||
*******************************************************************************/
|
||||
public static String summary(String prefix, Throwable t)
|
||||
{
|
||||
String message = sanitize(t.getMessage());
|
||||
if(message == null || message.isBlank())
|
||||
{
|
||||
return (prefix + ": " + t.getClass().getSimpleName());
|
||||
}
|
||||
message = message.strip();
|
||||
if(message.length() > MAX_SUMMARY_MESSAGE_CHARS)
|
||||
{
|
||||
message = message.substring(0, MAX_SUMMARY_MESSAGE_CHARS) + "...";
|
||||
}
|
||||
return (prefix + ": " + t.getClass().getSimpleName() + ": " + message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The error_detail: the exception class and sanitized message, each cause
|
||||
** ("Caused by: ..."), then the top stack frames of the root cause, bounded to
|
||||
** MAX_DETAIL_CHARS. Frames are class, method, file and line only (no values).
|
||||
*******************************************************************************/
|
||||
public static String detail(Throwable t)
|
||||
{
|
||||
StringBuilder out = new StringBuilder(line(t));
|
||||
Set<Throwable> seen = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
seen.add(t);
|
||||
Throwable root = t;
|
||||
Throwable cause = t.getCause();
|
||||
while(cause != null && seen.size() < MAX_CAUSES && seen.add(cause))
|
||||
{
|
||||
out.append("\nCaused by: ").append(line(cause));
|
||||
root = cause;
|
||||
cause = cause.getCause();
|
||||
}
|
||||
|
||||
List<StackTraceElement> frames = List.of(root.getStackTrace());
|
||||
int shown = Math.min(ROOT_CAUSE_FRAMES, frames.size());
|
||||
if(shown > 0)
|
||||
{
|
||||
out.append("\nRoot cause stack (top ").append(shown).append(" of ").append(frames.size()).append(" frames):");
|
||||
for(StackTraceElement frame : frames.subList(0, shown))
|
||||
{
|
||||
out.append("\n\tat ").append(frame);
|
||||
}
|
||||
}
|
||||
|
||||
if(out.length() > MAX_DETAIL_CHARS)
|
||||
{
|
||||
String marker = "\n... (truncated)";
|
||||
out.setLength(MAX_DETAIL_CHARS - marker.length());
|
||||
out.append(marker);
|
||||
}
|
||||
return (out.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "fully.qualified.Class: sanitized message".
|
||||
*******************************************************************************/
|
||||
private static String line(Throwable t)
|
||||
{
|
||||
String message = sanitize(t.getMessage());
|
||||
return (message == null ? t.getClass().getName() : t.getClass().getName() + ": " + message);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,9 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
*******************************************************************************/
|
||||
public class ScopeResolver
|
||||
{
|
||||
/** Exclusion reason of a table whose backend has no query interface (storage-only): it cannot be read, even when selected. */
|
||||
public static final String NOT_QUERYABLE = "NOT_QUERYABLE";
|
||||
|
||||
private final QInstance qInstance;
|
||||
|
||||
|
||||
@@ -205,6 +208,10 @@ public class ScopeResolver
|
||||
{
|
||||
excluded.add(new ExcludedTable(name, "BACKUPS"));
|
||||
}
|
||||
else if(!TableRoles.isQueryable(qInstance, table))
|
||||
{
|
||||
excluded.add(new ExcludedTable(name, NOT_QUERYABLE));
|
||||
}
|
||||
else if((role == TableRole.EPHEMERAL || role == TableRole.DERIVED || role == TableRole.EXTERNAL) && !explicit.contains(name) && !roles.contains(role))
|
||||
{
|
||||
excluded.add(new ExcludedTable(name, role.name()));
|
||||
|
||||
@@ -35,6 +35,6 @@ public enum TableRole
|
||||
SECRET,
|
||||
/** The tables of this qbit: the catalog is rebuilt from manifests. */
|
||||
BACKUPS,
|
||||
/** Tables backed by another system (API). Excluded by default; selectable. */
|
||||
/** Tables backed by another system (API), or by a backend that cannot be queried (storage-only: never read, even when selected). Excluded by default. */
|
||||
EXTERNAL
|
||||
}
|
||||
@@ -17,12 +17,20 @@
|
||||
package com.kingsrook.qbits.backups.scope;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import com.kingsrook.qbits.backups.BackupsQBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QModuleDispatchException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleDispatcher;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleInterface;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -31,6 +39,14 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
*******************************************************************************/
|
||||
public final class TableRoles
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(TableRoles.class);
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// whether a backend type's module has a query interface: the answer depends //
|
||||
// only on the module class, so it is cached per backend type (roleOf is hot) //
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
private static final Map<String, Boolean> QUERYABLE_BY_BACKEND_TYPE = new ConcurrentHashMap<>();
|
||||
|
||||
private static final Set<String> AUDIT_TABLES = Set.of("audit", "auditTable", "auditUser", "auditDetail");
|
||||
|
||||
private static final String[] SECRET_NAME_HINTS = { "refreshToken", "RefreshToken", "oauthToken", "OAuthToken", "apiKey" };
|
||||
@@ -76,6 +92,10 @@ public final class TableRoles
|
||||
{
|
||||
return (TableRole.SECRET);
|
||||
}
|
||||
if(qInstance != null && !isQueryable(qInstance, table))
|
||||
{
|
||||
return (TableRole.EXTERNAL);
|
||||
}
|
||||
if(AUDIT_TABLES.contains(name))
|
||||
{
|
||||
return (TableRole.AUDIT);
|
||||
@@ -108,6 +128,56 @@ public final class TableRoles
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the rows of a table can be read: its backend module must have a query
|
||||
** interface. A storage-only module (for example qbit-edi's preview download
|
||||
** backend) throws from getQueryInterface(), and reading its table would fail the
|
||||
** whole run, so such a table is never backed up. A backend whose module cannot
|
||||
** be resolved cannot be read either (not cached: the module may register later).
|
||||
*******************************************************************************/
|
||||
public static boolean isQueryable(QInstance qInstance, QTableMetaData table)
|
||||
{
|
||||
if(qInstance == null || table == null || table.getBackendName() == null)
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
QBackendMetaData backend = qInstance.getBackend(table.getBackendName());
|
||||
if(backend == null || backend.getBackendType() == null)
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
Boolean cached = QUERYABLE_BY_BACKEND_TYPE.get(backend.getBackendType());
|
||||
if(cached != null)
|
||||
{
|
||||
return (cached);
|
||||
}
|
||||
|
||||
QBackendModuleInterface module;
|
||||
try
|
||||
{
|
||||
module = new QBackendModuleDispatcher().getQBackendModule(backend);
|
||||
}
|
||||
catch(QModuleDispatchException e)
|
||||
{
|
||||
LOG.debug("No backend module for a table, so it cannot be backed up", logPair("table", table.getName()), logPair("backendType", backend.getBackendType()));
|
||||
return (false);
|
||||
}
|
||||
|
||||
boolean queryable;
|
||||
try
|
||||
{
|
||||
queryable = module.getQueryInterface() != null;
|
||||
}
|
||||
catch(IllegalStateException | UnsupportedOperationException e)
|
||||
{
|
||||
queryable = false;
|
||||
}
|
||||
QUERYABLE_BY_BACKEND_TYPE.put(backend.getBackendType(), queryable);
|
||||
return (queryable);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A table that is hidden and has a password or sensitive field (other than the
|
||||
** keys and dates) is treated as a table of secrets.
|
||||
|
||||
@@ -29,6 +29,8 @@ import com.kingsrook.qbits.backups.manifest.BackupManifest;
|
||||
import com.kingsrook.qbits.backups.model.BackupsTables;
|
||||
import com.kingsrook.qbits.backups.scope.PlanSettings;
|
||||
import com.kingsrook.qbits.backups.scope.ScopeResolver;
|
||||
import com.kingsrook.qbits.backups.scope.TableRole;
|
||||
import com.kingsrook.qbits.backups.scope.TableRoles;
|
||||
import com.kingsrook.qqq.backend.core.actions.async.AsyncJobCallback;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.jobs.plans.JobChunkScope;
|
||||
@@ -37,6 +39,11 @@ import com.kingsrook.qqq.backend.core.jobs.progress.JobLogLevel;
|
||||
import com.kingsrook.qqq.backend.core.jobs.progress.JobPhase;
|
||||
import com.kingsrook.qqq.backend.core.jobs.progress.JobProgress;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -230,6 +237,38 @@ class BackupEngineTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A table on a backend with no query interface (a storage-only module, like
|
||||
** qbit-edi's edi_preview_download) is left out of the scope, even when it is
|
||||
** selected by name, and the run succeeds. Before the fix the reader queried it
|
||||
** and the run FAILED with "Query is not implemented in this module".
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testUnqueryableTableIsExcluded() throws Exception
|
||||
{
|
||||
QInstance qInstance = QContext.getQInstance();
|
||||
qInstance.addBackend(new QBackendMetaData().withName("storageOnly").withBackendType(StorageOnlyBackendModule.class));
|
||||
qInstance.addTable(new QTableMetaData().withName("previewDownload").withBackendName("storageOnly").withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER)));
|
||||
|
||||
BackupRunResult result = run(plan(Map.of()));
|
||||
assertThat(result.getErrorMessage()).isNull();
|
||||
assertThat(result.getStatus()).isEqualTo("SUCCEEDED_WITH_WARNINGS");
|
||||
BackupManifest manifest = BackupManifest.read(BackupsTestUtils.BACKUPS.getBytes(result.getManifestKey()));
|
||||
assertThat(manifest.table("previewDownload")).isNull();
|
||||
assertThat(manifest.table("blogPost")).isNotNull();
|
||||
|
||||
assertThat(TableRoles.roleOf(qInstance, qInstance.getTable("previewDownload"))).isEqualTo(TableRole.EXTERNAL);
|
||||
assertThat(TableRoles.isQueryable(qInstance, qInstance.getTable("blogPost"))).isTrue();
|
||||
|
||||
ScopeResolver.ResolvedScope scope = new ScopeResolver(qInstance).resolve(PlanSettings.adHoc().with("scopeType", "TABLES").with("includeRoles", "DATA,EXTERNAL"),
|
||||
List.of(new ScopeResolver.ScopeRule(true, "TABLE", "previewDownload", null, false, null), new ScopeResolver.ScopeRule(true, "ROLE", "EXTERNAL", null, false, null)));
|
||||
assertThat(scope.names()).doesNotContain("previewDownload");
|
||||
assertThat(scope.excluded()).contains(new ScopeResolver.ExcludedTable("previewDownload", ScopeResolver.NOT_QUERYABLE));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Required snapshots fail before anything is written (no backend can open one yet).
|
||||
*******************************************************************************/
|
||||
@@ -352,13 +391,14 @@ class BackupEngineTest
|
||||
BackupRunRequest request = BackupRunFactory.forPlan(plan(Map.of()), "MANUAL").withProgress(progress)
|
||||
.withReader((table, filter, start, size, transaction, sink) ->
|
||||
{
|
||||
throw (new com.kingsrook.qqq.backend.core.exceptions.QException("https://storage-user:storage-password@host/?secret=storage-secret accessKey=storage-access row=private-row"));
|
||||
throw (new com.kingsrook.qqq.backend.core.exceptions.QException("https://storage-user:storage-password@host/?secret=storage-secret accessKey=storage-access Detail: Key (email)=(private-row) already exists."));
|
||||
});
|
||||
BackupRunResult result = new BackupEngine().execute(request);
|
||||
assertThat(result.getStatus()).isEqualTo("FAILED");
|
||||
assertThat(progress.logs).contains("ERROR:" + result.getErrorMessage());
|
||||
assertThat(progress.getSnapshot().getPhases()).anyMatch(p -> p.getStatus() == JobPhase.Status.FAILED);
|
||||
assertThat(String.join("\n", progress.logs) + result.getErrorMessage()).doesNotContain("storage-user", "storage-password", "storage-secret", "storage-access", "private-row", "https://");
|
||||
assertThat(result.getErrorMessage()).startsWith("Backup failed: QException: https://[REDACTED]@host/");
|
||||
assertThat(String.join("\n", progress.logs) + result.getErrorMessage() + result.getErrorDetail()).doesNotContain("storage-user", "storage-password", "storage-secret", "storage-access", "private-row");
|
||||
}
|
||||
|
||||
|
||||
@@ -408,7 +448,52 @@ class BackupEngineTest
|
||||
BackupRunResult result = new BackupEngine().execute(BackupRunFactory.forPlan(plan(Map.of()), "MANUAL").withProgress(progress).withStorage(storage));
|
||||
assertThat(result.getStatus()).isEqualTo("FAILED");
|
||||
assertThat(progress.logs).contains("ERROR:" + result.getErrorMessage());
|
||||
assertThat(String.join("\n", progress.logs) + result.getErrorMessage()).doesNotContain("endpoint-user", "endpoint-password", "access-secret", "secret-secret", "https://");
|
||||
assertThat(String.join("\n", progress.logs) + result.getErrorMessage() + result.getErrorDetail()).doesNotContain("endpoint-user", "endpoint-password", "access-secret", "secret-secret");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A failed put records what an operator needs on the run row, with no secrets:
|
||||
** error_message is "Backup failed: Class: message" and error_detail has the
|
||||
** class, the cause chain and the root cause frames; the presigned signature,
|
||||
** the access key id and the URL user-info are redacted, the object key is kept.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testFailedPutRecordsSanitizedMessageAndDetail() throws Exception
|
||||
{
|
||||
var storage = new com.kingsrook.qbits.backups.storage.InMemoryBackupStorage()
|
||||
{
|
||||
/*******************************************************************************
|
||||
** A MinIO-style failure with a presigned URL in the message.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public java.io.OutputStream openWrite(String key, com.kingsrook.qbits.backups.storage.WriteOptions options)
|
||||
{
|
||||
throw (new com.kingsrook.qbits.backups.storage.BackupStorageException(com.kingsrook.qbits.backups.storage.BackupStorageException.Reason.ACCESS_DENIED,
|
||||
"AccessDenied on PUT https://minio-user:minio-pass@minio.galaxy.lan/kof22-backups/" + key
|
||||
+ "?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20261016%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Signature=deadbeefcafe0123",
|
||||
new IOException("XMinioStorageFull: Storage backend has reached its minimum free drive threshold. Authorization: AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/x, Signature=feedface")));
|
||||
}
|
||||
};
|
||||
BackupRunResult result = new BackupEngine().execute(BackupRunFactory.forPlan(plan(Map.of()), "MANUAL").withStorage(storage));
|
||||
assertThat(result.getStatus()).isEqualTo("FAILED");
|
||||
|
||||
QRecord run = new BackupCatalog().get(BackupsTables.RUN, result.getRunId()).orElseThrow();
|
||||
String message = run.getValueString("errorMessage");
|
||||
String detail = run.getValueString("errorDetail");
|
||||
assertThat(message).isEqualTo(result.getErrorMessage());
|
||||
assertThat(detail).isEqualTo(result.getErrorDetail());
|
||||
|
||||
assertThat(message).startsWith("Backup failed: BackupStorageException: AccessDenied on PUT https://[REDACTED]@minio.galaxy.lan/kof22-backups/qqq-backups/testapp/nightly/")
|
||||
.doesNotContain("see server logs");
|
||||
assertThat(detail).startsWith("com.kingsrook.qbits.backups.storage.BackupStorageException: AccessDenied on PUT")
|
||||
.contains("\nCaused by: java.io.IOException: XMinioStorageFull: Storage backend has reached its minimum free drive threshold.")
|
||||
.contains("X-Amz-Signature=[REDACTED]")
|
||||
.contains("Root cause stack (top ")
|
||||
.contains("\tat ");
|
||||
assertThat(detail.length()).isLessThanOrEqualTo(FailureText.MAX_DETAIL_CHARS);
|
||||
assertThat(message + detail).doesNotContain("deadbeefcafe0123", "AKIAIOSFODNN7EXAMPLE", "minio-user", "minio-pass", "feedface");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.run;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.sql.SQLException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unit tests of the failure text sanitizer, summary and detail.
|
||||
*******************************************************************************/
|
||||
class FailureTextTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Null and plain text pass through; object keys and bucket names are kept.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testPlainTextIsKept()
|
||||
{
|
||||
assertThat(FailureText.sanitize(null)).isNull();
|
||||
String plain = "Query is not implemented in this module: PreviewDownloadBackend (bucket kof22-backups, key qqq-backups/kof22/nightly/2026/10/16/run/tables/a/part-00001.jsonl.gz)";
|
||||
assertThat(FailureText.sanitize(plain)).isEqualTo(plain);
|
||||
assertThat(FailureText.sanitize("objectKey=qqq-backups/x/y.gz bucket=kof22-backups")).isEqualTo("objectKey=qqq-backups/x/y.gz bucket=kof22-backups");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** URL user-info and presigned query parameters are redacted; host and path kept.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testUrls()
|
||||
{
|
||||
assertThat(FailureText.sanitize("PUT https://user:pass@minio.lan/bucket/a/b.gz?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2Fx&X-Amz-Signature=abc123&partNumber=2 failed"))
|
||||
.isEqualTo("PUT https://[REDACTED]@minio.lan/bucket/a/b.gz?X-Amz-Algorithm=[REDACTED]&X-Amz-Credential=[REDACTED]&X-Amz-Signature=[REDACTED]&partNumber=2 failed");
|
||||
assertThat(FailureText.sanitize("jdbc:postgresql://app:hunter2@db:5432/app")).isEqualTo("jdbc:postgresql://[REDACTED]@db:5432/app");
|
||||
assertThat(FailureText.sanitize("https://h/p?Signature=s1&Expires=1&AWSAccessKeyId=k&access_token=t&key=v"))
|
||||
.isEqualTo("https://h/p?Signature=[REDACTED]&Expires=1&AWSAccessKeyId=[REDACTED]&access_token=[REDACTED]&key=[REDACTED]");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** key=value and JSON secrets, Authorization and Bearer values.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testAssignmentsAndHeaders()
|
||||
{
|
||||
assertThat(FailureText.sanitize("password=p1 secret=\"s 2\" token='t3' key=k4 accessKey=a5 secretKey=s6 apiKey=x7 db.password = p8, other=ok"))
|
||||
.isEqualTo("password=[REDACTED] secret=[REDACTED] token=[REDACTED] key=[REDACTED] accessKey=[REDACTED] secretKey=[REDACTED] apiKey=[REDACTED] db.password = [REDACTED], other=ok");
|
||||
assertThat(FailureText.sanitize("{\"clientSecret\": \"abc\", \"name\": \"keep\"}")).isEqualTo("{\"clientSecret\": \"[REDACTED]\", \"name\": \"keep\"}");
|
||||
assertThat(FailureText.sanitize("Authorization: Bearer eyJhbGciOi.payload.sig\nnext line")).isEqualTo("Authorization: [REDACTED]\nnext line");
|
||||
assertThat(FailureText.sanitize("sent Bearer abcdefghijk to the API")).isEqualTo("sent Bearer [REDACTED] to the API");
|
||||
assertThat(FailureText.sanitize("id AKIAIOSFODNN7EXAMPLE and ASIAIOSFODNN7EXAMPLE")).isEqualTo("id [REDACTED] and [REDACTED]");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** PEM blocks (terminated or not) and Postgres row values.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testPemAndRowValues()
|
||||
{
|
||||
assertThat(FailureText.sanitize("bad key -----BEGIN PRIVATE KEY-----\nMIIabc\n-----END PRIVATE KEY----- here")).isEqualTo("bad key [REDACTED PEM] here");
|
||||
assertThat(FailureText.sanitize("cut -----BEGIN RSA PRIVATE KEY-----\nMIIabc")).isEqualTo("cut [REDACTED PEM]");
|
||||
assertThat(FailureText.sanitize("duplicate key value violates unique constraint \"u_email\" Key (email)=(ada@example.com) already exists."))
|
||||
.isEqualTo("duplicate key value violates unique constraint \"u_email\" Key (email)=([REDACTED]) already exists.");
|
||||
assertThat(FailureText.sanitize("ERROR: null value in column \"x\"\n Detail: Failing row contains (1, ada, secret-row).\n Where: x"))
|
||||
.isEqualTo("ERROR: null value in column \"x\"\n Detail: [REDACTED]\n Where: x");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The summary is short, sanitized and bounded; no message falls back to the class.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSummary()
|
||||
{
|
||||
assertThat(FailureText.summary(new IllegalStateException("Query is not implemented in this module: PreviewDownloadBackend")))
|
||||
.isEqualTo("Backup failed: IllegalStateException: Query is not implemented in this module: PreviewDownloadBackend");
|
||||
assertThat(FailureText.summary(new NullPointerException())).isEqualTo("Backup failed: NullPointerException");
|
||||
assertThat(FailureText.summary("Could not create the run record", new IOException(" password=x "))).isEqualTo("Could not create the run record: IOException: password=[REDACTED]");
|
||||
assertThat(FailureText.summary(new IOException("x".repeat(2000)))).hasSize("Backup failed: IOException: ".length() + FailureText.MAX_SUMMARY_MESSAGE_CHARS + 3);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The detail has the class, the cause chain and the root cause frames, bounded.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testDetail()
|
||||
{
|
||||
SQLException root = new SQLException("Key (id)=(4242xyz) already exists.");
|
||||
IOException middle = new IOException("write failed password=pwsecret", root);
|
||||
RuntimeException top = new RuntimeException("Backup of t failed", middle);
|
||||
String detail = FailureText.detail(top);
|
||||
assertThat(detail).startsWith("java.lang.RuntimeException: Backup of t failed\nCaused by: java.io.IOException: write failed password=[REDACTED]\nCaused by: java.sql.SQLException: Key (id)=([REDACTED]) already exists.\nRoot cause stack (top ");
|
||||
assertThat(detail).contains("\tat com.kingsrook.qbits.backups.run.FailureTextTest.testDetail(FailureTextTest.java:");
|
||||
assertThat(detail.split("\n\tat ")).hasSize(FailureText.ROOT_CAUSE_FRAMES + 1);
|
||||
assertThat(detail).doesNotContain("4242xyz", "pwsecret");
|
||||
|
||||
IOException noFrames = new IOException((String) null);
|
||||
noFrames.setStackTrace(new StackTraceElement[0]);
|
||||
assertThat(FailureText.detail(noFrames)).isEqualTo("java.io.IOException");
|
||||
|
||||
String huge = FailureText.detail(new IOException("y".repeat(20_000)));
|
||||
assertThat(huge).hasSize(FailureText.MAX_DETAIL_CHARS).endsWith("... (truncated)");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A cause cycle does not loop.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testCauseCycle()
|
||||
{
|
||||
CyclicException a = new CyclicException("a");
|
||||
CyclicException b = new CyclicException("b");
|
||||
a.setCause(b);
|
||||
b.setCause(a);
|
||||
assertThat(FailureText.detail(a)).startsWith("com.kingsrook.qbits.backups.run.FailureTextTest$CyclicException: a\nCaused by: com.kingsrook.qbits.backups.run.FailureTextTest$CyclicException: b\nRoot cause stack");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** An exception whose cause can be set to form a cycle.
|
||||
***************************************************************************/
|
||||
private static class CyclicException extends Exception
|
||||
{
|
||||
private Throwable cause;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
CyclicException(String message)
|
||||
{
|
||||
super(message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set the cause.
|
||||
*******************************************************************************/
|
||||
void setCause(Throwable cause)
|
||||
{
|
||||
this.cause = cause;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The cause.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized Throwable getCause()
|
||||
{
|
||||
return (cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.backups.run;
|
||||
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import com.kingsrook.qqq.backend.core.actions.interfaces.QStorageInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.storage.StorageInput;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleDispatcher;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleInterface;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A storage-only backend module (like qbit-edi's PreviewDownloadBackend): it
|
||||
** streams objects but has no query interface, so a table on it cannot be read.
|
||||
*******************************************************************************/
|
||||
public class StorageOnlyBackendModule implements QBackendModuleInterface, QStorageInterface
|
||||
{
|
||||
public static final String TYPE = "test-storage-only";
|
||||
|
||||
static
|
||||
{
|
||||
QBackendModuleDispatcher.registerBackendModule(new StorageOnlyBackendModule());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The backend type.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getBackendType()
|
||||
{
|
||||
return (TYPE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Storage only.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QStorageInterface getStorageInterface()
|
||||
{
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Nothing is stored.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public InputStream getInputStream(StorageInput input)
|
||||
{
|
||||
return (new ByteArrayInputStream(new byte[0]));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Writes are discarded.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public OutputStream createOutputStream(StorageInput input)
|
||||
{
|
||||
return (new ByteArrayOutputStream());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user