Files
tailscale/tstest/natlab/vmtest/images.go
T
Brad Fitzpatrick e062b46984 tstest/natlab, .github/workflows: add opt-in natlab CI workflow
The natlab vmtest suite (tstest/natlab/vmtest) and the integration nat
tests are gated behind --run-vm-tests because they need KVM and are
slow. Until now nothing in CI exercised them apart from a single
canary TestEasyEasy run on every PR.

Add .github/workflows/natlab-test.yml that runs the full opt-in suite
on demand (workflow_dispatch), on PRs labeled "natlab", and on main
every 12 hours via cron. The workflow has two phases:

  - "prepare" builds the gokrazy VM image, downloads the Ubuntu and
    FreeBSD cloud images once via the new natlabprep tool, and emits
    a dynamic JSON matrix of every TestX function it finds in the two
    opt-in packages.
  - "test" is a per-test matrix that depends on prepare. Each matrix
    job restores the shared caches and runs a single test, so adding
    a new TestFoo is automatically picked up on the next run without
    any workflow edits.

Rename the existing natlab-integrationtest.yml to natlab-basic.yml
since it's the small smoke variant (just TestEasyEasy on every PR);
the new natlab-test.yml is the bigger suite. The job inside is
renamed to EasyEasy for the same reason.

Move the macOS arm64 host check from vmtest.Env.Start into
vmtest.Env.AddNode so a test that adds a vmtest.MacOS node skips
immediately on a non-macOS host, and add an explicit
skipIfNotMacOSArm64 helper at the top of the two macOS-only tests
so the platform requirement is obvious to readers.

Quiet the takeAgentConnOne miss log in tstest/natlab/vnet by default
(it was the overwhelming majority of bytes in CI logs, with no signal
in healthy runs) and replace it with a periodic "still waiting" line
that only fires after 10s, so a truly stuck agent connection still
surfaces.

Updates #13038

Change-Id: I4582098d8865200fd5a73a9b696942319ccf3bf0
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-05-11 17:14:46 -07:00

240 lines
6.4 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package vmtest
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"log"
"net/http"
"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 (of the final qcow2, after any decompression)
MemoryMB int // RAM for the VM
IsGokrazy bool // true for gokrazy images (different QEMU setup)
IsMacOS bool // true for macOS images (launched via tailmac, not QEMU)
}
// GOOS returns the Go OS name for this image.
func (img OSImage) GOOS() string {
if img.IsMacOS {
return "darwin"
}
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 {
if img.IsMacOS {
return "arm64"
}
return "amd64"
}
var (
// Gokrazy is a minimal Tailscale appliance image built from the gokrazy/natlabapp directory.
Gokrazy = OSImage{
Name: "gokrazy",
IsGokrazy: true,
MemoryMB: 384,
}
// Ubuntu2404 is Ubuntu 24.04 LTS (Noble Numbat) cloud image.
Ubuntu2404 = OSImage{
Name: "ubuntu-24.04",
URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
MemoryMB: 1024,
}
// Debian12 is Debian 12 (Bookworm) generic cloud image.
Debian12 = OSImage{
Name: "debian-12",
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,
}
// MacOS is a macOS VM launched via tailmac (Apple Virtualization.framework).
// Uses a Tart pre-built base image (ghcr.io/cirruslabs/macos-tahoe-base)
// which is automatically pulled on first use. Only runs on macOS arm64 hosts.
MacOS = OSImage{
Name: "macos",
IsMacOS: true,
MemoryMB: 4096,
}
)
// CloudImages returns the set of QEMU-bootable cloud OS images natlab can
// use for vmtests, excluding gokrazy (built from source) and macOS (which
// uses a separate snapshot pipeline). It is intended for tooling such as
// a CI prep step that wants to warm the image cache.
func CloudImages() []OSImage {
return []OSImage{Ubuntu2404, Debian12, FreeBSD150}
}
// EnsureImage downloads img to the local cache if not already present.
// It is intended for tooling that wants to warm the image cache before
// running natlab vmtests (e.g. a CI prep step). The test framework also
// calls into the package-internal equivalent on demand.
func EnsureImage(ctx context.Context, img OSImage) error {
return ensureImage(ctx, img)
}
// imageCacheDir returns the directory for cached VM images.
func imageCacheDir() string {
if d := os.Getenv("VMTEST_CACHE_DIR"); d != "" {
return d
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".cache", "tailscale", "vmtest", "images")
}
// ensureImage downloads and caches the OS image if not already present.
func ensureImage(ctx context.Context, img OSImage) error {
if img.IsGokrazy {
return nil // gokrazy images are handled separately
}
cacheDir := imageCacheDir()
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return err
}
// Use a filename based on the image name.
cachedPath := filepath.Join(cacheDir, img.Name+".qcow2")
if _, err := os.Stat(cachedPath); err == nil {
// If we have a SHA256 to verify, check it.
if img.SHA256 != "" {
if err := verifySHA256(cachedPath, img.SHA256); err != nil {
log.Printf("cached image %s failed SHA256 check, re-downloading: %v", img.Name, err)
os.Remove(cachedPath)
} else {
return nil
}
} else {
return nil // exists, no hash to verify
}
}
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)
if err != nil {
return fmt.Errorf("downloading %s: %w", img.Name, err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("downloading %s: %w", img.Name, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
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 {
return err
}
defer func() {
f.Close()
os.Remove(tmpFile)
}()
h := sha256.New()
w := io.MultiWriter(f, h)
if _, err := io.Copy(w, src); err != nil {
return fmt.Errorf("downloading %s: %w", img.Name, err)
}
if err := f.Close(); err != nil {
return err
}
if img.SHA256 != "" {
got := hex.EncodeToString(h.Sum(nil))
if got != img.SHA256 {
return fmt.Errorf("SHA256 mismatch for %s: got %s, want %s", img.Name, got, img.SHA256)
}
}
if err := os.Rename(tmpFile, cachedPath); err != nil {
return err
}
log.Printf("downloaded %s", img.Name)
return nil
}
// verifySHA256 checks that the file at path has the expected SHA256 hash.
func verifySHA256(path, expected string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return err
}
got := hex.EncodeToString(h.Sum(nil))
if got != expected {
return fmt.Errorf("got %s, want %s", got, expected)
}
return nil
}
// cachedImagePath returns the filesystem path to the cached image for the given OS.
func cachedImagePath(img OSImage) string {
return filepath.Join(imageCacheDir(), img.Name+".qcow2")
}
// createOverlay creates a qcow2 overlay image on top of the given base image.
func createOverlay(base, overlay string) error {
out, err := exec.Command("qemu-img", "create",
"-f", "qcow2",
"-F", "qcow2",
"-b", base,
overlay).CombinedOutput()
if err != nil {
return fmt.Errorf("qemu-img create overlay: %v: %s", err, out)
}
return nil
}