Refactor/project structure (#3085)

* refactor project structure

* environment-based default registry

* fix tests

* update build container

* use docker platform for iso build emulation

* simplify compat

* Fix docker platform spec in run-compat.sh

* handle riscv compat

* fix bug with dep error exists attr

* undo removal of sorting

* use qemu for iso stage

---------

Co-authored-by: Mariusz Kogen <k0gen@pm.me>
Co-authored-by: Matt Hill <mattnine@protonmail.com>
This commit is contained in:
Aiden McClelland
2025-12-22 13:39:38 -07:00
committed by GitHub
parent eda08d5b0f
commit 96ae532879
389 changed files with 744 additions and 4005 deletions

View File

@@ -0,0 +1,323 @@
use std::collections::BTreeSet;
use rpc_toolkit::{Context, HandlerExt, ParentHandler, from_fn_async};
use crate::action::{ActionInput, ActionResult, display_action_result};
use crate::db::model::package::{
ActionMetadata, Task, TaskCondition, TaskEntry, TaskSeverity, TaskTrigger,
};
use crate::rpc_continuations::Guid;
use crate::service::cli::ContainerCliContext;
use crate::service::effects::prelude::*;
use crate::util::serde::HandlerExtSerde;
use crate::{ActionId, PackageId, ReplayId};
pub fn action_api<C: Context>() -> ParentHandler<C> {
ParentHandler::new()
.subcommand("export", from_fn_async(export_action).no_cli())
.subcommand(
"clear",
from_fn_async(clear_actions)
.no_display()
.with_call_remote::<ContainerCliContext>(),
)
.subcommand(
"get-input",
from_fn_async(get_action_input)
.with_display_serializable()
.with_call_remote::<ContainerCliContext>(),
)
.subcommand(
"run",
from_fn_async(run_action)
.with_display_serializable()
.with_custom_display_fn(|args, res| display_action_result(args.params, res))
.with_call_remote::<ContainerCliContext>(),
)
.subcommand("create-task", from_fn_async(create_task).no_cli())
.subcommand(
"clear-tasks",
from_fn_async(clear_tasks)
.no_display()
.with_call_remote::<ContainerCliContext>(),
)
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct ExportActionParams {
id: ActionId,
metadata: ActionMetadata,
}
pub async fn export_action(
context: EffectContext,
ExportActionParams { id, metadata }: ExportActionParams,
) -> Result<(), Error> {
let context = context.deref()?;
let package_id = context.seed.id.clone();
context
.seed
.ctx
.db
.mutate(|db| {
let model = db
.as_public_mut()
.as_package_data_mut()
.as_idx_mut(&package_id)
.or_not_found(&package_id)?
.as_actions_mut();
let mut value = model.de()?;
value.insert(id, metadata);
model.ser(&value)
})
.await
.result?;
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, TS, Parser)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct ClearActionsParams {
#[arg(long)]
pub except: Vec<ActionId>,
}
async fn clear_actions(
context: EffectContext,
ClearActionsParams { except }: ClearActionsParams,
) -> Result<(), Error> {
let except: BTreeSet<_> = except.into_iter().collect();
let context = context.deref()?;
let package_id = context.seed.id.clone();
context
.seed
.ctx
.db
.mutate(|db| {
db.as_public_mut()
.as_package_data_mut()
.as_idx_mut(&package_id)
.or_not_found(&package_id)?
.as_actions_mut()
.mutate(|a| Ok(a.retain(|e, _| except.contains(e))))
})
.await
.result?;
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, TS, Parser)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct GetActionInputParams {
#[serde(default)]
#[ts(skip)]
#[arg(skip)]
procedure_id: Guid,
#[ts(optional)]
package_id: Option<PackageId>,
action_id: ActionId,
}
async fn get_action_input(
context: EffectContext,
GetActionInputParams {
procedure_id,
package_id,
action_id,
}: GetActionInputParams,
) -> Result<Option<ActionInput>, Error> {
let context = context.deref()?;
if let Some(package_id) = package_id {
context
.seed
.ctx
.services
.get(&package_id)
.await
.as_ref()
.or_not_found(&package_id)?
.get_action_input(procedure_id, action_id)
.await
} else {
context.get_action_input(procedure_id, action_id).await
}
}
#[derive(Debug, Clone, Serialize, Deserialize, TS, Parser)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct RunActionParams {
#[serde(default)]
#[ts(skip)]
#[arg(skip)]
procedure_id: Guid,
#[ts(optional)]
package_id: Option<PackageId>,
action_id: ActionId,
#[ts(type = "any")]
input: Value,
}
async fn run_action(
context: EffectContext,
RunActionParams {
procedure_id,
package_id,
action_id,
input,
}: RunActionParams,
) -> Result<Option<ActionResult>, Error> {
let context = context.deref()?;
let package_id = package_id.as_ref().unwrap_or(&context.seed.id);
if package_id != &context.seed.id {
return Err(Error::new(
eyre!("calling actions on other packages is unsupported at this time"),
ErrorKind::InvalidRequest,
));
context
.seed
.ctx
.services
.get(&package_id)
.await
.as_ref()
.or_not_found(&package_id)?
.run_action(procedure_id, action_id, input)
.await
} else {
context.run_action(procedure_id, action_id, input).await
}
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct CreateTaskParams {
#[serde(default)]
#[ts(skip)]
procedure_id: Guid,
replay_id: ReplayId,
#[serde(flatten)]
task: Task,
}
async fn create_task(
context: EffectContext,
CreateTaskParams {
procedure_id,
replay_id,
task,
}: CreateTaskParams,
) -> Result<(), Error> {
let context = context.deref()?;
let src_id = &context.seed.id;
let active = match &task.when {
Some(TaskTrigger { once, condition }) => match condition {
TaskCondition::InputNotMatches => {
let Some(input) = task.input.as_ref() else {
return Err(Error::new(
eyre!("input-not-matches trigger requires input to be specified"),
ErrorKind::InvalidRequest,
));
};
if let Some(service) = context
.seed
.ctx
.services
.get(&task.package_id)
.await
.as_ref()
{
let Some(prev) = service
.get_action_input(procedure_id.clone(), task.action_id.clone())
.await?
else {
return Err(Error::new(
eyre!(
"action {} of {} has no input",
task.action_id,
task.package_id
),
ErrorKind::InvalidRequest,
));
};
if input.matches(prev.value.as_ref()) {
if *once {
return Ok(());
} else {
false
}
} else {
true
}
} else {
true // update when service is installed
}
}
},
None => true,
};
context
.seed
.ctx
.db
.mutate(|db| {
let pde = db
.as_public_mut()
.as_package_data_mut()
.as_idx_mut(src_id)
.or_not_found(src_id)?;
if active && task.severity == TaskSeverity::Critical {
pde.as_status_info_mut().stop()?;
}
pde.as_tasks_mut()
.insert(&replay_id, &TaskEntry { active, task })
})
.await
.result?;
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, TS, Parser)]
#[ts(type = "{ only: string[] } | { except: string[] }")]
#[ts(export)]
pub struct ClearTasksParams {
#[arg(long, conflicts_with = "except")]
pub only: Option<Vec<ReplayId>>,
#[arg(long, conflicts_with = "only")]
pub except: Option<Vec<ReplayId>>,
}
async fn clear_tasks(
context: EffectContext,
ClearTasksParams { only, except }: ClearTasksParams,
) -> Result<(), Error> {
let context = context.deref()?;
let package_id = context.seed.id.clone();
let only = only.map(|only| only.into_iter().collect::<BTreeSet<_>>());
let except = except.map(|except| except.into_iter().collect::<BTreeSet<_>>());
context
.seed
.ctx
.db
.mutate(|db| {
db.as_public_mut()
.as_package_data_mut()
.as_idx_mut(&package_id)
.or_not_found(&package_id)?
.as_tasks_mut()
.mutate(|a| {
Ok(a.retain(|e, _| {
only.as_ref().map_or(true, |only| !only.contains(e))
&& except.as_ref().map_or(true, |except| except.contains(e))
}))
})
})
.await
.result?;
Ok(())
}

View File

@@ -0,0 +1,319 @@
use std::cmp::min;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Arc, Mutex, Weak};
use std::time::{Duration, SystemTime};
use clap::Parser;
use futures::future::join_all;
use imbl::{Vector, vector};
use imbl_value::InternedString;
use serde::{Deserialize, Serialize};
use tracing::warn;
use ts_rs::TS;
use crate::net::ssl::FullchainCertData;
use crate::prelude::*;
use crate::service::effects::context::EffectContext;
use crate::service::effects::net::ssl::Algorithm;
use crate::service::rpc::{CallbackHandle, CallbackId};
use crate::service::{Service, ServiceActorSeed};
use crate::util::collections::EqMap;
use crate::util::future::NonDetachingJoinHandle;
use crate::{HostId, PackageId, ServiceInterfaceId};
#[derive(Default)]
pub struct ServiceCallbacks(Mutex<ServiceCallbackMap>);
#[derive(Default)]
struct ServiceCallbackMap {
get_service_interface: BTreeMap<(PackageId, ServiceInterfaceId), Vec<CallbackHandler>>,
list_service_interfaces: BTreeMap<PackageId, Vec<CallbackHandler>>,
get_system_smtp: Vec<CallbackHandler>,
get_host_info: BTreeMap<(PackageId, HostId), Vec<CallbackHandler>>,
get_ssl_certificate: EqMap<
(BTreeSet<InternedString>, FullchainCertData, Algorithm),
(NonDetachingJoinHandle<()>, Vec<CallbackHandler>),
>,
get_status: BTreeMap<PackageId, Vec<CallbackHandler>>,
get_container_ip: BTreeMap<PackageId, Vec<CallbackHandler>>,
}
impl ServiceCallbacks {
fn mutate<T>(&self, f: impl FnOnce(&mut ServiceCallbackMap) -> T) -> T {
let mut this = self.0.lock().unwrap();
f(&mut *this)
}
pub fn gc(&self) {
self.mutate(|this| {
this.get_service_interface.retain(|_, v| {
v.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0);
!v.is_empty()
});
this.list_service_interfaces.retain(|_, v| {
v.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0);
!v.is_empty()
});
this.get_system_smtp
.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0);
this.get_host_info.retain(|_, v| {
v.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0);
!v.is_empty()
});
this.get_ssl_certificate.retain(|_, (_, v)| {
v.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0);
!v.is_empty()
});
this.get_status.retain(|_, v| {
v.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0);
!v.is_empty()
});
})
}
pub(super) fn add_get_service_interface(
&self,
package_id: PackageId,
service_interface_id: ServiceInterfaceId,
handler: CallbackHandler,
) {
self.mutate(|this| {
this.get_service_interface
.entry((package_id, service_interface_id))
.or_default()
.push(handler);
})
}
#[must_use]
pub fn get_service_interface(
&self,
id: &(PackageId, ServiceInterfaceId),
) -> Option<CallbackHandlers> {
self.mutate(|this| {
Some(CallbackHandlers(
this.get_service_interface.remove(id).unwrap_or_default(),
))
.filter(|cb| !cb.0.is_empty())
})
}
pub(super) fn add_list_service_interfaces(
&self,
package_id: PackageId,
handler: CallbackHandler,
) {
self.mutate(|this| {
this.list_service_interfaces
.entry(package_id)
.or_default()
.push(handler);
})
}
#[must_use]
pub fn list_service_interfaces(&self, id: &PackageId) -> Option<CallbackHandlers> {
self.mutate(|this| {
Some(CallbackHandlers(
this.list_service_interfaces.remove(id).unwrap_or_default(),
))
.filter(|cb| !cb.0.is_empty())
})
}
pub(super) fn add_get_system_smtp(&self, handler: CallbackHandler) {
self.mutate(|this| {
this.get_system_smtp.push(handler);
})
}
#[must_use]
pub fn get_system_smtp(&self) -> Option<CallbackHandlers> {
self.mutate(|this| {
Some(CallbackHandlers(std::mem::take(&mut this.get_system_smtp)))
.filter(|cb| !cb.0.is_empty())
})
}
pub(super) fn add_get_host_info(
&self,
package_id: PackageId,
host_id: HostId,
handler: CallbackHandler,
) {
self.mutate(|this| {
this.get_host_info
.entry((package_id, host_id))
.or_default()
.push(handler);
})
}
#[must_use]
pub fn get_host_info(&self, id: &(PackageId, HostId)) -> Option<CallbackHandlers> {
self.mutate(|this| {
Some(CallbackHandlers(
this.get_host_info.remove(id).unwrap_or_default(),
))
.filter(|cb| !cb.0.is_empty())
})
}
pub(super) fn add_get_ssl_certificate(
&self,
ctx: EffectContext,
hostnames: BTreeSet<InternedString>,
cert: FullchainCertData,
algorithm: Algorithm,
handler: CallbackHandler,
) {
self.mutate(|this| {
this.get_ssl_certificate
.entry((hostnames.clone(), cert.clone(), algorithm))
.or_insert_with(|| {
(
tokio::spawn(async move {
if let Err(e) = async {
loop {
match cert
.expiration()
.ok()
.and_then(|e| e.duration_since(SystemTime::now()).ok())
{
Some(d) => {
tokio::time::sleep(min(Duration::from_secs(86400), d))
.await
}
_ => break,
}
}
let Ok(ctx) = ctx.deref() else {
return Ok(());
};
if let Some((_, callbacks)) =
ctx.seed.ctx.callbacks.mutate(|this| {
this.get_ssl_certificate
.remove(&(hostnames, cert, algorithm))
})
{
CallbackHandlers(callbacks).call(vector![]).await?;
}
Ok::<_, Error>(())
}
.await
{
tracing::error!(
"Error in callback handler for getSslCertificate: {e}"
);
tracing::debug!("{e:?}");
}
})
.into(),
Vec::new(),
)
})
.1
.push(handler);
})
}
pub(super) fn add_get_status(&self, package_id: PackageId, handler: CallbackHandler) {
self.mutate(|this| this.get_status.entry(package_id).or_default().push(handler))
}
#[must_use]
pub fn get_status(&self, package_id: &PackageId) -> Option<CallbackHandlers> {
self.mutate(|this| {
if let Some(watched) = this.get_status.remove(package_id) {
Some(CallbackHandlers(watched))
} else {
None
}
.filter(|cb| !cb.0.is_empty())
})
}
pub(super) fn add_get_container_ip(&self, package_id: PackageId, handler: CallbackHandler) {
self.mutate(|this| {
this.get_container_ip
.entry(package_id)
.or_default()
.push(handler)
})
}
#[must_use]
pub fn get_container_ip(&self, package_id: &PackageId) -> Option<CallbackHandlers> {
self.mutate(|this| {
this.get_container_ip
.remove(package_id)
.map(CallbackHandlers)
.filter(|cb| !cb.0.is_empty())
})
}
}
pub struct CallbackHandler {
handle: CallbackHandle,
seed: Weak<ServiceActorSeed>,
}
impl CallbackHandler {
pub fn new(service: &Service, handle: CallbackHandle) -> Self {
Self {
handle,
seed: Arc::downgrade(&service.seed),
}
}
pub async fn call(mut self, args: Vector<Value>) -> Result<(), Error> {
if let Some(seed) = self.seed.upgrade() {
seed.persistent_container
.callback(self.handle.take(), args)
.await?;
}
Ok(())
}
}
impl Drop for CallbackHandler {
fn drop(&mut self) {
if self.handle.is_active() {
warn!("Callback handler dropped while still active!");
}
}
}
pub struct CallbackHandlers(Vec<CallbackHandler>);
impl CallbackHandlers {
pub async fn call(self, args: Vector<Value>) -> Result<(), Error> {
let mut err = ErrorCollection::new();
for res in join_all(self.0.into_iter().map(|cb| cb.call(args.clone()))).await {
err.handle(res);
}
err.into_result()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, TS, Parser)]
#[ts(type = "{ only: number[] } | { except: number[] }")]
#[ts(export)]
pub struct ClearCallbacksParams {
#[arg(long, conflicts_with = "except")]
pub only: Option<Vec<CallbackId>>,
#[arg(long, conflicts_with = "only")]
pub except: Option<Vec<CallbackId>>,
}
pub(super) fn clear_callbacks(
context: EffectContext,
ClearCallbacksParams { only, except }: ClearCallbacksParams,
) -> Result<(), Error> {
let context = context.deref()?;
let only = only.map(|only| only.into_iter().collect::<BTreeSet<_>>());
let except = except.map(|except| except.into_iter().collect::<BTreeSet<_>>());
context.seed.persistent_container.state.send_modify(|s| {
s.callbacks.retain(|cb| {
only.as_ref().map_or(true, |only| !only.contains(cb))
&& except.as_ref().map_or(true, |except| except.contains(cb))
})
});
context.seed.ctx.callbacks.gc();
Ok(())
}

