Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3383b91e49 | ||
|
|
7bb33e528c |
No files matched your search
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
ARG VERSION=v1.4.3
|
ARG VERSION=v1.5.0
|
||||||
ARG MINIMUM_UPDATE_VERSION=v1.4.0
|
ARG MINIMUM_UPDATE_VERSION=v1.4.0
|
||||||
ARG MINIMUM_AGENT_PROTOCOL=2
|
ARG MINIMUM_AGENT_PROTOCOL=2
|
||||||
ARG RUNTIME_CONTRACT_VERSION=2
|
ARG RUNTIME_CONTRACT_VERSION=2
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ Beim ersten Start werden Datenbank und Verschlüsselungsschlüssel automatisch i
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
dyndns:
|
dyndns:
|
||||||
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.3
|
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.5.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
stop_grace_period: 20s
|
stop_grace_period: 20s
|
||||||
ports: ["9090:8080"]
|
ports: ["9090:8080"]
|
||||||
@@ -67,6 +67,8 @@ Administratoren können im Menü **Update** nach stabilen Releases suchen und ei
|
|||||||
|
|
||||||
Nur der separate, netzwerklose `update-agent` erhält Zugriff auf den Docker-Socket. Er kann ausschließlich das fest konfigurierte Anwendungs-Image ersetzen: Das neue Image wird vollständig geladen, bevor der laufende Container beendet wird, und erst nach erfolgreichem Healthcheck übernommen. Bei einem Fehler wird die vorherige Instanz automatisch wiederhergestellt. Ist Gitea oder die Registry nicht erreichbar, läuft der DNS-Updater unabhängig weiter und die zuletzt bekannte Update-Benachrichtigung bleibt erhalten.
|
Nur der separate, netzwerklose `update-agent` erhält Zugriff auf den Docker-Socket. Er kann ausschließlich das fest konfigurierte Anwendungs-Image ersetzen: Das neue Image wird vollständig geladen, bevor der laufende Container beendet wird, und erst nach erfolgreichem Healthcheck übernommen. Bei einem Fehler wird die vorherige Instanz automatisch wiederhergestellt. Ist Gitea oder die Registry nicht erreichbar, läuft der DNS-Updater unabhängig weiter und die zuletzt bekannte Update-Benachrichtigung bleibt erhalten.
|
||||||
|
|
||||||
|
Auf derselben Seite kann ein Administrator den vollständigen Anwendungscontainer geordnet neu starten. Dabei bleiben Daten-, Secret- und Steuer-Volumes unverändert. Der Neustart beendet den Prozess im Container; die in der unterstützten Compose-Konfiguration gesetzte Richtlinie `restart: unless-stopped` startet ihn anschließend automatisch wieder. Die Oberfläche erkennt die neue Prozessinstanz und verbindet sich selbstständig neu.
|
||||||
|
|
||||||
Alternativ lässt sich die Version weiterhin vollständig über Docker Compose aktualisieren. Dafür nur den konkret gepinnten Tag in der mit `&image` markierten Image-Zeile ändern; Web-App und Update-Agent verwenden dadurch garantiert dieselbe Version. Anschließend:
|
Alternativ lässt sich die Version weiterhin vollständig über Docker Compose aktualisieren. Dafür nur den konkret gepinnten Tag in der mit `&image` markierten Image-Zeile ändern; Web-App und Update-Agent verwenden dadurch garantiert dieselbe Version. Anschließend:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -88,6 +90,7 @@ Benutzer, Domains und Einstellungen bleiben in `dyndns-data`. Der Schlüssel fü
|
|||||||
- Admin- und Mitgliedskonten mit Domain- oder Subdomain-Rechten
|
- Admin- und Mitgliedskonten mit Domain- oder Subdomain-Rechten
|
||||||
- ausschließlich Administratoren verwalten Konten und Freigaben; Admin-Vollzugriff besitzt keine bearbeitbaren Einzelrechte
|
- ausschließlich Administratoren verwalten Konten und Freigaben; Admin-Vollzugriff besitzt keine bearbeitbaren Einzelrechte
|
||||||
- automatische Release-Prüfung, Update-Benachrichtigung und kontrolliertes Ein-Klick-Update mit Rollback
|
- automatische Release-Prüfung, Update-Benachrichtigung und kontrolliertes Ein-Klick-Update mit Rollback
|
||||||
|
- administrativer Container-Neustart mit automatischem Wiederverbinden der Weboberfläche
|
||||||
- eine Pi-hole-v6-Verbindung mit verschlüsseltem Anwendungspasswort und geschützter Anzeige nach erneuter Admin-Passwortprüfung
|
- eine Pi-hole-v6-Verbindung mit verschlüsseltem Anwendungspasswort und geschützter Anzeige nach erneuter Admin-Passwortprüfung
|
||||||
- lokale Pi-hole-DNS-Einträge für IPv4 und IPv6 anlegen, aktivieren/deaktivieren, bearbeiten, aus der WebUI trennen, wiederherstellen und vollständig löschen
|
- lokale Pi-hole-DNS-Einträge für IPv4 und IPv6 anlegen, aktivieren/deaktivieren, bearbeiten, aus der WebUI trennen, wiederherstellen und vollständig löschen
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Domain Control aktualisieren
|
# Domain Control aktualisieren
|
||||||
|
|
||||||
Die aktuelle Compose-Version ist `v1.4.3`. Datenbank, Einstellungen und
|
Die aktuelle Compose-Version ist `v1.5.0`. Datenbank, Einstellungen und
|
||||||
verschlüsselte API-Zugänge liegen in benannten Docker-Volumes und bleiben bei
|
verschlüsselte API-Zugänge liegen in benannten Docker-Volumes und bleiben bei
|
||||||
den folgenden Verfahren erhalten.
|
den folgenden Verfahren erhalten.
|
||||||
|
|
||||||
@@ -20,6 +20,17 @@ das Ziel-Image die laufende Version ausdrücklich unterstützt. Diese
|
|||||||
Kompatibilität wird nach dem Download und vor dem ersten Containerwechsel
|
Kompatibilität wird nach dem Download und vor dem ersten Containerwechsel
|
||||||
geprüft. Ein inkompatibles Release wird ohne Unterbrechung abgelehnt.
|
geprüft. Ein inkompatibles Release wird ohne Unterbrechung abgelehnt.
|
||||||
|
|
||||||
|
Nach einem Neustart des Docker-Hosts prüft die Web-App den Update-Agenten mit
|
||||||
|
kurzem Abstand erneut. Der Update-Klick führt zusätzlich immer eine aktuelle
|
||||||
|
Prüfung durch und wartet bei Bedarf darauf, dass der Container seinen
|
||||||
|
Start-Healthcheck beendet. Ein separater manueller Neustart der Anwendung ist
|
||||||
|
dafür nicht mehr erforderlich.
|
||||||
|
|
||||||
|
Über **Container neu starten** auf der Update-Seite lässt sich der vollständige
|
||||||
|
Anwendungscontainer außerdem gezielt neu starten. Datenbank, Secrets und
|
||||||
|
Einstellungen bleiben erhalten. Diese Funktion setzt die im mitgelieferten
|
||||||
|
Compose konfigurierte Richtlinie `restart: unless-stopped` voraus.
|
||||||
|
|
||||||
Die sichere Update-Basis beginnt mit `v1.4.0`. Installationen bis einschließlich
|
Die sichere Update-Basis beginnt mit `v1.4.0`. Installationen bis einschließlich
|
||||||
`v1.3.0` werden einmalig wie unten beschrieben mit Compose aktualisiert. Danach
|
`v1.3.0` werden einmalig wie unten beschrieben mit Compose aktualisiert. Danach
|
||||||
kann der Agent auch dann weitere kompatible Releases installieren, wenn seine
|
kann der Agent auch dann weitere kompatible Releases installieren, wenn seine
|
||||||
@@ -34,7 +45,7 @@ In `compose.yml` wird nur die zentrale, konkret gepinnte Image-Zeile geändert:
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
dyndns:
|
dyndns:
|
||||||
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.3
|
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.5.0
|
||||||
update-agent:
|
update-agent:
|
||||||
image: *image
|
image: *image
|
||||||
```
|
```
|
||||||
@@ -68,7 +79,7 @@ Die erwartete Antwort lautet `ok`.
|
|||||||
## Portainer oder Dockge
|
## Portainer oder Dockge
|
||||||
|
|
||||||
1. Den bestehenden Stack bearbeiten.
|
1. Den bestehenden Stack bearbeiten.
|
||||||
2. Nur den Tag der zentralen Image-Zeile auf `v1.4.3` setzen.
|
2. Nur den Tag der zentralen Image-Zeile auf `v1.5.0` setzen.
|
||||||
3. Den Stack mit vorherigem Image-Pull erneut bereitstellen.
|
3. Den Stack mit vorherigem Image-Pull erneut bereitstellen.
|
||||||
4. Kontrollieren, dass beide Container laufen und die Web-App `healthy` ist.
|
4. Kontrollieren, dass beide Container laufen und die Web-App `healthy` ist.
|
||||||
|
|
||||||
|
|||||||
+24
-1
@@ -9,6 +9,7 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -66,6 +67,7 @@ func commandForInvocation(arguments []string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run(logger *slog.Logger) error {
|
func run(logger *slog.Logger) error {
|
||||||
|
startedAt := time.Now()
|
||||||
// Credentials and session data must never inherit a permissive process umask.
|
// Credentials and session data must never inherit a permissive process umask.
|
||||||
syscall.Umask(0o077)
|
syscall.Umask(0o077)
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
@@ -103,7 +105,22 @@ func run(logger *slog.Logger) error {
|
|||||||
logger.Info("removed AAAA records for disabled IPv6", "count", deleted)
|
logger.Info("removed AAAA records for disabled IPv6", "count", deleted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
serverUI, err := web.New(cfg, database, cipher, updater, softwareUpdates, logger)
|
restartRequested := make(chan struct{})
|
||||||
|
var restartOnce sync.Once
|
||||||
|
requestRestart := func() {
|
||||||
|
restartOnce.Do(func() {
|
||||||
|
// Docker applies restart policies only after a container has stayed up
|
||||||
|
// long enough to be considered successfully started. Keep very early
|
||||||
|
// UI requests alive until that safety window has passed.
|
||||||
|
delay := time.Until(startedAt.Add(11 * time.Second))
|
||||||
|
if delay > 0 {
|
||||||
|
time.AfterFunc(delay, func() { close(restartRequested) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
close(restartRequested)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
serverUI, err := web.New(cfg, database, cipher, updater, softwareUpdates, logger, requestRestart)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -133,6 +150,12 @@ func run(logger *slog.Logger) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
case <-restartRequested:
|
||||||
|
logger.Info("container restart requested")
|
||||||
|
stop()
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
||||||
|
defer cancel()
|
||||||
|
return httpServer.Shutdown(shutdownCtx)
|
||||||
case <-appCtx.Done():
|
case <-appCtx.Done():
|
||||||
logger.Info("shutdown requested")
|
logger.Info("shutdown requested")
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
dyndns:
|
dyndns:
|
||||||
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.4.3
|
image: &image git.j-mahler.de/admin/domain-control-webinterface-prod:v1.5.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
stop_grace_period: 20s
|
stop_grace_period: 20s
|
||||||
ports: ["9090:8080"]
|
ports: ["9090:8080"]
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ import (
|
|||||||
|
|
||||||
var ErrCredentialMissing = errors.New("Update-Zugang ist noch nicht eingerichtet")
|
var ErrCredentialMissing = errors.New("Update-Zugang ist noch nicht eingerichtet")
|
||||||
|
|
||||||
|
const (
|
||||||
|
agentUnavailableRetryInterval = 2 * time.Second
|
||||||
|
agentAvailableCheckInterval = time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type updateAgent interface {
|
||||||
|
Start(context.Context, updateprotocol.Request) error
|
||||||
|
Status(context.Context) (updateprotocol.Status, error)
|
||||||
|
}
|
||||||
|
|
||||||
type State struct {
|
type State struct {
|
||||||
CurrentVersion string
|
CurrentVersion string
|
||||||
LatestVersion string
|
LatestVersion string
|
||||||
@@ -46,9 +56,10 @@ type Manager struct {
|
|||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
apiURL string
|
apiURL string
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
agent *AgentClient
|
agent updateAgent
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
checkMu sync.Mutex
|
checkMu sync.Mutex
|
||||||
|
requestMu sync.Mutex
|
||||||
stateMu sync.RWMutex
|
stateMu sync.RWMutex
|
||||||
state State
|
state State
|
||||||
checkSignal chan struct{}
|
checkSignal chan struct{}
|
||||||
@@ -87,8 +98,8 @@ func New(database *store.Store, cipher *secure.Cipher, apiURL, agentSocket strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Start(ctx context.Context) {
|
func (m *Manager) Start(ctx context.Context) {
|
||||||
|
go m.monitorAgent(ctx, agentUnavailableRetryInterval, agentAvailableCheckInterval)
|
||||||
go func() {
|
go func() {
|
||||||
m.refreshAgentStatus(ctx)
|
|
||||||
initial := time.NewTimer(15 * time.Second)
|
initial := time.NewTimer(15 * time.Second)
|
||||||
defer initial.Stop()
|
defer initial.Stop()
|
||||||
var ticker *time.Ticker
|
var ticker *time.Ticker
|
||||||
@@ -121,7 +132,28 @@ func (m *Manager) backgroundCheck(ctx context.Context) {
|
|||||||
if err := m.CheckNow(checkCtx); err != nil && !errors.Is(err, ErrCredentialMissing) && !errors.Is(err, context.Canceled) {
|
if err := m.CheckNow(checkCtx); err != nil && !errors.Is(err, ErrCredentialMissing) && !errors.Is(err, context.Canceled) {
|
||||||
m.logger.Warn("software update check failed", "error", err)
|
m.logger.Warn("software update check failed", "error", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) monitorAgent(ctx context.Context, unavailableInterval, availableInterval time.Duration) {
|
||||||
|
for {
|
||||||
m.refreshAgentStatus(ctx)
|
m.refreshAgentStatus(ctx)
|
||||||
|
delay := unavailableInterval
|
||||||
|
if m.State().AgentAvailable {
|
||||||
|
delay = availableInterval
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(delay)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if !timer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) TriggerCheck() {
|
func (m *Manager) TriggerCheck() {
|
||||||
@@ -190,6 +222,9 @@ func (m *Manager) CheckNow(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) RequestUpdate(ctx context.Context, version string) error {
|
func (m *Manager) RequestUpdate(ctx context.Context, version string) error {
|
||||||
|
m.requestMu.Lock()
|
||||||
|
defer m.requestMu.Unlock()
|
||||||
|
|
||||||
version = strings.TrimSpace(version)
|
version = strings.TrimSpace(version)
|
||||||
state := m.State()
|
state := m.State()
|
||||||
if !validReleaseVersion(version) || version != state.LatestVersion {
|
if !validReleaseVersion(version) || version != state.LatestVersion {
|
||||||
@@ -198,12 +233,16 @@ func (m *Manager) RequestUpdate(ctx context.Context, version string) error {
|
|||||||
if comparison, err := compareVersions(state.CurrentVersion, version); err != nil || comparison >= 0 {
|
if comparison, err := compareVersions(state.CurrentVersion, version); err != nil || comparison >= 0 {
|
||||||
return errors.New("die ausgewählte Version ist nicht neuer als die laufende Version")
|
return errors.New("die ausgewählte Version ist nicht neuer als die laufende Version")
|
||||||
}
|
}
|
||||||
if !state.AgentAvailable {
|
status, err := m.UpdateStatus(ctx)
|
||||||
|
if err != nil {
|
||||||
return errors.New("Update-Agent ist nicht erreichbar")
|
return errors.New("Update-Agent ist nicht erreichbar")
|
||||||
}
|
}
|
||||||
if !state.AgentCompatible {
|
if !agentCompatible(status) {
|
||||||
return errors.New("Web-App und Update-Agent müssen zuerst gemeinsam mit Docker Compose aktualisiert werden")
|
return errors.New("Web-App und Update-Agent müssen zuerst gemeinsam mit Docker Compose aktualisiert werden")
|
||||||
}
|
}
|
||||||
|
if status.Busy() {
|
||||||
|
return errors.New("ein Software-Update läuft bereits")
|
||||||
|
}
|
||||||
settings, err := m.store.GetSoftwareUpdateSettings(ctx)
|
settings, err := m.store.GetSoftwareUpdateSettings(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ package appupdate
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -19,6 +21,48 @@ import (
|
|||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type fakeUpdateAgent struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
status updateprotocol.Status
|
||||||
|
statusErr error
|
||||||
|
startErr error
|
||||||
|
statusCalls int
|
||||||
|
startCalls int
|
||||||
|
requests []updateprotocol.Request
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *fakeUpdateAgent) Status(context.Context) (updateprotocol.Status, error) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
a.statusCalls++
|
||||||
|
return a.status, a.statusErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *fakeUpdateAgent) Start(_ context.Context, request updateprotocol.Request) error {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
a.startCalls++
|
||||||
|
a.requests = append(a.requests, request)
|
||||||
|
if a.startErr == nil {
|
||||||
|
a.status.Phase = updateprotocol.PhaseQueued
|
||||||
|
a.status.TargetVersion = request.Version
|
||||||
|
}
|
||||||
|
return a.startErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *fakeUpdateAgent) setStatus(status updateprotocol.Status, err error) {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.status = status
|
||||||
|
a.statusErr = err
|
||||||
|
a.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *fakeUpdateAgent) counts() (status, start int) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
return a.statusCalls, a.startCalls
|
||||||
|
}
|
||||||
|
|
||||||
func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
|
func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
const credential = " test-password-or-token-that-must-stay-secret "
|
const credential = " test-password-or-token-that-must-stay-secret "
|
||||||
@@ -34,7 +78,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{"tag_name":"v1.4.4","html_url":"https://example.invalid/releases/v1.4.4","draft":false,"prerelease":false}`))
|
_, _ = w.Write([]byte(`{"tag_name":"v1.5.1","html_url":"https://example.invalid/releases/v1.5.1","draft":false,"prerelease":false}`))
|
||||||
}))
|
}))
|
||||||
defer api.Close()
|
defer api.Close()
|
||||||
|
|
||||||
@@ -67,7 +111,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
state := manager.State()
|
state := manager.State()
|
||||||
if !state.UpdateAvailable || state.LatestVersion != "v1.4.4" || state.LastError != "" || state.CredentialUsername != "registry-user" {
|
if !state.UpdateAvailable || state.LatestVersion != "v1.5.1" || state.LastError != "" || state.CredentialUsername != "registry-user" {
|
||||||
t.Fatalf("new release was not detected: %#v", state)
|
t.Fatalf("new release was not detected: %#v", state)
|
||||||
}
|
}
|
||||||
settings, err := database.GetSoftwareUpdateSettings(ctx)
|
settings, err := database.GetSoftwareUpdateSettings(ctx)
|
||||||
@@ -83,7 +127,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
|
|||||||
t.Fatal("unavailable Gitea endpoint was reported as successful")
|
t.Fatal("unavailable Gitea endpoint was reported as successful")
|
||||||
}
|
}
|
||||||
state = manager.State()
|
state = manager.State()
|
||||||
if !state.UpdateAvailable || state.LatestVersion != "v1.4.4" || state.LastError == "" {
|
if !state.UpdateAvailable || state.LatestVersion != "v1.5.1" || state.LastError == "" {
|
||||||
t.Fatalf("temporary outage removed the last known update reminder: %#v", state)
|
t.Fatalf("temporary outage removed the last known update reminder: %#v", state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,6 +182,116 @@ func TestAgentCompatibilityStartsAtStableV14Protocol(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRequestUpdateRefreshesStaleAgentStateBeforeStarting(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
agent := &fakeUpdateAgent{status: compatibleAgentStatus(updateprotocol.PhaseIdle)}
|
||||||
|
manager, database := updateReadyManager(t, agent)
|
||||||
|
if manager.State().AgentAvailable {
|
||||||
|
t.Fatal("test precondition failed: cached agent state is already available")
|
||||||
|
}
|
||||||
|
if err := manager.RequestUpdate(context.Background(), "v1.5.1"); err != nil {
|
||||||
|
t.Fatalf("live agent was rejected because of stale cached state: %v", err)
|
||||||
|
}
|
||||||
|
statusCalls, startCalls := agent.counts()
|
||||||
|
if statusCalls != 1 || startCalls != 1 {
|
||||||
|
t.Fatalf("agent was not checked immediately before start: status=%d start=%d", statusCalls, startCalls)
|
||||||
|
}
|
||||||
|
settings, err := database.GetSoftwareUpdateSettings(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if settings.DesiredVersion != "v1.5.1" {
|
||||||
|
t.Fatalf("target version was not persisted: %#v", settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestUpdateRejectsUnavailableIncompatibleAndBusyAgents(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
status updateprotocol.Status
|
||||||
|
statusErr error
|
||||||
|
}{
|
||||||
|
{name: "unavailable", statusErr: errors.New("offline")},
|
||||||
|
{name: "incompatible", status: updateprotocol.Status{Phase: updateprotocol.PhaseIdle, AgentVersion: "v1.3.0", Protocol: 1}},
|
||||||
|
{name: "busy", status: compatibleAgentStatus(updateprotocol.PhasePulling)},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
test := test
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
agent := &fakeUpdateAgent{status: test.status, statusErr: test.statusErr}
|
||||||
|
manager, database := updateReadyManager(t, agent)
|
||||||
|
if err := manager.RequestUpdate(context.Background(), "v1.5.1"); err == nil {
|
||||||
|
t.Fatal("unsafe update request was accepted")
|
||||||
|
}
|
||||||
|
_, startCalls := agent.counts()
|
||||||
|
if startCalls != 0 {
|
||||||
|
t.Fatalf("agent start was called %d times", startCalls)
|
||||||
|
}
|
||||||
|
settings, err := database.GetSoftwareUpdateSettings(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if settings.DesiredVersion != "" {
|
||||||
|
t.Fatalf("rejected update changed desired version: %#v", settings)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestUpdateSerializesParallelClicks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
agent := &fakeUpdateAgent{status: compatibleAgentStatus(updateprotocol.PhaseIdle)}
|
||||||
|
manager, _ := updateReadyManager(t, agent)
|
||||||
|
results := make(chan error, 2)
|
||||||
|
start := make(chan struct{})
|
||||||
|
for index := 0; index < 2; index++ {
|
||||||
|
go func() {
|
||||||
|
<-start
|
||||||
|
results <- manager.RequestUpdate(context.Background(), "v1.5.1")
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
var successes int
|
||||||
|
for index := 0; index < 2; index++ {
|
||||||
|
if err := <-results; err == nil {
|
||||||
|
successes++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, startCalls := agent.counts()
|
||||||
|
if successes != 1 || startCalls != 1 {
|
||||||
|
t.Fatalf("parallel clicks started an unexpected number of updates: successes=%d starts=%d", successes, startCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentMonitorRecoversAfterDelayedAgentStartup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
agent := &fakeUpdateAgent{statusErr: errors.New("not listening yet")}
|
||||||
|
manager := &Manager{agent: agent}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
manager.monitorAgent(ctx, 2*time.Millisecond, 20*time.Millisecond)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
waitForCondition(t, time.Second, func() bool {
|
||||||
|
statusCalls, _ := agent.counts()
|
||||||
|
return statusCalls > 0
|
||||||
|
})
|
||||||
|
agent.setStatus(compatibleAgentStatus(updateprotocol.PhaseIdle), nil)
|
||||||
|
waitForCondition(t, time.Second, func() bool {
|
||||||
|
state := manager.State()
|
||||||
|
return state.AgentAvailable && state.AgentCompatible
|
||||||
|
})
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("agent monitor did not stop after context cancellation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestManagerClearsCompletedDesiredVersionOnStartup(t *testing.T) {
|
func TestManagerClearsCompletedDesiredVersionOnStartup(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -176,3 +330,58 @@ func TestManagerClearsCompletedDesiredVersionOnStartup(t *testing.T) {
|
|||||||
t.Fatalf("completed desired version remained persisted: %#v", settings)
|
t.Fatalf("completed desired version remained persisted: %#v", settings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func compatibleAgentStatus(phase string) updateprotocol.Status {
|
||||||
|
return updateprotocol.Status{Phase: phase, AgentVersion: buildinfo.MinimumAgentVersion, Protocol: buildinfo.MinimumAgentProtocol}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateReadyManager(t *testing.T, agent updateAgent) (*Manager, *store.Store) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
keyFile := filepath.Join(t.TempDir(), "master-key")
|
||||||
|
key := make([]byte, 32)
|
||||||
|
for index := range key {
|
||||||
|
key[index] = byte(index + 1)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(keyFile, []byte(base64.RawStdEncoding.EncodeToString(key)), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cipher, err := secure.LoadCipher(keyFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
manager, err := New(database, cipher, "https://git.example/api/releases/latest", filepath.Join(t.TempDir(), "agent.sock"), time.Hour, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
manager.agent = agent
|
||||||
|
if err := manager.SaveCredential(ctx, "registry-user", "registry-password"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.RecordSoftwareUpdateSuccess(ctx, "v1.5.1", "https://git.example/releases/v1.5.1", time.Now()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
settings, err := database.GetSoftwareUpdateSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
manager.setStoredState(settings)
|
||||||
|
return manager, database
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForCondition(t *testing.T, timeout time.Duration, condition func() bool) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if condition() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatal("condition did not become true in time")
|
||||||
|
}
|
||||||
@@ -18,4 +18,4 @@ const (
|
|||||||
|
|
||||||
// Version is replaced through -ldflags for release images. The source fallback
|
// Version is replaced through -ldflags for release images. The source fallback
|
||||||
// keeps local builds and tests honest about the currently checked-out release.
|
// keeps local builds and tests honest about the currently checked-out release.
|
||||||
var Version = "v1.4.3"
|
var Version = "v1.5.0"
|
||||||
@@ -64,7 +64,7 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques
|
|||||||
if !updateprotocol.ValidVersion(request.Version) {
|
if !updateprotocol.ValidVersion(request.Version) {
|
||||||
return errors.New("invalid target version")
|
return errors.New("invalid target version")
|
||||||
}
|
}
|
||||||
if err := r.docker.negotiate(ctx); err != nil {
|
if err := waitForDockerAPI(ctx, 30*time.Second, time.Second, r.docker.negotiate); err != nil {
|
||||||
return &updateError{message: "Containerverwaltung ist nicht erreichbar; der Dienst läuft unverändert weiter", err: err}
|
return &updateError{message: "Containerverwaltung ist nicht erreichbar; der Dienst läuft unverändert weiter", err: err}
|
||||||
}
|
}
|
||||||
target, err := r.docker.findTarget(ctx, r.targetLabel)
|
target, err := r.docker.findTarget(ctx, r.targetLabel)
|
||||||
@@ -75,7 +75,18 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if inspected.State.Health == nil || inspected.State.Health.Status != "healthy" {
|
if inspected.State.Health == nil {
|
||||||
|
return &updateError{message: "Die laufende Instanz ist nicht healthy; das Update wurde sicherheitshalber nicht gestartet", err: errors.New("current container is not healthy")}
|
||||||
|
}
|
||||||
|
switch inspected.State.Health.Status {
|
||||||
|
case "healthy":
|
||||||
|
case "starting":
|
||||||
|
progress(updateprotocol.PhaseQueued, "Laufender Container beendet noch seinen Start-Healthcheck")
|
||||||
|
inspected, err = r.docker.waitCurrentHealthy(ctx, target.ID, inspected.Image, 100*time.Second, time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return &updateError{message: "Die laufende Instanz wurde nach dem Host-Start nicht rechtzeitig healthy; das Update wurde nicht gestartet", err: err}
|
||||||
|
}
|
||||||
|
default:
|
||||||
return &updateError{message: "Die laufende Instanz ist nicht healthy; das Update wurde sicherheitshalber nicht gestartet", err: errors.New("current container is not healthy")}
|
return &updateError{message: "Die laufende Instanz ist nicht healthy; das Update wurde sicherheitshalber nicht gestartet", err: errors.New("current container is not healthy")}
|
||||||
}
|
}
|
||||||
currentVersion, err := resolveCurrentVersion(request.CurrentVersion, inspected.Config, r.imageRepository)
|
currentVersion, err := resolveCurrentVersion(request.CurrentVersion, inspected.Config, r.imageRepository)
|
||||||
@@ -847,6 +858,72 @@ func (c *dockerClient) waitHealthy(ctx context.Context, id, expectedImageID, exp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) waitCurrentHealthy(ctx context.Context, id, expectedImageID string, timeout, pollInterval time.Duration) (containerInspect, error) {
|
||||||
|
deadline := time.NewTimer(timeout)
|
||||||
|
ticker := time.NewTicker(pollInterval)
|
||||||
|
defer deadline.Stop()
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return containerInspect{}, ctx.Err()
|
||||||
|
case <-deadline.C:
|
||||||
|
return containerInspect{}, errors.New("current container did not become healthy in time")
|
||||||
|
case <-ticker.C:
|
||||||
|
inspected, err := c.inspectContainer(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return containerInspect{}, err
|
||||||
|
}
|
||||||
|
if !inspected.State.Running {
|
||||||
|
return containerInspect{}, fmt.Errorf("current container stopped with exit code %d", inspected.State.ExitCode)
|
||||||
|
}
|
||||||
|
if expectedImageID != "" && inspected.Image != expectedImageID {
|
||||||
|
return containerInspect{}, fmt.Errorf("current container image changed from %s to %s", expectedImageID, inspected.Image)
|
||||||
|
}
|
||||||
|
if inspected.State.Health == nil {
|
||||||
|
return containerInspect{}, errors.New("current container has no healthcheck")
|
||||||
|
}
|
||||||
|
switch inspected.State.Health.Status {
|
||||||
|
case "healthy":
|
||||||
|
return inspected, nil
|
||||||
|
case "starting":
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
return containerInspect{}, fmt.Errorf("current container health is %q", inspected.State.Health.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForDockerAPI(ctx context.Context, timeout, interval time.Duration, negotiate func(context.Context) error) error {
|
||||||
|
retryCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
var lastErr error
|
||||||
|
for {
|
||||||
|
attemptCtx, attemptCancel := context.WithTimeout(retryCtx, 3*time.Second)
|
||||||
|
lastErr = negotiate(attemptCtx)
|
||||||
|
attemptCancel()
|
||||||
|
if lastErr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(interval)
|
||||||
|
select {
|
||||||
|
case <-retryCtx.Done():
|
||||||
|
if !timer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *dockerClient) request(ctx context.Context, method, path string, body io.Reader, headers http.Header, versioned bool) (*http.Response, error) {
|
func (c *dockerClient) request(ctx context.Context, method, path string, body io.Reader, headers http.Header, versioned bool) (*http.Response, error) {
|
||||||
if versioned {
|
if versioned {
|
||||||
path = c.apiPrefix + path
|
path = c.apiPrefix + path
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
package updateagent
|
package updateagent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||||
|
return f(request)
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateUpdateCompatibilityAllowsDirectMajorJump(t *testing.T) {
|
func TestValidateUpdateCompatibilityAllowsDirectMajorJump(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
current := compatibleImage("v1.4.0", "v1.4.0")
|
current := compatibleImage("v1.4.0", "v1.4.0")
|
||||||
@@ -17,6 +30,40 @@ func TestValidateUpdateCompatibilityAllowsDirectMajorJump(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWaitForDockerAPIRetriesBootTransient(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
var calls atomic.Int32
|
||||||
|
err := waitForDockerAPI(context.Background(), 100*time.Millisecond, time.Millisecond, func(context.Context) error {
|
||||||
|
if calls.Add(1) < 3 {
|
||||||
|
return errors.New("Docker is still starting")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil || calls.Load() != 3 {
|
||||||
|
t.Fatalf("Docker boot transient was not retried: calls=%d err=%v", calls.Load(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWaitCurrentHealthyAllowsStartingButRejectsUnhealthy(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
var calls atomic.Int32
|
||||||
|
client := dockerTestClient(func() string {
|
||||||
|
if calls.Add(1) == 1 {
|
||||||
|
return "starting"
|
||||||
|
}
|
||||||
|
return "healthy"
|
||||||
|
})
|
||||||
|
inspected, err := client.waitCurrentHealthy(context.Background(), "container-id", "sha256:current", 100*time.Millisecond, time.Millisecond)
|
||||||
|
if err != nil || inspected.State.Health == nil || inspected.State.Health.Status != "healthy" {
|
||||||
|
t.Fatalf("starting container did not become eligible: inspect=%#v err=%v", inspected, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unhealthy := dockerTestClient(func() string { return "unhealthy" })
|
||||||
|
if _, err := unhealthy.waitCurrentHealthy(context.Background(), "container-id", "sha256:current", 100*time.Millisecond, time.Millisecond); err == nil || !strings.Contains(err.Error(), "unhealthy") {
|
||||||
|
t.Fatalf("unhealthy container was not rejected immediately: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveCurrentVersionRequiresRuntimeAndImageMetadataToAgree(t *testing.T) {
|
func TestResolveCurrentVersionRequiresRuntimeAndImageMetadataToAgree(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
config := json.RawMessage(`{"Image":"registry.example/app:v1.4.0","Labels":{"org.opencontainers.image.version":"v1.4.0"}}`)
|
config := json.RawMessage(`{"Image":"registry.example/app:v1.4.0","Labels":{"org.opencontainers.image.version":"v1.4.0"}}`)
|
||||||
@@ -203,3 +250,11 @@ func contains(values []string, expected string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dockerTestClient(health func() string) *dockerClient {
|
||||||
|
transport := roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||||
|
body := `{"Id":"container-id","Image":"sha256:current","Config":{"Labels":{}},"State":{"Running":true,"ExitCode":0,"Health":{"Status":"` + health() + `"}}}`
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}, nil
|
||||||
|
})
|
||||||
|
return &dockerClient{client: &http.Client{Transport: transport}, apiPrefix: "/v1.0"}
|
||||||
|
}
|
||||||
+74
-3
@@ -43,6 +43,10 @@ type Server struct {
|
|||||||
limiter *loginLimiter
|
limiter *loginLimiter
|
||||||
dummyHash string
|
dummyHash string
|
||||||
authSlots chan struct{}
|
authSlots chan struct{}
|
||||||
|
instanceID string
|
||||||
|
restartContainer func()
|
||||||
|
restartMu sync.Mutex
|
||||||
|
restartPending bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type viewData struct {
|
type viewData struct {
|
||||||
@@ -79,6 +83,7 @@ type viewData struct {
|
|||||||
ErrorCount int
|
ErrorCount int
|
||||||
PiHoleView *piHoleView
|
PiHoleView *piHoleView
|
||||||
SoftwareUpdate appupdate.State
|
SoftwareUpdate appupdate.State
|
||||||
|
InstanceID string
|
||||||
}
|
}
|
||||||
|
|
||||||
type flashMessage struct {
|
type flashMessage struct {
|
||||||
@@ -113,12 +118,16 @@ type schedulerResponse struct {
|
|||||||
RecentRuns *[]schedulerRunResponse `json:"recent_runs,omitempty"`
|
RecentRuns *[]schedulerRunResponse `json:"recent_runs,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg config.Config, database *store.Store, cipher *secure.Cipher, schedule *scheduler.Scheduler, updates *appupdate.Manager, logger *slog.Logger) (*Server, error) {
|
func New(cfg config.Config, database *store.Store, cipher *secure.Cipher, schedule *scheduler.Scheduler, updates *appupdate.Manager, logger *slog.Logger, restartContainer func()) (*Server, error) {
|
||||||
templates, err := loadTemplates()
|
templates, err := loadTemplates()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &Server{config: cfg, store: database, cipher: cipher, scheduler: schedule, updates: updates, logger: logger, templates: templates, limiter: newLoginLimiter(), dummyHash: secure.PasswordTimingHash, authSlots: make(chan struct{}, 2)}, nil
|
instanceID, err := secure.RandomToken(18)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create process instance ID: %w", err)
|
||||||
|
}
|
||||||
|
return &Server{config: cfg, store: database, cipher: cipher, scheduler: schedule, updates: updates, logger: logger, templates: templates, limiter: newLoginLimiter(), dummyHash: secure.PasswordTimingHash, authSlots: make(chan struct{}, 2), instanceID: instanceID, restartContainer: restartContainer}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Handler() http.Handler {
|
func (s *Server) Handler() http.Handler {
|
||||||
@@ -170,6 +179,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.Handle("POST /system/update-credential", s.adminOnly(s.softwareUpdateCredentialSave))
|
mux.Handle("POST /system/update-credential", s.adminOnly(s.softwareUpdateCredentialSave))
|
||||||
mux.Handle("POST /system/check", s.adminOnly(s.softwareUpdateCheck))
|
mux.Handle("POST /system/check", s.adminOnly(s.softwareUpdateCheck))
|
||||||
mux.Handle("POST /system/update", s.adminOnly(s.softwareUpdateStart))
|
mux.Handle("POST /system/update", s.adminOnly(s.softwareUpdateStart))
|
||||||
|
mux.Handle("POST /system/restart", s.adminOnly(s.containerRestart))
|
||||||
mux.Handle("GET /errors", s.adminOnly(s.errorsPage))
|
mux.Handle("GET /errors", s.adminOnly(s.errorsPage))
|
||||||
mux.Handle("POST /errors/clear", s.adminOnly(s.errorsClear))
|
mux.Handle("POST /errors/clear", s.adminOnly(s.errorsClear))
|
||||||
mux.Handle("GET /account", s.authenticated(s.accountPage))
|
mux.Handle("GET /account", s.authenticated(s.accountPage))
|
||||||
@@ -192,6 +202,10 @@ func (s *Server) Handler() http.Handler {
|
|||||||
|
|
||||||
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
|
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
if s.instanceID != "" {
|
||||||
|
w.Header().Set("X-Domain-Control-Instance", s.instanceID)
|
||||||
|
}
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write([]byte("ok\n"))
|
_, _ = w.Write([]byte("ok\n"))
|
||||||
}
|
}
|
||||||
@@ -1304,7 +1318,7 @@ func (s *Server) softwareUpdatePage(w http.ResponseWriter, r *http.Request, sess
|
|||||||
statusCtx, cancel := context.WithTimeout(r.Context(), 750*time.Millisecond)
|
statusCtx, cancel := context.WithTimeout(r.Context(), 750*time.Millisecond)
|
||||||
_, _ = s.updates.UpdateStatus(statusCtx)
|
_, _ = s.updates.UpdateStatus(statusCtx)
|
||||||
cancel()
|
cancel()
|
||||||
s.render(w, "system", viewData{Title: "Systemupdate", Page: "system", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Now: time.Now(), SoftwareUpdate: s.updates.State()})
|
s.render(w, "system", viewData{Title: "Systemupdate", Page: "system", CSRF: session.CSRFToken, Flash: notice(r), User: userFromSession(session), IsAdmin: true, Now: time.Now(), SoftwareUpdate: s.updates.State(), InstanceID: s.instanceID})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) softwareUpdateCredentialSave(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
func (s *Server) softwareUpdateCredentialSave(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
@@ -1418,6 +1432,63 @@ func (s *Server) softwareUpdateStatusAPI(w http.ResponseWriter, r *http.Request,
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) containerRestart(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
|
if err := s.parseForm(w, r); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.validSessionCSRF(r, session) {
|
||||||
|
http.Error(w, "Ungültige Anfrage", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.restartContainer == nil {
|
||||||
|
http.Error(w, "Container-Neustart ist nicht konfiguriert.", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.updates != nil {
|
||||||
|
statusCtx, cancel := context.WithTimeout(r.Context(), time.Second)
|
||||||
|
updateStatus, statusErr := s.updates.UpdateStatus(statusCtx)
|
||||||
|
cancel()
|
||||||
|
if statusErr == nil && updateStatus.Busy() {
|
||||||
|
http.Error(w, "Während eines Software-Updates kann der Container nicht zusätzlich neu gestartet werden.", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.restartMu.Lock()
|
||||||
|
if s.restartPending {
|
||||||
|
s.restartMu.Unlock()
|
||||||
|
s.writeRestartAccepted(w, r, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.AddAudit(r.Context(), "system.container_restart", "Container-Neustart angefordert durch "+session.Username); err != nil {
|
||||||
|
s.restartMu.Unlock()
|
||||||
|
s.serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.restartPending = true
|
||||||
|
restart := s.restartContainer
|
||||||
|
s.restartMu.Unlock()
|
||||||
|
|
||||||
|
s.writeRestartAccepted(w, r, false)
|
||||||
|
if flusher, ok := w.(http.Flusher); ok {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
restart()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) writeRestartAccepted(w http.ResponseWriter, r *http.Request, alreadyPending bool) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
if strings.Contains(r.Header.Get("Accept"), "application/json") {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"accepted": true, "already_pending": alreadyPending, "instance_id": s.instanceID})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
_, _ = w.Write([]byte("Container-Neustart angefordert. Diese Seite ist kurz nicht erreichbar; lade sie anschließend neu.\n"))
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) errorsPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
func (s *Server) errorsPage(w http.ResponseWriter, r *http.Request, session *model.Session) {
|
||||||
targets, err := s.store.ListTargetsWithErrors(r.Context())
|
targets, err := s.store.ListTargetsWithErrors(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -292,7 +293,7 @@ func TestDashboardRendersLiveUpdateHooks(t *testing.T) {
|
|||||||
`data-dashboard-ipv6`,
|
`data-dashboard-ipv6`,
|
||||||
`data-recent-runs aria-live="polite" aria-busy="true"`,
|
`data-recent-runs aria-live="polite" aria-busy="true"`,
|
||||||
`Aktualisierung läuft`,
|
`Aktualisierung läuft`,
|
||||||
`features-28`,
|
`features-29`,
|
||||||
`data-local-time="1788001920"`,
|
`data-local-time="1788001920"`,
|
||||||
`dashboard-zone-item`,
|
`dashboard-zone-item`,
|
||||||
`DNS-Zone · example.de`,
|
`DNS-Zone · example.de`,
|
||||||
@@ -413,6 +414,7 @@ func TestMembersCannotSeeOrUseAccessAdministration(t *testing.T) {
|
|||||||
{method: http.MethodGet, path: "/users"},
|
{method: http.MethodGet, path: "/users"},
|
||||||
{method: http.MethodGet, path: "/users/new"},
|
{method: http.MethodGet, path: "/users/new"},
|
||||||
{method: http.MethodGet, path: "/system"},
|
{method: http.MethodGet, path: "/system"},
|
||||||
|
{method: http.MethodPost, path: "/system/restart", body: "csrf=member-csrf"},
|
||||||
{method: http.MethodPost, path: "/users/" + strconv.FormatInt(admin.ID, 10) + "/access", body: "csrf=member-csrf"},
|
{method: http.MethodPost, path: "/users/" + strconv.FormatInt(admin.ID, 10) + "/access", body: "csrf=member-csrf"},
|
||||||
}
|
}
|
||||||
for _, test := range requests {
|
for _, test := range requests {
|
||||||
@@ -523,13 +525,23 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
|
|||||||
t.Errorf("global update notification is missing %q", expected)
|
t.Errorf("global update notification is missing %q", expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var staleNotification bytes.Buffer
|
||||||
|
staleData := accountData
|
||||||
|
staleData.SoftwareUpdate.AgentAvailable = false
|
||||||
|
staleData.SoftwareUpdate.AgentCompatible = false
|
||||||
|
if err := templates["security"].ExecuteTemplate(&staleNotification, "security", staleData); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(staleNotification.String(), `data-software-update-form`) {
|
||||||
|
t.Fatal("stale boot-time agent state hides the live-checked update button")
|
||||||
|
}
|
||||||
|
|
||||||
var page bytes.Buffer
|
var page bytes.Buffer
|
||||||
systemData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: updateState}
|
systemData := viewData{Title: "Systemupdate", Page: "system", CSRF: "csrf", User: admin, IsAdmin: true, SoftwareUpdate: updateState, InstanceID: "instance-before-restart"}
|
||||||
if err := templates["system"].ExecuteTemplate(&page, "system", systemData); err != nil {
|
if err := templates["system"].ExecuteTemplate(&page, "system", systemData); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, expected := range []string{"Software-Update", "Jetzt aktualisieren", "Image laden", "Dienst wechseln", "Healthcheck", "Mit Gitea verbunden", "@registry-user", "Zugang ändern", "Passwort/Token", `<details class="update-credential-editor">`, `data-software-update-title`, `data-software-update-progress-label`, `data-software-update-progress-track`, `data-software-update-percent`, `data-local-time="1788001920"`} {
|
for _, expected := range []string{"Software-Update", "Jetzt aktualisieren", "Image laden", "Dienst wechseln", "Healthcheck", "Mit Gitea verbunden", "@registry-user", "Zugang ändern", "Passwort/Token", `<details class="update-credential-editor">`, `data-software-update-title`, `data-software-update-progress-label`, `data-software-update-progress-track`, `data-software-update-percent`, `data-local-time="1788001920"`, `action="/system/restart"`, `data-container-restart-form`, `data-instance-id="instance-before-restart"`, `data-container-restart-overlay`, "Container neu starten"} {
|
||||||
if !strings.Contains(page.String(), expected) {
|
if !strings.Contains(page.String(), expected) {
|
||||||
t.Errorf("software update page is missing %q", expected)
|
t.Errorf("software update page is missing %q", expected)
|
||||||
}
|
}
|
||||||
@@ -565,6 +577,77 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestContainerRestartRequiresCSRFAndAuditsOneRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
var restartCalls atomic.Int32
|
||||||
|
server := &Server{store: database, instanceID: "instance-before-restart", restartContainer: func() { restartCalls.Add(1) }}
|
||||||
|
session := &model.Session{CSRFToken: "valid-csrf", Username: "admin", Role: "admin"}
|
||||||
|
|
||||||
|
invalidResponse := httptest.NewRecorder()
|
||||||
|
invalidRequest := httptest.NewRequest(http.MethodPost, "/system/restart", strings.NewReader("csrf=wrong"))
|
||||||
|
invalidRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
server.containerRestart(invalidResponse, invalidRequest, session)
|
||||||
|
if invalidResponse.Code != http.StatusForbidden || restartCalls.Load() != 0 {
|
||||||
|
t.Fatalf("invalid CSRF triggered restart: status=%d calls=%d", invalidResponse.Code, restartCalls.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
requestRestart := func() *httptest.ResponseRecorder {
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/system/restart", strings.NewReader("csrf=valid-csrf"))
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
server.containerRestart(response, request, session)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
first := requestRestart()
|
||||||
|
if first.Code != http.StatusAccepted || restartCalls.Load() != 1 {
|
||||||
|
t.Fatalf("valid restart was not accepted once: status=%d calls=%d body=%s", first.Code, restartCalls.Load(), first.Body.String())
|
||||||
|
}
|
||||||
|
var result map[string]any
|
||||||
|
if err := json.NewDecoder(first.Body).Decode(&result); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result["accepted"] != true || result["instance_id"] != "instance-before-restart" {
|
||||||
|
t.Fatalf("unexpected restart response: %#v", result)
|
||||||
|
}
|
||||||
|
second := requestRestart()
|
||||||
|
if second.Code != http.StatusAccepted || restartCalls.Load() != 1 {
|
||||||
|
t.Fatalf("duplicate restart was not idempotent: status=%d calls=%d", second.Code, restartCalls.Load())
|
||||||
|
}
|
||||||
|
events, err := database.RecentAudit(ctx, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(events) != 1 || events[0].Kind != "system.container_restart" || !strings.Contains(events[0].Message, "admin") {
|
||||||
|
t.Fatalf("restart audit is missing or duplicated: %#v", events)
|
||||||
|
}
|
||||||
|
|
||||||
|
unconfigured := &Server{store: database}
|
||||||
|
unconfiguredResponse := httptest.NewRecorder()
|
||||||
|
unconfiguredRequest := httptest.NewRequest(http.MethodPost, "/system/restart", strings.NewReader("csrf=valid-csrf"))
|
||||||
|
unconfiguredRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
unconfigured.containerRestart(unconfiguredResponse, unconfiguredRequest, session)
|
||||||
|
if unconfiguredResponse.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("restart without process callback returned %d", unconfiguredResponse.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthExposesProcessInstanceWithoutCaching(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
server := &Server{instanceID: "current-process-instance"}
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
server.health(response, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||||
|
if response.Code != http.StatusOK || response.Header().Get("X-Domain-Control-Instance") != "current-process-instance" || response.Header().Get("Cache-Control") != "no-store" {
|
||||||
|
t.Fatalf("health response does not identify the current process: status=%d headers=%v", response.Code, response.Header())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUserCreationUsesAlignedFieldsWithoutPasswordMinimum(t *testing.T) {
|
func TestUserCreationUsesAlignedFieldsWithoutPasswordMinimum(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
templates, err := loadTemplates()
|
templates, err := loadTemplates()
|
||||||
|
|||||||
@@ -478,6 +478,115 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const containerRestartForm = document.querySelector('[data-container-restart-form]');
|
||||||
|
if (containerRestartForm) {
|
||||||
|
const overlay = document.querySelector('[data-container-restart-overlay]');
|
||||||
|
const title = overlay?.querySelector('[data-container-restart-title]');
|
||||||
|
const message = overlay?.querySelector('[data-container-restart-message]');
|
||||||
|
const close = overlay?.querySelector('[data-container-restart-close]');
|
||||||
|
const submit = containerRestartForm.querySelector('button[type="submit"]');
|
||||||
|
let polling = false;
|
||||||
|
let startedAt = 0;
|
||||||
|
let expectedInstance = containerRestartForm.dataset.instanceId || '';
|
||||||
|
let consecutiveReady = 0;
|
||||||
|
let sawUnavailable = false;
|
||||||
|
|
||||||
|
const setRestartState = (state, heading, text) => {
|
||||||
|
if (!overlay) return;
|
||||||
|
overlay.dataset.updateState = state;
|
||||||
|
overlay.setAttribute('aria-busy', String(state !== 'complete' && state !== 'failed'));
|
||||||
|
if (title) title.textContent = heading;
|
||||||
|
if (message) message.textContent = text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pollContainerRestart = async () => {
|
||||||
|
if (!polling) return;
|
||||||
|
if (Date.now() - startedAt > 90 * 1000) {
|
||||||
|
polling = false;
|
||||||
|
setRestartState('failed', 'Neustart nicht bestätigt', 'Die neue Container-Instanz wurde nicht rechtzeitig erreichbar. Prüfe den Containerstatus am Docker-Host.');
|
||||||
|
if (close) close.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/healthz?restart-check=${Date.now()}`, {cache: 'no-store', credentials: 'same-origin'});
|
||||||
|
if (!response.ok) throw new Error('healthcheck unavailable');
|
||||||
|
const currentInstance = response.headers.get('X-Domain-Control-Instance') || '';
|
||||||
|
const isNewInstance = expectedInstance
|
||||||
|
? (currentInstance !== '' && currentInstance !== expectedInstance) || (currentInstance === '' && sawUnavailable)
|
||||||
|
: sawUnavailable;
|
||||||
|
if (isNewInstance) {
|
||||||
|
consecutiveReady += 1;
|
||||||
|
setRestartState('restarting', 'Neue Instanz wird geprüft', 'Der Container ist wieder erreichbar. Die Verbindung wird noch einmal bestätigt.');
|
||||||
|
if (consecutiveReady >= 2) {
|
||||||
|
polling = false;
|
||||||
|
setRestartState('complete', 'Container neu gestartet', 'Die neue Container-Instanz ist bereit. Die Seite wird neu geladen.');
|
||||||
|
window.setTimeout(() => window.location.assign('/system'), 900);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
consecutiveReady = 0;
|
||||||
|
setRestartState('restarting', 'Container wird neu gestartet', 'Die bisherige Instanz wird geordnet beendet.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
sawUnavailable = true;
|
||||||
|
consecutiveReady = 0;
|
||||||
|
setRestartState('restarting', 'Verbindung wird aufgebaut', 'Der Container ist kurz nicht erreichbar und startet automatisch neu.');
|
||||||
|
}
|
||||||
|
window.setTimeout(pollContainerRestart, 700);
|
||||||
|
};
|
||||||
|
|
||||||
|
containerRestartForm.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (polling || !overlay) return;
|
||||||
|
if (!window.confirm('Den vollständigen DynDNS-Container jetzt neu starten? Der Dienst ist kurz nicht erreichbar.')) return;
|
||||||
|
|
||||||
|
const formData = new FormData(containerRestartForm);
|
||||||
|
const requestBody = new URLSearchParams();
|
||||||
|
for (const [name, value] of formData) {
|
||||||
|
if (typeof value === 'string') requestBody.append(name, value);
|
||||||
|
}
|
||||||
|
polling = true;
|
||||||
|
startedAt = Date.now();
|
||||||
|
consecutiveReady = 0;
|
||||||
|
sawUnavailable = false;
|
||||||
|
overlay.hidden = false;
|
||||||
|
if (close) close.hidden = true;
|
||||||
|
if (submit) submit.disabled = true;
|
||||||
|
document.body.classList.add('software-update-open');
|
||||||
|
setRestartState('restarting', 'Neustart wird vorbereitet', 'Die Anwendung wird geordnet beendet und automatisch wieder gestartet.');
|
||||||
|
|
||||||
|
let responseReceived = false;
|
||||||
|
try {
|
||||||
|
const response = await fetch(containerRestartForm.action, {method: 'POST', body: requestBody, headers: {'Accept': 'application/json'}, credentials: 'same-origin'});
|
||||||
|
responseReceived = true;
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
const result = contentType.includes('application/json')
|
||||||
|
? await response.json().catch(() => ({}))
|
||||||
|
: {message: (await response.text()).trim().slice(0, 240)};
|
||||||
|
if (!response.ok || !result.accepted) throw new Error(result.message || 'Container-Neustart konnte nicht gestartet werden.');
|
||||||
|
expectedInstance = result.instance_id || expectedInstance;
|
||||||
|
} catch (error) {
|
||||||
|
if (responseReceived) {
|
||||||
|
polling = false;
|
||||||
|
setRestartState('failed', 'Neustart nicht gestartet', error instanceof Error ? error.message : 'Container-Neustart konnte nicht gestartet werden.');
|
||||||
|
if (close) close.hidden = false;
|
||||||
|
if (submit) submit.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sawUnavailable = true;
|
||||||
|
}
|
||||||
|
window.setTimeout(pollContainerRestart, 350);
|
||||||
|
});
|
||||||
|
|
||||||
|
close?.addEventListener('click', () => {
|
||||||
|
polling = false;
|
||||||
|
overlay.hidden = true;
|
||||||
|
close.hidden = true;
|
||||||
|
document.body.classList.remove('software-update-open');
|
||||||
|
if (submit) submit.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const countdowns = [...document.querySelectorAll('[data-countdown]')];
|
const countdowns = [...document.querySelectorAll('[data-countdown]')];
|
||||||
const currentIPv4Fields = [...document.querySelectorAll('[data-current-ipv4-placeholder]')];
|
const currentIPv4Fields = [...document.querySelectorAll('[data-current-ipv4-placeholder]')];
|
||||||
const dashboardIPv4 = document.querySelector('[data-dashboard-ipv4]');
|
const dashboardIPv4 = document.querySelector('[data-dashboard-ipv4]');
|
||||||
|
|||||||
@@ -513,6 +513,17 @@ input:focus, textarea:focus, select:focus { border-color: var(--accent); box-sha
|
|||||||
.software-update-button { min-height: 44px; border-radius: 9px; box-shadow: 0 10px 22px rgba(37,99,235,.16); }
|
.software-update-button { min-height: 44px; border-radius: 9px; box-shadow: 0 10px 22px rgba(37,99,235,.16); }
|
||||||
.software-update-button i { font-style: normal; transition: transform .2s ease; }
|
.software-update-button i { font-style: normal; transition: transform .2s ease; }
|
||||||
.software-update-button:hover i { transform: translate(2px,-2px); }
|
.software-update-button:hover i { transform: translate(2px,-2px); }
|
||||||
|
.container-restart-panel { display: grid; width: min(930px,100%); grid-template-columns: 44px minmax(0,1fr) auto; gap: 14px; align-items: center; margin-bottom: 12px; }
|
||||||
|
.container-restart-icon { display: grid; width: 42px; height: 42px; place-items: center; border: 1px solid #c8d3df; border-radius: 12px; background: linear-gradient(145deg,#f7f9fb,#edf1f5); color: #526174; font-family: var(--mono); font-size: 18px; font-weight: 900; }
|
||||||
|
.container-restart-copy h2 { margin-bottom: 3px; }
|
||||||
|
.container-restart-copy p { max-width: 650px; margin: 0; color: var(--muted); font-size: 9px; line-height: 1.55; }
|
||||||
|
.container-restart-panel form { position: relative; z-index: 1; }
|
||||||
|
.container-restart-overlay .software-update-dialog { width: min(500px,100%); }
|
||||||
|
.container-restart-progress { height: 7px; overflow: hidden; margin-top: 18px; border: 1px solid rgba(147,197,253,.08); border-radius: 99px; background: #202f44; box-shadow: inset 0 1px 2px rgba(0,0,0,.2); }
|
||||||
|
.container-restart-progress i { display: block; width: 38%; height: 100%; border-radius: inherit; background: linear-gradient(90deg,#2563eb,#60a5fa,#2dd4bf); box-shadow: 0 0 17px rgba(96,165,250,.52); animation: container-restart-progress 1.35s ease-in-out infinite; }
|
||||||
|
.container-restart-overlay[data-update-state="complete"] .container-restart-progress i { width: 100%; background: #34d399; animation: none; }
|
||||||
|
.container-restart-overlay[data-update-state="failed"] .container-restart-progress i { width: 100%; background: #e45c70; animation: none; }
|
||||||
|
.container-restart-overlay [data-container-restart-close] { margin: 20px auto 0; border-color: #40516a; background: #1a2940; color: #dbe7f6; }
|
||||||
.update-credential-panel { width: min(930px,100%); }
|
.update-credential-panel { width: min(930px,100%); }
|
||||||
.update-credential-panel.is-connected { overflow: hidden; padding: 0; border-color: #b8dcd5; background: linear-gradient(120deg,#fff 62%,#f0faf7); }
|
.update-credential-panel.is-connected { overflow: hidden; padding: 0; border-color: #b8dcd5; background: linear-gradient(120deg,#fff 62%,#f0faf7); }
|
||||||
.update-access-state { display: grid; grid-template-columns: 44px minmax(0,1fr) auto; gap: 14px; align-items: center; padding: 18px; }
|
.update-access-state { display: grid; grid-template-columns: 44px minmax(0,1fr) auto; gap: 14px; align-items: center; padding: 18px; }
|
||||||
@@ -613,6 +624,7 @@ body.software-update-open { overflow: hidden; }
|
|||||||
@keyframes update-progress-shine { 0%,25% { transform: translateX(-110%); } 75%,100% { transform: translateX(110%); } }
|
@keyframes update-progress-shine { 0%,25% { transform: translateX(-110%); } 75%,100% { transform: translateX(110%); } }
|
||||||
@keyframes update-phase-pulse { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-2px); } }
|
@keyframes update-phase-pulse { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-2px); } }
|
||||||
@keyframes update-result-pop { 0% { transform: scale(.82) rotate(-5deg); } 70% { transform: scale(1.08) rotate(2deg); } 100% { transform: scale(1) rotate(0); } }
|
@keyframes update-result-pop { 0% { transform: scale(.82) rotate(-5deg); } 70% { transform: scale(1.08) rotate(2deg); } 100% { transform: scale(1) rotate(0); } }
|
||||||
|
@keyframes container-restart-progress { from { transform: translateX(-115%); } to { transform: translateX(305%); } }
|
||||||
|
|
||||||
.error-summary-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 10px; margin-bottom: 12px; }
|
.error-summary-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 10px; margin-bottom: 12px; }
|
||||||
.error-summary-grid article { display: flex; align-items: center; justify-content: space-between; padding: 13px 15px; border: 1px solid #efc7ce; border-radius: var(--radius-sm); background: #fff8f9; }
|
.error-summary-grid article { display: flex; align-items: center; justify-content: space-between; padding: 13px 15px; border: 1px solid #efc7ce; border-radius: var(--radius-sm); background: #fff8f9; }
|
||||||
@@ -709,6 +721,9 @@ body.software-update-open { overflow: hidden; }
|
|||||||
.software-version-visual { width: 46px; height: 46px; border-radius: 13px; }
|
.software-version-visual { width: 46px; height: 46px; border-radius: 13px; }
|
||||||
.software-version-action { grid-column: 1 / -1; }
|
.software-version-action { grid-column: 1 / -1; }
|
||||||
.software-version-action form, .software-version-action .button { width: 100%; }
|
.software-version-action form, .software-version-action .button { width: 100%; }
|
||||||
|
.container-restart-panel { grid-template-columns: 42px minmax(0,1fr); }
|
||||||
|
.container-restart-panel form { grid-column: 1 / -1; }
|
||||||
|
.container-restart-panel form, .container-restart-panel .button { width: 100%; }
|
||||||
.update-access-state { grid-template-columns: 42px minmax(0,1fr); }
|
.update-access-state { grid-template-columns: 42px minmax(0,1fr); }
|
||||||
.update-access-state > .status-pill { grid-column: 2; justify-self: start; }
|
.update-access-state > .status-pill { grid-column: 2; justify-self: start; }
|
||||||
.admin-full-access-card { grid-template-columns: 42px minmax(0,1fr); }
|
.admin-full-access-card { grid-template-columns: 42px minmax(0,1fr); }
|
||||||
@@ -755,6 +770,7 @@ body.software-update-open { overflow: hidden; }
|
|||||||
.software-update-dialog p { min-height: 30px; margin-bottom: 12px; }
|
.software-update-dialog p { min-height: 30px; margin-bottom: 12px; }
|
||||||
.software-update-phases { margin-top: 14px; }
|
.software-update-phases { margin-top: 14px; }
|
||||||
.software-update-dialog [data-software-update-close] { margin-top: 14px; }
|
.software-update-dialog [data-software-update-close] { margin-top: 14px; }
|
||||||
|
.container-restart-overlay [data-container-restart-close] { margin-top: 14px; }
|
||||||
.onboarding-intro { padding-block: 30px; }
|
.onboarding-intro { padding-block: 30px; }
|
||||||
.onboarding-copy { margin-top: 28px; }
|
.onboarding-copy { margin-top: 28px; }
|
||||||
.onboarding-copy h1 { margin-bottom: 15px; font-size: 44px; }
|
.onboarding-copy h1 { margin-bottom: 15px; font-size: 44px; }
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="color-scheme" content="light">
|
<meta name="color-scheme" content="light">
|
||||||
<title>{{.Title}} · DNS Control</title>
|
<title>{{.Title}} · DNS Control</title>
|
||||||
<link rel="stylesheet" href="/static/styles.css?v=features-28">
|
<link rel="stylesheet" href="/static/styles.css?v=features-29">
|
||||||
<script src="/static/app.js?v=features-28" defer></script>
|
<script src="/static/app.js?v=features-29" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="page-{{.Page}}">
|
<body class="page-{{.Page}}">
|
||||||
<a class="skip-link" href="#main">Zum Inhalt springen</a>
|
<a class="skip-link" href="#main">Zum Inhalt springen</a>
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
<div><span>Neue Version verfügbar</span><strong>{{.SoftwareUpdate.LatestVersion}} ist bereit</strong></div>
|
<div><span>Neue Version verfügbar</span><strong>{{.SoftwareUpdate.LatestVersion}} ist bereit</strong></div>
|
||||||
<div class="software-update-banner-actions">
|
<div class="software-update-banner-actions">
|
||||||
<a class="button button-quiet" href="/system">Details</a>
|
<a class="button button-quiet" href="/system">Details</a>
|
||||||
{{if .SoftwareUpdate.AgentCompatible}}<form action="/system/update" method="post" data-software-update-form><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="version" value="{{.SoftwareUpdate.LatestVersion}}"><button class="button button-primary" type="submit">Jetzt aktualisieren</button></form>{{end}}
|
{{if not .SoftwareUpdate.ComposeSyncRequired}}<form action="/system/update" method="post" data-software-update-form><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="version" value="{{.SoftwareUpdate.LatestVersion}}"><button class="button button-primary" type="submit">Jetzt aktualisieren</button></form>{{end}}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
{{if .SoftwareUpdate.ComposeSyncRequired}}<div class="update-inline-warning"><span>↻</span><p><strong>Compose einmal aktualisieren</strong><small>Der installierte Agent unterstützt die sichere Update-Basis noch nicht.</small></p></div>{{end}}
|
{{if .SoftwareUpdate.ComposeSyncRequired}}<div class="update-inline-warning"><span>↻</span><p><strong>Compose einmal aktualisieren</strong><small>Der installierte Agent unterstützt die sichere Update-Basis noch nicht.</small></p></div>{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="software-version-action">
|
<div class="software-version-action">
|
||||||
{{if and .SoftwareUpdate.UpdateAvailable .SoftwareUpdate.AgentCompatible}}
|
{{if and .SoftwareUpdate.UpdateAvailable (not .SoftwareUpdate.ComposeSyncRequired)}}
|
||||||
<form action="/system/update" method="post" data-software-update-form>
|
<form action="/system/update" method="post" data-software-update-form>
|
||||||
<input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="version" value="{{.SoftwareUpdate.LatestVersion}}">
|
<input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="version" value="{{.SoftwareUpdate.LatestVersion}}">
|
||||||
<button class="button button-primary software-update-button" type="submit"><span>Jetzt aktualisieren</span><i aria-hidden="true">↗</i></button>
|
<button class="button button-primary software-update-button" type="submit"><span>Jetzt aktualisieren</span><i aria-hidden="true">↗</i></button>
|
||||||
@@ -33,6 +33,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel container-restart-panel">
|
||||||
|
<span class="container-restart-icon" aria-hidden="true">↻</span>
|
||||||
|
<div class="container-restart-copy"><span class="section-label">Containersteuerung</span><h2>Anwendungscontainer neu starten</h2><p>Beendet den vollständigen DynDNS-Container geordnet und startet ihn mit unveränderten Daten, Secrets und Einstellungen neu. Der Dienst ist dabei kurz nicht erreichbar.</p></div>
|
||||||
|
<form action="/system/restart" method="post" data-container-restart-form data-instance-id="{{.InstanceID}}">
|
||||||
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
|
<button class="button button-quiet" type="submit">Container neu starten</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel update-credential-panel {{if .SoftwareUpdate.CredentialConfigured}}is-connected{{end}}" id="update-access">
|
<section class="panel update-credential-panel {{if .SoftwareUpdate.CredentialConfigured}}is-connected{{end}}" id="update-access">
|
||||||
{{if .SoftwareUpdate.CredentialConfigured}}
|
{{if .SoftwareUpdate.CredentialConfigured}}
|
||||||
<div class="update-access-state">
|
<div class="update-access-state">
|
||||||
@@ -54,5 +63,19 @@
|
|||||||
{{if .SoftwareUpdate.CredentialConfigured}}</details>{{end}}
|
{{if .SoftwareUpdate.CredentialConfigured}}</details>{{end}}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div class="software-update-overlay container-restart-overlay" data-container-restart-overlay data-update-state="restarting" hidden role="dialog" aria-modal="true" aria-labelledby="container-restart-title">
|
||||||
|
<div class="software-update-dialog">
|
||||||
|
<div class="software-update-visual" aria-hidden="true">
|
||||||
|
<span class="software-update-scan"></span>
|
||||||
|
<div class="software-update-orbit"><span>DC</span><i></i></div>
|
||||||
|
</div>
|
||||||
|
<span class="section-label">Container-Neustart</span>
|
||||||
|
<h2 id="container-restart-title" data-container-restart-title>Neustart wird vorbereitet</h2>
|
||||||
|
<p data-container-restart-message aria-live="polite">Die Anwendung wird geordnet beendet und automatisch wieder gestartet.</p>
|
||||||
|
<div class="container-restart-progress" aria-hidden="true"><i></i></div>
|
||||||
|
<button class="button button-quiet" type="button" data-container-restart-close hidden>Schließen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{template "software_update_overlay" .}}
|
{{template "software_update_overlay" .}}
|
||||||
{{end}}
|
{{end}}
|
||||||
Reference in new issue
Block a user