a73x

internal/agent/selfupdate/selfupdate_test.go

Ref:   Size: 12.6 KiB   History

package selfupdate

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"testing"
)

func serveBinary(t *testing.T, body []byte) (url, sha string) {
	t.Helper()
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Write(body)
	}))
	t.Cleanup(srv.Close)
	sum := sha256.Sum256(body)
	return srv.URL, hex.EncodeToString(sum[:])
}

// tarball builds a gzipped tar of the given name→content members, in the
// order listed, the way a release bundle lays them out.
func tarball(t *testing.T, members ...[2]string) []byte {
	t.Helper()
	var buf bytes.Buffer
	gz := gzip.NewWriter(&buf)
	tw := tar.NewWriter(gz)
	for _, m := range members {
		hdr := &tar.Header{
			Name:     m[0],
			Mode:     0o755,
			Size:     int64(len(m[1])),
			Typeflag: tar.TypeReg,
		}
		if err := tw.WriteHeader(hdr); err != nil {
			t.Fatal(err)
		}
		if _, err := tw.Write([]byte(m[1])); err != nil {
			t.Fatal(err)
		}
	}
	if err := tw.Close(); err != nil {
		t.Fatal(err)
	}
	if err := gz.Close(); err != nil {
		t.Fatal(err)
	}
	return buf.Bytes()
}

// hostBundle is the shape scripts/release.sh ships: a versioned directory
// holding the agent beside everything else a host installs.
func hostBundle(t *testing.T, agent string) []byte {
	t.Helper()
	return tarball(t,
		[2]string{"eitri-server_v9_linux_amd64/eitri-server", "server-binary"},
		[2]string{"eitri-server_v9_linux_amd64/eitri-agent", agent},
		[2]string{"eitri-server_v9_linux_amd64/eitri-agent.service", "[Unit]"},
	)
}

func TestApplyExtractsAgentFromTarball(t *testing.T) {
	dir := t.TempDir()
	exe := filepath.Join(dir, "eitri-agent")
	if err := os.WriteFile(exe, []byte("old"), 0o755); err != nil {
		t.Fatal(err)
	}
	url, sha := serveBinary(t, hostBundle(t, "new-binary"))

	var gotArgv0 string
	a := &Applier{
		ExePath: func() (string, error) { return exe, nil },
		Exec: func(argv0 string, argv, env []string) error {
			gotArgv0 = argv0
			return nil
		},
	}
	if err := a.Apply(context.Background(), Update{Version: "v9", URL: url, SHA256: sha}); err != nil {
		t.Fatal(err)
	}
	if b, _ := os.ReadFile(exe); string(b) != "new-binary" {
		t.Fatalf("bundle member not swapped in: %q", b)
	}
	if b, _ := os.ReadFile(exe + ".prev"); string(b) != "old" {
		t.Fatalf(".prev not preserved: %q", b)
	}
	if gotArgv0 != exe {
		t.Fatalf("exec argv0=%q want %q", gotArgv0, exe)
	}
	fi, _ := os.Stat(exe)
	if fi.Mode().Perm()&0o111 == 0 {
		t.Fatal("swapped binary not executable")
	}
	// The binary, its .prev, and nothing else: neither the download nor the
	// extracted member is left behind.
	if entries, _ := os.ReadDir(dir); len(entries) != 2 {
		t.Fatalf("temp file leaked: %v", entries)
	}
}

// TestApplyTarballShaCoversTheArchive pins which bytes the manifest sha
// describes: the artifact as served. A sha over the binary INSIDE the bundle
// is a mismatch, not a shortcut worth honouring.
func TestApplyTarballShaCoversTheArchive(t *testing.T) {
	dir := t.TempDir()
	exe := filepath.Join(dir, "eitri-agent")
	os.WriteFile(exe, []byte("old"), 0o755)
	url, _ := serveBinary(t, hostBundle(t, "new-binary"))
	inner := sha256.Sum256([]byte("new-binary"))

	execCalled := false
	a := &Applier{
		ExePath: func() (string, error) { return exe, nil },
		Exec:    func(string, []string, []string) error { execCalled = true; return nil },
	}
	err := a.Apply(context.Background(), Update{URL: url, SHA256: hex.EncodeToString(inner[:])})
	if err == nil || execCalled {
		t.Fatalf("want sha error without exec; err=%v execCalled=%v", err, execCalled)
	}
	if b, _ := os.ReadFile(exe); string(b) != "old" {
		t.Fatal("binary must be untouched on sha mismatch")
	}
}

