Files
bare-operating-system/packages/bare-os-booter/lib/bare-initd-user.js
T
2026-04-04 00:01:11 -04:00

440 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* User initd config on the personal drive: disabled units and optional drop-in ordering.
* Paths are logical VFS paths under $HOME.
*/
import b4a from 'b4a'
/** One unit per line; blank lines and `#…` comments ignored. */
export const BARE_INITD_DISABLED_FILE = '~/.config/bare-os/initd/disabled.txt'
/**
* Optional `name.unit` files under this directory. Supports `[Unit]` keys:
* After=, Requires=, Wants=, TimeoutStartSec=, TimeoutStopSec=, Restart=, RestartSec=, ExecStartPost=, SocketActivationIpc=,
* ReadinessPath= (VFS path until exists), ReadinessTimeoutSec=,
* ExecHealthCmd=, HealthIntervalSec=, HealthFailureThreshold=, BareMaxExecDepth=,
* Before= (reverse edge: listed units start after this one), OnFailure= (execLine after restart exhausted),
* FailureAction=exec|none (default exec when OnFailure is set),
* RestartMaxAttempts= (132; overrides default 3 for Restart=on-failure|always).
*/
export const BARE_INITD_UNITS_DIR = '~/.config/bare-os/units'
/** `systemctl --user` style overrides (merged on top of {@link BARE_INITD_UNITS_DIR}). */
export const BARE_INITD_SYSTEMCTL_USER_UNITS_DIR = '~/.config/bare-init/units'
/** Timer drop-ins: `[Timer]` with OnCalendar= (five cron fields) and ExecLine=. */
export const BARE_INITD_TIMERS_DIR = '~/.config/bare-os/timers'
/** Built-in ordering when no drop-in file exists (kernel-logger before cron). */
export const BARE_INITD_DEFAULT_AFTER = Object.freeze({
'bare-cron': ['kernel-logger']
})
/**
* @typedef {{
* after: string[],
* requires: string[],
* wants: string[],
* timeoutStartSec: number | null,
* timeoutStopSec: number | null,
* restart: string | null,
* restartSec: number | null,
* execStartPost: string | null,
* socketActivationIpc: string | null,
* readinessPath: string | null,
* readinessTimeoutSec: number | null,
* execHealthCmd: string | null,
* healthIntervalSec: number | null,
* healthFailureThreshold: number | null,
* bareMaxExecDepth: number | null,
* before: string[],
* onFailure: string | null,
* failureAction: 'exec' | 'none',
* restartMaxAttempts: number | null
* }} BareInitdUnitDropIn
*/
/** @returns {BareInitdUnitDropIn} */
export function emptyUnitDropIn() {
return {
after: [],
requires: [],
wants: [],
timeoutStartSec: null,
timeoutStopSec: null,
restart: null,
restartSec: null,
execStartPost: null,
socketActivationIpc: null,
readinessPath: null,
readinessTimeoutSec: null,
execHealthCmd: null,
healthIntervalSec: null,
healthFailureThreshold: null,
bareMaxExecDepth: null,
before: [],
onFailure: null,
failureAction: 'exec',
restartMaxAttempts: null
}
}
/**
* @param {string} val
* @returns {string[]}
*/
function parseList(val) {
return val
.split(/[\s,]+/)
.map((s) => s.trim())
.filter(Boolean)
}
/**
* Parse a systemd-inspired `.unit` snippet (only `[Unit]` keys are honored).
* @param {string} text
* @returns {BareInitdUnitDropIn}
*/
export function parseUnitDropInText(text) {
const out = emptyUnitDropIn()
let section = ''
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim()
if (!line || line.startsWith('#')) continue
if (/^\[[^\]]+\]$/.test(line)) {
section = line.slice(1, -1).toLowerCase()
continue
}
if (section !== 'unit') continue
const eq = line.indexOf('=')
if (eq < 1) continue
const key = line.slice(0, eq).trim().toLowerCase()
const val = line.slice(eq + 1).trim()
if (key === 'after') out.after.push(...parseList(val))
else if (key === 'requires') out.requires.push(...parseList(val))
else if (key === 'wants') out.wants.push(...parseList(val))
else if (key === 'timeoutstartsec') {
const n = Number.parseInt(val, 10)
if (Number.isFinite(n) && n > 0) out.timeoutStartSec = n
} else if (key === 'timeoutstopsec') {
const n = Number.parseInt(val, 10)
if (Number.isFinite(n) && n > 0) out.timeoutStopSec = n
} else if (key === 'restart') {
out.restart = val.toLowerCase()
} else if (key === 'restartsec') {
const n = Number.parseInt(val, 10)
if (Number.isFinite(n) && n >= 0) out.restartSec = n
} else if (key === 'execstartpost') {
out.execStartPost = val
} else if (key === 'socketactivationipc') {
if (/^[a-zA-Z0-9._-]+$/.test(val)) out.socketActivationIpc = val
} else if (key === 'readinesspath') {
if (val.trim()) out.readinessPath = val.trim()
} else if (key === 'readinesstimeoutsec') {
const n = Number.parseInt(val, 10)
if (Number.isFinite(n) && n > 0) out.readinessTimeoutSec = n
} else if (key === 'exechealthcmd') {
if (val.trim()) out.execHealthCmd = val.trim()
} else if (key === 'healthintervalsec') {
const n = Number.parseInt(val, 10)
if (Number.isFinite(n) && n > 0) out.healthIntervalSec = n
} else if (key === 'healthfailurethreshold') {
const n = Number.parseInt(val, 10)
if (Number.isFinite(n) && n > 0) out.healthFailureThreshold = n
} else if (key === 'baremaxexecdepth') {
const n = Number.parseInt(val, 10)
if (Number.isFinite(n) && n > 0) out.bareMaxExecDepth = n
} else if (key === 'before') {
out.before.push(...parseList(val))
} else if (key === 'onfailure') {
if (val.trim()) out.onFailure = val
} else if (key === 'failureaction') {
const v = val.toLowerCase()
if (v === 'none') out.failureAction = 'none'
else out.failureAction = 'exec'
} else if (key === 'restartmaxattempts') {
const n = Number.parseInt(val, 10)
if (Number.isFinite(n) && n >= 1 && n <= 32) out.restartMaxAttempts = n
}
}
out.after = [...new Set(out.after)]
out.requires = [...new Set(out.requires)]
out.wants = [...new Set(out.wants)]
out.before = [...new Set(out.before)]
return out
}
/**
* @param {BareInitdUnitDropIn} base
* @param {BareInitdUnitDropIn} user
* @returns {BareInitdUnitDropIn}
*/
export function mergeUnitDropIns(base, user) {
const pickArr = (u, b) =>
u.length ? [...new Set(u)] : [...new Set(b)]
return {
after: pickArr(user.after, base.after),
requires: pickArr(user.requires, base.requires),
wants: pickArr(user.wants, base.wants),
timeoutStartSec: user.timeoutStartSec ?? base.timeoutStartSec,
timeoutStopSec: user.timeoutStopSec ?? base.timeoutStopSec,
restart: user.restart ?? base.restart,
restartSec: user.restartSec ?? base.restartSec,
execStartPost: user.execStartPost ?? base.execStartPost,
socketActivationIpc: user.socketActivationIpc ?? base.socketActivationIpc,
readinessPath: user.readinessPath ?? base.readinessPath,
readinessTimeoutSec:
user.readinessTimeoutSec ?? base.readinessTimeoutSec,
execHealthCmd: user.execHealthCmd ?? base.execHealthCmd,
healthIntervalSec: user.healthIntervalSec ?? base.healthIntervalSec,
healthFailureThreshold:
user.healthFailureThreshold ?? base.healthFailureThreshold,
bareMaxExecDepth: user.bareMaxExecDepth ?? base.bareMaxExecDepth,
before: pickArr(user.before, base.before),
onFailure: user.onFailure ?? base.onFailure,
failureAction: user.failureAction ?? base.failureAction,
restartMaxAttempts: user.restartMaxAttempts ?? base.restartMaxAttempts
}
}
/**
* @param {{ readFile: (p: string) => Promise<Uint8Array | null> }} vfs
* @param {string} logicalPath
* @returns {Promise<BareInitdUnitDropIn>}
*/
export async function readUnitDropInFromPath(vfs, logicalPath) {
try {
const buf = await vfs.readFile(logicalPath)
if (!buf) return emptyUnitDropIn()
return parseUnitDropInText(b4a.toString(buf, 'utf8'))
} catch {
return emptyUnitDropIn()
}
}
/**
* @param {{ readFile: (p: string) => Promise<Uint8Array | null> }} vfs
* @returns {Promise<Set<string>>}
*/
export async function readInitdDisabledSet(vfs) {
const out = new Set()
try {
const buf = await vfs.readFile(BARE_INITD_DISABLED_FILE)
if (!buf) return out
const text = b4a.toString(buf, 'utf8')
for (const line of text.split(/\r?\n/)) {
const t = line.trim()
if (!t || t.startsWith('#')) continue
out.add(t)
}
} catch {
/* missing file */
}
return out
}
/**
* @param {{
* mkdir: (p: string, opts?: { recursive?: boolean }) => Promise<unknown>,
* writeFile: (p: string, buf: Uint8Array) => Promise<unknown>,
* unlink: (p: string) => Promise<unknown>
* }} vfs
* @param {Set<string>} disabled
*/
export async function writeInitdDisabledSet(vfs, disabled) {
await vfs.mkdir('~/.config/bare-os/initd', { recursive: true })
if (disabled.size === 0) {
try {
await vfs.unlink(BARE_INITD_DISABLED_FILE)
} catch {
/* absent */
}
return
}
const body = [...disabled].sort().join('\n') + '\n'
await vfs.writeFile(BARE_INITD_DISABLED_FILE, b4a.from(body, 'utf8'))
}
/**
* @param {{ readFile: (p: string) => Promise<Uint8Array | null> }} vfs
* @param {string} unitName
* @returns {Promise<BareInitdUnitDropIn>}
*/
export async function readUnitDropIn(vfs, unitName) {
if (!/^[a-zA-Z0-9._-]+$/.test(unitName)) return emptyUnitDropIn()
const base = await readUnitDropInFromPath(
vfs,
`${BARE_INITD_UNITS_DIR}/${unitName}.unit`
)
const usr = await readUnitDropInFromPath(
vfs,
`${BARE_INITD_SYSTEMCTL_USER_UNITS_DIR}/${unitName}.unit`
)
return mergeUnitDropIns(base, usr)
}
/**
* @param {{ readFile: (p: string) => Promise<Uint8Array | null> }} vfs
* @param {string} unitName
* @returns {Promise<string[]>}
*/
export async function readUnitAfterFromDropIn(vfs, unitName) {
const d = await readUnitDropIn(vfs, unitName)
return d.after
}
/**
* @param {{ readFile: (p: string) => Promise<Uint8Array | null> }} vfs
* @param {{ name: string }[]} services
* @param {Readonly<Record<string, string[]>>} defaultAfter
* @returns {Promise<Map<string, BareInitdUnitDropIn>>}
*/
export async function loadInitdUnitDropIns(vfs, services, defaultAfter) {
/** @type {Map<string, BareInitdUnitDropIn>} */
const map = new Map()
for (const s of services) {
const fromFile = await readUnitDropIn(vfs, s.name)
const def = defaultAfter[s.name] || []
fromFile.after = [...new Set([...def, ...fromFile.after])]
map.set(s.name, fromFile)
}
for (const [name, di] of map) {
for (const b of di.before) {
if (!/^[a-zA-Z0-9._-]+$/.test(b)) continue
const target = map.get(b)
if (target) {
target.after = [...new Set([...target.after, name])]
}
}
}
return map
}
/**
* @param {{ readFile: (p: string) => Promise<Uint8Array | null> }} vfs
* @param {{ name: string }[]} services
* @param {Readonly<Record<string, string[]>>} defaultAfter
* @returns {Promise<Map<string, string[]>>}
*/
export async function loadInitdAfterMap(vfs, services, defaultAfter) {
const full = await loadInitdUnitDropIns(vfs, services, defaultAfter)
const map = new Map()
for (const [k, v] of full) map.set(k, v.after)
return map
}
/**
* @template T extends { name: string }
* @param {T[]} services
* @param {Set<string>} disabled
* @param {Map<string, string[]>} requiresMap
*/
function filterSatisfiedRequires(services, disabled, requiresMap) {
const names = new Set(services.map((s) => s.name))
return services.filter((s) => {
for (const r of requiresMap.get(s.name) || []) {
if (!names.has(r)) return false
if (disabled.has(r)) return false
}
return true
})
}
/**
* Topological sort; cycles fall back to registration order for the remainder.
* @template T extends { name: string }
* @param {T[]} services
* @param {Set<string>} disabled
* @param {Map<string, string[]>} afterMap
* @param {Map<string, string[]>} [requiresMap]
* @param {Map<string, string[]>} [wantsMap]
* @returns {T[]}
*/
export function sortServicesForBoot(
services,
disabled,
afterMap,
requiresMap,
wantsMap
) {
const reqM = requiresMap || new Map()
const wanM = wantsMap || new Map()
let active = services.filter((s) => !disabled.has(s.name))
active = filterSatisfiedRequires(active, disabled, reqM)
const nameSet = new Set(active.map((s) => s.name))
/** @type {Map<string, Set<string>>} */
const prereq = new Map()
for (const s of active) {
const inc = new Set()
for (const a of afterMap.get(s.name) || []) {
if (nameSet.has(a)) inc.add(a)
}
for (const r of reqM.get(s.name) || []) {
if (nameSet.has(r)) inc.add(r)
}
for (const w of wanM.get(s.name) || []) {
if (nameSet.has(w)) inc.add(w)
}
prereq.set(s.name, inc)
}
const remaining = new Set(active.map((s) => s.name))
/** @type {T[]} */
const ordered = []
while (remaining.size) {
const ready = [...remaining].filter((n) => {
for (const p of prereq.get(n) || []) {
if (remaining.has(p)) return false
}
return true
})
if (!ready.length) {
const regOrder = active.map((s) => s.name).filter((n) => remaining.has(n))
for (const n of regOrder) {
const svc = active.find((s) => s.name === n)
if (svc) ordered.push(svc)
remaining.delete(n)
}
break
}
ready.sort()
for (const n of ready) {
const svc = active.find((s) => s.name === n)
if (svc) ordered.push(svc)
remaining.delete(n)
}
}
return ordered
}
/**
* @typedef {{ onCalendar: string, execLine: string }} BareOsTimerDropIn
*/
/**
* Parse `~/.config/bare-os/timers/*.timer` — `[Timer]` with OnCalendar= and ExecLine=.
* @param {string} text
* @returns {BareOsTimerDropIn | null}
*/
export function parseBareOsTimerFile(text) {
let onCalendar = ''
let execLine = ''
let section = ''
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim()
if (!line || line.startsWith('#')) continue
if (/^\[[^\]]+\]$/.test(line)) {
section = line.slice(1, -1).toLowerCase()
continue
}
if (section !== 'timer') continue
const eq = line.indexOf('=')
if (eq < 1) continue
const key = line.slice(0, eq).trim().toLowerCase()
const val = line.slice(eq + 1).trim()
if (key === 'oncalendar') onCalendar = val
else if (key === 'execline') execLine = val
}
if (!onCalendar.trim() || !execLine.trim()) return null
return { onCalendar: onCalendar.trim(), execLine: execLine.trim() }
}