216 lines
9.6 KiB
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)
|
|
}
|
|
}
|
|
}
|