mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-04-04 22:39:46 +00:00
Feature/subcontainers (#2720)
* wip: subcontainers * wip: subcontainer infra * rename NonDestroyableOverlay to SubContainerHandle * chore: Changes to the container and other things * wip: * wip: fixes * fix launch & exec Co-authored-by: Jade <Blu-J@users.noreply.github.com> * tweak apis * misc fixes * don't treat sigterm as error * handle health check set during starting --------- Co-authored-by: J H <dragondef@gmail.com> Co-authored-by: Jade <Blu-J@users.noreply.github.com>
This commit is contained in:
@@ -6,32 +6,35 @@ import { asError } from "../util/asError"
|
||||
import {
|
||||
ExecSpawnable,
|
||||
MountOptions,
|
||||
NonDestroyableOverlay,
|
||||
Overlay,
|
||||
} from "../util/Overlay"
|
||||
SubContainerHandle,
|
||||
SubContainer,
|
||||
} from "../util/SubContainer"
|
||||
import { splitCommand } from "../util/splitCommand"
|
||||
import { cpExecFile, cpExec } from "./Daemons"
|
||||
import * as cp from "child_process"
|
||||
|
||||
export class CommandController {
|
||||
private constructor(
|
||||
readonly runningAnswer: Promise<unknown>,
|
||||
private readonly overlay: ExecSpawnable,
|
||||
readonly pid: number | undefined,
|
||||
private state: { exited: boolean },
|
||||
private readonly subcontainer: SubContainer,
|
||||
private process: cp.ChildProcessWithoutNullStreams,
|
||||
readonly sigtermTimeout: number = DEFAULT_SIGTERM_TIMEOUT,
|
||||
) {}
|
||||
static of<Manifest extends T.Manifest>() {
|
||||
return async <A extends string>(
|
||||
effects: T.Effects,
|
||||
imageId: {
|
||||
id: keyof Manifest["images"] & T.ImageId
|
||||
sharedRun?: boolean
|
||||
},
|
||||
subcontainer:
|
||||
| {
|
||||
id: keyof Manifest["images"] & T.ImageId
|
||||
sharedRun?: boolean
|
||||
}
|
||||
| SubContainer,
|
||||
command: T.CommandType,
|
||||
options: {
|
||||
// Defaults to the DEFAULT_SIGTERM_TIMEOUT = 30_000ms
|
||||
sigtermTimeout?: number
|
||||
mounts?: { path: string; options: MountOptions }[]
|
||||
overlay?: ExecSpawnable
|
||||
runAsInit?: boolean
|
||||
env?:
|
||||
| {
|
||||
[variable: string]: string
|
||||
@@ -44,49 +47,60 @@ export class CommandController {
|
||||
},
|
||||
) => {
|
||||
const commands = splitCommand(command)
|
||||
const overlay =
|
||||
options.overlay ||
|
||||
(await (async () => {
|
||||
const overlay = await Overlay.of(effects, imageId)
|
||||
for (let mount of options.mounts || []) {
|
||||
await overlay.mount(mount.options, mount.path)
|
||||
}
|
||||
return overlay
|
||||
})())
|
||||
const childProcess = await overlay.spawn(commands, {
|
||||
env: options.env,
|
||||
})
|
||||
const subc =
|
||||
subcontainer instanceof SubContainer
|
||||
? subcontainer
|
||||
: await (async () => {
|
||||
const subc = await SubContainer.of(effects, subcontainer)
|
||||
for (let mount of options.mounts || []) {
|
||||
await subc.mount(mount.options, mount.path)
|
||||
}
|
||||
return subc
|
||||
})()
|
||||
let childProcess: cp.ChildProcessWithoutNullStreams
|
||||
if (options.runAsInit) {
|
||||
childProcess = await subc.launch(commands, {
|
||||
env: options.env,
|
||||
})
|
||||
} else {
|
||||
childProcess = await subc.spawn(commands, {
|
||||
env: options.env,
|
||||
})
|
||||
}
|
||||
const state = { exited: false }
|
||||
const answer = new Promise<null>((resolve, reject) => {
|
||||
childProcess.stdout.on(
|
||||
"data",
|
||||
options.onStdout ??
|
||||
((data: any) => {
|
||||
console.log(data.toString())
|
||||
}),
|
||||
)
|
||||
childProcess.stderr.on(
|
||||
"data",
|
||||
options.onStderr ??
|
||||
((data: any) => {
|
||||
console.error(asError(data))
|
||||
}),
|
||||
)
|
||||
|
||||
childProcess.on("exit", (code: any) => {
|
||||
if (code === 0) {
|
||||
childProcess.on("exit", (code) => {
|
||||
state.exited = true
|
||||
if (
|
||||
code === 0 ||
|
||||
code === 143 ||
|
||||
(code === null && childProcess.signalCode == "SIGTERM")
|
||||
) {
|
||||
return resolve(null)
|
||||
}
|
||||
return reject(new Error(`${commands[0]} exited with code ${code}`))
|
||||
if (code) {
|
||||
return reject(new Error(`${commands[0]} exited with code ${code}`))
|
||||
} else {
|
||||
return reject(
|
||||
new Error(
|
||||
`${commands[0]} exited with signal ${childProcess.signalCode}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const pid = childProcess.pid
|
||||
|
||||
return new CommandController(answer, overlay, pid, options.sigtermTimeout)
|
||||
return new CommandController(
|
||||
answer,
|
||||
state,
|
||||
subc,
|
||||
childProcess,
|
||||
options.sigtermTimeout,
|
||||
)
|
||||
}
|
||||
}
|
||||
get nonDestroyableOverlay() {
|
||||
return new NonDestroyableOverlay(this.overlay)
|
||||
get subContainerHandle() {
|
||||
return new SubContainerHandle(this.subcontainer)
|
||||
}
|
||||
async wait({ timeout = NO_TIMEOUT } = {}) {
|
||||
if (timeout > 0)
|
||||
@@ -96,75 +110,30 @@ export class CommandController {
|
||||
try {
|
||||
return await this.runningAnswer
|
||||
} finally {
|
||||
if (this.pid !== undefined) {
|
||||
await cpExecFile("pkill", ["-9", "-s", String(this.pid)]).catch(
|
||||
(_) => {},
|
||||
)
|
||||
if (!this.state.exited) {
|
||||
this.process.kill("SIGKILL")
|
||||
}
|
||||
await this.overlay.destroy?.().catch((_) => {})
|
||||
await this.subcontainer.destroy?.().catch((_) => {})
|
||||
}
|
||||
}
|
||||
async term({ signal = SIGTERM, timeout = this.sigtermTimeout } = {}) {
|
||||
if (this.pid === undefined) return
|
||||
try {
|
||||
await cpExecFile("pkill", [
|
||||
`-${signal.replace("SIG", "")}`,
|
||||
"-s",
|
||||
String(this.pid),
|
||||
])
|
||||
|
||||
const didTimeout = await waitSession(this.pid, timeout)
|
||||
if (didTimeout) {
|
||||
await cpExecFile("pkill", [`-9`, "-s", String(this.pid)]).catch(
|
||||
(_) => {},
|
||||
)
|
||||
if (!this.state.exited) {
|
||||
if (!this.process.kill(signal)) {
|
||||
console.error(
|
||||
`failed to send signal ${signal} to pid ${this.process.pid}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await this.overlay.destroy?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function waitSession(
|
||||
sid: number,
|
||||
timeout = NO_TIMEOUT,
|
||||
interval = 100,
|
||||
): Promise<boolean> {
|
||||
let nextInterval = interval * 2
|
||||
if (timeout >= 0 && timeout < nextInterval) {
|
||||
nextInterval = timeout
|
||||
}
|
||||
let nextTimeout = timeout
|
||||
if (timeout > 0) {
|
||||
if (timeout >= interval) {
|
||||
nextTimeout -= interval
|
||||
} else {
|
||||
nextTimeout = 0
|
||||
if (signal !== "SIGKILL") {
|
||||
setTimeout(() => {
|
||||
this.process.kill("SIGKILL")
|
||||
}, timeout)
|
||||
}
|
||||
await this.runningAnswer
|
||||
} finally {
|
||||
await this.subcontainer.destroy?.()
|
||||
}
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
let next: NodeJS.Timeout | null = null
|
||||
if (timeout !== 0) {
|
||||
next = setTimeout(() => {
|
||||
waitSession(sid, nextTimeout, nextInterval).then(resolve, reject)
|
||||
}, interval)
|
||||
}
|
||||
cpExecFile("ps", [`--sid=${sid}`, "-o", "--pid="]).then(
|
||||
(_) => {
|
||||
if (timeout === 0) {
|
||||
resolve(true)
|
||||
}
|
||||
},
|
||||
(e) => {
|
||||
if (next) {
|
||||
clearTimeout(next)
|
||||
}
|
||||
if (typeof e === "object" && e && "code" in e && e.code) {
|
||||
resolve(false)
|
||||
} else {
|
||||
reject(e)
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user