Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
220 lines
11 KiB
JavaScript
220 lines
11 KiB
JavaScript
#!/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;
|
|
}
|
|
}
|