8000 Do not review: CI test by norrietaylor · Pull Request #585 · gominimal/minimal · GitHub
[go: up one dir, main page]

Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
da39e86
feat(minimal2): add --network and --ingress flags to activate
norrietaylor Jun 24, 2026
52c4821
feat(minvmd): spawn host gvproxy + per-PTask vsock shuttle bridge
norrietaylor Jun 25, 2026
6b8c726
feat(minimald): shuttle OwnIp PTask taps to host gvproxy on DM1/3/4
norrietaylor Jun 25, 2026
5031d29
Merge branch 'main' into feat/activate-network-ingress-flags
norrietaylor Jun 25, 2026
1299c3c
chore: add justfile recipes to bring up minimal2 -> minvmd -> minimald
norrietaylor Jun 25, 2026
03decd1
feat(mctx,minimald): scaffold a default minimal.toml for empty sessions
norrietaylor Jun 25, 2026
bf53b37
feat(minimald,minvmd): guest root-netns egress via host gvproxy shuttle
norrietaylor Jun 25, 2026
6d02dde
refactor(mctx): make minimal.toml scaffolding a self-contained tempor…
norrietaylor Jun 25, 2026
e1236db
Merge branch 'feat/activate-network-ingress-flags' of https://github.…
norrietaylor Jun 25, 2026
caa4092
feat(minimald): apply own-IP ingress over the gvproxy control channel…
norrietaylor Jun 26, 2026
482be01
fix(minimald,minimal2): make the in-sandbox interactive attach work (…
norrietaylor Jun 26, 2026
a71d96a
fix(minvmd,minimald): grow VM RAM and exec the shell at /usr/bin/bash
norrietaylor Jun 26, 2026
73395f6
fix(minimald): mount devpts so the in-sandbox session PTY works
norrietaylor Jun 26, 2026
b9ad4f3
chore(minimald): devpts ptmx instance fix + session exit-code logging
norrietaylor Jun 26, 2026
9b9af44
fix(minimald): make the in-sandbox session shell work in the minvmd g…
norrietaylor Jun 26, 2026
a1d8399
style(minimald): rustfmt the ingress control-channel code
norrietaylor Jun 26, 2026
6a92e82
feat(minimald): start the host-side proxies in-VM and publish them on…
norrietaylor Jun 26, 2026
72d8055
docs(spec-networking): add the full-session-path CLI test plan + runner
norrietaylor Jun 26, 2026
455722d
fix(minimald): recover host routing from a poisoned registry lock
norrietaylor Jun 26, 2026
5161676
fix(minimald): bind in-VM proxies on 0.0.0.0 and expose only bound ports
norrietaylor Jun 26, 2026
df19d9b
perf(minimald): fence the blocking scaffold clone with block_in_place
norrietaylor Jun 26, 2026
66a9133
chore(minimald): log routine session exits at debug
norrietaylor Jun 26, 2026
a4b703e
fix(minvmd): fail own-ip boot when the gvproxy binary is missing
norrietaylor Jun 26, 2026
9da27a0
fix(minimald): adapt netns test to the ControlChannel ingress API
norrietaylor Jun 26, 2026
2b55134
docs(minimald,minvmd): drop references to the superseded host-shuttle PR
norrietaylor Jun 26, 2026
bac5b31
style(minvmd): rustfmt the switch-socket setup line
norrietaylor Jun 26, 2026
95b3213
style(minimald): derive SwitchTransport Default
norrietaylor Jun 26, 2026
dcecd6a
fix(minimald): mark ControlChannel non_exhaustive
norrietaylor Jun 26, 2026
cb5b868
fix(minvmd): tolerate a failed gvproxy spawn on non-own-IP boots
norrietaylor Jun 26, 2026
8e9fd7a
fix(minvmd): accept case-insensitive truthy MINVMD_VM_OWN_IP
norrietaylor Jun 26, 2026
1192223
fix(minimald): log fatal proxy accept-loop errors
norrietaylor Jun 26, 2026
460ee62
refactor(minimald): drop the duplicate non-zero session-exit log
norrietaylor Jun 26, 2026
c479def
fix(minimald): keep rustls optional to avoid a dual crypto-provider p…
norrietaylor Jun 26, 2026
ceec7cf
feat(sandbox2): add a Network abstraction for per-sandbox networking
norrietaylor Jun 26, 2026
9e0a8b4
refactor(minimald,minvmd): disambiguate the two GvproxySwitch types
norrietaylor Jun 26, 2026
62fe1c4
feat(minimald): move own-IP switch wiring into a sandbox2 Network impl
norrietaylor Jun 26, 2026
c6c2b53
feat(sandbox2): apply the configured Network in the generic run path
norrietaylor Jun 26, 2026
03b0be6
refactor(minvmd): remove the dead PtaskAttachment lease path
norrietaylor Jun 26, 2026
e85edbb
refactor(minimald-rpc,minimald,minvmd): unify the gvproxy switch prim…
norrietaylor Jun 26, 2026
a261d89
fix(minvmd,minimal2): make VM bring-up timeouts and guest RAM configu…
norrietaylor Jun 26, 2026
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
1 change: 1 addition & 0 deletions crates/mctx/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ impl From<sandbox2::Error> for Error {
"internal: file {} mapped to rootfs",
p.display()
)),
sandbox2::Error::Network(e) => Self::Other(anyhow::anyhow!(e.to_string())),
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions crates/mctx/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ pub use sandbox2::config::Invocation;
mod mfile_search_strategy;
pub use mfile_search_strategy::MFileSearchStrategy;

