Files
RenewCMS/test/update_helper_test.go

914 lines
31 KiB
Go

package test
import (
"RenewCMS/internal/domain/update"
infraUpdate "RenewCMS/internal/infrastructure/update"
"RenewCMS/internal/infrastructure/useCases"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"strings"
"testing"
"time"
)
const (
helperData = "/var/lib/renewcms"
helperBinary = "/opt/renewcms/renewcms"
helperBinaryDir = "/opt/renewcms"
helperOld = "v2.2.0-beta"
helperSentinel = "sentinel bytes that must never change"
)
var (
helperMarker = helperData + "/" + update.RestartRequestFileName
helperResult = helperData + "/" + update.ResultFileName
helperResultTmp = helperResult + ".tmp"
helperUpdatesDir = helperData + "/" + update.UpdatesDirName
helperStagedDir = helperUpdatesDir + "/" + downloadTag
helperAsset = update.AssetName(downloadTag, update.Platform{OS: "linux", Arch: "amd64"})
)
type fsVerifier struct {
files *fakeHelperFS
key ed25519.PublicKey
events *[]string
}
var _ update.SignatureVerifier = &fsVerifier{}
func (v *fsVerifier) Verify(checksumsPath string, signaturePath string) error {
*v.events = append(*v.events, "verify "+checksumsPath)
checksums, found := v.files.content(checksumsPath)
if !found {
return errors.New("checksums missing")
}
rawSignature, found := v.files.content(signaturePath)
if !found {
return errors.New("signature missing")
}
signature, err := base64.StdEncoding.DecodeString(strings.TrimSpace(rawSignature))
if err != nil || !ed25519.Verify(v.key, []byte(checksums), signature) {
return errors.New("signature mismatch")
}
return nil
}
type helperRig struct {
t *testing.T
fs *fakeHelperFS
running *fakeRunningInfoStore
clock *fakeClock
service *fakeServiceControl
health *fakeHealthChecker
logger *fakeUpdateLogger
events []string
public ed25519.PublicKey
private ed25519.PrivateKey
newBinary string
bootVersions []string
oldInfo update.RunningInfo
usecase *useCases.ApplyRestartRequestUseCase
}
func newHelperRig(t *testing.T) *helperRig {
t.Helper()
public, private, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
rig := &helperRig{
t: t,
fs: newFakeHelperFS(),
running: &fakeRunningInfoStore{},
clock: &fakeClock{now: restartBaseTime},
health: &fakeHealthChecker{healthy: true},
logger: &fakeUpdateLogger{},
public: public,
private: private,
newBinary: "new binary bytes 2.3.0",
bootVersions: []string{downloadTag, helperOld},
oldInfo: update.RunningInfo{Version: helperOld, Pid: 100, StartedAt: restartBaseTime.Add(-time.Hour)},
}
rig.fs.events = &rig.events
rig.fs.putFile(helperBinary, "old")
rig.fs.putDir(helperData)
rig.running.set(rig.oldInfo)
rig.service = &fakeServiceControl{clock: rig.clock, failOn: map[int]error{}, events: &rig.events}
rig.service.onRestart = func(call int) {
version := rig.bootVersions[min(call, len(rig.bootVersions))-1]
rig.running.set(update.RunningInfo{Version: version, Pid: 200 + call, StartedAt: rig.clock.Now().Add(time.Second)})
}
rig.usecase = rig.build()
return rig
}
func (r *helperRig) build() *useCases.ApplyRestartRequestUseCase {
return useCases.NewApplyRestartRequestUseCase(useCases.ApplyRestartRequestDependencies{
Requests: infraUpdate.NewFSRestartRequestStore(r.fs, helperMarker),
Results: infraUpdate.NewFSResultStore(r.fs, helperResult),
Running: r.running,
Files: r.fs,
Service: r.service,
Health: r.health,
Verifier: &fsVerifier{files: r.fs, key: r.public, events: &r.events},
Clock: r.clock,
Logger: r.logger,
Layout: update.HelperLayout{DataDir: helperData, BinaryPath: helperBinary},
Platform: update.Platform{OS: "linux", Arch: "amd64"},
})
}
func (r *helperRig) checksumsFor(binary string) string {
return update.SHA256Hex([]byte(binary)) + " " + helperAsset + "\n"
}
func (r *helperRig) sign(checksums string, key ed25519.PrivateKey) string {
return base64.StdEncoding.EncodeToString(ed25519.Sign(key, []byte(checksums))) + "\n"
}
func (r *helperRig) stage() {
checksums := r.checksumsFor(r.newBinary)
r.fs.putFile(helperStagedDir+"/"+helperAsset, r.newBinary)
r.fs.putFile(helperStagedDir+"/checksums.txt", checksums)
r.fs.putFile(helperStagedDir+"/checksums.txt.sig", r.sign(checksums, r.private))
}
func (r *helperRig) markUpdate(tag string) {
r.writeMarker(update.RestartRequest{Kind: update.RestartKindUpdate, Tag: tag, RequestedAt: restartBaseTime, RequestedBy: 1})
}
func (r *helperRig) markRestart() {
r.bootVersions = []string{helperOld, helperOld}
r.writeMarker(update.RestartRequest{Kind: update.RestartKindRestart, RequestedAt: restartBaseTime, RequestedBy: 1})
}
func (r *helperRig) writeMarker(request update.RestartRequest) {
content, _ := json.Marshal(request)
r.fs.putFile(helperMarker, string(content))
}
func (r *helperRig) apply() error {
r.t.Helper()
return r.usecase.Apply()
}
func (r *helperRig) result() update.Result {
r.t.Helper()
content, found := r.fs.content(helperResult)
if !found {
r.t.Fatal("update-result.json was not written")
}
var result update.Result
if err := json.Unmarshal([]byte(content), &result); err != nil {
r.t.Fatalf("result is not json: %v", err)
}
return result
}
func (r *helperRig) outcomes() []string {
var outcomes []string
for _, content := range r.fs.history[helperResult] {
var result update.Result
_ = json.Unmarshal([]byte(content), &result)
outcomes = append(outcomes, result.Outcome)
}
return outcomes
}
func (r *helperRig) assertBinary(want string) {
r.t.Helper()
if content, _ := r.fs.content(helperBinary); content != want {
r.t.Errorf("binary = %q, want %q", content, want)
}
}
func (r *helperRig) assertUntouched() {
r.t.Helper()
r.assertBinary("old")
if r.fs.exists(helperBinary + ".previous") {
r.t.Error("no .previous may be written")
}
if r.service.restartCount() != 0 {
r.t.Errorf("restarts = %d, want 0", r.service.restartCount())
}
}
func (r *helperRig) assertOutsideUntouched(sentinelPaths ...string) {
r.t.Helper()
if outside := r.fs.resolvedOutside(helperData, helperBinaryDir, "/tmp"); len(outside) != 0 {
r.t.Errorf("operations reached outside the allowed directories: %v", outside)
}
for _, target := range sentinelPaths {
if content, _ := r.fs.content(target); content != helperSentinel {
r.t.Errorf("%s changed: %q", target, content)
}
}
}
func (r *helperRig) assertCleaned() {
r.t.Helper()
if r.fs.exists(helperStagedDir) {
r.t.Error("the staged directory must be removed")
}
for name := range r.fs.nodes {
if strings.HasPrefix(name, "/tmp/renewcms-update-") {
r.t.Errorf("the temp directory must be removed, found %s", name)
}
}
}
func assertInOrder(t *testing.T, events []string, wants ...string) {
t.Helper()
position := 0
for _, want := range wants {
found := false
for ; position < len(events); position++ {
if strings.HasPrefix(events[position], want) {
found = true
position++
break
}
}
if !found {
t.Fatalf("%q is missing or out of order in:\n%s", want, strings.Join(events, "\n"))
}
}
}
func TestApplyPlainRestart(t *testing.T) {
t.Run("apply-update, a plain restart", func(t *testing.T) {
rig := newHelperRig(t)
rig.markRestart()
rig.fs.putFile(helperStagedDir+"/keep", "staged")
if err := rig.apply(); err != nil {
t.Fatal(err)
}
assertInOrder(t, rig.events, "lstat "+helperMarker, "read "+helperMarker, "remove "+helperMarker, "create "+helperResultTmp, "restart", "create "+helperResultTmp)
if got := rig.outcomes(); len(got) != 2 || got[0] != "running" || got[1] != "succeeded" {
t.Errorf("outcomes = %v", got)
}
result := rig.result()
if result.Kind != "restart" || result.FinishedAt == nil || result.FromVersion != helperOld || !result.StartedAt.Equal(restartBaseTime) {
t.Errorf("result = %+v", result)
}
if rig.service.restartCount() != 1 || rig.fs.exists(helperBinary+".previous") {
t.Error("one restart, no write next to the binary")
}
rig.assertBinary("old")
if content, _ := rig.fs.content(helperStagedDir + "/keep"); content != "staged" {
t.Error("the staged files must be untouched")
}
if rig.fs.exists(helperMarker) {
t.Error("the marker must be deleted")
}
})
t.Run("no marker means nothing to do", func(t *testing.T) {
rig := newHelperRig(t)
if err := rig.apply(); err != nil || rig.fs.exists(helperResult) || rig.service.restartCount() != 0 {
t.Errorf("err = %v", err)
}
})
t.Run("a corrupt marker or an unknown kind fails without restarting", func(t *testing.T) {
for name, content := range map[string]string{"corrupt": "{nope", "unknown kind": `{"kind":"reboot","requested_at":"2026-09-30T09:50:00Z"}`} {
t.Run(name, func(t *testing.T) {
rig := newHelperRig(t)
rig.fs.putFile(helperMarker, content)
err := rig.apply()
if err != nil || rig.result().Outcome != "failed" || rig.service.restartCount() != 0 || rig.fs.exists(helperMarker) {
t.Errorf("err = %v, result = %+v", err, rig.result())
}
})
}
})
}
func TestApplyStaleRunningInfo(t *testing.T) {
staleRows := map[string]struct {
kind string
boots func(rig *helperRig, call int)
}{
"still holds the old pid and old started_at": {"restart", func(rig *helperRig, call int) {}},
"holds the target version but the old pid and an old started_at": {"update", func(rig *helperRig, call int) {
rig.running.set(update.RunningInfo{Version: downloadTag, Pid: rig.oldInfo.Pid, StartedAt: rig.oldInfo.StartedAt})
}},
"is missing": {"restart", func(rig *helperRig, call int) { rig.running.unreadable() }},
"is corrupt": {"restart", func(rig *helperRig, call int) { rig.running.unreadable() }},
"holds the old version with a new pid (for an update)": {"update", func(rig *helperRig, call int) {
rig.running.set(update.RunningInfo{Version: helperOld, Pid: 300, StartedAt: rig.clock.Now().Add(time.Second)})
}},
}
for name, row := range staleRows {
t.Run("A stale running file never counts as success: "+name, func(t *testing.T) {
rig := newHelperRig(t)
if row.kind == "update" {
rig.stage()
rig.markUpdate(downloadTag)
} else {
rig.markRestart()
}
rig.service.onRestart = func(call int) {
if call == 1 {
row.boots(rig, call)
return
}
rig.running.set(update.RunningInfo{Version: helperOld, Pid: 500, StartedAt: rig.clock.Now().Add(time.Second)})
}
err := rig.apply()
result := rig.result()
first := rig.service.restarts[0]
if row.kind == "restart" {
if err == nil || result.Outcome != "failed" || rig.service.restartCount() != 1 {
t.Errorf("err = %v, result = %+v", err, result)
}
if rig.clock.Now().Sub(first) < update.RestartWindow {
t.Errorf("gave up after %v", rig.clock.Now().Sub(first))
}
return
}
if result.Outcome != "rolled_back" || rig.service.restartCount() != 2 {
t.Fatalf("result = %+v, restarts = %d", result, rig.service.restartCount())
}
if rig.service.restarts[1].Sub(first) < update.RestartWindow {
t.Errorf("rolled back after %v, before the 60 s window", rig.service.restarts[1].Sub(first))
}
})
}
t.Run("a file with the same pid but a later started_at counts as new", func(t *testing.T) {
rig := newHelperRig(t)
rig.markRestart()
rig.service.onRestart = func(int) {
rig.running.set(update.RunningInfo{Version: helperOld, Pid: rig.oldInfo.Pid, StartedAt: rig.clock.Now().Add(time.Second)})
}
if err := rig.apply(); err != nil || rig.result().Outcome != "succeeded" || rig.clock.sleeps != 0 {
t.Errorf("err = %v, outcome = %v, sleeps = %d", err, rig.result().Outcome, rig.clock.sleeps)
}
})
t.Run("success needs a healthy answer too", func(t *testing.T) {
rig := newHelperRig(t)
rig.markRestart()
rig.health.healthy = false
err := rig.apply()
if err == nil || rig.result().Outcome != "failed" {
t.Errorf("err = %v, result = %+v", err, rig.result())
}
})
}
func TestApplyUpdate(t *testing.T) {
t.Run("apply-update, an update, happy path", func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
temp := "/tmp/renewcms-update-1"
if err := rig.apply(); err != nil {
t.Fatal(err)
}
assertInOrder(t, rig.events,
"read "+helperMarker, "remove "+helperMarker, "create "+helperResultTmp,
"lstat "+helperStagedDir+"/"+helperAsset, "tempdir",
"copy "+helperStagedDir+"/"+helperAsset, "copy "+helperStagedDir+"/checksums.txt", "copy "+helperStagedDir+"/checksums.txt.sig",
"verify "+temp+"/checksums.txt", "read "+temp+"/checksums.txt", "read "+temp+"/"+helperAsset,
"copy "+helperBinary, "rename "+helperBinary+".previous.tmp",
"copy "+temp+"/"+helperAsset, "read "+helperBinary+".new", "chmod "+helperBinary+".new", "rename "+helperBinary+".new",
"restart", "create "+helperResultTmp, "removeall "+temp, "removeall "+helperStagedDir)
rig.assertBinary(rig.newBinary)
if previous, _ := rig.fs.content(helperBinary + ".previous"); previous != "old" {
t.Errorf(".previous = %q", previous)
}
if rig.fs.mode(helperBinary) != 0o755 || rig.fs.mode(temp) != 0 {
t.Errorf("binary mode = %o, temp directory must be gone", rig.fs.mode(helperBinary))
}
if rig.clock.sleeps != 0 || rig.service.restartCount() != 1 {
t.Errorf("success must be declared on the first matching poll: sleeps = %d", rig.clock.sleeps)
}
result := rig.result()
if result.Outcome != "succeeded" || result.Kind != "update" || result.Tag != downloadTag || result.FromVersion != helperOld {
t.Errorf("result = %+v", result)
}
rig.assertCleaned()
rig.assertOutsideUntouched()
if rig.fs.exists(helperBinary+".new") || rig.fs.exists(helperBinary+".previous.tmp") {
t.Error("no temp file may be left next to the binary")
}
})
t.Run("the verified copy lives in a private temp directory", func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
rig.fs.after("read", 4, func(f *fakeHelperFS) {
if f.mode("/tmp/renewcms-update-1") != 0o700 || f.nodes["/tmp/renewcms-update-1"].owner != "root" {
t.Errorf("temp directory mode = %o owner = %s", f.mode("/tmp/renewcms-update-1"), f.nodes["/tmp/renewcms-update-1"].owner)
}
})
_ = rig.apply()
})
t.Run("apply-update refuses an untrusted staged binary", func(t *testing.T) {
faults := map[string]struct {
mutate func(*helperRig)
reason string
}{
"the binary was changed after the job verified it": {func(r *helperRig) { r.fs.putFile(helperStagedDir+"/"+helperAsset, "tampered") }, "sha-256 mismatch"},
"checksums.txt was changed": {func(r *helperRig) {
r.fs.putFile(helperStagedDir+"/checksums.txt", r.checksumsFor("tampered"))
}, "signature"},
"the signature is from a different key": {func(r *helperRig) {
_, other, _ := ed25519.GenerateKey(rand.Reader)
r.fs.putFile(helperStagedDir+"/checksums.txt.sig", r.sign(r.checksumsFor(r.newBinary), other))
}, "signature"},
"the signature is missing": {func(r *helperRig) { delete(r.fs.nodes, helperStagedDir+"/checksums.txt.sig") }, "checksums.txt.sig"},
"checksums.txt has no line for the asset": {func(r *helperRig) {
checksums := update.SHA256Hex([]byte("x")) + " something_else\n"
r.fs.putFile(helperStagedDir+"/checksums.txt", checksums)
r.fs.putFile(helperStagedDir+"/checksums.txt.sig", r.sign(checksums, r.private))
}, "no line"},
"the staged binary is missing": {func(r *helperRig) { delete(r.fs.nodes, helperStagedDir+"/"+helperAsset) }, helperAsset},
}
for name, fault := range faults {
t.Run(name, func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
fault.mutate(rig)
err := rig.apply()
if err != nil || rig.result().Outcome != "failed" {
t.Errorf("err = %v, result = %+v", err, rig.result())
}
rig.assertUntouched()
rig.assertCleaned()
if !strings.Contains(rig.logger.text(), fault.reason) {
t.Errorf("the log must carry the reason %q: %s", fault.reason, rig.logger.text())
}
if raw, _ := rig.fs.content(helperResult); strings.Contains(raw, "mismatch") || strings.Contains(raw, helperData) {
t.Errorf("the technical reason must stay out of the result file: %s", raw)
}
})
}
})
}
func TestApplyRefusesSymlinks(t *testing.T) {
const outside = "/outside"
type row struct {
setup func(*helperRig)
link string
keep []string
}
rows := map[string]row{
"the marker itself is a symlink": {func(r *helperRig) {
r.fs.putFile(outside+"/secret", helperSentinel)
r.fs.putSymlink(helperMarker, outside+"/secret")
}, helperMarker, []string{outside + "/secret"}},
"<data>/updates is a symlink to another directory": {func(r *helperRig) {
r.fs.putDir(outside + "/updates/" + downloadTag)
r.fs.putFile(outside+"/updates/"+downloadTag+"/"+helperAsset, helperSentinel)
r.fs.putFile(outside+"/updates/"+downloadTag+"/checksums.txt", helperSentinel)
r.fs.putFile(outside+"/updates/"+downloadTag+"/checksums.txt.sig", helperSentinel)
r.forget(helperUpdatesDir)
r.fs.putSymlink(helperUpdatesDir, outside+"/updates")
}, helperUpdatesDir, []string{outside + "/updates/" + downloadTag + "/" + helperAsset}},
"<data>/updates/<tag> is a symlink to another directory": {func(r *helperRig) {
r.fs.putFile(outside+"/tag/"+helperAsset, helperSentinel)
r.fs.putFile(outside+"/tag/checksums.txt", helperSentinel)
r.fs.putFile(outside+"/tag/checksums.txt.sig", helperSentinel)
r.forget(helperStagedDir)
r.fs.putSymlink(helperStagedDir, outside+"/tag")
}, helperStagedDir, []string{outside + "/tag/" + helperAsset, outside + "/tag/checksums.txt"}},
"the staged binary is a symlink to /usr/bin/something": {func(r *helperRig) {
r.fs.putFile("/usr/bin/something", helperSentinel)
delete(r.fs.nodes, helperStagedDir+"/"+helperAsset)
r.fs.putSymlink(helperStagedDir+"/"+helperAsset, "/usr/bin/something")
}, helperStagedDir + "/" + helperAsset, []string{"/usr/bin/something"}},
"checksums.txt is a symlink": {func(r *helperRig) {
r.fs.putFile(outside+"/c", helperSentinel)
delete(r.fs.nodes, helperStagedDir+"/checksums.txt")
r.fs.putSymlink(helperStagedDir+"/checksums.txt", outside+"/c")
}, helperStagedDir + "/checksums.txt", []string{outside + "/c"}},
"checksums.txt.sig is a symlink": {func(r *helperRig) {
r.fs.putFile(outside+"/s", helperSentinel)
delete(r.fs.nodes, helperStagedDir+"/checksums.txt.sig")
r.fs.putSymlink(helperStagedDir+"/checksums.txt.sig", outside+"/s")
}, helperStagedDir + "/checksums.txt.sig", []string{outside + "/s"}},
"a symlink is placed at the result path": {func(r *helperRig) {
r.fs.putFile(outside+"/result-target", helperSentinel)
r.fs.putSymlink(helperResult, outside+"/result-target")
}, helperResult, []string{outside + "/result-target"}},
"a symlink is placed at a temp file name": {func(r *helperRig) {
r.fs.putFile(outside+"/tmp-target", helperSentinel)
r.fs.putSymlink(helperResultTmp, outside+"/tmp-target")
}, helperResultTmp, []string{outside + "/tmp-target"}},
}
for name, tc := range rows {
t.Run("apply-update refuses symlinks: "+name, func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
tc.setup(rig)
err := rig.apply()
if err != nil || rig.result().Outcome != "failed" {
t.Errorf("err = %v, result = %+v", err, rig.result())
}
rig.assertUntouched()
rig.assertOutsideUntouched(tc.keep...)
if !strings.Contains(rig.logger.text(), tc.link) {
t.Errorf("the log must name %s: %s", tc.link, rig.logger.text())
}
if node := rig.fs.nodes[helperResult]; node == nil || node.kind != "file" {
t.Error("the result must be a regular file written next to the link, not through it")
}
if len(rig.fs.touched("read", "copy")) > 0 {
for _, operation := range rig.fs.touched("read", "copy") {
if strings.HasPrefix(operation.resolved, outside) || strings.HasPrefix(operation.resolved, "/usr") {
t.Errorf("read through a link: %+v", operation)
}
}
}
})
}
t.Run("A symlinked marker is removed, not followed", func(t *testing.T) {
rig := newHelperRig(t)
rig.fs.putFile(outside+"/secret", helperSentinel)
rig.fs.putSymlink(helperMarker, outside+"/secret")
err := rig.apply()
if err != nil || rig.fs.exists(helperMarker) || rig.result().Outcome != "failed" {
t.Errorf("err = %v, marker present = %v", err, rig.fs.exists(helperMarker))
}
if content, _ := rig.fs.content(outside + "/secret"); content != helperSentinel {
t.Error("the target must be intact")
}
})
t.Run("a link found in the staged directory is unlinked, not followed, when cleaning", func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
rig.fs.putFile(outside+"/tag/"+helperAsset, helperSentinel)
rig.forget(helperStagedDir)
rig.fs.putSymlink(helperStagedDir, outside+"/tag")
_ = rig.apply()
if rig.fs.exists(helperStagedDir) {
t.Error("the link itself may be removed")
}
if content, _ := rig.fs.content(outside + "/tag/" + helperAsset); content != helperSentinel {
t.Error("the target must be intact")
}
})
}
func (r *helperRig) forget(prefix string) {
for name := range r.fs.nodes {
if name == prefix || strings.HasPrefix(name, prefix+"/") {
delete(r.fs.nodes, name)
}
}
}
func TestApplySwapIsRaceFree(t *testing.T) {
t.Run("A file swapped after verification is not installed", func(t *testing.T) {
swapAfter := map[string]struct {
operation string
call int
}{
"the copy step": {"copy", 3},
"the signature check": {"read", 2},
"the hash check": {"read", 3},
"the .previous copy": {"copy", 4},
"the final copy and install": {"copy", 5},
}
for name, point := range swapAfter {
t.Run(name, func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
rig.fs.after(point.operation, point.call, func(f *fakeHelperFS) {
f.putFile(helperStagedDir+"/"+helperAsset, "attacker bytes")
})
err := rig.apply()
if err != nil {
t.Fatal(err)
}
rig.assertBinary(rig.newBinary)
if rig.result().Outcome != "succeeded" {
t.Errorf("result = %+v", rig.result())
}
})
}
})
t.Run("a swap of the temp file in the binary's directory before the rename is caught by the re-hash", func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
rig.fs.after("copy", 5, func(f *fakeHelperFS) {
f.putFile(helperBinary+".new", "attacker bytes in the temp file")
})
err := rig.apply()
if err != nil || rig.result().Outcome != "failed" {
t.Errorf("err = %v, result = %+v", err, rig.result())
}
rig.assertBinary("old")
if rig.service.restartCount() != 0 || rig.fs.exists(helperBinary+".new") {
t.Error("no restart and the temp file must be removed")
}
})
}
func TestApplyResultWrites(t *testing.T) {
t.Run("Result and temp files are written without following links", func(t *testing.T) {
files := newFakeHelperFS()
files.putFile("/outside/a", helperSentinel)
files.putFile("/outside/b", helperSentinel)
files.putDir(helperData)
files.putSymlink(helperResult, "/outside/a")
files.putSymlink(helperResultTmp, "/outside/b")
store := infraUpdate.NewFSResultStore(files, helperResult)
err := store.Write(update.Result{Outcome: update.OutcomeFailed, Kind: "update", StartedAt: restartBaseTime, FinishedAt: finishedAt(restartBaseTime)})
if err != nil {
t.Fatal(err)
}
if a, _ := files.content("/outside/a"); a != helperSentinel {
t.Error("the result link target must be untouched")
}
if b, _ := files.content("/outside/b"); b != helperSentinel {
t.Error("the temp link target must be untouched")
}
if node := files.nodes[helperResult]; node == nil || node.kind != "file" || node.mode != 0o644 {
t.Errorf("result node = %+v", node)
}
if got, found := store.Read(); !found || got.Outcome != update.OutcomeFailed {
t.Errorf("read back = %+v, %v", got, found)
}
assertInOrder(t, files.eventsFrom(), "lstat", "remove", "create", "rename")
})
}
func (f *fakeHelperFS) eventsFrom() []string {
names := make([]string, 0, len(f.ops))
for _, operation := range f.ops {
names = append(names, operation.name+" "+operation.path)
}
return names
}
func TestApplyDistrustsTheMarker(t *testing.T) {
tags := map[string]string{
"../../etc/passwd": "../../etc/passwd",
"v2.3.0-beta/../../x": "v2.3.0-beta/../../x",
"an empty string": "",
"v2.1.0-beta (older than the running)": "v2.1.0-beta",
"not-a-version": "not-a-version",
"the running version itself": helperOld,
}
for name, tag := range tags {
t.Run("apply-update distrusts the marker: "+name, func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.fs.putFile("/var/lib/renewcms/etc/passwd", helperSentinel)
rig.markUpdate(tag)
err := rig.apply()
if err != nil || rig.result().Outcome != "failed" {
t.Errorf("err = %v, result = %+v", err, rig.result())
}
rig.assertUntouched()
rig.assertOutsideUntouched()
for _, operation := range rig.fs.ops {
if strings.Contains(operation.path, "..") || strings.Contains(operation.path, "passwd") || strings.HasPrefix(operation.path, helperUpdatesDir) {
t.Errorf("a distrusted tag reached the filesystem: %+v", operation)
}
}
if !rig.fs.exists(helperStagedDir + "/" + helperAsset) {
t.Error("staged files of another tag must be left alone")
}
})
}
}
func TestApplyRollsBack(t *testing.T) {
faults := map[string]func(*helperRig){
"/healthz never answers within 60 s": func(r *helperRig) {
r.health.healthy = false
r.service.onRestart = func(call int) {
if call == 2 {
r.health.healthy = true
}
r.running.set(update.RunningInfo{Version: r.bootVersions[call-1], Pid: 200 + call, StartedAt: r.clock.Now().Add(time.Second)})
}
},
"/healthz answers but update-running.json stays old": func(r *helperRig) {
r.service.onRestart = func(call int) {
if call == 2 {
r.running.set(update.RunningInfo{Version: helperOld, Pid: 300, StartedAt: r.clock.Now().Add(time.Second)})
}
}
},
"update-running.json holds another version": func(r *helperRig) {
r.service.onRestart = func(call int) {
version := "v9.9.9"
if call == 2 {
version = helperOld
}
r.running.set(update.RunningInfo{Version: version, Pid: 200 + call, StartedAt: r.clock.Now().Add(time.Second)})
}
},
"the service restart command fails": func(r *helperRig) {
r.service.failOn[1] = errors.New("systemctl restart renewcms: exit status 1")
},
}
for name, fault := range faults {
t.Run("apply-update rolls back when the new version does not come up: "+name, func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
fault(rig)
err := rig.apply()
if err != nil {
t.Fatalf("a rollback that worked is not an error: %v", err)
}
rig.assertBinary("old")
if rig.service.restartCount() != 2 {
t.Fatalf("restarts = %d", rig.service.restartCount())
}
if name != "the service restart command fails" && rig.service.restarts[1].Sub(rig.service.restarts[0]) < update.RestartWindow {
t.Errorf("rolled back after %v", rig.service.restarts[1].Sub(rig.service.restarts[0]))
}
assertInOrder(t, rig.events, "rename "+helperBinary+".new", "restart", "copy "+helperBinary+".previous", "rename "+helperBinary+".restore", "restart")
result := rig.result()
if result.Outcome != "rolled_back" || result.FromVersion != helperOld || result.Tag != downloadTag || result.FinishedAt == nil {
t.Errorf("result = %+v", result)
}
rig.assertCleaned()
if rig.fs.exists(helperBinary + ".restore") {
t.Error("no restore temp file may be left")
}
})
}
}
func TestApplySwapFailures(t *testing.T) {
t.Run("copying to .previous fails", func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
rig.fs.failNth("copy", 4, errors.New("no space left on device"))
err := rig.apply()
if err != nil || rig.result().Outcome != "failed" {
t.Errorf("err = %v, result = %+v", err, rig.result())
}
rig.assertUntouched()
})
t.Run("the atomic install fails", func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
rig.fs.failNth("rename", 3, errors.New("text file busy"))
err := rig.apply()
if err != nil || rig.result().Outcome != "failed" || rig.service.restartCount() != 0 {
t.Errorf("err = %v, result = %+v", err, rig.result())
}
rig.assertBinary("old")
if previous, _ := rig.fs.content(helperBinary + ".previous"); previous != "old" {
t.Errorf(".previous = %q", previous)
}
if rig.fs.exists(helperBinary + ".new") {
t.Error("the temp file must be removed")
}
})
t.Run("the rollback restore itself fails", func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
rig.service.failOn[1] = errors.New("restart failed")
rig.fs.failNth("copy", 6, errors.New("input/output error"))
err := rig.apply()
if err == nil || rig.result().Outcome != "failed" || rig.service.restartCount() != 1 {
t.Errorf("err = %v, result = %+v", err, rig.result())
}
if !strings.Contains(rig.logger.text(), "the site may be down") {
t.Errorf("the failure must be logged loudly: %s", rig.logger.text())
}
})
t.Run("the old version never returns after rollback", func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
rig.service.onRestart = func(call int) {
if call == 1 {
rig.running.set(update.RunningInfo{Version: "v9.9.9", Pid: 201, StartedAt: rig.clock.Now().Add(time.Second)})
}
}
err := rig.apply()
if err == nil || rig.result().Outcome != "failed" || rig.service.restartCount() != 2 {
t.Errorf("err = %v, result = %+v", err, rig.result())
}
})
}
func TestApplyAlwaysWritesAResult(t *testing.T) {
t.Run("a panic in the helper still leaves a failed result", func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
rig.service.panicOn = 1
err := rig.apply()
result := rig.result()
if err == nil || result.Outcome != "failed" || result.FinishedAt == nil || result.Kind != "update" || result.Tag != downloadTag || result.FromVersion != helperOld {
t.Errorf("err = %v, result = %+v", err, result)
}
assertInOrder(t, rig.events, "create "+helperResultTmp, "rename "+helperResultTmp, "restart", "create "+helperResultTmp)
})
t.Run("a running record exists before the first service call and every write is a temp file plus a rename", func(t *testing.T) {
rig := newHelperRig(t)
rig.markRestart()
var seenBeforeRestart []string
rig.service.onRestart = func(call int) {
seenBeforeRestart = append([]string(nil), rig.outcomes()...)
rig.running.set(update.RunningInfo{Version: helperOld, Pid: 300, StartedAt: rig.clock.Now().Add(time.Second)})
}
_ = rig.apply()
if len(seenBeforeRestart) != 1 || seenBeforeRestart[0] != "running" {
t.Errorf("outcomes before the restart = %v", seenBeforeRestart)
}
creates := rig.fs.operationCount("create")
renames := len(rig.fs.history[helperResult])
if creates != renames || creates != 2 {
t.Errorf("creates = %d, renames = %d", creates, renames)
}
})
t.Run("timestamps come from the clock and finished is not before started", func(t *testing.T) {
rig := newHelperRig(t)
rig.markRestart()
rig.clock.onSleep = func(sleeps int) {
if sleeps == 3 {
rig.running.set(update.RunningInfo{Version: helperOld, Pid: 300, StartedAt: rig.clock.Now().Add(time.Second)})
}
}
rig.service.onRestart = func(int) {}
if err := rig.apply(); err != nil {
t.Fatal(err)
}
result := rig.result()
if !result.StartedAt.Equal(restartBaseTime) || result.FinishedAt == nil || !result.FinishedAt.Equal(restartBaseTime.Add(3*time.Second)) || result.FinishedAt.Before(result.StartedAt) {
t.Errorf("result = %+v", result)
}
})
}