diff --git a/container-runtime/src/Adapters/Systems/SystemForEmbassy/MainLoop.ts b/container-runtime/src/Adapters/Systems/SystemForEmbassy/MainLoop.ts index 2b75d2959..5928d5f8b 100644 --- a/container-runtime/src/Adapters/Systems/SystemForEmbassy/MainLoop.ts +++ b/container-runtime/src/Adapters/Systems/SystemForEmbassy/MainLoop.ts @@ -56,29 +56,27 @@ export class MainLoop { if (jsMain) { throw new Error("Unreachable") } - const daemon = new Daemon(async () => { - const subcontainer = await DockerProcedureContainer.createSubContainer( - effects, - this.system.manifest.id, - this.system.manifest.main, - this.system.manifest.volumes, - `Main - ${currentCommand.join(" ")}`, - ) - return CommandController.of()( - this.effects, - subcontainer, - currentCommand, - { - runAsInit: true, - env: { - TINI_SUBREAPER: "true", - }, - sigtermTimeout: utils.inMs( - this.system.manifest.main["sigterm-timeout"], - ), + const subcontainer = await DockerProcedureContainer.createSubContainer( + effects, + this.system.manifest.id, + this.system.manifest.main, + this.system.manifest.volumes, + `Main - ${currentCommand.join(" ")}`, + ) + const daemon = await Daemon.of()( + this.effects, + subcontainer, + currentCommand, + { + runAsInit: true, + env: { + TINI_SUBREAPER: "true", }, - ) - }) + sigtermTimeout: utils.inMs( + this.system.manifest.main["sigterm-timeout"], + ), + }, + ) daemon.start() return { diff --git a/sdk/package/lib/mainFn/Daemon.ts b/sdk/package/lib/mainFn/Daemon.ts index bd3d13bb3..30af2ce92 100644 --- a/sdk/package/lib/mainFn/Daemon.ts +++ b/sdk/package/lib/mainFn/Daemon.ts @@ -1,9 +1,8 @@ import * as T from "../../../base/lib/types" import { asError } from "../../../base/lib/util/asError" import { Drop } from "../util" -import { ExecSpawnable, MountOptions, SubContainer } from "../util/SubContainer" +import { ExecSpawnable, SubContainer } from "../util/SubContainer" import { CommandController } from "./CommandController" -import { Mounts } from "./Mounts" const TIMEOUT_INCREMENT_MS = 1000 const MAX_TIMEOUT_MS = 30000 @@ -15,8 +14,11 @@ const MAX_TIMEOUT_MS = 30000 export class Daemon extends Drop { private commandController: CommandController | null = null private shouldBeRunning = false - constructor( + protected exitedSuccess = false + protected constructor( private startCommand: () => Promise>, + readonly oneshot: boolean = false, + protected onExitSuccessFns: (() => void)[] = [], ) { super() } @@ -29,6 +31,7 @@ export class Daemon extends Drop { subcontainer: SubContainer, command: T.CommandType, options: { + runAsInit?: boolean env?: | { [variable: string]: string @@ -56,6 +59,7 @@ export class Daemon extends Drop { return } this.shouldBeRunning = true + this.exitedSuccess = false let timeoutCounter = 0 ;(async () => { while (this.shouldBeRunning) { @@ -64,9 +68,27 @@ export class Daemon extends Drop { .term({ keepSubcontainer: true }) .catch((err) => console.error(err)) this.commandController = await this.startCommand() - await this.commandController - .wait({ keepSubcontainer: true }) - .catch((err) => console.error(err)) + if ( + this.oneshot && + (await this.commandController.wait({ keepSubcontainer: true }).then( + (_) => true, + (err) => { + console.error(err) + return false + }, + )) + ) { + for (const fn of this.onExitSuccessFns) { + try { + fn() + } catch (e) { + console.error("EXIT_SUCCESS handler", e) + } + } + this.onExitSuccessFns = [] + this.exitedSuccess = true + break + } await new Promise((resolve) => setTimeout(resolve, timeoutCounter)) timeoutCounter += TIMEOUT_INCREMENT_MS timeoutCounter = Math.min(MAX_TIMEOUT_MS, timeoutCounter) diff --git a/sdk/package/lib/mainFn/Daemons.ts b/sdk/package/lib/mainFn/Daemons.ts index c4637e74f..2b16afb26 100644 --- a/sdk/package/lib/mainFn/Daemons.ts +++ b/sdk/package/lib/mainFn/Daemons.ts @@ -16,6 +16,7 @@ import { HealthDaemon } from "./HealthDaemon" import { Daemon } from "./Daemon" import { CommandController } from "./CommandController" import { HealthCheck } from "../health/HealthCheck" +import { Oneshot } from "./Oneshot" export const cpExec = promisify(CP.exec) export const cpExecFile = promisify(CP.execFile) @@ -48,29 +49,41 @@ export type Ready = { trigger?: Trigger } -type DaemonsParams< +type NewDaemonParams = { + /** The command line command to start the daemon */ + command: T.CommandType + /** Information about the subcontainer in which the daemon runs */ + subcontainer: SubContainer + runAsInit?: boolean + env?: Record + sigtermTimeout?: number + onStdout?: (chunk: Buffer | string | any) => void + onStderr?: (chunk: Buffer | string | any) => void +} + +type AddDaemonParams< Manifest extends T.SDKManifest, Ids extends string, Id extends string, -> = - | { - /** The command line command to start the daemon */ - command: T.CommandType - /** Information about the subcontainer in which the daemon runs */ - subcontainer: SubContainer - env?: Record - ready: Ready - /** An array of IDs of prior daemons whose successful initializations are required before this daemon will initialize */ - requires: Exclude[] - sigtermTimeout?: number - onStdout?: (chunk: Buffer | string | any) => void - onStderr?: (chunk: Buffer | string | any) => void - } +> = ( + | NewDaemonParams | { daemon: Daemon - ready: Ready - requires: Exclude[] } +) & { + ready: Ready + /** An array of IDs of prior daemons whose successful initializations are required before this daemon will initialize */ + requires: Exclude[] +} + +type AddOneshotParams< + Manifest extends T.SDKManifest, + Ids extends string, + Id extends string, +> = NewDaemonParams & { + /** An array of IDs of prior daemons whose successful initializations are required before this daemon will initialize */ + requires: Exclude[] +} type ErrorDuplicateId = `The id '${Id}' is already used` @@ -138,8 +151,8 @@ export class Daemons /** * Returns the complete list of daemons, including the one defined here * @param id - * @param newDaemon - * @returns + * @param options + * @returns a new Daemons object */ addDaemon( // prettier-ignore @@ -148,7 +161,7 @@ export class Daemons ErrorDuplicateId extends Id ? never : Id extends Ids ? ErrorDuplicateId : Id, - options: DaemonsParams, + options: AddDaemonParams, ) { const daemon = "daemon" in options @@ -180,6 +193,55 @@ export class Daemons ) } + /** + * Returns the complete list of daemons, including a "oneshot" daemon one defined here + * a oneshot daemon is a command that executes once when started, and is considered "running" once it exits successfully + * @param id + * @param options + * @returns a new Daemons object + */ + addOneshot( + id: "" extends Id + ? never + : ErrorDuplicateId extends Id + ? never + : Id extends Ids + ? ErrorDuplicateId + : Id, + options: AddOneshotParams, + ) { + const daemon = Oneshot.of()( + this.effects, + options.subcontainer, + options.command, + { + ...options, + }, + ) + const healthDaemon = new HealthDaemon( + daemon, + options.requires + .map((x) => this.ids.indexOf(x)) + .filter((x) => x >= 0) + .map((id) => this.healthDaemons[id]), + id, + this.ids, + "EXIT_SUCCESS", + this.effects, + ) + const daemons = this.daemons.concat(daemon) + const ids = [...this.ids, id] as (Ids | Id)[] + const healthDaemons = [...this.healthDaemons, healthDaemon] + return new Daemons( + this.effects, + this.started, + daemons, + ids, + healthDaemons, + this.healthChecks, + ) + } + async term() { try { this.healthChecks.forEach((health) => health.stop()) diff --git a/sdk/package/lib/mainFn/HealthDaemon.ts b/sdk/package/lib/mainFn/HealthDaemon.ts index a29e433eb..1ccdb6788 100644 --- a/sdk/package/lib/mainFn/HealthDaemon.ts +++ b/sdk/package/lib/mainFn/HealthDaemon.ts @@ -5,6 +5,7 @@ import { Daemon } from "./Daemon" import { SetHealth, Effects, SDKManifest } from "../../../base/lib/types" import { DEFAULT_SIGTERM_TIMEOUT } from "." import { asError } from "../../../base/lib/util/asError" +import { Oneshot } from "./Oneshot" const oncePromise = () => { let resolve: (value: T) => void @@ -14,6 +15,8 @@ const oncePromise = () => { return { resolve: resolve!, promise } } +export const EXIT_SUCCESS = "EXIT_SUCCESS" as const + /** * Wanted a structure that deals with controlling daemons by their health status * States: @@ -33,7 +36,7 @@ export class HealthDaemon { private readonly dependencies: HealthDaemon[], readonly id: string, readonly ids: string[], - readonly ready: Ready, + readonly ready: Ready | typeof EXIT_SUCCESS, readonly effects: Effects, ) { this.readyPromise = new Promise((resolve) => (this.resolveReady = resolve)) @@ -87,6 +90,14 @@ export class HealthDaemon { this.healthCheckCleanup?.() } private async setupHealthCheck() { + if (this.ready === "EXIT_SUCCESS") { + if (this.daemon instanceof Oneshot) { + this.daemon.onExitSuccess(() => + this.setHealth({ result: "success", message: null }), + ) + } + return + } if (this.healthCheckCleanup) return const trigger = (this.ready.trigger ?? defaultTrigger)(() => ({ lastResult: this._health.result, @@ -96,6 +107,7 @@ export class HealthDaemon { done: true }>() new Promise(async () => { + if (this.ready === "EXIT_SUCCESS") return for ( let res = await Promise.race([status, trigger.next()]); !res.done; @@ -142,6 +154,7 @@ export class HealthDaemon { private async setHealth(health: HealthCheckResult) { this._health = health + if (this.ready === "EXIT_SUCCESS") return this.healthWatchers.forEach((watcher) => watcher()) const display = this.ready.display if (!display) { @@ -168,7 +181,7 @@ export class HealthDaemon { } async init() { - if (this.ready.display) { + if (this.ready !== "EXIT_SUCCESS" && this.ready.display) { this.effects.setHealth({ id: this.id, message: null, diff --git a/sdk/package/lib/mainFn/Oneshot.ts b/sdk/package/lib/mainFn/Oneshot.ts new file mode 100644 index 000000000..b4c20ae64 --- /dev/null +++ b/sdk/package/lib/mainFn/Oneshot.ts @@ -0,0 +1,49 @@ +import * as T from "../../../base/lib/types" +import { SubContainer } from "../util/SubContainer" +import { CommandController } from "./CommandController" +import { Daemon } from "./Daemon" + +/** + * This is a wrapper around CommandController that has a state of off, where the command shouldn't be running + * and the others state of running, where it will keep a living running command + * unlike Daemon, does not restart on success + */ + +export class Oneshot extends Daemon { + static of() { + return async ( + effects: T.Effects, + subcontainer: SubContainer, + command: T.CommandType, + options: { + env?: + | { + [variable: string]: string + } + | undefined + cwd?: string | undefined + user?: string | undefined + onStdout?: (chunk: Buffer | string | any) => void + onStderr?: (chunk: Buffer | string | any) => void + sigtermTimeout?: number + }, + ) => { + const startCommand = () => + CommandController.of()( + effects, + subcontainer, + command, + options, + ) + return new Oneshot(startCommand, true, []) + } + } + + onExitSuccess(fn: () => void) { + if (this.exitedSuccess) { + fn() + } else { + this.onExitSuccessFns.push(fn) + } + } +} diff --git a/sdk/package/package-lock.json b/sdk/package/package-lock.json index 83097a9ca..94783fa06 100644 --- a/sdk/package/package-lock.json +++ b/sdk/package/package-lock.json @@ -1,12 +1,12 @@ { "name": "@start9labs/start-sdk", - "version": "0.4.0-beta.12", + "version": "0.4.0-beta.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@start9labs/start-sdk", - "version": "0.4.0-beta.12", + "version": "0.4.0-beta.13", "license": "MIT", "dependencies": { "@iarna/toml": "^3.0.0", diff --git a/sdk/package/package.json b/sdk/package/package.json index b7f936ea1..92c33ed3d 100644 --- a/sdk/package/package.json +++ b/sdk/package/package.json @@ -1,6 +1,6 @@ { "name": "@start9labs/start-sdk", - "version": "0.4.0-beta.12", + "version": "0.4.0-beta.13", "description": "Software development kit to facilitate packaging services for StartOS", "main": "./package/lib/index.js", "types": "./package/lib/index.d.ts",