Skip to content

Commit 113168e

Browse files
feat: add -p/--publish port forwarding to vz run
Wire port forwarding through the full stack: CLI flag, vz.json `ports` field, proto CreateSandboxRequest, daemon handler, and into the existing boot_shared_vm port forwarding infrastructure. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bfd8714 commit 113168e

File tree

7 files changed

+143
-3
lines changed

7 files changed

+143
-3
lines changed

crates/vz-cli/src/commands/dev.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ pub struct DevRunArgs {
5050
#[arg(short, long)]
5151
pub env: Vec<String>,
5252

53+
/// Publish a container port to the host (HOST:CONTAINER[/PROTO]).
54+
#[arg(short = 'p', long = "publish")]
55+
pub publish: Vec<String>,
56+
5357
/// Force fresh VM (stop existing, re-provision).
5458
#[arg(long)]
5559
pub fresh: bool,
@@ -94,6 +98,10 @@ struct VzConfig {
9498
#[serde(default)]
9599
env: BTreeMap<String, String>,
96100

101+
/// Port mappings (HOST:CONTAINER or HOST:CONTAINER/PROTO).
102+
#[serde(default)]
103+
ports: Vec<String>,
104+
97105
/// Resource limits.
98106
#[serde(default)]
99107
resources: ResourceConfig,
@@ -140,6 +148,11 @@ pub async fn cmd_run(args: DevRunArgs) -> anyhow::Result<()> {
140148

141149
let volume_mounts = build_volume_mounts(&config, &project_dir)?;
142150

151+
// Merge ports from vz.json and CLI -p flags.
152+
let mut all_port_specs = config.ports.clone();
153+
all_port_specs.extend(args.publish.iter().cloned());
154+
let port_mappings = parse_port_mappings(&all_port_specs)?;
155+
143156
// --fresh: delete persistent disk so the container starts with a clean filesystem.
144157
if args.fresh {
145158
let run_dir = home_dir()?.join(".vz").join("run").join(&sandbox_id);
@@ -226,6 +239,7 @@ pub async fn cmd_run(args: DevRunArgs) -> anyhow::Result<()> {
226239
labels,
227240
volume_mounts: proto_mounts,
228241
disk_image_path: disk_image_path.to_string_lossy().to_string(),
242+
port_mappings: port_mappings.clone(),
229243
})
230244
.await
231245
.context("failed to create sandbox via daemon")?;
@@ -250,6 +264,12 @@ pub async fn cmd_run(args: DevRunArgs) -> anyhow::Result<()> {
250264
}
251265

252266
eprintln!("VM ready.");
267+
for pm in &port_mappings {
268+
eprintln!(
269+
" Port {} -> {} ({})",
270+
pm.host_port, pm.container_port, pm.protocol
271+
);
272+
}
253273

254274
// Run setup commands if needed.
255275
// When --fresh, force re-run by clearing any cached hashes.
@@ -956,6 +976,52 @@ fn is_terminal_state_error(error: &DaemonClientError) -> bool {
956976
)
957977
}
958978

979+
fn parse_port_mappings(
980+
specs: &[String],
981+
) -> anyhow::Result<Vec<runtime_v2::PortMapping>> {
982+
specs.iter().map(|s| parse_port_mapping(s)).collect()
983+
}
984+
985+
fn parse_port_mapping(spec: &str) -> anyhow::Result<runtime_v2::PortMapping> {
986+
let (ports_part, protocol) = match spec.split_once('/') {
987+
Some((ports, proto)) => (ports, proto.to_ascii_lowercase()),
988+
None => (spec, "tcp".to_string()),
989+
};
990+
991+
if protocol != "tcp" && protocol != "udp" {
992+
bail!(
993+
"invalid -p protocol '{protocol}' in '{spec}', expected tcp or udp"
994+
);
995+
}
996+
997+
let mut parts = ports_part.split(':');
998+
let host_str = parts
999+
.next()
1000+
.context("invalid -p value, expected HOST:CONTAINER[/PROTO]")?;
1001+
let container_str = parts
1002+
.next()
1003+
.with_context(|| format!("invalid -p value '{spec}', expected HOST:CONTAINER[/PROTO]"))?;
1004+
1005+
if parts.next().is_some() {
1006+
bail!(
1007+
"invalid -p value '{spec}', host IP is not supported yet; expected HOST:CONTAINER[/PROTO]"
1008+
);
1009+
}
1010+
1011+
let host_port = host_str
1012+
.parse::<u32>()
1013+
.with_context(|| format!("invalid host port '{host_str}' in -p '{spec}'"))?;
1014+
let container_port = container_str
1015+
.parse::<u32>()
1016+
.with_context(|| format!("invalid container port '{container_str}' in -p '{spec}'"))?;
1017+
1018+
Ok(runtime_v2::PortMapping {
1019+
host_port,
1020+
container_port,
1021+
protocol,
1022+
})
1023+
}
1024+
9591025
fn parse_memory(raw: Option<&str>) -> anyhow::Result<u64> {
9601026
match raw {
9611027
None => Ok(8192),

crates/vz-runtime-proto/proto/runtime_v2.proto

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,18 @@ message CreateSandboxRequest {
6161
repeated VolumeMount volume_mounts = 6;
6262
// Optional path to a persistent disk image (ext4) attached as VirtioBlock.
6363
string disk_image_path = 7;
64+
// Host-to-container port mappings for port forwarding.
65+
repeated PortMapping port_mappings = 8;
66+
}
67+
68+
// Host-to-container port mapping for port forwarding.
69+
message PortMapping {
70+
// Host port to listen on.
71+
uint32 host_port = 1;
72+
// Container port to forward to.
73+
uint32 container_port = 2;
74+
// Protocol: "tcp" or "udp".
75+
string protocol = 3;
6476
}
6577

6678
// A host directory to expose inside the shared VM via VirtioFS.

crates/vz-runtime-proto/src/generated/vz.runtime.v2.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,22 @@ pub struct CreateSandboxRequest {
4242
/// Optional path to a persistent disk image (ext4) attached as VirtioBlock.
4343
#[prost(string, tag = "7")]
4444
pub disk_image_path: ::prost::alloc::string::String,
45+
/// Host-to-container port mappings for port forwarding.
46+
#[prost(message, repeated, tag = "8")]
47+
pub port_mappings: ::prost::alloc::vec::Vec<PortMapping>,
48+
}
49+
/// Host-to-container port mapping for port forwarding.
50+
#[derive(Clone, PartialEq, ::prost::Message)]
51+
pub struct PortMapping {
52+
/// Host port to listen on.
53+
#[prost(uint32, tag = "1")]
54+
pub host_port: u32,
55+
/// Container port to forward to.
56+
#[prost(uint32, tag = "2")]
57+
pub container_port: u32,
58+
/// Protocol: "tcp" or "udp".
59+
#[prost(string, tag = "3")]
60+
pub protocol: ::prost::alloc::string::String,
4561
}
4662
/// A host directory to expose inside the shared VM via VirtioFS.
4763
#[derive(Clone, PartialEq, ::prost::Message)]

crates/vz-runtimed-client/src/sandbox.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,7 @@ impl DaemonClient {
356356
labels: HashMap::new(),
357357
volume_mounts: Vec::new(),
358358
disk_image_path: String::new(),
359+
port_mappings: Vec::new(),
359360
};
360361
let _ = self.create_sandbox(request).await?;
361362
Ok(())

crates/vz-runtimed/src/grpc/handlers/sandbox/mod.rs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ use std::path::{Path, PathBuf};
99
#[cfg(target_os = "linux")]
1010
use std::process::Command;
1111
use vz_runtime_contract::{
12-
RuntimeBackend, SPACE_CACHE_KEY_SCHEMA_VERSION, SpaceCacheIndex, SpaceCacheKey,
13-
SpaceCacheLookup, SpaceRemoteCacheTrustConfig, SpaceRemoteCacheVerificationOutcome,
12+
PortMapping as ContractPortMapping, PortProtocol, RuntimeBackend,
13+
SPACE_CACHE_KEY_SCHEMA_VERSION, SpaceCacheIndex, SpaceCacheKey, SpaceCacheLookup,
14+
SpaceRemoteCacheTrustConfig, SpaceRemoteCacheVerificationOutcome,
1415
SpaceRemoteCacheVerifiedArtifact, StackResourceHint, StackVolumeMount,
1516
};
1617
use vz_runtime_proto::runtime_v2::container_service_server::ContainerService as _;
@@ -800,6 +801,7 @@ async fn boot_runtime_sandbox_resources(
800801
labels: &BTreeMap<String, String>,
801802
explicit_mounts: &[vz_runtime_proto::runtime_v2::VolumeMount],
802803
disk_image_path: Option<std::path::PathBuf>,
804+
port_mappings: &[vz_runtime_proto::runtime_v2::PortMapping],
803805
request_id: &str,
804806
) -> Result<(), Status> {
805807
let mut volume_mounts = Vec::new();
@@ -855,10 +857,23 @@ async fn boot_runtime_sandbox_resources(
855857
disk_image_path,
856858
};
857859

860+
let ports: Vec<ContractPortMapping> = port_mappings
861+
.iter()
862+
.map(|pm| ContractPortMapping {
863+
host: pm.host_port as u16,
864+
container: pm.container_port as u16,
865+
protocol: match pm.protocol.as_str() {
866+
"udp" => PortProtocol::Udp,
867+
_ => PortProtocol::Tcp,
868+
},
869+
target_host: None,
870+
})
871+
.collect();
872+
858873
match daemon
859874
.manager()
860875
.backend()
861-
.boot_shared_vm(sandbox_id, Vec::new(), resources)
876+
.boot_shared_vm(sandbox_id, ports, resources)
862877
.await
863878
{
864879
Ok(()) => Ok(()),

crates/vz-runtimed/src/grpc/handlers/sandbox/rpc.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ impl runtime_v2::sandbox_service_server::SandboxService for SandboxServiceImpl {
6262
let normalized_idempotency_key = normalize_idempotency_key(idempotency_key.as_deref());
6363
let explicit_volume_mounts = request.volume_mounts;
6464
let explicit_disk_image_path = request.disk_image_path;
65+
let explicit_port_mappings = request.port_mappings;
6566
let mut labels: BTreeMap<String, String> = request.labels.into_iter().collect();
6667
// Requesters cannot predeclare default-source audit labels.
6768
labels.remove(SANDBOX_LABEL_BASE_IMAGE_DEFAULT_SOURCE);
@@ -184,6 +185,7 @@ impl runtime_v2::sandbox_service_server::SandboxService for SandboxServiceImpl {
184185
&labels,
185186
&explicit_volume_mounts,
186187
disk_image_path,
188+
&explicit_port_mappings,
187189
&request_id,
188190
)
189191
.await

0 commit comments

Comments
 (0)