feat(install): persist root dataset identity metadata

---
Build: pass | Tests: pass — Tests  2005 passed (2005)

---
Build: pass | Tests: pass — Tests  2005 passed (2005)
This commit is contained in:
Operator & Codex 2026-04-27 10:58:26 +02:00
parent d53a1e018d
commit 7919327a8b
5 changed files with 566 additions and 0 deletions

View file

@ -0,0 +1,199 @@
import fs from 'fs';
import path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
ensurePlatformRootDatasetIdentity,
planPlatformRootDatasetIdentity,
resolvePlatformRootDataset,
} from './install-identity.js';
function makeProjectRoot(): string {
const base = path.join(process.cwd(), 'tmp', 'tests');
fs.mkdirSync(base, { recursive: true });
return fs.mkdtempSync(path.join(base, 'clawdie-install-identity-'));
}
function removeDir(dirPath: string): void {
fs.rmSync(dirPath, { recursive: true, force: true });
}
describe('install identity', () => {
const roots: string[] = [];
afterEach(() => {
while (roots.length > 0) removeDir(roots.pop()!);
});
it('resolves root dataset from system.env', () => {
const root = makeProjectRoot();
roots.push(root);
fs.writeFileSync(path.join(root, 'system.env'), 'ZFS_POOL=tank\nZFS_PREFIX=mevy-runtime\n');
expect(resolvePlatformRootDataset(root)).toEqual({
pool: 'tank',
prefix: 'mevy-runtime',
dataset: 'tank/mevy-runtime',
});
});
it('creates the root dataset and stamps install metadata', () => {
const root = makeProjectRoot();
roots.push(root);
fs.writeFileSync(
path.join(root, '.env'),
'ASSISTANT_NAME=Mevy\nTENANT_ID=mevy\nAGENT_DOMAIN=osa.home.arpa\nTELEGRAM_ADMIN_IDS=85126311\n',
);
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ version: '0.10.0' }));
fs.writeFileSync(
path.join(root, 'system.env'),
'ZFS_POOL=tank\nZFS_PREFIX=mevy-runtime\nZFS_LAYOUT=raidz1\nZFS_DATA_DISKS=3\nZFS_HOT_SPARES=1\n',
);
const exec = vi.fn((cmd: string, args: string[]) => {
if (cmd === 'zfs' && args[0] === 'list') throw new Error('missing');
if (cmd === 'zfs' && args[0] === 'get') throw new Error('missing');
if (cmd === 'git') return 'abcdef12\n';
return '';
});
const result = ensurePlatformRootDatasetIdentity(root, 'fresh', {
commandExists: (name) => name === 'zfs' || name === 'git',
execFileSync: exec as never,
existsSync: fs.existsSync,
readFileSync: fs.readFileSync,
});
expect(result?.dataset).toBe('tank/mevy-runtime');
expect(result?.installUuid).toBeTruthy();
expect(exec).toHaveBeenCalledWith(
'zfs',
['create', '-p', '-o', 'mountpoint=none', '-o', 'canmount=off', 'tank/mevy-runtime'],
expect.any(Object),
);
expect(exec).toHaveBeenCalledWith(
'zfs',
['set', expect.stringMatching(/^org\.clawdie:install-uuid=/), 'tank/mevy-runtime'],
expect.any(Object),
);
expect(exec).toHaveBeenCalledWith(
'zfs',
['set', 'org.clawdie:iso-release=v0.10.0', 'tank/mevy-runtime'],
expect.any(Object),
);
expect(exec).toHaveBeenCalledWith(
'zfs',
['set', 'org.clawdie:hostname=osa', 'tank/mevy-runtime'],
expect.any(Object),
);
expect(exec).toHaveBeenCalledWith(
'zfs',
['set', 'org.clawdie:tenant-id=mevy', 'tank/mevy-runtime'],
expect.any(Object),
);
expect(exec).toHaveBeenCalledWith(
'zfs',
['set', 'org.clawdie:zfs-layout=raidz1', 'tank/mevy-runtime'],
expect.any(Object),
);
});
it('plans identity changes without mutating', () => {
const root = makeProjectRoot();
roots.push(root);
fs.writeFileSync(
path.join(root, '.env'),
'ASSISTANT_NAME=Mevy\nAGENT_DOMAIN=osa.home.arpa\nTELEGRAM_ADMIN_IDS=85126311,12345678\n',
);
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ version: '0.10.0' }));
fs.writeFileSync(
path.join(root, 'system.env'),
'ZFS_POOL=tank\nZFS_PREFIX=mevy-runtime\nZFS_LAYOUT=raidz1\nZFS_DATA_DISKS=3\nZFS_HOT_SPARES=1\n',
);
fs.writeFileSync(
path.join(root, 'setup.txt'),
[
'OPENROUTER_API_KEY=test',
'TELEGRAM_BOT_TOKEN=test:token',
'TELEGRAM_ADMIN_ID=85126311',
'ASSISTANT_NAME=Mevy',
'HOSTNAME=osa-box',
'ZFS_LAYOUT=raidz1',
'ZFS_DATA_DISKS=3',
'ZFS_HOT_SPARES=1',
'',
].join('\n'),
);
const exec = vi.fn((cmd: string, args: string[]) => {
if (cmd === 'zfs' && args[0] === 'list') throw new Error('missing');
if (cmd === 'zfs' && args[0] === 'get') throw new Error('missing');
if (cmd === 'git') return 'abcdef12\n';
throw new Error('unexpected mutation path');
});
const plan = planPlatformRootDatasetIdentity(root, 'fresh', {
commandExists: (name) => name === 'zfs' || name === 'git',
execFileSync: exec as never,
existsSync: fs.existsSync,
readFileSync: fs.readFileSync,
});
expect(plan?.dataset).toBe('tank/mevy-runtime');
expect(plan?.datasetExists).toBe(false);
expect(plan?.createArgs).toEqual([
'create',
'-p',
'-o',
'mountpoint=none',
'-o',
'canmount=off',
'tank/mevy-runtime',
]);
expect(plan?.setCommands.some((entry) => entry.property === 'install-uuid')).toBe(
true,
);
expect(plan?.setCommands.some((entry) => entry.property === 'iso-release' && entry.value === 'v0.10.0')).toBe(true);
expect(plan?.desired['assistant-name']).toBe('Mevy');
expect(plan?.desired.hostname).toBe('osa-box');
expect(plan?.desired['tenant-id']).toBe('mevy');
expect(plan?.desired['telegram-admin-hash']).toMatch(/^[0-9a-f]{16}$/u);
expect(plan?.desired['zfs-layout']).toBe('raidz1');
expect(plan?.desired['zfs-data-disks']).toBe(3);
expect(plan?.desired['zfs-hot-spares']).toBe(1);
});
it('refuses upgrade when persisted identity mismatches the requested setup', () => {
const root = makeProjectRoot();
roots.push(root);
fs.writeFileSync(
path.join(root, '.env'),
'ASSISTANT_NAME=Mevy\nTENANT_ID=mevy\nAGENT_INTERNAL_DOMAIN=mevy.home.arpa\n',
);
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ version: '0.10.0' }));
fs.writeFileSync(
path.join(root, 'system.env'),
'ZFS_POOL=tank\nZFS_PREFIX=mevy-runtime\nZFS_LAYOUT=raidz1\nZFS_DATA_DISKS=3\nZFS_HOT_SPARES=0\n',
);
const exec = vi.fn((cmd: string, args: string[]) => {
if (cmd !== 'zfs') throw new Error('unexpected command');
if (args[0] === 'list') return 'tank/mevy-runtime\n';
if (args[0] === 'get') {
if (args[4] === 'org.clawdie:assistant-name') return 'Other\n';
return '-\n';
}
throw new Error('unexpected mutation path');
});
expect(() =>
ensurePlatformRootDatasetIdentity(root, 'upgrade', {
commandExists: (name) => name === 'zfs',
execFileSync: exec as never,
existsSync: fs.existsSync,
readFileSync: fs.readFileSync,
}),
).toThrow(/upgrade refused/);
});
});

