#!/usr/bin/env node import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; const DEFAULT_KUBECONFIG = '/etc/rancher/k3s/k3s.yaml'; const DEFAULT_OUTPUT_DIR = '/var/lib/alga-appliance/support-bundles'; function nowStamp() { return new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z'); } function shellRun(command) { const result = spawnSync('sh', ['-c', command], { encoding: 'utf8', env: process.env }); return { ok: result.status === 0, stdout: result.stdout || '', stderr: result.stderr || '', status: result.status ?? 1 }; } function redactText(value) { return String(value) .replace(/((?:token|password|secret|client[-_]?key(?:-data)?|authorization)\s*[:=]\s*)([^\s"']+)/ig, '$1[REDACTED]') .replace(/("(?:[^"]*(?:token|password|secret|clientKey|client-key-data)[^"]*)"\s*:\s*")([^"]+)(")/ig, '$1[REDACTED]$3') .replace(/(authorization\s*:\s*bearer\s+)([^\s"']+)/ig, '$1[REDACTED]') .replace(/(kind:\s*Secret[\s\S]*?\ndata:\n)([\s\S]*?)(\n(?:---|apiVersion:|kind:|metadata:)|$)/ig, '$1 [REDACTED_SECRET_DATA]: [REDACTED]$3'); } function writeRedactedFile(file, content) { fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o750 }); fs.writeFileSync(file, `${redactText(content)}\n`, { mode: 0o600 }); } function readIfExists(file) { if (!fs.existsSync(file)) { return null; } return fs.readFileSync(file, 'utf8'); } function captureCommand(file, command, runCommand = shellRun) { const result = runCommand(command); const summary = [`$ ${command}`, '', result.stdout, result.stderr].filter(Boolean).join('\n'); writeRedactedFile(file, summary.trim()); return result.ok; } function shellQuote(value) { return `'${String(value).replaceAll("'", "'\\''")}'`; } function safeRelativePath(value) { const normalized = path.normalize(String(value || '')).replace(/^([/\\])+/, ''); if (!normalized || normalized.startsWith('..') || path.isAbsolute(normalized)) { return null; } return normalized; } function captureHostAgentDiagnostics(tempDir, socketPath, runCommand = shellRun) { const command = `curl --silent --show-error --max-time 30 --unix-socket ${shellQuote(socketPath)} -X POST http://localhost/v1/support-bundle`; const result = runCommand(command); if (!result.ok) { writeRedactedFile(path.join(tempDir, 'host', 'host-agent-error.txt'), [`$ ${command}`, result.stdout || '', result.stderr || '', `status=${result.status}`].filter(Boolean).join('\n')); return false; } let payload; try { payload = JSON.parse(result.stdout || '{}'); } catch (error) { writeRedactedFile(path.join(tempDir, 'host', 'host-agent-error.txt'), `Host agent returned invalid JSON: ${error instanceof Error ? error.message : String(error)}\n${result.stdout || ''}`); return false; } if (!payload.ok || !Array.isArray(payload.files)) { writeRedactedFile(path.join(tempDir, 'host', 'host-agent-error.txt'), `Host agent returned an unsuccessful response.\n${JSON.stringify(payload, null, 2)}`); return false; } writeRedactedFile(path.join(tempDir, 'host', 'host-agent-summary.txt'), `generatedAt=${payload.generatedAt || ''}\nagent=${payload.agent || 'alga-host-agent'}\nfiles=${payload.files.length}`); for (const file of payload.files) { const relative = safeRelativePath(file.path); if (!relative) continue; writeRedactedFile(path.join(tempDir, relative), file.content || ''); } return true; } export function generateSupportBundle(options = {}) { const kubeconfigPath = options.kubeconfigPath || DEFAULT_KUBECONFIG; const outputDir = options.outputDir || DEFAULT_OUTPUT_DIR; const stateFile = options.stateFile || '/var/lib/alga-appliance/install-state.json'; const releaseSelectionFile = options.releaseSelectionFile || '/etc/alga-appliance/release-selection.json'; const setupInputsFile = options.setupInputsFile || '/etc/alga-appliance/setup-inputs.json'; const runCommand = options.runCommand || shellRun; const hostAgentSocket = options.hostAgentSocket || process.env.ALGA_APPLIANCE_HOST_AGENT_SOCKET || '/run/alga-appliance/host-agent.sock'; const tempDir = options.tempDir || fs.mkdtempSync(path.join(os.tmpdir(), 'alga-ubuntu-support-')); fs.mkdirSync(tempDir, { recursive: true, mode: 0o750 }); fs.mkdirSync(path.join(tempDir, 'host'), { recursive: true, mode: 0o750 }); fs.mkdirSync(path.join(tempDir, 'cluster'), { recursive: true, mode: 0o750 }); fs.mkdirSync(path.join(tempDir, 'pod'), { recursive: true, mode: 0o750 }); fs.mkdirSync(path.join(tempDir, 'meta'), { recursive: true, mode: 0o750 }); writeRedactedFile(path.join(tempDir, 'meta', 'summary.txt'), [ `generatedAt=${new Date().toISOString()}`, `kubeconfigPath=${kubeconfigPath}`, `stateFile=${stateFile}`, `releaseSelectionFile=${releaseSelectionFile}` ].join('\n')); const maybeFiles = [ [stateFile, path.join(tempDir, 'meta', 'install-state.json')], [releaseSelectionFile, path.join(tempDir, 'meta', 'release-selection.json')], [setupInputsFile, path.join(tempDir, 'meta', 'setup-inputs.json')] ]; for (const [src, dest] of maybeFiles) { const content = readIfExists(src); if (content != null) { writeRedactedFile(dest, content); } } if (process.env.ALGA_APPLIANCE_MODE === 'kubernetes-control-plane') { if (!fs.existsSync(hostAgentSocket) || !captureHostAgentDiagnostics(tempDir, hostAgentSocket, runCommand)) { writeRedactedFile(path.join(tempDir, 'host', 'host-diagnostics-note.txt'), [ 'Support bundle was generated from the Kubernetes-hosted appliance control-plane pod.', `Host diagnostics agent socket was not available or did not return diagnostics: ${hostAgentSocket}`, 'For host bootstrap logs, run on the appliance host:', ' sudo journalctl -u alga-appliance-bootstrap.service -u alga-appliance-console.service -u k3s -n 1000 --no-pager', ' sudo /usr/bin/env node /opt/alga-appliance/host-service/support-bundle.mjs' ].join('\n')); } captureCommand(path.join(tempDir, 'pod', 'disk-usage.txt'), 'df -h', runCommand); captureCommand(path.join(tempDir, 'pod', 'ip-addresses.txt'), 'ip addr', runCommand); captureCommand(path.join(tempDir, 'pod', 'routes.txt'), 'ip route', runCommand); captureCommand(path.join(tempDir, 'pod', 'resolv-conf.txt'), 'cat /etc/resolv.conf', runCommand); } else { captureCommand(path.join(tempDir, 'host', 'appliance-journal.txt'), 'journalctl -u alga-appliance-bootstrap.service -u alga-appliance.service -u alga-appliance-console.service -u k3s -n 1000 --no-pager', runCommand); captureCommand(path.join(tempDir, 'host', 'k3s-service-status.txt'), 'systemctl status k3s --no-pager', runCommand); captureCommand(path.join(tempDir, 'host', 'disk-usage.txt'), 'df -h', runCommand); captureCommand(path.join(tempDir, 'host', 'ip-addresses.txt'), 'ip addr', runCommand); captureCommand(path.join(tempDir, 'host', 'routes.txt'), 'ip route', runCommand); captureCommand(path.join(tempDir, 'host', 'resolv-conf.txt'), 'cat /etc/resolv.conf', runCommand); captureCommand(path.join(tempDir, 'host', 'dns-lookup-ghcr.txt'), 'getent hosts ghcr.io', runCommand); captureCommand(path.join(tempDir, 'host', 'https-ghcr.txt'), 'curl -I --max-time 10 https://ghcr.io/v2/', runCommand); } const k = `kubectl --kubeconfig ${kubeconfigPath}`; captureCommand(path.join(tempDir, 'cluster', 'version.txt'), `${k} version`, runCommand); captureCommand(path.join(tempDir, 'cluster', 'nodes.txt'), `${k} get nodes -o wide`, runCommand); captureCommand(path.join(tempDir, 'cluster', 'namespaces.txt'), `${k} get namespaces`, runCommand); captureCommand(path.join(tempDir, 'cluster', 'pods.txt'), `${k} get pods -A -o wide`, runCommand); captureCommand(path.join(tempDir, 'cluster', 'deployments.txt'), `${k} get deployments -A`, runCommand); captureCommand(path.join(tempDir, 'cluster', 'statefulsets.txt'), `${k} get statefulsets -A`, runCommand); captureCommand(path.join(tempDir, 'cluster', 'jobs.txt'), `${k} get jobs -A`, runCommand); captureCommand(path.join(tempDir, 'cluster', 'pvcs.txt'), `${k} get pvc -A`, runCommand); captureCommand(path.join(tempDir, 'cluster', 'events.txt'), `${k} get events -A --sort-by=.lastTimestamp`, runCommand); captureCommand(path.join(tempDir, 'cluster', 'flux-sources.txt'), `${k} -n flux-system get gitrepositories.source.toolkit.fluxcd.io -o yaml`, runCommand); captureCommand(path.join(tempDir, 'cluster', 'flux-kustomizations.txt'), `${k} -n flux-system get kustomizations.kustomize.toolkit.fluxcd.io -o yaml`, runCommand); captureCommand(path.join(tempDir, 'cluster', 'helmreleases.txt'), `${k} -n alga-system get helmreleases.helm.toolkit.fluxcd.io -o yaml`, runCommand); captureCommand(path.join(tempDir, 'cluster', 'control-plane-resources.txt'), `${k} -n alga-appliance-control-plane get deploy,po,svc,cm,secrets -o wide`, runCommand); captureCommand(path.join(tempDir, 'cluster', 'control-plane-describe.txt'), `${k} -n alga-appliance-control-plane describe deploy appliance-control-plane`, runCommand); captureCommand(path.join(tempDir, 'cluster', 'control-plane-logs.txt'), `${k} -n alga-appliance-control-plane logs deploy/appliance-control-plane --all-containers --tail=400`, runCommand); captureCommand(path.join(tempDir, 'cluster', 'control-plane-previous-logs.txt'), `${k} -n alga-appliance-control-plane logs deploy/appliance-control-plane --all-containers --previous --tail=400`, runCommand); captureCommand(path.join(tempDir, 'cluster', 'app-bootstrap-resources.txt'), `${k} -n msp get jobs,pods,secrets,cm -l app.kubernetes.io/part-of=alga-psa -o wide`, runCommand); captureCommand(path.join(tempDir, 'cluster', 'bootstrap-jobs.txt'), `${k} -n msp get jobs -o wide`, runCommand); captureCommand(path.join(tempDir, 'cluster', 'bootstrap-job-logs.txt'), `${k} -n msp logs jobs/alga-bootstrap --tail=400`, runCommand); fs.mkdirSync(outputDir, { recursive: true, mode: 0o750 }); const bundlePath = path.join(outputDir, `alga-appliance-support-${nowStamp()}.tar.gz`); const tarResult = runCommand(`tar -C ${tempDir} -czf ${bundlePath} .`); if (!tarResult.ok) { return { ok: false, phase: 'support-bundle', message: 'Failed to create support bundle archive.', suspectedCause: tarResult.stderr.trim() || tarResult.stdout.trim() || 'tar failed', suggestedNextStep: 'Verify output directory permissions and tar availability, then retry.', retrySafe: true }; } return { ok: true, phase: 'support-bundle', message: 'Support bundle generated successfully.', bundlePath, redaction: 'Token/password/client-key values are redacted from captured text output.' }; } function parseArgs(argv) { const parsed = {}; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg === '--kubeconfig') { parsed.kubeconfigPath = argv[i + 1]; i += 1; } else if (arg === '--output-dir') { parsed.outputDir = argv[i + 1]; i += 1; } } return parsed; } if (import.meta.url === `file://${process.argv[1]}`) { const options = parseArgs(process.argv.slice(2)); const result = generateSupportBundle(options); process.stdout.write(`${JSON.stringify(result)}\n`); if (!result.ok) { process.exitCode = 1; } }