PSA/ee/appliance/host-service/setup-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

1361 lines
54 KiB
JavaScript
Executable File

#!/usr/bin/env node
import crypto from 'node:crypto';
import dns from 'node:dns';
import fs from 'node:fs';
import https from 'node:https';
import os from 'node:os';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { persistMaintenanceMetadata } from './metadata-engine.mjs';
import { redeemInstallCode, deriveApplianceId, licenseSeedFromRedeem } from './install-code.mjs';
// Path defaults honor the ALGA_APPLIANCE_* environment the control plane runs
// with, falling back to the bare-host locations. This keeps the setup workflow
// aligned with the pod's mounted paths (token secret, in-cluster kubeconfig,
// hostPath state) instead of hardcoded bare-host defaults.
const DEFAULT_SETUP_FILE = process.env.ALGA_APPLIANCE_SETUP_INPUTS_FILE || '/etc/alga-appliance/setup-inputs.json';
const DEFAULT_STATE_FILE = process.env.ALGA_APPLIANCE_STATE_FILE || '/var/lib/alga-appliance/install-state.json';
const DEFAULT_RESOLV_CONF = '/etc/resolv.conf';
// Registry-metadata source of truth: the appliance resolves a channel to an
// immutable release manifest published as an OCI artifact in this registry/repo.
const DEFAULT_REGISTRY_HOST = process.env.ALGA_APPLIANCE_REGISTRY_HOST || 'ghcr.io';
const DEFAULT_RELEASE_REPOSITORY = process.env.ALGA_APPLIANCE_RELEASE_REPOSITORY || 'nine-minds/alga-appliance-release';
const DEFAULT_KUBECONFIG = process.env.ALGA_APPLIANCE_KUBECONFIG || '/etc/rancher/k3s/k3s.yaml';
const DEFAULT_TOKEN_FILE = process.env.ALGA_APPLIANCE_TOKEN_FILE || '/var/lib/alga-appliance/setup-token';
const DEFAULT_RELEASE_SELECTION_FILE = process.env.ALGA_APPLIANCE_RELEASE_SELECTION_FILE || '/etc/alga-appliance/release-selection.json';
function isValidIpv4(value) {
const parts = value.split('.');
if (parts.length !== 4) {
return false;
}
return parts.every((part) => {
if (!/^\d+$/.test(part)) {
return false;
}
const n = Number(part);
return n >= 0 && n <= 255;
});
}
function readSystemResolvers(resolvConfPath = DEFAULT_RESOLV_CONF) {
if (!fs.existsSync(resolvConfPath)) {
return [];
}
const content = fs.readFileSync(resolvConfPath, 'utf8');
return content
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.startsWith('nameserver '))
.map((line) => line.replace('nameserver ', '').trim())
.filter((value) => value.length > 0);
}
function writeInstallState(state, stateFile = DEFAULT_STATE_FILE) {
const stateDir = path.dirname(stateFile);
fs.mkdirSync(stateDir, { recursive: true, mode: 0o750 });
fs.writeFileSync(stateFile, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
fs.chmodSync(stateDir, 0o750);
fs.chmodSync(stateFile, 0o600);
}
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 nowIso() {
return new Date().toISOString();
}
async function resolveAddressesWithServers(servers, hostname, family = 0) {
const resolver = new dns.promises.Resolver();
resolver.setServers(servers);
const records = [];
if (family !== 6) {
try {
for (const address of await resolver.resolve4(hostname)) {
records.push({ address, family: 4 });
}
} catch {
// No IPv4 records (or query error); fall through to IPv6 before giving up.
}
}
if (family !== 4 && records.length === 0) {
try {
for (const address of await resolver.resolve6(hostname)) {
records.push({ address, family: 6 });
}
} catch {
// Surfaced by the empty-result check below.
}
}
if (records.length === 0) {
throw new Error(`No A or AAAA records resolved for ${hostname} via DNS server(s) ${servers.join(', ')}`);
}
return records;
}
// Custom DNS lookup for https.request that resolves against explicit servers.
// Guards against empty results (which previously yielded "Invalid IP address:
// undefined") and honors Node's all/family lookup options.
function resolverLookup(servers) {
return (hostname, options, callback) => {
const done = typeof options === 'function' ? options : callback;
const opts = typeof options === 'object' && options ? options : {};
resolveAddressesWithServers(servers, hostname, opts.family || 0)
.then((records) => {
if (opts.all) {
done(null, records);
} else {
done(null, records[0].address, records[0].family);
}
})
.catch((error) => done(error));
};
}
function httpsRequest(url, timeoutMs = 8000, lookupServers = [], extra = {}) {
return new Promise((resolve, reject) => {
const requestOptions = {
method: extra.method || 'GET',
timeout: timeoutMs,
headers: extra.headers || {}
};
if (lookupServers && lookupServers.length > 0) {
requestOptions.lookup = resolverLookup(lookupServers);
}
const req = https.request(url, requestOptions, (res) => {
const status = res.statusCode || 0;
// Opt-in redirect following (OCI blob fetches 307 to a CDN). On a
// cross-host redirect, drop Authorization — the redirect target is a
// pre-signed URL and forwarding a bearer to a third party is unsafe.
const redirectsLeft = extra.followRedirects ? (extra.maxRedirects ?? 5) : 0;
if (status >= 300 && status < 400 && res.headers.location && redirectsLeft > 0) {
res.resume();
let nextUrl;
try {
nextUrl = new URL(res.headers.location, url).toString();
} catch (error) {
reject(error);
return;
}
const nextHeaders = { ...(extra.headers || {}) };
try {
if (new URL(nextUrl).host !== new URL(url).host) {
delete nextHeaders.Authorization;
delete nextHeaders.authorization;
}
} catch {
// keep headers if URL parsing fails; the next request will surface errors
}
httpsRequest(nextUrl, timeoutMs, lookupServers, {
...extra,
headers: nextHeaders,
maxRedirects: redirectsLeft - 1
}).then(resolve, reject);
return;
}
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
resolve({
statusCode: status,
headers: res.headers || {},
body: Buffer.concat(chunks).toString('utf8')
});
});
});
req.on('timeout', () => {
req.destroy(new Error(`Request timed out for ${url}`));
});
req.on('error', reject);
req.end();
});
}
function runShell(command, options = {}) {
const result = spawnSync('sh', ['-c', command], {
env: process.env,
encoding: 'utf8',
input: options.input || undefined
});
return {
ok: result.status === 0,
status: result.status ?? 1,
stdout: result.stdout || '',
stderr: result.stderr || ''
};
}
function shellQuote(value) {
return `'${String(value).replaceAll("'", "'\\''")}'`;
}
function ensureCommand(command) {
return spawnSync('sh', ['-c', `command -v ${command}`], { encoding: 'utf8' }).status === 0;
}
function ensureFluxCli(options = {}) {
if (ensureCommand('flux')) {
return { ok: true, installed: false };
}
const installCommand = options.fluxCliInstallCommand || 'curl -s https://fluxcd.io/install.sh | bash';
const result = runShell(installCommand);
if (!result.ok || !ensureCommand('flux')) {
return {
ok: false,
stdout: result.stdout,
stderr: result.stderr,
message: 'Flux CLI is not installed and automatic installation failed.'
};
}
return { ok: true, installed: true };
}
// --- OCI registry client -----------------------------------------------------
// Release metadata is pulled from an OCI registry (ghcr) instead of git. The
// release manifest is the artifact's config blob; everything it references
// (chart versions, the flux base bundle digest, image tags) is content-pinned.
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(', ');
const RELEASE_MANIFEST_SCHEMA = 'alga.appliance.release/v1';
// ghcr and Docker-style registries hand out an anonymous pull token for public
// repositories via the token endpoint advertised in the 401 challenge.
async function fetchRegistryPullToken(registryHost, repository, timeoutMs, lookupServers = []) {
const scope = encodeURIComponent(`repository:${repository}:pull`);
const url = `https://${registryHost}/token?service=${encodeURIComponent(registryHost)}&scope=${scope}`;
const response = await httpsRequest(url, timeoutMs, lookupServers, { headers: { Accept: 'application/json' } });
if (response.statusCode < 200 || response.statusCode >= 300) {
throw new Error(`Registry token request to ${registryHost} returned ${response.statusCode}`);
}
let token;
try {
const parsed = JSON.parse(response.body);
token = parsed.token || parsed.access_token;
} catch (error) {
throw new Error(`Registry token response was not JSON: ${error instanceof Error ? error.message : String(error)}`);
}
if (!token) {
throw new Error('Registry token response did not include a token.');
}
return token;
}
function ociV2Url(registryHost, repository, kind, reference) {
return `https://${registryHost}/v2/${repository}/${kind}/${reference}`;
}
// Resolve an OCI artifact reference (tag or digest) to its config blob parsed as
// JSON. The blob fetch follows the registry's redirect to its CDN.
async function fetchOciConfigJson(registryHost, repository, reference, timeoutMs, lookupServers = []) {
const token = await fetchRegistryPullToken(registryHost, repository, timeoutMs, lookupServers);
const authHeaders = { Authorization: `Bearer ${token}` };
const manifestUrl = ociV2Url(registryHost, repository, 'manifests', reference);
const manifestResponse = await httpsRequest(manifestUrl, timeoutMs, lookupServers, {
headers: { ...authHeaders, Accept: OCI_MANIFEST_ACCEPT }
});
if (manifestResponse.statusCode < 200 || manifestResponse.statusCode >= 300) {
throw new Error(`GET ${manifestUrl} returned ${manifestResponse.statusCode}`);
}
let manifest;
try {
manifest = JSON.parse(manifestResponse.body);
} catch (error) {
throw new Error(`OCI manifest for ${repository}:${reference} was not JSON: ${error instanceof Error ? error.message : String(error)}`);
}
const configDigest = manifest?.config?.digest;
if (!configDigest) {
throw new Error(`OCI manifest for ${repository}:${reference} has no config descriptor.`);
}
const manifestDigest = manifestResponse.headers['docker-content-digest'] || null;
const blobUrl = ociV2Url(registryHost, repository, 'blobs', configDigest);
const blobResponse = await httpsRequest(blobUrl, timeoutMs, lookupServers, {
headers: { ...authHeaders, Accept: 'application/json' },
followRedirects: true
});
if (blobResponse.statusCode < 200 || blobResponse.statusCode >= 300) {
throw new Error(`GET ${blobUrl} returned ${blobResponse.statusCode}`);
}
let config;
try {
config = JSON.parse(blobResponse.body);
} catch (error) {
throw new Error(`OCI config blob ${configDigest} was not JSON: ${error instanceof Error ? error.message : String(error)}`);
}
return { config, configDigest, manifestDigest };
}
// Validate + normalize a release manifest into the shape the engine consumes.
export function validateReleaseManifest(manifest) {
if (!manifest || typeof manifest !== 'object') {
throw new Error('Release manifest is empty or not an object.');
}
if (manifest.schema && manifest.schema !== RELEASE_MANIFEST_SCHEMA) {
throw new Error(`Unsupported release manifest schema "${manifest.schema}" (expected ${RELEASE_MANIFEST_SCHEMA}).`);
}
const version = String(manifest.version || '').trim();
if (!version) {
throw new Error('Release manifest is missing "version".');
}
const images = manifest.images && typeof manifest.images === 'object' ? manifest.images : null;
if (!images || !String(images.algaCore || '').trim()) {
throw new Error('Release manifest is missing images.algaCore.');
}
const config = manifest.config && typeof manifest.config === 'object' ? manifest.config : null;
if (!config || !String(config.repository || '').trim() || !String(config.digest || '').trim()) {
throw new Error('Release manifest is missing config.repository/config.digest (the flux base OCI bundle).');
}
return {
schema: RELEASE_MANIFEST_SCHEMA,
version,
valuesProfile: String(manifest.valuesProfile || 'single-node').trim() || 'single-node',
images,
controlPlane: manifest.controlPlane ? String(manifest.controlPlane).trim() : null,
config: {
repository: String(config.repository).trim(),
tag: config.tag ? String(config.tag).trim() : version,
digest: String(config.digest).trim()
},
charts: manifest.charts && typeof manifest.charts === 'object' ? manifest.charts : {},
// Per-service profile values (e.g. "alga-core.single-node.yaml" -> yaml text)
// are carried in the manifest so the host can render the runtime-values
// ConfigMaps without fetching anything from git.
profileValues: manifest.profileValues && typeof manifest.profileValues === 'object' ? manifest.profileValues : {}
};
}
// Resolve a channel (or an explicit version/digest reference) to a validated,
// immutable release manifest pulled from the OCI registry. No git involved.
export async function resolveReleaseManifest(reference, options = {}) {
const registryHost = options.registryHost || DEFAULT_REGISTRY_HOST;
const repository = options.releaseRepository || DEFAULT_RELEASE_REPOSITORY;
const timeoutMs = Number(options.timeoutMs || 8000);
const lookupServers = options.lookupServers || [];
if (options.releaseManifestOverride) {
return {
manifest: validateReleaseManifest(options.releaseManifestOverride),
registryHost,
repository,
reference,
manifestDigest: null
};
}
const { config, manifestDigest } = await fetchOciConfigJson(registryHost, repository, reference, timeoutMs, lookupServers);
return {
manifest: validateReleaseManifest(config),
registryHost,
repository,
reference,
manifestDigest
};
}
function yamlString(value) {
return JSON.stringify(String(value));
}
function validatePassword(value) {
if (value.length < 8) return 'Initial admin password must be at least 8 characters.';
if (!/[a-z]/.test(value)) return 'Initial admin password must include a lowercase letter.';
if (!/[A-Z]/.test(value)) return 'Initial admin password must include an uppercase letter.';
if (!/\d/.test(value)) return 'Initial admin password must include a number.';
if (!/[!@#$%^&*(),.?":{}|<>]/.test(value)) return 'Initial admin password must include a special character.';
return null;
}
function requiredText(raw, label) {
const value = String(raw || '').trim();
if (!value) {
throw new Error(`${label} is required.`);
}
return value;
}
function normalizeInitialTenant(raw) {
const source = raw.initialTenant && typeof raw.initialTenant === 'object' ? raw.initialTenant : raw;
const tenantName = requiredText(source.tenantName ?? source.initialTenantName ?? source.companyName, 'Company name');
const adminFirstName = requiredText(source.adminFirstName ?? source.initialAdminFirstName, 'Admin first name');
const adminLastName = requiredText(source.adminLastName ?? source.initialAdminLastName, 'Admin last name');
const adminEmail = requiredText(source.adminEmail ?? source.initialAdminEmail, 'Admin email').toLowerCase();
const adminPassword = String(source.adminPassword ?? source.initialAdminPassword ?? '');
const adminPasswordConfirm = source.adminPasswordConfirm ?? source.initialAdminPasswordConfirm;
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(adminEmail)) {
throw new Error('Enter a valid admin email address.');
}
if (adminPasswordConfirm !== undefined && adminPassword !== String(adminPasswordConfirm)) {
throw new Error('Initial admin password confirmation does not match.');
}
const passwordError = validatePassword(adminPassword);
if (passwordError) {
throw new Error(passwordError);
}
return {
tenantName,
adminFirstName,
adminLastName,
adminEmail,
adminPassword
};
}
function initialTenantSecretYaml(initialTenant, initialTenantId) {
// INITIAL_TENANT_ID (when an install code was redeemed) makes the bootstrap's
// create-tenant adopt the registry-minted tenant id instead of DB-generating
// one. Omitted (empty) on the legacy/no-code path — create-tenant then mints
// its own, unchanged. create-tenant.ts reads it from the pod env.
const tenantIdLine = initialTenantId ? `\n INITIAL_TENANT_ID: ${yamlString(initialTenantId)}` : '';
return `apiVersion: v1\nkind: Secret\nmetadata:\n name: appliance-initial-tenant\n namespace: msp\ntype: Opaque\nstringData:\n INITIAL_TENANT_NAME: ${yamlString(initialTenant.tenantName)}\n INITIAL_ADMIN_FIRST_NAME: ${yamlString(initialTenant.adminFirstName)}\n INITIAL_ADMIN_LAST_NAME: ${yamlString(initialTenant.adminLastName)}\n INITIAL_ADMIN_EMAIL: ${yamlString(initialTenant.adminEmail)}\n INITIAL_ADMIN_PASSWORD: ${yamlString(initialTenant.adminPassword)}${tenantIdLine}\n`;
}
function appUrlFromInput(value) {
const trimmed = String(value || '').trim();
if (!trimmed) return null;
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}
function hostFromAppUrl(value) {
try {
return new URL(value).hostname;
} catch {
return String(value || '').replace(/^https?:\/\//i, '').split('/')[0];
}
}
function setYamlScalar(yaml, target, value) {
const output = [];
const stack = [];
let replaced = false;
for (const line of yaml.split(/\r?\n/)) {
const match = line.match(/^(\s*)([A-Za-z0-9_-]+):(?:\s|$)/);
const indent = line.match(/^\s*/)?.[0].length || 0;
while (stack.length && stack[stack.length - 1].indent >= indent) {
stack.pop();
}
if (match) {
const key = match[2];
const parentPath = stack.map((entry) => entry.key);
if (!replaced && parentPath.length === target.length - 1 && parentPath.every((part, index) => part === target[index]) && key === target[target.length - 1]) {
output.push(`${line.slice(0, indent)}${key}: ${value}`);
replaced = true;
stack.push({ indent, key });
continue;
}
stack.push({ indent, key });
}
output.push(line);
}
if (!replaced) {
throw new Error(`Failed to update YAML value ${target.join('.')}`);
}
return output.join('\n');
}
function resolverServersForInputs(inputs, resolvConfPath = DEFAULT_RESOLV_CONF) {
return inputs.dnsMode === 'custom'
? inputs.dnsServers.split(',').map((value) => value.trim()).filter(Boolean)
: readSystemResolvers(resolvConfPath);
}
async function dnsLookup(hostname, servers) {
if (servers && servers.length > 0) {
return (await resolveAddressesWithServers(servers, hostname)).map((record) => record.address);
}
return dns.promises.resolve4(hostname);
}
export function validateSetupInputs(raw, options = {}) {
const channel = String(raw.channel || 'stable').trim();
const appHostname = String(raw.appHostname || '').trim();
const dnsMode = String(raw.dnsMode || 'system').trim();
const dnsServers = String(raw.dnsServers || '').trim();
// Optional advanced override: pin to a specific release version or digest
// instead of following the channel tag; release metadata is resolved from
// the OCI registry.
const releaseRef = String(raw.releaseRef || '').trim();
const initialTenant = options.requireInitialTenant === false ? null : normalizeInitialTenant(raw);
// Edition licensing fields (F078)
const editionChoice = String(raw.editionChoice || 'ee').trim();
if (!['ee', 'ce'].includes(editionChoice)) {
throw new Error('Invalid edition choice. Use ee or ce.');
}
const licenseKey = raw.licenseKey ? String(raw.licenseKey).trim() : null;
// Install code (from the registration email). When present it's the PRIMARY
// licensing path: redeemed at apply time to derive the tenant id + edition +
// (paid) license, overriding the manual editionChoice/licenseKey, which remain
// the airgap/manual fallback when no code is entered.
const installCode = raw.installCode ? String(raw.installCode).trim().toUpperCase() : null;
if (!['stable', 'nightly'].includes(channel)) {
throw new Error('Invalid channel. Use stable or nightly.');
}
if (!['system', 'custom'].includes(dnsMode)) {
throw new Error('Invalid DNS mode. Use system or custom.');
}
if (dnsMode === 'custom') {
const parsed = dnsServers
.split(',')
.map((value) => value.trim())
.filter((value) => value.length > 0);
if (parsed.length === 0) {
throw new Error('Custom DNS mode requires at least one DNS server.');
}
const invalid = parsed.filter((value) => !isValidIpv4(value));
if (invalid.length > 0) {
throw new Error(`Invalid custom DNS server(s): ${invalid.join(', ')}`);
}
}
return {
channel,
appHostname,
dnsMode,
dnsServers,
releaseRef,
initialTenant,
editionChoice,
licenseKey,
installCode,
submittedAt: nowIso()
};
}
export function persistSetupInputs(inputs, setupInputsFile = DEFAULT_SETUP_FILE) {
const setupDir = path.dirname(setupInputsFile);
fs.mkdirSync(setupDir, { recursive: true, mode: 0o750 });
fs.writeFileSync(setupInputsFile, `${JSON.stringify(inputs, null, 2)}\n`, { mode: 0o600 });
fs.chmodSync(setupDir, 0o750);
fs.chmodSync(setupInputsFile, 0o600);
}
function preflightFailure(phase, step, message, details) {
return {
ok: false,
phase,
step,
message,
details,
suspectedCause: message,
suggestedNextStep: details,
retrySafe: true
};
}
function baseState(inputs) {
return {
status: 'preflight-running',
phase: 'preflight',
lastAction: 'Running setup preflight checks before host mutation',
updatedAt: nowIso(),
setupInputs: {
channel: inputs.channel,
dnsMode: inputs.dnsMode,
releaseRef: inputs.releaseRef || undefined,
initialTenant: inputs.initialTenant ? {
tenantName: inputs.initialTenant.tenantName,
adminEmail: inputs.initialTenant.adminEmail
} : undefined
}
};
}
export async function runSetupPreflight(inputs, options = {}) {
const stateFile = options.stateFile || DEFAULT_STATE_FILE;
const resolvConfPath = options.resolvConfPath || DEFAULT_RESOLV_CONF;
const timeoutMs = Number(options.timeoutMs || 8000);
const registryHost = options.registryHost || DEFAULT_REGISTRY_HOST;
const releaseReference = (inputs.releaseRef || inputs.channel || 'stable').trim();
const proxySet = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'NO_PROXY', 'no_proxy']
.filter((name) => (process.env[name] || '').trim().length > 0);
const state = baseState(inputs);
writeInstallState(state, stateFile);
const servers = resolverServersForInputs(inputs, resolvConfPath);
if (inputs.dnsMode === 'system' && servers.length === 0) {
const failure = preflightFailure(
'dns',
'resolve-system-resolvers',
'No system DNS resolvers detected from /etc/resolv.conf.',
'Confirm DHCP/static resolver configuration and retry setup.'
);
writeInstallState({ ...state, status: 'preflight-blocked', phase: 'dns', lastAction: failure.message, failure, updatedAt: nowIso() }, stateFile);
return failure;
}
try {
await dnsLookup(registryHost, servers);
} catch (error) {
const failure = preflightFailure(
'dns',
'resolve-registry-host',
`DNS lookup failed for ${registryHost}.`,
`Verify DNS resolver reachability and split-horizon policy. ${error instanceof Error ? error.message : String(error)}`
);
writeInstallState({ ...state, status: 'preflight-blocked', phase: 'dns', lastAction: failure.message, failure, updatedAt: nowIso() }, stateFile);
return failure;
}
let resolvedRelease;
try {
resolvedRelease = await resolveReleaseManifest(releaseReference, {
registryHost,
releaseRepository: options.releaseRepository,
timeoutMs,
lookupServers: servers,
releaseManifestOverride: options.releaseManifestOverride
});
} catch (error) {
const failure = preflightFailure(
'registry-release-source',
'resolve-release-manifest',
`Unable to resolve release manifest for "${releaseReference}" from ${registryHost}.`,
`Verify the channel/release exists in the registry and outbound HTTPS to ${registryHost} is allowed by firewall/proxy policy. ${error instanceof Error ? error.message : String(error)}`
);
writeInstallState({ ...state, status: 'preflight-blocked', phase: 'registry-release-source', lastAction: failure.message, failure, updatedAt: nowIso() }, stateFile);
return failure;
}
try {
const ghcrResponse = await httpsRequest('https://ghcr.io/v2/', timeoutMs, servers);
if (![200, 401].includes(ghcrResponse.statusCode)) {
const failure = preflightFailure(
'network',
'reach-ghcr',
`GHCR reachability check returned ${ghcrResponse.statusCode}.`,
'Ensure outbound HTTPS to ghcr.io is allowed by firewall/proxy policy.'
);
writeInstallState({ ...state, status: 'preflight-blocked', phase: 'network', lastAction: failure.message, failure, updatedAt: nowIso() }, stateFile);
return failure;
}
} catch (error) {
const failure = preflightFailure(
'network',
'reach-ghcr',
'Network failure while contacting ghcr.io.',
`Check outbound HTTPS and proxy settings for GHCR. ${error instanceof Error ? error.message : String(error)}`
);
writeInstallState({ ...state, status: 'preflight-blocked', phase: 'network', lastAction: failure.message, failure, updatedAt: nowIso() }, stateFile);
return failure;
}
const success = {
ok: true,
phase: 'preflight',
message: 'Preflight checks passed. Safe to continue with k3s installation.',
checks: {
dns: {
mode: inputs.dnsMode,
resolvers: servers
},
release: {
registryHost,
reference: releaseReference,
version: resolvedRelease.manifest.version
},
ghcr: {
endpoint: 'https://ghcr.io/v2/'
},
egress: {
proxyVariablesDetected: proxySet
}
}
};
writeInstallState({
...state,
status: 'preflight-complete',
phase: 'preflight',
lastAction: success.message,
preflight: success.checks,
updatedAt: nowIso()
}, stateFile);
return success;
}
// Read-only network reachability checks (DNS + GitHub channel + GHCR).
// Unlike runSetupPreflight, this writes no install-state and has no side
// effects, so it is safe to call repeatedly from the live status path to
// re-validate a previously recorded network failure instead of trusting a
// stale record forever.
export async function runNetworkChecks(inputs, options = {}) {
const resolvConfPath = options.resolvConfPath || DEFAULT_RESOLV_CONF;
const timeoutMs = Number(options.timeoutMs || 8000);
const checkedAt = nowIso();
const errText = (error) => (error instanceof Error ? error.message : String(error));
const registryHost = options.registryHost || DEFAULT_REGISTRY_HOST;
const releaseReference = (inputs.releaseRef || inputs.channel || 'stable').trim();
const servers = resolverServersForInputs(inputs, resolvConfPath);
if (inputs.dnsMode === 'system' && servers.length === 0) {
return { ok: false, checkedAt, failure: preflightFailure('dns', 'resolve-system-resolvers', 'No system DNS resolvers detected from /etc/resolv.conf.', 'Confirm DHCP/static resolver configuration and retry setup.') };
}
try {
await dnsLookup(registryHost, servers);
} catch (error) {
return { ok: false, checkedAt, failure: preflightFailure('dns', 'resolve-registry-host', `DNS lookup failed for ${registryHost}.`, `Verify DNS resolver reachability and split-horizon policy. ${errText(error)}`) };
}
try {
const ghcrResponse = await httpsRequest('https://ghcr.io/v2/', timeoutMs, servers);
if (![200, 401].includes(ghcrResponse.statusCode)) {
return { ok: false, checkedAt, failure: preflightFailure('network', 'reach-ghcr', `GHCR reachability check returned ${ghcrResponse.statusCode}.`, 'Ensure outbound HTTPS to ghcr.io is allowed by firewall/proxy policy.') };
}
} catch (error) {
return { ok: false, checkedAt, failure: preflightFailure('network', 'reach-ghcr', 'Network failure while contacting ghcr.io.', `Check outbound HTTPS and proxy settings for GHCR. ${errText(error)}`) };
}
let resolvedRelease;
try {
resolvedRelease = await resolveReleaseManifest(releaseReference, {
registryHost,
releaseRepository: options.releaseRepository,
timeoutMs,
lookupServers: servers,
releaseManifestOverride: options.releaseManifestOverride
});
} catch (error) {
return { ok: false, checkedAt, failure: preflightFailure('registry-release-source', 'resolve-release-manifest', `Unable to resolve release manifest for "${releaseReference}" from ${registryHost}.`, `Verify the channel/release exists in the registry and outbound HTTPS to ${registryHost} is allowed by firewall/proxy policy. ${errText(error)}`) };
}
return { ok: true, checkedAt, failure: null, checks: { registryHost, reference: releaseReference, version: resolvedRelease.manifest.version } };
}
export function installFlux(options = {}) {
const stateFile = options.stateFile || DEFAULT_STATE_FILE;
const kubeconfigPath = options.kubeconfigPath || DEFAULT_KUBECONFIG;
const fluxInstallCommand = options.fluxInstallCommand || `flux install --namespace flux-system --kubeconfig ${kubeconfigPath}`;
const cli = options.fluxInstallCommand ? { ok: true, installed: false } : ensureFluxCli(options);
if (!cli.ok) {
const failure = preflightFailure(
'flux',
'install-flux-cli',
'Flux CLI is required before installing Flux controllers.',
`${cli.message || 'Install Flux CLI and retry.'} ${(cli.stderr || cli.stdout || '').trim()}`.trim()
);
writeInstallState({
status: 'flux-install-blocked',
phase: 'flux',
lastAction: failure.message,
failure,
updatedAt: nowIso()
}, stateFile);
return failure;
}
writeInstallState({
status: 'flux-install-running',
phase: 'flux',
lastAction: 'Installing Flux controllers into k3s cluster',
updatedAt: nowIso()
}, stateFile);
const result = spawnSync('sh', ['-c', fluxInstallCommand], {
env: process.env,
encoding: 'utf8'
});
if (result.status !== 0) {
const stderr = (result.stderr || '').trim();
const stdout = (result.stdout || '').trim();
const message = stderr || stdout || `flux install command failed with exit code ${result.status ?? 1}`;
const failure = preflightFailure(
'flux',
'install-flux',
'Flux installation failed.',
`Verify Flux CLI availability and cluster access. ${message}`
);
writeInstallState({
status: 'flux-install-blocked',
phase: 'flux',
lastAction: failure.message,
failure,
installerOutput: {
stdout: result.stdout || '',
stderr: result.stderr || ''
},
updatedAt: nowIso()
}, stateFile);
return failure;
}
const success = {
ok: true,
phase: 'flux',
message: 'Flux installation completed successfully.',
kubeconfigPath
};
writeInstallState({
status: 'flux-install-complete',
phase: 'flux',
lastAction: success.message,
flux: {
namespace: 'flux-system',
kubeconfigPath
},
updatedAt: nowIso()
}, stateFile);
return success;
}
export async function resolveChannelMetadata(inputs, options = {}) {
const stateFile = options.stateFile || DEFAULT_STATE_FILE;
const timeoutMs = Number(options.timeoutMs || 8000);
const registryHost = options.registryHost || DEFAULT_REGISTRY_HOST;
// Single source of truth: an immutable release manifest published as an OCI
// artifact in the registry, resolved by channel tag (or an explicit
// version/digest pin via inputs.releaseRef). No git, no branch.
const reference = (inputs.releaseRef || inputs.channel || 'stable').trim();
writeInstallState({
status: 'release-resolve-running',
phase: 'registry-release-source',
lastAction: `Resolving ${inputs.channel} release manifest from ${registryHost}`,
updatedAt: nowIso()
}, stateFile);
let resolved;
try {
resolved = await resolveReleaseManifest(reference, {
registryHost,
releaseRepository: options.releaseRepository,
timeoutMs,
lookupServers: resolverServersForInputs(inputs, options.resolvConfPath || DEFAULT_RESOLV_CONF),
releaseManifestOverride: options.releaseManifestOverride
});
} catch (error) {
const failure = preflightFailure(
'registry-release-source',
'resolve-release-manifest',
`Unable to resolve release manifest for "${reference}" from ${registryHost}.`,
error instanceof Error ? error.message : String(error)
);
writeInstallState({
status: 'release-resolve-blocked',
phase: 'registry-release-source',
lastAction: failure.message,
failure,
updatedAt: nowIso()
}, stateFile);
return failure;
}
const manifest = resolved.manifest;
const success = {
ok: true,
phase: 'registry-release-source',
message: `Resolved channel ${inputs.channel} to release ${manifest.version}.`,
channel: inputs.channel,
reference,
releaseVersion: manifest.version,
registryHost,
repository: resolved.repository,
manifestDigest: resolved.manifestDigest,
manifest
};
writeInstallState({
status: 'release-resolve-complete',
phase: 'registry-release-source',
lastAction: success.message,
release: {
selectedChannel: success.channel,
selectedReleaseVersion: success.releaseVersion,
registryHost,
repository: resolved.repository,
manifestDigest: resolved.manifestDigest
},
updatedAt: nowIso()
}, stateFile);
return success;
}
export async function applyRuntimeValuesAndReleaseSelection(inputs, releaseSelection, options = {}) {
const stateFile = options.stateFile || DEFAULT_STATE_FILE;
const kubeconfigPath = options.kubeconfigPath || DEFAULT_KUBECONFIG;
const releaseVersion = releaseSelection.releaseVersion;
const manifest = options.releaseManifestOverride
? validateReleaseManifest(options.releaseManifestOverride)
: releaseSelection.manifest;
const tempDir = options.runtimeValuesDir || fs.mkdtempSync(path.join(os.tmpdir(), 'alga-runtime-values-'));
const valuesDir = path.join(tempDir, 'values');
writeInstallState({
status: 'runtime-values-running',
phase: 'registry-release-source',
lastAction: 'Creating Kubernetes runtime values and release selection',
updatedAt: nowIso()
}, stateFile);
if (!manifest) {
const failure = preflightFailure(
'registry-release-source',
'missing-release-manifest',
'Release selection did not include a resolved manifest.',
'resolveChannelMetadata must run (and succeed) before applyRuntimeValuesAndReleaseSelection.'
);
writeInstallState({ status: 'runtime-values-blocked', phase: 'registry-release-source', lastAction: failure.message, failure, updatedAt: nowIso() }, stateFile);
return failure;
}
const profile = manifest.valuesProfile || 'single-node';
const names = ['alga-core', 'pgbouncer', 'temporal', 'workflow-worker', 'email-service', 'temporal-worker'];
const values = {};
try {
fs.mkdirSync(valuesDir, { recursive: true, mode: 0o700 });
for (const name of names) {
const key = `${name}.${profile}.yaml`;
const value = options.profileValuesOverride?.[key] ?? manifest.profileValues?.[key];
if (typeof value !== 'string') {
throw new Error(`Release manifest is missing profileValues["${key}"].`);
}
values[key] = value;
}
const images = manifest.images || {};
const bootstrapMode = options.bootstrapMode || 'recover';
values[`alga-core.${profile}.yaml`] = setYamlScalar(values[`alga-core.${profile}.yaml`], ['bootstrap', 'mode'], yamlString(bootstrapMode));
const appUrl = appUrlFromInput(inputs.appHostname);
if (appUrl) {
values[`alga-core.${profile}.yaml`] = setYamlScalar(values[`alga-core.${profile}.yaml`], ['appUrl'], yamlString(appUrl));
values[`alga-core.${profile}.yaml`] = setYamlScalar(values[`alga-core.${profile}.yaml`], ['host'], yamlString(hostFromAppUrl(appUrl)));
values[`alga-core.${profile}.yaml`] = setYamlScalar(values[`alga-core.${profile}.yaml`], ['domainSuffix'], '""');
}
if (images.algaCore) {
values[`alga-core.${profile}.yaml`] = setYamlScalar(values[`alga-core.${profile}.yaml`], ['setup', 'image', 'tag'], yamlString(images.algaCore));
values[`alga-core.${profile}.yaml`] = setYamlScalar(values[`alga-core.${profile}.yaml`], ['server', 'image', 'tag'], yamlString(images.algaCore));
}
if (images.workflowWorker) {
values[`workflow-worker.${profile}.yaml`] = setYamlScalar(values[`workflow-worker.${profile}.yaml`], ['image', 'tag'], yamlString(images.workflowWorker));
}
if (images.emailService) {
values[`email-service.${profile}.yaml`] = setYamlScalar(values[`email-service.${profile}.yaml`], ['image', 'tag'], yamlString(images.emailService));
}
if (images.temporalWorker) {
values[`temporal-worker.${profile}.yaml`] = setYamlScalar(values[`temporal-worker.${profile}.yaml`], ['image', 'tag'], yamlString(images.temporalWorker));
}
for (const [key, content] of Object.entries(values)) {
fs.writeFileSync(path.join(valuesDir, key), content.endsWith('\n') ? content : `${content}\n`, { mode: 0o600 });
}
fs.writeFileSync(path.join(tempDir, 'kustomization.yaml'), `apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\ngeneratorOptions:\n disableNameSuffixHash: true\nconfigMapGenerator:\n${names.map((name) => ` - name: appliance-values-${name}\n namespace: alga-system\n files:\n - ${name}.${profile}.yaml=values/${name}.${profile}.yaml`).join('\n')}\n`, { mode: 0o600 });
} catch (error) {
const failure = preflightFailure(
'registry-release-source',
'render-runtime-values',
'Unable to render runtime values for the selected release.',
error instanceof Error ? error.message : String(error)
);
writeInstallState({ status: 'runtime-values-blocked', phase: 'registry-release-source', lastAction: failure.message, failure, updatedAt: nowIso() }, stateFile);
return failure;
}
const authKey = options.algaAuthKey || crypto.randomBytes(32).toString('base64url');
const statusToken = fs.existsSync(options.tokenFile || DEFAULT_TOKEN_FILE)
? fs.readFileSync(options.tokenFile || DEFAULT_TOKEN_FILE, 'utf8').trim()
: crypto.randomBytes(24).toString('base64url');
// ── Install-code redemption (registration-driven licensing) ────────────────
// When the operator entered an install code, redeem it now against the
// alga-license service: this yields the registry-minted tenant id (adopted as
// INITIAL_TENANT_ID), the edition, and — for paid tiers — the first license +
// per-appliance credential + check-in URL. A bad/expired/used code or an
// unreachable service blocks the install with a clear message (it never
// silently self-generates a tenant). NOTE: the code is single-use and consumed
// here, so a failed install past this point needs a re-issued code.
let redeemResult = null;
if (inputs.installCode) {
const serviceUrl = options.algaLicenseServiceUrl || process.env.ALGA_LICENSE_SERVICE_URL;
const applianceId = deriveApplianceId(inputs.appHostname);
const doRedeem = options.redeemInstallCode || redeemInstallCode;
try {
redeemResult = await doRedeem({ serviceUrl, installCode: inputs.installCode, applianceId });
} catch (error) {
const failure = preflightFailure(
'registry-release-source',
'redeem-install-code',
'Could not redeem the install code.',
error instanceof Error ? error.message : String(error)
);
// A bad/expired/used code is operator-correctable: stop auto-retrying (it
// will never succeed) so the control-plane keeps the setup form open for a
// re-issued code. Transient/network errors stay retry-safe (unflagged).
if (error && error.correctable) {
failure.correctable = true;
failure.retrySafe = false;
}
writeInstallState({ status: 'runtime-values-blocked', phase: 'registry-release-source', lastAction: failure.message, failure, updatedAt: nowIso() }, stateFile);
return failure;
}
}
const initialTenantSecretPath = path.join(tempDir, 'initial-tenant-secret.yaml');
const hasInitialTenant = Boolean(inputs.initialTenant);
if (hasInitialTenant) {
try {
fs.writeFileSync(initialTenantSecretPath, initialTenantSecretYaml(inputs.initialTenant, redeemResult?.tenantId || ''), { mode: 0o600 });
} catch (error) {
const failure = preflightFailure(
'registry-release-source',
'render-initial-tenant-secret',
'Unable to render initial tenant Secret for appliance bootstrap.',
error instanceof Error ? error.message : String(error)
);
writeInstallState({ status: 'runtime-values-blocked', phase: 'registry-release-source', lastAction: failure.message, failure, updatedAt: nowIso() }, stateFile);
return failure;
}
}
// Build appliance-license-seed secret command (F079-F080). When an install code
// was redeemed it drives the edition + license (+ connected-refresh fields);
// otherwise fall back to the manual editionChoice/licenseKey path (airgap).
const licenseSeedLiterals = redeemResult
? licenseSeedFromRedeem(redeemResult)
: { EDITION_CHOICE: inputs.editionChoice || 'ee', ...(inputs.licenseKey ? { LICENSE_TOKEN: inputs.licenseKey } : {}) };
const licenseSeedArgs = Object.entries(licenseSeedLiterals)
.filter(([, value]) => value !== undefined && value !== '')
.map(([key, value]) => `--from-literal=${key}=${shellQuote(value)}`)
.join(' ');
const licenseSeedCmd = `kubectl --kubeconfig ${shellQuote(kubeconfigPath)} -n msp create secret generic appliance-license-seed ${licenseSeedArgs} --dry-run=client -o yaml | kubectl --kubeconfig ${shellQuote(kubeconfigPath)} apply -f -`;
const commands = [
`kubectl --kubeconfig ${shellQuote(kubeconfigPath)} create namespace msp --dry-run=client -o yaml | kubectl --kubeconfig ${shellQuote(kubeconfigPath)} apply -f -`,
`kubectl --kubeconfig ${shellQuote(kubeconfigPath)} create namespace alga-system --dry-run=client -o yaml | kubectl --kubeconfig ${shellQuote(kubeconfigPath)} apply -f -`,
`kubectl --kubeconfig ${shellQuote(kubeconfigPath)} create namespace appliance-system --dry-run=client -o yaml | kubectl --kubeconfig ${shellQuote(kubeconfigPath)} apply -f -`,
...(hasInitialTenant ? [`kubectl --kubeconfig ${shellQuote(kubeconfigPath)} apply -f ${shellQuote(initialTenantSecretPath)}`] : []),
`kubectl --kubeconfig ${shellQuote(kubeconfigPath)} -n msp create secret generic alga-psa-shared --from-literal=ALGA_AUTH_KEY=${shellQuote(authKey)} --dry-run=client -o yaml | kubectl --kubeconfig ${shellQuote(kubeconfigPath)} apply -f -`,
`kubectl --kubeconfig ${shellQuote(kubeconfigPath)} -n appliance-system create secret generic appliance-status-auth --from-literal=token=${shellQuote(statusToken)} --dry-run=client -o yaml | kubectl --kubeconfig ${shellQuote(kubeconfigPath)} apply -f -`,
licenseSeedCmd,
`kubectl --kubeconfig ${shellQuote(kubeconfigPath)} apply -k ${shellQuote(tempDir)}`,
`kubectl --kubeconfig ${shellQuote(kubeconfigPath)} -n alga-system create configmap appliance-release-selection --from-literal=releaseVersion=${shellQuote(releaseVersion)} --from-literal=selectedChannel=${shellQuote(inputs.channel)} --from-literal=appVersion=${shellQuote(manifest.version)} --from-literal=registryHost=${shellQuote(releaseSelection.registryHost || DEFAULT_REGISTRY_HOST)} --from-literal=repository=${shellQuote(releaseSelection.repository || DEFAULT_RELEASE_REPOSITORY)} --from-literal=manifestDigest=${shellQuote(releaseSelection.manifestDigest || '')} --from-literal=algaCoreTag=${shellQuote(manifest.images?.algaCore || '')} --from-literal=workflowWorkerTag=${shellQuote(manifest.images?.workflowWorker || '')} --from-literal=emailServiceTag=${shellQuote(manifest.images?.emailService || '')} --from-literal=temporalWorkerTag=${shellQuote(manifest.images?.temporalWorker || '')} --from-literal=controlPlaneTag=${shellQuote(manifest.controlPlane || '')} --dry-run=client -o yaml | kubectl --kubeconfig ${shellQuote(kubeconfigPath)} apply -f -`
];
for (const command of commands) {
const result = runShell(command);
if (!result.ok) {
const failure = preflightFailure(
'registry-release-source',
'apply-runtime-values',
'Failed to apply Kubernetes runtime values or release selection.',
(result.stderr || result.stdout || `exit ${result.status}`).trim()
);
writeInstallState({ status: 'runtime-values-blocked', phase: 'registry-release-source', lastAction: failure.message, failure, updatedAt: nowIso() }, stateFile);
return failure;
}
}
const success = {
ok: true,
phase: 'registry-release-source',
message: `Runtime values and release selection applied for ${releaseVersion}.`,
releaseVersion,
profile
};
writeInstallState({
status: 'runtime-values-complete',
phase: 'registry-release-source',
lastAction: success.message,
runtimeValues: { profile, releaseVersion },
updatedAt: nowIso()
}, stateFile);
return success;
}
export function applyFluxSource(inputs, releaseSelection, options = {}) {
const stateFile = options.stateFile || DEFAULT_STATE_FILE;
const kubeconfigPath = options.kubeconfigPath || DEFAULT_KUBECONFIG;
const fluxPath = options.fluxPath || './base';
const sourceName = options.fluxSourceName || 'alga-appliance';
const sourceNamespace = options.fluxNamespace || 'flux-system';
const applyCommand = options.fluxSourceApplyCommand || `kubectl --kubeconfig ${kubeconfigPath} apply -f -`;
const releaseManifest = options.releaseManifestOverride
? validateReleaseManifest(options.releaseManifestOverride)
: releaseSelection.manifest;
if (!releaseManifest) {
const failure = preflightFailure(
'flux',
'missing-release-manifest',
'Release selection did not include a resolved manifest.',
'resolveChannelMetadata must run (and succeed) before applyFluxSource.'
);
writeInstallState({ status: 'flux-source-blocked', phase: 'flux', lastAction: failure.message, failure, updatedAt: nowIso() }, stateFile);
return failure;
}
const configRepository = releaseManifest.config.repository;
const configDigest = releaseManifest.config.digest;
const configTag = releaseManifest.config.tag;
const ociUrl = `oci://${configRepository}`;
// Flux pulls the appliance config bundle (the rendered flux base overlay) as
// an OCI artifact pinned to its digest -- no GitRepository, no branch.
const manifest = `apiVersion: source.toolkit.fluxcd.io/v1\nkind: OCIRepository\nmetadata:\n name: ${sourceName}\n namespace: ${sourceNamespace}\nspec:\n interval: 1m0s\n url: ${ociUrl}\n ref:\n digest: ${configDigest}\n---\napiVersion: kustomize.toolkit.fluxcd.io/v1\nkind: Kustomization\nmetadata:\n name: ${sourceName}\n namespace: ${sourceNamespace}\nspec:\n interval: 5m0s\n path: ${fluxPath}\n prune: true\n sourceRef:\n kind: OCIRepository\n name: ${sourceName}\n`;
writeInstallState({
status: 'flux-source-running',
phase: 'flux',
lastAction: 'Applying Flux OCIRepository/Kustomization source configuration',
updatedAt: nowIso()
}, stateFile);
const result = spawnSync('sh', ['-c', applyCommand], {
env: process.env,
input: manifest,
encoding: 'utf8'
});
if (result.status !== 0) {
const stderr = (result.stderr || '').trim();
const stdout = (result.stdout || '').trim();
const message = stderr || stdout || `Flux source apply failed with exit code ${result.status ?? 1}`;
const failure = preflightFailure(
'flux',
'apply-flux-source',
'Failed to apply Flux OCIRepository/Kustomization manifests.',
message
);
writeInstallState({
status: 'flux-source-blocked',
phase: 'flux',
lastAction: failure.message,
failure,
updatedAt: nowIso()
}, stateFile);
return failure;
}
const success = {
ok: true,
phase: 'flux',
message: 'Flux source manifests applied successfully.',
source: {
name: sourceName,
namespace: sourceNamespace,
url: ociUrl,
digest: configDigest,
tag: configTag,
path: fluxPath
}
};
writeInstallState({
status: 'flux-source-complete',
phase: 'flux',
lastAction: success.message,
fluxSource: success.source,
updatedAt: nowIso()
}, stateFile);
return success;
}
export function applyReleaseSelectionConfiguration(inputs, releaseSelection, options = {}) {
const stateFile = options.stateFile || DEFAULT_STATE_FILE;
const releaseSelectionFile = options.releaseSelectionFile || DEFAULT_RELEASE_SELECTION_FILE;
writeInstallState({
status: 'release-config-running',
phase: 'registry-release-source',
lastAction: 'Persisting runtime values and selected release configuration',
updatedAt: nowIso()
}, stateFile);
const payload = {
updatedAt: nowIso(),
selectedChannel: releaseSelection.channel || inputs.channel,
selectedReleaseVersion: releaseSelection.releaseVersion,
registryHost: releaseSelection.registryHost || DEFAULT_REGISTRY_HOST,
repository: releaseSelection.repository,
manifestDigest: releaseSelection.manifestDigest,
runtime: {
appHostname: inputs.appHostname,
dnsMode: inputs.dnsMode,
dnsServers: inputs.dnsServers
}
};
try {
writeSecureJsonFile(releaseSelectionFile, payload);
} catch (error) {
const failure = preflightFailure(
'registry-release-source',
'write-release-selection',
'Unable to persist release selection configuration.',
error instanceof Error ? error.message : String(error)
);
writeInstallState({
status: 'release-config-blocked',
phase: 'registry-release-source',
lastAction: failure.message,
failure,
updatedAt: nowIso()
}, stateFile);
return failure;
}
const success = {
ok: true,
phase: 'registry-release-source',
message: `Release selection persisted to ${releaseSelectionFile}.`,
releaseSelectionFile
};
writeInstallState({
status: 'release-config-complete',
phase: 'registry-release-source',
lastAction: success.message,
releaseSelection: {
file: releaseSelectionFile,
selectedChannel: payload.selectedChannel,
selectedReleaseVersion: payload.selectedReleaseVersion
},
updatedAt: nowIso()
}, stateFile);
return success;
}
export async function runSetupWorkflow(inputs, options = {}) {
const preflight = await runSetupPreflight(inputs, options);
if (!preflight.ok) {
return preflight;
}
// The k3s substrate and local-path storage are provisioned by the host
// bootstrap (bootstrap-control-plane.sh) before this control-plane workflow
// ever runs. The setup workflow only layers Flux and the application release
// on top of that substrate.
const fluxResult = installFlux(options);
if (!fluxResult.ok) {
return fluxResult;
}
const releaseSelection = await resolveChannelMetadata(inputs, options);
if (!releaseSelection.ok) {
return releaseSelection;
}
const runtimeValuesResult = await applyRuntimeValuesAndReleaseSelection(inputs, releaseSelection, { ...options, bootstrapMode: options.bootstrapMode || 'recover' });
if (!runtimeValuesResult.ok) {
return runtimeValuesResult;
}
const fluxSourceResult = applyFluxSource(inputs, releaseSelection, options);
if (!fluxSourceResult.ok) {
return fluxSourceResult;
}
const configResult = applyReleaseSelectionConfiguration(inputs, releaseSelection, options);
if (!configResult.ok) {
return configResult;
}
persistMaintenanceMetadata({
metadataFile: options.metadataFile,
releaseSelectionFile: options.releaseSelectionFile,
installStateFile: options.stateFile
});
return configResult;
}
function parseCliArgs(argv) {
const parsed = { command: argv[0] || '' };
for (let i = 1; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--setup-inputs') {
parsed.setupInputsFile = argv[i + 1];
i += 1;
} else if (arg === '--state-file') {
parsed.stateFile = argv[i + 1];
i += 1;
} else if (arg === '--kubeconfig') {
parsed.kubeconfigPath = argv[i + 1];
i += 1;
} else if (arg === '--release-selection-file') {
parsed.releaseSelectionFile = argv[i + 1];
i += 1;
}
}
return parsed;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const args = parseCliArgs(process.argv.slice(2));
if (args.command === 'run') {
const setupInputsFile = args.setupInputsFile || DEFAULT_SETUP_FILE;
try {
const raw = JSON.parse(fs.readFileSync(setupInputsFile, 'utf8'));
const inputs = validateSetupInputs(raw);
const result = await runSetupWorkflow(inputs, args);
process.stdout.write(`${JSON.stringify(result)}\n`);
if (!result.ok) process.exitCode = 1;
} catch (error) {
const failure = preflightFailure(
'setup',
'run-setup-workflow',
'Setup workflow failed before it could complete.',
error instanceof Error ? error.message : String(error)
);
writeInstallState({ status: 'setup-blocked', phase: 'setup', lastAction: failure.message, failure, updatedAt: nowIso() }, args.stateFile || DEFAULT_STATE_FILE);
process.stderr.write(`${JSON.stringify(failure)}\n`);
process.exitCode = 1;
}
}
}