fix: harden DynDNS recovery and publish v1.5.1

This commit is contained in:
admin committed 2026-10-10 02:26:47 +02:00
1 parent 3383b91e49
commit 81def77e3c
37 files changed
+2415 -250

No files matched your search

+44
View File
@@ -1435,6 +1435,50 @@ func (s *Store) FinishUpdateRun(ctx context.Context, id int64, status, ipv4, ipv
return err
}
// CompleteUpdateRun commits history, dashboard state and audit together.
func (s *Store) CompleteUpdateRun(ctx context.Context, id int64, status, ipv4, ipv6, summary string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `UPDATE update_runs SET finished_at = ?, status = ?, ipv4 = ?, ipv6 = ?, summary = ? WHERE id = ?`, now(), status, ipv4, ipv6, summary, id); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `UPDATE settings SET last_ipv4 = COALESCE(NULLIF(?, ''), last_ipv4), last_ipv6 = COALESCE(NULLIF(?, ''), last_ipv6), last_run_at = ?, last_run_status = ? WHERE id = 1`, ipv4, ipv6, now(), status); err != nil {
return err
}
if status != "ok" {
if _, err := tx.ExecContext(ctx, `INSERT INTO audit_events(created_at, kind, message) VALUES(?, ?, ?)`, now(), "update."+status, summary); err != nil {
return err
}
}
return tx.Commit()
}
// RecoverInterruptedRuns is called once at process startup, before workers start.
func (s *Store) RecoverInterruptedRuns(ctx context.Context) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `UPDATE update_runs SET finished_at = ?, status = 'error', summary = 'Durch Neustart unterbrochen; erneute Prüfung folgt automatisch' WHERE status = 'running'`, now())
if err != nil {
return err
}
count, err := result.RowsAffected()
if err != nil {
return err
}
if count > 0 {
if _, err := tx.ExecContext(ctx, `UPDATE settings SET last_run_status = 'error' WHERE id = 1`); err != nil {
return err
}
}
return tx.Commit()
}
func (s *Store) RecentRuns(ctx context.Context, limit int) ([]model.UpdateRun, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, started_at, finished_at, status, ipv4, ipv6, summary FROM update_runs ORDER BY id DESC LIMIT ?`, limit)
if err != nil {
+43
View File
@@ -14,6 +14,49 @@ import (
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
)
func TestRunCompletionIsAtomicAndInterruptedRunsRecover(t *testing.T) {
ctx := context.Background()
database, err := Open(ctx, filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
id, err := database.CreateUpdateRun(ctx)
if err != nil {
t.Fatal(err)
}
if _, err := database.db.Exec(`CREATE TRIGGER reject_state BEFORE UPDATE ON settings BEGIN SELECT RAISE(FAIL, 'storage failure'); END`); err != nil {
t.Fatal(err)
}
if err := database.CompleteUpdateRun(ctx, id, "ok", "198.51.100.42", "", "completed"); err == nil {
t.Fatal("storage failure silently ignored")
}
runs, err := database.RecentRuns(ctx, 1)
if err != nil || runs[0].Status != "running" {
t.Fatalf("partially committed run: %#v %v", runs, err)
}
if _, err := database.db.Exec(`DROP TRIGGER reject_state`); err != nil {
t.Fatal(err)
}
if err := database.CompleteUpdateRun(ctx, id, "ok", "198.51.100.42", "", "completed"); err != nil {
t.Fatal(err)
}
if _, err := database.CreateUpdateRun(ctx); err != nil {
t.Fatal(err)
}
if err := database.RecoverInterruptedRuns(ctx); err != nil {
t.Fatal(err)
}
runs, err = database.RecentRuns(ctx, 10)
if err != nil || len(runs) != 2 || runs[0].Status != "error" || runs[0].FinishedAt.IsZero() || runs[1].Status != "ok" {
t.Fatalf("incorrect restart recovery: %#v %v", runs, err)
}
settings, err := database.GetSettings(ctx)
if err != nil || settings.LastIPv4 != "198.51.100.42" || settings.LastRunStatus != "error" {
t.Fatalf("restart corrupted state: %#v %v", settings, err)
}
}
func TestMigrateAppliesEveryIntermediateSchemaFromV1(t *testing.T) {
t.Parallel()
ctx := context.Background()