diff --git a/scripts/src/hwlab-node.ts b/scripts/src/hwlab-node.ts index 2f53439c..0ed2e0c9 100644 --- a/scripts/src/hwlab-node.ts +++ b/scripts/src/hwlab-node.ts @@ -1023,6 +1023,62 @@ function shouldRenderNodeRuntimeControlPlaneLocally(spec: HwlabRuntimeLaneSpec): return hwlabRuntimeLaneSpec(spec.lane).nodeId !== spec.nodeId; } +function yamlDependencyInstallScript(registry: string, fetchTimeoutSeconds: number, retries: number, context: string): string[] { + const timeoutSeconds = Math.max(15, Math.ceil(fetchTimeoutSeconds)); + const retryCount = Math.max(0, Math.floor(retries)); + const safeContext = context.replace(/[^A-Za-z0-9_.-]/gu, "-"); + return [ + `yaml_registry=${shellQuote(registry)}`, + `yaml_fetch_timeout=${shellQuote(String(timeoutSeconds))}`, + `yaml_fetch_retries=${shellQuote(String(retryCount))}`, + `yaml_dependency_context=${shellQuote(safeContext)}`, + "yaml_dependency_log() { echo '{\"event\":\"yaml-dependency\",\"context\":\"'\"$yaml_dependency_context\"'\",\"status\":\"'\"$1\"'\",\"manager\":\"'\"${2:-}\"'\"}' >&2; }", + "yaml_npm_debug_log_tail() {", + " yaml_npm_log_dir=\"${HOME:-/tmp}/.npm/_logs\"", + " if [ ! -d \"$yaml_npm_log_dir\" ]; then return 0; fi", + " yaml_npm_log=\"$(find \"$yaml_npm_log_dir\" -type f -name '*debug*.log' | sort | tail -n 1 || true)\"", + " if [ -n \"$yaml_npm_log\" ] && [ -f \"$yaml_npm_log\" ]; then", + " echo '{\"event\":\"yaml-dependency\",\"context\":\"'\"$yaml_dependency_context\"'\",\"status\":\"npm-debug-log\",\"path\":\"'\"$yaml_npm_log\"'\"}' >&2", + " tail -n 80 \"$yaml_npm_log\" >&2 || true", + " fi", + "}", + "if ! node -e 'require.resolve(\"yaml\")' >/dev/null 2>&1; then", + " mkdir -p node_modules/yaml", + " yaml_tarball=\"${yaml_registry%/}/yaml/-/yaml-2.8.3.tgz\"", + " yaml_tgz=\"$(mktemp)\"", + " if command -v curl >/dev/null 2>&1 && command -v tar >/dev/null 2>&1; then", + " if timeout \"$yaml_fetch_timeout\" curl -fsSL --retry \"$yaml_fetch_retries\" --connect-timeout 10 -o \"$yaml_tgz\" \"$yaml_tarball\"; then", + " tar -xzf \"$yaml_tgz\" -C node_modules/yaml --strip-components=1", + " yaml_dependency_log installed tarball", + " else", + " yaml_dependency_log tarball-failed tarball", + " fi", + " fi", + " rm -f \"$yaml_tgz\"", + "fi", + "if ! node -e 'require.resolve(\"yaml\")' >/dev/null 2>&1 && command -v bun >/dev/null 2>&1; then", + " rm -rf node_modules/yaml", + " if timeout \"$yaml_fetch_timeout\" bun add --no-save --ignore-scripts --registry \"$yaml_registry\" yaml@2.8.3; then", + " yaml_dependency_log installed bun", + " else", + " yaml_dependency_log bun-failed bun", + " fi", + "fi", + "if ! node -e 'require.resolve(\"yaml\")' >/dev/null 2>&1; then", + " rm -rf node_modules/yaml", + " command -v npm >/dev/null 2>&1 || { yaml_dependency_log failed missing-tool; exit 31; }", + " if npm install --package-lock=false --no-save --ignore-scripts --no-audit --no-fund --omit=dev --registry \"$yaml_registry\" yaml@2.8.3; then", + " yaml_dependency_log installed npm", + " else", + " yaml_dependency_log npm-failed npm", + " yaml_npm_debug_log_tail", + " exit 34", + " fi", + "fi", + "node -e 'require.resolve(\"yaml\")' >/dev/null 2>&1 || { yaml_dependency_log failed unresolved; exit 34; }", + ]; +} + function renderNodeRuntimeControlPlaneOnNode(spec: HwlabRuntimeLaneSpec, sourceCommit: string, timeoutSeconds: number): NodeRuntimeRenderResult { const token = nodeRuntimeRenderToken(); const renderDir = `/tmp/hwlab-${spec.nodeId.toLowerCase()}-${spec.lane}-control-plane-${shortSha(sourceCommit)}-${token}`; @@ -1043,16 +1099,7 @@ function renderNodeRuntimeControlPlaneOnNode(spec: HwlabRuntimeLaneSpec, sourceC "git clone --shared --no-checkout \"$cicd_repo\" \"$worktree_dir\"", "git -C \"$worktree_dir\" checkout --detach \"$source_commit\"", "cd \"$worktree_dir\"", - "if [ ! -d node_modules/yaml ]; then", - " if [ -f package-lock.json ]; then", - " npm ci --ignore-scripts --no-audit --prefer-offline", - " elif [ -f bun.lock ] || [ -f bun.lockb ]; then", - " bun install --frozen-lockfile --ignore-scripts", - " else", - " echo \"control-plane render cannot install dependencies: no lockfile found\" >&2", - " exit 42", - " fi", - "fi", + ...yamlDependencyInstallScript(spec.downloadProfile.npm.registry, spec.downloadProfile.npm.fetchTimeoutSeconds, spec.downloadProfile.npm.retries, "control-plane-render"), "node - \"$overlay_b64\" <<'NODE'", "const fs = require('fs');", "const YAML = require('yaml');", @@ -1133,10 +1180,8 @@ function renderNodeRuntimeControlPlaneLocal(spec: HwlabRuntimeLaneSpec, sourceCo "run_git clone --depth 1 --single-branch --branch \"$source_branch\" \"$source_url\" \"$worktree_dir\"", "test \"$(git -C \"$worktree_dir\" rev-parse HEAD)\" = \"$source_commit\"", "cd \"$worktree_dir\"", - "if [ ! -d node_modules/yaml ]; then", - " echo \"phase=local-install-yaml\" >&2", - " npm install --package-lock=false --no-save --ignore-scripts --no-audit --no-fund --omit=dev yaml@2.8.3", - "fi", + "echo \"phase=local-install-yaml\" >&2", + ...yamlDependencyInstallScript(spec.downloadProfile.npm.registry, spec.downloadProfile.npm.fetchTimeoutSeconds, spec.downloadProfile.npm.retries, "local-control-plane-render"), "node - \"$overlay_b64\" <<'NODE'", "const fs = require('fs');", "const YAML = require('yaml');", @@ -1226,6 +1271,61 @@ function nodeRuntimePipelinePostprocessScript(): string[] { " HWLAB_NODE_NO_PROXY: overlay.dockerNoProxy,", "};", "const stepEnv = { ...proxyEnv, ...dockerProxyEnv, ...(overlay.stepEnv || {}) };", + "function prepareSourceDependencyScript() {", + " const registry = String(overlay.npmRegistry || 'https://registry.npmjs.org/');", + " const timeoutSeconds = Math.max(15, Math.ceil(Number(overlay.npmFetchTimeoutMs || 120000) / 1000));", + " const retryCount = Math.max(0, Math.floor(Number(overlay.npmRetries || 3)));", + " return `prepare_source_dependencies_started_ms=\"$(ci_now_ms)\"", + "node <<'NODE_UNIDESK_YAML_DEPENDENCY'", + "const { spawnSync } = require('node:child_process');", + "const fs = require('node:fs');", + "const os = require('node:os');", + "const path = require('node:path');", + "const registry = ${JSON.stringify(registry)};", + "const timeoutMs = ${JSON.stringify(timeoutSeconds * 1000)};", + "const timeoutSeconds = ${JSON.stringify(timeoutSeconds)};", + "const retryCount = ${JSON.stringify(retryCount)};", + "const dependency = 'yaml';", + "const version = '2.8.3';", + "function emit(status, extra = {}) { console.error(JSON.stringify({ event: 'prepare-source-dependencies', status, dependency, ...extra })); }", + "function hasYaml() { try { require.resolve('yaml'); return true; } catch { return false; } }", + "function run(command, args) {", + " const result = spawnSync(command, args, { stdio: 'inherit', env: process.env, timeout: timeoutMs });", + " if (result.error) console.error(JSON.stringify({ event: 'prepare-source-dependencies', status: 'command-error', command, error: result.error.message }));", + " return result.status === 0;", + "}", + "function tailNpmLog() {", + " const dir = path.join(process.env.HOME || '/tmp', '.npm', '_logs');", + " if (!fs.existsSync(dir)) return;", + " const files = fs.readdirSync(dir).filter((name) => name.includes('debug') && name.endsWith('.log')).sort();", + " const file = files[files.length - 1];", + " if (!file) return;", + " const full = path.join(dir, file);", + " console.error(JSON.stringify({ event: 'prepare-source-dependencies', status: 'npm-debug-log', path: full }));", + " console.error(fs.readFileSync(full, 'utf8').split(/\\r?\\n/u).slice(-80).join('\\n'));", + "}", + "if (hasYaml()) { emit('cached'); process.exit(0); }", + "fs.mkdirSync('node_modules/yaml', { recursive: true });", + "const tarball = registry.replace(/\\/+$/u, '') + '/yaml/-/yaml-' + version + '.tgz';", + "const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hwlab-yaml-'));", + "const tgz = path.join(tmpDir, 'yaml.tgz');", + "if (run('curl', ['-fsSL', '--retry', String(retryCount), '--connect-timeout', '10', '--max-time', String(timeoutSeconds), '-o', tgz, tarball]) && run('tar', ['-xzf', tgz, '-C', 'node_modules/yaml', '--strip-components=1'])) emit('installed', { manager: 'tarball' });", + "else emit('tarball-failed', { manager: 'tarball' });", + "fs.rmSync(tmpDir, { recursive: true, force: true });", + "if (!hasYaml()) {", + " fs.rmSync('node_modules/yaml', { recursive: true, force: true });", + " if (run('bun', ['add', '--no-save', '--ignore-scripts', '--registry', registry, 'yaml@' + version]) && hasYaml()) emit('installed', { manager: 'bun' });", + " else emit('bun-failed', { manager: 'bun' });", + "}", + "if (!hasYaml()) {", + " fs.rmSync('node_modules/yaml', { recursive: true, force: true });", + " if (run('npm', ['install', '--package-lock=false', '--no-save', '--ignore-scripts', '--no-audit', '--no-fund', '--omit=dev', '--registry', registry, 'yaml@' + version]) && hasYaml()) emit('installed', { manager: 'npm' });", + " else { emit('npm-failed', { manager: 'npm' }); tailNpmLog(); process.exit(34); }", + "}", + "if (!hasYaml()) { emit('failed', { reason: 'unresolved' }); process.exit(34); }", + "NODE_UNIDESK_YAML_DEPENDENCY", + "ci_timing_emit prepare-source-dependencies succeeded \"$prepare_source_dependencies_started_ms\"`;", + "}", "function deployYamlOverlayScript() {", " const runtimeOverlay = JSON.stringify({", " nodeId: overlay.nodeId,", @@ -1248,6 +1348,7 @@ function nodeRuntimePipelinePostprocessScript(): string[] { " dockerNoProxyList: overlay.dockerNoProxyList,", " npmRegistry: overlay.npmRegistry,", " npmFetchTimeoutMs: overlay.npmFetchTimeoutMs,", + " npmRetries: overlay.npmRetries,", " });", " return `node - <<'NODE_UNIDESK_DEPLOY_YAML_OVERLAY'", "const fs = require('fs');", @@ -1679,6 +1780,10 @@ function nodeRuntimePipelinePostprocessScript(): string[] { "}", "function patchScript(script) {", " let result = String(script || '');", + " const prepareSourceDependencyPattern = new RegExp(String.raw`prepare_source_dependencies_started_ms=\"\\$\\(ci_now_ms\\)\"\\nif node -e 'require\\.resolve\\(\"yaml\"\\)'[\\s\\S]*?\\nci_timing_emit prepare-source-dependencies succeeded \"\\$prepare_source_dependencies_started_ms\"`, 'g');", + " if (result.includes('prepare_source_dependencies_started_ms=\"$(ci_now_ms)\"')) {", + " result = result.replace(prepareSourceDependencyPattern, prepareSourceDependencyScript());", + " }", " const artifactPublishNeedle = 'node scripts/artifact-publish.mjs --publish';", " if (result.includes(artifactPublishNeedle) && !result.includes('unidesk-deploy-yaml-overlay')) {", " result = result.replace(artifactPublishNeedle, `${deployYamlOverlayScript()}\\n${artifactPublishNeedle}`);", @@ -1828,6 +1933,8 @@ function nodeRuntimePipelinePostprocessScript(): string[] { " changed = true;", "}", "if (!structured && !changed && !text.includes(`--gitops-root ${quotedRoot}`) && !text.includes(`--gitops-root ${escapedQuotedRoot}`)) { throw new Error(`generated pipeline missing expected gitops-render invocation in ${pipelinePath}`); }", + "if (text.includes('prepare_source_dependencies_started_ms=\"$(ci_now_ms)\"') && text.includes('npm ci --ignore-scripts --no-audit --prefer-offline')) { throw new Error(`generated pipeline still uses full npm ci prepare-source dependency install in ${pipelinePath}`); }", + "if (text.includes('prepare_source_dependencies_started_ms=\"$(ci_now_ms)\"') && !text.includes('NODE_UNIDESK_YAML_DEPENDENCY')) { throw new Error(`generated pipeline missing UniDesk yaml dependency install in ${pipelinePath}`); }", "fs.writeFileSync(pipelinePath, text);", "function patchArgoYaml(filePath) {", " if (!YAML || !fs.existsSync(filePath)) return;", @@ -1887,6 +1994,7 @@ function nodeRuntimeRenderOverlay(spec: HwlabRuntimeLaneSpec): Record