Merge branch 'next/minor' of github.com:Start9Labs/start-os into next/major

This commit is contained in:
Aiden McClelland
2025-03-03 12:51:40 -07:00
213 changed files with 53468 additions and 12274 deletions

View File

@@ -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),
)
}

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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 || [],

View File

@@ -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,

View File

@@ -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,
),
)
}
}

View File

@@ -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,

View File

@@ -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))
}

View File

@@ -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"

View File

@@ -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"
}
}

View File

@@ -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"]