feat: add resilient container restart controls

This commit is contained in:
admin committed 2026-09-07 13:27:27 +02:00
1 parent 1bbb14470c
commit 7bb33e528c
13 files changed
+747 -28

No files matched your search

+86 -3
View File
@@ -14,6 +14,7 @@ import (
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
@@ -292,7 +293,7 @@ func TestDashboardRendersLiveUpdateHooks(t *testing.T) {
`data-dashboard-ipv6`,
`data-recent-runs aria-live="polite" aria-busy="true"`,
`Aktualisierung läuft`,
`features-28`,
`features-29`,
`data-local-time="1788001920"`,
`dashboard-zone-item`,
`DNS-Zone · example.de`,
@@ -413,6 +414,7 @@ func TestMembersCannotSeeOrUseAccessAdministration(t *testing.T) {
{method: http.MethodGet, path: "/users"},
{method: http.MethodGet, path: "/users/new"},
{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"},
}
for _, test := range requests {
@@ -523,13 +525,23 @@ func TestSoftwareUpdateRendersAdminNotificationAndProgressUI(t *testing.T) {
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
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 {
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) {
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) {
t.Parallel()
templates, err := loadTemplates()