@@ -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+
9591025fn parse_memory ( raw : Option < & str > ) -> anyhow:: Result < u64 > {
9601026 match raw {
9611027 None => Ok ( 8192 ) ,
0 commit comments