Files
rogee d7c883afc1
Docker image / Test (push) Successful in 2m12s
Docker image / Build and publish (push) Failing after 37s
HH-690: single-container Mihomo lifecycle and ExternalUI (#9)
2026-08-26 17:29:31 +08:00

430 lines
13 KiB
Go

package bootstrap
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"syscall"
"testing"
"time"
)
func TestRunColdStartAndSignalUpdate(t *testing.T) {
fixture := newLifecycleFixture(t)
trigger := make(chan os.Signal, 1)
ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1)
go func() { result <- Run(ctx, fixture.lifecycle(trigger)) }()
fixture.waitStarted(t)
waitFor(t, func() bool { return fixture.subscriptionRequests.Load() == 1 })
assertContains(t, filepath.Join(fixture.config.DataDir, "last-good", "subscription.yaml"), "first-node")
fixture.setSubscription("second-node", http.StatusOK)
trigger <- syscall.SIGHUP
waitFor(t, func() bool { return fixture.subscriptionRequests.Load() == 2 && fixture.reloadRequests.Load() == 1 })
assertContains(t, filepath.Join(fixture.config.DataDir, "last-good", "subscription.yaml"), "second-node")
cancel()
if err := <-result; !errors.Is(err, context.Canceled) {
t.Fatalf("Run() error = %v, want context cancellation", err)
}
}
func TestRunWarmCacheSurvivesImmediateUpdateFailureAndRestart(t *testing.T) {
fixture := newLifecycleFixture(t)
if err := PublishCandidate(context.Background(), fixture.config); err != nil {
t.Fatal(err)
}
fixture.setSubscription("ignored", http.StatusServiceUnavailable)
ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1)
go func() { result <- Run(ctx, fixture.lifecycle(nil)) }()
fixture.waitStarted(t)
waitFor(t, func() bool { return fixture.subscriptionRequests.Load() >= 2 })
assertContains(t, filepath.Join(fixture.config.DataDir, "last-good", "subscription.yaml"), "first-node")
select {
case err := <-result:
t.Fatalf("Run() stopped after warm-cache update failure: %v", err)
default:
}
cancel()
if err := <-result; !errors.Is(err, context.Canceled) {
t.Fatalf("Run() error = %v, want context cancellation", err)
}
}
func TestRunColdFailureDoesNotStartMihomo(t *testing.T) {
fixture := newLifecycleFixture(t)
fixture.setSubscription("ignored", http.StatusBadGateway)
err := Run(context.Background(), fixture.lifecycle(nil))
if err == nil || !strings.Contains(err.Error(), "cold-start candidate failed") {
t.Fatalf("Run() error = %v, want cold-start failure", err)
}
if _, statErr := os.Stat(fixture.startedPath); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("Mihomo started without a valid cache: %v", statErr)
}
}
func TestRunReloadFailureRestoresLastGood(t *testing.T) {
fixture := newLifecycleFixture(t)
if err := PublishCandidate(context.Background(), fixture.config); err != nil {
t.Fatal(err)
}
wantTarget := readLastGood(t, fixture.config.DataDir)
fixture.setSubscription("rejected-node", http.StatusOK)
fixture.reloadFailures.Store(1)
ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1)
go func() { result <- Run(ctx, fixture.lifecycle(nil)) }()
fixture.waitStarted(t)
waitFor(t, func() bool { return fixture.reloadRequests.Load() == 2 })
if got := readLastGood(t, fixture.config.DataDir); got != wantTarget {
t.Fatalf("last-good = %q, want restored %q", got, wantTarget)
}
assertContains(t, filepath.Join(fixture.config.DataDir, "last-good", "subscription.yaml"), "first-node")
cancel()
if err := <-result; !errors.Is(err, context.Canceled) {
t.Fatalf("Run() error = %v, want context cancellation", err)
}
}
func TestRunAndCandidateSerializeDataDirUpdates(t *testing.T) {
fixture := newLifecycleFixture(t)
if err := PublishCandidate(context.Background(), fixture.config); err != nil {
t.Fatal(err)
}
fixture.setSubscription("second-node", http.StatusOK)
fixture.blockNextSubscription.Store(true)
ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1)
go func() { result <- Run(ctx, fixture.lifecycle(nil)) }()
fixture.waitStarted(t)
select {
case <-fixture.subscriptionEntered:
case <-time.After(5 * time.Second):
close(fixture.subscriptionRelease)
t.Fatal("Run did not enter the locked candidate update")
}
candidateResult := make(chan error, 1)
go func() { candidateResult <- PublishCandidate(context.Background(), fixture.config) }()
select {
case err := <-candidateResult:
close(fixture.subscriptionRelease)
t.Fatalf("concurrent candidate bypassed the data lock: %v", err)
case <-time.After(100 * time.Millisecond):
}
close(fixture.subscriptionRelease)
if err := <-candidateResult; err != nil {
t.Fatalf("concurrent PublishCandidate() error = %v", err)
}
assertContains(t, filepath.Join(fixture.config.DataDir, "last-good", "subscription.yaml"), "second-node")
cancel()
if err := <-result; !errors.Is(err, context.Canceled) {
t.Fatalf("Run() error = %v, want context cancellation", err)
}
}
func TestRunWarmStartKeepsValidatedGenerationLocked(t *testing.T) {
fixture := newLifecycleFixture(t)
if err := PublishCandidate(context.Background(), fixture.config); err != nil {
t.Fatal(err)
}
firstTarget := readLastGood(t, fixture.config.DataDir)
fixture.setSubscription("second-node", http.StatusOK)
validated := make(chan struct{})
resume := make(chan struct{})
lifecycle := fixture.lifecycle(nil)
lifecycle.afterLastGoodValidated = func() {
close(validated)
<-resume
}
ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1)
go func() { result <- Run(ctx, lifecycle) }()
select {
case <-validated:
case <-time.After(5 * time.Second):
close(resume)
t.Fatal("Run did not pause after warm generation validation")
}
candidateStarted := make(chan struct{})
candidateResult := make(chan error, 1)
go func() {
close(candidateStarted)
candidateResult <- PublishCandidate(context.Background(), fixture.config)
}()
<-candidateStarted
select {
case err := <-candidateResult:
close(resume)
t.Fatalf("candidate bypassed the warm-start data lock: %v", err)
case <-time.After(100 * time.Millisecond):
}
if got := fixture.subscriptionRequests.Load(); got != 1 {
close(resume)
t.Fatalf("subscription requests = %d before warm start resumed, want 1", got)
}
if _, err := os.Stat(fixture.startedPath); !errors.Is(err, os.ErrNotExist) {
close(resume)
t.Fatalf("Mihomo started before warm validation resumed: %v", err)
}
close(resume)
fixture.waitStarted(t)
if err := <-candidateResult; err != nil {
t.Fatalf("concurrent PublishCandidate() error = %v", err)
}
activeTarget := "generations/a"
if firstTarget == activeTarget {
activeTarget = "generations/b"
}
if _, err := os.Stat(filepath.Join(fixture.config.DataDir, filepath.FromSlash(activeTarget))); err != nil {
t.Fatalf("active generation %q was removed: %v", activeTarget, err)
}
if _, err := os.Stat(filepath.Join(fixture.config.DataDir, "last-good")); err != nil {
t.Fatalf("last-good is dangling: %v", err)
}
select {
case err := <-result:
t.Fatalf("Run() stopped after concurrent candidate: %v", err)
default:
}
cancel()
if err := <-result; !errors.Is(err, context.Canceled) {
t.Fatalf("Run() error = %v, want context cancellation", err)
}
}
func TestRunRollbackPersistenceFailureStopsMihomo(t *testing.T) {
fixture := newLifecycleFixture(t)
if err := PublishCandidate(context.Background(), fixture.config); err != nil {
t.Fatal(err)
}
wantTarget := readLastGood(t, fixture.config.DataDir)
fixture.setSubscription("rejected-node", http.StatusOK)
fixture.reloadFailures.Store(1)
var syncCalls atomic.Int32
fixture.config.directorySync = func(path string) error {
if syncCalls.Add(1) == 3 {
return errors.New("injected rollback sync failure")
}
return syncDirectory(path)
}
result := make(chan error, 1)
go func() { result <- Run(context.Background(), fixture.lifecycle(nil)) }()
fixture.waitStarted(t)
err := waitResult(t, result)
if !strings.Contains(err.Error(), "persist restored last-good pointer") {
t.Fatalf("Run() error = %v, want rollback persistence failure", err)
}
if got := readLastGood(t, fixture.config.DataDir); got != wantTarget {
t.Fatalf("last-good = %q, want restored %q", got, wantTarget)
}
fixture.waitStopped(t)
}
func TestRunSecondReloadFailureStopsMihomo(t *testing.T) {
fixture := newLifecycleFixture(t)
if err := PublishCandidate(context.Background(), fixture.config); err != nil {
t.Fatal(err)
}
wantTarget := readLastGood(t, fixture.config.DataDir)
fixture.setSubscription("rejected-node", http.StatusOK)
fixture.reloadFailures.Store(2)
result := make(chan error, 1)
go func() { result <- Run(context.Background(), fixture.lifecycle(nil)) }()
fixture.waitStarted(t)
err := waitResult(t, result)
if !strings.Contains(err.Error(), "reload restored last-good") {
t.Fatalf("Run() error = %v, want second reload failure", err)
}
if got := readLastGood(t, fixture.config.DataDir); got != wantTarget {
t.Fatalf("last-good = %q, want restored %q", got, wantTarget)
}
fixture.waitStopped(t)
}
type lifecycleFixture struct {
config CandidateConfig
controllerURL string
startedPath string
lock sync.RWMutex
response string
status int
subscriptionRequests atomic.Int32
reloadRequests atomic.Int32
reloadFailures atomic.Int32
blockNextSubscription atomic.Bool
subscriptionEntered chan struct{}
subscriptionRelease chan struct{}
}
func newLifecycleFixture(t *testing.T) *lifecycleFixture {
t.Helper()
fixture := &lifecycleFixture{
response: fullSubscription("first-node"),
status: http.StatusOK,
subscriptionEntered: make(chan struct{}),
subscriptionRelease: make(chan struct{}),
}
subscription := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
fixture.subscriptionRequests.Add(1)
if fixture.blockNextSubscription.CompareAndSwap(true, false) {
close(fixture.subscriptionEntered)
<-fixture.subscriptionRelease
}
fixture.lock.RLock()
defer fixture.lock.RUnlock()
writer.WriteHeader(fixture.status)
_, _ = writer.Write([]byte(fixture.response))
}))
t.Cleanup(subscription.Close)
controller := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch {
case request.Method == http.MethodGet && request.URL.Path == "/version":
writer.WriteHeader(http.StatusOK)
case request.Method == http.MethodPut && request.URL.Path == "/configs":
fixture.reloadRequests.Add(1)
if fixture.consumeReloadFailure() {
http.Error(writer, "rejected", http.StatusInternalServerError)
return
}
writer.WriteHeader(http.StatusNoContent)
default:
http.NotFound(writer, request)
}
}))
t.Cleanup(controller.Close)
directory := t.TempDir()
fixture.startedPath = filepath.Join(directory, "started")
secret := writeFixture(t, directory, "subscription-secret", subscription.URL+"\n")
template := writeFixture(t, directory, "config.yaml", "mixed-port: 7890\nexternal-controller: 127.0.0.1:9090\nproxy-providers:\n subscription:\n type: file\n path: ./subscription.yaml\n")
mihomo := writeFixture(t, directory, "mihomo", fmt.Sprintf(`#!/bin/sh
set -eu
if [ "${1:-}" = -t ]; then
directory=
config=
while [ "$#" -gt 0 ]; do
case "$1" in
-d) directory=$2; shift 2 ;;
-f) config=$2; shift 2 ;;
*) shift ;;
esac
done
test -s "$config"
test -s "$directory/subscription.yaml"
! grep -F reject-validation "$directory/subscription.yaml" >/dev/null
exit 0
fi
printf '%%s' $$ > %q
trap 'exit 0' TERM INT
while :; do sleep 1; done
`, fixture.startedPath))
if err := os.Chmod(mihomo, 0o755); err != nil {
t.Fatal(err)
}
fixture.config = CandidateConfig{
SecretPath: secret,
DataDir: filepath.Join(directory, "data"),
TemplatePath: template,
MihomoBinary: mihomo,
}
fixture.controllerURL = controller.URL
return fixture
}
func (fixture *lifecycleFixture) lifecycle(trigger <-chan os.Signal) LifecycleConfig {
return LifecycleConfig{
Candidate: fixture.config,
ControllerURL: fixture.controllerURL,
UpdateInterval: time.Hour,
Trigger: trigger,
}
}
func (fixture *lifecycleFixture) setSubscription(name string, status int) {
fixture.lock.Lock()
defer fixture.lock.Unlock()
fixture.response = fullSubscription(name)
fixture.status = status
}
func (fixture *lifecycleFixture) waitStarted(t *testing.T) {
t.Helper()
waitFor(t, func() bool {
_, err := os.Stat(fixture.startedPath)
return err == nil
})
}
func (fixture *lifecycleFixture) waitStopped(t *testing.T) {
t.Helper()
waitFor(t, func() bool {
pidBytes, err := os.ReadFile(fixture.startedPath)
if err != nil {
return false
}
var pid int
if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil {
return false
}
return errors.Is(syscall.Kill(pid, 0), syscall.ESRCH)
})
}
func (fixture *lifecycleFixture) consumeReloadFailure() bool {
for {
remaining := fixture.reloadFailures.Load()
if remaining == 0 {
return false
}
if fixture.reloadFailures.CompareAndSwap(remaining, remaining-1) {
return true
}
}
}
func waitResult(t *testing.T, result <-chan error) error {
t.Helper()
select {
case err := <-result:
if err == nil {
t.Fatal("Run() error = nil")
}
return err
case <-time.After(5 * time.Second):
t.Fatal("Run did not fail closed")
return nil
}
}
func waitFor(t *testing.T, condition func() bool) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for !condition() {
if time.Now().After(deadline) {
t.Fatal("condition was not met")
}
time.Sleep(10 * time.Millisecond)
}
}