feat: add resilient updates and access controls
Container-Image veröffentlichen / container (push) Has been cancelled
Container-Image veröffentlichen / container (push) Has been cancelled
This commit is contained in:
1 parent
a000152423
commit
f02ec9977c
41 files changed
+3277
-151
No files matched your search
@@ -21,24 +21,42 @@ var (
|
||||
ErrDisabled = errors.New("the updater is disabled")
|
||||
)
|
||||
|
||||
const defaultRetryInitial = 5 * time.Second
|
||||
|
||||
type ipResolver interface {
|
||||
ResolveWithIPv6(ctx context.Context, ipv4Endpoints, ipv6Endpoints []string, ipv6Enabled bool) ipresolve.Result
|
||||
ValidateEndpoint(ctx context.Context, raw string) error
|
||||
}
|
||||
|
||||
type Scheduler struct {
|
||||
store *store.Store
|
||||
provider *hetzner.Client
|
||||
resolver *ipresolve.Resolver
|
||||
resolver ipResolver
|
||||
cipher *secure.Cipher
|
||||
logger *slog.Logger
|
||||
running atomic.Bool
|
||||
nextRun atomic.Int64
|
||||
wake chan struct{}
|
||||
retryMin time.Duration
|
||||
retryMax time.Duration
|
||||
}
|
||||
|
||||
func New(database *store.Store, provider *hetzner.Client, resolver *ipresolve.Resolver, cipher *secure.Cipher, logger *slog.Logger) *Scheduler {
|
||||
return &Scheduler{store: database, provider: provider, resolver: resolver, cipher: cipher, logger: logger, wake: make(chan struct{}, 1)}
|
||||
return &Scheduler{
|
||||
store: database,
|
||||
provider: provider,
|
||||
resolver: resolver,
|
||||
cipher: cipher,
|
||||
logger: logger,
|
||||
wake: make(chan struct{}, 1),
|
||||
retryMin: defaultRetryInitial,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) Start(ctx context.Context) {
|
||||
go func() {
|
||||
defer s.nextRun.Store(0)
|
||||
consecutiveFailures := 0
|
||||
for {
|
||||
settings, err := s.store.GetSettings(ctx)
|
||||
if err != nil {
|
||||
@@ -60,11 +78,22 @@ func (s *Scheduler) Start(ctx context.Context) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
wait := time.Duration(settings.UpdateIntervalSeconds) * time.Second
|
||||
if wait < time.Minute {
|
||||
wait = time.Minute
|
||||
}
|
||||
retryAfter := time.Duration(0)
|
||||
if settings.UpdaterEnabled {
|
||||
if _, runErr := s.Run(ctx); runErr != nil && !errors.Is(runErr, ErrRunInProgress) && !errors.Is(runErr, ErrDisabled) {
|
||||
s.logger.Warn("scheduled update run failed", "error", runErr)
|
||||
_, runErr := s.Run(ctx)
|
||||
if runErr == nil {
|
||||
consecutiveFailures = 0
|
||||
} else if !errors.Is(runErr, ErrRunInProgress) && !errors.Is(runErr, ErrDisabled) {
|
||||
consecutiveFailures++
|
||||
retryAfter = s.retryDelay(consecutiveFailures, wait)
|
||||
s.logger.Warn("scheduled update run failed", "error", runErr, "retry_in", retryAfter)
|
||||
}
|
||||
} else {
|
||||
consecutiveFailures = 0
|
||||
s.nextRun.Store(0)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -73,9 +102,8 @@ func (s *Scheduler) Start(ctx context.Context) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
wait := time.Duration(settings.UpdateIntervalSeconds) * time.Second
|
||||
if wait < time.Minute {
|
||||
wait = time.Minute
|
||||
if retryAfter > 0 {
|
||||
wait = retryAfter
|
||||
}
|
||||
s.nextRun.Store(time.Now().UTC().Add(wait).Unix())
|
||||
timer := time.NewTimer(wait)
|
||||
@@ -84,6 +112,7 @@ func (s *Scheduler) Start(ctx context.Context) {
|
||||
timer.Stop()
|
||||
return
|
||||
case <-s.wake:
|
||||
consecutiveFailures = 0
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
@@ -96,6 +125,30 @@ func (s *Scheduler) Start(ctx context.Context) {
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Scheduler) retryDelay(failures int, scheduledWait time.Duration) time.Duration {
|
||||
minimum, maximum := s.retryMin, scheduledWait
|
||||
if minimum <= 0 {
|
||||
minimum = defaultRetryInitial
|
||||
}
|
||||
if s.retryMax > 0 && s.retryMax < maximum {
|
||||
maximum = s.retryMax
|
||||
}
|
||||
if maximum < minimum {
|
||||
maximum = minimum
|
||||
}
|
||||
wait := minimum
|
||||
for attempt := 1; attempt < failures && wait < maximum; attempt++ {
|
||||
if wait > maximum/2 {
|
||||
return maximum
|
||||
}
|
||||
wait *= 2
|
||||
}
|
||||
if wait > maximum {
|
||||
return maximum
|
||||
}
|
||||
return wait
|
||||
}
|
||||
|
||||
func (s *Scheduler) Trigger() {
|
||||
s.nextRun.Store(0)
|
||||
select {
|
||||
@@ -131,22 +184,43 @@ func (s *Scheduler) Run(ctx context.Context) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ipResult := s.resolver.ResolveWithIPv6(ctx, settings.IPv4Endpoints, settings.IPv6Endpoints, settings.IPv6Enabled)
|
||||
if ipResult.IPv4 == "" && ipResult.IPv6 == "" {
|
||||
message := "Keine öffentliche IP ermittelt"
|
||||
if ipResult.IPv4Err != nil {
|
||||
message += ": " + ipResult.IPv4Err.Error()
|
||||
}
|
||||
return s.finishRun(ctx, runID, "error", "", "", message, errors.New(message))
|
||||
}
|
||||
|
||||
targets, err := s.store.ListManagedTargets(ctx)
|
||||
if err != nil {
|
||||
return s.finishRun(ctx, runID, "error", ipResult.IPv4, ipResult.IPv6, "Aktive Ziele konnten nicht geladen werden", err)
|
||||
return s.finishRun(ctx, runID, "error", "", "", "Aktive Ziele konnten nicht geladen werden", err)
|
||||
}
|
||||
needsIPv6 := false
|
||||
for _, target := range targets {
|
||||
if target.Target.UpdateIPv6 {
|
||||
needsIPv6 = true
|
||||
break
|
||||
}
|
||||
}
|
||||
ipResult := s.resolver.ResolveWithIPv6(ctx, settings.IPv4Endpoints, settings.IPv6Endpoints, settings.IPv6Enabled && needsIPv6)
|
||||
var resolutionErr error
|
||||
if ipResult.IPv4Err != nil {
|
||||
resolutionErr = errors.Join(resolutionErr, fmt.Errorf("IPv4-Auflösung fehlgeschlagen: %w", ipResult.IPv4Err))
|
||||
}
|
||||
if settings.IPv6Enabled && needsIPv6 && ipResult.IPv6Err != nil {
|
||||
resolutionErr = errors.Join(resolutionErr, fmt.Errorf("IPv6-Auflösung fehlgeschlagen: %w", ipResult.IPv6Err))
|
||||
}
|
||||
if ipResult.IPv4 == "" && ipResult.IPv6 == "" {
|
||||
message := "Keine öffentliche IP ermittelt"
|
||||
if resolutionErr == nil {
|
||||
resolutionErr = errors.New(message)
|
||||
} else {
|
||||
message += ": " + resolutionErr.Error()
|
||||
}
|
||||
return s.finishRun(ctx, runID, "error", "", "", message, resolutionErr)
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
summary := "Keine aktiven DynDNS-Ziele"
|
||||
return s.finishRun(ctx, runID, "ok", ipResult.IPv4, ipResult.IPv6, summary, nil)
|
||||
status := "ok"
|
||||
if resolutionErr != nil {
|
||||
status = "partial"
|
||||
summary += "; eine IP-Familie war nicht verfügbar"
|
||||
}
|
||||
return s.finishRun(ctx, runID, status, ipResult.IPv4, ipResult.IPv6, summary, resolutionErr)
|
||||
}
|
||||
|
||||
grouped := make(map[int64][]store.ManagedTarget)
|
||||
@@ -172,13 +246,18 @@ func (s *Scheduler) Run(ctx context.Context) (string, error) {
|
||||
status = "partial"
|
||||
}
|
||||
summary := fmt.Sprintf("%d Ziel(e) aktualisiert, %d Fehler", updated, failed)
|
||||
if ipResult.IPv4Err != nil || (settings.IPv6Enabled && ipResult.IPv6Err != nil) {
|
||||
if resolutionErr != nil {
|
||||
summary += "; eine IP-Familie war nicht verfügbar"
|
||||
if status == "ok" {
|
||||
status = "partial"
|
||||
}
|
||||
}
|
||||
return s.finishRun(ctx, runID, status, ipResult.IPv4, ipResult.IPv6, summary, nil)
|
||||
var runErr error
|
||||
if failed > 0 {
|
||||
runErr = fmt.Errorf("%d DynDNS-Ziel(e) konnten nicht synchronisiert werden", failed)
|
||||
}
|
||||
runErr = errors.Join(runErr, resolutionErr)
|
||||
return s.finishRun(ctx, runID, status, ipResult.IPv4, ipResult.IPv6, summary, runErr)
|
||||
}
|
||||
|
||||
func (s *Scheduler) finishRun(ctx context.Context, runID int64, status, ipv4, ipv6, summary string, runErr error) (string, error) {
|
||||
|
||||
@@ -5,11 +5,13 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -20,6 +22,24 @@ import (
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
||||
)
|
||||
|
||||
type retryResolver struct {
|
||||
calls atomic.Int32
|
||||
recovered chan struct{}
|
||||
}
|
||||
|
||||
func (r *retryResolver) ResolveWithIPv6(_ context.Context, _, _ []string, _ bool) ipresolve.Result {
|
||||
call := r.calls.Add(1)
|
||||
if call == 1 {
|
||||
return ipresolve.Result{IPv4Err: errors.New("network is not ready")}
|
||||
}
|
||||
if call == 2 {
|
||||
close(r.recovered)
|
||||
}
|
||||
return ipresolve.Result{IPv4: "198.51.100.42"}
|
||||
}
|
||||
|
||||
func (r *retryResolver) ValidateEndpoint(_ context.Context, _ string) error { return nil }
|
||||
|
||||
func TestSyncZoneImportsAddressRecordsAndKeepsArchivedTargetsDetached(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
@@ -164,6 +184,81 @@ func TestDisabledUpdaterRejectsRunsAndTriggerResetsCountdown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedulerRetriesFailedStartupResolutionWithoutWaitingForFullInterval(t *testing.T) {
|
||||
t.Parallel()
|
||||
database, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
settings, err := database.GetSettings(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpdateSettings(context.Background(), settings.UpdateIntervalSeconds, true, false, settings.IPv4Endpoints, settings.IPv6Endpoints); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resolver := &retryResolver{recovered: make(chan struct{})}
|
||||
schedule := New(database, nil, ipresolve.New(), nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
schedule.resolver = resolver
|
||||
schedule.retryMin = 5 * time.Millisecond
|
||||
schedule.retryMax = 20 * time.Millisecond
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
schedule.Start(ctx)
|
||||
|
||||
select {
|
||||
case <-resolver.recovered:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("scheduler did not retry the failed startup resolution")
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
settings, err = database.GetSettings(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.LastIPv4 == "198.51.100.42" {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("successful retry did not persist IPv4: %#v", settings)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
runs, err := database.RecentRuns(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(runs) != 2 || runs[0].Status != "ok" || runs[1].Status != "error" {
|
||||
t.Fatalf("unexpected startup retry history: %#v", runs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryDelayBacksOffToTheConfiguredSchedule(t *testing.T) {
|
||||
t.Parallel()
|
||||
schedule := &Scheduler{retryMin: 5 * time.Second}
|
||||
tests := []struct {
|
||||
failures int
|
||||
want time.Duration
|
||||
}{
|
||||
{failures: 1, want: 5 * time.Second},
|
||||
{failures: 2, want: 10 * time.Second},
|
||||
{failures: 3, want: 20 * time.Second},
|
||||
{failures: 4, want: 40 * time.Second},
|
||||
{failures: 5, want: time.Minute},
|
||||
{failures: 20, want: time.Minute},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if got := schedule.retryDelay(test.failures, time.Minute); got != test.want {
|
||||
t.Errorf("retry delay after %d failures = %s, want %s", test.failures, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGloballyDisabledIPv6SkipsAAAATargetWithoutError(t *testing.T) {
|
||||
t.Parallel()
|
||||
schedule := &Scheduler{}
|
||||
|
||||
Reference in new issue
Block a user