8000 feat(minimald): side-op subsystem, package builds by twitchyliquid64 · Pull Request #960 · gominimal/minimal · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/minimald/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ serde_json.workspace = true
sysinfo.workspace = true
tempfile.workspace = true
thiserror.workspace = true
uuid.workspace = true
vt100-ctt.workspace = true

tokio.workspace = true
Expand All @@ -70,6 +71,7 @@ graph.workspace = true
mctx.workspace = true
mfile.workspace = true
minimald-rpc.workspace = true
orchestrator.workspace = true
op.workspace = true
ot.workspace = true
paths.workspace = true
Expand Down
80 changes: 79 additions & 1 deletion crates/minimald/src/env.rs
8000
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ pub struct EnvArgs {
network_mode: NetworkMode,
own_ip_tap: Option<sandbox2::config::OwnIpTap>,
own_ip_dns: Option<std::net::Ipv4Addr>,
/// Weak handle to the owning session actor, wired into the command channel
/// so in-sandbox `min` commands can drive session side-ops (e.g. builds).
/// Every session env has one — this `Env` is always session-scoped.
session: crate::session::WeakSessionHandle,
/// When true (default), [`Env::build`] consumes package
/// [`SetupForPackages`] output for `env_vars`/`fs_mappings`.
/// When false, the caller supplies both via `env_vars` /
Expand All @@ -85,12 +89,14 @@ pub struct EnvArgs {

impl EnvArgs {
/// Creates args for an environment named `name`, with working directory
/// `cwd` and `/state` backed by `state_base_dir`.
/// `cwd` and `/state` backed by `state_base_dir`. `session` is a weak
/// handle to the owning session actor.
pub fn new(
name: impl Into<String>,
cwd: impl Into<DaemonAbsPath>,
home: impl Into<DaemonAbsPath>,
state_base_dir: impl Into<DaemonAbsPath>,
session: crate::session::WeakSessionHandle,
) -> Self {
Self {
name: name.into(),
Expand All @@ -105,6 +111,7 @@ impl EnvArgs {
network_mode: NetworkMode::HostNet,
own_ip_tap: None,
own_ip_dns: None,
session,
include_package_attr_wiring: true,
}
}
Expand Down Expand Up @@ -367,6 +374,7 @@ impl Env {
working: args.cwd.clone(),
has_packages: transitives.keys().copied().collect(),
ot: args.ot.clone(),
session: args.session.clone(),
ctx,
graph,
rx,
Expand Down Expand Up @@ -499,6 +507,9 @@ struct SessionChannel {
/// Packages already materialized into the rootfs.
has_packages: HashSet<BuildSpecRef>,
ot: Option<OpTracker>,
/// Weak handle to the owning session actor, used by session-scoped commands
/// (e.g. `min build`) to drive side-ops.
session: crate::session::WeakSessionHandle,
rx: mpsc::Receiver<ChannelRequest>,
}

Expand Down Expand Up @@ -573,6 +584,10 @@ impl SessionChannel {
self.run_task(stream, name, rest).await;
None
}
Some(("build", args)) => {
self.run_build(stream, args).await;
None
}
_ => {
let _ = writeln!(stream, "error: unhandled input '{line}'");
None
Expand Down Expand Up @@ -906,6 +921,68 @@ impl SessionChannel {
}
}

/// Implements `min build [--verbose] [--rebuild] pkgs...`: kicks off
/// a session side-op build and streams its progress back to the client
/// until the build completes.
async fn run_build(&mut self, stream: &mut UnixStream, args: &str) {
let mut flag_verbose = false;
let mut flag_rebuild = false;
let mut pkgs: Vec<String> = Vec::new();

for token in args.split_whitespace() {
match token {
"--verbose" => flag_verbose = true,
"--rebuild" => flag_rebuild = true,
_ => pkgs.push(token.to_string()),
}
}

let Some(session) = self.session.upgrade() else {
let _ = writeln!(stream, "error: session is gone");
return;
};

let mut events = match session.start_build(flag_rebuild, pkgs).await {
Ok(rx) => rx,
Err(e) => {
let _ = writeln!(stream, "error: {e}");
return;
}
};

use crate::session_sop::{BuildOutcome, BuildUpdate};
let mut renderer = orchestrator::BuildRenderer::new(flag_verbose);
let mut outcome = None;
while let Some(update) = events.recv().await {
match update {
BuildUpdate::Event(event) => {
if let Some(line) = renderer.render(event) {
let _ = writeln!(stream, "msg:{}", line.text);
}
}
BuildUpdate::Finished(o) => outcome = Some(o),
}
}

// Report the propagated outcome; only success claims completion.
match outcome {
Some(BuildOutcome::Success) => {
let _ = writeln!(stream, "msg:build finished");
}
Some(BuildOutcome::Failed(e)) => {
let _ = writeln!(stream, "error: build failed: {e}");
}
Some(BuildOutcome::Cancelled) => {
let _ = writeln!(stream, "error: build cancelled");
}
// The channel closed without a terminal update — the side-op died
// before reporting (e.g. the actor was torn down mid-build).
None => {
let _ = writeln!(stream, "error: build ended without reporting an outcome");
}
}
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
/// Writes an [`mctx::Error`] to the client as `msg:` lines (preserving its
/// multi-line, richly-formatted report) followed by an `error:` terminator.
fn write_error(e: &Error, stream: &mut UnixStream) {
Expand Down Expand Up @@ -1021,6 +1098,7 @@ mod tests {
.unwrap(),
has_packages: HashSet::new(),
ot: None,
session: crate::session::WeakSessionHandle::dangling(),
ctx,
graph,
rx,
Expand Down
7 changes: 6 additions & 1 deletion crates/minimald/src/env_min_helper.sh
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ min_run() {
__min_rpc "run" "$@"
}

min_build() {
__min_rpc "build" "$@"
}

min_check() {
__min_rpc "check" "$@"
}
Expand All @@ -127,7 +131,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
min_run "$@"
;;
build)
min_run build
min_build "$@"
;;
test)
min_run test
Expand All @@ -145,6 +149,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
echo "Search for packages: min search <query>" >&2
echo "Check minimal configuration: min check" >&2
echo "Run a task: min run <task name>" >&2
echo "Build packages: min build [packages]" >&2
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
echo "Try building a package (with potentially-stale dependencies): min patched-pkg <package name>" >&2
exit 1
;;
Expand Down
165 changes: 145 additions & 20 deletions crates/minimald/src/exec.rs
< 10BC8 td class="blob-code blob-code-addition js-file-line"> // (`--verbose` / `--rebuild` / package names).
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//! SSH `exec` request handling: spawns a process and bridges its stdio
//! over an SSH channel.
//! SSH `exec` request handling: Launching tasks defined in the session,
//! git recieve-pack, and launching side ops.
//!
//! Process spawning is abstracted via the [`Exec`] / [`Process`] traits
//! so the bridge logic can be exercised with a mock in tests without
Expand Down Expand Up @@ -807,33 +807,158 @@ pub(crate) async fn handle_exec(
}
};

let task = match argv.strip_prefix("min run ") {
let rem = match argv.strip_prefix("min ") {
Some(c) => c,
None => {
tracing::warn!(%session_id, "execution request rejected: expected `min run <task name>`");
tracing::warn!(%session_id, "execution request rejected: expected `min ` prefix");
session.channel_failure(id)?;
return Ok(());
}
Some(t) => t,
}.to_string();
session.channel_success(id)?;
}
.to_string();

spawn(async move {
let exec_task = ExecTask {
conn,
serv,
session: session_handle,
channel_id: id,
exec: TaskExec {
args: None,
task: task.to_string(),
},
};
exec_task.run(channel).await;
});
let mut c = rem.split(' ');
match c.next() {
None => {
tracing::warn!(%session_id, "execution request rejected: expected `min <sub command>`");
session.channel_failure(id)?;
return Ok(());
}
Some("run") => {
// `min run` with no task name would leave `strip_prefix("run ")`
// as `None` (and a trailing-space `min run ` as empty) — reject
// both before acking, rather than panicking on `.unwrap()`.
let task = rem.strip_prefix("run ").unwrap_or("").trim();
if task.is_empty() {
tracing::warn!(%session_id, "execution request rejected: expected `min run <task name>`");
session.channel_failure(id)?;
return Ok(());
}
let task = task.to_string();
session.channel_success(id)?;

spawn(async move {
let exec_task = ExecTask {
conn,
serv,
session: session_handle,
channel_id: id,
exec: TaskExec { args: None, task },
};
exec_task.run(channel).await;
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Some("build") => {
session.channel_success(id)?;
// Everything after the sub-command is the build's args
let build_args = rem.strip_prefix("build").unwrap_or("").trim().to_string();
spawn(async move {
run_build_exec(session_handle, id, channel, build_args).await;
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Some(other) => {
tracing::warn!(%session_id, "execution request rejected: unexpected min sub-command `{other}`");
session.channel_failure(id)?;
return Ok(());
}
};

Ok(())
}

/// Streams a `min build [--verbose] [--rebuild] [pkgs...]` exec over the SSH
/// channel. Kicks off a session side-op build based on an existing session.
async fn run_build_exec(
session: SessionHandle,
channel_id: ChannelId,
channel: Channel<Msg>,
args: String,
) {
use orchestrator::{BuildLineKind, BuildRenderer};

let mut verbose = false;
let mut rebuild = false;
let mut pkgs: Vec<String> = Vec::new();
for token in args.split_whitespace() {
match token {
"--verbose" => verbose = true,
"--rebuild" => rebuild = true,
_ => pkgs.push(token.to_string()),
}
}

// Same channel-half handling as `ExecTask::run`: stdout on the data
// stream, stderr on SSH extended-data type 1.
let (_rs, ws) = channel.split();
let mut w = ws.make_writer();
let mut e = ws.make_writer_ext(Some(1));

let exit_status = match session.start_build(rebuild, pkgs).await {
Ok(mut events) => {
use crate::session_sop::{BuildOutcome, BuildUpdate};
let mut renderer = BuildRenderer::new(verbose);
let mut outcome = None;
while let Some(update) = events.recv().await {
let event = match update {
BuildUpdate::Event(event) => event,
BuildUpdate::Finished(o) => {
outcome = Some(o);
continue;
}
};
let Some(line) = renderer.render(event) else {
continue;
};
let out: &mut (dyn AsyncWrite + Unpin + Send) = match line.kind {
BuildLineKind::Stderr => &mut e,
BuildLineKind::Status => &mut w,
};
if let Err(err) = out.write_all(format!("{}\n", line.text).as_bytes()).await {
// No reader on the channel — the client went away. Stop
// streaming; the side-op keeps running independently.
tracing::warn!(%channel_id, error = %err, "min build: channel write failed");
break;
}
}
// Map the propagated outcome to an exit code, reporting a
// failure/cancellation on the SSH stderr stream. Only success
// exits 0.
match outcome {
Some(BuildOutcome::Success) => 0,
Some(BuildOutcome::Failed(msg)) => {
let _ = e
.write_all(format!("build failed: {msg}\n").as_bytes())
.await;
1
}
Some(BuildOutcome::Cancelled) => {
let _ = e.write_all(b"build cancelled\n").await;
130
}
None => {
let _ = e
.write_all(b"build ended without reporting an outcome\n")
.await;
1
}
}
}
Err(err) => {
let _ = e
.write_all(format!("minimald: failed to start build: {err}\n").as_bytes())
.await;
1
}
};

let _ = w.flush().await;
let _ = e.flush().await;
let _ = ws.eof().await;
let _ = ws.exit_status(exit_status).await; // otherwise considered -1
let _ = ws.close().await; // needed to release the remote
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

async fn handle_git_receive(
ident: &str,
serv: ServerStateHandle,
Expand Down
1 change: 1 addition & 0 deletions crates/minimald/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub mod rpc;
pub mod server;
mod session;
pub mod session_host;
pub mod session_sop;
mod sessions;
mod sftp;
mod store;
Expand Down
Loading
0