This commit is contained in:
2026-08-18 18:11:28 -04:00
parent 0e9a650fa2
commit bbaf47028f
259 changed files with 0 additions and 0 deletions
@@ -0,0 +1,620 @@
/**
* 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'
/** Masked units cannot be started until `systemctl unmask` (unioned with disabled for boot order). */
export const BARE_INITD_MASKED_FILE = '~/.config/bare-os/initd/masked.txt'
/**
* Optional `name.unit` files under this directory. Supports `[Unit]` keys:
* After=, Requires=, Wants=, TimeoutStartSec=, TimeoutStopSec=, Restart=, RestartSec=, ExecStartPost=, SocketActivationIpc=,
* IdleSec= (with SocketActivationIpc: stop after idle seconds; requires unit `stop`),
* ReadinessPath= (VFS path until exists), or `exec:<execLine>` (bounded by ReadinessTimeoutSec=),
* ConditionPathExists= (skip unit start if path missing), AssertPathExists= (fail start if path missing),
* ConditionPathIsDirectory= / AssertPathIsDirectory= (same semantics for directories),
* 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'
/**
* Optional fragments: `~/.config/bare-os/units.d/<unitName>/*.conf` (sorted), merged after the main `.unit` and before {@link BARE_INITD_SYSTEMCTL_USER_UNITS_DIR}.
*/
export const BARE_INITD_UNITS_D_BASE = '~/.config/bare-os/units.d'
/**
* Timer drop-ins: `[Timer]` with OnCalendar= (five cron fields), EveryMs=, or OnInactiveSec=
* (seconds between end of last run and next start), optional Persistent=false (run once), 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'],
'bare-openssh': ['kernel-logger', 'bare-cron'],
'bare-os-www': ['kernel-logger'],
'bare-holesail': ['kernel-logger', 'bare-os-www'],
'bare-os-chat': ['kernel-logger'],
'bare-os-discord': ['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,
* idleSec: number | 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,
* conditionPathExists: string | null,
* assertPathExists: string | null,
* conditionPathIsDirectory: string | null,
* assertPathIsDirectory: string | null
* }} BareInitdUnitDropIn
*/
/** @returns {BareInitdUnitDropIn} */
export function emptyUnitDropIn() {
return {
after: [],
requires: [],
wants: [],
timeoutStartSec: null,
timeoutStopSec: null,
restart: null,
restartSec: null,
execStartPost: null,
socketActivationIpc: null,
idleSec: null,
readinessPath: null,
readinessTimeoutSec: null,
execHealthCmd: null,
healthIntervalSec: null,
healthFailureThreshold: null,
bareMaxExecDepth: null,
before: [],
onFailure: null,
failureAction: 'exec',
restartMaxAttempts: null,
conditionPathExists: null,
assertPathExists: null,
conditionPathIsDirectory: null,
assertPathIsDirectory: 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 === 'idlesec') {
const n = Number.parseInt(val, 10)
if (Number.isFinite(n) && n > 0 && n <= 86400) out.idleSec = n
} 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
} else if (key === 'conditionpathexists') {
const t = val.trim()
if (t.length > 0 && t.length <= 512) out.conditionPathExists = t
} else if (key === 'assertpathexists') {
const t = val.trim()
if (t.length > 0 && t.length <= 512) out.assertPathExists = t
} else if (key === 'conditionpathisdirectory') {
const t = val.trim()
if (t.length > 0 && t.length <= 512) out.conditionPathIsDirectory = t
} else if (key === 'assertpathisdirectory') {
const t = val.trim()
if (t.length > 0 && t.length <= 512) out.assertPathIsDirectory = t
}
}
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,
idleSec: user.idleSec ?? base.idleSec,
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,
conditionPathExists:
user.conditionPathExists ?? base.conditionPathExists,
assertPathExists: user.assertPathExists ?? base.assertPathExists,
conditionPathIsDirectory:
user.conditionPathIsDirectory ?? base.conditionPathIsDirectory,
assertPathIsDirectory:
user.assertPathIsDirectory ?? base.assertPathIsDirectory
}
}
/**
* @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()
const envRaw =
typeof globalThis.process !== 'undefined' &&
globalThis.process.env &&
typeof globalThis.process.env.BARE_OS_INITD_DISABLED === 'string'
? globalThis.process.env.BARE_OS_INITD_DISABLED
: ''
if (envRaw.trim()) {
for (const part of envRaw.split(/[\s,]+/)) {
const t = part.trim()
if (t) out.add(t)
}
}
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
* @returns {Promise<Set<string>>}
*/
export async function readInitdMaskedSet(vfs) {
const out = new Set()
try {
const buf = await vfs.readFile(BARE_INITD_MASKED_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 */
}
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>} masked
*/
export async function writeInitdMaskedSet(vfs, masked) {
await vfs.mkdir('~/.config/bare-os/initd', { recursive: true })
if (masked.size === 0) {
try {
await vfs.unlink(BARE_INITD_MASKED_FILE)
} catch {
/* absent */
}
return
}
const body = [...masked].sort().join('\n') + '\n'
await vfs.writeFile(BARE_INITD_MASKED_FILE, b4a.from(body, 'utf8'))
}
/**
* @param {{ readFile: (p: string) => Promise<Uint8Array | null>, readdir?: (p: string) => Promise<string[]> }} vfs
* @param {string} unitName
* @returns {Promise<BareInitdUnitDropIn>}
*/
export async function readUnitDropIn(vfs, unitName) {
if (!/^[a-zA-Z0-9._-]+$/.test(unitName)) return emptyUnitDropIn()
let merged = await readUnitDropInFromPath(
vfs,
`${BARE_INITD_UNITS_DIR}/${unitName}.unit`
)
const fragDir = `${BARE_INITD_UNITS_D_BASE}/${unitName}`
if (typeof vfs.readdir === 'function') {
try {
const names = await vfs.readdir(fragDir)
const confs = names
.filter((n) => typeof n === 'string' && n.endsWith('.conf'))
.sort()
for (const n of confs) {
const frag = await readUnitDropInFromPath(vfs, `${fragDir}/${n}`)
merged = mergeUnitDropIns(merged, frag)
}
} catch {
/* missing dir */
}
}
const usr = await readUnitDropInFromPath(
vfs,
`${BARE_INITD_SYSTEMCTL_USER_UNITS_DIR}/${unitName}.unit`
)
return mergeUnitDropIns(merged, 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 {{
* kind: 'calendar',
* onCalendar: string,
* execLine: string,
* jitterSec: number
* } | {
* kind: 'everyMs',
* everyMs: number,
* execLine: string,
* jitterSec: number
* } | {
* kind: 'onInactiveSec',
* inactiveSec: number,
* execLine: string,
* jitterSec: number,
* persistent: boolean
* }} BareOsTimerDropIn
*/
/**
* Parse `~/.config/bare-os/timers/*.timer` — `[Timer]` with OnCalendar=, EveryMs=, or OnInactiveSec= and ExecLine=.
* @param {string} text
* @returns {BareOsTimerDropIn | null}
*/
export function parseBareOsTimerFile(text) {
let onCalendar = ''
/** @type {number | null} */
let everyMs = null
/** @type {number | null} */
let inactiveSec = null
let persistent = true
/** @type {number | null} */
let jitterSec = null
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 === 'everyms') {
const n = Number.parseInt(val, 10)
if (Number.isFinite(n) && n >= 1000 && n <= 86400000) everyMs = n
} else if (key === 'oninactivesec') {
const n = Number.parseInt(val, 10)
if (Number.isFinite(n) && n >= 1 && n <= 86400) inactiveSec = n
} else if (key === 'persistent') {
const v = val.toLowerCase()
persistent = !(
v === '0' ||
v === 'false' ||
v === 'no' ||
v === 'off'
)
} else if (key === 'jittersec') {
const n = Number.parseInt(val, 10)
if (Number.isFinite(n) && n >= 0 && n <= 86400) jitterSec = n
} else if (key === 'execline') execLine = val
}
const el = execLine.trim()
if (!el) return null
const j = jitterSec != null ? jitterSec : 0
if (everyMs != null)
return { kind: 'everyMs', everyMs, execLine: el, jitterSec: j }
if (inactiveSec != null)
return {
kind: 'onInactiveSec',
inactiveSec,
execLine: el,
jitterSec: j,
persistent
}
if (!onCalendar.trim()) return null
return {
kind: 'calendar',
onCalendar: onCalendar.trim(),
execLine: el,
jitterSec: j
}
}