gokrazy, clientupdate: add start of Gokrazy auto-updates, tests

This adds support for Gokrazy GAF (Gokrazy Archive Format) zip
auto-updates, starting to wire up Tailscale's clientupdate mechanism
to Gokrazy's update mechanism.

Currently there's just a CLI command to update from a GAF URL,
with an --unsigned flag for use in a new natlab vmtest.

Next step would be publishing unstable track GAF files on
pkgs.tailscale.com, with detached signatures, and then making the
clientupdate mechanism also download those and check signatures.

Updates #20002

Change-Id: Ib03c56f17a57f8a4638398ef83549dac4813323d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick
2026-06-04 11:20:14 -07:00
committed by Brad Fitzpatrick
parent 6ff761c5f8
commit 772be1b0cc
15 changed files with 474 additions and 22 deletions
+118
View File
@@ -0,0 +1,118 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package vmtest_test
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"tailscale.com/tstest"
"tailscale.com/tstest/natlab/vmtest"
"tailscale.com/tstest/natlab/vnet"
)
// TestGokrazyUpdatesItselfToSameImage exercises the Gokrazy appliance update
// path end-to-end in QEMU. It builds a GAF for the same natlab image, serves it
// from the vnet fileserver, asks the guest to install it to the inactive
// partition, then verifies the guest rebooted successfully from the other root
// partition.
func TestGokrazyUpdatesItselfToSameImage(t *testing.T) {
env := vmtest.New(t)
wan := env.AddNetwork("1.0.0.1", "192.168.1.1/24", vnet.EasyNAT)
node := env.AddNode("gokrazy", wan,
vmtest.OS(vmtest.Gokrazy),
vmtest.DontJoinTailnet())
env.Start()
gaf := buildNatlabGAF(t)
env.RegisterFile("natlabapp.gaf", gaf)
rootBefore, err := env.GokrazyRoot(node)
if err != nil {
t.Fatalf("getting initial gokrazy root: %v", err)
}
t.Logf("initial gokrazy root: %s", rootBefore)
out, err := env.Tailscale(node,
"update",
"--",
"--gokrazy-update-from-url=http://files.tailscale/natlabapp.gaf",
"--unsigned",
)
if err != nil {
if errors.Is(err, io.EOF) {
t.Logf("update command connection ended during reboot: %v", err)
} else {
t.Fatalf("gokrazy update command failed: %v\n%s", err, out)
}
} else {
t.Logf("update command output:\n%s", out)
}
if err := tstest.WaitFor(90*time.Second, func() error {
rootAfter, err := env.GokrazyRoot(node)
if err != nil {
return err
}
if rootAfter == rootBefore {
return fmt.Errorf("still booted with root %q", rootAfter)
}
t.Logf("updated gokrazy root: %s", rootAfter)
return nil
}); err != nil {
t.Fatalf("waiting for gokrazy to reboot into inactive partition: %v", err)
}
}
func buildNatlabGAF(t *testing.T) []byte {
t.Helper()
modRoot := moduleRoot(t)
gafPath := filepath.Join(modRoot, "gokrazy", "natlabapp.gaf")
t.Cleanup(func() { os.Remove(gafPath) })
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "run", "build.go", "--gaf", "--app=natlabapp")
cmd.Dir = filepath.Join(modRoot, "gokrazy")
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
t.Fatalf("building natlabapp.gaf: %v\n%s", err, out.String())
}
t.Logf("built natlabapp.gaf:\n%s", out.String())
gaf, err := os.ReadFile(gafPath)
if err != nil {
t.Fatalf("reading %s: %v", gafPath, err)
}
return gaf
}
func moduleRoot(t *testing.T) string {
t.Helper()
out, err := exec.Command("go", "env", "GOMOD").CombinedOutput()
if err != nil {
t.Fatalf("go env GOMOD: %v\n%s", err, out)
}
gomod := strings.TrimSpace(string(out))
if gomod == "" || gomod == os.DevNull {
t.Fatal("not in a Go module")
}
return filepath.Dir(gomod)
}
+53
View File
@@ -400,6 +400,15 @@ func (e *Env) AddNetwork(opts ...any) *vnet.Network {
return e.cfg.AddNetwork(opts...)
}
// RegisterFile registers a file with the vnet fileserver.
// It is served at http://files.tailscale/<path>.
func (e *Env) RegisterFile(path string, data []byte) {
if e.server == nil {
e.t.Fatalf("RegisterFile called before Start")
}
e.server.RegisterFile(path, data)
}
// Node represents a virtual machine in the test environment.
type Node struct {
name string
@@ -1322,6 +1331,50 @@ func (e *Env) HTTPGet(from *Node, targetURL string) string {
return ""
}
// Tailscale runs the tailscale CLI on the given node via TTA.
func (e *Env) Tailscale(n *Node, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
q := url.Values{}
for _, arg := range args {
q.Add("arg", arg)
}
req, err := http.NewRequestWithContext(ctx, "GET", "http://unused/tailscale?"+q.Encode(), nil)
if err != nil {
return "", err
}
res, err := n.agent.HTTPClient.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
if res.StatusCode != http.StatusOK {
return string(body), fmt.Errorf("tailscale %q: %s: %s", args, res.Status, res.Header.Get("Exec-Err"))
}
return string(body), nil
}
// GokrazyRoot returns the kernel root= argument from a Gokrazy node.
func (e *Env) GokrazyRoot(n *Node) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", "http://unused/gokrazy-root", nil)
if err != nil {
return "", err
}
res, err := n.agent.HTTPClient.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
if res.StatusCode != http.StatusOK {
return "", fmt.Errorf("gokrazy-root: %s: %s", res.Status, strings.TrimSpace(string(body)))
}
return strings.TrimSpace(string(body)), nil
}
// setNodeScreenshot stores the latest screenshot data URI for a node.
func (e *Env) setNodeScreenshot(name, dataURI string) {
e.nodeStatusMu.Lock()