PSA/ee/appliance/host-service/update-engine.mjs
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

229 lines
7.9 KiB
JavaScript

#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { applyFluxSource, applyReleaseSelectionConfiguration, applyRuntimeValuesAndReleaseSelection, resolveChannelMetadata, validateSetupInputs } from './setup-engine.mjs';
import { persistMaintenanceMetadata } from './metadata-engine.mjs';
const DEFAULT_STATE_FILE = process.env.ALGA_APPLIANCE_STATE_FILE || '/var/lib/alga-appliance/install-state.json';
// Must honor ALGA_APPLIANCE_RELEASE_SELECTION_FILE like setup-engine/status-engine do:
// the control-plane Deployment sets it to /var/lib/alga-appliance (the only writable
// mount). Hardcoding /etc made POST /api/updates die at write-release-selection with
// `EACCES: mkdir /etc/alga-appliance`, so the app-channel update flow never worked.
const DEFAULT_RELEASE_SELECTION_FILE = process.env.ALGA_APPLIANCE_RELEASE_SELECTION_FILE || '/etc/alga-appliance/release-selection.json';
const DEFAULT_UPDATE_HISTORY_FILE = process.env.ALGA_APPLIANCE_UPDATE_HISTORY_FILE || '/var/lib/alga-appliance/update-history.json';
const DEFAULT_KUBECONFIG = '/etc/rancher/k3s/k3s.yaml';
function nowIso() {
return new Date().toISOString();
}
function writeSecureJsonFile(targetFile, value) {
const dir = path.dirname(targetFile);
fs.mkdirSync(dir, { recursive: true, mode: 0o750 });
fs.writeFileSync(targetFile, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
fs.chmodSync(dir, 0o750);
fs.chmodSync(targetFile, 0o600);
}
function readJsonFile(file) {
if (!fs.existsSync(file)) {
return null;
}
try {
return JSON.parse(fs.readFileSync(file, 'utf8'));
} catch {
return null;
}
}
function writeInstallState(state, stateFile) {
writeSecureJsonFile(stateFile, state);
}
function appendUpdateHistory(entry, historyFile) {
const existing = readJsonFile(historyFile);
const history = Array.isArray(existing?.history) ? existing.history : [];
const payload = {
updatedAt: nowIso(),
history: [entry, ...history].slice(0, 50)
};
writeSecureJsonFile(historyFile, payload);
}
function reconcileFluxAndHelm(options = {}) {
const kubeconfigPath = options.kubeconfigPath || DEFAULT_KUBECONFIG;
const fluxSourceName = options.fluxSourceName || 'alga-appliance';
const reconcileTimeout = options.reconcileTimeout || '15m';
const reconcileSourceCmd = options.reconcileSourceCommand
|| `flux --kubeconfig ${kubeconfigPath} reconcile source oci ${fluxSourceName} -n flux-system --timeout ${reconcileTimeout}`;
const reconcileHelmCmd = options.reconcileHelmCommand
|| `flux --kubeconfig ${kubeconfigPath} reconcile helmrelease alga-core -n alga-system --with-source --timeout ${reconcileTimeout}`;
const source = spawnSync('sh', ['-c', reconcileSourceCmd], { env: process.env, encoding: 'utf8' });
if (source.status !== 0) {
return {
ok: false,
phase: 'flux',
message: 'Flux source reconcile failed during app update.',
suspectedCause: (source.stderr || source.stdout || '').trim() || `exit ${source.status ?? 1}`,
suggestedNextStep: 'Verify Flux source-controller health and OCIRepository readiness.',
retrySafe: true
};
}
const helm = spawnSync('sh', ['-c', reconcileHelmCmd], { env: process.env, encoding: 'utf8' });
if (helm.status !== 0) {
return {
ok: false,
phase: 'flux',
message: 'HelmRelease reconcile failed during app update.',
suspectedCause: (helm.stderr || helm.stdout || '').trim() || `exit ${helm.status ?? 1}`,
suggestedNextStep: 'Inspect alga-core HelmRelease events and controller logs.',
retrySafe: true
};
}
return { ok: true, phase: 'flux', message: 'Flux source and HelmRelease reconcile completed.' };
}
export async function runAppChannelUpdate(rawInputs, options = {}) {
const stateFile = options.stateFile || DEFAULT_STATE_FILE;
const releaseSelectionFile = options.releaseSelectionFile || DEFAULT_RELEASE_SELECTION_FILE;
const updateHistoryFile = options.updateHistoryFile || DEFAULT_UPDATE_HISTORY_FILE;
const previousSelection = readJsonFile(releaseSelectionFile) || {};
const validated = validateSetupInputs({
channel: rawInputs.channel,
appHostname: rawInputs.appHostname || previousSelection.runtime?.appHostname || '',
dnsMode: rawInputs.dnsMode || previousSelection.runtime?.dnsMode || 'system',
dnsServers: rawInputs.dnsServers || previousSelection.runtime?.dnsServers || '',
releaseRef: rawInputs.releaseRef || ''
}, { requireInitialTenant: false });
writeInstallState({
status: 'update-running',
phase: 'registry-release-source',
lastAction: `Starting app-channel update to ${validated.channel}`,
updatedAt: nowIso(),
update: {
requestedChannel: validated.channel,
scope: 'application-only'
}
}, stateFile);
const releaseSelection = await resolveChannelMetadata(validated, options);
if (!releaseSelection.ok) {
appendUpdateHistory({
at: nowIso(),
channel: validated.channel,
ok: false,
phase: releaseSelection.phase,
message: releaseSelection.message
}, updateHistoryFile);
return releaseSelection;
}
const runtimeValuesResult = await applyRuntimeValuesAndReleaseSelection(validated, releaseSelection, options);
if (!runtimeValuesResult.ok) {
appendUpdateHistory({
at: nowIso(),
channel: validated.channel,
ok: false,
phase: runtimeValuesResult.phase,
message: runtimeValuesResult.message
}, updateHistoryFile);
return runtimeValuesResult;
}
const fluxSourceResult = applyFluxSource(validated, releaseSelection, options);
if (!fluxSourceResult.ok) {
appendUpdateHistory({
at: nowIso(),
channel: validated.channel,
ok: false,
phase: fluxSourceResult.phase,
message: fluxSourceResult.message
}, updateHistoryFile);
return fluxSourceResult;
}
const configResult = applyReleaseSelectionConfiguration(validated, releaseSelection, {
...options,
releaseSelectionFile
});
if (!configResult.ok) {
appendUpdateHistory({
at: nowIso(),
channel: validated.channel,
ok: false,
phase: configResult.phase,
message: configResult.message
}, updateHistoryFile);
return configResult;
}
const reconcileResult = reconcileFluxAndHelm(options);
if (!reconcileResult.ok) {
writeInstallState({
status: 'update-blocked',
phase: reconcileResult.phase,
lastAction: reconcileResult.message,
failure: reconcileResult,
updatedAt: nowIso(),
update: {
requestedChannel: validated.channel,
scope: 'application-only'
}
}, stateFile);
appendUpdateHistory({
at: nowIso(),
channel: validated.channel,
ok: false,
phase: reconcileResult.phase,
message: reconcileResult.message
}, updateHistoryFile);
return reconcileResult;
}
const result = {
ok: true,
phase: 'registry-release-source',
message: `App-channel update applied for ${validated.channel}; OS and k3s updates remain manual in v1.`,
releaseVersion: releaseSelection.releaseVersion,
selectedChannel: validated.channel,
updateScope: 'application-only'
};
writeInstallState({
status: 'update-complete',
phase: 'registry-release-source',
lastAction: result.message,
updatedAt: nowIso(),
update: {
requestedChannel: validated.channel,
selectedReleaseVersion: releaseSelection.releaseVersion,
scope: 'application-only'
}
}, stateFile);
appendUpdateHistory({
at: nowIso(),
channel: validated.channel,
ok: true,
releaseVersion: releaseSelection.releaseVersion,
message: result.message
}, updateHistoryFile);
persistMaintenanceMetadata({
metadataFile: options.metadataFile,
releaseSelectionFile,
installStateFile: stateFile,
osReleaseFile: options.osReleaseFile,
k3sVersionCommand: options.k3sVersionCommand
});
return result;
}