mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 12:11:56 +00:00
misc improvements (#2836)
* misc improvements * kill proc before destroying subcontainer fs * version bump * beta.11 * use bind mount explicitly * Update sdk/base/lib/Effects.ts Co-authored-by: Dominion5254 <musashidisciple@proton.me> --------- Co-authored-by: Dominion5254 <musashidisciple@proton.me>
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,6 +71,7 @@ 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")
|
||||
|
||||
@@ -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: {
|
||||
@@ -234,13 +238,7 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
|
||||
*/
|
||||
name?: string,
|
||||
): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> => {
|
||||
return runCommand<Manifest>(
|
||||
effects,
|
||||
image,
|
||||
command,
|
||||
options,
|
||||
name || (Array.isArray(command) ? command.join(" ") : command),
|
||||
)
|
||||
return runCommand<Manifest>(effects, image, command, options, name)
|
||||
},
|
||||
/**
|
||||
* @description Use this class to create an Action. By convention, each Action should receive its own file.
|
||||
@@ -1081,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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,14 +11,17 @@ export type HealthCheckParams = {
|
||||
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(
|
||||
@@ -33,7 +36,9 @@ 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.id,
|
||||
@@ -48,7 +53,8 @@ export function healthCheck(o: HealthCheckParams) {
|
||||
await o.effects.setHealth({
|
||||
name: o.name,
|
||||
id: o.id,
|
||||
result: "failure",
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -294,15 +310,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 +335,7 @@ export class SubContainer implements ExecSpawnable {
|
||||
"subcontainer",
|
||||
"launch",
|
||||
`--env=/media/startos/images/${this.imageId}.env`,
|
||||
`--user=${user}`,
|
||||
`--workdir=${workdir}`,
|
||||
...extra,
|
||||
this.rootfs,
|
||||
@@ -336,15 +354,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 +377,7 @@ export class SubContainer implements ExecSpawnable {
|
||||
"subcontainer",
|
||||
"exec",
|
||||
`--env=/media/startos/images/${this.imageId}.env`,
|
||||
`--user=${user}`,
|
||||
`--workdir=${workdir}`,
|
||||
...extra,
|
||||
this.rootfs,
|
||||
|
||||
Reference in New Issue
Block a user