Skip to content
Open
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
42 changes: 42 additions & 0 deletions cmd/bbox/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"os/signal"
"path/filepath"
"slices"
"strconv"
"strings"
"syscall"
"time"
Expand Down Expand Up @@ -54,6 +55,7 @@ import (
"github.com/stacklok/brood-box/pkg/domain/egress"
"github.com/stacklok/brood-box/pkg/domain/progress"
"github.com/stacklok/brood-box/pkg/domain/snapshot"
domvm "github.com/stacklok/brood-box/pkg/domain/vm"
"github.com/stacklok/brood-box/pkg/domain/workspace"
"github.com/stacklok/brood-box/pkg/sandbox"
)
Expand Down Expand Up @@ -103,6 +105,7 @@ func rootCmd() *cobra.Command {
timings bool
exec string
envForward []string
ports []string
)

cmd := &cobra.Command{
Expand Down Expand Up @@ -178,6 +181,7 @@ Example:
exec: exec,
commandArgs: commandArgs,
envForward: envForward,
ports: ports,
})
},
SilenceUsage: true,
Expand All @@ -189,6 +193,7 @@ Example:
cmd.Flags().StringVar(&tmpSize, "tmp-size", "", "Size of /tmp tmpfs inside the VM, e.g. 512m or 2g (empty = agent default)")
cmd.Flags().StringVar(&wsPath, "workspace", "", "Workspace directory to mount (default: current directory)")
cmd.Flags().Uint16Var(&sshPort, "ssh-port", 0, "Host SSH port (0 = auto-pick)")
cmd.Flags().StringSliceVar(&ports, "port", nil, "Forward an additional TCP port from guest to host as HOST:GUEST, bound to 127.0.0.1 (repeatable)")
cmd.Flags().StringVar(&cfgPath, "config", "", "Config file path (default: ~/.config/broodbox/config.yaml)")
cmd.Flags().StringVar(&image, "image", "", "Override OCI image reference")
cmd.Flags().BoolVar(&debug, "debug", false, "Enable debug-level logging to file (default: info level)")
Expand Down Expand Up @@ -376,6 +381,7 @@ type runFlags struct {
exec string
commandArgs []string
envForward []string
ports []string
}

