mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 12:11:56 +00:00
misc improvements (#2836)
* misc improvements * kill proc before destroying subcontainer fs * version bump * beta.11 * use bind mount explicitly * Update sdk/base/lib/Effects.ts Co-authored-by: Dominion5254 <musashidisciple@proton.me> --------- Co-authored-by: Dominion5254 <musashidisciple@proton.me>
This commit is contained in:
@@ -198,6 +198,9 @@ export function makeEffects(context: EffectContext): Effects {
|
|||||||
T.Effects["getContainerIp"]
|
T.Effects["getContainerIp"]
|
||||||
>
|
>
|
||||||
},
|
},
|
||||||
|
getOsIp(...[]: Parameters<T.Effects["getOsIp"]>) {
|
||||||
|
return rpcRound("get-os-ip", {}) as ReturnType<T.Effects["getOsIp"]>
|
||||||
|
},
|
||||||
getHostInfo: ((...[allOptions]: Parameters<T.Effects["getHostInfo"]>) => {
|
getHostInfo: ((...[allOptions]: Parameters<T.Effects["getHostInfo"]>) => {
|
||||||
const options = {
|
const options = {
|
||||||
...allOptions,
|
...allOptions,
|
||||||
|
|||||||
@@ -349,9 +349,6 @@ export class SystemForEmbassy implements System {
|
|||||||
) {
|
) {
|
||||||
await effects.action.clearRequests({ only: ["needs-config"] })
|
await effects.action.clearRequests({ only: ["needs-config"] })
|
||||||
}
|
}
|
||||||
await effects.setDataVersion({
|
|
||||||
version: ExtendedVersion.parseEmver(this.manifest.version).toString(),
|
|
||||||
})
|
|
||||||
} else if (this.manifest.config) {
|
} else if (this.manifest.config) {
|
||||||
await effects.action.request({
|
await effects.action.request({
|
||||||
packageId: this.manifest.id,
|
packageId: this.manifest.id,
|
||||||
@@ -361,6 +358,9 @@ export class SystemForEmbassy implements System {
|
|||||||
reason: "This service must be configured before it can be run",
|
reason: "This service must be configured before it can be run",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
await effects.setDataVersion({
|
||||||
|
version: ExtendedVersion.parseEmver(this.manifest.version).toString(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
async exportNetwork(effects: Effects) {
|
async exportNetwork(effects: Effects) {
|
||||||
for (const [id, interfaceValue] of Object.entries(
|
for (const [id, interfaceValue] of Object.entries(
|
||||||
|
|||||||
2
core/Cargo.lock
generated
2
core/Cargo.lock
generated
@@ -5976,7 +5976,7 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "start-os"
|
name = "start-os"
|
||||||
version = "0.3.6-alpha.14"
|
version = "0.3.6-alpha.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes 0.7.5",
|
"aes 0.7.5",
|
||||||
"async-acme",
|
"async-acme",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ keywords = [
|
|||||||
name = "start-os"
|
name = "start-os"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
repository = "https://github.com/Start9Labs/start-os"
|
repository = "https://github.com/Start9Labs/start-os"
|
||||||
version = "0.3.6-alpha.14" # VERSION_BUMP
|
version = "0.3.6-alpha.15" # VERSION_BUMP
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use std::cmp::max;
|
|||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use color_eyre::eyre::eyre;
|
use color_eyre::eyre::eyre;
|
||||||
@@ -149,7 +150,7 @@ pub fn main(args: impl IntoIterator<Item = OsString>) {
|
|||||||
.enable_all()
|
.enable_all()
|
||||||
.build()
|
.build()
|
||||||
.expect("failed to initialize runtime");
|
.expect("failed to initialize runtime");
|
||||||
rt.block_on(async {
|
let res = rt.block_on(async {
|
||||||
let mut server = WebServer::new(Acceptor::bind_upgradable(
|
let mut server = WebServer::new(Acceptor::bind_upgradable(
|
||||||
SelfContainedNetworkInterfaceListener::bind(80),
|
SelfContainedNetworkInterfaceListener::bind(80),
|
||||||
));
|
));
|
||||||
@@ -194,7 +195,9 @@ pub fn main(args: impl IntoIterator<Item = OsString>) {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
rt.shutdown_timeout(Duration::from_secs(60));
|
||||||
|
res
|
||||||
};
|
};
|
||||||
|
|
||||||
match res {
|
match res {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use std::backtrace;
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
|
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
|
||||||
@@ -484,10 +485,12 @@ impl Drop for RpcContext {
|
|||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
#[cfg(feature = "unstable")]
|
#[cfg(feature = "unstable")]
|
||||||
if self.0.is_closed.load(Ordering::SeqCst) {
|
if self.0.is_closed.load(Ordering::SeqCst) {
|
||||||
tracing::info!(
|
let count = Arc::strong_count(&self.0) - 1;
|
||||||
"RpcContext dropped. {} left.",
|
tracing::info!("RpcContext dropped. {} left.", count);
|
||||||
Arc::strong_count(&self.0) - 1
|
if count > 0 {
|
||||||
);
|
tracing::debug!("{}", backtrace::Backtrace::force_capture());
|
||||||
|
tracing::debug!("{:?}", eyre!(""))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ impl<A: Accept + Send + Sync + 'static> WebServer<A> {
|
|||||||
if !runner.is_empty() {
|
if !runner.is_empty() {
|
||||||
tokio::time::timeout(Duration::from_secs(60), runner)
|
tokio::time::timeout(Duration::from_secs(60), runner)
|
||||||
.await
|
.await
|
||||||
.ok();
|
.log_err();
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ pub const SIG_CONTEXT: &str = "s9pk";
|
|||||||
pub mod compat;
|
pub mod compat;
|
||||||
pub mod manifest;
|
pub mod manifest;
|
||||||
pub mod pack;
|
pub mod pack;
|
||||||
|
pub mod recipe;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
/
|
/
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ use crate::s9pk::merkle_archive::source::{
|
|||||||
into_dyn_read, ArchiveSource, DynFileSource, DynRead, FileSource, TmpSource,
|
into_dyn_read, ArchiveSource, DynFileSource, DynRead, FileSource, TmpSource,
|
||||||
};
|
};
|
||||||
use crate::s9pk::merkle_archive::{Entry, MerkleArchive};
|
use crate::s9pk::merkle_archive::{Entry, MerkleArchive};
|
||||||
|
use crate::s9pk::v2::recipe::DirRecipe;
|
||||||
use crate::s9pk::v2::SIG_CONTEXT;
|
use crate::s9pk::v2::SIG_CONTEXT;
|
||||||
use crate::s9pk::S9pk;
|
use crate::s9pk::S9pk;
|
||||||
use crate::util::io::{create_file, open_file, TmpDir};
|
use crate::util::io::{create_file, open_file, TmpDir};
|
||||||
@@ -363,6 +364,7 @@ pub enum ImageSource {
|
|||||||
build_args: Option<BTreeMap<String, BuildArg>>,
|
build_args: Option<BTreeMap<String, BuildArg>>,
|
||||||
},
|
},
|
||||||
DockerTag(String),
|
DockerTag(String),
|
||||||
|
// Recipe(DirRecipe),
|
||||||
}
|
}
|
||||||
impl ImageSource {
|
impl ImageSource {
|
||||||
pub fn ingredients(&self) -> Vec<PathBuf> {
|
pub fn ingredients(&self) -> Vec<PathBuf> {
|
||||||
@@ -399,6 +401,8 @@ impl ImageSource {
|
|||||||
working_dir: PathBuf,
|
working_dir: PathBuf,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
user: String,
|
user: String,
|
||||||
|
entrypoint: Option<Vec<String>>,
|
||||||
|
cmd: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
async move {
|
async move {
|
||||||
match self {
|
match self {
|
||||||
@@ -531,6 +535,8 @@ impl ImageSource {
|
|||||||
} else {
|
} else {
|
||||||
config.user.into()
|
config.user.into()
|
||||||
},
|
},
|
||||||
|
entrypoint: config.entrypoint,
|
||||||
|
cmd: config.cmd,
|
||||||
})
|
})
|
||||||
.with_kind(ErrorKind::Serialization)?
|
.with_kind(ErrorKind::Serialization)?
|
||||||
.into(),
|
.into(),
|
||||||
@@ -607,8 +613,8 @@ fn tar2sqfs(dest: impl AsRef<Path>) -> Result<Command, Error> {
|
|||||||
.arg("run")
|
.arg("run")
|
||||||
.arg("-i")
|
.arg("-i")
|
||||||
.arg("--rm")
|
.arg("--rm")
|
||||||
.arg("-v")
|
.arg("--mount")
|
||||||
.arg(format!("{}:/data:rw", directory.display()))
|
.arg(format!("type=bind,src={},dst=/data", directory.display()))
|
||||||
.arg("ghcr.io/start9labs/sdk/utils:latest")
|
.arg("ghcr.io/start9labs/sdk/utils:latest")
|
||||||
.arg("tar2sqfs")
|
.arg("tar2sqfs")
|
||||||
.arg("-q")
|
.arg("-q")
|
||||||
@@ -625,6 +631,8 @@ pub struct ImageMetadata {
|
|||||||
pub workdir: PathBuf,
|
pub workdir: PathBuf,
|
||||||
#[ts(type = "string")]
|
#[ts(type = "string")]
|
||||||
pub user: InternedString,
|
pub user: InternedString,
|
||||||
|
pub entrypoint: Option<Vec<String>>,
|
||||||
|
pub cmd: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip_all)]
|
#[instrument(skip_all)]
|
||||||
|
|||||||
21
core/startos/src/s9pk/v2/recipe.rs
Normal file
21
core/startos/src/s9pk/v2/recipe.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ts_rs::TS;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||||
|
pub struct DirRecipe(BTreeMap<PathBuf, Recipe>);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum Recipe {
|
||||||
|
Make(PathBuf),
|
||||||
|
Wget {
|
||||||
|
#[ts(type = "string")]
|
||||||
|
url: Url,
|
||||||
|
checksum: String,
|
||||||
|
},
|
||||||
|
Recipe(DirRecipe),
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
|
use std::net::Ipv4Addr;
|
||||||
|
|
||||||
use rpc_toolkit::{from_fn, from_fn_async, from_fn_blocking, Context, HandlerExt, ParentHandler};
|
use rpc_toolkit::{from_fn, from_fn_async, from_fn_blocking, Context, HandlerExt, ParentHandler};
|
||||||
|
|
||||||
use crate::echo;
|
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use crate::service::cli::ContainerCliContext;
|
use crate::service::cli::ContainerCliContext;
|
||||||
use crate::service::effects::context::EffectContext;
|
use crate::service::effects::context::EffectContext;
|
||||||
|
use crate::{echo, HOST_IP};
|
||||||
|
|
||||||
mod action;
|
mod action;
|
||||||
pub mod callbacks;
|
pub mod callbacks;
|
||||||
@@ -134,6 +136,10 @@ pub fn handler<C: Context>() -> ParentHandler<C> {
|
|||||||
"get-container-ip",
|
"get-container-ip",
|
||||||
from_fn_async(net::info::get_container_ip).no_cli(),
|
from_fn_async(net::info::get_container_ip).no_cli(),
|
||||||
)
|
)
|
||||||
|
.subcommand(
|
||||||
|
"get-os-ip",
|
||||||
|
from_fn(|_: C| Ok::<_, Error>(Ipv4Addr::from(HOST_IP))),
|
||||||
|
)
|
||||||
.subcommand(
|
.subcommand(
|
||||||
"export-service-interface",
|
"export-service-interface",
|
||||||
from_fn_async(net::interface::export_service_interface).no_cli(),
|
from_fn_async(net::interface::export_service_interface).no_cli(),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use std::net::Ipv4Addr;
|
use std::net::Ipv4Addr;
|
||||||
|
|
||||||
use crate::service::effects::prelude::*;
|
use crate::service::effects::prelude::*;
|
||||||
|
use crate::HOST_IP;
|
||||||
|
|
||||||
pub async fn get_container_ip(context: EffectContext) -> Result<Ipv4Addr, Error> {
|
pub async fn get_container_ip(context: EffectContext) -> Result<Ipv4Addr, Error> {
|
||||||
let context = context.deref()?;
|
let context = context.deref()?;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use models::ImageId;
|
|||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
use crate::disk::mount::filesystem::overlayfs::OverlayGuard;
|
use crate::disk::mount::filesystem::overlayfs::OverlayGuard;
|
||||||
|
use crate::disk::mount::guard::GenericMountGuard;
|
||||||
use crate::rpc_continuations::Guid;
|
use crate::rpc_continuations::Guid;
|
||||||
use crate::service::effects::prelude::*;
|
use crate::service::effects::prelude::*;
|
||||||
use crate::service::persistent_container::Subcontainer;
|
use crate::service::persistent_container::Subcontainer;
|
||||||
@@ -40,6 +41,24 @@ pub async fn destroy_subcontainer_fs(
|
|||||||
.await
|
.await
|
||||||
.remove(&guid)
|
.remove(&guid)
|
||||||
{
|
{
|
||||||
|
#[cfg(feature = "container-runtime")]
|
||||||
|
if tokio::fs::metadata(overlay.overlay.path().join("proc/1"))
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
let procfs = context
|
||||||
|
.seed
|
||||||
|
.persistent_container
|
||||||
|
.lxc_container
|
||||||
|
.get()
|
||||||
|
.or_not_found("lxc container")?
|
||||||
|
.rootfs_dir()
|
||||||
|
.join("proc");
|
||||||
|
let overlay_path = overlay.overlay.path().to_owned();
|
||||||
|
tokio::task::spawn_blocking(move || sync::kill_init(&procfs, &overlay_path))
|
||||||
|
.await
|
||||||
|
.with_kind(ErrorKind::Unknown)??;
|
||||||
|
}
|
||||||
overlay.overlay.unmount(true).await?;
|
overlay.overlay.unmount(true).await?;
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!("Could not find a subcontainer fs to destroy; assumming that it already is destroyed and will be skipping");
|
tracing::warn!("Could not find a subcontainer fs to destroy; assumming that it already is destroyed and will be skipping");
|
||||||
|
|||||||
@@ -20,6 +20,54 @@ const FWD_SIGNALS: &[c_int] = &[
|
|||||||
SIGTSTP, SIGTTIN, SIGTTOU, SIGURG, SIGUSR1, SIGUSR2, SIGVTALRM,
|
SIGTSTP, SIGTTIN, SIGTTOU, SIGURG, SIGUSR1, SIGUSR2, SIGVTALRM,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
pub fn kill_init(procfs: &Path, chroot: &Path) -> Result<(), Error> {
|
||||||
|
if chroot.join("proc/1").exists() {
|
||||||
|
let ns_id = procfs::process::Process::new_with_root(chroot.join("proc/1"))
|
||||||
|
.with_ctx(|_| (ErrorKind::Filesystem, "open subcontainer procfs"))?
|
||||||
|
.namespaces()
|
||||||
|
.with_ctx(|_| (ErrorKind::Filesystem, "read subcontainer pid 1 ns"))?
|
||||||
|
.0
|
||||||
|
.get(OsStr::new("pid"))
|
||||||
|
.or_not_found("pid namespace")?
|
||||||
|
.identifier;
|
||||||
|
for proc in procfs::process::all_processes_with_root(procfs)
|
||||||
|
.with_ctx(|_| (ErrorKind::Filesystem, "open procfs"))?
|
||||||
|
{
|
||||||
|
let proc = proc.with_ctx(|_| (ErrorKind::Filesystem, "read single process details"))?;
|
||||||
|
let pid = proc.pid();
|
||||||
|
if proc
|
||||||
|
.namespaces()
|
||||||
|
.with_ctx(|_| (ErrorKind::Filesystem, lazy_format!("read pid {} ns", pid)))?
|
||||||
|
.0
|
||||||
|
.get(OsStr::new("pid"))
|
||||||
|
.map_or(false, |ns| ns.identifier == ns_id)
|
||||||
|
{
|
||||||
|
let pids = proc.read::<NSPid>("status").with_ctx(|_| {
|
||||||
|
(
|
||||||
|
ErrorKind::Filesystem,
|
||||||
|
lazy_format!("read pid {} NSpid", pid),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if pids.0.len() == 2 && pids.0[1] == 1 {
|
||||||
|
nix::sys::signal::kill(Pid::from_raw(pid), nix::sys::signal::SIGKILL)
|
||||||
|
.with_ctx(|_| {
|
||||||
|
(
|
||||||
|
ErrorKind::Filesystem,
|
||||||
|
lazy_format!(
|
||||||
|
"kill pid {} (determined to be pid 1 in subcontainer)",
|
||||||
|
pid
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nix::mount::umount(&chroot.join("proc"))
|
||||||
|
.with_ctx(|_| (ErrorKind::Filesystem, "unmounting subcontainer procfs"))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
struct NSPid(Vec<i32>);
|
struct NSPid(Vec<i32>);
|
||||||
impl procfs::FromBufRead for NSPid {
|
impl procfs::FromBufRead for NSPid {
|
||||||
fn from_buf_read<R: std::io::BufRead>(r: R) -> procfs::ProcResult<Self> {
|
fn from_buf_read<R: std::io::BufRead>(r: R) -> procfs::ProcResult<Self> {
|
||||||
@@ -98,21 +146,27 @@ impl ExecParams {
|
|||||||
if let Some(uid) = user.as_deref().and_then(|u| u.parse::<u32>().ok()) {
|
if let Some(uid) = user.as_deref().and_then(|u| u.parse::<u32>().ok()) {
|
||||||
cmd.uid(uid);
|
cmd.uid(uid);
|
||||||
} else if let Some(user) = user {
|
} else if let Some(user) = user {
|
||||||
let (uid, gid) = std::fs::read_to_string("/etc/passwd")
|
let passwd = std::fs::read_to_string("/etc/passwd")
|
||||||
.with_ctx(|_| (ErrorKind::Filesystem, "read /etc/passwd"))?
|
.with_ctx(|_| (ErrorKind::Filesystem, "read /etc/passwd"));
|
||||||
.lines()
|
if passwd.is_err() && user == "root" {
|
||||||
.find_map(|l| {
|
cmd.uid(0);
|
||||||
let mut split = l.trim().split(":");
|
cmd.gid(0);
|
||||||
if user != split.next()? {
|
} else {
|
||||||
return None;
|
let (uid, gid) = passwd?
|
||||||
}
|
.lines()
|
||||||
split.next(); // throw away x
|
.find_map(|l| {
|
||||||
Some((split.next()?.parse().ok()?, split.next()?.parse().ok()?))
|
let mut split = l.trim().split(":");
|
||||||
// uid gid
|
if user != split.next()? {
|
||||||
})
|
return None;
|
||||||
.or_not_found(lazy_format!("{user} in /etc/passwd"))?;
|
}
|
||||||
cmd.uid(uid);
|
split.next(); // throw away x
|
||||||
cmd.gid(gid);
|
Some((split.next()?.parse().ok()?, split.next()?.parse().ok()?))
|
||||||
|
// uid gid
|
||||||
|
})
|
||||||
|
.or_not_found(lazy_format!("{user} in /etc/passwd"))?;
|
||||||
|
cmd.uid(uid);
|
||||||
|
cmd.gid(gid);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if let Some(workdir) = workdir {
|
if let Some(workdir) = workdir {
|
||||||
cmd.current_dir(workdir);
|
cmd.current_dir(workdir);
|
||||||
@@ -134,51 +188,7 @@ pub fn launch(
|
|||||||
command,
|
command,
|
||||||
}: ExecParams,
|
}: ExecParams,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
if chroot.join("proc/1").exists() {
|
kill_init(Path::new("/proc"), &chroot)?;
|
||||||
let ns_id = procfs::process::Process::new_with_root(chroot.join("proc/1"))
|
|
||||||
.with_ctx(|_| (ErrorKind::Filesystem, "open subcontainer procfs"))?
|
|
||||||
.namespaces()
|
|
||||||
.with_ctx(|_| (ErrorKind::Filesystem, "read subcontainer pid 1 ns"))?
|
|
||||||
.0
|
|
||||||
.get(OsStr::new("pid"))
|
|
||||||
.or_not_found("pid namespace")?
|
|
||||||
.identifier;
|
|
||||||
for proc in
|
|
||||||
procfs::process::all_processes().with_ctx(|_| (ErrorKind::Filesystem, "open procfs"))?
|
|
||||||
{
|
|
||||||
let proc = proc.with_ctx(|_| (ErrorKind::Filesystem, "read single process details"))?;
|
|
||||||
let pid = proc.pid();
|
|
||||||
if proc
|
|
||||||
.namespaces()
|
|
||||||
.with_ctx(|_| (ErrorKind::Filesystem, lazy_format!("read pid {} ns", pid)))?
|
|
||||||
.0
|
|
||||||
.get(OsStr::new("pid"))
|
|
||||||
.map_or(false, |ns| ns.identifier == ns_id)
|
|
||||||
{
|
|
||||||
let pids = proc.read::<NSPid>("status").with_ctx(|_| {
|
|
||||||
(
|
|
||||||
ErrorKind::Filesystem,
|
|
||||||
lazy_format!("read pid {} NSpid", pid),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if pids.0.len() == 2 && pids.0[1] == 1 {
|
|
||||||
nix::sys::signal::kill(Pid::from_raw(pid), nix::sys::signal::SIGKILL)
|
|
||||||
.with_ctx(|_| {
|
|
||||||
(
|
|
||||||
ErrorKind::Filesystem,
|
|
||||||
lazy_format!(
|
|
||||||
"kill pid {} (determined to be pid 1 in subcontainer)",
|
|
||||||
pid
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nix::mount::umount(&chroot.join("proc"))
|
|
||||||
.with_ctx(|_| (ErrorKind::Filesystem, "unmounting subcontainer procfs"))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (std::io::stdin().is_terminal()
|
if (std::io::stdin().is_terminal()
|
||||||
&& std::io::stdout().is_terminal()
|
&& std::io::stdout().is_terminal()
|
||||||
&& std::io::stderr().is_terminal())
|
&& std::io::stderr().is_terminal())
|
||||||
|
|||||||
@@ -61,30 +61,31 @@ impl StartOSLogger {
|
|||||||
use tracing_subscriber::prelude::*;
|
use tracing_subscriber::prelude::*;
|
||||||
use tracing_subscriber::{fmt, EnvFilter};
|
use tracing_subscriber::{fmt, EnvFilter};
|
||||||
|
|
||||||
let filter_layer = EnvFilter::builder()
|
let filter_layer = || {
|
||||||
.with_default_directive(
|
EnvFilter::builder()
|
||||||
format!("{}=info", std::module_path!().split("::").next().unwrap())
|
.with_default_directive(
|
||||||
.parse()
|
format!("{}=info", std::module_path!().split("::").next().unwrap())
|
||||||
.unwrap(),
|
.parse()
|
||||||
)
|
.unwrap(),
|
||||||
.from_env_lossy();
|
)
|
||||||
#[cfg(feature = "unstable")]
|
.from_env_lossy()
|
||||||
let filter_layer = filter_layer
|
};
|
||||||
.add_directive("tokio=trace".parse().unwrap())
|
|
||||||
.add_directive("runtime=trace".parse().unwrap());
|
|
||||||
let fmt_layer = fmt::layer()
|
let fmt_layer = fmt::layer()
|
||||||
.with_writer(logfile)
|
.with_writer(logfile)
|
||||||
.with_line_number(true)
|
.with_line_number(true)
|
||||||
.with_file(true)
|
.with_file(true)
|
||||||
.with_target(true);
|
.with_target(true)
|
||||||
|
.with_filter(filter_layer());
|
||||||
|
|
||||||
let sub = tracing_subscriber::registry()
|
let sub = tracing_subscriber::registry();
|
||||||
.with(filter_layer)
|
|
||||||
.with(fmt_layer)
|
|
||||||
.with(ErrorLayer::default());
|
|
||||||
|
|
||||||
#[cfg(feature = "unstable")]
|
#[cfg(feature = "unstable")]
|
||||||
let sub = sub.with(console_subscriber::spawn());
|
let sub = sub.with(console_subscriber::spawn());
|
||||||
|
#[cfg(not(feature = "unstable"))]
|
||||||
|
let sub = sub.with(filter_layer());
|
||||||
|
|
||||||
|
let sub = sub.with(fmt_layer).with(ErrorLayer::default());
|
||||||
|
|
||||||
sub
|
sub
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,9 @@ mod v0_3_6_alpha_11;
|
|||||||
mod v0_3_6_alpha_12;
|
mod v0_3_6_alpha_12;
|
||||||
mod v0_3_6_alpha_13;
|
mod v0_3_6_alpha_13;
|
||||||
mod v0_3_6_alpha_14;
|
mod v0_3_6_alpha_14;
|
||||||
|
mod v0_3_6_alpha_15;
|
||||||
|
|
||||||
pub type Current = v0_3_6_alpha_14::Version; // VERSION_BUMP
|
pub type Current = v0_3_6_alpha_15::Version; // VERSION_BUMP
|
||||||
|
|
||||||
impl Current {
|
impl Current {
|
||||||
#[instrument(skip(self, db))]
|
#[instrument(skip(self, db))]
|
||||||
@@ -131,7 +132,8 @@ enum Version {
|
|||||||
V0_3_6_alpha_11(Wrapper<v0_3_6_alpha_11::Version>),
|
V0_3_6_alpha_11(Wrapper<v0_3_6_alpha_11::Version>),
|
||||||
V0_3_6_alpha_12(Wrapper<v0_3_6_alpha_12::Version>),
|
V0_3_6_alpha_12(Wrapper<v0_3_6_alpha_12::Version>),
|
||||||
V0_3_6_alpha_13(Wrapper<v0_3_6_alpha_13::Version>),
|
V0_3_6_alpha_13(Wrapper<v0_3_6_alpha_13::Version>),
|
||||||
V0_3_6_alpha_14(Wrapper<v0_3_6_alpha_14::Version>), // VERSION_BUMP
|
V0_3_6_alpha_14(Wrapper<v0_3_6_alpha_14::Version>),
|
||||||
|
V0_3_6_alpha_15(Wrapper<v0_3_6_alpha_15::Version>), // VERSION_BUMP
|
||||||
Other(exver::Version),
|
Other(exver::Version),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +171,8 @@ impl Version {
|
|||||||
Self::V0_3_6_alpha_11(v) => DynVersion(Box::new(v.0)),
|
Self::V0_3_6_alpha_11(v) => DynVersion(Box::new(v.0)),
|
||||||
Self::V0_3_6_alpha_12(v) => DynVersion(Box::new(v.0)),
|
Self::V0_3_6_alpha_12(v) => DynVersion(Box::new(v.0)),
|
||||||
Self::V0_3_6_alpha_13(v) => DynVersion(Box::new(v.0)),
|
Self::V0_3_6_alpha_13(v) => DynVersion(Box::new(v.0)),
|
||||||
Self::V0_3_6_alpha_14(v) => DynVersion(Box::new(v.0)), // VERSION_BUMP
|
Self::V0_3_6_alpha_14(v) => DynVersion(Box::new(v.0)),
|
||||||
|
Self::V0_3_6_alpha_15(v) => DynVersion(Box::new(v.0)), // VERSION_BUMP
|
||||||
Self::Other(v) => {
|
Self::Other(v) => {
|
||||||
return Err(Error::new(
|
return Err(Error::new(
|
||||||
eyre!("unknown version {v}"),
|
eyre!("unknown version {v}"),
|
||||||
@@ -199,7 +202,8 @@ impl Version {
|
|||||||
Version::V0_3_6_alpha_11(Wrapper(x)) => x.semver(),
|
Version::V0_3_6_alpha_11(Wrapper(x)) => x.semver(),
|
||||||
Version::V0_3_6_alpha_12(Wrapper(x)) => x.semver(),
|
Version::V0_3_6_alpha_12(Wrapper(x)) => x.semver(),
|
||||||
Version::V0_3_6_alpha_13(Wrapper(x)) => x.semver(),
|
Version::V0_3_6_alpha_13(Wrapper(x)) => x.semver(),
|
||||||
Version::V0_3_6_alpha_14(Wrapper(x)) => x.semver(), // VERSION_BUMP
|
Version::V0_3_6_alpha_14(Wrapper(x)) => x.semver(),
|
||||||
|
Version::V0_3_6_alpha_15(Wrapper(x)) => x.semver(), // VERSION_BUMP
|
||||||
Version::Other(x) => x.clone(),
|
Version::Other(x) => x.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
39
core/startos/src/version/v0_3_6_alpha_15.rs
Normal file
39
core/startos/src/version/v0_3_6_alpha_15.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use exver::{PreReleaseSegment, VersionRange};
|
||||||
|
use imbl_value::json;
|
||||||
|
|
||||||
|
use super::v0_3_5::V0_3_0_COMPAT;
|
||||||
|
use super::{v0_3_6_alpha_14, VersionT};
|
||||||
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
lazy_static::lazy_static! {
|
||||||
|
static ref V0_3_6_alpha_15: exver::Version = exver::Version::new(
|
||||||
|
[0, 3, 6],
|
||||||
|
[PreReleaseSegment::String("alpha".into()), 15.into()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
|
pub struct Version;
|
||||||
|
|
||||||
|
impl VersionT for Version {
|
||||||
|
type Previous = v0_3_6_alpha_14::Version;
|
||||||
|
type PreUpRes = ();
|
||||||
|
|
||||||
|
async fn pre_up(self) -> Result<Self::PreUpRes, Error> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn semver(self) -> exver::Version {
|
||||||
|
V0_3_6_alpha_15.clone()
|
||||||
|
}
|
||||||
|
fn compat(self) -> &'static VersionRange {
|
||||||
|
&V0_3_0_COMPAT
|
||||||
|
}
|
||||||
|
fn up(self, db: &mut Value, _: Self::PreUpRes) -> Result<(), Error> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn down(self, _db: &mut Value) -> Result<(), Error> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,6 +131,8 @@ export type Effects = {
|
|||||||
}): Promise<Host | null>
|
}): Promise<Host | null>
|
||||||
/** Returns the IP address of the container */
|
/** Returns the IP address of the container */
|
||||||
getContainerIp(): Promise<string>
|
getContainerIp(): Promise<string>
|
||||||
|
/** Returns the IP address of StartOS */
|
||||||
|
getOsIp(): Promise<string>
|
||||||
// interface
|
// interface
|
||||||
/** Creates an interface bound to a specific host and port to show to the user */
|
/** Creates an interface bound to a specific host and port to show to the user */
|
||||||
exportServiceInterface(options: ExportServiceInterfaceParams): Promise<null>
|
exportServiceInterface(options: ExportServiceInterfaceParams): Promise<null>
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export type ImageMetadata = { workdir: string; user: string }
|
export type ImageMetadata = {
|
||||||
|
workdir: string
|
||||||
|
user: string
|
||||||
|
entrypoint: Array<string> | null
|
||||||
|
cmd: Array<string> | null
|
||||||
|
}
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ describe("startosTypeValidation ", () => {
|
|||||||
},
|
},
|
||||||
getSystemSmtp: {} as WithCallback<GetSystemSmtpParams>,
|
getSystemSmtp: {} as WithCallback<GetSystemSmtpParams>,
|
||||||
getContainerIp: undefined,
|
getContainerIp: undefined,
|
||||||
|
getOsIp: undefined,
|
||||||
getServicePortForward: {} as GetServicePortForwardParams,
|
getServicePortForward: {} as GetServicePortForwardParams,
|
||||||
clearServiceInterfaces: {} as ClearServiceInterfacesParams,
|
clearServiceInterfaces: {} as ClearServiceInterfacesParams,
|
||||||
exportServiceInterface: {} as ExportServiceInterfaceParams,
|
exportServiceInterface: {} as ExportServiceInterfaceParams,
|
||||||
|
|||||||
@@ -127,7 +127,11 @@ export type SmtpValue = {
|
|||||||
password: string | null | undefined
|
password: string | null | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CommandType = string | [string, ...string[]]
|
export class UseEntrypoint {
|
||||||
|
constructor(readonly overridCmd?: string[]) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CommandType = string | [string, ...string[]] | UseEntrypoint
|
||||||
|
|
||||||
export type DaemonReturned = {
|
export type DaemonReturned = {
|
||||||
wait(): Promise<unknown>
|
wait(): Promise<unknown>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const getHostname = (url: string): Hostname | null => {
|
|||||||
|
|
||||||
export type Filled = {
|
export type Filled = {
|
||||||
hostnames: HostnameInfo[]
|
hostnames: HostnameInfo[]
|
||||||
|
publicHostnames: HostnameInfo[]
|
||||||
onionHostnames: HostnameInfo[]
|
onionHostnames: HostnameInfo[]
|
||||||
localHostnames: HostnameInfo[]
|
localHostnames: HostnameInfo[]
|
||||||
ipHostnames: HostnameInfo[]
|
ipHostnames: HostnameInfo[]
|
||||||
@@ -25,6 +26,7 @@ export type Filled = {
|
|||||||
nonIpHostnames: HostnameInfo[]
|
nonIpHostnames: HostnameInfo[]
|
||||||
|
|
||||||
urls: UrlString[]
|
urls: UrlString[]
|
||||||
|
publicUrls: UrlString[]
|
||||||
onionUrls: UrlString[]
|
onionUrls: UrlString[]
|
||||||
localUrls: UrlString[]
|
localUrls: UrlString[]
|
||||||
ipUrls: UrlString[]
|
ipUrls: UrlString[]
|
||||||
@@ -105,6 +107,9 @@ export const filledAddress = (
|
|||||||
return {
|
return {
|
||||||
...addressInfo,
|
...addressInfo,
|
||||||
hostnames,
|
hostnames,
|
||||||
|
get publicHostnames() {
|
||||||
|
return hostnames.filter((h) => h.kind === "onion" || h.public)
|
||||||
|
},
|
||||||
get onionHostnames() {
|
get onionHostnames() {
|
||||||
return hostnames.filter((h) => h.kind === "onion")
|
return hostnames.filter((h) => h.kind === "onion")
|
||||||
},
|
},
|
||||||
@@ -141,6 +146,9 @@ export const filledAddress = (
|
|||||||
get urls() {
|
get urls() {
|
||||||
return this.hostnames.flatMap(toUrl)
|
return this.hostnames.flatMap(toUrl)
|
||||||
},
|
},
|
||||||
|
get publicUrls() {
|
||||||
|
return this.publicHostnames.flatMap(toUrl)
|
||||||
|
},
|
||||||
get onionUrls() {
|
get onionUrls() {
|
||||||
return this.onionHostnames.flatMap(toUrl)
|
return this.onionHostnames.flatMap(toUrl)
|
||||||
},
|
},
|
||||||
|
|||||||
11
sdk/base/package-lock.json
generated
11
sdk/base/package-lock.json
generated
@@ -28,7 +28,7 @@
|
|||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"ts-pegjs": "^4.2.1",
|
"ts-pegjs": "^4.2.1",
|
||||||
"tsx": "^4.7.1",
|
"tsx": "^4.7.1",
|
||||||
"typescript": "^5.0.4"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ampproject/remapping": {
|
"node_modules/@ampproject/remapping": {
|
||||||
@@ -4417,16 +4417,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.0.4",
|
"version": "5.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
|
||||||
"integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==",
|
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.20"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
|
|||||||
@@ -47,6 +47,6 @@
|
|||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"ts-pegjs": "^4.2.1",
|
"ts-pegjs": "^4.2.1",
|
||||||
"tsx": "^4.7.1",
|
"tsx": "^4.7.1",
|
||||||
"typescript": "^5.0.4"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"outDir": "../baseDist",
|
"outDir": "../baseDist",
|
||||||
"target": "es2018"
|
"target": "es2021"
|
||||||
},
|
},
|
||||||
"include": ["lib/**/*"],
|
"include": ["lib/**/*"],
|
||||||
"exclude": ["lib/**/*.spec.ts", "lib/**/*.gen.ts", "list", "node_modules"]
|
"exclude": ["lib/**/*.spec.ts", "lib/**/*.gen.ts", "list", "node_modules"]
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
import * as patterns from "../../base/lib/util/patterns"
|
import * as patterns from "../../base/lib/util/patterns"
|
||||||
import { BackupSync, Backups } from "./backup/Backups"
|
import { BackupSync, Backups } from "./backup/Backups"
|
||||||
import { smtpInputSpec } from "../../base/lib/actions/input/inputSpecConstants"
|
import { smtpInputSpec } from "../../base/lib/actions/input/inputSpecConstants"
|
||||||
import { Daemons } from "./mainFn/Daemons"
|
import { CommandController, Daemons } from "./mainFn/Daemons"
|
||||||
import { healthCheck, HealthCheckParams } from "./health/HealthCheck"
|
import { healthCheck, HealthCheckParams } from "./health/HealthCheck"
|
||||||
import { checkPortListening } from "./health/checkFns/checkPortListening"
|
import { checkPortListening } from "./health/checkFns/checkPortListening"
|
||||||
import { checkWebUrl, runHealthScript } from "./health/checkFns"
|
import { checkWebUrl, runHealthScript } from "./health/checkFns"
|
||||||
@@ -71,6 +71,7 @@ import { GetInput } from "../../base/lib/actions/setupActions"
|
|||||||
import { Run } from "../../base/lib/actions/setupActions"
|
import { Run } from "../../base/lib/actions/setupActions"
|
||||||
import * as actions from "../../base/lib/actions"
|
import * as actions from "../../base/lib/actions"
|
||||||
import { setupInit } from "./inits/setupInit"
|
import { setupInit } from "./inits/setupInit"
|
||||||
|
import * as fs from "node:fs/promises"
|
||||||
|
|
||||||
export const SDKVersion = testTypeVersion("0.3.6")
|
export const SDKVersion = testTypeVersion("0.3.6")
|
||||||
|
|
||||||
@@ -124,6 +125,7 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
|
|||||||
effects.getServicePortForward(...args),
|
effects.getServicePortForward(...args),
|
||||||
clearBindings: (effects, ...args) => effects.clearBindings(...args),
|
clearBindings: (effects, ...args) => effects.clearBindings(...args),
|
||||||
getContainerIp: (effects, ...args) => effects.getContainerIp(...args),
|
getContainerIp: (effects, ...args) => effects.getContainerIp(...args),
|
||||||
|
getOsIp: (effects, ...args) => effects.getOsIp(...args),
|
||||||
getSslKey: (effects, ...args) => effects.getSslKey(...args),
|
getSslKey: (effects, ...args) => effects.getSslKey(...args),
|
||||||
setDataVersion: (effects, ...args) => effects.setDataVersion(...args),
|
setDataVersion: (effects, ...args) => effects.setDataVersion(...args),
|
||||||
getDataVersion: (effects, ...args) => effects.getDataVersion(...args),
|
getDataVersion: (effects, ...args) => effects.getDataVersion(...args),
|
||||||
@@ -219,6 +221,8 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
|
|||||||
of: (effects: Effects, id: string) => new MultiHost({ id, effects }),
|
of: (effects: Effects, id: string) => new MultiHost({ id, effects }),
|
||||||
},
|
},
|
||||||
nullIfEmpty,
|
nullIfEmpty,
|
||||||
|
useEntrypoint: (overrideCmd?: string[]) =>
|
||||||
|
new T.UseEntrypoint(overrideCmd),
|
||||||
runCommand: async <A extends string>(
|
runCommand: async <A extends string>(
|
||||||
effects: Effects,
|
effects: Effects,
|
||||||
image: {
|
image: {
|
||||||
@@ -234,13 +238,7 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
|
|||||||
*/
|
*/
|
||||||
name?: string,
|
name?: string,
|
||||||
): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> => {
|
): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> => {
|
||||||
return runCommand<Manifest>(
|
return runCommand<Manifest>(effects, image, command, options, name)
|
||||||
effects,
|
|
||||||
image,
|
|
||||||
command,
|
|
||||||
options,
|
|
||||||
name || (Array.isArray(command) ? command.join(" ") : command),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* @description Use this class to create an Action. By convention, each Action should receive its own file.
|
* @description Use this class to create an Action. By convention, each Action should receive its own file.
|
||||||
@@ -1081,18 +1079,37 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
|
|||||||
export async function runCommand<Manifest extends T.SDKManifest>(
|
export async function runCommand<Manifest extends T.SDKManifest>(
|
||||||
effects: Effects,
|
effects: Effects,
|
||||||
image: { imageId: keyof Manifest["images"] & T.ImageId; sharedRun?: boolean },
|
image: { imageId: keyof Manifest["images"] & T.ImageId; sharedRun?: boolean },
|
||||||
command: string | [string, ...string[]],
|
command: T.CommandType,
|
||||||
options: CommandOptions & {
|
options: CommandOptions & {
|
||||||
mounts?: { path: string; options: MountOptions }[]
|
mounts?: { path: string; options: MountOptions }[]
|
||||||
},
|
},
|
||||||
name: string,
|
name?: string,
|
||||||
): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> {
|
): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> {
|
||||||
const commands = splitCommand(command)
|
let commands: string[]
|
||||||
|
if (command instanceof T.UseEntrypoint) {
|
||||||
|
const imageMeta: T.ImageMetadata = await fs
|
||||||
|
.readFile(`/media/startos/images/${image.imageId}.json`, {
|
||||||
|
encoding: "utf8",
|
||||||
|
})
|
||||||
|
.catch(() => "{}")
|
||||||
|
.then(JSON.parse)
|
||||||
|
commands = imageMeta.entrypoint ?? []
|
||||||
|
commands.concat(...(command.overridCmd ?? imageMeta.cmd ?? []))
|
||||||
|
} else commands = splitCommand(command)
|
||||||
return SubContainer.with(
|
return SubContainer.with(
|
||||||
effects,
|
effects,
|
||||||
image,
|
image,
|
||||||
options.mounts || [],
|
options.mounts || [],
|
||||||
name,
|
name ||
|
||||||
|
commands
|
||||||
|
.map((c) => {
|
||||||
|
if (c.includes(" ")) {
|
||||||
|
return `"${c.replace(/"/g, `\"`)}"`
|
||||||
|
} else {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.join(" "),
|
||||||
(subcontainer) => subcontainer.exec(commands),
|
(subcontainer) => subcontainer.exec(commands),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,17 @@ export type HealthCheckParams = {
|
|||||||
id: HealthCheckId
|
id: HealthCheckId
|
||||||
name: string
|
name: string
|
||||||
trigger?: Trigger
|
trigger?: Trigger
|
||||||
|
gracePeriod?: number
|
||||||
fn(): Promise<HealthCheckResult> | HealthCheckResult
|
fn(): Promise<HealthCheckResult> | HealthCheckResult
|
||||||
onFirstSuccess?: () => unknown | Promise<unknown>
|
onFirstSuccess?: () => unknown | Promise<unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function healthCheck(o: HealthCheckParams) {
|
export function healthCheck(o: HealthCheckParams) {
|
||||||
new Promise(async () => {
|
new Promise(async () => {
|
||||||
|
const start = performance.now()
|
||||||
let currentValue: TriggerInput = {}
|
let currentValue: TriggerInput = {}
|
||||||
const getCurrentValue = () => currentValue
|
const getCurrentValue = () => currentValue
|
||||||
|
const gracePeriod = o.gracePeriod ?? 5000
|
||||||
const trigger = (o.trigger ?? defaultTrigger)(getCurrentValue)
|
const trigger = (o.trigger ?? defaultTrigger)(getCurrentValue)
|
||||||
const triggerFirstSuccess = once(() =>
|
const triggerFirstSuccess = once(() =>
|
||||||
Promise.resolve(
|
Promise.resolve(
|
||||||
@@ -33,7 +36,9 @@ export function healthCheck(o: HealthCheckParams) {
|
|||||||
res = await trigger.next()
|
res = await trigger.next()
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { result, message } = await o.fn()
|
let { result, message } = await o.fn()
|
||||||
|
if (result === "failure" && performance.now() - start <= gracePeriod)
|
||||||
|
result = "starting"
|
||||||
await o.effects.setHealth({
|
await o.effects.setHealth({
|
||||||
name: o.name,
|
name: o.name,
|
||||||
id: o.id,
|
id: o.id,
|
||||||
@@ -48,7 +53,8 @@ export function healthCheck(o: HealthCheckParams) {
|
|||||||
await o.effects.setHealth({
|
await o.effects.setHealth({
|
||||||
name: o.name,
|
name: o.name,
|
||||||
id: o.id,
|
id: o.id,
|
||||||
result: "failure",
|
result:
|
||||||
|
performance.now() - start <= gracePeriod ? "starting" : "failure",
|
||||||
message: asMessage(e) || "",
|
message: asMessage(e) || "",
|
||||||
})
|
})
|
||||||
currentValue.lastResult = "failure"
|
currentValue.lastResult = "failure"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "../util/SubContainer"
|
} from "../util/SubContainer"
|
||||||
import { splitCommand } from "../util"
|
import { splitCommand } from "../util"
|
||||||
import * as cp from "child_process"
|
import * as cp from "child_process"
|
||||||
|
import * as fs from "node:fs/promises"
|
||||||
|
|
||||||
export class CommandController {
|
export class CommandController {
|
||||||
private constructor(
|
private constructor(
|
||||||
@@ -45,7 +46,17 @@ export class CommandController {
|
|||||||
onStderr?: (chunk: Buffer | string | any) => void
|
onStderr?: (chunk: Buffer | string | any) => void
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const commands = splitCommand(command)
|
let commands: string[]
|
||||||
|
if (command instanceof T.UseEntrypoint) {
|
||||||
|
const imageMeta: T.ImageMetadata = await fs
|
||||||
|
.readFile(`/media/startos/images/${subcontainer.imageId}.json`, {
|
||||||
|
encoding: "utf8",
|
||||||
|
})
|
||||||
|
.catch(() => "{}")
|
||||||
|
.then(JSON.parse)
|
||||||
|
commands = imageMeta.entrypoint ?? []
|
||||||
|
commands.concat(...(command.overridCmd ?? imageMeta.cmd ?? []))
|
||||||
|
} else commands = splitCommand(command)
|
||||||
const subc =
|
const subc =
|
||||||
subcontainer instanceof SubContainer
|
subcontainer instanceof SubContainer
|
||||||
? subcontainer
|
? subcontainer
|
||||||
@@ -55,10 +66,15 @@ export class CommandController {
|
|||||||
subcontainer,
|
subcontainer,
|
||||||
options?.subcontainerName || commands.join(" "),
|
options?.subcontainerName || commands.join(" "),
|
||||||
)
|
)
|
||||||
for (let mount of options.mounts || []) {
|
try {
|
||||||
await subc.mount(mount.options, mount.path)
|
for (let mount of options.mounts || []) {
|
||||||
|
await subc.mount(mount.options, mount.path)
|
||||||
|
}
|
||||||
|
return subc
|
||||||
|
} catch (e) {
|
||||||
|
await subc.destroy()
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
return subc
|
|
||||||
})()
|
})()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ export type Ready = {
|
|||||||
fn: (
|
fn: (
|
||||||
spawnable: ExecSpawnable,
|
spawnable: ExecSpawnable,
|
||||||
) => Promise<HealthCheckResult> | HealthCheckResult
|
) => Promise<HealthCheckResult> | HealthCheckResult
|
||||||
|
/**
|
||||||
|
* A duration in milliseconds to treat a failing health check as "starting"
|
||||||
|
*
|
||||||
|
* defaults to 5000
|
||||||
|
*/
|
||||||
|
gracePeriod?: number
|
||||||
trigger?: Trigger
|
trigger?: Trigger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export class HealthDaemon {
|
|||||||
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
|
||||||
|
private started?: number
|
||||||
private resolveReady: (() => void) | undefined
|
private resolveReady: (() => void) | undefined
|
||||||
private readyPromise: Promise<void>
|
private readyPromise: Promise<void>
|
||||||
constructor(
|
constructor(
|
||||||
@@ -75,6 +76,7 @@ export class HealthDaemon {
|
|||||||
|
|
||||||
if (newStatus) {
|
if (newStatus) {
|
||||||
;(await this.daemon).start()
|
;(await this.daemon).start()
|
||||||
|
this.started = performance.now()
|
||||||
this.setupHealthCheck()
|
this.setupHealthCheck()
|
||||||
} else {
|
} else {
|
||||||
;(await this.daemon).stop()
|
;(await this.daemon).stop()
|
||||||
@@ -146,14 +148,21 @@ export class HealthDaemon {
|
|||||||
this._health = health
|
this._health = health
|
||||||
this.healthWatchers.forEach((watcher) => watcher())
|
this.healthWatchers.forEach((watcher) => watcher())
|
||||||
const display = this.ready.display
|
const display = this.ready.display
|
||||||
const result = health.result
|
|
||||||
if (!display) {
|
if (!display) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let result = health.result
|
||||||
|
if (
|
||||||
|
result === "failure" &&
|
||||||
|
this.started &&
|
||||||
|
performance.now() - this.started <= (this.ready.gracePeriod ?? 5000)
|
||||||
|
)
|
||||||
|
result = "starting"
|
||||||
await this.effects.setHealth({
|
await this.effects.setHealth({
|
||||||
...health,
|
...health,
|
||||||
id: this.id,
|
id: this.id,
|
||||||
name: display,
|
name: display,
|
||||||
|
result,
|
||||||
} as SetHealth)
|
} as SetHealth)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,15 @@ export interface ExecSpawnable {
|
|||||||
* @see {@link ExecSpawnable}
|
* @see {@link ExecSpawnable}
|
||||||
*/
|
*/
|
||||||
export class SubContainer implements ExecSpawnable {
|
export class SubContainer implements ExecSpawnable {
|
||||||
|
private static finalizationEffects: { effects?: T.Effects } = {}
|
||||||
|
private static registry = new FinalizationRegistry((guid: string) => {
|
||||||
|
if (this.finalizationEffects.effects) {
|
||||||
|
this.finalizationEffects.effects.subcontainer
|
||||||
|
.destroyFs({ guid })
|
||||||
|
.catch((e) => console.error("failed to cleanup SubContainer", guid, e))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
private leader: cp.ChildProcess
|
private leader: cp.ChildProcess
|
||||||
private leaderExited: boolean = false
|
private leaderExited: boolean = false
|
||||||
private waitProc: () => Promise<null>
|
private waitProc: () => Promise<null>
|
||||||
@@ -55,6 +64,8 @@ export class SubContainer implements ExecSpawnable {
|
|||||||
readonly rootfs: string,
|
readonly rootfs: string,
|
||||||
readonly guid: T.Guid,
|
readonly guid: T.Guid,
|
||||||
) {
|
) {
|
||||||
|
if (!SubContainer.finalizationEffects.effects)
|
||||||
|
SubContainer.finalizationEffects.effects = effects
|
||||||
this.leaderExited = false
|
this.leaderExited = false
|
||||||
this.leader = cp.spawn("start-cli", ["subcontainer", "launch", rootfs], {
|
this.leader = cp.spawn("start-cli", ["subcontainer", "launch", rootfs], {
|
||||||
killSignal: "SIGKILL",
|
killSignal: "SIGKILL",
|
||||||
@@ -94,6 +105,8 @@ export class SubContainer implements ExecSpawnable {
|
|||||||
imageId,
|
imageId,
|
||||||
name,
|
name,
|
||||||
})
|
})
|
||||||
|
const res = new SubContainer(effects, imageId, rootfs, guid)
|
||||||
|
SubContainer.registry.register(res, guid, res)
|
||||||
|
|
||||||
const shared = ["dev", "sys"]
|
const shared = ["dev", "sys"]
|
||||||
if (!!sharedRun) {
|
if (!!sharedRun) {
|
||||||
@@ -111,7 +124,7 @@ export class SubContainer implements ExecSpawnable {
|
|||||||
await execFile("mount", ["--rbind", from, to])
|
await execFile("mount", ["--rbind", from, to])
|
||||||
}
|
}
|
||||||
|
|
||||||
return new SubContainer(effects, imageId, rootfs, guid)
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
static async with<T>(
|
static async with<T>(
|
||||||
@@ -202,6 +215,7 @@ export class SubContainer implements ExecSpawnable {
|
|||||||
const guid = this.guid
|
const guid = this.guid
|
||||||
await this.killLeader()
|
await this.killLeader()
|
||||||
await this.effects.subcontainer.destroyFs({ guid })
|
await this.effects.subcontainer.destroyFs({ guid })
|
||||||
|
SubContainer.registry.unregister(this)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,8 +238,9 @@ export class SubContainer implements ExecSpawnable {
|
|||||||
.catch(() => "{}")
|
.catch(() => "{}")
|
||||||
.then(JSON.parse)
|
.then(JSON.parse)
|
||||||
let extra: string[] = []
|
let extra: string[] = []
|
||||||
|
let user = imageMeta.user || "root"
|
||||||
if (options?.user) {
|
if (options?.user) {
|
||||||
extra.push(`--user=${options.user}`)
|
user = options.user
|
||||||
delete options.user
|
delete options.user
|
||||||
}
|
}
|
||||||
let workdir = imageMeta.workdir || "/"
|
let workdir = imageMeta.workdir || "/"
|
||||||
@@ -239,6 +254,7 @@ export class SubContainer implements ExecSpawnable {
|
|||||||
"subcontainer",
|
"subcontainer",
|
||||||
"exec",
|
"exec",
|
||||||
`--env=/media/startos/images/${this.imageId}.env`,
|
`--env=/media/startos/images/${this.imageId}.env`,
|
||||||
|
`--user=${user}`,
|
||||||
`--workdir=${workdir}`,
|
`--workdir=${workdir}`,
|
||||||
...extra,
|
...extra,
|
||||||
this.rootfs,
|
this.rootfs,
|
||||||
@@ -294,15 +310,16 @@ export class SubContainer implements ExecSpawnable {
|
|||||||
options?: CommandOptions,
|
options?: CommandOptions,
|
||||||
): Promise<cp.ChildProcessWithoutNullStreams> {
|
): Promise<cp.ChildProcessWithoutNullStreams> {
|
||||||
await this.waitProc()
|
await this.waitProc()
|
||||||
const imageMeta: any = await fs
|
const imageMeta: T.ImageMetadata = await fs
|
||||||
.readFile(`/media/startos/images/${this.imageId}.json`, {
|
.readFile(`/media/startos/images/${this.imageId}.json`, {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
})
|
})
|
||||||
.catch(() => "{}")
|
.catch(() => "{}")
|
||||||
.then(JSON.parse)
|
.then(JSON.parse)
|
||||||
let extra: string[] = []
|
let extra: string[] = []
|
||||||
|
let user = imageMeta.user || "root"
|
||||||
if (options?.user) {
|
if (options?.user) {
|
||||||
extra.push(`--user=${options.user}`)
|
user = options.user
|
||||||
delete options.user
|
delete options.user
|
||||||
}
|
}
|
||||||
let workdir = imageMeta.workdir || "/"
|
let workdir = imageMeta.workdir || "/"
|
||||||
@@ -318,6 +335,7 @@ export class SubContainer implements ExecSpawnable {
|
|||||||
"subcontainer",
|
"subcontainer",
|
||||||
"launch",
|
"launch",
|
||||||
`--env=/media/startos/images/${this.imageId}.env`,
|
`--env=/media/startos/images/${this.imageId}.env`,
|
||||||
|
`--user=${user}`,
|
||||||
`--workdir=${workdir}`,
|
`--workdir=${workdir}`,
|
||||||
...extra,
|
...extra,
|
||||||
this.rootfs,
|
this.rootfs,
|
||||||
@@ -336,15 +354,16 @@ export class SubContainer implements ExecSpawnable {
|
|||||||
options: CommandOptions & StdioOptions = { stdio: "inherit" },
|
options: CommandOptions & StdioOptions = { stdio: "inherit" },
|
||||||
): Promise<cp.ChildProcess> {
|
): Promise<cp.ChildProcess> {
|
||||||
await this.waitProc()
|
await this.waitProc()
|
||||||
const imageMeta: any = await fs
|
const imageMeta: T.ImageMetadata = await fs
|
||||||
.readFile(`/media/startos/images/${this.imageId}.json`, {
|
.readFile(`/media/startos/images/${this.imageId}.json`, {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
})
|
})
|
||||||
.catch(() => "{}")
|
.catch(() => "{}")
|
||||||
.then(JSON.parse)
|
.then(JSON.parse)
|
||||||
let extra: string[] = []
|
let extra: string[] = []
|
||||||
if (options.user) {
|
let user = imageMeta.user || "root"
|
||||||
extra.push(`--user=${options.user}`)
|
if (options?.user) {
|
||||||
|
user = options.user
|
||||||
delete options.user
|
delete options.user
|
||||||
}
|
}
|
||||||
let workdir = imageMeta.workdir || "/"
|
let workdir = imageMeta.workdir || "/"
|
||||||
@@ -358,6 +377,7 @@ export class SubContainer implements ExecSpawnable {
|
|||||||
"subcontainer",
|
"subcontainer",
|
||||||
"exec",
|
"exec",
|
||||||
`--env=/media/startos/images/${this.imageId}.env`,
|
`--env=/media/startos/images/${this.imageId}.env`,
|
||||||
|
`--user=${user}`,
|
||||||
`--workdir=${workdir}`,
|
`--workdir=${workdir}`,
|
||||||
...extra,
|
...extra,
|
||||||
this.rootfs,
|
this.rootfs,
|
||||||
|
|||||||
15
sdk/package/package-lock.json
generated
15
sdk/package/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@start9labs/start-sdk",
|
"name": "@start9labs/start-sdk",
|
||||||
"version": "0.3.6-beta.9",
|
"version": "0.3.6-beta.11",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@start9labs/start-sdk",
|
"name": "@start9labs/start-sdk",
|
||||||
"version": "0.3.6-beta.9",
|
"version": "0.3.6-beta.11",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@iarna/toml": "^2.2.5",
|
"@iarna/toml": "^2.2.5",
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"ts-pegjs": "^4.2.1",
|
"ts-pegjs": "^4.2.1",
|
||||||
"tsx": "^4.7.1",
|
"tsx": "^4.7.1",
|
||||||
"typescript": "^5.0.4"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"../base": {
|
"../base": {
|
||||||
@@ -4438,16 +4438,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.0.4",
|
"version": "5.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
|
||||||
"integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==",
|
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.20"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@start9labs/start-sdk",
|
"name": "@start9labs/start-sdk",
|
||||||
"version": "0.3.6-beta.9",
|
"version": "0.3.6-beta.11",
|
||||||
"description": "Software development kit to facilitate packaging services for StartOS",
|
"description": "Software development kit to facilitate packaging services for StartOS",
|
||||||
"main": "./package/lib/index.js",
|
"main": "./package/lib/index.js",
|
||||||
"types": "./package/lib/index.d.ts",
|
"types": "./package/lib/index.d.ts",
|
||||||
@@ -55,6 +55,6 @@
|
|||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"ts-pegjs": "^4.2.1",
|
"ts-pegjs": "^4.2.1",
|
||||||
"tsx": "^4.7.1",
|
"tsx": "^4.7.1",
|
||||||
"typescript": "^5.0.4"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"outDir": "../dist",
|
"outDir": "../dist",
|
||||||
"target": "es2018"
|
"target": "es2021"
|
||||||
},
|
},
|
||||||
"include": ["lib/**/*", "../base/lib/util/Hostname.ts"],
|
"include": ["lib/**/*", "../base/lib/util/Hostname.ts"],
|
||||||
"exclude": ["lib/**/*.spec.ts", "lib/**/*.gen.ts", "list", "node_modules"]
|
"exclude": ["lib/**/*.spec.ts", "lib/**/*.gen.ts", "list", "node_modules"]
|
||||||
|
|||||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "startos-ui",
|
"name": "startos-ui",
|
||||||
"version": "0.3.6-alpha.14",
|
"version": "0.3.6-alpha.15",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "startos-ui",
|
"name": "startos-ui",
|
||||||
"version": "0.3.6-alpha.14",
|
"version": "0.3.6-alpha.15",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^14.1.0",
|
"@angular/animations": "^14.1.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "startos-ui",
|
"name": "startos-ui",
|
||||||
"version": "0.3.6-alpha.14",
|
"version": "0.3.6-alpha.15",
|
||||||
"author": "Start9 Labs, Inc",
|
"author": "Start9 Labs, Inc",
|
||||||
"homepage": "https://start9.com/",
|
"homepage": "https://start9.com/",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
Reference in New Issue
Block a user