clawdie-ai/setup/install-identity.ts

299 lines
9.8 KiB
TypeScript
Raw Normal View History

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';
import { logger } from '../src/logger.js';
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;
warn: (message: string) => void;
}
const DEFAULT_DEPS: InstallIdentityDeps = {
commandExists,
execFileSync,
existsSync: fs.existsSync,
readFileSync: fs.readFileSync,
warn: (message: string) => logger.warn(message),
};
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 {
// 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.
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.`,
);
}
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`,
);
}
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,
};
}