From f23461635ac12f57d622cc368ad4e1986a0aff0b Mon Sep 17 00:00:00 2001 From: Gary Date: Fri, 9 Oct 2026 08:56:06 +0200 Subject: [PATCH 01/33] fix(packages): read apt in English so a Docker that is behind is seen on a translated host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apt-cache policy prints "Installé :" and "Candidat :" on a French host, and the Docker check looked for "Installed:" and "Candidate:". It found no candidate and said no newer Docker was offered, though apt offered one. Package tools now run with LC_ALL=C. --- service/system_package.go | 14 +++++++++++--- service/system_package_test.go | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/service/system_package.go b/service/system_package.go index ab3e2118..846b265b 100644 --- a/service/system_package.go +++ b/service/system_package.go @@ -98,11 +98,19 @@ type systemPackageUpdater struct { dpkgLocked func() bool } +// untranslatedCommand runs a package tool with its output in English. apt translates its +// text ("Installé :" and "Candidat :" where `apt-cache policy` says "Installed:" and +// "Candidate:"), and this code reads that text: on a host that is not in English it would +// find nothing, and say so as if nothing were there. +func untranslatedCommand(ctx context.Context, name string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Env = append(os.Environ(), "LC_ALL=C") + return cmd.CombinedOutput() +} + func newSystemPackageUpdater() *systemPackageUpdater { return &systemPackageUpdater{ - command: func(ctx context.Context, name string, args ...string) ([]byte, error) { - return exec.CommandContext(ctx, name, args...).CombinedOutput() - }, + command: untranslatedCommand, start: func(path string, _ string, args ...string) ([]byte, error) { return exec.Command(path, args...).CombinedOutput() }, diff --git a/service/system_package_test.go b/service/system_package_test.go index f131f3d9..0ea199ac 100644 --- a/service/system_package_test.go +++ b/service/system_package_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -12,6 +13,21 @@ import ( "github.com/ReCasaOS/CasaOS/pkg/config" ) +func TestPackageToolsAreAskedForEnglishOutput(t *testing.T) { + sh, err := exec.LookPath("sh") + if err != nil { + t.Skip("no sh to ask for the environment") + } + + // a host in French: apt-cache policy would print "Installé :" and "Candidat :" + t.Setenv("LC_ALL", "fr_FR.UTF-8") + t.Setenv("LANG", "fr_FR.UTF-8") + got, err := newSystemPackageUpdater().command(context.Background(), sh, "-c", "printf %s \"$LC_ALL\"") + if err != nil || string(got) != "C" { + t.Fatalf("a package tool ran with LC_ALL=%q (%v), want C", got, err) + } +} + func TestIsDebianFamily(t *testing.T) { tests := []struct { name string From 31cb8d5d01ca1d959f504a60b8a22b2fa1dbae66 Mon Sep 17 00:00:00 2001 From: Gary Date: Fri, 9 Oct 2026 09:48:49 +0200 Subject: [PATCH 02/33] feat(packages): the engine allowlist and the apt plan of a Docker update Pure helpers in pkg/dockerpkg for the Update Docker button: the seven engine package names, the dpkg version check, the major version of an engine and when an upgrade jumps it, and the parser of an apt simulation into a plan (what it installs, upgrades, removes), with the list of what makes the plan unacceptable, its id, its pins and its engine versions. Nothing in them runs a command, and nothing apt printed gets out of them unless it passed validation. --- pkg/dockerpkg/engine.go | 75 ++++++++ pkg/dockerpkg/engine_test.go | 109 +++++++++++ pkg/dockerpkg/plan.go | 165 +++++++++++++++++ pkg/dockerpkg/plan_test.go | 339 +++++++++++++++++++++++++++++++++++ 4 files changed, 688 insertions(+) create mode 100644 pkg/dockerpkg/engine.go create mode 100644 pkg/dockerpkg/engine_test.go create mode 100644 pkg/dockerpkg/plan.go create mode 100644 pkg/dockerpkg/plan_test.go diff --git a/pkg/dockerpkg/engine.go b/pkg/dockerpkg/engine.go new file mode 100644 index 00000000..68ea31e7 --- /dev/null +++ b/pkg/dockerpkg/engine.go @@ -0,0 +1,75 @@ +package dockerpkg + +import ( + "regexp" + "strings" +) + +// The "Update Docker" button upgrades the engine's own packages and nothing else. This file +// is what says which packages those are, what a version string may look like before it is +// allowed near a command line, a log or a page, and when an upgrade changes the major version. + +// EngineNames are the only packages the button may touch, sorted. Any other package in the +// transaction apt would run (an upgrade, a new package, a removal) makes it refuse. The +// distribution's own docker.io, containerd, docker-compose-v2 and docker-buildx are of the +// family (see IsFamily) and are not in this list on purpose: they are not Docker's repository. +// +// It is a variable so that it can be listed and shown; IsEngineName does not read it, so +// changing it at run time does not widen what the button may touch. +var EngineNames = []string{ + "containerd.io", + "docker-buildx-plugin", + "docker-ce", + "docker-ce-cli", + "docker-ce-rootless-extras", + "docker-compose-plugin", + "docker-model-plugin", +} + +// engineSet is EngineNames as it was when the program started. +var engineSet = func() map[string]struct{} { + set := make(map[string]struct{}, len(EngineNames)) + for _, name := range EngineNames { + set[name] = struct{}{} + } + return set +}() + +// IsEngineName reports whether name is one of EngineNames, with or without the architecture +// apt adds to a foreign package ("containerd.io:armhf"). +func IsEngineName(name string) bool { + _, ok := engineSet[base(name)] + return ok +} + +var versionPattern = regexp.MustCompile(`^([0-9]+:)?[0-9][A-Za-z0-9.+~-]*$`) + +const maxVersionLen = 100 + +// ValidVersion reports whether v is a dpkg version made only of the characters of one: an +// optional epoch, then a digit and letters, digits and . + ~ -. It is the test a version +// passes before it is printed in a log, sent to the page or put in an environment variable +// or an apt argument ("name=version"). No shell character can pass it. +func ValidVersion(v string) bool { + return len(v) <= maxVersionLen && versionPattern.MatchString(v) +} + +// Major is the first number of an engine version: "29.8.0" is "29", "24.0.5" is "24". It +// takes the version Docker reports, or a whole dpkg version ("5:29.8.0-1~debian.11~bullseye", +// whose epoch is not the major). It is "" for text that does not start with digits. It is +// the number in its shortest form, so that "029" and "29" are the same major. +func Major(engineVersion string) string { + version := EngineVersion(engineVersion) + major, _, _ := strings.Cut(version, ".") + if major = strings.TrimLeft(major, "0"); major == "" && version != "" { + return "0" + } + return major +} + +// MajorJump reports whether an upgrade from one engine version to another changes the major +// version. It is false when either of them has none. +func MajorJump(from, to string) bool { + a, b := Major(from), Major(to) + return a != "" && b != "" && a != b +} diff --git a/pkg/dockerpkg/engine_test.go b/pkg/dockerpkg/engine_test.go new file mode 100644 index 00000000..a8179e95 --- /dev/null +++ b/pkg/dockerpkg/engine_test.go @@ -0,0 +1,109 @@ +package dockerpkg + +import ( + "reflect" + "sort" + "strings" + "testing" +) + +func TestEngineNames(t *testing.T) { + want := []string{"containerd.io", "docker-buildx-plugin", "docker-ce", "docker-ce-cli", "docker-ce-rootless-extras", "docker-compose-plugin", "docker-model-plugin"} + if !reflect.DeepEqual(EngineNames, want) { + t.Errorf("EngineNames = %v, want %v", EngineNames, want) + } + if !sort.StringsAreSorted(EngineNames) { + t.Errorf("EngineNames is not sorted: %v", EngineNames) + } + for _, name := range EngineNames { + if !ValidName(name) || !IsFamily(name) || !IsEngineName(name) { + t.Errorf("%q is not a valid name of the Docker family", name) + } + } +} + +func TestIsEngineName(t *testing.T) { + for _, name := range append([]string{"containerd.io:armhf", "docker-ce:amd64"}, EngineNames...) { + if !IsEngineName(name) { + t.Errorf("IsEngineName(%q) = false", name) + } + } + // the distribution's packages are in the family, and are not the button's to touch + for _, name := range []string{"docker.io", "containerd", "docker-compose-v2", "docker-buildx", "docker", "docker-ce-extra", "libc6", "", "DOCKER-CE", ":amd64", " docker-ce", "docker-ce\n", "snapd", "docker-ce;x", "docker-cex", "docker-ce-cli2"} { + if IsEngineName(name) { + t.Errorf("IsEngineName(%q) = true", name) + } + } +} + +func TestIsEngineNameIgnoresChangesToEngineNames(t *testing.T) { + saved := EngineNames + t.Cleanup(func() { EngineNames = saved }) + EngineNames = append(append([]string{}, saved...), "libc6") + if IsEngineName("libc6") { + t.Error("appending to EngineNames at run time widened the allowlist") + } +} + +func TestValidVersion(t *testing.T) { + for _, v := range []string{ + "5:28.0.4-1~debian.11~bullseye", "5:29.8.0-1~debian.11~bullseye", "5:29.8.0-1~ubuntu.24.04~noble", + "29.8.0", "1.7.27-1", "2.1.4-1~ubuntu.22.04~jammy", "0", "1:2", "1.0+dfsg1-9+deb13u1", strings.Repeat("1", 100), + } { + if !ValidVersion(v) { + t.Errorf("ValidVersion(%q) = false", v) + } + } + for _, v := range []string{ + "", "5:", ":1", "a1", "-1", "~1", ".1", "1;2", "1`id`", "$(id)", "1 2", "1\n", "1.0\n2", "\n1", "1&&2", "1|2", "1'2", `1"2`, `1\2`, + "1/2", "1=2", "1<2", "5::1", "x:1", "1:2:3", "1.0\x00", "\uff11\uff12", "1,2", "1#", "1*", "1?", "1{2}", "1[2]", "1!", "1%", "1^", + strings.Repeat("1", 101), "5:" + strings.Repeat("1", 99), + } { + if ValidVersion(v) { + t.Errorf("ValidVersion(%q) = true", v) + } + } +} + +func TestMajor(t *testing.T) { + cases := map[string]string{ + "29.8.0": "29", "28.0.4": "28", "24.0.5": "24", "9.1": "9", "10.0": "10", "5": "5", "0.9": "0", + // a number, not a text: leading zeros do not make another major + "029.8": "29", "000": "0", "007.1": "7", + // the dpkg string, epoch and all, gives the engine's major, not the epoch + "5:29.8.0-1~debian.11~bullseye": "29", "5:28.0.4-1~ubuntu.22.04~jammy": "28", + "": "", "junk": "", ".5": "", "-1": "", "x29": "", ":29": "", + } + for in, want := range cases { + if got := Major(in); got != want { + t.Errorf("Major(%q) = %q, want %q", in, got, want) + } + } +} + +func TestMajorJump(t *testing.T) { + cases := []struct { + from, to string + want bool + }{ + {"28.0.4", "29.8.0", true}, + {"29.8.0", "29.8.1", false}, + {"24.0.5", "24.0.9", false}, + {"24.0.5", "25.0.0", true}, + {"9.9", "10.0", true}, + {"10.0", "9.9", true}, + {"9.0", "09.0", false}, + {"1.7", "2.1", true}, + {"5", "5", false}, + {"", "29.8.0", false}, + {"28.0.4", "", false}, + {"", "", false}, + {"junk", "29.8.0", false}, + {"28.0.4", "junk", false}, + } + for _, c := range cases { + if got := MajorJump(c.from, c.to); got != c.want { + t.Errorf("MajorJump(%q, %q) = %v, want %v", c.from, c.to, got, c.want) + } + } +} diff --git a/pkg/dockerpkg/plan.go b/pkg/dockerpkg/plan.go new file mode 100644 index 00000000..b4297d7f --- /dev/null +++ b/pkg/dockerpkg/plan.go @@ -0,0 +1,165 @@ +package dockerpkg + +import ( + "crypto/sha256" + "encoding/hex" + "regexp" + "sort" + "strings" +) + +// InvalidName stands in, in Offending, for a package name that is not fit to be shown: the +// list goes to a log and to the page, and what apt printed there is not trusted until it +// has passed ValidName. +const InvalidName = "(invalid name)" + +// PlanPackage is one `Inst` line of an apt simulation: a package it would install or upgrade. +// The versions are the whole dpkg strings ("5:29.8.0-1~debian.11~bullseye"), epoch and all. +type PlanPackage struct { + Name string `json:"name"` + Current string `json:"current_version"` + Candidate string `json:"candidate_version"` + // New is a package that is not installed yet: apt printed no [current version]. + New bool `json:"new,omitempty"` +} + +// offending reports whether the button must refuse a transaction that holds this package: it +// is not one of the engine's, or its name or a version is not text that may go near a command +// line. A package that is New has no current version, and "" is not a version: it is refused +// with no rule of its own. +func (pkg PlanPackage) offending() bool { + return !IsEngineName(pkg.Name) || !ValidName(pkg.Name) || !ValidVersion(pkg.Current) || !ValidVersion(pkg.Candidate) +} + +// Plan is what an apt simulation says it would do. +type Plan struct { + // Packages are the Inst lines, in the order apt printed them. + Packages []PlanPackage + // Removed is every package it would remove (a Remv or Purg line), as apt printed it: + // show Offending, not this. + Removed []string +} + +var ( + planLinePattern = regexp.MustCompile(`^(Inst|Remv|Purg)(?:\s+(.*))?$`) + // planInstPattern reads what follows "Inst ": the name, the installed version in square + // brackets when there is one, and the candidate that opens the round brackets. + planInstPattern = regexp.MustCompile(`^(\S+)(?:\s+\[([^\]]*)\])?(?:\s+\((\S+))?`) +) + +// ParsePlan reads the Inst, Remv and Purg lines of `apt-get -s`: +// +// Inst docker-ce [5:28.0.4-1~debian.11~bullseye] (5:29.8.0-1~debian.11~bullseye Docker CE:bullseye [amd64]) +// Inst docker-model-plugin (1.0.0-1~debian.11~bullseye Docker CE:bullseye [amd64]) +// Remv docker-compose-v2 [2.1.0] +// +// It fails closed: a line that begins like one of those and is not shaped like it still +// counts, as a package with what could be read of it, so that Offending names it. +func ParsePlan(simulation string) Plan { + var plan Plan + // not a bufio.Scanner: its line limit would stop at a long line and hide the rest + for _, line := range strings.Split(simulation, "\n") { + m := planLinePattern.FindStringSubmatch(strings.TrimSpace(line)) + if m == nil { + continue + } + if m[1] != "Inst" { + name := "" + if fields := strings.Fields(m[2]); len(fields) > 0 { + name = fields[0] + } + plan.Removed = append(plan.Removed, name) + continue + } + pkg := PlanPackage{New: true} + if im := planInstPattern.FindStringSubmatch(m[2]); im != nil { + pkg.Name, pkg.Current, pkg.Candidate = im[1], im[2], im[3] + pkg.New = pkg.Current == "" + } + plan.Packages = append(plan.Packages, pkg) + } + return plan +} + +// Offending lists, sorted and once each, what makes the plan unacceptable to the button: a +// package that would be removed, a package that is not one of EngineNames, a package that is +// not installed yet, and a package whose name or versions fail ValidName and ValidVersion. +// Every item is a valid name or InvalidName. It is empty (not nil) when the plan is within +// bounds; a plan with no package at all is within bounds, and it is for the caller to say +// that there is nothing to update. +func (p Plan) Offending() []string { + seen := map[string]struct{}{} + add := func(name string) { + if !ValidName(name) { + name = InvalidName + } + seen[name] = struct{}{} + } + for _, name := range p.Removed { + add(name) + } + for _, pkg := range p.Packages { + if pkg.offending() { + add(pkg.Name) + } + } + names := make([]string, 0, len(seen)) + for name := range seen { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// ID identifies the plan: the lower case hex SHA-256 of the lines "name current>candidate" +// of its packages, sorted and joined with "\n". It does not depend on the order apt printed +// them in and changes with any name or version, so that "I confirmed this" can be told +// from "apt now says something else". +func (p Plan) ID() string { + lines := make([]string, 0, len(p.Packages)) + for _, pkg := range p.Packages { + lines = append(lines, pkg.Name+" "+pkg.Current+">"+pkg.Candidate) + } + sort.Strings(lines) + sum := sha256.Sum256([]byte(strings.Join(lines, "\n"))) + return hex.EncodeToString(sum[:]) +} + +// Pins are the "name=candidate" arguments that make apt install exactly this plan, sorted. +// Only a package that Offending does not name is in them, so nothing that failed validation +// can get into an argument by a caller that forgot to ask. Never nil. +func (p Plan) Pins() []string { + pins := []string{} + for _, pkg := range p.Packages { + if !pkg.offending() { + pins = append(pins, pkg.Name+"="+pkg.Candidate) + } + } + sort.Strings(pins) + return pins +} + +// Names are the names of the packages of the plan, sorted, under the same rule as Pins. +// Never nil. +func (p Plan) Names() []string { + names := []string{} + for _, pkg := range p.Packages { + if !pkg.offending() { + names = append(names, pkg.Name) + } + } + sort.Strings(names) + return names +} + +// Engine is the engine version (digits and dots, see EngineVersion) the plan upgrades docker-ce +// from and to. When docker-ce is not in the plan, because only a plugin or containerd.io +// moves, both are "" and the caller says what is installed. +func (p Plan) Engine() (from, to string) { + for _, pkg := range p.Packages { + if base(pkg.Name) == "docker-ce" { + return EngineVersion(pkg.Current), EngineVersion(pkg.Candidate) + } + } + return "", "" +} diff --git a/pkg/dockerpkg/plan_test.go b/pkg/dockerpkg/plan_test.go new file mode 100644 index 00000000..427493e3 --- /dev/null +++ b/pkg/dockerpkg/plan_test.go @@ -0,0 +1,339 @@ +package dockerpkg + +import ( + "crypto/sha256" + "encoding/hex" + "reflect" + "sort" + "strings" + "testing" +) + +// What `apt-get -s --no-remove install --only-upgrade --no-install-recommends ` +// prints on Debian 11 with Docker's repository: the owner's box, a major jump. +const debian11Simulation = `NOTE: This is only a simulation! + apt-get needs root privileges for real execution. + Keep also in mind that locking is deactivated, + so don't depend on the relevance to the real current situation! +Reading package lists... +Building dependency tree... +Reading state information... +The following packages will be upgraded: + containerd.io docker-buildx-plugin docker-ce docker-ce-cli docker-ce-rootless-extras docker-compose-plugin +6 upgraded, 0 newly installed, 0 to remove and 3 not upgraded. +Inst containerd.io [1.7.27-1] (2.1.4-1~debian.11~bullseye Docker CE:bullseye [amd64]) +Inst docker-ce-cli [5:28.0.4-1~debian.11~bullseye] (5:29.8.0-1~debian.11~bullseye Docker CE:bullseye [amd64]) +Inst docker-ce [5:28.0.4-1~debian.11~bullseye] (5:29.8.0-1~debian.11~bullseye Docker CE:bullseye [amd64]) +Inst docker-ce-rootless-extras [5:28.0.4-1~debian.11~bullseye] (5:29.8.0-1~debian.11~bullseye Docker CE:bullseye [amd64]) +Inst docker-buildx-plugin [0.22.0-1~debian.11~bullseye] (0.30.1-1~debian.11~bullseye Docker CE:bullseye [amd64]) +Inst docker-compose-plugin [2.34.0-1~debian.11~bullseye] (5.0.0-1~debian.11~bullseye Docker CE:bullseye [amd64]) +Conf containerd.io (2.1.4-1~debian.11~bullseye Docker CE:bullseye [amd64]) +Conf docker-ce-cli (5:29.8.0-1~debian.11~bullseye Docker CE:bullseye [amd64]) +Conf docker-ce (5:29.8.0-1~debian.11~bullseye Docker CE:bullseye [amd64]) +Conf docker-ce-rootless-extras (5:29.8.0-1~debian.11~bullseye Docker CE:bullseye [amd64]) +Conf docker-buildx-plugin (0.30.1-1~debian.11~bullseye Docker CE:bullseye [amd64]) +Conf docker-compose-plugin (5.0.0-1~debian.11~bullseye Docker CE:bullseye [amd64]) +` + +// Ubuntu 22.04, same major, with a plugin that is new (not installed yet). +const ubuntu2204Simulation = `Reading package lists... +Building dependency tree... +Reading state information... +Inst containerd.io [2.3.5-1~ubuntu.22.04~jammy] (2.3.6-1~ubuntu.22.04~jammy Docker CE:jammy [amd64]) +Inst docker-ce [5:29.8.1-1~ubuntu.22.04~jammy] (5:29.8.2-1~ubuntu.22.04~jammy Docker CE:jammy [amd64]) +Inst docker-model-plugin (1.0.0-1~ubuntu.22.04~jammy Docker CE:jammy [amd64]) +Conf docker-ce (5:29.8.2-1~ubuntu.22.04~jammy Docker CE:jammy [amd64]) +` + +func TestParsePlanDebian11(t *testing.T) { + plan := ParsePlan(debian11Simulation) + want := []PlanPackage{ + {Name: "containerd.io", Current: "1.7.27-1", Candidate: "2.1.4-1~debian.11~bullseye"}, + {Name: "docker-ce-cli", Current: "5:28.0.4-1~debian.11~bullseye", Candidate: "5:29.8.0-1~debian.11~bullseye"}, + {Name: "docker-ce", Current: "5:28.0.4-1~debian.11~bullseye", Candidate: "5:29.8.0-1~debian.11~bullseye"}, + {Name: "docker-ce-rootless-extras", Current: "5:28.0.4-1~debian.11~bullseye", Candidate: "5:29.8.0-1~debian.11~bullseye"}, + {Name: "docker-buildx-plugin", Current: "0.22.0-1~debian.11~bullseye", Candidate: "0.30.1-1~debian.11~bullseye"}, + {Name: "docker-compose-plugin", Current: "2.34.0-1~debian.11~bullseye", Candidate: "5.0.0-1~debian.11~bullseye"}, + } + if !reflect.DeepEqual(plan.Packages, want) { + t.Errorf("Packages = %#v\nwant %#v", plan.Packages, want) + } + if len(plan.Removed) != 0 { + t.Errorf("Removed = %v", plan.Removed) + } + if bad := plan.Offending(); len(bad) != 0 { + t.Errorf("Offending() = %v, want none", bad) + } + if from, to := plan.Engine(); from != "28.0.4" || to != "29.8.0" { + t.Errorf("Engine() = %q, %q", from, to) + } + if from, to := plan.Engine(); !MajorJump(from, to) { + t.Error("28.0.4 -> 29.8.0 is a major jump") + } + wantNames := []string{"containerd.io", "docker-buildx-plugin", "docker-ce", "docker-ce-cli", "docker-ce-rootless-extras", "docker-compose-plugin"} + if got := plan.Names(); !reflect.DeepEqual(got, wantNames) { + t.Errorf("Names() = %v, want %v", got, wantNames) + } + wantPins := []string{ + "containerd.io=2.1.4-1~debian.11~bullseye", + "docker-buildx-plugin=0.30.1-1~debian.11~bullseye", + "docker-ce-cli=5:29.8.0-1~debian.11~bullseye", + "docker-ce-rootless-extras=5:29.8.0-1~debian.11~bullseye", + "docker-ce=5:29.8.0-1~debian.11~bullseye", + "docker-compose-plugin=5.0.0-1~debian.11~bullseye", + } + if got := plan.Pins(); !reflect.DeepEqual(got, wantPins) { + t.Errorf("Pins() = %v, want %v", got, wantPins) + } + if !sort.StringsAreSorted(plan.Pins()) { + t.Error("Pins() is not sorted") + } +} + +func TestParsePlanUbuntu2204NewPackage(t *testing.T) { + plan := ParsePlan(ubuntu2204Simulation) + want := []PlanPackage{ + {Name: "containerd.io", Current: "2.3.5-1~ubuntu.22.04~jammy", Candidate: "2.3.6-1~ubuntu.22.04~jammy"}, + {Name: "docker-ce", Current: "5:29.8.1-1~ubuntu.22.04~jammy", Candidate: "5:29.8.2-1~ubuntu.22.04~jammy"}, + {Name: "docker-model-plugin", Candidate: "1.0.0-1~ubuntu.22.04~jammy", New: true}, + } + if !reflect.DeepEqual(plan.Packages, want) { + t.Errorf("Packages = %#v\nwant %#v", plan.Packages, want) + } + if bad := plan.Offending(); !reflect.DeepEqual(bad, []string{"docker-model-plugin"}) { + t.Errorf("Offending() = %v, want the new package", bad) + } + if from, to := plan.Engine(); from != "29.8.1" || to != "29.8.2" || MajorJump(from, to) { + t.Errorf("Engine() = %q, %q", from, to) + } +} + +func TestParsePlanLineShapes(t *testing.T) { + plan := ParsePlan(strings.Join([]string{ + "Inst docker-ce [5:28.0.4-1~debian.11~bullseye] (5:29.8.0-1~debian.11~bullseye Docker CE:bullseye [amd64])", + "Inst docker-compose-plugin (5.0.0-1~debian.11~bullseye Docker CE:bullseye [amd64])", + "Inst containerd.io:armhf [2.3.5-1~ubuntu.22.04~jammy] (2.3.6-1~ubuntu.22.04~jammy Docker CE:jammy [armhf])", + "Inst docker-ce-cli [5:28.0.4-1] (5:29.8.0-1 Docker CE:bullseye [amd64]) []", + " Inst docker-buildx-plugin [0.22.0-1] (0.30.1-1 Docker CE:bullseye [amd64])\r", + "Inst\tdocker-model-plugin\t[1.0.0-1]\t(1.1.0-1 Docker CE:bullseye [amd64])", + "Remv docker-compose-v2 [2.1.0]", + "Remv docker.io:arm64 [26.1.5]", + "Purg old-thing [1.0]", + "Conf docker-ce (5:29.8.0-1~debian.11~bullseye Docker CE:bullseye [amd64])", + "The following packages will be upgraded:", + " docker-ce docker-ce-cli", + "Inst", + "", + }, "\n")) + want := []PlanPackage{ + {Name: "docker-ce", Current: "5:28.0.4-1~debian.11~bullseye", Candidate: "5:29.8.0-1~debian.11~bullseye"}, + {Name: "docker-compose-plugin", Candidate: "5.0.0-1~debian.11~bullseye", New: true}, + {Name: "containerd.io:armhf", Current: "2.3.5-1~ubuntu.22.04~jammy", Candidate: "2.3.6-1~ubuntu.22.04~jammy"}, + {Name: "docker-ce-cli", Current: "5:28.0.4-1", Candidate: "5:29.8.0-1"}, + {Name: "docker-buildx-plugin", Current: "0.22.0-1", Candidate: "0.30.1-1"}, + {Name: "docker-model-plugin", Current: "1.0.0-1", Candidate: "1.1.0-1"}, + {Name: "", New: true}, + } + if !reflect.DeepEqual(plan.Packages, want) { + t.Errorf("Packages = %#v\nwant %#v", plan.Packages, want) + } + if wantRemoved := []string{"docker-compose-v2", "docker.io:arm64", "old-thing"}; !reflect.DeepEqual(plan.Removed, wantRemoved) { + t.Errorf("Removed = %v, want %v", plan.Removed, wantRemoved) + } +} + +func TestParsePlanEmptyAndNoise(t *testing.T) { + for _, in := range []string{"", "\n\n", "Reading package lists...\nBuilding dependency tree...\n0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.\n", "E: Unable to locate package docker-ce\n"} { + plan := ParsePlan(in) + if len(plan.Packages) != 0 || len(plan.Removed) != 0 { + t.Errorf("ParsePlan(%q) = %#v", in, plan) + } + if bad := plan.Offending(); bad == nil || len(bad) != 0 { + t.Errorf("Offending() of an empty plan = %#v, want an empty, non-nil list", bad) + } + if plan.Pins() == nil || plan.Names() == nil || len(plan.Pins()) != 0 || len(plan.Names()) != 0 { + t.Errorf("Pins/Names of an empty plan = %#v / %#v", plan.Pins(), plan.Names()) + } + if from, to := plan.Engine(); from != "" || to != "" { + t.Errorf("Engine() of an empty plan = %q, %q", from, to) + } + } +} + +func TestParsePlanVeryLongLineDoesNotHideLaterLines(t *testing.T) { + // a scanner with a line limit would stop here, and never see the removal + long := "Reading " + strings.Repeat("x", 200000) + "\n" + plan := ParsePlan(long + "Remv docker-compose-v2 [2.1.0]\n") + if !reflect.DeepEqual(plan.Removed, []string{"docker-compose-v2"}) { + t.Errorf("Removed = %v", plan.Removed) + } +} + +func offendingOf(simulation string) []string { return ParsePlan(simulation).Offending() } + +func TestOffendingEachKindOfViolation(t *testing.T) { + ok := "Inst docker-ce [5:28.0.4-1] (5:29.8.0-1 Docker CE:bullseye [amd64])\n" + cases := []struct { + name, simulation string + want []string + }{ + {"nothing wrong", ok, []string{}}, + {"a removal", ok + "Remv docker-compose-v2 [2.1.0]\n", []string{"docker-compose-v2"}}, + {"a removal of an engine package", ok + "Remv containerd.io [1.7.27-1]\n", []string{"containerd.io"}}, + {"a purge", ok + "Purg docker-compose-v2 [2.1.0]\n", []string{"docker-compose-v2"}}, + {"a package that is not the engine", ok + "Inst libc6 [2.31-13+deb11u5] (2.31-13+deb11u11 Debian-Security:11/stable-security [amd64])\n", []string{"libc6"}}, + {"docker.io", ok + "Inst docker.io [26.1.5] (26.1.6 Debian [amd64])\n", []string{"docker.io"}}, + {"containerd", ok + "Inst containerd [1.6.20] (1.6.21 Debian [amd64])\n", []string{"containerd"}}, + {"docker-compose-v2", ok + "Inst docker-compose-v2 [2.1] (2.2 Debian [amd64])\n", []string{"docker-compose-v2"}}, + {"docker-buildx", ok + "Inst docker-buildx [0.1] (0.2 Debian [amd64])\n", []string{"docker-buildx"}}, + {"a new package, even an engine one", ok + "Inst docker-model-plugin (1.0.0-1 Docker CE:bullseye [amd64])\n", []string{"docker-model-plugin"}}, + {"a new package that is not an engine one", ok + "Inst libseccomp2 (2.5.1 Debian [amd64])\n", []string{"libseccomp2"}}, + {"an empty current version", ok + "Inst docker-model-plugin [] (1.0.0-1 Docker CE:bullseye [amd64])\n", []string{"docker-model-plugin"}}, + {"a hostile name", ok + "Inst x;rm [1] (2 repo [amd64])\n", []string{InvalidName}}, + {"a command substitution as a name", ok + "Inst $(id) [1] (2 repo [amd64])\n", []string{InvalidName}}, + {"a flag as a name", ok + "Inst -oAPT::Foo=1 [1] (2 repo [amd64])\n", []string{InvalidName}}, + {"an upper case name", ok + "Inst Docker-CE [1] (2 repo [amd64])\n", []string{InvalidName}}, + {"an architecture that is a substitution", ok + "Inst docker-ce:$(id) [1] (2 repo [amd64])\n", []string{InvalidName}}, + {"an architecture with a semicolon", ok + "Inst containerd.io:amd64;reboot [1] (2 repo [amd64])\n", []string{InvalidName}}, + {"an overlong name", ok + "Inst docker-ce" + strings.Repeat("e", 200) + " [1] (2 repo [amd64])\n", []string{InvalidName}}, + {"a line with no name", ok + "Inst\n", []string{InvalidName}}, + {"a hostile removed name", ok + "Remv a;b [1]\nRemv\n", []string{InvalidName}}, + {"a hostile candidate", "Inst docker-ce [5:28.0.4-1] (5:29.8.0-1;reboot Docker CE [amd64])\n", []string{"docker-ce"}}, + {"a backtick in the candidate", "Inst docker-ce [5:28.0.4-1] (5:29.8.0-1`id` Docker CE [amd64])\n", []string{"docker-ce"}}, + {"a hostile current version", "Inst docker-ce [5:28.0.4-1$(id)] (5:29.8.0-1 Docker CE [amd64])\n", []string{"docker-ce"}}, + {"an overlong candidate", "Inst docker-ce [5:28.0.4-1] (5:" + strings.Repeat("9", 120) + " Docker CE [amd64])\n", []string{"docker-ce"}}, + {"an overlong current version", "Inst docker-ce [5:" + strings.Repeat("9", 120) + "] (5:29.8.0-1 Docker CE [amd64])\n", []string{"docker-ce"}}, + {"a candidate that is cut", "Inst docker-ce [5:28.0.4-1] (\n", []string{"docker-ce"}}, + {"a line apt did not shape like that", "Inst docker-ce garbage\n", []string{"docker-ce"}}, + {"a candidate without a source", "Inst docker-ce [5:28.0.4-1] (5:29.8.0-1)\n", []string{"docker-ce"}}, + {"several violations, sorted, once each", + "Inst zeta [1] (2 x)\nInst alpha [1] (2 x)\nInst zeta [1] (3 x)\nRemv alpha [1]\nRemv beta [1]\nInst a;b [1] (2 x)\nInst c;d [1] (2 x)\n", + []string{InvalidName, "alpha", "beta", "zeta"}}, + } + for _, c := range cases { + got := offendingOf(c.simulation) + if got == nil { + got = []string{} + } + if !reflect.DeepEqual(got, c.want) { + t.Errorf("%s: Offending() = %#v, want %#v", c.name, got, c.want) + } + for _, name := range got { + if name != InvalidName && !ValidName(name) { + t.Errorf("%s: Offending() holds %q, which is not a valid name", c.name, name) + } + } + } +} + +func TestPinsAndNamesLeaveOutWhatOffends(t *testing.T) { + plan := ParsePlan("Inst docker-ce [5:28.0.4-1] (5:29.8.0-1 Docker CE [amd64])\n" + + "Inst docker-ce-cli [5:28.0.4-1] (5:29.8.0-1;reboot Docker CE [amd64])\n" + + "Inst libc6 [2.31] (2.32 Debian [amd64])\n" + + "Inst x;rm [1] (2 repo [amd64])\n" + + "Inst docker-ce-rootless-extras:$(id) [5:28.0.4-1] (5:29.8.0-1 Docker CE [amd64])\n" + + "Inst docker-model-plugin (1.0.0-1 Docker CE [amd64])\n" + + "Inst containerd.io:armhf [1.7.27-1] (2.1.4-1 Docker CE [armhf])\n") + if got, want := plan.Pins(), []string{"containerd.io:armhf=2.1.4-1", "docker-ce=5:29.8.0-1"}; !reflect.DeepEqual(got, want) { + t.Errorf("Pins() = %v, want %v", got, want) + } + if got, want := plan.Names(), []string{"containerd.io:armhf", "docker-ce"}; !reflect.DeepEqual(got, want) { + t.Errorf("Names() = %v, want %v", got, want) + } + if bad := plan.Offending(); len(bad) == 0 { + t.Error("the plan is not acceptable") + } +} + +func TestPlanIDIsTheHashOfTheSortedLines(t *testing.T) { + plan := ParsePlan(debian11Simulation) + lines := []string{ + "containerd.io 1.7.27-1>2.1.4-1~debian.11~bullseye", + "docker-buildx-plugin 0.22.0-1~debian.11~bullseye>0.30.1-1~debian.11~bullseye", + "docker-ce 5:28.0.4-1~debian.11~bullseye>5:29.8.0-1~debian.11~bullseye", + "docker-ce-cli 5:28.0.4-1~debian.11~bullseye>5:29.8.0-1~debian.11~bullseye", + "docker-ce-rootless-extras 5:28.0.4-1~debian.11~bullseye>5:29.8.0-1~debian.11~bullseye", + "docker-compose-plugin 2.34.0-1~debian.11~bullseye>5.0.0-1~debian.11~bullseye", + } + sum := sha256.Sum256([]byte(strings.Join(lines, "\n"))) + // the lines as sort.Strings orders them: "docker-ce " is before "docker-ce-cli " (a space is before '-') + if want := hex.EncodeToString(sum[:]); plan.ID() != want { + t.Errorf("ID() = %s, want %s", plan.ID(), want) + } + if id := plan.ID(); len(id) != 64 || id != strings.ToLower(id) { + t.Errorf("ID() = %q is not 64 lower case hex", id) + } + // a new package is "name >candidate" + one := ParsePlan("Inst docker-model-plugin (1.0.0-1 Docker CE [amd64])\n") + sum = sha256.Sum256([]byte("docker-model-plugin >1.0.0-1")) + if want := hex.EncodeToString(sum[:]); one.ID() != want { + t.Errorf("ID() of a new package = %s, want %s", one.ID(), want) + } + // nothing + sum = sha256.Sum256(nil) + if want := hex.EncodeToString(sum[:]); ParsePlan("").ID() != want { + t.Errorf("ID() of nothing = %s, want %s", ParsePlan("").ID(), want) + } +} + +func TestPlanIDIsStableUnderLineOrderAndNoise(t *testing.T) { + id := ParsePlan(debian11Simulation).ID() + var inst []string + for _, line := range strings.Split(debian11Simulation, "\n") { + if strings.HasPrefix(line, "Inst ") { + inst = append(inst, line) + } + } + for shift := 1; shift < len(inst); shift++ { + rotated := append(append([]string{}, inst[shift:]...), inst[:shift]...) + if got := ParsePlan("Reading package lists...\n" + strings.Join(rotated, "\n") + "\nConf docker-ce (x)\n").ID(); got != id { + t.Errorf("ID() changed with the order of the lines (shift %d): %s != %s", shift, got, id) + } + } + reversed := append([]string{}, inst...) + sort.Sort(sort.Reverse(sort.StringSlice(reversed))) + if got := ParsePlan(strings.Join(reversed, "\n")).ID(); got != id { + t.Errorf("ID() changed with the reversed order: %s != %s", got, id) + } +} + +func TestPlanIDChangesWithAnyVersionOrName(t *testing.T) { + base := debian11Simulation + id := ParsePlan(base).ID() + changes := map[string]string{ + "a candidate": strings.Replace(base, "(5:29.8.0-1~debian.11~bullseye Docker CE:bullseye [amd64])\nInst docker-ce [", "(5:29.8.1-1~debian.11~bullseye Docker CE:bullseye [amd64])\nInst docker-ce [", 1), + "a current": strings.Replace(base, "[1.7.27-1]", "[1.7.26-1]", 1), + "the epoch": strings.Replace(base, "Inst docker-ce [5:28.0.4", "Inst docker-ce [4:28.0.4", 1), + "a name": strings.Replace(base, "Inst docker-buildx-plugin", "Inst docker-model-plugin", 1), + "a package": base + "Inst docker-model-plugin (1.0.0-1 Docker CE [amd64])\n", + "one dropped": strings.Replace(base, "Inst docker-compose-plugin [2.34.0-1~debian.11~bullseye] (5.0.0-1~debian.11~bullseye Docker CE:bullseye [amd64])\n", "", 1), + "the last bit": strings.Replace(base, "5.0.0-1~debian.11~bullseye Docker CE:bullseye [amd64])\nConf", "5.0.1-1~debian.11~bullseye Docker CE:bullseye [amd64])\nConf", 1), + } + for what, simulation := range changes { + if simulation == base { + t.Fatalf("the test did not change %s", what) + } + if got := ParsePlan(simulation).ID(); got == id { + t.Errorf("ID() did not change with %s", what) + } + } +} + +func TestPlanEngine(t *testing.T) { + cases := []struct { + name, simulation, from, to string + }{ + {"docker-ce", "Inst docker-ce [5:28.0.4-1~debian.11~bullseye] (5:29.8.0-1~debian.11~bullseye Docker CE [amd64])\n", "28.0.4", "29.8.0"}, + {"docker-ce for a foreign architecture", "Inst docker-ce:i386 [5:24.0.5-1] (5:24.0.9-1 Docker CE [i386])\n", "24.0.5", "24.0.9"}, + {"only containerd moves", "Inst containerd.io [1.7.27-1] (2.1.4-1 Docker CE [amd64])\n", "", ""}, + {"docker-ce-cli is not docker-ce", "Inst docker-ce-cli [5:28.0.4-1] (5:29.8.0-1 Docker CE [amd64])\n", "", ""}, + {"docker-ce is new", "Inst docker-ce (5:29.8.0-1 Docker CE [amd64])\n", "", "29.8.0"}, + {"a candidate that is not a version", "Inst docker-ce [5:28.0.4-1] (junk Docker CE [amd64])\n", "28.0.4", ""}, + {"nothing", "", "", ""}, + } + for _, c := range cases { + if from, to := ParsePlan(c.simulation).Engine(); from != c.from || to != c.to { + t.Errorf("%s: Engine() = %q, %q, want %q, %q", c.name, from, to, c.from, c.to) + } + } +} From adfc532f83cef22acfeab8855728682810b73897 Mon Sep 17 00:00:00 2001 From: Gary Date: Fri, 9 Oct 2026 09:50:07 +0200 Subject: [PATCH 03/33] feat(packages): read the running containers and the daemon for a Docker update The docker inspect and docker info templates, and their parsers: one JSON object per container (name, image, restart policy, host network, published ports) and one for the daemon (version, swarm). Every free-text field goes through {{json}}, and the parsers skip what is malformed and validate what they keep, since container names and images are third-party text. --- pkg/dockerpkg/containers.go | 223 ++++++++++++++++++++ pkg/dockerpkg/containers_test.go | 337 +++++++++++++++++++++++++++++++ 2 files changed, 560 insertions(+) create mode 100644 pkg/dockerpkg/containers.go create mode 100644 pkg/dockerpkg/containers_test.go diff --git a/pkg/dockerpkg/containers.go b/pkg/dockerpkg/containers.go new file mode 100644 index 00000000..f7bc8a6d --- /dev/null +++ b/pkg/dockerpkg/containers.go @@ -0,0 +1,223 @@ +package dockerpkg + +import ( + "encoding/json" + "errors" + "regexp" + "sort" + "strings" + "unicode" + "unicode/utf8" +) + +// Port is a port a container publishes on the box: container port 80 reached on host port 8080. +type Port struct { + Port int `json:"port"` + Protocol string `json:"protocol"` + HostPort int `json:"host_port"` +} + +// Container is a running container, as the confirmation of an update lists it. +type Container struct { + Name string `json:"name"` + Image string `json:"image"` + // RestartPolicy is Docker's: "no" or "" (it will not come back by itself), "always", + // "unless-stopped" or "on-failure". + RestartPolicy string `json:"restart_policy"` + // HostNetwork is a container that shares the box's network: its ports are the box's ports. + HostNetwork bool `json:"host_network"` + // Ports are the ports it publishes, never nil. + Ports []Port `json:"ports"` +} + +// ContainerInspectFormat is the `docker inspect --format` template that prints one JSON +// object per container, on one line: `docker inspect --format $(docker ps -q)`. +// Every free-text field goes through {{json}}, so that no name or image, whatever it holds, +// can break the line or the object. ParseContainers reads it. +const ContainerInspectFormat = `{"name":{{json .Name}},"image":{{json .Config.Image}},"restart":{{json .HostConfig.RestartPolicy.Name}},"network":{{json .HostConfig.NetworkMode}},"ports":{{json .NetworkSettings.Ports}}}` + +var ( + containerNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`) + restartPolicyPattern = regexp.MustCompile(`^[a-z-]*$`) + daemonVersionPattern = regexp.MustCompile(`^[0-9A-Za-z][0-9A-Za-z.+~_-]*$`) +) + +const ( + maxContainerNameLen = 128 + maxRestartPolicyLen = 32 + maxImageLen = 256 + maxDaemonVersionLen = 100 +) + +// validContainerName is Docker's own rule for the name of a container. +func validContainerName(name string) bool { + return len(name) <= maxContainerNameLen && containerNamePattern.MatchString(name) +} + +// validRestartPolicy is the name of a restart policy: "", "no", "always", "unless-stopped", +// "on-failure". +func validRestartPolicy(policy string) bool { + return len(policy) <= maxRestartPolicyLen && restartPolicyPattern.MatchString(policy) +} + +type inspectedContainer struct { + Name string `json:"name"` + Image string `json:"image"` + Restart string `json:"restart"` + Network string `json:"network"` + Ports map[string][]struct { + HostPort string `json:"HostPort"` + } `json:"ports"` +} + +// ParseContainers reads the output of `docker inspect --format ContainerInspectFormat`. It +// is tolerant and strict at once: a line that is not a JSON object of that shape is skipped +// (so is a container whose name does not follow Docker's rule or whose restart policy is not +// a policy's name), and what is kept has been through validation: the name without its +// leading "/", the image cleaned of control characters and cut to a bound, the published +// ports only. The result is sorted by name and never nil. +func ParseContainers(output string) []Container { + containers := []Container{} + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var raw inspectedContainer + if err := json.Unmarshal([]byte(line), &raw); err != nil { + continue + } + name := strings.TrimPrefix(raw.Name, "/") + if !validContainerName(name) || !validRestartPolicy(raw.Restart) { + continue + } + containers = append(containers, Container{ + Name: name, + Image: boundedText(raw.Image, maxImageLen), + RestartPolicy: raw.Restart, + HostNetwork: raw.Network == "host", + Ports: publishedPorts(raw.Ports), + }) + } + sort.SliceStable(containers, func(i, j int) bool { return containers[i].Name < containers[j].Name }) + return containers +} + +// boundedText drops the control and line-separator characters of text and cuts it to at most +// max bytes, on a character boundary. +func boundedText(text string, max int) string { + text = strings.Map(func(r rune) rune { + if unicode.IsControl(r) || r == '\u2028' || r == '\u2029' { + return -1 + } + return r + }, text) + if len(text) > max { + text = text[:max] + for !utf8.ValidString(text) { + text = text[:len(text)-1] + } + } + return text +} + +// portNumber is a TCP or UDP port in decimal, or 0 when s is not one. +func portNumber(s string) int { + if s == "" || len(s) > 5 { + return 0 + } + n := 0 + for _, r := range s { + if r < '0' || r > '9' { + return 0 + } + n = n*10 + int(r-'0') + } + if n > 65535 { + return 0 + } + return n +} + +// publishedPorts reads Docker's port map ("80/tcp": [{"HostPort": "8080"}]), keeping what is +// published on a host port, once each, in order. +func publishedPorts(bindings map[string][]struct { + HostPort string `json:"HostPort"` +}) []Port { + ports := []Port{} + seen := map[Port]struct{}{} + for key, list := range bindings { + number, protocol, hasProtocol := strings.Cut(key, "/") + if !hasProtocol { + protocol = "tcp" + } + containerPort := portNumber(number) + if containerPort == 0 || (protocol != "tcp" && protocol != "udp" && protocol != "sctp") { + continue + } + for _, binding := range list { + hostPort := portNumber(binding.HostPort) + if hostPort == 0 { + continue + } + port := Port{Port: containerPort, Protocol: protocol, HostPort: hostPort} + if _, dup := seen[port]; !dup { + seen[port] = struct{}{} + ports = append(ports, port) + } + } + } + sort.Slice(ports, func(i, j int) bool { + a, b := ports[i], ports[j] + if a.Port != b.Port { + return a.Port < b.Port + } + if a.Protocol != b.Protocol { + return a.Protocol < b.Protocol + } + return a.HostPort < b.HostPort + }) + return ports +} + +// DaemonInfo is what the update needs to know of the Docker daemon. +type DaemonInfo struct { + // ServerVersion is the version the running daemon reports. + ServerVersion string + // SwarmActive is a node that is part of a swarm. + SwarmActive bool +} + +// DaemonInfoFormat is the `docker info --format` template ParseDaemonInfo reads: one JSON +// object on one line. +const DaemonInfoFormat = `{"ServerVersion":{{json .ServerVersion}},"SwarmState":{{json .Swarm.LocalNodeState}}}` + +// ParseDaemonInfo reads the output of `docker info --format DaemonInfoFormat`. It is an +// error when no daemon answered: the CLI then prints nothing, or what it knows of itself +// without a ServerVersion, or an error text. The first line that is a JSON object is the +// answer, so a warning before it does no harm. +func ParseDaemonInfo(output string) (DaemonInfo, error) { + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "{") { + continue + } + var raw struct { + ServerVersion string `json:"ServerVersion"` + SwarmState string `json:"SwarmState"` + } + if err := json.Unmarshal([]byte(line), &raw); err != nil { + continue + } + if !validDaemonVersion(raw.ServerVersion) { + return DaemonInfo{}, errors.New("the Docker daemon did not report a usable version") + } + return DaemonInfo{ServerVersion: raw.ServerVersion, SwarmActive: raw.SwarmState == "active"}, nil + } + return DaemonInfo{}, errors.New("the Docker daemon did not answer") +} + +// validDaemonVersion is the version a daemon reports: "29.8.0", "25.0.0-rc.1", "20.10.24+dfsg1". +func validDaemonVersion(v string) bool { + return len(v) <= maxDaemonVersionLen && daemonVersionPattern.MatchString(v) +} diff --git a/pkg/dockerpkg/containers_test.go b/pkg/dockerpkg/containers_test.go new file mode 100644 index 00000000..34141d87 --- /dev/null +++ b/pkg/dockerpkg/containers_test.go @@ -0,0 +1,337 @@ +package dockerpkg + +import ( + "bytes" + "encoding/json" + "reflect" + "strings" + "testing" + "text/template" + "unicode/utf8" +) + +// dockerTemplate renders a format the way `docker inspect --format` / `docker info --format` +// does: Docker's `json` function does not escape HTML and is followed by nothing. +func dockerTemplate(t *testing.T, format string, data any) string { + t.Helper() + tmpl, err := template.New("docker").Funcs(template.FuncMap{ + "json": func(v any) (string, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return "", err + } + return strings.TrimSpace(buf.String()), nil + }, + }).Option("missingkey=error").Parse(format) + if err != nil { + t.Fatalf("the format does not parse: %v", err) + } + var out bytes.Buffer + if err := tmpl.Execute(&out, data); err != nil { + t.Fatalf("the format does not run: %v", err) + } + return out.String() +} + +// inspected is the part of `docker inspect` the format reads, as the CLI hands it to a template. +func inspected(name, image, policy, network string, ports map[string]any) map[string]any { + return map[string]any{ + "Name": name, + "Config": map[string]any{"Image": image}, + "HostConfig": map[string]any{"RestartPolicy": map[string]any{"Name": policy}, "NetworkMode": network}, + "NetworkSettings": map[string]any{"Ports": ports}, + } +} + +func binding(hostIP, hostPort string) map[string]any { + return map[string]any{"HostIp": hostIP, "HostPort": hostPort} +} + +func TestContainerInspectFormatRoundTrip(t *testing.T) { + if strings.ContainsAny(ContainerInspectFormat, "\n\r") { + t.Errorf("the format spreads over several lines: %q", ContainerInspectFormat) + } + var out strings.Builder + for _, c := range []map[string]any{ + inspected("/web", "nginx:1.27", "always", "bridge", map[string]any{ + "80/tcp": []any{binding("0.0.0.0", "8080"), binding("::", "8080")}, + "443/tcp": nil, + "53/udp": []any{binding("0.0.0.0", "53")}, + }), + inspected("/db", "mariadb:11", "unless-stopped", "default", map[string]any{}), + inspected("/pihole", "pihole/pihole", "no", "host", map[string]any{}), + // third-party text: quotes, backslashes, HTML, a new line, a line separator + inspected("/hostile", "a\"b\\c\nd\u2028e", "always", "bridge", nil), + } { + out.WriteString(dockerTemplate(t, ContainerInspectFormat, c)) + out.WriteString("\n") // docker inspect puts the new line itself + } + if n := strings.Count(out.String(), "\n"); n != 4 { + t.Fatalf("four containers made %d lines:\n%s", n, out.String()) + } + got := ParseContainers(out.String()) + want := []Container{ + {Name: "db", Image: "mariadb:11", RestartPolicy: "unless-stopped", Ports: []Port{}}, + {Name: "hostile", Image: "a\"b\\cde", RestartPolicy: "always", Ports: []Port{}}, + {Name: "pihole", Image: "pihole/pihole", RestartPolicy: "no", HostNetwork: true, Ports: []Port{}}, + {Name: "web", Image: "nginx:1.27", RestartPolicy: "always", Ports: []Port{{Port: 53, Protocol: "udp", HostPort: 53}, {Port: 80, Protocol: "tcp", HostPort: 8080}}}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("ParseContainers() =\n%#v\nwant\n%#v", got, want) + } +} + +// Whatever a field holds, the template's output is one line of valid JSON: that is what +// {{json}} is for, and a field that went in with {{.Field}} would break it. +func TestContainerInspectFormatEscapesEveryFreeTextField(t *testing.T) { + hostile := "a\"b\\c\nd\te{{.}}\u2028" + for _, c := range []map[string]any{ + inspected(hostile, "x", "no", "bridge", nil), + inspected("/x", hostile, "no", "bridge", nil), + inspected("/x", "x", hostile, "bridge", nil), + inspected("/x", "x", "no", hostile, nil), + inspected("/x", "x", "no", "bridge", map[string]any{hostile: []any{binding(hostile, hostile)}}), + } { + out := dockerTemplate(t, ContainerInspectFormat, c) + if strings.ContainsAny(out, "\n\r") || !json.Valid([]byte(out)) { + t.Errorf("the output is not one line of JSON: %q", out) + } + } + for _, v := range []any{hostile, nil} { + info := map[string]any{"ServerVersion": v, "Swarm": map[string]any{"LocalNodeState": hostile}} + if out := dockerTemplate(t, DaemonInfoFormat, info); strings.ContainsAny(out, "\n\r") || !json.Valid([]byte(out)) { + t.Errorf("the daemon info is not one line of JSON: %q", out) + } + } +} + +const inspectLines = `{"name":"/web","image":"nginx:1.27","restart":"always","network":"bridge","ports":{"80/tcp":[{"HostIp":"0.0.0.0","HostPort":"8080"},{"HostIp":"::","HostPort":"8080"}],"443/tcp":null,"53/udp":[{"HostIp":"0.0.0.0","HostPort":"53"}]}} +{"name":"/db","image":"mariadb:11","restart":"unless-stopped","network":"bridge","ports":{}} +{"name":"/pihole","image":"pihole/pihole","restart":"no","network":"host","ports":{}} +` + +func TestParseContainers(t *testing.T) { + got := ParseContainers(inspectLines) + want := []Container{ + {Name: "db", Image: "mariadb:11", RestartPolicy: "unless-stopped", Ports: []Port{}}, + {Name: "pihole", Image: "pihole/pihole", RestartPolicy: "no", HostNetwork: true, Ports: []Port{}}, + {Name: "web", Image: "nginx:1.27", RestartPolicy: "always", Ports: []Port{{Port: 53, Protocol: "udp", HostPort: 53}, {Port: 80, Protocol: "tcp", HostPort: 8080}}}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("ParseContainers() =\n%#v\nwant\n%#v", got, want) + } + // the JSON the API sends + encoded, err := json.Marshal(got[2]) + if err != nil || string(encoded) != `{"name":"web","image":"nginx:1.27","restart_policy":"always","host_network":false,"ports":[{"port":53,"protocol":"udp","host_port":53},{"port":80,"protocol":"tcp","host_port":8080}]}` { + t.Errorf("Container as JSON = %s (%v)", encoded, err) + } +} + +func TestParseContainersNothing(t *testing.T) { + for _, in := range []string{"", "\n\n", " \n", "Error response from daemon: No such container: x\n"} { + got := ParseContainers(in) + if got == nil || len(got) != 0 { + t.Errorf("ParseContainers(%q) = %#v, want an empty, non-nil list", in, got) + } + } +} + +func TestParseContainersSkipsMalformedLinesAndKeepsTheRest(t *testing.T) { + in := strings.Join([]string{ + `not json at all`, + `{"name":"/truncated","image":"x","restart":"no","netw`, + `[]`, + `null`, + `42`, + `"/web"`, + `{"name":"/typed","image":"x","restart":"no","network":"bridge","ports":"80"}`, + `{"name":"/typed2","image":7,"restart":"no","network":"bridge","ports":{}}`, + `{"name":"/ok","image":"nginx","restart":"no","network":"bridge","ports":{}}` + "\r", + ` {"name":"/ok2","image":"nginx","restart":"","network":"bridge","ports":{}} `, + ``, + }, "\n") + got := ParseContainers(in) + if len(got) != 2 || got[0].Name != "ok" || got[1].Name != "ok2" || got[1].RestartPolicy != "" { + t.Errorf("ParseContainers() = %#v", got) + } +} + +func containerLine(name, policy string) string { + nameJSON, _ := json.Marshal(name) + policyJSON, _ := json.Marshal(policy) + return `{"name":` + string(nameJSON) + `,"image":"x","restart":` + string(policyJSON) + `,"network":"bridge","ports":{}}` + "\n" +} + +func TestParseContainersValidatesNamesAndPolicies(t *testing.T) { + var accepted = []string{"web", "/web", "a", "A1", "0day", "my_app-1.2", strings.Repeat("a", 128), "/" + strings.Repeat("a", 128)} + for _, name := range accepted { + if got := ParseContainers(containerLine(name, "always")); len(got) != 1 || got[0].Name != strings.TrimPrefix(name, "/") { + t.Errorf("name %q gave %#v", name, got) + } + } + for _, name := range []string{"", "/", "//web", "-web", "_web", ".web", "/-web", "a b", "/a b", "x;rm -rf /", "$(id)", "`id`", "a\nb", "a\tb", "a/b", "web\x00", "wéb", "a&b", "a|b", "a>b", "a'b", `a"b`, "a\\b", "a*", "a:b", "a@b", strings.Repeat("a", 129), "/" + strings.Repeat("a", 129), "CASAOS_DOCKER_UPDATE_SUCCESS x"} { + if got := ParseContainers(containerLine(name, "always")); len(got) != 0 { + t.Errorf("name %q was accepted: %#v", name, got) + } + } + for _, policy := range []string{"", "no", "always", "unless-stopped", "on-failure"} { + if got := ParseContainers(containerLine("web", policy)); len(got) != 1 || got[0].RestartPolicy != policy { + t.Errorf("policy %q gave %#v", policy, got) + } + } + for _, policy := range []string{"Always", "always;x", "always x", "on-failure:5", "$(id)", "a\nb", "alwayś", strings.Repeat("a", 33), "1", "no_"} { + if got := ParseContainers(containerLine("web", policy)); len(got) != 0 { + t.Errorf("policy %q was accepted: %#v", policy, got) + } + } +} + +func TestParseContainersBoundsAndCleansTheImage(t *testing.T) { + // one byte, then two-byte characters: the 256th byte is the first half of one + long := "a" + strings.Repeat("é", 1000) + line := `{"name":"/web","image":` + mustJSON(long) + `,"restart":"no","network":"bridge","ports":{}}` + got := ParseContainers(line) + if len(got) != 1 { + t.Fatalf("an overlong image gave %#v", got) + } + if n := len(got[0].Image); n != 255 || !utf8.ValidString(got[0].Image) { + t.Fatalf("an overlong image gave %d bytes, valid %v", n, utf8.ValidString(got[0].Image)) + } + line = `{"name":"/web","image":` + mustJSON("ngi\x1b[31mnx\r\n\x00\x7f:1") + `,"restart":"no","network":"bridge","ports":{}}` + if got := ParseContainers(line); len(got) != 1 || got[0].Image != "ngi[31mnx:1" { + t.Errorf("control characters in the image gave %#v", got) + } + // text stays text: the page escapes it + html := `` + line = `{"name":"/web","image":` + mustJSON(html) + `,"restart":"no","network":"bridge","ports":{}}` + if got := ParseContainers(line); len(got) != 1 || got[0].Image != html { + t.Errorf("an image with HTML gave %#v", got) + } +} + +func mustJSON(v any) string { + b, err := json.Marshal(v) + if err != nil { + panic(err) + } + return string(b) +} + +func TestParseContainersPorts(t *testing.T) { + ports := `{` + + `"80/tcp":[{"HostIp":"0.0.0.0","HostPort":"8080"}],` + // published + `"81":[{"HostIp":"0.0.0.0","HostPort":"8081"}],` + // no protocol: tcp + `"443/tcp":null,` + // exposed, not published + `"8443/tcp":[],` + + `"9000/tcp":[{"HostIp":"0.0.0.0","HostPort":""}],` + + `"9001/tcp":[{"HostIp":"0.0.0.0","HostPort":"0"}],` + + `"9002/tcp":[{"HostIp":"0.0.0.0","HostPort":"99999"}],` + + `"9003/tcp":[{"HostIp":"0.0.0.0","HostPort":"-1"}],` + + `"9004/tcp":[{"HostIp":"0.0.0.0","HostPort":"+80"}],` + + `"9005/tcp":[{"HostIp":"0.0.0.0","HostPort":"80a"}],` + + `"9006/tcp":[{"HostIp":"0.0.0.0","HostPort":"1-2"}],` + + `"0/tcp":[{"HostIp":"0.0.0.0","HostPort":"1234"}],` + + `"70000/tcp":[{"HostIp":"0.0.0.0","HostPort":"1235"}],` + + `"abc/tcp":[{"HostIp":"0.0.0.0","HostPort":"1236"}],` + + `"-5/tcp":[{"HostIp":"0.0.0.0","HostPort":"1237"}],` + + `"80/x;rm":[{"HostIp":"0.0.0.0","HostPort":"1238"}],` + + `"80/":[{"HostIp":"0.0.0.0","HostPort":"1239"}],` + + `"53/udp":[{"HostIp":"0.0.0.0","HostPort":"53"},{"HostIp":"::","HostPort":"53"}],` + + `"5000/sctp":[{"HostIp":"0.0.0.0","HostPort":"5000"}],` + + `"1000/tcp":[{"HostIp":"0.0.0.0","HostPort":"2000"},{"HostIp":"0.0.0.0","HostPort":"1999"}]` + + `}` + got := ParseContainers(`{"name":"/p","image":"x","restart":"no","network":"bridge","ports":` + ports + `}`) + want := []Port{ + {Port: 53, Protocol: "udp", HostPort: 53}, + {Port: 80, Protocol: "tcp", HostPort: 8080}, + {Port: 81, Protocol: "tcp", HostPort: 8081}, + {Port: 1000, Protocol: "tcp", HostPort: 1999}, + {Port: 1000, Protocol: "tcp", HostPort: 2000}, + {Port: 5000, Protocol: "sctp", HostPort: 5000}, + } + if len(got) != 1 || !reflect.DeepEqual(got[0].Ports, want) { + t.Errorf("ports = %#v\nwant %#v", got, want) + } +} + +func TestParseContainersHostNetwork(t *testing.T) { + for network, want := range map[string]bool{"host": true, "bridge": false, "default": false, "none": false, "container:abc": false, "Host": false, "host ": false, "": false, "my-host": false} { + line := `{"name":"/n","image":"x","restart":"no","network":` + mustJSON(network) + `,"ports":{}}` + if got := ParseContainers(line); len(got) != 1 || got[0].HostNetwork != want { + t.Errorf("network %q gave %#v, want host network %v", network, got, want) + } + } +} + +func TestParseContainersSortedByName(t *testing.T) { + got := ParseContainers(containerLine("zeta", "no") + containerLine("alpha", "no") + containerLine("Beta", "no") + containerLine("mid", "no")) + var names []string + for _, c := range got { + names = append(names, c.Name) + } + if !reflect.DeepEqual(names, []string{"Beta", "alpha", "mid", "zeta"}) { + t.Errorf("names = %v", names) + } +} + +func TestDaemonInfoFormatRoundTrip(t *testing.T) { + if strings.ContainsAny(DaemonInfoFormat, "\n\r") { + t.Errorf("the format spreads over several lines: %q", DaemonInfoFormat) + } + info := func(version, swarm string) map[string]any { + return map[string]any{"ServerVersion": version, "Swarm": map[string]any{"LocalNodeState": swarm}} + } + cases := []struct { + version, swarm string + want DaemonInfo + }{ + {"28.0.4", "inactive", DaemonInfo{ServerVersion: "28.0.4"}}, + {"29.8.0", "active", DaemonInfo{ServerVersion: "29.8.0", SwarmActive: true}}, + {"29.8.0", "", DaemonInfo{ServerVersion: "29.8.0"}}, + {"29.8.0", "pending", DaemonInfo{ServerVersion: "29.8.0"}}, + {"29.8.0", "error", DaemonInfo{ServerVersion: "29.8.0"}}, + {"29.8.0", "locked", DaemonInfo{ServerVersion: "29.8.0"}}, + {"25.0.0-rc.1", "inactive", DaemonInfo{ServerVersion: "25.0.0-rc.1"}}, + {"20.10.24+dfsg1", "inactive", DaemonInfo{ServerVersion: "20.10.24+dfsg1"}}, + } + for _, c := range cases { + out := dockerTemplate(t, DaemonInfoFormat, info(c.version, c.swarm)) + "\n" + got, err := ParseDaemonInfo(out) + if err != nil || got != c.want { + t.Errorf("%s / %s: ParseDaemonInfo(%q) = %#v, %v, want %#v", c.version, c.swarm, out, got, err, c.want) + } + } +} + +func TestParseDaemonInfo(t *testing.T) { + good := map[string]DaemonInfo{ + `{"ServerVersion":"29.8.0","SwarmState":"inactive"}`: {ServerVersion: "29.8.0"}, + `{"ServerVersion":"29.8.0","SwarmState":"active"}` + "\n": {ServerVersion: "29.8.0", SwarmActive: true}, + " {\"ServerVersion\":\"29.8.0\",\"SwarmState\":\"active\"}\r\n": {ServerVersion: "29.8.0", SwarmActive: true}, + `{"ServerVersion":"29.8.0"}`: {ServerVersion: "29.8.0"}, + `{"ServerVersion":"29.8.0","SwarmState":"Active"}`: {ServerVersion: "29.8.0"}, + "WARNING: something\n" + `{"ServerVersion":"28.0.4","SwarmState":"inactive"}`: {ServerVersion: "28.0.4"}, + `{"ServerVersion":"28.0.4","SwarmState":"inactive"}` + "\n" + `{"ServerVersion":"1.0","SwarmState":"active"}`: {ServerVersion: "28.0.4"}, + } + for in, want := range good { + if got, err := ParseDaemonInfo(in); err != nil || got != want { + t.Errorf("ParseDaemonInfo(%q) = %#v, %v, want %#v", in, got, err, want) + } + } + for _, in := range []string{ + "", "\n", "garbage", "[]", "null", "{}", `{"ServerVersion":""}`, `{"ServerVersion":null}`, `{"ServerVersion":7}`, + // what Docker CLI 29.8.0 printed for DaemonInfoFormat with no daemon running (exit status 1) + `{"ServerVersion":"","SwarmState":""}` + "\n", + `{"ServerVersion":"29.8.0"`, // cut off + `{"SwarmState":"active"}`, // the daemon did not answer: the CLI prints what it knows of itself + `{"ServerVersion":"29.8.0;reboot"}`, `{"ServerVersion":"29 8"}`, `{"ServerVersion":"$(id)"}`, `{"ServerVersion":"-1"}`, + `{"ServerVersion":"` + strings.Repeat("1", 101) + `"}`, `{"ServerVersion":"29.8.0\n"}`, + "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", + } { + if got, err := ParseDaemonInfo(in); err == nil { + t.Errorf("ParseDaemonInfo(%q) = %#v, want an error", in, got) + } + } +} From 2684394a0e04ac3d706468cbd9c1a67d1b0d5e6d Mon Sep 17 00:00:00 2001 From: Gary Date: Fri, 9 Oct 2026 09:50:08 +0200 Subject: [PATCH 04/33] feat(packages): the run log of a Docker update and its marker parser The first line of the log is the core's, with a random nonce; the parser takes the nonce from that line only and counts a later line as a marker only if it has the exact shape and carries that nonce. Apt output, a container named like a marker and a forged nonce are ignored, every field is validated again, and the last terminal marker wins. The rollback command is built from validated pins only. --- pkg/dockerpkg/run.go | 250 ++++++++++++++++++++++ pkg/dockerpkg/run_test.go | 426 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 676 insertions(+) create mode 100644 pkg/dockerpkg/run.go create mode 100644 pkg/dockerpkg/run_test.go diff --git a/pkg/dockerpkg/run.go b/pkg/dockerpkg/run.go new file mode 100644 index 00000000..26680553 --- /dev/null +++ b/pkg/dockerpkg/run.go @@ -0,0 +1,250 @@ +package dockerpkg + +import ( + "crypto/rand" + "encoding/hex" + "strings" + "time" +) + +// The Docker update runs in a unit of its own and reports in a log file. Everything the core +// shows of a run, it reads back from that file; and everything in that file is suspect, since +// apt, dpkg, the packages' scripts and Docker all write to it. A line counts as the unit's +// word only if it has the shape of a marker and carries the run's nonce, which only the +// core's first line and the unit's environment hold. + +const markerPrefix = "CASAOS_DOCKER_UPDATE_" + +// How a run ended (Run.Terminal). +const ( + // TerminalSuccess is an engine that was installed, runs, and answers. + TerminalSuccess = "success" + // TerminalRestartPending is an engine installed whose daemon is still the old one. + TerminalRestartPending = "restart_pending" + // TerminalFailed is a run that stopped, with a reason (Run.FailReason). + TerminalFailed = "failed" +) + +// Why a run failed (Run.FailReason). +const ( + FailGuard = "guard" + FailDownload = "download" + FailInstall = "install" + FailDaemon = "daemon" +) + +// NewNonce is 16 random bytes from the system's source, in lower case hex. +func NewNonce() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// ValidNonce reports whether s is 32 lower case hex digits, what NewNonce makes. +func ValidNonce(s string) bool { + if len(s) != 32 { + return false + } + for i := 0; i < len(s); i++ { + if c := s[i]; (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return false + } + } + return true +} + +// QueuedMarker is the first line of the log, which the core writes (and so truncates the log +// with) before it starts the unit: the nonce is read from it, and from no other line. +func QueuedMarker(nonce string, at time.Time) string { + return markerPrefix + "QUEUED " + nonce + " " + at.UTC().Format(time.RFC3339) + "\n" +} + +// NotReturned is a container that was running before the update and is not running after it. +type NotReturned struct { + Name string `json:"name"` + RestartPolicy string `json:"restart_policy"` +} + +// Run is what a log says of a Docker update. Its zero value is "no run": the log has no valid +// first line. Previous and NotReturned are nil when the log has none. +type Run struct { + Nonce string + // QueuedAt is the time on the first line; StartedAt the unit's first word; InstalledAt + // the end of the install, zero if it never got there; CompletedAt the terminal marker's. + QueuedAt, StartedAt, InstalledAt, CompletedAt time.Time + // Terminal is TerminalSuccess, TerminalRestartPending, TerminalFailed, or "" while the + // log has no valid terminal marker. The last valid one counts. + Terminal string + // FailReason is FailGuard, FailDownload, FailInstall or FailDaemon, when Terminal is + // TerminalFailed. + FailReason string + // Previous are the engine's packages as they were installed before the update, as + // "name=version", each validated: what a rollback would install. + Previous []string + // DaemonVersion is the version the daemon reported once it was back, "" if it never did. + DaemonVersion string + // NotReturned are the containers that were running before and were not after. + NotReturned []NotReturned +} + +// markerOf splits a line that is a marker of this run: "CASAOS_DOCKER_UPDATE_ " +// and, if there is more, a space and the fields. Anything else (a prefix in the middle of a +// line, another nonce, another case) is not one. +func markerOf(line, nonce string) (kind string, fields []string, ok bool) { + rest, found := strings.CutPrefix(line, markerPrefix) + if !found { + return "", nil, false + } + kind, rest, found = strings.Cut(rest, " ") + if !found { + return "", nil, false + } + given, rest, _ := strings.Cut(rest, " ") + if given != nonce { + return "", nil, false + } + return kind, strings.Fields(rest), true +} + +func parseMarkerTime(s string) (time.Time, bool) { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return time.Time{}, false + } + return t.UTC(), true +} + +// validPin is "name=version" for a package of the engine and a version that passes +// ValidVersion: the only thing that may be put on an apt command line. +func validPin(pin string) bool { + name, version, found := strings.Cut(pin, "=") + return found && ValidName(name) && IsEngineName(name) && ValidVersion(version) +} + +// ParseRun reads the log of a Docker update. The nonce is the one on line 1, which must be +// exactly the core's QUEUED marker; with no such line it is the zero Run. After that a line +// is read only if it is "CASAOS_DOCKER_UPDATE_ " with the fields its KIND +// takes; every field is validated again; and what is not a marker is ignored, whatever it +// looks like: apt's output, a container named like a marker, a forged nonce. The last +// terminal marker wins, the first STARTED, INSTALLED and PREVIOUS, the last DAEMON. +// +// A caller that reads only the end of a long log must put line 1 back in front of it. +func ParseRun(log string) Run { + head, tail, _ := strings.Cut(log, "\n") + first := strings.Split(strings.TrimRight(head, "\r"), " ") + if len(first) != 3 || first[0] != markerPrefix+"QUEUED" || !ValidNonce(first[1]) { + return Run{} + } + queued, ok := parseMarkerTime(first[2]) + if !ok { + return Run{} + } + run := Run{Nonce: first[1], QueuedAt: queued} + for _, line := range strings.Split(tail, "\n") { + kind, fields, ok := markerOf(strings.TrimRight(line, "\r"), run.Nonce) + if !ok { + continue + } + switch kind { + case "STARTED": + if t, ok := oneTime(fields); ok && run.StartedAt.IsZero() { + run.StartedAt = t + } + case "INSTALLED": + if t, ok := oneTime(fields); ok && run.InstalledAt.IsZero() { + run.InstalledAt = t + } + case "PREVIOUS": + if run.Previous == nil { + run.Previous = validPins(fields) + } + case "DAEMON": + if len(fields) == 1 && validDaemonVersion(fields[0]) { + run.DaemonVersion = fields[0] + } + case "NOTRETURNED": + if container, ok := notReturned(fields); ok { + run.NotReturned = append(run.NotReturned, container) + } + case "SUCCESS", "RESTART_PENDING": + if t, ok := oneTime(fields); ok { + run.Terminal, run.FailReason, run.CompletedAt = TerminalSuccess, "", t + if kind == "RESTART_PENDING" { + run.Terminal = TerminalRestartPending + } + } + case "FAILED": + if len(fields) == 2 && validFailReason(fields[1]) { + if t, ok := parseMarkerTime(fields[0]); ok { + run.Terminal, run.FailReason, run.CompletedAt = TerminalFailed, fields[1], t + } + } + } + } + return run +} + +// oneTime is the fields of a marker that holds a time and nothing else. +func oneTime(fields []string) (time.Time, bool) { + if len(fields) != 1 { + return time.Time{}, false + } + return parseMarkerTime(fields[0]) +} + +func validFailReason(reason string) bool { + switch reason { + case FailGuard, FailDownload, FailInstall, FailDaemon: + return true + } + return false +} + +// validPins keeps the valid "name=version" fields, once each, in order; nil when none is. +func validPins(fields []string) []string { + var pins []string + seen := map[string]struct{}{} + for _, pin := range fields { + if _, dup := seen[pin]; dup || !validPin(pin) { + continue + } + seen[pin] = struct{}{} + pins = append(pins, pin) + } + return pins +} + +// notReturned reads the fields of a NOTRETURNED marker: a container's name and its restart +// policy, which is empty when Docker had none to tell. +func notReturned(fields []string) (NotReturned, bool) { + if len(fields) == 0 || len(fields) > 2 { + return NotReturned{}, false + } + container := NotReturned{Name: fields[0]} + if len(fields) == 2 { + container.RestartPolicy = fields[1] + } + if !validContainerName(container.Name) || !validRestartPolicy(container.RestartPolicy) { + return NotReturned{}, false + } + return container, true +} + +// RollbackCommand is the command that puts the engine back as it was, from the "name=version" +// pins of Run.Previous: "sudo apt-get install --allow-downgrades name=version ...". It is +// built from the pins that pass validation, and only from those, so that no shell character +// is in it; "" when there is none. Nothing runs it: it is shown to the owner. +func RollbackCommand(previous []string) string { + var pins []string + for _, pin := range previous { + if validPin(pin) { + pins = append(pins, pin) + } + } + if len(pins) == 0 { + return "" + } + return "sudo apt-get install --allow-downgrades " + strings.Join(pins, " ") +} diff --git a/pkg/dockerpkg/run_test.go b/pkg/dockerpkg/run_test.go new file mode 100644 index 00000000..0ee068f5 --- /dev/null +++ b/pkg/dockerpkg/run_test.go @@ -0,0 +1,426 @@ +package dockerpkg + +import ( + "reflect" + "strings" + "testing" + "time" +) + +const ( + testNonce = "0f1e2d3c4b5a69788796a5b4c3d2e1f0" + otherNonce = "ffffffffffffffffffffffffffffffff" +) + +var ( + t0 = time.Date(2026, 10, 9, 10, 0, 0, 0, time.UTC) + ts = func(seconds int) string { return t0.Add(time.Duration(seconds) * time.Second).Format(time.RFC3339) } +) + +func marker(kind, fields string) string { + line := "CASAOS_DOCKER_UPDATE_" + kind + " " + testNonce + if fields != "" { + line += " " + fields + } + return line +} + +func logOf(lines ...string) string { return strings.Join(lines, "\n") + "\n" } + +func TestQueuedMarker(t *testing.T) { + if got, want := QueuedMarker(testNonce, t0), "CASAOS_DOCKER_UPDATE_QUEUED "+testNonce+" 2026-10-09T10:00:00Z\n"; got != want { + t.Errorf("QueuedMarker() = %q, want %q", got, want) + } + // always UTC, whatever the clock's zone + zone := time.FixedZone("CEST", 2*60*60) + if got, want := QueuedMarker(testNonce, time.Date(2026, 10, 9, 12, 0, 0, 0, zone)), "CASAOS_DOCKER_UPDATE_QUEUED "+testNonce+" 2026-10-09T10:00:00Z\n"; got != want { + t.Errorf("QueuedMarker() in another zone = %q, want %q", got, want) + } + // and what the parser takes as its first line + run := ParseRun(QueuedMarker(testNonce, t0)) + if run.Nonce != testNonce || !run.QueuedAt.Equal(t0) { + t.Errorf("ParseRun(QueuedMarker()) = %#v", run) + } +} + +func TestValidNonce(t *testing.T) { + for _, s := range []string{testNonce, otherNonce, strings.Repeat("0", 32), strings.Repeat("a", 32), "0123456789abcdef0123456789abcdef"} { + if !ValidNonce(s) { + t.Errorf("ValidNonce(%q) = false", s) + } + } + for _, s := range []string{"", strings.Repeat("a", 31), strings.Repeat("a", 33), strings.ToUpper(testNonce), strings.Repeat("g", 32), strings.Repeat("a", 31) + " ", strings.Repeat("a", 31) + "\n", " " + strings.Repeat("a", 31), "0x" + strings.Repeat("a", 30), strings.Repeat("a", 31) + "\u00e9", strings.Repeat("a", 16) + "\x00" + strings.Repeat("a", 15)} { + if ValidNonce(s) { + t.Errorf("ValidNonce(%q) = true", s) + } + } +} + +func TestNewNonce(t *testing.T) { + a, err := NewNonce() + if err != nil || !ValidNonce(a) { + t.Fatalf("NewNonce() = %q, %v", a, err) + } + if b, _ := NewNonce(); a == b { + t.Errorf("two nonces are both %q", a) + } +} + +func TestParseRunSuccess(t *testing.T) { + log := logOf( + strings.TrimSuffix(QueuedMarker(testNonce, t0), "\n"), + marker("STARTED", ts(1)), + marker("PREVIOUS", "containerd.io=1.7.27-1 docker-ce=5:28.0.4-1~debian.11~bullseye docker-ce-cli=5:28.0.4-1~debian.11~bullseye"), + "container: web always", + "container: db unless-stopped", + "container: cache no", + "Reading package lists...", + "Get:1 https://download.docker.com/linux/debian bullseye/stable amd64 docker-ce amd64 5:29.8.0-1~debian.11~bullseye [20.5 MB]", + marker("DOWNLOADED", ts(60)), + "Preparing to unpack .../docker-ce_5%3a29.8.0-1~debian.11~bullseye_amd64.deb ...", + marker("INSTALLED", ts(90)), + marker("DAEMON", "29.8.0"), + marker("NOTRETURNED", "cache no"), + marker("SUCCESS", ts(100)), + ) + want := Run{ + Nonce: testNonce, QueuedAt: t0, StartedAt: t0.Add(1 * time.Second), InstalledAt: t0.Add(90 * time.Second), CompletedAt: t0.Add(100 * time.Second), + Terminal: TerminalSuccess, DaemonVersion: "29.8.0", + Previous: []string{"containerd.io=1.7.27-1", "docker-ce=5:28.0.4-1~debian.11~bullseye", "docker-ce-cli=5:28.0.4-1~debian.11~bullseye"}, + NotReturned: []NotReturned{{Name: "cache", RestartPolicy: "no"}}, + } + got := ParseRun(log) + if !reflect.DeepEqual(got, want) { + t.Errorf("ParseRun() =\n%#v\nwant\n%#v", got, want) + } +} + +func TestParseRunTerminalMarkers(t *testing.T) { + head := []string{QueuedMarker(testNonce, t0)[:len(QueuedMarker(testNonce, t0))-1], marker("STARTED", ts(1))} + cases := []struct { + name string + tail []string + terminal string + reason string + completedSec int + }{ + {"nothing yet", nil, "", "", 0}, + {"success", []string{marker("SUCCESS", ts(10))}, TerminalSuccess, "", 10}, + {"restart pending", []string{marker("DAEMON", "28.0.4"), marker("RESTART_PENDING", ts(11))}, TerminalRestartPending, "", 11}, + {"failed guard", []string{marker("GUARD", "plan"), marker("FAILED", ts(12)+" guard")}, TerminalFailed, FailGuard, 12}, + {"failed download", []string{marker("FAILED", ts(13)+" download")}, TerminalFailed, FailDownload, 13}, + {"failed install", []string{marker("FAILED", ts(14)+" install")}, TerminalFailed, FailInstall, 14}, + {"failed daemon", []string{marker("FAILED", ts(15)+" daemon")}, TerminalFailed, FailDaemon, 15}, + {"the last terminal marker wins (failed after success)", []string{marker("SUCCESS", ts(10)), marker("FAILED", ts(20)+" daemon")}, TerminalFailed, FailDaemon, 20}, + {"the last terminal marker wins (success after failed)", []string{marker("FAILED", ts(10)+" install"), marker("SUCCESS", ts(21))}, TerminalSuccess, "", 21}, + {"a failure with no reason is not a marker", []string{marker("FAILED", ts(10))}, "", "", 0}, + {"a failure with an unknown reason is not a marker", []string{marker("FAILED", ts(10)+" reboot")}, "", "", 0}, + {"a failure with a hostile reason is not a marker", []string{marker("FAILED", ts(10)+" install;reboot")}, "", "", 0}, + {"a failure with a second reason is not a marker", []string{marker("FAILED", ts(10)+" install daemon")}, "", "", 0}, + {"a success with no time is not a marker", []string{marker("SUCCESS", "")}, "", "", 0}, + {"a success with a bad time is not a marker", []string{marker("SUCCESS", "yesterday"), marker("SUCCESS", "2026-10-09 10:00:00"), marker("SUCCESS", "2026-13-09T10:00:00Z")}, "", "", 0}, + {"a success with a field too many is not a marker", []string{marker("SUCCESS", ts(10)+" extra")}, "", "", 0}, + {"a bad terminal marker leaves the good one before it", []string{marker("SUCCESS", ts(10)), marker("FAILED", ts(11)+" nonsense")}, TerminalSuccess, "", 10}, + {"a time in another zone is UTC", []string{marker("SUCCESS", "2026-10-09T12:00:30+02:00")}, TerminalSuccess, "", 30}, + } + for _, c := range cases { + got := ParseRun(logOf(append(append([]string{}, head...), c.tail...)...)) + if got.Nonce != testNonce || got.Terminal != c.terminal || got.FailReason != c.reason { + t.Errorf("%s: Terminal = %q, FailReason = %q (nonce %q), want %q, %q", c.name, got.Terminal, got.FailReason, got.Nonce, c.terminal, c.reason) + } + wantCompleted := time.Time{} + if c.terminal != "" { + wantCompleted = t0.Add(time.Duration(c.completedSec) * time.Second) + } + if !got.CompletedAt.Equal(wantCompleted) || got.CompletedAt.Location() != time.UTC && !got.CompletedAt.IsZero() { + t.Errorf("%s: CompletedAt = %v, want %v", c.name, got.CompletedAt, wantCompleted) + } + } +} + +func TestParseRunNeedsTheFirstLine(t *testing.T) { + good := marker("QUEUED", ts(0)) + later := []string{marker("STARTED", ts(1)), marker("DAEMON", "29.8.0"), marker("SUCCESS", ts(5))} + for name, first := range map[string]string{ + "nothing": "", + "apt output": "Reading package lists...", + "the truncation notice": "[Earlier log output omitted]", + "a started marker": marker("STARTED", ts(0)), + "a leading space": " " + good, + "a leading tab": "\t" + good, + "a byte order mark": "\ufeff" + good, + "a short nonce": "CASAOS_DOCKER_UPDATE_QUEUED " + testNonce[:31] + " " + ts(0), + "a long nonce": "CASAOS_DOCKER_UPDATE_QUEUED " + testNonce + "0 " + ts(0), + "an upper case nonce": "CASAOS_DOCKER_UPDATE_QUEUED " + strings.ToUpper(testNonce) + " " + ts(0), + "a nonce that is not hex": "CASAOS_DOCKER_UPDATE_QUEUED " + strings.Repeat("z", 32) + " " + ts(0), + "no time": "CASAOS_DOCKER_UPDATE_QUEUED " + testNonce, + "a bad time": "CASAOS_DOCKER_UPDATE_QUEUED " + testNonce + " soon", + "a field too many": good + " extra", + "another prefix": "CASAOS_PACKAGE_UPDATE_QUEUED " + testNonce + " " + ts(0), + "two spaces": "CASAOS_DOCKER_UPDATE_QUEUED " + testNonce + " " + ts(0), + "lower case": strings.ToLower(good), + } { + got := ParseRun(logOf(append([]string{first}, later...)...)) + if !reflect.DeepEqual(got, Run{}) { + t.Errorf("%s: ParseRun() = %#v, want the zero Run", name, got) + } + } + // the first line is the first line: a good marker on the second line does not count + if got := ParseRun(logOf(append([]string{"Reading package lists...", good}, later...)...)); !reflect.DeepEqual(got, Run{}) { + t.Errorf("a QUEUED line that is not first gave %#v", got) + } + if got := ParseRun(""); !reflect.DeepEqual(got, Run{}) { + t.Errorf("ParseRun(\"\") = %#v", got) + } + // a first line with no new line yet, or with a carriage return, is the first line + if got := ParseRun(good); got.Nonce != testNonce || got.Terminal != "" { + t.Errorf("ParseRun(first line only) = %#v", got) + } + if got := ParseRun(good + "\r\n" + marker("SUCCESS", ts(5)) + "\r\n"); got.Nonce != testNonce || got.Terminal != TerminalSuccess { + t.Errorf("ParseRun(CRLF log) = %#v", got) + } + // the one nonce is the one on line one: a second QUEUED line changes nothing + other := "CASAOS_DOCKER_UPDATE_QUEUED " + otherNonce + " " + ts(50) + got := ParseRun(logOf(good, other, marker("QUEUED", ts(60)), marker("SUCCESS", ts(70)))) + if got.Nonce != testNonce || !got.QueuedAt.Equal(t0) || got.Terminal != TerminalSuccess { + t.Errorf("a second QUEUED line changed the run: %#v", got) + } +} + +func TestParseRunIgnoresForgedAndHostileLines(t *testing.T) { + forged := func(kind, fields string) string { + return "CASAOS_DOCKER_UPDATE_" + kind + " " + otherNonce + " " + fields + } + log := logOf( + strings.TrimSuffix(QueuedMarker(testNonce, t0), "\n"), + marker("STARTED", ts(1)), + // a container named like a marker, in the unit's own snapshot lines + "container: CASAOS_DOCKER_UPDATE_SUCCESS always", + "container: CASAOS_DOCKER_UPDATE_FAILED "+ts(2)+" install no", + // the right kind with the wrong nonce + forged("SUCCESS", ts(3)), + forged("FAILED", ts(3)+" install"), + forged("DAEMON", "6.6.6"), + forged("PREVIOUS", "docker-ce=5:1.0"), + forged("NOTRETURNED", "forged no"), + "CASAOS_DOCKER_UPDATE_SUCCESS "+testNonce[:31]+" "+ts(3), + "CASAOS_DOCKER_UPDATE_SUCCESS "+testNonce+"0 "+ts(3), + "CASAOS_DOCKER_UPDATE_SUCCESS "+strings.ToUpper(testNonce)+" "+ts(3), + // no nonce at all + "CASAOS_DOCKER_UPDATE_SUCCESS", + "CASAOS_DOCKER_UPDATE_SUCCESS "+ts(3), + // the right nonce, in the middle of apt or dpkg output + "Setting up docker-ce (5:29.8.0-1~debian.11~bullseye) ... "+marker("SUCCESS", ts(4)), + "Processing triggers for man-db ... "+marker("FAILED", ts(4)+" install"), + "x"+marker("SUCCESS", ts(4)), + " "+marker("SUCCESS", ts(4)), + "\t"+marker("SUCCESS", ts(4)), + "| "+marker("SUCCESS", ts(4)), + "> "+marker("FAILED", ts(4)+" daemon"), + "container: "+marker("SUCCESS", ts(4)), + // the wrong prefix, case, kind or spacing + strings.ToLower(marker("SUCCESS", ts(5))), + "CASAOS_DOCKER_UPDATE_SUCCESSX "+testNonce+" "+ts(5), + "CASAOS_DOCKER_UPDATE_SUCCES "+testNonce+" "+ts(5), + "CASAOS_DOCKER_UPDATE_ "+testNonce+" "+ts(5), + "CASAOS_DOCKER_UPDATE_BOGUS "+testNonce+" "+ts(5), + "CASAOS_DOCKER_UPDATE_SUCCESS\t"+testNonce+" "+ts(5), + "CASAOS_DOCKER_UPDATE_SUCCESS "+testNonce+" "+ts(5), + "CASAOS_DOCKER_UPDATE_SUCCESS"+testNonce+" "+ts(5), + "CASAOS_PACKAGE_UPDATE_SUCCESS "+testNonce+" "+ts(5), + "CASAOS_DOCKER_UPDATE_SUCCESS "+testNonce, // no time + "Reading package lists... Done", + ) + got := ParseRun(log) + want := Run{Nonce: testNonce, QueuedAt: t0, StartedAt: t0.Add(time.Second)} + if !reflect.DeepEqual(got, want) { + t.Errorf("hostile lines changed the run:\n%#v\nwant\n%#v", got, want) + } + // and a good marker after all of that still counts + got = ParseRun(log + marker("SUCCESS", ts(9)) + "\n") + if got.Terminal != TerminalSuccess || !got.CompletedAt.Equal(t0.Add(9*time.Second)) { + t.Errorf("the genuine marker after hostile lines gave %#v", got) + } +} + +func TestParseRunAContainerNamedLikeAMarkerIsOnlyAName(t *testing.T) { + log := logOf( + strings.TrimSuffix(QueuedMarker(testNonce, t0), "\n"), + marker("NOTRETURNED", "CASAOS_DOCKER_UPDATE_SUCCESS no"), + marker("NOTRETURNED", "CASAOS_DOCKER_UPDATE_FAILED always"), + ) + got := ParseRun(log) + want := []NotReturned{{Name: "CASAOS_DOCKER_UPDATE_SUCCESS", RestartPolicy: "no"}, {Name: "CASAOS_DOCKER_UPDATE_FAILED", RestartPolicy: "always"}} + if got.Terminal != "" || !reflect.DeepEqual(got.NotReturned, want) { + t.Errorf("ParseRun() = %#v", got) + } +} + +func TestParseRunValidatesPrevious(t *testing.T) { + long := func(n int) string { return strings.Repeat("a", n) } + fields := []string{ + "containerd.io=1.7.27-1", // good + "docker-ce;rm=5:1", // a shell character in the name + "docker-ce=5:1;reboot", // ... in the version + "docker-ce-cli=5:1`id`", // a backtick + "docker-buildx-plugin=$(id)", // a substitution + "evil=1.0", // a name that is not one of the seven + "libc6=2.31-13", // a good name that is not the engine + "docker.io=26.1.5", // the distribution's package + "docker-ce=5:1=2", // two equals + "docker-ce", // no version + "docker-ce=", // an empty version + "=1.0", // an empty name + "docker-model-plugin=" + strings.Repeat("1", 101), // overlong version + long(129) + "=1.0", // overlong name + "-oAPT::Foo=1", // an option + "docker-ce:amd64;reboot=5:1", // an architecture that is a command + "containerd.io:$(id)=1.0", // ... or a substitution + "DOCKER-CE=5:1", // upper case + "docker-ce=1\u00e9", // not ASCII + "docker-compose-plugin=2.34.0-1", // good + "containerd.io=1.7.27-1", // the same, twice + "docker-ce:amd64=5:28.0.4-1", // good, with an architecture + } + got := ParseRun(logOf(QueuedMarker(testNonce, t0)[:len(QueuedMarker(testNonce, t0))-1], marker("PREVIOUS", strings.Join(fields, " ")))).Previous + want := []string{"containerd.io=1.7.27-1", "docker-compose-plugin=2.34.0-1", "docker-ce:amd64=5:28.0.4-1"} + if !reflect.DeepEqual(got, want) { + t.Errorf("Previous = %#v, want %#v", got, want) + } + // nothing valid, or nothing at all: no list + for _, only := range []string{"", "evil=1.0 docker-ce;x=1"} { + got := ParseRun(logOf(QueuedMarker(testNonce, t0)[:len(QueuedMarker(testNonce, t0))-1], marker("PREVIOUS", only))).Previous + if len(got) != 0 { + t.Errorf("Previous of %q = %#v", only, got) + } + } + // the first snapshot is the snapshot + got = ParseRun(logOf(QueuedMarker(testNonce, t0)[:len(QueuedMarker(testNonce, t0))-1], marker("PREVIOUS", "docker-ce=5:28.0.4-1"), marker("PREVIOUS", "docker-ce=5:29.0.0-1"))).Previous + if !reflect.DeepEqual(got, []string{"docker-ce=5:28.0.4-1"}) { + t.Errorf("a second PREVIOUS marker gave %#v", got) + } +} + +func TestParseRunValidatesDaemonVersion(t *testing.T) { + first := QueuedMarker(testNonce, t0)[:len(QueuedMarker(testNonce, t0))-1] + for fields, want := range map[string]string{ + "29.8.0": "29.8.0", "28.0.4": "28.0.4", "25.0.0-rc.1": "25.0.0-rc.1", "20.10.24+dfsg1": "20.10.24+dfsg1", + "": "", "29.8.0;reboot": "", "29.8.0 extra": "", "$(id)": "", "`id`": "", "-1": "", "29/8": "", "29.8.0\r": "29.8.0", + strings.Repeat("1", 101): "", "1é": "", + } { + if got := ParseRun(logOf(first, marker("DAEMON", fields))).DaemonVersion; got != want { + t.Errorf("DaemonVersion of %q = %q, want %q", fields, got, want) + } + } + // the last good one is the daemon that is running now; a bad one does not replace it + got := ParseRun(logOf(first, marker("DAEMON", "28.0.4"), marker("DAEMON", "29.8.0"), marker("DAEMON", "29.8.0;x"))).DaemonVersion + if got != "29.8.0" { + t.Errorf("DaemonVersion = %q", got) + } +} + +func TestParseRunValidatesNotReturned(t *testing.T) { + first := QueuedMarker(testNonce, t0)[:len(QueuedMarker(testNonce, t0))-1] + long := strings.Repeat("a", 129) + cases := []struct { + fields string + want []NotReturned + }{ + {"cache no", []NotReturned{{"cache", "no"}}}, + {"db unless-stopped", []NotReturned{{"db", "unless-stopped"}}}, + {"my_app-1.2 on-failure", []NotReturned{{"my_app-1.2", "on-failure"}}}, + {"lonely", []NotReturned{{"lonely", ""}}}, // a container with no policy at all + {"lonely2 ", []NotReturned{{"lonely2", ""}}}, // ... printed with its trailing space + {strings.Repeat("a", 128) + " no", []NotReturned{{strings.Repeat("a", 128), "no"}}}, + {"", nil}, + {"a b always", nil}, // a name with a space is two fields and a policy + {"a;b no", nil}, // shell characters + {"$(id) no", nil}, + {"`id` no", nil}, + {"a|b no", nil}, + {"a&b no", nil}, + {"a>b no", nil}, + {"-dash no", nil}, // not a container name + {".dot no", nil}, + {long + " no", nil}, // overlong + {"web No", nil}, // upper case policy + {"web always;x", nil}, + {"web " + strings.Repeat("a", 33), nil}, + {"web 1", nil}, + {"web no extra", nil}, // a field too many + {"wéb no", nil}, + } + for _, c := range cases { + got := ParseRun(logOf(first, marker("NOTRETURNED", c.fields))).NotReturned + if !reflect.DeepEqual(got, c.want) && !(len(got) == 0 && len(c.want) == 0) { + t.Errorf("NotReturned of %q = %#v, want %#v", c.fields, got, c.want) + } + } + // several, in the order they were printed + got := ParseRun(logOf(first, marker("NOTRETURNED", "b no"), marker("NOTRETURNED", "bad;x no"), marker("NOTRETURNED", "a always"))).NotReturned + if want := []NotReturned{{"b", "no"}, {"a", "always"}}; !reflect.DeepEqual(got, want) { + t.Errorf("NotReturned = %#v, want %#v", got, want) + } +} + +func TestParseRunRunningAndSkippedSteps(t *testing.T) { + first := QueuedMarker(testNonce, t0)[:len(QueuedMarker(testNonce, t0))-1] + // queued, not started + if got := ParseRun(first); got.Nonce != testNonce || !got.QueuedAt.Equal(t0) || !got.StartedAt.IsZero() || got.Terminal != "" || !got.CompletedAt.IsZero() { + t.Errorf("a queued run = %#v", got) + } + // installed, but not finished + got := ParseRun(logOf(first, marker("STARTED", ts(1)), marker("INSTALLED", ts(30)))) + if got.Terminal != "" || !got.InstalledAt.Equal(t0.Add(30*time.Second)) { + t.Errorf("an installed run = %#v", got) + } + // a failed run that never installed has no InstalledAt + got = ParseRun(logOf(first, marker("STARTED", ts(1)), marker("GUARD", "plan"), marker("FAILED", ts(2)+" guard"))) + if got.Terminal != TerminalFailed || !got.InstalledAt.IsZero() { + t.Errorf("a refused run = %#v", got) + } + // the first STARTED is the start + got = ParseRun(logOf(first, marker("STARTED", ts(1)), marker("STARTED", ts(9)))) + if !got.StartedAt.Equal(t0.Add(time.Second)) { + t.Errorf("StartedAt = %v", got.StartedAt) + } +} + +func TestRollbackCommand(t *testing.T) { + got := RollbackCommand([]string{"docker-ce=5:28.0.4-1~debian.11~bullseye", "containerd.io=1.7.27-1", "docker-ce-cli=5:28.0.4-1~debian.11~bullseye"}) + want := "sudo apt-get install --allow-downgrades docker-ce=5:28.0.4-1~debian.11~bullseye containerd.io=1.7.27-1 docker-ce-cli=5:28.0.4-1~debian.11~bullseye" + if got != want { + t.Errorf("RollbackCommand() = %q, want %q", got, want) + } + for _, none := range [][]string{nil, {}, {""}} { + if got := RollbackCommand(none); got != "" { + t.Errorf("RollbackCommand(%q) = %q, want none", none, got) + } + } + hostile := []string{ + "docker-ce;reboot=5:1", "docker-ce=5:1;reboot", "docker-ce=5:1`id`", "docker-ce=$(id)", "docker-ce=5:1 && reboot", "docker-ce=5:1\nreboot", + "docker-ce=5:1|id", "docker-ce=5:1>x", "docker-ce='5:1'", `docker-ce="5:1"`, "docker-ce=5:1\\", "docker-ce=5:1#", "docker-ce=5:1&", + "docker-ce", "docker-ce=", "=5:1", "=", "", "docker-ce=5:1=2", "libc6=2.31", "docker.io=26.1.5", "-oAPT::Foo=1", "--allow-remove-essential=1", + "DOCKER-CE=5:1", strings.Repeat("a", 129) + "=1", "docker-ce=" + strings.Repeat("1", 101), + "docker-ce:amd64;reboot=5:1", "containerd.io:$(id)=1.0", + } + if got := RollbackCommand(hostile); got != "" { + t.Errorf("RollbackCommand(hostile) = %q, want none", got) + } + // among good ones, only the good ones + got = RollbackCommand(append(append([]string{"docker-ce=5:28.0.4-1"}, hostile...), "containerd.io=1.7.27-1")) + if want := "sudo apt-get install --allow-downgrades docker-ce=5:28.0.4-1 containerd.io=1.7.27-1"; got != want { + t.Errorf("RollbackCommand(mixed) = %q, want %q", got, want) + } + if strings.ContainsAny(got, ";&|$`'\"\\<>()\n#*?!{}") { + t.Errorf("the command holds a shell character: %q", got) + } +} + +func TestRollbackCommandFromARun(t *testing.T) { + first := QueuedMarker(testNonce, t0)[:len(QueuedMarker(testNonce, t0))-1] + run := ParseRun(logOf(first, marker("PREVIOUS", "docker-ce=5:28.0.4-1 evil;x=1 containerd.io=1.7.27-1"), marker("FAILED", ts(5)+" daemon"))) + if got, want := RollbackCommand(run.Previous), "sudo apt-get install --allow-downgrades docker-ce=5:28.0.4-1 containerd.io=1.7.27-1"; got != want { + t.Errorf("RollbackCommand() = %q, want %q", got, want) + } +} From 364aa4687f03fe8bb9e3c15d2d5f4e4d31f8f1db Mon Sep 17 00:00:00 2001 From: Gary Date: Fri, 9 Oct 2026 10:13:14 +0200 Subject: [PATCH 05/33] test(packages): let the apt fake answer docker, per name and in version order The fake had one answer for every install simulation, a lexicographic dpkg version compare (29.10 came before 29.9) and "success, no output" for every command it did not know, which hides a check the code forgot to make. It now answers docker info, ps and inspect (healthy, stopped, hanging until its deadline, in a swarm), dpkg-query for any package with a status, and an install simulation made of the lines of the names it was asked for. A strict option reports an unanswered command as a test failure and as a failed command; the existing tests do not set it and behave as before. The compare orders epoch and number runs numerically. The generic unit's tests that need /bin/bash skip when the host has none. --- service/system_package_docker_test.go | 137 +++++++++++++++++++++++++- 1 file changed, 134 insertions(+), 3 deletions(-) diff --git a/service/system_package_docker_test.go b/service/system_package_docker_test.go index 0be82ad8..a8d06f8f 100644 --- a/service/system_package_docker_test.go +++ b/service/system_package_docker_test.go @@ -6,9 +6,11 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "runtime" "strings" "testing" + "time" "github.com/ReCasaOS/CasaOS/common" "github.com/ReCasaOS/CasaOS/pkg/dockerpkg" @@ -28,12 +30,87 @@ type aptBox struct { // onInstallSimulation runs when the list is simulated: something else may start meanwhile. onInstallSimulation func() calls []string + + // packages are the other packages on the box, with their dpkg version, and statuses their + // dpkg abbreviation ("ii" unless set; "hi" is on hold). + packages map[string]string + statuses map[string]string + // enginePlans, when set, makes the simulation of an install answer from the names it was + // asked for: the lines of each of them, in the order they were asked, and nothing for a name + // that is not in it. Without it that simulation answers installSimulation whatever it is asked. + enginePlans map[string]string + // The docker command. daemonInfo is what `docker info` prints (a healthy daemon, not in a + // swarm, when empty); noDaemon makes it fail as with a stopped daemon; daemonHangs makes it + // wait for its deadline. runningIDs are what `docker ps -q` prints, inspected what + // `docker inspect` prints and inspectFailure that it exits non-zero. + daemonInfo string + noDaemon bool + daemonHangs bool + runningIDs []string + inspected string + inspectFailure bool + // strict, when set, reports a command that nothing here answers (a real install, a + // systemctl start, a df...) as a test failure and as a failed command, instead of letting it + // succeed with no output, which would hide a check the code forgot to make. + strict interface{ Errorf(string, ...any) } +} + +const healthyDaemon = `{"ServerVersion":"28.0.4","SwarmState":"inactive"}` + "\n" + +// unexpected is the answer to a command that nothing here answers. +func (b *aptBox) unexpected(name string, args []string) ([]byte, error) { + if b.strict == nil { + return nil, nil + } + b.strict.Errorf("unexpected command: %s %s", name, strings.Join(args, " ")) + return nil, errors.New("unexpected command " + name) +} + +var versionParts = regexp.MustCompile(`\d+|\D+`) + +func compareNumbers(a, b string) int { + a, b = strings.TrimLeft(a, "0"), strings.TrimLeft(b, "0") + if len(a) != len(b) { + return len(a) - len(b) + } + return strings.Compare(a, b) +} + +// compareDpkgVersions orders two dpkg versions the way dpkg does for the versions of these +// tests: the epoch first, then the number runs as numbers (29.10 is after 29.9) and the rest as +// text. It does not know that "~" sorts before everything. +func compareDpkgVersions(a, b string) int { + epoch := func(v string) (string, string) { + if e, rest, found := strings.Cut(v, ":"); found && regexp.MustCompile(`^\d+$`).MatchString(e) { + return e, rest + } + return "0", v + } + ea, ra := epoch(a) + eb, rb := epoch(b) + if c := compareNumbers(ea, eb); c != 0 { + return c + } + pa, pb := versionParts.FindAllString(ra, -1), versionParts.FindAllString(rb, -1) + for i := 0; i < len(pa) && i < len(pb); i++ { + c := strings.Compare(pa[i], pb[i]) + if pa[i][0] >= '0' && pa[i][0] <= '9' && pb[i][0] >= '0' && pb[i][0] <= '9' { + c = compareNumbers(pa[i], pb[i]) + } + if c != 0 { + return c + } + } + return len(pa) - len(pb) } -func (b *aptBox) command(_ context.Context, name string, args ...string) ([]byte, error) { +func (b *aptBox) command(ctx context.Context, name string, args ...string) ([]byte, error) { b.calls = append(b.calls, name+" "+strings.Join(args, " ")) switch name { case "systemctl": + if len(args) == 0 || args[0] != "show" { + return b.unexpected(name, args) + } if len(args) > 1 && b.activeUnits[args[1]] { return []byte("active\n"), nil } @@ -50,10 +127,17 @@ func (b *aptBox) command(_ context.Context, name string, args ...string) ([]byte if pkg == "docker.io" && b.dockerIO != "" { return []byte("ii \t" + b.dockerIO + "\n"), nil } + if version, ok := b.packages[pkg]; ok { + status := b.statuses[pkg] + if status == "" { + status = "ii" + } + return []byte(status + " \t" + version + "\n"), nil + } return nil, errors.New("dpkg-query: no packages found matching " + pkg) case "dpkg": // dpkg --compare-versions gt : true when the candidate is later - if len(args) == 4 && args[0] == "--compare-versions" && args[2] == "gt" && args[1] > args[3] { + if len(args) == 4 && args[0] == "--compare-versions" && args[2] == "gt" && compareDpkgVersions(args[1], args[3]) > 0 { return nil, nil } return nil, errors.New("exit status 1") @@ -77,12 +161,56 @@ func (b *aptBox) command(_ context.Context, name string, args ...string) ([]byte if b.installFailure != "" { return []byte(b.installFailure), errors.New("exit status 100") } + if b.enginePlans != nil { + var plan strings.Builder + for _, asked := range args[indexOf(args, "install")+1:] { + if !strings.HasPrefix(asked, "-") { + plan.WriteString(b.enginePlans[asked]) + } + } + return []byte(plan.String()), nil + } return []byte(b.installSimulation), nil case strings.Contains(joined, " -s "): return []byte(b.upgradeSimulation), nil } + return b.unexpected(name, args) + } + if strings.HasSuffix(name, "docker") && len(args) > 0 { + switch args[0] { + case "info": + switch { + case b.daemonHangs: + // the command is run with a deadline, and ends when it does + if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > 5*time.Second { + return b.unexpected(name, args) + } + return nil, context.DeadlineExceeded + case b.noDaemon: + return []byte("Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?\n"), errors.New("exit status 1") + case b.daemonInfo != "": + return []byte(b.daemonInfo), nil + } + return []byte(healthyDaemon), nil + case "ps": + return []byte(strings.Join(b.runningIDs, "\n") + "\n"), nil + case "inspect": + if b.inspectFailure { + return []byte(b.inspected), errors.New("exit status 1") + } + return []byte(b.inspected), nil + } } - return nil, nil + return b.unexpected(name, args) +} + +func indexOf(list []string, want string) int { + for i, item := range list { + if item == want { + return i + } + } + return -1 } const ( @@ -455,6 +583,9 @@ func runUnitCommand(t *testing.T, plan string, protectDocker bool) (log string, if runtime.GOOS != "linux" { t.Skip("the unit's command is for a Linux shell") } + if _, err := os.Stat("/bin/bash"); err != nil { + t.Skip("the unit's command is run by bash, which this host does not have") + } dir := t.TempDir() plans := filepath.Join(dir, "plan.txt") real := filepath.Join(dir, "real-install.txt") From 73a1974e8231e442c76df281cc8c942c30c71530 Mon Sep 17 00:00:00 2001 From: Gary Date: Fri, 9 Oct 2026 10:13:15 +0200 Subject: [PATCH 06/33] feat(packages): say in the check whether the button may update Docker GET /v1/sys/packages gains docker.update when the engine has something to update: available, a refusal code (unsupported, origin, held, daemon, swarm, plan, disk, looked at in that order), the names that make a plan unacceptable, from/to/major_jump, the plan id and the packages with their whole dpkg versions. The plan is apt's own simulation of upgrading the engine's packages that are on the box (--only-upgrade, --no-remove, no recommends, no lock), made only once the cheap reasons have passed. A simulation that fails, a removal, a package that is not the engine's, a new package and a name or version that is not fit for a command line all refuse it, and a refused plan carries nothing hostile to the page. The disk needs 1 GiB in apt's cache and in /usr, read with statfs behind a seam; a place that cannot be read counts only if neither can. dockerUpdatePreflight is what the start of the update will call again under the lock. The fields of the Docker line are as they were. --- service/disk_space_linux.go | 15 + service/disk_space_linux_test.go | 19 + service/disk_space_other.go | 10 + service/system_package.go | 10 + service/system_package_docker.go | 3 + service/system_package_docker_update.go | 231 +++++++ service/system_package_docker_update_test.go | 612 +++++++++++++++++++ service/system_package_test.go | 2 + 8 files changed, 902 insertions(+) create mode 100644 service/disk_space_linux.go create mode 100644 service/disk_space_linux_test.go create mode 100644 service/disk_space_other.go create mode 100644 service/system_package_docker_update.go create mode 100644 service/system_package_docker_update_test.go diff --git a/service/disk_space_linux.go b/service/disk_space_linux.go new file mode 100644 index 00000000..cba2043b --- /dev/null +++ b/service/disk_space_linux.go @@ -0,0 +1,15 @@ +//go:build linux + +package service + +import "syscall" + +// freeDiskBytes is the room left on the filesystem of path for a program that is not root +// (what the filesystem keeps for root is not counted). +func freeDiskBytes(path string) (uint64, error) { + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err != nil { + return 0, err + } + return stat.Bavail * uint64(stat.Bsize), nil +} diff --git a/service/disk_space_linux_test.go b/service/disk_space_linux_test.go new file mode 100644 index 00000000..00518ebf --- /dev/null +++ b/service/disk_space_linux_test.go @@ -0,0 +1,19 @@ +//go:build linux + +package service + +import ( + "path/filepath" + "testing" +) + +func TestFreeDiskBytesReadsTheFilesystem(t *testing.T) { + // through the updater, so that the wiring of the real reader is tested as well + free, err := newSystemPackageUpdater().freeBytes(t.TempDir()) + if err != nil || free == 0 { + t.Fatalf("freeBytes() = %d, %v: a temporary directory has room", free, err) + } + if _, err := freeDiskBytes(filepath.Join(t.TempDir(), "missing")); err == nil { + t.Fatal("freeDiskBytes() read a path that is not there") + } +} diff --git a/service/disk_space_other.go b/service/disk_space_other.go new file mode 100644 index 00000000..357a23fd --- /dev/null +++ b/service/disk_space_other.go @@ -0,0 +1,10 @@ +//go:build !linux + +package service + +import "errors" + +// freeDiskBytes: only a Linux host has the room to look at; elsewhere it cannot be read. +func freeDiskBytes(string) (uint64, error) { + return 0, errors.New("free disk space is read on Linux only") +} diff --git a/service/system_package.go b/service/system_package.go index 846b265b..bf82bdd3 100644 --- a/service/system_package.go +++ b/service/system_package.go @@ -96,6 +96,8 @@ type systemPackageUpdater struct { now func() time.Time // dpkgLocked says whether a package manager holds dpkg's lock; nil means never. dpkgLocked func() bool + // freeBytes is the room left on the filesystem of a path; nil means it cannot be read. + freeBytes func(path string) (uint64, error) } // untranslatedCommand runs a package tool with its output in English. apt translates its @@ -123,6 +125,7 @@ func newSystemPackageUpdater() *systemPackageUpdater { stat: os.Stat, now: time.Now, dpkgLocked: dpkgLockHeld, + freeBytes: freeDiskBytes, } } @@ -223,6 +226,13 @@ func (u *systemPackageUpdater) check() (SystemPackageUpdates, error) { result.Updates, dockerUpdates = splitDockerUpdates(parseAPTUpgradeSimulation(string(output)), u.engineInstalled(ctx)) result.Count = len(result.Updates) result.Docker = u.dockerInfo(ctx, dockerUpdates) + // The simulation of the engine's update is the expensive part: only for an engine that has + // something to update. + if docker := result.Docker; docker != nil && (len(docker.Updates) > 0 || docker.Candidate != "") { + if update, _ := u.dockerUpdatePreflight(ctx, support); update.Refusal != "" || len(update.Packages) > 0 { + docker.Update = &update + } + } now := u.now().UTC() result.CheckedAt = &now result.RebootRequired = u.rebootRequired() diff --git a/service/system_package_docker.go b/service/system_package_docker.go index 3428f729..77be0b33 100644 --- a/service/system_package_docker.go +++ b/service/system_package_docker.go @@ -47,6 +47,9 @@ type SystemPackageDocker struct { // ManualCommand is what to type to update Docker oneself; empty when this does not // know a command to stand behind. ManualCommand string `json:"manual_command,omitempty"` + // Update is whether the page may update the engine itself, and with what; absent when + // there is nothing to update. + Update *SystemPackageDockerUpdate `json:"update,omitempty"` } const systemDockerUpdateUnit = "casaos-docker-update.service" // reserved for an update of Docker on its own diff --git a/service/system_package_docker_update.go b/service/system_package_docker_update.go new file mode 100644 index 00000000..bba9227a --- /dev/null +++ b/service/system_package_docker_update.go @@ -0,0 +1,231 @@ +package service + +import ( + "context" + "sort" + "time" + + "github.com/ReCasaOS/CasaOS/pkg/dockerpkg" +) + +// The "Update Docker" button upgrades the engine's own packages (dockerpkg.EngineNames) and +// nothing else. This file is what decides, before the button is offered and again before it +// does anything, whether it may: the answer is a refusal code that the page turns into text, +// never a sentence it has to read, and the plan, the transaction apt itself says it would +// make, which the owner confirms and which is checked again when the update starts. + +// The reasons the button is refused for, in the order they are looked at: the first that +// applies is the one given. The page maps these codes to its own words. +const ( + // DockerRefusalUnsupported is a host that is not Debian-family root with apt-get and + // systemd-run. + DockerRefusalUnsupported = "unsupported" + // DockerRefusalOrigin is an engine that is not docker-ce from Docker's own repository. + DockerRefusalOrigin = "origin" + // DockerRefusalHeld is docker-ce, or another package of the engine, on hold: the owner + // put it there, and the button does not undo it. + DockerRefusalHeld = "held" + // DockerRefusalDaemon is a Docker that does not answer: no docker command, or the daemon + // does not tell its version in time. + DockerRefusalDaemon = "daemon" + // DockerRefusalSwarm is a node that is part of a swarm. + DockerRefusalSwarm = "swarm" + // DockerRefusalPlan is a transaction that is not exactly an upgrade of the engine's own + // packages: it failed, or it removes a package, or touches another, or installs a new one. + DockerRefusalPlan = "plan" + // DockerRefusalDisk is too little room for the packages. + DockerRefusalDisk = "disk" +) + +const ( + // systemDockerInfoTimeout is how long the Docker daemon has to answer. + systemDockerInfoTimeout = 5 * time.Second + // systemDockerMinFreeBytes is the room the packages of the engine are downloaded and + // unpacked in. + // ponytail: flat floor, size-based check if it ever refuses a box that had room + systemDockerMinFreeBytes = 1 << 30 +) + +// systemDockerDiskPaths are the places that need the room: where apt keeps what it downloads, +// and where the engine is installed. +var systemDockerDiskPaths = []string{"/var/cache/apt/archives", "/usr"} + +// SystemPackageDockerUpdate is what the check says of updating the engine from the page. It is +// there when the engine has something to update. +type SystemPackageDockerUpdate struct { + // Available is true when the button may be offered: nothing refuses it, and there is + // something to update. + Available bool `json:"available"` + // Refusal is "" or one of the DockerRefusal codes. + Refusal string `json:"refusal"` + // RefusalDetail is the names of the packages that make the plan unacceptable (valid + // package names, or dockerpkg.InvalidName), for DockerRefusalPlan; otherwise empty, never null. + RefusalDetail []string `json:"refusal_detail"` + // From and To are the engine's version before and after, digits and dots: 28.0.4, 29.8.0. + // They are the same when only a package other than docker-ce moves. + From string `json:"from"` + To string `json:"to"` + // MajorJump is a change of the engine's first number: 28 to 29. + MajorJump bool `json:"major_jump"` + // PlanID names the plan, see dockerpkg.Plan.ID: the page sends it back to say which plan + // the owner confirmed. + PlanID string `json:"plan_id"` + // Packages are what the plan installs, with the whole dpkg versions, sorted by name; never + // null. A refusal for a reason that comes before the plan has none; neither does one for + // DockerRefusalPlan. + Packages []SystemPackageUpdate `json:"packages"` +} + +// dockerUpdatePreflight looks at the box and says whether the engine may be updated by the +// button, and with which plan. It runs nothing that changes the box: a few dpkg and docker +// queries and, once the cheap reasons for a refusal are out of the way, one simulation of the +// upgrade, the expensive part. It is for the check, and for the start of the update, which +// calls it again under the lock: the start refuses unless Refusal is "", the plan is not +// empty (Packages is) and PlanID is the one the owner confirmed. +// +// The plan is returned only when it is within bounds, whether or not a later reason (the +// disk) refuses: it is the zero Plan otherwise. Plan.Pins and Plan.Names are what to put on +// the command line, and nothing else may be. +func (u *systemPackageUpdater) dockerUpdatePreflight(ctx context.Context, support systemPackageSupport) (SystemPackageDockerUpdate, dockerpkg.Plan) { + update := SystemPackageDockerUpdate{RefusalDetail: []string{}, Packages: []SystemPackageUpdate{}} + refuse := func(code string, detail ...string) (SystemPackageDockerUpdate, dockerpkg.Plan) { + update.Refusal = code + update.RefusalDetail = append(update.RefusalDetail, detail...) + return update, dockerpkg.Plan{} + } + + if !support.supported { + return refuse(DockerRefusalUnsupported) + } + if u.engineOrigin(ctx) != dockerpkg.OriginDockerRepo { + return refuse(DockerRefusalOrigin) + } + installed, held := u.enginePackages(ctx) + if held { + return refuse(DockerRefusalHeld) + } + _, daemon, err := u.dockerDaemon(ctx) + if err != nil { + return refuse(DockerRefusalDaemon) + } + if daemon.SwarmActive { + return refuse(DockerRefusalSwarm) + } + + plan, err := u.enginePlan(ctx, support, installed) + if err != nil { + // fail closed: a simulation that did not succeed says nothing of what would happen + return refuse(DockerRefusalPlan) + } + if offending := plan.Offending(); len(offending) > 0 { + return refuse(DockerRefusalPlan, offending...) + } + + for _, pkg := range plan.Packages { + update.Packages = append(update.Packages, SystemPackageUpdate{Name: pkg.Name, CurrentVersion: pkg.Current, CandidateVersion: pkg.Candidate}) + } + sort.Slice(update.Packages, func(i, j int) bool { return update.Packages[i].Name < update.Packages[j].Name }) + if len(update.Packages) == 0 { + return update, plan + } + update.PlanID = plan.ID() + update.From, update.To = plan.Engine() + if update.From == "" { + // docker-ce is not in the plan: a plugin or containerd.io moves, the engine does not + update.From = dockerpkg.EngineVersion(u.installedVersion(ctx, "docker-ce")) + update.To = update.From + } + update.MajorJump = dockerpkg.MajorJump(update.From, update.To) + + if u.diskLow() { + update.Refusal = DockerRefusalDisk + return update, plan + } + update.Available = true + return update, plan +} + +// engineOrigin is where the Docker engine on this box came from, "" when there is none. +// dockerInfo reads the same, for the line it shows. +func (u *systemPackageUpdater) engineOrigin(ctx context.Context) dockerpkg.Origin { + switch { + case u.installedVersion(ctx, "docker-ce") != "": + policy, _ := u.command(ctx, "apt-cache", "policy", "docker-ce") + return dockerpkg.OriginFromPolicy(string(policy)) + case u.installedVersion(ctx, "docker.io") != "": + return dockerpkg.OriginDistribution + } + if _, err := u.command(ctx, "snap", "list", "docker"); err == nil { + return dockerpkg.OriginSnap + } + return "" +} + +// enginePackages are the packages of dockerpkg.EngineNames that are on the box, in that order, +// and whether any of them is on hold. +func (u *systemPackageUpdater) enginePackages(ctx context.Context) (installed []string, held bool) { + for _, name := range dockerpkg.EngineNames { + if u.installedVersion(ctx, name) == "" { + continue + } + installed = append(installed, name) + if u.packageHeld(ctx, name) { + held = true + } + } + return installed, held +} + +// dockerDaemon asks the docker command, within systemDockerInfoTimeout, what the daemon says +// of itself. It returns the command's path, to run the other docker commands with. +func (u *systemPackageUpdater) dockerDaemon(ctx context.Context) (string, dockerpkg.DaemonInfo, error) { + dockerPath, err := u.lookPath("docker") + if err != nil { + return "", dockerpkg.DaemonInfo{}, err + } + ctx, cancel := context.WithTimeout(ctx, systemDockerInfoTimeout) + defer cancel() + output, err := u.command(ctx, dockerPath, "info", "--format", dockerpkg.DaemonInfoFormat) + if err != nil { + return "", dockerpkg.DaemonInfo{}, err + } + info, err := dockerpkg.ParseDaemonInfo(string(output)) + return dockerPath, info, err +} + +// enginePlan simulates upgrading the installed packages of the engine, all of them in one +// transaction, as apt would run it. Only the packages that are on the box are named: with +// --only-upgrade apt skips the others (and fails, "Unable to locate package", for one that +// its repositories do not carry, as docker-model-plugin may not be). +func (u *systemPackageUpdater) enginePlan(ctx context.Context, support systemPackageSupport, installed []string) (dockerpkg.Plan, error) { + if len(installed) == 0 { + return dockerpkg.Plan{}, nil + } + args := systemPackageSimulateArgs(append([]string{"install", "--only-upgrade", "--no-install-recommends"}, installed...)...) + output, err := u.command(ctx, support.aptPath, args...) + if err != nil { + return dockerpkg.Plan{}, err + } + return dockerpkg.ParsePlan(string(output)), nil +} + +// diskLow says whether there is too little room for the packages: less than +// systemDockerMinFreeBytes on either place that needs it. A place that cannot be read is no +// reason by itself, unless neither can. +func (u *systemPackageUpdater) diskLow() bool { + var read int + for _, path := range systemDockerDiskPaths { + if u.freeBytes == nil { + continue + } + free, err := u.freeBytes(path) + if err != nil { + continue + } + read++ + if free < systemDockerMinFreeBytes { + return true + } + } + return read == 0 +} diff --git a/service/system_package_docker_update_test.go b/service/system_package_docker_update_test.go new file mode 100644 index 00000000..f807ff7b --- /dev/null +++ b/service/system_package_docker_update_test.go @@ -0,0 +1,612 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "reflect" + "sort" + "strings" + "testing" + + "github.com/ReCasaOS/CasaOS/pkg/dockerpkg" +) + +// The owner's box: Debian 11, docker-ce 28.0.4 from Docker's repository, 29.8.0 on offer. +const ( + debian28 = "5:28.0.4-1~debian.11~bullseye" + debian29 = "5:29.8.0-1~debian.11~bullseye" + + ownerPolicy = "docker-ce:\n Installed: " + debian28 + "\n Candidate: " + debian29 + "\n Version table:\n " + debian29 + " 500\n 500 https://download.docker.com/linux/debian bullseye/stable amd64 Packages\n *** " + debian28 + " 100\n 100 /var/lib/dpkg/status\n" + // the same box, with the engine coming from a mirror that is not Docker's + mirrorPolicy = "docker-ce:\n Installed: " + debian28 + "\n Candidate: " + debian29 + "\n Version table:\n " + debian29 + " 500\n 500 http://mirror.example.com/docker bullseye/stable amd64 Packages\n *** " + debian28 + " 100\n 100 /var/lib/dpkg/status\n" +) + +func instEngine(name, current, candidate string) string { + return fmt.Sprintf("Inst %s [%s] (%s Docker CE:bullseye [amd64])\n", name, current, candidate) +} + +var ( + ownerDockerCE = instEngine("docker-ce", debian28, debian29) + ownerCLI = instEngine("docker-ce-cli", debian28, debian29) + ownerContaind = instEngine("containerd.io", "1.7.27-1", "2.1.4-1") +) + +// ownerBox is the box the button is built for, with every answer the check needs given: any +// command it does not answer fails the test. +func ownerBox(t *testing.T) *aptBox { + return &aptBox{ + strict: t, + upgradeSimulation: simLibc + ownerDockerCE + ownerCLI + ownerContaind, + dockerCE: debian28, + packages: map[string]string{"docker-ce-cli": debian28, "containerd.io": "1.7.27-1"}, + policy: ownerPolicy, + enginePlans: map[string]string{"docker-ce": ownerDockerCE, "docker-ce-cli": ownerCLI, "containerd.io": ownerContaind}, + } +} + +func checkBox(t *testing.T, box *aptBox, tweak func(*systemPackageUpdater)) SystemPackageUpdates { + t.Helper() + updater := newTestSystemPackageUpdater(t) + updater.command = box.command + if tweak != nil { + tweak(updater) + } + got, err := updater.check() + if err != nil { + t.Fatalf("check() error = %v", err) + } + return got +} + +// engineSimulations are the calls that simulated installing the engine's packages. +func engineSimulations(box *aptBox) []string { + var calls []string + for _, call := range box.calls { + if strings.Contains(call, " -s ") && strings.Contains(call, " install ") { + calls = append(calls, call) + } + } + return calls +} + +func TestDockerUpdateOwnersCase(t *testing.T) { + box := ownerBox(t) + got := checkBox(t, box, nil) + + if got.Docker == nil || got.Docker.Update == nil { + t.Fatalf("Docker = %#v: no update", got.Docker) + } + update := got.Docker.Update + if !update.Available || update.Refusal != "" || update.RefusalDetail == nil || len(update.RefusalDetail) != 0 { + t.Errorf("update = %#v, want available and no refusal", update) + } + if update.From != "28.0.4" || update.To != "29.8.0" || !update.MajorJump { + t.Errorf("from %q to %q, major jump %v: want 28.0.4, 29.8.0, true", update.From, update.To, update.MajorJump) + } + wantPackages := []SystemPackageUpdate{ + {Name: "containerd.io", CurrentVersion: "1.7.27-1", CandidateVersion: "2.1.4-1"}, + {Name: "docker-ce", CurrentVersion: debian28, CandidateVersion: debian29}, + {Name: "docker-ce-cli", CurrentVersion: debian28, CandidateVersion: debian29}, + } + if !reflect.DeepEqual(update.Packages, wantPackages) { + t.Errorf("packages = %#v, want %#v", update.Packages, wantPackages) + } + + // the plan's id is the hash of its sorted lines "name current>candidate" + var lines []string + for _, pkg := range wantPackages { + lines = append(lines, pkg.Name+" "+pkg.CurrentVersion+">"+pkg.CandidateVersion) + } + sort.Strings(lines) + sum := sha256.Sum256([]byte(strings.Join(lines, "\n"))) + if update.PlanID != hex.EncodeToString(sum[:]) || len(update.PlanID) != 64 { + t.Errorf("plan_id = %q, want %x", update.PlanID, sum) + } + // ... and the same whatever order apt printed them in, and on the next look + reversed := ownerBox(t) + reversed.enginePlans = nil + reversed.installSimulation = ownerCLI + ownerContaind + ownerDockerCE + if again := checkBox(t, reversed, nil); again.Docker.Update.PlanID != update.PlanID || !reflect.DeepEqual(again.Docker.Update.Packages, wantPackages) { + t.Errorf("after apt printed the lines in another order: plan_id = %q, packages = %#v; want %q and %#v", again.Docker.Update.PlanID, again.Docker.Update.Packages, update.PlanID, wantPackages) + } + if again := checkBox(t, ownerBox(t), nil); again.Docker.Update.PlanID != update.PlanID { + t.Errorf("plan_id = %q on a second look, want %q", again.Docker.Update.PlanID, update.PlanID) + } + + // the transaction apt was asked for: an upgrade of what is on the box, nothing removed, no lock + want := "/usr/bin/apt-get -s --no-remove -o Debug::NoLocking=true -o Dpkg::Use-Pty=0 install --only-upgrade --no-install-recommends containerd.io docker-ce docker-ce-cli" + if calls := engineSimulations(box); len(calls) != 1 || calls[0] != want { + t.Errorf("simulations = %#v, want exactly %q", calls, want) + } + // the daemon was asked once, in the format the parser reads + var asked int + for _, call := range box.calls { + if call == "/usr/bin/docker info --format "+dockerpkg.DaemonInfoFormat { + asked++ + } + } + if asked != 1 { + t.Errorf("docker info was run %d times: %#v", asked, box.calls) + } + + // the Docker line itself is what it was + if got.Docker.Version != "28.0.4" || got.Docker.Origin != "docker-repository" || !got.Docker.RestartsDocker || len(got.Docker.Updates) != 3 || + got.Docker.Candidate != "" || got.Docker.Held || !strings.HasPrefix(got.Docker.ManualCommand, "sudo apt-get update && sudo apt-get install --only-upgrade ") { + t.Errorf("Docker = %#v", got.Docker) + } + if got.Count != 1 || len(got.Updates) != 1 || got.Updates[0].Name != "libc6" { + t.Errorf("the update list = %#v: Docker's packages are not in it", got.Updates) + } +} + +func TestDockerUpdateAPatchOfTheSameMajor(t *testing.T) { + box := ownerBox(t) + const current, candidate = "5:29.8.1-1~debian.11~bullseye", "5:29.8.2-1~debian.11~bullseye" + engine, client := instEngine("docker-ce", current, candidate), instEngine("docker-ce-cli", current, candidate) + box.upgradeSimulation = engine + client + box.dockerCE = current + box.packages = map[string]string{"docker-ce-cli": current} + box.policy = strings.NewReplacer(debian28, current, debian29, candidate).Replace(ownerPolicy) + box.enginePlans = map[string]string{"docker-ce": engine, "docker-ce-cli": client} + + update := checkBox(t, box, nil).Docker.Update + if update == nil || !update.Available || update.From != "29.8.1" || update.To != "29.8.2" || update.MajorJump || len(update.Packages) != 2 { + t.Fatalf("update = %#v, want 29.8.1 to 29.8.2, available, no major jump", update) + } +} + +func TestDockerUpdateIsFoundWhenAptKeepsDockerCEBack(t *testing.T) { + // apt's upgrade leaves docker-ce out (it would need a new dependency), but installing it + // by name resolves it: the plan is the explicit install, not the upgrade + box := ownerBox(t) + box.upgradeSimulation = simLibc + got := checkBox(t, box, nil) + + if got.Docker.Candidate != "29.8.0" || len(got.Docker.Updates) != 0 { + t.Fatalf("Docker = %#v, want a candidate and no update listed", got.Docker) + } + update := got.Docker.Update + if update == nil || !update.Available || update.To != "29.8.0" || !update.MajorJump || len(update.Packages) != 3 { + t.Fatalf("update = %#v, want the explicit install's plan", update) + } +} + +func TestDockerUpdateOfAPluginAloneKeepsTheEngineVersion(t *testing.T) { + box := ownerBox(t) + const plugin = "2.40.0-1~debian.11~bullseye" + const newer = "2.40.1-1~debian.11~bullseye" + box.upgradeSimulation = instEngine("docker-compose-plugin", plugin, newer) + box.packages["docker-compose-plugin"] = plugin + box.policy = strings.NewReplacer(debian29, debian28).Replace(ownerPolicy) // the engine is current + box.enginePlans = map[string]string{"docker-compose-plugin": instEngine("docker-compose-plugin", plugin, newer)} + + got := checkBox(t, box, nil) + update := got.Docker.Update + if update == nil || !update.Available || update.From != "28.0.4" || update.To != "28.0.4" || update.MajorJump || len(update.Packages) != 1 || update.Packages[0].Name != "docker-compose-plugin" { + t.Fatalf("update = %#v, want the engine's version for both ends and one package", update) + } + if got.Docker.RestartsDocker { + t.Error("a plugin restarts Docker") + } +} + +func TestDockerUpdateIsOmittedWhenThereIsNothingToUpdate(t *testing.T) { + // the engine is at the newest version and nothing of its family is pending: the line is + // there, an update is not, and apt is not asked to simulate one + box := ownerBox(t) + box.upgradeSimulation = simLibc + box.policy = currentPolicy + box.dockerCE = dockerCE + got := checkBox(t, box, nil) + if got.Docker == nil || got.Docker.Update != nil { + t.Fatalf("Docker = %#v, want a line without an update", got.Docker) + } + if calls := engineSimulations(box); len(calls) != 0 { + t.Errorf("the engine's plan was simulated for a box with nothing to update: %#v", calls) + } + + // no Docker at all + if got := checkBox(t, &aptBox{strict: t, upgradeSimulation: simLibc}, nil); got.Docker != nil { + t.Errorf("Docker = %#v on a box without Docker", got.Docker) + } + + // a candidate that apt's explicit install does not offer either: nothing to update, so + // no update, whatever the policy said + box = ownerBox(t) + box.upgradeSimulation = simLibc + box.enginePlans = map[string]string{} + if got := checkBox(t, box, nil); got.Docker == nil || got.Docker.Candidate == "" || got.Docker.Update != nil { + t.Errorf("Docker = %#v, want a candidate and no update", got.Docker) + } +} + +type refusalCase struct { + name string + // change makes the owner's box what the case is + change func(*aptBox) + tweak func(*systemPackageUpdater) + code string + detail []string + // planned: the transaction was simulated, and parsed, so that what it is is told + planned bool +} + +func lowDisk(free map[string]uint64) func(*systemPackageUpdater) { + return func(u *systemPackageUpdater) { + u.freeBytes = func(path string) (uint64, error) { + if n, ok := free[path]; ok { + return n, nil + } + return 0, errors.New("statfs " + path + ": no such file or directory") + } + } +} + +const ( + aptArchives = "/var/cache/apt/archives" + usr = "/usr" + gib = uint64(1 << 30) +) + +func refusalCases() []refusalCase { + remove := func(line string) func(*aptBox) { + return func(b *aptBox) { b.enginePlans["docker-ce-cli"] += line } + } + return []refusalCase{ + {name: "the engine is a mirror's", change: func(b *aptBox) { b.policy = mirrorPolicy }, code: "origin"}, + {name: "the engine is the distribution's", change: func(b *aptBox) { + b.dockerCE, b.packages = "", nil + b.dockerIO = "26.1.5+dfsg1-9" + b.upgradeSimulation = simLibc + "Inst docker.io [26.1.5+dfsg1-9] (26.1.6+dfsg1-1 Debian:11/stable [amd64])\n" + }, code: "origin"}, + {name: "docker-ce is on hold", change: func(b *aptBox) { b.dockerCEStatus = "hi" }, code: "held"}, + {name: "the client is on hold", change: func(b *aptBox) { b.statuses = map[string]string{"docker-ce-cli": "hi"} }, code: "held"}, + {name: "a plugin is on hold", change: func(b *aptBox) { + b.packages["docker-buildx-plugin"] = "0.21.1-1~debian.11~bullseye" + b.statuses = map[string]string{"docker-buildx-plugin": "hi"} + }, code: "held"}, + {name: "there is no docker command", tweak: func(u *systemPackageUpdater) { + u.lookPath = func(name string) (string, error) { + if name == "docker" { + return "", errors.New("not found") + } + return "/usr/bin/" + name, nil + } + }, code: "daemon"}, + {name: "the daemon is stopped", change: func(b *aptBox) { b.noDaemon = true }, code: "daemon"}, + {name: "the daemon does not answer in time", change: func(b *aptBox) { b.daemonHangs = true }, code: "daemon"}, + {name: "the daemon does not say its version", change: func(b *aptBox) { b.daemonInfo = "WARNING: no memory limit support\n" }, code: "daemon"}, + {name: "the node is in a swarm", change: func(b *aptBox) { b.daemonInfo = `{"ServerVersion":"28.0.4","SwarmState":"active"}` }, code: "swarm"}, + {name: "the simulation fails", change: func(b *aptBox) { + b.installFailure = "E: Unable to correct problems, you have held broken packages." + }, code: "plan", detail: []string{}}, + {name: "the simulation fails for a removal", change: func(b *aptBox) { + b.installFailure = "E: Packages need to be removed but remove is disabled." + }, code: "plan", detail: []string{}}, + {name: "a removal", change: remove("Remv docker-compose-v2 [2.1.0]\n"), code: "plan", detail: []string{"docker-compose-v2"}}, + {name: "a package that is not the engine's", change: remove(simLibc), code: "plan", detail: []string{"libc6"}}, + {name: "a new dependency", change: remove("Inst libnewdep (1.0 Debian:11/stable [amd64])\n"), code: "plan", detail: []string{"libnewdep"}}, + {name: "a new package of the engine", change: remove("Inst docker-model-plugin (1.0.0-1~debian.11~bullseye Docker CE:bullseye [amd64])\n"), code: "plan", detail: []string{"docker-model-plugin"}}, + {name: "a version that is not a version", change: func(b *aptBox) { + b.enginePlans["docker-ce"] = instEngine("docker-ce", debian28, "5:29.8.0-1;touch${IFS}x") + }, code: "plan", detail: []string{"docker-ce"}}, + {name: "a package named like a command", change: remove("Inst evil$(reboot) [1] (2 x [amd64])\n"), code: "plan", detail: []string{dockerpkg.InvalidName}}, + {name: "little room in apt's cache", tweak: lowDisk(map[string]uint64{aptArchives: gib - 1, usr: 50 * gib}), code: "disk", planned: true}, + {name: "little room in /usr", tweak: lowDisk(map[string]uint64{aptArchives: 50 * gib, usr: 1 << 20}), code: "disk", planned: true}, + {name: "little room and a place that cannot be read", tweak: lowDisk(map[string]uint64{usr: 1 << 20}), code: "disk", planned: true}, + {name: "no place can be read", tweak: lowDisk(nil), code: "disk", planned: true}, + {name: "no way to read", tweak: func(u *systemPackageUpdater) { u.freeBytes = nil }, code: "disk", planned: true}, + } +} + +func TestDockerUpdateRefusals(t *testing.T) { + for _, c := range refusalCases() { + t.Run(c.name, func(t *testing.T) { + box := ownerBox(t) + if c.change != nil { + c.change(box) + } + got := checkBox(t, box, c.tweak) + if got.Docker == nil || got.Docker.Update == nil { + t.Fatalf("Docker = %#v: no update to refuse", got.Docker) + } + update := got.Docker.Update + if update.Available || update.Refusal != c.code { + t.Errorf("available = %v, refusal = %q, want a refusal %q", update.Available, update.Refusal, c.code) + } + wantDetail := c.detail + if wantDetail == nil { + wantDetail = []string{} + } + if update.RefusalDetail == nil || !reflect.DeepEqual(update.RefusalDetail, wantDetail) { + t.Errorf("refusal_detail = %#v, want %#v", update.RefusalDetail, wantDetail) + } + if update.Packages == nil { + t.Error("packages is null, want a list") + } + + // the expensive simulation is made only once the cheap reasons have passed + simulated := len(engineSimulations(box)) > 0 + if want := c.code == "plan" || c.code == "disk"; simulated != want { + t.Errorf("simulated = %v, want %v: %#v", simulated, want, box.calls) + } + // what the plan says is said when it can be, even if a later reason refuses + if c.planned { + if update.PlanID == "" || update.From != "28.0.4" || update.To != "29.8.0" || !update.MajorJump || len(update.Packages) != 3 { + t.Errorf("update = %#v, want the plan told even though the disk refuses", update) + } + } else if update.PlanID != "" || len(update.Packages) != 0 { + t.Errorf("update = %#v, want no plan", update) + } + // nothing a hostile apt printed gets to the page + encoded, _ := json.Marshal(update) + for _, bad := range []string{"touch", "reboot", "${IFS}"} { + if strings.Contains(string(encoded), bad) { + t.Errorf("the update %s holds %q", encoded, bad) + } + } + }) + } +} + +func TestDockerUpdateDiskFloor(t *testing.T) { + for name, c := range map[string]struct { + tweak func(*systemPackageUpdater) + want bool + }{ + "exactly the floor on both": {lowDisk(map[string]uint64{aptArchives: gib, usr: gib}), true}, + "one byte short on one": {lowDisk(map[string]uint64{aptArchives: gib, usr: gib - 1}), false}, + "apt's cache cannot be read": {lowDisk(map[string]uint64{usr: 5 * gib}), true}, + "/usr cannot be read": {lowDisk(map[string]uint64{aptArchives: 5 * gib}), true}, + "room on both": {lowDisk(map[string]uint64{aptArchives: 5 * gib, usr: 5 * gib}), true}, + "short on both": {lowDisk(map[string]uint64{aptArchives: 100, usr: 100}), false}, + "none left in the one that is there": {lowDisk(map[string]uint64{aptArchives: 0, usr: 5 * gib}), false}, + } { + update := checkBox(t, ownerBox(t), c.tweak).Docker.Update + if update == nil || update.Available != c.want || (!c.want && update.Refusal != "disk") || (c.want && update.Refusal != "") { + t.Errorf("%s: update = %#v, want available = %v", name, update, c.want) + } + } +} + +func TestDockerUpdateRefusalOrder(t *testing.T) { + // the first reason that applies is the one given: each case has this reason and the ones after it + supported := systemPackageSupport{supported: true, aptPath: "/usr/bin/apt-get", systemdPath: "/usr/bin/systemd-run"} + cases := []struct { + name string + support systemPackageSupport + change func(*aptBox) + tweak func(*systemPackageUpdater) + want string + }{ + {"unsupported over origin", systemPackageSupport{}, func(b *aptBox) { b.policy = mirrorPolicy }, nil, "unsupported"}, + {"origin over held", supported, func(b *aptBox) { b.policy = mirrorPolicy; b.dockerCEStatus = "hi" }, nil, "origin"}, + {"held over daemon", supported, func(b *aptBox) { b.dockerCEStatus = "hi"; b.noDaemon = true }, nil, "held"}, + {"held over swarm", supported, func(b *aptBox) { + b.dockerCEStatus = "hi" + b.daemonInfo = `{"ServerVersion":"28.0.4","SwarmState":"active"}` + }, nil, "held"}, + {"daemon over plan", supported, func(b *aptBox) { b.noDaemon = true; b.installFailure = "E: no" }, nil, "daemon"}, + {"swarm over plan", supported, func(b *aptBox) { + b.daemonInfo = `{"ServerVersion":"28.0.4","SwarmState":"active"}` + b.installFailure = "E: no" + }, nil, "swarm"}, + {"plan over disk", supported, func(b *aptBox) { b.installFailure = "E: no" }, lowDisk(nil), "plan"}, + } + for _, c := range cases { + box := ownerBox(t) + c.change(box) + updater := newTestSystemPackageUpdater(t) + updater.command = box.command + if c.tweak != nil { + c.tweak(updater) + } + update, plan := updater.dockerUpdatePreflight(context.Background(), c.support) + if update.Refusal != c.want || update.Available { + t.Errorf("%s: refusal = %q, available = %v, want %q", c.name, update.Refusal, update.Available, c.want) + } + if len(plan.Packages) != 0 { + t.Errorf("%s: a plan is returned with a refusal for the plan: %#v", c.name, plan) + } + } +} + +func TestDockerUpdatePreflightOfAnEngineThatIsNotDockerCE(t *testing.T) { + supported := systemPackageSupport{supported: true, aptPath: "/usr/bin/apt-get"} + updater := newTestSystemPackageUpdater(t) + updater.command = (&aptBox{strict: t, snap: true}).command + if update, _ := updater.dockerUpdatePreflight(context.Background(), supported); update.Refusal != DockerRefusalOrigin { + t.Errorf("a snap: refusal = %q", update.Refusal) + } + updater.command = (&aptBox{strict: t}).command + if update, _ := updater.dockerUpdatePreflight(context.Background(), supported); update.Refusal != DockerRefusalOrigin { + t.Errorf("no engine: refusal = %q", update.Refusal) + } +} + +func TestDockerUpdatePreflightHandsTheStartThePlanItWillInstall(t *testing.T) { + updater := newTestSystemPackageUpdater(t) + updater.command = ownerBox(t).command + update, plan := updater.dockerUpdatePreflight(context.Background(), systemPackageSupport{supported: true, aptPath: "/usr/bin/apt-get"}) + if !update.Available || plan.ID() != update.PlanID { + t.Fatalf("update = %#v, plan = %#v", update, plan) + } + // sorted as strings: "docker-ce-cli=" comes before "docker-ce=", "-" being before "=" + wantPins := []string{"containerd.io=2.1.4-1", "docker-ce-cli=" + debian29, "docker-ce=" + debian29} + if !reflect.DeepEqual(plan.Pins(), wantPins) || !reflect.DeepEqual(plan.Names(), []string{"containerd.io", "docker-ce", "docker-ce-cli"}) { + t.Errorf("pins = %#v, names = %#v", plan.Pins(), plan.Names()) + } +} + +func TestDockerUpdateChangesNothing(t *testing.T) { + // the check only looks: the one apt-get that is not a simulation is the index refresh the + // check always made, and nothing is started + box := ownerBox(t) + updater := newTestSystemPackageUpdater(t) + updater.command = box.command + var started bool + updater.start = func(string, string, ...string) ([]byte, error) { started = true; return nil, nil } + if _, err := updater.check(); err != nil { + t.Fatal(err) + } + if started { + t.Error("the check started a unit") + } + for _, call := range box.calls { + if strings.Contains(call, "apt-get") && !strings.Contains(call, " -s ") && !strings.HasSuffix(call, "apt-get update") { + t.Errorf("the check ran %q", call) + } + } +} + +func TestSystemPackagesJSONCarriesTheDockerUpdate(t *testing.T) { + encode := func(v any) map[string]any { + data, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + var decoded map[string]any + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + return decoded + } + keys := func(m map[string]any) []string { + var out []string + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out + } + + docker := encode(checkBox(t, ownerBox(t), nil))["docker"].(map[string]any) + update := docker["update"].(map[string]any) + if got := keys(update); !reflect.DeepEqual(got, []string{"available", "from", "major_jump", "packages", "plan_id", "refusal", "refusal_detail", "to"}) { + t.Errorf("update keys = %v", got) + } + if update["available"] != true || update["refusal"] != "" || update["from"] != "28.0.4" || update["to"] != "29.8.0" || update["major_jump"] != true { + t.Errorf("update = %#v", update) + } + if detail, ok := update["refusal_detail"].([]any); !ok || len(detail) != 0 { + t.Errorf("refusal_detail = %#v, want []", update["refusal_detail"]) + } + packages := update["packages"].([]any) + if len(packages) != 3 { + t.Fatalf("packages = %#v", packages) + } + first := packages[0].(map[string]any) + if got := keys(first); !reflect.DeepEqual(got, []string{"candidate_version", "current_version", "name"}) || first["name"] != "containerd.io" { + t.Errorf("a package = %v %#v", got, first) + } + // every field the line had is still there, and nothing else was added to it but the update + for _, key := range []string{"installed", "origin", "version", "updates", "restarts_docker", "manual_command"} { + if _, ok := docker[key]; !ok { + t.Errorf("the Docker line lost %q: %v", key, keys(docker)) + } + } + if len(docker) != 7 { + t.Errorf("the Docker line holds %v", keys(docker)) + } + + // a refusal before the plan: the list is [] and not null, the strings are empty + refused := ownerBox(t) + refused.policy = mirrorPolicy + refusedUpdate := encode(checkBox(t, refused, nil))["docker"].(map[string]any)["update"].(map[string]any) + if refusedUpdate["refusal"] != "origin" || refusedUpdate["available"] != false || refusedUpdate["plan_id"] != "" { + t.Errorf("update = %#v", refusedUpdate) + } + if list, ok := refusedUpdate["packages"].([]any); !ok || len(list) != 0 { + t.Errorf("packages = %#v, want []", refusedUpdate["packages"]) + } + if list, ok := refusedUpdate["refusal_detail"].([]any); !ok || len(list) != 0 { + t.Errorf("refusal_detail = %#v, want []", refusedUpdate["refusal_detail"]) + } + + // nothing to update: no "update" key at all + idle := ownerBox(t) + idle.upgradeSimulation, idle.policy, idle.dockerCE = simLibc, currentPolicy, dockerCE + idleDocker := encode(checkBox(t, idle, nil))["docker"].(map[string]any) + if _, there := idleDocker["update"]; there { + t.Errorf("the Docker line of a box with nothing to update has an update: %#v", idleDocker) + } +} + +func TestTheFakeAptBoxComparesVersionsNumerically(t *testing.T) { + for _, c := range []struct { + candidate, installed string + later bool + }{ + {"5:29.10.0-1~u", "5:29.9.0-1~u", true}, + {"5:29.9.0-1~u", "5:29.10.0-1~u", false}, + {"5:29.8.2-1", "5:29.8.1-1", true}, + {"29.8.2", "5:28.0.4", false}, // the epoch first + {"1:1.0", "9:99", false}, + {"10:1", "9:99", true}, + {"5:28.0.4-1", "5:28.0.4-1", false}, + {"2.1.4-1", "1.7.27-1", true}, + } { + _, err := (&aptBox{}).command(context.Background(), "dpkg", "--compare-versions", c.candidate, "gt", c.installed) + if (err == nil) != c.later { + t.Errorf("%q gt %q = %v, want %v", c.candidate, c.installed, err == nil, c.later) + } + } +} + +func TestACandidateOf29_10IsNewerThan29_9(t *testing.T) { + box := ownerBox(t) + const installed, candidate = "5:29.9.0-1~debian.11~bullseye", "5:29.10.0-1~debian.11~bullseye" + box.upgradeSimulation = simLibc + box.dockerCE = installed + box.policy = strings.NewReplacer(debian28, installed, debian29, candidate).Replace(ownerPolicy) + box.enginePlans = map[string]string{} + if got := checkBox(t, box, nil).Docker; got.Candidate != "29.10.0" { + t.Errorf("candidate = %q: 29.10.0 is later than 29.9.0", got.Candidate) + } +} + +type recordedErrors struct{ messages []string } + +func (r *recordedErrors) Errorf(format string, args ...any) { + r.messages = append(r.messages, fmt.Sprintf(format, args...)) +} + +func TestAStrictAptBoxFailsOnACommandNobodyAnswers(t *testing.T) { + for _, call := range [][]string{ + {"df", "-h"}, + {"systemctl", "start", "docker.service"}, + {"/usr/bin/apt-get", "-y", "install", "docker-ce"}, + {"/usr/bin/docker", "version"}, + {"apt-mark", "showhold"}, + } { + var recorded recordedErrors + _, err := (&aptBox{strict: &recorded}).command(context.Background(), call[0], call[1:]...) + if len(recorded.messages) != 1 || err == nil { + t.Errorf("%v: errors = %v, err = %v", call, recorded.messages, err) + } + // without the option it is what it always was: success, no output + if out, err := (&aptBox{}).command(context.Background(), call[0], call[1:]...); out != nil || err != nil { + t.Errorf("%v: a box that is not strict answered %q, %v", call, out, err) + } + } + var recorded recordedErrors + strict := &aptBox{strict: &recorded, dockerCE: debian28} + for _, call := range [][]string{ + {"systemctl", "show", "x.service", "--property=ActiveState", "--value"}, + {"dpkg-query", "-W", "docker-ce"}, + {"/usr/bin/apt-get", "update"}, + {"/usr/bin/apt-get", "-s", "upgrade"}, + {"/usr/bin/docker", "info"}, + {"/usr/bin/docker", "ps", "-q"}, + {"/usr/bin/docker", "inspect", "abc"}, + } { + strict.command(context.Background(), call[0], call[1:]...) + } + if len(recorded.messages) != 0 { + t.Errorf("a command the box knows was reported: %v", recorded.messages) + } +} diff --git a/service/system_package_test.go b/service/system_package_test.go index 0ea199ac..bdfdc2a7 100644 --- a/service/system_package_test.go +++ b/service/system_package_test.go @@ -380,5 +380,7 @@ func newTestSystemPackageUpdater(t *testing.T) *systemPackageUpdater { return os.Stat(path) }, now: func() time.Time { return time.Date(2026, time.August, 13, 1, 2, 3, 0, time.UTC) }, + // plenty of room, unless a test says otherwise + freeBytes: func(string) (uint64, error) { return 50 << 30, nil }, } } From 7aa54cb69a77149fb7d22e0bdc4f4c100b329cf9 Mon Sep 17 00:00:00 2001 From: Gary Date: Fri, 9 Oct 2026 10:13:20 +0200 Subject: [PATCH 07/33] feat(packages): list the running containers for the Docker confirmation GET /v1/sys/docker/containers answers {running, containers} from the docker command alone: docker info for the daemon, docker ps -q for the ids, one docker inspect for the containers, all within 5 seconds. Only hexadecimal ids go to inspect, names and images are validated and bounded by the parser, and a daemon that does not answer gives running false and an empty list. It does not take the updater's lock, so it answers while a check or an update is under way. --- route/v1.go | 1 + route/v1/system.go | 14 ++ route/v1/system_package_test.go | 83 +++++++++++ service/system.go | 2 + service/system_docker_containers.go | 63 ++++++++ service/system_docker_containers_test.go | 178 +++++++++++++++++++++++ 6 files changed, 341 insertions(+) create mode 100644 service/system_docker_containers.go create mode 100644 service/system_docker_containers_test.go diff --git a/route/v1.go b/route/v1.go index 7e989b2c..24e2915e 100644 --- a/route/v1.go +++ b/route/v1.go @@ -112,6 +112,7 @@ func InitV1Router() http.Handler { v1SysGroup.GET("/packages", v1.GetSystemPackageUpdates) v1SysGroup.POST("/packages/update", v1.StartSystemPackageUpdate) v1SysGroup.GET("/packages/update/status", v1.GetSystemPackageUpdateStatus) + v1SysGroup.GET("/docker/containers", v1.GetSystemDockerContainers) v1SysGroup.GET("/hardware", v1.GetSystemHardwareInfo) // hardware/info diff --git a/route/v1/system.go b/route/v1/system.go index 15626bc5..21367de6 100644 --- a/route/v1/system.go +++ b/route/v1/system.go @@ -157,6 +157,20 @@ func GetSystemPackageUpdateStatus(ctx echo.Context) error { }) } +// @Summary list the running Docker containers, which an update of Docker stops +// @Produce application/json +// @Tags sys +// @Security ApiKeyAuth +// @Success 200 {object} model.Result +// @Router /sys/docker/containers [get] +func GetSystemDockerContainers(ctx echo.Context) error { + return ctx.JSON(http.StatusOK, model.Result{ + Success: common_err.SUCCESS, + Message: common_err.GetMsg(common_err.SUCCESS), + Data: service.MyService.System().GetSystemDockerContainers(), + }) +} + // @Summary get logs // @Produce application/json // @Accept application/json diff --git a/route/v1/system_package_test.go b/route/v1/system_package_test.go index 8e6d3bed..90c09d72 100644 --- a/route/v1/system_package_test.go +++ b/route/v1/system_package_test.go @@ -6,9 +6,11 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" "github.com/ReCasaOS/CasaOS/model" + "github.com/ReCasaOS/CasaOS/pkg/dockerpkg" "github.com/ReCasaOS/CasaOS/service" "github.com/labstack/echo/v4" ) @@ -19,6 +21,7 @@ type fakeSystemPackageService struct { updateStatus service.SystemPackageUpdateStatus checkErr error startErr error + containers service.SystemDockerContainers } func (f *fakeSystemPackageService) GetSystemPackageUpdates() (service.SystemPackageUpdates, error) { @@ -33,6 +36,10 @@ func (f *fakeSystemPackageService) GetSystemPackageUpdateStatus() service.System return f.updateStatus } +func (f *fakeSystemPackageService) GetSystemDockerContainers() service.SystemDockerContainers { + return f.containers +} + type fakeSystemPackageRepository struct { service.Repository system service.SystemService @@ -62,6 +69,10 @@ func performSystemPackageRequest(t *testing.T, method, path string) *httptest.Re if err := GetSystemPackageUpdateStatus(context); err != nil { t.Fatalf("GetSystemPackageUpdateStatus() error = %v", err) } + case method == http.MethodGet && path == "/v1/sys/docker/containers": + if err := GetSystemDockerContainers(context); err != nil { + t.Fatalf("GetSystemDockerContainers() error = %v", err) + } default: t.Fatalf("unsupported test request %s %s", method, path) } @@ -217,3 +228,75 @@ func TestGetSystemPackageUpdatesCarriesDockerOnItsOwnLine(t *testing.T) { t.Fatalf("response data = %#v", response.Data) } } + +func TestGetSystemPackageUpdatesCarriesTheDockerUpdate(t *testing.T) { + original := service.MyService + t.Cleanup(func() { service.MyService = original }) + + fakeSystem := &fakeSystemPackageService{updates: service.SystemPackageUpdates{ + Supported: true, + Manager: "apt", + Updates: []service.SystemPackageUpdate{}, + Docker: &service.SystemPackageDocker{ + Installed: true, Origin: "docker-repository", Version: "28.0.4", + Updates: []service.SystemPackageUpdate{}, + Update: &service.SystemPackageDockerUpdate{ + Available: true, RefusalDetail: []string{}, From: "28.0.4", To: "29.8.0", MajorJump: true, PlanID: "ab", + Packages: []service.SystemPackageUpdate{{Name: "docker-ce", CurrentVersion: "5:28.0.4-1", CandidateVersion: "5:29.8.0-1"}}, + }, + }, + }} + service.MyService = fakeSystemPackageRepository{system: fakeSystem} + + recorder := performSystemPackageRequest(t, http.MethodGet, "/v1/sys/packages") + var response model.Result + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + data, _ := response.Data.(map[string]interface{}) + docker, _ := data["docker"].(map[string]interface{}) + update, _ := docker["update"].(map[string]interface{}) + packages, _ := update["packages"].([]interface{}) + if update["available"] != true || update["refusal"] != "" || update["from"] != "28.0.4" || update["to"] != "29.8.0" || update["major_jump"] != true || update["plan_id"] != "ab" || len(packages) != 1 { + t.Fatalf("docker.update = %#v", update) + } + if detail, ok := update["refusal_detail"].([]interface{}); !ok || len(detail) != 0 { + t.Fatalf("refusal_detail = %#v, want []", update["refusal_detail"]) + } +} + +func TestGetSystemDockerContainersResponse(t *testing.T) { + original := service.MyService + t.Cleanup(func() { service.MyService = original }) + + fakeSystem := &fakeSystemPackageService{containers: service.SystemDockerContainers{ + Running: true, + Containers: []dockerpkg.Container{{ + Name: "x", Image: "nginx", RestartPolicy: "always", HostNetwork: false, + Ports: []dockerpkg.Port{{Port: 80, Protocol: "tcp", HostPort: 8080}}, + }}, + }} + service.MyService = fakeSystemPackageRepository{system: fakeSystem} + + recorder := performSystemPackageRequest(t, http.MethodGet, "/v1/sys/docker/containers") + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK) + } + want := `{"success":200,"message":"ok","data":{"running":true,"containers":[{"name":"x","image":"nginx","restart_policy":"always","host_network":false,"ports":[{"port":80,"protocol":"tcp","host_port":8080}]}]}}` + if got := strings.TrimSpace(recorder.Body.String()); got != want { + t.Fatalf("body = %s, want %s", got, want) + } +} + +func TestGetSystemDockerContainersWithoutADaemon(t *testing.T) { + original := service.MyService + t.Cleanup(func() { service.MyService = original }) + + service.MyService = fakeSystemPackageRepository{system: &fakeSystemPackageService{containers: service.SystemDockerContainers{Containers: []dockerpkg.Container{}}}} + + recorder := performSystemPackageRequest(t, http.MethodGet, "/v1/sys/docker/containers") + want := `{"success":200,"message":"ok","data":{"running":false,"containers":[]}}` + if recorder.Code != http.StatusOK || strings.TrimSpace(recorder.Body.String()) != want { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } +} diff --git a/service/system.go b/service/system.go index 474bc4ff..2220a07d 100644 --- a/service/system.go +++ b/service/system.go @@ -44,6 +44,8 @@ type SystemService interface { GetSystemPackageUpdates() (SystemPackageUpdates, error) StartSystemPackageUpdate() (SystemPackageUpdateStatus, error) GetSystemPackageUpdateStatus() SystemPackageUpdateStatus + // GetSystemDockerContainers lists the running containers, which an update of Docker stops. + GetSystemDockerContainers() SystemDockerContainers GetSystemConfigDebug() []string GetCasaOSLogs(lineNumber int) string UpdateAssist() diff --git a/service/system_docker_containers.go b/service/system_docker_containers.go new file mode 100644 index 00000000..ec298b3e --- /dev/null +++ b/service/system_docker_containers.go @@ -0,0 +1,63 @@ +package service + +import ( + "context" + "regexp" + "strings" + + "github.com/ReCasaOS/CasaOS/pkg/dockerpkg" +) + +// SystemDockerContainers is the running containers, as the confirmation of an update of Docker +// lists them: the update stops every one of them, and only those with a restart policy start +// again by themselves. +type SystemDockerContainers struct { + // Running is whether the Docker daemon answered. When it did not, there is no list. + Running bool `json:"running"` + // Containers are the containers that run now, never null. Names and images are text the + // owner's apps chose, and are for display only. + Containers []dockerpkg.Container `json:"containers"` +} + +// containerIDPattern is the short or the whole id `docker ps -q` prints. Only what matches is +// handed back to docker inspect, so that nothing a command printed can pass for an option. +var containerIDPattern = regexp.MustCompile(`^[0-9a-f]{12,64}$`) + +// GetSystemDockerContainers lists the running containers. It is quick, asks no one but the +// docker command and does not wait for a package operation: the page asks it when the owner +// opens the confirmation. +func (s *systemService) GetSystemDockerContainers() SystemDockerContainers { + ctx, cancel := context.WithTimeout(context.Background(), systemDockerInfoTimeout) + defer cancel() + return s.systemPackageUpdater().dockerContainers(ctx) +} + +func (u *systemPackageUpdater) dockerContainers(ctx context.Context) SystemDockerContainers { + result := SystemDockerContainers{Containers: []dockerpkg.Container{}} + dockerPath, _, err := u.dockerDaemon(ctx) + if err != nil { + return result + } + output, err := u.command(ctx, dockerPath, "ps", "-q") + if err != nil { + return result + } + var ids []string + for _, line := range strings.Split(string(output), "\n") { + if id := strings.TrimSpace(line); containerIDPattern.MatchString(id) { + ids = append(ids, id) + } + } + result.Running = true + if len(ids) == 0 { + return result + } + // A container that stops between the two commands makes inspect fail for it and print the + // others; with nothing at all printed the daemon is not answering. + output, err = u.command(ctx, dockerPath, append([]string{"inspect", "--format", dockerpkg.ContainerInspectFormat}, ids...)...) + result.Containers = dockerpkg.ParseContainers(string(output)) + if err != nil && len(result.Containers) == 0 { + result.Running = false + } + return result +} diff --git a/service/system_docker_containers_test.go b/service/system_docker_containers_test.go new file mode 100644 index 00000000..19fc2254 --- /dev/null +++ b/service/system_docker_containers_test.go @@ -0,0 +1,178 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + "time" + + "github.com/ReCasaOS/CasaOS/pkg/dockerpkg" +) + +// The docker command's answers for the list of containers + +const ( + inspectedWeb = `{"name":"/web","image":"nginx:1.27","restart":"always","network":"bridge","ports":{"80/tcp":[{"HostIp":"0.0.0.0","HostPort":"8080"}],"443/tcp":null}}` + inspectedPihole = `{"name":"/pihole","image":"pihole/pihole:latest","restart":"unless-stopped","network":"host","ports":{}}` + inspectedBatch = `{"name":"/batch-1","image":"alpine","restart":"no","network":"bridge","ports":null}` + inspectedHTMLName = `{"name":"/","image":"x","restart":"no","network":"bridge","ports":null}` + inspectedShell = `{"name":"/x;rm -rf /","image":"x","restart":"no","network":"bridge","ports":null}` + inspectedPolicy = `{"name":"/odd","image":"x","restart":"always; reboot","network":"bridge","ports":null}` + inspectedImage = "{\"name\":\"/imagebox\",\"image\":\"\\u0000\\u001b[31m\",\"restart\":\"\",\"network\":\"bridge\",\"ports\":null}" +) + +func containersOf(t *testing.T, box *aptBox, tweak func(*systemPackageUpdater)) SystemDockerContainers { + t.Helper() + updater := newTestSystemPackageUpdater(t) + updater.command = box.command + if tweak != nil { + tweak(updater) + } + return (&systemService{packageUpdates: updater}).GetSystemDockerContainers() +} + +func marshalled(t *testing.T, v any) string { + t.Helper() + data, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func TestDockerContainersWhenTheDaemonDoesNotAnswer(t *testing.T) { + for name, c := range map[string]struct { + box *aptBox + tweak func(*systemPackageUpdater) + }{ + "stopped": {&aptBox{strict: t, noDaemon: true}, nil}, + "no answer": {&aptBox{strict: t, daemonHangs: true}, nil}, + "gibberish": {&aptBox{strict: t, daemonInfo: "nothing useful\n"}, nil}, + "no command": {&aptBox{strict: t}, func(u *systemPackageUpdater) { + u.lookPath = func(string) (string, error) { return "", errors.New("not found") } + }}, + } { + got := containersOf(t, c.box, c.tweak) + if got.Running || got.Containers == nil || len(got.Containers) != 0 || marshalled(t, got) != `{"running":false,"containers":[]}` { + t.Errorf("%s: %s", name, marshalled(t, got)) + } + for _, call := range c.box.calls { + if strings.Contains(call, " ps ") || strings.Contains(call, " inspect ") { + t.Errorf("%s: %q was run for a daemon that did not answer", name, call) + } + } + } +} + +func TestDockerContainersOfADaemonWithNone(t *testing.T) { + box := &aptBox{strict: t} + got := containersOf(t, box, nil) + if marshalled(t, got) != `{"running":true,"containers":[]}` { + t.Errorf("got %s", marshalled(t, got)) + } + for _, call := range box.calls { + if strings.Contains(call, "inspect") { + t.Errorf("inspect was run with nothing to inspect: %q", call) + } + } +} + +func TestDockerContainersAreListedAndValidated(t *testing.T) { + box := &aptBox{ + strict: t, + // what `docker ps -q` printed, and what a hostile or broken command might add to it + runningIDs: []string{"0123456789ab", "--format={{.}}", "a1b2c3d4e5f6a1b2c3d4e5f6", "$(reboot)", "NOTHEX123456", "fedcba987654", " ", "-a1b2c3d4e5f6"}, + inspected: strings.Join([]string{ + inspectedWeb, inspectedPihole, inspectedBatch, inspectedHTMLName, inspectedShell, inspectedPolicy, inspectedImage, + "Error: No such object: gone", "{not json", + }, "\n") + "\n", + } + got := containersOf(t, box, nil) + + var names []string + for _, c := range got.Containers { + names = append(names, c.Name) + } + if !got.Running || !reflect.DeepEqual(names, []string{"batch-1", "imagebox", "pihole", "web"}) { + t.Fatalf("running = %v, containers = %s", got.Running, marshalled(t, got.Containers)) + } + byName := map[string]dockerpkg.Container{} + for _, c := range got.Containers { + byName[c.Name] = c + } + if web := byName["web"]; web.RestartPolicy != "always" || web.HostNetwork || web.Image != "nginx:1.27" || !reflect.DeepEqual(web.Ports, []dockerpkg.Port{{Port: 80, Protocol: "tcp", HostPort: 8080}}) { + t.Errorf("web = %#v", web) + } + if pihole := byName["pihole"]; !pihole.HostNetwork || pihole.RestartPolicy != "unless-stopped" || pihole.Ports == nil || len(pihole.Ports) != 0 { + t.Errorf("pihole = %#v", pihole) + } + if batch := byName["batch-1"]; batch.RestartPolicy != "no" || batch.Ports == nil { + t.Errorf("batch-1 = %#v", batch) + } + // an image is text for display: no control character, whatever else it holds + if image := byName["imagebox"].Image; image != "[31m" { + t.Errorf("image = %q", image) + } + // a name that is not a Docker name, or a policy that is not a policy, is not listed + if strings.Contains(marshalled(t, got), "rm -rf") || strings.Contains(marshalled(t, got), "reboot") { + t.Errorf("a hostile string was passed on: %s", marshalled(t, got)) + } + + // only ids went to docker inspect, after its fixed options + want := "/usr/bin/docker inspect --format " + dockerpkg.ContainerInspectFormat + " 0123456789ab a1b2c3d4e5f6a1b2c3d4e5f6 fedcba987654" + var inspects []string + for _, call := range box.calls { + if strings.Contains(call, " inspect ") { + inspects = append(inspects, call) + } + } + if len(inspects) != 1 || inspects[0] != want { + t.Errorf("inspect calls = %#v, want %q", inspects, want) + } +} + +func TestDockerContainersWhenInspectFails(t *testing.T) { + // a container stopped between the two commands: the others are still listed + partial := &aptBox{strict: t, runningIDs: []string{"0123456789ab", "fedcba987654"}, inspected: inspectedWeb + "\nError: No such object: fedcba987654\n", inspectFailure: true} + if got := containersOf(t, partial, nil); !got.Running || len(got.Containers) != 1 || got.Containers[0].Name != "web" { + t.Errorf("partial: %s", marshalled(t, got)) + } + // nothing at all came back: the daemon is not answering + none := &aptBox{strict: t, runningIDs: []string{"0123456789ab"}, inspected: "Cannot connect to the Docker daemon\n", inspectFailure: true} + if got := containersOf(t, none, nil); got.Running || got.Containers == nil || len(got.Containers) != 0 { + t.Errorf("none: %s", marshalled(t, got)) + } +} + +func TestDockerContainersAreAskedQuicklyAndWithoutTheLock(t *testing.T) { + box := &aptBox{strict: t, runningIDs: []string{"0123456789ab"}, inspected: inspectedWeb + "\n"} + updater := newTestSystemPackageUpdater(t) + var withoutDeadline []string + updater.command = func(ctx context.Context, name string, args ...string) ([]byte, error) { + if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > 5*time.Second { + withoutDeadline = append(withoutDeadline, name+" "+strings.Join(args, " ")) + } + return box.command(ctx, name, args...) + } + system := &systemService{packageUpdates: updater} + + // a check or an update holds the lock for minutes; the list must not wait for it + updater.mu.Lock() + defer updater.mu.Unlock() + done := make(chan SystemDockerContainers, 1) + go func() { done <- system.GetSystemDockerContainers() }() + select { + case got := <-done: + if !got.Running || len(got.Containers) != 1 { + t.Errorf("got %s", marshalled(t, got)) + } + case <-time.After(3 * time.Second): + t.Fatal("the list of containers waited for the updater's lock") + } + if len(withoutDeadline) != 0 { + t.Errorf("docker was run without a deadline of at most 5 s: %#v", withoutDeadline) + } +} From 1a9e8dc41fd5886e75ba5cdab84537afa05a260f Mon Sep 17 00:00:00 2001 From: Gary Date: Fri, 9 Oct 2026 10:35:56 +0200 Subject: [PATCH 08/33] feat(packages): list the app operations in progress, not only whether there are some The Docker update names the apps that hold it. appsBusy stays, as the wrapper that treats no answer as busy. --- pkg/autoupdate/autoupdate.go | 45 ++++++++++++++++++++------ pkg/autoupdate/autoupdate_test.go | 52 +++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 10 deletions(-) diff --git a/pkg/autoupdate/autoupdate.go b/pkg/autoupdate/autoupdate.go index aa8a6b8d..1b3716bf 100644 --- a/pkg/autoupdate/autoupdate.go +++ b/pkg/autoupdate/autoupdate.go @@ -18,6 +18,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "math/rand/v2" "net/http" "os" @@ -266,29 +267,53 @@ func (a *AutoUpdate) unitActive() bool { return true } -// appsBusy asks AppManagement whether an app operation runs, the way the core -// reaches the other services: at the address it leaves in the runtime path, -// with this boot's secret. No answer, or any operation listed, is busy. -func appsBusy(ctx context.Context, runtimePath string) bool { +// Operation is an app operation in progress, as AppManagement lists it. Both fields are +// AppManagement's text: for the caller to validate before it shows or logs them. +type Operation struct { + App string `json:"app"` + Kind string `json:"kind"` +} + +// AppOperations asks AppManagement which app operations are in progress, the way the core +// reaches the other services: at the address it leaves in the runtime path, with this boot's +// secret. The list is empty (not nil) when nothing runs. An error is no answer: it could not +// be asked, or what it said is not the list, and a caller that must not act on a guess treats +// it as an operation in progress. +func AppOperations(ctx context.Context, runtimePath string) ([]Operation, error) { address, err := os.ReadFile(filepath.Join(runtimePath, external.AppManageURLFilename)) if err != nil { - return true + return nil, err } ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() request, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(strings.TrimSpace(string(address)), "/")+operationsPath, nil) if err != nil { - return true + return nil, err } _ = external.InternalRequestEditor(runtimePath)(ctx, request) // never fails response, err := http.DefaultClient.Do(request) if err != nil { - return true + return nil, err } defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("AppManagement answered %d", response.StatusCode) + } var body struct { - Operations *[]json.RawMessage `json:"operations"` + Operations *[]Operation `json:"operations"` + } + if err := json.NewDecoder(response.Body).Decode(&body); err != nil { + return nil, err + } + if body.Operations == nil { + return nil, errors.New("AppManagement's answer has no list of operations") } - return response.StatusCode != http.StatusOK || json.NewDecoder(response.Body).Decode(&body) != nil || - body.Operations == nil || len(*body.Operations) > 0 + return *body.Operations, nil +} + +// appsBusy is AppOperations for a caller that only needs to know whether to wait. No answer, +// or any operation listed, is busy. +func appsBusy(ctx context.Context, runtimePath string) bool { + operations, err := AppOperations(ctx, runtimePath) + return err != nil || len(operations) > 0 } diff --git a/pkg/autoupdate/autoupdate_test.go b/pkg/autoupdate/autoupdate_test.go index 100558de..85697974 100644 --- a/pkg/autoupdate/autoupdate_test.go +++ b/pkg/autoupdate/autoupdate_test.go @@ -553,6 +553,58 @@ func TestAppManagementIsAskedAsAnInternalRequest(t *testing.T) { }) } +// AppOperations is the list appsBusy only counts: the Docker update says which apps hold it. +func TestAppOperationsListsWhatAppManagementDoes(t *testing.T) { + runtimePath := t.TempDir() + if err := external.WriteInternalSecret(runtimePath); err != nil { + t.Fatal(err) + } + serve := func(status int, body string) { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(server.Close) + if err := os.WriteFile(filepath.Join(runtimePath, external.AppManageURLFilename), []byte(server.URL), 0o644); err != nil { + t.Fatal(err) + } + } + + serve(http.StatusOK, `{"operations":[{"app":"immich","kind":"backup"},{"app":"casaos-system","kind":"backup"}]}`) + got, err := AppOperations(context.Background(), runtimePath) + if err != nil || len(got) != 2 || got[0] != (Operation{App: "immich", Kind: "backup"}) || got[1].App != "casaos-system" { + t.Fatalf("AppOperations() = %#v, %v", got, err) + } + + serve(http.StatusOK, `{"operations":[]}`) + got, err = AppOperations(context.Background(), runtimePath) + if err != nil || got == nil || len(got) != 0 { + t.Fatalf("AppOperations() = %#v, %v: nothing runs, and that is an answer", got, err) + } + + // what is not the list is no answer, never "nothing runs" + for name, c := range map[string]struct { + status int + body string + }{ + "no list": {http.StatusOK, `{}`}, + "a null list": {http.StatusOK, `{"operations":null}`}, + "not JSON": {http.StatusOK, `ok`}, + "an operation that is not one": {http.StatusOK, `{"operations":[7]}`}, + "no such route": {http.StatusNotFound, `{"operations":[]}`}, + "an error": {http.StatusInternalServerError, `{"operations":[]}`}, + } { + serve(c.status, c.body) + if got, err := AppOperations(context.Background(), runtimePath); err == nil { + t.Errorf("%s: AppOperations() = %#v, nil: want an error", name, got) + } + } + if _, err := AppOperations(context.Background(), t.TempDir()); err == nil { + t.Error("no app-management.url: want an error") + } +} + func TestAPackageUpdateOrDpkgsLockHoldsTheStart(t *testing.T) { a, b := newBox(t) saveState(t, a, on()) From cfc922157a9ea7a5fa3fa584f6ac5bc450c9df3b Mon Sep 17 00:00:00 2001 From: Gary Date: Fri, 9 Oct 2026 10:35:56 +0200 Subject: [PATCH 09/33] feat(packages): the script of the unit that updates Docker A constant run by /bin/sh; its parameters travel in the environment of the unit. Guard, download, install, wait for the daemon, say which containers did not come back. --- service/system_package_docker_script.go | 227 ++++++ service/system_package_docker_script_test.go | 767 +++++++++++++++++++ 2 files changed, 994 insertions(+) create mode 100644 service/system_package_docker_script.go create mode 100644 service/system_package_docker_script_test.go diff --git a/service/system_package_docker_script.go b/service/system_package_docker_script.go new file mode 100644 index 00000000..63596c13 --- /dev/null +++ b/service/system_package_docker_script.go @@ -0,0 +1,227 @@ +package service + +import ( + "errors" + "fmt" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/ReCasaOS/CasaOS/pkg/dockerpkg" +) + +// The unit that updates Docker runs a script that is a constant: nothing that comes from the +// page, from apt or from Docker is ever put into its text. What varies travels in the unit's +// environment (systemd-run --setenv), after the core validated it, and the script treats it as +// data: it is quoted, or split into the words it was validated to be made of, and never eval'd. + +const ( + // systemDockerDaemonTimeout is how long the unit waits for the daemon after the install. + systemDockerDaemonTimeout = 300 + // systemDockerReturnTimeout is how long it waits for the containers that start again by + // themselves. + systemDockerReturnTimeout = 90 + // systemDockerPoll is the seconds between two looks. + systemDockerPoll = 2 +) + +// dockerUpdateScript is run as `/bin/sh -c `. It is POSIX sh, for dash as well as busybox +// ash: no arrays, no [[ ]], no <<<, no local, no pipefail. It is a raw string, which Go +// reads without the carriage returns of a CRLF checkout. +// +// Every line it writes in the log that is the unit's word is "CASAOS_DOCKER_UPDATE_ +// ...", one KIND of dockerpkg.ParseRun, and the last line is the one terminal marker. +// What apt, dpkg or Docker print is never given such a start. +const dockerUpdateScript = `set -f +: "${CASAOS_DU_NONCE:?}" "${CASAOS_DU_LOG:?}" "${CASAOS_DU_APT:?}" +exec >> "$CASAOS_DU_LOG" 2>&1 + +poll=${CASAOS_DU_POLL:-2} +daemon_timeout=${CASAOS_DU_DAEMON_TIMEOUT:-300} +return_timeout=${CASAOS_DU_RETURN_TIMEOUT:-90} + +ts() { date -u +%Y-%m-%dT%H:%M:%SZ; } +mark() { printf 'CASAOS_DOCKER_UPDATE_%s %s%s\n' "$1" "$CASAOS_DU_NONCE" "${2:+ $2}"; } +fail() { mark FAILED "$(ts) $1"; exit 1; } +# A docker command waits for ever on a socket whose service will not start: bound them all. +bound() { n=$1; shift; if command -v timeout >/dev/null 2>&1; then timeout "$n" "$@"; else "$@"; fi; } + +# wait_daemon sets $running to the version the daemon reports; it fails after the timeout. +wait_daemon() { + deadline=$(( $(date +%s) + daemon_timeout )) + while :; do + running=$(bound 20 docker version --format '{{.Server.Version}}' 2>/dev/null) + case $running in + '' | [!0-9A-Za-z]* | *[!0-9A-Za-z.+~_-]*) ;; + *) return 0 ;; + esac + [ "$(date +%s)" -lt "$deadline" ] || return 1 + sleep "$poll" + done +} + +mark STARTED "$(ts)" + +# The lists below are split into words: there must be some, and nothing in them but what a pin +# or a name is made of. Any doubt refuses, before anything is looked at. +pins=$(printf '%s' "$CASAOS_DU_PINS" | tr -d ' ') +names=$(printf '%s' "$CASAOS_DU_NAMES" | tr -d ' ') +case "$pins$names" in *[!A-Za-z0-9.+~:=-]*) pins= ;; esac +if [ -z "$pins" ] || [ -z "$names" ]; then + mark GUARD plan + fail guard +fi + +# What is installed now (what a rollback would put back) and what runs now. Only text of the +# shape of a package pin or of a container's name and policy gets into the log. +previous= +for name in $CASAOS_DU_NAMES; do + for pin in $(dpkg-query -W -f='${Package}=${Version}\n' "$name" 2>/dev/null | grep -E '^[a-z0-9][a-z0-9+.-]*=([0-9]+:)?[0-9][A-Za-z0-9.+~-]*$'); do + previous="$previous $pin" + done +done +mark PREVIOUS "${previous# }" + +containers= +ids=$(bound 20 docker ps -q 2>/dev/null | grep -E '^[0-9a-f]{12,64}$') +if [ -n "$ids" ]; then + containers=$(bound 20 docker inspect --format '{{.Name}} {{.HostConfig.RestartPolicy.Name}}' $ids 2>/dev/null | sed 's#^/##' | grep -E '^[A-Za-z0-9][A-Za-z0-9_.-]* [a-z-]*$') +fi +while read -r name policy; do + [ -n "$name" ] && printf 'container: %s %s\n' "$name" "$policy" +done <&1) || ok=0 +while read -r kind pkg rest; do + case $kind in + Inst) + case " $CASAOS_DU_NAMES " in *" $pkg "*) ;; *) ok=0 ;; esac + # an upgrade names the version it replaces; a package that is new does not + case $rest in '['*) ;; *) ok=0 ;; esac + ;; + Remv | Purg) ok=0 ;; + esac +done </dev/null) + while read -r name policy; do + [ -n "$name" ] || continue + printf '%s\n' "$up" | grep -Fxq -- "$name" && continue + missing="$missing $name:$policy" + case $policy in '' | no) ;; *) waiting=1 ;; esac + done <> "$d/calls.log" +case " $* " in +*" -s "*) + cat "$d/plan.txt" 2>/dev/null + exit "$(cat "$d/sim.rc" 2>/dev/null || echo 0)" ;; +*" --download-only "*) + exit "$(cat "$d/download.rc" 2>/dev/null || echo 0)" ;; +*" update "*) + exit 0 ;; +esac +rc=$(cat "$d/install.rc" 2>/dev/null || echo 0) +if [ "$rc" -eq 0 ]; then + : > "$d/installed" + cp "$d/down.after.install" "$d/down.left" 2>/dev/null +fi +exit "$rc" +` + +const fakeDocker = `#!/bin/sh +d=$FAKE_DIR +echo "docker $*" >> "$d/calls.log" +case "$*" in +"ps -q") cat "$d/ids.txt" 2>/dev/null ;; +"inspect "*) cat "$d/inspect.txt" 2>/dev/null ;; +"ps --format"*) + n=$(cat "$d/ps.count" 2>/dev/null || echo 0) + n=$((n + 1)) + echo "$n" > "$d/ps.count" + f="$d/ps.$n" + [ -f "$f" ] || f="$d/ps.last" + cat "$f" 2>/dev/null ;; +"version "*) + if [ -f "$d/broken" ] && [ ! -f "$d/started" ]; then exit 1; fi + left=$(cat "$d/down.left" 2>/dev/null || echo 0) + if [ "$left" -gt 0 ]; then + echo $((left - 1)) > "$d/down.left" + exit 1 + fi + if [ -f "$d/installed" ] && [ ! -f "$d/stale" ]; then cat "$d/version.after"; else cat "$d/version.before"; fi ;; +esac +exit 0 +` + +const fakeDpkgQuery = `#!/bin/sh +d=$FAKE_DIR +echo "dpkg-query $*" >> "$d/calls.log" +for last; do :; done +cat "$d/dpkg/$last" 2>/dev/null || exit 1 +` + +const fakeSystemctl = `#!/bin/sh +d=$FAKE_DIR +echo "systemctl $*" >> "$d/calls.log" +case "$*" in +*docker.service*) : > "$d/started" ;; +esac +exit 0 +` + +const fakeSleep = `#!/bin/sh +d=$FAKE_DIR +n=${1:-1} +[ "$n" -ge 1 ] 2>/dev/null || n=1 +t=$(cat "$d/clock" 2>/dev/null || echo 0) +echo $((t + n)) > "$d/clock" +` + +// timeout runs what it is given, and writes it down: every call to docker must have been bounded +const fakeTimeout = `#!/bin/sh +echo "timeout $*" >> "$FAKE_DIR/calls.log" +shift +exec "$@" +` + +const fakeDate = `#!/bin/sh +d=$FAKE_DIR +t=$(cat "$d/clock" 2>/dev/null || echo 0) +case "$*" in +*+%s*) echo $((1790000000 + t)) ;; +*) printf '2026-10-09T%02d:%02d:%02dZ\n' $((t / 3600 % 24)) $((t / 60 % 60)) $((t % 60)) ;; +esac +` + +type scriptBox struct { + t *testing.T + dir string + env map[string]string +} + +func writeFile(t *testing.T, path, content string, mode os.FileMode) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), mode); err != nil { + t.Fatal(err) + } +} + +// newScriptBox is the owner's box: 28.0.4 installed, 29.8.0 confirmed, three containers running. +func newScriptBox(t *testing.T) *scriptBox { + t.Helper() + if runtime.GOOS != "linux" { + t.Skip("the unit's script is for a Linux shell") + } + if _, err := os.Stat("/bin/sh"); err != nil { + t.Skip("the unit's script is run by /bin/sh, which this host does not have") + } + dir := t.TempDir() + b := &scriptBox{t: t, dir: dir, env: map[string]string{}} + for name, body := range map[string]string{ + "apt-get": fakeAptGet, "docker": fakeDocker, "dpkg-query": fakeDpkgQuery, + "systemctl": fakeSystemctl, "sleep": fakeSleep, "date": fakeDate, "timeout": fakeTimeout, + } { + writeFile(t, filepath.Join(dir, "bin", name), body, 0o755) + } + b.set("plan.txt", ownerContaind+ownerDockerCE+ownerCLI) + b.set("dpkg/containerd.io", "containerd.io=1.7.27-1\n") + b.set("dpkg/docker-ce", "docker-ce="+debian28+"\n") + b.set("dpkg/docker-ce-cli", "docker-ce-cli="+debian28+"\n") + b.set("ids.txt", "aaaaaaaaaaaa\nbbbbbbbbbbbb\ncccccccccccc\n") + b.set("inspect.txt", "/web always\n/db unless-stopped\n/job no\n") + // the daemon is down for two looks after the install, then it is the new one; the containers + // that have a policy come back one look after the other, the one that has none never does + b.set("version.before", "28.0.4\n") + b.set("version.after", "29.8.0\n") + b.set("down.after.install", "2\n") + b.set("ps.1", "web\n") + b.set("ps.last", "web\ndb\n") + return b +} + +func (b *scriptBox) set(name, content string) { + b.t.Helper() + writeFile(b.t, filepath.Join(b.dir, name), content, 0o644) +} + +func (b *scriptBox) logPath() string { return filepath.Join(b.dir, "docker-update.log") } + +type scriptRun struct { + log string + exit int + calls []string + run dockerpkg.Run +} + +func (b *scriptBox) env0() map[string]string { + return map[string]string{ + "PATH": filepath.Join(b.dir, "bin") + ":/usr/bin:/bin", + "FAKE_DIR": b.dir, + "CASAOS_DU_NONCE": scriptNonce, + "CASAOS_DU_LOG": b.logPath(), + "CASAOS_DU_APT": filepath.Join(b.dir, "bin", "apt-get"), + "CASAOS_DU_PINS": "containerd.io=2.1.4-1 docker-ce=" + debian29 + " docker-ce-cli=" + debian29, + "CASAOS_DU_NAMES": "containerd.io docker-ce docker-ce-cli", + "CASAOS_DU_TO": "29.8.0", + "CASAOS_DU_DAEMON_TIMEOUT": "10", + "CASAOS_DU_RETURN_TIMEOUT": "10", + "CASAOS_DU_POLL": "0", + "DEBIAN_FRONTEND": "noninteractive", + } +} + +// run writes the first line the core writes, then runs the script as systemd-run would. +func (b *scriptBox) run() scriptRun { + b.t.Helper() + writeFile(b.t, b.logPath(), dockerpkg.QueuedMarker(scriptNonce, time.Date(2026, 10, 9, 9, 59, 0, 0, time.UTC)), 0o644) + env := b.env0() + for k, v := range b.env { + if v == unsetEnv { + delete(env, k) + } else { + env[k] = v + } + } + var environ []string + for k, v := range env { + environ = append(environ, k+"="+v) + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "/bin/sh", "-c", dockerUpdateScript) + cmd.Env = environ + cmd.Dir = b.dir + out, err := cmd.CombinedOutput() + var result scriptRun + var exitErr *exec.ExitError + switch { + case err == nil: + case errors.As(err, &exitErr): + result.exit = exitErr.ExitCode() + default: + b.t.Fatalf("the script did not run: %v", err) + } + if ctx.Err() != nil { + b.t.Fatalf("the script did not end: %s", out) + } + data, _ := os.ReadFile(b.logPath()) + result.log = string(data) + result.run = dockerpkg.ParseRun(result.log) + calls, _ := os.ReadFile(filepath.Join(b.dir, "calls.log")) + result.calls = strings.Split(strings.TrimSpace(string(calls)), "\n") + if len(out) > 0 { + // everything goes to the log: what reaches the unit's own output is a script that failed to start + b.t.Logf("the unit's own output: %s", out) + } + return result +} + +// called counts the calls of a program that begin with prefix ("docker ps", "systemctl"). +func (r scriptRun) called(prefix string) int { + var n int + for _, call := range r.calls { + if strings.HasPrefix(call, prefix) { + n++ + } + } + return n +} + +// mentions counts the calls that have the text anywhere in them. +func (r scriptRun) mentions(text string) int { + var n int + for _, call := range r.calls { + if strings.Contains(call, text) { + n++ + } + } + return n +} + +// unbounded are the calls to docker and the start of the daemon that did not go through timeout: +// a docker command waits for ever on a socket whose service will not start. +func (r scriptRun) unbounded() []string { + var calls []string + for _, call := range r.calls { + if strings.HasPrefix(call, "docker ") && !r.hasBounded(call) { + calls = append(calls, call) + } + if strings.HasPrefix(call, "systemctl start") && !r.hasBounded(call) { + calls = append(calls, call) + } + } + return calls +} + +// hasBounded: the call was made by `timeout `, which wrote itself down first. +func (r scriptRun) hasBounded(call string) bool { + return r.called("timeout 20 "+call)+r.called("timeout 120 "+call) > 0 +} + +// aptInstalls are the calls that downloaded or installed something: not a simulation. +func (r scriptRun) aptChanges() []string { + var calls []string + for _, call := range r.calls { + if strings.HasPrefix(call, "apt-get ") && !strings.Contains(call, " -s ") { + calls = append(calls, call) + } + } + return calls +} + +var terminalLine = regexp.MustCompile(`^CASAOS_DOCKER_UPDATE_(SUCCESS|RESTART_PENDING|FAILED)( |$)`) + +// checkLogShape holds of every run: each line the unit speaks with has the nonce as its first +// field, and there is one terminal marker and it is the last line. +func checkLogShape(t *testing.T, log string) { + t.Helper() + lines := strings.Split(strings.TrimRight(log, "\n"), "\n") + var terminals int + for i, line := range lines { + if i == 0 { + continue + } + if strings.HasPrefix(line, "CASAOS_DOCKER_UPDATE_") { + if fields := strings.Fields(line); len(fields) < 2 || fields[1] != scriptNonce { + t.Errorf("a marker without the nonce: %q", line) + } + } + if terminalLine.MatchString(line) { + terminals++ + if i != len(lines)-1 { + t.Errorf("the terminal marker %q is not the last line (line %d of %d)", line, i+1, len(lines)) + } + } + } + if terminals != 1 { + t.Errorf("%d terminal markers, want exactly one:\n%s", terminals, log) + } +} + +// markerKinds are the kinds of the unit's marker lines, in the order written. +func markerKinds(log string) []string { + var kinds []string + for _, line := range strings.Split(log, "\n")[1:] { + if rest, ok := strings.CutPrefix(line, "CASAOS_DOCKER_UPDATE_"); ok { + kind, _, _ := strings.Cut(rest, " ") + kinds = append(kinds, kind) + } + } + return kinds +} + +func TestTheScriptIsPOSIXShAndAConstant(t *testing.T) { + // no shell of this list can be run by dash or busybox ash + for _, bashism := range []string{"[[", "<<<", "pipefail", "local ", "eval ", "function ", "declare ", "source ", "${!", "&>", "<("} { + if strings.Contains(dockerUpdateScript, bashism) { + t.Errorf("the script has %q, which is not POSIX sh", bashism) + } + } + // nothing in it restarts Docker or refreshes the index + for _, forbidden := range []string{"docker restart", "docker stop", "docker start", "docker kill", "docker rm", "systemctl restart", "systemctl stop", "systemctl reload", "service docker", "apt-get"} { + if strings.Contains(dockerUpdateScript, forbidden) { + t.Errorf("the script has %q", forbidden) + } + } + if strings.Contains(dockerUpdateScript, "`") || strings.Contains(dockerUpdateScript, "\r") { + t.Error("the script has a backtick or a carriage return") + } + if runtime.GOOS == "linux" { + if _, err := os.Stat("/bin/sh"); err == nil { + // sh -n reads the whole script and runs none of it + cmd := exec.Command("/bin/sh", "-n", "-c", dockerUpdateScript) + if out, err := cmd.CombinedOutput(); err != nil { + t.Errorf("sh -n: %v: %s", err, out) + } + } + } +} + +func TestTheUnitUpdatesTheOwnersDocker(t *testing.T) { + b := newScriptBox(t) + r := b.run() + checkLogShape(t, r.log) + + if r.exit != 0 || r.run.Terminal != dockerpkg.TerminalSuccess || r.run.FailReason != "" { + t.Fatalf("exit = %d, run = %#v\n%s", r.exit, r.run, r.log) + } + wantPrevious := []string{"containerd.io=1.7.27-1", "docker-ce=" + debian28, "docker-ce-cli=" + debian28} + if !reflect.DeepEqual(r.run.Previous, wantPrevious) { + t.Errorf("previous = %#v, want %#v", r.run.Previous, wantPrevious) + } + if r.run.DaemonVersion != "29.8.0" || r.run.StartedAt.IsZero() || r.run.InstalledAt.IsZero() || r.run.CompletedAt.IsZero() { + t.Errorf("run = %#v", r.run) + } + // the container that has no policy is the one that did not come back + if want := []dockerpkg.NotReturned{{Name: "job", RestartPolicy: "no"}}; !reflect.DeepEqual(r.run.NotReturned, want) { + t.Errorf("not returned = %#v, want %#v", r.run.NotReturned, want) + } + for _, line := range []string{"container: web always", "container: db unless-stopped", "container: job no"} { + if !strings.Contains(r.log, "\n"+line+"\n") { + t.Errorf("the log lacks %q:\n%s", line, r.log) + } + } + if got, want := strings.Join(markerKinds(r.log), " "), "STARTED PREVIOUS DOWNLOADED INSTALLED DAEMON NOTRETURNED SUCCESS"; got != want { + t.Errorf("markers = %s, want %s", got, want) + } + + // apt was asked: the simulation of the guard, the download, the install, in that order, with + // the confirmed pins; and nothing else + pins := "containerd.io=2.1.4-1 docker-ce=" + debian29 + " docker-ce-cli=" + debian29 + apt := filepath.Join(b.dir, "bin", "apt-get") + var aptCalls []string + for _, call := range r.calls { + if strings.HasPrefix(call, "apt-get ") { + aptCalls = append(aptCalls, call) + } + } + wantApt := []string{ + "apt-get -s --no-remove -o Debug::NoLocking=true -o Dpkg::Use-Pty=0 install --only-upgrade --no-install-recommends " + pins, + "apt-get -y --no-remove --download-only -o Dpkg::Use-Pty=0 -o DPkg::Lock::Timeout=120 install --only-upgrade --no-install-recommends " + pins, + "apt-get -y --no-remove -o Dpkg::Use-Pty=0 -o Dpkg::Options::=--force-confold -o DPkg::Lock::Timeout=120 install --only-upgrade --no-install-recommends " + pins, + } + if !reflect.DeepEqual(aptCalls, wantApt) { + t.Errorf("apt-get calls (%s):\n%s\nwant:\n%s", apt, strings.Join(aptCalls, "\n"), strings.Join(wantApt, "\n")) + } + // Docker is never restarted, stopped or started by the script; it only waits for it + for _, verb := range []string{"restart", "stop", "start", "kill", "rm", "run", "exec", "pause"} { + if n := r.called("docker " + verb); n != 0 { + t.Errorf("docker %s was called %d times", verb, n) + } + } + if n := r.called("systemctl"); n != 0 { + t.Errorf("systemctl was called %d times for an update that went well", n) + } + if n := r.called("apt-get update"); n != 0 { + t.Error("the script refreshed the index") + } + if calls := r.unbounded(); len(calls) != 0 { + t.Errorf("calls that were not bounded by timeout: %v", calls) + } +} + +func TestTheUnitSaysRestartPendingWhenTheDaemonIsStillTheOldOne(t *testing.T) { + b := newScriptBox(t) + b.set("stale", "") + b.set("down.after.install", "0\n") + r := b.run() + checkLogShape(t, r.log) + if r.exit != 0 || r.run.Terminal != dockerpkg.TerminalRestartPending || r.run.DaemonVersion != "28.0.4" { + t.Fatalf("exit = %d, run = %#v\n%s", r.exit, r.run, r.log) + } + if n := r.called("systemctl"); n != 0 { + t.Errorf("systemctl was called %d times: nothing restarts Docker", n) + } + for _, verb := range []string{"restart", "stop", "start"} { + if n := r.called("docker " + verb); n != 0 { + t.Errorf("docker %s was called", verb) + } + } +} + +func TestTheUnitHasNoVersionToCheckWhenDockerCEIsNotInThePlan(t *testing.T) { + b := newScriptBox(t) + b.env["CASAOS_DU_TO"] = "" + b.set("stale", "") + r := b.run() + checkLogShape(t, r.log) + if r.exit != 0 || r.run.Terminal != dockerpkg.TerminalSuccess { + t.Fatalf("exit = %d, run = %#v\n%s", r.exit, r.run, r.log) + } +} + +func TestTheUnitDoesNotWaitForAContainerThatHasNoPolicy(t *testing.T) { + b := newScriptBox(t) + // two containers, neither of which comes back, and neither of which is waited for + b.set("ids.txt", "aaaaaaaaaaaa\nbbbbbbbbbbbb\n") + b.set("inspect.txt", "/web no\n/old \n") + b.set("ps.1", "") + b.set("ps.last", "") + r := b.run() + checkLogShape(t, r.log) + if r.run.Terminal != dockerpkg.TerminalSuccess || len(r.run.NotReturned) != 2 { + t.Fatalf("run = %#v\n%s", r.run, r.log) + } + // "no" and no policy at all both end up in the report, in the order of the snapshot + if want := []dockerpkg.NotReturned{{Name: "web", RestartPolicy: "no"}, {Name: "old"}}; !reflect.DeepEqual(r.run.NotReturned, want) { + t.Errorf("not returned = %#v, want %#v", r.run.NotReturned, want) + } + if looks := r.called("docker ps --format"); looks != 1 { + t.Errorf("the containers were looked at %d times: a container that never comes back is not waited for", looks) + } +} + +func TestTheUnitWaitsForTheContainersThatComeBack(t *testing.T) { + b := newScriptBox(t) + b.set("inspect.txt", "/web always\n/db unless-stopped\n") + b.set("ids.txt", "aaaaaaaaaaaa\nbbbbbbbbbbbb\n") + b.set("ps.1", "") + b.set("ps.2", "web\n") + b.set("ps.3", "web\n") + b.set("ps.last", "web\ndb\n") + r := b.run() + checkLogShape(t, r.log) + if r.run.Terminal != dockerpkg.TerminalSuccess || len(r.run.NotReturned) != 0 { + t.Fatalf("run = %#v\n%s", r.run, r.log) + } + if looks := r.called("docker ps --format"); looks != 4 { + t.Errorf("the containers were looked at %d times, want 4", looks) + } + // ... and a container that does not come back in time is told, with its policy + b = newScriptBox(t) + b.set("inspect.txt", "/web always\n/db on-failure\n") + b.set("ids.txt", "aaaaaaaaaaaa\nbbbbbbbbbbbb\n") + b.set("ps.last", "web\n") + r = b.run() + checkLogShape(t, r.log) + if want := []dockerpkg.NotReturned{{Name: "db", RestartPolicy: "on-failure"}}; r.run.Terminal != dockerpkg.TerminalSuccess || !reflect.DeepEqual(r.run.NotReturned, want) { + t.Fatalf("run = %#v\n%s", r.run, r.log) + } +} + +func TestTheUnitInstallsNothingWhenTheGuardRefuses(t *testing.T) { + cases := map[string]struct { + plan string + rc string + }{ + "another package": {ownerContaind + ownerDockerCE + ownerCLI + simLibc, ""}, + "another package alone": {simLibc, ""}, + "a removal": {ownerContaind + ownerDockerCE + ownerCLI + "Remv docker-compose-v2 [2.1.0]\n", ""}, + "a purge": {ownerContaind + ownerDockerCE + ownerCLI + "Purg docker-compose-v2 [2.1.0]\n", ""}, + "a new package": {ownerContaind + ownerDockerCE + ownerCLI + "Inst libnewdep (1.0 Debian:11/stable [amd64])\n", ""}, + "a new package of the engine": {ownerContaind + ownerCLI + "Inst docker-ce (5:29.8.0-1~debian.11~bullseye Docker CE:bullseye [amd64])\n", ""}, + "a package of another arch": {ownerContaind + ownerDockerCE + ownerCLI + "Inst containerd.io:armhf [1.7.27-1] (2.1.4-1 Docker CE [armhf])\n", ""}, + "a simulation that fails": {ownerContaind + ownerDockerCE + ownerCLI, "100"}, + "a simulation that fails, bare": {"E: Unable to correct problems, you have held broken packages.\n", "100"}, + "a name that only looks like one": {ownerContaind + ownerDockerCE + ownerCLI + "Inst docker-ce-cli-extra [1] (2 x [amd64])\n", ""}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + b := newScriptBox(t) + b.set("plan.txt", c.plan) + if c.rc != "" { + b.set("sim.rc", c.rc) + } + r := b.run() + checkLogShape(t, r.log) + if r.exit != 1 || r.run.Terminal != dockerpkg.TerminalFailed || r.run.FailReason != dockerpkg.FailGuard { + t.Fatalf("exit = %d, run = %#v\n%s", r.exit, r.run, r.log) + } + if !strings.Contains(r.log, "\nCASAOS_DOCKER_UPDATE_GUARD "+scriptNonce+" plan\n") { + t.Errorf("no GUARD marker:\n%s", r.log) + } + if changes := r.aptChanges(); len(changes) != 0 { + t.Errorf("apt-get changed the box after the guard refused: %v", changes) + } + if _, err := os.Stat(filepath.Join(b.dir, "installed")); err == nil { + t.Error("something was installed") + } + // the guard is the only thing the daemon was not asked about + if r.called("docker version") != 0 { + t.Error("the daemon was waited for after a refusal") + } + }) + } +} + +func TestTheUnitRefusesAListItCannotTrust(t *testing.T) { + cases := map[string]map[string]string{ + "a pin with a command in it": {"CASAOS_DU_PINS": "docker-ce=5:29.8.0;touch${IFS}x"}, + "a pin with a quote": {"CASAOS_DU_PINS": "docker-ce=5:29.8.0'x"}, + "a name with a glob": {"CASAOS_DU_NAMES": "docker-*"}, + "a new line in the pins": {"CASAOS_DU_PINS": "docker-ce=1\ndocker-ce-cli=2"}, + "no pins": {"CASAOS_DU_PINS": ""}, + "no names": {"CASAOS_DU_NAMES": "", "CASAOS_DU_PINS": ""}, + } + for name, env := range cases { + t.Run(name, func(t *testing.T) { + b := newScriptBox(t) + b.env = env + r := b.run() + checkLogShape(t, r.log) + if r.exit != 1 || r.run.FailReason != dockerpkg.FailGuard { + t.Fatalf("exit = %d, run = %#v\n%s", r.exit, r.run, r.log) + } + // nothing was run at all: not even a look at what is installed + if r.called("apt-get") != 0 || r.called("dpkg-query") != 0 || r.called("docker") != 0 { + t.Errorf("the script ran something with a list it does not trust: %v", r.calls) + } + if _, err := os.Stat(filepath.Join(b.dir, "x")); err == nil { + t.Error("a command in a pin ran") + } + }) + } +} + +func TestTheUnitDownloadsBeforeItChangesAnything(t *testing.T) { + b := newScriptBox(t) + b.set("download.rc", "100") + r := b.run() + checkLogShape(t, r.log) + if r.exit != 1 || r.run.Terminal != dockerpkg.TerminalFailed || r.run.FailReason != dockerpkg.FailDownload { + t.Fatalf("exit = %d, run = %#v\n%s", r.exit, r.run, r.log) + } + if got := len(r.aptChanges()); got != 1 || !strings.Contains(r.aptChanges()[0], "--download-only") { + t.Errorf("apt-get changes = %v: only the download, which changes nothing, may have run", r.aptChanges()) + } + if _, err := os.Stat(filepath.Join(b.dir, "installed")); err == nil { + t.Error("something was installed") + } + if r.run.InstalledAt != (time.Time{}) || strings.Contains(r.log, "_DOWNLOADED") || r.called("systemctl") != 0 { + t.Errorf("the log or the calls say more than a failed download:\n%s\n%v", r.log, r.calls) + } + // the owner's rollback has what was there before + if len(r.run.Previous) != 3 { + t.Errorf("previous = %#v", r.run.Previous) + } +} + +func TestTheUnitTriesOnceToBringDockerBackWhenTheInstallFails(t *testing.T) { + b := newScriptBox(t) + b.set("install.rc", "100") + // dpkg stopped the daemon and left it down: it is up again once systemd is asked to start it + b.set("broken", "") + r := b.run() + checkLogShape(t, r.log) + if r.exit != 1 || r.run.Terminal != dockerpkg.TerminalFailed || r.run.FailReason != dockerpkg.FailInstall { + t.Fatalf("exit = %d, run = %#v\n%s", r.exit, r.run, r.log) + } + if n := r.called("systemctl start docker.socket docker.service"); n != 1 { + t.Errorf("systemctl start was called %d times, want exactly once: %v", n, r.calls) + } + if n := r.called("systemctl"); n != 1 { + t.Errorf("systemctl was called %d times, want only the one start: %v", n, r.calls) + } + if calls := r.unbounded(); len(calls) != 0 { + t.Errorf("calls that were not bounded by timeout: %v", calls) + } + if strings.Contains(r.log, "_INSTALLED") || strings.Contains(r.log, "_DAEMON") { + t.Errorf("the log says the install went through or the daemon is back:\n%s", r.log) + } + + // a daemon that does not come back is still an install that failed, and is asked for once + b = newScriptBox(t) + b.set("install.rc", "100") + b.set("broken", "") + // systemctl start does nothing here: the daemon stays down + writeFile(t, filepath.Join(b.dir, "bin", "systemctl"), "#!/bin/sh\necho \"systemctl $*\" >> \"$FAKE_DIR/calls.log\"\nexit 0\n", 0o755) + r = b.run() + checkLogShape(t, r.log) + if r.exit != 1 || r.run.FailReason != dockerpkg.FailInstall || r.called("systemctl start") != 1 { + t.Fatalf("exit = %d, run = %#v, calls = %v\n%s", r.exit, r.run, r.calls, r.log) + } +} + +func TestTheUnitSaysDaemonWhenDockerNeverComesBack(t *testing.T) { + b := newScriptBox(t) + b.set("broken", "") + // systemctl start does not help (a daemon.json that dockerd refuses) + writeFile(t, filepath.Join(b.dir, "bin", "systemctl"), "#!/bin/sh\necho \"systemctl $*\" >> \"$FAKE_DIR/calls.log\"\nexit 0\n", 0o755) + r := b.run() + checkLogShape(t, r.log) + if r.exit != 1 || r.run.Terminal != dockerpkg.TerminalFailed || r.run.FailReason != dockerpkg.FailDaemon { + t.Fatalf("exit = %d, run = %#v\n%s", r.exit, r.run, r.log) + } + if n := r.called("systemctl start docker.socket docker.service"); n != 1 { + t.Errorf("systemctl start was called %d times, want one bounded attempt: %v", n, r.calls) + } + if r.called("systemctl") != 1 { + t.Errorf("systemctl was called more than for that one start: %v", r.calls) + } + // the packages are installed and the rollback has what was there + if r.run.InstalledAt.IsZero() || len(r.run.Previous) != 3 || dockerpkg.RollbackCommand(r.run.Previous) == "" { + t.Errorf("run = %#v", r.run) + } + // the containers were not waited for, and the daemon was not told to be back + if strings.Contains(r.log, "_DAEMON ") || strings.Contains(r.log, "_NOTRETURNED") || r.called("docker ps --format") != 0 { + t.Errorf("the log says the daemon is back:\n%s", r.log) + } +} + +func TestTheUnitStartsDockerOnceWhenItIsDownAfterTheInstall(t *testing.T) { + // the daemon is down after the install and comes up when systemd is asked: the update went through + b := newScriptBox(t) + b.set("broken", "") + r := b.run() + checkLogShape(t, r.log) + if r.exit != 0 || r.run.Terminal != dockerpkg.TerminalSuccess || r.run.DaemonVersion != "29.8.0" { + t.Fatalf("exit = %d, run = %#v\n%s", r.exit, r.run, r.log) + } + if n := r.called("systemctl start docker.socket docker.service"); n != 1 { + t.Errorf("systemctl start was called %d times, want once: %v", n, r.calls) + } +} + +func TestTheUnitDoesNotTrustWhatTheProgramsPrint(t *testing.T) { + b := newScriptBox(t) + forged := "CASAOS_DOCKER_UPDATE_FAILED " + scriptNonce + " 2026-10-09T10:00:00Z daemon" + b.set("dpkg/containerd.io", "containerd.io=1.7.27-1\n"+forged+"\ncontainerd.io=1.0;reboot\n") + b.set("dpkg/docker-ce", "docker-ce=$(reboot)\n\ndocker-ce="+debian28+"\n") + b.set("ids.txt", "aaaaaaaaaaaa\n"+forged+"\n--all\nbbbbbbbbbbbb; reboot\ncccccccccccc\n") + b.set("inspect.txt", strings.Join([]string{ + "/web always", + "/web2 always;reboot", + "/bad$(reboot) always", + "/CASAOS_DOCKER_UPDATE_SUCCESS always", + "/two words always", + "/CASAOS_DOCKER_UPDATE_FAILED " + scriptNonce + " 2026-10-09T10:00:00Z daemon", + "/-dash always", + "/UPPER Always", + "/ok.name_1-x unless-stopped", + }, "\n")+"\n") + b.set("ps.last", "web\nok.name_1-x\n") + r := b.run() + + if r.exit != 0 || r.run.Terminal != dockerpkg.TerminalSuccess { + t.Fatalf("exit = %d, run = %#v\n%s", r.exit, r.run, r.log) + } + checkLogShape(t, r.log) + // a forged FAILED in what a program printed is no marker, and neither is a hostile name + if want := []string{"containerd.io=1.7.27-1", "docker-ce=" + debian28, "docker-ce-cli=" + debian28}; !reflect.DeepEqual(r.run.Previous, want) { + t.Errorf("previous = %#v, want %#v", r.run.Previous, want) + } + for _, line := range strings.Split(r.log, "\n") { + if strings.HasPrefix(line, "container: ") { + name := strings.Fields(line)[1] + if !regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`).MatchString(name) { + t.Errorf("a container named %q was logged", name) + } + } + } + for _, bad := range []string{"reboot", "$(", "two words", "web2", "UPPER", "-dash"} { + if strings.Contains(strings.ReplaceAll(r.log, forged, ""), bad) { + t.Errorf("the log holds %q:\n%s", bad, r.log) + } + } + // the container named like a marker is a container, and is told as one + want := []dockerpkg.NotReturned{{Name: "CASAOS_DOCKER_UPDATE_SUCCESS", RestartPolicy: "always"}} + if !reflect.DeepEqual(r.run.NotReturned, want) { + t.Errorf("not returned = %#v, want %#v", r.run.NotReturned, want) + } + if n := r.mentions("--all") + r.mentions("reboot"); n != 0 { + t.Errorf("a hostile id reached docker inspect: %v", r.calls) + } +} + +func TestTheUnitDoesNotReportAVersionThatIsNotOne(t *testing.T) { + b := newScriptBox(t) + b.set("version.after", "29.8.0; reboot\n") + r := b.run() + checkLogShape(t, r.log) + if r.run.Terminal != dockerpkg.TerminalFailed || r.run.FailReason != dockerpkg.FailDaemon || strings.Contains(r.log, "reboot") { + t.Fatalf("run = %#v\n%s", r.run, r.log) + } +} + +func TestTheScriptNeedsItsNonceItsLogAndApt(t *testing.T) { + for _, name := range []string{"CASAOS_DU_NONCE", "CASAOS_DU_LOG", "CASAOS_DU_APT"} { + b := newScriptBox(t) + b.env[name] = unsetEnv + r := b.run() + if r.exit == 0 || r.called("apt-get") != 0 || strings.Contains(r.log, "_STARTED") { + t.Errorf("%s unset: exit = %d, calls = %v, log = %q", name, r.exit, r.calls, r.log) + } + } +} + +func TestTheUnitsDefaultsAreTheWaitsOfTheSpec(t *testing.T) { + // no timeout in the environment: 300 s for the daemon, 90 for the containers + b := newScriptBox(t) + b.set("broken", "") + writeFile(t, filepath.Join(b.dir, "bin", "systemctl"), "#!/bin/sh\necho \"systemctl $*\" >> \"$FAKE_DIR/calls.log\"\nexit 0\n", 0o755) + for _, name := range []string{"CASAOS_DU_DAEMON_TIMEOUT", "CASAOS_DU_RETURN_TIMEOUT", "CASAOS_DU_POLL"} { + b.env[name] = unsetEnv + } + r := b.run() + if r.run.FailReason != dockerpkg.FailDaemon { + t.Fatalf("run = %#v", r.run) + } + clock, _ := os.ReadFile(filepath.Join(b.dir, "clock")) + var seconds int + if _, err := fmt.Sscan(strings.TrimSpace(string(clock)), &seconds); err != nil || seconds < 600 || seconds > 700 { + t.Errorf("the unit waited %q virtual seconds, want two waits of 300 s (a poll of 2 s)", clock) + } +} From bf8a162e673631f7fed566d721737d311ddb4cbd Mon Sep 17 00:00:00 2001 From: Gary Date: Fri, 9 Oct 2026 11:33:40 +0200 Subject: [PATCH 10/33] feat(packages): write the failed line of a Docker update the core could not start The log of a run is whole even when systemd-run refuses: first line, what it said, one terminal marker with the run's nonce. --- pkg/dockerpkg/marker.go | 10 ++++++++++ pkg/dockerpkg/marker_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 pkg/dockerpkg/marker.go create mode 100644 pkg/dockerpkg/marker_test.go diff --git a/pkg/dockerpkg/marker.go b/pkg/dockerpkg/marker.go new file mode 100644 index 00000000..a33d2968 --- /dev/null +++ b/pkg/dockerpkg/marker.go @@ -0,0 +1,10 @@ +package dockerpkg + +import "time" + +// FailedMarker is the terminal line of a run that failed, as the unit writes it: the core writes +// it itself for a unit that could not be started, so that the log is a whole run. reason is one +// of the Fail constants. +func FailedMarker(nonce string, at time.Time, reason string) string { + return markerPrefix + "FAILED " + nonce + " " + at.UTC().Format(time.RFC3339) + " " + reason + "\n" +} diff --git a/pkg/dockerpkg/marker_test.go b/pkg/dockerpkg/marker_test.go new file mode 100644 index 00000000..30dafae3 --- /dev/null +++ b/pkg/dockerpkg/marker_test.go @@ -0,0 +1,27 @@ +package dockerpkg + +import ( + "strings" + "testing" + "time" +) + +func TestFailedMarkerIsTheLineParseRunReads(t *testing.T) { + const nonce = "0123456789abcdef0123456789abcdef" + at := time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC) + queued := QueuedMarker(nonce, at.Add(-time.Minute)) + for _, reason := range []string{FailGuard, FailDownload, FailInstall, FailDaemon} { + line := FailedMarker(nonce, at, reason) + if !strings.HasSuffix(line, "\n") || strings.Count(line, "\n") != 1 { + t.Fatalf("FailedMarker() = %q, want one line", line) + } + run := ParseRun(queued + "systemd-run said no\n" + line) + if run.Terminal != TerminalFailed || run.FailReason != reason || !run.CompletedAt.Equal(at) { + t.Errorf("%s: run = %#v", reason, run) + } + } + // another nonce is not the run's word + if run := ParseRun(queued + FailedMarker("ffffffffffffffffffffffffffffffff", at, FailGuard)); run.Terminal != "" { + t.Errorf("run = %#v", run) + } +} From faecf75d20a7eb84fe5f7013aaebb9b33b70dae2 Mon Sep 17 00:00:00 2001 From: Gary Date: Fri, 9 Oct 2026 11:33:40 +0200 Subject: [PATCH 11/33] feat(packages): start the Docker update and say how it goes StartDockerUpdate checks again what the page checked, the apps and the plan the owner confirmed, writes the first line of the log and starts the unit. DockerUpdateStatus reads only the log and systemd, takes no lock and asks Docker nothing. --- service/system.go | 8 + service/system_package.go | 2 + service/system_package_docker_run.go | 431 ++++++++++++++ service/system_package_docker_run_test.go | 556 +++++++++++++++++++ service/system_package_docker_script.go | 15 +- service/system_package_docker_status_test.go | 434 +++++++++++++++ 6 files changed, 1442 insertions(+), 4 deletions(-) create mode 100644 service/system_package_docker_run.go create mode 100644 service/system_package_docker_run_test.go create mode 100644 service/system_package_docker_status_test.go diff --git a/service/system.go b/service/system.go index 2220a07d..f41208b6 100644 --- a/service/system.go +++ b/service/system.go @@ -46,6 +46,14 @@ type SystemService interface { GetSystemPackageUpdateStatus() SystemPackageUpdateStatus // GetSystemDockerContainers lists the running containers, which an update of Docker stops. GetSystemDockerContainers() SystemDockerContainers + // StartDockerUpdate updates the Docker engine to the plan the owner confirmed (its id). A + // *DockerUpdateRefusal says why it did not start; the status comes with the error. + StartDockerUpdate(planID string) (SystemDockerUpdateStatus, error) + // DockerUpdateStatus says how the update of Docker is going, or how it went. + DockerUpdateStatus() SystemDockerUpdateStatus + // SetAppOperations tells how to ask which apps have an operation in progress; the update of + // Docker is refused while it cannot be asked. + SetAppOperations(list AppOperationsFunc) GetSystemConfigDebug() []string GetCasaOSLogs(lineNumber int) string UpdateAssist() diff --git a/service/system_package.go b/service/system_package.go index bf82bdd3..5ba39e3b 100644 --- a/service/system_package.go +++ b/service/system_package.go @@ -98,6 +98,8 @@ type systemPackageUpdater struct { dpkgLocked func() bool // freeBytes is the room left on the filesystem of a path; nil means it cannot be read. freeBytes func(path string) (uint64, error) + // appOperations says which apps have an operation in progress; nil means it cannot be asked. + appOperations AppOperationsFunc } // untranslatedCommand runs a package tool with its output in English. apt translates its diff --git a/service/system_package_docker_run.go b/service/system_package_docker_run.go new file mode 100644 index 00000000..839d5f21 --- /dev/null +++ b/service/system_package_docker_run.go @@ -0,0 +1,431 @@ +package service + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "github.com/ReCasaOS/CasaOS/pkg/dockerpkg" +) + +// The "Update Docker" button: start the unit that updates the engine, and say how it is going. +// Both share the System packages updater's lock, its seams and its way of knowing about a run: +// nothing is kept in memory, the log of the run and systemd are read each time, so that a restart +// of the core in the middle of the update changes nothing. + +const systemDockerUpdateLog = "docker-update.log" + +// The reasons the update does not start for, after the static ones of dockerUpdatePreflight +// (DockerRefusalOrigin and the others): things that change from one minute to the next. +const ( + // DockerRefusalRunning is the update of Docker, or a System packages update, already running. + DockerRefusalRunning = "running" + // DockerRefusalMaintenance is another update or a package manager busy on the box. + DockerRefusalMaintenance = "maintenance" + // DockerRefusalApps is an app operation in progress, or AppManagement that cannot say. + DockerRefusalApps = "apps" + // DockerRefusalChanged is a plan that is not the one the owner confirmed. + DockerRefusalChanged = "changed" + // DockerRefusalNothing is an engine with nothing to update. + DockerRefusalNothing = "nothing" +) + +// ErrDockerUpdateBadPlanID is a plan id that is not 64 lower case hex digits. +var ErrDockerUpdateBadPlanID = errors.New("the plan id is not the id of a plan") + +// DockerUpdateRefusal is why the update did not start. The page turns the Code into its own words +// and shows Detail (names the core validated) with them; Reason is English, for a log or a client +// that does not know the code. +type DockerUpdateRefusal struct { + Code string + Detail []string + Reason string +} + +func (r *DockerUpdateRefusal) Error() string { return r.Reason } + +var dockerRefusalReasons = map[string]string{ + DockerRefusalOrigin: "Docker was not installed from Docker's own repository, so it cannot be updated from here.", + DockerRefusalHeld: "Docker's packages are on hold. The hold is yours to release; this will not override it.", + DockerRefusalDaemon: "Docker does not answer, so there is nothing to check the update against.", + DockerRefusalSwarm: "This node is part of a Docker swarm, which this update does not handle.", + DockerRefusalPlan: "The update would change more than Docker's own packages.", + DockerRefusalDisk: "There is not enough free disk space for the update.", + DockerRefusalRunning: "An update is already running.", + DockerRefusalApps: "An app is being installed, updated, backed up or restored.", + DockerRefusalChanged: "A newer version appeared: confirm the update again.", + DockerRefusalNothing: "There is nothing to update.", + DockerRefusalMaintenance: "Another update or package operation is running on this box.", +} + +// The states and outcomes of a run, as the status says them. The states are the System packages +// update's own. +const ( + systemDockerOutcomeSuccess = "success" + systemDockerOutcomeRestartPending = "restart_pending" + systemDockerOutcomeFailed = "failed" + + // systemDockerErrorNoResult is a unit that ended without a terminal marker. + systemDockerErrorNoResult = "no_result" + + // systemDockerStartTimeout bounds what the start does before it starts the unit. + systemDockerStartTimeout = time.Minute + // systemDockerUnitTimeout bounds a question to systemd. + systemDockerUnitTimeout = 10 * time.Second + // systemDockerHeadBytes is read from the start of a log that was cut: its first line is the + // run's nonce. + systemDockerHeadBytes = 256 + + // dockerLogOmitted is what readBoundedSystemPackageLog puts in front of a log it cut. + dockerLogOmitted = "[Earlier log output omitted]\n" +) + +var dockerFailMessages = map[string]string{ + dockerpkg.FailGuard: "The update was no longer what you confirmed, so nothing was installed.", + dockerpkg.FailDownload: "The packages could not be downloaded. Nothing was changed.", + dockerpkg.FailInstall: "The packages could not be installed.", + dockerpkg.FailDaemon: "Docker did not come back after the update.", +} + +// SystemDockerUpdateStatus is how the update of Docker is going, or how it went: the same +// object comes back from the start and from the status. +type SystemDockerUpdateStatus struct { + Supported bool `json:"supported"` + // State is idle, running, finalizing, succeeded or failed. + State string `json:"state"` + // Outcome is success or restart_pending (State succeeded), failed (State failed), or "". + Outcome string `json:"outcome"` + Error string `json:"error"` + // ErrorCode is guard, download, install, daemon or no_result for a run that failed, and + // the code of the refusal for a start that was refused. + ErrorCode string `json:"error_code"` + // RefusalDetail goes with a refusal: the names of the packages or of the apps it is about. + RefusalDetail []string `json:"refusal_detail,omitempty"` + // ExitCode is there for the page's sake and is null: the unit reports in its log. + ExitCode *int `json:"exit_code"` + StartedAt string `json:"started_at"` + CompletedAt string `json:"completed_at"` + // From and To are the engine's versions before and after, from the log. To is there when + // the new engine runs. + From string `json:"from"` + To string `json:"to"` + // NotReturned are the containers that were running before and were not after. + NotReturned []dockerpkg.NotReturned `json:"not_returned"` + // RollbackCommand is what to type to put the packages back, for a run that failed. + RollbackCommand string `json:"rollback_command"` + // Log is the end of the unit's log, at most 128 KiB. + Log string `json:"log"` +} + +// AppOperationsFunc says which apps have an operation in progress (an install, an update, a +// backup, a restore), by name. An error is no answer. main sets it from AppManagement. +type AppOperationsFunc func(ctx context.Context) ([]string, error) + +func (s *systemService) StartDockerUpdate(planID string) (SystemDockerUpdateStatus, error) { + return s.systemPackageUpdater().startDockerUpdate(planID) +} + +func (s *systemService) DockerUpdateStatus() SystemDockerUpdateStatus { + return s.systemPackageUpdater().dockerStatus() +} + +func (s *systemService) SetAppOperations(list AppOperationsFunc) { + updater := s.systemPackageUpdater() + updater.mu.Lock() + defer updater.mu.Unlock() + updater.appOperations = list +} + +var ( + dockerPlanIDPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) + // dockerAppNamePattern is what the name of an app may be before it is shown. + dockerAppNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`) +) + +func (u *systemPackageUpdater) dockerLogPath() string { + return filepath.Join(filepath.Dir(u.logPath()), systemDockerUpdateLog) +} + +// refused is the status and the error of a start that did not happen. A refusal leaves the page +// the reason in the data as well as in the message: it clears its own error when it asks again. +func (u *systemPackageUpdater) dockerRefused(support systemPackageSupport, code string, detail []string, reason string) (SystemDockerUpdateStatus, error) { + status := SystemDockerUpdateStatus{Supported: support.supported, State: systemPackageUpdateStateIdle, NotReturned: []dockerpkg.NotReturned{}} + if code == DockerRefusalRunning && u.unitActive(systemDockerUpdateUnit) { + status = u.dockerStatus() + } + if reason == "" { + reason = dockerRefusalReasons[code] + } + status.Error, status.ErrorCode = reason, code + if len(detail) > 0 { + status.RefusalDetail = detail + } + return status, &DockerUpdateRefusal{Code: code, Detail: detail, Reason: reason} +} + +// dockerBlocked is the refusal, when something else is running that the update must not start +// beside: itself or a System packages update, then the ReCasaOS update and dpkg's lock. It is +// nil when nothing is. +func (u *systemPackageUpdater) dockerBlocked(support systemPackageSupport) (SystemDockerUpdateStatus, error) { + if u.unitActive(systemDockerUpdateUnit) || u.isRunning() { + return u.dockerRefused(support, DockerRefusalRunning, nil, "") + } + if reason, busy := u.maintenanceBusy(context.Background(), systemDockerUpdateUnit); busy { + return u.dockerRefused(support, DockerRefusalMaintenance, nil, fmt.Sprintf("%s: %s", dockerRefusalReasons[DockerRefusalMaintenance], reason)) + } + return SystemDockerUpdateStatus{}, nil +} + +// startDockerUpdate starts the unit that updates the engine to the plan the owner confirmed. +// It checks what the page's check checked, again, and what only matters now: nothing else is +// running, no app is busy, and the plan is still that plan. It writes the first line of the +// log, and returns as soon as systemd has the unit. +func (u *systemPackageUpdater) startDockerUpdate(planID string) (SystemDockerUpdateStatus, error) { + if !dockerPlanIDPattern.MatchString(planID) { + return SystemDockerUpdateStatus{State: systemPackageUpdateStateIdle, NotReturned: []dockerpkg.NotReturned{}}, ErrDockerUpdateBadPlanID + } + support := u.support() + if !support.supported { + status := SystemDockerUpdateStatus{State: systemPackageUpdateStateIdle, NotReturned: []dockerpkg.NotReturned{}, Error: support.reason, ErrorCode: DockerRefusalUnsupported} + return status, ErrSystemPackageUpdatesUnsupported + } + + u.mu.Lock() + defer u.mu.Unlock() + if status, err := u.dockerBlocked(support); err != nil { + return status, err + } + + // The simulation and the questions take a while on a small box: not with the lock held, so that + // the status and the check can still answer, and the box is looked at again afterwards. + u.mu.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), systemDockerStartTimeout) + update, plan := u.dockerUpdatePreflight(ctx, support) + var operations []string + var asked error + if update.Refusal == "" && update.PlanID == planID { + // the last question before the lock is taken again, so that the answer is as fresh as can be + operations, asked = u.dockerAppOperations(ctx) + } + cancel() + u.mu.Lock() + if status, err := u.dockerBlocked(support); err != nil { + return status, err + } + + switch { + case update.Refusal != "": + return u.dockerRefused(support, update.Refusal, update.RefusalDetail, "") + case len(plan.Packages) == 0: + return u.dockerRefused(support, DockerRefusalNothing, nil, "") + case update.PlanID != planID: + return u.dockerRefused(support, DockerRefusalChanged, nil, "") + case asked != nil: + return u.dockerRefused(support, DockerRefusalApps, nil, "AppManagement did not say whether an app is busy, so nothing was started.") + case len(operations) > 0: + return u.dockerRefused(support, DockerRefusalApps, operations, "") + } + + _, to := plan.Engine() + nonce, err := dockerpkg.NewNonce() + if err != nil { + return u.dockerFailedToStart(support, "", fmt.Sprintf("prepare Docker update: %v", err), err) + } + args, err := dockerUpdateArgs(nonce, u.dockerLogPath(), support.aptPath, plan.Pins(), plan.Names(), to) + if err != nil { + // the plan passed Offending, so this is a bug; the unit would run as root + return u.dockerRefused(support, DockerRefusalPlan, nil, "") + } + logPath := u.dockerLogPath() + if err := u.mkdirAll(filepath.Dir(logPath), 0o755); err != nil { + return u.dockerFailedToStart(support, "", fmt.Sprintf("prepare Docker update log: %v", err), err) + } + queuedAt := u.now().UTC() + queued := dockerpkg.QueuedMarker(nonce, queuedAt) + if err := u.writeFile(logPath, []byte(queued), 0o644); err != nil { + return u.dockerFailedToStart(support, "", fmt.Sprintf("prepare Docker update log: %v", err), err) + } + if output, err := u.start(support.systemdPath, systemDockerUpdateUnit, args...); err != nil { + // The log is a whole run all the same: the first line, what systemd said, a last line. + failure := queued + trimSystemPackageOutput(output) + "\n" + dockerpkg.FailedMarker(nonce, u.now(), dockerpkg.FailGuard) + _ = u.writeFile(logPath, []byte(failure), 0o644) + return u.dockerFailedToStart(support, nonce, fmt.Sprintf("start Docker update: %s", trimSystemPackageOutput(output)), fmt.Errorf("start Docker update: %w", err)) + } + + return SystemDockerUpdateStatus{ + Supported: true, + State: systemPackageUpdateStateRunning, + StartedAt: queuedAt.Format(time.RFC3339), + NotReturned: []dockerpkg.NotReturned{}, + Log: queued, + }, nil +} + +// dockerFailedToStart is the answer to a start that broke on the box's side. When the log is a +// whole run (nonce is its) the status is what the status will say of it. +func (u *systemPackageUpdater) dockerFailedToStart(support systemPackageSupport, nonce, message string, err error) (SystemDockerUpdateStatus, error) { + status := SystemDockerUpdateStatus{Supported: support.supported, State: systemPackageUpdateStateFailed, Outcome: systemDockerOutcomeFailed, NotReturned: []dockerpkg.NotReturned{}} + if nonce != "" { + status = u.dockerStatus() + } + status.Error = message + if status.CompletedAt == "" { + status.CompletedAt = u.now().UTC().Format(time.RFC3339) + } + return status, err +} + +// dockerAppOperations are the apps with an operation in progress, valid names only, sorted and +// once each; the error is that AppManagement could not be asked, or did not answer. +func (u *systemPackageUpdater) dockerAppOperations(ctx context.Context) ([]string, error) { + if u.appOperations == nil { + return nil, errors.New("the box cannot ask AppManagement") + } + apps, err := u.appOperations(ctx) + if err != nil { + return nil, err + } + seen := map[string]struct{}{} + for _, app := range apps { + if !dockerAppNamePattern.MatchString(app) { + app = dockerpkg.InvalidName + } + seen[app] = struct{}{} + } + names := make([]string, 0, len(seen)) + for name := range seen { + names = append(names, name) + } + sort.Strings(names) + return names, nil +} + +// unitActive says whether a systemd unit is running, starting or stopping. Systemd not +// answering is not running: the log has the last word then. +func (u *systemPackageUpdater) unitActive(unit string) bool { + ctx, cancel := context.WithTimeout(context.Background(), systemDockerUnitTimeout) + defer cancel() + output, err := u.command(ctx, "systemctl", "show", unit, "--property=ActiveState", "--value") + if err != nil { + return false + } + switch strings.TrimSpace(string(output)) { + case "active", "activating", "deactivating": + return true + } + return false +} + +// dockerStatus says how the update of Docker is going. It takes no lock and asks Docker nothing: +// the log, whose first line holds the run's nonce, and systemd are all it reads. +func (u *systemPackageUpdater) dockerStatus() SystemDockerUpdateStatus { + support := u.support() + status := SystemDockerUpdateStatus{Supported: support.supported, State: systemPackageUpdateStateIdle, NotReturned: []dockerpkg.NotReturned{}} + if !support.supported { + return status + } + + logPath := u.dockerLogPath() + log, err := u.readLog(logPath, systemPackageLogMaxBytes) + if err == nil { + status.Log = log + } + // A log that was cut has lost its first line, and with it the nonce: it is put back. + parsed := log + if strings.HasPrefix(log, dockerLogOmitted) { + if head, err := readDockerLogHead(logPath); err == nil { + parsed = head + "\n" + log + } + } + run := dockerpkg.ParseRun(parsed) + active := u.unitActive(systemDockerUpdateUnit) + + if run.Nonce == "" { + // no run of ours in the log: the unit, if there is one, is not ours to describe + if active { + status.State = systemPackageUpdateStateRunning + if info, err := u.stat(logPath); err == nil { + status.StartedAt = info.ModTime().UTC().Format(time.RFC3339) + } + } + return status + } + + status.StartedAt = formatDockerTime(run.StartedAt, run.QueuedAt) + status.NotReturned = append(status.NotReturned, run.NotReturned...) + from := "" + for _, pin := range run.Previous { + if name, version, _ := strings.Cut(pin, "="); name == "docker-ce" { + from = dockerpkg.EngineVersion(version) + } + } + status.From = from + + switch run.Terminal { + case dockerpkg.TerminalSuccess: + status.State, status.Outcome = systemPackageUpdateStateSuccess, systemDockerOutcomeSuccess + status.To = dockerpkg.EngineVersion(run.DaemonVersion) + if status.From == "" { + // docker-ce was not in the plan: the engine is the one that runs + status.From = status.To + } + case dockerpkg.TerminalRestartPending: + // the packages are installed and the daemon is still the old one + status.State, status.Outcome = systemPackageUpdateStateSuccess, systemDockerOutcomeRestartPending + case dockerpkg.TerminalFailed: + status.State, status.Outcome = systemPackageUpdateStateFailed, systemDockerOutcomeFailed + status.ErrorCode = run.FailReason + status.Error = dockerFailMessages[run.FailReason] + status.RollbackCommand = dockerpkg.RollbackCommand(run.Previous) + case "": + switch { + case active: + status.State = systemPackageUpdateStateRunning + case u.isWithinResultGrace(logPath): + // systemd can say the unit is gone just before the last line reached the log + status.State = systemPackageUpdateStateFinalizing + default: + status.State, status.Outcome = systemPackageUpdateStateFailed, systemDockerOutcomeFailed + status.ErrorCode = systemDockerErrorNoResult + status.Error = "The Docker update stopped before it reported a result." + // it stopped somewhere: what was there before is in the log + status.RollbackCommand = dockerpkg.RollbackCommand(run.Previous) + } + } + if !run.CompletedAt.IsZero() { + status.CompletedAt = run.CompletedAt.Format(time.RFC3339) + } + return status +} + +func formatDockerTime(times ...time.Time) string { + for _, t := range times { + if !t.IsZero() { + return t.UTC().Format(time.RFC3339) + } + } + return "" +} + +// readDockerLogHead is the first line of the log. +func readDockerLogHead(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + buf := make([]byte, systemDockerHeadBytes) + n, err := io.ReadFull(file, buf) + if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) { + return "", err + } + head, _, _ := strings.Cut(string(buf[:n]), "\n") + return head, nil +} diff --git a/service/system_package_docker_run_test.go b/service/system_package_docker_run_test.go new file mode 100644 index 00000000..e68060af --- /dev/null +++ b/service/system_package_docker_run_test.go @@ -0,0 +1,556 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/ReCasaOS/CasaOS/common" + "github.com/ReCasaOS/CasaOS/pkg/dockerpkg" +) + +// ownerPlanID is the id of the plan the owner's box offers: what the page echoes back. +func ownerPlanID(t *testing.T) string { + t.Helper() + update := checkBox(t, ownerBox(t), nil).Docker.Update + if update == nil || update.PlanID == "" { + t.Fatalf("the owner's box has no plan: %#v", update) + } + return update.PlanID +} + +// started is what the updater handed to systemd-run. +type started struct { + count int + path string + unit string + args []string +} + +type startedBox struct { + updater *systemPackageUpdater + box *aptBox + started *started + planID string +} + +// newStartBox is the owner's box with nothing running and no app busy, and a systemd-run that +// takes the unit and records it. +func newStartBox(t *testing.T, tweak func(*aptBox)) *startedBox { + t.Helper() + box := ownerBox(t) + if tweak != nil { + tweak(box) + } + updater := newTestSystemPackageUpdater(t) + updater.command = box.command + updater.appOperations = func(context.Context) ([]string, error) { return []string{}, nil } + s := &started{} + updater.start = func(path, unit string, args ...string) ([]byte, error) { + s.count++ + s.path, s.unit, s.args = path, unit, args + return nil, nil + } + return &startedBox{updater: updater, box: box, started: s, planID: ownerPlanID(t)} +} + +func (s *startedBox) env(name string) string { + for _, arg := range s.started.args { + if value, ok := strings.CutPrefix(arg, "--setenv="+name+"="); ok { + return value + } + } + return "\x00missing" +} + +func (s *startedBox) noLog(t *testing.T) { + t.Helper() + if _, err := os.Stat(s.updater.dockerLogPath()); !os.IsNotExist(err) { + t.Errorf("a log was written for an update that was not started: %v", err) + } +} + +func TestStartDockerUpdateOwnersCase(t *testing.T) { + s := newStartBox(t, nil) + status, err := s.updater.startDockerUpdate(s.planID) + if err != nil { + t.Fatalf("startDockerUpdate() error = %v", err) + } + if status.State != "running" || !status.Supported || status.StartedAt != "2026-08-13T01:02:03Z" || status.Error != "" || status.NotReturned == nil { + t.Fatalf("status = %#v", status) + } + + // the log's first line is the core's, with a nonce that only that line and the unit have + log, err := os.ReadFile(s.updater.dockerLogPath()) + if err != nil { + t.Fatal(err) + } + run := dockerpkg.ParseRun(string(log)) + if run.Nonce == "" || !dockerpkg.ValidNonce(run.Nonce) || string(log) != dockerpkg.QueuedMarker(run.Nonce, s.updater.now()) { + t.Fatalf("log = %q", log) + } + if info, err := os.Stat(s.updater.dockerLogPath()); err != nil || info.Mode().Perm() != 0o644 { + t.Errorf("the log's mode = %v, %v: the System packages update's log has 0644", info, err) + } + if got, want := s.updater.dockerLogPath(), filepath.Join(filepath.Dir(s.updater.logPath()), "docker-update.log"); got != want { + t.Errorf("log path = %q, want %q", got, want) + } + + // systemd-run is given the unit, detached, and every parameter as an environment variable + if s.started.count != 1 || s.started.path != "/usr/bin/systemd-run" || s.started.unit != "casaos-docker-update.service" { + t.Fatalf("started = %#v", s.started) + } + wantArgs := []string{ + "--quiet", "--no-block", "--collect", + "--unit=casaos-docker-update.service", + "--property=Type=exec", + "--description=CasaOS Docker update", + "--setenv=DEBIAN_FRONTEND=noninteractive", + "--setenv=CASAOS_DU_NONCE=" + run.Nonce, + "--setenv=CASAOS_DU_LOG=" + s.updater.dockerLogPath(), + "--setenv=CASAOS_DU_APT=/usr/bin/apt-get", + "--setenv=CASAOS_DU_PINS=containerd.io=2.1.4-1 docker-ce-cli=" + debian29 + " docker-ce=" + debian29, + "--setenv=CASAOS_DU_NAMES=containerd.io docker-ce docker-ce-cli", + "--setenv=CASAOS_DU_TO=29.8.0", + "--setenv=CASAOS_DU_DAEMON_TIMEOUT=300", + "--setenv=CASAOS_DU_RETURN_TIMEOUT=90", + "--setenv=CASAOS_DU_POLL=2", + "/bin/sh", "-c", dockerUpdateScript, + } + if !reflect.DeepEqual(s.started.args, wantArgs) { + t.Errorf("systemd-run arguments:\n%s\nwant:\n%s", strings.Join(s.started.args, "\n"), strings.Join(wantArgs, "\n")) + } + // nothing that varies is in the script's text + script := s.started.args[len(s.started.args)-1] + for _, data := range []string{run.Nonce, debian28, debian29, "29.8.0", "docker-ce", "containerd", s.updater.dockerLogPath(), "/usr/bin/apt-get"} { + if strings.Contains(script, data) { + t.Errorf("the script holds %q", data) + } + } + + // the plan was looked at again, and nothing else changed on the box + if len(engineSimulations(s.box)) != 1 { + t.Errorf("simulations = %#v", engineSimulations(s.box)) + } + for _, call := range s.box.calls { + if strings.Contains(call, "apt-get") && !strings.Contains(call, " -s ") && !strings.HasSuffix(call, "apt-get update") { + t.Errorf("the start ran %q", call) + } + } +} + +func TestStartDockerUpdateIsTheSameThroughTheService(t *testing.T) { + s := newStartBox(t, nil) + s.updater.appOperations = nil + system := &systemService{packageUpdates: s.updater} + // the owner's box can be asked once main tells how + system.SetAppOperations(func(context.Context) ([]string, error) { return nil, nil }) + if _, err := system.StartDockerUpdate(s.planID); err != nil || s.started.count != 1 { + t.Fatalf("StartDockerUpdate() error = %v, started %d", err, s.started.count) + } + logAgeOf(s.updater, time.Second) + if status := system.DockerUpdateStatus(); status.State != "finalizing" || status.StartedAt != "2026-08-13T01:02:03Z" { + t.Errorf("DockerUpdateStatus() = %#v", status) + } +} + +func TestStartDockerUpdateRefusesWhatTheCheckRefuses(t *testing.T) { + for _, c := range refusalCases() { + t.Run(c.name, func(t *testing.T) { + s := newStartBox(t, nil) + // the box changes after the page's check: the refusal is the start's own look + if c.change != nil { + c.change(s.box) + } + if c.tweak != nil { + c.tweak(s.updater) + } + status, err := s.updater.startDockerUpdate(s.planID) + var refusal *DockerUpdateRefusal + if !errors.As(err, &refusal) || refusal.Code != c.code { + t.Fatalf("error = %v, want a refusal %q", err, c.code) + } + want := c.detail + if want == nil { + want = []string{} + } + if len(refusal.Detail) != len(want) || (len(want) > 0 && !reflect.DeepEqual(refusal.Detail, want)) { + t.Errorf("detail = %#v, want %#v", refusal.Detail, want) + } + // the reason travels in the data as well as in the error + if status.ErrorCode != c.code || status.Error == "" || status.Error != refusal.Error() || status.State != "idle" { + t.Errorf("status = %#v", status) + } + if s.started.count != 0 { + t.Error("a unit was started") + } + s.noLog(t) + }) + } +} + +func TestStartDockerUpdateReasonsAreEnglishAndHaveNoDataInThem(t *testing.T) { + s := newStartBox(t, func(b *aptBox) { b.enginePlans["docker-ce-cli"] += "Remv evil$(reboot) [1]\n" }) + _, err := s.updater.startDockerUpdate(s.planID) + var refusal *DockerUpdateRefusal + if !errors.As(err, &refusal) || refusal.Code != "plan" || !reflect.DeepEqual(refusal.Detail, []string{dockerpkg.InvalidName}) { + t.Fatalf("error = %v", err) + } + if strings.Contains(refusal.Reason, "reboot") || refusal.Reason == "" { + t.Errorf("reason = %q", refusal.Reason) + } +} + +func TestStartDockerUpdateRefusesABadPlanID(t *testing.T) { + good := strings.Repeat("a", 64) + for name, id := range map[string]string{ + "empty": "", + "short": good[:63], + "long": good + "a", + "upper": strings.Repeat("A", 64), + "not hex": strings.Repeat("g", 64), + "a command": "x; reboot " + good[:54], + "new line": good[:63] + "\n", + } { + s := newStartBox(t, nil) + calls := len(s.box.calls) + _, err := s.updater.startDockerUpdate(id) + if !errors.Is(err, ErrDockerUpdateBadPlanID) || s.started.count != 0 || len(s.box.calls) != calls { + t.Errorf("%s: error = %v, started %d, calls %v", name, err, s.started.count, s.box.calls[calls:]) + } + } +} + +func TestStartDockerUpdateSaysChangedForAPlanThatIsNotTheConfirmedOne(t *testing.T) { + // the page confirmed a plan, and a newer Docker appeared since + s := newStartBox(t, nil) + s.box.enginePlans["docker-ce"] = instEngine("docker-ce", debian28, "5:29.9.0-1~debian.11~bullseye") + status, err := s.updater.startDockerUpdate(s.planID) + var refusal *DockerUpdateRefusal + if !errors.As(err, &refusal) || refusal.Code != "changed" || status.ErrorCode != "changed" { + t.Fatalf("error = %v, status = %#v", err, status) + } + if s.started.count != 0 { + t.Error("a unit was started") + } + s.noLog(t) + + // a plan id of the right shape that is nobody's + s = newStartBox(t, nil) + if _, err := s.updater.startDockerUpdate(strings.Repeat("0", 64)); !errors.As(err, &refusal) || refusal.Code != "changed" { + t.Fatalf("error = %v, want changed", err) + } +} + +func TestStartDockerUpdateWithNothingToUpdate(t *testing.T) { + s := newStartBox(t, nil) + s.box.enginePlans = map[string]string{} + s.box.upgradeSimulation = simLibc + status, err := s.updater.startDockerUpdate(s.planID) + var refusal *DockerUpdateRefusal + if !errors.As(err, &refusal) || refusal.Code != "nothing" || status.ErrorCode != "nothing" { + t.Fatalf("error = %v", err) + } + if s.started.count != 0 { + t.Error("a unit was started") + } + s.noLog(t) +} + +func TestStartDockerUpdateSaysUnsupportedOnAHostThatCannotUpdate(t *testing.T) { + s := newStartBox(t, nil) + s.updater.getEUID = func() int { return 1000 } + before := len(s.box.calls) + status, err := s.updater.startDockerUpdate(s.planID) + if !errors.Is(err, ErrSystemPackageUpdatesUnsupported) || status.Supported || status.ErrorCode != "unsupported" || status.Error == "" { + t.Fatalf("error = %v, status = %#v", err, status) + } + if len(s.box.calls) != before || s.started.count != 0 { + t.Errorf("something ran: %v", s.box.calls[before:]) + } + s.noLog(t) + if got := s.updater.dockerStatus(); got.Supported || got.State != "idle" { + t.Errorf("status = %#v", got) + } +} + +func TestStartDockerUpdateWaitsForOtherWork(t *testing.T) { + cases := map[string]struct { + active map[string]bool + locked bool + code string + }{ + "the update of Docker": {map[string]bool{systemDockerUpdateUnit: true}, false, "running"}, + "a System packages update": {map[string]bool{systemPackageUpdateUnit: true}, false, "running"}, + "a ReCasaOS update": {map[string]bool{common.UPDATE_UNIT + ".service": true}, false, "maintenance"}, + "a package manager's lock": {nil, true, "maintenance"}, + "the update of Docker, too": {map[string]bool{systemDockerUpdateUnit: true, common.UPDATE_UNIT + ".service": true}, true, "running"}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + s := newStartBox(t, func(b *aptBox) { b.activeUnits = c.active }) + s.updater.dpkgLocked = func() bool { return c.locked } + calls := len(s.box.calls) + status, err := s.updater.startDockerUpdate(s.planID) + var refusal *DockerUpdateRefusal + if !errors.As(err, &refusal) || refusal.Code != c.code || status.ErrorCode != c.code || status.Error == "" { + t.Fatalf("error = %v, status = %#v, want %s", err, status, c.code) + } + if s.started.count != 0 { + t.Error("a unit was started") + } + // refused before the slow part: the plan was not simulated + for _, call := range s.box.calls[calls:] { + if strings.Contains(call, " -s ") { + t.Errorf("the plan was simulated for an update that cannot start: %s", call) + } + } + s.noLog(t) + if c.code == "running" && c.active[systemDockerUpdateUnit] && status.State != "running" { + t.Errorf("a Docker update runs and the status says %q", status.State) + } + }) + } +} + +// Something starts while the plan is simulated: the start looks again once it has the lock. +func TestStartDockerUpdateLooksAgainAfterTheSlowPart(t *testing.T) { + cases := map[string]struct { + change func(*startedBox) + code string + }{ + "the update of Docker": {func(s *startedBox) { s.box.activeUnits[systemDockerUpdateUnit] = true }, "running"}, + "a System packages update": {func(s *startedBox) { s.box.activeUnits[systemPackageUpdateUnit] = true }, "running"}, + "a ReCasaOS update": {func(s *startedBox) { s.box.activeUnits[common.UPDATE_UNIT+".service"] = true }, "maintenance"}, + "a package manager": {func(s *startedBox) { s.updater.dpkgLocked = func() bool { return true } }, "maintenance"}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + s := newStartBox(t, func(b *aptBox) { b.activeUnits = map[string]bool{} }) + simulated := false + s.box.onInstallSimulation = func() { + if !simulated { + simulated = true + c.change(s) + } + } + _, err := s.updater.startDockerUpdate(s.planID) + var refusal *DockerUpdateRefusal + if !errors.As(err, &refusal) || refusal.Code != c.code { + t.Fatalf("error = %v, want %s", err, c.code) + } + if s.started.count != 0 { + t.Error("a unit was started") + } + s.noLog(t) + }) + } +} + +func TestStartDockerUpdateFailsClosedOnApps(t *testing.T) { + cases := map[string]struct { + list AppOperationsFunc + detail []string + reason string + }{ + "apps with an operation": {func(context.Context) ([]string, error) { return []string{"immich", "casaos-system", "immich"}, nil }, []string{"casaos-system", "immich"}, ""}, + "hostile names": {func(context.Context) ([]string, error) { + return []string{"a b", "x$(reboot)", "