feat: establish secure update foundation
Container-Image veröffentlichen / container (push) v1.4.0 wurde manuell veröffentlicht und verifiziert

This commit is contained in:
admin committed 2026-08-29 11:30:43 +02:00
1 parent f02ec9977c
commit bb60c06c0e
28 files changed
+1403 -280

No files matched your search

+205
View File
@@ -0,0 +1,205 @@
package updateagent
import (
"encoding/json"
"strconv"
"testing"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
)
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 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
}