377 lines
12 KiB
Go
377 lines
12 KiB
Go
package bootstrap
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"proxy-pool/internal/config"
|
|
"proxy-pool/internal/controller/admin"
|
|
"proxy-pool/internal/domain/adminstate"
|
|
"proxy-pool/internal/platform/lifecycle"
|
|
)
|
|
|
|
func TestProviderSupervisorAppliesDisableAndConfigurationReplacement(t *testing.T) {
|
|
configuration, err := config.Load(strings.NewReader(bootstrapTestConfig))
|
|
if err != nil {
|
|
t.Fatalf("config.Load(): %v", err)
|
|
}
|
|
store, err := config.NewStore(configuration)
|
|
if err != nil {
|
|
t.Fatalf("config.NewStore(): %v", err)
|
|
}
|
|
state := &mutableProviderState{enabled: map[string]bool{"provider-a": true, "provider-b": true}}
|
|
started := make(chan providerRuntimeEvent, 4)
|
|
stopped := make(chan providerRuntimeEvent, 4)
|
|
supervisor, err := newProviderSupervisor(store, state, func(name string, upstream config.Upstream) (lifecycle.Runner, error) {
|
|
event := providerRuntimeEvent{name: name, url: upstream.API.URL}
|
|
return supervisorRunnerFunc(func(ctx context.Context) error {
|
|
started <- event
|
|
<-ctx.Done()
|
|
stopped <- event
|
|
return ctx.Err()
|
|
}), nil
|
|
}, nil, nil, nil, bootstrapTestFingerprintKey, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("newProviderSupervisor(): %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() { done <- supervisor.Run(ctx) }()
|
|
waitForRuntimeEvents(t, started, 2)
|
|
|
|
state.set("provider-a", false)
|
|
supervisor.Notify()
|
|
if event := waitForRuntimeEvent(t, stopped); event.name != "provider-a" {
|
|
t.Fatalf("stopped Provider = %s, want provider-a", event.name)
|
|
}
|
|
|
|
updated := store.Current()
|
|
providerB := updated.Upstreams["provider-b"]
|
|
providerB.API.URL = "https://replacement.invalid/proxies"
|
|
updated.Upstreams["provider-b"] = providerB
|
|
if !store.PublishRevision(updated, 1) {
|
|
t.Fatal("PublishRevision() rejected updated configuration")
|
|
}
|
|
supervisor.Notify()
|
|
if event := waitForRuntimeEvent(t, stopped); event.name != "provider-b" {
|
|
t.Fatalf("replaced Provider = %s, want provider-b", event.name)
|
|
}
|
|
if event := waitForRuntimeEvent(t, started); event.name != "provider-b" || event.url != providerB.API.URL {
|
|
t.Fatalf("replacement Provider = %+v", event)
|
|
}
|
|
|
|
cancel()
|
|
if err := <-done; !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("Run() error = %v, want context cancellation", err)
|
|
}
|
|
}
|
|
|
|
func TestProviderSupervisorPropagatesUnexpectedRuntimeFailure(t *testing.T) {
|
|
configuration, err := config.Load(strings.NewReader(bootstrapTestConfig))
|
|
if err != nil {
|
|
t.Fatalf("config.Load(): %v", err)
|
|
}
|
|
store, err := config.NewStore(configuration)
|
|
if err != nil {
|
|
t.Fatalf("config.NewStore(): %v", err)
|
|
}
|
|
wantErr := errors.New("coordination stopped")
|
|
supervisor, err := newProviderSupervisor(store, nil, func(string, config.Upstream) (lifecycle.Runner, error) {
|
|
return supervisorRunnerFunc(func(context.Context) error { return wantErr }), nil
|
|
}, nil, nil, nil, bootstrapTestFingerprintKey, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("newProviderSupervisor(): %v", err)
|
|
}
|
|
if err := supervisor.Run(context.Background()); !errors.Is(err, wantErr) {
|
|
t.Fatalf("Run() error = %v, want %v", err, wantErr)
|
|
}
|
|
}
|
|
|
|
func TestProviderSupervisorRetainsRuntimesWhileManagementStateIsUnavailable(t *testing.T) {
|
|
configuration, err := config.Load(strings.NewReader(bootstrapTestConfig))
|
|
if err != nil {
|
|
t.Fatalf("config.Load(): %v", err)
|
|
}
|
|
store, err := config.NewStore(configuration)
|
|
if err != nil {
|
|
t.Fatalf("config.NewStore(): %v", err)
|
|
}
|
|
state := &mutableProviderState{enabled: map[string]bool{"provider-a": true, "provider-b": true}}
|
|
started := make(chan providerRuntimeEvent, 2)
|
|
stopped := make(chan providerRuntimeEvent, 2)
|
|
supervisor, err := newProviderSupervisor(store, state, func(name string, upstream config.Upstream) (lifecycle.Runner, error) {
|
|
event := providerRuntimeEvent{name: name, url: upstream.API.URL}
|
|
return supervisorRunnerFunc(func(ctx context.Context) error {
|
|
started <- event
|
|
<-ctx.Done()
|
|
stopped <- event
|
|
return ctx.Err()
|
|
}), nil
|
|
}, nil, nil, nil, bootstrapTestFingerprintKey, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("newProviderSupervisor(): %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() { done <- supervisor.Run(ctx) }()
|
|
waitForRuntimeEvents(t, started, 2)
|
|
|
|
state.setError(errors.New("PostgreSQL temporarily unavailable"))
|
|
supervisor.Notify()
|
|
select {
|
|
case err := <-done:
|
|
t.Fatalf("Supervisor stopped during transient state failure: %v", err)
|
|
case event := <-stopped:
|
|
t.Fatalf("Provider stopped during transient state failure: %+v", event)
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
|
|
state.setError(nil)
|
|
state.set("provider-a", false)
|
|
supervisor.Notify()
|
|
if event := waitForRuntimeEvent(t, stopped); event.name != "provider-a" {
|
|
t.Fatalf("stopped Provider = %s, want provider-a", event.name)
|
|
}
|
|
|
|
cancel()
|
|
if err := <-done; !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("Run() error = %v, want context cancellation", err)
|
|
}
|
|
}
|
|
|
|
func TestProviderSupervisorStopsStaleRuntimesAndLoadsAuthoritativeConfiguration(t *testing.T) {
|
|
initial, err := config.Load(strings.NewReader(bootstrapTestConfig))
|
|
if err != nil {
|
|
t.Fatalf("config.Load(): %v", err)
|
|
}
|
|
store, err := config.NewStore(initial)
|
|
if err != nil {
|
|
t.Fatalf("config.NewStore(): %v", err)
|
|
}
|
|
initialChecksum, err := config.Fingerprint(initial, bootstrapTestFingerprintKey)
|
|
if err != nil {
|
|
t.Fatalf("config.Fingerprint(initial): %v", err)
|
|
}
|
|
state := &mutableProviderState{
|
|
enabled: map[string]bool{"provider-a": true, "provider-b": true},
|
|
checksum: initialChecksum,
|
|
revision: 1,
|
|
}
|
|
source := &mutableProviderConfigurationSource{configuration: initial}
|
|
started := make(chan providerRuntimeEvent, 4)
|
|
stopped := make(chan providerRuntimeEvent, 4)
|
|
supervisor, err := newProviderSupervisor(store, state, func(name string, upstream config.Upstream) (lifecycle.Runner, error) {
|
|
event := providerRuntimeEvent{name: name, url: upstream.API.URL}
|
|
return supervisorRunnerFunc(func(ctx context.Context) error {
|
|
started <- event
|
|
<-ctx.Done()
|
|
stopped <- event
|
|
return ctx.Err()
|
|
}), nil
|
|
}, nil, nil, source, bootstrapTestFingerprintKey, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("newProviderSupervisor(): %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() { done <- supervisor.Run(ctx) }()
|
|
waitForRuntimeEvents(t, started, 2)
|
|
|
|
updated := store.Current()
|
|
providerA := updated.Upstreams["provider-a"]
|
|
providerA.API.URL = "https://replacement.invalid/proxies"
|
|
updated.Upstreams["provider-a"] = providerA
|
|
updatedChecksum, err := config.Fingerprint(updated, bootstrapTestFingerprintKey)
|
|
if err != nil {
|
|
t.Fatalf("config.Fingerprint(updated): %v", err)
|
|
}
|
|
state.setChecksum(updatedChecksum)
|
|
supervisor.Notify()
|
|
waitForRuntimeEvents(t, stopped, 2)
|
|
select {
|
|
case err := <-done:
|
|
t.Fatalf("Supervisor stopped for stale local configuration: %v", err)
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
|
|
source.set(updated)
|
|
supervisor.Notify()
|
|
events := []providerRuntimeEvent{waitForRuntimeEvent(t, started), waitForRuntimeEvent(t, started)}
|
|
foundReplacement := false
|
|
for _, event := range events {
|
|
if event.name == "provider-a" && event.url == providerA.API.URL {
|
|
foundReplacement = true
|
|
}
|
|
}
|
|
if !foundReplacement {
|
|
t.Fatalf("started Provider runtimes = %+v, replacement missing", events)
|
|
}
|
|
if currentChecksum, err := config.Fingerprint(store.Current(), bootstrapTestFingerprintKey); err != nil || currentChecksum != updatedChecksum {
|
|
t.Fatalf("published checksum = %q, %v; want %q", currentChecksum, err, updatedChecksum)
|
|
}
|
|
|
|
cancel()
|
|
if err := <-done; !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("Run() error = %v, want context cancellation", err)
|
|
}
|
|
}
|
|
|
|
func TestProviderSupervisorDoesNotPublishConfigurationOlderThanLocalRevision(t *testing.T) {
|
|
initial, err := config.Load(strings.NewReader(bootstrapTestConfig))
|
|
if err != nil {
|
|
t.Fatalf("config.Load(): %v", err)
|
|
}
|
|
store, err := config.NewStore(initial)
|
|
if err != nil {
|
|
t.Fatalf("config.NewStore(): %v", err)
|
|
}
|
|
store.PublishRevision(initial, 1)
|
|
|
|
candidate := store.Current()
|
|
providerA := candidate.Upstreams["provider-a"]
|
|
providerA.API.URL = "https://candidate.invalid/proxies"
|
|
candidate.Upstreams["provider-a"] = providerA
|
|
candidateChecksum, err := config.Fingerprint(candidate, bootstrapTestFingerprintKey)
|
|
if err != nil {
|
|
t.Fatalf("config.Fingerprint(candidate): %v", err)
|
|
}
|
|
newest := store.Current()
|
|
providerA = newest.Upstreams["provider-a"]
|
|
providerA.API.URL = "https://newest.invalid/proxies"
|
|
newest.Upstreams["provider-a"] = providerA
|
|
|
|
source := providerConfigurationSourceFunc(func(context.Context) (admin.LoadedConfiguration, error) {
|
|
if !store.PublishRevision(newest, 3) {
|
|
t.Fatal("failed to publish simulated concurrent revision")
|
|
}
|
|
return admin.LoadedConfiguration{Value: candidate, Source: "controller.yaml"}, nil
|
|
})
|
|
supervisor, err := newProviderSupervisor(
|
|
store,
|
|
&mutableProviderState{},
|
|
func(string, config.Upstream) (lifecycle.Runner, error) {
|
|
return supervisorRunnerFunc(func(context.Context) error { return nil }), nil
|
|
},
|
|
nil,
|
|
nil,
|
|
source,
|
|
bootstrapTestFingerprintKey,
|
|
time.Hour,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("newProviderSupervisor(): %v", err)
|
|
}
|
|
|
|
_, err = supervisor.synchronizeConfiguration(context.Background(), initial, adminstate.Snapshot{
|
|
Config: &adminstate.ConfigRevision{Revision: 2, Checksum: candidateChecksum},
|
|
})
|
|
if !errors.Is(err, errProviderManagementSnapshotStale) {
|
|
t.Fatalf("synchronizeConfiguration() error = %v, want stale snapshot", err)
|
|
}
|
|
if got := store.Revision(); got != 3 {
|
|
t.Fatalf("configuration revision = %d, want 3", got)
|
|
}
|
|
}
|
|
|
|
type providerRuntimeEvent struct {
|
|
name string
|
|
url string
|
|
}
|
|
|
|
type supervisorRunnerFunc func(context.Context) error
|
|
|
|
func (run supervisorRunnerFunc) Run(ctx context.Context) error { return run(ctx) }
|
|
|
|
type mutableProviderState struct {
|
|
mu sync.Mutex
|
|
enabled map[string]bool
|
|
err error
|
|
checksum string
|
|
revision uint64
|
|
}
|
|
|
|
func (state *mutableProviderState) Snapshot(context.Context) (adminstate.Snapshot, error) {
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
if state.err != nil {
|
|
return adminstate.Snapshot{}, state.err
|
|
}
|
|
snapshot := adminstate.Snapshot{Upstreams: make([]adminstate.UpstreamState, 0, len(state.enabled))}
|
|
if state.checksum != "" {
|
|
snapshot.Config = &adminstate.ConfigRevision{
|
|
Revision: state.revision, ConfigVersion: "cfg-" + state.checksum, Checksum: state.checksum,
|
|
}
|
|
}
|
|
for name, enabled := range state.enabled {
|
|
snapshot.Upstreams = append(snapshot.Upstreams, adminstate.UpstreamState{Name: name, Enabled: enabled})
|
|
}
|
|
return snapshot, nil
|
|
}
|
|
|
|
func (state *mutableProviderState) setError(err error) {
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
state.err = err
|
|
}
|
|
|
|
func (state *mutableProviderState) setChecksum(checksum string) {
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
state.checksum = checksum
|
|
state.revision++
|
|
}
|
|
|
|
type mutableProviderConfigurationSource struct {
|
|
mu sync.Mutex
|
|
configuration *config.Config
|
|
}
|
|
|
|
type providerConfigurationSourceFunc func(context.Context) (admin.LoadedConfiguration, error)
|
|
|
|
func (source providerConfigurationSourceFunc) LoadConfiguration(ctx context.Context) (admin.LoadedConfiguration, error) {
|
|
return source(ctx)
|
|
}
|
|
|
|
func (source *mutableProviderConfigurationSource) LoadConfiguration(context.Context) (admin.LoadedConfiguration, error) {
|
|
source.mu.Lock()
|
|
defer source.mu.Unlock()
|
|
return admin.LoadedConfiguration{Value: source.configuration, Source: "controller.yaml"}, nil
|
|
}
|
|
|
|
func (source *mutableProviderConfigurationSource) set(configuration *config.Config) {
|
|
source.mu.Lock()
|
|
defer source.mu.Unlock()
|
|
source.configuration = configuration
|
|
}
|
|
|
|
func (state *mutableProviderState) set(name string, enabled bool) {
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
state.enabled[name] = enabled
|
|
}
|
|
|
|
func waitForRuntimeEvents(t *testing.T, events <-chan providerRuntimeEvent, count int) {
|
|
t.Helper()
|
|
for range count {
|
|
_ = waitForRuntimeEvent(t, events)
|
|
}
|
|
}
|
|
|
|
func waitForRuntimeEvent(t *testing.T, events <-chan providerRuntimeEvent) providerRuntimeEvent {
|
|
t.Helper()
|
|
select {
|
|
case event := <-events:
|
|
return event
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for Provider runtime event")
|
|
return providerRuntimeEvent{}
|
|
}
|
|
}
|