View File

@@ -0,0 +1,27 @@
use std::sync::{Arc, Weak};
use rpc_toolkit::Context;
use crate::prelude::*;
use crate::service::Service;
#[derive(Clone)]
pub(in crate::service) struct EffectContext(Weak<Service>);
impl EffectContext {
pub fn new(service: Weak<Service>) -> Self {
Self(service)
}
}
impl Context for EffectContext {}
impl EffectContext {
pub(super) fn deref(&self) -> Result<Arc<Service>, Error> {
if let Some(seed) = Weak::upgrade(&self.0) {
Ok(seed)
} else {
Err(Error::new(
eyre!("Service has already been destroyed"),
ErrorKind::InvalidRequest,
))
}
}
}

View File

@@ -0,0 +1,173 @@
use std::str::FromStr;
use chrono::Utc;
use clap::builder::ValueParserFactory;
use crate::PackageId;
use crate::service::RebuildParams;
use crate::service::effects::prelude::*;
use crate::service::rpc::CallbackId;
use crate::status::{DesiredStatus, StatusInfo};
use crate::util::FromStrParser;
pub async fn rebuild(context: EffectContext) -> Result<(), Error> {
let seed = context.deref()?.seed.clone();
let ctx = seed.ctx.clone();
let id = seed.id.clone();
drop(seed);
tokio::spawn(async move {
super::super::rebuild(ctx, RebuildParams { id })
.await
.log_err()
});
Ok(())
}
pub async fn restart(context: EffectContext) -> Result<(), Error> {
let context = context.deref()?;
let id = &context.seed.id;
context
.seed
.ctx
.db
.mutate(|db| {
db.as_public_mut()
.as_package_data_mut()
.as_idx_mut(id)
.or_not_found(id)?
.as_status_info_mut()
.as_desired_mut()
.map_mutate(|s| Ok(s.restart()))
})
.await
.result?;
Ok(())
}
pub async fn shutdown(context: EffectContext) -> Result<(), Error> {
let context = context.deref()?;
let id = &context.seed.id;
context
.seed
.ctx
.db
.mutate(|db| {
db.as_public_mut()
.as_package_data_mut()
.as_idx_mut(id)
.or_not_found(id)?
.as_status_info_mut()
.stop()
})
.await
.result?;
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, TS, Parser)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct GetStatusParams {
#[ts(optional)]
pub package_id: Option<PackageId>,
#[ts(optional)]
#[arg(skip)]
pub callback: Option<CallbackId>,
}
pub async fn get_status(
context: EffectContext,
GetStatusParams {
package_id,
callback,
}: GetStatusParams,
) -> Result<StatusInfo, Error> {
let context = context.deref()?;
let id = package_id.unwrap_or_else(|| context.seed.id.clone());
let db = context.seed.ctx.db.peek().await;
if let Some(callback) = callback {
let callback = callback.register(&context.seed.persistent_container);
context.seed.ctx.callbacks.add_get_status(
id.clone(),
super::callbacks::CallbackHandler::new(&context, callback),
);
}
let status = db
.as_public()
.as_package_data()
.as_idx(&id)
.or_not_found(&id)?
.as_status_info()
.de()?;
Ok(status)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub enum SetMainStatusStatus {
Running,
Stopped,
}
impl FromStr for SetMainStatusStatus {
type Err = color_eyre::eyre::Report;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"running" => Ok(Self::Running),
"stopped" => Ok(Self::Stopped),
_ => Err(eyre!("unknown status {s}")),
}
}
}
impl ValueParserFactory for SetMainStatusStatus {
type Parser = FromStrParser<Self>;
fn value_parser() -> Self::Parser {
FromStrParser::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct SetMainStatus {
status: SetMainStatusStatus,
}
pub async fn set_main_status(
context: EffectContext,
SetMainStatus { status }: SetMainStatus,
) -> Result<(), Error> {
let context = context.deref()?;
let id = &context.seed.id;
context
.seed
.ctx
.db
.mutate(|db| {
let s = db
.as_public_mut()
.as_package_data_mut()
.as_idx_mut(id)
.or_not_found(id)?
.as_status_info_mut();
let prev = s.as_started_mut().replace(&match status {
SetMainStatusStatus::Running => Some(Utc::now()),
SetMainStatusStatus::Stopped => None,
})?;
if prev.is_none() && status == SetMainStatusStatus::Running {
s.as_desired_mut().map_mutate(|s| {
Ok(match s {
DesiredStatus::Restarting => DesiredStatus::Running,
x => x,
})
})?;
}
Ok(())
})
.await
.result?;
Ok(())
}

View File

@@ -0,0 +1,369 @@
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::str::FromStr;
use clap::builder::ValueParserFactory;
use exver::VersionRange;
use imbl_value::InternedString;
use crate::db::model::package::{
CurrentDependencies, CurrentDependencyInfo, CurrentDependencyKind, ManifestPreference,
TaskEntry,
};
use crate::disk::mount::filesystem::bind::{Bind, FileType};
use crate::disk::mount::filesystem::idmapped::{IdMap, IdMapped};
use crate::disk::mount::filesystem::{FileSystem, MountType};
use crate::disk::mount::util::{is_mountpoint, unmount};
use crate::service::effects::prelude::*;
use crate::status::health_check::NamedHealthCheckResult;
use crate::util::{FromStrParser, VersionString};
use crate::volume::data_dir;
use crate::{DATA_DIR, HealthCheckId, PackageId, ReplayId, VolumeId};
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct MountTarget {
package_id: PackageId,
volume_id: VolumeId,
subpath: Option<PathBuf>,
readonly: bool,
#[serde(skip_deserializing)]
#[ts(skip)]
filetype: FileType,
#[serde(default)]
idmap: Vec<IdMap>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct MountParams {
location: PathBuf,
target: MountTarget,
}
pub async fn mount(
context: EffectContext,
MountParams {
location,
target:
MountTarget {
package_id,
volume_id,
subpath,
readonly,
filetype,
idmap,
},
}: MountParams,
) -> Result<(), Error> {
let context = context.deref()?;
let subpath = subpath.unwrap_or_default();
let subpath = subpath.strip_prefix("/").unwrap_or(&subpath);
let source = data_dir(DATA_DIR, &package_id, &volume_id).join(subpath);
let location = location.strip_prefix("/").unwrap_or(&location);
let mountpoint = context
.seed
.persistent_container
.lxc_container
.get()
.or_not_found("lxc container")?
.rootfs_dir()
.join(location);
if is_mountpoint(&mountpoint).await? {
unmount(&mountpoint, true).await?;
}
IdMapped::new(
Bind::new(source).with_type(filetype),
IdMap::stack(
vec![IdMap {
from_id: 0,
to_id: 100000,
range: 65536,
}],
idmap,
),
)
.mount(
mountpoint,
if readonly {
MountType::ReadOnly
} else {
MountType::ReadWrite
},
)
.await?;
Ok(())
}
pub async fn get_installed_packages(context: EffectContext) -> Result<BTreeSet<PackageId>, Error> {
context
.deref()?
.seed
.ctx
.db
.peek()
.await
.into_public()
.into_package_data()
.keys()
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase", tag = "kind")]
#[serde(rename_all_fields = "camelCase")]
#[ts(export)]
pub enum DependencyRequirement {
Running {
id: PackageId,
health_checks: BTreeSet<HealthCheckId>,
#[ts(type = "string")]
version_range: VersionRange,
},
Exists {
id: PackageId,
#[ts(type = "string")]
version_range: VersionRange,
},
}
// filebrowser:exists,bitcoind:running:foo+bar+baz
impl FromStr for DependencyRequirement {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.split_once(':') {
Some((id, "e")) | Some((id, "exists")) => Ok(Self::Exists {
id: id.parse()?,
version_range: "*".parse()?, // TODO
}),
Some((id, rest)) => {
let health_checks = match rest.split_once(':') {
Some(("r", rest)) | Some(("running", rest)) => rest
.split('+')
.map(|id| id.parse().map_err(Error::from))
.collect(),
Some((kind, _)) => Err(Error::new(
eyre!("unknown dependency kind {kind}"),
ErrorKind::InvalidRequest,
)),
None => match rest {
"r" | "running" => Ok(BTreeSet::new()),
kind => Err(Error::new(
eyre!("unknown dependency kind {kind}"),
ErrorKind::InvalidRequest,
)),
},
}?;
Ok(Self::Running {
id: id.parse()?,
health_checks,
version_range: "*".parse()?, // TODO
})
}
None => Ok(Self::Running {
id: s.parse()?,
health_checks: BTreeSet::new(),
version_range: "*".parse()?, // TODO
}),
}
}
}
impl ValueParserFactory for DependencyRequirement {
type Parser = FromStrParser<Self>;
fn value_parser() -> Self::Parser {
FromStrParser::new()
}
}
#[derive(Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
#[command(rename_all = "camelCase")]
#[ts(export)]
pub struct SetDependenciesParams {
dependencies: Vec<DependencyRequirement>,
}
pub async fn set_dependencies(
context: EffectContext,
SetDependenciesParams { dependencies }: SetDependenciesParams,
) -> Result<(), Error> {
let context = context.deref()?;
let id = &context.seed.id;
let mut deps = BTreeMap::new();
for dependency in dependencies {
let (dep_id, kind, version_range) = match dependency {
DependencyRequirement::Exists { id, version_range } => {
(id, CurrentDependencyKind::Exists, version_range)
}
DependencyRequirement::Running {
id,
health_checks,
version_range,
} => (
id,
CurrentDependencyKind::Running { health_checks },
version_range,
),
};
let info = CurrentDependencyInfo {
title: context
.seed
.persistent_container
.s9pk
.dependency_metadata(&dep_id)
.await?
.map(|m| m.title),
icon: context
.seed
.persistent_container
.s9pk
.dependency_icon_data_url(&dep_id)
.await?,
kind,
version_range,
};
deps.insert(dep_id, info);
}
context
.seed
.ctx
.db
.mutate(|db| {
db.as_public_mut()
.as_package_data_mut()
.as_idx_mut(id)
.or_not_found(id)?
.as_current_dependencies_mut()
.ser(&CurrentDependencies(deps))
})
.await
.result
}
pub async fn get_dependencies(context: EffectContext) -> Result<Vec<DependencyRequirement>, Error> {
let context = context.deref()?;
let id = &context.seed.id;
let db = context.seed.ctx.db.peek().await;
let data = db
.as_public()
.as_package_data()
.as_idx(id)
.or_not_found(id)?
.as_current_dependencies()
.de()?;
Ok(data
.0
.into_iter()
.map(|(id, current_dependency_info)| {
let CurrentDependencyInfo {
version_range,
kind,
..
} = current_dependency_info;
match kind {
CurrentDependencyKind::Exists => {
DependencyRequirement::Exists { id, version_range }
}
CurrentDependencyKind::Running { health_checks } => {
DependencyRequirement::Running {
id,
health_checks,
version_range,
}
}
}
})
.collect())
}
#[derive(Debug, Clone, Serialize, Deserialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct CheckDependenciesParam {
#[ts(optional)]
package_ids: Option<Vec<PackageId>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct CheckDependenciesResult {
package_id: PackageId,
#[ts(type = "string | null")]
title: Option<InternedString>,
installed_version: Option<VersionString>,
satisfies: BTreeSet<VersionString>,
is_running: bool,
tasks: BTreeMap<ReplayId, TaskEntry>,
health_checks: BTreeMap<HealthCheckId, NamedHealthCheckResult>,
}
pub async fn check_dependencies(
context: EffectContext,
CheckDependenciesParam { package_ids }: CheckDependenciesParam,
) -> Result<Vec<CheckDependenciesResult>, Error> {
let context = context.deref()?;
let db = context.seed.ctx.db.peek().await;
let pde = db
.as_public()
.as_package_data()
.as_idx(&context.seed.id)
.or_not_found(&context.seed.id)?;
let current_dependencies = pde.as_current_dependencies().de()?;
let tasks = pde.as_tasks().de()?;
let package_dependency_info: Vec<_> = package_ids
.unwrap_or_else(|| current_dependencies.0.keys().cloned().collect())
.into_iter()
.filter_map(|x| {
let info = current_dependencies.0.get(&x)?;
Some((x, info))
})
.collect();
let mut results = Vec::with_capacity(package_dependency_info.len());
for (package_id, dependency_info) in package_dependency_info {
let title = dependency_info.title.clone();
let Some(package) = db.as_public().as_package_data().as_idx(&package_id) else {
let tasks = tasks
.iter()
.filter(|(_, v)| v.task.package_id == package_id)
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
results.push(CheckDependenciesResult {
package_id,
title,
installed_version: None,
satisfies: BTreeSet::new(),
is_running: false,
tasks,
health_checks: Default::default(),
});
continue;
};
let manifest = package.as_state_info().as_manifest(ManifestPreference::New);
let installed_version = manifest.as_version().de()?.into_version();
let satisfies = manifest.as_satisfies().de()?;
let installed_version = Some(installed_version.clone().into());
let is_running = package
.as_status_info()
.as_started()
.transpose_ref()
.is_some();
let health_checks = package.as_status_info().as_health().de()?;
let tasks = tasks
.iter()
.filter(|(_, v)| v.task.package_id == package_id)
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
results.push(CheckDependenciesResult {
package_id,
title,
installed_version,
satisfies,
is_running,
tasks,
health_checks,
});
}
Ok(results)
}

View File

@@ -0,0 +1,36 @@
use crate::HealthCheckId;
use crate::service::effects::prelude::*;
use crate::status::health_check::NamedHealthCheckResult;
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct SetHealth {
id: HealthCheckId,
#[serde(flatten)]
result: NamedHealthCheckResult,
}
pub async fn set_health(
context: EffectContext,
SetHealth { id, result }: SetHealth,
) -> Result<(), Error> {
let context = context.deref()?;
let package_id = &context.seed.id;
context
.seed
.ctx
.db
.mutate(move |db| {
db.as_public_mut()
.as_package_data_mut()
.as_idx_mut(package_id)
.or_not_found(package_id)?
.as_status_info_mut()
.as_health_mut()
.insert(&id, &result)
})
.await
.result?;
Ok(())
}

View File

@@ -0,0 +1,190 @@
use std::net::Ipv4Addr;
use rpc_toolkit::{Context, HandlerExt, ParentHandler, from_fn, from_fn_async, from_fn_blocking};
use crate::prelude::*;
use crate::service::cli::ContainerCliContext;
use crate::service::effects::context::EffectContext;
use crate::{HOST_IP, echo};
mod action;
pub mod callbacks;
pub mod context;
mod control;
mod dependency;
mod health;
mod net;
mod prelude;
mod subcontainer;
mod system;
mod version;
pub fn handler<C: Context>() -> ParentHandler<C> {
ParentHandler::new()
.subcommand("git-info", from_fn(|_: C| crate::version::git_info()))
.subcommand(
"echo",
from_fn(echo::<EffectContext>).with_call_remote::<ContainerCliContext>(),
)
// action
.subcommand("action", action::action_api::<C>())
// callbacks
.subcommand(
"clear-callbacks",
from_fn(callbacks::clear_callbacks).no_cli(),
)
// control
.subcommand(
"rebuild",
from_fn_async(control::rebuild)
.no_display()
.with_call_remote::<ContainerCliContext>(),
)
.subcommand(
"restart",
from_fn_async(control::restart)
.no_display()
.with_call_remote::<ContainerCliContext>(),
)
.subcommand(
"shutdown",
from_fn_async(control::shutdown)
.no_display()
.with_call_remote::<ContainerCliContext>(),
)
.subcommand(
"set-main-status",
from_fn_async(control::set_main_status)
.no_display()
.with_call_remote::<ContainerCliContext>(),
)
.subcommand(
"get-status",
from_fn_async(control::get_status)
.no_display()
.with_call_remote::<ContainerCliContext>(),
)
// dependency
.subcommand(
"set-dependencies",
from_fn_async(dependency::set_dependencies)
.no_display()
.with_call_remote::<ContainerCliContext>(),
)
.subcommand(
"get-dependencies",
from_fn_async(dependency::get_dependencies)
.no_display()
.with_call_remote::<ContainerCliContext>(),
)
.subcommand(
"check-dependencies",
from_fn_async(dependency::check_dependencies)
.no_display()
.with_call_remote::<ContainerCliContext>(),
)
.subcommand("mount", from_fn_async(dependency::mount).no_cli())
.subcommand(
"get-installed-packages",
from_fn_async(dependency::get_installed_packages).no_cli(),
)
// health
.subcommand("set-health", from_fn_async(health::set_health).no_cli())
// subcontainer
.subcommand(
"subcontainer",
ParentHandler::<C>::new()
.subcommand(
"launch",
from_fn_blocking(subcontainer::launch).no_display(),
)
.subcommand(
"launch-init",
from_fn_blocking(subcontainer::launch_init).no_display(),
)
.subcommand("exec", from_fn_blocking(subcontainer::exec).no_display())
.subcommand(
"exec-command",
from_fn_blocking(subcontainer::exec_command).no_display(),
)
.subcommand(
"create-fs",
from_fn_async(subcontainer::create_subcontainer_fs)
.with_custom_display_fn(|_, (path, _)| Ok(println!("{}", path.display())))
.with_call_remote::<ContainerCliContext>(),
)
.subcommand(
"destroy-fs",
from_fn_async(subcontainer::destroy_subcontainer_fs)
.no_display()
.with_call_remote::<ContainerCliContext>(),
),
)
// net
.subcommand("bind", from_fn_async(net::bind::bind).no_cli())
.subcommand(
"get-service-port-forward",
from_fn_async(net::bind::get_service_port_forward).no_cli(),
)
.subcommand(
"clear-bindings",
from_fn_async(net::bind::clear_bindings).no_cli(),
)
.subcommand(
"get-host-info",
from_fn_async(net::host::get_host_info).no_cli(),
)
.subcommand(
"get-container-ip",
from_fn_async(net::info::get_container_ip).no_cli(),
)
.subcommand(
"get-os-ip",
from_fn(|_: C| Ok::<_, Error>(Ipv4Addr::from(HOST_IP))),
)
.subcommand(
"export-service-interface",
from_fn_async(net::interface::export_service_interface).no_cli(),
)
.subcommand(
"get-service-interface",
from_fn_async(net::interface::get_service_interface).no_cli(),
)
.subcommand(
"list-service-interfaces",
from_fn_async(net::interface::list_service_interfaces).no_cli(),
)
.subcommand(
"clear-service-interfaces",
from_fn_async(net::interface::clear_service_interfaces).no_cli(),
)
.subcommand(
"get-ssl-certificate",
from_fn_async(net::ssl::get_ssl_certificate).no_cli(),
)
.subcommand("get-ssl-key", from_fn_async(net::ssl::get_ssl_key).no_cli())
.subcommand(
"set-data-version",
from_fn_async(version::set_data_version)
.no_display()
.with_call_remote::<ContainerCliContext>(),
)
.subcommand(
"get-data-version",
from_fn_async(version::get_data_version)
.with_custom_display_fn(|_, v| {
if let Some(v) = v {
println!("{v}")
} else {
println!("N/A")
}
Ok(())
})
.with_call_remote::<ContainerCliContext>(),
)
// system
.subcommand(
"get-system-smtp",
from_fn_async(system::get_system_smtp).no_cli(),
)
}

View File

@@ -0,0 +1,93 @@
use crate::net::host::binding::{BindId, BindOptions, NetInfo};
use crate::service::effects::prelude::*;
use crate::{HostId, PackageId};
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct BindParams {
id: HostId,
internal_port: u16,
#[serde(flatten)]
options: BindOptions,
}
pub async fn bind(
context: EffectContext,
BindParams {
id,
internal_port,
options,
}: BindParams,
) -> Result<(), Error> {
let context = context.deref()?;
context
.seed
.persistent_container
.net_service
.bind(id, internal_port, options)
.await
}
#[derive(Debug, Clone, Serialize, Deserialize, TS, Parser)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct ClearBindingsParams {
#[serde(default)]
pub except: Vec<BindId>,
}
pub async fn clear_bindings(
context: EffectContext,
ClearBindingsParams { except }: ClearBindingsParams,
) -> Result<(), Error> {
let context = context.deref()?;
context
.seed
.persistent_container
.net_service
.clear_bindings(except.into_iter().collect())
.await?;
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct GetServicePortForwardParams {
#[ts(optional)]
package_id: Option<PackageId>,
host_id: HostId,
internal_port: u16,
}
pub async fn get_service_port_forward(
context: EffectContext,
GetServicePortForwardParams {
package_id,
host_id,
internal_port,
}: GetServicePortForwardParams,
) -> Result<NetInfo, Error> {
let context = context.deref()?;
let package_id = package_id.unwrap_or_else(|| context.seed.id.clone());
Ok(context
.seed
.ctx
.db
.peek()
.await
.as_public()
.as_package_data()
.as_idx(&package_id)
.or_not_found(&package_id)?
.as_hosts()
.as_idx(&host_id)
.or_not_found(&host_id)?
.as_bindings()
.de()?
.get(&internal_port)
.or_not_found(lazy_format!("binding for port {internal_port}"))?
.net
.clone())
}

View File

@@ -0,0 +1,47 @@
use crate::net::host::Host;
use crate::service::effects::callbacks::CallbackHandler;
use crate::service::effects::prelude::*;
use crate::service::rpc::CallbackId;
use crate::{HostId, PackageId};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct GetHostInfoParams {
host_id: HostId,
#[ts(optional)]
package_id: Option<PackageId>,
#[ts(optional)]
callback: Option<CallbackId>,
}
pub async fn get_host_info(
context: EffectContext,
GetHostInfoParams {
host_id,
package_id,
callback,
}: GetHostInfoParams,
) -> Result<Option<Host>, Error> {
let context = context.deref()?;
let db = context.seed.ctx.db.peek().await;
let package_id = package_id.unwrap_or_else(|| context.seed.id.clone());
if let Some(callback) = callback {
let callback = callback.register(&context.seed.persistent_container);
context.seed.ctx.callbacks.add_get_host_info(
package_id.clone(),
host_id.clone(),
CallbackHandler::new(&context, callback),
);
}
let res = db
.as_public()
.as_package_data()
.as_idx(&package_id)
.and_then(|m| m.as_hosts().as_idx(&host_id))
.map(|m| m.de())
.transpose()?;
Ok(res)
}

View File

@@ -0,0 +1,53 @@
use std::net::Ipv4Addr;
use crate::PackageId;
use crate::service::effects::callbacks::CallbackHandler;
use crate::service::effects::prelude::*;
use crate::service::rpc::CallbackId;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct GetContainerIpParams {
#[ts(optional)]
package_id: Option<PackageId>,
#[ts(optional)]
callback: Option<CallbackId>,
}
pub async fn get_container_ip(
context: EffectContext,
GetContainerIpParams {
package_id,
callback,
}: GetContainerIpParams,
) -> Result<Option<Ipv4Addr>, Error> {
let context = context.deref()?;
if let Some(package_id) = package_id.filter(|id| id != &context.seed.id) {
if let Some(callback) = callback {
// ip is static for the lifetime of the container, so callback unnecessary for self
let callback = callback.register(&context.seed.persistent_container);
context
.seed
.ctx
.callbacks
.add_get_container_ip(package_id.clone(), CallbackHandler::new(&context, callback));
}
let Some(svc) = &*context.seed.ctx.services.get(&package_id).await else {
return Ok(None);
};
let Some(lxc) = svc.seed.persistent_container.lxc_container.get() else {
return Ok(None);
};
let res = lxc.ip().await?;
Ok(Some(res))
} else {
let Some(lxc) = context.seed.persistent_container.lxc_container.get() else {
return Ok(None);
};
lxc.ip().await.map(Some)
}
}

View File

@@ -0,0 +1,231 @@
use std::collections::BTreeMap;
use imbl::vector;
use crate::net::service_interface::{AddressInfo, ServiceInterface, ServiceInterfaceType};
use crate::service::effects::callbacks::CallbackHandler;
use crate::service::effects::prelude::*;
use crate::service::rpc::CallbackId;
use crate::{PackageId, ServiceInterfaceId};
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct ExportServiceInterfaceParams {
id: ServiceInterfaceId,
name: String,
description: String,
masked: bool,
address_info: AddressInfo,
r#type: ServiceInterfaceType,
}
pub async fn export_service_interface(
context: EffectContext,
ExportServiceInterfaceParams {
id,
name,
description,
masked,
address_info,
r#type,
}: ExportServiceInterfaceParams,
) -> Result<(), Error> {
let context = context.deref()?;
let package_id = context.seed.id.clone();
let service_interface = ServiceInterface {
id: id.clone(),
name,
description,
masked,
address_info,
interface_type: r#type,
};
let res = context
.seed
.ctx
.db
.mutate(|db| {
let ifaces = db
.as_public_mut()
.as_package_data_mut()
.as_idx_mut(&package_id)
.or_not_found(&package_id)?
.as_service_interfaces_mut();
ifaces.insert(&id, &service_interface)?;
Ok(())
})
.await;
res.result?;
if res.revision.is_some() {
if let Some(callbacks) = context
.seed
.ctx
.callbacks
.get_service_interface(&(package_id.clone(), id))
{
callbacks.call(vector![]).await?;
}
if let Some(callbacks) = context
.seed
.ctx
.callbacks
.list_service_interfaces(&package_id)
{
callbacks.call(vector![]).await?;
}
}
Ok(())
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct GetServiceInterfaceParams {
#[ts(optional)]
package_id: Option<PackageId>,
service_interface_id: ServiceInterfaceId,
#[ts(optional)]
callback: Option<CallbackId>,
}
pub async fn get_service_interface(
context: EffectContext,
GetServiceInterfaceParams {
package_id,
service_interface_id,
callback,
}: GetServiceInterfaceParams,
) -> Result<Option<ServiceInterface>, Error> {
let context = context.deref()?;
let package_id = package_id.unwrap_or_else(|| context.seed.id.clone());
let db = context.seed.ctx.db.peek().await;
if let Some(callback) = callback {
let callback = callback.register(&context.seed.persistent_container);
context.seed.ctx.callbacks.add_get_service_interface(
package_id.clone(),
service_interface_id.clone(),
CallbackHandler::new(&context, callback),
);
}
let interface = db
.as_public()
.as_package_data()
.as_idx(&package_id)
.and_then(|m| m.as_service_interfaces().as_idx(&service_interface_id))
.map(|m| m.de())
.transpose()?;
Ok(interface)
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct ListServiceInterfacesParams {
#[ts(optional)]
package_id: Option<PackageId>,
#[ts(optional)]
callback: Option<CallbackId>,
}
pub async fn list_service_interfaces(
context: EffectContext,
ListServiceInterfacesParams {
package_id,
callback,
}: ListServiceInterfacesParams,
) -> Result<BTreeMap<ServiceInterfaceId, ServiceInterface>, Error> {
let context = context.deref()?;
let package_id = package_id.unwrap_or_else(|| context.seed.id.clone());
if let Some(callback) = callback {
let callback = callback.register(&context.seed.persistent_container);
context.seed.ctx.callbacks.add_list_service_interfaces(
package_id.clone(),
CallbackHandler::new(&context, callback),
);
}
let res = context
.seed
.ctx
.db
.peek()
.await
.into_public()
.into_package_data()
.into_idx(&package_id)
.map(|m| m.into_service_interfaces().de())
.transpose()?
.unwrap_or_default();
Ok(res)
}
#[derive(Debug, Clone, Serialize, Deserialize, TS, Parser)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct ClearServiceInterfacesParams {
pub except: Vec<ServiceInterfaceId>,
}
pub async fn clear_service_interfaces(
context: EffectContext,
ClearServiceInterfacesParams { except }: ClearServiceInterfacesParams,
) -> Result<(), Error> {
let context = context.deref()?;
let package_id = context.seed.id.clone();
let res = context
.seed
.ctx
.db
.mutate(|db| {
let mut removed = Vec::new();
db.as_public_mut()
.as_package_data_mut()
.as_idx_mut(&package_id)
.or_not_found(&package_id)?
.as_service_interfaces_mut()
.mutate(|s| {
Ok(s.retain(|id, _| {
if except.contains(id) {
true
} else {
removed.push(id.clone());
false
}
}))
})?;
Ok(removed)
})
.await;
let removed = res.result?;
if res.revision.is_some() {
for id in removed {
if let Some(callbacks) = context
.seed
.ctx
.callbacks
.get_service_interface(&(package_id.clone(), id))
{
callbacks.call(vector![]).await?;
}
}
if let Some(callbacks) = context
.seed
.ctx
.callbacks
.list_service_interfaces(&package_id)
{
callbacks.call(vector![]).await?;
}
}
Ok(())
}

View File

@@ -0,0 +1,5 @@
pub mod bind;
pub mod host;
pub mod info;
pub mod interface;
pub mod ssl;

View File

@@ -0,0 +1,230 @@
use std::collections::BTreeSet;
use std::net::IpAddr;
use imbl_value::InternedString;
use ipnet::IpNet;
use itertools::Itertools;
use openssl::pkey::{PKey, Private};
use crate::HOST_IP;
use crate::service::effects::callbacks::CallbackHandler;
use crate::service::effects::prelude::*;
use crate::service::rpc::CallbackId;
use crate::util::serde::Pem;
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, TS, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub enum Algorithm {
Ecdsa,
Ed25519,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct GetSslCertificateParams {
#[ts(type = "string[]")]
hostnames: BTreeSet<InternedString>,
#[ts(optional)]
algorithm: Option<Algorithm>, //"ecdsa" | "ed25519"
#[ts(optional)]
callback: Option<CallbackId>,
}
pub async fn get_ssl_certificate(
ctx: EffectContext,
GetSslCertificateParams {
hostnames,
algorithm,
callback,
}: GetSslCertificateParams,
) -> Result<Vec<String>, Error> {
let context = ctx.deref()?;
let algorithm = algorithm.unwrap_or(Algorithm::Ecdsa);
let cert = context
.seed
.ctx
.db
.mutate(|db| {
let errfn = |h: &str| Error::new(eyre!("unknown hostname: {h}"), ErrorKind::NotFound);
let entries = db.as_public().as_package_data().as_entries()?;
let packages = entries.iter().map(|(k, _)| k).collect::<BTreeSet<_>>();
let allowed_hostnames = entries
.iter()
.map(|(_, m)| m.as_hosts().as_entries())
.flatten_ok()
.map_ok(|(_, m)| {
Ok(m.as_onions()
.de()?
.iter()
.map(InternedString::from_display)
.chain(m.as_public_domains().keys()?)
.chain(m.as_private_domains().de()?)
.chain(
m.as_hostname_info()
.de()?
.values()
.flatten()
.map(|h| h.to_san_hostname()),
)
.collect::<Vec<_>>())
})
.map(|a| a.and_then(|a| a))
.flatten_ok()
.try_collect::<_, BTreeSet<_>, _>()?;
for hostname in &hostnames {
if let Some(internal) = hostname
.strip_suffix(".embassy")
.or_else(|| hostname.strip_suffix(".startos"))
{
if !packages.contains(internal) {
return Err(errfn(&*hostname));
}
} else if let Ok(ip) = hostname.parse::<IpAddr>() {
if IpNet::new(HOST_IP.into(), 24)
.with_kind(ErrorKind::ParseNetAddress)?
.contains(&ip)
{
Ok(())
} else if db
.as_public()
.as_server_info()
.as_network()
.as_gateways()
.as_entries()?
.into_iter()
.flat_map(|(_, net)| net.as_ip_info().transpose_ref())
.flat_map(|net| net.as_deref().as_subnets().de().log_err())
.flatten()
.any(|s| s.addr() == ip)
{
Ok(())
} else {
Err(errfn(&*hostname))
}?;
} else {
if !allowed_hostnames.contains(hostname) {
return Err(errfn(&*hostname));
}
}
}
db.as_private_mut()
.as_key_store_mut()
.as_local_certs_mut()
.cert_for(&hostnames)
})
.await
.result?;
let fullchain = match algorithm {
Algorithm::Ecdsa => cert.fullchain_nistp256(),
Algorithm::Ed25519 => cert.fullchain_ed25519(),
};
let res = fullchain
.into_iter()
.map(|c| c.to_pem())
.map_ok(String::from_utf8)
.map(|a| Ok::<_, Error>(a??))
.try_collect()?;
if let Some(callback) = callback {
let callback = callback.register(&context.seed.persistent_container);
context.seed.ctx.callbacks.add_get_ssl_certificate(
ctx,
hostnames,
cert,
algorithm,
CallbackHandler::new(&context, callback),
);
}
Ok(res)
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct GetSslKeyParams {
#[ts(type = "string[]")]
hostnames: BTreeSet<InternedString>,
#[ts(optional)]
algorithm: Option<Algorithm>, //"ecdsa" | "ed25519"
}
pub async fn get_ssl_key(
context: EffectContext,
GetSslKeyParams {
hostnames,
algorithm,
}: GetSslKeyParams,
) -> Result<Pem<PKey<Private>>, Error> {
let context = context.deref()?;
let package_id = &context.seed.id;
let algorithm = algorithm.unwrap_or(Algorithm::Ecdsa);
let container_ip = if let Some(lxc) = context.seed.persistent_container.lxc_container.get() {
Some(lxc.ip().await?)
} else {
None
};
let cert = context
.seed
.ctx
.db
.mutate(|db| {
let errfn = |h: &str| Error::new(eyre!("unknown hostname: {h}"), ErrorKind::NotFound);
let mut allowed_hostnames = db
.as_public()
.as_package_data()
.as_idx(package_id)
.into_iter()
.map(|m| m.as_hosts().as_entries())
.flatten_ok()
.map_ok(|(_, m)| {
Ok(m.as_onions()
.de()?
.iter()
.map(InternedString::from_display)
.chain(m.as_public_domains().keys()?)
.chain(m.as_private_domains().de()?)
.chain(
m.as_hostname_info()
.de()?
.values()
.flatten()
.map(|h| h.to_san_hostname()),
)
.collect::<Vec<_>>())
})
.map(|a| a.and_then(|a| a))
.flatten_ok()
.try_collect::<_, BTreeSet<_>, _>()?;
allowed_hostnames.extend(container_ip.as_ref().map(InternedString::from_display));
for hostname in &hostnames {
if let Some(internal) = hostname
.strip_suffix(".embassy")
.or_else(|| hostname.strip_suffix(".startos"))
{
if internal != &**package_id {
return Err(errfn(&*hostname));
}
} else {
if !allowed_hostnames.contains(hostname) {
return Err(errfn(&*hostname));
}
}
}
db.as_private_mut()
.as_key_store_mut()
.as_local_certs_mut()
.cert_for(&hostnames)
})
.await
.result?;
let key = match algorithm {
Algorithm::Ecdsa => cert.leaf.keys.nistp256,
Algorithm::Ed25519 => cert.leaf.keys.ed25519,
};
Ok(Pem(key))
}

View File

@@ -0,0 +1,16 @@
pub use clap::Parser;
pub use serde::{Deserialize, Serialize};
pub use ts_rs::TS;
pub use crate::prelude::*;
use crate::rpc_continuations::Guid;
pub(super) use crate::service::effects::context::EffectContext;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct EventId {
#[serde(default)]
#[arg(default_value_t, long)]
pub event_id: Guid,
}

View File

@@ -0,0 +1,142 @@
use std::path::{Path, PathBuf};
use imbl_value::InternedString;
use tokio::process::Command;
use crate::ImageId;
use crate::disk::mount::filesystem::overlayfs::OverlayGuard;
use crate::disk::mount::guard::GenericMountGuard;
use crate::rpc_continuations::Guid;
use crate::service::effects::prelude::*;
use crate::service::persistent_container::Subcontainer;
use crate::util::Invoke;
#[cfg(target_os = "linux")]
mod sync;
#[cfg(not(target_os = "linux"))]
mod sync_dummy;
pub use sync::*;
#[cfg(not(target_os = "linux"))]
use sync_dummy as sync;
#[derive(Debug, Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct DestroySubcontainerFsParams {
guid: Guid,
}
#[instrument(skip_all)]
pub async fn destroy_subcontainer_fs(
context: EffectContext,
DestroySubcontainerFsParams { guid }: DestroySubcontainerFsParams,
) -> Result<(), Error> {
let context = context.deref()?;
if let Some(overlay) = context
.seed
.persistent_container
.subcontainers
.lock()
.await
.remove(&guid)
{
#[cfg(target_os = "linux")]
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?;
} else {
tracing::warn!(
"Could not find a subcontainer fs to destroy; assumming that it already is destroyed and will be skipping"
);
}
Ok(())
}
#[derive(Debug, Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct CreateSubcontainerFsParams {
image_id: ImageId,
#[ts(type = "string | null")]
name: Option<InternedString>,
}
#[instrument(skip_all)]
pub async fn create_subcontainer_fs(
context: EffectContext,
CreateSubcontainerFsParams { image_id, name }: CreateSubcontainerFsParams,
) -> Result<(PathBuf, Guid), Error> {
let context = context.deref()?;
if let Some(image) = context
.seed
.persistent_container
.images
.get(&image_id)
.cloned()
{
let guid = Guid::new();
let rootfs_dir = context
.seed
.persistent_container
.lxc_container
.get()
.ok_or_else(|| {
Error::new(
eyre!("PersistentContainer has been destroyed"),
ErrorKind::Incoherent,
)
})?
.rootfs_dir();
let mountpoint = rootfs_dir
.join("media/startos/subcontainers")
.join(guid.as_ref());
tokio::fs::create_dir_all(&mountpoint).await?;
let container_mountpoint = Path::new("/").join(
mountpoint
.strip_prefix(rootfs_dir)
.with_kind(ErrorKind::Incoherent)?,
);
tracing::info!("Mounting overlay {guid} for {image_id}");
let subcontainer_wrapper = Subcontainer {
overlay: OverlayGuard::mount(image, &mountpoint).await?,
name: name
.unwrap_or_else(|| InternedString::intern(format!("subcontainer-{}", image_id))),
image_id: image_id.clone(),
};
Command::new("chown")
.arg("100000:100000")
.arg(&mountpoint)
.invoke(ErrorKind::Filesystem)
.await?;
tracing::info!("Mounted overlay {guid} for {image_id}");
context
.seed
.persistent_container
.subcontainers
.lock()
.await
.insert(guid.clone(), subcontainer_wrapper);
Ok((container_mountpoint, guid))
} else {
Err(Error::new(
eyre!("image {image_id} not found in s9pk"),
ErrorKind::NotFound,
))
}
}

View File

@@ -0,0 +1,718 @@
use std::collections::BTreeMap;
use std::ffi::{OsStr, OsString, c_int};
use std::fs::File;
use std::io::{IsTerminal, Read};
use std::os::unix::process::{CommandExt, ExitStatusExt};
use std::path::{Path, PathBuf};
use std::process::{Command as StdCommand, Stdio};
use std::sync::Arc;
use nix::errno::Errno;
use nix::sched::CloneFlags;
use nix::unistd::Pid;
use signal_hook::consts::signal::*;
use termion::raw::IntoRawMode;
use tokio::sync::oneshot;
use crate::CAP_1_KiB;
use crate::service::effects::ContainerCliContext;
use crate::service::effects::prelude::*;
use crate::util::io::TermSize;
const FWD_SIGNALS: &[c_int] = &[
SIGABRT, SIGALRM, SIGCONT, SIGHUP, SIGINT, SIGIO, SIGPIPE, SIGPROF, SIGQUIT, SIGTERM, SIGTRAP,
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 {
match nix::sys::signal::kill(
Pid::from_raw(pid),
Some(nix::sys::signal::SIGKILL),
) {
Err(Errno::ESRCH) => Ok(()),
a => a,
}
.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>);
impl procfs::FromBufRead for NSPid {
fn from_buf_read<R: std::io::BufRead>(r: R) -> procfs::ProcResult<Self> {
for line in r.lines() {
let line = line?;
if let Some(row) = line.trim().strip_prefix("NSpid:") {
return Ok(Self(
row.trim()
.split_ascii_whitespace()
.map(|pid| pid.parse::<i32>())
.collect::<Result<Vec<_>, _>>()?,
));
}
}
Err(procfs::ProcError::Incomplete(None))
}
}
fn open_file_read(path: impl AsRef<Path>) -> Result<File, Error> {
File::open(&path).with_ctx(|_| {
(
ErrorKind::Filesystem,
lazy_format!("open r {}", path.as_ref().display()),
)
})
}
#[derive(Debug, Clone, Serialize, Deserialize, Parser)]
pub struct ExecParams {
#[arg(long)]
force_tty: bool,
#[arg(long)]
force_stderr_tty: bool,
#[arg(long)]
pty_size: Option<TermSize>,
#[arg(short, long)]
env: Vec<String>,
#[arg(long)]
env_file: Option<PathBuf>,
#[arg(short, long)]
workdir: Option<PathBuf>,
#[arg(short, long)]
user: Option<String>,
chroot: PathBuf,
#[arg(trailing_var_arg = true)]
command: Vec<OsString>,
}
impl ExecParams {
fn exec(&self) -> Result<(), Error> {
let ExecParams {
env,
env_file,
workdir,
user,
chroot,
command,
..
} = self;
let Some(([command], args)) = command.split_at_checked(1) else {
return Err(Error::new(
eyre!("command cannot be empty"),
ErrorKind::InvalidRequest,
));
};
let mut cmd = StdCommand::new(command);
let passwd = std::fs::read_to_string(chroot.join("etc/passwd"))
.with_ctx(|_| (ErrorKind::Filesystem, "read /etc/passwd"))
.log_err()
.unwrap_or_default();
let mut home = None;
if let Some((uid, gid)) =
if let Some(uid) = user.as_deref().and_then(|u| u.parse::<u32>().ok()) {
Some((uid, uid))
} else if let Some((uid, gid)) = user
.as_deref()
.and_then(|u| u.split_once(":"))
.and_then(|(u, g)| Some((u.parse::<u32>().ok()?, g.parse::<u32>().ok()?)))
{
Some((uid, gid))
} else if let Some(user) = user {
Some(
if let Some((uid, gid)) = passwd.lines().find_map(|l| {
let l = l.trim();
let mut split = l.split(":");
if user != split.next()? {
return None;
}
split.next(); // throw away x
let uid = split.next()?.parse().ok()?;
let gid = split.next()?.parse().ok()?;
split.next(); // throw away group name
home = split.next();
Some((uid, gid))
// uid gid
}) {
(uid, gid)
} else if user == "root" {
(0, 0)
} else {
None.or_not_found(lazy_format!("{user} in /etc/passwd"))?
},
)
} else {
None
}
{
if home.is_none() {
home = passwd.lines().find_map(|l| {
let l = l.trim();
let mut split = l.split(":");
split.next(); // throw away user name
split.next(); // throw away x
if split.next()?.parse::<u32>().ok()? != uid {
return None;
}
split.next(); // throw away gid
split.next(); // throw away group name
split.next()
})
};
std::os::unix::fs::chown("/proc/self/fd/0", Some(uid), Some(gid)).ok();
std::os::unix::fs::chown("/proc/self/fd/1", Some(uid), Some(gid)).ok();
std::os::unix::fs::chown("/proc/self/fd/2", Some(uid), Some(gid)).ok();
cmd.uid(uid);
cmd.gid(gid);
} else {
home = Some("/root");
}
cmd.env("HOME", home.unwrap_or("/"));
let env_string = if let Some(env_file) = &env_file {
std::fs::read_to_string(env_file)
.with_ctx(|_| (ErrorKind::Filesystem, lazy_format!("read {env:?}")))?
} else {
Default::default()
};
let env = env_string
.lines()
.chain(env.iter().map(|l| l.as_str()))
.map(|l| l.trim())
.filter_map(|l| l.split_once("="))
.collect::<BTreeMap<_, _>>();
std::os::unix::fs::chroot(chroot)
.with_ctx(|_| (ErrorKind::Filesystem, lazy_format!("chroot {chroot:?}")))?;
cmd.args(args);
for (k, v) in env {
cmd.env(k, v);
}
if let Some(workdir) = workdir {
cmd.current_dir(workdir);
} else {
cmd.current_dir("/");
}
Err(cmd.exec().into())
}
}
pub fn launch(
_: ContainerCliContext,
ExecParams {
force_tty,
force_stderr_tty,
pty_size,
env,
env_file,
workdir,
user,
chroot,
command,
}: ExecParams,
) -> Result<(), Error> {
use std::io::Write;
kill_init(Path::new("/proc"), &chroot)?;
let mut sig = signal_hook::iterator::Signals::new(FWD_SIGNALS)?;
let (send_pid, recv_pid) = oneshot::channel();
std::thread::spawn(move || {
if let Ok(pid) = recv_pid.blocking_recv() {
for sig in sig.forever() {
match nix::sys::signal::kill(
Pid::from_raw(pid),
Some(nix::sys::signal::Signal::try_from(sig).unwrap()),
) {
Err(Errno::ESRCH) => Ok(()),
a => a,
}
.unwrap()
}
}
});
let mut stdin = std::io::stdin();
let stdout = std::io::stdout();
let stderr = std::io::stderr();
let stderr_tty = force_stderr_tty || stderr.is_terminal();
let tty = force_tty || (stdin.is_terminal() && stdout.is_terminal());
let raw = if stdin.is_terminal() && stdout.is_terminal() {
Some(termion::get_tty()?.into_raw_mode()?)
} else {
None
};
let (stdin_send, stdin_recv) = oneshot::channel::<Box<dyn Write + Send>>();
std::thread::spawn(move || {
if let Ok(mut cstdin) = stdin_recv.blocking_recv() {
if tty {
let mut buf = [0_u8; CAP_1_KiB];
while let Ok(n) = stdin.read(&mut buf) {
if n == 0 {
break;
}
cstdin.write_all(&buf[..n]).ok();
cstdin.flush().ok();
}
} else {
std::io::copy(&mut stdin, &mut cstdin).unwrap();
}
}
});
let (stdout_send, stdout_recv) = oneshot::channel::<Box<dyn std::io::Read + Send>>();
let stdout_thread = std::thread::spawn(move || {
if let Ok(mut cstdout) = stdout_recv.blocking_recv() {
if tty {
let mut stdout = stdout.lock();
let mut buf = [0_u8; CAP_1_KiB];
while let Ok(n) = cstdout.read(&mut buf) {
if n == 0 {
break;
}
stdout.write_all(&buf[..n]).ok();
stdout.flush().ok();
}
} else {
std::io::copy(&mut cstdout, &mut stdout.lock()).unwrap();
}
}
});
let (stderr_send, stderr_recv) = oneshot::channel::<Box<dyn std::io::Read + Send>>();
let stderr_thread = if !stderr_tty {
Some(std::thread::spawn(move || {
if let Ok(mut cstderr) = stderr_recv.blocking_recv() {
std::io::copy(&mut cstderr, &mut stderr.lock()).unwrap();
}
}))
} else {
None
};
nix::sched::unshare(CloneFlags::CLONE_NEWPID)
.with_ctx(|_| (ErrorKind::Filesystem, "unshare pid ns"))?;
nix::sched::unshare(CloneFlags::CLONE_NEWCGROUP)
.with_ctx(|_| (ErrorKind::Filesystem, "unshare cgroup ns"))?;
nix::sched::unshare(CloneFlags::CLONE_NEWIPC)
.with_ctx(|_| (ErrorKind::Filesystem, "unshare ipc ns"))?;
if tty {
use pty_process::blocking as pty_process;
let (pty, pts) = pty_process::open().with_kind(ErrorKind::Filesystem)?;
let mut cmd = pty_process::Command::new("/usr/bin/start-container");
cmd = cmd.arg("subcontainer").arg("launch-init");
for env in env {
cmd = cmd.arg("-e").arg(env)
}
if let Some(env_file) = env_file {
cmd = cmd.arg("--env-file").arg(env_file);
}
if let Some(workdir) = workdir {
cmd = cmd.arg("--workdir").arg(workdir);
}
if let Some(user) = user {
cmd = cmd.arg("--user").arg(user);
}
cmd = cmd.arg(&chroot).args(&command);
if !stderr_tty {
cmd = cmd.stderr(Stdio::piped());
}
let mut child = cmd
.spawn(pts)
.map_err(color_eyre::eyre::Report::msg)
.with_ctx(|_| (ErrorKind::Filesystem, "spawning child process"))?;
send_pid.send(child.id() as i32).unwrap_or_default();
if let Some(pty_size) = pty_size.or_else(|| TermSize::get_current()) {
let size = if let Some((x, y)) = pty_size.pixels {
::pty_process::Size::new_with_pixel(pty_size.rows, pty_size.cols, x, y)
} else {
::pty_process::Size::new(pty_size.rows, pty_size.cols)
};
pty.resize(size).with_kind(ErrorKind::Filesystem)?;
}
let shared = ArcPty(Arc::new(pty));
stdin_send
.send(Box::new(shared.clone()))
.unwrap_or_default();
stdout_send
.send(Box::new(shared.clone()))
.unwrap_or_default();
if let Some(stderr) = child.stderr.take() {
stderr_send.send(Box::new(stderr)).unwrap_or_default();
}
let exit = child
.wait()
.with_ctx(|_| (ErrorKind::Filesystem, "waiting on child process"))?;
stdout_thread.join().unwrap();
stderr_thread.map(|t| t.join().unwrap());
if let Some(code) = exit.code() {
drop(raw);
std::process::exit(code);
} else if exit.success() || exit.signal() == Some(15) {
Ok(())
} else {
Err(Error::new(
color_eyre::eyre::Report::msg(exit),
ErrorKind::Unknown,
))
}
} else {
let mut cmd = StdCommand::new("/usr/bin/start-container");
cmd.arg("subcontainer").arg("launch-init");
for env in env {
cmd.arg("-e").arg(env);
}
if let Some(env_file) = env_file {
cmd.arg("--env-file").arg(env_file);
}
if let Some(workdir) = workdir {
cmd.arg("--workdir").arg(workdir);
}
if let Some(user) = user {
cmd.arg("--user").arg(user);
}
cmd.arg(&chroot);
cmd.args(&command);
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut child = cmd
.spawn()
.map_err(color_eyre::eyre::Report::msg)
.with_ctx(|_| (ErrorKind::Filesystem, "spawning child process"))?;
send_pid.send(child.id() as i32).unwrap_or_default();
stdin_send
.send(Box::new(child.stdin.take().unwrap()))
.unwrap_or_default();
stdout_send
.send(Box::new(child.stdout.take().unwrap()))
.unwrap_or_default();
stderr_send
.send(Box::new(child.stderr.take().unwrap()))
.unwrap_or_default();
// TODO: subreaping, signal handling
let exit = child
.wait()
.with_ctx(|_| (ErrorKind::Filesystem, "waiting on child process"))?;
stdout_thread.join().unwrap();
stderr_thread.map(|t| t.join().unwrap());
if let Some(code) = exit.code() {
nix::mount::umount(&chroot.join("proc"))
.with_ctx(|_| (ErrorKind::Filesystem, "umount procfs"))?;
std::process::exit(code);
} else if exit.success() || exit.signal() == Some(15) {
Ok(())
} else {
Err(Error::new(
color_eyre::eyre::Report::msg(exit),
ErrorKind::Unknown,
))
}
}
}
pub fn launch_init(_: ContainerCliContext, params: ExecParams) -> Result<(), Error> {
nix::mount::mount(
Some("proc"),
&params.chroot.join("proc"),
Some("proc"),
nix::mount::MsFlags::empty(),
None::<&str>,
)
.with_ctx(|_| (ErrorKind::Filesystem, "mount procfs"))?;
if params.command.is_empty() {
signal_hook::iterator::Signals::new(signal_hook::consts::TERM_SIGNALS)?
.forever()
.next();
std::process::exit(0)
} else {
params.exec()
}
}
#[derive(Clone)]
struct ArcPty(Arc<pty_process::blocking::Pty>);
impl std::io::Write for ArcPty {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
(&*self.0).write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
(&*self.0).flush()
}
}
impl std::io::Read for ArcPty {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
(&*self.0).read(buf)
}
}
pub fn exec(
_: ContainerCliContext,
ExecParams {
force_tty,
force_stderr_tty,
pty_size,
env,
env_file,
workdir,
user,
chroot,
command,
}: ExecParams,
) -> Result<(), Error> {
use std::io::Write;
let mut sig = signal_hook::iterator::Signals::new(FWD_SIGNALS)?;
let (send_pid, recv_pid) = oneshot::channel();
std::thread::spawn(move || {
if let Ok(pid) = recv_pid.blocking_recv() {
for sig in sig.forever() {
match nix::sys::signal::kill(
Pid::from_raw(pid),
Some(nix::sys::signal::Signal::try_from(sig).unwrap()),
) {
Err(Errno::ESRCH) => Ok(()),
a => a,
}
.unwrap();
}
}
});
let mut stdin = std::io::stdin();
let stdout = std::io::stdout();
let stderr = std::io::stderr();
let stderr_tty = force_stderr_tty || stderr.is_terminal();
let tty = force_tty || (stdin.is_terminal() && stdout.is_terminal());
let raw = if stdin.is_terminal() && stdout.is_terminal() {
Some(termion::get_tty()?.into_raw_mode()?)
} else {
None
};
let (stdin_send, stdin_recv) = oneshot::channel::<Box<dyn Write + Send>>();
std::thread::spawn(move || {
if let Ok(mut cstdin) = stdin_recv.blocking_recv() {
if tty {
let mut buf = [0_u8; CAP_1_KiB];
while let Ok(n) = stdin.read(&mut buf) {
if n == 0 {
break;
}
cstdin.write_all(&buf[..n]).ok();
cstdin.flush().ok();
}
} else {
std::io::copy(&mut stdin, &mut cstdin).unwrap();
}
}
});
let (stdout_send, stdout_recv) = oneshot::channel::<Box<dyn std::io::Read + Send>>();
let stdout_thread = std::thread::spawn(move || {
if let Ok(mut cstdout) = stdout_recv.blocking_recv() {
if tty {
let mut stdout = stdout.lock();
let mut buf = [0_u8; CAP_1_KiB];
while let Ok(n) = cstdout.read(&mut buf) {
if n == 0 {
break;
}
stdout.write_all(&buf[..n]).ok();
stdout.flush().ok();
}
} else {
std::io::copy(&mut cstdout, &mut stdout.lock()).unwrap();
}
}
});
let (stderr_send, stderr_recv) = oneshot::channel::<Box<dyn std::io::Read + Send>>();
let stderr_thread = if !stderr_tty {
Some(std::thread::spawn(move || {
if let Ok(mut cstderr) = stderr_recv.blocking_recv() {
std::io::copy(&mut cstderr, &mut stderr.lock()).unwrap();
}
}))
} else {
None
};
nix::sched::setns(
open_file_read(chroot.join("proc/1/ns/pid"))?,
CloneFlags::CLONE_NEWPID,
)
.with_ctx(|_| (ErrorKind::Filesystem, "set pid ns"))?;
nix::sched::setns(
open_file_read(chroot.join("proc/1/ns/cgroup"))?,
CloneFlags::CLONE_NEWCGROUP,
)
.with_ctx(|_| (ErrorKind::Filesystem, "set cgroup ns"))?;
nix::sched::setns(
open_file_read(chroot.join("proc/1/ns/ipc"))?,
CloneFlags::CLONE_NEWIPC,
)
.with_ctx(|_| (ErrorKind::Filesystem, "set ipc ns"))?;
if tty {
use pty_process::blocking as pty_process;
let (pty, pts) = pty_process::open().with_kind(ErrorKind::Filesystem)?;
let mut cmd = pty_process::Command::new("/usr/bin/start-container");
cmd = cmd.arg("subcontainer").arg("exec-command");
for env in env {
cmd = cmd.arg("-e").arg(env);
}
if let Some(env_file) = env_file {
cmd = cmd.arg("--env-file").arg(env_file);
}
if let Some(workdir) = workdir {
cmd = cmd.arg("--workdir").arg(workdir);
}
if let Some(user) = user {
cmd = cmd.arg("--user").arg(user);
}
cmd = cmd.arg(&chroot).args(&command);
if !stderr_tty {
cmd = cmd.stderr(Stdio::piped());
}
let mut child = cmd
.spawn(pts)
.map_err(color_eyre::eyre::Report::msg)
.with_ctx(|_| (ErrorKind::Filesystem, "spawning child process"))?;
send_pid.send(child.id() as i32).unwrap_or_default();
if let Some(pty_size) = pty_size.or_else(|| TermSize::get_current()) {
let size = if let Some((x, y)) = pty_size.pixels {
::pty_process::Size::new_with_pixel(pty_size.rows, pty_size.cols, x, y)
} else {
::pty_process::Size::new(pty_size.rows, pty_size.cols)
};
pty.resize(size).with_kind(ErrorKind::Filesystem)?;
}
let shared = ArcPty(Arc::new(pty));
stdin_send
.send(Box::new(shared.clone()))
.unwrap_or_default();
stdout_send
.send(Box::new(shared.clone()))
.unwrap_or_default();
if let Some(stderr) = child.stderr.take() {
stderr_send.send(Box::new(stderr)).unwrap_or_default();
}
let exit = child
.wait()
.with_ctx(|_| (ErrorKind::Filesystem, "waiting on child process"))?;
stdout_thread.join().unwrap();
stderr_thread.map(|t| t.join().unwrap());
if let Some(code) = exit.code() {
drop(raw);
std::process::exit(code);
} else if exit.success() {
Ok(())
} else {
Err(Error::new(
color_eyre::eyre::Report::msg(exit),
ErrorKind::Unknown,
))
}
} else {
let mut cmd = StdCommand::new("/usr/bin/start-container");
cmd.arg("subcontainer").arg("exec-command");
for env in env {
cmd.arg("-e").arg(env);
}
if let Some(env_file) = env_file {
cmd.arg("--env-file").arg(env_file);
}
if let Some(workdir) = workdir {
cmd.arg("--workdir").arg(workdir);
}
if let Some(user) = user {
cmd.arg("--user").arg(user);
}
cmd.arg(&chroot);
cmd.args(&command);
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut child = cmd
.spawn()
.map_err(color_eyre::eyre::Report::msg)
.with_ctx(|_| (ErrorKind::Filesystem, "spawning child process"))?;
send_pid.send(child.id() as i32).unwrap_or_default();
stdin_send
.send(Box::new(child.stdin.take().unwrap()))
.unwrap_or_default();
stdout_send
.send(Box::new(child.stdout.take().unwrap()))
.unwrap_or_default();
stderr_send
.send(Box::new(child.stderr.take().unwrap()))
.unwrap_or_default();
let exit = child
.wait()
.with_ctx(|_| (ErrorKind::Filesystem, "waiting on child process"))?;
stdout_thread.join().unwrap();
stderr_thread.map(|t| t.join().unwrap());
if let Some(code) = exit.code() {
std::process::exit(code);
} else if exit.success() {
Ok(())
} else {
Err(Error::new(
color_eyre::eyre::Report::msg(exit),
ErrorKind::Unknown,
))
}
}
}
pub fn exec_command(_: ContainerCliContext, params: ExecParams) -> Result<(), Error> {
params.exec()
}

View File

@@ -0,0 +1,30 @@
use crate::service::effects::ContainerCliContext;
use crate::service::effects::prelude::*;
pub fn launch(_: ContainerCliContext) -> Result<(), Error> {
Err(Error::new(
eyre!("requires feature container-runtime"),
ErrorKind::InvalidRequest,
))
}
pub fn launch_init(_: ContainerCliContext) -> Result<(), Error> {
Err(Error::new(
eyre!("requires feature container-runtime"),
ErrorKind::InvalidRequest,
))
}
pub fn exec(_: ContainerCliContext) -> Result<(), Error> {
Err(Error::new(
eyre!("requires feature container-runtime"),
ErrorKind::InvalidRequest,
))
}
pub fn exec_command(_: ContainerCliContext) -> Result<(), Error> {
Err(Error::new(
eyre!("requires feature container-runtime"),
ErrorKind::InvalidRequest,
))
}

View File

@@ -0,0 +1,40 @@
use crate::service::effects::callbacks::CallbackHandler;
use crate::service::effects::prelude::*;
use crate::service::rpc::CallbackId;
use crate::system::SmtpValue;
#[derive(Debug, Clone, Serialize, Deserialize, TS, Parser)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct GetSystemSmtpParams {
#[arg(skip)]
callback: Option<CallbackId>,
}
pub async fn get_system_smtp(
context: EffectContext,
GetSystemSmtpParams { callback }: GetSystemSmtpParams,
) -> Result<Option<SmtpValue>, Error> {
let context = context.deref()?;
if let Some(callback) = callback {
let callback = callback.register(&context.seed.persistent_container);
context
.seed
.ctx
.callbacks
.add_get_system_smtp(CallbackHandler::new(&context, callback));
}
let res = context
.seed
.ctx
.db
.peek()
.await
.into_public()
.into_server_info()
.into_smtp()
.de()?;
Ok(res)
}

View File

@@ -0,0 +1,46 @@
use std::path::Path;
use crate::DATA_DIR;
use crate::service::effects::prelude::*;
use crate::util::io::{delete_file, maybe_read_file_to_string, write_file_atomic};
use crate::volume::PKG_VOLUME_DIR;
#[derive(Debug, Clone, Serialize, Deserialize, TS, Parser)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct SetDataVersionParams {
#[ts(type = "string")]
version: Option<String>,
}
#[instrument(skip(context))]
pub async fn set_data_version(
context: EffectContext,
SetDataVersionParams { version }: SetDataVersionParams,
) -> Result<(), Error> {
let context = context.deref()?;
let package_id = &context.seed.id;
let path = Path::new(DATA_DIR)
.join(PKG_VOLUME_DIR)
.join(package_id)
.join("data")
.join(".version");
if let Some(version) = version {
write_file_atomic(path, version.as_bytes()).await?;
} else {
delete_file(path).await?;
}
Ok(())
}
#[instrument(skip_all)]
pub async fn get_data_version(context: EffectContext) -> Result<Option<String>, Error> {
let context = context.deref()?;
let package_id = &context.seed.id;
let path = Path::new(DATA_DIR)
.join(PKG_VOLUME_DIR)
.join(package_id)
.join("data")
.join(".version");
maybe_read_file_to_string(path).await
}