// TEMPORARY: default-`minimal.toml` scaffolding for empty session workspaces.
// See `scaffold.rs`; remove with the workspace-upload gap.
mod scaffold;
pub use scaffold::scaffold_default_mfile;

pub use env::Env;
use tokio::sync::Semaphore;
use toml_edit::{Array, DocumentMut, Item, TableLike, Value};
Expand Down
125 changes: 125 additions & 0 deletions crates/mctx/src/scaffold.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
//! TEMPORARY: scaffolding a default `minimal.toml` for empty session workspaces.
//!
//! This is a stop-gap. A session whose workspace has no `minimal.toml` (the
//! workspace-upload gap) cannot build a [`crate::Context`], so [`minimald`]
//! writes a default shell-stack one here on first attach. Remove this module
//! once sessions receive their project files (including a real `minimal.toml`)
//! and minimald no longer needs to fabricate one.
//!
//! [`generate_mfile`] below is a deliberate COPY of
//! `minimal::cmd_init::generate_mfile` (and its `DEFAULT_PKGS` /
//! `DEFAULT_PKGS_BRANCH` / `FALLBACK_STACK` constants). It is duplicated rather
//! than shared on purpose: this whole module is temporary, so keeping it
//! self-contained means deleting one file removes the workaround cleanly,
//! without unpicking a refactor of `cmd_init`. Keep the two in sync only if it
//! is cheap to; otherwise let this copy lag — it is on its way out.

use std::path::{Path, PathBuf};

use common::SpecOrigin;
use common::repo_spec::Repo;

use crate::{Config, Context, Error};

/// TEMPORARY (see module docs): default upstream package source.
const DEFAULT_PKGS: &str = "https://github.com/gominimal/pkgs";
/// TEMPORARY (see module docs): branch tracked for [`DEFAULT_PKGS`].
const DEFAULT_PKGS_BRANCH: &str = "main";
/// TEMPORARY (see module docs): stack used when no matcher applies.
const FALLBACK_STACK: &str = "shell";

/// TEMPORARY (see module docs): resolve the latest [`DEFAULT_PKGS_BRANCH`]
/// commit and write a default shell `minimal.toml` into `repo_dir`, returning
/// the written path. Used to bootstrap a session workspace that has none.
pub fn scaffold_default_mfile(
config: &Config,
repo_dir: impl AsRef<Path>,
) -> Result<PathBuf, Error> {
let (mut vcs, _cache, _stdlib) = Context::sub_setup(config)?;
let (_dir, rev) = vcs.checkout_of(
DEFAULT_PKGS,
checkouts::GitRef::Branch(DEFAULT_PKGS_BRANCH.to_string()),
)?;

let origin = SpecOrigin::Repo(Repo::Git {
url: DEFAULT_PKGS.to_string(),
rev,
tracking: Some(common::repo_spec::GitRef::Branch(
DEFAULT_PKGS_BRANCH.to_string(),
)),
});

let content = generate_mfile(None, &origin);
let path = repo_dir.as_ref().join(mfile::MFILE_NAME);
std::fs::write(&path, &content)
.map_err(|e| Error::IO("writing scaffolded minimal.toml", path.clone(), e))?;
Ok(path)
}

