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:
parent
d53a1e018d
commit
7919327a8b
5 changed files with 566 additions and 0 deletions
199
setup/install-identity.test.ts
Normal file
199
setup/install-identity.test.ts
Normal 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
286
setup/install-identity.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}`;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue