fix: guard code queue hostpath source rollout
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
|
||||
export const codeQueueSourceSubdir = "src/components/microservices/code-queue/src";
|
||||
const tsExtensions = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".json"] as const;
|
||||
|
||||
export interface MissingRelativeImport {
|
||||
importer: string;
|
||||
specifier: string;
|
||||
expected: string[];
|
||||
}
|
||||
|
||||
export interface CodeQueueSourceImportPreflightResult {
|
||||
ok: boolean;
|
||||
guard: "code-queue-hostpath-source-imports";
|
||||
root: string;
|
||||
sourceRoot: string;
|
||||
checkedFiles: number;
|
||||
checkedImports: number;
|
||||
missing: MissingRelativeImport[];
|
||||
degradedReason: "none" | "source-root-missing" | "missing-relative-import-target";
|
||||
message: string;
|
||||
}
|
||||
|
||||
function normalizedRelative(from: string, to: string): string {
|
||||
return relative(from, to).split("\\").join("/");
|
||||
}
|
||||
|
||||
function walkTsFiles(dir: string): string[] {
|
||||
const result: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git" || entry.name === ".state") continue;
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
result.push(...walkTsFiles(path));
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && /\.tsx?$/u.test(entry.name)) result.push(path);
|
||||
}
|
||||
return result.sort();
|
||||
}
|
||||
|
||||
function stripComments(text: string): string {
|
||||
return text
|
||||
.replace(/\/\*[\s\S]*?\*\//gu, "")
|
||||
.replace(/(^|[^:])\/\/.*$/gmu, "$1");
|
||||
}
|
||||
|
||||
function relativeImportSpecifiers(text: string): string[] {
|
||||
const cleaned = stripComments(text);
|
||||
const specifiers = new Set<string>();
|
||||
const patterns = [
|
||||
/\b(?:import|export)\s+(?:type\s+)?(?:[\s\S]*?\s+from\s+)?["'](\.{1,2}\/[^"']+)["']/gu,
|
||||
/\bimport\s*\(\s*["'](\.{1,2}\/[^"']+)["']\s*\)/gu,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
for (const match of cleaned.matchAll(pattern)) {
|
||||
const specifier = match[1];
|
||||
if (specifier !== undefined) specifiers.add(specifier);
|
||||
}
|
||||
}
|
||||
return [...specifiers].sort();
|
||||
}
|
||||
|
||||
function importCandidates(importer: string, specifier: string): string[] {
|
||||
const base = resolve(dirname(importer), specifier);
|
||||
if (tsExtensions.some((extension) => base.endsWith(extension))) return [base];
|
||||
return [
|
||||
...tsExtensions.map((extension) => `${base}${extension}`),
|
||||
...tsExtensions.map((extension) => join(base, `index${extension}`)),
|
||||
];
|
||||
}
|
||||
|
||||
function importTargetExists(candidates: string[]): boolean {
|
||||
return candidates.some((candidate) => {
|
||||
if (!existsSync(candidate)) return false;
|
||||
const stats = statSync(candidate);
|
||||
return stats.isFile();
|
||||
});
|
||||
}
|
||||
|
||||
export function codeQueueSourceImportPreflight(rootDir: string): CodeQueueSourceImportPreflightResult {
|
||||
const root = resolve(rootDir);
|
||||
const sourceRoot = resolve(root, codeQueueSourceSubdir);
|
||||
if (!existsSync(sourceRoot) || !statSync(sourceRoot).isDirectory()) {
|
||||
return {
|
||||
ok: false,
|
||||
guard: "code-queue-hostpath-source-imports",
|
||||
root,
|
||||
sourceRoot,
|
||||
checkedFiles: 0,
|
||||
checkedImports: 0,
|
||||
missing: [],
|
||||
degradedReason: "source-root-missing",
|
||||
message: `Code Queue source root is missing: ${codeQueueSourceSubdir}`,
|
||||
};
|
||||
}
|
||||
|
||||
const files = walkTsFiles(sourceRoot);
|
||||
const missing: MissingRelativeImport[] = [];
|
||||
let checkedImports = 0;
|
||||
for (const file of files) {
|
||||
const text = readFileSync(file, "utf8");
|
||||
for (const specifier of relativeImportSpecifiers(text)) {
|
||||
checkedImports += 1;
|
||||
const candidates = importCandidates(file, specifier);
|
||||
if (importTargetExists(candidates)) continue;
|
||||
missing.push({
|
||||
importer: normalizedRelative(root, file),
|
||||
specifier,
|
||||
expected: candidates.map((candidate) => normalizedRelative(root, candidate)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: missing.length === 0,
|
||||
guard: "code-queue-hostpath-source-imports",
|
||||
root,
|
||||
sourceRoot,
|
||||
checkedFiles: files.length,
|
||||
checkedImports,
|
||||
missing,
|
||||
degradedReason: missing.length === 0 ? "none" : "missing-relative-import-target",
|
||||
message: missing.length === 0
|
||||
? `Code Queue hostPath source import preflight passed (${files.length} files, ${checkedImports} relative imports).`
|
||||
: `Code Queue hostPath source import preflight failed: ${missing.length} relative import target(s) are missing.`,
|
||||
};
|
||||
}
|
||||
|
||||
function optionValue(args: string[], name: string): string | null {
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1) return null;
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.length === 0) throw new Error(`${name} requires a value`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function runCodeQueueSourceGuardCli(args: string[]): CodeQueueSourceImportPreflightResult {
|
||||
if (args.includes("--help") || args.includes("-h")) {
|
||||
return {
|
||||
ok: true,
|
||||
guard: "code-queue-hostpath-source-imports",
|
||||
root: process.cwd(),
|
||||
sourceRoot: resolve(process.cwd(), codeQueueSourceSubdir),
|
||||
checkedFiles: 0,
|
||||
checkedImports: 0,
|
||||
missing: [],
|
||||
degradedReason: "none",
|
||||
message: "usage: bun scripts/code-queue-source-guard.ts --root <repo-root>",
|
||||
};
|
||||
}
|
||||
const root = optionValue(args, "--root") ?? process.cwd();
|
||||
return codeQueueSourceImportPreflight(root);
|
||||
}
|
||||
|
||||
export function emitCodeQueueSourceGuardCli(args: string[]): void {
|
||||
const result = runCodeQueueSourceGuardCli(args);
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
}
|
||||
+105
-4
@@ -8,6 +8,7 @@ import { ensureGithubSshIdentityForProvider } from "./deploy-ssh-identity";
|
||||
import { runArtifactRegistryCommand } from "./artifact-registry";
|
||||
import { startJob } from "./jobs";
|
||||
import { coreInternalFetch } from "./microservices";
|
||||
import { codeQueueSourceImportPreflight, codeQueueSourceSubdir } from "./code-queue-source-guard";
|
||||
|
||||
type DeployAction = "check" | "plan" | "apply";
|
||||
type DeployEnvironment = "dev" | "prod";
|
||||
@@ -128,7 +129,9 @@ const pollIntervalMs = 5_000;
|
||||
const remoteDeployRoot = "/home/ubuntu/.unidesk/deploy";
|
||||
const k8sNamespace = "unidesk";
|
||||
const k8sKubeconfig = "/etc/rancher/k3s/k3s.yaml";
|
||||
const k3sDeployDir = "/home/ubuntu/cq-deploy";
|
||||
// Production k3s hostPath repo. Code Queue production Pods mount this path as /app and /root/unidesk,
|
||||
// so deploy guards must validate this tree rather than config.json development.worktreePath.
|
||||
const k3sProductionHostPathRepoDir = "/home/ubuntu/cq-deploy";
|
||||
const providerGatewayWsEgressProxyUrl = "http://127.0.0.1:18789";
|
||||
const nativeK3sInstallVersion = "v1.34.1+k3s1";
|
||||
const nativeK3sImage = "rancher/k3s:v1.34.1-k3s1";
|
||||
@@ -191,7 +194,7 @@ function isHelpArg(value: string | undefined): boolean {
|
||||
}
|
||||
|
||||
export function deployHelp(action: string | undefined = undefined): Record<string, unknown> {
|
||||
const command = action === undefined || isHelpArg(action) ? "deploy check|plan|apply" : `deploy ${action}`;
|
||||
const command = action === undefined || isHelpArg(action) ? "deploy check|plan|apply|guard" : `deploy ${action}`;
|
||||
return {
|
||||
ok: true,
|
||||
command,
|
||||
@@ -199,11 +202,13 @@ export function deployHelp(action: string | undefined = undefined): Record<strin
|
||||
check: "bun scripts/cli.ts deploy check [--file deploy.json | --env dev|prod] [--service id]",
|
||||
plan: "bun scripts/cli.ts deploy plan [--file deploy.json | --env dev|prod] [--service id]",
|
||||
apply: "bun scripts/cli.ts deploy apply [--file deploy.json | --env dev|prod] [--service id] [--commit full-sha] [--dry-run] [--force] [--timeout-ms N] [--run-now]",
|
||||
guard: "bun scripts/cli.ts deploy guard code-queue-source [--root /home/ubuntu/cq-deploy]",
|
||||
},
|
||||
actions: {
|
||||
check: "Validate desired repo+commit state against live service health and commit markers.",
|
||||
plan: "Show desired/live drift, or with --env show the environment-ref dry-run plan without touching runtime resources.",
|
||||
apply: "Start an async target-side reconcile job unless --run-now is explicitly present.",
|
||||
guard: "Run local deployment guards without mutating runtime resources.",
|
||||
},
|
||||
options: [
|
||||
{ name: "--file <path>", default: defaultDeployFile, description: "Desired-state manifest path relative to the repo root. JSON and ESM JS manifests are supported, for example deploy.json or develop.js. Local D601 maintenance apply is limited to approved direct exceptions; Code Queue direct rollout is disabled." },
|
||||
@@ -214,6 +219,7 @@ export function deployHelp(action: string | undefined = undefined): Record<strin
|
||||
{ name: "--force", description: "Redeploy even when the live commit appears up to date." },
|
||||
{ name: "--timeout-ms <n>", default: defaultTimeoutMs, description: "Per-step timeout budget where supported." },
|
||||
{ name: "--run-now", description: "Run apply in the foreground worker process; omit it for fire-and-forget async job mode." },
|
||||
{ name: "guard code-queue-source --root <path>", description: "Validate Code Queue hostPath source relative imports before any scheduler rollout; failures report degradedReason and missing import targets." },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -887,7 +893,7 @@ function targetExportDir(service: UniDeskMicroserviceConfig, runId: string): str
|
||||
|
||||
function targetWorkDir(service: UniDeskMicroserviceConfig): string {
|
||||
if (isDevK3sDeployService(service)) return service.development.worktreePath;
|
||||
if (service.deployment.mode === "k3sctl-managed") return k3sDeployDir;
|
||||
if (service.deployment.mode === "k3sctl-managed") return k3sProductionHostPathRepoDir;
|
||||
if (targetIsMain(service) && isUnideskRepo(service.repository.url)) {
|
||||
return rootPath(".state", "deploy", "work", safeId(service.id));
|
||||
}
|
||||
@@ -1223,6 +1229,79 @@ function syncSourceScript(service: UniDeskMicroserviceConfig, exportDir: string)
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
function claudeqqDeployAssetOverlayCommands(): string[] {
|
||||
const assets = [
|
||||
{
|
||||
@@ -2480,6 +2559,12 @@ async function applyOneService(config: UniDeskConfig, service: UniDeskMicroservi
|
||||
const sync = await step(config, service, "sync-source", syncSourceScript(service, exportDir), targetIsMain(service) ? repoRoot : "/home/ubuntu", 90_000, !targetIsMain(service));
|
||||
if (!pushStep(steps, sync)) return { ok: false, serviceId: service.id, startedAt, finishedAt: nowIso(), resolvedCommit, before, steps };
|
||||
|
||||
const codeQueueSourcePreflight = codeQueueSourcePreflightScript(service);
|
||||
if (codeQueueSourcePreflight.length > 0) {
|
||||
const sourcePreflight = await step(config, service, "code-queue-hostpath-source-preflight", codeQueueSourcePreflight, targetWorkDir(service), 60_000, !targetIsMain(service));
|
||||
if (!pushStep(steps, sourcePreflight)) return { ok: false, serviceId: service.id, startedAt, finishedAt: nowIso(), resolvedCommit, before, steps };
|
||||
}
|
||||
|
||||
const controlManifestSyncScript = syncK8sControlManifestsScript(service);
|
||||
if (controlManifestSyncScript.length > 0) {
|
||||
const controlManifestStep = isDevK3sDeployService(service) ? "verify-target-k3s-manifest" : "sync-k3s-control-manifests";
|
||||
@@ -2901,10 +2986,26 @@ function applyJob(config: UniDeskConfig, args: string[], options: DeployOptions)
|
||||
};
|
||||
}
|
||||
|
||||
function runDeployGuardCommand(args: string[]): unknown {
|
||||
const [guardName] = args;
|
||||
if (guardName !== "code-queue-source") {
|
||||
return {
|
||||
ok: false,
|
||||
supported: false,
|
||||
error: "unsupported-deploy-guard",
|
||||
guard: guardName ?? null,
|
||||
supportedGuards: ["code-queue-source"],
|
||||
};
|
||||
}
|
||||
const root = optionValue(args.slice(1), ["--root"]) ?? repoRoot;
|
||||
return codeQueueSourceImportPreflight(root);
|
||||
}
|
||||
|
||||
export async function runDeployCommand(config: UniDeskConfig | null, args: string[]): Promise<unknown> {
|
||||
const [actionRaw = "check"] = args;
|
||||
if (isHelpArg(actionRaw) || args.slice(1).some(isHelpArg)) return deployHelp(isHelpArg(actionRaw) ? undefined : actionRaw);
|
||||
if (!["check", "plan", "apply"].includes(actionRaw)) throw new Error("deploy command must be one of: check, plan, apply");
|
||||
if (actionRaw === "guard") return runDeployGuardCommand(args.slice(1));
|
||||
if (!["check", "plan", "apply"].includes(actionRaw)) throw new Error("deploy command must be one of: check, plan, apply, guard");
|
||||
const action = actionRaw as DeployAction;
|
||||
const options = parseOptions(args.slice(1));
|
||||
if (options.environment !== null) {
|
||||
|
||||
Reference in New Issue
Block a user