tstest/natlab: add TestSubnetRouterFreeBSD with FreeBSD cloud image support

As a warm-up to making natlab support multiple operating systems,
start with an easy one (in that it's also Unixy and open source like
Linux) and add FreeBSD 15.0 as a VM OS option for the vmtest
integration test framework, and add TestSubnetRouterFreeBSD which
tests subnet routing through a FreeBSD VM (Gokrazy → FreeBSD →
Gokrazy).

Key changes:
- Add FreeBSD150 OSImage using the official FreeBSD 15.0
  BASIC-CLOUDINIT cloud image (xz-compressed qcow2)
- Add GOOS()/IsFreeBSD() methods to OSImage for cross-compilation
  and OS-specific behavior
- Handle xz-compressed image downloads in ensureImage
- Refactor compileBinaries into compileBinariesForOS to support
  multiple GOOS targets (linux, freebsd), with binaries registered
  at <goos>/<name> paths on the file server VIP
- Add FreeBSD-specific cloud-init (nuageinit) user-data generation:
  string-form runcmd (nuageinit doesn't support YAML arrays),
  fetch(1) instead of curl, FreeBSD sysctl names for IP forwarding,
  mkdir /usr/local/bin, PATH setup for tta
- Skip network-config in cidata ISO for FreeBSD (DHCP via rc.conf)

Updates tailscale/tailscale#13038

Change-Id: Ibeb4f7d02659d5cd8e3a7c3a66ee7b1a92a0110d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick
2026-04-09 01:37:43 +00:00
committed by Brad Fitzpatrick
parent 85d6ba9473
commit dca1d8eea1
6 changed files with 163 additions and 39 deletions
+40 -3
View File
@@ -14,17 +14,36 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/ulikunitz/xz"
)
// OSImage describes a VM operating system image.
type OSImage struct {
Name string
URL string // download URL for the cloud image
SHA256 string // expected SHA256 hash of the image
SHA256 string // expected SHA256 hash of the image (of the final qcow2, after any decompression)
MemoryMB int // RAM for the VM
IsGokrazy bool // true for gokrazy images (different QEMU setup)
}
// GOOS returns the Go OS name for this image.
func (img OSImage) GOOS() string {
if img.IsGokrazy {
return "linux"
}
if strings.HasPrefix(img.Name, "freebsd") {
return "freebsd"
}
return "linux"
}
// GOARCH returns the Go architecture name for this image.
func (img OSImage) GOARCH() string {
return "amd64"
}
var (
// Gokrazy is a minimal Tailscale appliance image built from the gokrazy/natlabapp directory.
Gokrazy = OSImage{
@@ -46,6 +65,14 @@ var (
URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2",
MemoryMB: 1024,
}
// FreeBSD150 is FreeBSD 15.0-RELEASE with BASIC-CLOUDINIT (nuageinit) support.
// The image is distributed as xz-compressed qcow2.
FreeBSD150 = OSImage{
Name: "freebsd-15.0",
URL: "https://download.freebsd.org/releases/VM-IMAGES/15.0-RELEASE/amd64/Latest/FreeBSD-15.0-RELEASE-amd64-BASIC-CLOUDINIT-ufs.qcow2.xz",
MemoryMB: 1024,
}
)
// imageCacheDir returns the directory for cached VM images.
@@ -84,6 +111,7 @@ func ensureImage(ctx context.Context, img OSImage) error {
}
}
isXZ := strings.HasSuffix(img.URL, ".xz")
log.Printf("downloading %s from %s...", img.Name, img.URL)
req, err := http.NewRequestWithContext(ctx, "GET", img.URL, nil)
@@ -99,6 +127,16 @@ func ensureImage(ctx context.Context, img OSImage) error {
return fmt.Errorf("downloading %s: HTTP %s", img.Name, resp.Status)
}
// Set up the reader pipeline: HTTP body → (optional xz decompress) → file.
var src io.Reader = resp.Body
if isXZ {
xzr, err := xz.NewReader(resp.Body)
if err != nil {
return fmt.Errorf("creating xz reader for %s: %w", img.Name, err)
}
src = xzr
}
tmpFile := cachedPath + ".tmp"
f, err := os.Create(tmpFile)
if err != nil {
@@ -111,8 +149,7 @@ func ensureImage(ctx context.Context, img OSImage) error {
h := sha256.New()
w := io.MultiWriter(f, h)
if _, err := io.Copy(w, resp.Body); err != nil {
if _, err := io.Copy(w, src); err != nil {
return fmt.Errorf("downloading %s: %w", img.Name, err)
}
if err := f.Close(); err != nil {