Files
Felis/cmd/felis/update_apply_test.go

216 lines
9.6 KiB
Go

package main
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"felis.lolicon.best/internal/updater"
"felis.lolicon.best/internal/updates"
)
const updateTestSHA = "0123456789abcdef0123456789abcdef01234567"
func TestUpdateRefPlanPinsTheReviewedCommit(t *testing.T) {
target := updater.InstallTarget{Revision: updateTestSHA}
for _, opts := range []updateOptions{
{cfgPath: "/etc/felis/felis.toml"},
{cfgPath: "/etc/felis/felis.toml", force: true, all: true},
{cfgPath: "/etc/felis/felis.toml", selected: map[string]bool{"k3s": true}},
} {
out := renderInstallPlan(target, opts)
if !strings.Contains(out, "sudo felis update --apply --ref "+updateTestSHA) || strings.Contains(out, "--dev") {
t.Fatalf("moving target in apply command: %s", out)
}
if !strings.Contains(out, "No update is applied") || !strings.Contains(out, "User server images remain pinned") {
t.Fatalf("missing scope: %s", out)
}
if strings.Contains(out, " --all") != opts.dependencies() || strings.Contains(out, "--ref "+updateTestSHA+" --all --force") != opts.force {
t.Fatalf("apply command lost options: %s", out)
}
}
opts := updateOptions{cfgPath: "/path/'literal $(id).toml", repoURL: "https://github.com/example/Felis.git", preserveToken: true}
out := renderInstallPlan(updater.InstallTarget{Release: "v0.2.0", Revision: updateTestSHA, Script: `#!/bin/bash
FELIS_RELEASE="${FELIS_RELEASE:-}"`}, opts)
for _, want := range []string{"--version v0.2.0 --expect-commit " + updateTestSHA, "--preserve-env=FELIS_GITHUB_TOKEN", "FELIS_REPO_URL=" + shellQuote(opts.repoURL), "--config " + shellQuote(opts.cfgPath)} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q: %s", want, out)
}
}
older := renderInstallPlan(updater.InstallTarget{Release: "v0.2.0", Revision: updateTestSHA, Script: "#!/bin/bash\n# FELIS_RELEASE"}, updateOptions{cfgPath: "/etc/felis/felis.toml"})
if !strings.Contains(older, "--apply --ref "+updateTestSHA) || strings.Contains(older, "--version v0.2.0") {
t.Fatalf("old installer must offer a working pinned source apply: %s", older)
}
if strings.Contains(renderInstallPlan(target, updateOptions{apply: true}), "No update is applied") {
t.Fatal("apply must not claim it is only a check")
}
}
func TestUpdateRejectsConflictingOrUnsafeFlagsBeforeDiscovery(t *testing.T) {
for _, args := range [][]string{
{"--apply", "--check"}, {"--apply", "--record"}, {"--now"},
{"--dev", "--version", "v0.2.0"}, {"--dev", "--ref", "main"},
{"--ref", "main", "--version", "v0.2.0"}, {"--record", "--dev"},
{"--version", "v0.2.0-rc.1"}, {"--version", "v0.2.0+gabc1234"},
{"--apply", "--mc"}, {"apply"}, {"--apply", "--expect-commit", "short"}, {"--expect-commit", updateTestSHA},
} {
var out, errb strings.Builder
if got := cmdUpdate(args, &out, &errb); got != 2 || out.Len() != 0 {
t.Errorf("args %v: code %d, out %q, err %q", args, got, out.String(), errb.String())
}
}
o := updateOptions{release: "0.2.0"}
if err := o.validate(false, true, false); err != nil || o.release != "v0.2.0" {
t.Fatalf("normalize version: %v / %q", err, o.release)
}
}
func TestUpdateApplySafetyAndOrdering(t *testing.T) {
now := time.Now()
open := updates.Window{Start: now.Add(-time.Hour), End: now.Add(time.Hour)}
future := updates.Window{Start: now.Add(time.Hour), End: now.Add(2 * time.Hour)}
ended := updates.Window{Start: now.Add(-2 * time.Hour), End: now.Add(-time.Hour)}
failed := errors.New("failed")
for _, tc := range []struct {
name string
opts updateOptions
windows []updates.Window
windowErr, backupErr, installErr, verifyErr error
want []string
wantErr bool
}{
{name: "active", windows: []updates.Window{open, open}, want: []string{"window", "backup", "window", "install", "verify"}},
{name: "unset", windows: []updates.Window{{}}, want: []string{"window"}, wantErr: true},
{name: "future", windows: []updates.Window{future}, want: []string{"window"}, wantErr: true},
{name: "ended", windows: []updates.Window{ended}, want: []string{"window"}, wantErr: true},
{name: "force cannot bypass", opts: updateOptions{force: true}, windows: []updates.Window{future}, want: []string{"window"}, wantErr: true},
{name: "manual maintenance", opts: updateOptions{now: true}, windows: []updates.Window{{}, {}}, want: []string{"window", "backup", "window", "install", "verify"}},
{name: "manual cannot bypass unreadable window", opts: updateOptions{now: true}, windowErr: failed, want: []string{"window"}, wantErr: true},
{name: "backup failure", windows: []updates.Window{open}, backupErr: failed, want: []string{"window", "backup"}, wantErr: true},
{name: "expires during backup", windows: []updates.Window{open, ended}, want: []string{"window", "backup", "window"}, wantErr: true},
{name: "install failure", windows: []updates.Window{open, open}, installErr: failed, want: []string{"window", "backup", "window", "install"}, wantErr: true},
{name: "verification failure", windows: []updates.Window{open, open}, verifyErr: failed, want: []string{"window", "backup", "window", "install", "verify"}, wantErr: true},
} {
t.Run(tc.name, func(t *testing.T) {
var calls []string
reads := 0
steps := updateApplySteps{
window: func(context.Context) (updates.Window, error) {
calls = append(calls, "window")
if tc.windowErr != nil {
return updates.Window{}, tc.windowErr
}
w := tc.windows[reads]
reads++
return w, nil
},
backup: func(context.Context) error { calls = append(calls, "backup"); return tc.backupErr },
install: func(context.Context) error { calls = append(calls, "install"); return tc.installErr },
verify: func(context.Context) error { calls = append(calls, "verify"); return tc.verifyErr },
}
err := runUpdateApply(context.Background(), tc.opts, steps)
if (err != nil) != tc.wantErr || !reflect.DeepEqual(calls, tc.want) {
t.Fatalf("calls %v, err %v; want %v, error %v", calls, err, tc.want, tc.wantErr)
}
})
}
}
func TestUpdateInstallerCannotUseStaleSourceOrBypassGuards(t *testing.T) {
env := []string{"FELIS_REF=stale", "FELIS_REF=duplicate", "FELIS_BOOTSTRAP_BINARY=/old", "FELIS_BOOTSTRAP_FROM_TUI=1", "FELIS_SKIP_FETCH=1", "FELIS_RELEASE=v0.1.0", "FELIS_GAME_STACK=latest", "FELIS_PREFLIGHT=warn", "FELIS_PRE_MIGRATE_BACKUP=0", "FELIS_GITHUB_TOKEN=private-token", "PATH=/usr/bin"}
for _, release := range []string{"", "v0.2.0"} {
out := updateInstallerEnv(env, updater.InstallTarget{Release: release, Revision: updateTestSHA}, updateOptions{force: true, all: true}, "https://github.com/FelisMC/Felis.git")
got := map[string]string{}
for _, item := range out {
key, value, _ := strings.Cut(item, "=")
if _, exists := got[key]; exists {
t.Fatalf("duplicate environment key %s", key)
}
got[key] = value
}
for key, want := range map[string]string{"FELIS_BOOTSTRAP_BINARY": "", "FELIS_BOOTSTRAP_FROM_TUI": "", "FELIS_SKIP_FETCH": "", "FELIS_RELEASE": release, "FELIS_GAME_STACK": "pinned", "FELIS_PREFLIGHT": "strict", "FELIS_PRE_MIGRATE_BACKUP": "1", "FELIS_FORCE_UPDATE": "1", "FELIS_UPGRADE_DEPS": "1", "FELIS_GITHUB_TOKEN": "private-token"} {
if got[key] != want {
t.Errorf("%s = %q; want %q", key, got[key], want)
}
}
wantRef := updateTestSHA
if release != "" {
wantRef = ""
}
if got["FELIS_REF"] != wantRef {
t.Fatalf("wrong source: %v", got)
}
}
}
func TestUpdateExecutesInstallerAndPropagatesFailure(t *testing.T) {
var stdout, stderr strings.Builder
err := runUpdateInstaller(context.Background(), "#!/bin/bash\nprintf 'target:%s' \"$FELIS_REF\"\nprintf 'diagnostic' >&2\nexit 7\n", append(os.Environ(), "FELIS_REF="+updateTestSHA), &stdout, &stderr)
if err == nil || stdout.String() != "target:"+updateTestSHA || stderr.String() != "diagnostic" {
t.Fatalf("out %q, stderr %q, err %v", stdout.String(), stderr.String(), err)
}
}
func TestUpdateCancellationStopsInstallerChildren(t *testing.T) {
dir := t.TempDir()
ready, stopped := filepath.Join(dir, "ready"), filepath.Join(dir, "child-stopped")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
script := `#!/bin/bash
trap 'exit 0' TERM
(
trap 'echo stopped > "$STOPPED"; exit 0' TERM
echo ready > "$READY"
sleep 30
) &
wait
`
go func() {
done <- runUpdateInstaller(ctx, script, append(os.Environ(), "READY="+ready, "STOPPED="+stopped), io.Discard, io.Discard)
}()
deadline := time.Now().Add(3 * time.Second)
for {
if _, err := os.Stat(ready); err == nil {
break
}
if time.Now().After(deadline) {
t.Fatal("installer did not become ready")
}
time.Sleep(10 * time.Millisecond)
}
cancel()
select {
case err := <-done:
if err == nil {
t.Fatal("interrupted installation must not report success")
}
case <-time.After(3 * time.Second):
t.Fatal("installer children kept running after cancellation")
}
if _, err := os.Stat(stopped); err != nil {
t.Fatalf("installer child did not receive cancellation: %v", err)
}
}
func TestInstalledUpdateMustMatchExactTarget(t *testing.T) {
for _, tc := range []struct {
version, release string
want bool
}{
{"v0.2.0", "v0.2.0", true}, {"v0.2.1", "v0.2.0", false},
{"v0.0.0+g0123456", "", true}, {"v0.0.0+gunknown", "", false},
{"v0.0.0+g012", "", false}, {"v0.0.0+g9876543", "", false},
} {
if got := installedUpdateMatches(tc.version, updater.InstallTarget{Release: tc.release, Revision: updateTestSHA}); got != tc.want {
t.Errorf("%s / %s matched = %v", tc.version, tc.release, got)
}
}
}