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 DiagnosticsAdvanced 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...
Bootstrap log
No bootstrap log loaded yet.
Updates
Selected channel-
Resolved release-
Flux source-
No update job has run yet.
Update job
Loading update status...
Pods
Loading pods...
| Namespace | Pod | Status | Ready | Restarts | Age | Node |
|---|
Select a pod to view logs.
Diagnostics
Raw readiness, blocker, component, operation, bootstrap, and event details.
Loading diagnostics...
`;
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: {}