#!/bin/sh set -eu UNIDESK_PAC_EVALUATOR_PATH=$(mktemp) printf '%s' "$UNIDESK_PAC_EVALUATOR_B64" | base64 -d >"$UNIDESK_PAC_EVALUATOR_PATH" export UNIDESK_PAC_EVALUATOR_PATH trap 'rm -f "$UNIDESK_PAC_EVALUATOR_PATH"' 0 1 2 15 json_escape() { sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g' | tr '\n' ' ' } json_string() { printf '%s' "$1" | json_escape } now_epoch() { date +%s } duration_seconds() { start="$1" end="$2" if [ -n "$start" ] && [ -n "$end" ]; then s=$(date -d "$start" +%s 2>/dev/null || echo "") e=$(date -d "$end" +%s 2>/dev/null || echo "") if [ -n "$s" ] && [ -n "$e" ]; then echo $((e - s)) return fi fi echo null } json_normalize() { UNIDESK_JSON_NORMALIZE_INPUT="${1:-}" node <<'NODE' const input = String(process.env.UNIDESK_JSON_NORMALIZE_INPUT || '').trim(); if (!input) { process.stdout.write('{}'); process.exit(0); } for (let end = input.length; end > 0; end -= 1) { try { process.stdout.write(JSON.stringify(JSON.parse(input.slice(0, end)))); process.exit(0); } catch {} } process.stdout.write('{}'); NODE } api_url() { prepare_gitea_api_base printf '%s/api/v1/%s' "$UNIDESK_PAC_GITEA_API_BASE_URL" "$1" } prepare_gitea_api_base() { if [ -n "${UNIDESK_PAC_GITEA_API_BASE_URL:-}" ]; then return fi base="$UNIDESK_PAC_GITEA_BASE_URL" hostport=$(printf '%s' "$base" | sed -n 's#^http://\([^/]*\).*$#\1#p') if printf '%s' "$hostport" | grep -q '\.svc\.cluster\.local'; then host=${hostport%%:*} port=${hostport##*:} if [ "$port" = "$hostport" ]; then port=80; fi service=${host%%.*} rest=${host#*.} namespace=${rest%%.*} cluster_ip=$(kubectl -n "$namespace" get svc "$service" -o jsonpath='{.spec.clusterIP}') UNIDESK_PAC_GITEA_API_BASE_URL="http://$cluster_ip:$port" else UNIDESK_PAC_GITEA_API_BASE_URL="$base" fi export UNIDESK_PAC_GITEA_API_BASE_URL } gitea_api() { method="$1" path="$2" body="${3:-}" tmp=$(mktemp) if [ -n "$body" ]; then code=$(curl -sS -o "$tmp" -w '%{http_code}' -u "$UNIDESK_PAC_GITEA_ADMIN_USERNAME:$UNIDESK_PAC_GITEA_ADMIN_PASSWORD" \ -H 'Content-Type: application/json' \ -X "$method" \ --data "$body" \ "$(api_url "$path")" || true) else code=$(curl -sS -o "$tmp" -w '%{http_code}' -u "$UNIDESK_PAC_GITEA_ADMIN_USERNAME:$UNIDESK_PAC_GITEA_ADMIN_PASSWORD" \ -X "$method" \ "$(api_url "$path")" || true) fi case "$code" in 2*) cat "$tmp"; rm -f "$tmp"; return 0 ;; esac response=$(head -c 500 "$tmp" | tr '\n' ' ' | sed 's/[[:space:]][[:space:]]*/ /g') rm -f "$tmp" printf 'gitea api %s %s failed: HTTP %s response=%s\n' "$method" "$path" "$code" "$response" >&2 return 22 } ensure_token() { name="unidesk-pac-$(date +%Y%m%d%H%M%S)" body=$(printf '{"name":"%s","scopes":["all"]}' "$name") out=$(gitea_api POST "users/$UNIDESK_PAC_GITEA_API_USERNAME/tokens" "$body") printf '%s' "$out" | sed -n 's/.*"sha1"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' } ensure_webhook() { hooks=$(gitea_api GET "repos/$UNIDESK_PAC_GITEA_OWNER/$UNIDESK_PAC_GITEA_REPO/hooks") hook_ids=$(HOOKS_JSON="$hooks" node <<'NODE' const data = JSON.parse(process.env.HOOKS_JSON || '[]'); const url = process.env.UNIDESK_PAC_WEBHOOK_URL; for (const item of data) if (item?.config?.url === url && item?.id) console.log(item.id); NODE ) hook_id=$(printf '%s\n' "$hook_ids" | sed -n '1p') body=$(printf '{"type":"gitea","config":{"url":"%s","content_type":"json","secret":"%s"},"events":["push"],"active":true}' "$UNIDESK_PAC_WEBHOOK_URL" "$UNIDESK_PAC_WEBHOOK_SECRET") if [ -n "$hook_id" ]; then if ! gitea_api PATCH "repos/$UNIDESK_PAC_GITEA_OWNER/$UNIDESK_PAC_GITEA_REPO/hooks/$hook_id" "$body" >/dev/null; then gitea_api DELETE "repos/$UNIDESK_PAC_GITEA_OWNER/$UNIDESK_PAC_GITEA_REPO/hooks/$hook_id" >/dev/null || true created=$(gitea_api POST "repos/$UNIDESK_PAC_GITEA_OWNER/$UNIDESK_PAC_GITEA_REPO/hooks" "$body") hook_id=$(printf '%s' "$created" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p') fi printf '%s\n' "$hook_ids" | sed -n '2,$p' | while IFS= read -r duplicate_id; do [ -n "$duplicate_id" ] || continue gitea_api DELETE "repos/$UNIDESK_PAC_GITEA_OWNER/$UNIDESK_PAC_GITEA_REPO/hooks/$duplicate_id" >/dev/null || true done else created=$(gitea_api POST "repos/$UNIDESK_PAC_GITEA_OWNER/$UNIDESK_PAC_GITEA_REPO/hooks" "$body") hook_id=$(printf '%s' "$created" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p') fi printf '%s' "$hook_id" } repository_manifest() { cat < "$tmp" kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f "$tmp" >/dev/null rm -f "$tmp" } wait_ready() { timeout="${UNIDESK_PAC_WAIT_TIMEOUT_SECONDS:-55}" end=$(( $(now_epoch) + timeout )) while [ "$(now_epoch)" -lt "$end" ]; do ready=$(kubectl -n "$UNIDESK_PAC_RELEASE_NAMESPACE" get deploy "$UNIDESK_PAC_CONTROLLER_SERVICE_NAME" -o jsonpath='{.status.readyReplicas}' 2>/dev/null || true) desired=$(kubectl -n "$UNIDESK_PAC_RELEASE_NAMESPACE" get deploy "$UNIDESK_PAC_CONTROLLER_SERVICE_NAME" -o jsonpath='{.spec.replicas}' 2>/dev/null || true) if [ "${ready:-0}" = "${desired:-1}" ] && [ "${ready:-0}" != "0" ]; then return 0 fi sleep 2 done return 1 } apply_action() { if [ "$UNIDESK_PAC_DRY_RUN" = "1" ]; then printf '{"ok":true,"mode":"dry-run","mutation":false,"valuesPrinted":false}\n' return fi apply_release kubectl create ns "$UNIDESK_PAC_TARGET_NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - >/dev/null consumer_rbac_manifest | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f - >/dev/null token=$(ensure_token) test -n "$token" kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" create secret generic "$UNIDESK_PAC_SECRET_NAME" \ --from-literal="$UNIDESK_PAC_TOKEN_KEY=$token" \ --from-literal="$UNIDESK_PAC_WEBHOOK_SECRET_KEY=$UNIDESK_PAC_WEBHOOK_SECRET" \ --dry-run=client -o yaml | kubectl apply -f - >/dev/null repository_manifest | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f - >/dev/null argo_bootstrap=false if [ -n "${UNIDESK_PAC_ARGO_BOOTSTRAP_MANIFEST_B64:-}" ]; then argo_tmp=$(mktemp) printf '%s' "$UNIDESK_PAC_ARGO_BOOTSTRAP_MANIFEST_B64" | base64 -d >"$argo_tmp" kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f "$argo_tmp" >/dev/null rm -f "$argo_tmp" kubectl -n "$UNIDESK_PAC_ARGO_NAMESPACE" annotate application "$UNIDESK_PAC_ARGO_APPLICATION" argocd.argoproj.io/refresh=hard --overwrite >/dev/null argo_bootstrap=true fi hook_id=$(ensure_webhook) ready=false if wait_ready; then ready=true; fi printf '{"ok":%s,"mode":"confirmed","mutation":true,"controllerReady":%s,"argoBootstrap":%s,"webhook":{"present":true,"id":"%s","url":"%s"},"repository":"%s","namespace":"%s","valuesPrinted":false}\n' "$ready" "$ready" "$argo_bootstrap" "$(json_string "$hook_id")" "$(json_string "$UNIDESK_PAC_WEBHOOK_URL")" "$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" "$(json_string "$UNIDESK_PAC_TARGET_NAMESPACE")" } condition_status() { kubectl -n "$1" get "$2" "$3" -o jsonpath='{range .status.conditions[*]}{.type}={.status}:{.reason}{";"}{end}' 2>/dev/null || true } pipeline_rows() { payload_file=$(mktemp) kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get pipelinerun -o json >"$payload_file" 2>/dev/null || printf '{"items":[]}' >"$payload_file" node - "$payload_file" <<'NODE' const fs = require('node:fs'); const input = fs.readFileSync(process.argv[2], 'utf8') || '{"items":[]}'; const data = input ? JSON.parse(input) : { items: [] }; function firstString(...values) { for (const value of values) { if (typeof value === 'string' && value.length > 0) return value; } return null; } function cond(item) { const c = (item.status?.conditions || []).find((x) => x.type === 'Succeeded') || {}; return { status: c.status || '', reason: c.reason || '' }; } function mapParams(params) { const out = {}; for (const item of params || []) { if (!item || typeof item.name !== 'string') continue; if (typeof item.value === 'string') out[item.name] = item.value; else if (item.value !== undefined && item.value !== null) out[item.name] = JSON.stringify(item.value); } return out; } function extractCommit(item, params) { const labels = item.metadata?.labels || {}; const annotations = item.metadata?.annotations || {}; return firstString( labels['pipelinesascode.tekton.dev/sha'], labels['pipelinesascode.tekton.dev/commit'], labels['agentrun.pikastech.local/source-commit'], labels['unidesk.ai/source-commit'], labels['hwlab.pikastech.local/source-commit'], annotations['pipelinesascode.tekton.dev/sha'], annotations['pipelinesascode.tekton.dev/commit'], annotations['unidesk.ai/source-commit'], params.revision, params.source_revision, params.sourceCommit, params.source_commit, params.git_sha, params.sha, params.commit ); } function durationSeconds(item) { const start = item.status?.startTime; const done = item.status?.completionTime; if (!start) return null; return Math.max(0, Math.round(((done ? Date.parse(done) : Date.now()) - Date.parse(start)) / 1000)); } const prefix = process.env.UNIDESK_PAC_PIPELINE_RUN_PREFIX; const pipeline = process.env.UNIDESK_PAC_PIPELINE_NAME; const rows = (data.items || []) .filter((item) => { const labels = item.metadata?.labels || {}; const name = item.metadata?.name || ''; return name.startsWith(prefix) || labels['tekton.dev/pipeline'] === pipeline || labels['tekton.dev/pipelineName'] === pipeline; }) .sort((a, b) => Date.parse(b.metadata?.creationTimestamp || 0) - Date.parse(a.metadata?.creationTimestamp || 0)) .slice(0, 8) .map((item) => { const c = cond(item); const params = mapParams(item.spec?.params); return { name: item.metadata.name, status: c.status, reason: c.reason, createdAt: item.metadata.creationTimestamp || null, startTime: item.status?.startTime || null, completionTime: item.status?.completionTime || null, durationSeconds: durationSeconds(item), sourceCommit: extractCommit(item, params), }; }); process.stdout.write(JSON.stringify(rows)); NODE rm -f "$payload_file" } history_rows() { node <<'NODE' const fs = require('node:fs'); const cp = require('node:child_process'); const { extractPacArtifactEvidence, parsePacLogRecords, taskTerminalRecord } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH); const limit = Math.max(1, Math.min(50, Number.parseInt(process.env.UNIDESK_PAC_HISTORY_LIMIT || '5', 10) || 5)); const detailId = process.env.UNIDESK_PAC_HISTORY_ID || ''; const consumers = JSON.parse(Buffer.from(process.env.UNIDESK_PAC_HISTORY_CONSUMERS_B64 || 'W10=', 'base64').toString('utf8')); const displayTimeZone = process.env.UNIDESK_PAC_DISPLAY_TIME_ZONE || 'UTC'; const kubectlJsonErrors = []; const displayTimeFormatter = new Intl.DateTimeFormat('sv-SE', { timeZone: displayTimeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); function kubectlJson(args, fallback, context) { const tmpDir = fs.mkdtempSync('/tmp/unidesk-pac-history-'); const outPath = `${tmpDir}/kubectl.json`; let outFd = null; try { outFd = fs.openSync(outPath, 'w'); const result = cp.spawnSync('kubectl', args, { stdio: ['ignore', outFd, 'pipe'], encoding: 'utf8', timeout: 30000, maxBuffer: 1024 * 1024, }); fs.closeSync(outFd); outFd = null; if (result.error || result.status !== 0) { kubectlJsonErrors.push({ context: context || args.join(' '), status: result.status, error: result.error ? String(result.error.message || result.error) : null, stderr: String(result.stderr || '').slice(0, 1000), }); return fallback; } const out = fs.readFileSync(outPath, 'utf8'); return JSON.parse(out || JSON.stringify(fallback)); } catch (error) { kubectlJsonErrors.push({ context: context || args.join(' '), status: null, error: String(error && error.message ? error.message : error).slice(0, 1000), stderr: null, }); return fallback; } finally { if (outFd !== null) { try { fs.closeSync(outFd); } catch {} } try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} } } function condition(item) { const c = (item.status?.conditions || []).find((x) => x.type === 'Succeeded') || {}; return { status: c.status || '', reason: c.reason || '', message: c.message || '' }; } function durationSeconds(start, done) { if (!start) return null; const startMs = Date.parse(start); const doneMs = done ? Date.parse(done) : Date.now(); if (!Number.isFinite(startMs) || !Number.isFinite(doneMs)) return null; return Math.max(0, Math.round((doneMs - startMs) / 1000)); } function formatDisplayTime(value) { if (!value) return null; const date = new Date(value); if (!Number.isFinite(date.getTime())) return null; return displayTimeFormatter.format(date); } function mapParams(params) { const out = {}; for (const item of params || []) { if (!item || typeof item.name !== 'string') continue; if (typeof item.value === 'string') out[item.name] = item.value; else if (item.value !== undefined && item.value !== null) out[item.name] = JSON.stringify(item.value); } return out; } function firstString(...values) { for (const value of values) { if (typeof value === 'string' && value.length > 0) return value; } return null; } function extractCommit(item, params) { const labels = item.metadata?.labels || {}; const annotations = item.metadata?.annotations || {}; return firstString( labels['pipelinesascode.tekton.dev/sha'], labels['pipelinesascode.tekton.dev/commit'], labels['unidesk.ai/source-commit'], labels['hwlab.pikastech.local/source-commit'], labels['agentrun.pikastech.local/source-commit'], annotations['pipelinesascode.tekton.dev/sha'], annotations['pipelinesascode.tekton.dev/commit'], annotations['unidesk.ai/source-commit'], params.revision, params.source_revision, params.sourceCommit, params.source_commit, params.git_sha, params.sha, params.commit ); } function extractBranch(item, params) { const labels = item.metadata?.labels || {}; const annotations = item.metadata?.annotations || {}; return firstString( labels['pipelinesascode.tekton.dev/branch'], annotations['pipelinesascode.tekton.dev/branch'], params.source_branch, params.branch, params.git_branch ); } function recursiveEnvReuse(value, state) { if (value === null || value === undefined) return; if (Array.isArray(value)) { for (const item of value) recursiveEnvReuse(item, state); return; } if (typeof value !== 'object') return; if (typeof value.buildSkippedCount === 'number') state.buildSkippedCount = value.buildSkippedCount; if (typeof value.serviceReusedCount === 'number') state.serviceReusedCount = value.serviceReusedCount; if (typeof value.reusedServiceCount === 'number') state.serviceReusedCount = value.reusedServiceCount; if (typeof value.envReuse === 'string') state.status = value.envReuse; if (typeof value.envReuseStatus === 'string') state.status = value.envReuseStatus; if (value.envReuse && typeof value.envReuse === 'object') { const env = value.envReuse; if (typeof env.mode === 'string') state.status = env.mode; if (typeof env.status === 'string') state.status = env.status; if (typeof env.dependencyReuse === 'string') state.status = env.dependencyReuse; if (typeof env.buildSkippedCount === 'number') state.buildSkippedCount = env.buildSkippedCount; if (typeof env.serviceReusedCount === 'number') state.serviceReusedCount = env.serviceReusedCount; } if (typeof value.status === 'string' && /reus|skip|built|publish/u.test(value.status)) state.status = value.status; for (const item of Object.values(value)) recursiveEnvReuse(item, state); } function envReuseForPipelineRun(namespace, name, taskRuns) { const state = { status: null, buildSkippedCount: null, serviceReusedCount: null, buildCache: null, source: 'pipeline-task-logs', sourceObservation: null, artifact: null, collector: null }; const matching = (taskRuns.items || []).filter((item) => item.metadata?.labels?.['tekton.dev/pipelineRun'] === name); const contractTaskRuns = matching.filter((item) => taskTerminalRecord(item) !== null); const terminalRecords = contractTaskRuns.map(taskTerminalRecord).filter(Boolean); const logErrors = []; let logs = ''; for (const item of contractTaskRuns) { const podName = item.status?.podName; if (!podName) { logErrors.push({ taskRun: item.metadata?.name || null, reason: 'pod-name-missing' }); continue; } const result = cp.spawnSync('kubectl', ['-n', namespace, 'logs', podName, '--all-containers=true'], { encoding: 'utf8', timeout: 12000, maxBuffer: 4 * 1024 * 1024, }); if (result.error || result.status !== 0) { logErrors.push({ taskRun: item.metadata?.name || null, reason: result.error ? String(result.error.message || result.error) : String(result.stderr || '').slice(0, 240) }); continue; } logs += `${result.stdout || ''}\n`; } if (contractTaskRuns.length === 0) { state.source = 'pipeline-run-log-fallback'; const result = cp.spawnSync('kubectl', ['-n', namespace, 'logs', '-l', `tekton.dev/pipelineRun=${name}`, '--all-containers=true', '--tail=320'], { encoding: 'utf8', timeout: 12000, maxBuffer: 2 * 1024 * 1024, }); if (result.error || result.status !== 0) { return { ...state, source: 'logs-unavailable', collector: { mode: state.source, contractTaskCount: 0, terminalRecordCount: 0, logErrorCount: 1, valuesPrinted: false } }; } logs = result.stdout || ''; } const lines = logs.split(/\r?\n/u); const headerIndex = lines.findIndex((line) => /^ENV_REUSE\s+NODE_DEPS\s+/u.test(line.trim())); if (headerIndex >= 0) { const row = (lines.slice(headerIndex + 1).find((line) => line.trim()) || '').trim().split(/\s+/u); if (row.length >= 5) { state.status = row[0]; state.buildCache = row[4]; } } const records = [...parsePacLogRecords(logs), ...terminalRecords]; for (const parsed of records) recursiveEnvReuse(parsed, state); state.artifact = extractPacArtifactEvidence(records, logs); state.sourceObservation = state.artifact.sourceObservation; state.collector = { mode: state.source, contractTaskCount: contractTaskRuns.length, terminalRecordCount: terminalRecords.length, logErrorCount: logErrors.length, logErrors, valuesPrinted: false, }; return state; } function failedStep(item) { const steps = (item.status?.steps || []).map((step) => ({ name: step.name || null, container: step.container || null, exitCode: Number.isInteger(step.terminated?.exitCode) ? step.terminated.exitCode : null, reason: step.terminated?.reason || null, terminationReason: step.terminationReason || null, })); return steps.find((step) => step.exitCode !== null && step.exitCode !== 0 && step.terminationReason !== 'Skipped') || steps.find((step) => step.terminationReason === 'Error') || null; } function taskSummary(taskRuns, name, namespace) { const rows = (taskRuns.items || []) .filter((item) => item.metadata?.labels?.['tekton.dev/pipelineRun'] === name) .map((item) => { const c = condition(item); return { name: item.metadata?.name || '', status: c.status || null, reason: c.reason || null, message: c.message || null, failedStep: failedStep(item), startTime: item.status?.startTime || null, completionTime: item.status?.completionTime || null, durationSeconds: durationSeconds(item.status?.startTime, item.status?.completionTime), }; }); const longest = [...rows].sort((a, b) => (b.durationSeconds || 0) - (a.durationSeconds || 0))[0] || null; const failed = rows.find((row) => row.status === 'False') || null; const target = process.env.UNIDESK_PAC_TARGET_ID || ''; return { count: rows.length, longest, failed, details: detailId ? rows : undefined, logsCommand: target && namespace ? `trans ${target}:k3s logs -n ${namespace} -l tekton.dev/pipelineRun=${name} --all-containers --tail=240` : null, }; } function rowFor(consumer, item, taskRuns) { const params = mapParams(item.spec?.params); const c = condition(item); const tasks = taskSummary(taskRuns, item.metadata.name, consumer.namespace); const labels = item.metadata?.labels || {}; const annotations = item.metadata?.annotations || {}; const sourceCommit = extractCommit(item, params); const evidence = envReuseForPipelineRun(consumer.namespace, item.metadata.name, taskRuns); const sourceObservation = evidence.sourceObservation === null ? null : { ...evidence.sourceObservation, sourceMatched: sourceCommit !== null && evidence.sourceObservation.sourceCommit === sourceCommit, }; return { id: item.metadata.name, consumer: consumer.id, repository: consumer.repository, repo: consumer.repo, repoUrl: consumer.repoUrl, pipeline: consumer.pipeline, pipelineRun: item.metadata.name, triggeredAt: item.metadata?.creationTimestamp || item.status?.startTime || null, startTime: item.status?.startTime || null, completionTime: item.status?.completionTime || null, displayTime: formatDisplayTime(item.metadata?.creationTimestamp || item.status?.startTime || null), displayStartTime: formatDisplayTime(item.status?.startTime || null), displayCompletionTime: formatDisplayTime(item.status?.completionTime || null), displayTimeZone, durationSeconds: durationSeconds(item.status?.startTime || item.metadata?.creationTimestamp, item.status?.completionTime), status: c.status || null, reason: c.reason || null, commit: sourceCommit, branch: extractBranch(item, params), eventType: firstString(labels['pipelinesascode.tekton.dev/event-type'], annotations['pipelinesascode.tekton.dev/event-type']), sender: firstString(labels['pipelinesascode.tekton.dev/sender'], annotations['pipelinesascode.tekton.dev/sender']), envReuse: { status: evidence.status, buildSkippedCount: evidence.buildSkippedCount, serviceReusedCount: evidence.serviceReusedCount, buildCache: evidence.buildCache, source: evidence.source, }, sourceObservation, artifact: evidence.artifact, collector: evidence.collector, taskRuns: tasks, source: 'gitea-pac-tekton-live', valuesPrinted: false, }; } function pipelineRunMatchesConsumer(consumer, item) { const labels = item.metadata?.labels || {}; const name = item.metadata?.name || ''; return name.startsWith(consumer.pipelineRunPrefix) || labels['tekton.dev/pipeline'] === consumer.pipeline || labels['tekton.dev/pipelineName'] === consumer.pipeline; } const rows = []; const consumerSummaries = []; for (const consumer of consumers) { const pipelineRuns = kubectlJson(['-n', consumer.namespace, 'get', 'pipelinerun', '-o', 'json'], { items: [] }, `${consumer.id}:pipelinerun`); const taskRuns = kubectlJson(['-n', consumer.namespace, 'get', 'taskrun', '-o', 'json'], { items: [] }, `${consumer.id}:taskrun`); let matches = (pipelineRuns.items || []).filter((item) => { const name = item.metadata?.name || ''; if (detailId) return name === detailId && pipelineRunMatchesConsumer(consumer, item); return pipelineRunMatchesConsumer(consumer, item); }); matches = matches.sort((a, b) => Date.parse(b.metadata?.creationTimestamp || 0) - Date.parse(a.metadata?.creationTimestamp || 0)); if (!detailId) matches = matches.slice(0, limit); for (const item of matches) rows.push(rowFor(consumer, item, taskRuns)); consumerSummaries.push({ id: consumer.id, namespace: consumer.namespace, repository: consumer.repository, pipelineRunPrefix: consumer.pipelineRunPrefix, totalPipelineRuns: (pipelineRuns.items || []).length, matched: matches.length }); } rows.sort((a, b) => Date.parse(b.triggeredAt || 0) - Date.parse(a.triggeredAt || 0)); process.stdout.write(JSON.stringify({ rows, consumers: consumerSummaries, errors: kubectlJsonErrors })); NODE } task_rows() { payload_file=$(mktemp) kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get taskrun -o json >"$payload_file" 2>/dev/null || printf '{"items":[]}' >"$payload_file" node - "$payload_file" <<'NODE' const fs = require('node:fs'); const input = fs.readFileSync(process.argv[2], 'utf8') || '{"items":[]}'; const data = input ? JSON.parse(input) : { items: [] }; const pr = process.env.UNIDESK_PAC_TARGET_PIPELINERUN || ''; if (!pr) { process.stdout.write('[]'); process.exit(0); } function cond(item) { const c = (item.status?.conditions || []).find((x) => x.type === 'Succeeded') || {}; return { status: c.status || '', reason: c.reason || '', message: c.message || '' }; } function failedStep(item) { const steps = (item.status?.steps || []).map((step) => ({ name: step.name || null, exitCode: Number.isInteger(step.terminated?.exitCode) ? step.terminated.exitCode : null, reason: step.terminated?.reason || null, terminationReason: step.terminationReason || null, })); return steps.find((step) => step.exitCode !== null && step.exitCode !== 0 && step.terminationReason !== 'Skipped') || steps.find((step) => step.terminationReason === 'Error') || null; } function durationSeconds(item) { const start = item.status?.startTime; const done = item.status?.completionTime; if (!start) return null; return Math.max(0, Math.round(((done ? Date.parse(done) : Date.now()) - Date.parse(start)) / 1000)); } const rows = (data.items || []) .filter((item) => item.metadata?.labels?.['tekton.dev/pipelineRun'] === pr) .sort((a, b) => Date.parse(a.metadata?.creationTimestamp || 0) - Date.parse(b.metadata?.creationTimestamp || 0)) .slice(0, 20) .map((item) => { const c = cond(item); return { name: item.metadata.name, pipelineRun: item.metadata.labels?.['tekton.dev/pipelineRun'] || null, status: c.status, reason: c.reason, message: c.message || null, failedStep: failedStep(item), startTime: item.status?.startTime || null, completionTime: item.status?.completionTime || null, durationSeconds: durationSeconds(item) }; }); process.stdout.write(JSON.stringify(rows)); NODE rm -f "$payload_file" } artifact_summary() { if [ -z "${UNIDESK_PAC_TARGET_PIPELINERUN:-}" ]; then printf '{}' return fi task_file=$(mktemp) kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get taskrun -l "tekton.dev/pipelineRun=$UNIDESK_PAC_TARGET_PIPELINERUN" -o json >"$task_file" 2>/dev/null || printf '{"items":[]}' >"$task_file" node - "$task_file" <<'NODE' const fs = require('node:fs'); const cp = require('node:child_process'); const { extractPacArtifactEvidence, parsePacLogRecords, taskTerminalRecord } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH); const input = JSON.parse(fs.readFileSync(process.argv[2], 'utf8') || '{"items":[]}'); const namespace = process.env.UNIDESK_PAC_TARGET_NAMESPACE; const pipelineRun = process.env.UNIDESK_PAC_TARGET_PIPELINERUN; const taskRuns = (input.items || []).filter((item) => item.metadata?.labels?.['tekton.dev/pipelineRun'] === pipelineRun); const contractTaskRuns = taskRuns.filter((item) => taskTerminalRecord(item) !== null); const terminalRecords = contractTaskRuns.map(taskTerminalRecord).filter(Boolean); const logErrors = []; let logText = ''; for (const item of contractTaskRuns) { const podName = item.status?.podName; if (!podName) { logErrors.push({ taskRun: item.metadata?.name || null, reason: 'pod-name-missing' }); continue; } const result = cp.spawnSync('kubectl', ['-n', namespace, 'logs', podName, '--all-containers=true'], { encoding: 'utf8', timeout: 12000, maxBuffer: 4 * 1024 * 1024, }); if (result.error || result.status !== 0) { logErrors.push({ taskRun: item.metadata?.name || null, reason: result.error ? String(result.error.message || result.error) : String(result.stderr || '').slice(0, 240) }); continue; } logText += `${result.stdout || ''}\n`; } let mode = 'pipeline-task-logs'; if (contractTaskRuns.length === 0) { mode = 'pipeline-run-log-fallback'; const fallback = cp.spawnSync('kubectl', ['-n', namespace, 'logs', '-l', `tekton.dev/pipelineRun=${pipelineRun}`, '--all-containers=true', '--tail=320'], { encoding: 'utf8', timeout: 12000, maxBuffer: 2 * 1024 * 1024, }); if (!fallback.error && fallback.status === 0) logText = fallback.stdout || ''; else logErrors.push({ taskRun: null, reason: fallback.error ? String(fallback.error.message || fallback.error) : String(fallback.stderr || '').slice(0, 240) }); } const records = [...parsePacLogRecords(logText), ...terminalRecords]; const out = extractPacArtifactEvidence(records, logText); out.collector = { mode, contractTaskCount: contractTaskRuns.length, terminalRecordCount: terminalRecords.length, logErrorCount: logErrors.length, logErrors, valuesPrinted: false, }; process.stdout.write(JSON.stringify(out)); NODE rm -f "$task_file" } runtime_summary() { app_file=$(mktemp) params_file=$(mktemp) kubectl -n "$UNIDESK_PAC_ARGO_NAMESPACE" get application "$UNIDESK_PAC_ARGO_APPLICATION" -o json >"$app_file" 2>/dev/null || printf '{}' >"$app_file" printf '%s' "$UNIDESK_PAC_PARAMS_JSON" >"$params_file" target=$(node - "$app_file" "$params_file" <<'NODE' const fs = require('node:fs'); const app = JSON.parse(fs.readFileSync(process.argv[2], 'utf8') || '{}'); const params = JSON.parse(fs.readFileSync(process.argv[3], 'utf8') || '{}'); const prefix = process.env.UNIDESK_PAC_CONSUMER_ID === 'unidesk-host' ? 'unidesk_host_' : ''; const param = (name) => params[`${prefix}${name}`] ?? params[name]; if (param('runtime_namespace') && param('runtime_deployment')) { process.stdout.write(`${param('runtime_namespace')}\t${param('runtime_deployment')}`); process.exit(0); } const resource = (app.status?.resources || []).find((item) => item.kind === 'Deployment' && item.namespace && item.name); if (resource) process.stdout.write(`${resource.namespace}\t${resource.name}`); NODE ) rm -f "$app_file" "$params_file" if [ -z "$target" ]; then printf '{}' return fi namespace=$(printf '%s' "$target" | cut -f1) name=$(printf '%s' "$target" | cut -f2) deploy_file=$(mktemp) kubectl -n "$namespace" get deploy "$name" -o json >"$deploy_file" 2>/dev/null || printf '{}' >"$deploy_file" node - "$deploy_file" <<'NODE' || printf '{}' const fs = require('node:fs'); const input = fs.readFileSync(process.argv[2], 'utf8').trim(); if (!input) { process.stdout.write('{}'); process.exit(0); } const deploy = JSON.parse(input); const image = deploy.spec?.template?.spec?.containers?.[0]?.image || null; const digest = image && String(image).includes('@') ? String(image).split('@').slice(1).join('@') : null; process.stdout.write(JSON.stringify({ namespace: deploy.metadata?.namespace || null, deployment: deploy.metadata?.name || null, readyReplicas: deploy.status?.readyReplicas ?? null, replicas: deploy.spec?.replicas ?? null, image, digest, valuesPrinted: false, })); NODE rm -f "$deploy_file" } resolve_git_service_url() { repo_url="$1" host=$(UNIDESK_PAC_GIT_URL="$repo_url" node <<'NODE' try { process.stdout.write(new URL(process.env.UNIDESK_PAC_GIT_URL || '').hostname); } catch {} NODE ) case "$host" in *.svc.cluster.local) service=${host%%.*} rest=${host#*.} namespace=${rest%%.*} cluster_ip=$(kubectl -n "$namespace" get svc "$service" -o jsonpath='{.spec.clusterIP}' 2>/dev/null || true) if [ -z "$cluster_ip" ]; then return 1 fi UNIDESK_PAC_GIT_URL="$repo_url" UNIDESK_PAC_GIT_CLUSTER_IP="$cluster_ip" node <<'NODE' try { const url = new URL(process.env.UNIDESK_PAC_GIT_URL || ''); url.hostname = process.env.UNIDESK_PAC_GIT_CLUSTER_IP || ''; process.stdout.write(url.toString()); } catch { process.exit(1); } NODE ;; *) printf '%s' "$repo_url" ;; esac } gitops_revision_relation() { artifact_json="${1:-}" argo_json="${2:-}" [ -n "$artifact_json" ] || artifact_json='{}' [ -n "$argo_json" ] || argo_json='{}' fields=$(UNIDESK_PAC_ARTIFACT_JSON="$artifact_json" UNIDESK_PAC_ARGO_JSON="$argo_json" node <<'NODE' function parse(value) { try { return JSON.parse(value || '{}'); } catch { return {}; } } function line(key, value) { process.stdout.write(`${key}=${Buffer.from(String(value || ''), 'utf8').toString('base64')}\n`); } const artifact = parse(process.env.UNIDESK_PAC_ARTIFACT_JSON); const argo = parse(process.env.UNIDESK_PAC_ARGO_JSON); line('expected', artifact.gitopsCommit); line('observed', argo.revision); line('repoUrl', argo.repoURL); line('targetRevision', argo.targetRevision); line('retained', artifact.sourceObservation?.mode === 'no-runtime-change' && artifact.sourceObservation?.valid === true ? 'true' : 'false'); NODE ) expected=$(printf '%s\n' "$fields" | sed -n 's/^expected=//p' | base64 -d 2>/dev/null || true) observed=$(printf '%s\n' "$fields" | sed -n 's/^observed=//p' | base64 -d 2>/dev/null || true) repo_url=$(printf '%s\n' "$fields" | sed -n 's/^repoUrl=//p' | base64 -d 2>/dev/null || true) target_revision=$(printf '%s\n' "$fields" | sed -n 's/^targetRevision=//p' | base64 -d 2>/dev/null || true) retained=$(printf '%s\n' "$fields" | sed -n 's/^retained=//p' | base64 -d 2>/dev/null || true) relation=unknown proof=unavailable reason=missing-revision-context fetch_ok=false if [ "$retained" = true ] && printf '%s' "$observed" | grep -Eq '^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$'; then relation=retained proof=structured-no-runtime-change-plan reason=source-observation-retains-current-revision elif printf '%s\n%s\n' "$expected" "$observed" | grep -Eqv '^[0-9a-fA-F]{7,64}$'; then reason=invalid-or-missing-revision elif [ -z "$repo_url" ] \ || ! git check-ref-format "refs/heads/$target_revision" >/dev/null 2>&1; then reason=invalid-or-missing-git-context else repo_dir=$(mktemp -d) if resolved_url=$(resolve_git_service_url "$repo_url") \ && git init --bare -q "$repo_dir/repo.git" \ && timeout "$UNIDESK_PAC_WAIT_TIMEOUT_SECONDS" git --git-dir="$repo_dir/repo.git" fetch -q --no-tags "$resolved_url" "+refs/heads/$target_revision:refs/remotes/origin/$target_revision" >/dev/null 2>&1; then fetch_ok=true if git --git-dir="$repo_dir/repo.git" cat-file -e "$expected^{commit}" 2>/dev/null \ && git --git-dir="$repo_dir/repo.git" cat-file -e "$observed^{commit}" 2>/dev/null; then proof=owning-git-commit-graph expected=$(git --git-dir="$repo_dir/repo.git" rev-parse "$expected^{commit}") observed=$(git --git-dir="$repo_dir/repo.git" rev-parse "$observed^{commit}") if [ "$expected" = "$observed" ]; then relation=exact reason=commits-equal elif git --git-dir="$repo_dir/repo.git" merge-base --is-ancestor "$expected" "$observed" >/dev/null 2>&1; then relation=descendant reason=observed-descends-from-selected elif git --git-dir="$repo_dir/repo.git" merge-base --is-ancestor "$observed" "$expected" >/dev/null 2>&1; then relation=stale reason=observed-precedes-selected else relation=diverged reason=commits-have-diverged fi else reason=commit-object-missing fi else reason=owning-git-fetch-failed fi rm -rf "$repo_dir" fi UNIDESK_PAC_GITOPS_EXPECTED="$expected" \ UNIDESK_PAC_GITOPS_OBSERVED="$observed" \ UNIDESK_PAC_GITOPS_REPO_URL="$repo_url" \ UNIDESK_PAC_GITOPS_TARGET_REVISION="$target_revision" \ UNIDESK_PAC_GITOPS_RELATION="$relation" \ UNIDESK_PAC_GITOPS_PROOF="$proof" \ UNIDESK_PAC_GITOPS_REASON="$reason" \ UNIDESK_PAC_GITOPS_FETCH_OK="$fetch_ok" node <<'NODE' process.stdout.write(JSON.stringify({ relation: process.env.UNIDESK_PAC_GITOPS_RELATION || 'unknown', expectedRevision: process.env.UNIDESK_PAC_GITOPS_EXPECTED || null, observedRevision: process.env.UNIDESK_PAC_GITOPS_OBSERVED || null, repoURL: process.env.UNIDESK_PAC_GITOPS_REPO_URL || null, targetRevision: process.env.UNIDESK_PAC_GITOPS_TARGET_REVISION || null, proof: process.env.UNIDESK_PAC_GITOPS_PROOF || 'unavailable', reason: process.env.UNIDESK_PAC_GITOPS_REASON || null, fetchOk: process.env.UNIDESK_PAC_GITOPS_FETCH_OK === 'true', valuesPrinted: false, })); NODE } cicd_diagnostics() { params_file=$(mktemp) pipelines_file=$(mktemp) artifact_file=$(mktemp) argo_file=$(mktemp) runtime_file=$(mktemp) printf '%s' "$UNIDESK_PAC_PARAMS_JSON" >"$params_file" printf '%s' "${1:-[]}" >"$pipelines_file" printf '%s' "${2:-{}}" >"$artifact_file" printf '%s' "${3:-{}}" >"$argo_file" printf '%s' "${4:-{}}" >"$runtime_file" probe_env=$(node - "$params_file" "$pipelines_file" "$artifact_file" <<'NODE' const fs = require('node:fs'); function parseLoose(path, fallback) { const input = fs.readFileSync(path, 'utf8').trim(); if (!input) return fallback; for (let end = input.length; end > 0; end -= 1) { try { return JSON.parse(input.slice(0, end)); } catch {} } return fallback; } const params = parseLoose(process.argv[2], {}); const pipelines = parseLoose(process.argv[3], []); const artifact = parseLoose(process.argv[4], {}); const latest = Array.isArray(pipelines) ? pipelines[0] || {} : {}; const sourceCommit = latest.sourceCommit || null; const noRuntimeChange = artifact.sourceObservation?.mode === 'no-runtime-change' && artifact.sourceObservation?.valid === true; const runtimeSourceCommit = artifact.runtimeSourceCommit || (noRuntimeChange ? null : sourceCommit); const tag = !noRuntimeChange && runtimeSourceCommit ? String(runtimeSourceCommit).slice(0, 12) : ''; const prefix = process.env.UNIDESK_PAC_CONSUMER_ID === 'unidesk-host' ? 'unidesk_host_' : ''; const param = (name) => params[`${prefix}${name}`] ?? params[name]; const imageRepository = typeof param('image_repository') === 'string' ? param('image_repository') : ''; const probeBase = typeof param('registry_probe_base') === 'string' ? param('registry_probe_base').replace(/\/+$/u, '') : ''; let registryUrl = ''; if (imageRepository && tag) { const firstSlash = imageRepository.indexOf('/'); const registry = firstSlash >= 0 ? imageRepository.slice(0, firstSlash) : imageRepository; const path = firstSlash >= 0 ? imageRepository.slice(firstSlash + 1) : ''; const base = probeBase || (registry ? `http://${registry}` : ''); registryUrl = base && path ? `${base}/v2/${path}/manifests/${tag}` : ''; } function line(key, value) { process.stdout.write(`${key}=${Buffer.from(String(value || ''), 'utf8').toString('base64')}\n`); } line('imageRepository', imageRepository); line('registryProbeBase', probeBase); line('sourceCommit', sourceCommit || ''); line('runtimeSourceCommit', runtimeSourceCommit || ''); line('tag', tag); line('registryUrl', registryUrl); line('sentinelId', param('sentinel_id') || ''); line('gitopsBranch', param('gitops_branch') || ''); line('gitopsManifestPath', param('gitops_manifest_path') || ''); line('runtimeNamespace', param('runtime_namespace') || ''); line('runtimeService', param('runtime_service') || ''); line('runtimeServicePort', param('runtime_service_port') || ''); line('healthPath', param('health_path') || ''); line('healthUrl', param('health_url') || ''); NODE ) image_repository=$(printf '%s\n' "$probe_env" | sed -n 's/^imageRepository=//p' | base64 -d 2>/dev/null || true) registry_probe_base=$(printf '%s\n' "$probe_env" | sed -n 's/^registryProbeBase=//p' | base64 -d 2>/dev/null || true) source_commit=$(printf '%s\n' "$probe_env" | sed -n 's/^sourceCommit=//p' | base64 -d 2>/dev/null || true) runtime_source_commit=$(printf '%s\n' "$probe_env" | sed -n 's/^runtimeSourceCommit=//p' | base64 -d 2>/dev/null || true) image_tag=$(printf '%s\n' "$probe_env" | sed -n 's/^tag=//p' | base64 -d 2>/dev/null || true) registry_url=$(printf '%s\n' "$probe_env" | sed -n 's/^registryUrl=//p' | base64 -d 2>/dev/null || true) sentinel_id=$(printf '%s\n' "$probe_env" | sed -n 's/^sentinelId=//p' | base64 -d 2>/dev/null || true) gitops_branch=$(printf '%s\n' "$probe_env" | sed -n 's/^gitopsBranch=//p' | base64 -d 2>/dev/null || true) gitops_manifest_path=$(printf '%s\n' "$probe_env" | sed -n 's/^gitopsManifestPath=//p' | base64 -d 2>/dev/null || true) runtime_namespace=$(printf '%s\n' "$probe_env" | sed -n 's/^runtimeNamespace=//p' | base64 -d 2>/dev/null || true) runtime_service=$(printf '%s\n' "$probe_env" | sed -n 's/^runtimeService=//p' | base64 -d 2>/dev/null || true) runtime_service_port=$(printf '%s\n' "$probe_env" | sed -n 's/^runtimeServicePort=//p' | base64 -d 2>/dev/null || true) health_path=$(printf '%s\n' "$probe_env" | sed -n 's/^healthPath=//p' | base64 -d 2>/dev/null || true) health_url=$(printf '%s\n' "$probe_env" | sed -n 's/^healthUrl=//p' | base64 -d 2>/dev/null || true) registry_present=false registry_digest="" if [ -n "$registry_url" ]; then header_file=$(mktemp) if curl -fsS -D "$header_file" -o /dev/null -H 'Accept: application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' "$registry_url" >/dev/null 2>&1; then registry_present=true fi headers=$(cat "$header_file" 2>/dev/null || true) rm -f "$header_file" if printf '%s' "$headers" | grep -qi '^HTTP/.* 200'; then registry_present=true; fi registry_digest=$(printf '%s\n' "$headers" | awk 'BEGIN{IGNORECASE=1} /^Docker-Content-Digest:/ { sub(/\r$/,"",$2); print $2; exit }') fi registry_present_json=false if [ "$registry_present" = true ]; then registry_present_json=true; fi health_status="" if [ -n "$health_url" ]; then if [ -n "$runtime_namespace" ] && [ -n "$runtime_service" ] && [ -n "$runtime_service_port" ] && [ -n "$health_path" ]; then health_file=$(mktemp) if kubectl get --raw "/api/v1/namespaces/$runtime_namespace/services/$runtime_service:$runtime_service_port/proxy$health_path" >"$health_file" 2>/dev/null \ && node - "$health_file" <<'NODE' const fs = require('node:fs'); const body = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); process.exit(body?.ok === true ? 0 : 1); NODE then health_status=200 else health_status=503 fi rm -f "$health_file" else health_status=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 8 "$health_url" 2>/dev/null || true) fi fi export UNIDESK_PAC_DIAG_IMAGE_REPOSITORY="$image_repository" export UNIDESK_PAC_DIAG_REGISTRY_PROBE_BASE="$registry_probe_base" export UNIDESK_PAC_DIAG_SOURCE_COMMIT="$source_commit" export UNIDESK_PAC_DIAG_RUNTIME_SOURCE_COMMIT="$runtime_source_commit" export UNIDESK_PAC_DIAG_IMAGE_TAG="$image_tag" export UNIDESK_PAC_DIAG_REGISTRY_URL="$registry_url" export UNIDESK_PAC_DIAG_REGISTRY_PRESENT="$registry_present_json" export UNIDESK_PAC_DIAG_REGISTRY_DIGEST="$registry_digest" export UNIDESK_PAC_DIAG_SENTINEL_ID="$sentinel_id" export UNIDESK_PAC_DIAG_GITOPS_BRANCH="$gitops_branch" export UNIDESK_PAC_DIAG_GITOPS_MANIFEST_PATH="$gitops_manifest_path" export UNIDESK_PAC_DIAG_HEALTH_URL="$health_url" export UNIDESK_PAC_DIAG_HEALTH_STATUS="$health_status" node - "$params_file" "$pipelines_file" "$artifact_file" "$argo_file" "$runtime_file" <<'NODE' const fs = require('node:fs'); const { evaluatePacStatus } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH); function parseLoose(path, fallback) { const input = fs.readFileSync(path, 'utf8').trim(); if (!input) return fallback; for (let end = input.length; end > 0; end -= 1) { try { return JSON.parse(input.slice(0, end)); } catch {} } return fallback; } const pipelines = parseLoose(process.argv[3], []); const artifact = parseLoose(process.argv[4], {}); const argo = parseLoose(process.argv[5], {}); const runtime = parseLoose(process.argv[6], {}); const latest = Array.isArray(pipelines) ? pipelines[0] || {} : {}; const evaluated = evaluatePacStatus({ sourceCommit: process.env.UNIDESK_PAC_DIAG_SOURCE_COMMIT || latest.sourceCommit || null, runtimeSourceCommit: process.env.UNIDESK_PAC_DIAG_RUNTIME_SOURCE_COMMIT || artifact.runtimeSourceCommit || null, imageRepository: process.env.UNIDESK_PAC_DIAG_IMAGE_REPOSITORY || null, registryProbeBase: process.env.UNIDESK_PAC_DIAG_REGISTRY_PROBE_BASE || null, imageTag: process.env.UNIDESK_PAC_DIAG_IMAGE_TAG || null, registryUrl: process.env.UNIDESK_PAC_DIAG_REGISTRY_URL || null, registryPresent: process.env.UNIDESK_PAC_DIAG_REGISTRY_PRESENT === 'true', registryDigest: process.env.UNIDESK_PAC_DIAG_REGISTRY_DIGEST || null, gitopsBranch: process.env.UNIDESK_PAC_DIAG_GITOPS_BRANCH || null, gitopsManifestPath: process.env.UNIDESK_PAC_DIAG_GITOPS_MANIFEST_PATH || null, healthUrl: process.env.UNIDESK_PAC_DIAG_HEALTH_URL || null, healthStatus: process.env.UNIDESK_PAC_DIAG_HEALTH_STATUS || null, pipelineRun: latest.name || null, artifact, argo, runtime, }); process.stdout.write(JSON.stringify(evaluated)); NODE rm -f "$params_file" "$pipelines_file" "$artifact_file" "$argo_file" "$runtime_file" } hook_summary() { hooks=$(gitea_api GET "repos/$UNIDESK_PAC_GITEA_OWNER/$UNIDESK_PAC_GITEA_REPO/hooks" 2>/dev/null || echo '[]') HOOKS_JSON="$hooks" node <<'NODE' const data = JSON.parse(process.env.HOOKS_JSON || '[]'); const url = process.env.UNIDESK_PAC_WEBHOOK_URL; const rows = data.filter((item) => item?.config?.url === url).map((item) => ({ id: item.id, active: item.active, type: item.type, events: item.events || [], url: item.config?.url || '' })); process.stdout.write(JSON.stringify(rows)); NODE } status_action() { crd=$(kubectl get crd repositories.pipelinesascode.tekton.dev -o name 2>/dev/null || true) controller_ready=$(kubectl -n "$UNIDESK_PAC_RELEASE_NAMESPACE" get deploy "$UNIDESK_PAC_CONTROLLER_SERVICE_NAME" -o jsonpath='{.status.readyReplicas}/{.spec.replicas}' 2>/dev/null || echo "0/0") repository_condition=$(condition_status "$UNIDESK_PAC_TARGET_NAMESPACE" repository "$UNIDESK_PAC_REPOSITORY_NAME") pipelines=$(pipeline_rows) latest=$(printf '%s' "$pipelines" | node -e 'const fs=require("fs"); const a=JSON.parse(fs.readFileSync(0,"utf8")||"[]"); process.stdout.write(a[0]?.name||"")') export UNIDESK_PAC_TARGET_PIPELINERUN="$latest" tasks=$(task_rows) artifact=$(json_normalize "$(artifact_summary)") hooks=$(hook_summary) argo=$(kubectl -n "$UNIDESK_PAC_ARGO_NAMESPACE" get application "$UNIDESK_PAC_ARGO_APPLICATION" -o json 2>/dev/null | node -e 'const fs=require("fs"); const s=fs.readFileSync(0,"utf8").trim(); if(!s){process.stdout.write("{}"); process.exit(0)} const a=JSON.parse(s); const source=a.spec?.source || a.spec?.sources?.[0] || {}; let repoURL=source.repoURL||null; try { const url=new URL(repoURL); url.username=""; url.password=""; repoURL=url.toString(); } catch {} process.stdout.write(JSON.stringify({sync:a.status?.sync?.status||null, health:a.status?.health?.status||null, revision:a.status?.sync?.revision||null, repoURL, targetRevision:source.targetRevision||null}))' || echo '{}') argo=$(json_normalize "$argo") revision_relation=$(gitops_revision_relation "$artifact" "$argo") artifact=$(UNIDESK_PAC_ARTIFACT_JSON="$artifact" UNIDESK_PAC_REVISION_RELATION_JSON="$revision_relation" node <<'NODE' const artifact = JSON.parse(process.env.UNIDESK_PAC_ARTIFACT_JSON || '{}'); const revisionRelation = JSON.parse(process.env.UNIDESK_PAC_REVISION_RELATION_JSON || '{}'); process.stdout.write(JSON.stringify({ ...artifact, gitopsCommit: revisionRelation.expectedRevision || artifact.gitopsCommit || null })); NODE ) argo=$(UNIDESK_PAC_ARGO_JSON="$argo" UNIDESK_PAC_REVISION_RELATION_JSON="$revision_relation" node <<'NODE' const argo = JSON.parse(process.env.UNIDESK_PAC_ARGO_JSON || '{}'); const revisionRelation = JSON.parse(process.env.UNIDESK_PAC_REVISION_RELATION_JSON || '{"relation":"unknown"}'); process.stdout.write(JSON.stringify({ ...argo, revisionRelation })); NODE ) runtime=$(json_normalize "$(runtime_summary)") diagnostics=$(cicd_diagnostics "$pipelines" "$artifact" "$argo" "$runtime") printf '{"ok":%s,"crdPresent":%s,"controllerReady":"%s","repository":{"name":"%s","repo":"%s/%s","url":"%s","condition":"%s"},"repositoryCondition":"%s","webhooks":%s,"pipelineRuns":%s,"taskRuns":%s,"artifact":%s,"argo":%s,"runtime":%s,"diagnostics":%s,"valuesPrinted":false}\n' \ "$( [ -n "$crd" ] && [ "$controller_ready" != "0/0" ] && echo true || echo false )" \ "$( [ -n "$crd" ] && echo true || echo false )" \ "$(json_string "$controller_ready")" \ "$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" \ "$(json_string "$UNIDESK_PAC_GITEA_OWNER")" \ "$(json_string "$UNIDESK_PAC_GITEA_REPO")" \ "$(json_string "$UNIDESK_PAC_REPOSITORY_URL")" \ "$(json_string "$repository_condition")" \ "$(json_string "$repository_condition")" \ "$hooks" "$pipelines" "$tasks" "$artifact" "$argo" "$runtime" "$diagnostics" } history_action() { crd=$(kubectl get crd repositories.pipelinesascode.tekton.dev -o name 2>/dev/null || true) controller_ready=$(kubectl -n "$UNIDESK_PAC_RELEASE_NAMESPACE" get deploy "$UNIDESK_PAC_CONTROLLER_SERVICE_NAME" -o jsonpath='{.status.readyReplicas}/{.spec.replicas}' 2>/dev/null || echo "0/0") repository_condition=$(condition_status "$UNIDESK_PAC_TARGET_NAMESPACE" repository "$UNIDESK_PAC_REPOSITORY_NAME") hooks=$(hook_summary) history=$(history_rows) rows=$(printf '%s' "$history" | node -e 'const fs=require("fs"); const h=JSON.parse(fs.readFileSync(0,"utf8")||"{}"); process.stdout.write(JSON.stringify(h.rows||[]));') consumers=$(printf '%s' "$history" | node -e 'const fs=require("fs"); const h=JSON.parse(fs.readFileSync(0,"utf8")||"{}"); process.stdout.write(JSON.stringify(h.consumers||[]));') errors=$(printf '%s' "$history" | node -e 'const fs=require("fs"); const h=JSON.parse(fs.readFileSync(0,"utf8")||"{}"); process.stdout.write(JSON.stringify(h.errors||[]));') printf '{"ok":%s,"crdPresent":%s,"controllerReady":"%s","repositoryCondition":"%s","repository":{"name":"%s","repo":"%s/%s","url":"%s"},"consumer":"%s","display":{"timeZone":"%s"},"consumerRows":%s,"rows":%s,"historyErrors":%s,"webhooks":%s,"source":"gitea-pac-tekton-live","historyStore":"none","valuesPrinted":false}\n' \ "$( [ -n "$crd" ] && [ "$controller_ready" != "0/0" ] && echo true || echo false )" \ "$( [ -n "$crd" ] && echo true || echo false )" \ "$(json_string "$controller_ready")" \ "$(json_string "$repository_condition")" \ "$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" \ "$(json_string "$UNIDESK_PAC_GITEA_OWNER")" \ "$(json_string "$UNIDESK_PAC_GITEA_REPO")" \ "$(json_string "$UNIDESK_PAC_REPOSITORY_URL")" \ "$(json_string "$UNIDESK_PAC_CONSUMER_ID")" \ "$(json_string "$UNIDESK_PAC_DISPLAY_TIME_ZONE")" \ "$consumers" "$rows" "$errors" "$hooks" } debug_step_action() { fixtures=$(node <<'NODE' const { runPacStatusFixtureChecks } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH); const result = runPacStatusFixtureChecks(); process.stdout.write(JSON.stringify(result)); NODE ) if [ -z "$UNIDESK_PAC_HISTORY_ID" ]; then printf '%s\n' "$fixtures" FIXTURES_JSON="$fixtures" node -e 'const result=JSON.parse(process.env.FIXTURES_JSON||"{}"); if(!result.ok) process.exitCode=1' return fi history=$(history_rows) UNIDESK_PAC_DEBUG_FIXTURES_JSON="$fixtures" UNIDESK_PAC_DEBUG_HISTORY_JSON="$history" node <<'NODE' function record(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; } function terminalRow(sourceObservation, key, step) { const source = record(record(sourceObservation.terminalSources)[key]); return { step, present: source.present === true, status: source.status || 'missing', source: source.source || null, taskRun: source.taskRun || null, reason: source.reason || null, results: Array.isArray(source.results) ? source.results : [], markerPresent: source.markerPresent === true, markerSource: source.markerSource || null, valuesPrinted: false, }; } const fixtures = JSON.parse(process.env.UNIDESK_PAC_DEBUG_FIXTURES_JSON || '{}'); const history = JSON.parse(process.env.UNIDESK_PAC_DEBUG_HISTORY_JSON || '{}'); const rows = Array.isArray(history.rows) ? history.rows : []; const historyErrors = Array.isArray(history.errors) ? history.errors : []; const row = record(rows[0]); const sourceObservation = record(row.sourceObservation); const artifact = record(row.artifact); const catalog = record(artifact.catalog); const collector = record(row.collector); const terminal = [ terminalRow(sourceObservation, 'planArtifacts', 'plan-artifacts'), terminalRow(sourceObservation, 'collectArtifacts', 'collect-artifacts'), terminalRow(sourceObservation, 'gitopsPromote', 'gitops-promote'), ]; let firstBreak = null; function breakAt(code, stage, reason) { if (firstBreak === null) firstBreak = { code, stage, reason, valuesPrinted: false }; } if (historyErrors.length > 0) breakAt('target-read-failed', 'target-read', historyErrors[0]?.error || historyErrors[0]?.stderr || 'target-side history read failed'); if (Object.keys(row).length === 0) breakAt('pipeline-run-not-found', 'pipeline-run', `PipelineRun ${process.env.UNIDESK_PAC_HISTORY_ID || '-'} was not found for the selected consumer`); if (Object.keys(row).length > 0 && row.status !== 'True') breakAt('pipeline-run-not-succeeded', 'pipeline-run', `PipelineRun terminal status is ${row.status || row.reason || 'unknown'}`); if (Object.keys(row).length > 0 && Object.keys(sourceObservation).length === 0) breakAt('source-observation-missing', 'plan-artifacts', 'g14-ci-plan structured evidence is missing'); if (sourceObservation.sourceMatched !== true) breakAt('source-observation-mismatch', 'plan-artifacts', 'g14-ci-plan source commit does not match the selected PipelineRun'); if (sourceObservation.valid !== true) breakAt('source-observation-invalid', 'plan-artifacts', sourceObservation.reason || 'delivery plan is incomplete or contradictory'); for (const item of terminal) { if (item.present !== true || item.status !== 'succeeded') breakAt('terminal-evidence-incomplete', item.step, `${item.step} TaskRun terminal status is ${item.status}`); } if (Number(collector.logErrorCount || 0) > 0) breakAt('contract-log-read-failed', 'collector', 'one or more contract TaskRun logs could not be read'); if (sourceObservation.mode === 'delivery') { if (catalog.present !== true || catalog.status !== 'published') breakAt('artifact-catalog-missing', 'collect-artifacts', 'published artifact catalog evidence is missing'); if (catalog.registryVerified !== true) breakAt('artifact-catalog-unverified', 'collect-artifacts', 'artifact catalog registry verification is not true'); if (!Array.isArray(artifact.digests) || artifact.digests.length === 0) breakAt('artifact-digest-missing', 'collect-artifacts', 'artifact catalog did not expose any sha256 digest'); if (typeof artifact.gitopsCommit !== 'string' || artifact.gitopsCommit.length === 0) breakAt('gitops-commit-missing', 'gitops-promote', 'GitOps commit is not visible in the successful promotion TaskRun'); } const realRun = { ok: firstBreak === null, found: Object.keys(row).length > 0, pipelineRun: Object.keys(row).length === 0 ? null : { id: row.id || row.pipelineRun || null, status: row.status || null, reason: row.reason || null, sourceCommit: row.commit || null, sourceMatched: sourceObservation.sourceMatched === true, valuesPrinted: false, }, mode: sourceObservation.mode || null, valid: sourceObservation.valid === true, terminal, artifact: { imageStatus: artifact.imageStatus || null, envReuse: artifact.envReuse || null, digests: Array.isArray(artifact.digests) ? artifact.digests : [], gitopsCommit: artifact.gitopsCommit || null, sourceCommit: artifact.sourceCommit || null, catalog: { present: catalog.present === true, status: catalog.status || null, sourceCommit: catalog.sourceCommit || null, registryVerified: catalog.registryVerified === true, publishedCount: catalog.publishedCount ?? null, reusedCount: catalog.reusedCount ?? null, requiredServiceCount: catalog.requiredServiceCount ?? null, digestCount: catalog.digestCount ?? null, source: catalog.source || null, valuesPrinted: false, }, valuesPrinted: false, }, collector: { mode: collector.mode || null, contractTaskCount: collector.contractTaskCount ?? null, terminalRecordCount: collector.terminalRecordCount ?? null, logErrorCount: collector.logErrorCount ?? null, logErrors: Array.isArray(collector.logErrors) ? collector.logErrors : [], valuesPrinted: false, }, firstBreak, valuesPrinted: false, }; const out = { ok: fixtures.ok === true && realRun.ok, checks: Array.isArray(fixtures.checks) ? fixtures.checks : [], realRun, valuesPrinted: false, }; process.stdout.write(`${JSON.stringify(out)}\n`); if (!out.ok) process.exitCode = 1; NODE } webhook_test_action() { hooks=$(hook_summary) hook_id=$(printf '%s' "$hooks" | node -e 'const fs=require("fs"); const a=JSON.parse(fs.readFileSync(0,"utf8")||"[]"); process.stdout.write(String(a[0]?.id||""))') test -n "$hook_id" gitea_api POST "repos/$UNIDESK_PAC_GITEA_OWNER/$UNIDESK_PAC_GITEA_REPO/hooks/$hook_id/tests" '{}' >/dev/null printf '{"ok":true,"mutation":true,"webhookTestSent":true,"hookId":"%s","valuesPrinted":false}\n' "$(json_string "$hook_id")" } case "$UNIDESK_PAC_ACTION" in apply) apply_action ;; status) status_action ;; history) history_action ;; debug-step) debug_step_action ;; webhook-test) webhook_test_action ;; *) printf '{"ok":false,"error":"unsupported-action","valuesPrinted":false}\n'; exit 2 ;; esac