Fix/overlay destroy (#2707)

* feature: Make all errors in console.error be including an error for that stack tract

* feature: Make all errors in console.error be including an error for that stack tract

* fix: Add the tinisubreaper for the subreapers to know they are not the reaper

* fix: overlay always destroyed

* chore: Move the style of destroy to just private
This commit is contained in:
Jade
2024-08-14 11:16:23 -06:00
committed by GitHub
parent 7ef25a3816
commit c704626a39
19 changed files with 261 additions and 137 deletions

View File

@@ -20,6 +20,7 @@
"node-fetch": "^3.1.0", "node-fetch": "^3.1.0",
"ts-matches": "^5.5.1", "ts-matches": "^5.5.1",
"tslib": "^2.5.3", "tslib": "^2.5.3",
"tslog": "^4.9.3",
"typescript": "^5.1.3", "typescript": "^5.1.3",
"yaml": "^2.3.1" "yaml": "^2.3.1"
}, },
@@ -5627,6 +5628,17 @@
"version": "2.6.3", "version": "2.6.3",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tslog": {
"version": "4.9.3",
"resolved": "https://registry.npmjs.org/tslog/-/tslog-4.9.3.tgz",
"integrity": "sha512-oDWuGVONxhVEBtschLf2cs/Jy8i7h1T+CpdkTNWQgdAF7DhRo2G8vMCgILKe7ojdEkLhICWgI1LYSSKaJsRgcw==",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/fullstack-build/tslog?sponsor=1"
}
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.3.2", "version": "0.3.2",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz",
@@ -9746,6 +9758,11 @@
"tslib": { "tslib": {
"version": "2.6.3" "version": "2.6.3"
}, },
"tslog": {
"version": "4.9.3",
"resolved": "https://registry.npmjs.org/tslog/-/tslog-4.9.3.tgz",
"integrity": "sha512-oDWuGVONxhVEBtschLf2cs/Jy8i7h1T+CpdkTNWQgdAF7DhRo2G8vMCgILKe7ojdEkLhICWgI1LYSSKaJsRgcw=="
},
"type-check": { "type-check": {
"version": "0.3.2", "version": "0.3.2",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz",

View File

@@ -1,4 +1,4 @@
import { types as T } from "@start9labs/start-sdk" import { types as T, utils } from "@start9labs/start-sdk"
import * as net from "net" import * as net from "net"
import { object, string, number, literals, some, unknown } from "ts-matches" import { object, string, number, literals, some, unknown } from "ts-matches"
import { Effects } from "../Models/Effects" import { Effects } from "../Models/Effects"
@@ -65,7 +65,10 @@ const rpcRoundFor =
) )
if (testRpcError(res)) { if (testRpcError(res)) {
let message = res.error.message let message = res.error.message
console.error("Error in host RPC:", { method, params }) console.error(
"Error in host RPC:",
utils.asError({ method, params }),
)
if (string.test(res.error.data)) { if (string.test(res.error.data)) {
message += ": " + res.error.data message += ": " + res.error.data
console.error(`Details: ${res.error.data}`) console.error(`Details: ${res.error.data}`)

View File

@@ -4,19 +4,35 @@ import { Overlay, types as T } from "@start9labs/start-sdk"
import { promisify } from "util" import { promisify } from "util"
import { DockerProcedure, VolumeId } from "../../../Models/DockerProcedure" import { DockerProcedure, VolumeId } from "../../../Models/DockerProcedure"
import { Volume } from "./matchVolume" import { Volume } from "./matchVolume"
import { ExecSpawnable } from "@start9labs/start-sdk/cjs/lib/util/Overlay"
export const exec = promisify(cp.exec) export const exec = promisify(cp.exec)
export const execFile = promisify(cp.execFile) export const execFile = promisify(cp.execFile)
export class DockerProcedureContainer { export class DockerProcedureContainer {
private constructor(readonly overlay: Overlay) {} private constructor(private readonly overlay: ExecSpawnable) {}
// static async readonlyOf(data: DockerProcedure) {
// return DockerProcedureContainer.of(data, ["-o", "ro"])
// }
static async of( static async of(
effects: T.Effects, effects: T.Effects,
packageId: string, packageId: string,
data: DockerProcedure, data: DockerProcedure,
volumes: { [id: VolumeId]: Volume }, volumes: { [id: VolumeId]: Volume },
options: { overlay?: ExecSpawnable } = {},
) {
const overlay =
options?.overlay ??
(await DockerProcedureContainer.createOverlay(
effects,
packageId,
data,
volumes,
))
return new DockerProcedureContainer(overlay)
}
static async createOverlay(
effects: T.Effects,
packageId: string,
data: DockerProcedure,
volumes: { [id: VolumeId]: Volume },
) { ) {
const overlay = await Overlay.of(effects, { id: data.image }) const overlay = await Overlay.of(effects, { id: data.image })
@@ -84,23 +100,18 @@ export class DockerProcedureContainer {
} }
} }
} }
return overlay
return new DockerProcedureContainer(overlay)
} }
async exec(commands: string[], { destroy = true } = {}) { async exec(commands: string[], {} = {}) {
try { try {
return await this.overlay.exec(commands) return await this.overlay.exec(commands)
} finally { } finally {
if (destroy) await this.overlay.destroy() await this.overlay.destroy?.()
} }
} }
async execFail( async execFail(commands: string[], timeoutMs: number | null, {} = {}) {
commands: string[],
timeoutMs: number | null,
{ destroy = true } = {},
) {
try { try {
const res = await this.overlay.exec(commands, {}, timeoutMs) const res = await this.overlay.exec(commands, {}, timeoutMs)
if (res.exitCode !== 0) { if (res.exitCode !== 0) {
@@ -114,7 +125,7 @@ export class DockerProcedureContainer {
} }
return res return res
} finally { } finally {
if (destroy) await this.overlay.destroy() await this.overlay.destroy?.()
} }
} }

View File

@@ -5,6 +5,7 @@ import { T, utils } from "@start9labs/start-sdk"
import { Daemon } from "@start9labs/start-sdk/cjs/lib/mainFn/Daemon" import { Daemon } from "@start9labs/start-sdk/cjs/lib/mainFn/Daemon"
import { Effects } from "../../../Models/Effects" import { Effects } from "../../../Models/Effects"
import { off } from "node:process" import { off } from "node:process"
import { CommandController } from "@start9labs/start-sdk/cjs/lib/mainFn/CommandController"
const EMBASSY_HEALTH_INTERVAL = 15 * 1000 const EMBASSY_HEALTH_INTERVAL = 15 * 1000
const EMBASSY_PROPERTIES_LOOP = 30 * 1000 const EMBASSY_PROPERTIES_LOOP = 30 * 1000
@@ -14,9 +15,8 @@ const EMBASSY_PROPERTIES_LOOP = 30 * 1000
* Also, this has an ability to clean itself up too if need be. * Also, this has an ability to clean itself up too if need be.
*/ */
export class MainLoop { export class MainLoop {
private _mainDockerContainer?: DockerProcedureContainer get mainOverlay() {
get mainDockerContainer() { return this.mainEvent?.daemon?.overlay
return this._mainDockerContainer
} }
private healthLoops?: { private healthLoops?: {
name: string name: string
@@ -52,27 +52,33 @@ export class MainLoop {
await this.setupInterfaces(effects) await this.setupInterfaces(effects)
await effects.setMainStatus({ status: "running" }) await effects.setMainStatus({ status: "running" })
const jsMain = (this.system.moduleCode as any)?.jsMain const jsMain = (this.system.moduleCode as any)?.jsMain
const dockerProcedureContainer = await DockerProcedureContainer.of(
effects,
this.system.manifest.id,
this.system.manifest.main,
this.system.manifest.volumes,
)
this._mainDockerContainer = dockerProcedureContainer
if (jsMain) { if (jsMain) {
throw new Error("Unreachable") throw new Error("Unreachable")
} }
const daemon = await Daemon.of()( const daemon = new Daemon(async () => {
this.effects, const overlay = await DockerProcedureContainer.createOverlay(
{ id: this.system.manifest.main.image }, effects,
currentCommand, this.system.manifest.id,
{ this.system.manifest.main,
overlay: dockerProcedureContainer.overlay, this.system.manifest.volumes,
sigtermTimeout: utils.inMs( )
this.system.manifest.main["sigterm-timeout"], return CommandController.of()(
), this.effects,
},
) { id: this.system.manifest.main.image },
currentCommand,
{
overlay,
env: {
TINI_SUBREAPER: "true",
},
sigtermTimeout: utils.inMs(
this.system.manifest.main["sigterm-timeout"],
),
},
)
})
daemon.start() daemon.start()
return { return {
daemon, daemon,
@@ -128,10 +134,11 @@ export class MainLoop {
const main = await mainEvent const main = await mainEvent
delete this.mainEvent delete this.mainEvent
delete this.healthLoops delete this.healthLoops
await main?.daemon.stop().catch((e) => console.error(e)) await main?.daemon
.stop()
.catch((e) => console.error(`Main loop error`, utils.asError(e)))
this.effects.setMainStatus({ status: "stopped" }) this.effects.setMainStatus({ status: "stopped" })
if (healthLoops) healthLoops.forEach((x) => clearInterval(x.interval)) if (healthLoops) healthLoops.forEach((x) => clearInterval(x.interval))
delete this._mainDockerContainer
} }
private constructHealthLoops() { private constructHealthLoops() {
@@ -144,24 +151,27 @@ export class MainLoop {
const actionProcedure = value const actionProcedure = value
const timeChanged = Date.now() - start const timeChanged = Date.now() - start
if (actionProcedure.type === "docker") { if (actionProcedure.type === "docker") {
const overlay = actionProcedure.inject
? this.mainOverlay
: undefined
// prettier-ignore // prettier-ignore
const container = const container =
actionProcedure.inject && this._mainDockerContainer ?
this._mainDockerContainer :
await DockerProcedureContainer.of( await DockerProcedureContainer.of(
effects, effects,
manifest.id, manifest.id,
actionProcedure, actionProcedure,
manifest.volumes, manifest.volumes,
{
overlay,
}
) )
const shouldDestroy = container !== this._mainDockerContainer
const executed = await container.exec( const executed = await container.exec(
[ [
actionProcedure.entrypoint, actionProcedure.entrypoint,
...actionProcedure.args, ...actionProcedure.args,
JSON.stringify(timeChanged), JSON.stringify(timeChanged),
], ],
{ destroy: shouldDestroy }, {},
) )
if (executed.exitCode === 0) { if (executed.exitCode === 0) {
await effects.setHealth({ await effects.setHealth({

View File

@@ -194,7 +194,7 @@ export class SystemForEmbassy implements System {
const moduleCode = await import(EMBASSY_JS_LOCATION) const moduleCode = await import(EMBASSY_JS_LOCATION)
.catch((_) => require(EMBASSY_JS_LOCATION)) .catch((_) => require(EMBASSY_JS_LOCATION))
.catch(async (_) => { .catch(async (_) => {
console.error("Could not load the js") console.error(utils.asError("Could not load the js"))
console.error({ console.error({
exists: await fs.stat(EMBASSY_JS_LOCATION), exists: await fs.stat(EMBASSY_JS_LOCATION),
}) })
@@ -798,17 +798,18 @@ export class SystemForEmbassy implements System {
const actionProcedure = this.manifest.actions?.[actionId]?.implementation const actionProcedure = this.manifest.actions?.[actionId]?.implementation
if (!actionProcedure) return { message: "Action not found", value: null } if (!actionProcedure) return { message: "Action not found", value: null }
if (actionProcedure.type === "docker") { if (actionProcedure.type === "docker") {
const container = const overlay = actionProcedure.inject
actionProcedure.inject && this.currentRunning?.mainDockerContainer ? this.currentRunning?.mainOverlay
? this.currentRunning?.mainDockerContainer : undefined
: await DockerProcedureContainer.of( const container = await DockerProcedureContainer.of(
effects, effects,
this.manifest.id, this.manifest.id,
actionProcedure, actionProcedure,
this.manifest.volumes, this.manifest.volumes,
) {
const shouldDestroy = overlay,
container !== this.currentRunning?.mainDockerContainer },
)
return JSON.parse( return JSON.parse(
( (
await container.execFail( await container.execFail(
@@ -818,7 +819,6 @@ export class SystemForEmbassy implements System {
JSON.stringify(formData), JSON.stringify(formData),
], ],
timeoutMs, timeoutMs,
{ destroy: shouldDestroy },
) )
).stdout.toString(), ).stdout.toString(),
) )
@@ -987,7 +987,10 @@ async function updateConfig(
}) })
.once() .once()
.catch((x) => { .catch((x) => {
console.error("Could not get the service interface", x) console.error(
"Could not get the service interface",
utils.asError(x),
)
return null return null
}) })
const catchFn = <X>(fn: () => X) => { const catchFn = <X>(fn: () => X) => {

View File

@@ -3,7 +3,7 @@ import * as oet from "./oldEmbassyTypes"
import { Volume } from "../../../Models/Volume" import { Volume } from "../../../Models/Volume"
import * as child_process from "child_process" import * as child_process from "child_process"
import { promisify } from "util" import { promisify } from "util"
import { daemons, startSdk, T } from "@start9labs/start-sdk" import { daemons, startSdk, T, utils } from "@start9labs/start-sdk"
import "isomorphic-fetch" import "isomorphic-fetch"
import { Manifest } from "./matchManifest" import { Manifest } from "./matchManifest"
import { DockerProcedureContainer } from "./DockerProcedureContainer" import { DockerProcedureContainer } from "./DockerProcedureContainer"
@@ -124,19 +124,19 @@ export const polyfillEffects = (
wait(): Promise<oet.ResultType<string>> wait(): Promise<oet.ResultType<string>>
term(): Promise<void> term(): Promise<void>
} { } {
const dockerProcedureContainer = DockerProcedureContainer.of( const promiseOverlay = DockerProcedureContainer.createOverlay(
effects, effects,
manifest.id, manifest.id,
manifest.main, manifest.main,
manifest.volumes, manifest.volumes,
) )
const daemon = dockerProcedureContainer.then((dockerProcedureContainer) => const daemon = promiseOverlay.then((overlay) =>
daemons.runCommand()( daemons.runCommand()(
effects, effects,
{ id: manifest.main.image }, { id: manifest.main.image },
[input.command, ...(input.args || [])], [input.command, ...(input.args || [])],
{ {
overlay: dockerProcedureContainer.overlay, overlay,
}, },
), ),
) )
@@ -224,16 +224,16 @@ export const polyfillEffects = (
return new Promise((resolve) => setTimeout(resolve, timeMs)) return new Promise((resolve) => setTimeout(resolve, timeMs))
}, },
trace(whatToPrint: string): void { trace(whatToPrint: string): void {
console.trace(whatToPrint) console.trace(utils.asError(whatToPrint))
}, },
warn(whatToPrint: string): void { warn(whatToPrint: string): void {
console.warn(whatToPrint) console.warn(utils.asError(whatToPrint))
}, },
error(whatToPrint: string): void { error(whatToPrint: string): void {
console.error(whatToPrint) console.error(utils.asError(whatToPrint))
}, },
debug(whatToPrint: string): void { debug(whatToPrint: string): void {
console.debug(whatToPrint) console.debug(utils.asError(whatToPrint))
}, },
info(whatToPrint: string): void { info(whatToPrint: string): void {
console.log(false) console.log(false)
@@ -357,7 +357,7 @@ export const polyfillEffects = (
}) })
spawned.stderr.on("data", (data: unknown) => { spawned.stderr.on("data", (data: unknown) => {
console.error(String(data)) console.error(`polyfill.runAsync`, utils.asError(data))
}) })
const id = async () => { const id = async () => {

View File

@@ -4,7 +4,7 @@ import matches, { any, number, object, string, tuple } from "ts-matches"
import { Effects } from "../../Models/Effects" import { Effects } from "../../Models/Effects"
import { RpcResult, matchRpcResult } from "../RpcListener" import { RpcResult, matchRpcResult } from "../RpcListener"
import { duration } from "../../Models/Duration" import { duration } from "../../Models/Duration"
import { T } from "@start9labs/start-sdk" import { T, utils } from "@start9labs/start-sdk"
import { Volume } from "../../Models/Volume" import { Volume } from "../../Models/Volume"
import { MainEffects } from "@start9labs/start-sdk/cjs/lib/StartSdk" import { MainEffects } from "@start9labs/start-sdk/cjs/lib/StartSdk"
import { CallbackHolder } from "../../Models/CallbackHolder" import { CallbackHolder } from "../../Models/CallbackHolder"
@@ -57,7 +57,9 @@ export class SystemForStartOs implements System {
if (this.runningMain) { if (this.runningMain) {
this.runningMain.callbacks this.runningMain.callbacks
.callCallback(callback, args) .callCallback(callback, args)
.catch((error) => console.error(`callback ${callback} failed`, error)) .catch((error) =>
console.error(`callback ${callback} failed`, utils.asError(error)),
)
} else { } else {
console.warn(`callback ${callback} ignored because system is not running`) console.warn(`callback ${callback} ignored because system is not running`)
} }

View File

@@ -34,7 +34,7 @@ pub fn chroot<C: Context>(
args, args,
}: ChrootParams, }: ChrootParams,
) -> Result<(), Error> { ) -> Result<(), Error> {
let mut cmd = std::process::Command::new(command); let mut cmd: std::process::Command = std::process::Command::new(command);
if let Some(env) = env { if let Some(env) = env {
for (k, v) in std::fs::read_to_string(env)? for (k, v) in std::fs::read_to_string(env)?
.lines() .lines()

View File

@@ -2,6 +2,7 @@ import * as T from "../types"
import * as child_process from "child_process" import * as child_process from "child_process"
import { promises as fsPromises } from "fs" import { promises as fsPromises } from "fs"
import { asError } from "../util"
export type BACKUP = "BACKUP" export type BACKUP = "BACKUP"
export const DEFAULT_OPTIONS: T.BackupOptions = { export const DEFAULT_OPTIONS: T.BackupOptions = {
@@ -183,7 +184,7 @@ async function runRsync(
}) })
spawned.stderr.on("data", (data: unknown) => { spawned.stderr.on("data", (data: unknown) => {
console.error(String(data)) console.error(`Backups.runAsync`, asError(data))
}) })
const id = async () => { const id = async () => {

View File

@@ -57,7 +57,9 @@ export function setupConfig<
return { return {
setConfig: (async ({ effects, input }) => { setConfig: (async ({ effects, input }) => {
if (!validator.test(input)) { if (!validator.test(input)) {
await console.error(String(validator.errorMessage(input))) await console.error(
new Error(validator.errorMessage(input)?.toString()),
)
return { error: "Set config type error for config" } return { error: "Set config type error for config" }
} }
await effects.clearBindings() await effects.clearBindings()

View File

@@ -8,6 +8,7 @@ import { once } from "../util/once"
import { Overlay } from "../util/Overlay" import { Overlay } from "../util/Overlay"
import { object, unknown } from "ts-matches" import { object, unknown } from "ts-matches"
import * as T from "../types" import * as T from "../types"
import { asError } from "../util/asError"
export type HealthCheckParams = { export type HealthCheckParams = {
effects: Effects effects: Effects
@@ -44,7 +45,7 @@ export function healthCheck(o: HealthCheckParams) {
}) })
currentValue.lastResult = result currentValue.lastResult = result
await triggerFirstSuccess().catch((err) => { await triggerFirstSuccess().catch((err) => {
console.error(err) console.error(asError(err))
}) })
} catch (e) { } catch (e) {
await o.effects.setHealth({ await o.effects.setHealth({

View File

@@ -1,4 +1,5 @@
import { Effects } from "../../types" import { Effects } from "../../types"
import { asError } from "../../util/asError"
import { HealthCheckResult } from "./HealthCheckResult" import { HealthCheckResult } from "./HealthCheckResult"
import { timeoutPromise } from "./index" import { timeoutPromise } from "./index"
import "isomorphic-fetch" import "isomorphic-fetch"
@@ -29,7 +30,7 @@ export const checkWebUrl = async (
.catch((e) => { .catch((e) => {
console.warn(`Error while fetching URL: ${url}`) console.warn(`Error while fetching URL: ${url}`)
console.error(JSON.stringify(e)) console.error(JSON.stringify(e))
console.error(e.toString()) console.error(asError(e))
return { result: "failure" as const, message: errorMessage } return { result: "failure" as const, message: errorMessage }
}) })
} }

View File

@@ -2,14 +2,20 @@ import { DEFAULT_SIGTERM_TIMEOUT } from "."
import { NO_TIMEOUT, SIGKILL, SIGTERM } from "../StartSdk" import { NO_TIMEOUT, SIGKILL, SIGTERM } from "../StartSdk"
import * as T from "../types" import * as T from "../types"
import { MountOptions, Overlay } from "../util/Overlay" import { asError } from "../util/asError"
import {
ExecSpawnable,
MountOptions,
NonDestroyableOverlay,
Overlay,
} from "../util/Overlay"
import { splitCommand } from "../util/splitCommand" import { splitCommand } from "../util/splitCommand"
import { cpExecFile, cpExec } from "./Daemons" import { cpExecFile, cpExec } from "./Daemons"
export class CommandController { export class CommandController {
private constructor( private constructor(
readonly runningAnswer: Promise<unknown>, readonly runningAnswer: Promise<unknown>,
readonly overlay: Overlay, private readonly overlay: ExecSpawnable,
readonly pid: number | undefined, readonly pid: number | undefined,
readonly sigtermTimeout: number = DEFAULT_SIGTERM_TIMEOUT, readonly sigtermTimeout: number = DEFAULT_SIGTERM_TIMEOUT,
) {} ) {}
@@ -25,7 +31,7 @@ export class CommandController {
// Defaults to the DEFAULT_SIGTERM_TIMEOUT = 30_000ms // Defaults to the DEFAULT_SIGTERM_TIMEOUT = 30_000ms
sigtermTimeout?: number sigtermTimeout?: number
mounts?: { path: string; options: MountOptions }[] mounts?: { path: string; options: MountOptions }[]
overlay?: Overlay overlay?: ExecSpawnable
env?: env?:
| { | {
[variable: string]: string [variable: string]: string
@@ -38,10 +44,15 @@ export class CommandController {
}, },
) => { ) => {
const commands = splitCommand(command) const commands = splitCommand(command)
const overlay = options.overlay || (await Overlay.of(effects, imageId)) const overlay =
for (let mount of options.mounts || []) { options.overlay ||
await overlay.mount(mount.options, mount.path) (await (async () => {
} const overlay = await Overlay.of(effects, imageId)
for (let mount of options.mounts || []) {
await overlay.mount(mount.options, mount.path)
}
return overlay
})())
const childProcess = await overlay.spawn(commands, { const childProcess = await overlay.spawn(commands, {
env: options.env, env: options.env,
}) })
@@ -57,7 +68,7 @@ export class CommandController {
"data", "data",
options.onStderr ?? options.onStderr ??
((data: any) => { ((data: any) => {
console.error(data.toString()) console.error(asError(data))
}), }),
) )
@@ -74,7 +85,10 @@ export class CommandController {
return new CommandController(answer, overlay, pid, options.sigtermTimeout) return new CommandController(answer, overlay, pid, options.sigtermTimeout)
} }
} }
async wait(timeout: number = NO_TIMEOUT) { get nonDestroyableOverlay() {
return new NonDestroyableOverlay(this.overlay)
}
async wait({ timeout = NO_TIMEOUT } = {}) {
if (timeout > 0) if (timeout > 0)
setTimeout(() => { setTimeout(() => {
this.term() this.term()
@@ -87,7 +101,7 @@ export class CommandController {
(_) => {}, (_) => {},
) )
} }
await this.overlay.destroy().catch((_) => {}) await this.overlay.destroy?.().catch((_) => {})
} }
} }
async term({ signal = SIGTERM, timeout = this.sigtermTimeout } = {}) { async term({ signal = SIGTERM, timeout = this.sigtermTimeout } = {}) {
@@ -106,7 +120,7 @@ export class CommandController {
) )
} }
} finally { } finally {
await this.overlay.destroy() await this.overlay.destroy?.()
} }
} }
} }

View File

@@ -1,5 +1,6 @@
import * as T from "../types" import * as T from "../types"
import { MountOptions, Overlay } from "../util/Overlay" import { asError } from "../util/asError"
import { ExecSpawnable, MountOptions, Overlay } from "../util/Overlay"
import { CommandController } from "./CommandController" import { CommandController } from "./CommandController"
const TIMEOUT_INCREMENT_MS = 1000 const TIMEOUT_INCREMENT_MS = 1000
@@ -12,7 +13,10 @@ const MAX_TIMEOUT_MS = 30000
export class Daemon { export class Daemon {
private commandController: CommandController | null = null private commandController: CommandController | null = null
private shouldBeRunning = false private shouldBeRunning = false
private constructor(private startCommand: () => Promise<CommandController>) {} constructor(private startCommand: () => Promise<CommandController>) {}
get overlay(): undefined | ExecSpawnable {
return this.commandController?.nonDestroyableOverlay
}
static of<Manifest extends T.Manifest>() { static of<Manifest extends T.Manifest>() {
return async <A extends string>( return async <A extends string>(
effects: T.Effects, effects: T.Effects,
@@ -41,7 +45,6 @@ export class Daemon {
return new Daemon(startCommand) return new Daemon(startCommand)
} }
} }
async start() { async start() {
if (this.commandController) { if (this.commandController) {
return return
@@ -57,7 +60,7 @@ export class Daemon {
timeoutCounter = Math.max(MAX_TIMEOUT_MS, timeoutCounter) timeoutCounter = Math.max(MAX_TIMEOUT_MS, timeoutCounter)
} }
}).catch((err) => { }).catch((err) => {
console.error(err) console.error(asError(err))
}) })
} }
async term(termOptions?: { async term(termOptions?: {
@@ -72,8 +75,8 @@ export class Daemon {
}) { }) {
this.shouldBeRunning = false this.shouldBeRunning = false
await this.commandController await this.commandController
?.term(termOptions) ?.term({ ...termOptions })
.catch((e) => console.error(e)) .catch((e) => console.error(asError(e)))
this.commandController = null this.commandController = null
} }
} }

View File

@@ -4,6 +4,7 @@ import { Ready } from "./Daemons"
import { Daemon } from "./Daemon" import { Daemon } from "./Daemon"
import { Effects, SetHealth } from "../types" import { Effects, SetHealth } from "../types"
import { DEFAULT_SIGTERM_TIMEOUT } from "." import { DEFAULT_SIGTERM_TIMEOUT } from "."
import { asError } from "../util/asError"
const oncePromise = <T>() => { const oncePromise = <T>() => {
let resolve: (value: T) => void let resolve: (value: T) => void
@@ -21,13 +22,13 @@ const oncePromise = <T>() => {
* *
*/ */
export class HealthDaemon { export class HealthDaemon {
#health: HealthCheckResult = { result: "starting", message: null } private _health: HealthCheckResult = { result: "starting", message: null }
#healthWatchers: Array<() => unknown> = [] private healthWatchers: Array<() => unknown> = []
#running = false private running = false
constructor( constructor(
readonly daemon: Promise<Daemon>, private readonly daemon: Promise<Daemon>,
readonly daemonIndex: number, readonly daemonIndex: number,
readonly dependencies: HealthDaemon[], private readonly dependencies: HealthDaemon[],
readonly id: string, readonly id: string,
readonly ids: string[], readonly ids: string[],
readonly ready: Ready, readonly ready: Ready,
@@ -43,12 +44,12 @@ export class HealthDaemon {
signal?: NodeJS.Signals | undefined signal?: NodeJS.Signals | undefined
timeout?: number | undefined timeout?: number | undefined
}) { }) {
this.#healthWatchers = [] this.healthWatchers = []
this.#running = false this.running = false
this.#healthCheckCleanup?.() this.healthCheckCleanup?.()
await this.daemon.then((d) => await this.daemon.then((d) =>
d.stop({ d.term({
timeout: this.sigtermTimeout, timeout: this.sigtermTimeout,
...termOptions, ...termOptions,
}), }),
@@ -57,17 +58,17 @@ export class HealthDaemon {
/** Want to add another notifier that the health might have changed */ /** Want to add another notifier that the health might have changed */
addWatcher(watcher: () => unknown) { addWatcher(watcher: () => unknown) {
this.#healthWatchers.push(watcher) this.healthWatchers.push(watcher)
} }
get health() { get health() {
return Object.freeze(this.#health) return Object.freeze(this._health)
} }
private async changeRunning(newStatus: boolean) { private async changeRunning(newStatus: boolean) {
if (this.#running === newStatus) return if (this.running === newStatus) return
this.#running = newStatus this.running = newStatus
if (newStatus) { if (newStatus) {
;(await this.daemon).start() ;(await this.daemon).start()
@@ -80,14 +81,14 @@ export class HealthDaemon {
} }
} }
#healthCheckCleanup: (() => void) | null = null private healthCheckCleanup: (() => void) | null = null
private turnOffHealthCheck() { private turnOffHealthCheck() {
this.#healthCheckCleanup?.() this.healthCheckCleanup?.()
} }
private async setupHealthCheck() { private async setupHealthCheck() {
if (this.#healthCheckCleanup) return if (this.healthCheckCleanup) return
const trigger = (this.ready.trigger ?? defaultTrigger)(() => ({ const trigger = (this.ready.trigger ?? defaultTrigger)(() => ({
lastResult: this.#health.result, lastResult: this._health.result,
})) }))
const { promise: status, resolve: setStatus } = oncePromise<{ const { promise: status, resolve: setStatus } = oncePromise<{
@@ -102,7 +103,7 @@ export class HealthDaemon {
const response: HealthCheckResult = await Promise.resolve( const response: HealthCheckResult = await Promise.resolve(
this.ready.fn(), this.ready.fn(),
).catch((err) => { ).catch((err) => {
console.error(err) console.error(asError(err))
return { return {
result: "failure", result: "failure",
message: "message" in err ? err.message : String(err), message: "message" in err ? err.message : String(err),
@@ -112,15 +113,15 @@ export class HealthDaemon {
} }
}).catch((err) => console.error(`Daemon ${this.id} failed: ${err}`)) }).catch((err) => console.error(`Daemon ${this.id} failed: ${err}`))
this.#healthCheckCleanup = () => { this.healthCheckCleanup = () => {
setStatus({ done: true }) setStatus({ done: true })
this.#healthCheckCleanup = null this.healthCheckCleanup = null
} }
} }
private async setHealth(health: HealthCheckResult) { private async setHealth(health: HealthCheckResult) {
this.#health = health this._health = health
this.#healthWatchers.forEach((watcher) => watcher()) this.healthWatchers.forEach((watcher) => watcher())
const display = this.ready.display const display = this.ready.display
const result = health.result const result = health.result
if (!display) { if (!display) {
@@ -134,7 +135,7 @@ export class HealthDaemon {
} }
private async updateStatus() { private async updateStatus() {
const healths = this.dependencies.map((d) => d.#health) const healths = this.dependencies.map((d) => d._health)
this.changeRunning(healths.every((x) => x.result === "success")) this.changeRunning(healths.every((x) => x.result === "success"))
} }
} }

View File

@@ -1,3 +1,5 @@
import { asError } from "../../util/asError"
const msb = 0x80 const msb = 0x80
const dropMsb = 0x7f const dropMsb = 0x7f
const maxSize = Math.floor((8 * 8 + 7) / 7) const maxSize = Math.floor((8 * 8 + 7) / 7)
@@ -38,7 +40,7 @@ export class VarIntProcessor {
if (success) { if (success) {
return result return result
} else { } else {
console.error(this.buf) console.error(asError(this.buf))
return null return null
} }
} }

View File

@@ -5,7 +5,40 @@ import { promisify } from "util"
import { Buffer } from "node:buffer" import { Buffer } from "node:buffer"
export const execFile = promisify(cp.execFile) export const execFile = promisify(cp.execFile)
const WORKDIR = (imageId: string) => `/media/startos/images/${imageId}/` const WORKDIR = (imageId: string) => `/media/startos/images/${imageId}/`
export class Overlay {
type ExecResults = {
exitCode: number | null
exitSignal: NodeJS.Signals | null
stdout: string | Buffer
stderr: string | Buffer
}
/**
* This is the type that is going to describe what an overlay could do. The main point of the
* overlay is to have commands that run in a chrooted environment. This is useful for running
* commands in a containerized environment. But, I wanted the destroy to sometimes be doable, for example the
* case where the overlay isn't owned by the process, the overlay shouldn't be destroyed.
*/
export interface ExecSpawnable {
get destroy(): undefined | (() => Promise<void>)
exec(
command: string[],
options?: CommandOptions,
timeoutMs?: number | null,
): Promise<ExecResults>
spawn(
command: string[],
options?: CommandOptions,
): Promise<cp.ChildProcessWithoutNullStreams>
}
/**
* Want to limit what we can do in a container, so we want to launch a container with a specific image and the mounts.
*
* Implements:
* @see {@link ExecSpawnable}
*/
export class Overlay implements ExecSpawnable {
private destroyed = false
private constructor( private constructor(
readonly effects: T.Effects, readonly effects: T.Effects,
readonly imageId: T.ImageId, readonly imageId: T.ImageId,
@@ -39,23 +72,6 @@ export class Overlay {
return new Overlay(effects, id, rootfs, guid) return new Overlay(effects, id, rootfs, guid)
} }
static async with<T>(
effects: T.Effects,
image: { id: T.ImageId; sharedRun?: boolean },
mounts: { options: MountOptions; path: string }[],
fn: (overlay: Overlay) => Promise<T>,
): Promise<T> {
const overlay = await Overlay.of(effects, image)
try {
for (let mount of mounts) {
await overlay.mount(mount.options, mount.path)
}
return await fn(overlay)
} finally {
await overlay.destroy()
}
}
async mount(options: MountOptions, path: string): Promise<Overlay> { async mount(options: MountOptions, path: string): Promise<Overlay> {
path = path.startsWith("/") path = path.startsWith("/")
? `${this.rootfs}${path}` ? `${this.rootfs}${path}`
@@ -70,7 +86,7 @@ export class Overlay {
await fs.mkdir(from, { recursive: true }) await fs.mkdir(from, { recursive: true })
await fs.mkdir(path, { recursive: true }) await fs.mkdir(path, { recursive: true })
await await execFile("mount", ["--bind", from, path]) await execFile("mount", ["--bind", from, path])
} else if (options.type === "assets") { } else if (options.type === "assets") {
const subpath = options.subpath const subpath = options.subpath
? options.subpath.startsWith("/") ? options.subpath.startsWith("/")
@@ -101,10 +117,14 @@ export class Overlay {
return this return this
} }
async destroy() { get destroy() {
const imageId = this.imageId return async () => {
const guid = this.guid if (this.destroyed) return
await this.effects.destroyOverlayedImage({ guid }) this.destroyed = true
const imageId = this.imageId
const guid = this.guid
await this.effects.destroyOverlayedImage({ guid })
}
} }
async exec( async exec(
@@ -218,6 +238,32 @@ export class Overlay {
} }
} }
/**
* Take an overlay but remove the ability to add the mounts and the destroy function.
* Lets other functions, like health checks, to not destroy the parents.
*
*/
export class NonDestroyableOverlay implements ExecSpawnable {
constructor(private overlay: ExecSpawnable) {}
get destroy() {
return undefined
}
exec(
command: string[],
options?: CommandOptions,
timeoutMs?: number | null,
): Promise<ExecResults> {
return this.overlay.exec(command, options, timeoutMs)
}
spawn(
command: string[],
options?: CommandOptions,
): Promise<cp.ChildProcessWithoutNullStreams> {
return this.overlay.spawn(command, options)
}
}
export type CommandOptions = { export type CommandOptions = {
env?: { [variable: string]: string } env?: { [variable: string]: string }
cwd?: string cwd?: string

6
sdk/lib/util/asError.ts Normal file
View File

@@ -0,0 +1,6 @@
export const asError = (e: unknown) => {
if (e instanceof Error) {
return new Error(e as any)
}
return new Error(`${e}`)
}

View File

@@ -7,6 +7,7 @@ import "./Overlay"
import "./once" import "./once"
export { GetServiceInterface, getServiceInterface } from "./getServiceInterface" export { GetServiceInterface, getServiceInterface } from "./getServiceInterface"
export { asError } from "./asError"
export { getServiceInterfaces } from "./getServiceInterfaces" export { getServiceInterfaces } from "./getServiceInterfaces"
export { addressHostToUrl } from "./getServiceInterface" export { addressHostToUrl } from "./getServiceInterface"
export { hostnameInfoToAddress } from "./Hostname" export { hostnameInfoToAddress } from "./Hostname"