Files
pikasTech-unidesk/scripts/src/deploy/scripts.ts
T
2026-07-10 05:09:13 +02:00

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");
}