fix: add backend-core publish preflight
This commit is contained in:
@@ -56,8 +56,17 @@ export interface ArtifactRegistryReadonlyProbe {
|
||||
timeoutMs: number;
|
||||
healthMode: boolean;
|
||||
options: ArtifactRegistryOptions;
|
||||
remoteCommandShape: string;
|
||||
}
|
||||
|
||||
export type ArtifactRegistryFailureClassification =
|
||||
| "local-docker-required"
|
||||
| "provider-ssh-command-missing"
|
||||
| "ssh-helper-command-shape-incompatible"
|
||||
| "remote-command-timeout"
|
||||
| "registry-not-installed"
|
||||
| "registry-unhealthy";
|
||||
|
||||
const defaultOptions: ArtifactRegistryOptions = {
|
||||
environment: null,
|
||||
providerId: "D601",
|
||||
@@ -1033,10 +1042,6 @@ container=${shellQuote(options.containerName)}
|
||||
expected_image=${shellQuote(options.image)}
|
||||
port=${options.port}
|
||||
kv readonly true
|
||||
kv unit_path "$unit"
|
||||
kv compose_path "$compose"
|
||||
kv config_path "$config"
|
||||
kv storage_path "$storage"
|
||||
kv unit_exists "$(bool_file "$unit")"
|
||||
kv compose_exists "$(bool_file "$compose")"
|
||||
kv config_exists "$(bool_file "$config")"
|
||||
@@ -1092,9 +1097,6 @@ unit_hash="$(hash_file "$unit")"
|
||||
kv config_hash "$config_hash"
|
||||
kv compose_hash "$compose_hash"
|
||||
kv unit_hash "$unit_hash"
|
||||
kv expected_config_hash ${shellQuote(hashes[bundle.paths.config] ?? "")}
|
||||
kv expected_compose_hash ${shellQuote(hashes[bundle.paths.compose] ?? "")}
|
||||
kv expected_unit_hash ${shellQuote(hashes[bundle.paths.unit] ?? "")}
|
||||
kv config_hash_matches "$([ -n "$config_hash" ] && [ "$config_hash" = ${shellQuote(hashes[bundle.paths.config] ?? "")} ] && printf true || printf false)"
|
||||
kv compose_hash_matches "$([ -n "$compose_hash" ] && [ "$compose_hash" = ${shellQuote(hashes[bundle.paths.compose] ?? "")} ] && printf true || printf false)"
|
||||
kv unit_hash_matches "$([ -n "$unit_hash" ] && [ "$unit_hash" = ${shellQuote(hashes[bundle.paths.unit] ?? "")} ] && printf true || printf false)"
|
||||
@@ -1115,6 +1117,56 @@ function asBool(value: string | undefined): boolean {
|
||||
return value === "true";
|
||||
}
|
||||
|
||||
function registryRecommendedAction(classification: ArtifactRegistryFailureClassification | null): string {
|
||||
if (classification === null) return "none";
|
||||
if (classification === "local-docker-required") return "run the read-only check through --main-server-ip <host> or from a main-server CLI with backend-core available";
|
||||
if (classification === "provider-ssh-command-missing") return "restore D601 provider-gateway host.ssh dispatch/capability before retrying artifact registry health";
|
||||
if (classification === "ssh-helper-command-shape-incompatible") return "upgrade the CLI/control-plane host.ssh helper shape so it can run bash -lc readonly probes";
|
||||
if (classification === "remote-command-timeout") return "rerun artifact-registry health and inspect D601 host.ssh latency if the timeout repeats";
|
||||
if (classification === "registry-not-installed") return "install the D601 artifact registry through the controlled artifact-registry install path, then rerun health";
|
||||
return "restore the D601 artifact registry service/container/API until registry health passes";
|
||||
}
|
||||
|
||||
function readonlyRemoteCommandShape(action: "status" | "health", options: ArtifactRegistryOptions): string {
|
||||
return `host.ssh provider=${options.providerId} mode=exec argv=bash -lc <artifact-registry-${action}-readonly-script> timeoutMs=${options.timeoutMs}`;
|
||||
}
|
||||
|
||||
function classifyProviderSshCommandFailure(command: CommandResult): ArtifactRegistryFailureClassification {
|
||||
const output = `${command.stderr}\n${command.stdout}`.toLowerCase();
|
||||
if (command.timedOut || output.includes("timed out") || output.includes("timeout")) return "remote-command-timeout";
|
||||
if (output.includes("no such container: unidesk-backend-core") || output.includes("no such container: unidesk-database") || output.includes("backend-core bridge unavailable")) {
|
||||
return "local-docker-required";
|
||||
}
|
||||
if (output.includes("provider does not declare host.ssh") || output.includes("provider is offline") || output.includes("host.ssh capability")) {
|
||||
return "provider-ssh-command-missing";
|
||||
}
|
||||
if (output.includes("remote frontend transport does not support") || output.includes("ssh helper") || output.includes("unsupported command") || output.includes("bad substitution") || output.includes("command is too long")) {
|
||||
return "ssh-helper-command-shape-incompatible";
|
||||
}
|
||||
if (command.exitCode === 126 || command.exitCode === 127 || output.includes("command not found") || output.includes("exec format error")) {
|
||||
return "ssh-helper-command-shape-incompatible";
|
||||
}
|
||||
return "provider-ssh-command-missing";
|
||||
}
|
||||
|
||||
function providerSshCommandFailureScopes(classification: ArtifactRegistryFailureClassification): string[] {
|
||||
const scopes = ["provider-ssh-command"];
|
||||
if (classification === "remote-command-timeout") scopes.push("remote-command-timeout");
|
||||
if (classification === "local-docker-required") scopes.push("local-docker-control-plane");
|
||||
if (classification === "ssh-helper-command-shape-incompatible") scopes.push("ssh-helper-command-shape");
|
||||
return scopes;
|
||||
}
|
||||
|
||||
function registryHealthFailureClassification(checks: Record<string, boolean>, failedScopes: string[]): ArtifactRegistryFailureClassification | null {
|
||||
if (failedScopes.length === 0) return null;
|
||||
const installed = checks.unitExists === true
|
||||
|| checks.composeExists === true
|
||||
|| checks.configExists === true
|
||||
|| checks.storageExists === true
|
||||
|| checks.containerRunning === true;
|
||||
return installed ? "registry-unhealthy" : "registry-not-installed";
|
||||
}
|
||||
|
||||
function registryHealthDecision(checks: Record<string, boolean>, commandOk: boolean): Record<string, unknown> {
|
||||
const scopeChecks: Array<[string, boolean]> = [
|
||||
["systemd", checks.systemctlAvailable === true && checks.unitExists === true && checks.unitActive === true],
|
||||
@@ -1129,11 +1181,14 @@ function registryHealthDecision(checks: Record<string, boolean>, commandOk: bool
|
||||
const healthyScopes = scopeChecks.filter(([, ok]) => ok).map(([scope]) => scope);
|
||||
const failedScopes = scopeChecks.filter(([, ok]) => !ok).map(([scope]) => scope);
|
||||
const runtimeApiHealthy = checks.containerRunning === true && checks.loopbackOnly === true && checks.v2Ok === true;
|
||||
const failureClassification = commandOk ? registryHealthFailureClassification(checks, failedScopes) : "provider-ssh-command-missing";
|
||||
return {
|
||||
decision: commandOk && failedScopes.length === 0 ? "healthy" : commandOk || runtimeApiHealthy ? "service-degraded" : "retryable-transient",
|
||||
retryable: true,
|
||||
failureClassification,
|
||||
healthyScopes,
|
||||
failedScopes,
|
||||
recommendedAction: registryRecommendedAction(failureClassification),
|
||||
runtimeApiHealthy,
|
||||
};
|
||||
}
|
||||
@@ -1273,8 +1328,40 @@ function runRemoteScript(options: ArtifactRegistryOptions, script: string, timeo
|
||||
return runCommand(command, repoRoot, { timeoutMs });
|
||||
}
|
||||
|
||||
function readonlyCommandFailureResult(
|
||||
options: ArtifactRegistryOptions,
|
||||
command: CommandResult,
|
||||
action: "status" | "health",
|
||||
): Record<string, unknown> {
|
||||
const bundle = renderBundle(options);
|
||||
const failureClassification = classifyProviderSshCommandFailure(command);
|
||||
return {
|
||||
ok: false,
|
||||
readonly: true,
|
||||
installed: false,
|
||||
healthy: false,
|
||||
decision: failureClassification === "local-docker-required" ? "infra-blocked" : "retryable-transient",
|
||||
retryable: failureClassification !== "ssh-helper-command-shape-incompatible",
|
||||
failureClassification,
|
||||
recommendedAction: registryRecommendedAction(failureClassification),
|
||||
remoteCommandShape: readonlyRemoteCommandShape(action, options),
|
||||
healthyScopes: [],
|
||||
failedScopes: providerSshCommandFailureScopes(failureClassification),
|
||||
runtimeApiHealthy: false,
|
||||
checks: {},
|
||||
expected: {
|
||||
endpoint: `http://${options.host}:${options.port}`,
|
||||
image: options.image,
|
||||
paths: bundle.paths,
|
||||
},
|
||||
command: artifactRegistryCommandTail(command),
|
||||
};
|
||||
}
|
||||
|
||||
function statusFromValues(options: ArtifactRegistryOptions, values: Record<string, string>, command: CommandResult, healthMode: boolean): Record<string, unknown> {
|
||||
const commandOk = command.exitCode === 0 && !command.timedOut;
|
||||
const bundle = renderBundle(options);
|
||||
const hashes = Object.fromEntries(bundle.files.map((item) => [item.path, item.sha256]));
|
||||
const checks = {
|
||||
systemctlAvailable: asBool(values.systemctl_available),
|
||||
dockerAvailable: asBool(values.docker_available),
|
||||
@@ -1316,12 +1403,13 @@ function statusFromValues(options: ArtifactRegistryOptions, values: Record<strin
|
||||
installed,
|
||||
healthy,
|
||||
...decision,
|
||||
remoteCommandShape: readonlyRemoteCommandShape(healthMode ? "health" : "status", options),
|
||||
checks,
|
||||
observed: {
|
||||
unit: { path: values.unit_path, active: values.unit_active, enabled: values.unit_enabled },
|
||||
compose: { path: values.compose_path, sha256: values.compose_hash },
|
||||
config: { path: values.config_path, sha256: values.config_hash },
|
||||
storage: { path: values.storage_path },
|
||||
unit: { path: bundle.paths.unit, active: values.unit_active, enabled: values.unit_enabled },
|
||||
compose: { path: bundle.paths.compose, sha256: values.compose_hash ?? null },
|
||||
config: { path: bundle.paths.config, sha256: values.config_hash ?? null },
|
||||
storage: { path: bundle.paths.storage },
|
||||
container: {
|
||||
name: options.containerName,
|
||||
running: values.container_running,
|
||||
@@ -1337,9 +1425,9 @@ function statusFromValues(options: ArtifactRegistryOptions, values: Record<strin
|
||||
registryApi: { v2HttpCode: values.v2_http_code },
|
||||
},
|
||||
expected: {
|
||||
unitHash: values.expected_unit_hash,
|
||||
composeHash: values.expected_compose_hash,
|
||||
configHash: values.expected_config_hash,
|
||||
unitHash: hashes[bundle.paths.unit] ?? "",
|
||||
composeHash: hashes[bundle.paths.compose] ?? "",
|
||||
configHash: hashes[bundle.paths.config] ?? "",
|
||||
image: options.image,
|
||||
endpoint: `http://${options.host}:${options.port}`,
|
||||
},
|
||||
@@ -1352,24 +1440,7 @@ function runReadonlyStatus(options: ArtifactRegistryOptions, healthMode: boolean
|
||||
const script = statusScript(options, bundle);
|
||||
const result = runRemoteScript(options, script);
|
||||
if (result.exitCode !== 0 || result.timedOut) {
|
||||
return {
|
||||
ok: false,
|
||||
readonly: true,
|
||||
installed: false,
|
||||
healthy: false,
|
||||
decision: "retryable-transient",
|
||||
retryable: true,
|
||||
healthyScopes: [],
|
||||
failedScopes: ["provider-ssh-command"],
|
||||
runtimeApiHealthy: false,
|
||||
checks: {},
|
||||
expected: {
|
||||
endpoint: `http://${options.host}:${options.port}`,
|
||||
image: options.image,
|
||||
paths: bundle.paths,
|
||||
},
|
||||
command: artifactRegistryCommandTail(result),
|
||||
};
|
||||
return readonlyCommandFailureResult(options, result, healthMode ? "health" : "status");
|
||||
}
|
||||
return statusFromValues(options, parseKeyValueOutput(result.stdout), result, healthMode);
|
||||
}
|
||||
@@ -1384,6 +1455,7 @@ export function buildArtifactRegistryReadonlyProbe(action: "status" | "health",
|
||||
timeoutMs: options.timeoutMs,
|
||||
healthMode,
|
||||
options,
|
||||
remoteCommandShape: readonlyRemoteCommandShape(action, options),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1392,25 +1464,7 @@ export function artifactRegistryReadonlyResultFromCommand(
|
||||
command: CommandResult,
|
||||
): Record<string, unknown> {
|
||||
if (command.exitCode !== 0 || command.timedOut) {
|
||||
const bundle = renderBundle(probe.options);
|
||||
return {
|
||||
ok: false,
|
||||
readonly: true,
|
||||
installed: false,
|
||||
healthy: false,
|
||||
decision: "retryable-transient",
|
||||
retryable: true,
|
||||
healthyScopes: [],
|
||||
failedScopes: ["provider-ssh-command"],
|
||||
runtimeApiHealthy: false,
|
||||
checks: {},
|
||||
expected: {
|
||||
endpoint: `http://${probe.options.host}:${probe.options.port}`,
|
||||
image: probe.options.image,
|
||||
paths: bundle.paths,
|
||||
},
|
||||
command: artifactRegistryCommandTail(command),
|
||||
};
|
||||
return readonlyCommandFailureResult(probe.options, command, probe.action);
|
||||
}
|
||||
return statusFromValues(probe.options, parseKeyValueOutput(command.stdout), command, probe.healthMode);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user