/// TEMPORARY (see module docs): a verbatim copy of
/// `minimal::cmd_init::generate_mfile`. Renders a `minimal.toml` for the given
/// upstream `origin`; `matched_stack` falls back to the `shell` stack here since
/// minimald does no project auto-detection.
fn generate_mfile(
matched_stack: Option<(String, Vec<String>, Vec<String>)>,
origin: &SpecOrigin,
) -> String {
// Fall back to the "shell" stack if no project-specific matcher hit.
let (stack_name, build_packages, runtime_packages) = matched_stack
.map(|h| (h.0, h.1, h.2))
.unwrap_or_else(|| (FALLBACK_STACK.to_string(), vec![], vec![]));

let mut content = String::new();
content.push_str("# Generated by `minimal init`\n\n");

// [upstream]
content.push_str("[upstream] # Source of software & tooling\n");
match &origin {
SpecOrigin::Repo(Repo::Git { url, rev, tracking }) => {
content.push_str(&format!("repo = \"{}\"\n", url));
if let Some(common::repo_spec::GitRef::Branch(b)) = tracking {
content.push_str(&format!("bran 10000 ch = \"{}\"\n", b));
}
content.push_str(&format!("locked_commit = \"{}\"\n", rev,));
}
SpecOrigin::LocalDir { given, .. } => {
content.push_str(&format!("dir = \"{}\"\n", given.to_str().unwrap()))
}
SpecOrigin::Inline => unreachable!(),
}
content.push('\n');

content.push_str("[stack]\n");
content.push_str(&format!("use = \"{}\"\n", stack_name));
if !build_packages.is_empty() {
content.push_str(&format!(
"build_packages = [{}]\n",
build_packages
.into_iter()
.map(|s| format!("\"{}\"", s))
.collect::<Vec<_>>()
.join(", ")
));
}
if !runtime_packages.is_empty() {
content.push_str(&format!(
"runtime_packages = [{}]\n",
runtime_packages
.into_iter()
.map(|s| format!("\"{}\"", s))
.collect::<Vec<_>>()
.join(", ")
));
}

content.push('\n');

content.push_str("[defaults]\n");
content.push_str("state_key = \"dev\"\n");
content.push('\n');
content.push_str("[tasks.shell]\n");
content.push_str("interactive = true\n");
content.push_str("packages = [\"base\"]\n");
content.push_str("exec = \"bash --noprofile -l\"\n");
content
}
33 changes: 27 additions & 6 deletions crates/minimal2/src/autospawn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,31 @@ use std::time::{Duration, Instant};
use minvmd::lifecycle::Lifecycle;

/// Default timeout in seconds to wait for the UDS when spawning minvmd (R4.5).
///
/// A cold VM boot (kernel bring-up + the in-VM minimald init: mount,
/// pivot_root, networking) was observed at ~22–28s before the bridge socket
/// accepts, and is slower under host load, so the default carries margin above
/// minvmd's own READY wait. This is an upper bound — the spawn returns as soon
/// as the UDS is ready — so it only costs time on a genuinely slow/failed boot.
/// Override with [`SPAWN_TIMEOUT_ENV`].
#[cfg(any(target_os = "macos", target_os = "linux"))]
const DEFAULT_SPAWN_TIMEOUT_SECS: u64 = 75;

/// Environment variable overriding [`DEFAULT_SPAWN_TIMEOUT_SECS`].
#[cfg(any(target_os = "macos", target_os = "linux"))]
const DEFAULT_SPAWN_TIMEOUT_SECS: u64 = 8;
const SPAWN_TIMEOUT_ENV: &str = "MINIMAL_SPAWN_TIMEOUT_SECS";

