Brad Fitzpatrick 02ffe5baa8 tstest/natlab/vmtest: add macOS VM snapshot caching for fast test starts
Cache a pre-booted macOS VM snapshot on disk so subsequent test runs
restore from the snapshot instead of cold-booting. The snapshot is keyed
by the Tart base image digest and a code version constant
(macOSSnapshotCodeVersion); bumping either invalidates the cache.

Snapshot preparation (one-time):
- Boot the Tart base image with a NAT NIC (--nat-nic flag)
- Wait for SSH, compile and install cmd/tta as a LaunchDaemon
- TTA polls the host via AF_VSOCK for an IP assignment; during prep
  the host replies "wait"
- Disconnect NIC, save VM state via SIGINT

Test fast path (cached, ~7s to agent connected):
- APFS clone the snapshot, write test-specific config.json
- Launch Host.app with --disconnected-nic --attach-network --assign-ip
- VZ restores from SaveFile.vzvmsave (~5s with 4GB RAM)
- TTA's vsock poll gets the IP config, sets static IP via ifconfig
  (bypasses DHCP entirely), switches driver addr to the IP directly
  (bypasses DNS), and resets the dial context so the reverse-dial
  reconnects immediately
- TTA agent connects to test driver within ~2s of IP assignment

Key optimizations:
- 4GB RAM instead of 8GB: halves SaveFile.vzvmsave (1.4GB vs 2.4GB),
  halves restore time (5.5s vs 11s)
- AF_VSOCK IP assignment: bypasses macOS DHCP (~5-7s saved)
- Direct IP dial: bypasses DNS resolution for test-driver.tailscale
- Dial context reset: cancels stale in-flight dials from snapshot
- Kill instead of SIGINT for test VM cleanup (no state save needed)
- Parallel VM launches

Also:
- Add TestDriverIPv4/TestDriverPort constants to vnet
- Add --nat-nic and --assign-ip flags to Host.app
- Fix SIGINT handler: retain DispatchSource globally, use dispatchMain()
- Add vsock listener (port 51011) to Host.app for IP config protocol
- Add disconnectNetwork() to VMController for clean snapshot state
- Fix Makefile: set -o pipefail so xcodebuild failures aren't swallowed

Updates #13038

Change-Id: Icbab73b57af7df3ae96136fb49cda2536310f31b
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-29 08:17:13 -07:00

109 lines
3.2 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package vnet
import (
"fmt"
"net/netip"
)
var vips = map[string]virtualIP{} // DNS name => details
var (
fakeDNS = newVIP("dns", "4.11.4.11", "2411::411")
fakeProxyControlplane = newVIP("controlplane.tailscale.com", 1)
fakeTestAgent = newVIP("test-driver.tailscale", 2)
fakeControl = newVIP("control.tailscale", 3)
fakeDERP1 = newVIP("derp1.tailscale", "33.4.0.1") // 3340=DERP; 1=derp 1
fakeDERP2 = newVIP("derp2.tailscale", "33.4.0.2") // 3340=DERP; 2=derp 2
fakeLogCatcher = newVIP("log.tailscale.com", 4)
fakeSyslog = newVIP("syslog.tailscale", 9)
fakeCloudInit = newVIP("cloud-init.tailscale", 5) // serves cloud-init metadata/userdata per node
fakeFiles = newVIP("files.tailscale", 6) // serves binary files (tta, tailscale, tailscaled) to VMs
)
type virtualIP struct {
name string // for DNS
v4 netip.Addr
v6 netip.Addr
}
func (v virtualIP) Match(a netip.Addr) bool {
return v.v4 == a.Unmap() || v.v6 == a
}
// TestDriverIPv4 returns the IPv4 address of the test driver VIP (52.52.0.2).
// TTA agents dial this IP on port TestDriverPort to connect to the test harness.
func TestDriverIPv4() netip.Addr { return fakeTestAgent.v4 }
// TestDriverPort is the port the test driver listens on.
const TestDriverPort = 8008
// FakeDNSIPv4 returns the fake DNS IPv4 address.
func FakeDNSIPv4() netip.Addr { return fakeDNS.v4 }
// FakeDNSIPv6 returns the fake DNS IPv6 address.
func FakeDNSIPv6() netip.Addr { return fakeDNS.v6 }
// FakeSyslogIPv4 returns the fake syslog IPv4 address.
func FakeSyslogIPv4() netip.Addr { return fakeSyslog.v4 }
// FakeSyslogIPv6 returns the fake syslog IPv6 address.
func FakeSyslogIPv6() netip.Addr { return fakeSyslog.v6 }
// newVIP returns a new virtual IP.
//
// opts may be an IPv4 an IPv6 (in string form) or an int (bounded by uint8) to
// use IPv4 of 52.52.0.x.
//
// If the IPv6 is omitted, one is derived from the IPv4.
//
// If an opt is invalid or the DNS name is already used, it panics.
func newVIP(name string, opts ...any) (v virtualIP) {
if _, ok := vips[name]; ok {
panic(fmt.Sprintf("duplicate VIP %q", name))
}
v.name = name
for _, o := range opts {
switch o := o.(type) {
case string:
if ip, err := netip.ParseAddr(o); err == nil {
if ip.Is4() {
v.v4 = ip
} else if ip.Is6() {
v.v6 = ip
}
} else {
panic(fmt.Sprintf("unsupported string option %q", o))
}
case int:
if o <= 0 || o > 255 {
panic(fmt.Sprintf("bad octet %d", o))
}
v.v4 = netip.AddrFrom4([4]byte{52, 52, 0, byte(o)})
default:
panic(fmt.Sprintf("unknown option type %T", o))
}
}
if !v.v6.IsValid() && v.v4.IsValid() {
// Map 1.2.3.4 to 2052::0102:0304
// But make 52.52.0.x map to 2052::x
a := [16]byte{0: 0x20, 1: 0x52} // 2052::
v4 := v.v4.As4()
if v4[0] == 52 && v4[1] == 52 && v4[2] == 0 {
a[15] = v4[3]
} else {
copy(a[12:], v.v4.AsSlice())
}
v.v6 = netip.AddrFrom16(a)
}
for _, b := range vips {
if b.Match(v.v4) || b.Match(v.v6) {
panic(fmt.Sprintf("VIP %q collides with %q", name, v.name))
}
}
vips[name] = v
return v
}