apiVersion: v1 kind: ServiceAccount metadata: name: appliance-status namespace: appliance-system --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: appliance-status-readonly rules: - apiGroups: [""] resources: ["nodes", "pods", "persistentvolumeclaims", "events", "configmaps"] verbs: ["get", "list", "watch"] - apiGroups: [""] resources: ["configmaps"] verbs: ["create", "patch", "update"] - apiGroups: [""] resources: ["pods/log"] verbs: ["get"] - apiGroups: ["apps"] resources: ["deployments", "statefulsets"] verbs: ["get", "list", "watch"] - apiGroups: ["batch"] resources: ["jobs"] verbs: ["get", "list", "watch", "create"] - apiGroups: ["source.toolkit.fluxcd.io"] resources: ["ocirepositories", "helmrepositories"] verbs: ["get", "list", "watch", "patch"] - apiGroups: ["kustomize.toolkit.fluxcd.io"] resources: ["kustomizations"] verbs: ["get", "list", "watch", "patch"] - apiGroups: ["helm.toolkit.fluxcd.io"] resources: ["helmreleases"] verbs: ["get", "list", "watch", "patch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: appliance-status-readonly roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: appliance-status-readonly subjects: - kind: ServiceAccount name: appliance-status namespace: appliance-system --- apiVersion: apps/v1 kind: Deployment metadata: name: appliance-status namespace: appliance-system spec: replicas: 1 strategy: type: RollingUpdate rollingUpdate: maxSurge: 0 maxUnavailable: 1 selector: matchLabels: app.kubernetes.io/name: appliance-status template: metadata: labels: app.kubernetes.io/name: appliance-status spec: dnsPolicy: ClusterFirst serviceAccountName: appliance-status automountServiceAccountToken: true containers: - name: status image: node:20-alpine imagePullPolicy: IfNotPresent command: - sh - -c - | cat <<'JS' >/tmp/server.js const fs = require('fs'); const http = require('http'); const https = require('https'); const token = process.env.STATUS_TOKEN || ''; const hostIp = process.env.HOST_IP || ''; const namespace = process.env.POD_NAMESPACE || 'appliance-system'; const serviceHost = process.env.KUBERNETES_SERVICE_HOST; const servicePort = process.env.KUBERNETES_SERVICE_PORT || '443'; const saTokenPath = '/var/run/secrets/kubernetes.io/serviceaccount/token'; const caPath = '/var/run/secrets/kubernetes.io/serviceaccount/ca.crt'; const COMPONENTS = [ { name: 'alga-core', kind: 'deployment', resource: 'alga-core-sebastian', tier: 'login' }, { name: 'db', kind: 'statefulset', resource: 'db', tier: 'core' }, { name: 'redis', kind: 'statefulset', resource: 'redis', tier: 'core' }, { name: 'pgbouncer', kind: 'deployment', resource: 'pgbouncer', tier: 'core' }, { name: 'temporal', kind: 'deployment', resource: 'temporal', tier: 'background' }, { name: 'workflow-worker', kind: 'deployment', resource: 'workflow-worker', tier: 'background' }, { name: 'email-service', kind: 'deployment', resource: 'email-service', tier: 'background' }, { name: 'temporal-worker', kind: 'deployment', resource: 'temporal-worker', tier: 'background' }, ]; const APPLIANCE_NAMESPACES = ['msp', 'alga-system', 'appliance-system', 'flux-system', 'local-path-storage', 'kube-system']; function readFile(path) { try { return fs.readFileSync(path, 'utf8').trim(); } catch { return ''; } } const kubeToken = readFile(saTokenPath); const kubeCa = fs.existsSync(caPath) ? fs.readFileSync(caPath) : undefined; function timestampOf(event) { return event.lastTimestamp || event.eventTime || event.metadata?.creationTimestamp || ''; } function fallbackCanonical() { const loginUrl = hostIp ? `http://${hostIp}:3000` : null; const now = new Date().toISOString(); return { service: 'appliance-status', status: 'installing', siteId: 'appliance-single-node', timestamp: now, release: { selectedReleaseVersion: null, appVersion: null, channel: null, sourceRevision: null, manifestDigest: null }, urls: { statusUrl: hostIp ? `http://${hostIp}:8080` : null, loginUrl }, loginUrl, rollup: { state: 'installing', message: loginUrl ? 'Appliance bootstrap is in progress. Login will be available once core services are ready.' : 'Appliance bootstrap is in progress.', nextAction: 'Keep this page open and refresh while core services bootstrap.', }, currentPhase: 'bootstrap_in_progress', nextAction: 'Keep this page open and refresh while core services bootstrap.', tiers: { platform: { ready: false, status: 'progressing' }, core: { ready: false, status: 'progressing' }, bootstrap: { ready: false, status: 'progressing' }, login: { ready: false, status: 'progressing' }, background: { ready: false, status: 'progressing' }, fullHealth: { ready: false, status: 'progressing' }, }, topBlockers: [], activeOperations: [], components: [], recentEvents: [], loginProbe: { checked: false, reachable: false, statusCode: null, location: null }, }; } function kubeGet(path) { if (!serviceHost || !kubeToken) return Promise.resolve(null); return new Promise((resolve) => { const req = https.request({ host: serviceHost, port: servicePort, method: 'GET', path, ca: kubeCa, headers: { Authorization: `Bearer ${kubeToken}`, Accept: 'application/json' }, timeout: 3000, }, (res) => { let body = ''; res.setEncoding('utf8'); res.on('data', (chunk) => { body += chunk; }); res.on('end', () => { if (res.statusCode < 200 || res.statusCode >= 300) return resolve(null); try { resolve(JSON.parse(body)); } catch { resolve(null); } }); }); req.on('timeout', () => req.destroy()); req.on('error', () => resolve(null)); req.end(); }); } function kubeGetText(path) { if (!serviceHost || !kubeToken) return Promise.resolve(null); return new Promise((resolve) => { const req = https.request({ host: serviceHost, port: servicePort, method: 'GET', path, ca: kubeCa, headers: { Authorization: `Bearer ${kubeToken}` }, timeout: 5000, }, (res) => { let body = ''; res.setEncoding('utf8'); res.on('data', (chunk) => { body += chunk; }); res.on('end', () => { if (res.statusCode < 200 || res.statusCode >= 300) return resolve(null); resolve(body); }); }); req.on('timeout', () => req.destroy()); req.on('error', () => resolve(null)); req.end(); }); } function kubeRequest(method, path, body = null, extraHeaders = {}) { if (!serviceHost || !kubeToken) return Promise.resolve({ ok: false, statusCode: 0, body: null, text: 'Kubernetes service account is unavailable' }); return new Promise((resolve) => { const payload = body == null ? null : JSON.stringify(body); const headers = { Authorization: `Bearer ${kubeToken}`, Accept: 'application/json', ...extraHeaders }; if (payload != null && !headers['Content-Type']) headers['Content-Type'] = 'application/json'; const req = https.request({ host: serviceHost, port: servicePort, method, path, ca: kubeCa, headers, timeout: 8000, }, (res) => { let text = ''; res.setEncoding('utf8'); res.on('data', (chunk) => { text += chunk; }); res.on('end', () => { let parsed = null; try { parsed = text ? JSON.parse(text) : null; } catch { parsed = null; } resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, statusCode: res.statusCode || 0, body: parsed, text }); }); }); req.on('timeout', () => req.destroy(new Error('Kubernetes request timed out'))); req.on('error', (error) => resolve({ ok: false, statusCode: 0, body: null, text: error.message })); if (payload != null) req.write(payload); req.end(); }); } function jsonResponse(res, statusCode, body) { res.writeHead(statusCode, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); } function readJsonBody(req) { return new Promise((resolve, reject) => { let body = ''; req.setEncoding('utf8'); req.on('data', (chunk) => { body += chunk; if (body.length > 32768) reject(new Error('Request body is too large')); }); req.on('end', () => { if (!body.trim()) return resolve({}); try { resolve(JSON.parse(body)); } catch (error) { reject(error); } }); req.on('error', reject); }); } function httpProbe(rawUrl) { if (!rawUrl) return Promise.resolve({ checked: false, reachable: false, statusCode: null, location: null }); return new Promise((resolve) => { let parsed; try { parsed = new URL(rawUrl); } catch { return resolve({ checked: true, reachable: false, statusCode: null, location: null }); } const lib = parsed.protocol === 'https:' ? https : http; const req = lib.request(parsed, { method: 'HEAD', timeout: 3000, rejectUnauthorized: false }, (res) => { const code = res.statusCode || null; const location = res.headers.location || null; const redirect = [301, 302, 307, 308].includes(code); const validLocation = !redirect || !location || location.includes('/msp/dashboard') || location.includes('/login') || location.includes(parsed.host); resolve({ checked: true, reachable: !!code && code >= 200 && code < 400 && validLocation, statusCode: code, location }); res.resume(); }); req.on('timeout', () => req.destroy()); req.on('error', () => resolve({ checked: true, reachable: false, statusCode: null, location: null })); req.end(); }); } function readyCondition(item) { return (item?.status?.conditions || []).find((entry) => entry.type === 'Ready') || null; } function fluxItems(items) { return (items || []).map((item) => { const ready = readyCondition(item); return { name: item.metadata?.name || 'unknown', ready: ready?.status === 'True', status: ready?.status || 'Unknown', reason: ready?.reason || '', message: ready?.message || '', revision: item.status?.artifact?.revision || item.status?.lastAppliedRevision || '', }; }); } function isProgressingStatus(entry) { const status = String(entry?.status || '').toLowerCase(); const reason = String(entry?.reason || '').toLowerCase(); const message = String(entry?.message || '').toLowerCase(); return status === 'unknown' || reason.includes('progress') || reason.includes('dependencynotready') || message.includes('reconciliation in progress') || message.includes('running health checks') || message.includes("running 'install' action") || message.includes("running 'upgrade' action") || message.includes('fulfilling prerequisites') || message.includes('latest generation of object has not been reconciled') || message.includes('dependency') && message.includes('not ready'); } function isHelmStorageStuck(entry) { const message = String(entry?.message || ''); return /has no deployed releases|another operation \((install|upgrade|rollback)\) is in progress|uninstalling/i.test(message); } function reconciliationOperations(state) { const rows = [...(state.sources || []), ...(state.kustomizations || []), ...(state.helmReleases || [])]; return rows .filter((entry) => !entry.ready && isProgressingStatus(entry) && !isHelmStorageStuck(entry)) .map((entry) => ({ component: entry.name, operation: 'reconciling', image: null, estimatedCompressedBytes: null, estimatedSizeHuman: null, progressPercent: null, progressAvailable: false, progressReason: 'Flux and Helm report phase-level reconciliation status, not byte-level progress.', startedAt: null, elapsedSeconds: null, message: entry.message || `${entry.name} is reconciling.`, })); } function tierStatus(ready) { return ready ? 'healthy' : 'progressing'; } function readinessStatus(ready, total) { if (!total) return 'unknown'; if (ready >= total) return 'healthy'; if (ready > 0) return 'degraded'; return 'unhealthy'; } function resourceReadiness(kind, item) { if (!item) return { status: 'unknown', ready: 0, total: 0 }; const total = item.spec?.replicas ?? 1; const ready = item.status?.readyReplicas ?? 0; return { status: readinessStatus(ready, total), ready, total }; } function componentFromObject(name) { const value = String(name || '').toLowerCase(); if (value.includes('workflow-worker')) return 'workflow-worker'; if (value.includes('temporal-worker')) return 'temporal-worker'; if (value.includes('email-service')) return 'email-service'; if (value.includes('temporal')) return 'temporal'; if (value.includes('alga-core-sebastian') || value.includes('alga-core')) return 'alga-core'; if (value.includes('db-')) return 'db'; return null; } function imageSizeEstimate(image) { const value = String(image || ''); if (/ghcr\.io\/nine-minds\/alga-psa-ee:/i.test(value)) { return { estimatedCompressedBytes: 1865000000, estimatedSizeHuman: '~1.8 GB' }; } if (/node:20-alpine/i.test(value)) { return { estimatedCompressedBytes: 59000000, estimatedSizeHuman: '~60 MB' }; } return { estimatedCompressedBytes: null, estimatedSizeHuman: null }; } function imageFromMessage(message) { const text = String(message || ''); return (text.match(/image "([^"]+)"/i) || text.match(/([a-z0-9./_-]+:[a-zA-Z0-9._-]+)/))?.[1] || null; } function elapsedSecondsSince(timestamp) { if (!timestamp) return null; const parsed = Date.parse(timestamp); if (!Number.isFinite(parsed)) return null; return Math.max(0, Math.floor((Date.now() - parsed) / 1000)); } function collectActiveOperations(events, podsJson) { const operations = []; const seen = new Set(); for (const pod of podsJson?.items || []) { const podName = pod.metadata?.name || ''; const component = componentFromObject(podName) || 'unknown'; const allContainers = [...(pod.spec?.initContainers || []), ...(pod.spec?.containers || [])]; const allStatuses = [...(pod.status?.initContainerStatuses || []), ...(pod.status?.containerStatuses || [])]; for (const status of allStatuses) { const waiting = status.state?.waiting; if (!waiting || !['ContainerCreating', 'PodInitializing'].includes(waiting.reason)) continue; const spec = allContainers.find((container) => container.name === status.name); const image = spec?.image || status.image || null; const key = `${component}:${podName}:${status.name}:${image}`; if (seen.has(key)) continue; seen.add(key); const size = imageSizeEstimate(image); operations.push({ component, operation: 'pulling_image', image, ...size, progressPercent: null, progressAvailable: false, progressReason: 'Kubernetes does not expose image pull byte progress.', startedAt: pod.metadata?.creationTimestamp || null, elapsedSeconds: elapsedSecondsSince(pod.metadata?.creationTimestamp), message: image ? `Preparing ${component}: pulling or unpacking ${image}. This can take several minutes on a local VM.` : `Preparing ${component}: ${waiting.reason}.`, }); } } if (operations.length > 0 || (podsJson?.items || []).length > 0) { return operations; } for (const event of events || []) { const message = String(event.message || ''); if (!/pulling image/i.test(message) || /successfully pulled|failed to pull/i.test(message)) continue; const image = imageFromMessage(message); const component = componentFromObject(event.involvedObject) || 'unknown'; const key = `${component}:${image || message}`; if (seen.has(key)) continue; seen.add(key); const size = imageSizeEstimate(image); operations.push({ component, operation: 'pulling_image', image, ...size, progressPercent: null, progressAvailable: false, progressReason: 'Kubernetes does not expose image pull byte progress.', startedAt: event.lastTimestamp || null, elapsedSeconds: elapsedSecondsSince(event.lastTimestamp), message: image ? `Pulling image ${image}. This can take several minutes on a local VM.` : message, }); } return operations; } function summarizeEvents(json) { const items = (json?.items || []).slice().sort((a, b) => timestampOf(a).localeCompare(timestampOf(b))); return items.slice(-20).map((item) => ({ namespace: item.metadata?.namespace || item.involvedObject?.namespace || 'unknown', reason: item.reason || 'Unknown', type: item.type || 'Normal', message: item.message || '', lastTimestamp: timestampOf(item) || null, involvedObject: `${item.involvedObject?.kind || 'Unknown'}/${item.involvedObject?.name || 'unknown'}`, })); } function podAgeSeconds(pod) { const timestamp = pod.metadata?.creationTimestamp; if (!timestamp) return null; const parsed = Date.parse(timestamp); if (!Number.isFinite(parsed)) return null; return Math.max(0, Math.floor((Date.now() - parsed) / 1000)); } function podDisplayStatus(pod) { if (pod.metadata?.deletionTimestamp) return 'Terminating'; const statuses = [...(pod.status?.initContainerStatuses || []), ...(pod.status?.containerStatuses || [])]; const waiting = statuses.find((entry) => entry.state?.waiting); if (waiting) return waiting.state.waiting.reason || 'Waiting'; const terminated = statuses.find((entry) => entry.state?.terminated && entry.state.terminated.exitCode !== 0); if (terminated) return terminated.state.terminated.reason || 'Error'; return pod.status?.phase || 'Unknown'; } function summarizePod(pod) { const regularStatuses = pod.status?.containerStatuses || []; const allStatuses = [...(pod.status?.initContainerStatuses || []), ...regularStatuses]; const containers = [...(pod.spec?.initContainers || []), ...(pod.spec?.containers || [])].map((container) => container.name); const ready = regularStatuses.filter((entry) => entry.ready).length; const total = pod.spec?.containers?.length || regularStatuses.length || 0; const restarts = allStatuses.reduce((sum, entry) => sum + (entry.restartCount || 0), 0); return { namespace: pod.metadata?.namespace || 'unknown', name: pod.metadata?.name || 'unknown', status: podDisplayStatus(pod), phase: pod.status?.phase || 'Unknown', ready, total, readyText: `${ready}/${total}`, restarts, ageSeconds: podAgeSeconds(pod), nodeName: pod.spec?.nodeName || null, podIP: pod.status?.podIP || null, containers, createdAt: pod.metadata?.creationTimestamp || null, }; } function podRank(pod) { if (['Running', 'Succeeded'].includes(pod.status) && pod.ready === pod.total) return 2; if (pod.status === 'Pending' || pod.status === 'ContainerCreating' || pod.status === 'PodInitializing') return 1; return 0; } async function collectPods(scope = 'appliance') { const json = scope && scope !== 'all' && scope !== 'appliance' ? await kubeGet(`/api/v1/namespaces/${encodeURIComponent(scope)}/pods`) : await kubeGet('/api/v1/pods'); let pods = (json?.items || []).map(summarizePod); if (!scope || scope === 'appliance') { pods = pods.filter((pod) => APPLIANCE_NAMESPACES.includes(pod.namespace)); } pods.sort((a, b) => (podRank(a) - podRank(b)) || a.namespace.localeCompare(b.namespace) || a.name.localeCompare(b.name) ); return { scope: scope || 'appliance', namespaces: ['appliance', 'all', ...APPLIANCE_NAMESPACES], pods, timestamp: new Date().toISOString(), }; } async function collectPodLogs(namespaceName, podName, containerName, tailLines = 300) { if (!namespaceName || !podName) { return { namespace: namespaceName || null, pod: podName || null, container: containerName || null, lines: [], error: 'namespace and pod are required' }; } const safeTail = Math.max(20, Math.min(Number.parseInt(String(tailLines || 300), 10) || 300, 5000)); const params = new URLSearchParams({ tailLines: String(safeTail), timestamps: 'true' }); if (containerName) params.set('container', containerName); const text = await kubeGetText(`/api/v1/namespaces/${encodeURIComponent(namespaceName)}/pods/${encodeURIComponent(podName)}/log?${params.toString()}`); return { namespace: namespaceName, pod: podName, container: containerName || null, tailLines: safeTail, lines: text ? String(text).split(/\r?\n/).filter(Boolean) : [], available: !!text, timestamp: new Date().toISOString(), }; } function parseAppUrl(configMap) { for (const value of Object.values(configMap?.data || {})) { const match = String(value).match(/appUrl:\s*["']?([^\n"']+)/); if (match) return match[1].trim(); } return null; } function summarizeBootstrapJob(jobsJson) { const items = jobsJson?.items || []; const job = items.find((entry) => /bootstrap/i.test(entry.metadata?.name || '') && /alga-core/i.test(entry.metadata?.name || '')) || items.find((entry) => /bootstrap/i.test(entry.metadata?.name || '')); if (!job) return { state: 'waiting', completed: false, failed: false, name: null }; const conditions = job.status?.conditions || []; const completed = conditions.some((entry) => entry.type === 'Complete' && entry.status === 'True') || (job.status?.succeeded || 0) > 0; const failed = conditions.some((entry) => entry.type === 'Failed' && entry.status === 'True'); const active = (job.status?.active || 0) > 0; if (failed) return { state: 'failed', completed: false, failed: true, name: job.metadata?.name || null }; if (completed) return { state: 'completed', completed: true, failed: false, name: job.metadata?.name || null }; if (active) return { state: 'running', completed: false, failed: false, name: job.metadata?.name || null }; return { state: 'waiting', completed: false, failed: false, name: job.metadata?.name || null }; } function bootstrapPodsForJob(podsJson, jobName) { const items = podsJson?.items || []; return items .filter((pod) => { const name = pod.metadata?.name || ''; const labels = pod.metadata?.labels || {}; return (jobName && labels['job-name'] === jobName) || (/bootstrap/i.test(name) && /alga-core/i.test(name)); }) .sort((a, b) => String(b.metadata?.creationTimestamp || '').localeCompare(String(a.metadata?.creationTimestamp || ''))); } function detectErrorLines(logText) { const lines = String(logText || '').split(/\r?\n/).filter(Boolean); const errors = []; for (const line of lines) { if (/\b(ERROR|FATAL)\b|Configured seed directory does not exist|Migration failed|seed .*failed|Unhandled|Exception/i.test(line)) { errors.push(line); } } return errors.slice(-10); } function bootstrapNextAction(reason) { if (/Configured seed directory does not exist/i.test(reason)) { return 'Verify the appliance image contains the configured seed directory, or update the bootstrap seed path in the release/chart values.'; } if (/password authentication failed|connection refused|could not connect|database/i.test(reason)) { return 'Check database readiness, credentials, and bootstrap job environment, then retry recover mode.'; } return 'Review the bootstrap log excerpt below, fix the reported issue, then rerun bootstrap recover mode.'; } async function collectBootstrapLogs(bootstrapJob, podsJson) { const empty = { available: false, pod: null, container: null, tail: [], detectedErrors: [] }; if (!bootstrapJob?.name || (!bootstrapJob.failed && bootstrapJob.state !== 'running')) return empty; const pods = bootstrapPodsForJob(podsJson, bootstrapJob.name); const preferred = pods.find((pod) => pod.status?.phase === 'Failed') || pods[0]; if (!preferred) return empty; const podName = preferred.metadata?.name; const containers = preferred.spec?.containers || []; const container = containers.find((entry) => entry.name === 'bootstrap')?.name || containers[0]?.name || 'bootstrap'; const logText = await kubeGetText(`/api/v1/namespaces/msp/pods/${encodeURIComponent(podName)}/log?container=${encodeURIComponent(container)}&tailLines=160×tamps=true`); if (!logText) return { ...empty, pod: podName, container }; const tail = String(logText).split(/\r?\n/).filter(Boolean).slice(-160); return { available: true, pod: podName, container, tail, detectedErrors: detectErrorLines(logText), }; } function detectBootstrapFailure(bootstrap) { if (!bootstrap?.job?.failed) return null; const signal = bootstrap.logs?.detectedErrors?.at(-1) || `Bootstrap job failed: ${bootstrap.job.name || 'unknown job'}`; const reason = /^Bootstrap failed:/i.test(signal) ? signal : `Bootstrap failed: ${signal}`; return { component: bootstrap.job.name || 'bootstrap', reason, nextAction: bootstrapNextAction(signal), }; } function detectMissingImage(events) { const match = events.find((entry) => /imagepullbackoff|failed to pull image|errimagepull/i.test(entry.message || '') && /not found/i.test(entry.message || '')); if (!match) return null; const component = componentFromObject(match.involvedObject); const tier = COMPONENTS.find((entry) => entry.name === component)?.tier || 'background'; const image = (String(match.message || '').match(/([a-z0-9./_-]+:[a-zA-Z0-9._-]+)/) || [])[1] || null; return { component, tier, image, message: match.message || 'Image tag not found' }; } function detectInterruptedPull(events) { const match = events.find((entry) => /failed to pull image|imagepullbackoff|errimagepull/i.test(entry.message || '') && /(context canceled|cancelled|context deadline exceeded)/i.test(entry.message || '')); if (!match) return null; return { component: componentFromObject(match.involvedObject), message: match.message || 'Image pull interrupted' }; } function firstNotReady(entries, names) { return names .map((name) => entries.find((entry) => entry.name === name)) .find((entry) => entry && entry.ready !== true && entry.status !== 'unknown') || null; } function makeBlocker(state, loginReady) { const missingImage = detectMissingImage(state.recentEvents); if (missingImage) { const loginBlocking = missingImage.tier !== 'background'; return { layer: 'Image tag availability', component: missingImage.component || 'unknown', loginBlocking, reason: `Image tag not found: ${missingImage.image || 'referenced image tag'}`, nextAction: 'Publish the missing image tag or update the appliance release manifest to a valid tag.', }; } const interrupted = detectInterruptedPull(state.recentEvents); if (interrupted) { return { layer: 'Image pull interruption', component: interrupted.component || 'unknown', loginBlocking: false, reason: `Image pull interrupted and retryable: ${interrupted.message}`, nextAction: 'Wait for automatic retry or restart the affected pod if retries stall.', }; } const bootstrapFailure = detectBootstrapFailure(state.bootstrap); if (bootstrapFailure) { return { layer: 'Bootstrap job failure', component: bootstrapFailure.component, loginBlocking: true, reason: bootstrapFailure.reason, nextAction: bootstrapFailure.nextAction, }; } const badCore = firstNotReady(state.components, ['db', 'redis', 'pgbouncer', 'alga-core']); if (badCore) { return { layer: 'workload readiness failure', component: badCore.name, loginBlocking: true, reason: `${badCore.name} is ${badCore.status}`, nextAction: 'Check workload pods and recent events, then collect a support bundle.', }; } const badSource = (state.sources || []).find((entry) => !entry.ready && !isProgressingStatus(entry)); const badKustomization = (state.kustomizations || []).find((entry) => !entry.ready && !isProgressingStatus(entry)); if (badSource || badKustomization) { const row = badSource || badKustomization; return { layer: 'Flux source/reconcile failure', component: row.name, loginBlocking: !loginReady, reason: row.message || 'One or more Flux resources are not Ready', nextAction: 'Review Flux OCIRepository and Kustomization conditions.', }; } const stuckHelm = (state.helmReleases || []).find((entry) => !entry.ready && isHelmStorageStuck(entry)); if (stuckHelm) { return { layer: 'Helm release storage issue', component: stuckHelm.name, loginBlocking: !loginReady, reason: stuckHelm.message || `${stuckHelm.name} has inconsistent Helm release storage`, nextAction: 'Inspect Helm release secrets and HelmRelease events. If storage is stuck in uninstalling, clear stale release storage and reconcile.', }; } const badHelm = (state.helmReleases || []).find((entry) => !entry.ready && !isProgressingStatus(entry)); if (badHelm) { return { layer: 'Helm release failure', component: badHelm.name, loginBlocking: !loginReady, reason: badHelm.message || 'One or more appliance HelmRelease objects are not Ready', nextAction: 'Inspect HelmRelease status and reconcile events.', }; } const badBackground = (state.components || []).find((entry) => entry.tier === 'background' && !entry.ready && entry.status !== 'unknown'); if (badBackground) { return { layer: 'background workload readiness failure', component: badBackground.name, loginBlocking: false, reason: `${badBackground.name} is ${badBackground.status}`, nextAction: 'Review background workload pods. Login can continue if core is ready.', }; } return { layer: 'none', reason: 'No blocker detected', nextAction: 'No immediate action required.' }; } async function collectCanonical() { const fallback = fallbackCanonical(); if (!serviceHost || !kubeToken) return fallback; const [nodesJson, sourcesJson, kustomizationsJson, helmJson, selectionJson, valuesJson, jobsJson, eventsJson, podsJson] = await Promise.all([ kubeGet('/api/v1/nodes'), kubeGet('/apis/source.toolkit.fluxcd.io/v1/namespaces/flux-system/ocirepositories'), kubeGet('/apis/kustomize.toolkit.fluxcd.io/v1/namespaces/flux-system/kustomizations'), kubeGet('/apis/helm.toolkit.fluxcd.io/v2/namespaces/alga-system/helmreleases'), kubeGet('/api/v1/namespaces/alga-system/configmaps/appliance-release-selection'), kubeGet('/api/v1/namespaces/alga-system/configmaps/appliance-values-alga-core'), kubeGet('/apis/batch/v1/namespaces/msp/jobs'), kubeGet('/api/v1/events'), kubeGet('/api/v1/namespaces/msp/pods'), ]); const resources = await Promise.all(COMPONENTS.map(async (component) => { const path = component.kind === 'deployment' ? `/apis/apps/v1/namespaces/msp/deployments/${component.resource}` : `/apis/apps/v1/namespaces/msp/statefulsets/${component.resource}`; const item = await kubeGet(path); const readiness = resourceReadiness(component.kind, item); return { name: component.name, tier: component.tier, ready: readiness.status === 'healthy', status: readiness.status, message: item ? '' : 'Resource not found or unavailable', namespace: 'msp', }; })); const sources = fluxItems(sourcesJson?.items); const kustomizations = fluxItems(kustomizationsJson?.items); const helmReleases = fluxItems(helmJson?.items); const recentEvents = summarizeEvents(eventsJson); const imageOperations = collectActiveOperations(recentEvents, podsJson); const appUrl = parseAppUrl(valuesJson) || fallback.urls.loginUrl; const loginProbe = await httpProbe(appUrl); const nodesReady = (nodesJson?.items || []).length > 0 && (nodesJson?.items || []).every((node) => readyCondition(node)?.status === 'True'); const platformReady = nodesReady && sources.some((entry) => entry.name === 'alga-appliance' && entry.ready) && kustomizations.some((entry) => entry.name === 'alga-platform' && entry.ready); const coreReady = ['db', 'redis', 'pgbouncer'].every((name) => resources.find((entry) => entry.name === name)?.ready === true); const webReady = resources.find((entry) => entry.name === 'alga-core')?.ready === true; const bootstrapJob = summarizeBootstrapJob(jobsJson); const bootstrapLogs = await collectBootstrapLogs(bootstrapJob, podsJson); const bootstrap = { job: bootstrapJob, logs: bootstrapLogs }; const helmHealthy = helmReleases.length > 0 && helmReleases.every((entry) => entry.ready); const bootstrapReady = coreReady && !bootstrapJob.failed && (bootstrapJob.completed || helmHealthy); const loginReady = coreReady && webReady && loginProbe.reachable === true; const backgroundReady = resources.filter((entry) => entry.tier === 'background').every((entry) => entry.ready); const fullyHealthy = loginReady && backgroundReady; const state = { components: resources, recentEvents, sources, kustomizations, helmReleases, activeOperations: imageOperations, bootstrap }; const reconcileOperations = reconciliationOperations(state); const activeOperations = [...imageOperations, ...reconcileOperations].slice(0, 6); state.activeOperations = activeOperations; const topBlocker = makeBlocker(state, loginReady); const blockers = topBlocker.layer === 'none' ? [] : [{ severity: topBlocker.loginBlocking === false ? 'background' : 'critical', component: topBlocker.component || topBlocker.layer, layer: topBlocker.layer, reason: topBlocker.reason, nextAction: topBlocker.nextAction, loginBlocking: typeof topBlocker.loginBlocking === 'boolean' ? topBlocker.loginBlocking : !loginReady, }]; let rollupState = 'installing'; let rollupMessage = 'Appliance installation is in progress.'; let nextAction = 'Wait for readiness checks to complete.'; if (activeOperations.length > 0 && !loginReady) { rollupState = 'installing'; rollupMessage = activeOperations[0].message || 'Appliance installation is in progress.'; nextAction = 'Wait for the active operation to complete. Image pulls can take several minutes on a local VM.'; } else if (!platformReady || !coreReady || !bootstrapReady || !loginReady) { const blocking = blockers.find((entry) => entry.loginBlocking !== false); if (blocking) { rollupState = 'failed_action_required'; rollupMessage = blocking.layer === 'Bootstrap job failure' ? blocking.reason : 'A core platform blocker requires action before login is available.'; nextAction = blocking.nextAction; } } else if (fullyHealthy) { rollupState = 'fully_healthy'; rollupMessage = 'All selected services are healthy.'; nextAction = 'No immediate action required.'; } else if (loginReady) { rollupState = backgroundReady ? 'ready_to_log_in' : 'ready_with_background_issues'; rollupMessage = backgroundReady ? 'Alga is ready to log in.' : 'Alga is ready to log in. Background services need attention.'; nextAction = backgroundReady ? 'Open the login URL.' : (blockers[0]?.nextAction || 'Open the login URL and review background blockers.'); } const sourceRevision = sources.find((entry) => entry.name === 'alga-appliance')?.revision || null; const result = { service: 'appliance-status', status: rollupState, siteId: selectionJson?.data?.siteId || 'appliance-single-node', timestamp: new Date().toISOString(), release: { selectedReleaseVersion: selectionJson?.data?.releaseVersion || null, appVersion: selectionJson?.data?.appVersion || null, channel: selectionJson?.data?.selectedChannel || selectionJson?.data?.channel || null, selectedChannel: selectionJson?.data?.selectedChannel || selectionJson?.data?.channel || null, sourceRevision, manifestDigest: selectionJson?.data?.manifestDigest || null, registryHost: selectionJson?.data?.registryHost || null, repository: selectionJson?.data?.repository || null, }, urls: { statusUrl: hostIp ? `http://${hostIp}:8080` : null, loginUrl: appUrl }, loginUrl: appUrl, rollup: { state: rollupState, message: rollupMessage, nextAction }, currentPhase: rollupState, nextAction, tiers: { platform: { ready: platformReady, status: tierStatus(platformReady) }, core: { ready: coreReady, status: tierStatus(coreReady) }, bootstrap: { ready: bootstrapReady, status: tierStatus(bootstrapReady) }, login: { ready: loginReady, status: tierStatus(loginReady) }, background: { ready: backgroundReady, status: tierStatus(backgroundReady) }, fullHealth: { ready: fullyHealthy, status: tierStatus(fullyHealthy) }, }, topBlockers: blockers, activeOperations, components: resources, bootstrap, recentEvents, loginProbe, }; return result; } function unauthorized(res) { res.writeHead(401, { 'Content-Type': 'application/json', 'WWW-Authenticate': 'Bearer realm="appliance-status"', }); res.end(JSON.stringify({ error: 'unauthorized' })); } function isAuthorized(req) { if (!token) return false; const auth = req.headers.authorization || ''; if (auth.startsWith('Bearer ')) return auth.slice('Bearer '.length).trim() === token; const url = new URL(req.url, 'http://localhost'); return url.searchParams.get('token') === token; } function overviewFromStatus(status) { return { installState: status.rollup.state, currentPhase: status.currentPhase, loginUrl: status.urls.loginUrl, nextAction: status.rollup.nextAction, message: status.rollup.message, timestamp: status.timestamp, }; } function diagnosticsFromStatus(status) { return { overview: overviewFromStatus(status), tiers: status.tiers, components: status.components, topBlockers: status.topBlockers, activeOperations: status.activeOperations, bootstrap: status.bootstrap, recentEvents: status.recentEvents, flux: { note: 'Flux source, Kustomization, and Helm details are summarized into tiers and blockers.' }, supportBundle: { available: true, action: 'run_script', script: 'ee/appliance/scripts/collect-support-bundle.sh', requiresToken: true, note: 'Bundle download endpoint is deferred; run via CLI/operator for now.', }, }; } const UPDATE_JOB_LABEL = 'app.kubernetes.io/name=appliance-update'; const UPDATE_CHANNELS = ['stable', 'nightly']; function updateJobScript() { return String.raw`const fs = require('fs'); const http = require('http'); const https = require('https'); const { execFileSync } = require('child_process'); const path = require('path'); const action = process.env.UPDATE_ACTION || 'apply'; const requestedChannel = process.env.UPDATE_CHANNEL || ''; const serviceHost = process.env.KUBERNETES_SERVICE_HOST; const servicePort = process.env.KUBERNETES_SERVICE_PORT || '443'; const token = fs.readFileSync('/var/run/secrets/kubernetes.io/serviceaccount/token', 'utf8').trim(); const ca = fs.readFileSync('/var/run/secrets/kubernetes.io/serviceaccount/ca.crt'); function kubeRequest(method, requestPath, body = null, contentType = 'application/json') { return new Promise((resolve, reject) => { const payload = body == null ? null : JSON.stringify(body); const headers = { Authorization: 'Bearer ' + token, Accept: 'application/json' }; if (payload != null) headers['Content-Type'] = contentType; const req = https.request({ host: serviceHost, port: servicePort, method, path: requestPath, ca, headers, timeout: 15000 }, (res) => { let text = ''; res.setEncoding('utf8'); res.on('data', (chunk) => { text += chunk; }); res.on('end', () => { let parsed = null; try { parsed = text ? JSON.parse(text) : null; } catch {} if (res.statusCode < 200 || res.statusCode >= 300) { const error = new Error(method + ' ' + requestPath + ' failed with ' + res.statusCode + ': ' + text.slice(0, 500)); error.statusCode = res.statusCode; error.body = parsed; reject(error); return; } resolve(parsed); }); }); req.on('timeout', () => req.destroy(new Error(method + ' ' + requestPath + ' timed out'))); req.on('error', reject); if (payload != null) req.write(payload); req.end(); }); } async function kubeGet(requestPath) { return kubeRequest('GET', requestPath); } async function kubePatch(requestPath, body) { return kubeRequest('PATCH', requestPath, body, 'application/merge-patch+json'); } async function kubePost(requestPath, body) { return kubeRequest('POST', requestPath, body); } const REGISTRY_HOST = process.env.ALGA_APPLIANCE_REGISTRY_HOST || 'ghcr.io'; const RELEASE_REPOSITORY = process.env.ALGA_APPLIANCE_RELEASE_REPOSITORY || 'nine-minds/alga-appliance-release'; const OCI_MANIFEST_ACCEPT = [ 'application/vnd.oci.image.manifest.v1+json', 'application/vnd.oci.image.index.v1+json', 'application/vnd.docker.distribution.manifest.v2+json' ].join(', '); function httpRequest(url, options = {}) { return new Promise((resolve, reject) => { const parsed = new URL(url); const lib = parsed.protocol === 'https:' ? https : http; const req = lib.request(parsed, { method: options.method || 'GET', headers: options.headers || {}, timeout: options.timeout || 30000 }, (res) => { if (options.followRedirects && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { res.resume(); httpRequest(new URL(res.headers.location, parsed).toString(), options).then(resolve, reject); return; } let text = ''; res.setEncoding('utf8'); res.on('data', (chunk) => { text += chunk; }); res.on('end', () => resolve({ statusCode: res.statusCode, headers: res.headers, body: text })); }); req.on('timeout', () => req.destroy(new Error('request timed out: ' + url))); req.on('error', reject); req.end(); }); } async function registryToken() { const scope = encodeURIComponent('repository:' + RELEASE_REPOSITORY + ':pull'); const url = 'https://' + REGISTRY_HOST + '/token?service=' + encodeURIComponent(REGISTRY_HOST) + '&scope=' + scope; const response = await httpRequest(url, { headers: { Accept: 'application/json' } }); if (response.statusCode < 200 || response.statusCode >= 300) throw new Error('Registry token request failed with ' + response.statusCode); const parsed = JSON.parse(response.body || '{}'); const token = parsed.token || parsed.access_token; if (!token) throw new Error('Registry token response did not include a token'); return token; } function registryUrl(kind, reference) { return 'https://' + REGISTRY_HOST + '/v2/' + RELEASE_REPOSITORY + '/' + kind + '/' + reference; } async function resolveRelease(channel) { const token = await registryToken(); const headers = { Authorization: 'Bearer ' + token }; const manifestUrl = registryUrl('manifests', channel); const manifestResponse = await httpRequest(manifestUrl, { headers: { ...headers, Accept: OCI_MANIFEST_ACCEPT } }); if (manifestResponse.statusCode < 200 || manifestResponse.statusCode >= 300) throw new Error('GET ' + manifestUrl + ' returned ' + manifestResponse.statusCode); const ociManifest = JSON.parse(manifestResponse.body || '{}'); const configDigest = ociManifest?.config?.digest; if (!configDigest) throw new Error('OCI release artifact for ' + channel + ' has no config descriptor'); const blobUrl = registryUrl('blobs', configDigest); const blobResponse = await httpRequest(blobUrl, { headers: { ...headers, Accept: 'application/json' }, followRedirects: true }); if (blobResponse.statusCode < 200 || blobResponse.statusCode >= 300) throw new Error('GET ' + blobUrl + ' returned ' + blobResponse.statusCode); const release = JSON.parse(blobResponse.body || '{}'); const releaseVersion = String(release.version || '').trim(); if (!releaseVersion) throw new Error('OCI release manifest is missing version'); if (!release.images?.algaCore && !release.app?.images?.algaCore) throw new Error('OCI release manifest is missing images.algaCore'); return { channelJson: { channel, releaseVersion, registryHost: REGISTRY_HOST, repository: RELEASE_REPOSITORY, manifestDigest: manifestResponse.headers['docker-content-digest'] || '' }, releaseVersion, release }; } function yamlString(value) { return JSON.stringify(String(value)); } function setYamlValue(source, dottedPath, value) { const target = dottedPath.split('.'); const lines = source.split(/\r?\n/); const stack = []; let replaced = false; const output = lines.map((line) => { const stripped = line.trimStart(); const indent = line.length - stripped.length; while (stack.length && indent <= stack[stack.length - 1][0]) stack.pop(); if (stripped.endsWith(':') && !stripped.startsWith('- ')) { stack.push([indent, stripped.slice(0, -1).trim()]); return line; } const current = stack.map((entry) => entry[1]); if (current.join('.') === target.slice(0, -1).join('.') && stripped.startsWith(target[target.length - 1] + ':')) { replaced = true; return line.slice(0, indent) + target[target.length - 1] + ': ' + value; } return line; }); if (!replaced) throw new Error('Failed to update ' + dottedPath); return output.join('\n'); } async function patchAnnotation(apiPath, name) { const requestedAt = new Date().toISOString(); try { await kubePatch(apiPath, { metadata: { annotations: { 'reconcile.fluxcd.io/requestedAt': requestedAt } } }); console.log('requested reconcile:', name); } catch (error) { if (error.statusCode === 404) console.log('skipping missing resource:', name); else throw error; } } async function currentChannel() { try { const selection = await kubeGet('/api/v1/namespaces/alga-system/configmaps/appliance-release-selection'); return selection?.data?.selectedChannel || selection?.data?.channel || 'stable'; } catch { return 'stable'; } } function configKey(configMap, name, profile) { const preferred = name + '.' + profile + '.yaml'; if (configMap?.data?.[preferred] != null) return preferred; const existing = Object.keys(configMap?.data || {}).find((key) => key.startsWith(name + '.') && key.endsWith('.yaml')); return existing || preferred; } async function patchValuesConfigMap(name, profile, mutate, sourceOverride) { const cm = await kubeGet('/api/v1/namespaces/alga-system/configmaps/appliance-values-' + name); const key = configKey(cm, name, profile); const source = sourceOverride ?? cm?.data?.[key]; if (source == null) throw new Error('ConfigMap appliance-values-' + name + ' does not contain ' + key); const updated = mutate(source); await kubePatch('/api/v1/namespaces/alga-system/configmaps/appliance-values-' + name, { data: { [key]: updated } }); console.log('updated values configmap:', name, key); } async function patchFluxSource(release) { const config = release?.config || {}; if (!config.repository || !config.digest) return; await kubePatch('/apis/source.toolkit.fluxcd.io/v1/namespaces/flux-system/ocirepositories/alga-appliance', { spec: { url: 'oci://' + config.repository, ref: { digest: config.digest } } }); console.log('updated OCIRepository alga-appliance to', config.digest); } async function applyRelease(channel) { const { channelJson, releaseVersion, release } = await resolveRelease(channel); const profile = release?.valuesProfile || release?.app?.valuesProfile || 'single-node'; const images = release?.images || release?.app?.images || {}; const profileValues = release?.profileValues || {}; console.log('applying channel:', channel, 'release:', releaseVersion, 'profile:', profile); await patchFluxSource(release); await patchValuesConfigMap('alga-core', profile, (yaml) => { let out = images.algaCore ? setYamlValue(yaml, 'setup.image.tag', yamlString(images.algaCore)) : yaml; out = images.algaCore ? setYamlValue(out, 'server.image.tag', yamlString(images.algaCore)) : out; return out; }, profileValues['alga-core.' + profile + '.yaml']); await patchValuesConfigMap('workflow-worker', profile, (yaml) => images.workflowWorker ? setYamlValue(yaml, 'image.tag', yamlString(images.workflowWorker)) : yaml, profileValues['workflow-worker.' + profile + '.yaml']); await patchValuesConfigMap('email-service', profile, (yaml) => images.emailService ? setYamlValue(yaml, 'image.tag', yamlString(images.emailService)) : yaml, profileValues['email-service.' + profile + '.yaml']); await patchValuesConfigMap('temporal-worker', profile, (yaml) => images.temporalWorker ? setYamlValue(yaml, 'image.tag', yamlString(images.temporalWorker)) : yaml, profileValues['temporal-worker.' + profile + '.yaml']); for (const name of ['pgbouncer', 'temporal']) { const key = name + '.' + profile + '.yaml'; if (typeof profileValues[key] === 'string') { await patchValuesConfigMap(name, profile, (yaml) => yaml, profileValues[key]); } } const selectionData = { releaseVersion, selectedChannel: channel, appVersion: release?.version || '', registryHost: channelJson.registryHost || '', repository: channelJson.repository || '', manifestDigest: channelJson.manifestDigest || '', algaCoreTag: images.algaCore || '', workflowWorkerTag: images.workflowWorker || '', emailServiceTag: images.emailService || '', temporalWorkerTag: images.temporalWorker || '', controlPlaneTag: release?.controlPlane || '', }; try { await kubePatch('/api/v1/namespaces/alga-system/configmaps/appliance-release-selection', { data: selectionData }); } catch (error) { if (error.statusCode !== 404) throw error; await kubePost('/api/v1/namespaces/alga-system/configmaps', { apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'appliance-release-selection', namespace: 'alga-system' }, data: selectionData }); } console.log('updated release selection'); } async function requestReconciles() { await patchAnnotation('/apis/source.toolkit.fluxcd.io/v1/namespaces/flux-system/ocirepositories/alga-appliance', 'OCIRepository/alga-appliance'); for (const name of ['alga-platform', 'alga-core', 'alga-background', 'alga-appliance']) { await patchAnnotation('/apis/kustomize.toolkit.fluxcd.io/v1/namespaces/flux-system/kustomizations/' + name, 'Kustomization/' + name); } for (const name of ['alga-core', 'pgbouncer', 'temporal', 'workflow-worker', 'email-service', 'temporal-worker']) { await patchAnnotation('/apis/helm.toolkit.fluxcd.io/v2/namespaces/alga-system/helmreleases/' + name, 'HelmRelease/' + name); } } (async () => { if (action === 'check') { console.log('checking for updates by reconciling Flux source and tiers'); await requestReconciles(); console.log('check submitted'); return; } const channel = requestedChannel || await currentChannel(); if (!['stable', 'nightly'].includes(channel)) throw new Error('Unsupported channel: ' + channel); await applyRelease(channel); await requestReconciles(); console.log('upgrade submitted for channel ' + channel); })().catch((error) => { console.error(error.stack || error.message || String(error)); process.exit(1); });`; } function updateJobState(job) { const conditions = job?.status?.conditions || []; if (conditions.some((entry) => entry.type === 'Complete' && entry.status === 'True')) return 'succeeded'; if (conditions.some((entry) => entry.type === 'Failed' && entry.status === 'True')) return 'failed'; if ((job?.status?.active || 0) > 0) return 'running'; return 'pending'; } function summarizeUpdateJob(job) { if (!job) return null; return { name: job.metadata?.name || null, action: job.metadata?.labels?.['appliance.alga/status-action'] || null, channel: job.metadata?.labels?.['appliance.alga/channel'] || null, state: updateJobState(job), createdAt: job.metadata?.creationTimestamp || null, startedAt: job.status?.startTime || null, completedAt: job.status?.completionTime || null, active: job.status?.active || 0, succeeded: job.status?.succeeded || 0, failed: job.status?.failed || 0, }; } async function collectUpdateStatus() { const jobsJson = await kubeGet(`/apis/batch/v1/namespaces/appliance-system/jobs?labelSelector=${encodeURIComponent(UPDATE_JOB_LABEL)}`); const jobs = (jobsJson?.items || []).slice().sort((a, b) => String(b.metadata?.creationTimestamp || '').localeCompare(String(a.metadata?.creationTimestamp || ''))); const latest = jobs[0] || null; let logs = { available: false, lines: [], pod: null, container: 'updater' }; if (latest?.metadata?.name) { const pods = await kubeGet(`/api/v1/namespaces/appliance-system/pods?labelSelector=${encodeURIComponent('job-name=' + latest.metadata.name)}`); const pod = (pods?.items || [])[0]; if (pod?.metadata?.name) { logs = await collectPodLogs('appliance-system', pod.metadata.name, 'updater', 300); } } return { latest: summarizeUpdateJob(latest), active: jobs.map(summarizeUpdateJob).filter((job) => job && ['pending', 'running'].includes(job.state)), history: jobs.slice(0, 5).map(summarizeUpdateJob), logs, }; } async function createUpdateJob(action, channel) { const current = await collectUpdateStatus(); if (current.active.length > 0) { return { conflict: true, status: current }; } if (!['check', 'apply'].includes(action)) throw new Error(`Unsupported update action: ${action}`); if (action === 'apply' && channel && !UPDATE_CHANNELS.includes(channel)) throw new Error(`Unsupported channel: ${channel}`); const suffix = Date.now().toString(36); const name = `appliance-update-${suffix}`; const script = updateJobScript(); const command = `cat <<'UPDATER' >/tmp/update.js\n${script}\nUPDATER\nnode /tmp/update.js`; const labels = { 'app.kubernetes.io/name': 'appliance-update', 'appliance.alga/status-action': action, }; if (channel) labels['appliance.alga/channel'] = channel; const job = { apiVersion: 'batch/v1', kind: 'Job', metadata: { name, namespace: 'appliance-system', labels }, spec: { backoffLimit: 0, ttlSecondsAfterFinished: 86400, template: { metadata: { labels }, spec: { restartPolicy: 'Never', serviceAccountName: 'appliance-status', automountServiceAccountToken: true, containers: [{ name: 'updater', image: 'node:20-alpine', imagePullPolicy: 'IfNotPresent', command: ['sh', '-c', command], env: [ { name: 'UPDATE_ACTION', value: action }, { name: 'UPDATE_CHANNEL', value: channel || '' }, ], }], }, }, }, }; const created = await kubeRequest('POST', '/apis/batch/v1/namespaces/appliance-system/jobs', job); if (!created.ok) throw new Error(`Failed to create update job: ${created.text}`); return { conflict: false, job: summarizeUpdateJob(created.body) }; } const server = http.createServer(async (req, res) => { if (req.url.startsWith('/healthz')) { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('ok'); return; } if (!isAuthorized(req)) { unauthorized(res); return; } try { if (req.url.startsWith('/api/update/status')) { const status = await collectUpdateStatus(); jsonResponse(res, 200, status); return; } if (req.url.startsWith('/api/update/check')) { if (req.method !== 'POST') { jsonResponse(res, 405, { error: 'method_not_allowed' }); return; } const result = await createUpdateJob('check', ''); jsonResponse(res, result.conflict ? 409 : 202, result); return; } if (req.url.startsWith('/api/update/apply')) { if (req.method !== 'POST') { jsonResponse(res, 405, { error: 'method_not_allowed' }); return; } const body = await readJsonBody(req); const result = await createUpdateJob('apply', body.channel || ''); jsonResponse(res, result.conflict ? 409 : 202, result); return; } if (req.url.startsWith('/api/pods/logs')) { const url = new URL(req.url, 'http://localhost'); const logs = await collectPodLogs( url.searchParams.get('namespace'), url.searchParams.get('pod'), url.searchParams.get('container'), url.searchParams.get('tailLines') || 300, ); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(logs)); return; } if (req.url.startsWith('/api/pods')) { const url = new URL(req.url, 'http://localhost'); const pods = await collectPods(url.searchParams.get('namespace') || 'appliance'); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(pods)); return; } if (req.url.startsWith('/api/status')) { const status = await collectCanonical(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(status)); return; } if (req.url.startsWith('/api/overview')) { const status = await collectCanonical(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(overviewFromStatus(status))); return; } if (req.url.startsWith('/api/diagnostics')) { const status = await collectCanonical(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(diagnosticsFromStatus(status))); return; } if (req.url.startsWith('/diagnostics')) { const page = 'Appliance Diagnostics

Advanced Diagnostics

Readiness tiers, components, blockers, and recent events.

Loading...
'; res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(page); return; } const page = String.raw` Alga Appliance Status
Alga PSA Appliance

Install status

Loading status...

Overview

Install state-
Current phase-
Login URL-
Next action-

Current operation

Checking active work...

Readiness tiers

-

Blockers

-

Bootstrap log

No bootstrap log loaded yet.

Recent events

-
`; res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(page); } catch (error) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'status_collection_failed', message: error.message })); } }); server.listen(8080, '0.0.0.0'); JS exec node /tmp/server.js env: - name: STATUS_TOKEN valueFrom: secretKeyRef: name: appliance-status-auth key: token - name: HOST_IP valueFrom: fieldRef: fieldPath: status.hostIP - name: POD_NAMESPACE valueFrom: fieldRef: fieldPath: metadata.namespace ports: - name: http containerPort: 8080 volumeMounts: - name: tmp mountPath: /tmp securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true runAsNonRoot: true runAsUser: 10001 runAsGroup: 10001 capabilities: drop: - ALL resources: requests: cpu: 25m memory: 96Mi limits: cpu: 250m memory: 256Mi livenessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 5 periodSeconds: 10 timeoutSeconds: 3 readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 5 timeoutSeconds: 3 volumes: - name: tmp emptyDir: {}