import { mkdirSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import type { DeployObservationAssertion, DeployObservationCommand, DeployObservationDeploymentEvidence, DeployObservationEvidence, DeployObservationInput, DeployObservationJenkinsEvidence, DeployObservationPodEvidence, DeployObservationResult, DeployObservationRuntimeHealthEvidence, DeployObservationStatus, } from '../types.js'; import { tryPowerShell } from '../core/exec.js'; import { formatDateInShanghai, resolveInsideRoot, workspaceRoot, } from '../core/workspace.js'; const defaultArtifactRoot = '.kt-workspace/test-artifacts/deploy-observation'; const unsafeHereStringPattern = /[\r\n]|'@|"@/; interface NormalizedInput extends Required< Pick< DeployObservationInput, | 'artifactRoot' | 'container' | 'deployment' | 'healthUrl' | 'jenkinsHome' | 'jobName' | 'kubeconfigPath' | 'namespace' | 'project' | 'selector' | 'sshTarget' > > { buildNumber?: string; execute: boolean; expectedCommit?: string; imageTag?: string; smoke?: string; sshPort?: number; } interface NormalizeEvidenceInput { checkedAt?: Date; input: DeployObservationInput; sectionMap: Map; } function normalizeArtifactRoot(value?: string): string { const artifactRoot = value?.trim() || defaultArtifactRoot; assertSafeStructuredInput('artifactRoot', artifactRoot); const base = resolveInsideRoot(defaultArtifactRoot); const target = resolveInsideRoot(artifactRoot); const relative = path.relative(base, target); if (relative.startsWith('..') || path.isAbsolute(relative)) { throw new Error( `Deploy observation artifacts must stay under ${defaultArtifactRoot}`, ); } return artifactRoot; } function assertSafeStructuredInput(field: string, value?: string): void { if (!value) return; if (unsafeHereStringPattern.test(value)) { throw new Error( `Unsafe deploy observation input "${field}": CR/LF and PowerShell here-string terminators are not allowed.`, ); } } function cleanStructuredInput( field: string, value: string | undefined, ): string | undefined { const trimmed = value?.trim(); assertSafeStructuredInput(field, trimmed); return trimmed || undefined; } function normalizeSshTarget(value?: string, execute = false): string { const target = cleanStructuredInput('sshTarget', value) || 'nas'; if (target.startsWith('-')) { throw new Error( 'Unsafe deploy observation input "sshTarget": SSH option injection is not allowed.', ); } if (execute && target !== 'nas') { throw new Error( 'Unsafe deploy observation input "sshTarget": execute mode only allows the stabilized "nas" SSH target.', ); } return target; } function normalizeHttpUrl(field: string, value: string): string { let parsed: URL; try { parsed = new URL(value); } catch { throw new Error( `Unsupported deploy observation ${field}: value must be a valid http(s) URL.`, ); } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new Error( `Unsupported deploy observation ${field}: URL must use http or https.`, ); } if (parsed.username || parsed.password) { throw new Error( `Unsupported deploy observation ${field}: URL credentials are not allowed.`, ); } return parsed.toString(); } function normalizeSmokeCommand(value?: string): string | undefined { const smoke = value?.trim(); if (!smoke) return undefined; assertSafeStructuredInput('smoke', smoke); const match = smoke.match( /^curl\s+-fsS\s+--max-time\s+([1-9]\d?)\s+(['"]?)(https?:\/\/[^\s'"`;$|&<>\\]+)\2$/, ); if (!match) { throw new Error( 'Unsupported deploy observation smoke command: only bounded curl -fsS --max-time GET smoke is allowed.', ); } const timeout = Number(match[1]); const url = match[3]; if (!Number.isInteger(timeout) || timeout < 1 || timeout > 30) { throw new Error( 'Unsupported deploy observation smoke command: --max-time must be between 1 and 30 seconds.', ); } const parsedUrl = normalizeHttpUrl('smoke command', url); return `curl -fsS --max-time ${timeout} ${shellSingleQuote(parsedUrl)}`; } function normalizeInput(input: DeployObservationInput = {}): NormalizedInput { const deployment = cleanStructuredInput('deployment', input.deployment) || 'kt-template-online-api'; const healthUrl = cleanStructuredInput('healthUrl', input.healthUrl) || 'http://127.0.0.1:48085/health/runtime'; const execute = input.execute === true; return { artifactRoot: normalizeArtifactRoot(input.artifactRoot), buildNumber: cleanStructuredInput('buildNumber', input.buildNumber), container: cleanStructuredInput('container', input.container) || 'api', deployment, execute, expectedCommit: cleanStructuredInput( 'expectedCommit', input.expectedCommit, ), healthUrl: normalizeHttpUrl('healthUrl', healthUrl), imageTag: cleanStructuredInput('imageTag', input.imageTag), jenkinsHome: cleanStructuredInput('jenkinsHome', input.jenkinsHome) || '/vol1/docker/jenkins/jenkins_home', jobName: cleanStructuredInput('jobName', input.jobName) || 'KT-Template/KT-Template-API/main', kubeconfigPath: cleanStructuredInput('kubeconfigPath', input.kubeconfigPath) || '/vol1/docker/kt-k8s/kubeconfig/kt-nas.jenkins.yaml', namespace: cleanStructuredInput('namespace', input.namespace) || 'kt-prod', project: cleanStructuredInput('project', input.project) || 'api', selector: cleanStructuredInput('selector', input.selector) || `app=${deployment}`, smoke: normalizeSmokeCommand(input.smoke), sshPort: typeof input.sshPort === 'number' && Number.isFinite(input.sshPort) ? input.sshPort : undefined, sshTarget: normalizeSshTarget(input.sshTarget, execute), }; } function shellSingleQuote(value: string): string { return `'${value.replaceAll("'", "'\"'\"'")}'`; } function powerShellArgument(value: string): string { if (/^[A-Za-z0-9._@:/-]+$/.test(value)) return value; return `'${value.replaceAll("'", "''")}'`; } function numberOrNull(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null; } function stringOrNull(value: unknown): string | null { return typeof value === 'string' && value.length > 0 ? value : null; } function parseJsonObject(value: string): Record | null { try { const parsed = JSON.parse(value); return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : null; } catch { return null; } } function getPath(source: unknown, keys: string[]): unknown { return keys.reduce((current, key) => { if (!current || typeof current !== 'object') return undefined; return (current as Record)[key]; }, source); } function getArray(source: unknown, keys: string[]): unknown[] { const value = getPath(source, keys); return Array.isArray(value) ? value : []; } function objectAt(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null; } function findNamedObject( values: unknown[], name: string, ): Record | null { const objects = values .map(objectAt) .filter((item): item is Record => Boolean(item)); return objects.find((item) => stringOrNull(item.name) === name) || null; } function readKeyValueSection(value: string): Record { return Object.fromEntries( value .split(/\r?\n/) .map((line) => { const index = line.indexOf('='); return index >= 0 ? [line.slice(0, index).trim(), line.slice(index + 1).trim()] : null; }) .filter((item): item is [string, string] => Boolean(item?.[0])), ); } function buildRemoteScript(input: NormalizedInput): string { const smokeBlock = input.smoke ? [ 'section smoke_text', 'set +e', `${input.smoke} 2>&1`, 'SMOKE_EXIT=$?', 'set -u', 'section smoke_status', 'printf "exitCode=%s\\n" "$SMOKE_EXIT"', ].join('\n') : [ 'section smoke_text', "echo 'no task smoke command provided'", 'section smoke_status', 'printf "exitCode=\\n"', ].join('\n'); return [ 'set -u', 'section() { printf "\\n__KT_SECTION:%s__\\n" "$1"; }', `JOB_NAME=${shellSingleQuote(input.jobName)}`, `BUILD_NUMBER=${shellSingleQuote(input.buildNumber || '')}`, `JENKINS_HOME=${shellSingleQuote(input.jenkinsHome)}`, `KUBECONFIG_PATH=${shellSingleQuote(input.kubeconfigPath)}`, `NAMESPACE=${shellSingleQuote(input.namespace)}`, `DEPLOYMENT=${shellSingleQuote(input.deployment)}`, `SELECTOR=${shellSingleQuote(input.selector)}`, `HEALTH_URL=${shellSingleQuote(input.healthUrl)}`, 'KUBECONFIG_ACTIVE="$KUBECONFIG_PATH"', 'KUBECONFIG_TEMP=""', 'cleanup_kubeconfig_temp() { if [ -n "$KUBECONFIG_TEMP" ]; then rm -f "$KUBECONFIG_TEMP"; fi; }', 'trap cleanup_kubeconfig_temp EXIT', 'if [ -f "$KUBECONFIG_PATH" ] && grep -q "https://k3d-kt-nas-serverlb:6443" "$KUBECONFIG_PATH"; then', ' K3D_API_PORT="$(docker port k3d-kt-nas-serverlb 6443/tcp 2>/dev/null | tail -1 | rev | cut -d: -f1 | rev || true)"', ' if [ -n "$K3D_API_PORT" ]; then', ' KUBECONFIG_TEMP="/tmp/kt-deploy-observation-kubeconfig-$$.yaml"', ' sed "s#https://k3d-kt-nas-serverlb:6443#https://127.0.0.1:${K3D_API_PORT}#g" "$KUBECONFIG_PATH" > "$KUBECONFIG_TEMP"', ' KUBECONFIG_ACTIVE="$KUBECONFIG_TEMP"', ' fi', 'fi', 'JOB_DIR="$JENKINS_HOME/jobs/$JOB_NAME/builds"', 'if [ ! -d "$JOB_DIR" ]; then', ' JOB_SLASH_COUNT="$(printf "%s" "$JOB_NAME" | awk -F/ \'{print NF-1}\')"', ' if [ "$JOB_SLASH_COUNT" = "2" ]; then', ' JOB_FOLDER="${JOB_NAME%%/*}"', ' JOB_BRANCH="${JOB_NAME##*/}"', ' JOB_PIPELINE="${JOB_NAME#*/}"', ' JOB_PIPELINE="${JOB_PIPELINE%/*}"', ' MULTIBRANCH_JOB_DIR="$JENKINS_HOME/jobs/$JOB_FOLDER/jobs/$JOB_PIPELINE/branches/$JOB_BRANCH/builds"', ' if [ -d "$MULTIBRANCH_JOB_DIR" ]; then', ' JOB_DIR="$MULTIBRANCH_JOB_DIR"', ' else', ' FOUND_JOB_DIR="$(find "$JENKINS_HOME/jobs" -path "*/jobs/$JOB_PIPELINE/branches/$JOB_BRANCH/builds" -type d -print -quit 2>/dev/null || true)"', ' if [ -z "$FOUND_JOB_DIR" ]; then FOUND_JOB_DIR="$(find "$JENKINS_HOME/jobs" -path "*/jobs/$JOB_PIPELINE/branches/main/builds" -type d -print -quit 2>/dev/null || true)"; fi', ' if [ -n "$FOUND_JOB_DIR" ]; then JOB_DIR="$FOUND_JOB_DIR"; fi', ' fi', ' fi', 'fi', 'if [ -z "$BUILD_NUMBER" ] && [ -d "$JOB_DIR" ]; then BUILD_NUMBER="$(find "$JOB_DIR" -maxdepth 1 -type d -printf "%f\\n" 2>/dev/null | grep -E "^[0-9]+$" | sort -n | tail -1 || true)"; fi', 'BUILD_DIR="$JOB_DIR/$BUILD_NUMBER"', 'section jenkins_meta', 'printf "jobName=%s\\n" "$JOB_NAME"', 'printf "buildNumber=%s\\n" "$BUILD_NUMBER"', 'if [ -f "$BUILD_DIR/build.xml" ]; then RESULT="$(grep -Eo "[^<]*" "$BUILD_DIR/build.xml" 2>/dev/null | sed -E "s###g" | tail -1 || true)"; else RESULT=""; fi', 'printf "result=%s\\n" "$RESULT"', 'section jenkins_build_xml', 'if [ -f "$BUILD_DIR/build.xml" ]; then grep -E "<(hash|sha1)>[0-9a-fA-F]{7,40}" "$BUILD_DIR/build.xml" 2>/dev/null | head -n 20 || true; else echo "jenkins build.xml not found: $BUILD_DIR/build.xml"; fi', 'section jenkins_log_tail', 'if [ -f "$BUILD_DIR/log" ]; then tail -n 160 "$BUILD_DIR/log"; else echo "jenkins log not found: $BUILD_DIR/log"; fi', 'section deployment_json', 'kubectl --kubeconfig "$KUBECONFIG_ACTIVE" --request-timeout=8s -n "$NAMESPACE" get deployment "$DEPLOYMENT" -o json 2>&1 || true', 'section pods_json', 'kubectl --kubeconfig "$KUBECONFIG_ACTIVE" --request-timeout=8s -n "$NAMESPACE" get pods -l "$SELECTOR" -o json 2>&1 || true', 'section events_tail', 'kubectl --kubeconfig "$KUBECONFIG_ACTIVE" --request-timeout=8s -n "$NAMESPACE" get events --sort-by=.lastTimestamp 2>&1 | tail -n 80 || true', 'section health_json', 'curl -fsS --max-time 8 "$HEALTH_URL" 2>&1 || true', smokeBlock, ].join('\n'); } export function buildDeployObservationCommands( input: DeployObservationInput = {}, ): DeployObservationCommand[] { const normalized = normalizeInput(input); const sshParts = [ 'ssh', ...(normalized.sshPort ? ['-p', String(normalized.sshPort)] : []), powerShellArgument(normalized.sshTarget), '"tr -d \'\\015\' | bash -s"', ]; const command = [ "$script = @'", buildRemoteScript(normalized), "'@", `$script | ${sshParts.join(' ')}`, ].join('\n'); return [ { command, name: 'nas-deploy-observation', }, ]; } export function buildDeployObservationSectionMap( stdout: string, ): Map { const sections = new Map(); let current: string | null = null; let buffer: string[] = []; for (const line of stdout.split(/\r?\n/)) { const marker = line.match(/^__KT_SECTION:([A-Za-z0-9_-]+)__$/); if (marker) { if (current) sections.set(current, buffer.join('\n').trim()); current = marker[1]; buffer = []; } else if (current) { buffer.push(line); } } if (current) sections.set(current, buffer.join('\n').trim()); return sections; } function normalizeJenkinsEvidence( input: NormalizedInput, sectionMap: Map, ): DeployObservationJenkinsEvidence { const meta = readKeyValueSection(sectionMap.get('jenkins_meta') || ''); const logTail = sectionMap.get('jenkins_log_tail') || ''; const buildXmlRevision = sectionMap.get('jenkins_build_xml') || ''; const commitHaystack = `${logTail}\n${buildXmlRevision}`.toLowerCase(); const logFinishedStatuses = [...logTail.matchAll(/Finished:\s*([A-Z_]+)/gi)]; const logFinishedStatus = logFinishedStatuses.at(-1)?.[1]?.toUpperCase() || null; const logTailAvailable = logTail.trim().length > 0 && !/jenkins log not found:/i.test(logTail); const finishedStatus = logFinishedStatus || (logTailAvailable ? null : meta.result?.toUpperCase() || null); const expectedCommit = input.expectedCommit || null; return { buildNumber: meta.buildNumber || input.buildNumber || null, commitMatched: expectedCommit ? commitHaystack.includes(expectedCommit.toLowerCase()) : null, expectedCommit, finishedStatus, jobName: input.jobName, }; } function normalizeDeploymentEvidence( input: NormalizedInput, deploymentJson: Record | null, ): DeployObservationDeploymentEvidence { const container = findNamedObject( getArray(deploymentJson, ['spec', 'template', 'spec', 'containers']), input.container, ); return { container: input.container, containerFound: Boolean(container), desiredReplicas: numberOrNull(getPath(deploymentJson, ['spec', 'replicas'])) ?? numberOrNull(getPath(deploymentJson, ['status', 'replicas'])) ?? 1, generation: numberOrNull(getPath(deploymentJson, ['metadata', 'generation'])), image: stringOrNull(container?.image), namespace: input.namespace, observedGeneration: numberOrNull( getPath(deploymentJson, ['status', 'observedGeneration']), ), readyReplicas: numberOrNull( getPath(deploymentJson, ['status', 'readyReplicas']), ), updatedReplicas: numberOrNull( getPath(deploymentJson, ['status', 'updatedReplicas']), ), }; } function scorePod( pod: Record, input: NormalizedInput, ): number { const phase = stringOrNull(getPath(pod, ['status', 'phase'])); const statuses = getArray(pod, ['status', 'containerStatuses']); const status = findNamedObject(statuses, input.container); const image = stringOrNull(status?.image); return ( (phase === 'Running' ? 4 : 0) + (image && input.imageTag && image.includes(input.imageTag) ? 2 : 0) + (status?.ready === true ? 1 : 0) ); } function normalizePodEvidence( input: NormalizedInput, podsJson: Record | null, ): DeployObservationPodEvidence { const pods = getArray(podsJson, ['items']) .map(objectAt) .filter((item): item is Record => Boolean(item)) .sort((left, right) => scorePod(right, input) - scorePod(left, input)); const pod = pods[0] || null; const status = findNamedObject( getArray(pod, ['status', 'containerStatuses']), input.container, ); const specContainer = findNamedObject( getArray(pod, ['spec', 'containers']), input.container, ); return { containerFound: Boolean(status || specContainer), image: stringOrNull(status?.image) || stringOrNull(specContainer?.image), name: stringOrNull(getPath(pod, ['metadata', 'name'])), phase: stringOrNull(getPath(pod, ['status', 'phase'])), ready: typeof status?.ready === 'boolean' ? status.ready : null, restartCount: numberOrNull(status?.restartCount), }; } function normalizeRuntimeHealthEvidence( healthJson: Record | null, ): DeployObservationRuntimeHealthEvidence { const checks = getArray(healthJson, ['checks']); return { checkCount: checks.length || null, service: stringOrNull(healthJson?.service), status: stringOrNull(healthJson?.status), }; } function parseSmokeExitCode(sectionMap: Map): number | null { const status = readKeyValueSection(sectionMap.get('smoke_status') || ''); if (!status.exitCode?.trim()) return null; const exitCode = Number(status.exitCode); return Number.isInteger(exitCode) && exitCode >= 0 ? exitCode : null; } function buildAssertion( name: string, passed: boolean, message: string, critical = true, ): DeployObservationAssertion { return { critical, message, name, passed, }; } function aggregateStatus( assertions: DeployObservationAssertion[], ): DeployObservationStatus { if (assertions.every((item) => item.passed)) return 'passed'; if (assertions.some((item) => item.critical && !item.passed)) return 'failed'; return 'blocked'; } function toFilenameSlug(value: string): string { const slug = value .replace(/[^A-Za-z0-9_-]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 80); return slug || 'project'; } export function normalizeDeployObservationEvidence({ checkedAt = new Date(), input, sectionMap, }: NormalizeEvidenceInput): DeployObservationEvidence { const normalized = normalizeInput(input); const jenkins = normalizeJenkinsEvidence(normalized, sectionMap); const deployment = normalizeDeploymentEvidence( normalized, parseJsonObject(sectionMap.get('deployment_json') || ''), ); const pod = normalizePodEvidence( normalized, parseJsonObject(sectionMap.get('pods_json') || ''), ); const runtimeHealth = normalizeRuntimeHealthEvidence( parseJsonObject(sectionMap.get('health_json') || ''), ); const smokeOutput = sectionMap.get('smoke_text') || ''; const smokeExitCode = parseSmokeExitCode(sectionMap); const expectedImageTag = normalized.imageTag || normalized.expectedCommit; const deploymentObserved = deployment.generation !== null && deployment.observedGeneration !== null && deployment.observedGeneration >= deployment.generation; const deploymentReplicasReady = deployment.updatedReplicas !== null && deployment.readyReplicas !== null && deployment.desiredReplicas !== null && deployment.updatedReplicas >= deployment.desiredReplicas && deployment.readyReplicas >= deployment.desiredReplicas; const runtimeHealthStatusAllowed = runtimeHealth.status === 'ready' || runtimeHealth.status === 'live' || runtimeHealth.status === 'degraded'; const smokeSuccess = Boolean(normalized.smoke && smokeExitCode === 0); const assertions = [ buildAssertion( 'jenkins-build-success', jenkins.finishedStatus === 'SUCCESS', `Jenkins ${jenkins.jobName} #${jenkins.buildNumber || 'unknown'} result=${jenkins.finishedStatus || 'missing'}`, ), buildAssertion( 'jenkins-commit-match', !jenkins.expectedCommit || jenkins.commitMatched === true, jenkins.expectedCommit ? `Expected commit ${jenkins.expectedCommit} matched=${jenkins.commitMatched}` : 'No expected commit supplied', Boolean(jenkins.expectedCommit), ), buildAssertion( 'deployment-container-found', deployment.containerFound, `Deployment container=${deployment.container} found=${deployment.containerFound}`, ), buildAssertion( 'deployment-image-tag', Boolean( deployment.image && (!expectedImageTag || deployment.image.includes(expectedImageTag)), ), `Deployment image=${deployment.image || 'missing'}`, ), buildAssertion( 'deployment-generation-observed', deploymentObserved, `Deployment generation=${deployment.generation} observedGeneration=${deployment.observedGeneration}`, ), buildAssertion( 'deployment-replicas-ready', deploymentReplicasReady, `Deployment desired=${deployment.desiredReplicas} ready=${deployment.readyReplicas} updated=${deployment.updatedReplicas}`, ), buildAssertion( 'pod-container-found', pod.containerFound, `Pod container=${normalized.container} found=${pod.containerFound}`, ), buildAssertion( 'pod-running', pod.phase === 'Running' && pod.ready === true, `Pod ${pod.name || 'missing'} phase=${pod.phase || 'missing'} ready=${pod.ready}`, ), buildAssertion( 'pod-image-tag', Boolean(pod.image && (!expectedImageTag || pod.image.includes(expectedImageTag))), `Pod image=${pod.image || 'missing'}`, ), buildAssertion( 'pod-restart-count-zero', pod.restartCount === 0, `Pod restartCount=${pod.restartCount}`, ), buildAssertion( 'runtime-health-available', runtimeHealth.service === 'kt-template-online-api' && runtimeHealthStatusAllowed, `Runtime health status=${runtimeHealth.status || 'missing'}`, ), buildAssertion( 'task-smoke-success', smokeSuccess, normalized.smoke ? `Task smoke exitCode=${smokeExitCode ?? 'missing'}` : 'No task smoke command supplied; rollout evidence is not functional evidence', ), ]; return { assertions, details: { deployment, eventsTail: sectionMap.get('events_tail') || '', jenkins, pod, runtimeHealth, smoke: { command: normalized.smoke || null, exitCode: smokeExitCode, output: smokeOutput, success: smokeSuccess, }, }, endedAt: checkedAt.toISOString(), environment: 'production', operation: 'kt_deploy_observation', project: normalized.project, schemaVersion: 1, startedAt: checkedAt.toISOString(), status: aggregateStatus(assertions), target: `${normalized.namespace}/${normalized.deployment}`, taskType: 'deploy', title: 'KT API deployment observation', }; } function writeEvidenceArtifact( artifactRoot: string, evidence: DeployObservationEvidence, ): string { const root = resolveInsideRoot(artifactRoot); const directory = path.join(root, formatDateInShanghai()); const safeProject = toFilenameSlug(evidence.project); const filePath = path.resolve( directory, `${safeProject}-${Date.now()}-deploy-observation.json`, ); const relative = path.relative(root, filePath); if (relative.startsWith('..') || path.isAbsolute(relative)) { throw new Error( `Deploy observation artifact path escaped artifact root: ${filePath}`, ); } mkdirSync(directory, { recursive: true }); writeFileSync(filePath, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8'); return filePath; } export async function buildDeployObservation( input: DeployObservationInput = {}, ): Promise { const normalized = normalizeInput(input); const commands = buildDeployObservationCommands(normalized); const requiredEvidence = [ 'Jenkins job/build number and final Finished status', 'Expected commit hash match when a commit is supplied', 'K8s Deployment target container, desiredReplicas, updatedReplicas, readyReplicas, generation, observedGeneration', 'Running Pod selected by target container/current image tag with restartCount', 'GET /health/runtime response with service/status/check count', 'Task-specific smoke command output', ]; const notes = [ 'Default execute=false only returns the bounded read-only command.', 'execute=true runs through ssh nas with CRLF-stripped here-string and writes local JSON evidence.', 'Jenkins/K8s rollout evidence is deployment evidence; task smoke is still required for functional completion.', 'The command reads Jenkins build files, kubectl status, events, and HTTP health only; it does not read Secrets or mutate remote state.', ]; if (!normalized.execute) { return { commands, execute: false, notes, requiredEvidence, }; } const command = commands[0]?.command || ''; const result = await tryPowerShell(command, workspaceRoot, 180_000); const sectionMap = buildDeployObservationSectionMap(result.stdout); const evidence = normalizeDeployObservationEvidence({ input: normalized, sectionMap, }); const artifactPath = writeEvidenceArtifact(normalized.artifactRoot, evidence); return { artifactPath, commands, evidence, execute: true, notes, requiredEvidence, result, }; }