mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 20:14:49 +00:00
sdk comments
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user