Files
firecracker-orchestrator/orchestrator/network.go
Honza Novak d0da012a82 feat: show golden VM tag in clone list, add console logging, fix ubuntu boot
- Persist golden VM tag to clones/{id}/tag at spawn time
- GET /clones now returns [{id, tag}] objects instead of plain IDs
- Web UI renders tag as a dim label next to each clone entry (clone 3 · default)
- Pre-existing fixes included in this commit:
  - console: tee all PTY output to clones/{id}/console.log for boot capture
  - network: destroy stale tap before recreating to avoid EBUSY errors
  - orchestrator: fix ubuntu systemd boot (custom fc-console.service, fstab,
    mask serial-getty udev dep, longer settle time, correct package list)
  - config: remove quiet/loglevel=0 from default boot args

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 22:04:11 +00:00

132 lines
4.0 KiB
Go

package orchestrator
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os/exec"
"strings"
)
// setupBridge creates the host bridge + enables NAT if it doesn't exist.
func (o *Orchestrator) setupBridge() error {
if o.cfg.Bridge == "none" {
return nil
}
// check if bridge already exists
if err := run("ip", "link", "show", o.cfg.Bridge); err == nil {
return nil // already up
}
if err := run("ip", "link", "add", o.cfg.Bridge, "type", "bridge"); err != nil {
return fmt.Errorf("create bridge: %w", err)
}
if err := run("ip", "addr", "add", o.cfg.BridgeCIDR, "dev", o.cfg.Bridge); err != nil {
return fmt.Errorf("add bridge addr: %w", err)
}
if err := run("ip", "link", "set", o.cfg.Bridge, "up"); err != nil {
return fmt.Errorf("bring bridge up: %w", err)
}
// find default route interface for NAT
out, err := exec.Command("ip", "-4", "route", "show", "default").Output()
if err == nil {
fields := strings.Fields(string(out))
for i, f := range fields {
if f == "dev" && i+1 < len(fields) {
iface := fields[i+1]
_ = run("sysctl", "-qw", "net.ipv4.ip_forward=1")
// idempotent: ignore error if rule exists
_ = run("iptables", "-t", "nat", "-A", "POSTROUTING",
"-o", iface, "-j", "MASQUERADE")
break
}
}
}
o.log.Infof("bridge %s up on %s", o.cfg.Bridge, o.cfg.BridgeCIDR)
return nil
}
// createTap creates a tap device and attaches it to the bridge.
func (o *Orchestrator) createTap(name string) error {
// Destroy any stale tap with this name before (re)creating it.
_ = run("ip", "link", "del", name)
if err := run("ip", "tuntap", "add", "dev", name, "mode", "tap"); err != nil {
return fmt.Errorf("create tap %s: %w", name, err)
}
if err := run("ip", "link", "set", name, "up"); err != nil {
return fmt.Errorf("bring tap %s up: %w", name, err)
}
if o.cfg.Bridge != "none" {
if err := run("ip", "link", "set", name, "master", o.cfg.Bridge); err != nil {
return fmt.Errorf("attach tap %s to bridge: %w", name, err)
}
}
return nil
}
func destroyTap(name string) {
_ = run("ip", "link", "del", name)
}
// configureMmds writes per-clone IP config to the Firecracker MMDS so that
// the fc-net-init daemon running inside the guest can read and apply it.
// It makes two API calls to the Firecracker Unix socket:
//
// 1. PUT /mmds/config — associates MMDS with the guest's first NIC ("1")
// 2. PUT /mmds — stores ip/gw/dns values the guest daemon will read
func configureMmds(ctx context.Context, sockPath, ip, gw, dns string) error {
httpClient := &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return net.Dial("unix", sockPath)
},
},
}
doJSON := func(method, path string, body any) error {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal %s: %w", path, err)
}
req, err := http.NewRequestWithContext(ctx, method,
"http://localhost"+path, bytes.NewReader(data))
if err != nil {
return fmt.Errorf("build request %s: %w", path, err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("%s %s: %w", method, path, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("%s %s failed (%d): %s", method, path, resp.StatusCode, b)
}
return nil
}
// 1. MMDS configuration (version, network_interfaces binding, etc.) is
// persisted in the golden snapshot, so we don't need to configure it here.
// In fact, Firecracker will reject PUT /mmds/config with a 400 error
// on a restored VM, which previously caused this function to abort early.
// 2. Store the network config the guest daemon will poll for.
return doJSON(http.MethodPut, "/mmds", map[string]string{
"ip": ip,
"gw": gw,
"dns": dns,
})
}
// run executes a command, returning an error if it fails.
func run(name string, args ...string) error {
return exec.Command(name, args...).Run()
}