feat: add resilient updates and access controls
Container-Image veröffentlichen / container (push) Has been cancelled

This commit is contained in:
admin committed 2026-08-28 14:02:07 +02:00
1 parent a000152423
commit f02ec9977c
41 files changed
+3277 -151

No files matched your search

+95
View File
@@ -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{}