func TestApplyTarballWithoutAgentMember(t *testing.T) {
	dir := t.TempDir()
	exe := filepath.Join(dir, "eitri-agent")
	os.WriteFile(exe, []byte("old"), 0o755)
	body := tarball(t,
		[2]string{"eitri-server_v9_linux_amd64/eitri-server", "server-binary"},
		[2]string{"eitri-server_v9_linux_amd64/eitri-agent.service", "[Unit]"},
	)
	url, sha := serveBinary(t, body)

	execCalled := false
	a := &Applier{
		ExePath: func() (string, error) { return exe, nil },
		Exec:    func(string, []string, []string) error { execCalled = true; return nil },
	}
	err := a.Apply(context.Background(), Update{URL: url, SHA256: sha})
	if err == nil || execCalled {
		t.Fatalf("want extract error without exec; err=%v execCalled=%v", err, execCalled)
	}
	if b, _ := os.ReadFile(exe); string(b) != "old" {
		t.Fatal("binary must be untouched when the archive carries no agent")
	}
	if _, err := os.Stat(exe + ".prev"); err == nil {
		t.Fatal("no .prev should exist when the swap never happened")
	}
	if entries, _ := os.ReadDir(dir); len(entries) != 1 {
		t.Fatalf("temp file leaked: %v", entries)
	}
}

// TestApplyTarballHostileMemberNames: an entry that names itself out of the
// bundle is not the agent. It is skipped rather than matched on its basename,
// and nothing is written outside the directory the binary lives in.
func TestApplyTarballHostileMemberNames(t *testing.T) {
	root := t.TempDir()
	dir := filepath.Join(root, "bin")
	if err := os.Mkdir(dir, 0o755); err != nil {
		t.Fatal(err)
	}
	exe := filepath.Join(dir, "eitri-agent")
	os.WriteFile(exe, []byte("old"), 0o755)
	body := tarball(t,
		[2]string{"../eitri-agent", "escaped"},
		[2]string{"/etc/eitri-agent", "absolute"},
		[2]string{"bundle/../../eitri-agent", "traversed"},
	)
	url, sha := serveBinary(t, body)

	execCalled := false
	a := &Applier{
		ExePath: func() (string, error) { return exe, nil },
		Exec:    func(string, []string, []string) error { execCalled = true; return nil },
	}
	err := a.Apply(context.Background(), Update{URL: url, SHA256: sha})
	if err == nil || execCalled {
		t.Fatalf("want extract error without exec; err=%v execCalled=%v", err, execCalled)
	}
	if b, _ := os.ReadFile(exe); string(b) != "old" {
		t.Fatalf("binary must be untouched: %q", b)
	}
	if _, err := os.Stat(filepath.Join(root, "eitri-agent")); err == nil {
		t.Fatal("a member wrote outside the binary's directory")
	}
	if entries, _ := os.ReadDir(root); len(entries) != 1 {
		t.Fatalf("wrote outside the binary's directory: %v", entries)
	}
}

