Procházet zdrojové kódy

cmd/containerboot: add tests.

Signed-off-by: David Anderson <[email protected]>
David Anderson před 3 roky
rodič
revize
2111357568

+ 396 - 0
cmd/containerboot/main_test.go

@@ -0,0 +1,396 @@
+// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build linux
+
+package main
+
+import (
+	"bytes"
+	_ "embed"
+	"encoding/base64"
+	"encoding/json"
+	"encoding/pem"
+	"errors"
+	"fmt"
+	"io"
+	"io/fs"
+	"net"
+	"net/http"
+	"net/http/httptest"
+	"net/netip"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"golang.org/x/sys/unix"
+	"tailscale.com/ipn/ipnstate"
+)
+
+func TestContainerBoot(t *testing.T) {
+	d, err := os.MkdirTemp("", "containerboot")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	lapi := localAPI{FSRoot: d}
+	if err := lapi.Start(); err != nil {
+		t.Fatal(err)
+	}
+	defer lapi.Close()
+
+	kube := kubeServer{FSRoot: d}
+	if err := kube.Start(); err != nil {
+		t.Fatal(err)
+	}
+	defer kube.Close()
+
+	for _, path := range []string{"var/lib", "usr/bin", "tmp"} {
+		if err := os.MkdirAll(filepath.Join(d, path), 0700); err != nil {
+			t.Fatal(err)
+		}
+	}
+	if err := os.WriteFile(filepath.Join(d, "usr/bin/tailscaled"), fakeTailscaled, 0700); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(d, "usr/bin/tailscale"), fakeTailscale, 0700); err != nil {
+		t.Fatal(err)
+	}
+
+	boot := filepath.Join(d, "containerboot")
+	if err := exec.Command("go", "build", "-o", boot, "tailscale.com/cmd/containerboot").Run(); err != nil {
+		t.Fatalf("Building containerboot: %v", err)
+	}
+
+	argFile := filepath.Join(d, "args")
+
+	lapi.Reset()
+	kube.Reset()
+
+	cmd := exec.Command(boot)
+	cmd.Env = []string{
+		fmt.Sprintf("PATH=%s/usr/bin:%s", d, os.Getenv("PATH")),
+		fmt.Sprintf("TS_TEST_RECORD_ARGS=%s", argFile),
+		fmt.Sprintf("TS_TEST_SOCKET=%s", lapi.Path),
+		fmt.Sprintf("TS_SOCKET=%s", filepath.Join(d, "tmp/tailscaled.sock")),
+	}
+	cbOut := &lockingBuffer{}
+	cmd.Stderr = cbOut
+	if err := cmd.Start(); err != nil {
+		t.Fatalf("starting containerboot: %v", err)
+	}
+	defer func() {
+		cmd.Process.Signal(unix.SIGTERM)
+		cmd.Process.Wait()
+	}()
+
+	want := `
+/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking
+/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false
+`
+	waitArgs(t, 2*time.Second, d, argFile, want)
+
+	lapi.SetStatus(ipnstate.Status{
+		BackendState: "Running",
+		TailscaleIPs: []netip.Addr{
+			netip.MustParseAddr("100.64.0.1"),
+		},
+	})
+
+	waitLogLine(t, 2*time.Second, cbOut, "Startup complete, waiting for shutdown signal")
+}
+
+type lockingBuffer struct {
+	sync.Mutex
+	b bytes.Buffer
+}
+
+func (b *lockingBuffer) Write(bs []byte) (int, error) {
+	b.Lock()
+	defer b.Unlock()
+	return b.b.Write(bs)
+}
+
+func (b *lockingBuffer) String() string {
+	b.Lock()
+	defer b.Unlock()
+	return b.b.String()
+}
+
+// waitLogLine looks for want in the contents of b.
+//
+// Only lines starting with 'boot: ' (the output of containerboot
+// itself) are considered, and the logged timestamp is ignored.
+//
+// waitLogLine fails the entire test if path doesn't contain want
+// before the timeout.
+func waitLogLine(t *testing.T, timeout time.Duration, b *lockingBuffer, want string) {
+	deadline := time.Now().Add(timeout)
+	for time.Now().Before(deadline) {
+		for _, line := range strings.Split(b.String(), "\n") {
+			if !strings.HasPrefix(line, "boot: ") {
+				continue
+			}
+			if strings.HasSuffix(line, " "+want) {
+				return
+			}
+		}
+		time.Sleep(100 * time.Millisecond)
+	}
+	t.Fatalf("timed out waiting for wanted output line %q. Output:\n%s", want, b.String())
+}
+
+// waitArgs waits until the contents of path matches wantArgs, a set
+// of command lines recorded by test_tailscale.sh and
+// test_tailscaled.sh.
+//
+// All occurrences of removeStr are removed from the file prior to
+// comparison. This is used to remove the varying temporary root
+// directory name from recorded commandlines, so that wantArgs can be
+// a constant value.
+//
+// waitArgs fails the entire test if path doesn't contain wantArgs
+// before the timeout.
+func waitArgs(t *testing.T, timeout time.Duration, removeStr, path, wantArgs string) {
+	t.Helper()
+	wantArgs = strings.TrimSpace(wantArgs)
+	deadline := time.Now().Add(timeout)
+	var got string
+	for time.Now().Before(deadline) {
+		bs, err := os.ReadFile(path)
+		if errors.Is(err, fs.ErrNotExist) {
+			// Don't bother logging that the file doesn't exist, it
+			// should start existing soon.
+			goto loop
+		} else if err != nil {
+			t.Logf("reading %q: %v", path, err)
+			goto loop
+		}
+		got = strings.TrimSpace(string(bs))
+		got = strings.ReplaceAll(got, removeStr, "")
+		if got == wantArgs {
+			return
+		}
+	loop:
+		time.Sleep(100 * time.Millisecond)
+	}
+	t.Fatalf("waiting for args file %q to have expected output, got:\n%s\n\nWant: %s", path, got, wantArgs)
+}
+
+//go:embed test_tailscaled.sh
+var fakeTailscaled []byte
+
+//go:embed test_tailscale.sh
+var fakeTailscale []byte
+
+// localAPI is a minimal fake tailscaled LocalAPI server that presents
+// just enough functionality for containerboot to function
+// correctly. In practice this means it only supports querying
+// tailscaled status, and panics on all other uses to make it very
+// obvious that something unexpected happened.
+type localAPI struct {
+	FSRoot string
+	Path   string // populated by Start
+
+	srv *http.Server
+
+	sync.Mutex
+	status ipnstate.Status
+}
+
+func (l *localAPI) Start() error {
+	path := filepath.Join(l.FSRoot, "tmp/tailscaled.sock.fake")
+	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+		return err
+	}
+
+	ln, err := net.Listen("unix", path)
+	if err != nil {
+		return err
+	}
+
+	l.srv = &http.Server{
+		Handler: l,
+	}
+	l.Path = path
+	go l.srv.Serve(ln)
+	return nil
+}
+
+func (l *localAPI) Close() {
+	l.srv.Close()
+}
+
+func (l *localAPI) Reset() {
+	l.SetStatus(ipnstate.Status{
+		BackendState: "NoState",
+	})
+}
+
+func (l *localAPI) SetStatus(st ipnstate.Status) {
+	l.Lock()
+	defer l.Unlock()
+	l.status = st
+}
+
+func (l *localAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	if r.Method != "GET" {
+		panic(fmt.Sprintf("unsupported method %q", r.Method))
+	}
+	if r.URL.Path != "/localapi/v0/status" {
+		panic(fmt.Sprintf("unsupported localAPI path %q", r.URL.Path))
+	}
+	w.Header().Set("Content-Type", "application/json")
+	l.Lock()
+	defer l.Unlock()
+	if err := json.NewEncoder(w).Encode(l.status); err != nil {
+		panic("json encode failed")
+	}
+}
+
+// kubeServer is a minimal fake Kubernetes server that presents just
+// enough functionality for containerboot to function correctly. In
+// practice this means it only supports reading and modifying a single
+// kube secret, and panics on all other uses to make it very obvious
+// that something unexpected happened.
+type kubeServer struct {
+	FSRoot string
+	Addr   string // populated by Start
+
+	srv *httptest.Server
+
+	sync.Mutex
+	secret map[string]string
+}
+
+func (k *kubeServer) Secret() map[string]string {
+	k.Lock()
+	defer k.Unlock()
+	ret := map[string]string{}
+	for k, v := range k.secret {
+		ret[k] = v
+	}
+	return ret
+}
+
+func (k *kubeServer) SetSecret(key, val string) {
+	k.Lock()
+	defer k.Unlock()
+	k.secret[key] = val
+}
+
+func (k *kubeServer) Reset() {
+	k.Lock()
+	defer k.Unlock()
+	k.secret = map[string]string{}
+}
+
+func (k *kubeServer) Start() error {
+	root := filepath.Join(k.FSRoot, "var/run/secrets/kubernetes.io/serviceaccount")
+
+	if err := os.MkdirAll(root, 0700); err != nil {
+		return err
+	}
+
+	if err := os.WriteFile(filepath.Join(root, "namespace"), []byte("default"), 0600); err != nil {
+		return err
+	}
+	if err := os.WriteFile(filepath.Join(root, "token"), []byte("bearer_token"), 0600); err != nil {
+		return err
+	}
+
+	k.srv = httptest.NewTLSServer(k)
+	k.Addr = k.srv.Listener.Addr().String()
+
+	var cert bytes.Buffer
+	if err := pem.Encode(&cert, &pem.Block{Type: "CERTIFICATE", Bytes: k.srv.Certificate().Raw}); err != nil {
+		return err
+	}
+	if err := os.WriteFile(filepath.Join(root, "ca.crt"), cert.Bytes(), 0600); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (k *kubeServer) Close() {
+	k.srv.Close()
+}
+
+func (k *kubeServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	if r.Header.Get("Authorization") != "Bearer bearer_token" {
+		panic("client didn't provide bearer token in request")
+	}
+	if r.URL.Path != "/api/v1/namespaces/default/secrets/tailscale" {
+		panic(fmt.Sprintf("unhandled fake kube api path %q", r.URL.Path))
+	}
+
+	bs, err := io.ReadAll(r.Body)
+	if err != nil {
+		http.Error(w, fmt.Sprintf("reading request body: %v", err), http.StatusInternalServerError)
+		return
+	}
+
+	switch r.Method {
+	case "GET":
+		w.Header().Set("Content-Type", "application/json")
+		ret := map[string]map[string]string{
+			"data": map[string]string{},
+		}
+		k.Lock()
+		defer k.Unlock()
+		for k, v := range k.secret {
+			v := base64.StdEncoding.EncodeToString([]byte(v))
+			if err != nil {
+				panic("encode failed")
+			}
+			ret["data"][k] = v
+		}
+		if err := json.NewEncoder(w).Encode(ret); err != nil {
+			panic("encode failed")
+		}
+	case "PATCH":
+		switch r.Header.Get("Content-Type") {
+		case "application/json-patch+json":
+			req := []struct {
+				Op   string `json:"op"`
+				Path string `json:"path"`
+			}{}
+			if err := json.Unmarshal(bs, &req); err != nil {
+				panic(fmt.Sprintf("json decode failed: %v. Body:\n\n%s", err, string(bs)))
+			}
+			k.Lock()
+			defer k.Unlock()
+			for _, op := range req {
+				if op.Op != "remove" {
+					panic(fmt.Sprintf("unsupported json-patch op %q", op.Op))
+				}
+				if !strings.HasPrefix(op.Path, "/data/") {
+					panic(fmt.Sprintf("unsupported json-patch path %q", op.Path))
+				}
+				delete(k.secret, strings.TrimPrefix(op.Path, "/data/"))
+			}
+		case "application/strategic-merge-patch+json":
+			req := struct {
+				Data map[string]string `json:"stringData"`
+			}{}
+			if err := json.Unmarshal(bs, &req); err != nil {
+				panic(fmt.Sprintf("json decode failed: %v. Body:\n\n%s", err, string(bs)))
+			}
+			k.Lock()
+			defer k.Unlock()
+			for key, val := range req.Data {
+				k.secret[key] = val
+			}
+		default:
+			panic(fmt.Sprintf("unknown content type %q", r.Header.Get("Content-Type")))
+		}
+	default:
+		panic(fmt.Sprintf("unhandled HTTP method %q", r.Method))
+	}
+}

+ 7 - 0
cmd/containerboot/test_tailscale.sh

@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+#
+# This is a fake tailscale CLI that records its arguments and exits successfully.
+#
+# It is used by main_test.go to test the behavior of containerboot.
+
+echo $0 $@ >>$TS_TEST_RECORD_ARGS

+ 37 - 0
cmd/containerboot/test_tailscaled.sh

@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+#
+# This is a fake tailscale CLI that records its arguments, symlinks a
+# fake LocalAPI socket into place, and does nothing until terminated.
+#
+# It is used by main_test.go to test the behavior of containerboot.
+
+set -eu
+
+echo $0 $@ >>$TS_TEST_RECORD_ARGS
+
+socket=""
+while [[ $# -gt 0 ]]; do
+	case $1 in
+		--socket=*)
+			socket="${1#--socket=}"
+			shift
+			;;
+		--socket)
+			shift
+			socket="$1"
+			shift
+			;;
+		*)
+			shift
+			;;
+	esac
+done
+
+if [[ -z "$socket" ]]; then
+	echo "didn't find socket path in args"
+	exit 1
+fi
+
+ln -s "$TS_TEST_SOCKET" "$socket"
+
+while true; do sleep 1; done