Feature/sdk improvements (#2879)

* sdk improvements

* subcontainer fixes, disable wifi on migration if not in use, filterable interfaces
This commit is contained in:
Aiden McClelland
2025-04-18 14:11:13 -06:00
committed by GitHub
parent dcfbaa9243
commit 2c65033c0a
19 changed files with 426 additions and 136 deletions

View File

@@ -9,6 +9,9 @@ import {
ExecOptions, ExecOptions,
ExecSpawnable, ExecSpawnable,
} from "@start9labs/start-sdk/package/lib/util/SubContainer" } 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 exec = promisify(cp.exec)
export const execFile = promisify(cp.execFile) export const execFile = promisify(cp.execFile)
@@ -42,8 +45,9 @@ export class DockerProcedureContainer {
name: string, name: string,
) { ) {
const subcontainer = await SubContainer.of( const subcontainer = await SubContainer.of(
effects, effects as BackupEffects,
{ imageId: data.image }, { imageId: data.image },
null,
name, name,
) )
@@ -57,14 +61,10 @@ export class DockerProcedureContainer {
const volumeMount = volumes[mount] const volumeMount = volumes[mount]
if (volumeMount.type === "data") { if (volumeMount.type === "data") {
await subcontainer.mount( await subcontainer.mount(
{ type: "volume", id: mount, subpath: null, readonly: false }, Mounts.of().addVolume(mount, null, mounts[mount], false),
mounts[mount],
) )
} else if (volumeMount.type === "assets") { } else if (volumeMount.type === "assets") {
await subcontainer.mount( await subcontainer.mount(Mounts.of().addAssets(mount, mounts[mount]))
{ type: "assets", subpath: mount },
mounts[mount],
)
} else if (volumeMount.type === "certificate") { } else if (volumeMount.type === "certificate") {
const hostnames = [ const hostnames = [
`${packageId}.embassy`, `${packageId}.embassy`,
@@ -107,10 +107,7 @@ export class DockerProcedureContainer {
}) })
.catch(console.warn) .catch(console.warn)
} else if (volumeMount.type === "backup") { } else if (volumeMount.type === "backup") {
await subcontainer.mount( await subcontainer.mount(Mounts.of().addBackups(null, mounts[mount]))
{ type: "backup", subpath: null },
mounts[mount],
)
} }
} }
} }

View File