func run(parentCtx context.Context, agentName string, flags runFlags) error {
Expand All @@ -401,6 +407,11 @@ func run(parentCtx context.Context, agentName string, flags runFlags) error {
return fmt.Errorf("invalid --env-forward: %w", err)
}

parsedPorts, portsErr := parsePortForwards(flags.ports)
if portsErr != nil {
return portsErr
}

// --no-image-cache + --pull=never is a contradiction: "never" requires the
// cache to serve hits, but "no-image-cache" disables it entirely.
if flags.noImageCache && flags.pull == domainconfig.PullNever {
Expand Down Expand Up @@ -938,6 +949,7 @@ func run(parentCtx context.Context, agentName string, flags runFlags) error {
TmpSizeMiB: tmpSizeMiB,
Workspace: ws,
SSHPort: flags.sshPort,
ExtraPorts: parsedPorts,
ImageOverride: flags.image,
EgressProfile: flags.egressProfile,
AllowHosts: parsedAllowHosts,
Expand Down Expand Up @@ -1428,6 +1440,36 @@ func imageCacheDir() (string, error) {
// this notice, `git submodule add` would silently half-land: the entry
// appears in `.gitmodules` on the host, but there is no populated
// `.git/modules/<name>/` to back it.
// parsePortForwards parses repeated --port HOST:GUEST values into PortForward
// entries. Rejects malformed input, port 0, and duplicate host ports.
func parsePortForwards(specs []string) ([]domvm.PortForward, error) {
if len(specs) == 0 {
return nil, nil
}
out := make([]domvm.PortForward, 0, len(specs))
seenHost := make(map[uint16]struct{}, len(specs))
for _, spec := range specs {
host, guest, ok := strings.Cut(spec, ":")
if !ok {
return nil, fmt.Errorf("--port %q: expected HOST:GUEST", spec)
}
hostPort, err := strconv.ParseUint(strings.TrimSpace(host), 10, 16)
if err != nil || hostPort == 0 {
return nil, fmt.Errorf("--port %q: host port must be 1-65535", spec)
}
guestPort, err := strconv.ParseUint(strings.TrimSpace(guest), 10, 16)
if err != nil || guestPort == 0 {
return nil, fmt.Errorf("--port %q: guest port must be 1-65535", spec)
}
if _, dup := seenHost[uint16(hostPort)]; dup {
return nil, fmt.Errorf("--port: host port %d used more than once", hostPort)
}
seenHost[uint16(hostPort)] = struct{}{}
out = append(out, domvm.PortForward{Host: uint16(hostPort), Guest: uint16(guestPort)})
}
return out, nil
}

func maybePrintSubmoduleHint(w io.Writer, accepted []snapshot.FileChange) {
for _, ch := range accepted {
if filepath.Base(ch.RelPath) == ".gitmodules" {
Expand Down
13 changes: 12 additions & 1 deletion internal/infra/vm/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ func (r *MicroVMRunner) Start(ctx context.Context, cfg domvm.VMConfig) (domvm.VM
microvm.WithMemory(cfg.Memory.MiB()),
microvm.WithLogLevel(cfg.LogLevel),
microvm.WithTmpSize(cfg.TmpSize.MiB()),
microvm.WithPorts(microvm.PortForward{Host: sshPort, Guest: 22}),
microvm.WithPorts(buildPortForwards(sshPort, cfg.ExtraPorts)...),
microvm.WithRootFSHook(
hooks.InjectAuthorizedKeys(pubKey),
hooks.InjectFile("/etc/ssh/ssh_host_ecdsa_key", hostKeyPEM, 0o600),
Expand Down Expand Up @@ -463,3 +463,14 @@ func pickFreePort() (uint16, error) {
}
return port, nil
}

// buildPortForwards returns the SSH forward followed by any extra forwards.
// The SSH forward (host -> guest:22) is always first.
func buildPortForwards(sshPort uint16, extra []domvm.PortForward) []microvm.PortForward {
out := make([]microvm.PortForward, 0, 1+len(extra))
out = append(out, microvm.PortForward{Host: sshPort, Guest: 22})
for _, pf := range extra {
out = append(out, microvm.PortForward{Host: pf.Host, Guest: pf.Guest})
}
return out
}
11 changes: 11 additions & 0 deletions pkg/domain/vm/vm.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,17 @@ type VMConfig struct {
// Valid values: "always", "if-not-present", "never".
// Empty defaults to "if-not-present".
PullPolicy string

// ExtraPorts are additional host->guest TCP forwards in addition to SSH.
// Host side always binds 127.0.0.1 (enforced by the runtime).
ExtraPorts []PortForward
}

// PortForward maps a host TCP port to a guest TCP port.
// Host side always binds 127.0.0.1.
type PortForward struct {
Host uint16
Guest uint16
}

// HostService describes an HTTP service exposed from host to guest.
Expand Down
4 changes: 4 additions & 0 deletions pkg/sandbox/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ type RunOpts struct {
// SSHPort is the host port for SSH (0 = auto-pick).
SSHPort uint16

// ExtraPorts are additional host->guest TCP forwards beyond SSH.
ExtraPorts []domvm.PortForward

// ImageOverride overrides the agent's OCI image reference.
ImageOverride string

Expand Down Expand Up @@ -530,6 +533,7 @@ func (s *SandboxRunner) Prepare(ctx context.Context, agentName string, opts RunO
CPUs: ag.DefaultCPUs,
Memory: ag.DefaultMemory,
SSHPort: opts.SSHPort,
ExtraPorts: opts.ExtraPorts,
WorkspacePath: workspacePath,
EnvVars: envVars,
EgressPolicy: egressPolicy,
Expand Down