/// The minvmd UDS-readiness wait, overridable via [`SPAWN_TIMEOUT_ENV`]. A
/// non-numeric, empty, or zero value falls back to [`DEFAULT_SPAWN_TIMEOUT_SECS`]
/// — a zero timeout would give the boot no readiness window.
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn spawn_timeout_secs() -> u64 {
std::env::var(SPAWN_TIMEOUT_ENV)
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.filter(|&secs| secs > 0)
.unwrap_or(DEFAULT_SPAWN_TIMEOUT_SECS)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

/// Poll interval while waiting for a shutting-down minvmd to reach a terminal
/// state before deciding whether to spawn.
Expand Down Expand Up @@ -104,15 +127,13 @@ pub fn ensure_minvmd_running() -> io::Result<()> {
}

// Not running; spawn minvmd run --detach with timeout (R4.5).
tracing::info!(
"spawning minvmd run --detach with timeout {}",
DEFAULT_SPAWN_TIMEOUT_SECS
);
let timeout_secs = spawn_timeout_secs();
tracing::info!("spawning minvmd run --detach with timeout {timeout_secs}");
let output = Command::new("minvmd")
.arg("run")
.arg("--detach")
.arg("--timeout")
.arg(DEFAULT_SPAWN_TIMEOUT_SECS.to_string())
.arg(timeout_secs.to_string())
.output()
.map_err(|e| io::Error::new(e.kind(), format!("failed to spawn minvmd: {}", e)))?;

Expand Down
133 changes: 126 additions & 7 deletions crates/minimal2/src/main.rs
D4FE
Original file line number Diff line number Diff line change
Expand Up @@ -164,11 +164,71 @@ struct ActivateArgs {
/// Project path to activate (defaults to current directory)
#[arg(default_value = ".")]
path: String,
/// Network mode: no-net, host-net (default), or own-ip.
#[arg(long, value_enum, default_value_t = CliNetworkMode::HostNet)]
network: CliNetworkMode,
/// Static ingress port mapping `EXT:INT[/PROTO]` (PROTO = tcp|udp, default
/// tcp). Repeatable. Requires `--network own-ip`.
#[arg(long = "ingress", value_name = "EXT:INT[/PROTO]")]
ingress: Vec<String>,
/// Automatically attach after creation
#[arg(long)]
attach: bool,
}

/// CLI surface for [`sessions::NetworkMode`]. A local `ValueEnum` keeps the
/// `sessions` crate free of a clap dependency.
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
enum CliNetworkMode {
NoNet,
HostNet,
OwnIp,
}

impl From<CliNetworkMode> for sessions::NetworkMode {
fn from(m: CliNetworkMode) -> Self {
match m {
CliNetworkMode::NoNet => sessions::NetworkMode::NoNet,
CliNetworkMode::HostNet => sessions::NetworkMode::HostNet,
CliNetworkMode::OwnIp => sessions::NetworkMode::OwnIp,
}
}
}

/// Parse an `--ingress EXT:INT[/PROTO]` spec into a [`sessions::PortMapping`].
/// PROTO defaults to tcp; only tcp/udp are accepted (gvproxy's static forwarder
/// exposes no other transport).
fn parse_ingress_mapping(spec: &str) -> Result<sessions::PortMapping, String> {
let (ports, proto) = match spec.split_once('/') {
Some((ports, proto)) => (ports, parse_ingress_proto(proto)?),
None => (spec, sessions::IpProto::Tcp),
};
let (ext, int) = ports
.split_once(':')
.ok_or_else(|| format!("ingress '{spec}': expected EXT:INT[/PROTO]"))?;
let external_port = ext
.parse::<u16>()
.map_err(|_| format!("ingress '{spec}': invalid external port '{ext}'"))?;
let internal_port = int
.parse::<u16>()
.map_err(|_| format!("ingress '{spec}': invalid internal port '{int}'"))?;
Ok(sessions::PortMapping {
external_port,
internal_port,
proto,
})
}

fn parse_ingress_proto(proto: &str) -> Result<sessions::IpProto, String> {
match proto.to_ascii_lowercase().as_str() {
"tcp" => Ok(sessions::IpProto::Tcp),
"udp" => Ok(sessions::IpProto::Udp),
other => Err(format!(
"ingress: unsupported protocol '{other}' (use tcp or udp)"
)),
}
}

#[derive(Debug, Args)]
struct AttachArgs {
/// Session identifier (UUID or session name)
Expand Down Expand Up @@ -384,13 +444,31 @@ async fn cmd_activate(global: &GlobalArgs, args: ActivateArgs) -> Result<(), ()>
.or_else(|_| std::env::var("LOGNAME"))
.ok();

let mut port_mappings = Vec::with_capacity(args.ingress.len());
for spec in &args.ingress {
match parse_ingress_mapping(spec) {
Ok(mapping) => port_mappings.push(mapping),
Err(e) => {
eprintln!("{e}");
return Err(());
}
}
}
let policy = sessions::SessionPolicy {
egress: None,
ingress: (!port_mappings.is_empty()).then_some(sessions::IngressPolicy {
port_mappings,
dynamic_allowed_range: None,
}),
};

let record = sessions::Record {
id: sessions::SessionId::nil(),
name: args.name.clone(),
username,
project_path: abs_path,
network: sessions::NetworkMode::default(),
policy: Default::default(),
network: args.network.into(),
policy,
attrs: Default::default(),
};

Expand All @@ -403,10 +481,13 @@ async fn cmd_activate(global: &GlobalArgs, args: ActivateArgs) -> Result<(), ()>
.await
.map_err(|e| eprintln!("CreateSession RPC failed: {e}"))?;

let created = match resp.ok() {
Some(r) => r,
None => {
eprintln!("CreateSession returned an error from the daemon");
// Surface the daemon's typed policy/network-mode validation error (e.g.
// ingress on a non-own-ip session, privileged host port) rather than a
// generic failure line.
let created = match resp {
minimald_rpc::Errorable::Ok(r) => r,
minimald_rpc::Errorable::Err { error } => {
eprintln!("CreateSession failed: {error}");
return Err(());
}
};
Expand Down Expand Up @@ -494,9 +575,19 @@ async fn cmd_attach(global: &GlobalArgs, args: AttachArgs) -> Result<(), ()> {
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"local-0",
]);

// The interactive path opens the in-sandbox session shell via the daemon's
// `shell_request`, which requires a PTY. Force one with `-tt` so the shell
// works even when our stdin is not a tty (e.g. driven from a script for
// automated networking tests); without it ssh skips the PTY and the daemon
// rejects the shell. The `--command` path is a non-interactive exec and
// needs no PTY.
if args.command.is_none() {
ssh.arg("-tt");
}
ssh.arg("local-0");

// If a command was provided, pass it to ssh (non-interactive exec).
// Otherwise, ssh opens an interactive shell via shell_request.
if let Some(ref cmd) = args.command {
Expand Down Expand Up @@ -890,3 +981,31 @@ async fn cmd_login(global: &GlobalArgs, args: LoginArgs) -> Result<(), ()> {

Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn ingress_spec_defaults_to_tcp() {
let m = parse_ingress_mapping("18080:80").unwrap();
assert_eq!(m.external_port, 18080);
assert_eq!(m.internal_port, 80);
assert_eq!(m.proto, sessions::IpProto::Tcp);
}

#[test]
fn ingress_spec_parses_explicit_proto() {
let m = parse_ingress_mapping("5353:53/udp").unwrap();
assert_eq!(m.external_port, 5353);
assert_eq!(m.internal_port, 53);
assert_eq!(m.proto, sessions::IpProto::Udp);
}

#[test]
fn ingress_spec_rejects_malformed_and_bad_proto() {
assert!(parse_ingress_mapping("18080").is_err());
assert!(parse_ingress_mapping("notaport:80").is_err());
assert!(parse_ingress_mapping("18080:80/icmp").is_err());
}
}
4 changes: 4 additions & 0 deletions crates/minimald-rpc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ use sessions::SessionId;

pub use sessions::{EgressPolicy, IngressPolicy, IpProto, NetworkMode, PortMapping, SessionPolicy};

/// Shared gvproxy-switch primitives (subnet arithmetic, MAC derivation, wire
/// constants, `-config` rendering) used by both `minimald` and `minvmd`.
pub mod switch;

pub const RPC_SUBSYSTEM_PREFIX: &str = "minimald-v1-";

/// Describes a minimal-specific RPC method sent over ssh.
Expand Down
Loading
Loading
0