import { HealthReceipt, Signals } from "../../../base/lib/types" import { HealthCheckResult } from "../health/checkFns" import { Trigger } from "../trigger" import * as T from "../../../base/lib/types" import { Mounts } from "./Mounts" import { ExecSpawnable, MountOptions, SubContainer } from "../util/SubContainer" import { promisify } from "node:util" import * as CP from "node:child_process" export { Daemon } from "./Daemon" export { CommandController } from "./CommandController" import { HealthDaemon } from "./HealthDaemon" import { Daemon } from "./Daemon" import { CommandController } from "./CommandController" export const cpExec = promisify(CP.exec) export const cpExecFile = promisify(CP.execFile) export type Ready = { /** A human-readable display name for the health check. If null, the health check itself will be from the UI */ display: string | null /** * @description The function to determine the health status of the daemon * * The SDK provides some built-in health checks. To see them, type sdk.healthCheck. * * @example * ``` fn: () => sdk.healthCheck.checkPortListening(effects, 80, { successMessage: 'service listening on port 80', errorMessage: 'service is unreachable', }) * ``` */ fn: ( spawnable: ExecSpawnable, ) => Promise | HealthCheckResult trigger?: Trigger } type DaemonsParams< Manifest extends T.SDKManifest, Ids extends string, Command 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: | { /** The ID of the image. Must be one of the image IDs declared in the manifest */ id: keyof Manifest["images"] & T.ImageId /** * Whether or not to share the `/run` directory with the parent container. * This is useful if you are trying to connect to a service that exposes a unix domain socket or auth cookie via the `/run` directory */ sharedRun?: boolean } | SubContainer /** For mounting the necessary volumes. Syntax: sdk.Mounts.of().addVolume() */ mounts: Mounts 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 } type ErrorDuplicateId = `The id '${Id}' is already used` export const runCommand = () => CommandController.of() /** * A class for defining and controlling the service daemons ```ts Daemons.of({ effects, started, interfaceReceipt, // Provide the interfaceReceipt to prove it was completed healthReceipts, // Provide the healthReceipts or [] to prove they were at least considered }).addDaemon('webui', { command: 'hello-world', // The command to start the daemon ready: { display: 'Web Interface', // The function to run to determine the health status of the daemon fn: () => checkPortListening(effects, 80, { successMessage: 'The web interface is ready', errorMessage: 'The web interface is not ready', }), }, requires: [], }) ``` */ export class Daemons implements T.DaemonBuildable { private constructor( readonly effects: T.Effects, readonly started: (onTerm: () => PromiseLike) => PromiseLike, readonly daemons: Promise[], readonly ids: Ids[], readonly healthDaemons: HealthDaemon[], ) {} /** * Returns an empty new Daemons class with the provided inputSpec. * * Call .addDaemon() on the returned class to add a daemon. * * Daemons run in the order they are defined, with latter daemons being capable of * depending on prior daemons * @param options * @returns */ static of(options: { effects: T.Effects started: (onTerm: () => PromiseLike) => PromiseLike healthReceipts: HealthReceipt[] }) { return new Daemons( options.effects, options.started, [], [], [], ) } /** * Returns the complete list of daemons, including the one defined here * @param id * @param newDaemon * @returns */ addDaemon( // prettier-ignore id: "" extends Id ? never : ErrorDuplicateId extends Id ? never : Id extends Ids ? ErrorDuplicateId : Id, options: DaemonsParams, ) { const daemonIndex = this.daemons.length const daemon = Daemon.of()( this.effects, options.subcontainer, options.command, { ...options, mounts: options.mounts.build(), subcontainerName: id, }, ) const healthDaemon = new HealthDaemon( daemon, daemonIndex, options.requires .map((x) => this.ids.indexOf(id as any)) .filter((x) => x >= 0) .map((id) => this.healthDaemons[id]), id, this.ids, options.ready, this.effects, options.sigtermTimeout, ) 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, ) } async build() { const built = { term: async () => { try { for (let result of await Promise.allSettled( this.healthDaemons.map((x) => x.term({ timeout: x.sigtermTimeout }), ), )) { if (result.status === "rejected") { console.error(result.reason) } } } finally { this.effects.setMainStatus({ status: "stopped" }) } }, } this.started(() => built.term()) return built } }