286
setup/install-identity.ts Normal file
View file

@ -0,0 +1,286 @@
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 {
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;
}
const DEFAULT_DEPS: InstallIdentityDeps = {
commandExists,
execFileSync,
existsSync: fs.existsSync,
readFileSync: fs.readFileSync,
};
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 {
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 (!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',
'zfs-layout',
'zfs-data-disks',
'zfs-hot-spares',
]);
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,
};
}

View file

@ -45,6 +45,7 @@ describe('detectExistingInstall', () => {
expect(detection.existing).toBe(true);
expect(detection.softSignals).toEqual(['env', 'groups']);
expect(detection.strongSignals).toEqual([]);
expect(detection.installUuid).toBeNull();
});
it('treats a strong signal as an existing install', () => {
@ -62,6 +63,7 @@ describe('detectExistingInstall', () => {
expect(detection.existing).toBe(true);
expect(detection.strongSignals).toContain('service');
expect(detection.installUuid).toBeNull();
});
it('prefers system.env zfs pool/prefix when matching datasets', () => {
@ -83,6 +85,31 @@ describe('detectExistingInstall', () => {
expect(detection.existing).toBe(true);
expect(detection.strongSignals).toContain('zfs');
expect(detection.rootDataset).toBe('tank/mevy-runtime');
});
it('treats persisted zfs install metadata as a strong signal', () => {
const root = makeProjectRoot();
roots.push(root);
fs.writeFileSync(
path.join(root, 'system.env'),
['ZFS_POOL=tank', 'ZFS_PREFIX=mevy-runtime', ''].join('\n'),
);
const detection = detectExistingInstall(root, {
existsSync: (target) => fs.existsSync(target) && target !== '/usr/local/etc/rc.d/mevy',
commandExists: (name) => name === 'zfs',
execFileSync: ((cmd: string, args: string[]) => {
if (cmd !== 'zfs') throw new Error('unexpected command');
if (args[0] === 'list') return '';
if (args[0] === 'get') return 'abc123\n';
throw new Error('unexpected zfs args');
}) as typeof import('child_process').execFileSync,
});
expect(detection.existing).toBe(true);
expect(detection.strongSignals).toContain('zfs-metadata');
expect(detection.installUuid).toBe('abc123');
});
it('returns fresh when no signals are present', () => {
@ -108,11 +135,14 @@ describe('resolveInstallMode', () => {
groupsDir: false,
serviceFile: false,
zfsDataset: false,
zfsMetadata: false,
runtimeUser: false,
},
strongSignals: [],
softSignals: [],
existing: false,
rootDataset: null,
installUuid: null,
};
const existingDetection = {
@ -121,11 +151,14 @@ describe('resolveInstallMode', () => {
groupsDir: true,
serviceFile: false,
zfsDataset: true,
zfsMetadata: false,
runtimeUser: false,
},
strongSignals: ['zfs'],
softSignals: ['env', 'groups'],
existing: true,
rootDataset: 'zroot/clawdie-runtime',
installUuid: null,
};
it('resolves auto to fresh when nothing exists', () => {

View file

@ -8,14 +8,17 @@ import {
ZFS_PREFIX,
} from '../src/config.js';
import type { FirstBootInstallMode } from './first-boot.js';
import { resolvePlatformRootDataset } from './install-identity.js';
import { commandExists } from './platform.js';
import { loadSystemEnv } from './system-env.js';
import { getZfsMetadata } from './zfs-metadata.js';
export interface ExistingInstallSignals {
envFile: boolean;
groupsDir: boolean;
serviceFile: boolean;
zfsDataset: boolean;
zfsMetadata: boolean;
runtimeUser: boolean;
}
@ -24,6 +27,8 @@ export interface ExistingInstallDetection {
strongSignals: string[];
softSignals: string[];
existing: boolean;
rootDataset: string | null;
installUuid: string | null;
}
export interface ResolvedInstallMode {
@ -119,6 +124,25 @@ function hasZfsDataset(projectRoot: string, deps: DetectorDeps): boolean {
}
}
function getZfsInstallUuid(
projectRoot: string,
deps: DetectorDeps,
): { rootDataset: string | null; installUuid: string | null } {
if (!deps.commandExists('zfs')) {
return { rootDataset: null, installUuid: null };
}
try {
const root = resolvePlatformRootDataset(projectRoot, deps);
const metadata = getZfsMetadata(root.dataset, ['install-uuid'], deps);
return {
rootDataset: root.dataset,
installUuid: metadata['install-uuid'],
};
} catch {
return { rootDataset: null, installUuid: null };
}
}
export function detectExistingInstall(
projectRoot: string,
deps: Partial<DetectorDeps> = {},
@ -129,14 +153,18 @@ export function detectExistingInstall(
groupsDir: hasNonEmptyGroupsDir(projectRoot, resolvedDeps),
serviceFile: hasInstalledService(resolvedDeps),
zfsDataset: hasZfsDataset(projectRoot, resolvedDeps),
zfsMetadata: false,
runtimeUser: hasRuntimeUser(resolvedDeps),
};
const zfsIdentity = getZfsInstallUuid(projectRoot, resolvedDeps);
signals.zfsMetadata = Boolean(zfsIdentity.installUuid);
const strongSignals: string[] = [];
const softSignals: string[] = [];
if (signals.serviceFile) strongSignals.push('service');
if (signals.zfsDataset) strongSignals.push('zfs');
if (signals.zfsMetadata) strongSignals.push('zfs-metadata');
if (signals.runtimeUser) strongSignals.push('runtime-user');
if (signals.envFile) softSignals.push('env');
if (signals.groupsDir) softSignals.push('groups');
@ -148,6 +176,8 @@ export function detectExistingInstall(
strongSignals,
softSignals,
existing,
rootDataset: zfsIdentity.rootDataset,
installUuid: zfsIdentity.installUuid,
};
}

View file

@ -24,6 +24,7 @@ import { auditEnvFile } from './env-audit.js';
import { PLATFORM_SERVICE_NAME } from '../src/config.js';
import { SOCKET_PATH as HOSTD_SOCKET_PATH } from '../src/hostd/types.js';
import type { FirstBootInstallMode } from './first-boot.js';
import { ensurePlatformRootDatasetIdentity } from './install-identity.js';
import { detectExistingInstall, resolveInstallMode } from './install-mode.js';
// ── Types ─────────────────────────────────────────────────────────────────────
@ -699,6 +700,23 @@ export async function run(argv: string[]): Promise<void> {
if (exitCode === 0) {
let snapshotTaken: string | undefined;
if (step.name === 'onboarding') {
try {
const identity = ensurePlatformRootDatasetIdentity(
projectRoot,
resolvedInstallMode.effective,
);
if (identity) {
logger.info(identity, 'Ensured platform root dataset identity');
}
} catch (error) {
logger.warn(
{ error: error instanceof Error ? error.message : String(error) },
'Platform root dataset identity initialization failed',
);
}
}
// Snapshot after successful step if configured and dataset known
if (step.snapshot && bastilleDataset) {
const tag = `${step.snapshot}-${Date.now()}`;