fix: surface pgdata backup read-only blockers

This commit is contained in:
Codex
2026-05-20 20:03:43 +00:00
parent c6a27e6c2b
commit d766875a39
6 changed files with 173 additions and 4 deletions
+127 -1
View File
@@ -1,4 +1,5 @@
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { runCommand } from "./command";
import { type UniDeskConfig, repoRoot } from "./config";
import { jsonByteLength, previewJson } from "./preview";
@@ -27,11 +28,136 @@ function dockerCoreFetchCommand(path: string, init?: { method?: string; body?: u
return ["docker", "exec", "unidesk-backend-core", "sh", "-lc", script];
}
interface RelatedContainer {
name: string;
image: string;
status: string;
}
function unquoteEnvValue(value: string): string {
return value.replace(/^['"]|['"]$/g, "");
}
function baiduSecretPresence(envPath: string): Record<string, unknown> {
const keys = [
"UNIDESK_BAIDU_NETDISK_CLIENT_ID",
"UNIDESK_BAIDU_NETDISK_CLIENT_SECRET",
"UNIDESK_BAIDU_NETDISK_TOKEN_KEY",
];
if (!existsSync(envPath)) {
return { envPath, exists: false, keys: Object.fromEntries(keys.map((key) => [key, { present: false, nonEmpty: false }])) };
}
const env = new Map<string, string>();
for (const line of readFileSync(envPath, "utf8").split(/\r?\n/u)) {
if (line.length === 0 || line.trimStart().startsWith("#")) continue;
const index = line.indexOf("=");
if (index === -1) continue;
env.set(line.slice(0, index), unquoteEnvValue(line.slice(index + 1)));
}
return {
envPath,
exists: true,
keys: Object.fromEntries(keys.map((key) => [key, { present: env.has(key), nonEmpty: Boolean((env.get(key) || "").trim()) }])),
};
}
function listRelatedStackContainers(): RelatedContainer[] {
const result = runCommand(["docker", "ps", "-a", "--format", "{{json .}}"], repoRoot);
if (result.exitCode !== 0 || result.stdout.trim().length === 0) return [];
const names = ["unidesk-backend-core", "unidesk-database", "baidu-netdisk-backend"];
return result.stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line) as Record<string, string>;
} catch {
return null;
}
})
.filter((row): row is Record<string, string> => row !== null)
.filter((row) => names.some((name) => row.Names === name || String(row.Names || "").startsWith(`${name}.`)))
.map((row) => ({ name: row.Names ?? "", image: row.Image ?? "", status: row.Status ?? "" }));
}
export function backendCoreUnavailableDiagnostic(detail: {
exitCode: number | null;
stdoutTail: string;
stderrTail: string;
relatedContainers: RelatedContainer[];
envPath: string;
baiduSecretPresence: Record<string, unknown>;
}): Record<string, unknown> {
const expectedContainers = ["unidesk-backend-core", "unidesk-database", "baidu-netdisk-backend"];
const presentExactNames = new Set(detail.relatedContainers.map((container) => container.name));
const missingContainers = expectedContainers.filter((name) => !presentExactNames.has(name));
const verifyOnlyObserved = detail.relatedContainers.some((container) => /\.verify-/u.test(container.name));
return {
ok: false,
failureKind: "target-stack-not-running",
degradedReason: "backend-core-container-missing",
runnerDisposition: "infra-blocked",
readOnlyReview: true,
message: verifyOnlyObserved
? "backend-core/database target containers are not running; only verify-only containers were observed."
: "backend-core target container is not running, so private backend-core API checks cannot observe schedules or microservices.",
targetStack: {
expectedContainers,
missingContainers,
relatedContainers: detail.relatedContainers,
verifyOnlyObserved,
},
observed: {
commandExitCode: detail.exitCode,
stdoutTail: detail.stdoutTail,
stderrTail: detail.stderrTail,
},
baiduNetdiskConfigPresence: detail.baiduSecretPresence,
readOnlyCommands: [
"bun scripts/cli.ts server status",
"bun scripts/cli.ts schedule list",
"bun scripts/cli.ts schedule runs --limit 20",
"bun scripts/cli.ts microservice health baidu-netdisk",
"bun scripts/cli.ts microservice proxy baidu-netdisk /api/auth/status --raw",
"bun scripts/cli.ts microservice proxy baidu-netdisk '/api/transfers?limit=20' --raw",
],
authorizationRequiredForRecovery: [
"restore non-empty Baidu Netdisk runtime secrets in the controlled Compose env source without printing secret values",
"start or deploy the production target stack containing backend-core, database, and baidu-netdisk",
"after health is green, explicitly authorize schedule retry-run or schedule run before triggering a real PGDATA backup",
],
blockedWithoutAuthorization: [
"server start",
"server rebuild backend-core",
"server rebuild baidu-netdisk",
"deploy apply --env prod --service baidu-netdisk",
"schedule run <id>",
"schedule retry-run <failedRunId>",
],
};
}
function backendCoreContainerMissing(stderr: string): boolean {
return stderr.includes("No such container: unidesk-backend-core");
}
export function coreInternalFetch(path: string, init?: { method?: string; body?: unknown; maxResponseBytes?: number }): unknown {
if (!path.startsWith("/")) throw new Error("core internal path must start with /");
const command = dockerCoreFetchCommand(path, init);
const result = runCommand(command, repoRoot);
if (result.exitCode !== 0) {
if (backendCoreContainerMissing(result.stderr)) {
const envPath = join(repoRoot, ".state", "docker-compose.env");
return backendCoreUnavailableDiagnostic({
exitCode: result.exitCode,
stdoutTail: result.stdout.slice(-1200),
stderrTail: result.stderr.slice(-1200),
relatedContainers: listRelatedStackContainers(),
envPath,
baiduSecretPresence: baiduSecretPresence(envPath),
});
}
const parsedStdout = parseJsonRecord(result.stdout.trim());
if (parsedStdout !== null) {
return {