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

This commit is contained in:
Matt Hill
2025-02-08 19:19:35 -07:00
parent 95cad7bdd9
commit 95722802dc
206 changed files with 11364 additions and 4104 deletions

View File

@@ -102,7 +102,6 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
| "clearServiceInterfaces"
| "bind"
| "getHostInfo"
| "getPrimaryUrl"
type MainUsedEffects = "setMainStatus" | "setHealth"
type CallbackEffects = "constRetry" | "clearCallbacks"
type AlreadyExposed = "getSslCertificate" | "getSystemSmtp"
@@ -216,18 +215,14 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
}),
},
host: {
// static: (effects: Effects, id: string) =>
// new StaticHost({ id, effects }),
// single: (effects: Effects, id: string) =>
// new SingleHost({ id, effects }),
multi: (effects: Effects, id: string) => new MultiHost({ id, effects }),
MultiHost: {
of: (effects: Effects, id: string) => new MultiHost({ id, effects }),
},
nullIfEmpty,
runCommand: async <A extends string>(
effects: Effects,
image: {
id: keyof Manifest["images"] & T.ImageId
imageId: keyof Manifest["images"] & T.ImageId
sharedRun?: boolean
},
command: T.CommandType,
@@ -379,7 +374,6 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
id: 'ui',
description: 'The primary web app for this service.',
type: 'ui',
hasPrimary: false,
masked: false,
schemeOverride: null,
username: null,
@@ -397,8 +391,6 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
id: string
/** The human readable description. */
description: string
/** No effect until StartOS v0.4.0. If true, forces the user to select one URL (i.e. .onion, .local, or IP address) as the primary URL. This is needed by some services to function properly. */
hasPrimary: boolean
/** Affects how the interface appears to the user. One of: 'ui', 'api', 'p2p'. If 'ui', the user will see a "Launch UI" button */
type: ServiceInterfaceType
/** (optional) prepends the provided username to all URLs. */
@@ -552,7 +544,7 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
inputSpecSpec,
async ({ effects, input }) => {
// ** UI multi-host **
const uiMulti = sdk.host.multi(effects, 'ui-multi')
const uiMulti = sdk.MultiHost.of(effects, 'ui-multi')
const uiMultiOrigin = await uiMulti.bindPort(80, {
protocol: 'http',
})
@@ -562,7 +554,6 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
id: 'primary-ui',
description: 'The primary web app for this service.',
type: 'ui',
hasPrimary: false,
masked: false,
schemeOverride: null,
username: null,
@@ -575,7 +566,6 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
id: 'admin-ui',
description: 'The admin web app for this service.',
type: 'ui',
hasPrimary: false,
masked: false,
schemeOverride: null,
username: null,
@@ -586,7 +576,7 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
const uiReceipt = await uiMultiOrigin.export([primaryUi, adminUi])
// ** API multi-host **
const apiMulti = sdk.host.multi(effects, 'api-multi')
const apiMulti = sdk.MultiHost.of(effects, 'api-multi')
const apiMultiOrigin = await apiMulti.bindPort(5959, {
protocol: 'http',
})
@@ -596,7 +586,6 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
id: 'api',
description: 'The advanced API for this service.',
type: 'api',
hasPrimary: false,
masked: false,
schemeOverride: null,
username: null,
@@ -688,6 +677,18 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
return Daemons.of<Manifest>({ effects, started, healthReceipts })
},
},
SubContainer: {
of(
effects: Effects,
image: {
imageId: T.ImageId & keyof Manifest["images"]
sharedRun?: boolean
},
name: string,
) {
return SubContainer.of(effects, image, name)
},
},
List: {
/**
* @description Create a list of text inputs.
@@ -1269,7 +1270,6 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
* @example default: 'radio1'
*/
default: keyof Variants & string
required: boolean
/**
* @description A mapping of unique radio options to their human readable display format.
* @example
@@ -1410,7 +1410,7 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
export async function runCommand<Manifest extends T.SDKManifest>(
effects: Effects,
image: { id: keyof Manifest["images"] & T.ImageId; sharedRun?: boolean },
image: { imageId: keyof Manifest["images"] & T.ImageId; sharedRun?: boolean },
command: string | [string, ...string[]],
options: CommandOptions & {
mounts?: { path: string; options: MountOptions }[]

View File

@@ -6,16 +6,17 @@ import * as CP from "node:child_process"
const cpExec = promisify(CP.exec)
export function containsAddress(x: string, port: number) {
export function containsAddress(x: string, port: number, address?: bigint) {
const readPorts = x
.split("\n")
.filter(Boolean)
.splice(1)
.map((x) => x.split(" ").filter(Boolean)[1]?.split(":")?.[1])
.filter(Boolean)
.map((x) => Number.parseInt(x, 16))
.filter(Number.isFinite)
return readPorts.indexOf(port) >= 0
.map((x) => x.split(" ").filter(Boolean)[1]?.split(":"))
.filter((x) => x?.length > 1)
.map(([addr, p]) => [BigInt(`0x${addr}`), Number.parseInt(p, 16)] as const)
return !!readPorts.find(
([addr, p]) => (address === undefined || address === addr) && port === p,
)
}
/**
@@ -39,9 +40,19 @@ export async function checkPortListening(
await cpExec(`cat /proc/net/tcp`, {}).then(stringFromStdErrOut),
port,
) ||
containsAddress(
await cpExec(`cat /proc/net/tcp6`, {}).then(stringFromStdErrOut),
port,
BigInt(0),
) ||
containsAddress(
await cpExec("cat /proc/net/udp", {}).then(stringFromStdErrOut),
port,
) ||
containsAddress(
await cpExec("cat /proc/net/udp6", {}).then(stringFromStdErrOut),
port,
BigInt(0),
)
if (hasAddress) {
return { result: "success", message: options.successMessage }

View File

@@ -23,7 +23,7 @@ export class CommandController {
effects: T.Effects,
subcontainer:
| {
id: keyof Manifest["images"] & T.ImageId
imageId: keyof Manifest["images"] & T.ImageId
sharedRun?: boolean
}
| SubContainer,
@@ -60,51 +60,59 @@ export class CommandController {
}
return subc
})()
let childProcess: cp.ChildProcess
if (options.runAsInit) {
childProcess = await subc.launch(commands, {
env: options.env,
})
} else {
childProcess = await subc.spawn(commands, {
env: options.env,
stdio: options.onStdout || options.onStderr ? "pipe" : "inherit",
try {
let childProcess: cp.ChildProcess
if (options.runAsInit) {
childProcess = await subc.launch(commands, {
env: options.env,
})
} else {
childProcess = await subc.spawn(commands, {
env: options.env,
stdio: options.onStdout || options.onStderr ? "pipe" : "inherit",
})
}
if (options.onStdout) childProcess.stdout?.on("data", options.onStdout)
if (options.onStderr) childProcess.stderr?.on("data", options.onStderr)
const state = { exited: false }
const answer = new Promise<null>((resolve, reject) => {
childProcess.on("exit", (code) => {
state.exited = true
if (
code === 0 ||
code === 143 ||
(code === null && childProcess.signalCode == "SIGTERM")
) {
return resolve(null)
}
if (code) {
return reject(
new Error(`${commands[0]} exited with code ${code}`),
)
} else {
return reject(
new Error(
`${commands[0]} exited with signal ${childProcess.signalCode}`,
),
)
}
})
})
return new CommandController(
answer,
state,
subc,
childProcess,
options.sigtermTimeout,
)
} catch (e) {
await subc.destroy()
throw e
}
if (options.onStdout) childProcess.stdout?.on("data", options.onStdout)
if (options.onStderr) childProcess.stderr?.on("data", options.onStderr)
const state = { exited: false }
const answer = new Promise<null>((resolve, reject) => {
childProcess.on("exit", (code) => {
state.exited = true
if (
code === 0 ||
code === 143 ||
(code === null && childProcess.signalCode == "SIGTERM")
) {
return resolve(null)
}
if (code) {
return reject(new Error(`${commands[0]} exited with code ${code}`))
} else {
return reject(
new Error(
`${commands[0]} exited with signal ${childProcess.signalCode}`,
),
)
}
})
})
return new CommandController(
answer,
state,
subc,
childProcess,
options.sigtermTimeout,
)
}
}
get subContainerHandle() {
@@ -121,7 +129,7 @@ export class CommandController {
if (!this.state.exited) {
this.process.kill("SIGKILL")
}
await this.subcontainer.destroy?.().catch((_) => {})
await this.subcontainer.destroy().catch((_) => {})
}
}
async term({ signal = SIGTERM, timeout = this.sigtermTimeout } = {}) {
@@ -141,7 +149,7 @@ export class CommandController {
await this.runningAnswer
} finally {
await this.subcontainer.destroy?.()
await this.subcontainer.destroy()
}
}
}

View File

@@ -22,7 +22,7 @@ export class Daemon {
effects: T.Effects,
subcontainer:
| {
id: keyof Manifest["images"] & T.ImageId
imageId: keyof Manifest["images"] & T.ImageId
sharedRun?: boolean
}
| SubContainer,
@@ -60,6 +60,8 @@ export class Daemon {
let timeoutCounter = 0
new Promise(async () => {
while (this.shouldBeRunning) {
if (this.commandController)
await this.commandController.term().catch((err) => console.error(err))
this.commandController = await this.startCommand()
await this.commandController.wait().catch((err) => console.error(err))
await new Promise((resolve) => setTimeout(resolve, timeoutCounter))

View File

@@ -5,7 +5,7 @@ import { HealthCheckResult } from "../health/checkFns"
import { Trigger } from "../trigger"
import * as T from "../../../base/lib/types"
import { Mounts } from "./Mounts"
import { ExecSpawnable, MountOptions } from "../util/SubContainer"
import { ExecSpawnable, MountOptions, SubContainer } from "../util/SubContainer"
import { promisify } from "node:util"
import * as CP from "node:child_process"
@@ -49,16 +49,18 @@ type DaemonsParams<
> = {
/** The command line command to start the daemon */
command: T.CommandType
/** Information about the image in which the daemon runs */
image: {
/** The ID of the image. Must be one of the image IDs declared in the manifest */
id: keyof Manifest["images"] & T.ImageId
/**
* Whether or not to share the `/run` directory with the parent container.
* This is useful if you are trying to connect to a service that exposes a unix domain socket or auth cookie via the `/run` directory
*/
sharedRun?: boolean
}
/** Information about the subcontainer in which the daemon runs */
subcontainer:
| {
/** The ID of the image. Must be one of the image IDs declared in the manifest */
imageId: keyof Manifest["images"] & T.ImageId
/**
* Whether or not to share the `/run` directory with the parent container.
* This is useful if you are trying to connect to a service that exposes a unix domain socket or auth cookie via the `/run` directory
*/
sharedRun?: boolean
}
| SubContainer
/** For mounting the necessary volumes. Syntax: sdk.Mounts.of().addVolume() */
mounts: Mounts<Manifest>
env?: Record<string, string>
@@ -147,11 +149,16 @@ export class Daemons<Manifest extends T.SDKManifest, Ids extends string>
options: DaemonsParams<Manifest, Ids, Command, Id>,
) {
const daemonIndex = this.daemons.length
const daemon = Daemon.of()(this.effects, options.image, options.command, {
...options,
mounts: options.mounts.build(),
subcontainerName: id,
})
const daemon = Daemon.of()(
this.effects,
options.subcontainer,
options.command,
{
...options,
mounts: options.mounts.build(),
subcontainerName: id,
},
)
const healthDaemon = new HealthDaemon(
daemon,
daemonIndex,
@@ -178,14 +185,18 @@ export class Daemons<Manifest extends T.SDKManifest, Ids extends string>
}
async build() {
this.updateMainHealth()
this.healthDaemons.forEach((x) =>
x.addWatcher(() => this.updateMainHealth()),
)
const built = {
term: async (options?: { signal?: Signals; timeout?: number }) => {
term: async () => {
try {
await Promise.all(this.healthDaemons.map((x) => x.term(options)))
for (let result of await Promise.allSettled(
this.healthDaemons.map((x) =>
x.term({ timeout: x.sigtermTimeout }),
),
)) {
if (result.status === "rejected") {
console.error(result.reason)
}
}
} finally {
this.effects.setMainStatus({ status: "stopped" })
}
@@ -194,8 +205,4 @@ export class Daemons<Manifest extends T.SDKManifest, Ids extends string>
this.started(() => built.term())
return built
}
private updateMainHealth() {
this.effects.setMainStatus({ status: "running" })
}
}

View File

@@ -25,6 +25,8 @@ export class HealthDaemon {
private _health: HealthCheckResult = { result: "starting", message: null }
private healthWatchers: Array<() => unknown> = []
private running = false
private resolveReady: (() => void) | undefined
private readyPromise: Promise<void>
constructor(
private readonly daemon: Promise<Daemon>,
readonly daemonIndex: number,
@@ -35,6 +37,7 @@ export class HealthDaemon {
readonly effects: Effects,
readonly sigtermTimeout: number = DEFAULT_SIGTERM_TIMEOUT,
) {
this.readyPromise = new Promise((resolve) => (this.resolveReady = resolve))
this.updateStatus()
this.dependencies.forEach((d) => d.addWatcher(() => this.updateStatus()))
}
@@ -112,6 +115,12 @@ export class HealthDaemon {
message: "message" in err ? err.message : String(err),
}
})
if (
this.resolveReady &&
(response.result === "success" || response.result === "disabled")
) {
this.resolveReady()
}
await this.setHealth(response)
} else {
await this.setHealth({
@@ -129,6 +138,10 @@ export class HealthDaemon {
}
}
onReady() {
return this.readyPromise
}
private async setHealth(health: HealthCheckResult) {
this._health = health
this.healthWatchers.forEach((watcher) => watcher())

View File

@@ -26,16 +26,6 @@ export function setupManifest<
return manifest
}
function gitHash(): string {
const hash = execSync("git rev-parse HEAD").toString().trim()
try {
execSync("git diff-index --quiet HEAD --")
return hash
} catch (e) {
return hash + "-modified"
}
}
export function buildManifest<
Id extends string,
Version extends string,
@@ -68,7 +58,6 @@ export function buildManifest<
)
return {
...manifest,
gitHash: gitHash(),
osVersion: SDKVersion,
version: versions.current.options.version,
releaseNotes: versions.current.options.releaseNotes,

View File

@@ -5,7 +5,7 @@ import { sdk } from "../test/output.sdk"
describe("host", () => {
test("Testing that the types work", () => {
async function test(effects: Effects) {
const foo = sdk.host.multi(effects, "foo")
const foo = sdk.MultiHost.of(effects, "foo")
const fooOrigin = await foo.bindPort(80, {
protocol: "http" as const,
preferredExternalPort: 80,
@@ -15,7 +15,6 @@ describe("host", () => {
name: "Foo",
id: "foo",
description: "A Foo",
hasPrimary: false,
type: "ui",
username: "bar",
path: "/baz",

View File

@@ -86,12 +86,12 @@ export class SubContainer implements ExecSpawnable {
}
static async of(
effects: T.Effects,
image: { id: T.ImageId; sharedRun?: boolean },
image: { imageId: T.ImageId; sharedRun?: boolean },
name: string,
) {
const { id, sharedRun } = image
const { imageId, sharedRun } = image
const [rootfs, guid] = await effects.subcontainer.createFs({
imageId: id as string,
imageId,
name,
})
@@ -111,12 +111,12 @@ export class SubContainer implements ExecSpawnable {
await execFile("mount", ["--rbind", from, to])
}
return new SubContainer(effects, id, rootfs, guid)
return new SubContainer(effects, imageId, rootfs, guid)
}
static async with<T>(
effects: T.Effects,
image: { id: T.ImageId; sharedRun?: boolean },
image: { imageId: T.ImageId; sharedRun?: boolean },
mounts: { options: MountOptions; path: string }[],
name: string,
fn: (subContainer: SubContainer) => Promise<T>,

View File

@@ -1,12 +1,12 @@
{
"name": "@start9labs/start-sdk",
"version": "0.3.6-alpha.21",
"version": "0.3.6-beta.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@start9labs/start-sdk",
"version": "0.3.6-alpha.21",
"version": "0.3.6-beta.4",
"license": "MIT",
"dependencies": {
"@iarna/toml": "^2.2.5",
@@ -15,7 +15,7 @@
"isomorphic-fetch": "^3.0.0",
"lodash.merge": "^4.6.2",
"mime-types": "^2.1.35",
"ts-matches": "^6.1.0",
"ts-matches": "^6.2.1",
"yaml": "^2.2.2"
},
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@start9labs/start-sdk",
"version": "0.3.6-alpha.21",
"version": "0.3.6-beta.4",
"description": "Software development kit to facilitate packaging services for StartOS",
"main": "./package/lib/index.js",
"types": "./package/lib/index.d.ts",
@@ -34,7 +34,7 @@
"isomorphic-fetch": "^3.0.0",
"lodash.merge": "^4.6.2",
"mime-types": "^2.1.35",
"ts-matches": "^6.1.0",
"ts-matches": "^6.2.1",
"yaml": "^2.2.2",
"@iarna/toml": "^2.2.5",
"@noble/curves": "^1.4.0",