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")
}
}