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

+133
View File
@@ -2,9 +2,11 @@ package updateagent
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
@@ -13,8 +15,85 @@ import (
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
)
func TestLostStopResponseRestartsPreviousContainer(t *testing.T) {
current := compatibleImage("v1.4.0", "v1.4.0")
current.ID = "sha256:current"
target := compatibleImage("v1.5.0", "v1.4.0")
var config map[string]json.RawMessage
if err := json.Unmarshal(current.Config, &config); err != nil {
t.Fatal(err)
}
config["Image"] = json.RawMessage(`"registry.example/app:v1.4.0"`)
containerConfig, _ := json.Marshal(config)
stopped, restarted := false, false
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
var body any
status := http.StatusOK
switch strings.TrimPrefix(r.URL.Path, "/v1.0") {
case "/version":
body = map[string]string{"ApiVersion": "1.0"}
case "/containers/json":
body = []containerSummary{{ID: "old", State: "running"}}
case "/containers/old/json":
body = map[string]any{"Id": "old", "Name": "/app", "Image": current.ID, "Config": json.RawMessage(containerConfig), "HostConfig": map[string]any{}, "State": map[string]any{"Running": !stopped, "Health": map[string]string{"Status": "healthy"}}}
case "/images/sha256:current/json":
body = current
case "/images/registry.example/app:v1.5.0/json":
body = target
case "/images/create":
body = map[string]string{"status": "downloaded"}
case "/containers/old/stop":
stopped = true
return nil, io.ErrUnexpectedEOF
case "/containers/old/start":
stopped = false
restarted = true
status = http.StatusNoContent
default:
t.Errorf("unexpected Docker call: %s %s", r.Method, r.URL.Path)
status = http.StatusInternalServerError
}
encoded, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(encoded)))}, nil
})
runner := &DockerRunner{docker: &dockerClient{client: &http.Client{Transport: transport}}, imageRepository: "registry.example/app", logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
err := runner.Update(context.Background(), updateprotocol.Request{Version: "v1.5.0", CurrentVersion: "v1.4.0"}, func(string, string) {})
if !errors.Is(err, io.ErrUnexpectedEOF) || !restarted || stopped {
t.Fatalf("lost stop response left service down: restarted=%v stopped=%v err=%v", restarted, stopped, err)
}
}
func TestHealthDeadlineCancelsStalledDockerRequest(t *testing.T) {
for _, replacement := range []bool{false, true} {
t.Run(strconv.FormatBool(replacement), func(t *testing.T) {
var requests atomic.Int32
client := &dockerClient{client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
requests.Add(1)
<-r.Context().Done()
return nil, r.Context().Err()
})}}
// Parent outlives the health deadline; the request must use the latter.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var err error
if replacement {
err = client.waitHealthy(ctx, "id", "", "", 1200*time.Millisecond)
} else {
_, err = client.waitCurrentHealthy(ctx, "id", "", 30*time.Millisecond, time.Millisecond)
}
if !errors.Is(err, context.DeadlineExceeded) || ctx.Err() != nil || requests.Load() != 1 {
t.Fatalf("health deadline did not cancel the Docker request: %v requests=%d", err, requests.Load())
}
})
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
@@ -258,3 +337,57 @@ func dockerTestClient(health func() string) *dockerClient {
})
return &dockerClient{client: &http.Client{Transport: transport}, apiPrefix: "/v1.0"}
}
func TestImagePullUsesAnonymousThenOptionalCredentials(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name, first string
status int
credentials bool
wantCalls int
wantErr bool
}{
{"public", `{"status":"download complete"}`, 200, false, 1, false},
{"public with stale credential", `{"status":"download complete"}`, 200, true, 1, false},
{"private stream error", `{"error":"unauthorized: authentication required"}`, 200, true, 2, false},
{"private status", `{"message":"denied"}`, 401, true, 2, false},
{"private errorDetail", `{"errorDetail":{"code":403,"message":"denied"}}`, 200, true, 2, false},
{"private no access", `{"error":"unauthorized"}`, 200, false, 1, true},
{"network error", `{"error":"connection timed out"}`, 200, true, 1, true},
} {
t.Run(test.name, func(t *testing.T) {
calls := 0
client := &dockerClient{client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
calls++
status, body := test.status, test.first
if calls == 1 {
if r.Header.Get("X-Registry-Auth") != "" {
t.Error("first pull sent credentials")
}
} else {
raw, err := base64.URLEncoding.DecodeString(r.Header.Get("X-Registry-Auth"))
if err != nil {
t.Fatal(err)
}
var auth map[string]string
if err = json.Unmarshal(raw, &auth); err != nil {
t.Fatal(err)
}
if auth["username"] != "user" || auth["password"] != "test-password" {
t.Error("missing private pull credentials")
}
status, body = 200, `{"status":"download complete"}`
}
return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}, nil
})}}
username, password := "", ""
if test.credentials {
username, password = "user", "test-password"
}
err := client.pullImage(context.Background(), "registry.example/app", "v2.0.0", username, password)
if (err != nil) != test.wantErr || calls != test.wantCalls {
t.Fatalf("pull calls=%d err=%v", calls, err)
}
})
}
}