mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +00:00
Merge branch 'next/minor' of github.com:Start9Labs/start-os into next/major
This commit is contained in:
@@ -26,7 +26,7 @@ import {
|
||||
import * as patterns from "../../base/lib/util/patterns"
|
||||
import { BackupSync, Backups } from "./backup/Backups"
|
||||
import { smtpInputSpec } from "../../base/lib/actions/input/inputSpecConstants"
|
||||
import { Daemons } from "./mainFn/Daemons"
|
||||
import { CommandController, Daemons } from "./mainFn/Daemons"
|
||||
import { healthCheck, HealthCheckParams } from "./health/HealthCheck"
|
||||
import { checkPortListening } from "./health/checkFns/checkPortListening"
|
||||
import { checkWebUrl, runHealthScript } from "./health/checkFns"
|
||||
@@ -71,8 +71,9 @@ import { GetInput } from "../../base/lib/actions/setupActions"
|
||||
import { Run } from "../../base/lib/actions/setupActions"
|
||||
import * as actions from "../../base/lib/actions"
|
||||
import { setupInit } from "./inits/setupInit"
|
||||
import * as fs from "node:fs/promises"
|
||||
|
||||
export const SDKVersion = testTypeVersion("0.3.6")
|
||||
export const OSVersion = testTypeVersion("0.3.6-alpha.15")
|
||||
|
||||
// prettier-ignore
|
||||
type AnyNeverCond<T extends any[], Then, Else> =
|
||||
@@ -124,6 +125,7 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
|
||||
effects.getServicePortForward(...args),
|
||||
clearBindings: (effects, ...args) => effects.clearBindings(...args),
|
||||
getContainerIp: (effects, ...args) => effects.getContainerIp(...args),
|
||||
getOsIp: (effects, ...args) => effects.getOsIp(...args),
|
||||
getSslKey: (effects, ...args) => effects.getSslKey(...args),
|
||||
setDataVersion: (effects, ...args) => effects.setDataVersion(...args),
|
||||
getDataVersion: (effects, ...args) => effects.getDataVersion(...args),
|
||||
@@ -219,6 +221,8 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
|
||||
of: (effects: Effects, id: string) => new MultiHost({ id, effects }),
|
||||
},
|
||||
nullIfEmpty,
|
||||
useEntrypoint: (overrideCmd?: string[]) =>
|
||||
new T.UseEntrypoint(overrideCmd),
|
||||
runCommand: async <A extends string>(
|
||||
effects: Effects,
|
||||
image: {
|
||||
@@ -229,7 +233,10 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
|
||||
options: CommandOptions & {
|
||||
mounts?: { path: string; options: MountOptions }[]
|
||||
},
|
||||
name: string,
|
||||
/**
|
||||
* A name to use to refer to the ephemeral subcontainer for debugging purposes
|
||||
*/
|
||||
name?: string,
|
||||
): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> => {
|
||||
return runCommand<Manifest>(effects, image, command, options, name)
|
||||
},
|
||||
@@ -301,14 +308,12 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
|
||||
| Record<string, any>
|
||||
| InputSpec<any, any>
|
||||
| InputSpec<any, never>,
|
||||
Type extends
|
||||
ExtractInputSpecType<InputSpecType> = ExtractInputSpecType<InputSpecType>,
|
||||
>(
|
||||
id: Id,
|
||||
metadata: MaybeFn<Omit<T.ActionMetadata, "hasInput">>,
|
||||
inputSpec: InputSpecType,
|
||||
getInput: GetInput<Type>,
|
||||
run: Run<Type>,
|
||||
getInput: GetInput<InputSpecType>,
|
||||
run: Run<InputSpecType>,
|
||||
) => Action.withInput(id, metadata, inputSpec, getInput, run),
|
||||
/**
|
||||
* @description Use this function to create an action that does not accept form input
|
||||
@@ -688,6 +693,18 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
|
||||
) {
|
||||
return SubContainer.of(effects, image, name)
|
||||
},
|
||||
with<T>(
|
||||
effects: T.Effects,
|
||||
image: {
|
||||
imageId: T.ImageId & keyof Manifest["images"]
|
||||
sharedRun?: boolean
|
||||
},
|
||||
mounts: { options: MountOptions; path: string }[],
|
||||
name: string,
|
||||
fn: (subContainer: SubContainer) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return SubContainer.with(effects, image, mounts, name, fn)
|
||||
},
|
||||
},
|
||||
List: {
|
||||
/**
|
||||
@@ -702,108 +719,15 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
|
||||
* @param aSpec - attributes describing each member of the list.
|
||||
*/
|
||||
obj: <Type extends Record<string, any>>(
|
||||
a: {
|
||||
name: string
|
||||
description?: string | null
|
||||
/** Presents a warning before adding/removing/editing a list item. */
|
||||
warning?: string | null
|
||||
default?: []
|
||||
minLength?: number | null
|
||||
maxLength?: number | null
|
||||
},
|
||||
aSpec: {
|
||||
spec: InputSpec<Type, Store>
|
||||
/**
|
||||
* @description The ID of a required field on the inner object whose value will be used to display items in the list.
|
||||
* @example
|
||||
* In this example, we use the value of the `label` field to display members of the list.
|
||||
*
|
||||
* ```
|
||||
spec: InputSpec.of({
|
||||
label: Value.text({
|
||||
name: 'Label',
|
||||
required: false,
|
||||
default: null,
|
||||
})
|
||||
})
|
||||
displayAs: 'label',
|
||||
uniqueBy: null,
|
||||
* ```
|
||||
*
|
||||
*/
|
||||
displayAs?: null | string
|
||||
/**
|
||||
* @description The ID(s) of required fields on the inner object whose value(s) will be used to enforce uniqueness in the list.
|
||||
* @example
|
||||
* In this example, we use the `label` field to enforce uniqueness, meaning the label field must be unique from other entries.
|
||||
*
|
||||
* ```
|
||||
spec: InputSpec.of({
|
||||
label: Value.text({
|
||||
name: 'Label',
|
||||
required: true,
|
||||
default: null,
|
||||
})
|
||||
pubkey: Value.text({
|
||||
name: 'Pubkey',
|
||||
required: true,
|
||||
default: null,
|
||||
})
|
||||
})
|
||||
displayAs: 'label',
|
||||
uniqueBy: 'label',
|
||||
* ```
|
||||
* @example
|
||||
* In this example, we use the `label` field AND the `pubkey` field to enforce uniqueness, meaning both these fields must be unique from other entries.
|
||||
*
|
||||
* ```
|
||||
spec: InputSpec.of({
|
||||
label: Value.text({
|
||||
name: 'Label',
|
||||
required: true,
|
||||
default: null,
|
||||
})
|
||||
pubkey: Value.text({
|
||||
name: 'Pubkey',
|
||||
required: true,
|
||||
default: null,
|
||||
})
|
||||
})
|
||||
displayAs: 'label',
|
||||
uniqueBy: { all: ['label', 'pubkey'] },
|
||||
* ```
|
||||
*/
|
||||
uniqueBy?: null | UniqueBy
|
||||
},
|
||||
a: Parameters<typeof List.obj<Type, Store>>[0],
|
||||
aSpec: Parameters<typeof List.obj<Type, Store>>[1],
|
||||
) => List.obj<Type, Store>(a, aSpec),
|
||||
/**
|
||||
* @description Create a list of dynamic text inputs.
|
||||
* @param a - attributes of the list itself.
|
||||
* @param aSpec - attributes describing each member of the list.
|
||||
*/
|
||||
dynamicText: (
|
||||
getA: LazyBuild<
|
||||
Store,
|
||||
{
|
||||
name: string
|
||||
description?: string | null
|
||||
warning?: string | null
|
||||
default?: string[]
|
||||
minLength?: number | null
|
||||
maxLength?: number | null
|
||||
disabled?: false | string
|
||||
generate?: null | RandomString
|
||||
spec: {
|
||||
masked?: boolean
|
||||
placeholder?: string | null
|
||||
minLength?: number | null
|
||||
maxLength?: number | null
|
||||
patterns: Pattern[]
|
||||
inputmode?: ListValueSpecText["inputmode"]
|
||||
}
|
||||
}
|
||||
>,
|
||||
) => List.dynamicText<Store>(getA),
|
||||
dynamicText: List.dynamicText<Store>,
|
||||
},
|
||||
StorePath: pathBuilder<Store>(),
|
||||
Value: {
|
||||
@@ -1092,244 +1016,14 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
|
||||
*/
|
||||
list: Value.list,
|
||||
hidden: Value.hidden,
|
||||
dynamicToggle: (
|
||||
a: LazyBuild<
|
||||
Store,
|
||||
{
|
||||
name: string
|
||||
description?: string | null
|
||||
/** Presents a warning prompt before permitting the value to change. */
|
||||
warning?: string | null
|
||||
default: boolean
|
||||
disabled?: false | string
|
||||
}
|
||||
>,
|
||||
) => Value.dynamicToggle<Store>(a),
|
||||
dynamicText: (
|
||||
getA: LazyBuild<
|
||||
Store,
|
||||
{
|
||||
name: string
|
||||
description?: string | null
|
||||
/** Presents a warning prompt before permitting the value to change. */
|
||||
warning?: string | null
|
||||
/**
|
||||
* @description optionally provide a default value.
|
||||
* @type { string | RandomString | null }
|
||||
* @example default: null
|
||||
* @example default: 'World'
|
||||
* @example default: { charset: 'abcdefg', len: 16 }
|
||||
*/
|
||||
default: DefaultString | null
|
||||
required: boolean
|
||||
/**
|
||||
* @description Mask (aka camouflage) text input with dots: ● ● ●
|
||||
* @default false
|
||||
*/
|
||||
masked?: boolean
|
||||
placeholder?: string | null
|
||||
minLength?: number | null
|
||||
maxLength?: number | null
|
||||
/**
|
||||
* @description A list of regular expressions to which the text must conform to pass validation. A human readable description is provided in case the validation fails.
|
||||
* @default []
|
||||
* @example
|
||||
* ```
|
||||
[
|
||||
{
|
||||
regex: "[a-z]",
|
||||
description: "May only contain lower case letters from the English alphabet."
|
||||
}
|
||||
]
|
||||
* ```
|
||||
*/
|
||||
patterns?: Pattern[]
|
||||
/**
|
||||
* @description Informs the browser how to behave and which keyboard to display on mobile
|
||||
* @default "text"
|
||||
*/
|
||||
inputmode?: ValueSpecText["inputmode"]
|
||||
/**
|
||||
* @description Displays a button that will generate a random string according to the provided charset and len attributes.
|
||||
*/
|
||||
generate?: null | RandomString
|
||||
}
|
||||
>,
|
||||
) => Value.dynamicText<Store>(getA),
|
||||
dynamicTextarea: (
|
||||
getA: LazyBuild<
|
||||
Store,
|
||||
{
|
||||
name: string
|
||||
description?: string | null
|
||||
/** Presents a warning prompt before permitting the value to change. */
|
||||
warning?: string | null
|
||||
default: string | null
|
||||
required: boolean
|
||||
minLength?: number | null
|
||||
maxLength?: number | null
|
||||
placeholder?: string | null
|
||||
disabled?: false | string
|
||||
}
|
||||
>,
|
||||
) => Value.dynamicTextarea<Store>(getA),
|
||||
dynamicNumber: (
|
||||
getA: LazyBuild<
|
||||
Store,
|
||||
{
|
||||
name: string
|
||||
description?: string | null
|
||||
/** Presents a warning prompt before permitting the value to change. */
|
||||
warning?: string | null
|
||||
/**
|
||||
* @description optionally provide a default value.
|
||||
* @type { number | null }
|
||||
* @example default: null
|
||||
* @example default: 7
|
||||
*/
|
||||
default: number | null
|
||||
required: boolean
|
||||
min?: number | null
|
||||
max?: number | null
|
||||
/**
|
||||
* @description How much does the number increase/decrease when using the arrows provided by the browser.
|
||||
* @default 1
|
||||
*/
|
||||
step?: number | null
|
||||
/**
|
||||
* @description Requires the number to be an integer.
|
||||
*/
|
||||
integer: boolean
|
||||
/**
|
||||
* @description Optionally display units to the right of the input box.
|
||||
*/
|
||||
units?: string | null
|
||||
placeholder?: string | null
|
||||
disabled?: false | string
|
||||
}
|
||||
>,
|
||||
) => Value.dynamicNumber<Store>(getA),
|
||||
dynamicColor: (
|
||||
getA: LazyBuild<
|
||||
Store,
|
||||
{
|
||||
name: string
|
||||
description?: string | null
|
||||
/** Presents a warning prompt before permitting the value to change. */
|
||||
warning?: string | null
|
||||
/**
|
||||
* @description optionally provide a default value.
|
||||
* @type { string | null }
|
||||
* @example default: null
|
||||
* @example default: 'ffffff'
|
||||
*/
|
||||
default: string | null
|
||||
required: boolean
|
||||
disabled?: false | string
|
||||
}
|
||||
>,
|
||||
) => Value.dynamicColor<Store>(getA),
|
||||
dynamicDatetime: (
|
||||
getA: LazyBuild<
|
||||
Store,
|
||||
{
|
||||
name: string
|
||||
description?: string | null
|
||||
/** Presents a warning prompt before permitting the value to change. */
|
||||
warning?: string | null
|
||||
/**
|
||||
* @description optionally provide a default value.
|
||||
* @type { string | null }
|
||||
* @example default: null
|
||||
* @example default: '1985-12-16 18:00:00.000'
|
||||
*/
|
||||
default: string
|
||||
required: boolean
|
||||
/**
|
||||
* @description Informs the browser how to behave and which date/time component to display.
|
||||
* @default "datetime-local"
|
||||
*/
|
||||
inputmode?: ValueSpecDatetime["inputmode"]
|
||||
min?: string | null
|
||||
max?: string | null
|
||||
disabled?: false | string
|
||||
}
|
||||
>,
|
||||
) => Value.dynamicDatetime<Store>(getA),
|
||||
dynamicSelect: <Variants extends Record<string, string>>(
|
||||
getA: LazyBuild<
|
||||
Store,
|
||||
{
|
||||
name: string
|
||||
description?: string | null
|
||||
/** Presents a warning prompt before permitting the value to change. */
|
||||
warning?: string | null
|
||||
/**
|
||||
* @description provide a default value from the list of values.
|
||||
* @type { default: string }
|
||||
* @example default: 'radio1'
|
||||
*/
|
||||
default: keyof Variants & string
|
||||
/**
|
||||
* @description A mapping of unique radio options to their human readable display format.
|
||||
* @example
|
||||
* ```
|
||||
{
|
||||
radio1: "Radio 1"
|
||||
radio2: "Radio 2"
|
||||
radio3: "Radio 3"
|
||||
}
|
||||
* ```
|
||||
*/
|
||||
values: Variants
|
||||
/**
|
||||
* @options
|
||||
* - false - The field can be modified.
|
||||
* - string - The field cannot be modified. The provided text explains why.
|
||||
* - string[] - The field can be modified, but the values contained in the array cannot be selected.
|
||||
* @default false
|
||||
*/
|
||||
disabled?: false | string | string[]
|
||||
}
|
||||
>,
|
||||
) => Value.dynamicSelect<Store>(getA),
|
||||
dynamicMultiselect: (
|
||||
getA: LazyBuild<
|
||||
Store,
|
||||
{
|
||||
name: string
|
||||
description?: string | null
|
||||
/** Presents a warning prompt before permitting the value to change. */
|
||||
warning?: string | null
|
||||
/**
|
||||
* @description A simple list of which options should be checked by default.
|
||||
*/
|
||||
default: string[]
|
||||
/**
|
||||
* @description A mapping of checkbox options to their human readable display format.
|
||||
* @example
|
||||
* ```
|
||||
{
|
||||
option1: "Option 1"
|
||||
option2: "Option 2"
|
||||
option3: "Option 3"
|
||||
}
|
||||
* ```
|
||||
*/
|
||||
values: Record<string, string>
|
||||
minLength?: number | null
|
||||
maxLength?: number | null
|
||||
/**
|
||||
* @options
|
||||
* - false - The field can be modified.
|
||||
* - string - The field cannot be modified. The provided text explains why.
|
||||
* - string[] - The field can be modified, but the values contained in the array cannot be selected.
|
||||
* @default false
|
||||
*/
|
||||
disabled?: false | string | string[]
|
||||
}
|
||||
>,
|
||||
) => Value.dynamicMultiselect<Store>(getA),
|
||||
dynamicToggle: Value.dynamicToggle<Store>,
|
||||
dynamicText: Value.dynamicText<Store>,
|
||||
dynamicTextarea: Value.dynamicTextarea<Store>,
|
||||
dynamicNumber: Value.dynamicNumber<Store>,
|
||||
dynamicColor: Value.dynamicColor<Store>,
|
||||
dynamicDatetime: Value.dynamicDatetime<Store>,
|
||||
dynamicSelect: Value.dynamicSelect<Store>,
|
||||
dynamicMultiselect: Value.dynamicMultiselect<Store>,
|
||||
filteredUnion: <
|
||||
VariantValues extends {
|
||||
[K in string]: {
|
||||
@@ -1338,16 +1032,13 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
|
||||
}
|
||||
},
|
||||
>(
|
||||
getDisabledFn: LazyBuild<Store, string[]>,
|
||||
a: {
|
||||
name: string
|
||||
description?: string | null
|
||||
warning?: string | null
|
||||
default: keyof VariantValues & string
|
||||
},
|
||||
aVariants:
|
||||
| Variants<VariantValues, Store>
|
||||
| Variants<VariantValues, never>,
|
||||
getDisabledFn: Parameters<
|
||||
typeof Value.filteredUnion<VariantValues, Store>
|
||||
>[0],
|
||||
a: Parameters<typeof Value.filteredUnion<VariantValues, Store>>[1],
|
||||
aVariants: Parameters<
|
||||
typeof Value.filteredUnion<VariantValues, Store>
|
||||
>[2],
|
||||
) =>
|
||||
Value.filteredUnion<VariantValues, Store>(
|
||||
getDisabledFn,
|
||||
@@ -1363,33 +1054,10 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
|
||||
}
|
||||
},
|
||||
>(
|
||||
getA: LazyBuild<
|
||||
Store,
|
||||
{
|
||||
name: string
|
||||
description?: string | null
|
||||
/** Presents a warning prompt before permitting the value to change. */
|
||||
warning?: string | null
|
||||
/**
|
||||
* @description provide a default value from the list of variants.
|
||||
* @type { string }
|
||||
* @example default: 'variant1'
|
||||
*/
|
||||
default: keyof VariantValues & string
|
||||
required: boolean
|
||||
/**
|
||||
* @options
|
||||
* - false - The field can be modified.
|
||||
* - string - The field cannot be modified. The provided text explains why.
|
||||
* - string[] - The field can be modified, but the values contained in the array cannot be selected.
|
||||
* @default false
|
||||
*/
|
||||
disabled: false | string | string[]
|
||||
}
|
||||
>,
|
||||
aVariants:
|
||||
| Variants<VariantValues, Store>
|
||||
| Variants<VariantValues, never>,
|
||||
getA: Parameters<typeof Value.dynamicUnion<VariantValues, Store>>[0],
|
||||
aVariants: Parameters<
|
||||
typeof Value.dynamicUnion<VariantValues, Store>
|
||||
>[1],
|
||||
) => Value.dynamicUnion<VariantValues, Store>(getA, aVariants),
|
||||
},
|
||||
Variants: {
|
||||
@@ -1411,18 +1079,37 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
|
||||
export async function runCommand<Manifest extends T.SDKManifest>(
|
||||
effects: Effects,
|
||||
image: { imageId: keyof Manifest["images"] & T.ImageId; sharedRun?: boolean },
|
||||
command: string | [string, ...string[]],
|
||||
command: T.CommandType,
|
||||
options: CommandOptions & {
|
||||
mounts?: { path: string; options: MountOptions }[]
|
||||
},
|
||||
name: string,
|
||||
name?: string,
|
||||
): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> {
|
||||
const commands = splitCommand(command)
|
||||
let commands: string[]
|
||||
if (command instanceof T.UseEntrypoint) {
|
||||
const imageMeta: T.ImageMetadata = await fs
|
||||
.readFile(`/media/startos/images/${image.imageId}.json`, {
|
||||
encoding: "utf8",
|
||||
})
|
||||
.catch(() => "{}")
|
||||
.then(JSON.parse)
|
||||
commands = imageMeta.entrypoint ?? []
|
||||
commands.concat(...(command.overridCmd ?? imageMeta.cmd ?? []))
|
||||
} else commands = splitCommand(command)
|
||||
return SubContainer.with(
|
||||
effects,
|
||||
image,
|
||||
options.mounts || [],
|
||||
name,
|
||||
name ||
|
||||
commands
|
||||
.map((c) => {
|
||||
if (c.includes(" ")) {
|
||||
return `"${c.replace(/"/g, `\"`)}"`
|
||||
} else {
|
||||
return c
|
||||
}
|
||||
})
|
||||
.join(" "),
|
||||
(subcontainer) => subcontainer.exec(commands),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effects, HealthReceipt } from "../../../base/lib/types"
|
||||
import { Effects, HealthCheckId, HealthReceipt } from "../../../base/lib/types"
|
||||
import { HealthCheckResult } from "./checkFns/HealthCheckResult"
|
||||
import { Trigger } from "../trigger"
|
||||
import { TriggerInput } from "../trigger/TriggerInput"
|
||||
@@ -8,16 +8,20 @@ import { object, unknown } from "ts-matches"
|
||||
|
||||
export type HealthCheckParams = {
|
||||
effects: Effects
|
||||
id: HealthCheckId
|
||||
name: string
|
||||
trigger?: Trigger
|
||||
gracePeriod?: number
|
||||
fn(): Promise<HealthCheckResult> | HealthCheckResult
|
||||
onFirstSuccess?: () => unknown | Promise<unknown>
|
||||
}
|
||||
|
||||
export function healthCheck(o: HealthCheckParams) {
|
||||
new Promise(async () => {
|
||||
const start = performance.now()
|
||||
let currentValue: TriggerInput = {}
|
||||
const getCurrentValue = () => currentValue
|
||||
const gracePeriod = o.gracePeriod ?? 5000
|
||||
const trigger = (o.trigger ?? defaultTrigger)(getCurrentValue)
|
||||
const triggerFirstSuccess = once(() =>
|
||||
Promise.resolve(
|
||||
@@ -32,10 +36,12 @@ export function healthCheck(o: HealthCheckParams) {
|
||||
res = await trigger.next()
|
||||
) {
|
||||
try {
|
||||
const { result, message } = await o.fn()
|
||||
let { result, message } = await o.fn()
|
||||
if (result === "failure" && performance.now() - start <= gracePeriod)
|
||||
result = "starting"
|
||||
await o.effects.setHealth({
|
||||
name: o.name,
|
||||
id: o.name,
|
||||
id: o.id,
|
||||
result,
|
||||
message: message || "",
|
||||
})
|
||||
@@ -46,8 +52,9 @@ export function healthCheck(o: HealthCheckParams) {
|
||||
} catch (e) {
|
||||
await o.effects.setHealth({
|
||||
name: o.name,
|
||||
id: o.name,
|
||||
result: "failure",
|
||||
id: o.id,
|
||||
result:
|
||||
performance.now() - start <= gracePeriod ? "starting" : "failure",
|
||||
message: asMessage(e) || "",
|
||||
})
|
||||
currentValue.lastResult = "failure"
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "../util/SubContainer"
|
||||
import { splitCommand } from "../util"
|
||||
import * as cp from "child_process"
|
||||
import * as fs from "node:fs/promises"
|
||||
|
||||
export class CommandController {
|
||||
private constructor(
|
||||
@@ -45,7 +46,17 @@ export class CommandController {
|
||||
onStderr?: (chunk: Buffer | string | any) => void
|
||||
},
|
||||
) => {
|
||||
const commands = splitCommand(command)
|
||||
let commands: string[]
|
||||
if (command instanceof T.UseEntrypoint) {
|
||||
const imageMeta: T.ImageMetadata = await fs
|
||||
.readFile(`/media/startos/images/${subcontainer.imageId}.json`, {
|
||||
encoding: "utf8",
|
||||
})
|
||||
.catch(() => "{}")
|
||||
.then(JSON.parse)
|
||||
commands = imageMeta.entrypoint ?? []
|
||||
commands.concat(...(command.overridCmd ?? imageMeta.cmd ?? []))
|
||||
} else commands = splitCommand(command)
|
||||
const subc =
|
||||
subcontainer instanceof SubContainer
|
||||
? subcontainer
|
||||
@@ -55,10 +66,15 @@ export class CommandController {
|
||||
subcontainer,
|
||||
options?.subcontainerName || commands.join(" "),
|
||||
)
|
||||
for (let mount of options.mounts || []) {
|
||||
await subc.mount(mount.options, mount.path)
|
||||
try {
|
||||
for (let mount of options.mounts || []) {
|
||||
await subc.mount(mount.options, mount.path)
|
||||
}
|
||||
return subc
|
||||
} catch (e) {
|
||||
await subc.destroy()
|
||||
throw e
|
||||
}
|
||||
return subc
|
||||
})()
|
||||
|
||||
try {
|
||||
|
||||
@@ -38,6 +38,12 @@ export type Ready = {
|
||||
fn: (
|
||||
spawnable: ExecSpawnable,
|
||||
) => Promise<HealthCheckResult> | HealthCheckResult
|
||||
/**
|
||||
* A duration in milliseconds to treat a failing health check as "starting"
|
||||
*
|
||||
* defaults to 5000
|
||||
*/
|
||||
gracePeriod?: number
|
||||
trigger?: Trigger
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export class HealthDaemon {
|
||||
private _health: HealthCheckResult = { result: "starting", message: null }
|
||||
private healthWatchers: Array<() => unknown> = []
|
||||
private running = false
|
||||
private started?: number
|
||||
private resolveReady: (() => void) | undefined
|
||||
private readyPromise: Promise<void>
|
||||
constructor(
|
||||
@@ -75,6 +76,7 @@ export class HealthDaemon {
|
||||
|
||||
if (newStatus) {
|
||||
;(await this.daemon).start()
|
||||
this.started = performance.now()
|
||||
this.setupHealthCheck()
|
||||
} else {
|
||||
;(await this.daemon).stop()
|
||||
@@ -146,14 +148,21 @@ export class HealthDaemon {
|
||||
this._health = health
|
||||
this.healthWatchers.forEach((watcher) => watcher())
|
||||
const display = this.ready.display
|
||||
const result = health.result
|
||||
if (!display) {
|
||||
return
|
||||
}
|
||||
let result = health.result
|
||||
if (
|
||||
result === "failure" &&
|
||||
this.started &&
|
||||
performance.now() - this.started <= (this.ready.gracePeriod ?? 5000)
|
||||
)
|
||||
result = "starting"
|
||||
await this.effects.setHealth({
|
||||
...health,
|
||||
id: this.id,
|
||||
name: display,
|
||||
result,
|
||||
} as SetHealth)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
SDKManifest,
|
||||
SDKImageInputSpec,
|
||||
} from "../../../base/lib/types/ManifestTypes"
|
||||
import { SDKVersion } from "../StartSdk"
|
||||
import { OSVersion } from "../StartSdk"
|
||||
import { VersionGraph } from "../version/VersionGraph"
|
||||
import { execSync } from "child_process"
|
||||
|
||||
@@ -58,7 +58,7 @@ export function buildManifest<
|
||||
)
|
||||
return {
|
||||
...manifest,
|
||||
osVersion: SDKVersion,
|
||||
osVersion: manifest.osVersion ?? OSVersion,
|
||||
version: versions.current.options.version,
|
||||
releaseNotes: versions.current.options.releaseNotes,
|
||||
satisfies: versions.current.options.satisfies || [],
|
||||
|
||||
@@ -12,7 +12,7 @@ export class GetStore<Store, StoreValue> {
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns the value of Store at the provided path. Restart the service if the value changes
|
||||
* Returns the value of Store at the provided path. Reruns the context from which it has been called if the underlying value changes
|
||||
*/
|
||||
const() {
|
||||
return this.effects.store.get<Store, StoreValue>({
|
||||
@@ -32,7 +32,7 @@ export class GetStore<Store, StoreValue> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches the value of Store at the provided path. Takes a custom callback function to run whenever the value changes
|
||||
* Watches the value of Store at the provided path. Returns an async iterator that yields whenever the value changes
|
||||
*/
|
||||
async *watch() {
|
||||
while (true) {
|
||||
@@ -48,6 +48,33 @@ export class GetStore<Store, StoreValue> {
|
||||
await waitForNext
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches the value of Store at the provided path. Takes a custom callback function to run whenever the value changes
|
||||
*/
|
||||
onChange(
|
||||
callback: (value: StoreValue | null, error?: Error) => void | Promise<void>,
|
||||
) {
|
||||
;(async () => {
|
||||
for await (const value of this.watch()) {
|
||||
try {
|
||||
await callback(value)
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"callback function threw an error @ GetStore.onChange",
|
||||
e,
|
||||
)
|
||||
}
|
||||
}
|
||||
})()
|
||||
.catch((e) => callback(null, e))
|
||||
.catch((e) =>
|
||||
console.error(
|
||||
"callback function threw an error @ GetStore.onChange",
|
||||
e,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
export function getStore<Store, StoreValue>(
|
||||
effects: Effects,
|
||||
|
||||
@@ -9,7 +9,7 @@ export class GetSslCertificate {
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns the system SMTP credentials. Restarts the service if the credentials change
|
||||
* Returns the an SSL Certificate for the given hostnames if permitted. Restarts the service if it changes
|
||||
*/
|
||||
const() {
|
||||
return this.effects.getSslCertificate({
|
||||
@@ -19,7 +19,7 @@ export class GetSslCertificate {
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Returns the system SMTP credentials. Does nothing if the credentials change
|
||||
* Returns the an SSL Certificate for the given hostnames if permitted. Does nothing if it changes
|
||||
*/
|
||||
once() {
|
||||
return this.effects.getSslCertificate({
|
||||
@@ -27,8 +27,9 @@ export class GetSslCertificate {
|
||||
algorithm: this.algorithm,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches the system SMTP credentials. Takes a custom callback function to run whenever the credentials change
|
||||
* Watches the SSL Certificate for the given hostnames if permitted. Returns an async iterator that yields whenever the value changes
|
||||
*/
|
||||
async *watch() {
|
||||
while (true) {
|
||||
@@ -44,4 +45,34 @@ export class GetSslCertificate {
|
||||
await waitForNext
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches the SSL Certificate for the given hostnames if permitted. Takes a custom callback function to run whenever it changes
|
||||
*/
|
||||
onChange(
|
||||
callback: (
|
||||
value: [string, string, string] | null,
|
||||
error?: Error,
|
||||
) => void | Promise<void>,
|
||||
) {
|
||||
;(async () => {
|
||||
for await (const value of this.watch()) {
|
||||
try {
|
||||
await callback(value)
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"callback function threw an error @ GetSslCertificate.onChange",
|
||||
e,
|
||||
)
|
||||
}
|
||||
}
|
||||
})()
|
||||
.catch((e) => callback(null, e))
|
||||
.catch((e) =>
|
||||
console.error(
|
||||
"callback function threw an error @ GetSslCertificate.onChange",
|
||||
e,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,15 @@ export interface ExecSpawnable {
|
||||
* @see {@link ExecSpawnable}
|
||||
*/
|
||||
export class SubContainer implements ExecSpawnable {
|
||||
private static finalizationEffects: { effects?: T.Effects } = {}
|
||||
private static registry = new FinalizationRegistry((guid: string) => {
|
||||
if (this.finalizationEffects.effects) {
|
||||
this.finalizationEffects.effects.subcontainer
|
||||
.destroyFs({ guid })
|
||||
.catch((e) => console.error("failed to cleanup SubContainer", guid, e))
|
||||
}
|
||||
})
|
||||
|
||||
private leader: cp.ChildProcess
|
||||
private leaderExited: boolean = false
|
||||
private waitProc: () => Promise<null>
|
||||
@@ -55,6 +64,8 @@ export class SubContainer implements ExecSpawnable {
|
||||
readonly rootfs: string,
|
||||
readonly guid: T.Guid,
|
||||
) {
|
||||
if (!SubContainer.finalizationEffects.effects)
|
||||
SubContainer.finalizationEffects.effects = effects
|
||||
this.leaderExited = false
|
||||
this.leader = cp.spawn("start-cli", ["subcontainer", "launch", rootfs], {
|
||||
killSignal: "SIGKILL",
|
||||
@@ -94,6 +105,8 @@ export class SubContainer implements ExecSpawnable {
|
||||
imageId,
|
||||
name,
|
||||
})
|
||||
const res = new SubContainer(effects, imageId, rootfs, guid)
|
||||
SubContainer.registry.register(res, guid, res)
|
||||
|
||||
const shared = ["dev", "sys"]
|
||||
if (!!sharedRun) {
|
||||
@@ -111,7 +124,7 @@ export class SubContainer implements ExecSpawnable {
|
||||
await execFile("mount", ["--rbind", from, to])
|
||||
}
|
||||
|
||||
return new SubContainer(effects, imageId, rootfs, guid)
|
||||
return res
|
||||
}
|
||||
|
||||
static async with<T>(
|
||||
@@ -202,6 +215,7 @@ export class SubContainer implements ExecSpawnable {
|
||||
const guid = this.guid
|
||||
await this.killLeader()
|
||||
await this.effects.subcontainer.destroyFs({ guid })
|
||||
SubContainer.registry.unregister(this)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -224,8 +238,9 @@ export class SubContainer implements ExecSpawnable {
|
||||
.catch(() => "{}")
|
||||
.then(JSON.parse)
|
||||
let extra: string[] = []
|
||||
let user = imageMeta.user || "root"
|
||||
if (options?.user) {
|
||||
extra.push(`--user=${options.user}`)
|
||||
user = options.user
|
||||
delete options.user
|
||||
}
|
||||
let workdir = imageMeta.workdir || "/"
|
||||
@@ -239,6 +254,7 @@ export class SubContainer implements ExecSpawnable {
|
||||
"subcontainer",
|
||||
"exec",
|
||||
`--env=/media/startos/images/${this.imageId}.env`,
|
||||
`--user=${user}`,
|
||||
`--workdir=${workdir}`,
|
||||
...extra,
|
||||
this.rootfs,
|
||||
@@ -247,18 +263,28 @@ export class SubContainer implements ExecSpawnable {
|
||||
options || {},
|
||||
)
|
||||
if (options?.input) {
|
||||
await new Promise<null>((resolve, reject) =>
|
||||
child.stdin.write(options.input, (e) => {
|
||||
if (e) {
|
||||
reject(e)
|
||||
} else {
|
||||
resolve(null)
|
||||
}
|
||||
}),
|
||||
)
|
||||
await new Promise<null>((resolve) => child.stdin.end(resolve))
|
||||
await new Promise<null>((resolve, reject) => {
|
||||
try {
|
||||
child.stdin.on("error", (e) => reject(e))
|
||||
child.stdin.write(options.input, (e) => {
|
||||
if (e) {
|
||||
reject(e)
|
||||
} else {
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
await new Promise<null>((resolve, reject) => {
|
||||
try {
|
||||
child.stdin.end(resolve)
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
const pid = child.pid
|
||||
const stdout = { data: "" as string }
|
||||
const stderr = { data: "" as string }
|
||||
const appendData =
|
||||
@@ -294,15 +320,16 @@ export class SubContainer implements ExecSpawnable {
|
||||
options?: CommandOptions,
|
||||
): Promise<cp.ChildProcessWithoutNullStreams> {
|
||||
await this.waitProc()
|
||||
const imageMeta: any = await fs
|
||||
const imageMeta: T.ImageMetadata = await fs
|
||||
.readFile(`/media/startos/images/${this.imageId}.json`, {
|
||||
encoding: "utf8",
|
||||
})
|
||||
.catch(() => "{}")
|
||||
.then(JSON.parse)
|
||||
let extra: string[] = []
|
||||
let user = imageMeta.user || "root"
|
||||
if (options?.user) {
|
||||
extra.push(`--user=${options.user}`)
|
||||
user = options.user
|
||||
delete options.user
|
||||
}
|
||||
let workdir = imageMeta.workdir || "/"
|
||||
@@ -318,6 +345,7 @@ export class SubContainer implements ExecSpawnable {
|
||||
"subcontainer",
|
||||
"launch",
|
||||
`--env=/media/startos/images/${this.imageId}.env`,
|
||||
`--user=${user}`,
|
||||
`--workdir=${workdir}`,
|
||||
...extra,
|
||||
this.rootfs,
|
||||
@@ -336,15 +364,16 @@ export class SubContainer implements ExecSpawnable {
|
||||
options: CommandOptions & StdioOptions = { stdio: "inherit" },
|
||||
): Promise<cp.ChildProcess> {
|
||||
await this.waitProc()
|
||||
const imageMeta: any = await fs
|
||||
const imageMeta: T.ImageMetadata = await fs
|
||||
.readFile(`/media/startos/images/${this.imageId}.json`, {
|
||||
encoding: "utf8",
|
||||
})
|
||||
.catch(() => "{}")
|
||||
.then(JSON.parse)
|
||||
let extra: string[] = []
|
||||
if (options.user) {
|
||||
extra.push(`--user=${options.user}`)
|
||||
let user = imageMeta.user || "root"
|
||||
if (options?.user) {
|
||||
user = options.user
|
||||
delete options.user
|
||||
}
|
||||
let workdir = imageMeta.workdir || "/"
|
||||
@@ -358,6 +387,7 @@ export class SubContainer implements ExecSpawnable {
|
||||
"subcontainer",
|
||||
"exec",
|
||||
`--env=/media/startos/images/${this.imageId}.env`,
|
||||
`--user=${user}`,
|
||||
`--workdir=${workdir}`,
|
||||
...extra,
|
||||
this.rootfs,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as matches from "ts-matches"
|
||||
import * as YAML from "yaml"
|
||||
import * as TOML from "@iarna/toml"
|
||||
import merge from "lodash.merge"
|
||||
import * as T from "../../../base/lib/types"
|
||||
import * as fs from "node:fs/promises"
|
||||
import { asError } from "../../../base/lib/util"
|
||||
@@ -43,6 +42,24 @@ async function onCreated(path: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function fileMerge(...args: any[]): any {
|
||||
let res = args.shift()
|
||||
for (const arg of args) {
|
||||
if (res === arg) continue
|
||||
else if (
|
||||
typeof res === "object" &&
|
||||
typeof arg === "object" &&
|
||||
!Array.isArray(res) &&
|
||||
!Array.isArray(arg)
|
||||
) {
|
||||
for (const key of Object.keys(arg)) {
|
||||
res[key] = fileMerge(res[key], arg[key])
|
||||
}
|
||||
} else res = arg
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Use this class to read/write an underlying configuration file belonging to the upstream service.
|
||||
*
|
||||
@@ -158,33 +175,54 @@ export class FileHelper<A> {
|
||||
return null
|
||||
}
|
||||
|
||||
private readOnChange(
|
||||
callback: (value: A | null, error?: Error) => void | Promise<void>,
|
||||
) {
|
||||
;(async () => {
|
||||
for await (const value of this.readWatch()) {
|
||||
try {
|
||||
await callback(value)
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"callback function threw an error @ FileHelper.read.onChange",
|
||||
e,
|
||||
)
|
||||
}
|
||||
}
|
||||
})()
|
||||
.catch((e) => callback(null, e))
|
||||
.catch((e) =>
|
||||
console.error(
|
||||
"callback function threw an error @ FileHelper.read.onChange",
|
||||
e,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
get read() {
|
||||
return {
|
||||
once: () => this.readOnce(),
|
||||
const: (effects: T.Effects) => this.readConst(effects),
|
||||
watch: () => this.readWatch(),
|
||||
onChange: (
|
||||
callback: (value: A | null, error?: Error) => void | Promise<void>,
|
||||
) => this.readOnChange(callback),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts full structured data and performs a merge with the existing file on disk if it exists.
|
||||
* Accepts full structured data and overwrites the existing file on disk if it exists.
|
||||
*/
|
||||
async write(data: A) {
|
||||
const fileData = (await this.readFile()) || {}
|
||||
const mergeData = merge({}, fileData, data)
|
||||
return await this.writeFile(this.validate(mergeData))
|
||||
return await this.writeFile(this.validate(data))
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts partial structured data and performs a merge with the existing file on disk.
|
||||
*/
|
||||
async merge(data: T.DeepPartial<A>) {
|
||||
const fileData =
|
||||
(await this.readFile()) ||
|
||||
(() => {
|
||||
throw new Error(`${this.path}: does not exist`)
|
||||
})()
|
||||
const mergeData = merge({}, fileData, data)
|
||||
const fileData = (await this.readFile()) || null
|
||||
const mergeData = fileMerge(fileData, data)
|
||||
return await this.writeFile(this.validate(mergeData))
|
||||
}
|
||||
|
||||
|
||||
30
sdk/package/package-lock.json
generated
30
sdk/package/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@start9labs/start-sdk",
|
||||
"version": "0.3.6-beta.4",
|
||||
"version": "0.3.6-beta.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@start9labs/start-sdk",
|
||||
"version": "0.3.6-beta.4",
|
||||
"version": "0.3.6-beta.14",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@iarna/toml": "^2.2.5",
|
||||
@@ -19,6 +19,25 @@
|
||||
"yaml": "^2.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@types/jest": "^29.4.0",
|
||||
"@types/lodash.merge": "^4.6.2",
|
||||
"copyfiles": "^2.4.1",
|
||||
"jest": "^29.4.3",
|
||||
"peggy": "^3.0.2",
|
||||
"prettier": "^3.2.5",
|
||||
"ts-jest": "^29.0.5",
|
||||
"ts-node": "^10.9.1",
|
||||
"ts-pegjs": "^4.2.1",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
},
|
||||
"../base": {
|
||||
"name": "@start9labs/start-sdk-base",
|
||||
"extraneous": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@types/jest": "^29.4.0",
|
||||
"@types/lodash.merge": "^4.6.2",
|
||||
@@ -4938,9 +4957,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.7.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz",
|
||||
"integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==",
|
||||
"version": "5.7.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
|
||||
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -5146,7 +5165,6 @@
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz",
|
||||
"integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@start9labs/start-sdk",
|
||||
"version": "0.3.6-beta.4",
|
||||
"version": "0.3.6-beta.14",
|
||||
"description": "Software development kit to facilitate packaging services for StartOS",
|
||||
"main": "./package/lib/index.js",
|
||||
"types": "./package/lib/index.d.ts",
|
||||
@@ -58,7 +58,6 @@
|
||||
"ts-node": "^10.9.1",
|
||||
"ts-pegjs": "^4.2.1",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.0.4",
|
||||
"yaml": "^2.2.2"
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"skipLibCheck": true,
|
||||
"module": "commonjs",
|
||||
"outDir": "../dist",
|
||||
"target": "es2018"
|
||||
"target": "es2021"
|
||||
},
|
||||
"include": ["lib/**/*", "../base/lib/util/Hostname.ts"],
|
||||
"exclude": ["lib/**/*.spec.ts", "lib/**/*.gen.ts", "list", "node_modules"]
|
||||
|
||||
Reference in New Issue
Block a user