fix: remoteize backend-core publish preflight
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { readConfig } from "./src/config";
|
||||
import { runCiPublishBackendCoreDryRunPreflight, type PublishPreflightTransport } from "./src/ci";
|
||||
import { autoRemoteCiPublishUserServiceDryRunPlan } from "./src/remote";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -15,7 +16,125 @@ function asRecord(value: unknown, label: string): JsonRecord {
|
||||
const commit = "0123456789abcdef0123456789abcdef01234567";
|
||||
const config = readConfig();
|
||||
|
||||
const readyTransport: PublishPreflightTransport = {
|
||||
function healthyRegistryStdout(): string {
|
||||
return [
|
||||
"systemctl_available=true",
|
||||
"docker_available=true",
|
||||
"curl_available=true",
|
||||
"unit_exists=true",
|
||||
"unit_active=active",
|
||||
"unit_enabled=enabled",
|
||||
"compose_exists=true",
|
||||
"config_exists=true",
|
||||
"storage_exists=true",
|
||||
"container_running=true",
|
||||
"container_status=running",
|
||||
"container_image=registry:2.8.3",
|
||||
"container_restart_policy=always",
|
||||
"listener_count=1",
|
||||
"bad_listener_count=0",
|
||||
"loopback_only=true",
|
||||
"v2_http_code=200",
|
||||
"image_matches=true",
|
||||
"config_hash_matches=true",
|
||||
"compose_hash_matches=true",
|
||||
"unit_hash_matches=true",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function readyTransport(kind: "remote-frontend" | "local-docker" = "remote-frontend"): PublishPreflightTransport {
|
||||
return {
|
||||
kind,
|
||||
remoteHost: kind === "remote-frontend" ? "https://example.invalid" : null,
|
||||
coreFetch: async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
ok: true,
|
||||
dbReady: true,
|
||||
},
|
||||
}),
|
||||
dispatchHostSsh: async (command) => {
|
||||
if (command.includes("kubectl get namespace unidesk-ci")) {
|
||||
return {
|
||||
ok: true,
|
||||
taskId: "task-ci-runner",
|
||||
status: "succeeded",
|
||||
stdout: [
|
||||
"provider_host_ssh=ok",
|
||||
"kubectl=ok",
|
||||
"docker=ok",
|
||||
"docker_socket=true",
|
||||
"namespace=true",
|
||||
"tekton_pipeline=true",
|
||||
"tekton_task=true",
|
||||
"service_account=true",
|
||||
"pvc=true",
|
||||
"source_parent_directory=true",
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
raw: {},
|
||||
};
|
||||
}
|
||||
if (command.includes("/v2/")) {
|
||||
return {
|
||||
ok: true,
|
||||
taskId: "task-registry",
|
||||
status: "succeeded",
|
||||
stdout: healthyRegistryStdout(),
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
raw: {},
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
taskId: "task-host-ssh",
|
||||
status: "succeeded",
|
||||
stdout: "provider_host_ssh=ok\n",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
raw: {},
|
||||
};
|
||||
},
|
||||
commandCwd: "/workspace/unidesk",
|
||||
artifactRegistryCommand: (probe) => ["mock", probe.action, probe.remoteCommandShape],
|
||||
};
|
||||
}
|
||||
|
||||
const localMissingTransport: PublishPreflightTransport = {
|
||||
kind: "local-docker",
|
||||
coreFetch: async () => ({
|
||||
ok: false,
|
||||
status: 503,
|
||||
body: {
|
||||
ok: false,
|
||||
failureKind: "target-stack-not-running",
|
||||
runnerDisposition: "infra-blocked",
|
||||
degradedReason: "backend-core-container-missing",
|
||||
message: "backend-core unavailable from runner-local Docker",
|
||||
},
|
||||
}),
|
||||
dispatchHostSsh: async (command, waitMs, remoteTimeoutMs) => ({
|
||||
ok: false,
|
||||
taskId: null,
|
||||
status: "infra-blocked",
|
||||
stdout: "",
|
||||
stderr: `backend-core bridge unavailable while dispatching readonly SSH task (${waitMs}/${remoteTimeoutMs})`,
|
||||
exitCode: null,
|
||||
raw: {
|
||||
ok: false,
|
||||
failureKind: "target-stack-not-running",
|
||||
runnerDisposition: "infra-blocked",
|
||||
command,
|
||||
},
|
||||
}),
|
||||
commandCwd: "/workspace/unidesk",
|
||||
artifactRegistryCommand: (probe) => ["mock", probe.action, probe.remoteCommandShape],
|
||||
};
|
||||
|
||||
const remoteInfraBlockedTransport: PublishPreflightTransport = {
|
||||
kind: "remote-frontend",
|
||||
remoteHost: "https://example.invalid",
|
||||
coreFetch: async () => ({
|
||||
@@ -27,28 +146,7 @@ const readyTransport: PublishPreflightTransport = {
|
||||
},
|
||||
}),
|
||||
dispatchHostSsh: async (command) => {
|
||||
if (command.includes("kubectl get namespace unidesk-ci")) {
|
||||
return {
|
||||
ok: true,
|
||||
taskId: "task-ci-runner",
|
||||
status: "succeeded",
|
||||
stdout: [
|
||||
"kubectl=ok",
|
||||
"docker=ok",
|
||||
"docker_socket=true",
|
||||
"namespace=true",
|
||||
"tekton_pipeline=true",
|
||||
"tekton_task=true",
|
||||
"service_account=true",
|
||||
"pvc=true",
|
||||
"source_parent_directory=true",
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
raw: {},
|
||||
};
|
||||
}
|
||||
if (command.includes("v2/")) {
|
||||
if (command.includes("/v2/")) {
|
||||
return {
|
||||
ok: true,
|
||||
taskId: "task-registry",
|
||||
@@ -63,14 +161,14 @@ const readyTransport: PublishPreflightTransport = {
|
||||
"compose_exists=true",
|
||||
"config_exists=true",
|
||||
"storage_exists=true",
|
||||
"container_running=true",
|
||||
"container_status=running",
|
||||
"container_running=false",
|
||||
"container_status=exited",
|
||||
"container_image=registry:2.8.3",
|
||||
"container_restart_policy=always",
|
||||
"listener_count=1",
|
||||
"listener_count=0",
|
||||
"bad_listener_count=0",
|
||||
"loopback_only=true",
|
||||
"v2_http_code=200",
|
||||
"loopback_only=false",
|
||||
"v2_http_code=000",
|
||||
"image_matches=true",
|
||||
"config_hash_matches=true",
|
||||
"compose_hash_matches=true",
|
||||
@@ -83,9 +181,20 @@ const readyTransport: PublishPreflightTransport = {
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
taskId: "task-host-ssh",
|
||||
taskId: "task-ci-runner",
|
||||
status: "succeeded",
|
||||
stdout: "provider_host_ssh=ok\n",
|
||||
stdout: [
|
||||
"provider_host_ssh=ok",
|
||||
"kubectl=ok",
|
||||
"docker=ok",
|
||||
"docker_socket=true",
|
||||
"namespace=true",
|
||||
"tekton_pipeline=true",
|
||||
"tekton_task=true",
|
||||
"service_account=true",
|
||||
"pvc=true",
|
||||
"source_parent_directory=true",
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
raw: {},
|
||||
@@ -96,22 +205,55 @@ const readyTransport: PublishPreflightTransport = {
|
||||
};
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const autoRemote = autoRemoteCiPublishUserServiceDryRunPlan(config, [
|
||||
"ci",
|
||||
"publish-backend-core",
|
||||
"--commit",
|
||||
commit,
|
||||
"--dry-run",
|
||||
], {
|
||||
CODE_QUEUE_SERVICE_ROLE: "scheduler",
|
||||
CODE_QUEUE_DEV_CONTAINER_MASTER_HOST: "74.48.78.17",
|
||||
});
|
||||
assertCondition(autoRemote.enabled === true, "Code Queue runner backend-core dry-run should auto-select remote frontend transport", autoRemote);
|
||||
assertCondition(autoRemote.host === "74.48.78.17", "auto remote backend-core plan should use CODE_QUEUE_DEV_CONTAINER_MASTER_HOST", autoRemote);
|
||||
assertCondition(String(autoRemote.command ?? "").includes("ci publish-backend-core"), "auto remote command should preserve backend-core publish dry-run", autoRemote);
|
||||
|
||||
const localMissing = await runCiPublishBackendCoreDryRunPreflight(config, [
|
||||
"publish-backend-core",
|
||||
"--commit",
|
||||
commit,
|
||||
"--dry-run",
|
||||
], localMissingTransport);
|
||||
const localMissingRecord = asRecord(localMissing, "local missing backend-core preflight");
|
||||
const localMissingControlPlane = asRecord(localMissingRecord.remoteControlPlaneCandidate, "local missing control plane");
|
||||
const localBlockedScopes = Array.isArray(localMissingRecord.blockedScopes) ? localMissingRecord.blockedScopes as string[] : [];
|
||||
assertCondition(localMissingRecord.ok === false, "local missing backend-core preflight should fail", localMissingRecord);
|
||||
assertCondition(localMissingRecord.failureClassification === "local-docker-required", "local Docker absence should classify local-docker-required", localMissingRecord);
|
||||
assertCondition(localMissingControlPlane.transport === "local-docker", "local missing control plane should state local transport", localMissingControlPlane);
|
||||
assertCondition(localMissingControlPlane.remoteCapable === false, "local missing control plane should not claim remote capable", localMissingControlPlane);
|
||||
assertCondition(String(localMissingControlPlane.remoteCommandShape ?? "").includes("publish-backend-core"), "fallback should expose backend-core remote command shape", localMissingControlPlane);
|
||||
assertCondition(localBlockedScopes.includes("local-docker-control-plane"), "local missing blockedScopes should expose local-docker-control-plane", localMissingRecord);
|
||||
assertCondition(asRecord(localMissingRecord.d601Ci, "local missing d601Ci").dryRunWillCompileRust === false, "local dry-run must not compile Rust", localMissingRecord);
|
||||
|
||||
const result = await runCiPublishBackendCoreDryRunPreflight(config, [
|
||||
"publish-backend-core",
|
||||
"--commit",
|
||||
commit,
|
||||
"--dry-run",
|
||||
], readyTransport);
|
||||
], readyTransport());
|
||||
|
||||
const record = asRecord(result, "backend-core preflight");
|
||||
const source = asRecord(record.source, "source");
|
||||
const sourceAuth = asRecord(record.sourceAuth, "sourceAuth");
|
||||
const d601Ci = asRecord(record.d601Ci, "d601Ci");
|
||||
const ciRunner = asRecord(record.ciRunner, "ciRunner");
|
||||
const artifactSummary = asRecord(record.artifactSummary, "artifactSummary");
|
||||
const artifactRequirements = asRecord(record.artifactRequirements, "artifactRequirements");
|
||||
const requiredLabels = asRecord(artifactRequirements.requiredLabels, "requiredLabels");
|
||||
const devApplyPath = asRecord(record.devApplyPath, "devApplyPath");
|
||||
const controlledPublish = asRecord(record.controlledPublish, "controlledPublish");
|
||||
const controlPlane = asRecord(record.remoteControlPlaneCandidate, "remoteControlPlaneCandidate");
|
||||
const blockedScopes = Array.isArray(record.blockedScopes) ? record.blockedScopes as string[] : [];
|
||||
|
||||
assertCondition(record.ok === true, "ready backend-core preflight should pass", record);
|
||||
@@ -122,6 +264,10 @@ async function main(): Promise<void> {
|
||||
assertCondition(record.wouldBuildOnD601 === true, "preflight should state real publish would build on D601", record);
|
||||
assertCondition(record.dryRunBuildStarted === false, "dry-run must not start a build", record);
|
||||
assertCondition(blockedScopes.length === 0, "ready preflight should have no blocked scopes", record);
|
||||
assertCondition(record.failureClassification === null, "ready preflight should not classify a failure", record);
|
||||
assertCondition(ciRunner.environment === "D601", "ciRunner should name D601", ciRunner);
|
||||
assertCondition(controlPlane.transport === "remote-frontend", "ready remote preflight should expose remote frontend transport", controlPlane);
|
||||
assertCondition(String(controlPlane.remoteCommandShape ?? "").includes("publish-backend-core"), "controlPlane command shape should be backend-core-specific", controlPlane);
|
||||
assertCondition(source.repoFetchUrl === "git@github.com:pikasTech/unidesk.git", "source auth should use GitHub SSH fetch URL", source);
|
||||
assertCondition(sourceAuth.egressProxy === "http://127.0.0.1:18789", "source auth should name provider egress proxy", sourceAuth);
|
||||
assertCondition(d601Ci.wouldBuildOnD601 === true, "D601 CI runner preflight should expose D601 build boundary", d601Ci);
|
||||
@@ -136,14 +282,37 @@ async function main(): Promise<void> {
|
||||
assertCondition(String(record.boundary ?? "").includes("no Rust compile"), "boundary should explicitly forbid Rust compile during dry-run", record);
|
||||
assertCondition(String(record.recommendedAction ?? "").includes("ci publish-backend-core"), "recommended action should name real publish command", record);
|
||||
|
||||
const remoteBlocked = await runCiPublishBackendCoreDryRunPreflight(config, [
|
||||
"publish-backend-core",
|
||||
"--commit",
|
||||
commit,
|
||||
"--dry-run",
|
||||
], remoteInfraBlockedTransport);
|
||||
const remoteBlockedRecord = asRecord(remoteBlocked, "remote infra-blocked backend-core preflight");
|
||||
const remoteBlockedControlPlane = asRecord(remoteBlockedRecord.remoteControlPlaneCandidate, "remote infra-blocked control plane");
|
||||
const remoteBlockedScopes = Array.isArray(remoteBlockedRecord.blockedScopes) ? remoteBlockedRecord.blockedScopes as string[] : [];
|
||||
assertCondition(remoteBlockedRecord.ok === false, "remote infra-blocked preflight should fail", remoteBlockedRecord);
|
||||
assertCondition(remoteBlockedRecord.failureClassification === "registry-unhealthy" || remoteBlockedRecord.failureClassification === "registry-not-installed", "remote infra-blocked should classify registry failure, not local docker", remoteBlockedRecord);
|
||||
assertCondition(remoteBlockedControlPlane.transport === "remote-frontend", "remote infra-blocked should keep remote frontend transport", remoteBlockedControlPlane);
|
||||
assertCondition(!remoteBlockedScopes.includes("local-docker-control-plane"), "remote infra-blocked must not blame runner local Docker", remoteBlockedRecord);
|
||||
assertCondition(remoteBlockedScopes.includes("registry-container") || remoteBlockedScopes.includes("registry-api") || remoteBlockedScopes.includes("provider-ssh-command"), "remote infra-blocked should expose registry/provider scope", remoteBlockedRecord);
|
||||
assertCondition(String(remoteBlockedRecord.recommendedAction ?? "").includes("registry"), "remote infra-blocked should recommend registry recovery", remoteBlockedRecord);
|
||||
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
ok: true,
|
||||
checks: [
|
||||
"runner backend-core dry-run auto-selects the remote frontend control plane",
|
||||
"local-docker-required fallback is explicit and exposes a backend-core remote command shape",
|
||||
"remote ready preflight reports D601/registry/artifact requirements without starting CI",
|
||||
"remote infra-blocked preflight does not misclassify runner-local Docker absence",
|
||||
"backend-core dry-run exposes target commit, source repo, D601 runner and registry target",
|
||||
"backend-core dry-run reports wouldBuildOnD601 without starting a build",
|
||||
"artifact labels and digest requirements are visible without faking a digest",
|
||||
"standard path is publish artifact, verify labels/digest, then dev pull-only apply",
|
||||
],
|
||||
localBlockedScopes,
|
||||
readyBlockedScopes: record.blockedScopes,
|
||||
remoteBlockedScopes,
|
||||
}, null, 2)}\n`);
|
||||
}
|
||||
|
||||
|
||||
+303
-28
@@ -87,7 +87,7 @@ interface DispatchResult {
|
||||
raw: unknown;
|
||||
}
|
||||
|
||||
type PublishPreflightFailureClassification = "auth-missing" | "remote-proxy-missing" | "provider-unreachable" | "local-docker-required" | "registry-not-installed" | "registry-unhealthy" | "remote-command-timeout" | "ssh-helper-command-shape-incompatible";
|
||||
type PublishPreflightFailureClassification = "auth-missing" | "remote-proxy-missing" | "provider-unreachable" | "local-docker-required" | "registry-not-installed" | "registry-unhealthy" | "remote-command-timeout" | "ssh-helper-command-shape-incompatible" | "ci-runner-not-ready";
|
||||
type PublishPreflightControlChannel = "backend-core" | "database" | "provider" | "registry";
|
||||
type PublishPreflightDetailedChannel = "backend-core-api" | "provider-dispatch" | "provider-host-ssh" | "database" | "artifact-registry";
|
||||
|
||||
@@ -463,6 +463,7 @@ function recommendedPublishPreflightAction(
|
||||
if (failureClassification === "remote-command-timeout") return "rerun artifact-registry health and inspect D601 host.ssh latency if the timeout repeats";
|
||||
if (failureClassification === "registry-not-installed") return "install the D601 artifact registry through the controlled artifact-registry install path, then rerun health";
|
||||
if (failureClassification === "registry-unhealthy") return "restore the D601 artifact registry service/container/API until artifact-registry health passes";
|
||||
if (failureClassification === "ci-runner-not-ready") return "restore D601 unidesk-ci Tekton runner prerequisites before authorizing backend-core artifact publication";
|
||||
if (missingControlChannels.includes("registry")) return "restore or install the D601 artifact registry until artifact-registry health passes";
|
||||
return `restore missing control channel(s): ${missingControlChannels.join(", ") || "unknown"}`;
|
||||
}
|
||||
@@ -470,6 +471,7 @@ function recommendedPublishPreflightAction(
|
||||
function publishPreflightControlPlane(
|
||||
transport: PublishPreflightTransport,
|
||||
failureClassification: PublishPreflightFailureClassification | null,
|
||||
remoteCommandShape = "bun scripts/cli.ts --main-server-ip <host> ci publish-user-service --service <id> --commit <full-sha> --dry-run",
|
||||
): Record<string, unknown> {
|
||||
const kind = transportKind(transport);
|
||||
const remoteCapable = kind !== "local-docker";
|
||||
@@ -481,7 +483,7 @@ function publishPreflightControlPlane(
|
||||
failureClassification,
|
||||
preferredRunnerPath: "frontend-private-proxy",
|
||||
fallbackRunnerPath: "main-server-local-docker",
|
||||
remoteCommandShape: "bun scripts/cli.ts --main-server-ip <host> ci publish-user-service --service <id> --commit <full-sha> --dry-run",
|
||||
remoteCommandShape,
|
||||
providerTunnel: {
|
||||
providerId: d601ProviderId,
|
||||
command: "host.ssh",
|
||||
@@ -497,6 +499,84 @@ function publishPreflightFailedScopes(preflight: PublishPreflight): string[] {
|
||||
return preflight.missingChannels;
|
||||
}
|
||||
|
||||
function ciRunnerPreflightScript(sourceHostPath: string): string {
|
||||
return [
|
||||
"set -euo pipefail",
|
||||
`export KUBECONFIG=${shellQuote(d601Kubeconfig)}`,
|
||||
"printf 'provider_host_ssh=ok\\n'",
|
||||
"printf 'kubectl='",
|
||||
"command -v kubectl >/dev/null && printf 'ok\\n' || { printf 'missing\\n'; exit 127; }",
|
||||
"printf 'docker='",
|
||||
"command -v docker >/dev/null && printf 'ok\\n' || { printf 'missing\\n'; exit 127; }",
|
||||
"printf 'docker_socket='",
|
||||
"if [ -S /var/run/docker.sock ] || [ -S /run/docker.sock ]; then printf 'true\\n'; else printf 'false\\n'; fi",
|
||||
"printf 'namespace='",
|
||||
"kubectl get namespace unidesk-ci >/dev/null 2>&1 && printf 'true\\n' || printf 'false\\n'",
|
||||
"printf 'tekton_pipeline='",
|
||||
"kubectl get pipeline/unidesk-backend-core-artifact-publish -n unidesk-ci >/dev/null 2>&1 && printf 'true\\n' || printf 'false\\n'",
|
||||
"printf 'tekton_task='",
|
||||
"kubectl get task/unidesk-backend-core-artifact-publish -n unidesk-ci >/dev/null 2>&1 && printf 'true\\n' || printf 'false\\n'",
|
||||
"printf 'service_account='",
|
||||
"kubectl get serviceaccount/unidesk-ci-runner -n unidesk-ci >/dev/null 2>&1 && printf 'true\\n' || printf 'false\\n'",
|
||||
"printf 'pvc='",
|
||||
"kubectl get pvc/unidesk-ci-cache -n unidesk-ci >/dev/null 2>&1 && printf 'true\\n' || printf 'false\\n'",
|
||||
"printf 'source_parent_directory='",
|
||||
`test -d ${shellQuote(posixPath.dirname(sourceHostPath))} && printf 'true\\n' || printf 'false\\n'`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function keyValueBool(stdout: string, key: string): boolean {
|
||||
const match = new RegExp(`^${key}=(.*)$`, "mu").exec(stdout);
|
||||
if (match === null) return false;
|
||||
const value = match[1]?.trim().toLowerCase() ?? "";
|
||||
return value === "true" || value === "ok";
|
||||
}
|
||||
|
||||
function backendCoreCiRunnerReady(result: DispatchResult): boolean {
|
||||
return result.ok
|
||||
&& keyValueBool(result.stdout, "kubectl")
|
||||
&& keyValueBool(result.stdout, "docker")
|
||||
&& keyValueBool(result.stdout, "namespace")
|
||||
&& keyValueBool(result.stdout, "tekton_pipeline")
|
||||
&& keyValueBool(result.stdout, "tekton_task")
|
||||
&& keyValueBool(result.stdout, "service_account")
|
||||
&& keyValueBool(result.stdout, "pvc")
|
||||
&& keyValueBool(result.stdout, "source_parent_directory");
|
||||
}
|
||||
|
||||
function backendCoreArtifactRequirements(options: CiPublishBackendCoreOptions, plannedArtifact: ArtifactSummary): Record<string, unknown> {
|
||||
return {
|
||||
requiredLabels: {
|
||||
"unidesk.ai/service-id": "backend-core",
|
||||
"unidesk.ai/source-commit": options.commit,
|
||||
"unidesk.ai/source-repo": options.repoUrl,
|
||||
"unidesk.ai/dockerfile": options.dockerfile,
|
||||
},
|
||||
digest: {
|
||||
requiredAfterPublish: true,
|
||||
dryRunValue: plannedArtifact.digest,
|
||||
fields: ["artifactSummary.digest", "artifactSummary.digestRef"],
|
||||
source: "D601 registry manifest HEAD after ci publish-backend-core succeeds",
|
||||
},
|
||||
imageRef: plannedArtifact.imageRef,
|
||||
};
|
||||
}
|
||||
|
||||
function backendCoreDevApplyPath(options: CiPublishBackendCoreOptions, plannedArtifact: ArtifactSummary): Record<string, unknown> {
|
||||
return {
|
||||
environment: "dev",
|
||||
pullOnly: true,
|
||||
apply: `bun scripts/cli.ts deploy apply --env dev --service backend-core --commit ${options.commit}`,
|
||||
sourceImage: plannedArtifact.imageRef,
|
||||
forbidden: ["--env prod", "cargo build", "docker build", "docker compose build", "server rebuild backend-core"],
|
||||
note: "PROD is intentionally not part of backend-core publish preflight; dev apply still needs explicit authorization after artifact publication.",
|
||||
};
|
||||
}
|
||||
|
||||
function backendCoreRemoteCommandShape(commit: string): string {
|
||||
return `bun scripts/cli.ts --main-server-ip <host> ci publish-backend-core --commit ${commit} --dry-run`;
|
||||
}
|
||||
|
||||
function dispatchPreflightFailure(command: string, result: DispatchResult): DispatchResult {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -1499,6 +1579,103 @@ async function publishUserServicePreflight(
|
||||
};
|
||||
}
|
||||
|
||||
async function publishBackendCorePreflight(
|
||||
_config: UniDeskConfig,
|
||||
options: CiPublishBackendCoreOptions,
|
||||
plannedArtifact: ArtifactSummary,
|
||||
transport: PublishPreflightTransport,
|
||||
): Promise<PublishPreflight> {
|
||||
const providerId = d601ProviderId;
|
||||
const channels: PublishPreflightChannelProbe[] = [];
|
||||
const overview = await transport.coreFetch("/api/overview", { maxResponseBytes: 500_000 });
|
||||
const overviewBody = coreBody(overview);
|
||||
const backendCoreOk = responseOk(overview) && overviewBody?.dbReady === true;
|
||||
channels.push(channelProbe("backend-core-api", "backend-core", backendCoreOk, "dispatch API, provider catalog, task polling, and database-backed CI state", {
|
||||
ok: responseOk(overview),
|
||||
dbReady: overviewBody?.dbReady ?? null,
|
||||
runnerDisposition: asRecord(overview)?.runnerDisposition ?? null,
|
||||
failureKind: asRecord(overview)?.failureKind ?? null,
|
||||
detail: backendCoreUnavailable(overview) ? overview : {
|
||||
status: asRecord(overview)?.status ?? null,
|
||||
body: overviewBody,
|
||||
},
|
||||
}));
|
||||
channels.push(channelProbe("database", "database", backendCoreOk, "backend-core task dispatch, provider state, Tekton task polling, and source identity lookup", {
|
||||
dbReady: overviewBody?.dbReady ?? false,
|
||||
observedThrough: "backend-core /api/overview",
|
||||
}));
|
||||
|
||||
const ciScript = ciRunnerPreflightScript(options.sourceHostPath);
|
||||
const ciProbe = await transport.dispatchHostSsh(ciScript, 30_000, 15_000);
|
||||
const ciRunnerReady = backendCoreCiRunnerReady(ciProbe);
|
||||
channels.push(channelProbe("provider-dispatch", "provider", ciProbe.taskId !== null || ciProbe.ok, "backend-core /api/dispatch can create D601 host.ssh tasks", {
|
||||
taskId: ciProbe.taskId,
|
||||
status: ciProbe.status,
|
||||
ok: ciProbe.taskId !== null || ciProbe.ok,
|
||||
exitCode: ciProbe.exitCode,
|
||||
stderrTail: ciProbe.stderr.slice(-1200),
|
||||
}));
|
||||
channels.push(channelProbe("provider-host-ssh", "provider", ciRunnerReady, "D601 host.ssh can observe Docker, kubectl, Tekton, PVC, and backend-core source parent without starting CI", {
|
||||
taskId: ciProbe.taskId,
|
||||
status: ciProbe.status,
|
||||
exitCode: ciProbe.exitCode,
|
||||
stdoutTail: ciProbe.stdout.slice(-1600),
|
||||
stderrTail: ciProbe.stderr.slice(-1200),
|
||||
raw: ciRunnerReady ? undefined : dispatchPreflightFailure("backend-core ci runner readonly probe", ciProbe).raw,
|
||||
}));
|
||||
|
||||
const registryOptions = parseArtifactRegistryOptions(["--provider-id", providerId]);
|
||||
const registryProbe = buildArtifactRegistryReadonlyProbe("health", registryOptions);
|
||||
const registryDispatch = await transport.dispatchHostSsh(registryProbe.script, Math.max(registryProbe.timeoutMs, 30_000), registryProbe.timeoutMs);
|
||||
const registryCommand = commandResultFromDispatch(transport.artifactRegistryCommand(registryProbe), transport.commandCwd, registryDispatch);
|
||||
const registry = artifactRegistryReadonlyResultFromCommand(registryProbe, registryCommand);
|
||||
const registryRecord = asRecord(registry);
|
||||
const registryOk = registryRecord?.ok === true;
|
||||
channels.push(channelProbe("artifact-registry", "registry", registryOk, "backend-core commit-pinned image push and later dev CD manifest checks", registry));
|
||||
|
||||
const missingChannels = channels.filter((item) => !item.ok).map((item) => item.channel);
|
||||
const controlChannels = summarizePublishControlChannels(channels);
|
||||
const missingControlChannels = controlChannels.filter((item) => !item.ok).map((item) => item.channel);
|
||||
const ready = missingChannels.length === 0;
|
||||
const baseFailureClassification = ready ? null : classifyPublishPreflightFailure(transport, overview, ciProbe, registry);
|
||||
const failureClassification = !ready && baseFailureClassification === null ? "ci-runner-not-ready" : baseFailureClassification;
|
||||
const recommendedAction = ready
|
||||
? `authorize and run: bun scripts/cli.ts ci publish-backend-core --commit ${options.commit} --wait-ms 1200000`
|
||||
: recommendedPublishPreflightAction(failureClassification, registry, missingControlChannels);
|
||||
return {
|
||||
ok: ready,
|
||||
runnerDisposition: ready ? "ready" : "infra-blocked",
|
||||
failureClassification,
|
||||
serviceId: "backend-core",
|
||||
commit: options.commit,
|
||||
providerId,
|
||||
supportedArtifactPublish: true,
|
||||
missingChannels,
|
||||
missingControlChannels,
|
||||
controlChannels,
|
||||
channels,
|
||||
registry,
|
||||
controlPlane: publishPreflightControlPlane(transport, failureClassification, backendCoreRemoteCommandShape(options.commit)),
|
||||
recommendedAction,
|
||||
remoteCommandShape: registryProbe.remoteCommandShape,
|
||||
next: ready
|
||||
? [
|
||||
`bun scripts/cli.ts ci publish-backend-core --commit ${options.commit} --wait-ms 1200000`,
|
||||
`verify ${plannedArtifact.imageRef} labels and digest before any dev artifact consumer apply`,
|
||||
`dev-only follow-up: bun scripts/cli.ts deploy apply --env dev --service backend-core --commit ${options.commit}`,
|
||||
]
|
||||
: [
|
||||
`Restore missing control channel(s): ${missingControlChannels.join(", ") || "unknown"}.`,
|
||||
failureClassification === "local-docker-required"
|
||||
? `From a Code Queue runner, rerun this read-only preflight through the remote frontend transport: ${backendCoreRemoteCommandShape(options.commit)}.`
|
||||
: "Run from the main-server CLI or use remote frontend transport against a healthy frontend/backend-core path.",
|
||||
"Restore backend-core/database/provider-gateway/Host SSH/Tekton/registry readiness before retrying backend-core artifact publication.",
|
||||
"This preflight must remain read-only; do not start Tekton, compile Rust, push registry artifacts, deploy/apply, or restart services.",
|
||||
],
|
||||
boundary: "preflight is read-only: no Tekton PipelineRun, no Rust compile, no source export, no image push, no registry write, no deploy apply, no service restart",
|
||||
};
|
||||
}
|
||||
|
||||
async function readArtifactSummaryFromPipelineRun(name: string, context: ArtifactSummaryContext): Promise<ArtifactSummary> {
|
||||
const result = await runRemoteKubectlRaw([
|
||||
"set -euo pipefail",
|
||||
@@ -1583,32 +1760,13 @@ async function publishBackendCoreArtifact(config: UniDeskConfig, options: CiPubl
|
||||
};
|
||||
const plannedArtifact = artifactSummaryDefaults(summaryContext);
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
ok: true,
|
||||
mode: "dry-run",
|
||||
pipeline: "unidesk-backend-core-artifact-publish",
|
||||
namespace: "unidesk-ci",
|
||||
repoUrl: options.repoUrl,
|
||||
commit: options.commit,
|
||||
sourceHostPath: options.sourceHostPath,
|
||||
source: {
|
||||
ok: true,
|
||||
mode: "planned-only",
|
||||
providerId: d601ProviderId,
|
||||
repoUrl: options.repoUrl,
|
||||
repoFetchUrl: repoSshUrl(options.repoUrl),
|
||||
commit: options.commit,
|
||||
dockerfile: options.dockerfile,
|
||||
imageRepository: options.imageRepository,
|
||||
sourceHostPath: options.sourceHostPath,
|
||||
},
|
||||
artifact: plannedArtifact.imageRef,
|
||||
artifactSummary: plannedArtifact,
|
||||
boundary: "dry-run only; no D601 source export, no Tekton submission, no production mutation",
|
||||
next: [
|
||||
`bun scripts/cli.ts ci publish-backend-core --commit ${options.commit} --wait-ms 1200000`,
|
||||
],
|
||||
};
|
||||
return runCiPublishBackendCoreDryRunPreflight(config, [
|
||||
"publish-backend-core",
|
||||
"--commit",
|
||||
options.commit,
|
||||
"--dry-run",
|
||||
...(options.waitMs > 0 ? ["--wait-ms", String(options.waitMs)] : []),
|
||||
], localPublishPreflightTransport, options);
|
||||
}
|
||||
const source = await prepareBackendCoreArtifactSource(config, options);
|
||||
const name = await remoteCreatePipelineRun(backendCoreArtifactPipelineRunManifest(options));
|
||||
@@ -1836,6 +1994,123 @@ export async function runCiPublishUserServiceDryRunPreflight(
|
||||
};
|
||||
}
|
||||
|
||||
export async function runCiPublishBackendCoreDryRunPreflight(
|
||||
config: UniDeskConfig,
|
||||
args: string[],
|
||||
transport: PublishPreflightTransport,
|
||||
resolvedOptions?: CiPublishBackendCoreOptions,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const commit = requireFullCommit(stringOption(args, "--commit") ?? stringOption(args, "--revision"));
|
||||
if (!args.includes("--dry-run")) throw new Error("publish-backend-core preflight requires --dry-run");
|
||||
if (stringOption(args, "--repo") !== null || stringOption(args, "--repo-url") !== null) {
|
||||
throw new Error("ci publish-backend-core reads source repo from CI.json; edit CI.json instead of using --repo");
|
||||
}
|
||||
const artifact = resolveCatalogArtifact("backend-core");
|
||||
if (artifact.kind !== "source-build") throw new Error("backend-core must be modeled as a source-build artifact in CI.json");
|
||||
if (artifact.status === "blocked") return blockedArtifactResult(artifact, commit, blockedReason(artifact));
|
||||
const dockerfile = requireRepoRelativePath(artifact.source.dockerfile, "CI.json.artifacts.backend-core.source.dockerfile");
|
||||
const options: CiPublishBackendCoreOptions = resolvedOptions ?? {
|
||||
repoUrl: artifact.source.repo,
|
||||
commit,
|
||||
waitMs: numberOption(args, "--wait-ms", 0),
|
||||
sourceHostPath: backendCoreArtifactSourceHostPath(commit),
|
||||
dockerfile,
|
||||
imageRepository: artifact.image.repository,
|
||||
dryRun: true,
|
||||
};
|
||||
const summaryContext: ArtifactSummaryContext = {
|
||||
serviceId: "backend-core",
|
||||
commit,
|
||||
repoUrl: options.repoUrl,
|
||||
dockerfile: options.dockerfile,
|
||||
imageRepository: options.imageRepository,
|
||||
};
|
||||
const plannedArtifact = artifactSummaryDefaults(summaryContext);
|
||||
const preflight = await publishBackendCorePreflight(config, options, plannedArtifact, transport);
|
||||
const plannedRepoFetchUrl = repoSshUrl(options.repoUrl);
|
||||
const blockedScopes = publishPreflightFailedScopes(preflight);
|
||||
return {
|
||||
ok: preflight.ok,
|
||||
mode: "dry-run-preflight",
|
||||
runnerDisposition: preflight.runnerDisposition,
|
||||
pipeline: "unidesk-backend-core-artifact-publish",
|
||||
namespace: "unidesk-ci",
|
||||
repoUrl: options.repoUrl,
|
||||
commit: options.commit,
|
||||
targetCommit: options.commit,
|
||||
sourceRepo: options.repoUrl,
|
||||
serviceId: "backend-core",
|
||||
providerId: d601ProviderId,
|
||||
ciRunner: {
|
||||
providerId: d601ProviderId,
|
||||
environment: "D601",
|
||||
namespace: "unidesk-ci",
|
||||
pipeline: "unidesk-backend-core-artifact-publish",
|
||||
wouldBuildOnD601: true,
|
||||
realBuildRequiresAuthorization: true,
|
||||
},
|
||||
registryTarget: plannedArtifact.repository,
|
||||
wouldBuildOnD601: true,
|
||||
dryRunBuildStarted: false,
|
||||
supportedArtifactPublish: preflight.supportedArtifactPublish,
|
||||
missingChannels: preflight.missingChannels,
|
||||
missingControlChannels: preflight.missingControlChannels,
|
||||
controlChannels: preflight.controlChannels,
|
||||
channels: preflight.channels,
|
||||
failureClassification: preflight.failureClassification,
|
||||
failedScopes: blockedScopes,
|
||||
blockedScopes,
|
||||
recommendedAction: preflight.recommendedAction,
|
||||
remoteCommandShape: preflight.remoteCommandShape,
|
||||
remoteControlPlaneCandidate: preflight.controlPlane,
|
||||
controlPlane: preflight.controlPlane,
|
||||
registry: preflight.registry,
|
||||
sourceHostPath: options.sourceHostPath,
|
||||
source: {
|
||||
ok: preflight.ok,
|
||||
mode: "planned-only",
|
||||
providerId: d601ProviderId,
|
||||
repoUrl: options.repoUrl,
|
||||
repoFetchUrl: plannedRepoFetchUrl,
|
||||
repoProbeUrl: repoConnectivityProbeUrl(plannedRepoFetchUrl),
|
||||
commit: options.commit,
|
||||
serviceId: "backend-core",
|
||||
dockerfile: options.dockerfile,
|
||||
imageRepository: options.imageRepository,
|
||||
sourceHostPath: options.sourceHostPath,
|
||||
},
|
||||
sourceAuth: {
|
||||
repoFetchUrl: plannedRepoFetchUrl,
|
||||
egressProxy: providerGatewayWsEgressProxyUrl,
|
||||
gitSshProxy: repoNeedsGithubSshIdentity(plannedRepoFetchUrl),
|
||||
identityRequired: repoNeedsGithubSshIdentity(plannedRepoFetchUrl),
|
||||
},
|
||||
d601Ci: {
|
||||
providerId: d601ProviderId,
|
||||
namespace: "unidesk-ci",
|
||||
pipeline: "unidesk-backend-core-artifact-publish",
|
||||
wouldBuildOnD601: true,
|
||||
dryRunWillStartTekton: false,
|
||||
dryRunWillCompileRust: false,
|
||||
dryRunWillPushRegistry: false,
|
||||
},
|
||||
artifact: plannedArtifact.imageRef,
|
||||
artifactSummary: plannedArtifact,
|
||||
artifactRequirements: backendCoreArtifactRequirements(options, plannedArtifact),
|
||||
controlledPublish: {
|
||||
environment: "D601",
|
||||
namespace: "unidesk-ci",
|
||||
pipeline: "unidesk-backend-core-artifact-publish",
|
||||
command: `bun scripts/cli.ts ci publish-backend-core --commit ${options.commit} --wait-ms 1200000`,
|
||||
requiresReadyControlChannels: publishPreflightControlChannelOrder,
|
||||
requiresHumanAuthorization: true,
|
||||
},
|
||||
devApplyPath: backendCoreDevApplyPath(options, plannedArtifact),
|
||||
boundary: preflight.boundary,
|
||||
next: preflight.next,
|
||||
};
|
||||
}
|
||||
|
||||
async function runRemoteDevE2ELauncher(options: CiDevE2EOptions): Promise<DispatchResult> {
|
||||
const scriptTimeoutMs = Math.max(options.scriptTimeoutMs, options.waitMs, 60_000);
|
||||
const remoteTimeoutMs = 45_000;
|
||||
|
||||
+32
-29
@@ -11,7 +11,7 @@ import {
|
||||
buildArtifactRegistryReadonlyProbe,
|
||||
parseArtifactRegistryOptions,
|
||||
} from "./artifact-registry";
|
||||
import { runCiPublishUserServiceDryRunPreflight } from "./ci";
|
||||
import { runCiPublishBackendCoreDryRunPreflight, runCiPublishUserServiceDryRunPreflight } from "./ci";
|
||||
|
||||
export interface RemoteCliOptions {
|
||||
host: string | null;
|
||||
@@ -109,9 +109,9 @@ export function autoRemoteCiPublishUserServiceDryRunPlan(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): AutoRemoteCiPublishPlan {
|
||||
const [top, sub] = args;
|
||||
const isPublishDryRun = top === "ci" && sub === "publish-user-service" && args.includes("--dry-run");
|
||||
const isPublishDryRun = top === "ci" && (sub === "publish-user-service" || sub === "publish-backend-core") && args.includes("--dry-run");
|
||||
if (!isPublishDryRun) {
|
||||
return { enabled: false, host: null, reason: "not ci publish-user-service --dry-run", failureClassification: null, transport: "frontend", command: null };
|
||||
return { enabled: false, host: null, reason: "not ci publish-user-service/publish-backend-core --dry-run", failureClassification: null, transport: "frontend", command: null };
|
||||
}
|
||||
if (truthyDisabled(env.UNIDESK_CI_PUBLISH_AUTO_REMOTE)) {
|
||||
return { enabled: false, host: null, reason: "UNIDESK_CI_PUBLISH_AUTO_REMOTE disables automatic remote preflight", failureClassification: "local-docker-required", transport: "frontend", command: null };
|
||||
@@ -721,36 +721,39 @@ async function remoteArtifactRegistry(session: FrontendSession, args: string[]):
|
||||
|
||||
async function remoteCi(session: FrontendSession, config: UniDeskConfig, args: string[]): Promise<unknown> {
|
||||
const action = args[1] ?? "status";
|
||||
if (action !== "publish-user-service" || !args.includes("--dry-run")) {
|
||||
throw new Error("remote frontend transport supports only ci publish-user-service --dry-run preflight; real CI publication must run from the controlled main-server CLI after preflight is ready");
|
||||
if ((action !== "publish-user-service" && action !== "publish-backend-core") || !args.includes("--dry-run")) {
|
||||
throw new Error("remote frontend transport supports only ci publish-user-service --dry-run and ci publish-backend-core --dry-run preflight; real CI publication must run from the controlled main-server CLI after preflight is ready");
|
||||
}
|
||||
const transport = {
|
||||
kind: "remote-frontend" as const,
|
||||
remoteHost: session.baseUrl,
|
||||
coreFetch: (path: string, init?: { method?: string; body?: unknown; maxResponseBytes?: number }) => frontendJson(session, path, init === undefined ? undefined : {
|
||||
method: init.method,
|
||||
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
||||
}, 12_000, init?.maxResponseBytes ?? 500_000),
|
||||
dispatchHostSsh: async (command: string, waitMs: number, remoteTimeoutMs: number) => {
|
||||
const dispatched = await dispatchHostSshJson(session, "D601", command, remoteTimeoutMs, waitMs);
|
||||
const task = dispatched.task;
|
||||
const result = task?.result ?? {};
|
||||
return {
|
||||
ok: task?.status === "succeeded" && (typeof result.exitCode !== "number" || result.exitCode === 0) && dispatched.taskId !== null,
|
||||
taskId: dispatched.taskId,
|
||||
status: task?.status ?? null,
|
||||
stdout: typeof result.stdout === "string" ? result.stdout : "",
|
||||
stderr: typeof result.stderr === "string" ? result.stderr : "",
|
||||
exitCode: typeof result.exitCode === "number" ? result.exitCode : null,
|
||||
raw: task ?? dispatched.wait ?? dispatched.dispatch,
|
||||
};
|
||||
},
|
||||
commandCwd: ".",
|
||||
artifactRegistryCommand: (probe: ReturnType<typeof buildArtifactRegistryReadonlyProbe>) => ["frontend", "/api/dispatch", probe.providerId, "host.ssh", probe.action, dispatchedTaskShape(probe.remoteCommandShape)],
|
||||
};
|
||||
return {
|
||||
transport: "frontend",
|
||||
readonly: true,
|
||||
result: await runCiPublishUserServiceDryRunPreflight(config, args.slice(1), {
|
||||
kind: "remote-frontend",
|
||||
remoteHost: session.baseUrl,
|
||||
coreFetch: (path, init) => frontendJson(session, path, init === undefined ? undefined : {
|
||||
method: init.method,
|
||||
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
||||
}, 12_000, init?.maxResponseBytes ?? 500_000),
|
||||
dispatchHostSsh: async (command, waitMs, remoteTimeoutMs) => {
|
||||
const dispatched = await dispatchHostSshJson(session, "D601", command, remoteTimeoutMs, waitMs);
|
||||
const task = dispatched.task;
|
||||
const result = task?.result ?? {};
|
||||
return {
|
||||
ok: task?.status === "succeeded" && (typeof result.exitCode !== "number" || result.exitCode === 0) && dispatched.taskId !== null,
|
||||
taskId: dispatched.taskId,
|
||||
status: task?.status ?? null,
|
||||
stdout: typeof result.stdout === "string" ? result.stdout : "",
|
||||
stderr: typeof result.stderr === "string" ? result.stderr : "",
|
||||
exitCode: typeof result.exitCode === "number" ? result.exitCode : null,
|
||||
raw: task ?? dispatched.wait ?? dispatched.dispatch,
|
||||
};
|
||||
},
|
||||
commandCwd: ".",
|
||||
artifactRegistryCommand: (probe) => ["frontend", "/api/dispatch", probe.providerId, "host.ssh", probe.action, dispatchedTaskShape(probe.remoteCommandShape)],
|
||||
}),
|
||||
result: action === "publish-backend-core"
|
||||
? await runCiPublishBackendCoreDryRunPreflight(config, args.slice(1), transport)
|
||||
: await runCiPublishUserServiceDryRunPreflight(config, args.slice(1), transport),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user