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:
committed by
Brad Fitzpatrick
parent
6ff761c5f8
commit
772be1b0cc
@@ -11,6 +11,7 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -37,6 +38,24 @@ import (
|
||||
"tailscale.com/version/distro"
|
||||
)
|
||||
|
||||
// GokrazyUpdateArgs contains arguments for updating a Gokrazy appliance from a
|
||||
// GAF fetched from a URL.
|
||||
type GokrazyUpdateArgs struct {
|
||||
// URL is the GAF download URL.
|
||||
URL string
|
||||
|
||||
// AllowUnsigned permits installing a GAF without signature verification.
|
||||
// This is intended for tests until signed GAF verification is implemented.
|
||||
AllowUnsigned bool
|
||||
|
||||
// Logf is optional; nil discards log messages.
|
||||
Logf logger.Logf
|
||||
}
|
||||
|
||||
// GokrazyUpdateFromURL updates a Gokrazy appliance from a GAF fetched from a
|
||||
// URL, if Gokrazy update support is linked into the binary.
|
||||
var GokrazyUpdateFromURL feature.Hook[func(context.Context, GokrazyUpdateArgs) error]
|
||||
|
||||
const (
|
||||
StableTrack = "stable"
|
||||
UnstableTrack = "unstable"
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux
|
||||
|
||||
package clientupdate
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
gokrazyUpdateSocket = "/run/gokrazy-http.sock"
|
||||
gokrazyUpdateBaseURL = "http://gokrazy-local-unixsock"
|
||||
)
|
||||
|
||||
// GokrazyUpdateFromURL downloads a Gokrazy archive format file from args.URL,
|
||||
// installs its partitions using the local gokrazy init update API, switches to
|
||||
// the new root partition, and asks gokrazy to reboot.
|
||||
//
|
||||
// The local gokrazy API is reached over gokrazyUpdateSocket. The
|
||||
// gokrazyUpdateBaseURL host is only a net/http URL sentinel; it is not resolved
|
||||
// with DNS.
|
||||
func init() {
|
||||
GokrazyUpdateFromURL.Set(gokrazyUpdateFromURL)
|
||||
}
|
||||
|
||||
func gokrazyUpdateFromURL(ctx context.Context, args GokrazyUpdateArgs) error {
|
||||
logf := args.Logf
|
||||
if logf == nil {
|
||||
logf = logger.Discard
|
||||
}
|
||||
if !args.AllowUnsigned {
|
||||
return fmt.Errorf("signed GAF verification is not implemented yet; see https://github.com/tailscale/tailscale/issues/20002")
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp("", "tailscale-gokrazy-*.gaf")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", args.URL, nil)
|
||||
if err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("download GAF: %s", res.Status)
|
||||
}
|
||||
if _, err := io.Copy(tmp, res.Body); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
zr, err := zip.OpenReader(tmpName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
gokClient := gokrazyHTTPClient()
|
||||
for _, part := range []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{"root.img", "/update/root"},
|
||||
{"boot.img", "/update/boot"},
|
||||
{"mbr.img", "/update/mbr"},
|
||||
} {
|
||||
if err := putGokrazyGAFMember(ctx, gokClient, zr.File, part.name, part.path); err != nil {
|
||||
return err
|
||||
}
|
||||
logf("wrote %s", part.name)
|
||||
}
|
||||
if err := postGokrazy(ctx, gokClient, "/update/switch"); err != nil {
|
||||
return err
|
||||
}
|
||||
logf("switched boot target")
|
||||
if err := postGokrazy(ctx, gokClient, "/reboot?async=true&kexec_merge_cmdline=true"); err != nil {
|
||||
return err
|
||||
}
|
||||
logf("reboot requested")
|
||||
return nil
|
||||
}
|
||||
|
||||
func gokrazyHTTPClient() *http.Client {
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, "unix", gokrazyUpdateSocket)
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: tr,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func putGokrazyGAFMember(ctx context.Context, hc *http.Client, files []*zip.File, name, path string) error {
|
||||
var zf *zip.File
|
||||
for _, f := range files {
|
||||
if f.Name == name {
|
||||
zf = f
|
||||
break
|
||||
}
|
||||
}
|
||||
if zf == nil {
|
||||
return fmt.Errorf("GAF is missing %s", name)
|
||||
}
|
||||
rc, err := zf.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
h := crc32.NewIEEE()
|
||||
body := io.TeeReader(rc, h)
|
||||
req, err := http.NewRequestWithContext(ctx, "PUT", gokrazyUpdateBaseURL+path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.ContentLength = int64(zf.UncompressedSize64)
|
||||
req.Header.Set("X-Gokrazy-Update-Hash", "crc32")
|
||||
res, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
resBody, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("PUT %s: %s: %s", path, res.Status, strings.TrimSpace(string(resBody)))
|
||||
}
|
||||
if got, want := strings.TrimSpace(string(resBody)), fmt.Sprintf("%08x", h.Sum32()); got != want {
|
||||
return fmt.Errorf("PUT %s: gokrazy checksum = %q; want %q", path, got, want)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func postGokrazy(ctx context.Context, hc *http.Client, path string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", gokrazyUpdateBaseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||||
return fmt.Errorf("POST %s: %s: %s", path, res.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"runtime"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
@@ -67,8 +68,19 @@ var updateArgs struct {
|
||||
version string // explicit version; empty means auto
|
||||
}
|
||||
|
||||
const gokrazyUpdateFromURLMagicArg = "--gokrazy-update-from-url"
|
||||
|
||||
func runUpdate(ctx context.Context, args []string) error {
|
||||
if len(args) > 0 {
|
||||
if runtime.GOOS == "linux" && distro.Get() == distro.Gokrazy {
|
||||
gokArgs, err := gokrazyUpdateArgsFromMagicArg(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if gokArgs != nil {
|
||||
return clientupdate.GokrazyUpdateFromURL.Get()(ctx, *gokArgs)
|
||||
}
|
||||
}
|
||||
return flag.ErrHelp
|
||||
}
|
||||
if updateArgs.version != "" && updateArgs.track != "" {
|
||||
@@ -102,3 +114,39 @@ func confirmUpdate(ver string) bool {
|
||||
msg := fmt.Sprintf("This will update Tailscale from %v to %v. Continue?", version.Short(), ver)
|
||||
return prompt.YesNo(msg, true)
|
||||
}
|
||||
|
||||
// gokrazyUpdateArgsFromMagicArg parses the Gokrazy update-from-URL command-line
|
||||
// flow. It returns nil if args do not select that flow. A non-nil result means
|
||||
// the caller may safely invoke clientupdate.GokrazyUpdateFromURL.
|
||||
func gokrazyUpdateArgsFromMagicArg(args []string) (*clientupdate.GokrazyUpdateArgs, error) {
|
||||
var updateURL string
|
||||
var unsigned bool
|
||||
|
||||
fs := flag.NewFlagSet("gokrazy-update", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
// This flag path is exercised end-to-end by TestGokrazyUpdatesItselfToSameImage.
|
||||
fs.StringVar(&updateURL, gokrazyUpdateFromURLMagicArg[2:], "", "URL of the Gokrazy archive format file to install")
|
||||
fs.BoolVar(&unsigned, "unsigned", false, "allow an unsigned GAF; for tests only")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if fs.NArg() != 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if updateURL == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if !unsigned {
|
||||
return nil, errors.New("signed GAF verification is not implemented yet; see https://github.com/tailscale/tailscale/issues/20002; pass --unsigned for test updates")
|
||||
}
|
||||
if !clientupdate.GokrazyUpdateFromURL.IsSet() {
|
||||
return nil, errors.New("gokrazy update support is not linked into this binary")
|
||||
}
|
||||
return &clientupdate.GokrazyUpdateArgs{
|
||||
URL: updateURL,
|
||||
AllowUnsigned: unsigned,
|
||||
Logf: func(format string, args ...any) {
|
||||
printf(format+"\n", args...)
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -372,6 +372,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
vendor/golang.org/x/text/unicode/bidi from vendor/golang.org/x/net/idna+
|
||||
vendor/golang.org/x/text/unicode/norm from vendor/golang.org/x/net/idna
|
||||
archive/tar from tailscale.com/clientupdate
|
||||
L archive/zip from tailscale.com/clientupdate
|
||||
bufio from compress/flate+
|
||||
bytes from archive/tar+
|
||||
cmp from slices+
|
||||
|
||||
@@ -572,6 +572,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
vendor/golang.org/x/text/unicode/bidi from vendor/golang.org/x/net/idna+
|
||||
vendor/golang.org/x/text/unicode/norm from vendor/golang.org/x/net/idna
|
||||
archive/tar from tailscale.com/clientupdate
|
||||
L archive/zip from tailscale.com/clientupdate
|
||||
bufio from compress/flate+
|
||||
bytes from archive/tar+
|
||||
cmp from slices+
|
||||
|
||||
@@ -213,6 +213,24 @@ func main() {
|
||||
}
|
||||
serveCmd(w, "tailscale", args...)
|
||||
})
|
||||
ttaMux.HandleFunc("/tailscale", func(w http.ResponseWriter, r *http.Request) {
|
||||
serveCmd(w, "tailscale", r.URL.Query()["arg"]...)
|
||||
})
|
||||
ttaMux.HandleFunc("/gokrazy-root", func(w http.ResponseWriter, r *http.Request) {
|
||||
cmdLine, err := os.ReadFile("/proc/cmdline")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for s := range strings.FieldsSeq(string(cmdLine)) {
|
||||
if root, ok := strings.CutPrefix(s, "root="); ok {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
io.WriteString(w, root+"\n")
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Error(w, "no root= in /proc/cmdline", http.StatusInternalServerError)
|
||||
})
|
||||
ttaMux.HandleFunc("/ip", func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, ok := r.Context().Value(connContextKey).(net.Conn)
|
||||
if !ok {
|
||||
|
||||
@@ -164,4 +164,4 @@
|
||||
});
|
||||
};
|
||||
}
|
||||
# nix-direnv cache busting line: sha256-4NBYQl+JWkZCQWs2UdtbxdUvq7TMUm5pka1ppUUQafU=
|
||||
# nix-direnv cache busting line: sha256-lgWFUDa1hHooieOSE/2L74RHtfkzK2q4W2GiDQOrxGw=
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
"sri": "sha256-cY5yryX+p/xtoTv+WZEKFagiIl0OREHnJY1Bk5VpVVc="
|
||||
},
|
||||
"vendor": {
|
||||
"goModSum": "sha256-QmU8vJ5K/YWKT1BvN5+F8IVMyR2sCRyE5y4IHWiwlB4=",
|
||||
"sri": "sha256-4NBYQl+JWkZCQWs2UdtbxdUvq7TMUm5pka1ppUUQafU="
|
||||
"goModSum": "sha256-ep5BPjx4MPaldI71++7vgJPSyftdBqlKciKyx08MOQw=",
|
||||
"sri": "sha256-lgWFUDa1hHooieOSE/2L74RHtfkzK2q4W2GiDQOrxGw="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7
|
||||
github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02
|
||||
github.com/bradfitz/go-tool-cache v0.0.0-20260216153636-9e5201344fe5
|
||||
github.com/bradfitz/monogok v0.0.0-20260429173803-229ef7981a6b
|
||||
github.com/bradfitz/monogok v0.0.0-20260604043651-77f55eaefb19
|
||||
github.com/bramvdbogaerde/go-scp v1.4.0
|
||||
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc
|
||||
github.com/chromedp/chromedp v0.15.1
|
||||
@@ -103,7 +103,7 @@ require (
|
||||
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7
|
||||
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc
|
||||
github.com/tailscale/setec v0.0.0-20251203133219-2ab774e4129a
|
||||
github.com/tailscale/ts-gokrazy v0.0.0-20260429180033-fe741c6deb44
|
||||
github.com/tailscale/ts-gokrazy v0.0.0-20260604151927-fc3a567bcf75
|
||||
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976
|
||||
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6
|
||||
github.com/tailscale/wireguard-go v0.0.0-20260527010701-b48af7099cad
|
||||
|
||||
@@ -207,8 +207,8 @@ github.com/bombsimon/wsl/v4 v4.2.1 h1:Cxg6u+XDWff75SIFFmNsqnIOgob+Q9hG6y/ioKbRFi
|
||||
github.com/bombsimon/wsl/v4 v4.2.1/go.mod h1:Xu/kDxGZTofQcDGCtQe9KCzhHphIe0fDuyWTxER9Feo=
|
||||
github.com/bradfitz/go-tool-cache v0.0.0-20260216153636-9e5201344fe5 h1:0sG3c7afYdBNlc3QyhckvZ4bV9iqlfqCQM1i+mWm0eE=
|
||||
github.com/bradfitz/go-tool-cache v0.0.0-20260216153636-9e5201344fe5/go.mod h1:78ZLITnBUCDJeU01+wYYJKaPYYgsDzJPRfxeI8qFh5g=
|
||||
github.com/bradfitz/monogok v0.0.0-20260429173803-229ef7981a6b h1:lhWZfi1U/yi8zuFA6pkJKYv45pVAC3xs6SUE2QsjsEE=
|
||||
github.com/bradfitz/monogok v0.0.0-20260429173803-229ef7981a6b/go.mod h1:TG1HbU9fRVDnNgXncVkKz9GdvjIvqquXjH6QZSEVmY4=
|
||||
github.com/bradfitz/monogok v0.0.0-20260604043651-77f55eaefb19 h1:1nIDDePfdaBpj8/Sv0pZVWylK5JqPVkS9CgxbwRRc8Y=
|
||||
github.com/bradfitz/monogok v0.0.0-20260604043651-77f55eaefb19/go.mod h1:TG1HbU9fRVDnNgXncVkKz9GdvjIvqquXjH6QZSEVmY4=
|
||||
github.com/bramvdbogaerde/go-scp v1.4.0 h1:jKMwpwCbcX1KyvDbm/PDJuXcMuNVlLGi0Q0reuzjyKY=
|
||||
github.com/bramvdbogaerde/go-scp v1.4.0/go.mod h1:on2aH5AxaFb2G0N5Vsdy6B0Ml7k9HuHSwfo1y0QzAbQ=
|
||||
github.com/breml/bidichk v0.2.7 h1:dAkKQPLl/Qrk7hnP6P+E0xOodrq8Us7+U0o4UBOAlQY=
|
||||
@@ -1169,8 +1169,8 @@ github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+y
|
||||
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc=
|
||||
github.com/tailscale/setec v0.0.0-20251203133219-2ab774e4129a h1:TApskGPim53XY5WRt5hX4DnO8V6CmVoimSklryIoGMM=
|
||||
github.com/tailscale/setec v0.0.0-20251203133219-2ab774e4129a/go.mod h1:+6WyG6kub5/5uPsMdYQuSti8i6F5WuKpFWLQnZt/Mms=
|
||||
github.com/tailscale/ts-gokrazy v0.0.0-20260429180033-fe741c6deb44 h1:a6GdEBrBcDy/4XQ2CxKQvuCaKN8EFL5JTE7ZFOkXDzQ=
|
||||
github.com/tailscale/ts-gokrazy v0.0.0-20260429180033-fe741c6deb44/go.mod h1:mu0sethAvP7xItcfBAxMJWiXZ3ZQ5qbKmjPYizOkSHE=
|
||||
github.com/tailscale/ts-gokrazy v0.0.0-20260604151927-fc3a567bcf75 h1:qdJT1/hv1Iji8j4mFUt1fvVPl97Oszvp0TUZWhMD5oY=
|
||||
github.com/tailscale/ts-gokrazy v0.0.0-20260604151927-fc3a567bcf75/go.mod h1:mu0sethAvP7xItcfBAxMJWiXZ3ZQ5qbKmjPYizOkSHE=
|
||||
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14=
|
||||
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
|
||||
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M=
|
||||
|
||||
@@ -4,6 +4,9 @@ help:
|
||||
image:
|
||||
go run build.go --build
|
||||
|
||||
gaf:
|
||||
go run build.go --gaf
|
||||
|
||||
qemu: image
|
||||
qemu-system-x86_64 -m 1G -drive file=tsapp.img,format=raw -boot d -netdev user,id=user.0 -device virtio-net-pci,netdev=user.0 -serial mon:stdio -audio none
|
||||
|
||||
|
||||
+26
-12
@@ -30,6 +30,7 @@ var (
|
||||
app = flag.String("app", "tsapp", "appliance name; one of the subdirectories of gokrazy/")
|
||||
bucket = flag.String("bucket", "tskrazy-import", "S3 bucket to upload disk image to while making AMI")
|
||||
build = flag.Bool("build", false, "if true, just build locally and stop, without uploading")
|
||||
gaf = flag.Bool("gaf", false, "if true, build a gokrazy archive format file instead of a full disk image")
|
||||
)
|
||||
|
||||
func findMkfsExt4() (string, error) {
|
||||
@@ -95,7 +96,7 @@ func main() {
|
||||
if err := buildImage(); err != nil {
|
||||
log.Fatalf("build image: %v", err)
|
||||
}
|
||||
if *build {
|
||||
if *build || *gaf {
|
||||
log.Printf("built. stopping.")
|
||||
return
|
||||
}
|
||||
@@ -122,11 +123,6 @@ func main() {
|
||||
}
|
||||
|
||||
func buildImage() error {
|
||||
mkfs, err := findMkfsExt4()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -134,19 +130,37 @@ func buildImage() error {
|
||||
if fi, err := os.Stat(filepath.Join(dir, *app)); err != nil || !fi.IsDir() {
|
||||
return fmt.Errorf("in wrong directory %v; no %q subdirectory found", dir, *app)
|
||||
}
|
||||
// Build the tsapp.img
|
||||
|
||||
args := []string{"run", "github.com/bradfitz/monogok/cmd/monogok"}
|
||||
if *gaf {
|
||||
args = append(args,
|
||||
"overwrite",
|
||||
"--gaf", filepath.Join(dir, *app+".gaf"),
|
||||
)
|
||||
} else {
|
||||
args = append(args,
|
||||
"overwrite",
|
||||
"--full", filepath.Join(dir, *app+".img"),
|
||||
"--target_storage_bytes=1258299392",
|
||||
)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd := exec.Command("go", "run",
|
||||
"github.com/bradfitz/monogok/cmd/monogok",
|
||||
"overwrite",
|
||||
"--full", filepath.Join(dir, *app+".img"),
|
||||
"--target_storage_bytes=1258299392")
|
||||
cmd := exec.Command("go", args...)
|
||||
cmd.Dir = filepath.Join(dir, *app)
|
||||
cmd.Stdout = io.MultiWriter(os.Stdout, &buf)
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
if *gaf {
|
||||
return nil
|
||||
}
|
||||
|
||||
mkfs, err := findMkfsExt4()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// monogok overwrite emits a line of text saying how to run mkfs.ext4
|
||||
// to create the ext4 /perm filesystem. Parse that and run it.
|
||||
|
||||
@@ -16,4 +16,4 @@
|
||||
) {
|
||||
src = ./.;
|
||||
}).shellNix
|
||||
# nix-direnv cache busting line: sha256-4NBYQl+JWkZCQWs2UdtbxdUvq7TMUm5pka1ppUUQafU=
|
||||
# nix-direnv cache busting line: sha256-lgWFUDa1hHooieOSE/2L74RHtfkzK2q4W2GiDQOrxGw=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user