mirror of https://github.com/tailscale/tailscale/
cmd/containerboot: add tests.
Signed-off-by: David Anderson <danderson@tailscale.com>pull/6262/head
parent
b683921b87
commit
2111357568
@ -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))
|
||||
}
|
||||
}
|
@ -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
|
@ -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
|
Loading…
Reference in New Issue