package updateagent import ( "context" "encoding/base64" "encoding/json" "errors" "io" "log/slog" "net/http" "strconv" "strings" "sync/atomic" "testing" "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) { return f(request) } func TestValidateUpdateCompatibilityAllowsDirectMajorJump(t *testing.T) { t.Parallel() current := compatibleImage("v1.4.0", "v1.4.0") target := compatibleImage("v2.0.0", "v1.4.0") if err := validateUpdateCompatibility("v1.4.0", "v2.0.0", current, target); err != nil { t.Fatalf("compatible direct update was rejected: %v", err) } } 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) { t.Parallel() config := json.RawMessage(`{"Image":"registry.example/app:v1.4.0","Labels":{"org.opencontainers.image.version":"v1.4.0"}}`) version, err := resolveCurrentVersion("", config, "registry.example/app") if err != nil || version != "v1.4.0" { t.Fatalf("exact image version was not resolved: version=%q err=%v", version, err) } if _, err := resolveCurrentVersion("v1.5.0", config, "registry.example/app"); err == nil { t.Fatal("untrusted reported version overrode the running image metadata") } mismatch := json.RawMessage(`{"Image":"registry.example/app:v1.4.0","Labels":{"org.opencontainers.image.version":"v1.3.0"}}`) if _, err := resolveCurrentVersion("", mismatch, "registry.example/app"); err == nil { t.Fatal("mismatching image reference and label were accepted") } } func TestValidateUpdateCompatibilityRejectsUnsupportedJump(t *testing.T) { t.Parallel() tests := []struct { name string mutate func(map[string]any) }{ {name: "minimum version", mutate: func(config map[string]any) { config["Labels"].(map[string]string)[labelMinimumVersion] = "v1.5.0" }}, {name: "runtime contract", mutate: func(config map[string]any) { config["Labels"].(map[string]string)[labelRuntimeContract] = strconv.Itoa(buildinfo.RuntimeContractVersion + 1) }}, {name: "database contract", mutate: func(config map[string]any) { config["Labels"].(map[string]string)[labelDatabaseContract] = "2" }}, {name: "agent protocol", mutate: func(config map[string]any) { config["Labels"].(map[string]string)[labelMinimumAgent] = strconv.Itoa(buildinfo.UpdateProtocolVersion + 1) }}, {name: "wrong image version", mutate: func(config map[string]any) { config["Labels"].(map[string]string)[labelImageVersion] = "v1.9.9" }}, {name: "missing healthcheck", mutate: func(config map[string]any) { delete(config, "Healthcheck") }}, } for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { t.Parallel() image := compatibleImage("v2.0.0", "v1.4.0") var config map[string]any if err := json.Unmarshal(image.Config, &config); err != nil { t.Fatal(err) } labels := make(map[string]string) for key, value := range config["Labels"].(map[string]any) { labels[key] = value.(string) } config["Labels"] = labels test.mutate(config) image.Config, _ = json.Marshal(config) if err := validateUpdateCompatibility("v1.4.0", "v2.0.0", compatibleImage("v1.4.0", "v1.4.0"), image); err == nil { t.Fatal("incompatible image was accepted") } }) } } func TestValidateUpdateCompatibilityRejectsSchemaRegressionAfterEarlierSkip(t *testing.T) { t.Parallel() current := compatibleImage("v2.0.0", "v1.4.0") target := compatibleImage("v2.1.0", "v1.4.0") current.Config = withImageLabel(t, current.Config, labelSchemaVersion, "20") target.Config = withImageLabel(t, target.Config, labelSchemaVersion, "19") if err := validateUpdateCompatibility("v2.0.0", "v2.1.0", current, target); err == nil { t.Fatal("target with an older schema contract was accepted") } } func TestBuildReplacementConfigUsesNewImageDefaultsAndKeepsDeploymentOverrides(t *testing.T) { t.Parallel() oldImage := json.RawMessage(`{ "User":"dyndns:dyndns", "Entrypoint":["/old-entrypoint"], "WorkingDir":"/data", "Env":["PATH=/old","OLD_DEFAULT=1"], "Labels":{"org.opencontainers.image.version":"v1.3.0","image-owned":"old"}, "ExposedPorts":{"8080/tcp":{}}, "Volumes":{"/data":{}} }`) targetImage := json.RawMessage(`{ "User":"dyndns:dyndns", "Entrypoint":["/new-entrypoint"], "WorkingDir":"/data", "Env":["PATH=/new","NEW_DEFAULT=1"], "Labels":{"org.opencontainers.image.version":"v2.0.0","image-owned":"new"}, "ExposedPorts":{"8080/tcp":{}}, "Volumes":{"/data":{}} }`) container := containerInspect{ ID: "0123456789abcdef", Config: json.RawMessage(`{ "Hostname":"0123456789ab", "User":"dyndns:dyndns", "Entrypoint":["/old-entrypoint"], "WorkingDir":"/data", "Env":["PATH=/old","OLD_DEFAULT=1","CUSTOM=yes"], "Labels":{"org.opencontainers.image.version":"v0.0.0","image-owned":"old","com.docker.compose.project":"domain-control","de.j-mahler.domain-control.role":"app"}, "ExposedPorts":{"8080/tcp":{},"9091/tcp":{}}, "Volumes":{"/data":{},"/custom":{}} }`), } config, err := buildReplacementConfig(container, oldImage, targetImage, "registry.example/app:v2.0.0") if err != nil { t.Fatal(err) } if _, exists := config["Hostname"]; exists { t.Fatal("generated container hostname was copied") } var image, user string var entrypoint, environment []string var labels map[string]string var ports, volumes map[string]any _ = json.Unmarshal(config["Image"], &image) _ = json.Unmarshal(config["User"], &user) _ = json.Unmarshal(config["Entrypoint"], &entrypoint) _ = json.Unmarshal(config["Env"], &environment) _ = json.Unmarshal(config["Labels"], &labels) _ = json.Unmarshal(config["ExposedPorts"], &ports) _ = json.Unmarshal(config["Volumes"], &volumes) if image != "registry.example/app:v2.0.0" || user != runtimeUser || len(entrypoint) != 1 || entrypoint[0] != "/new-entrypoint" { t.Fatalf("new image defaults were not retained: image=%q user=%q entrypoint=%v", image, user, entrypoint) } if !contains(environment, "PATH=/new") || !contains(environment, "NEW_DEFAULT=1") || !contains(environment, "CUSTOM=yes") || contains(environment, "OLD_DEFAULT=1") { t.Fatalf("environment was merged incorrectly: %v", environment) } if labels[labelImageVersion] != "v2.0.0" || labels["image-owned"] != "new" || labels["com.docker.compose.project"] != "domain-control" || labels["de.j-mahler.domain-control.role"] != "app" { t.Fatalf("labels were merged incorrectly: %#v", labels) } if _, exists := ports["9091/tcp"]; !exists { t.Fatalf("custom exposed port was lost: %#v", ports) } if _, exists := volumes["/custom"]; !exists { t.Fatalf("custom volume was lost: %#v", volumes) } } func compatibleImage(version, minimum string) imageInspect { config := map[string]any{ "User": runtimeUser, "Entrypoint": []string{runtimeEntrypoint}, "Cmd": []string{runtimeCommand}, "WorkingDir": runtimeWorkingDirectory, "ExposedPorts": map[string]any{ "8080/tcp": map[string]any{}, }, "Healthcheck": map[string]any{ "Test": []string{"CMD", runtimeEntrypoint, "healthcheck"}, }, "Labels": map[string]string{ labelImageVersion: version, labelMinimumVersion: minimum, labelMinimumAgent: strconv.Itoa(buildinfo.MinimumAgentProtocol), labelRuntimeContract: strconv.Itoa(buildinfo.RuntimeContractVersion), labelDatabaseContract: strconv.Itoa(buildinfo.DatabaseContractVersion), labelSchemaVersion: strconv.Itoa(buildinfo.CurrentSchemaVersion), }, } encoded, _ := json.Marshal(config) return imageInspect{ID: "sha256:target", Config: encoded} } func withImageLabel(t *testing.T, raw json.RawMessage, key, value string) json.RawMessage { t.Helper() var config map[string]any if err := json.Unmarshal(raw, &config); err != nil { t.Fatal(err) } labels, ok := config["Labels"].(map[string]any) if !ok { t.Fatal("image labels are missing") } labels[key] = value encoded, err := json.Marshal(config) if err != nil { t.Fatal(err) } return encoded } func contains(values []string, expected string) bool { for _, value := range values { if value == expected { return true } } 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"} } 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) } }) } }