// TestApplyTarballResumeKeepsPrev is the exec-failure retry for a bundle: the
// sha the manifest carries describes the archive, so the duplicate is only
// visible once the member is out, and .prev must survive it.
func TestApplyTarballResumeKeepsPrev(t *testing.T) {
	dir := t.TempDir()
	exe := filepath.Join(dir, "eitri-agent")
	if err := os.WriteFile(exe, []byte("already-swapped-binary"), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(exe+".prev", []byte("old"), 0o755); err != nil {
		t.Fatal(err)
	}
	url, sha := serveBinary(t, hostBundle(t, "already-swapped-binary"))

	var gotArgv0 string
	a := &Applier{
		ExePath: func() (string, error) { return exe, nil },
		Exec: func(argv0 string, argv, env []string) error {
			gotArgv0 = argv0
			return nil
		},
	}
	if err := a.Apply(context.Background(), Update{Version: "v9", URL: url, SHA256: sha}); err != nil {
		t.Fatal(err)
	}
	if b, _ := os.ReadFile(exe + ".prev"); string(b) != "old" {
		t.Fatalf(".prev must survive an exec-failure retry: %q", b)
	}
	if gotArgv0 != exe {
		t.Fatalf("exec argv0=%q want %q", gotArgv0, exe)
	}
	if entries, _ := os.ReadDir(dir); len(entries) != 2 {
		t.Fatalf("temp file leaked: %v", entries)
	}
}

// TestApplyNonGzipArtifactSwapsWhole: the artifact type comes from the bytes,
// and bytes that merely start like an archive are still a bare binary. The
// manifest names it, its sha covers it, it lands whole.
func TestApplyNonGzipArtifactSwapsWhole(t *testing.T) {
	dir := t.TempDir()
	exe := filepath.Join(dir, "eitri-agent")
	os.WriteFile(exe, []byte("old"), 0o755)
	body := []byte{0x1f, 0x00, 'n', 'o', 't', '-', 'g', 'z'}
	url, sha := serveBinary(t, body)

	a := &Applier{
		ExePath: func() (string, error) { return exe, nil },
		Exec:    func(string, []string, []string) error { return nil },
	}
	if err := a.Apply(context.Background(), Update{URL: url, SHA256: sha}); err != nil {
		t.Fatal(err)
	}
	if b, _ := os.ReadFile(exe); !bytes.Equal(b, body) {
		t.Fatalf("bare artifact not swapped in whole: %q", b)
	}
}

func TestApplySwapsAndExecs(t *testing.T) {
	dir := t.TempDir()
	exe := filepath.Join(dir, "eitri-agent")
	if err := os.WriteFile(exe, []byte("old"), 0o755); err != nil {
		t.Fatal(err)
	}
	url, sha := serveBinary(t, []byte("new-binary"))

	var gotArgv0 string
	var gotArgv []string
	a := &Applier{
		ExePath: func() (string, error) { return exe, nil },
		Exec: func(argv0 string, argv, env []string) error {
			gotArgv0, gotArgv = argv0, argv
			return nil
		},
	}
	if err := a.Apply(context.Background(), Update{Version: "v9", URL: url, SHA256: sha}); err != nil {
		t.Fatal(err)
	}
	if b, _ := os.ReadFile(exe); string(b) != "new-binary" {
		t.Fatalf("binary not swapped: %q", b)
	}
	if b, _ := os.ReadFile(exe + ".prev"); string(b) != "old" {
		t.Fatalf(".prev not preserved: %q", b)
	}
	if gotArgv0 != exe || len(gotArgv) == 0 || gotArgv[0] != os.Args[0] {
		t.Fatalf("exec argv0=%q argv=%v", gotArgv0, gotArgv)
	}
	fi, _ := os.Stat(exe)
	if fi.Mode().Perm()&0o111 == 0 {
		t.Fatal("swapped binary not executable")
	}
}

func TestApplyShaMismatchLeavesBinary(t *testing.T) {
	dir := t.TempDir()
	exe := filepath.Join(dir, "eitri-agent")
	os.WriteFile(exe, []byte("old"), 0o755)
	url, _ := serveBinary(t, []byte("evil"))

	// A stale temp from some earlier, crashed attempt: Apply must sweep this
	// before doing anything else, and it must be gone afterward too.
	stale := filepath.Join(dir, ".eitri-agent-upgrade-stale")
	if err := os.WriteFile(stale, []byte("litter"), 0o644); err != nil {
		t.Fatal(err)
	}

	execCalled := false
	a := &Applier{
		ExePath: func() (string, error) { return exe, nil },
		Exec:    func(string, []string, []string) error { execCalled = true; return nil },
	}
	err := a.Apply(context.Background(), Update{Version: "v9", URL: url, SHA256: "00"})
	if err == nil || execCalled {
		t.Fatalf("want sha error without exec; err=%v execCalled=%v", err, execCalled)
	}
	if b, _ := os.ReadFile(exe); string(b) != "old" {
		t.Fatal("binary must be untouched on sha mismatch")
	}
	if _, err := os.Stat(exe + ".prev"); err == nil {
		t.Fatal("no .prev should exist on sha mismatch")
	}
	if _, err := os.Stat(stale); err == nil {
		t.Fatal("stale pre-existing temp must be swept")
	}
	// No temp litter (this attempt's own temp is cleaned up too).
	entries, _ := os.ReadDir(dir)
	if len(entries) != 1 {
		t.Fatalf("temp file leaked: %v", entries)
	}
}

// TestApplyResumesAfterSwappedExec covers the case where a PRIOR Apply already
// renamed the new binary into place but then syscall.Exec failed (e.g. ENOMEM,
// or the new binary is not actually executable on this kernel): the running
// process is still the old one, the server keeps re-offering the same
// upgrade, and this retry must not re-download or re-copy — that copyFile
// would overwrite .prev (the true previous version) with the binary that is
// already at <exe>, destroying the recovery copy for nothing.
func TestApplyResumesAfterSwappedExec(t *testing.T) {
	dir := t.TempDir()
	exe := filepath.Join(dir, "eitri-agent")
	swapped := []byte("already-swapped-binary")
	sum := sha256.Sum256(swapped)
	sha := hex.EncodeToString(sum[:])
	if err := os.WriteFile(exe, swapped, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(exe+".prev", []byte("old"), 0o755); err != nil {
		t.Fatal(err)
	}

	var requests int
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		requests++
		w.Write([]byte("should not be fetched"))
	}))
	defer srv.Close()

	var gotArgv0 string
	a := &Applier{
		ExePath: func() (string, error) { return exe, nil },
		Exec: func(argv0 string, argv, env []string) error {
			gotArgv0 = argv0
			return nil
		},
	}
	if err := a.Apply(context.Background(), Update{Version: "v9", URL: srv.URL, SHA256: sha}); err != nil {
		t.Fatal(err)
	}
	if requests != 0 {
		t.Fatalf("want zero HTTP requests on idempotent resume, got %d", requests)
	}
	if b, _ := os.ReadFile(exe + ".prev"); string(b) != "old" {
		t.Fatalf(".prev must survive an exec-failure retry: %q", b)
	}
	if gotArgv0 != exe {
		t.Fatalf("exec argv0=%q want %q", gotArgv0, exe)
	}
}

func TestApplyDownloadError(t *testing.T) {
	dir := t.TempDir()
	exe := filepath.Join(dir, "eitri-agent")
	os.WriteFile(exe, []byte("old"), 0o755)
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusNotFound)
	}))
	defer srv.Close()
	a := &Applier{ExePath: func() (string, error) { return exe, nil },
		Exec: func(string, []string, []string) error { return nil }}
	if err := a.Apply(context.Background(), Update{URL: srv.URL, SHA256: "00"}); err == nil {
		t.Fatal("want download error")
	}
}