sdk comments

This commit is contained in:
Matt Hill
2026-02-05 08:10:53 -07:00
parent 58e0b166cb
commit 9519684185
37 changed files with 3603 additions and 150 deletions

View File

@@ -67,24 +67,83 @@ import {
import { getOwnServiceInterfaces } from "../../base/lib/util/getServiceInterfaces"
import { Volumes, createVolumes } from "./util/Volume"
/**
* The minimum StartOS version this SDK is compatible with.
* Used internally for version checking.
*/
export const OSVersion = testTypeVersion("0.4.0-alpha.19")
/** @internal Helper type for conditional type resolution */
// prettier-ignore
type AnyNeverCond<T extends any[], Then, Else> =
type AnyNeverCond<T extends any[], Then, Else> =
T extends [] ? Else :
T extends [never, ...Array<any>] ? Then :
T extends [any, ...infer U] ? AnyNeverCond<U,Then, Else> :
never
/**
* The main SDK class for building StartOS service packages.
*
* StartSdk provides a fluent API for creating services with type-safe access to:
* - Service manifest and volumes
* - Actions (user-callable operations)
* - Health checks and daemon management
* - Network interfaces
* - Dependency management
* - Backup/restore functionality
* - Container management (SubContainer)
*
* @typeParam Manifest - The service manifest type, providing type safety for volumes, images, and dependencies
*
* @example
* ```typescript
* // In sdk.ts - create and export the SDK instance
* import { StartSdk } from '@start9labs/start-sdk'
* import { manifest } from './manifest'
*
* export const sdk = StartSdk.of().withManifest(manifest).build(true)
*
* // Now use sdk throughout your package:
* // sdk.volumes.main - type-safe access to volumes
* // sdk.Action.withInput(...) - create actions
* // sdk.Daemons.of(effects) - create daemon managers
* // sdk.SubContainer.of(...) - create containers
* ```
*/
export class StartSdk<Manifest extends T.SDKManifest> {
private constructor(readonly manifest: Manifest) {}
/**
* Creates a new StartSdk builder instance.
* Call `.withManifest()` next to attach your service manifest.
*
* @returns A new StartSdk instance (uninitialized)
*/
static of() {
return new StartSdk<never>(null as never)
}
/**
* Attaches a manifest to the SDK, enabling type-safe access to
* volumes, images, and dependencies defined in the manifest.
*
* @typeParam Manifest - The manifest type
* @param manifest - The service manifest object
* @returns A new StartSdk instance with the manifest attached
*/
withManifest<Manifest extends T.SDKManifest = never>(manifest: Manifest) {
return new StartSdk<Manifest>(manifest)
}
/**
* Builds the final SDK instance with all utilities and helpers.
*
* This must be called after `.withManifest()` to get the usable SDK object.
* The `isReady` parameter is a type-level check that ensures a manifest was provided.
*
* @param isReady - Pass `true` (only compiles if manifest was provided)
* @returns The complete SDK object with all methods and utilities
*/
build(isReady: AnyNeverCond<[Manifest], "Build not ready", true>) {
type NestedEffects = "subcontainer" | "store" | "action"
type InterfaceEffects =
@@ -741,6 +800,45 @@ export class StartSdk<Manifest extends T.SDKManifest> {
}
}
/**
* Runs a command in a temporary container and returns the output.
*
* This is a convenience function for one-off command execution.
* For long-running processes or multiple commands, use `sdk.SubContainer` instead.
*
* @typeParam Manifest - The service manifest type
*
* @param effects - Effects instance for system operations
* @param image - The container image to use
* @param image.imageId - Image ID from the manifest's images
* @param image.sharedRun - Whether to share the run directory with other containers
* @param command - The command to execute (string, array, or UseEntrypoint)
* @param options - Execution options including mounts and environment
* @param options.mounts - Volume mounts for the container (or null for none)
* @param name - Optional name for debugging/logging
* @returns Promise resolving to stdout and stderr from the command
* @throws ExitError if the command exits with a non-zero code or signal
*
* @example
* ```typescript
* // Run a simple command
* const result = await runCommand(
* effects,
* { imageId: 'main' },
* ['echo', 'Hello, World!'],
* { mounts: null }
* )
* console.log(result.stdout) // "Hello, World!\n"
*
* // Run with volume mounts
* const result = await runCommand(
* effects,
* { imageId: 'main' },
* ['cat', '/data/config.json'],
* { mounts: sdk.Mounts.of().mountVolume({ volumeId: 'main', mountpoint: '/data' }) }
* )
* ```
*/
export async function runCommand<Manifest extends T.SDKManifest>(
effects: Effects,
image: { imageId: keyof Manifest["images"] & T.ImageId; sharedRun?: boolean },

View File

@@ -1,3 +1,27 @@
/**
* @module Backups
*
* Provides backup and restore functionality for StartOS services.
* The Backups class uses rsync to efficiently synchronize service data
* to and from backup destinations.
*
* @example
* ```typescript
* // Simple backup of all volumes
* export const createBackup = Backups.ofVolumes<Manifest>('main', 'config')
*
* // Advanced backup with hooks
* export const createBackup = Backups.ofVolumes<Manifest>('main')
* .setPreBackup(async (effects) => {
* // Stop accepting writes before backup
* await stopService()
* })
* .setPostBackup(async (effects) => {
* // Resume after backup
* await startService()
* })
* ```
*/
import * as T from "../../../base/lib/types"
import * as child_process from "child_process"
import * as fs from "fs/promises"
@@ -5,32 +29,113 @@ import { Affine, asError } from "../util"
import { ExtendedVersion, VersionRange } from "../../../base/lib"
import { InitKind, InitScript } from "../../../base/lib/inits"
/**
* Default sync options for backup/restore operations.
* - `delete: true` - Remove files in destination that don't exist in source
* - `exclude: []` - No exclusions by default
*/
export const DEFAULT_OPTIONS: T.SyncOptions = {
delete: true,
exclude: [],
}
/**
* Configuration for a single backup synchronization operation.
* Maps a source data path to a backup destination path.
*
* @typeParam Volumes - The volume ID type from the manifest
*/
export type BackupSync<Volumes extends string> = {
/** Source path on the data volume (e.g., "/media/startos/volumes/main/data") */
dataPath: `/media/startos/volumes/${Volumes}/${string}`
/** Destination path in the backup (e.g., "/media/startos/backup/volumes/main/") */
backupPath: `/media/startos/backup/${string}`
/** Sync options applied to both backup and restore */
options?: Partial<T.SyncOptions>
/** Sync options applied only during backup (merged with options) */
backupOptions?: Partial<T.SyncOptions>
/** Sync options applied only during restore (merged with options) */
restoreOptions?: Partial<T.SyncOptions>
}
/**
* Effects type with backup context marker.
* Provides type safety to prevent backup operations in non-backup contexts.
*/
export type BackupEffects = T.Effects & Affine<"Backups">
/**
* Manages backup and restore operations for a StartOS service.
*
* Exposed via `sdk.Backups`. The Backups class provides a fluent API for
* configuring which volumes to back up and optional hooks for pre/post
* backup/restore operations. It uses rsync for efficient incremental backups.
*
* Common usage patterns:
* - Simple: `sdk.Backups.ofVolumes('main')` - Back up the main volume
* - Multiple volumes: `sdk.Backups.ofVolumes('main', 'config', 'logs')`
* - With hooks: Add pre/post callbacks for database dumps, service stops, etc.
* - Custom paths: Use `addSync()` for non-standard backup mappings
*
* @typeParam M - The service manifest type for type-safe volume names
*
* @example
* ```typescript
* // In your package's exports:
* export const createBackup = Backups.ofVolumes<Manifest>('main', 'config')
*
* // With database dump before backup
* export const createBackup = Backups.ofVolumes<Manifest>('main')
* .setPreBackup(async (effects) => {
* // Create a database dump before backing up files
* await subcontainer.exec(['pg_dump', '-f', '/data/backup.sql'])
* })
*
* // Exclude temporary files
* export const createBackup = Backups.withOptions({ exclude: ['*.tmp', 'cache/'] })
* .addVolume('main')
* ```
*/
export class Backups<M extends T.SDKManifest> implements InitScript {
private constructor(
/** @internal Default sync options */
private options = DEFAULT_OPTIONS,
/** @internal Options specific to restore operations */
private restoreOptions: Partial<T.SyncOptions> = {},
/** @internal Options specific to backup operations */
private backupOptions: Partial<T.SyncOptions> = {},
/** @internal Set of sync configurations */
private backupSet = [] as BackupSync<M["volumes"][number]>[],
/** @internal Hook called before backup starts */
private preBackup = async (effects: BackupEffects) => {},
/** @internal Hook called after backup completes */
private postBackup = async (effects: BackupEffects) => {},
/** @internal Hook called before restore starts */
private preRestore = async (effects: BackupEffects) => {},
/** @internal Hook called after restore completes */
private postRestore = async (effects: BackupEffects) => {},
) {}
/**
* Creates a Backups instance configured to back up the specified volumes.
* This is the most common way to create a backup configuration.
*
* Each volume is backed up to a corresponding path in the backup destination
* using the volume's name as the subdirectory.
*
* @typeParam M - The manifest type (inferred from volume names)
* @param volumeNames - Volume IDs to include in backups (from manifest.volumes)
* @returns A configured Backups instance
*
* @example
* ```typescript
* // Back up a single volume
* export const createBackup = Backups.ofVolumes<Manifest>('main')
*
* // Back up multiple volumes
* export const createBackup = Backups.ofVolumes<Manifest>('main', 'config', 'logs')
* ```
*/
static ofVolumes<M extends T.SDKManifest = never>(
...volumeNames: Array<M["volumes"][number]>
): Backups<M> {
@@ -42,18 +147,56 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
)
}
/**
* Creates a Backups instance from explicit sync configurations.
* Use this for custom source/destination path mappings.
*
* @typeParam M - The manifest type
* @param syncs - Array of sync configurations
* @returns A configured Backups instance
*
* @example
* ```typescript
* const backups = Backups.ofSyncs<Manifest>(
* { dataPath: '/media/startos/volumes/main/data', backupPath: '/media/startos/backup/data' },
* { dataPath: '/media/startos/volumes/main/config', backupPath: '/media/startos/backup/config' }
* )
* ```
*/
static ofSyncs<M extends T.SDKManifest = never>(
...syncs: BackupSync<M["volumes"][number]>[]
) {
return syncs.reduce((acc, x) => acc.addSync(x), new Backups<M>())
}
/**
* Creates a Backups instance with custom default sync options.
* Call `addVolume()` or `addSync()` to add volumes after setting options.
*
* @typeParam M - The manifest type
* @param options - Default sync options (merged with DEFAULT_OPTIONS)
* @returns An empty Backups instance with the specified options
*
* @example
* ```typescript
* // Exclude cache and temp files from all backups
* export const createBackup = Backups.withOptions<Manifest>({
* exclude: ['cache/', '*.tmp', '*.log']
* }).addVolume('main')
* ```
*/
static withOptions<M extends T.SDKManifest = never>(
options?: Partial<T.SyncOptions>,
) {
return new Backups<M>({ ...DEFAULT_OPTIONS, ...options })
}
/**
* Sets default sync options for both backup and restore operations.
*
* @param options - Sync options to merge with current defaults
* @returns This instance for chaining
*/
setOptions(options?: Partial<T.SyncOptions>) {
this.options = {
...this.options,
@@ -62,6 +205,13 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
return this
}
/**
* Sets sync options applied only during backup operations.
* These are merged with the default options.
*
* @param options - Backup-specific sync options
* @returns This instance for chaining
*/
setBackupOptions(options?: Partial<T.SyncOptions>) {
this.backupOptions = {
...this.backupOptions,
@@ -70,6 +220,13 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
return this
}
/**
* Sets sync options applied only during restore operations.
* These are merged with the default options.
*
* @param options - Restore-specific sync options
* @returns This instance for chaining
*/
setRestoreOptions(options?: Partial<T.SyncOptions>) {
this.restoreOptions = {
...this.restoreOptions,
@@ -78,26 +235,88 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
return this
}
/**
* Registers a function to run before backup starts.
* Use this to prepare the service for backup (e.g., flush caches,
* create database dumps, pause writes).
*
* @param fn - Async function to run before backup
* @returns This instance for chaining
*
* @example
* ```typescript
* Backups.ofVolumes<Manifest>('main')
* .setPreBackup(async (effects) => {
* // Flush database to disk
* await db.checkpoint()
* })
* ```
*/
setPreBackup(fn: (effects: BackupEffects) => Promise<void>) {
this.preBackup = fn
return this
}
/**
* Registers a function to run after backup completes.
* Use this to resume normal operations after backup.
*
* @param fn - Async function to run after backup
* @returns This instance for chaining
*/
setPostBackup(fn: (effects: BackupEffects) => Promise<void>) {
this.postBackup = fn
return this
}
/**
* Registers a function to run before restore starts.
* Use this to prepare for incoming data (e.g., stop services,
* clear existing data).
*
* @param fn - Async function to run before restore
* @returns This instance for chaining
*/
setPreRestore(fn: (effects: BackupEffects) => Promise<void>) {
this.preRestore = fn
return this
}
/**
* Registers a function to run after restore completes.
* Use this to finalize restore (e.g., run migrations, rebuild indexes).
*
* @param fn - Async function to run after restore
* @returns This instance for chaining
*
* @example
* ```typescript
* Backups.ofVolumes<Manifest>('main')
* .setPostRestore(async (effects) => {
* // Rebuild search indexes after restore
* await rebuildIndexes()
* })
* ```
*/
setPostRestore(fn: (effects: BackupEffects) => Promise<void>) {
this.postRestore = fn
return this
}
/**
* Adds a volume to the backup configuration.
*
* @param volume - Volume ID from the manifest
* @param options - Optional sync options for this specific volume
* @returns This instance for chaining
*
* @example
* ```typescript
* Backups.withOptions<Manifest>({ exclude: ['*.tmp'] })
* .addVolume('main')
* .addVolume('logs', { backupOptions: { exclude: ['*.log'] } })
* ```
*/
addVolume(
volume: M["volumes"][number],
options?: Partial<{
@@ -113,11 +332,30 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
})
}
/**
* Adds a custom sync configuration to the backup.
* Use this for non-standard path mappings.
*
* @param sync - Sync configuration with source and destination paths
* @returns This instance for chaining
*/
addSync(sync: BackupSync<M["volumes"][0]>) {
this.backupSet.push(sync)
return this
}
/**
* Creates a backup by syncing all configured volumes to the backup destination.
* Called by StartOS when the user initiates a backup.
*
* Execution order:
* 1. Runs preBackup hook
* 2. Syncs each volume using rsync
* 3. Saves the data version to the backup
* 4. Runs postBackup hook
*
* @param effects - Effects instance for system operations
*/
async createBackup(effects: T.Effects) {
await this.preBackup(effects as BackupEffects)
for (const item of this.backupSet) {
@@ -143,12 +381,30 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
return
}
/**
* InitScript implementation - handles restore during initialization.
* Called automatically during the init phase when kind is "restore".
*
* @param effects - Effects instance
* @param kind - The initialization kind (only acts on "restore")
*/
async init(effects: T.Effects, kind: InitKind): Promise<void> {
if (kind === "restore") {
await this.restoreBackup(effects)
}
}
/**
* Restores data from a backup by syncing from backup destination to volumes.
*
* Execution order:
* 1. Runs preRestore hook
* 2. Syncs each volume from backup using rsync
* 3. Restores the data version from the backup
* 4. Runs postRestore hook
*
* @param effects - Effects instance for system operations
*/
async restoreBackup(effects: T.Effects) {
this.preRestore(effects as BackupEffects)
@@ -176,6 +432,13 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
}
}
/**
* Executes rsync to synchronize files between source and destination.
*
* @param rsyncOptions - Configuration for the rsync operation
* @returns Object with methods to get process ID, wait for completion, and check progress
* @internal
*/
async function runRsync(rsyncOptions: {
srcPath: string
dstPath: string

View File

@@ -1,3 +1,9 @@
/**
* @module HealthCheck
*
* Provides the core health check management class that runs periodic health checks
* and reports results to the StartOS host.
*/
import { Effects, HealthCheckId } from "../../../base/lib/types"
import { HealthCheckResult } from "./checkFns/HealthCheckResult"
import { Trigger } from "../trigger"
@@ -6,24 +12,102 @@ import { defaultTrigger } from "../trigger/defaultTrigger"
import { once, asError, Drop } from "../util"
import { object, unknown } from "ts-matches"
/**
* Configuration options for creating a health check.
*
* @example
* ```typescript
* const params: HealthCheckParams = {
* id: 'main',
* name: 'Main Service',
* gracePeriod: 30000, // 30s grace period
* trigger: cooldownTrigger(5000), // Check every 5s
* fn: async () => {
* const isHealthy = await checkService()
* return {
* result: isHealthy ? 'success' : 'failure',
* message: isHealthy ? 'Service running' : 'Service not responding'
* }
* }
* }
* ```
*/
export type HealthCheckParams = {
/** Unique identifier for this health check (e.g., 'main', 'rpc', 'database') */
id: HealthCheckId
/** Human-readable name displayed in the StartOS UI */
name: string
/**
* Trigger controlling when the health check runs.
* @default defaultTrigger (1s before first success, 30s after)
*/
trigger?: Trigger
/**
* Time in milliseconds during which failures are reported as "starting" instead.
* This prevents false failure alerts during normal service startup.
* @default 10000 (10 seconds)
*/
gracePeriod?: number
/**
* The health check function. Called periodically according to the trigger.
* Should return (or resolve to) a HealthCheckResult with result and message.
*/
fn(): Promise<HealthCheckResult> | HealthCheckResult
}
/**
* Manages periodic health check execution for a service.
*
* HealthCheck runs a check function according to a trigger schedule and reports
* results to StartOS. It handles:
* - Grace period logic (failures during startup report as "starting")
* - Trigger-based scheduling (adjustable check frequency)
* - Error handling (exceptions become failure results)
* - Start/stop lifecycle management
*
* Usually created indirectly via `Daemons.addDaemon()` or `Daemons.addHealthCheck()`,
* but can be created directly with `HealthCheck.of()` for advanced use cases.
*
* @example
* ```typescript
* // Direct creation (advanced usage)
* const check = HealthCheck.of(effects, {
* id: 'database',
* name: 'Database Connection',
* gracePeriod: 20000,
* trigger: cooldownTrigger(10000),
* fn: async () => {
* const connected = await db.ping()
* return {
* result: connected ? 'success' : 'failure',
* message: connected ? 'Connected' : 'Cannot reach database'
* }
* }
* })
*
* // Start checking (usually tied to daemon start)
* check.start()
*
* // Stop checking (usually tied to daemon stop)
* check.stop()
* ```
*/
export class HealthCheck extends Drop {
/** @internal Timestamp when the service was started (null if stopped) */
private started: number | null = null
/** @internal Callback to update started state and wake the check loop */
private setStarted = (started: number | null) => {
this.started = started
}
/** @internal Flag indicating the check loop should exit */
private exited = false
/** @internal Callback to signal the check loop to exit */
private exit = () => {
this.exited = true
}
/** @internal Current trigger input state */
private currentValue: TriggerInput = {}
/** @internal Promise representing the running check loop */
private promise: Promise<void>
private constructor(effects: Effects, o: HealthCheckParams) {
super()
@@ -92,22 +176,60 @@ export class HealthCheck extends Drop {
}
})
}
/**
* Creates a new HealthCheck instance.
*
* @param effects - Effects instance for communicating with StartOS
* @param options - Health check configuration
* @returns A new HealthCheck instance (initially stopped)
*
* @example
* ```typescript
* const check = HealthCheck.of(effects, {
* id: 'main',
* name: 'Main',
* fn: () => ({ result: 'success', message: 'OK' })
* })
* ```
*/
static of(effects: Effects, options: HealthCheckParams): HealthCheck {
return new HealthCheck(effects, options)
}
/**
* Starts the health check loop.
* The check function will begin executing according to the trigger schedule.
* Has no effect if already started.
*/
start() {
if (this.started) return
this.setStarted(performance.now())
}
/**
* Stops the health check loop.
* The check function will stop executing until `start()` is called again.
* Has no effect if already stopped.
*/
stop() {
if (!this.started) return
this.setStarted(null)
}
/**
* Called when the HealthCheck is being disposed.
* Signals the check loop to exit permanently.
* @internal
*/
onDrop(): void {
this.exit()
}
}
/**
* Extracts an error message from an unknown error value.
* @internal
*/
function asMessage(e: unknown) {
if (object({ message: unknown }).test(e)) return String(e.message)
const value = String(e)

View File

@@ -1,3 +1,30 @@
import { T } from "../../../../base/lib"
/**
* The result returned by a health check function.
*
* Contains the status result and a message describing the current state.
* The `name` field is added automatically by the health check system.
*
* @example
* ```typescript
* // Success result
* const healthy: HealthCheckResult = {
* result: 'success',
* message: 'Server responding on port 8080'
* }
*
* // Failure result
* const unhealthy: HealthCheckResult = {
* result: 'failure',
* message: 'Connection refused on port 8080'
* }
*
* // Starting result (usually set automatically by grace period)
* const starting: HealthCheckResult = {
* result: 'starting',
* message: 'Waiting for server to initialize...'
* }
* ```
*/
export type HealthCheckResult = Omit<T.NamedHealthCheckResult, "name">

View File

@@ -6,6 +6,15 @@ import * as CP from "node:child_process"
const cpExec = promisify(CP.exec)
/**
* Parses /proc/net/tcp* or /proc/net/udp* output to check if a port is listening.
*
* @param x - Raw content from /proc/net/tcp or similar file
* @param port - Port number to look for
* @param address - Optional specific address to match (undefined matches any)
* @returns True if the port is found in the listening sockets
* @internal
*/
export function containsAddress(x: string, port: number, address?: bigint) {
const readPorts = x
.split("\n")
@@ -20,8 +29,42 @@ export function containsAddress(x: string, port: number, address?: bigint) {
}
/**
* This is used to check if a port is listening on the system.
* Used during the health check fn or the check main fn.
* Checks if a specific port is listening on the local system.
*
* This is a low-level health check that reads from /proc/net/ to determine
* if a service is listening on a port. It checks both TCP and UDP, on both
* IPv4 and IPv6 interfaces.
*
* This is useful for services where you want to verify the server process
* has started and is accepting connections, even if it's not yet responding
* to application-level requests.
*
* @param effects - Effects instance (currently unused but included for API consistency)
* @param port - The port number to check
* @param options.successMessage - Message to include when the port is listening
* @param options.errorMessage - Message to include when the port is not listening
* @param options.timeoutMessage - Message when the check times out (default: auto-generated)
* @param options.timeout - Maximum time to wait for the check in milliseconds (default: 1000)
* @returns Promise resolving to a HealthCheckResult
*
* @example
* ```typescript
* // Check if PostgreSQL is listening on port 5432
* const check = () => checkPortListening(effects, 5432, {
* successMessage: 'PostgreSQL is accepting connections',
* errorMessage: 'PostgreSQL is not listening on port 5432'
* })
*
* // Use in health check config
* daemons.addHealthCheck({
* id: 'database',
* name: 'Database Port',
* fn: () => checkPortListening(effects, 5432, {
* successMessage: 'Database listening',
* errorMessage: 'Database not responding'
* })
* })
* ```
*/
export async function checkPortListening(
effects: Effects,

View File

@@ -5,10 +5,41 @@ import { timeoutPromise } from "./index"
import "isomorphic-fetch"
/**
* This is a helper function to check if a web url is reachable.
* @param url
* @param createSuccess
* @returns
* Checks if a web URL is reachable by making an HTTP request.
*
* This is useful for services that expose an HTTP health endpoint
* or for checking if a web UI is responding.
*
* Note: This only checks if the request completes without network errors.
* It does NOT check the HTTP status code - a 500 error response would
* still be considered "success" since the server responded.
*
* @param effects - Effects instance (currently unused but included for API consistency)
* @param url - The URL to fetch (e.g., "http://localhost:8080/health")
* @param options.timeout - Maximum time to wait for a response in milliseconds (default: 1000)
* @param options.successMessage - Message to include when check succeeds
* @param options.errorMessage - Message to include when check fails
* @returns Promise resolving to a HealthCheckResult
*
* @example
* ```typescript
* // Basic usage
* const check = () => checkWebUrl(effects, 'http://localhost:8080/health')
*
* // With custom options
* const check = () => checkWebUrl(effects, 'http://localhost:3000', {
* timeout: 5000,
* successMessage: 'Web UI is responding',
* errorMessage: 'Cannot reach web UI'
* })
*
* // Use in health check config
* daemons.addHealthCheck({
* id: 'web',
* name: 'Web Interface',
* fn: () => checkWebUrl(effects, 'http://localhost:8080')
* })
* ```
*/
export const checkWebUrl = async (
effects: Effects,

View File

@@ -1,8 +1,55 @@
/**
* @module checkFns
*
* Provides pre-built health check functions for common use cases.
* These can be used directly in health check configurations or as building blocks
* for custom health checks.
*
* Available functions:
* - `checkPortListening` - Check if a port is open and listening
* - `checkWebUrl` - Check if a web URL is reachable
* - `runHealthScript` - Run a shell script and check its exit code
* - `timeoutPromise` - Helper to add timeouts to async operations
*
* @example
* ```typescript
* import { checkPortListening, checkWebUrl } from '@start9labs/sdk'
*
* // Check if port 8080 is listening
* const portCheck = () => checkPortListening(effects, 8080, {
* successMessage: 'Server is listening',
* errorMessage: 'Server not responding'
* })
*
* // Check if web UI is reachable
* const webCheck = () => checkWebUrl(effects, 'http://localhost:8080/health', {
* timeout: 5000,
* successMessage: 'Web UI is up'
* })
* ```
*/
import { runHealthScript } from "./runHealthScript"
export { checkPortListening } from "./checkPortListening"
export { HealthCheckResult } from "./HealthCheckResult"
export { checkWebUrl } from "./checkWebUrl"
/**
* Creates a promise that rejects after the specified timeout.
* Useful for adding timeouts to async operations using `Promise.race()`.
*
* @param ms - Timeout duration in milliseconds
* @param options.message - Error message when timeout occurs
* @returns A promise that rejects after `ms` milliseconds
*
* @example
* ```typescript
* // Add a 5-second timeout to an async operation
* const result = await Promise.race([
* someAsyncOperation(),
* timeoutPromise(5000, { message: 'Operation timed out' })
* ])
* ```
*/
export function timeoutPromise(ms: number, { message = "Timed out" } = {}) {
return new Promise<never>((resolve, reject) =>
setTimeout(() => reject(new Error(message)), ms),

View File

@@ -4,11 +4,47 @@ import { SubContainer } from "../../util/SubContainer"
import { SDKManifest } from "../../types"
/**
* Running a health script, is used when we want to have a simple
* script in bash or something like that. It should return something that is useful
* in {result: string} else it is considered an error
* @param param0
* @returns
* Runs a command inside a subcontainer and uses the exit code for health status.
*
* This is useful when the service provides a CLI health check command or when
* you want to run a custom bash script to determine health status. The command
* must exit with code 0 for success; any other exit code is treated as failure.
*
* @typeParam Manifest - The service manifest type
* @param runCommand - Command and arguments to execute (e.g., ['pg_isready', '-U', 'postgres'])
* @param subcontainer - The SubContainer to run the command in
* @param options.timeout - Maximum time to wait for the command in milliseconds (default: 30000)
* @param options.errorMessage - Message to include when the command fails
* @param options.message - Function to generate success message from stdout
* @returns Promise resolving to a HealthCheckResult
* @throws HealthCheckResult with result: "failure" if the command fails or times out
*
* @example
* ```typescript
* // Check PostgreSQL readiness using pg_isready
* const check = () => runHealthScript(
* ['pg_isready', '-U', 'postgres'],
* subcontainer,
* {
* timeout: 5000,
* errorMessage: 'PostgreSQL is not ready'
* }
* )
*
* // Custom bash health check
* const check = () => runHealthScript(
* ['bash', '-c', 'curl -sf http://localhost:8080/health || exit 1'],
* subcontainer,
* { errorMessage: 'Health endpoint check failed' }
* )
*
* // Use in health check config
* daemons.addHealthCheck({
* id: 'cli',
* name: 'CLI Health Check',
* fn: () => runHealthScript(['myapp', 'healthcheck'], subcontainer)
* })
* ```
*/
export const runHealthScript = async <Manifest extends SDKManifest>(
runCommand: string[],

View File

@@ -1,3 +1,34 @@
/**
* @module Daemons
*
* This module provides the Daemons class for managing service processes (daemons)
* and their health checks. Daemons are long-running processes that make up the
* core functionality of a StartOS service.
*
* @example
* ```typescript
* // Basic daemon setup
* export const main = sdk.setupMain(async ({ effects }) => {
* const container = await sdk.SubContainer.of(effects, { imageId: 'main' }, mounts, 'main')
*
* return sdk.Daemons.of(effects)
* .addDaemon('primary', {
* subcontainer: container,
* exec: { command: ['my-server', '--config', '/data/config.json'] },
* ready: {
* display: 'Server',
* fn: () => sdk.healthCheck.checkPortListening(effects, 8080, {
* successMessage: 'Server is ready',
* errorMessage: 'Server is not responding',
* }),
* gracePeriod: 30000, // 30 second startup grace period
* },
* requires: [],
* })
* })
* ```
*/
import { Signals } from "../../../base/lib/types"
import { HealthCheckResult } from "../health/checkFns"
@@ -16,48 +47,124 @@ import { Daemon } from "./Daemon"
import { CommandController } from "./CommandController"
import { Oneshot } from "./Oneshot"
/** @internal Promisified child process exec */
export const cpExec = promisify(CP.exec)
/** @internal Promisified child process execFile */
export const cpExecFile = promisify(CP.execFile)
/**
* Configuration for a daemon's readiness/health check.
*
* Health checks determine when a daemon is considered "ready" and report
* status to the StartOS UI. They run periodically and can be customized
* with grace periods and triggers.
*/
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: () => Promise<HealthCheckResult> | HealthCheckResult
/**
* A duration in milliseconds to treat a failing health check as "starting"
* Human-readable display name for the health check shown in the UI.
* If null, the health check will not be visible in the UI.
*
* defaults to 5000
* @example "Web Interface"
* @example "Database Connection"
*/
display: string | null
/**
* Function that determines the health status of the daemon.
*
* The SDK provides built-in health checks:
* - `sdk.healthCheck.checkPortListening()` - Check if a port is listening
* - `sdk.healthCheck.checkWebUrl()` - Check if an HTTP endpoint responds
* - `sdk.healthCheck.runHealthScript()` - Run a custom health check script
*
* @returns HealthCheckResult with status ("success", "failure", or "starting") and message
*
* @example
* ```typescript
* fn: () => sdk.healthCheck.checkPortListening(effects, 8080, {
* successMessage: 'Server is ready',
* errorMessage: 'Server is not responding',
* })
* ```
*
* @example
* ```typescript
* // Custom health check
* fn: async () => {
* const result = await container.exec(['my-health-check'])
* return result.exitCode === 0
* ? { result: 'success', message: 'Healthy' }
* : { result: 'failure', message: 'Unhealthy' }
* }
* ```
*/
fn: () => Promise<HealthCheckResult> | HealthCheckResult
/**
* Duration in milliseconds to treat a failing health check as "starting" instead of "failure".
*
* This gives the daemon time to initialize before health check failures are reported.
* After the grace period expires, failures will be reported normally.
*
* @default 5000 (5 seconds)
*
* @example 30000 // 30 second startup time
* @example 120000 // 2 minutes for slow-starting services
*/
gracePeriod?: number
/**
* Optional trigger configuration for when to run the health check.
* If not specified, uses the default trigger (periodic checks).
*
* @see defaultTrigger, cooldownTrigger, changeOnFirstSuccess, successFailure
*/
trigger?: Trigger
}
/**
* Options for executing a command as a daemon.
*/
export type ExecCommandOptions = {
/** The command to execute (string, array, or UseEntrypoint) */
command: T.CommandType
// Defaults to the DEFAULT_SIGTERM_TIMEOUT = 30_000ms
/**
* Timeout in milliseconds to wait for graceful shutdown after sending SIGTERM.
* After this timeout, SIGKILL will be sent.
* @default 30000 (30 seconds)
*/
sigtermTimeout?: number
/**
* If true, run the command as PID 1 (init process).
* This affects signal handling and zombie process reaping.
*/
runAsInit?: boolean
/** Environment variables to set for the process */
env?:
| {
[variable in string]?: string
}
| undefined
/** Working directory for the process */
cwd?: string | undefined
/** User to run the process as (e.g., "root", "nobody") */
user?: string | undefined
/**
* Callback invoked for each chunk of stdout output.
* Useful for logging or monitoring process output.
*/
onStdout?: (chunk: Buffer | string | any) => void
/**
* Callback invoked for each chunk of stderr output.
* Useful for logging or monitoring process errors.
*/
onStderr?: (chunk: Buffer | string | any) => void
}
@@ -127,31 +234,61 @@ type AddHealthCheckParams<Ids extends string, Id extends string> = {
type ErrorDuplicateId<Id extends string> = `The id '${Id}' is already used`
/** @internal Helper to create a CommandController */
export const runCommand = <Manifest extends T.SDKManifest>() =>
CommandController.of<Manifest, SubContainer<Manifest>>()
/**
* 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: [],
})
```
* Manager class for defining and controlling service daemons.
*
* Exposed via `sdk.Daemons`. Daemons are long-running processes that make up
* your service. The Daemons class provides a fluent API for:
* - Defining multiple daemons with dependencies between them
* - Configuring health checks for each daemon
* - Managing startup order based on dependency requirements
* - Handling graceful shutdown in reverse dependency order
*
* @typeParam Manifest - The service manifest type
* @typeParam Ids - Union type of all daemon IDs (accumulates as daemons are added)
*
* @example
* ```typescript
* // Single daemon service
* return sdk.Daemons.of(effects)
* .addDaemon('primary', {
* subcontainer,
* exec: { command: sdk.useEntrypoint() },
* ready: {
* display: 'Server',
* fn: () => sdk.healthCheck.checkPortListening(effects, 8080, { ... }),
* },
* requires: [],
* })
* ```
*
* @example
* ```typescript
* // Multi-daemon service with dependencies
* return sdk.Daemons.of(effects)
* .addDaemon('database', {
* subcontainer: dbContainer,
* exec: { command: ['postgres', '-D', '/data'] },
* ready: { display: 'Database', fn: checkDbReady },
* requires: [], // No dependencies
* })
* .addDaemon('api', {
* subcontainer: apiContainer,
* exec: { command: ['node', 'server.js'] },
* ready: { display: 'API Server', fn: checkApiReady },
* requires: ['database'], // Waits for database to be ready
* })
* .addDaemon('worker', {
* subcontainer: workerContainer,
* exec: { command: ['node', 'worker.js'] },
* ready: { display: 'Background Worker', fn: checkWorkerReady },
* requires: ['database', 'api'], // Waits for both
* })
* ```
*/
export class Daemons<Manifest extends T.SDKManifest, Ids extends string>
implements T.DaemonBuildable

View File

@@ -1,18 +1,65 @@
/**
* @module Mounts
*
* This module provides a fluent API for configuring volume mounts for SubContainers.
* The Mounts class uses a builder pattern to accumulate mount configurations that
* are then applied when a container starts.
*
* Mount types supported:
* - **Volumes** - Service-owned data directories defined in the manifest
* - **Assets** - Static files bundled with the service package
* - **Dependencies** - Volumes from other services this service depends on
* - **Backups** - Special mount for backup operations
*
* @example
* ```typescript
* const mounts = Mounts.of<Manifest>()
* .mountVolume({
* volumeId: 'main',
* mountpoint: '/data',
* readonly: false,
* subpath: null
* })
* .mountAssets({
* mountpoint: '/config',
* subpath: 'default-config'
* })
* .mountDependency({
* dependencyId: 'bitcoind',
* volumeId: 'data',
* mountpoint: '/bitcoin',
* readonly: true,
* subpath: null
* })
* .build()
* ```
*/
import * as T from "../../../base/lib/types"
import { IdMap, MountOptions } from "../util/SubContainer"
/**
* Array of mount configurations ready to be applied to a container.
* Each entry maps a mountpoint path to its mount options.
*/
type MountArray = { mountpoint: string; options: MountOptions }[]
/**
* Common options shared across all mount types.
* These options control where and how a resource is mounted into a container.
*/
type SharedOptions = {
/** The path within the resource to mount. Use `null` to mount the entire resource */
subpath: string | null
/** Where to mount the resource. e.g. /data */
/** The absolute path inside the container where the resource will be accessible (e.g., "/data") */
mountpoint: string
/**
* Whether to mount this as a file or directory
* Whether to mount this as a file or directory.
* - `"file"` - Mount a single file
* - `"directory"` - Mount a directory (default)
* - `"infer"` - Automatically detect based on the source
*
* defaults to "directory"
* */
* @default "directory"
*/
type?: "file" | "directory" | "infer"
// /**
// * Whether to map uids/gids for the mount
@@ -33,37 +80,142 @@ type SharedOptions = {
// }[]
}
/**
* Options for mounting one of the service's own volumes.
* Volumes are persistent storage areas defined in the service manifest.
*
* @typeParam Manifest - The service manifest type, used for type-safe volume ID validation
*
* @example
* ```typescript
* {
* volumeId: 'main', // Must match a volume defined in manifest
* mountpoint: '/data', // Where it appears in the container
* readonly: false, // Allow writes
* subpath: null // Mount the entire volume
* }
* ```
*/
type VolumeOpts<Manifest extends T.SDKManifest> = {
/** The ID of the volume to mount. Must be one of the volume IDs defined in the manifest */
volumeId: Manifest["volumes"][number]
/** Whether or not the resource should be readonly for this subcontainer */
/** If true, the volume will be mounted read-only (writes will fail) */
readonly: boolean
} & SharedOptions
/**
* Options for mounting a volume from a dependency service.
* This allows accessing data from services that this service depends on.
*
* @typeParam Manifest - The dependency's manifest type, used for type-safe volume ID validation
*
* @example
* ```typescript
* {
* dependencyId: 'bitcoind', // The dependency's package ID
* volumeId: 'data', // A volume from the dependency's manifest
* mountpoint: '/bitcoin-data', // Where it appears in this container
* readonly: true, // Usually read-only for safety
* subpath: 'blocks' // Optionally mount only a subdirectory
* }
* ```
*/
type DependencyOpts<Manifest extends T.SDKManifest> = {
/** The ID of the dependency */
/** The package ID of the dependency service */
dependencyId: Manifest["id"]
/** The ID of the volume to mount. Must be one of the volume IDs defined in the manifest of the dependency */
/** The ID of the volume to mount from the dependency. Must be defined in the dependency's manifest */
volumeId: Manifest["volumes"][number]
/** Whether or not the resource should be readonly for this subcontainer */
/** If true, the volume will be mounted read-only (writes will fail) */
readonly: boolean
} & SharedOptions
/**
* Fluent builder for configuring container volume mounts.
*
* Exposed via `sdk.Mounts`. The Mounts class uses an immutable builder pattern -
* each method returns a new Mounts instance with the additional configuration,
* leaving the original unchanged. Call `build()` at the end to get the final mount array.
*
* @typeParam Manifest - The service manifest type for volume ID validation
* @typeParam Backups - Type tracking whether backup mounts have been added
*
* @example
* ```typescript
* // Basic usage with a single volume
* const mounts = Mounts.of<Manifest>()
* .mountVolume({
* volumeId: 'main',
* mountpoint: '/data',
* readonly: false,
* subpath: null
* })
* .build()
*
* // Complex setup with multiple mount types
* const mounts = Mounts.of<Manifest>()
* .mountVolume({ volumeId: 'main', mountpoint: '/data', readonly: false, subpath: null })
* .mountVolume({ volumeId: 'logs', mountpoint: '/var/log/app', readonly: false, subpath: null })
* .mountAssets({ mountpoint: '/etc/app', subpath: 'config' })
* .mountDependency<BitcoinManifest>({
* dependencyId: 'bitcoind',
* volumeId: 'data',
* mountpoint: '/bitcoin',
* readonly: true,
* subpath: null
* })
* .build()
* ```
*/
export class Mounts<
Manifest extends T.SDKManifest,
Backups extends SharedOptions = never,
> {
private constructor(
/** @internal Accumulated volume mount configurations */
readonly volumes: VolumeOpts<Manifest>[],
/** @internal Accumulated asset mount configurations */
readonly assets: SharedOptions[],
/** @internal Accumulated dependency mount configurations */
readonly dependencies: DependencyOpts<T.SDKManifest>[],
/** @internal Accumulated backup mount configurations */
readonly backups: Backups[],
) {}
/**
* Creates a new empty Mounts builder.
* This is the starting point for building mount configurations.
*
* @typeParam Manifest - The service manifest type for volume ID validation
* @returns A new empty Mounts builder instance
*
* @example
* ```typescript
* const mounts = Mounts.of<MyManifest>()
* .mountVolume({ ... })
* .build()
* ```
*/
static of<Manifest extends T.SDKManifest>() {
return new Mounts<Manifest>([], [], [], [])
}
/**
* Adds a volume mount to the configuration.
* Volumes are persistent storage areas owned by this service.
*
* @param options - Volume mount configuration
* @returns A new Mounts instance with the volume added
*
* @example
* ```typescript
* mounts.mountVolume({
* volumeId: 'main', // Must exist in manifest.volumes
* mountpoint: '/data', // Container path
* readonly: false, // Allow writes
* subpath: null // Mount entire volume
* })
* ```
*/
mountVolume(options: VolumeOpts<Manifest>) {
return new Mounts<Manifest, Backups>(
[...this.volumes, options],
@@ -73,6 +225,21 @@ export class Mounts<
)
}
/**
* Adds an assets mount to the configuration.
* Assets are static files bundled with the service package (read-only).
*
* @param options - Asset mount configuration
* @returns A new Mounts instance with the asset mount added
*
* @example
* ```typescript
* mounts.mountAssets({
* mountpoint: '/etc/myapp', // Where to mount in container
* subpath: 'default-config' // Subdirectory within assets
* })
* ```
*/
mountAssets(options: SharedOptions) {
return new Mounts<Manifest, Backups>(
[...this.volumes],
@@ -82,6 +249,27 @@ export class Mounts<
)
}
/**
* Adds a dependency volume mount to the configuration.
* This mounts a volume from another service that this service depends on.
*
* @typeParam DependencyManifest - The manifest type of the dependency service
* @param options - Dependency mount configuration
* @returns A new Mounts instance with the dependency mount added
*
* @example
* ```typescript
* import { manifest as bitcoinManifest } from 'bitcoind-startos'
*
* mounts.mountDependency<typeof bitcoinManifest>({
* dependencyId: 'bitcoind',
* volumeId: 'data',
* mountpoint: '/bitcoin',
* readonly: true, // Usually read-only for safety
* subpath: null
* })
* ```
*/
mountDependency<DependencyManifest extends T.SDKManifest>(
options: DependencyOpts<DependencyManifest>,
) {
@@ -93,6 +281,21 @@ export class Mounts<
)
}
/**
* Adds a backup mount to the configuration.
* This is used during backup operations to provide access to the backup destination.
*
* @param options - Backup mount configuration
* @returns A new Mounts instance with the backup mount added
*
* @example
* ```typescript
* mounts.mountBackups({
* mountpoint: '/backup',
* subpath: null
* })
* ```
*/
mountBackups(options: SharedOptions) {
return new Mounts<
Manifest,
@@ -108,6 +311,24 @@ export class Mounts<
)
}
/**
* Finalizes the mount configuration and returns the mount array.
* Validates that no two mounts use the same mountpoint.
*
* @returns Array of mount configurations ready to apply to a container
* @throws Error if the same mountpoint is used more than once
*
* @example
* ```typescript
* const mountArray = Mounts.of<Manifest>()
* .mountVolume({ volumeId: 'main', mountpoint: '/data', readonly: false, subpath: null })
* .mountAssets({ mountpoint: '/config', subpath: null })
* .build()
*
* // Use with SubContainer
* subcontainer.exec({ command: 'myapp', mounts: mountArray })
* ```
*/
build(): MountArray {
const mountpoints = new Set()
for (let mountpoint of this.volumes

View File

@@ -1,5 +1,26 @@
import { HealthStatus } from "../../../base/lib/types"
/**
* Input state provided to trigger functions.
* Contains information about the health check's current state
* that triggers can use to adjust their timing behavior.
*
* @example
* ```typescript
* const myTrigger: Trigger = (getInput) => {
* return (async function* () {
* while (true) {
* const input: TriggerInput = getInput()
* // Check more frequently if last result was failure
* const delay = input.lastResult === 'failure' ? 1000 : 30000
* await new Promise(r => setTimeout(r, delay))
* yield
* }
* })()
* }
* ```
*/
export type TriggerInput = {
/** The result of the most recent health check execution, if any */
lastResult?: HealthStatus
}

View File

@@ -1,5 +1,35 @@
import { Trigger } from "./index"
/**
* Creates a trigger that uses different timing before and after the first successful health check.
*
* This is useful for services that need frequent checks during startup (to quickly report
* when they become healthy) but can reduce check frequency once they're running stably.
*
* The trigger switches permanently to `afterFirstSuccess` timing once a success is seen.
* It does NOT switch back even if the service later becomes unhealthy.
*
* @param o.beforeFirstSuccess - Trigger to use until the first successful health check
* @param o.afterFirstSuccess - Trigger to use after the first successful health check
* @returns A composite trigger that switches behavior after first success
*
* @example
* ```typescript
* // Check every second while starting, every 30 seconds once healthy
* const trigger = changeOnFirstSuccess({
* beforeFirstSuccess: cooldownTrigger(1000), // 1s while starting
* afterFirstSuccess: cooldownTrigger(30000) // 30s after healthy
* })
*
* // Use in a health check
* daemons.addHealthCheck({
* id: 'main',
* name: 'Main Health',
* trigger,
* fn: checkServiceHealth
* })
* ```
*/
export function changeOnFirstSuccess(o: {
beforeFirstSuccess: Trigger
afterFirstSuccess: Trigger

View File

@@ -1,3 +1,25 @@
/**
* Creates a simple timer-based trigger that fires at regular intervals.
* This is the most basic trigger type - it just waits the specified
* time between each health check.
*
* @param timeMs - Interval between health checks in milliseconds
* @returns A trigger factory function
*
* @example
* ```typescript
* // Check health every 5 seconds
* const trigger = cooldownTrigger(5000)
*
* // Use in a health check
* daemons.addHealthCheck({
* id: 'main',
* name: 'Main Check',
* trigger: cooldownTrigger(10000), // Every 10 seconds
* fn: async () => ({ result: 'success', message: 'OK' })
* })
* ```
*/
export function cooldownTrigger(timeMs: number) {
return async function* () {
while (true) {

View File

@@ -1,6 +1,35 @@
import { cooldownTrigger } from "./cooldownTrigger"
import { changeOnFirstSuccess } from "./changeOnFirstSuccess"
/**
* The default trigger used when no custom trigger is specified for a health check.
*
* Provides sensible defaults for most services:
* - **Before first success**: Checks every 1 second (rapid during startup)
* - **After first success**: Checks every 30 seconds (stable once healthy)
*
* This trigger is automatically used by `Daemons.addDaemon()` and `Daemons.addHealthCheck()`
* when no `trigger` option is provided.
*
* @example
* ```typescript
* // These are equivalent - both use defaultTrigger
* daemons.addHealthCheck({
* id: 'main',
* name: 'Main',
* fn: checkHealth
* // trigger: defaultTrigger // implicit
* })
*
* // Custom trigger overrides the default
* daemons.addHealthCheck({
* id: 'main',
* name: 'Main',
* trigger: cooldownTrigger(5000), // Check every 5s instead
* fn: checkHealth
* })
* ```
*/
export const defaultTrigger = changeOnFirstSuccess({
beforeFirstSuccess: cooldownTrigger(1000),
afterFirstSuccess: cooldownTrigger(30000),

View File

@@ -1,7 +1,57 @@
/**
* @module trigger
*
* Triggers control when health checks are executed. They are async generators
* that yield when a health check should run. This allows fine-grained control
* over check frequency based on the service's current state.
*
* Built-in triggers:
* - `cooldownTrigger(ms)` - Simple timer-based trigger
* - `changeOnFirstSuccess` - Different timing before/after first successful check
* - `successFailure` - Different timing based on success/failure state
* - `lastStatus` - Configurable timing per health status
*
* @example
* ```typescript
* // Check every 5 seconds
* const trigger = cooldownTrigger(5000)
*
* // Fast checks until healthy, then slow down
* const adaptiveTrigger = changeOnFirstSuccess({
* beforeFirstSuccess: cooldownTrigger(1000), // 1s while starting
* afterFirstSuccess: cooldownTrigger(30000) // 30s once healthy
* })
* ```
*/
import { TriggerInput } from "./TriggerInput"
export { changeOnFirstSuccess } from "./changeOnFirstSuccess"
export { cooldownTrigger } from "./cooldownTrigger"
/**
* A trigger function that controls when health checks execute.
*
* Triggers are async generator factories. Given a function to get the current
* input state (e.g., last health result), they return an async iterator that
* yields when a health check should run.
*
* @param getInput - Function returning the current trigger input state
* @returns An async iterator that yields when a check should run
*
* @example
* ```typescript
* // Custom trigger that checks every 10s during success, 2s during failure
* const myTrigger: Trigger = (getInput) => {
* return (async function* () {
* while (true) {
* const { lastResult } = getInput()
* const delay = lastResult === 'success' ? 10000 : 2000
* await new Promise(r => setTimeout(r, delay))
* yield
* }
* })()
* }
* ```
*/
export type Trigger = (
getInput: () => TriggerInput,
) => AsyncIterator<unknown, unknown, never>

View File

@@ -1,10 +1,39 @@
import { Trigger } from "."
import { HealthStatus } from "../../../base/lib/types"
/**
* Configuration for status-based trigger selection.
* Maps health statuses to triggers, with a required default fallback.
*/
export type LastStatusTriggerParams = { [k in HealthStatus]?: Trigger } & {
/** Trigger to use when no status-specific trigger is defined */
default: Trigger
}
/**
* Creates a trigger that dynamically selects timing based on the last health check result.
*
* This allows different check frequencies for each health status:
* - `success` - When the service is healthy
* - `failure` - When the service is unhealthy
* - `starting` - When the service is still starting up
*
* Uses the `default` trigger for any status not explicitly configured.
*
* @param o - Map of health statuses to triggers, plus a required default
* @returns A composite trigger that adapts to the current health status
*
* @example
* ```typescript
* // Check frequently during failure, rarely during success
* const adaptiveTrigger = lastStatus({
* success: cooldownTrigger(60000), // 60s when healthy
* failure: cooldownTrigger(5000), // 5s when unhealthy
* starting: cooldownTrigger(2000), // 2s while starting
* default: cooldownTrigger(10000) // 10s fallback
* })
* ```
*/
export function lastStatus(o: LastStatusTriggerParams): Trigger {
return async function* (getInput) {
let trigger = o.default(getInput)

View File

@@ -1,6 +1,26 @@
import { Trigger } from "."
import { lastStatus } from "./lastStatus"
/**
* Creates a trigger with different timing for success vs failure/starting states.
*
* This is a simplified wrapper around `lastStatus` for the common case
* where you want one timing during healthy operation and another during
* any error condition (failure or starting).
*
* @param o.duringSuccess - Trigger to use when the last check succeeded
* @param o.duringError - Trigger to use for failure, starting, or unknown states
* @returns A composite trigger that adapts to success/failure state
*
* @example
* ```typescript
* // Check every minute when healthy, every 5 seconds when unhealthy
* const trigger = successFailure({
* duringSuccess: cooldownTrigger(60000), // 1 minute
* duringError: cooldownTrigger(5000) // 5 seconds
* })
* ```
*/
export const successFailure = (o: {
duringSuccess: Trigger
duringError: Trigger

View File

@@ -1,3 +1,51 @@
/**
* @module SubContainer
*
* This module provides the SubContainer class for running containerized processes.
* SubContainers are isolated environments created from Docker images where your
* service's main processes run.
*
* SubContainers provide:
* - Isolated filesystem from a Docker image
* - Volume mounting for persistent data
* - Command execution (exec, execFail, spawn, launch)
* - File system operations within the container
*
* @example
* ```typescript
* // Create a subcontainer with volume mounts
* const container = await sdk.SubContainer.of(
* effects,
* { imageId: 'main' },
* sdk.Mounts.of()
* .mountVolume({ volumeId: 'main', mountpoint: '/data' }),
* 'my-container'
* )
*
* // Execute a command
* const result = await container.exec(['cat', '/data/config.json'])
* console.log(result.stdout)
*
* // Run as a daemon
* const process = await container.launch(['my-server', '--config', '/data/config.json'])
* ```
*
* @example
* ```typescript
* // Use withTemp for one-off commands
* const output = await sdk.SubContainer.withTemp(
* effects,
* { imageId: 'main' },
* mounts,
* 'generate-password',
* async (container) => {
* const result = await container.execFail(['openssl', 'rand', '-hex', '16'])
* return result.stdout.toString().trim()
* }
* )
* ```
*/
import * as fs from "fs/promises"
import * as T from "../../../base/lib/types"
import * as cp from "child_process"
@@ -9,17 +57,29 @@ import { Mounts } from "../mainFn/Mounts"
import { BackupEffects } from "../backup/Backups"
import { PathBase } from "./Volume"
/** @internal Promisified execFile */
export const execFile = promisify(cp.execFile)
const False = () => false
/**
* Results from executing a command in a SubContainer.
*/
type ExecResults = {
/** Exit code (null if terminated by signal) */
exitCode: number | null
/** Signal that terminated the process (null if exited normally) */
exitSignal: NodeJS.Signals | null
/** Standard output from the command */
stdout: string | Buffer
/** Standard error from the command */
stderr: string | Buffer
}
/**
* Options for exec operations.
*/
export type ExecOptions = {
/** Input to write to the command's stdin */
input?: string | Buffer
}
@@ -69,18 +129,38 @@ async function bind(
await execFile("mount", [...args, from, to])
}
/**
* Interface for a SubContainer - an isolated container environment for running processes.
*
* SubContainers provide a sandboxed filesystem from a Docker image with mounted
* volumes for persistent data. Use `sdk.SubContainer.of()` to create one.
*
* @typeParam Manifest - The service manifest type (for type-safe image/volume references)
* @typeParam Effects - The Effects type (usually T.Effects)
*/
export interface SubContainer<
Manifest extends T.SDKManifest,
Effects extends T.Effects = T.Effects,
> extends Drop,
PathBase {
/** The image ID this container was created from */
readonly imageId: keyof Manifest["images"] & T.ImageId
/** The root filesystem path of this container */
readonly rootfs: string
/** Unique identifier for this container instance */
readonly guid: T.Guid
/**
* Get the absolute path to a file or directory within this subcontainer's rootfs
* @param path Path relative to the rootfs
* Gets the absolute path to a file or directory within this container's rootfs.
*
* @param path - Path relative to the rootfs (e.g., "/data/config.json")
* @returns The absolute path on the host filesystem
*
* @example
* ```typescript
* const configPath = container.subpath('/data/config.json')
* // Returns something like "/media/startos/containers/<guid>/data/config.json"
* ```
*/
subpath(path: string): string
@@ -168,7 +248,30 @@ export interface SubContainer<
}
/**
* Want to limit what we can do in a container, so we want to launch a container with a specific image and the mounts.
* An owned SubContainer that manages its own lifecycle.
*
* This is the primary implementation of SubContainer. When destroyed, it cleans up
* the container filesystem. Use `sdk.SubContainer.of()` which returns a reference-counted
* wrapper for easier lifecycle management.
*
* @typeParam Manifest - The service manifest type
* @typeParam Effects - The Effects type
*
* @example
* ```typescript
* // Direct usage (manual cleanup required)
* const container = await SubContainerOwned.of(effects, { imageId: 'main' }, mounts, 'name')
* try {
* await container.exec(['my-command'])
* } finally {
* await container.destroy()
* }
*
* // Or use withTemp for automatic cleanup
* await SubContainerOwned.withTemp(effects, { imageId: 'main' }, mounts, 'name', async (c) => {
* await c.exec(['my-command'])
* })
* ```
*/
export class SubContainerOwned<
Manifest extends T.SDKManifest,
@@ -882,71 +985,131 @@ export class SubContainerRc<
}
}
/**
* Options for command execution in a SubContainer.
*/
export type CommandOptions = {
/**
* Environment variables to set for this command
* Environment variables to set for this command.
* Variables with undefined values are ignored.
*
* @example
* ```typescript
* env: { NODE_ENV: 'production', DEBUG: 'app:*' }
* ```
*/
env?: { [variable in string]?: string }
/**
* the working directory to run this command in
* The working directory to run this command in.
* Defaults to the image's WORKDIR or "/" if not specified.
*/
cwd?: string
/**
* the user to run this command as
* The user to run this command as.
* Defaults to the image's USER or "root" if not specified.
*
* @example "root", "nobody", "app"
*/
user?: string
}
/**
* Options for process stdio handling.
*/
export type StdioOptions = {
/** How to handle stdio streams */
stdio?: cp.IOType
}
export type IdMap = { fromId: number; toId: number; range: number }
/**
* User/group ID mapping for volume mounts.
* Used for mapping container UIDs to host UIDs.
*/
export type IdMap = {
/** Source ID in the host namespace */
fromId: number
/** Target ID in the container namespace */
toId: number
/** Number of IDs to map (contiguous range) */
range: number
}
/** Union of all mount option types */
export type MountOptions =
| MountOptionsVolume
| MountOptionsAssets
| MountOptionsPointer
| MountOptionsBackup
/** Mount options for a service volume */
export type MountOptionsVolume = {
type: "volume"
/** ID of the volume to mount */
volumeId: string
/** Subpath within the volume (null for root) */
subpath: string | null
/** Whether the mount is read-only */
readonly: boolean
/** How to treat the mount target (file, directory, or auto-detect) */
filetype: "file" | "directory" | "infer"
/** UID/GID mappings */
idmap: IdMap[]
}
/** Mount options for service assets (read-only resources bundled with the package) */
export type MountOptionsAssets = {
type: "assets"
/** Subpath within the assets directory */
subpath: string | null
/** How to treat the mount target */
filetype: "file" | "directory" | "infer"
/** UID/GID mappings */
idmap: { fromId: number; toId: number; range: number }[]
}
/** Mount options for a volume from another service (dependency) */
export type MountOptionsPointer = {
type: "pointer"
/** Package ID of the dependency */
packageId: string
/** Volume ID within the dependency */
volumeId: string
/** Subpath within the volume */
subpath: string | null
/** Whether the mount is read-only */
readonly: boolean
/** UID/GID mappings */
idmap: { fromId: number; toId: number; range: number }[]
}
/** Mount options for backup data (during backup/restore operations) */
export type MountOptionsBackup = {
type: "backup"
/** Subpath within the backup */
subpath: string | null
/** How to treat the mount target */
filetype: "file" | "directory" | "infer"
/** UID/GID mappings */
idmap: { fromId: number; toId: number; range: number }[]
}
/** @internal Helper to wait for a specified time */
function wait(time: number) {
return new Promise((resolve) => setTimeout(resolve, time))
}
/**
* Error thrown when a command exits with a non-zero code or signal.
*
* Contains the full execution result including stdout/stderr for debugging.
*/
export class ExitError extends Error {
constructor(
/** The command that was executed */
readonly command: string,
/** The execution result */
readonly result: {
exitCode: number | null
exitSignal: T.Signals | null