@@ -6,6 +6,7 @@ import { Daemon } from "@start9labs/start-sdk/package/lib/mainFn/Daemon"
import { Effects } from "../../../Models/Effects" import { Effects } from "../../../Models/Effects"
import { off } from "node:process" import { off } from "node:process"
import { CommandController } from "@start9labs/start-sdk/package/lib/mainFn/CommandController" 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_HEALTH_INTERVAL = 15 * 1000
const EMBASSY_PROPERTIES_LOOP = 30 * 1000 const EMBASSY_PROPERTIES_LOOP = 30 * 1000
@@ -24,7 +25,7 @@ export class MainLoop {
}[] }[]
private mainEvent?: { private mainEvent?: {
daemon: Daemon daemon: Daemon<SDKManifest>
} }
private constructor( private constructor(
@@ -72,6 +73,7 @@ export class MainLoop {
env: { env: {
TINI_SUBREAPER: "true", TINI_SUBREAPER: "true",
}, },
mounts: null,
sigtermTimeout: utils.inMs( sigtermTimeout: utils.inMs(
this.system.manifest.main["sigterm-timeout"], this.system.manifest.main["sigterm-timeout"],
), ),

View File

@@ -139,7 +139,7 @@ export const polyfillEffects = (
effects, effects,
subcontainer, subcontainer,
[input.command, ...(input.args || [])], [input.command, ...(input.args || [])],
{}, { mounts: null },
), ),
) )
return { return {

13
core/Cargo.lock generated
View File

@@ -5510,6 +5510,18 @@ dependencies = [
"tempfile", "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]] [[package]]
name = "sha1" name = "sha1"
version = "0.10.6" version = "0.10.6"
@@ -6091,6 +6103,7 @@ dependencies = [
"serde_urlencoded", "serde_urlencoded",
"serde_with", "serde_with",
"serde_yml", "serde_yml",
"sha-crypt",
"sha2 0.10.8", "sha2 0.10.8",
"shell-words", "shell-words",
"signal-hook", "signal-hook",

View File

@@ -183,6 +183,7 @@ serde_toml = { package = "toml", version = "0.8.2" }
serde_urlencoded = "0.7" serde_urlencoded = "0.7"
serde_with = { version = "3.4.0", features = ["macros", "json"] } serde_with = { version = "3.4.0", features = ["macros", "json"] }
serde_yaml = { package = "serde_yml", version = "0.0.10" } serde_yaml = { package = "serde_yml", version = "0.0.10" }
sha-crypt = "0.5.0"
sha2 = "0.10.2" sha2 = "0.10.2"
shell-words = "1" shell-words = "1"
signal-hook = "0.3.17" signal-hook = "0.3.17"

View File

@@ -9,6 +9,7 @@ use josekit::jwk::Jwk;
use rpc_toolkit::yajrc::RpcError; use rpc_toolkit::yajrc::RpcError;
use rpc_toolkit::{from_fn_async, Context, HandlerArgs, HandlerExt, ParentHandler}; use rpc_toolkit::{from_fn_async, Context, HandlerArgs, HandlerExt, ParentHandler};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::io::AsyncWriteExt;
use tracing::instrument; use tracing::instrument;
use ts_rs::TS; use ts_rs::TS;
@@ -19,6 +20,7 @@ use crate::middleware::auth::{
}; };
use crate::prelude::*; use crate::prelude::*;
use crate::util::crypto::EncryptedWire; use crate::util::crypto::EncryptedWire;
use crate::util::io::create_file_mod;
use crate::util::serde::{display_serializable, HandlerExtSerde, WithIoFormat}; use crate::util::serde::{display_serializable, HandlerExtSerde, WithIoFormat};
use crate::{ensure_code, Error, ResultExt}; 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)] #[derive(Clone, Serialize, Deserialize, TS)]
#[serde(untagged)] #[serde(untagged)]
#[ts(export)] #[ts(export)]
@@ -210,7 +236,7 @@ pub async fn login_impl(
) -> Result<LoginRes, Error> { ) -> Result<LoginRes, Error> {
let password = password.unwrap_or_default().decrypt(&ctx)?; let password = password.unwrap_or_default().decrypt(&ctx)?;
if ephemeral { let tok = if ephemeral {
check_password_against_db(&ctx.db.peek().await, &password)?; check_password_against_db(&ctx.db.peek().await, &password)?;
let hash_token = HashSessionToken::new(); let hash_token = HashSessionToken::new();
ctx.ephemeral_sessions.mutate(|s| { ctx.ephemeral_sessions.mutate(|s| {
@@ -242,7 +268,16 @@ pub async fn login_impl(
}) })
.await .await
.result .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)] #[derive(Deserialize, Serialize, Parser, TS)]

View File

@@ -17,6 +17,7 @@ use tracing::instrument;
use ts_rs::TS; use ts_rs::TS;
use crate::account::AccountInfo; use crate::account::AccountInfo;
use crate::auth::write_shadow;
use crate::backup::restore::recover_full_embassy; use crate::backup::restore::recover_full_embassy;
use crate::backup::target::BackupTargetFS; use crate::backup::target::BackupTargetFS;
use crate::context::rpc::InitRpcContextPhases; use crate::context::rpc::InitRpcContextPhases;
@@ -88,8 +89,8 @@ async fn setup_init(
.db .db
.mutate(|m| { .mutate(|m| {
let mut account = AccountInfo::load(m)?; let mut account = AccountInfo::load(m)?;
if let Some(password) = password { if let Some(password) = &password {
account.set_password(&password)?; account.set_password(password)?;
} }
account.save(m)?; account.save(m)?;
m.as_public_mut() m.as_public_mut()
@@ -101,6 +102,10 @@ async fn setup_init(
.await .await
.result?; .result?;
if let Some(password) = &password {
write_shadow(&password).await?;
}
Ok((account, init_result)) Ok((account, init_result))
} }
@@ -346,6 +351,8 @@ pub async fn complete(ctx: SetupContext) -> Result<SetupResult, Error> {
.arg(format!("--hostname={}", res.hostname.0)) .arg(format!("--hostname={}", res.hostname.0))
.invoke(ErrorKind::ParseSysInfo) .invoke(ErrorKind::ParseSysInfo)
.await?; .await?;
Command::new("sync").invoke(ErrorKind::Filesystem).await?;
Ok(res.clone()) Ok(res.clone())
} }
Some(Err(e)) => Err(e.clone_output()), Some(Err(e)) => Err(e.clone_output()),
@@ -465,6 +472,8 @@ async fn fresh_setup(
) )
.await?; .await?;
write_shadow(start_os_password).await?;
Ok(((&account).try_into()?, rpc_ctx)) Ok(((&account).try_into()?, rpc_ctx))
} }

View File

@@ -944,6 +944,23 @@ pub async fn create_file(path: impl AsRef<Path>) -> Result<File, Error> {
.with_ctx(|_| (ErrorKind::Filesystem, lazy_format!("create {path:?}"))) .with_ctx(|_| (ErrorKind::Filesystem, lazy_format!("create {path:?}")))
} }
pub async fn create_file_mod(path: impl AsRef<Path>, mode: u32) -> Result<File, Error> {
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<Path>) -> Result<File, Error> { pub async fn append_file(path: impl AsRef<Path>) -> Result<File, Error> {
let path = path.as_ref(); let path = path.as_ref();
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {

View File

@@ -31,7 +31,7 @@ impl VersionT for Version {
fn up(self, db: &mut Value, _: Self::PreUpRes) -> Result<(), Error> { fn up(self, db: &mut Value, _: Self::PreUpRes) -> Result<(), Error> {
let host = db["public"]["serverInfo"]["host"].clone(); let host = db["public"]["serverInfo"]["host"].clone();
let mut wifi = db["public"]["serverInfo"]["wifi"].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(); let mut network_interfaces = db["public"]["serverInfo"]["networkInterfaces"].clone();
for (k, v) in network_interfaces for (k, v) in network_interfaces
.as_object_mut() .as_object_mut()

View File

@@ -15,8 +15,28 @@ export const getHostname = (url: string): Hostname | null => {
return last 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 Formats> = Format extends "hostname-info"
? HostnameInfo
: Format extends "url"
? URL
: UrlString
export type Filled = { export type Filled = {
hostnames: HostnameInfo[] hostnames: HostnameInfo[]
filter: <Format extends Formats = "urlstring">(
filter: Filter,
format?: Format,
) => FormatReturnTy<Format>[]
publicHostnames: HostnameInfo[] publicHostnames: HostnameInfo[]
onionHostnames: HostnameInfo[] onionHostnames: HostnameInfo[]
localHostnames: HostnameInfo[] localHostnames: HostnameInfo[]
@@ -97,6 +117,47 @@ export const addressHostToUrl = (
return res 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 = ( export const filledAddress = (
host: Host, host: Host,
addressInfo: AddressInfo, addressInfo: AddressInfo,
@@ -107,6 +168,14 @@ export const filledAddress = (
return { return {
...addressInfo, ...addressInfo,
hostnames, hostnames,
filter: <T extends Formats = "urlstring">(filter: Filter, format?: T) => {
const res = filterRec(hostnames, filter, false)
if (format === "hostname-info") return res as FormatReturnTy<T>[]
const urls = res.flatMap(toUrl)
if (format === "url")
return urls.map((u) => new URL(u)) as FormatReturnTy<T>[]
return urls as FormatReturnTy<T>[]
},
get publicHostnames() { get publicHostnames() {
return hostnames.filter((h) => h.kind === "onion" || h.public) return hostnames.filter((h) => h.kind === "onion" || h.public)
}, },

View File

@@ -306,7 +306,7 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
}, },
command: T.CommandType, command: T.CommandType,
options: CommandOptions & { options: CommandOptions & {
mounts: Mounts<Manifest> mounts: Mounts<Manifest> | null
}, },
/** /**
* A name to use to refer to the ephemeral subcontainer for debugging purposes * A name to use to refer to the ephemeral subcontainer for debugging purposes
@@ -766,25 +766,40 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
}, },
}, },
SubContainer: { 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( of(
effects: Effects, effects: Effects,
image: { image: {
imageId: T.ImageId & keyof Manifest["images"] imageId: T.ImageId & keyof Manifest["images"]
sharedRun?: boolean sharedRun?: boolean
}, },
mounts: Mounts<Manifest> | null,
name: string, 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<T>( with<T>(
effects: T.Effects, effects: T.Effects,
image: { image: {
imageId: T.ImageId & keyof Manifest["images"] imageId: T.ImageId & keyof Manifest["images"]
sharedRun?: boolean sharedRun?: boolean
}, },
mounts: { options: MountOptions; mountpoint: string }[], mounts: Mounts<Manifest> | null,
name: string, name: string,
fn: (subContainer: SubContainer) => Promise<T>, fn: (subContainer: SubContainer<Manifest>) => Promise<T>,
): Promise<T> { ): Promise<T> {
return SubContainer.with(effects, image, mounts, name, fn) return SubContainer.with(effects, image, mounts, name, fn)
}, },
@@ -1164,7 +1179,7 @@ export async function runCommand<Manifest extends T.SDKManifest>(
image: { imageId: keyof Manifest["images"] & T.ImageId; sharedRun?: boolean }, image: { imageId: keyof Manifest["images"] & T.ImageId; sharedRun?: boolean },
command: T.CommandType, command: T.CommandType,
options: CommandOptions & { options: CommandOptions & {
mounts: Mounts<Manifest> mounts: Mounts<Manifest> | null
}, },
name?: string, name?: string,
): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> { ): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> {
@@ -1182,7 +1197,7 @@ export async function runCommand<Manifest extends T.SDKManifest>(
return SubContainer.with( return SubContainer.with(
effects, effects,
image, image,
options.mounts.build(), options.mounts,
name || name ||
commands commands
.map((c) => { .map((c) => {

View File

@@ -1,7 +1,7 @@
import * as T from "../../../base/lib/types" import * as T from "../../../base/lib/types"
import * as child_process from "child_process" import * as child_process from "child_process"
import * as fs from "fs/promises" import * as fs from "fs/promises"
import { asError, StorePath } from "../util" import { Affine, asError, StorePath } from "../util"
export const DEFAULT_OPTIONS: T.SyncOptions = { export const DEFAULT_OPTIONS: T.SyncOptions = {
delete: true, delete: true,
@@ -15,12 +15,18 @@ export type BackupSync<Volumes extends string> = {
restoreOptions?: Partial<T.SyncOptions> restoreOptions?: Partial<T.SyncOptions>
} }
export type BackupEffects = T.Effects & Affine<"Backups">
export class Backups<M extends T.SDKManifest> { export class Backups<M extends T.SDKManifest> {
private constructor( private constructor(
private options = DEFAULT_OPTIONS, private options = DEFAULT_OPTIONS,
private restoreOptions: Partial<T.SyncOptions> = {}, private restoreOptions: Partial<T.SyncOptions> = {},
private backupOptions: Partial<T.SyncOptions> = {}, private backupOptions: Partial<T.SyncOptions> = {},
private backupSet = [] as BackupSync<M["volumes"][number]>[], private backupSet = [] as BackupSync<M["volumes"][number]>[],
private preBackup = async (effects: BackupEffects) => {},
private postBackup = async (effects: BackupEffects) => {},
private preRestore = async (effects: BackupEffects) => {},
private postRestore = async (effects: BackupEffects) => {},
) {} ) {}
static withVolumes<M extends T.SDKManifest = never>( static withVolumes<M extends T.SDKManifest = never>(
@@ -93,6 +99,7 @@ export class Backups<M extends T.SDKManifest> {
} }
async createBackup(effects: T.Effects) { async createBackup(effects: T.Effects) {
await this.preBackup(effects as BackupEffects)
for (const item of this.backupSet) { for (const item of this.backupSet) {
const rsyncResults = await runRsync({ const rsyncResults = await runRsync({
srcPath: item.dataPath, srcPath: item.dataPath,
@@ -116,6 +123,7 @@ export class Backups<M extends T.SDKManifest> {
await fs.writeFile("/media/startos/backup/dataVersion.txt", dataVersion, { await fs.writeFile("/media/startos/backup/dataVersion.txt", dataVersion, {
encoding: "utf-8", encoding: "utf-8",
}) })
await this.postBackup(effects as BackupEffects)
return return
} }

View File

@@ -1,6 +1,7 @@
import { HealthCheckResult } from "./HealthCheckResult" import { HealthCheckResult } from "./HealthCheckResult"
import { timeoutPromise } from "./index" import { timeoutPromise } from "./index"
import { SubContainer } from "../../util/SubContainer" import { SubContainer } from "../../util/SubContainer"
import { SDKManifest } from "../../types"
/** /**
* Running a health script, is used when we want to have a simple * Running a health script, is used when we want to have a simple
@@ -9,9 +10,9 @@ import { SubContainer } from "../../util/SubContainer"
* @param param0 * @param param0
* @returns * @returns
*/ */
export const runHealthScript = async ( export const runHealthScript = async <Manifest extends SDKManifest>(
runCommand: string[], runCommand: string[],
subcontainer: SubContainer, subcontainer: SubContainer<Manifest>,
{ {
timeout = 30000, timeout = 30000,
errorMessage = `Error while running command: ${runCommand}`, errorMessage = `Error while running command: ${runCommand}`,

View File

@@ -10,12 +10,13 @@ import {
import { Drop, splitCommand } from "../util" import { Drop, splitCommand } from "../util"
import * as cp from "child_process" import * as cp from "child_process"
import * as fs from "node:fs/promises" import * as fs from "node:fs/promises"
import { Mounts } from "./Mounts"
export class CommandController extends Drop { export class CommandController<Manifest extends T.SDKManifest> extends Drop {
private constructor( private constructor(
readonly runningAnswer: Promise<unknown>, readonly runningAnswer: Promise<unknown>,
private state: { exited: boolean }, private state: { exited: boolean },
private readonly subcontainer: SubContainer, private readonly subcontainer: SubContainer<Manifest>,
private process: cp.ChildProcess, private process: cp.ChildProcess,
readonly sigtermTimeout: number = DEFAULT_SIGTERM_TIMEOUT, readonly sigtermTimeout: number = DEFAULT_SIGTERM_TIMEOUT,
) { ) {
@@ -29,13 +30,13 @@ export class CommandController extends Drop {
imageId: keyof Manifest["images"] & T.ImageId imageId: keyof Manifest["images"] & T.ImageId
sharedRun?: boolean sharedRun?: boolean
} }
| SubContainer, | SubContainer<Manifest>,
command: T.CommandType, command: T.CommandType,
options: { options: {
subcontainerName?: string subcontainerName?: string
// Defaults to the DEFAULT_SIGTERM_TIMEOUT = 30_000ms // Defaults to the DEFAULT_SIGTERM_TIMEOUT = 30_000ms
sigtermTimeout?: number sigtermTimeout?: number
mounts?: { mountpoint: string; options: MountOptions }[] mounts: Mounts<Manifest> | null
runAsInit?: boolean runAsInit?: boolean
env?: env?:
| { | {
@@ -65,13 +66,12 @@ export class CommandController extends Drop {
: await SubContainer.of( : await SubContainer.of(
effects, effects,
subcontainer, subcontainer,
null,
options?.subcontainerName || commands.join(" "), options?.subcontainerName || commands.join(" "),
) )
try { try {
for (let mount of options.mounts || []) { if (options.mounts) await subc.mount(options.mounts)
await subc.mount(mount.options, mount.mountpoint)
}
let childProcess: cp.ChildProcess let childProcess: cp.ChildProcess
if (options.runAsInit) { if (options.runAsInit) {

View File

@@ -2,6 +2,7 @@ import * as T from "../../../base/lib/types"
import { asError } from "../../../base/lib/util/asError" import { asError } from "../../../base/lib/util/asError"
import { ExecSpawnable, MountOptions, SubContainer } from "../util/SubContainer" import { ExecSpawnable, MountOptions, SubContainer } from "../util/SubContainer"
import { CommandController } from "./CommandController" import { CommandController } from "./CommandController"
import { Mounts } from "./Mounts"
const TIMEOUT_INCREMENT_MS = 1000 const TIMEOUT_INCREMENT_MS = 1000
const MAX_TIMEOUT_MS = 30000 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 * and the others state of running, where it will keep a living running command
*/ */
export class Daemon { export class Daemon<Manifest extends T.SDKManifest> {
private commandController: CommandController | null = null private commandController: CommandController<Manifest> | null = null
private shouldBeRunning = false private shouldBeRunning = false
constructor(private startCommand: () => Promise<CommandController>) {} constructor(
private startCommand: () => Promise<CommandController<Manifest>>,
) {}
get subContainerHandle(): undefined | ExecSpawnable { get subContainerHandle(): undefined | ExecSpawnable {
return this.commandController?.subContainerHandle return this.commandController?.subContainerHandle
} }
@@ -25,11 +28,11 @@ export class Daemon {
imageId: keyof Manifest["images"] & T.ImageId imageId: keyof Manifest["images"] & T.ImageId
sharedRun?: boolean sharedRun?: boolean
} }
| SubContainer, | SubContainer<Manifest>,
command: T.CommandType, command: T.CommandType,
options: { options: {
subcontainerName?: string subcontainerName?: string
mounts?: { mountpoint: string; options: MountOptions }[] mounts: Mounts<Manifest> | null
env?: env?:
| { | {
[variable: string]: string [variable: string]: string

View File

@@ -67,7 +67,7 @@ type DaemonsParams<
*/ */
sharedRun?: boolean sharedRun?: boolean
} }
| SubContainer | SubContainer<Manifest>
/** For mounting the necessary volumes. Syntax: sdk.Mounts.of().addVolume() */ /** For mounting the necessary volumes. Syntax: sdk.Mounts.of().addVolume() */
mounts: Mounts<Manifest> mounts: Mounts<Manifest>
env?: Record<string, string> env?: Record<string, string>
@@ -113,9 +113,9 @@ export class Daemons<Manifest extends T.SDKManifest, Ids extends string>
private constructor( private constructor(
readonly effects: T.Effects, readonly effects: T.Effects,
readonly started: (onTerm: () => PromiseLike<void>) => PromiseLike<null>, readonly started: (onTerm: () => PromiseLike<void>) => PromiseLike<null>,
readonly daemons: Promise<Daemon>[], readonly daemons: Promise<Daemon<Manifest>>[],
readonly ids: Ids[], readonly ids: Ids[],
readonly healthDaemons: HealthDaemon[], readonly healthDaemons: HealthDaemon<Manifest>[],
readonly healthChecks: HealthCheck[], readonly healthChecks: HealthCheck[],
) {} ) {}
/** /**
@@ -164,7 +164,6 @@ export class Daemons<Manifest extends T.SDKManifest, Ids extends string>
options.command, options.command,
{ {
...options, ...options,
mounts: options.mounts.build(),
subcontainerName: id, subcontainerName: id,
}, },
) )

View File

@@ -2,7 +2,7 @@ import { HealthCheckResult } from "../health/checkFns"
import { defaultTrigger } from "../trigger/defaultTrigger" import { defaultTrigger } from "../trigger/defaultTrigger"
import { Ready } from "./Daemons" import { Ready } from "./Daemons"
import { Daemon } from "./Daemon" 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 { DEFAULT_SIGTERM_TIMEOUT } from "."
import { asError } from "../../../base/lib/util/asError" import { asError } from "../../../base/lib/util/asError"
@@ -21,7 +21,7 @@ const oncePromise = <T>() => {
* -- Running: Daemon is running and the status is in the health * -- Running: Daemon is running and the status is in the health
* *
*/ */
export class HealthDaemon { export class HealthDaemon<Manifest extends SDKManifest> {
private _health: HealthCheckResult = { result: "starting", message: null } private _health: HealthCheckResult = { result: "starting", message: null }
private healthWatchers: Array<() => unknown> = [] private healthWatchers: Array<() => unknown> = []
private running = false private running = false
@@ -29,9 +29,9 @@ export class HealthDaemon {
private resolveReady: (() => void) | undefined private resolveReady: (() => void) | undefined
private readyPromise: Promise<void> private readyPromise: Promise<void>
constructor( constructor(
private readonly daemon: Promise<Daemon>, private readonly daemon: Promise<Daemon<Manifest>>,
readonly daemonIndex: number, readonly daemonIndex: number,
private readonly dependencies: HealthDaemon[], private readonly dependencies: HealthDaemon<Manifest>[],
readonly id: string, readonly id: string,
readonly ids: string[], readonly ids: string[],
readonly ready: Ready, readonly ready: Ready,

View File

@@ -3,7 +3,13 @@ import { MountOptions } from "../util/SubContainer"
type MountArray = { mountpoint: string; options: MountOptions }[] type MountArray = { mountpoint: string; options: MountOptions }[]
export class Mounts<Manifest extends T.SDKManifest> { export class Mounts<
Manifest extends T.SDKManifest,
Backups extends {
subpath: string | null
mountpoint: string
} = never,
> {
private constructor( private constructor(
readonly volumes: { readonly volumes: {
id: Manifest["volumes"][number] id: Manifest["volumes"][number]
@@ -22,10 +28,11 @@ export class Mounts<Manifest extends T.SDKManifest> {
mountpoint: string mountpoint: string
readonly: boolean readonly: boolean
}[], }[],
readonly backups: Backups[],
) {} ) {}
static of<Manifest extends T.SDKManifest>() { static of<Manifest extends T.SDKManifest>() {
return new Mounts<Manifest>([], [], []) return new Mounts<Manifest>([], [], [], [])
} }
addVolume( addVolume(
@@ -38,13 +45,20 @@ export class Mounts<Manifest extends T.SDKManifest> {
/** Whether or not the volume should be readonly for this daemon */ /** Whether or not the volume should be readonly for this daemon */
readonly: boolean, readonly: boolean,
) { ) {
this.volumes.push({ return new Mounts<Manifest, Backups>(
id, [
subpath, ...this.volumes,
mountpoint, {
readonly, id,
}) subpath,
return this mountpoint,
readonly,
},
],
[...this.assets],
[...this.dependencies],
[...this.backups],
)
} }
addAssets( addAssets(
@@ -53,11 +67,18 @@ export class Mounts<Manifest extends T.SDKManifest> {
/** Where to mount the asset. e.g. /asset */ /** Where to mount the asset. e.g. /asset */
mountpoint: string, mountpoint: string,
) { ) {
this.assets.push({ return new Mounts<Manifest, Backups>(
subpath, [...this.volumes],
mountpoint, [
}) ...this.assets,
return this {
subpath,
mountpoint,
},
],
[...this.dependencies],
[...this.backups],
)
} }
addDependency<DependencyManifest extends T.SDKManifest>( addDependency<DependencyManifest extends T.SDKManifest>(
@@ -72,14 +93,36 @@ export class Mounts<Manifest extends T.SDKManifest> {
/** Whether or not the volume should be readonly for this daemon */ /** Whether or not the volume should be readonly for this daemon */
readonly: boolean, readonly: boolean,
) { ) {
this.dependencies.push({ return new Mounts<Manifest, Backups>(
dependencyId, [...this.volumes],
volumeId, [...this.assets],
subpath, [
mountpoint, ...this.dependencies,
readonly, {
}) dependencyId,
return this 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 { build(): MountArray {
@@ -130,3 +173,7 @@ export class Mounts<Manifest extends T.SDKManifest> {
) )
} }
} }
const a = Mounts.of().addBackups(null, "")
// @ts-expect-error
const m: Mounts<T.SDKManifest, never> = a

View File

@@ -5,6 +5,8 @@ import { promisify } from "util"
import { Buffer } from "node:buffer" import { Buffer } from "node:buffer"
import { once } from "../../../base/lib/util/once" import { once } from "../../../base/lib/util/once"
import { Drop } from "./Drop" import { Drop } from "./Drop"
import { Mounts } from "../mainFn/Mounts"
import { BackupEffects } from "../backup/Backups"
export const execFile = promisify(cp.execFile) export const execFile = promisify(cp.execFile)
const False = () => false const False = () => false
@@ -46,13 +48,20 @@ export interface ExecSpawnable {
* Implements: * Implements:
* @see {@link ExecSpawnable} * @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 destroyed = false
private leader: cp.ChildProcess private leader: cp.ChildProcess
private leaderExited: boolean = false private leaderExited: boolean = false
private waitProc: () => Promise<null> private waitProc: () => Promise<null>
private constructor( private constructor(
readonly effects: T.Effects, readonly effects: Effects,
readonly imageId: T.ImageId, readonly imageId: T.ImageId,
readonly rootfs: string, readonly rootfs: string,
readonly guid: T.Guid, readonly guid: T.Guid,
@@ -87,9 +96,23 @@ export class SubContainer extends Drop implements ExecSpawnable {
}), }),
) )
} }
static async of( static async of<Manifest extends T.SDKManifest, Effects extends T.Effects>(
effects: T.Effects, effects: Effects,
image: { imageId: T.ImageId; sharedRun?: boolean }, image: {
imageId: keyof Manifest["images"] & T.ImageId
sharedRun?: boolean
},
mounts:
| (Effects extends BackupEffects
? Mounts<
Manifest,
{
subpath: string | null
mountpoint: string
}
>
: Mounts<Manifest, never>)
| null,
name: string, name: string,
) { ) {
const { imageId, sharedRun } = image const { imageId, sharedRun } = image
@@ -97,86 +120,121 @@ export class SubContainer extends Drop implements ExecSpawnable {
imageId, imageId,
name, name,
}) })
const res = new SubContainer(effects, imageId, rootfs, guid) const res = new SubContainer(effects, imageId, rootfs, guid)
const shared = ["dev", "sys"] try {
if (!!sharedRun) { if (mounts) {
shared.push("run") 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<T>( static async with<
effects: T.Effects, Manifest extends T.SDKManifest,
image: { imageId: T.ImageId; sharedRun?: boolean }, T,
mounts: { options: MountOptions; mountpoint: string }[], 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<Manifest, never>)
| null,
name: string, name: string,
fn: (subContainer: SubContainer) => Promise<T>, fn: (subContainer: SubContainer<Manifest, Effects>) => Promise<T>,
): Promise<T> { ): Promise<T> {
const subContainer = await SubContainer.of(effects, image, name) const subContainer = await SubContainer.of(effects, image, mounts, name)
try { try {
for (let mount of mounts) {
await subContainer.mount(mount.options, mount.mountpoint)
}
return await fn(subContainer) return await fn(subContainer)
} finally { } finally {
await subContainer.destroy() await subContainer.destroy()
} }
} }
async mount(options: MountOptions, path: string): Promise<SubContainer> { async mount(
path = path.startsWith("/") mounts: Effects extends BackupEffects
? `${this.rootfs}${path}` ? Mounts<
: `${this.rootfs}/${path}` Manifest,
if (options.type === "volume") { {
const subpath = options.subpath subpath: string | null
? options.subpath.startsWith("/") mountpoint: string
? options.subpath }
: `/${options.subpath}` >
: "/" : Mounts<Manifest, never>,
const from = `/media/startos/volumes/${options.id}${subpath}` ): Promise<SubContainer<Manifest, Effects>> {
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(from, { recursive: true })
await fs.mkdir(path, { recursive: true }) await fs.mkdir(path, { recursive: true })
await execFile("mount", ["--bind", from, path]) await execFile("mount", ["--bind", from, path])
} else if (options.type === "assets") { } else if (options.type === "assets") {
const subpath = options.subpath const subpath = options.subpath
? options.subpath.startsWith("/") ? options.subpath.startsWith("/")
? options.subpath ? options.subpath
: `/${options.subpath}` : `/${options.subpath}`
: "/" : "/"
const from = `/media/startos/assets/${subpath}` const from = `/media/startos/assets/${subpath}`
await fs.mkdir(from, { recursive: true }) await fs.mkdir(from, { recursive: true })
await fs.mkdir(path, { recursive: true }) await fs.mkdir(path, { recursive: true })
await execFile("mount", ["--bind", from, path]) await execFile("mount", ["--bind", from, path])
} else if (options.type === "pointer") { } else if (options.type === "pointer") {
await this.effects.mount({ location: path, target: options }) await this.effects.mount({ location: path, target: options })
} else if (options.type === "backup") { } else if (options.type === "backup") {
const subpath = options.subpath const subpath = options.subpath
? options.subpath.startsWith("/") ? options.subpath.startsWith("/")
? options.subpath ? options.subpath
: `/${options.subpath}` : `/${options.subpath}`
: "/" : "/"
const from = `/media/startos/backup${subpath}` const from = `/media/startos/backup${subpath}`
await fs.mkdir(from, { recursive: true }) await fs.mkdir(from, { recursive: true })
await fs.mkdir(path, { recursive: true }) await fs.mkdir(path, { recursive: true })
await execFile("mount", ["--bind", from, path]) await execFile("mount", ["--bind", from, path])
} else { } else {
throw new Error(`unknown type ${(options as any).type}`) throw new Error(`unknown type ${(options as any).type}`)
}
} }
return this return this
} }
@@ -217,6 +275,13 @@ export class SubContainer extends Drop implements ExecSpawnable {
this.destroy() 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( async exec(
command: string[], command: string[],
options?: CommandOptions & ExecOptions, options?: CommandOptions & ExecOptions,
@@ -422,8 +487,17 @@ export class SubContainerHandle implements ExecSpawnable {
} }
export type CommandOptions = { export type CommandOptions = {
/**
* Environment variables to set for this command
*/
env?: { [variable: string]: string } env?: { [variable: string]: string }
/**
* the working directory to run this command in
*/
cwd?: string cwd?: string
/**
* the user to run this command as
*/
user?: string user?: string
} }