Files
pikasTech-unidesk/scripts/ci-publish-user-service-preflight-contract-test.ts
T

160 lines
9.6 KiB
TypeScript

import { readConfig } from "./src/config";
import { runCiPublishUserServiceDryRunPreflight, type PublishPreflightTransport } from "./src/ci";
import { autoRemoteCiPublishUserServiceDryRunPlan } from "./src/remote";
type JsonRecord = Record<string, unknown>;
function assertCondition(condition: unknown, message: string, detail: unknown = {}): void {
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
}
function asRecord(value: unknown, label: string): JsonRecord {
assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, { value });
return value as JsonRecord;
}
const commit = "0123456789abcdef0123456789abcdef01234567";
const config = readConfig();
const infraBlockedTransport: PublishPreflightTransport = {
coreFetch: async (path) => ({
ok: false,
status: 503,
body: {
ok: false,
failureKind: "target-stack-not-running",
runnerDisposition: "infra-blocked",
degradedReason: "backend-core-container-missing",
message: `backend-core unavailable for ${path}`,
},
}),
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) => [process.execPath, "scripts/cli.ts", "ssh", probe.providerId, "argv", "bash", "-lc", probe.script],
};
async function main(): Promise<void> {
const autoRemote = autoRemoteCiPublishUserServiceDryRunPlan(config, [
"ci",
"publish-user-service",
"--service",
"frontend",
"--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 publish dry-run should auto-select remote frontend transport", autoRemote);
assertCondition(autoRemote.host === "74.48.78.17", "auto remote plan should use CODE_QUEUE_DEV_CONTAINER_MASTER_HOST", autoRemote);
assertCondition(autoRemote.transport === "frontend", "auto remote plan should use frontend transport", autoRemote);
assertCondition(String(autoRemote.command ?? "").includes("--main-server-ip 74.48.78.17"), "auto remote plan should expose command shape", autoRemote);
const nonRunner = autoRemoteCiPublishUserServiceDryRunPlan(config, [
"ci",
"publish-user-service",
"--service",
"frontend",
"--commit",
commit,
"--dry-run",
], {});
assertCondition(nonRunner.enabled === false, "non-runner local CLI should not auto-remote without a host hint", nonRunner);
assertCondition(nonRunner.failureClassification === "local-docker-required", "non-runner disabled plan should classify local docker requirement", nonRunner);
const result = await runCiPublishUserServiceDryRunPreflight(config, [
"publish-user-service",
"--service",
"frontend",
"--commit",
commit,
"--dry-run",
], infraBlockedTransport);
const record = asRecord(result, "preflight");
const source = asRecord(record.source, "source");
const channels = Array.isArray(record.channels) ? record.channels.map((item) => asRecord(item, "channel")) : [];
const controlChannels = Array.isArray(record.controlChannels) ? record.controlChannels.map((item) => asRecord(item, "control channel")) : [];
const registry = asRecord(record.registry, "registry");
const controlledPublish = asRecord(record.controlledPublish, "controlledPublish");
const controlPlane = asRecord(record.controlPlane, "controlPlane");
const backendCore = asRecord(channels.find((item) => item.channel === "backend-core-api")?.detail, "backendCore detail");
const backendCoreTransport = asRecord(backendCore.detail, "backendCore transport payload");
const backendCoreBody = asRecord(backendCoreTransport.body, "backendCore body payload");
const providerDispatch = asRecord(channels.find((item) => item.channel === "provider-dispatch")?.detail, "providerDispatch detail");
const missingChannels = Array.isArray(record.missingChannels) ? record.missingChannels as string[] : [];
const missingControlChannels = Array.isArray(record.missingControlChannels) ? record.missingControlChannels as string[] : [];
assertCondition(record.ok === false, "infra-blocked preflight should fail", record);
assertCondition(record.mode === "dry-run-preflight", "dry-run preflight mode should be reported", record);
assertCondition(record.runnerDisposition === "infra-blocked", "runnerDisposition should be infra-blocked", record);
assertCondition(record.failureClassification === "local-docker-required", "local backend-core absence should classify as local-docker-required", record);
assertCondition(controlPlane.transport === "local-docker", "local preflight should expose local-docker transport", controlPlane);
assertCondition(controlPlane.remoteCapable === false, "local preflight should not claim remote-capable transport", controlPlane);
assertCondition(controlPlane.preferredRunnerPath === "frontend-private-proxy", "controlPlane should name frontend private proxy as preferred path", controlPlane);
assertCondition(Array.isArray(record.missingChannels), "missingChannels should be an array", record);
assertCondition(Array.isArray(record.missingControlChannels), "missingControlChannels should be an array", record);
assertCondition(Array.isArray(record.failedScopes), "failedScopes should be an array", record);
assertCondition((record.failedScopes as unknown[]).includes("local-docker-control-plane"), "failedScopes should expose publish-blocking registry scope", record);
assertCondition(missingChannels.includes("backend-core-api"), "backend-core-api should be missing", record);
assertCondition(missingChannels.includes("database"), "database should be missing", record);
assertCondition(missingChannels.includes("provider-dispatch"), "provider-dispatch should be missing", record);
assertCondition(missingChannels.includes("provider-host-ssh"), "provider-host-ssh should be missing", record);
assertCondition(missingChannels.includes("artifact-registry"), "artifact-registry should be missing", record);
assertCondition(missingControlChannels.join(",") === "backend-core,database,provider,registry", "missingControlChannels should name runner-facing domains", record);
assertCondition(controlChannels.length === 4, "controlChannels should report four runner-facing domains", controlChannels);
assertCondition(controlChannels.every((item) => item.ok === false), "infra-blocked transport should fail every control channel", controlChannels);
assertCondition(
(controlChannels.find((item) => item.channel === "provider")?.probes as unknown[]).join(",") === "provider-dispatch,provider-host-ssh",
"provider control channel should map provider dispatch and host ssh probes",
controlChannels,
);
assertCondition(!JSON.stringify(record).includes("No such container: unidesk-database"), "raw container error should not leak", record);
assertCondition(backendCoreBody.failureKind === "target-stack-not-running", "backend-core detail should classify target-stack-not-running", backendCoreBody);
assertCondition(providerDispatch.status === "infra-blocked", "provider dispatch should be infra-blocked", providerDispatch);
assertCondition(registry.ok === false, "registry channel should fail without backend-core bridge", registry);
assertCondition(Array.isArray(channels) && channels.length >= 5, "expected five channel probes", channels);
assertCondition(source.mode === "planned-only", "source should remain planned-only", source);
assertCondition(source.repoFetchUrl === "git@github.com:pikasTech/unidesk.git", "source repo should use CI catalog ssh form", source);
assertCondition(asRecord(record.artifactSummary, "artifactSummary").imageRef === `127.0.0.1:5000/unidesk/frontend:${commit}`, "artifact ref should remain commit-pinned", record.artifactSummary);
assertCondition(controlledPublish.environment === "D601", "controlledPublish should name the controlled environment", controlledPublish);
assertCondition(controlledPublish.namespace === "unidesk-ci", "controlledPublish should name the Tekton namespace", controlledPublish);
assertCondition(controlledPublish.pipeline === "unidesk-user-service-artifact-publish", "controlledPublish should name the user-service pipeline", controlledPublish);
assertCondition(String(controlledPublish.command ?? "").includes("--wait-ms 1200000"), "controlledPublish should provide the real publish command shape", controlledPublish);
assertCondition(String(record.boundary ?? "").includes("read-only"), "boundary should state preflight is read-only", record);
process.stdout.write(`${JSON.stringify({
ok: true,
checks: [
"dry-run preflight returns infra-blocked when backend-core/database/provider channels are absent",
"missing channel list names the absent channels",
"artifact summary remains commit-pinned and read-only",
"missingControlChannels maps detailed probes to backend-core/database/provider/registry",
"controlledPublish identifies D601 unidesk-ci as the only place for the real publish",
"runner-like environments auto-select the existing frontend remote transport",
"local backend-core absence is classified as local-docker-required",
],
missingChannels: record.missingChannels,
missingControlChannels: record.missingControlChannels,
registry,
}, null, 2)}\n`);
}
if (import.meta.main) {
await main();
}