From 2c65033c0a99569dd3175c630203984bef614b5b Mon Sep 17 00:00:00 2001 From: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com> Date: Fri, 18 Apr 2025 14:11:13 -0600 Subject: [PATCH] Feature/sdk improvements (#2879) * sdk improvements * subcontainer fixes, disable wifi on migration if not in use, filterable interfaces --- .../DockerProcedureContainer.ts | 19 +- .../Systems/SystemForEmbassy/MainLoop.ts | 4 +- .../SystemForEmbassy/polyfillEffects.ts | 2 +- core/Cargo.lock | 13 ++ core/startos/Cargo.toml | 1 + core/startos/src/auth.rs | 37 ++- core/startos/src/setup.rs | 13 +- core/startos/src/util/io.rs | 17 ++ core/startos/src/version/v0_4_0_alpha_0.rs | 2 +- sdk/base/lib/util/getServiceInterface.ts | 69 ++++++ sdk/package/lib/StartSdk.ts | 27 ++- sdk/package/lib/backup/Backups.ts | 10 +- .../lib/health/checkFns/runHealthScript.ts | 5 +- sdk/package/lib/mainFn/CommandController.ts | 14 +- sdk/package/lib/mainFn/Daemon.ts | 13 +- sdk/package/lib/mainFn/Daemons.ts | 7 +- sdk/package/lib/mainFn/HealthDaemon.ts | 8 +- sdk/package/lib/mainFn/Mounts.ts | 91 ++++++-- sdk/package/lib/util/SubContainer.ts | 210 ++++++++++++------ 19 files changed, 426 insertions(+), 136 deletions(-) diff --git a/container-runtime/src/Adapters/Systems/SystemForEmbassy/DockerProcedureContainer.ts b/container-runtime/src/Adapters/Systems/SystemForEmbassy/DockerProcedureContainer.ts index 941e21ac8..b472862db 100644 --- a/container-runtime/src/Adapters/Systems/SystemForEmbassy/DockerProcedureContainer.ts +++ b/container-runtime/src/Adapters/Systems/SystemForEmbassy/DockerProcedureContainer.ts @@ -9,6 +9,9 @@ import { ExecOptions, ExecSpawnable, } from "@start9labs/start-sdk/package/lib/util/SubContainer" +import { Mounts } from "@start9labs/start-sdk/package/lib/mainFn/Mounts" +import { Manifest } from "@start9labs/start-sdk/base/lib/osBindings" +import { BackupEffects } from "@start9labs/start-sdk/package/lib/backup/Backups" export const exec = promisify(cp.exec) export const execFile = promisify(cp.execFile) @@ -42,8 +45,9 @@ export class DockerProcedureContainer { name: string, ) { const subcontainer = await SubContainer.of( - effects, + effects as BackupEffects, { imageId: data.image }, + null, name, ) @@ -57,14 +61,10 @@ export class DockerProcedureContainer { const volumeMount = volumes[mount] if (volumeMount.type === "data") { await subcontainer.mount( - { type: "volume", id: mount, subpath: null, readonly: false }, - mounts[mount], + Mounts.of().addVolume(mount, null, mounts[mount], false), ) } else if (volumeMount.type === "assets") { - await subcontainer.mount( - { type: "assets", subpath: mount }, - mounts[mount], - ) + await subcontainer.mount(Mounts.of().addAssets(mount, mounts[mount])) } else if (volumeMount.type === "certificate") { const hostnames = [ `${packageId}.embassy`, @@ -107,10 +107,7 @@ export class DockerProcedureContainer { }) .catch(console.warn) } else if (volumeMount.type === "backup") { - await subcontainer.mount( - { type: "backup", subpath: null }, - mounts[mount], - ) + await subcontainer.mount(Mounts.of().addBackups(null, mounts[mount])) } } } diff --git a/container-runtime/src/Adapters/Systems/SystemForEmbassy/MainLoop.ts b/container-runtime/src/Adapters/Systems/SystemForEmbassy/MainLoop.ts index fa76a1f84..4ab4266e0 100644 --- a/container-runtime/src/Adapters/Systems/SystemForEmbassy/MainLoop.ts +++ b/container-runtime/src/Adapters/Systems/SystemForEmbassy/MainLoop.ts @@ -6,6 +6,7 @@ import { Daemon } from "@start9labs/start-sdk/package/lib/mainFn/Daemon" import { Effects } from "../../../Models/Effects" import { off } from "node:process" import { CommandController } from "@start9labs/start-sdk/package/lib/mainFn/CommandController" +import { SDKManifest } from "@start9labs/start-sdk/base/lib/types" const EMBASSY_HEALTH_INTERVAL = 15 * 1000 const EMBASSY_PROPERTIES_LOOP = 30 * 1000 @@ -24,7 +25,7 @@ export class MainLoop { }[] private mainEvent?: { - daemon: Daemon + daemon: Daemon } private constructor( @@ -72,6 +73,7 @@ export class MainLoop { env: { TINI_SUBREAPER: "true", }, + mounts: null, sigtermTimeout: utils.inMs( this.system.manifest.main["sigterm-timeout"], ), diff --git a/container-runtime/src/Adapters/Systems/SystemForEmbassy/polyfillEffects.ts b/container-runtime/src/Adapters/Systems/SystemForEmbassy/polyfillEffects.ts index 88e9d7ae6..fe56bfe54 100644 --- a/container-runtime/src/Adapters/Systems/SystemForEmbassy/polyfillEffects.ts +++ b/container-runtime/src/Adapters/Systems/SystemForEmbassy/polyfillEffects.ts @@ -139,7 +139,7 @@ export const polyfillEffects = ( effects, subcontainer, [input.command, ...(input.args || [])], - {}, + { mounts: null }, ), ) return { diff --git a/core/Cargo.lock b/core/Cargo.lock index ebf98d709..a0d511d5b 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -5510,6 +5510,18 @@ dependencies = [ "tempfile", ] +[[package]] +name = "sha-crypt" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88e79009728d8311d42d754f2f319a975f9e38f156fd5e422d2451486c78b286" +dependencies = [ + "base64ct", + "rand 0.8.5", + "sha2 0.10.8", + "subtle", +] + [[package]] name = "sha1" version = "0.10.6" @@ -6091,6 +6103,7 @@ dependencies = [ "serde_urlencoded", "serde_with", "serde_yml", + "sha-crypt", "sha2 0.10.8", "shell-words", "signal-hook", diff --git a/core/startos/Cargo.toml b/core/startos/Cargo.toml index 2801fc667..55d6a8622 100644 --- a/core/startos/Cargo.toml +++ b/core/startos/Cargo.toml @@ -183,6 +183,7 @@ serde_toml = { package = "toml", version = "0.8.2" } serde_urlencoded = "0.7" serde_with = { version = "3.4.0", features = ["macros", "json"] } serde_yaml = { package = "serde_yml", version = "0.0.10" } +sha-crypt = "0.5.0" sha2 = "0.10.2" shell-words = "1" signal-hook = "0.3.17" diff --git a/core/startos/src/auth.rs b/core/startos/src/auth.rs index 58aa54cc9..c5a0193c3 100644 --- a/core/startos/src/auth.rs +++ b/core/startos/src/auth.rs @@ -9,6 +9,7 @@ use josekit::jwk::Jwk; use rpc_toolkit::yajrc::RpcError; use rpc_toolkit::{from_fn_async, Context, HandlerArgs, HandlerExt, ParentHandler}; use serde::{Deserialize, Serialize}; +use tokio::io::AsyncWriteExt; use tracing::instrument; use ts_rs::TS; @@ -19,6 +20,7 @@ use crate::middleware::auth::{ }; use crate::prelude::*; use crate::util::crypto::EncryptedWire; +use crate::util::io::create_file_mod; use crate::util::serde::{display_serializable, HandlerExtSerde, WithIoFormat}; use crate::{ensure_code, Error, ResultExt}; @@ -41,6 +43,30 @@ impl Map for Sessions { } } +pub async fn write_shadow(password: &str) -> Result<(), Error> { + let shadow_contents = tokio::fs::read_to_string("/etc/shadow").await?; + let mut shadow_file = + create_file_mod("/media/startos/config/overlay/etc/shadow", 0o640).await?; + for line in shadow_contents.lines() { + if line.starts_with("start9:") { + let rest = line.splitn(3, ":").nth(2).ok_or_else(|| { + Error::new(eyre!("malformed /etc/shadow"), ErrorKind::ParseSysInfo) + })?; + let pw = sha_crypt::sha512_simple(password, &sha_crypt::Sha512Params::default()) + .map_err(|e| Error::new(eyre!("{e:?}"), ErrorKind::Serialization))?; + shadow_file + .write_all(format!("start9:{pw}:{rest}\n").as_bytes()) + .await?; + } else { + shadow_file.write_all(line.as_bytes()).await?; + shadow_file.write_all(b"\n").await?; + } + } + shadow_file.sync_all().await?; + tokio::fs::copy("/media/startos/config/overlay/etc/shadow", "/etc/shadow").await?; + Ok(()) +} + #[derive(Clone, Serialize, Deserialize, TS)] #[serde(untagged)] #[ts(export)] @@ -210,7 +236,7 @@ pub async fn login_impl( ) -> Result { let password = password.unwrap_or_default().decrypt(&ctx)?; - if ephemeral { + let tok = if ephemeral { check_password_against_db(&ctx.db.peek().await, &password)?; let hash_token = HashSessionToken::new(); ctx.ephemeral_sessions.mutate(|s| { @@ -242,7 +268,16 @@ pub async fn login_impl( }) .await .result + }?; + + if tokio::fs::metadata("/media/startos/config/overlay/etc/shadow") + .await + .is_err() + { + write_shadow(&password).await?; } + + Ok(tok) } #[derive(Deserialize, Serialize, Parser, TS)] diff --git a/core/startos/src/setup.rs b/core/startos/src/setup.rs index a1976e285..046974cd0 100644 --- a/core/startos/src/setup.rs +++ b/core/startos/src/setup.rs @@ -17,6 +17,7 @@ use tracing::instrument; use ts_rs::TS; use crate::account::AccountInfo; +use crate::auth::write_shadow; use crate::backup::restore::recover_full_embassy; use crate::backup::target::BackupTargetFS; use crate::context::rpc::InitRpcContextPhases; @@ -88,8 +89,8 @@ async fn setup_init( .db .mutate(|m| { let mut account = AccountInfo::load(m)?; - if let Some(password) = password { - account.set_password(&password)?; + if let Some(password) = &password { + account.set_password(password)?; } account.save(m)?; m.as_public_mut() @@ -101,6 +102,10 @@ async fn setup_init( .await .result?; + if let Some(password) = &password { + write_shadow(&password).await?; + } + Ok((account, init_result)) } @@ -346,6 +351,8 @@ pub async fn complete(ctx: SetupContext) -> Result { .arg(format!("--hostname={}", res.hostname.0)) .invoke(ErrorKind::ParseSysInfo) .await?; + Command::new("sync").invoke(ErrorKind::Filesystem).await?; + Ok(res.clone()) } Some(Err(e)) => Err(e.clone_output()), @@ -465,6 +472,8 @@ async fn fresh_setup( ) .await?; + write_shadow(start_os_password).await?; + Ok(((&account).try_into()?, rpc_ctx)) } diff --git a/core/startos/src/util/io.rs b/core/startos/src/util/io.rs index d9035c774..0495e2677 100644 --- a/core/startos/src/util/io.rs +++ b/core/startos/src/util/io.rs @@ -944,6 +944,23 @@ pub async fn create_file(path: impl AsRef) -> Result { .with_ctx(|_| (ErrorKind::Filesystem, lazy_format!("create {path:?}"))) } +pub async fn create_file_mod(path: impl AsRef, mode: u32) -> Result { + let path = path.as_ref(); + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent) + .await + .with_ctx(|_| (ErrorKind::Filesystem, lazy_format!("mkdir -p {parent:?}")))?; + } + OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .mode(mode) + .open(path) + .await + .with_ctx(|_| (ErrorKind::Filesystem, lazy_format!("create {path:?}"))) +} + pub async fn append_file(path: impl AsRef) -> Result { let path = path.as_ref(); if let Some(parent) = path.parent() { diff --git a/core/startos/src/version/v0_4_0_alpha_0.rs b/core/startos/src/version/v0_4_0_alpha_0.rs index 3c33a1b83..69297ac9e 100644 --- a/core/startos/src/version/v0_4_0_alpha_0.rs +++ b/core/startos/src/version/v0_4_0_alpha_0.rs @@ -31,7 +31,7 @@ impl VersionT for Version { fn up(self, db: &mut Value, _: Self::PreUpRes) -> Result<(), Error> { let host = db["public"]["serverInfo"]["host"].clone(); let mut wifi = db["public"]["serverInfo"]["wifi"].clone(); - wifi["enabled"] = Value::Bool(true); + wifi["enabled"] = Value::Bool(!wifi["selected"].is_null()); let mut network_interfaces = db["public"]["serverInfo"]["networkInterfaces"].clone(); for (k, v) in network_interfaces .as_object_mut() diff --git a/sdk/base/lib/util/getServiceInterface.ts b/sdk/base/lib/util/getServiceInterface.ts index 3aa3782d6..2084c0532 100644 --- a/sdk/base/lib/util/getServiceInterface.ts +++ b/sdk/base/lib/util/getServiceInterface.ts @@ -15,8 +15,28 @@ export const getHostname = (url: string): Hostname | null => { return last } +type FilterKinds = "onion" | "local" | "domain" | "ip" | "ipv4" | "ipv6" +export type Filter = { + visibility?: "public" | "private" + kind?: FilterKinds | FilterKinds[] + exclude?: Filter +} + +type Formats = "hostname-info" | "urlstring" | "url" +type FormatReturnTy = Format extends "hostname-info" + ? HostnameInfo + : Format extends "url" + ? URL + : UrlString + export type Filled = { hostnames: HostnameInfo[] + + filter: ( + filter: Filter, + format?: Format, + ) => FormatReturnTy[] + publicHostnames: HostnameInfo[] onionHostnames: HostnameInfo[] localHostnames: HostnameInfo[] @@ -97,6 +117,47 @@ export const addressHostToUrl = ( return res } +function filterRec( + hostnames: HostnameInfo[], + filter: Filter, + invert: boolean, +): HostnameInfo[] { + if (filter.visibility === "public") + hostnames = hostnames.filter( + (h) => invert !== (h.kind === "onion" || h.public), + ) + if (filter.visibility === "private") + hostnames = hostnames.filter( + (h) => invert !== (h.kind !== "onion" && !h.public), + ) + if (filter.kind) { + const kind = new Set( + Array.isArray(filter.kind) ? filter.kind : [filter.kind], + ) + if (kind.has("ip")) { + kind.add("ipv4") + kind.add("ipv6") + } + hostnames = hostnames.filter( + (h) => + invert !== + ((kind.has("onion") && h.kind === "onion") || + (kind.has("local") && + h.kind === "ip" && + h.hostname.kind === "local") || + (kind.has("domain") && + h.kind === "ip" && + h.hostname.kind === "domain") || + (kind.has("ipv4") && h.kind === "ip" && h.hostname.kind === "ipv4") || + (kind.has("ipv6") && h.kind === "ip" && h.hostname.kind === "ipv6")), + ) + } + + if (filter.exclude) return filterRec(hostnames, filter.exclude, !invert) + + return hostnames +} + export const filledAddress = ( host: Host, addressInfo: AddressInfo, @@ -107,6 +168,14 @@ export const filledAddress = ( return { ...addressInfo, hostnames, + filter: (filter: Filter, format?: T) => { + const res = filterRec(hostnames, filter, false) + if (format === "hostname-info") return res as FormatReturnTy[] + const urls = res.flatMap(toUrl) + if (format === "url") + return urls.map((u) => new URL(u)) as FormatReturnTy[] + return urls as FormatReturnTy[] + }, get publicHostnames() { return hostnames.filter((h) => h.kind === "onion" || h.public) }, diff --git a/sdk/package/lib/StartSdk.ts b/sdk/package/lib/StartSdk.ts index 5c2d99e3a..2fb1e40ad 100644 --- a/sdk/package/lib/StartSdk.ts +++ b/sdk/package/lib/StartSdk.ts @@ -306,7 +306,7 @@ export class StartSdk { }, command: T.CommandType, options: CommandOptions & { - mounts: Mounts + mounts: Mounts | null }, /** * A name to use to refer to the ephemeral subcontainer for debugging purposes @@ -766,25 +766,40 @@ export class StartSdk { }, }, SubContainer: { + /** + * @description Create a new SubContainer + * @param effects + * @param image - what container image to use + * @param mounts - what to mount to the subcontainer + * @param name - a name to use to refer to the subcontainer for debugging purposes + */ of( effects: Effects, image: { imageId: T.ImageId & keyof Manifest["images"] sharedRun?: boolean }, + mounts: Mounts | null, name: string, ) { - return SubContainer.of(effects, image, name) + return SubContainer.of(effects, image, mounts, name) }, + /** + * @description Create a new SubContainer + * @param effects + * @param image - what container image to use + * @param mounts - what to mount to the subcontainer + * @param name - a name to use to refer to the ephemeral subcontainer for debugging purposes + */ with( effects: T.Effects, image: { imageId: T.ImageId & keyof Manifest["images"] sharedRun?: boolean }, - mounts: { options: MountOptions; mountpoint: string }[], + mounts: Mounts | null, name: string, - fn: (subContainer: SubContainer) => Promise, + fn: (subContainer: SubContainer) => Promise, ): Promise { return SubContainer.with(effects, image, mounts, name, fn) }, @@ -1164,7 +1179,7 @@ export async function runCommand( image: { imageId: keyof Manifest["images"] & T.ImageId; sharedRun?: boolean }, command: T.CommandType, options: CommandOptions & { - mounts: Mounts + mounts: Mounts | null }, name?: string, ): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> { @@ -1182,7 +1197,7 @@ export async function runCommand( return SubContainer.with( effects, image, - options.mounts.build(), + options.mounts, name || commands .map((c) => { diff --git a/sdk/package/lib/backup/Backups.ts b/sdk/package/lib/backup/Backups.ts index ef7ee4cca..18fcbe795 100644 --- a/sdk/package/lib/backup/Backups.ts +++ b/sdk/package/lib/backup/Backups.ts @@ -1,7 +1,7 @@ import * as T from "../../../base/lib/types" import * as child_process from "child_process" import * as fs from "fs/promises" -import { asError, StorePath } from "../util" +import { Affine, asError, StorePath } from "../util" export const DEFAULT_OPTIONS: T.SyncOptions = { delete: true, @@ -15,12 +15,18 @@ export type BackupSync = { restoreOptions?: Partial } +export type BackupEffects = T.Effects & Affine<"Backups"> + export class Backups { private constructor( private options = DEFAULT_OPTIONS, private restoreOptions: Partial = {}, private backupOptions: Partial = {}, private backupSet = [] as BackupSync[], + private preBackup = async (effects: BackupEffects) => {}, + private postBackup = async (effects: BackupEffects) => {}, + private preRestore = async (effects: BackupEffects) => {}, + private postRestore = async (effects: BackupEffects) => {}, ) {} static withVolumes( @@ -93,6 +99,7 @@ export class Backups { } async createBackup(effects: T.Effects) { + await this.preBackup(effects as BackupEffects) for (const item of this.backupSet) { const rsyncResults = await runRsync({ srcPath: item.dataPath, @@ -116,6 +123,7 @@ export class Backups { await fs.writeFile("/media/startos/backup/dataVersion.txt", dataVersion, { encoding: "utf-8", }) + await this.postBackup(effects as BackupEffects) return } diff --git a/sdk/package/lib/health/checkFns/runHealthScript.ts b/sdk/package/lib/health/checkFns/runHealthScript.ts index 7ecd1ad75..e98422bc0 100644 --- a/sdk/package/lib/health/checkFns/runHealthScript.ts +++ b/sdk/package/lib/health/checkFns/runHealthScript.ts @@ -1,6 +1,7 @@ import { HealthCheckResult } from "./HealthCheckResult" import { timeoutPromise } from "./index" import { SubContainer } from "../../util/SubContainer" +import { SDKManifest } from "../../types" /** * Running a health script, is used when we want to have a simple @@ -9,9 +10,9 @@ import { SubContainer } from "../../util/SubContainer" * @param param0 * @returns */ -export const runHealthScript = async ( +export const runHealthScript = async ( runCommand: string[], - subcontainer: SubContainer, + subcontainer: SubContainer, { timeout = 30000, errorMessage = `Error while running command: ${runCommand}`, diff --git a/sdk/package/lib/mainFn/CommandController.ts b/sdk/package/lib/mainFn/CommandController.ts index 19be60e77..e31d883a9 100644 --- a/sdk/package/lib/mainFn/CommandController.ts +++ b/sdk/package/lib/mainFn/CommandController.ts @@ -10,12 +10,13 @@ import { import { Drop, splitCommand } from "../util" import * as cp from "child_process" import * as fs from "node:fs/promises" +import { Mounts } from "./Mounts" -export class CommandController extends Drop { +export class CommandController extends Drop { private constructor( readonly runningAnswer: Promise, private state: { exited: boolean }, - private readonly subcontainer: SubContainer, + private readonly subcontainer: SubContainer, private process: cp.ChildProcess, readonly sigtermTimeout: number = DEFAULT_SIGTERM_TIMEOUT, ) { @@ -29,13 +30,13 @@ export class CommandController extends Drop { imageId: keyof Manifest["images"] & T.ImageId sharedRun?: boolean } - | SubContainer, + | SubContainer, command: T.CommandType, options: { subcontainerName?: string // Defaults to the DEFAULT_SIGTERM_TIMEOUT = 30_000ms sigtermTimeout?: number - mounts?: { mountpoint: string; options: MountOptions }[] + mounts: Mounts | null runAsInit?: boolean env?: | { @@ -65,13 +66,12 @@ export class CommandController extends Drop { : await SubContainer.of( effects, subcontainer, + null, options?.subcontainerName || commands.join(" "), ) try { - for (let mount of options.mounts || []) { - await subc.mount(mount.options, mount.mountpoint) - } + if (options.mounts) await subc.mount(options.mounts) let childProcess: cp.ChildProcess if (options.runAsInit) { diff --git a/sdk/package/lib/mainFn/Daemon.ts b/sdk/package/lib/mainFn/Daemon.ts index 8f254286c..625caa4b7 100644 --- a/sdk/package/lib/mainFn/Daemon.ts +++ b/sdk/package/lib/mainFn/Daemon.ts @@ -2,6 +2,7 @@ import * as T from "../../../base/lib/types" import { asError } from "../../../base/lib/util/asError" import { ExecSpawnable, MountOptions, SubContainer } from "../util/SubContainer" import { CommandController } from "./CommandController" +import { Mounts } from "./Mounts" const TIMEOUT_INCREMENT_MS = 1000 const MAX_TIMEOUT_MS = 30000 @@ -10,10 +11,12 @@ const MAX_TIMEOUT_MS = 30000 * and the others state of running, where it will keep a living running command */ -export class Daemon { - private commandController: CommandController | null = null +export class Daemon { + private commandController: CommandController | null = null private shouldBeRunning = false - constructor(private startCommand: () => Promise) {} + constructor( + private startCommand: () => Promise>, + ) {} get subContainerHandle(): undefined | ExecSpawnable { return this.commandController?.subContainerHandle } @@ -25,11 +28,11 @@ export class Daemon { imageId: keyof Manifest["images"] & T.ImageId sharedRun?: boolean } - | SubContainer, + | SubContainer, command: T.CommandType, options: { subcontainerName?: string - mounts?: { mountpoint: string; options: MountOptions }[] + mounts: Mounts | null env?: | { [variable: string]: string diff --git a/sdk/package/lib/mainFn/Daemons.ts b/sdk/package/lib/mainFn/Daemons.ts index 9732d6e7a..c23a7d3e4 100644 --- a/sdk/package/lib/mainFn/Daemons.ts +++ b/sdk/package/lib/mainFn/Daemons.ts @@ -67,7 +67,7 @@ type DaemonsParams< */ sharedRun?: boolean } - | SubContainer + | SubContainer /** For mounting the necessary volumes. Syntax: sdk.Mounts.of().addVolume() */ mounts: Mounts env?: Record @@ -113,9 +113,9 @@ export class Daemons private constructor( readonly effects: T.Effects, readonly started: (onTerm: () => PromiseLike) => PromiseLike, - readonly daemons: Promise[], + readonly daemons: Promise>[], readonly ids: Ids[], - readonly healthDaemons: HealthDaemon[], + readonly healthDaemons: HealthDaemon[], readonly healthChecks: HealthCheck[], ) {} /** @@ -164,7 +164,6 @@ export class Daemons options.command, { ...options, - mounts: options.mounts.build(), subcontainerName: id, }, ) diff --git a/sdk/package/lib/mainFn/HealthDaemon.ts b/sdk/package/lib/mainFn/HealthDaemon.ts index 5f9ea7f27..1f833b127 100644 --- a/sdk/package/lib/mainFn/HealthDaemon.ts +++ b/sdk/package/lib/mainFn/HealthDaemon.ts @@ -2,7 +2,7 @@ import { HealthCheckResult } from "../health/checkFns" import { defaultTrigger } from "../trigger/defaultTrigger" import { Ready } from "./Daemons" import { Daemon } from "./Daemon" -import { SetHealth, Effects } from "../../../base/lib/types" +import { SetHealth, Effects, SDKManifest } from "../../../base/lib/types" import { DEFAULT_SIGTERM_TIMEOUT } from "." import { asError } from "../../../base/lib/util/asError" @@ -21,7 +21,7 @@ const oncePromise = () => { * -- Running: Daemon is running and the status is in the health * */ -export class HealthDaemon { +export class HealthDaemon { private _health: HealthCheckResult = { result: "starting", message: null } private healthWatchers: Array<() => unknown> = [] private running = false @@ -29,9 +29,9 @@ export class HealthDaemon { private resolveReady: (() => void) | undefined private readyPromise: Promise constructor( - private readonly daemon: Promise, + private readonly daemon: Promise>, readonly daemonIndex: number, - private readonly dependencies: HealthDaemon[], + private readonly dependencies: HealthDaemon[], readonly id: string, readonly ids: string[], readonly ready: Ready, diff --git a/sdk/package/lib/mainFn/Mounts.ts b/sdk/package/lib/mainFn/Mounts.ts index 74d116957..49d6b914e 100644 --- a/sdk/package/lib/mainFn/Mounts.ts +++ b/sdk/package/lib/mainFn/Mounts.ts @@ -3,7 +3,13 @@ import { MountOptions } from "../util/SubContainer" type MountArray = { mountpoint: string; options: MountOptions }[] -export class Mounts { +export class Mounts< + Manifest extends T.SDKManifest, + Backups extends { + subpath: string | null + mountpoint: string + } = never, +> { private constructor( readonly volumes: { id: Manifest["volumes"][number] @@ -22,10 +28,11 @@ export class Mounts { mountpoint: string readonly: boolean }[], + readonly backups: Backups[], ) {} static of() { - return new Mounts([], [], []) + return new Mounts([], [], [], []) } addVolume( @@ -38,13 +45,20 @@ export class Mounts { /** Whether or not the volume should be readonly for this daemon */ readonly: boolean, ) { - this.volumes.push({ - id, - subpath, - mountpoint, - readonly, - }) - return this + return new Mounts( + [ + ...this.volumes, + { + id, + subpath, + mountpoint, + readonly, + }, + ], + [...this.assets], + [...this.dependencies], + [...this.backups], + ) } addAssets( @@ -53,11 +67,18 @@ export class Mounts { /** Where to mount the asset. e.g. /asset */ mountpoint: string, ) { - this.assets.push({ - subpath, - mountpoint, - }) - return this + return new Mounts( + [...this.volumes], + [ + ...this.assets, + { + subpath, + mountpoint, + }, + ], + [...this.dependencies], + [...this.backups], + ) } addDependency( @@ -72,14 +93,36 @@ export class Mounts { /** Whether or not the volume should be readonly for this daemon */ readonly: boolean, ) { - this.dependencies.push({ - dependencyId, - volumeId, - subpath, - mountpoint, - readonly, - }) - return this + return new Mounts( + [...this.volumes], + [...this.assets], + [ + ...this.dependencies, + { + dependencyId, + volumeId, + subpath, + mountpoint, + readonly, + }, + ], + [...this.backups], + ) + } + + addBackups(subpath: string | null, mountpoint: string) { + return new Mounts< + Manifest, + { + subpath: string | null + mountpoint: string + } + >( + [...this.volumes], + [...this.assets], + [...this.dependencies], + [...this.backups, { subpath, mountpoint }], + ) } build(): MountArray { @@ -130,3 +173,7 @@ export class Mounts { ) } } + +const a = Mounts.of().addBackups(null, "") +// @ts-expect-error +const m: Mounts = a diff --git a/sdk/package/lib/util/SubContainer.ts b/sdk/package/lib/util/SubContainer.ts index 8630c7aa2..69159b90b 100644 --- a/sdk/package/lib/util/SubContainer.ts +++ b/sdk/package/lib/util/SubContainer.ts @@ -5,6 +5,8 @@ import { promisify } from "util" import { Buffer } from "node:buffer" import { once } from "../../../base/lib/util/once" import { Drop } from "./Drop" +import { Mounts } from "../mainFn/Mounts" +import { BackupEffects } from "../backup/Backups" export const execFile = promisify(cp.execFile) const False = () => false @@ -46,13 +48,20 @@ export interface ExecSpawnable { * Implements: * @see {@link ExecSpawnable} */ -export class SubContainer extends Drop implements ExecSpawnable { +export class SubContainer< + Manifest extends T.SDKManifest, + Effects extends T.Effects = T.Effects, + > + extends Drop + implements ExecSpawnable +{ private destroyed = false + private leader: cp.ChildProcess private leaderExited: boolean = false private waitProc: () => Promise private constructor( - readonly effects: T.Effects, + readonly effects: Effects, readonly imageId: T.ImageId, readonly rootfs: string, readonly guid: T.Guid, @@ -87,9 +96,23 @@ export class SubContainer extends Drop implements ExecSpawnable { }), ) } - static async of( - effects: T.Effects, - image: { imageId: T.ImageId; sharedRun?: boolean }, + static async of( + effects: Effects, + image: { + imageId: keyof Manifest["images"] & T.ImageId + sharedRun?: boolean + }, + mounts: + | (Effects extends BackupEffects + ? Mounts< + Manifest, + { + subpath: string | null + mountpoint: string + } + > + : Mounts) + | null, name: string, ) { const { imageId, sharedRun } = image @@ -97,86 +120,121 @@ export class SubContainer extends Drop implements ExecSpawnable { imageId, name, }) + const res = new SubContainer(effects, imageId, rootfs, guid) - const shared = ["dev", "sys"] - if (!!sharedRun) { - shared.push("run") + try { + if (mounts) { + await res.mount(mounts) + } + const shared = ["dev", "sys"] + if (!!sharedRun) { + shared.push("run") + } + + await fs.mkdir(`${rootfs}/etc`, { recursive: true }) + await fs.copyFile("/etc/resolv.conf", `${rootfs}/etc/resolv.conf`) + + for (const dirPart of shared) { + const from = `/${dirPart}` + const to = `${rootfs}/${dirPart}` + await fs.mkdir(from, { recursive: true }) + await fs.mkdir(to, { recursive: true }) + await execFile("mount", ["--rbind", from, to]) + } + + return res + } finally { + await res.destroy() } - - await fs.mkdir(`${rootfs}/etc`, { recursive: true }) - await fs.copyFile("/etc/resolv.conf", `${rootfs}/etc/resolv.conf`) - - for (const dirPart of shared) { - const from = `/${dirPart}` - const to = `${rootfs}/${dirPart}` - await fs.mkdir(from, { recursive: true }) - await fs.mkdir(to, { recursive: true }) - await execFile("mount", ["--rbind", from, to]) - } - - return res } - static async with( - effects: T.Effects, - image: { imageId: T.ImageId; sharedRun?: boolean }, - mounts: { options: MountOptions; mountpoint: string }[], + static async with< + Manifest extends T.SDKManifest, + T, + Effects extends T.Effects, + >( + effects: Effects, + image: { + imageId: keyof Manifest["images"] & T.ImageId + sharedRun?: boolean + }, + mounts: + | (Effects extends BackupEffects + ? Mounts< + Manifest, + { + subpath: string | null + mountpoint: string + } + > + : Mounts) + | null, name: string, - fn: (subContainer: SubContainer) => Promise, + fn: (subContainer: SubContainer) => Promise, ): Promise { - const subContainer = await SubContainer.of(effects, image, name) + const subContainer = await SubContainer.of(effects, image, mounts, name) try { - for (let mount of mounts) { - await subContainer.mount(mount.options, mount.mountpoint) - } return await fn(subContainer) } finally { await subContainer.destroy() } } - async mount(options: MountOptions, path: string): Promise { - path = path.startsWith("/") - ? `${this.rootfs}${path}` - : `${this.rootfs}/${path}` - if (options.type === "volume") { - const subpath = options.subpath - ? options.subpath.startsWith("/") - ? options.subpath - : `/${options.subpath}` - : "/" - const from = `/media/startos/volumes/${options.id}${subpath}` + async mount( + mounts: Effects extends BackupEffects + ? Mounts< + Manifest, + { + subpath: string | null + mountpoint: string + } + > + : Mounts, + ): Promise> { + for (let mount of mounts.build()) { + let { options, mountpoint } = mount + const path = mountpoint.startsWith("/") + ? `${this.rootfs}${mountpoint}` + : `${this.rootfs}/${mountpoint}` + if (options.type === "volume") { + const subpath = options.subpath + ? options.subpath.startsWith("/") + ? options.subpath + : `/${options.subpath}` + : "/" + const from = `/media/startos/volumes/${options.id}${subpath}` - await fs.mkdir(from, { recursive: true }) - await fs.mkdir(path, { recursive: true }) - await execFile("mount", ["--bind", from, path]) - } else if (options.type === "assets") { - const subpath = options.subpath - ? options.subpath.startsWith("/") - ? options.subpath - : `/${options.subpath}` - : "/" - const from = `/media/startos/assets/${subpath}` + await fs.mkdir(from, { recursive: true }) + await fs.mkdir(path, { recursive: true }) + await execFile("mount", ["--bind", from, path]) + } else if (options.type === "assets") { + const subpath = options.subpath + ? options.subpath.startsWith("/") + ? options.subpath + : `/${options.subpath}` + : "/" + const from = `/media/startos/assets/${subpath}` - await fs.mkdir(from, { recursive: true }) - await fs.mkdir(path, { recursive: true }) - await execFile("mount", ["--bind", from, path]) - } else if (options.type === "pointer") { - await this.effects.mount({ location: path, target: options }) - } else if (options.type === "backup") { - const subpath = options.subpath - ? options.subpath.startsWith("/") - ? options.subpath - : `/${options.subpath}` - : "/" - const from = `/media/startos/backup${subpath}` + await fs.mkdir(from, { recursive: true }) + await fs.mkdir(path, { recursive: true }) + await execFile("mount", ["--bind", from, path]) + } else if (options.type === "pointer") { + await this.effects.mount({ location: path, target: options }) + } else if (options.type === "backup") { + const subpath = options.subpath + ? options.subpath.startsWith("/") + ? options.subpath + : `/${options.subpath}` + : "/" + const from = `/media/startos/backup${subpath}` - await fs.mkdir(from, { recursive: true }) - await fs.mkdir(path, { recursive: true }) - await execFile("mount", ["--bind", from, path]) - } else { - throw new Error(`unknown type ${(options as any).type}`) + await fs.mkdir(from, { recursive: true }) + await fs.mkdir(path, { recursive: true }) + await execFile("mount", ["--bind", from, path]) + } else { + throw new Error(`unknown type ${(options as any).type}`) + } } return this } @@ -217,6 +275,13 @@ export class SubContainer extends Drop implements ExecSpawnable { this.destroy() } + /** + * @description run a command inside this subcontainer + * @param commands an array representing the command and args to execute + * @param options + * @param timeoutMs how long to wait before killing the command in ms + * @returns + */ async exec( command: string[], options?: CommandOptions & ExecOptions, @@ -422,8 +487,17 @@ export class SubContainerHandle implements ExecSpawnable { } export type CommandOptions = { + /** + * Environment variables to set for this command + */ env?: { [variable: string]: string } + /** + * the working directory to run this command in + */ cwd?: string + /** + * the user to run this command as + */ user?: string }