736 lines
39 KiB
TypeScript
736 lines
39 KiB
TypeScript
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. scripts module for scripts/src/deploy.ts.
|
|
|
|
// Moved mechanically from scripts/src/deploy.ts:1519-2218 for #903.
|
|
|
|
import { createHash } from "node:crypto";
|
|
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { runCommand } from "../command";
|
|
import { type UniDeskConfig, type UniDeskMicroserviceConfig, repoRoot, rootPath } from "../config";
|
|
import { ensureGithubSshIdentityForProvider } from "../deploy-ssh-identity";
|
|
import { baiduNetdiskRuntimeSecretRequirements, runtimeSecretContractFromEnvText, type RuntimeSecretContract, runArtifactRegistryCommand } from "../artifact-registry";
|
|
import { startJob } from "../jobs";
|
|
import { coreInternalFetch } from "../microservices";
|
|
import { codeQueueSourceImportPreflight, codeQueueSourceSubdir } from "../code-queue-source-guard";
|
|
import { k3sGuardShellLines, defaultNativeKubeconfig } from "../k3s-target-guard";
|
|
import { composeRuntimeEnvValue } from "../runtime-env";
|
|
import {
|
|
compareDeployJsonExecutorMirrors,
|
|
deployJsonCommitImage,
|
|
deployJsonDriftResult,
|
|
deployJsonSourceOfTruth,
|
|
encodeDeployJsonServiceContract,
|
|
hasDeployJsonExecutorContract,
|
|
k3sManifestExecutorMirror,
|
|
parseDeployJsonServiceContract,
|
|
type DeployJsonExecutorMirror,
|
|
type DeployJsonServiceContract,
|
|
} from "../deploy-json-contract";
|
|
|
|
import type { DeployManifestService } from "./types";
|
|
import { k3sTargetGuardScript, isUnideskRepo, providerSourceRepoUrl, safeId, shellQuote } from "./options";
|
|
import { buildBaseImageFallbacks, buildCachePrelude, buildImageTag, devK3sPrepullImages, directBuildContextOverride, directComposeEnvFile, directComposeFile, directDockerfileOverride, k8sManifestPath, sourceBuildContext, sourceDockerfilePath, sourceFallbackWorktree, sourceProxyPrelude, sourceWorkDir, targetIsMain, targetRepoDir, targetWorkDir } from "./paths";
|
|
import { isDevK3sDeployService, isDirectComposeDeployMode } from "./service-plan";
|
|
import { k8sKubeconfig, nativeK3sCtrAddress, nativeK3sImage, nativeK3sInstallVersion, providerGatewayWsEgressProxyUrl } from "./types";
|
|
|
|
export function syncDevFrontendAuthScript(config: UniDeskConfig): string {
|
|
const sshClientToken = composeRuntimeEnvValue("UNIDESK_SSH_CLIENT_TOKEN");
|
|
if (sshClientToken === null) throw new Error(`UNIDESK_SSH_CLIENT_TOKEN must be present in ${rootPath(".state", "docker-compose.env")} before deploying dev frontend`);
|
|
const sshClientRouteAllowlist = composeRuntimeEnvValue("UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST") ?? "G14,G14:*,D601,D601:*";
|
|
const data = {
|
|
AUTH_USERNAME: Buffer.from(config.auth.username, "utf8").toString("base64"),
|
|
AUTH_PASSWORD: Buffer.from(config.auth.password, "utf8").toString("base64"),
|
|
SESSION_SECRET: Buffer.from(config.auth.sessionSecret, "utf8").toString("base64"),
|
|
UNIDESK_SSH_CLIENT_TOKEN: Buffer.from(sshClientToken, "utf8").toString("base64"),
|
|
};
|
|
const runtimeConfig = {
|
|
SESSION_TTL_SECONDS: String(config.auth.sessionTtlSeconds),
|
|
UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST: sshClientRouteAllowlist,
|
|
};
|
|
return [
|
|
"set -euo pipefail",
|
|
k3sTargetGuardScript(),
|
|
`secret_patch=${shellQuote(JSON.stringify({ data }))}`,
|
|
`config_patch=${shellQuote(JSON.stringify({ data: runtimeConfig }))}`,
|
|
`kubectl -n unidesk-dev patch secret unidesk-dev-runtime-secrets --type merge -p "$secret_patch"`,
|
|
`kubectl -n unidesk-dev patch configmap unidesk-dev-runtime-config --type merge -p "$config_patch"`,
|
|
"echo dev_frontend_auth_synced=ok",
|
|
].join("\n");
|
|
}
|
|
|
|
export function prepareSourceScript(service: UniDeskMicroserviceConfig, desired: DeployManifestService, exportDir: string): string {
|
|
if (targetIsMain(service) && isUnideskRepo(desired.repo)) {
|
|
return [
|
|
"set -euo pipefail",
|
|
`repo=${shellQuote(repoRoot)}`,
|
|
`commit=${shellQuote(desired.commitId)}`,
|
|
`export_dir=${shellQuote(exportDir)}`,
|
|
"mkdir -p \"$(dirname \"$export_dir\")\"",
|
|
"git -C \"$repo\" fetch --no-tags origin \"$commit\" || git -C \"$repo\" fetch --no-tags --prune origin '+refs/heads/*:refs/remotes/origin/*' '+refs/tags/*:refs/tags/*'",
|
|
"resolved=$(git -C \"$repo\" rev-parse --verify \"$commit^{commit}\")",
|
|
"rm -rf \"$export_dir\"",
|
|
"mkdir -p \"$export_dir\"",
|
|
"git -C \"$repo\" archive --format=tar \"$resolved\" | tar -xf - -C \"$export_dir\"",
|
|
"printf 'resolved_commit=%s\\nexport_dir=%s\\nsource_repo=%s\\n' \"$resolved\" \"$export_dir\" \"$repo\"",
|
|
].join("\n");
|
|
}
|
|
const fallbackWorktree = sourceFallbackWorktree(service);
|
|
return [
|
|
"set -euo pipefail",
|
|
sourceProxyPrelude(service),
|
|
`repo=${shellQuote(targetRepoDir(service))}`,
|
|
`repo_url=${shellQuote(providerSourceRepoUrl(desired.repo))}`,
|
|
`commit=${shellQuote(desired.commitId)}`,
|
|
`export_dir=${shellQuote(exportDir)}`,
|
|
`fallback_worktree=${shellQuote(fallbackWorktree)}`,
|
|
"mkdir -p \"$(dirname \"$repo\")\" \"$(dirname \"$export_dir\")\"",
|
|
"source_repo=\"\"",
|
|
"source_mode=\"remote-fetch\"",
|
|
"source_log=$(mktemp /tmp/unidesk-deploy-source.XXXXXX.log)",
|
|
"if { [ -d \"$repo/.git\" ] || { rm -rf \"$repo\" && git clone --no-checkout \"$repo_url\" \"$repo\"; }; } >\"$source_log\" 2>&1; then",
|
|
" cd \"$repo\"",
|
|
" if { git remote set-url origin \"$repo_url\" && { git fetch --no-tags origin \"$commit\" || git fetch --no-tags --prune origin '+refs/heads/*:refs/remotes/origin/*' '+refs/tags/*:refs/tags/*'; } && git rev-parse --verify \"$commit^{commit}\" > /tmp/unidesk-deploy-resolved-commit; } >>\"$source_log\" 2>&1; then",
|
|
" resolved=$(cat /tmp/unidesk-deploy-resolved-commit)",
|
|
" source_repo=\"$repo\"",
|
|
" fi",
|
|
"fi",
|
|
"if [ -z \"$source_repo\" ]; then",
|
|
" echo target_source_remote_prepare=failed",
|
|
" tail -80 \"$source_log\" || true",
|
|
" if [ -n \"$fallback_worktree\" ] && git -C \"$fallback_worktree\" rev-parse --is-inside-work-tree >/dev/null 2>&1 && git -C \"$fallback_worktree\" rev-parse --verify \"$commit^{commit}\" > /tmp/unidesk-deploy-resolved-commit 2>/dev/null; then",
|
|
" resolved=$(cat /tmp/unidesk-deploy-resolved-commit)",
|
|
" source_repo=$(git -C \"$fallback_worktree\" rev-parse --show-toplevel)",
|
|
" source_mode=\"provider-worktree\"",
|
|
" printf 'target_source_fallback_worktree=%s\\n' \"$fallback_worktree\"",
|
|
" else",
|
|
" cat \"$source_log\" >&2 || true",
|
|
" exit 128",
|
|
" fi",
|
|
"fi",
|
|
"rm -rf \"$export_dir\"",
|
|
"mkdir -p \"$export_dir\"",
|
|
"git -C \"$source_repo\" archive --format=tar \"$resolved\" | tar -xf - -C \"$export_dir\"",
|
|
"rm -f \"$source_log\" /tmp/unidesk-deploy-resolved-commit",
|
|
"printf 'resolved_commit=%s\\nexport_dir=%s\\nsource_repo=%s\\nsource_mode=%s\\n' \"$resolved\" \"$export_dir\" \"$source_repo\" \"$source_mode\"",
|
|
].filter((line) => line.length > 0).join("\n");
|
|
}
|
|
|
|
export function syncSourceScript(service: UniDeskMicroserviceConfig, exportDir: string): string {
|
|
const workDir = sourceWorkDir(service);
|
|
const buildContext = sourceBuildContext(service);
|
|
const dockerfilePath = sourceDockerfilePath(service);
|
|
const overlayCommands = service.id === "claudeqq" ? claudeqqDeployAssetOverlayCommands() : [];
|
|
return [
|
|
"set -euo pipefail",
|
|
`export_dir=${shellQuote(exportDir)}`,
|
|
`work_dir=${shellQuote(workDir)}`,
|
|
`build_context=${shellQuote(buildContext)}`,
|
|
`dockerfile_path=${shellQuote(dockerfilePath)}`,
|
|
"mkdir -p \"$work_dir\"",
|
|
[
|
|
"rsync -a --delete",
|
|
"--exclude '.git/'",
|
|
"--exclude '.state/'",
|
|
"--exclude 'logs/'",
|
|
"--exclude '**/node_modules/'",
|
|
"--exclude '**/dist/'",
|
|
"\"$export_dir/\"",
|
|
"\"$work_dir/\"",
|
|
].join(" "),
|
|
...overlayCommands,
|
|
"test -f \"$build_context/$dockerfile_path\"",
|
|
"printf 'synced deploy worktree to %s\\nbuild_context=%s\\n' \"$work_dir\" \"$build_context\"",
|
|
].join("\n");
|
|
}
|
|
|
|
export function codeQueueSourcePreflightScript(service: UniDeskMicroserviceConfig): string {
|
|
if (service.id !== "code-queue") return "";
|
|
const workDir = sourceWorkDir(service);
|
|
return [
|
|
"set -euo pipefail",
|
|
`guard_root=${shellQuote(workDir)}`,
|
|
"if [ -f \"$guard_root/scripts/code-queue-source-guard.ts\" ] && command -v bun >/dev/null 2>&1; then",
|
|
" bun \"$guard_root/scripts/code-queue-source-guard.ts\" --root \"$guard_root\"",
|
|
"else",
|
|
" python3 - \"$guard_root\" <<'PY'",
|
|
"import json",
|
|
"import os",
|
|
"import re",
|
|
"import sys",
|
|
"",
|
|
`SOURCE_SUBDIR = ${JSON.stringify(codeQueueSourceSubdir)}`,
|
|
"EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.json']",
|
|
"",
|
|
"def rel(root, path):",
|
|
" return os.path.relpath(path, root).replace(os.sep, '/')",
|
|
"",
|
|
"def strip_comments(text):",
|
|
" text = re.sub(r'/\\*[\\s\\S]*?\\*/', '', text)",
|
|
" return re.sub(r'(^|[^:])//.*$', r'\\1', text, flags=re.MULTILINE)",
|
|
"",
|
|
"def specifiers(text):",
|
|
" clean = strip_comments(text)",
|
|
" values = set()",
|
|
" patterns = [",
|
|
" re.compile(r'\\b(?:import|export)\\s+(?:type\\s+)?(?:[\\s\\S]*?\\s+from\\s+)?[\"\\'](\\.{1,2}/[^\"\\']+)[\"\\']'),",
|
|
" re.compile(r'\\bimport\\s*\\(\\s*[\"\\'](\\.{1,2}/[^\"\\']+)[\"\\']\\s*\\)'),",
|
|
" ]",
|
|
" for pattern in patterns:",
|
|
" values.update(match.group(1) for match in pattern.finditer(clean))",
|
|
" return sorted(values)",
|
|
"",
|
|
"def candidates(importer, specifier):",
|
|
" base = os.path.abspath(os.path.join(os.path.dirname(importer), specifier))",
|
|
" if any(base.endswith(extension) for extension in EXTENSIONS):",
|
|
" return [base]",
|
|
" return [base + extension for extension in EXTENSIONS] + [os.path.join(base, 'index' + extension) for extension in EXTENSIONS]",
|
|
"",
|
|
"root = os.path.abspath(sys.argv[1])",
|
|
"source_root = os.path.join(root, SOURCE_SUBDIR)",
|
|
"if not os.path.isdir(source_root):",
|
|
" print(json.dumps({'ok': False, 'guard': 'code-queue-hostpath-source-imports', 'root': root, 'sourceRoot': source_root, 'checkedFiles': 0, 'checkedImports': 0, 'missing': [], 'degradedReason': 'source-root-missing', 'message': 'Code Queue source root is missing: ' + SOURCE_SUBDIR}, ensure_ascii=False))",
|
|
" raise SystemExit(1)",
|
|
"files = []",
|
|
"for current_root, dirs, names in os.walk(source_root):",
|
|
" dirs[:] = [name for name in dirs if name not in ('node_modules', 'dist', '.git', '.state')]",
|
|
" for name in names:",
|
|
" if name.endswith('.ts') or name.endswith('.tsx'):",
|
|
" files.append(os.path.join(current_root, name))",
|
|
"files.sort()",
|
|
"missing = []",
|
|
"checked_imports = 0",
|
|
"for path in files:",
|
|
" with open(path, encoding='utf-8') as handle:",
|
|
" text = handle.read()",
|
|
" for specifier in specifiers(text):",
|
|
" checked_imports += 1",
|
|
" expected = candidates(path, specifier)",
|
|
" if any(os.path.isfile(candidate) for candidate in expected):",
|
|
" continue",
|
|
" missing.append({'importer': rel(root, path), 'specifier': specifier, 'expected': [rel(root, candidate) for candidate in expected]})",
|
|
"result = {'ok': not missing, 'guard': 'code-queue-hostpath-source-imports', 'root': root, 'sourceRoot': source_root, 'checkedFiles': len(files), 'checkedImports': checked_imports, 'missing': missing, 'degradedReason': 'none' if not missing else 'missing-relative-import-target', 'message': 'Code Queue hostPath source import preflight passed (%d files, %d relative imports).' % (len(files), checked_imports) if not missing else 'Code Queue hostPath source import preflight failed: %d relative import target(s) are missing.' % len(missing)}",
|
|
"print(json.dumps(result, ensure_ascii=False))",
|
|
"raise SystemExit(0 if result['ok'] else 1)",
|
|
"PY",
|
|
"fi",
|
|
].join("\n");
|
|
}
|
|
|
|
export function claudeqqDeployAssetOverlayCommands(): string[] {
|
|
const assets = [
|
|
{
|
|
relativePath: "$build_context/$dockerfile_path",
|
|
sourcePath: rootPath("src/components/microservices/claudeqq/Dockerfile"),
|
|
label: "Dockerfile",
|
|
},
|
|
{
|
|
relativePath: "$build_context/unidesk-adapter.cjs",
|
|
sourcePath: rootPath("src/components/microservices/claudeqq/adapter.js"),
|
|
label: "unidesk-adapter.cjs",
|
|
},
|
|
];
|
|
return assets.flatMap((asset) => {
|
|
if (!existsSync(asset.sourcePath)) throw new Error(`claudeqq deploy asset missing: ${asset.sourcePath}`);
|
|
const encoded = Buffer.from(readFileSync(asset.sourcePath, "utf8"), "utf8").toString("base64");
|
|
return [
|
|
`mkdir -p "$(dirname "${asset.relativePath}")"`,
|
|
`printf %s ${shellQuote(encoded)} | base64 -d > "${asset.relativePath}"`,
|
|
`printf 'synced_claudeqq_deploy_asset=%s\\n' ${shellQuote(asset.label)}`,
|
|
];
|
|
});
|
|
}
|
|
|
|
export function syncK8sControlManifestsScript(service: UniDeskMicroserviceConfig): string {
|
|
if (isDevK3sDeployService(service)) {
|
|
const manifest = k8sManifestPath(service);
|
|
return [
|
|
"set -euo pipefail",
|
|
`target_root=${shellQuote(targetWorkDir(service))}`,
|
|
`relative_path=${shellQuote(manifest)}`,
|
|
"target_file=\"$target_root/$relative_path\"",
|
|
"test -f \"$target_file\"",
|
|
"printf 'target_k3s_control_manifest=%s\\n' \"$target_file\"",
|
|
].join("\n");
|
|
}
|
|
if (service.deployment.mode !== "k3sctl-managed" || isUnideskRepo(service.repository.url)) return "";
|
|
const manifests = service.repository.composeFile.endsWith(".k8s.yaml")
|
|
? [service.repository.composeFile]
|
|
: [service.repository.composeFile, k8sManifestPath(service)];
|
|
const commands = [
|
|
"set -euo pipefail",
|
|
`target_root=${shellQuote(targetWorkDir(service))}`,
|
|
"mkdir -p \"$target_root\"",
|
|
];
|
|
for (const relativePath of manifests) {
|
|
const absolutePath = rootPath(relativePath);
|
|
if (!existsSync(absolutePath)) throw new Error(`${service.id} k3s control manifest missing: ${relativePath}`);
|
|
const encoded = Buffer.from(readFileSync(absolutePath, "utf8"), "utf8").toString("base64");
|
|
commands.push(
|
|
`relative_path=${shellQuote(relativePath)}`,
|
|
`target_file="$target_root/$relative_path"`,
|
|
"mkdir -p \"$(dirname \"$target_file\")\"",
|
|
`printf %s ${shellQuote(encoded)} | base64 -d > "$target_file"`,
|
|
"printf 'synced_k3s_control_manifest=%s\\n' \"$target_file\"",
|
|
);
|
|
}
|
|
return commands.join("\n");
|
|
}
|
|
|
|
export function buildImageScript(service: UniDeskMicroserviceConfig, desired: DeployManifestService, resolvedCommit: string): string {
|
|
const image = buildImageTag(service);
|
|
const buildContext = sourceBuildContext(service);
|
|
const dockerfilePath = sourceDockerfilePath(service);
|
|
const dockerfile = `${buildContext}/${dockerfilePath}`;
|
|
const labelArgs = [
|
|
"--label", `unidesk.ai/service-id=${service.id}`,
|
|
"--label", `unidesk.ai/source-repo=${desired.repo}`,
|
|
"--label", `unidesk.ai/source-commit=${resolvedCommit}`,
|
|
"--label", `unidesk.ai/dockerfile=${dockerfilePath}`,
|
|
];
|
|
const commonArgs = [
|
|
"--progress=plain",
|
|
...labelArgs,
|
|
"-t", image,
|
|
"-f", dockerfile,
|
|
buildContext,
|
|
];
|
|
const proxyBuildArgs = targetIsMain(service)
|
|
? []
|
|
: [
|
|
"--network", "host",
|
|
"--build-arg", `HTTP_PROXY=${providerGatewayWsEgressProxyUrl}`,
|
|
"--build-arg", `HTTPS_PROXY=${providerGatewayWsEgressProxyUrl}`,
|
|
"--build-arg", `ALL_PROXY=${providerGatewayWsEgressProxyUrl}`,
|
|
"--build-arg", "NO_PROXY=localhost,127.0.0.1,::1,host.docker.internal",
|
|
];
|
|
return [
|
|
"set -euo pipefail",
|
|
sourceProxyPrelude(service),
|
|
`image=${shellQuote(image)}`,
|
|
`dockerfile=${shellQuote(dockerfile)}`,
|
|
"if ! docker buildx version >/dev/null 2>&1; then",
|
|
" if ! docker buildx inspect default >/dev/null 2>&1; then echo target_build_builder=missing >&2; exit 1; fi",
|
|
"fi",
|
|
...devK3sPrepullImages(service).flatMap((imageName) => [
|
|
`if ! docker image inspect ${shellQuote(imageName)} >/dev/null 2>&1; then`,
|
|
` docker pull ${shellQuote(imageName)}`,
|
|
"fi",
|
|
]),
|
|
"builder_args=()",
|
|
"if docker buildx inspect --builder default >/dev/null 2>&1; then builder_args=(--builder default); echo target_build_builder=default; else echo target_build_builder=implicit; fi",
|
|
"docker buildx inspect \"${builder_args[@]}\" --bootstrap || true",
|
|
"echo target_build_builder_cleanup=not-required",
|
|
...buildCachePrelude("$dockerfile", buildBaseImageFallbacks(service)),
|
|
`docker buildx build "\${builder_args[@]}" --load "\${cache_args[@]}" "\${base_args[@]}" ${[...proxyBuildArgs, ...commonArgs].map(shellQuote).join(" ")}`,
|
|
"docker image inspect \"$image\" --format 'image_id={{.Id}} labels={{json .Config.Labels}}'",
|
|
].filter((line) => line.length > 0).join("\n");
|
|
}
|
|
|
|
export function patchDevK3sManifestScript(service: UniDeskMicroserviceConfig, desired: DeployManifestService, resolvedCommit: string): string {
|
|
const manifest = `${targetWorkDir(service)}/${k8sManifestPath(service)}`;
|
|
const image = buildImageTag(service);
|
|
const serviceId = service.id;
|
|
const deploymentName = service.repository.composeService;
|
|
return [
|
|
"set -euo pipefail",
|
|
`manifest=${shellQuote(manifest)}`,
|
|
`service_id=${shellQuote(serviceId)}`,
|
|
`deployment_name=${shellQuote(deploymentName)}`,
|
|
`image=${shellQuote(image)}`,
|
|
`repo=${shellQuote(desired.repo)}`,
|
|
`commit=${shellQuote(resolvedCommit)}`,
|
|
`requested_commit=${shellQuote(desired.commitId)}`,
|
|
`python3 - "$manifest" "$service_id" "$deployment_name" "$image" "$repo" "$commit" "$requested_commit" <<'PY'`,
|
|
"import re",
|
|
"import sys",
|
|
"path, service_id, deployment_name, image, repo, commit, requested_commit = sys.argv[1:]",
|
|
"text = open(path, encoding='utf-8').read()",
|
|
"segments = re.split(r'(?m)^---\\s*$', text)",
|
|
"kept = []",
|
|
"for segment in segments:",
|
|
" if not segment.strip():",
|
|
" continue",
|
|
" haystack = '\\n' + segment + '\\n'",
|
|
" if f'unidesk.ai/deploy-service-id: {service_id}' in segment or f'\\n name: {deployment_name}\\n' in haystack:",
|
|
" kept.append(segment)",
|
|
"if not kept:",
|
|
" raise SystemExit(f'dev service {service_id}/{deployment_name} not found in {path}')",
|
|
"patched = []",
|
|
"for segment in kept:",
|
|
" segment = segment.replace('unidesk.ai/image-source: deploy-env-commit', 'unidesk.ai/image-source: deploy-env-commit')",
|
|
" segment = re.sub(r'image: unidesk-[^\\n]+:dev-placeholder', f'image: {image}', segment)",
|
|
" segment = re.sub(r'value: replace-with-deploy-env-commit', f'value: {commit}', segment)",
|
|
" segment = segment.replace('value: https://github.com/pikasTech/unidesk', f'value: {repo}')",
|
|
" patched.append(segment.strip() + '\\n')",
|
|
"out = '---\\n'.join(patched)",
|
|
"open(path, 'w', encoding='utf-8').write(out)",
|
|
"print(f'dev_k3s_manifest_patched={path}')",
|
|
"print(f'dev_k3s_manifest_service={service_id}')",
|
|
"print(f'dev_k3s_manifest_deployment={deployment_name}')",
|
|
"print(f'dev_k3s_manifest_image={image}')",
|
|
"print(f'dev_k3s_manifest_commit={commit}')",
|
|
"PY",
|
|
].join("\n");
|
|
}
|
|
|
|
export function directComposeResolveScript(service: UniDeskMicroserviceConfig): string {
|
|
const projectHint = targetIsMain(service) ? "unidesk" : "";
|
|
return [
|
|
`work_dir=${shellQuote(targetWorkDir(service))}`,
|
|
`compose_file=${shellQuote(directComposeFile(service))}`,
|
|
`compose_env_file=${shellQuote(directComposeEnvFile(service))}`,
|
|
`compose_service=${shellQuote(service.repository.composeService)}`,
|
|
`container=${shellQuote(service.repository.containerName)}`,
|
|
`project_hint=${shellQuote(projectHint)}`,
|
|
`build_context_override=${shellQuote(directBuildContextOverride(service))}`,
|
|
`build_dockerfile_override=${shellQuote(directDockerfileOverride(service))}`,
|
|
"compose_env_args=()",
|
|
"if [ -n \"$compose_env_file\" ]; then compose_env_args=(--env-file \"$compose_env_file\"); fi",
|
|
"running_project=$(docker inspect -f '{{ index .Config.Labels \"com.docker.compose.project\" }}' \"$container\" 2>/dev/null || true)",
|
|
"running_service=$(docker inspect -f '{{ index .Config.Labels \"com.docker.compose.service\" }}' \"$container\" 2>/dev/null || true)",
|
|
"running_image=$(docker inspect -f '{{.Config.Image}}' \"$container\" 2>/dev/null || true)",
|
|
"if [ \"$running_project\" = '<no value>' ]; then running_project=''; fi",
|
|
"if [ \"$running_service\" = '<no value>' ]; then running_service=''; fi",
|
|
"project=\"$running_project\"",
|
|
"if [ -z \"$project\" ]; then project=\"$project_hint\"; fi",
|
|
"if [ -z \"$project\" ]; then project=$(basename \"$work_dir\"); fi",
|
|
"config_json=$(docker compose \"${compose_env_args[@]}\" -f \"$compose_file\" -p \"$project\" config --format json)",
|
|
"compose_image=$(printf '%s' \"$config_json\" | python3 -c 'import json,sys; svc=sys.argv[1]; d=json.load(sys.stdin); print(((d.get(\"services\") or {}).get(svc) or {}).get(\"image\") or \"\")' \"$compose_service\")",
|
|
"image=\"$compose_image\"",
|
|
"if [ -z \"$image\" ]; then image=\"$running_image\"; fi",
|
|
"if [ -z \"$image\" ]; then image=\"${project}-${compose_service}\"; fi",
|
|
"build_context=$(printf '%s' \"$config_json\" | python3 -c 'import json,sys; svc=sys.argv[1]; b=(((json.load(sys.stdin).get(\"services\") or {}).get(svc) or {}).get(\"build\") or {}); print((b if isinstance(b,str) else b.get(\"context\")) or \".\")' \"$compose_service\")",
|
|
"build_dockerfile=$(printf '%s' \"$config_json\" | python3 -c 'import json,sys; svc=sys.argv[1]; b=(((json.load(sys.stdin).get(\"services\") or {}).get(svc) or {}).get(\"build\") or {}); print(\"Dockerfile\" if isinstance(b,str) else (b.get(\"dockerfile\") or \"Dockerfile\"))' \"$compose_service\")",
|
|
"build_target=$(printf '%s' \"$config_json\" | python3 -c 'import json,sys; svc=sys.argv[1]; b=(((json.load(sys.stdin).get(\"services\") or {}).get(svc) or {}).get(\"build\") or {}); print(\"\" if isinstance(b,str) else (b.get(\"target\") or \"\"))' \"$compose_service\")",
|
|
"build_args_file=$(mktemp /tmp/unidesk-compose-build-args.XXXXXX)",
|
|
"printf '%s' \"$config_json\" | python3 -c 'import json,sys; svc=sys.argv[1]; b=(((json.load(sys.stdin).get(\"services\") or {}).get(svc) or {}).get(\"build\") or {}); args={} if isinstance(b,str) else (b.get(\"args\") or {}); items=args.items() if isinstance(args,dict) else [(str(x).split(\"=\",1)[0], str(x).split(\"=\",1)[1] if \"=\" in str(x) else \"\") for x in args]; [print(str(k)+\"=\"+(\"\" if v is None else str(v))) for k,v in items]' \"$compose_service\" > \"$build_args_file\"",
|
|
"if [ -n \"$build_context_override\" ]; then build_context=\"$build_context_override\"; fi",
|
|
"if [ -n \"$build_dockerfile_override\" ]; then build_dockerfile=\"$build_dockerfile_override\"; fi",
|
|
"case \"$build_context\" in /*) ;; *) build_context=$(cd \"$(dirname \"$compose_file\")\" && cd \"$build_context\" && pwd) ;; esac",
|
|
"case \"$build_dockerfile\" in /*) dockerfile_abs=\"$build_dockerfile\" ;; *) dockerfile_abs=\"$build_context/$build_dockerfile\" ;; esac",
|
|
"test -f \"$dockerfile_abs\"",
|
|
"printf 'compose_project=%s\\ncompose_service=%s\\ncompose_image=%s\\nbuild_context=%s\\nbuild_dockerfile=%s\\nbuild_target=%s\\n' \"$project\" \"$compose_service\" \"$image\" \"$build_context\" \"$dockerfile_abs\" \"$build_target\"",
|
|
].join("\n");
|
|
}
|
|
|
|
export function buildDirectImageScript(service: UniDeskMicroserviceConfig, desired: DeployManifestService, resolvedCommit: string): string {
|
|
const proxyBuildArgs = targetIsMain(service)
|
|
? []
|
|
: [
|
|
"--network", "host",
|
|
"--build-arg", `HTTP_PROXY=${providerGatewayWsEgressProxyUrl}`,
|
|
"--build-arg", `HTTPS_PROXY=${providerGatewayWsEgressProxyUrl}`,
|
|
"--build-arg", `ALL_PROXY=${providerGatewayWsEgressProxyUrl}`,
|
|
"--build-arg", "NO_PROXY=localhost,127.0.0.1,::1,host.docker.internal",
|
|
];
|
|
const labelArgs = [
|
|
"--label", `unidesk.ai/service-id=${service.id}`,
|
|
"--label", `unidesk.ai/source-repo=${desired.repo}`,
|
|
"--label", `unidesk.ai/source-commit=${resolvedCommit}`,
|
|
"--label", `unidesk.ai/dockerfile=${service.repository.dockerfile}`,
|
|
];
|
|
return [
|
|
"set -euo pipefail",
|
|
sourceProxyPrelude(service),
|
|
directComposeResolveScript(service),
|
|
"builder_args=()",
|
|
"if docker buildx inspect --builder default >/dev/null 2>&1; then builder_args=(--builder default); echo target_build_builder=default; else echo target_build_builder=implicit; fi",
|
|
"docker buildx inspect \"${builder_args[@]}\" --bootstrap || true",
|
|
...buildCachePrelude("$dockerfile_abs", buildBaseImageFallbacks(service)),
|
|
"compose_build_args=()",
|
|
"while IFS= read -r item; do [ -n \"$item\" ] && compose_build_args+=(--build-arg \"$item\"); done < \"$build_args_file\"",
|
|
"target_args=()",
|
|
"if [ -n \"$build_target\" ]; then target_args=(--target \"$build_target\"); fi",
|
|
`docker buildx build "\${builder_args[@]}" --load "\${cache_args[@]}" "\${base_args[@]}" ${[...proxyBuildArgs, ...labelArgs].map(shellQuote).join(" ")} "\${compose_build_args[@]}" "\${target_args[@]}" --progress=plain -t "$image" -f "$dockerfile_abs" "$build_context"`,
|
|
"docker image inspect \"$image\" --format 'image_id={{.Id}} labels={{json .Config.Labels}}'",
|
|
].filter((line) => line.length > 0).join("\n");
|
|
}
|
|
|
|
export function imageLabelVerifyScript(service: UniDeskMicroserviceConfig, expectedCommit: string): string {
|
|
return [
|
|
"set -euo pipefail",
|
|
`container=${shellQuote(service.repository.containerName)}`,
|
|
`expected=${shellQuote(expectedCommit)}`,
|
|
"cid=$(docker ps -q -f name=\"^/${container}$\" | head -1)",
|
|
"test -n \"$cid\"",
|
|
"image_id=$(docker inspect -f '{{.Image}}' \"$cid\")",
|
|
"actual=$(docker image inspect -f '{{ index .Config.Labels \"unidesk.ai/source-commit\" }}' \"$image_id\")",
|
|
"test \"$actual\" = \"$expected\"",
|
|
"printf 'container=%s image_id=%s deploy_commit=%s\\n' \"$container\" \"$image_id\" \"$actual\"",
|
|
].join("\n");
|
|
}
|
|
|
|
export function directDeployEnvPrefix(service: UniDeskMicroserviceConfig): string {
|
|
return `UNIDESK_${service.id.replace(/[^A-Za-z0-9]+/gu, "_").replace(/^_+|_+$/gu, "").toUpperCase()}_DEPLOY`;
|
|
}
|
|
|
|
export function directComposeDeployEnv(service: UniDeskMicroserviceConfig, desired: DeployManifestService, resolvedCommit: string): Record<string, string> {
|
|
if (!isDirectComposeDeployMode(service)) return {};
|
|
const prefix = directDeployEnvPrefix(service);
|
|
return {
|
|
[`${prefix}_SERVICE_ID`]: service.id,
|
|
[`${prefix}_REPO`]: desired.repo,
|
|
[`${prefix}_COMMIT`]: resolvedCommit,
|
|
[`${prefix}_REQUESTED_COMMIT`]: desired.commitId,
|
|
};
|
|
}
|
|
|
|
export function composeEnvStampLines(service: UniDeskMicroserviceConfig, desired: DeployManifestService, resolvedCommit: string): string[] {
|
|
const deployEnv = directComposeDeployEnv(service, desired, resolvedCommit);
|
|
const entries = Object.entries(deployEnv);
|
|
if (entries.length === 0) return [];
|
|
const encoded = Buffer.from(JSON.stringify(deployEnv), "utf8").toString("base64");
|
|
return [
|
|
`deploy_env_b64=${shellQuote(encoded)}`,
|
|
"if [ -n \"$compose_env_file\" ]; then",
|
|
" python3 - \"$compose_env_file\" \"$deploy_env_b64\" <<'PY'",
|
|
"import base64, json, re, sys",
|
|
"path = sys.argv[1]",
|
|
"updates = json.loads(base64.b64decode(sys.argv[2]).decode('utf-8'))",
|
|
"def env_value(value):",
|
|
" text = str(value)",
|
|
" return text if re.match(r'^[A-Za-z0-9_./:@-]+$', text) else json.dumps(text)",
|
|
"try:",
|
|
" lines = open(path, encoding='utf-8').read().splitlines()",
|
|
"except FileNotFoundError:",
|
|
" lines = []",
|
|
"seen = set()",
|
|
"out = []",
|
|
"for line in lines:",
|
|
" if '=' not in line or line.startswith('#'):",
|
|
" out.append(line)",
|
|
" continue",
|
|
" key = line.split('=', 1)[0]",
|
|
" if key in updates:",
|
|
" out.append(f'{key}={env_value(updates[key])}')",
|
|
" seen.add(key)",
|
|
" else:",
|
|
" out.append(line)",
|
|
"for key, value in updates.items():",
|
|
" if key not in seen:",
|
|
" out.append(f'{key}={env_value(value)}')",
|
|
"with open(path, 'w', encoding='utf-8') as handle:",
|
|
" handle.write('\\n'.join(out) + '\\n')",
|
|
"PY",
|
|
" echo compose_env_deploy_stamp=updated",
|
|
"fi",
|
|
];
|
|
}
|
|
|
|
export function composeDeployScript(service: UniDeskMicroserviceConfig, desired: DeployManifestService, resolvedCommit: string): string {
|
|
return [
|
|
"set -euo pipefail",
|
|
directComposeResolveScript(service),
|
|
...composeEnvStampLines(service, desired, resolvedCommit),
|
|
"docker compose \"${compose_env_args[@]}\" -f \"$compose_file\" -p \"$project\" up -d --no-build --no-deps --force-recreate \"$compose_service\"",
|
|
"ready=0",
|
|
"for attempt in $(seq 1 90); do",
|
|
" cid=$(docker ps -q -f name=\"^/${container}$\" | head -1)",
|
|
" if [ -n \"$cid\" ]; then",
|
|
" health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \"$cid\" 2>/dev/null || true)",
|
|
" echo compose_container_probe container=$container attempt=$attempt health=$health",
|
|
" if [ \"$health\" = \"healthy\" ] || [ \"$health\" = \"running\" ]; then ready=1; break; fi",
|
|
" else",
|
|
" echo compose_container_probe container=$container attempt=$attempt cid=missing",
|
|
" fi",
|
|
" sleep 1",
|
|
"done",
|
|
"test \"$ready\" = \"1\"",
|
|
].join("\n");
|
|
}
|
|
|
|
export function writeComposeEnvFallbackPath(): string {
|
|
return rootPath(".state", "docker-compose.env");
|
|
}
|
|
|
|
export function rootAccessPrelude(): string[] {
|
|
return [
|
|
"root_exec() {",
|
|
" if [ \"$(id -u)\" = \"0\" ]; then \"$@\"; return; fi",
|
|
" if sudo -n true >/dev/null 2>&1; then sudo -n \"$@\"; return; fi",
|
|
" if [ -x /mnt/c/Windows/System32/wsl.exe ]; then /mnt/c/Windows/System32/wsl.exe -u root -- \"$@\"; return; fi",
|
|
" echo 'native_k3s_root_access=missing' >&2",
|
|
" return 1",
|
|
"}",
|
|
"root_shell() {",
|
|
" script=\"$1\"",
|
|
" if [ \"$(id -u)\" = \"0\" ]; then bash -lc \"$script\"; return; fi",
|
|
" if sudo -n true >/dev/null 2>&1; then sudo -n bash -lc \"$script\"; return; fi",
|
|
" if [ -x /mnt/c/Windows/System32/wsl.exe ]; then /mnt/c/Windows/System32/wsl.exe -u root -- bash -lc \"$script\"; return; fi",
|
|
" echo 'native_k3s_root_access=missing' >&2",
|
|
" return 1",
|
|
"}",
|
|
];
|
|
}
|
|
|
|
export function ensureNativeK3sScript(): string {
|
|
const installCommand = [
|
|
"INSTALL_K3S_SKIP_DOWNLOAD=true",
|
|
`INSTALL_K3S_VERSION=${nativeK3sInstallVersion}`,
|
|
"INSTALL_K3S_EXEC=\"server --disable traefik --disable servicelb --disable metrics-server --node-name D601 --node-label unidesk.ai/node-id=D601 --node-label unidesk.ai/provider-id=D601 --tls-san 127.0.0.1 --tls-san host.docker.internal --write-kubeconfig-mode 644\"",
|
|
"sh /tmp/unidesk-install-k3s.sh",
|
|
].join(" ");
|
|
return [
|
|
"set -euo pipefail",
|
|
...rootAccessPrelude(),
|
|
`native_k3s_image=${shellQuote(nativeK3sImage)}`,
|
|
`native_ctr_address=${shellQuote(nativeK3sCtrAddress)}`,
|
|
`build_proxy=${shellQuote(providerGatewayWsEgressProxyUrl)}`,
|
|
"configure_provider_deploy_proxy() {",
|
|
" export DOCKER_CONFIG=/tmp/unidesk-docker-config-clean",
|
|
" mkdir -p \"$DOCKER_CONFIG\"",
|
|
" [ -f \"$DOCKER_CONFIG/config.json\" ] || printf '{}' > \"$DOCKER_CONFIG/config.json\"",
|
|
" export HTTP_PROXY=\"$build_proxy\" HTTPS_PROXY=\"$build_proxy\" ALL_PROXY=\"$build_proxy\"",
|
|
" export NO_PROXY=\"localhost,127.0.0.1,::1,host.docker.internal\"",
|
|
" if ! curl -fsSI --max-time 20 -x \"$build_proxy\" https://github.com >/dev/null; then",
|
|
" echo native_k3s_provider_egress_proxy_unavailable=$build_proxy >&2",
|
|
" exit 1",
|
|
" fi",
|
|
" echo native_k3s_provider_egress_proxy=provider-gateway-ws-egress:$build_proxy",
|
|
"}",
|
|
"install_native_k3s_binaries() {",
|
|
" missing=0",
|
|
" for binary in k3s ctr containerd containerd-shim-runc-v2 crictl runc cni flannel bridge host-local loopback portmap bandwidth firewall; do",
|
|
" command -v \"$binary\" >/dev/null 2>&1 || missing=1",
|
|
" done",
|
|
" if [ \"$missing\" = \"0\" ]; then return; fi",
|
|
" docker image inspect \"$native_k3s_image\" >/dev/null 2>&1 || docker pull \"$native_k3s_image\"",
|
|
" tmp_dir=$(mktemp -d)",
|
|
" tmp_container=$(docker create \"$native_k3s_image\" sh -lc true)",
|
|
" docker cp \"$tmp_container:/bin/.\" \"$tmp_dir/bin\"",
|
|
" docker rm \"$tmp_container\" >/dev/null",
|
|
" for binary in k3s ctr containerd containerd-shim-runc-v2 crictl runc cni flannel bridge host-local loopback portmap bandwidth firewall; do",
|
|
" if [ -e \"$tmp_dir/bin/$binary\" ]; then",
|
|
" root_exec install -m 755 \"$tmp_dir/bin/$binary\" \"/usr/local/bin/$binary\"",
|
|
" echo native_k3s_binary_installed=$binary",
|
|
" fi",
|
|
" done",
|
|
" rm -rf \"$tmp_dir\"",
|
|
"}",
|
|
"unmount_invalid_wsl_kubelet_mounts() {",
|
|
" if awk 'NF != 6 && $0 ~ /\\/Docker\\/host/ {found=1} END {exit found ? 0 : 1}' /proc/mounts; then",
|
|
" root_exec umount /Docker/host >/dev/null 2>&1 || root_exec umount -l /Docker/host >/dev/null 2>&1 || true",
|
|
" echo native_k3s_unmounted_invalid_mount=/Docker/host",
|
|
" fi",
|
|
" awk 'NF != 6 {print \"native_k3s_invalid_mount_line=\" NR \":\" $0; bad=1} END {exit bad}' /proc/mounts",
|
|
"}",
|
|
"install_system_images_from_legacy_k3s() {",
|
|
" legacy_container=$(docker ps --format '{{.Names}} {{.Image}}' | awk '$2 ~ /^rancher\\/k3s:/ {print $1; exit}')",
|
|
" if [ -n \"$legacy_container\" ]; then",
|
|
" if docker exec \"$legacy_container\" ctr -n k8s.io images export /tmp/unidesk-k3s-system-images.tar docker.io/rancher/local-path-provisioner:v0.0.32 docker.io/rancher/mirrored-coredns-coredns:1.12.3 >/dev/null 2>&1; then",
|
|
" docker cp \"$legacy_container:/tmp/unidesk-k3s-system-images.tar\" /tmp/unidesk-k3s-system-images.tar >/dev/null",
|
|
" root_exec ctr --address \"$native_ctr_address\" -n k8s.io images import /tmp/unidesk-k3s-system-images.tar >/dev/null",
|
|
" echo native_k3s_imported_legacy_system_images=$legacy_container",
|
|
" fi",
|
|
" fi",
|
|
"}",
|
|
"install_pause_image() {",
|
|
" pause_image=rancher/mirrored-pause:3.6",
|
|
" pause_entrypoint=$(docker image inspect \"$pause_image\" --format '{{json .Config.Entrypoint}}' 2>/dev/null || true)",
|
|
" if ! printf '%s' \"$pause_entrypoint\" | grep -q '\"/pause\"'; then",
|
|
" docker image rm \"$pause_image\" >/dev/null 2>&1 || true",
|
|
" docker pull --platform linux/amd64 \"$pause_image\"",
|
|
" pause_entrypoint=$(docker image inspect \"$pause_image\" --format '{{json .Config.Entrypoint}}' 2>/dev/null || true)",
|
|
" fi",
|
|
" if ! printf '%s' \"$pause_entrypoint\" | grep -q '\"/pause\"'; then",
|
|
" echo native_k3s_pause_image_invalid_entrypoint=$pause_entrypoint >&2",
|
|
" exit 1",
|
|
" fi",
|
|
" docker save \"$pause_image\" -o /tmp/unidesk-k3s-pause.tar",
|
|
" root_exec ctr --address \"$native_ctr_address\" -n k8s.io images rm docker.io/rancher/mirrored-pause:3.6 >/dev/null 2>&1 || true",
|
|
" root_exec ctr --address \"$native_ctr_address\" -n k8s.io images import /tmp/unidesk-k3s-pause.tar >/dev/null",
|
|
" root_exec ctr --address \"$native_ctr_address\" -n k8s.io images ls | grep -F 'docker.io/rancher/mirrored-pause:3.6' >/dev/null",
|
|
" echo native_k3s_imported_pause_image=rancher/mirrored-pause:3.6",
|
|
"}",
|
|
"configure_provider_deploy_proxy",
|
|
"install_native_k3s_binaries",
|
|
"unmount_invalid_wsl_kubelet_mounts",
|
|
"if ! systemctl cat k3s.service >/dev/null 2>&1; then",
|
|
" curl -fsSL --max-time 60 https://get.k3s.io -o /tmp/unidesk-install-k3s.sh",
|
|
` root_shell ${shellQuote(installCommand)}`,
|
|
"fi",
|
|
"if ! systemctl is-active --quiet k3s; then",
|
|
" root_exec systemctl enable --now k3s",
|
|
"fi",
|
|
"for attempt in $(seq 1 60); do",
|
|
` if KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl get nodes >/dev/null 2>&1; then break; fi`,
|
|
" sleep 2",
|
|
"done",
|
|
k3sTargetGuardScript(),
|
|
`KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl get nodes -l unidesk.ai/node-id=D601 --no-headers | grep -q .`,
|
|
`KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl wait --for=condition=Ready node -l unidesk.ai/node-id=D601 --timeout=180s`,
|
|
"install_system_images_from_legacy_k3s",
|
|
"install_pause_image",
|
|
`KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl -n kube-system delete pod -l k8s-app=kube-dns --ignore-not-found >/dev/null 2>&1 || true`,
|
|
`KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl -n kube-system delete pod -l app=local-path-provisioner --ignore-not-found >/dev/null 2>&1 || true`,
|
|
"legacy_k3s_containers=$(docker ps --format '{{.Names}} {{.Image}}' | awk '$2 ~ /^rancher\\/k3s:/ {print $1}')",
|
|
"for container in $legacy_k3s_containers; do",
|
|
" docker update --restart=no \"$container\" >/dev/null 2>&1 || true",
|
|
" docker stop \"$container\" >/dev/null",
|
|
" echo stopped_containerized_k3s=$container",
|
|
"done",
|
|
`KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl get nodes -o wide`,
|
|
"printf 'native_k3s=ready kubeconfig=%s\\n' /etc/rancher/k3s/k3s.yaml",
|
|
].join("\n");
|
|
}
|
|
|
|
export function importK3sImageScript(service: UniDeskMicroserviceConfig): string {
|
|
const image = buildImageTag(service);
|
|
const archive = `/tmp/unidesk-${safeId(service.id)}-k3s-image.tar`;
|
|
const manifest = `${targetWorkDir(service)}/${k8sManifestPath(service)}`;
|
|
const codeQueueManifestPreflight = service.id === "code-queue"
|
|
? [
|
|
"if [ -f \"$manifest\" ]; then",
|
|
" bad_manifest_images=$(python3 - \"$manifest\" \"$image\" <<'PY'",
|
|
"import re, sys",
|
|
"path, expected = sys.argv[1], sys.argv[2]",
|
|
"required = {'code-queue','code-queue-read','code-queue-write','d601-provider-egress-proxy','d601-tcp-egress-gateway'}",
|
|
"text = open(path, encoding='utf-8').read()",
|
|
"bad = []",
|
|
"seen = set()",
|
|
"for doc in re.split(r'(?m)^---\\s*$', text):",
|
|
" if not re.search(r'(?m)^kind:\\s*Deployment\\s*$', doc):",
|
|
" continue",
|
|
" match = re.search(r'(?ms)^metadata:\\s*$.*?^ name:\\s*([A-Za-z0-9_.-]+)\\s*$', doc)",
|
|
" name = match.group(1) if match else ''",
|
|
" if name not in required:",
|
|
" continue",
|
|
" seen.add(name)",
|
|
" images = re.findall(r'(?m)^\\s*image:\\s*\"?([^\"\\s#]+)\"?', doc)",
|
|
" if not images or any(image != expected for image in images):",
|
|
" found = ','.join(images) if images else '<none>'",
|
|
" bad.append(f'{name}:{found}')",
|
|
"missing = sorted(required - seen)",
|
|
"bad.extend(f'{name}:<missing>' for name in missing)",
|
|
"print('\\n'.join(bad))",
|
|
"PY",
|
|
" )",
|
|
" if [ -n \"$bad_manifest_images\" ]; then",
|
|
" printf 'code_queue_manifest_image_preflight_failed image=%s\\n%s\\n' \"$image\" \"$bad_manifest_images\" >&2",
|
|
" exit 1",
|
|
" fi",
|
|
" echo code_queue_manifest_image_preflight=ok image=$image",
|
|
"fi",
|
|
]
|
|
: [];
|
|
return [
|
|
"set -euo pipefail",
|
|
...rootAccessPrelude(),
|
|
`image=${shellQuote(image)}`,
|
|
`archive=${shellQuote(archive)}`,
|
|
`manifest=${shellQuote(manifest)}`,
|
|
"docker image inspect \"$image\" >/dev/null",
|
|
...codeQueueManifestPreflight,
|
|
"rm -f \"$archive\"",
|
|
"docker save \"$image\" -o \"$archive\"",
|
|
`root_exec ctr --address ${shellQuote(nativeK3sCtrAddress)} -n k8s.io images import "$archive"`,
|
|
"rm -f \"$archive\"",
|
|
`if ! root_exec ctr --address ${shellQuote(nativeK3sCtrAddress)} -n k8s.io images ls | grep -F "$image"; then`,
|
|
" printf 'native_k3s_containerd_image_missing=%s\\n' \"$image\" >&2",
|
|
" exit 1",
|
|
"fi",
|
|
"printf 'native_k3s_containerd_image_present=%s\\n' \"$image\"",
|
|
].join("\n");
|
|
}
|