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