standardize user-service artifact CD
This commit is contained in:
@@ -5,7 +5,7 @@ import { readConfig, repoRoot, rootPath } from "./config";
|
||||
import { resolveComposeCommand, writeComposeEnv } from "./docker";
|
||||
import { startJob } from "./jobs";
|
||||
|
||||
type ArtifactRegistryAction = "plan" | "render" | "status" | "health" | "install" | "deploy-backend-core";
|
||||
type ArtifactRegistryAction = "plan" | "render" | "status" | "health" | "install" | "deploy-backend-core" | "deploy-service";
|
||||
|
||||
interface ArtifactRegistryOptions {
|
||||
providerId: string;
|
||||
@@ -23,6 +23,8 @@ interface ArtifactRegistryOptions {
|
||||
runNow: boolean;
|
||||
commit: string | null;
|
||||
targetImage: string;
|
||||
serviceId: string | null;
|
||||
sourceRepo: string;
|
||||
}
|
||||
|
||||
interface RenderedFile {
|
||||
@@ -59,6 +61,59 @@ const defaultOptions: ArtifactRegistryOptions = {
|
||||
runNow: false,
|
||||
commit: null,
|
||||
targetImage: "unidesk-backend-core",
|
||||
serviceId: null,
|
||||
sourceRepo: "https://github.com/pikasTech/unidesk",
|
||||
};
|
||||
const supportedArtifactConsumerServices = ["backend-core", "decision-center"] as const;
|
||||
type SupportedArtifactConsumerService = typeof supportedArtifactConsumerServices[number];
|
||||
|
||||
interface ArtifactConsumerSpec {
|
||||
serviceId: SupportedArtifactConsumerService;
|
||||
kind: "compose" | "d601-k3s";
|
||||
registryRepository: string;
|
||||
dockerfile: string;
|
||||
targetImage: string;
|
||||
targetCommitImage: (commit: string) => string;
|
||||
deployRef: string;
|
||||
k3s?: {
|
||||
namespace: string;
|
||||
manifestPath: string;
|
||||
deploymentName: string;
|
||||
serviceName: string;
|
||||
servicePort: number;
|
||||
containerName: string;
|
||||
healthPath: string;
|
||||
};
|
||||
}
|
||||
|
||||
const artifactConsumerSpecs: Record<SupportedArtifactConsumerService, ArtifactConsumerSpec> = {
|
||||
"backend-core": {
|
||||
serviceId: "backend-core",
|
||||
kind: "compose",
|
||||
registryRepository: "unidesk/backend-core",
|
||||
dockerfile: "src/components/backend-core/Dockerfile",
|
||||
targetImage: "unidesk-backend-core",
|
||||
targetCommitImage: (commit: string) => `unidesk-backend-core:${commit}`,
|
||||
deployRef: "deploy.json#environments.prod.services.backend-core",
|
||||
},
|
||||
"decision-center": {
|
||||
serviceId: "decision-center",
|
||||
kind: "d601-k3s",
|
||||
registryRepository: "unidesk/decision-center",
|
||||
dockerfile: "src/components/microservices/decision-center/Dockerfile",
|
||||
targetImage: "unidesk-decision-center:d601",
|
||||
targetCommitImage: (commit: string) => `unidesk-decision-center:${commit}`,
|
||||
deployRef: "deploy.json#environments.prod.services.decision-center",
|
||||
k3s: {
|
||||
namespace: "unidesk",
|
||||
manifestPath: "/home/ubuntu/cq-deploy/src/components/microservices/k3sctl-adapter/k3s/decision-center.k8s.yaml",
|
||||
deploymentName: "decision-center",
|
||||
serviceName: "decision-center",
|
||||
servicePort: 4277,
|
||||
containerName: "decision-center",
|
||||
healthPath: "/health",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function isHelpArg(value: string | undefined): boolean {
|
||||
@@ -124,6 +179,12 @@ function parseOptions(args: string[]): ArtifactRegistryOptions {
|
||||
} else if (arg === "--target-image") {
|
||||
options.targetImage = requireValue(args, index, arg);
|
||||
index += 1;
|
||||
} else if (arg === "--service" || arg === "--service-id") {
|
||||
options.serviceId = requireValue(args, index, arg);
|
||||
index += 1;
|
||||
} else if (arg === "--source-repo") {
|
||||
options.sourceRepo = requireValue(args, index, arg);
|
||||
index += 1;
|
||||
} else {
|
||||
throw new Error(`unknown artifact-registry option: ${arg}`);
|
||||
}
|
||||
@@ -389,6 +450,27 @@ function commandTail(result: CommandResult): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
function artifactConsumerSpec(serviceId: string): ArtifactConsumerSpec | null {
|
||||
return (artifactConsumerSpecs as Record<string, ArtifactConsumerSpec | undefined>)[serviceId] ?? null;
|
||||
}
|
||||
|
||||
function unsupportedService(serviceId: string, options: ArtifactRegistryOptions): Record<string, unknown> {
|
||||
return {
|
||||
ok: false,
|
||||
supported: false,
|
||||
error: "unsupported",
|
||||
serviceId,
|
||||
providerId: options.providerId,
|
||||
reason: "No standardized D601 registry CD consumer is implemented for this service.",
|
||||
supportedServices: supportedArtifactConsumerServices,
|
||||
policy: "unsupported services must not silently fall back to maintenance-channel source builds or legacy direct deployment",
|
||||
};
|
||||
}
|
||||
|
||||
function artifactImageRef(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec, commit: string): string {
|
||||
return `127.0.0.1:${options.port}/${spec.registryRepository}:${commit}`;
|
||||
}
|
||||
|
||||
function runRemoteScript(options: ArtifactRegistryOptions, script: string, timeoutMs = options.timeoutMs): CommandResult {
|
||||
const command = [process.execPath, "scripts/cli.ts", "ssh", options.providerId, "argv", "bash", "-lc", script];
|
||||
return runCommand(command, repoRoot, { timeoutMs });
|
||||
@@ -587,7 +669,7 @@ function upsertEnvFileValues(path: string, values: Record<string, string>): void
|
||||
writeFileSync(path, `${lines.join("\n")}\n`, "utf8");
|
||||
}
|
||||
|
||||
function pullBackendCoreArtifactFromD601(options: ArtifactRegistryOptions, sourceImage: string): CommandResult {
|
||||
function pullArtifactFromD601(options: ArtifactRegistryOptions, sourceImage: string): CommandResult {
|
||||
const remoteScript = [
|
||||
"set -euo pipefail",
|
||||
`image=${shellQuote(sourceImage)}`,
|
||||
@@ -595,7 +677,7 @@ function pullBackendCoreArtifactFromD601(options: ArtifactRegistryOptions, sourc
|
||||
"trap 'rm -rf \"$DOCKER_CONFIG\"' EXIT",
|
||||
"printf '{}\\n' > \"$DOCKER_CONFIG/config.json\"",
|
||||
"docker pull -q \"$image\" >/dev/null",
|
||||
"docker image inspect \"$image\" --format 'remote_source={{ index .Config.Labels \"unidesk.ai/source-commit\" }} remote_service={{ index .Config.Labels \"unidesk.ai/service-id\" }}' >&2",
|
||||
"docker image inspect \"$image\" --format 'remote_source={{ index .Config.Labels \"unidesk.ai/source-commit\" }} remote_service={{ index .Config.Labels \"unidesk.ai/service-id\" }} remote_dockerfile={{ index .Config.Labels \"unidesk.ai/dockerfile\" }}' >&2",
|
||||
"docker save \"$image\" | gzip -1",
|
||||
].join("\n");
|
||||
const sshCommand = [
|
||||
@@ -615,37 +697,73 @@ function pullBackendCoreArtifactFromD601(options: ArtifactRegistryOptions, sourc
|
||||
return runCommand(["bash", "-lc", pipeline], repoRoot, { timeoutMs: Math.max(options.timeoutMs, 900_000) });
|
||||
}
|
||||
|
||||
async function deployBackendCoreNow(options: ArtifactRegistryOptions): Promise<Record<string, unknown>> {
|
||||
const commit = options.commit;
|
||||
if (commit === null) throw new Error("artifact-registry deploy-backend-core requires --commit <full-sha>");
|
||||
const health = runReadonlyStatus(options, true);
|
||||
if (health.ok !== true) {
|
||||
return { ok: false, error: "D601 artifact registry is not healthy", health };
|
||||
}
|
||||
const sourceImage = `127.0.0.1:${options.port}/unidesk/backend-core:${commit}`;
|
||||
const composeImage = options.targetImage;
|
||||
const commitImage = `${options.targetImage}:${commit}`;
|
||||
const registryProbeScript = [
|
||||
function registryArtifactProbeScript(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec, commit: string): string {
|
||||
const sourceImage = artifactImageRef(options, spec, commit);
|
||||
return [
|
||||
"set -euo pipefail",
|
||||
`registry_image=${shellQuote(sourceImage)}`,
|
||||
`manifest_url=${shellQuote(`http://127.0.0.1:${options.port}/v2/unidesk/backend-core/manifests/${commit}`)}`,
|
||||
"headers=$(mktemp /tmp/unidesk-backend-core-manifest.XXXXXX.headers)",
|
||||
`manifest_url=${shellQuote(`http://127.0.0.1:${options.port}/v2/${spec.registryRepository}/manifests/${commit}`)}`,
|
||||
"headers=$(mktemp /tmp/unidesk-artifact-manifest.XXXXXX.headers)",
|
||||
"trap 'rm -f \"$headers\"' EXIT",
|
||||
"curl -fsSI -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' -D \"$headers\" -o /dev/null \"$manifest_url\"",
|
||||
"manifest_digest=$(awk 'BEGIN{IGNORECASE=1} /^Docker-Content-Digest:/ {gsub(/\\r/, \"\", $2); print $2; exit}' \"$headers\")",
|
||||
"printf 'registry_image=%s\\nmanifest_url=%s\\nmanifest_digest=%s\\n' \"$registry_image\" \"$manifest_url\" \"$manifest_digest\"",
|
||||
].join("\n");
|
||||
const registryProbe = runRemoteScript(options, registryProbeScript, Math.max(options.timeoutMs, 120_000));
|
||||
}
|
||||
|
||||
function registryArtifactMissingMessage(spec: ArtifactConsumerSpec): string {
|
||||
return `${spec.serviceId} image artifact is missing from D601 registry; run CI artifact publication first`;
|
||||
}
|
||||
|
||||
function verifyLocalArtifactLabels(
|
||||
localLoadedImage: string,
|
||||
spec: ArtifactConsumerSpec,
|
||||
commit: string,
|
||||
): Record<string, unknown> | null {
|
||||
const inspectPulled = runCommand(["docker", "image", "inspect", localLoadedImage, "--format", "{{json .Config.Labels}}"], repoRoot);
|
||||
const labelCommit = runCommand(["docker", "image", "inspect", localLoadedImage, "--format", "{{ index .Config.Labels \"unidesk.ai/source-commit\" }}"], repoRoot);
|
||||
const labelService = runCommand(["docker", "image", "inspect", localLoadedImage, "--format", "{{ index .Config.Labels \"unidesk.ai/service-id\" }}"], repoRoot);
|
||||
const labelDockerfile = runCommand(["docker", "image", "inspect", localLoadedImage, "--format", "{{ index .Config.Labels \"unidesk.ai/dockerfile\" }}"], repoRoot);
|
||||
const observed = {
|
||||
commit: labelCommit.stdout.trim(),
|
||||
serviceId: labelService.stdout.trim(),
|
||||
dockerfile: labelDockerfile.stdout.trim(),
|
||||
labels: inspectPulled.stdout.trim(),
|
||||
};
|
||||
const ok = observed.commit === commit
|
||||
&& observed.serviceId === spec.serviceId
|
||||
&& observed.dockerfile === spec.dockerfile;
|
||||
if (ok) return null;
|
||||
return {
|
||||
ok: false,
|
||||
step: "image-label-verify",
|
||||
expected: { commit, serviceId: spec.serviceId, dockerfile: spec.dockerfile },
|
||||
observed,
|
||||
};
|
||||
}
|
||||
|
||||
async function deployBackendCoreNow(options: ArtifactRegistryOptions): Promise<Record<string, unknown>> {
|
||||
const commit = options.commit;
|
||||
if (commit === null) throw new Error("artifact-registry deploy-backend-core requires --commit <full-sha>");
|
||||
const spec = artifactConsumerSpecs["backend-core"];
|
||||
const health = runReadonlyStatus(options, true);
|
||||
if (health.ok !== true) {
|
||||
return { ok: false, error: "D601 artifact registry is not healthy", health };
|
||||
}
|
||||
const sourceImage = artifactImageRef(options, spec, commit);
|
||||
const composeImage = options.targetImage === defaultOptions.targetImage ? spec.targetImage : options.targetImage;
|
||||
const commitImage = `${composeImage}:${commit}`;
|
||||
const registryProbe = runRemoteScript(options, registryArtifactProbeScript(options, spec, commit), Math.max(options.timeoutMs, 120_000));
|
||||
if (registryProbe.exitCode !== 0 || registryProbe.timedOut) {
|
||||
return {
|
||||
ok: false,
|
||||
step: "registry-artifact-check",
|
||||
error: "backend-core image artifact is missing from D601 registry; run CI artifact publication first",
|
||||
error: registryArtifactMissingMessage(spec),
|
||||
sourceImage,
|
||||
registryProbe: commandTail(registryProbe),
|
||||
};
|
||||
}
|
||||
const pull = pullBackendCoreArtifactFromD601(options, sourceImage);
|
||||
const pull = pullArtifactFromD601(options, sourceImage);
|
||||
if (pull.exitCode !== 0 || pull.timedOut) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -656,18 +774,8 @@ async function deployBackendCoreNow(options: ArtifactRegistryOptions): Promise<R
|
||||
};
|
||||
}
|
||||
const localLoadedImage = sourceImage;
|
||||
const inspectPulled = runCommand(["docker", "image", "inspect", localLoadedImage, "--format", "{{json .Config.Labels}}"], repoRoot);
|
||||
const labelCommit = runCommand(["docker", "image", "inspect", localLoadedImage, "--format", "{{ index .Config.Labels \"unidesk.ai/source-commit\" }}"], repoRoot);
|
||||
const labelService = runCommand(["docker", "image", "inspect", localLoadedImage, "--format", "{{ index .Config.Labels \"unidesk.ai/service-id\" }}"], repoRoot);
|
||||
if (labelCommit.stdout.trim() !== commit || labelService.stdout.trim() !== "backend-core") {
|
||||
return {
|
||||
ok: false,
|
||||
step: "image-label-verify",
|
||||
expected: { commit, serviceId: "backend-core" },
|
||||
observed: { commit: labelCommit.stdout.trim(), serviceId: labelService.stdout.trim(), labels: inspectPulled.stdout.trim() },
|
||||
registryProbe: commandTail(registryProbe),
|
||||
};
|
||||
}
|
||||
const labelFailure = verifyLocalArtifactLabels(localLoadedImage, spec, commit);
|
||||
if (labelFailure !== null) return { ...labelFailure, registryProbe: commandTail(registryProbe) };
|
||||
const tag = runCommand(["docker", "tag", localLoadedImage, composeImage], repoRoot);
|
||||
if (tag.exitCode !== 0 || tag.timedOut) {
|
||||
return { ok: false, step: "docker-tag", targetImage: composeImage, tag: commandTail(tag), registryProbe: commandTail(registryProbe) };
|
||||
@@ -679,9 +787,9 @@ async function deployBackendCoreNow(options: ArtifactRegistryOptions): Promise<R
|
||||
const config = readConfig();
|
||||
const runtimeEnv = writeComposeEnv(config, false);
|
||||
upsertEnvFileValues(runtimeEnv.envFile, {
|
||||
UNIDESK_DEPLOY_REF: "deploy.json#environments.prod.services.backend-core",
|
||||
UNIDESK_DEPLOY_REF: spec.deployRef,
|
||||
UNIDESK_DEPLOY_SERVICE_ID: "backend-core",
|
||||
UNIDESK_DEPLOY_REPO: "https://github.com/pikasTech/unidesk",
|
||||
UNIDESK_DEPLOY_REPO: options.sourceRepo,
|
||||
UNIDESK_DEPLOY_COMMIT: commit,
|
||||
UNIDESK_DEPLOY_REQUESTED_COMMIT: commit,
|
||||
});
|
||||
@@ -756,10 +864,305 @@ function deployBackendCoreJob(args: string[], options: ArtifactRegistryOptions):
|
||||
};
|
||||
}
|
||||
|
||||
function dryRunArtifactConsumerPlan(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec, commit: string): Record<string, unknown> {
|
||||
const sourceImage = artifactImageRef(options, spec, commit);
|
||||
const common = {
|
||||
ok: true,
|
||||
supported: true,
|
||||
dryRun: true,
|
||||
mutation: false,
|
||||
providerId: options.providerId,
|
||||
serviceId: spec.serviceId,
|
||||
commit,
|
||||
sourceRepo: options.sourceRepo,
|
||||
sourceImage,
|
||||
requiredLabels: {
|
||||
"unidesk.ai/service-id": spec.serviceId,
|
||||
"unidesk.ai/source-commit": commit,
|
||||
"unidesk.ai/dockerfile": spec.dockerfile,
|
||||
},
|
||||
registryProbe: {
|
||||
method: "HEAD",
|
||||
url: `http://127.0.0.1:${options.port}/v2/${spec.registryRepository}/manifests/${commit}`,
|
||||
},
|
||||
boundary: "prod CD is artifact-consumer only: verify commit-pinned registry image, pull/import, deploy, then verify live commit/image/health; it never builds source on prod",
|
||||
};
|
||||
if (spec.kind === "compose") {
|
||||
return {
|
||||
...common,
|
||||
target: {
|
||||
kind: "compose",
|
||||
composeService: "backend-core",
|
||||
targetImage: options.targetImage === defaultOptions.targetImage ? spec.targetImage : options.targetImage,
|
||||
deployCommandShape: "docker compose up -d --no-build --no-deps --force-recreate backend-core",
|
||||
},
|
||||
validation: [
|
||||
"D601 registry /v2 manifest exists for the commit tag",
|
||||
"loaded image labels match service id, source commit, and Dockerfile",
|
||||
"running Compose container image label matches the requested commit",
|
||||
"backend-core /health succeeds for the recreated container",
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...common,
|
||||
target: {
|
||||
kind: "d601-k3s",
|
||||
namespace: spec.k3s?.namespace,
|
||||
deployment: spec.k3s?.deploymentName,
|
||||
service: spec.k3s?.serviceName,
|
||||
stableImage: spec.targetImage,
|
||||
runtimeImage: spec.targetCommitImage(commit),
|
||||
manifestPath: spec.k3s?.manifestPath,
|
||||
deployCommandShape: "kubectl set image + set env + annotate + rollout status",
|
||||
},
|
||||
validation: [
|
||||
"D601 registry /v2 manifest exists for the commit tag before mutation",
|
||||
"D601 Docker-pulled image labels match service id, source commit, and Dockerfile",
|
||||
"native k3s containerd has the commit image and stable runtime image tag",
|
||||
"Deployment annotation and pod image id label match the requested commit",
|
||||
"service health via Kubernetes API service proxy returns the same deploy.commit",
|
||||
],
|
||||
rollback: rollbackInfo(spec, commit),
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackInfo(spec: ArtifactConsumerSpec, commit: string): Record<string, unknown> {
|
||||
if (spec.kind === "compose") {
|
||||
return {
|
||||
type: "compose-retag-recreate",
|
||||
previousImageHint: "Use docker image ls / docker inspect to find the previous labeled backend-core image id; Compose volumes are unchanged.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "d601-k3s-previous-commit",
|
||||
serviceId: spec.serviceId,
|
||||
currentCommit: commit,
|
||||
discovery: `kubectl -n ${spec.k3s?.namespace} rollout history deployment/${spec.k3s?.deploymentName} && kubectl -n ${spec.k3s?.namespace} get deployment ${spec.k3s?.deploymentName} -o jsonpath='{.metadata.annotations.unidesk\\.ai/deploy-previous-commit}'`,
|
||||
commandShape: `bun scripts/cli.ts deploy apply --env prod --service ${spec.serviceId} --commit <previous-full-sha>`,
|
||||
note: "Rollback is exposed as the same artifact consumer pointed at a previous commit-pinned image that still exists in D601 registry.",
|
||||
};
|
||||
}
|
||||
|
||||
function d601K3sArtifactDeployScript(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec, commit: string): string {
|
||||
if (spec.k3s === undefined) throw new Error(`${spec.serviceId} missing k3s artifact consumer config`);
|
||||
const sourceImage = artifactImageRef(options, spec, commit);
|
||||
const commitImage = spec.targetCommitImage(commit);
|
||||
const k3s = spec.k3s;
|
||||
const labels = [
|
||||
`unidesk.ai/deploy-service-id=${spec.serviceId}`,
|
||||
`unidesk.ai/deploy-repo=${options.sourceRepo}`,
|
||||
`unidesk.ai/deploy-commit=${commit}`,
|
||||
`unidesk.ai/deploy-requested-commit=${commit}`,
|
||||
`unidesk.ai/image-source=${sourceImage}`,
|
||||
];
|
||||
return [
|
||||
"set -euo pipefail",
|
||||
rootExecPrelude(),
|
||||
`registry_image=${shellQuote(sourceImage)}`,
|
||||
`stable_image=${shellQuote(spec.targetImage)}`,
|
||||
`commit_image=${shellQuote(commitImage)}`,
|
||||
`service_id=${shellQuote(spec.serviceId)}`,
|
||||
`source_repo=${shellQuote(options.sourceRepo)}`,
|
||||
`commit=${shellQuote(commit)}`,
|
||||
`dockerfile=${shellQuote(spec.dockerfile)}`,
|
||||
`namespace=${shellQuote(k3s.namespace)}`,
|
||||
`deployment=${shellQuote(k3s.deploymentName)}`,
|
||||
`container_name=${shellQuote(k3s.containerName)}`,
|
||||
`service_name=${shellQuote(k3s.serviceName)}`,
|
||||
`service_port=${shellQuote(String(k3s.servicePort))}`,
|
||||
`health_path=${shellQuote(k3s.healthPath)}`,
|
||||
`manifest=${shellQuote(k3s.manifestPath)}`,
|
||||
"export KUBECONFIG=/etc/rancher/k3s/k3s.yaml",
|
||||
"command -v docker >/dev/null",
|
||||
"command -v kubectl >/dev/null",
|
||||
"command -v ctr >/dev/null",
|
||||
"test -S /run/k3s/containerd/containerd.sock",
|
||||
`curl -fsSI -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' ${shellQuote(`http://127.0.0.1:${options.port}/v2/${spec.registryRepository}/manifests/${commit}`)} >/dev/null`,
|
||||
"docker pull -q \"$registry_image\" >/dev/null",
|
||||
"label_commit=$(docker image inspect \"$registry_image\" --format '{{ index .Config.Labels \"unidesk.ai/source-commit\" }}')",
|
||||
"label_service=$(docker image inspect \"$registry_image\" --format '{{ index .Config.Labels \"unidesk.ai/service-id\" }}')",
|
||||
"label_dockerfile=$(docker image inspect \"$registry_image\" --format '{{ index .Config.Labels \"unidesk.ai/dockerfile\" }}')",
|
||||
"test \"$label_commit\" = \"$commit\"",
|
||||
"test \"$label_service\" = \"$service_id\"",
|
||||
"test \"$label_dockerfile\" = \"$dockerfile\"",
|
||||
"docker tag \"$registry_image\" \"$stable_image\"",
|
||||
"docker tag \"$registry_image\" \"$commit_image\"",
|
||||
"archive=$(mktemp /tmp/unidesk-artifact-k3s-image.XXXXXX.tar)",
|
||||
"trap 'rm -f \"$archive\" \"$health_tmp\"' EXIT",
|
||||
"docker save \"$commit_image\" \"$stable_image\" -o \"$archive\"",
|
||||
"root_exec ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images import \"$archive\" >/dev/null",
|
||||
"root_exec ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images ls | grep -F \"$stable_image\" >/dev/null",
|
||||
"root_exec ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images ls | grep -F \"$commit_image\" >/dev/null",
|
||||
"if [ -f \"$manifest\" ]; then kubectl apply -f \"$manifest\"; else echo artifact_cd_manifest_missing=$manifest; fi",
|
||||
"previous_commit=$(kubectl -n \"$namespace\" get deployment \"$deployment\" -o jsonpath='{.metadata.annotations.unidesk\\.ai/deploy-commit}' 2>/dev/null || true)",
|
||||
"kubectl -n \"$namespace\" set image \"deployment/$deployment\" \"$container_name=$commit_image\"",
|
||||
"kubectl -n \"$namespace\" set env \"deployment/$deployment\" \"UNIDESK_DEPLOY_SERVICE_ID=$service_id\" \"UNIDESK_DEPLOY_REPO=$source_repo\" \"UNIDESK_DEPLOY_COMMIT=$commit\" \"UNIDESK_DEPLOY_REQUESTED_COMMIT=$commit\"",
|
||||
`kubectl -n "$namespace" annotate "deployment/$deployment" ${labels.map(shellQuote).join(" ")} --overwrite`,
|
||||
"if [ -n \"$previous_commit\" ] && [ \"$previous_commit\" != \"$commit\" ]; then kubectl -n \"$namespace\" annotate \"deployment/$deployment\" \"unidesk.ai/deploy-previous-commit=$previous_commit\" --overwrite; fi",
|
||||
"kubectl -n \"$namespace\" rollout status \"deployment/$deployment\" --timeout=180s",
|
||||
"deployment_commit=$(kubectl -n \"$namespace\" get deployment \"$deployment\" -o jsonpath='{.metadata.annotations.unidesk\\.ai/deploy-commit}')",
|
||||
"test \"$deployment_commit\" = \"$commit\"",
|
||||
"deployment_image=$(kubectl -n \"$namespace\" get deployment \"$deployment\" -o jsonpath='{.spec.template.spec.containers[?(@.name==\"'\"$container_name\"'\")].image}')",
|
||||
"test \"$deployment_image\" = \"$commit_image\"",
|
||||
"containerd_config_label() {",
|
||||
" ref=\"$1\"",
|
||||
" key=\"$2\"",
|
||||
" target_digest=$(root_exec ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images info \"$ref\" | python3 -c 'import json,sys; print(((json.load(sys.stdin).get(\"Target\") or {}).get(\"Digest\")) or \"\")')",
|
||||
" test -n \"$target_digest\"",
|
||||
" python3 - \"$target_digest\" \"$key\" <<'PY'",
|
||||
"import json",
|
||||
"import subprocess",
|
||||
"import sys",
|
||||
"",
|
||||
"digest, key = sys.argv[1:3]",
|
||||
"base = ['ctr', '--address', '/run/k3s/containerd/containerd.sock', '-n', 'k8s.io', 'content', 'get']",
|
||||
"",
|
||||
"def content(ref):",
|
||||
" return subprocess.check_output(base + [ref])",
|
||||
"",
|
||||
"def labels_from(ref):",
|
||||
" obj = json.loads(content(ref))",
|
||||
" if isinstance(obj.get('manifests'), list):",
|
||||
" for item in obj['manifests']:",
|
||||
" platform = item.get('platform') or {}",
|
||||
" if platform.get('architecture') in ('amd64', '') or not platform:",
|
||||
" value = labels_from(item.get('digest', ''))",
|
||||
" if value:",
|
||||
" return value",
|
||||
" return ''",
|
||||
" config = obj.get('config') or {}",
|
||||
" config_digest = config.get('digest') or ''",
|
||||
" if not config_digest:",
|
||||
" return ''",
|
||||
" cfg = json.loads(content(config_digest))",
|
||||
" return str(((cfg.get('config') or {}).get('Labels') or {}).get(key) or '')",
|
||||
"",
|
||||
"print(labels_from(digest))",
|
||||
"PY",
|
||||
"}",
|
||||
"image_label_commit=$(containerd_config_label \"$commit_image\" 'unidesk.ai/source-commit')",
|
||||
"image_label_service=$(containerd_config_label \"$commit_image\" 'unidesk.ai/service-id')",
|
||||
"test \"$image_label_commit\" = \"$commit\"",
|
||||
"test \"$image_label_service\" = \"$service_id\"",
|
||||
"pod=$(kubectl -n \"$namespace\" get pod -l \"app.kubernetes.io/name=$deployment\" -o jsonpath='{.items[0].metadata.name}')",
|
||||
"test -n \"$pod\"",
|
||||
"pod_image=$(kubectl -n \"$namespace\" get pod \"$pod\" -o jsonpath='{.spec.containers[?(@.name==\"'\"$container_name\"'\")].image}')",
|
||||
"test \"$pod_image\" = \"$commit_image\"",
|
||||
"pod_image_id=$(kubectl -n \"$namespace\" get pod \"$pod\" -o jsonpath='{.status.containerStatuses[?(@.name==\"'\"$container_name\"'\")].imageID}')",
|
||||
"if [ -z \"$pod_image_id\" ]; then pod_image_id=$(kubectl -n \"$namespace\" get pod \"$pod\" -o jsonpath='{.status.containerStatuses[0].imageID}'); fi",
|
||||
"case \"$health_path\" in /*) ;; *) health_path=\"/$health_path\" ;; esac",
|
||||
"proxy_path=\"/api/v1/namespaces/$namespace/services/http:$service_name:$service_port/proxy$health_path\"",
|
||||
"health_tmp=$(mktemp /tmp/unidesk-artifact-health.XXXXXX.json)",
|
||||
"for attempt in $(seq 1 60); do",
|
||||
" if kubectl get --raw \"$proxy_path\" > \"$health_tmp\" 2>/tmp/unidesk-artifact-health.err; then",
|
||||
" health_commit=$(python3 -c 'import json,sys; print(((json.load(open(sys.argv[1])).get(\"deploy\") or {}).get(\"commit\") or \"\"))' \"$health_tmp\" 2>/dev/null || true)",
|
||||
" health_ok=$(python3 -c 'import json,sys; print(\"true\" if json.load(open(sys.argv[1])).get(\"ok\") is True else \"false\")' \"$health_tmp\" 2>/dev/null || true)",
|
||||
" echo \"artifact_cd_health_probe attempt=$attempt ok=$health_ok commit=$health_commit\"",
|
||||
" if [ \"$health_ok\" = \"true\" ] && [ \"$health_commit\" = \"$commit\" ]; then break; fi",
|
||||
" else",
|
||||
" echo \"artifact_cd_health_probe attempt=$attempt request=failed\"",
|
||||
" fi",
|
||||
" if [ \"$attempt\" = \"60\" ]; then echo artifact_cd_health_failed >&2; cat \"$health_tmp\" >&2 || true; exit 1; fi",
|
||||
" sleep 2",
|
||||
"done",
|
||||
"printf 'artifact_cd_service=%s\\nartifact_cd_source_image=%s\\nartifact_cd_stable_image=%s\\nartifact_cd_runtime_image=%s\\nartifact_cd_commit=%s\\nartifact_cd_previous_commit=%s\\nartifact_cd_pod=%s\\nartifact_cd_pod_image=%s\\nartifact_cd_pod_image_id=%s\\nartifact_cd_image_label_commit=%s\\nartifact_cd_health_commit=%s\\n' \"$service_id\" \"$registry_image\" \"$stable_image\" \"$commit_image\" \"$commit\" \"$previous_commit\" \"$pod\" \"$pod_image\" \"$pod_image_id\" \"$image_label_commit\" \"$health_commit\"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function deployD601K3sArtifactNow(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec): Promise<Record<string, unknown>> {
|
||||
const commit = options.commit;
|
||||
if (commit === null) throw new Error("artifact-registry deploy-service requires --commit <full-sha>");
|
||||
const health = runReadonlyStatus(options, true);
|
||||
if (health.ok !== true) return { ok: false, serviceId: spec.serviceId, error: "D601 artifact registry is not healthy", health };
|
||||
const sourceImage = artifactImageRef(options, spec, commit);
|
||||
const registryProbe = runRemoteScript(options, registryArtifactProbeScript(options, spec, commit), Math.max(options.timeoutMs, 120_000));
|
||||
if (registryProbe.exitCode !== 0 || registryProbe.timedOut) {
|
||||
return {
|
||||
ok: false,
|
||||
supported: true,
|
||||
serviceId: spec.serviceId,
|
||||
step: "registry-artifact-check",
|
||||
error: registryArtifactMissingMessage(spec),
|
||||
sourceImage,
|
||||
registryProbe: commandTail(registryProbe),
|
||||
};
|
||||
}
|
||||
const deployScript = d601K3sArtifactDeployScript(options, spec, commit);
|
||||
const deploy = runRemoteScript(options, deployScript, Math.max(options.timeoutMs, 420_000));
|
||||
if (deploy.exitCode !== 0 || deploy.timedOut) {
|
||||
return {
|
||||
ok: false,
|
||||
supported: true,
|
||||
serviceId: spec.serviceId,
|
||||
step: "d601-k3s-artifact-deploy",
|
||||
sourceImage,
|
||||
registryProbe: commandTail(registryProbe),
|
||||
deploy: commandTail(deploy),
|
||||
rollback: rollbackInfo(spec, commit),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
supported: true,
|
||||
serviceId: spec.serviceId,
|
||||
commit,
|
||||
providerId: options.providerId,
|
||||
sourceRepo: options.sourceRepo,
|
||||
sourceImage,
|
||||
stableImage: spec.targetImage,
|
||||
runtimeImage: spec.targetCommitImage(commit),
|
||||
registryProbe: commandTail(registryProbe),
|
||||
deploy: commandTail(deploy),
|
||||
validation: {
|
||||
liveCommit: commit,
|
||||
imageLabelCommit: commit,
|
||||
serviceHealthCommit: commit,
|
||||
healthyOldVersionAccepted: false,
|
||||
},
|
||||
rollback: rollbackInfo(spec, commit),
|
||||
};
|
||||
}
|
||||
|
||||
async function deployServiceNow(options: ArtifactRegistryOptions): Promise<Record<string, unknown>> {
|
||||
if (options.serviceId === null) throw new Error("artifact-registry deploy-service requires --service <id>");
|
||||
if (options.commit === null) throw new Error("artifact-registry deploy-service requires --commit <full-sha>");
|
||||
const spec = artifactConsumerSpec(options.serviceId);
|
||||
if (spec === null) return unsupportedService(options.serviceId, options);
|
||||
if (options.dryRun) return dryRunArtifactConsumerPlan(options, spec, options.commit);
|
||||
if (spec.kind === "compose") return deployBackendCoreNow({ ...options, targetImage: options.targetImage || spec.targetImage });
|
||||
return deployD601K3sArtifactNow(options, spec);
|
||||
}
|
||||
|
||||
function deployServiceJob(args: string[], options: ArtifactRegistryOptions): Record<string, unknown> {
|
||||
if (options.serviceId === null) throw new Error("artifact-registry deploy-service requires --service <id>");
|
||||
if (options.commit === null) throw new Error("artifact-registry deploy-service requires --commit <full-sha>");
|
||||
const spec = artifactConsumerSpec(options.serviceId);
|
||||
if (spec === null) return unsupportedService(options.serviceId, options);
|
||||
if (options.dryRun) return dryRunArtifactConsumerPlan(options, spec, options.commit);
|
||||
const runArgs = args.includes("--run-now") ? args : [...args, "--run-now"];
|
||||
const command = [process.execPath, rootPath("scripts", "cli.ts"), "artifact-registry", ...runArgs];
|
||||
const job = startJob("artifact_registry_service_cd", command, `Pull and deploy ${options.serviceId} artifact ${options.commit} from D601 registry`);
|
||||
return {
|
||||
ok: true,
|
||||
mode: "async-job",
|
||||
serviceId: options.serviceId,
|
||||
job,
|
||||
statusCommand: `bun scripts/cli.ts job status ${job.id}`,
|
||||
tailCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 30000`,
|
||||
note: "User-service CD continues in the background: D601 registry health, commit-pinned artifact check, pull/import, rollout, image-label and live health commit verification.",
|
||||
rollback: rollbackInfo(spec, options.commit),
|
||||
};
|
||||
}
|
||||
|
||||
function serviceIdFromDeployBackendCoreArgs(action: ArtifactRegistryAction, options: ArtifactRegistryOptions): string | null {
|
||||
return action === "deploy-backend-core" ? "backend-core" : options.serviceId;
|
||||
}
|
||||
|
||||
function localHelp(): Record<string, unknown> {
|
||||
return {
|
||||
ok: true,
|
||||
command: "artifact-registry plan|render|status|health|install|deploy-backend-core",
|
||||
command: "artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service",
|
||||
output: "json",
|
||||
usage: [
|
||||
"bun scripts/cli.ts artifact-registry plan [--provider-id D601]",
|
||||
@@ -768,8 +1171,15 @@ function localHelp(): Record<string, unknown> {
|
||||
"bun scripts/cli.ts artifact-registry health [--provider-id D601]",
|
||||
"bun scripts/cli.ts artifact-registry install [--provider-id D601]",
|
||||
"bun scripts/cli.ts artifact-registry deploy-backend-core --commit <full-sha> [--run-now] [--provider-id D601]",
|
||||
"bun scripts/cli.ts artifact-registry deploy-service --service decision-center --commit <full-sha> [--dry-run] [--run-now] [--provider-id D601]",
|
||||
],
|
||||
firstStage: "install now writes the rendered systemd/Compose/config files and starts the registry",
|
||||
artifactConsumers: {
|
||||
supportedServices: supportedArtifactConsumerServices,
|
||||
unsupportedPolicy: "return structured unsupported; never fall back to legacy maintenance-channel source builds",
|
||||
prodCommand: "bun scripts/cli.ts deploy apply --env prod --service decision-center",
|
||||
rollbackShape: "rerun the same artifact consumer with a previous commit-pinned image",
|
||||
},
|
||||
defaults: defaultOptions,
|
||||
};
|
||||
}
|
||||
@@ -777,9 +1187,10 @@ function localHelp(): Record<string, unknown> {
|
||||
export async function runArtifactRegistryCommand(args: string[]): Promise<unknown> {
|
||||
const action = args[0];
|
||||
if (isHelpArg(action)) return localHelp();
|
||||
if (action !== "plan" && action !== "render" && action !== "status" && action !== "health" && action !== "install" && action !== "deploy-backend-core") {
|
||||
throw new Error("artifact-registry usage: plan|render|status|health|install|deploy-backend-core");
|
||||
if (action !== "plan" && action !== "render" && action !== "status" && action !== "health" && action !== "install" && action !== "deploy-backend-core" && action !== "deploy-service") {
|
||||
throw new Error("artifact-registry usage: plan|render|status|health|install|deploy-backend-core|deploy-service");
|
||||
}
|
||||
const typedAction = action as ArtifactRegistryAction;
|
||||
const options = parseOptions(args.slice(1));
|
||||
if (action === "plan") return plan(options);
|
||||
if (action === "render") return { ok: true, providerId: options.providerId, render: renderBundle(options) };
|
||||
@@ -792,5 +1203,9 @@ export async function runArtifactRegistryCommand(args: string[]): Promise<unknow
|
||||
if (options.commit === null) throw new Error("artifact-registry deploy-backend-core requires --commit <full-sha>");
|
||||
return options.runNow ? await deployBackendCoreNow(options) : deployBackendCoreJob(args, options);
|
||||
}
|
||||
if (action === "deploy-service") {
|
||||
const serviceId = serviceIdFromDeployBackendCoreArgs(typedAction, options);
|
||||
return options.runNow || options.dryRun ? await deployServiceNow({ ...options, serviceId }) : deployServiceJob(args, { ...options, serviceId });
|
||||
}
|
||||
throw new Error("unreachable artifact-registry action");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user