213 lines
6.7 KiB
Go
213 lines
6.7 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.failNextReload.Store(true)
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
type lifecycleFixture struct {
|
|
config CandidateConfig
|
|
controllerURL string
|
|
startedPath string
|
|
lock sync.RWMutex
|
|
response string
|
|
status int
|
|
subscriptionRequests atomic.Int32
|
|
reloadRequests atomic.Int32
|
|
failNextReload atomic.Bool
|
|
}
|
|
|
|
func newLifecycleFixture(t *testing.T) *lifecycleFixture {
|
|
t.Helper()
|
|
fixture := &lifecycleFixture{response: fullSubscription("first-node"), status: http.StatusOK}
|
|
subscription := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
|
fixture.subscriptionRequests.Add(1)
|
|
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.failNextReload.CompareAndSwap(true, false) {
|
|
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 started > %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 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)
|
|
}
|
|
}
|