394 lines
15 KiB
Go
394 lines
15 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|