2026-04-27 10:58:26 +02:00
|
|
|
import { execFileSync } from 'child_process';
|
|
|
|
|
import { createHash, randomUUID } from 'crypto';
|
|
|
|
|
import fs from 'fs';
|
|
|
|
|
import path from 'path';
|
|
|
|
|
|
|
|
|
|
import { ASSISTANT_NAME, TENANT_ID, ZFS_PREFIX } from '../src/config.js';
|
2026-04-27 12:16:46 +02:00
|
|
|
import { logger } from '../src/logger.js';
|
2026-04-27 10:58:26 +02:00
|
|
|
import {
|
|
|
|
|
FIRST_BOOT_SETUP_SCHEMA_VERSION,
|
|
|
|
|
parseFirstBootConfig,
|
|
|
|
|
} from './first-boot.js';
|
|
|
|
|
import { commandExists } from './platform.js';
|
|
|
|
|
import { extractEnvValue } from './profile.js';
|
|
|
|
|
import { SYSTEM_ENV_SCHEMA_VERSION, loadSystemEnv } from './system-env.js';
|
|
|
|
|
import { getZfsMetadata, setZfsMetadata } from './zfs-metadata.js';
|
|
|
|
|
|
|
|
|
|
interface InstallIdentityDeps {
|
|
|
|
|
commandExists: (name: string) => boolean;
|
|
|
|
|
execFileSync: typeof execFileSync;
|
|
|
|
|
existsSync: (filePath: string) => boolean;
|
|
|
|
|
readFileSync: typeof fs.readFileSync;
|
2026-04-27 12:16:46 +02:00
|
|
|
warn: (message: string) => void;
|
2026-04-27 10:58:26 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const DEFAULT_DEPS: InstallIdentityDeps = {
|
|
|
|
|
commandExists,
|
|
|
|
|
execFileSync,
|
|
|
|
|
existsSync: fs.existsSync,
|
|
|
|
|
readFileSync: fs.readFileSync,
|
2026-04-27 12:16:46 +02:00
|
|
|
warn: (message: string) => logger.warn(message),
|
2026-04-27 10:58:26 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export interface PlatformRootDataset {
|
|
|
|
|
pool: string;
|
|
|
|
|
prefix: string;
|
|
|
|
|
dataset: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface InstallIdentityPlan {
|
|
|
|
|
dataset: string;
|
|
|
|
|
datasetExists: boolean;
|
|
|
|
|
installUuid: string;
|
|
|
|
|
current: Record<string, string | null>;
|
|
|
|
|
desired: Record<string, string | number | null>;
|
|
|
|
|
mismatches: Array<{ property: string; current: string; desired: string }>;
|
|
|
|
|
createArgs: string[] | null;
|
|
|
|
|
setCommands: Array<{ property: string; value: string }>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const IDENTITY_METADATA_KEYS = [
|
|
|
|
|
'install-uuid',
|
|
|
|
|
'setup-schema',
|
|
|
|
|
'system-schema',
|
|
|
|
|
'iso-release',
|
|
|
|
|
'iso-commit',
|
|
|
|
|
'installed-at',
|
|
|
|
|
'assistant-name',
|
|
|
|
|
'hostname',
|
|
|
|
|
'tenant-id',
|
|
|
|
|
'telegram-admin-hash',
|
|
|
|
|
'install-mode',
|
|
|
|
|
'zfs-layout',
|
|
|
|
|
'zfs-data-disks',
|
|
|
|
|
'zfs-hot-spares',
|
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
function tryLoadFirstBootConfig(
|
|
|
|
|
projectRoot: string,
|
|
|
|
|
deps: InstallIdentityDeps,
|
|
|
|
|
): ReturnType<typeof parseFirstBootConfig> | null {
|
2026-04-27 12:16:46 +02:00
|
|
|
// On the ISO firstboot path, setup.txt may remain on the writable FAT32
|
|
|
|
|
// config partition rather than being copied into projectRoot. In that case
|
|
|
|
|
// we intentionally fall back to values already materialized into .env by the
|
|
|
|
|
// shell bridge instead of treating the missing local file as fatal.
|
2026-04-27 10:58:26 +02:00
|
|
|
const setupFile = path.join(projectRoot, 'setup.txt');
|
|
|
|
|
if (!deps.existsSync(setupFile)) return null;
|
|
|
|
|
try {
|
|
|
|
|
return parseFirstBootConfig(deps.readFileSync(setupFile, 'utf-8'));
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function deriveHostname(
|
|
|
|
|
setupConfig: ReturnType<typeof parseFirstBootConfig> | null,
|
|
|
|
|
envContent: string,
|
|
|
|
|
): string | null {
|
|
|
|
|
if (setupConfig?.hostname) return setupConfig.hostname;
|
|
|
|
|
for (const key of ['AGENT_INTERNAL_DOMAIN', 'AGENT_DOMAIN'] as const) {
|
|
|
|
|
const fromDomain = extractEnvValue(envContent, key) || '';
|
|
|
|
|
if (!fromDomain.trim()) continue;
|
|
|
|
|
const parts = fromDomain.split('.').map((part) => part.trim().toLowerCase()).filter(Boolean);
|
|
|
|
|
if (parts.length >= 3 || (parts.length >= 2 && parts[0] !== 'home')) {
|
|
|
|
|
return parts[0] || null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function deriveTenantId(envContent: string): string | null {
|
|
|
|
|
return extractEnvValue(envContent, 'TENANT_ID') || TENANT_ID || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function deriveAssistantName(
|
|
|
|
|
setupConfig: ReturnType<typeof parseFirstBootConfig> | null,
|
|
|
|
|
envContent: string,
|
|
|
|
|
): string | null {
|
|
|
|
|
return (
|
|
|
|
|
setupConfig?.assistantName ||
|
|
|
|
|
extractEnvValue(envContent, 'ASSISTANT_NAME') ||
|
|
|
|
|
ASSISTANT_NAME ||
|
|
|
|
|
null
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function deriveTelegramAdminHash(
|
|
|
|
|
setupConfig: ReturnType<typeof parseFirstBootConfig> | null,
|
|
|
|
|
envContent: string,
|
|
|
|
|
): string | null {
|
|
|
|
|
const setupAdmin = setupConfig?.telegramAdminId
|
|
|
|
|
? String(setupConfig.telegramAdminId)
|
|
|
|
|
: '';
|
|
|
|
|
const envAdmins =
|
|
|
|
|
extractEnvValue(envContent, 'TELEGRAM_ADMIN_IDS') ||
|
|
|
|
|
extractEnvValue(envContent, 'TELEGRAM_ADMIN_ID') ||
|
|
|
|
|
'';
|
|
|
|
|
const raw = setupAdmin || envAdmins;
|
|
|
|
|
if (!raw.trim()) return null;
|
|
|
|
|
return createHash('sha256').update(raw.trim(), 'utf-8').digest('hex').slice(0, 16);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function currentIsoRelease(projectRoot: string, deps: InstallIdentityDeps): string {
|
|
|
|
|
try {
|
|
|
|
|
const packageJson = JSON.parse(
|
|
|
|
|
deps.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8'),
|
|
|
|
|
) as { version?: string };
|
|
|
|
|
const version = String(packageJson.version || '').trim();
|
|
|
|
|
if (version) return `v${version.replace(/^v/u, '')}`;
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
return 'v0.0.0';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function currentIsoCommit(projectRoot: string, deps: InstallIdentityDeps): string | null {
|
|
|
|
|
if (!deps.commandExists('git')) return null;
|
|
|
|
|
try {
|
|
|
|
|
const out = deps.execFileSync(
|
|
|
|
|
'git',
|
|
|
|
|
['-C', projectRoot, 'rev-parse', '--short=8', 'HEAD'],
|
|
|
|
|
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
|
|
|
);
|
|
|
|
|
return out.trim() || null;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function datasetExists(dataset: string, deps: InstallIdentityDeps): boolean {
|
|
|
|
|
try {
|
|
|
|
|
deps.execFileSync('zfs', ['list', '-H', '-o', 'name', dataset], {
|
|
|
|
|
stdio: ['ignore', 'ignore', 'ignore'],
|
|
|
|
|
});
|
|
|
|
|
return true;
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function resolvePlatformRootDataset(
|
|
|
|
|
projectRoot: string = process.cwd(),
|
|
|
|
|
deps: Partial<InstallIdentityDeps> = {},
|
|
|
|
|
): PlatformRootDataset {
|
|
|
|
|
const resolvedDeps = { ...DEFAULT_DEPS, ...deps };
|
|
|
|
|
const systemEnv = loadSystemEnv(projectRoot, resolvedDeps);
|
|
|
|
|
const pool = systemEnv.zfsPool || 'zroot';
|
|
|
|
|
const prefix = systemEnv.zfsPrefix || ZFS_PREFIX;
|
|
|
|
|
return { pool, prefix, dataset: `${pool}/${prefix}` };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function ensurePlatformRootDatasetIdentity(
|
|
|
|
|
projectRoot: string = process.cwd(),
|
|
|
|
|
installMode: 'fresh' | 'upgrade' | 'rescue' = 'fresh',
|
|
|
|
|
deps: Partial<InstallIdentityDeps> = {},
|
|
|
|
|
): { dataset: string; installUuid: string } | null {
|
|
|
|
|
const resolvedDeps = { ...DEFAULT_DEPS, ...deps };
|
|
|
|
|
if (!resolvedDeps.commandExists('zfs')) return null;
|
|
|
|
|
|
|
|
|
|
const plan = planPlatformRootDatasetIdentity(
|
|
|
|
|
projectRoot,
|
|
|
|
|
installMode,
|
|
|
|
|
resolvedDeps,
|
|
|
|
|
);
|
|
|
|
|
if (!plan) return null;
|
|
|
|
|
if (installMode === 'upgrade' && plan.mismatches.length > 0) {
|
|
|
|
|
const details = plan.mismatches
|
|
|
|
|
.map(({ property, current, desired }) => `${property}: current=${current} desired=${desired}`)
|
|
|
|
|
.join(', ');
|
|
|
|
|
throw new Error(
|
|
|
|
|
`upgrade refused: persisted dataset identity on ${plan.dataset} does not match requested setup (${details}). Use rescue to inspect and repair first.`,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-27 12:16:46 +02:00
|
|
|
if (installMode === 'rescue' && plan.mismatches.length > 0) {
|
|
|
|
|
const details = plan.mismatches
|
|
|
|
|
.map(({ property, current, desired }) => `${property}: persisted=${current} requested=${desired}`)
|
|
|
|
|
.join(', ');
|
|
|
|
|
resolvedDeps.warn(
|
|
|
|
|
`rescue mode: persisted identity differs from requested setup (${details}) — continuing`,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-27 10:58:26 +02:00
|
|
|
|
|
|
|
|
if (!plan.datasetExists && plan.createArgs) {
|
|
|
|
|
resolvedDeps.execFileSync(
|
|
|
|
|
'zfs',
|
|
|
|
|
plan.createArgs,
|
|
|
|
|
{ stdio: ['ignore', 'ignore', 'pipe'] },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setZfsMetadata(plan.dataset, plan.desired, resolvedDeps);
|
|
|
|
|
|
|
|
|
|
return { dataset: plan.dataset, installUuid: plan.installUuid };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function planPlatformRootDatasetIdentity(
|
|
|
|
|
projectRoot: string = process.cwd(),
|
|
|
|
|
installMode: 'fresh' | 'upgrade' | 'rescue' = 'fresh',
|
|
|
|
|
deps: Partial<InstallIdentityDeps> = {},
|
|
|
|
|
): InstallIdentityPlan | null {
|
|
|
|
|
const resolvedDeps = { ...DEFAULT_DEPS, ...deps };
|
|
|
|
|
if (!resolvedDeps.commandExists('zfs')) return null;
|
|
|
|
|
|
|
|
|
|
const root = resolvePlatformRootDataset(projectRoot, resolvedDeps);
|
|
|
|
|
const exists = datasetExists(root.dataset, resolvedDeps);
|
|
|
|
|
const current = getZfsMetadata(root.dataset, [...IDENTITY_METADATA_KEYS], resolvedDeps);
|
|
|
|
|
const setupConfig = tryLoadFirstBootConfig(projectRoot, resolvedDeps);
|
|
|
|
|
const systemEnv = loadSystemEnv(projectRoot, resolvedDeps);
|
|
|
|
|
const envFile = path.join(projectRoot, '.env');
|
|
|
|
|
const envContent = resolvedDeps.existsSync(envFile)
|
|
|
|
|
? resolvedDeps.readFileSync(envFile, 'utf-8')
|
|
|
|
|
: '';
|
|
|
|
|
const installUuid = current['install-uuid'] || randomUUID();
|
|
|
|
|
const timestamp = new Date().toISOString();
|
|
|
|
|
|
|
|
|
|
const desired: Record<string, string | number | null> = {
|
|
|
|
|
'install-uuid': installUuid,
|
|
|
|
|
'setup-schema': String(FIRST_BOOT_SETUP_SCHEMA_VERSION),
|
|
|
|
|
'system-schema': String(SYSTEM_ENV_SCHEMA_VERSION),
|
|
|
|
|
'iso-release': setupConfig?.isoRelease || currentIsoRelease(projectRoot, resolvedDeps),
|
|
|
|
|
'iso-commit': setupConfig?.isoGitCommit || currentIsoCommit(projectRoot, resolvedDeps),
|
|
|
|
|
'installed-at': current['installed-at'] || timestamp,
|
|
|
|
|
'assistant-name': deriveAssistantName(setupConfig, envContent),
|
|
|
|
|
hostname: deriveHostname(setupConfig, envContent),
|
|
|
|
|
'tenant-id': deriveTenantId(envContent),
|
|
|
|
|
'telegram-admin-hash': deriveTelegramAdminHash(setupConfig, envContent),
|
|
|
|
|
'install-mode': installMode,
|
|
|
|
|
'zfs-layout': setupConfig?.zfsLayout || systemEnv.zfsLayout || null,
|
|
|
|
|
'zfs-data-disks':
|
|
|
|
|
setupConfig?.zfsDataDisks ?? systemEnv.zfsDataDisks ?? null,
|
|
|
|
|
'zfs-hot-spares':
|
|
|
|
|
setupConfig?.zfsHotSpares ?? systemEnv.zfsHotSpares ?? null,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const setCommands = Object.entries(desired)
|
|
|
|
|
.filter(([, value]) => value)
|
|
|
|
|
.filter(([key, value]) => current[key] !== value)
|
|
|
|
|
.map(([property, value]) => ({ property, value: String(value) }));
|
|
|
|
|
const mismatchKeys = new Set([
|
|
|
|
|
'assistant-name',
|
|
|
|
|
'hostname',
|
|
|
|
|
'tenant-id',
|
|
|
|
|
'telegram-admin-hash',
|
|
|
|
|
]);
|
|
|
|
|
const mismatches = Object.entries(desired)
|
|
|
|
|
.filter(([key, value]) => mismatchKeys.has(key) && value !== null && value !== undefined)
|
|
|
|
|
.map(([property, value]) => ({
|
|
|
|
|
property,
|
|
|
|
|
current: current[property],
|
|
|
|
|
desired: String(value),
|
|
|
|
|
}))
|
|
|
|
|
.filter(
|
|
|
|
|
(entry): entry is { property: string; current: string; desired: string } =>
|
|
|
|
|
Boolean(entry.current) && entry.current !== entry.desired,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
dataset: root.dataset,
|
|
|
|
|
datasetExists: exists,
|
|
|
|
|
installUuid,
|
|
|
|
|
current,
|
|
|
|
|
desired,
|
|
|
|
|
mismatches,
|
|
|
|
|
createArgs: exists
|
|
|
|
|
? null
|
|
|
|
|
: ['create', '-p', '-o', 'mountpoint=none', '-o', 'canmount=off', root.dataset],
|
|
|
|
|
setCommands,
|
|
|
|
|
};
|
|
|
|
|
}
|