245 lines
7.3 KiB
TypeScript
245 lines
7.3 KiB
TypeScript
import { execFileSync } from 'child_process';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
import type { FirstBootZfsLayout } from './first-boot.js';
|
|
import { commandExists } from './platform.js';
|
|
import { normalizeResourceId } from '../src/platform-layout.js';
|
|
|
|
export interface SystemEnvConfig {
|
|
systemSchemaVersion: number;
|
|
networkExternalIf: string | null;
|
|
networkInternalIf: string | null;
|
|
tailscaleIf: string | null;
|
|
zfsPool: string | null;
|
|
zfsLayout: FirstBootZfsLayout | null;
|
|
zfsDataDisks: number | null;
|
|
zfsHotSpares: number | null;
|
|
zfsDisks: string[];
|
|
zfsSpareDisks: string[];
|
|
zfsPrefix: string | null;
|
|
gpuDevice: string | null;
|
|
sndDevice: string | null;
|
|
}
|
|
|
|
export interface ResolvedSystemEnv extends SystemEnvConfig {
|
|
detected: {
|
|
networkExternalIf: boolean;
|
|
networkInternalIf: boolean;
|
|
tailscaleIf: boolean;
|
|
gpuDevice: boolean;
|
|
sndDevice: boolean;
|
|
};
|
|
}
|
|
|
|
interface SystemEnvDeps {
|
|
commandExists: (name: string) => boolean;
|
|
execFileSync: typeof execFileSync;
|
|
existsSync: (filePath: string) => boolean;
|
|
readFileSync: typeof fs.readFileSync;
|
|
}
|
|
|
|
const DEFAULT_DEPS: SystemEnvDeps = {
|
|
commandExists,
|
|
execFileSync,
|
|
existsSync: fs.existsSync,
|
|
readFileSync: fs.readFileSync,
|
|
};
|
|
|
|
export const SYSTEM_ENV_SCHEMA_VERSION = 1;
|
|
|
|
function parseScalar(content: string, key: string): string | null {
|
|
const match = content.match(new RegExp(`^${key}=(.*)$`, 'm'));
|
|
if (!match) return null;
|
|
const raw = match[1].trim().replace(/^['"]|['"]$/gu, '');
|
|
return raw || null;
|
|
}
|
|
|
|
function parseInteger(content: string, key: string): number | null {
|
|
const value = parseScalar(content, key);
|
|
if (!value) return null;
|
|
if (!/^\d+$/u.test(value)) return null;
|
|
return Number.parseInt(value, 10);
|
|
}
|
|
|
|
function parseCsv(content: string, key: string): string[] {
|
|
const value = parseScalar(content, key);
|
|
if (!value) return [];
|
|
return value
|
|
.split(',')
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function parseLayout(content: string): FirstBootZfsLayout | null {
|
|
const value = (parseScalar(content, 'ZFS_LAYOUT') || '').toLowerCase();
|
|
if (!value) return null;
|
|
if (
|
|
value === 'single' ||
|
|
value === 'mirror' ||
|
|
value === 'raidz1' ||
|
|
value === 'raidz2'
|
|
) {
|
|
return value;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function loadSystemEnv(
|
|
projectRoot: string = process.cwd(),
|
|
deps: Partial<SystemEnvDeps> = {},
|
|
): SystemEnvConfig {
|
|
const resolvedDeps = { ...DEFAULT_DEPS, ...deps };
|
|
const systemEnvFile = path.join(projectRoot, 'system.env');
|
|
if (!resolvedDeps.existsSync(systemEnvFile)) {
|
|
return {
|
|
systemSchemaVersion: SYSTEM_ENV_SCHEMA_VERSION,
|
|
networkExternalIf: null,
|
|
networkInternalIf: null,
|
|
tailscaleIf: null,
|
|
zfsPool: null,
|
|
zfsLayout: null,
|
|
zfsDataDisks: null,
|
|
zfsHotSpares: null,
|
|
zfsDisks: [],
|
|
zfsSpareDisks: [],
|
|
zfsPrefix: null,
|
|
gpuDevice: null,
|
|
sndDevice: null,
|
|
};
|
|
}
|
|
|
|
const content = resolvedDeps.readFileSync(systemEnvFile, 'utf-8');
|
|
const parsedSchema = parseInteger(content, 'SYSTEM_SCHEMA_VERSION');
|
|
if (parsedSchema !== null && parsedSchema !== SYSTEM_ENV_SCHEMA_VERSION) {
|
|
throw new Error(
|
|
`system.env SYSTEM_SCHEMA_VERSION=${parsedSchema} is not supported. Expected ${SYSTEM_ENV_SCHEMA_VERSION}.`,
|
|
);
|
|
}
|
|
return {
|
|
systemSchemaVersion: parsedSchema ?? SYSTEM_ENV_SCHEMA_VERSION,
|
|
networkExternalIf: parseScalar(content, 'NETWORK_EXTERNAL_IF'),
|
|
networkInternalIf: parseScalar(content, 'NETWORK_INTERNAL_IF'),
|
|
tailscaleIf: parseScalar(content, 'TAILSCALE_IF'),
|
|
zfsPool: parseScalar(content, 'ZFS_POOL'),
|
|
zfsLayout: parseLayout(content),
|
|
zfsDataDisks: parseInteger(content, 'ZFS_DATA_DISKS'),
|
|
zfsHotSpares: parseInteger(content, 'ZFS_HOT_SPARES'),
|
|
zfsDisks: parseCsv(content, 'ZFS_DISKS'),
|
|
zfsSpareDisks: parseCsv(content, 'ZFS_SPARE_DISKS'),
|
|
zfsPrefix: (() => {
|
|
const raw = parseScalar(content, 'ZFS_PREFIX');
|
|
return raw ? normalizeResourceId(raw) || null : null;
|
|
})(),
|
|
gpuDevice: parseScalar(content, 'GPU_DEVICE'),
|
|
sndDevice: parseScalar(content, 'SND_DEVICE'),
|
|
};
|
|
}
|
|
|
|
function detectDefaultRouteInterface(deps: SystemEnvDeps): string | null {
|
|
if (!deps.commandExists('route')) return null;
|
|
try {
|
|
const out = deps.execFileSync('route', ['-n', 'get', 'default'], {
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
});
|
|
return out.match(/^\s*interface:\s*(\S+)/mu)?.[1]?.trim() || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function detectInterfaces(deps: SystemEnvDeps): string[] {
|
|
if (!deps.commandExists('ifconfig')) return [];
|
|
try {
|
|
const out = deps.execFileSync('ifconfig', ['-l'], {
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
});
|
|
return out
|
|
.trim()
|
|
.split(/\s+/u)
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function detectInternalIf(deps: SystemEnvDeps): string | null {
|
|
const interfaces = detectInterfaces(deps);
|
|
if (interfaces.includes('warden0')) return 'warden0';
|
|
const bridge = interfaces.find((name) => /^bridge\d+$/u.test(name));
|
|
return bridge || null;
|
|
}
|
|
|
|
function detectTailscaleIf(deps: SystemEnvDeps): string | null {
|
|
const interfaces = detectInterfaces(deps);
|
|
return interfaces.includes('tailscale0') ? 'tailscale0' : null;
|
|
}
|
|
|
|
function detectGpuDevice(deps: SystemEnvDeps): string | null {
|
|
if (deps.existsSync('/dev/drm0')) return 'drm0';
|
|
if (deps.existsSync('/dev/dri/card0')) return 'drm0';
|
|
if (!deps.commandExists('pciconf')) return null;
|
|
try {
|
|
const out = deps.execFileSync('pciconf', ['-lv'], {
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
});
|
|
if (/(VGA|display)/iu.test(out)) {
|
|
return 'auto-gpu';
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function detectSndDevice(deps: SystemEnvDeps): string | null {
|
|
if (deps.existsSync('/dev/pcm0')) return 'pcm0';
|
|
if (deps.commandExists('cat') && deps.existsSync('/dev/sndstat')) {
|
|
try {
|
|
const out = deps.execFileSync('cat', ['/dev/sndstat'], {
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
});
|
|
const match = out.match(/\b(pcm\d+)\b/u);
|
|
return match?.[1] || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function resolveSystemEnv(
|
|
projectRoot: string = process.cwd(),
|
|
deps: Partial<SystemEnvDeps> = {},
|
|
): ResolvedSystemEnv {
|
|
const resolvedDeps = { ...DEFAULT_DEPS, ...deps };
|
|
const loaded = loadSystemEnv(projectRoot, resolvedDeps);
|
|
|
|
const detectedExternal = !loaded.networkExternalIf;
|
|
const detectedInternal = !loaded.networkInternalIf;
|
|
const detectedTailscale = !loaded.tailscaleIf;
|
|
const detectedGpu = !loaded.gpuDevice;
|
|
const detectedSnd = !loaded.sndDevice;
|
|
|
|
return {
|
|
...loaded,
|
|
networkExternalIf:
|
|
loaded.networkExternalIf || detectDefaultRouteInterface(resolvedDeps),
|
|
networkInternalIf: loaded.networkInternalIf || detectInternalIf(resolvedDeps),
|
|
tailscaleIf: loaded.tailscaleIf || detectTailscaleIf(resolvedDeps),
|
|
gpuDevice: loaded.gpuDevice || detectGpuDevice(resolvedDeps),
|
|
sndDevice: loaded.sndDevice || detectSndDevice(resolvedDeps),
|
|
detected: {
|
|
networkExternalIf: detectedExternal,
|
|
networkInternalIf: detectedInternal,
|
|
tailscaleIf: detectedTailscale,
|
|
gpuDevice: detectedGpu,
|
|
sndDevice: detectedSnd,
|
|
},
|
|
};
|
|
}
|