diff --git a/main.go b/main.go index e1095c32..5baae1e1 100644 --- a/main.go +++ b/main.go @@ -126,6 +126,14 @@ func main() { alerts.Default.Latest = func() string { return service.MyService.Casa().GetCasaosVersion().Version } alerts.Default.AutoUpdates = func() bool { return autoupdate.Default.Status().Enabled } autoupdate.Default.Notify = alerts.Default.AutoUpdate + // The update of Docker stops every container, and each app's watch says so: those events are + // dropped while it runs and for ten minutes after. When a run ends the owner is told once, from + // the run's own log and systemd, so with the dashboard closed and across a restart of the core: + // the alerts look when the core starts (a run that ended or goes on) and when the button + // starts a run. Set before Run, which starts the first look. + alerts.Default.Muted = service.MyService.System().DockerUpdateMuted + alerts.Default.LastDockerRun = lastDockerRun + service.MyService.System().OnDockerUpdateQueued(alerts.Default.WatchDockerUpdate) go alerts.Default.Run(context.Background()) // Automatic updates start the button's own update, read the button's own // version.json, and settle an update that restarted the core before checking. @@ -138,6 +146,19 @@ func main() { return err } autoupdate.Default.Busy = service.MyService.System().MaintenanceBusy + // The update of Docker stops every container: it waits for the apps that AppManagement says + // are being installed, updated, backed up or restored, and does not start when it cannot ask. + service.MyService.System().SetAppOperations(func(ctx context.Context) ([]string, error) { + operations, err := autoupdate.AppOperations(ctx, config.CommonInfo.RuntimePath) + if err != nil { + return nil, err + } + apps := make([]string, 0, len(operations)) + for _, operation := range operations { + apps = append(apps, operation.App) + } + return apps, nil + }) go autoupdate.Default.Run(context.Background()) v1Router := route.InitV1Router() @@ -272,6 +293,17 @@ func main() { } } +// lastDockerRun is the last run of the Docker update as the alerts tell it, read from the run's +// log and from systemd: it asks Docker nothing and takes no lock of the update. +func lastDockerRun() alerts.DockerRun { + status, nonce := service.MyService.System().DockerUpdateRun() + run := alerts.DockerRun{Nonce: nonce, Outcome: status.Outcome, ErrorCode: status.ErrorCode, To: status.To} + for _, container := range status.NotReturned { + run.NotReturned = append(run.NotReturned, alerts.DockerNotReturned{Name: container.Name, RestartPolicy: container.RestartPolicy}) + } + return run +} + // dashboardAddress is the dashboard's address on the box's first network, or // "" when it has none. func dashboardAddress() string { diff --git a/pkg/alerts/alerts.go b/pkg/alerts/alerts.go index 8216ad53..f606be97 100644 --- a/pkg/alerts/alerts.go +++ b/pkg/alerts/alerts.go @@ -11,6 +11,12 @@ // one "resolved" message, only if its alert was sent and once per alert sent. // That memory is the core's: a restart forgets it, and at worst a reminder // comes early. +// +// Two things are about the update of Docker, which stops every container. While +// it runs and just after, the apps' container events are dropped (Hub.Muted), +// raise and resolve alike. And when it ends the owner is told once, whether or +// not the dashboard is open, even across a restart of the core: the nonce of +// the last run told is kept in docker-update.notified. package alerts import ( @@ -87,12 +93,21 @@ type Hub struct { Latest func() string Current func() string AutoUpdates func() bool + // Muted says whether the apps' container events are to be ignored now: a Docker update stops + // every container, and each app's watch says so. It is asked for each of those events, so it + // must be cheap. + Muted func() bool + // LastDockerRun is the last run of the Docker update, from the run's log and systemd. + LastDockerRun func() DockerRun modifying sync.Mutex // serialises Update's read-modify-writes of alerts.json mu sync.Mutex // guards sent and lastFailure sent map[string]*record lastFailure *Failure + dockerMu sync.Mutex // serialises dockerEnded + dockerNotified string // the nonce of the last Docker update told, guarded by dockerMu + // Read and written by Run's goroutine only. seen []disk // the disks of the previous poll, nil before the first releaseChecked time.Time // the last look for a newer release @@ -105,26 +120,30 @@ type Hub struct { var Default = New("/") // New keeps alerts.json under root and sends through Shoutrrr. Address, -// Latest and AutoUpdates are left to the caller. +// Latest, AutoUpdates, Muted and LastDockerRun are left to the caller. func New(root string) *Hub { return &Hub{ - Root: root, - RuntimePath: func() string { return config.CommonInfo.RuntimePath }, - Now: time.Now, - Hostname: os.Hostname, - Address: func() string { return "" }, - Send: send, - Latest: func() string { return "" }, - Current: version.CurrentVersion, - AutoUpdates: func() bool { return true }, - sent: map[string]*record{}, + Root: root, + RuntimePath: func() string { return config.CommonInfo.RuntimePath }, + Now: time.Now, + Hostname: os.Hostname, + Address: func() string { return "" }, + Send: send, + Latest: func() string { return "" }, + Current: version.CurrentVersion, + AutoUpdates: func() bool { return true }, + Muted: func() bool { return false }, + LastDockerRun: func() DockerRun { return DockerRun{} }, + sent: map[string]*record{}, } } -// Run follows the message bus until ctx is done, and looks at the disks and -// for a newer release five minutes after the start, then every hour. +// Run follows the message bus until ctx is done, tells a Docker update that ended +// or is going on, and looks at the disks and for a newer release five minutes +// after the start, then every hour. func (h *Hub) Run(ctx context.Context) { go h.followBus(ctx) + go h.watchDocker(ctx) timer := time.NewTimer(5 * time.Minute) defer timer.Stop() for { diff --git a/pkg/alerts/bus.go b/pkg/alerts/bus.go index 9673f1e7..a980d098 100644 --- a/pkg/alerts/bus.go +++ b/pkg/alerts/bus.go @@ -41,9 +41,13 @@ var operations = map[string]string{ // tests can shorten it. var recheckRegistered = time.Minute +// containerEvents are the events of AppManagement's watch of the containers. They are the ones a +// Docker update stirs up, and so the only ones Hub.Muted silences. +var containerEvents = []string{"app:container-died", "app:container-unhealthy", "app:container-restarting", "app:container-healthy"} + // busEvents are the events of AppManagement this package subscribes to. func busEvents() []string { - names := []string{"backup:error", "app:container-died", "app:container-unhealthy", "app:container-restarting", "app:container-healthy"} + names := append([]string{"backup:error"}, containerEvents...) for operation := range operations { names = append(names, "app:"+operation+"-error") } @@ -158,8 +162,14 @@ func registered(ctx context.Context, address, runtimePath string) ([]string, err return names, nil } -// onEvent raises or resolves the alert of an event. +// onEvent raises or resolves the alert of an event. While Docker is being updated, and just +// after, a container event is dropped, raise or resolve: it is Docker restarting. Dropped is +// dropped, the Hub's memory of what it sent is not touched, so that no "resolved" follows an +// alert that was never sent. func (h *Hub) onEvent(name string, properties map[string]string) { + if slices.Contains(containerEvents, name) && h.Muted() { + return + } a, resolved, ok := fromEvent(name, properties) switch { case !ok: diff --git a/pkg/alerts/docker.go b/pkg/alerts/docker.go new file mode 100644 index 00000000..d109d901 --- /dev/null +++ b/pkg/alerts/docker.go @@ -0,0 +1,174 @@ +package alerts + +import ( + "context" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/ReCasaOS/CasaOS-Common/utils/logger" + "github.com/ReCasaOS/CasaOS/pkg/dockerpkg" + "github.com/ReCasaOS/CasaOS/pkg/utils/file" + "go.uber.org/zap" +) + +// DockerNotifiedFile holds the nonce of the last Docker update that was told to the owner. It is +// the core's only memory of it: a restart of the core, at any moment of a run, changes nothing. +const DockerNotifiedFile = "/var/lib/casaos/docker-update.notified" + +var ( + // dockerPoll is how often the watcher looks for the end of a run, and dockerPolls how many + // times it looks: 5 seconds for 2 hours, more than any update takes. A run that outlasts it + // is told by the next start of the core. Vars, so tests can shorten them. + dockerPoll = 5 * time.Second + dockerPolls = 1440 +) + +// maxNotReturned is how many container names a message lists before it says "and N more". +const maxNotReturned = 10 + +// DockerRun is a run of the Docker update as far as the owner's phone is concerned. The service +// reads it from the run's log and from systemd; nothing here talks to Docker. +type DockerRun struct { + // Nonce names the run, and is "" when there is no run to tell of. + Nonce string + // Outcome is "success", "restart_pending" or "failed" once the run has ended, and "" while it + // has not. + Outcome string + // ErrorCode is why a run failed: guard, download, install, daemon, start or no_result. + ErrorCode string + // To is the version of the engine that runs after a run that succeeded. + To string + // NotReturned are the containers that were running before the update and were not after, + // already validated by whoever read the log. + NotReturned []DockerNotReturned +} + +// DockerNotReturned is a container that was running before the update and was not running when the +// unit stopped waiting for it. +type DockerNotReturned struct { + Name string + // RestartPolicy is Docker's. A container whose policy is "" or "no" does not start again by + // itself; one that has another may, and was not running when the unit stopped waiting. + RestartPolicy string +} + +// WatchDockerUpdate is the update button's hook: a run was just started, tell the owner when it +// ends. It returns at once. +func (h *Hub) WatchDockerUpdate() { + go h.watchDocker(context.Background()) +} + +// watchDocker waits for the last run of the Docker update to end and tells it, looking every few +// seconds for about two hours. It is started with the core, for a run that ended or is going on +// while the core was down or restarting, and by the button, for the one it starts: with no run +// to wait for it returns at once. Any number of them may run: dockerEnded tells a run once. +func (h *Hub) watchDocker(ctx context.Context) { + for i := 0; i < dockerPolls; i++ { + run := h.LastDockerRun() + switch { + case run.Nonce == "": + return + case run.Outcome != "": + h.dockerEnded(run) + return + } + select { + case <-time.After(dockerPoll): + case <-ctx.Done(): + return + } + } +} + +// dockerEnded sends the one message of a run that ended, and does nothing for a run it already +// told: the nonce of the last one is in memory and in the marker file, and the file is written +// before the message is sent, so that no restart, however badly timed, tells a run twice. It is +// written whether or not there is a channel to tell: the owner who adds one next week is not sent +// the news of last week's run. +func (h *Hub) dockerEnded(run DockerRun) { + h.dockerMu.Lock() + defer h.dockerMu.Unlock() + if run.Nonce == "" || run.Nonce == h.dockerNotified { + return + } + path := filepath.Join(h.Root, DockerNotifiedFile) + if data, err := os.ReadFile(path); err == nil && strings.TrimSpace(string(data)) == run.Nonce { + h.dockerNotified = run.Nonce + return + } + // ponytail: without the file (an unwritable state directory) a restart tells the run again. + h.dockerNotified = run.Nonce + if err := file.WriteFileAtomic(path, []byte(run.Nonce+"\n")); err != nil { + logger.Error("alerts: cannot remember that a Docker update was told", zap.Error(err)) + } + // one key per run: the six hours an alert stays quiet are for repeats, not for the next run + h.raise(alert{key: "docker-update:" + run.Nonce, category: Updates, sentence: dockerSentence(run)}) +} + +// dockerSentence is what the owner reads of a run that ended: what became of Docker, then the +// containers that are not running again. +func dockerSentence(run DockerRun) string { + var sentence string + switch run.Outcome { + case "success": + sentence = "Docker was updated" + if run.To != "" { + sentence += " to " + run.To + } + sentence += "." + case "restart_pending": + sentence = "Docker's new version is installed, but the old one still runs: restart Docker to switch (sudo systemctl restart docker, which stops every container)." + default: + sentence = "The Docker update failed: " + dockerFailure(run.ErrorCode) + "." + } + // A container with no restart policy is said never to come back. One with a policy is only said + // to be down after the wait, and not promised to come back: a policy such as on-failure does + // not bring a container up after the daemon stopped it cleanly. + var stay, late []string + for _, container := range run.NotReturned { + if container.RestartPolicy == "" || container.RestartPolicy == "no" { + stay = append(stay, container.Name) + } else { + late = append(late, container.Name) + } + } + if len(stay) > 0 { + sentence += " Not running again: " + nameList(stay) + " (they do not start by themselves)." + } + if len(late) > 0 { + sentence += " Still not running after " + strconv.Itoa(dockerpkg.ReturnWaitSeconds) + " seconds: " + nameList(late) + " (they have a restart policy; if they stay stopped, start them from the dashboard)." + } + if run.Outcome == "failed" { + sentence += " Details are in the dashboard." + } + return sentence +} + +// nameList is the names, the first maxNotReturned of them, and how many more there are. +func nameList(names []string) string { + list := strings.Join(names[:min(len(names), maxNotReturned)], ", ") + if len(names) > maxNotReturned { + list += " and " + strconv.Itoa(len(names)-maxNotReturned) + " more" + } + return list +} + +// dockerFailure is why a run failed, in words: the code is the unit's and nothing else is said. +func dockerFailure(code string) string { + switch code { + case "guard": + return "it was no longer what you confirmed, so nothing was installed" + case "download": + return "the packages could not be downloaded, nothing was changed" + case "install": + return "the packages could not be installed" + case "daemon": + return "Docker did not come back after the update" + case "start": + return "the update could not be started, nothing was changed" + } + return "it stopped before it reported a result" +} diff --git a/pkg/alerts/docker_test.go b/pkg/alerts/docker_test.go new file mode 100644 index 00000000..e4575609 --- /dev/null +++ b/pkg/alerts/docker_test.go @@ -0,0 +1,525 @@ +package alerts + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +const ( + runA = "0123456789abcdef0123456789abcdef" + runB = "fedcba9876543210fedcba9876543210" +) + +// finished is the end of a Docker update that went well. +func finished(nonce string) DockerRun { + return DockerRun{Nonce: nonce, Outcome: "success", To: "29.8.0"} +} + +// notified is what the marker file holds, "" when there is none. +func notified(h *Hub) string { + data, err := os.ReadFile(filepath.Join(h.Root, DockerNotifiedFile)) + if err != nil { + return "" + } + return string(data) +} + +// restarted is the same box after a restart of the core: the files are there, the memory is not. +func restarted(h *Hub, o *outbox) *Hub { + again := New(h.Root) + again.Now, again.Hostname, again.Address, again.Send = h.Now, h.Hostname, h.Address, o.send + return again +} + +// faster makes the watcher look every millisecond, at most polls times. +func faster(t *testing.T, polls int) { + t.Helper() + every, times := dockerPoll, dockerPolls + dockerPoll, dockerPolls = time.Millisecond, polls + t.Cleanup(func() { dockerPoll, dockerPolls = every, times }) +} + +// manyContainers are n containers named prefix1 to prefixN, with that restart policy. +func manyContainers(policy, prefix string, n int) []DockerNotReturned { + var list []DockerNotReturned + for i := 1; i <= n; i++ { + list = append(list, DockerNotReturned{Name: prefix + strconv.Itoa(i), RestartPolicy: policy}) + } + return list +} + +var fourContainerEvents = []string{"app:container-died", "app:container-unhealthy", "app:container-restarting", "app:container-healthy"} + +// During a Docker update, and just after, the container events of the apps are Docker +// restarting, not news; the other events are still news. +func TestContainerEventsAreDroppedWhileMuted(t *testing.T) { + h, o, _ := newHub(t) + muted, asked := true, 0 + h.Muted = func() bool { asked++; return muted } + immich := map[string]string{"app:name": "immich", "docker:container:name": "immich-server"} + + for _, name := range fourContainerEvents { + h.onEvent(name, immich) + } + h.sending.Wait() + if len(o.sent) != 0 || len(h.sent) != 0 || asked != 4 { + t.Fatalf("muted: sent %+v, on record %v, asked %d times; want nothing sent or kept, and the question asked for each of the four", o.sent, h.sent, asked) + } + + // what is not about a container is not muted, and does not even ask + asked = 0 + h.onEvent("app:install-error", immich) + h.onEvent("app:update-error", immich) + h.onEvent("backup:error", map[string]string{"app:name": "immich", "backup:destination": "offsite", "backup:kind": "backup"}) + h.sending.Wait() + if got := len(o.texts(phone)); got != 3 || asked != 0 { + t.Fatalf("while muted: %d sent, asked %d times; want the 3 errors and no question", got, asked) + } + + // and the container events are heard again once it is over + muted = false + h.onEvent("app:container-died", immich) + h.sending.Wait() + if got := o.texts(phone); len(got) != 4 || !strings.HasPrefix(got[3], "immich stopped unexpectedly") { + t.Fatalf("after the mute: sent %q", got) + } +} + +// A raise that was dropped was never sent, so there is nothing for its resolution to follow. +func TestAMutedAlertHasNoResolvedMessageLater(t *testing.T) { + h, o, _ := newHub(t) + muted := true + h.Muted = func() bool { return muted } + immich := map[string]string{"app:name": "immich", "docker:container:name": "immich-server"} + + h.onEvent("app:container-died", immich) + muted = false + h.onEvent("app:container-healthy", immich) + h.sending.Wait() + + if len(o.sent) != 0 || len(h.sent) != 0 { + t.Fatalf("sent %+v, on record %v; want nothing: the alert was never sent", o.sent, h.sent) + } +} + +// An alert sent before the update keeps its state through it: the muted events are not counted as +// repeats, and do not resolve it. Its resolution is sent once, by the first one heard after. +func TestAnAlertSentBeforeTheUpdateIsLeftAloneByIt(t *testing.T) { + h, o, _ := newHub(t) + muted := false + h.Muted = func() bool { return muted } + immich := map[string]string{"app:name": "immich", "docker:container:name": "immich-server"} + h.onEvent("app:container-died", immich) + h.sending.Wait() + if len(o.texts(phone)) != 1 { + t.Fatalf("sent %q", o.texts(phone)) + } + + muted = true + h.onEvent("app:container-died", immich) + h.onEvent("app:container-healthy", immich) + h.sending.Wait() + if r := h.sent["app:immich:runtime"]; r == nil || r.repeats != 0 || r.resolved || len(o.texts(phone)) != 1 { + t.Fatalf("record %+v, sent %q; want it untouched and nothing new sent", r, o.texts(phone)) + } + + muted = false + h.onEvent("app:container-healthy", immich) + h.sending.Wait() + if got := o.texts(phone); len(got) != 2 || !strings.HasPrefix(got[1], "Resolved: immich runs normally again.") { + t.Fatalf("sent %q, want the resolution once", got) + } +} + +func TestTheDockerUpdateEndsWithOneMessage(t *testing.T) { + for name, tc := range map[string]struct { + run DockerRun + want string + }{ + "updated": { + DockerRun{Outcome: "success", To: "29.8.0"}, + "Docker was updated to 29.8.0.", + }, + "updated, version unknown": { + DockerRun{Outcome: "success"}, + "Docker was updated.", + }, + "updated, containers missing": { + DockerRun{Outcome: "success", To: "29.8.0", NotReturned: []DockerNotReturned{{"job", "no"}, {"scratch", ""}}}, + "Docker was updated to 29.8.0. Not running again: job, scratch (they do not start by themselves).", + }, + // a container with a restart policy is not said never to start (it may), and not promised + // to either: on-failure, say, does not bring a container back after a daemon restart + // that stopped it with exit code 0 + "updated, containers late": { + DockerRun{Outcome: "success", To: "29.8.0", NotReturned: []DockerNotReturned{{"db", "always"}, {"web", "unless-stopped"}, {"worker", "on-failure"}}}, + "Docker was updated to 29.8.0. Still not running after 90 seconds: db, web, worker (they have a restart policy; if they stay stopped, start them from the dashboard).", + }, + "updated, containers missing and late": { + DockerRun{Outcome: "success", To: "29.8.0", NotReturned: []DockerNotReturned{{"db", "always"}, {"job", "no"}, {"web", "unless-stopped"}, {"scratch", ""}}}, + "Docker was updated to 29.8.0. Not running again: job, scratch (they do not start by themselves). Still not running after 90 seconds: db, web (they have a restart policy; if they stay stopped, start them from the dashboard).", + }, + "failed, containers late": { + DockerRun{Outcome: "failed", ErrorCode: "daemon", NotReturned: []DockerNotReturned{{"db", "always"}}}, + "The Docker update failed: Docker did not come back after the update. Still not running after 90 seconds: db (they have a restart policy; if they stay stopped, start them from the dashboard). Details are in the dashboard.", + }, + "restart pending": { + DockerRun{Outcome: "restart_pending"}, + "Docker's new version is installed, but the old one still runs: restart Docker to switch (sudo systemctl restart docker, which stops every container).", + }, + "failed, guard": { + DockerRun{Outcome: "failed", ErrorCode: "guard"}, + "The Docker update failed: it was no longer what you confirmed, so nothing was installed. Details are in the dashboard.", + }, + "failed, download": { + DockerRun{Outcome: "failed", ErrorCode: "download"}, + "The Docker update failed: the packages could not be downloaded, nothing was changed. Details are in the dashboard.", + }, + "failed, install": { + DockerRun{Outcome: "failed", ErrorCode: "install"}, + "The Docker update failed: the packages could not be installed. Details are in the dashboard.", + }, + "failed, daemon": { + DockerRun{Outcome: "failed", ErrorCode: "daemon"}, + "The Docker update failed: Docker did not come back after the update. Details are in the dashboard.", + }, + "failed, start": { + DockerRun{Outcome: "failed", ErrorCode: "start"}, + "The Docker update failed: the update could not be started, nothing was changed. Details are in the dashboard.", + }, + "failed, no result": { + DockerRun{Outcome: "failed", ErrorCode: "no_result"}, + "The Docker update failed: it stopped before it reported a result. Details are in the dashboard.", + }, + "failed, a code nobody knows": { + DockerRun{Outcome: "failed", ErrorCode: "x\nreboot"}, + "The Docker update failed: it stopped before it reported a result. Details are in the dashboard.", + }, + "failed, containers missing": { + DockerRun{Outcome: "failed", ErrorCode: "daemon", NotReturned: []DockerNotReturned{{"db", "no"}}}, + "The Docker update failed: Docker did not come back after the update. Not running again: db (they do not start by themselves). Details are in the dashboard.", + }, + "many containers missing": { + DockerRun{Outcome: "success", To: "29.8.0", NotReturned: manyContainers("no", "c", 12)}, + "Docker was updated to 29.8.0. Not running again: c1, c2, c3, c4, c5, c6, c7, c8, c9, c10 and 2 more (they do not start by themselves).", + }, + "many containers late": { + DockerRun{Outcome: "success", To: "29.8.0", NotReturned: manyContainers("always", "c", 11)}, + "Docker was updated to 29.8.0. Still not running after 90 seconds: c1, c2, c3, c4, c5, c6, c7, c8, c9, c10 and 1 more (they have a restart policy; if they stay stopped, start them from the dashboard).", + }, + // each list is cut on its own + "many of both": { + DockerRun{Outcome: "success", To: "29.8.0", NotReturned: append(manyContainers("no", "n", 11), manyContainers("always", "l", 12)...)}, + "Docker was updated to 29.8.0. Not running again: n1, n2, n3, n4, n5, n6, n7, n8, n9, n10 and 1 more (they do not start by themselves). Still not running after 90 seconds: l1, l2, l3, l4, l5, l6, l7, l8, l9, l10 and 2 more (they have a restart policy; if they stay stopped, start them from the dashboard).", + }, + } { + t.Run(name, func(t *testing.T) { + h, o, _ := newHub(t) + tc.run.Nonce = runA + + h.dockerEnded(tc.run) + h.sending.Wait() + + if len(o.sent) != 2 || o.sent[0].title != "ReCasaOS · box · updates" || o.texts(phone)[0] != tc.want+"\nhttp://192.168.1.20" { + t.Fatalf("sent %+v, want %q", o.sent, tc.want) + } + if _, kept := h.sent["docker-update:"+runA]; !kept { + t.Fatalf("not on record under docker-update:%s: %v", runA, h.sent) + } + }) + } +} + +// One message per run: a run is told once, and a second run an hour after the first is not +// taken for a repeat of it. +func TestEachRunIsToldOnceAndNotTakenForTheLastOne(t *testing.T) { + h, o, now := newHub(t) + + h.dockerEnded(finished(runA)) + h.dockerEnded(finished(runA)) + *now = now.Add(time.Hour) // inside the six hours an alert already sent stays quiet + h.dockerEnded(finished(runB)) + h.dockerEnded(finished(runB)) + h.sending.Wait() + + if got := o.texts(phone); len(got) != 2 { + t.Fatalf("sent %q, want one message for each of the two runs", got) + } + if got := notified(h); got != runB+"\n" { + t.Fatalf("marker = %q, want the last run's nonce", got) + } +} + +// The marker file is what survives the core: a run told before the restart is not told again, +// one that ended while the core was down is told once. +func TestARunIsToldOnceAcrossARestart(t *testing.T) { + h, o, _ := newHub(t) + h.dockerEnded(finished(runA)) + h.sending.Wait() + if got := notified(h); got != runA+"\n" { + t.Fatalf("marker = %q", got) + } + if entries, _ := os.ReadDir(filepath.Dir(filepath.Join(h.Root, DockerNotifiedFile))); len(entries) != 2 { + t.Errorf("the state directory holds %v, want alerts.json and the marker only: the write leaves nothing behind", entries) + } + + again := restarted(h, o) + again.LastDockerRun = func() DockerRun { return finished(runA) } + again.watchDocker(context.Background()) + again.sending.Wait() + if got := o.texts(phone); len(got) != 1 { + t.Fatalf("after the restart the same run was told again: %q", got) + } + + // the run that ended while the core was down + again = restarted(h, o) + again.LastDockerRun = func() DockerRun { return DockerRun{Nonce: runB, Outcome: "failed", ErrorCode: "daemon"} } + again.watchDocker(context.Background()) + again.sending.Wait() + got := o.texts(phone) + if len(got) != 2 || !strings.HasPrefix(got[1], "The Docker update failed: Docker did not come back") { + t.Fatalf("sent %q, want the second run told once", got) + } + if notified(h) != runB+"\n" { + t.Fatalf("marker = %q", notified(h)) + } + + // and a third restart tells nothing + third := restarted(h, o) + third.LastDockerRun = func() DockerRun { return DockerRun{Nonce: runB, Outcome: "failed", ErrorCode: "daemon"} } + third.watchDocker(context.Background()) + third.sending.Wait() + if got := o.texts(phone); len(got) != 2 { + t.Fatalf("sent %q", got) + } +} + +// A marker file that is garbage is no nonce: the run is told, and the file is mended. +func TestAMarkerThatIsGarbageDoesNotSilenceARun(t *testing.T) { + h, o, _ := newHub(t) + if err := os.WriteFile(filepath.Join(h.Root, DockerNotifiedFile), []byte("\x00\x00 not a nonce"), 0o600); err != nil { + t.Fatal(err) + } + + h.dockerEnded(finished(runA)) + h.sending.Wait() + + if len(o.texts(phone)) != 1 || notified(h) != runA+"\n" { + t.Fatalf("sent %q, marker %q", o.texts(phone), notified(h)) + } +} + +// The two goroutines that can look at the end of a run (the one from the core's start and the +// one from the button) must not both tell it. +func TestRacingNotifiersSendOnce(t *testing.T) { + h, o, now := newHub(t) + // The hub's own six quiet hours for an alert already sent would hide a second send: the clock + // jumps a day at each look, so that only the nonce stands between the goroutines. + var looks atomic.Int64 + h.Now = func() time.Time { return now.Add(time.Duration(looks.Add(1)) * 24 * time.Hour) } + start := make(chan struct{}) + var all sync.WaitGroup + for range 32 { + all.Add(1) + go func() { + defer all.Done() + <-start + h.dockerEnded(finished(runA)) + }() + } + close(start) + all.Wait() + h.sending.Wait() + + if got := o.texts(phone); len(got) != 1 { + t.Fatalf("sent %q, want the run told once", got) + } + if got := o.texts(mail); len(got) != 1 { + t.Fatalf("the mail box got %q", got) + } +} + +func TestRacingWatchersSendOnce(t *testing.T) { + faster(t, 1000) + h, o, _ := newHub(t) + var polls atomic.Int32 + h.LastDockerRun = func() DockerRun { + if polls.Add(1) < 20 { + return DockerRun{Nonce: runA} + } + return finished(runA) + } + var all sync.WaitGroup + for range 4 { + all.Add(1) + go func() { + defer all.Done() + h.watchDocker(context.Background()) + }() + } + all.Wait() + h.sending.Wait() + + if got := o.texts(phone); len(got) != 1 { + t.Fatalf("sent %q, want the run told once", got) + } +} + +func TestTheWatcherWaitsForTheEndOfTheRun(t *testing.T) { + faster(t, 1000) + h, o, _ := newHub(t) + var polls atomic.Int32 + h.LastDockerRun = func() DockerRun { + if polls.Add(1) < 3 { + return DockerRun{Nonce: runA} // running + } + return finished(runA) + } + + h.watchDocker(context.Background()) + h.sending.Wait() + + if polls.Load() != 3 || len(o.texts(phone)) != 1 { + t.Fatalf("looked %d times, sent %q", polls.Load(), o.texts(phone)) + } +} + +func TestTheWatcherGivesUpWhenTheRunNeverEnds(t *testing.T) { + faster(t, 5) + h, o, _ := newHub(t) + var polls atomic.Int32 + h.LastDockerRun = func() DockerRun { polls.Add(1); return DockerRun{Nonce: runA} } + + h.watchDocker(context.Background()) + h.sending.Wait() + + if polls.Load() != 5 || len(o.sent) != 0 || notified(h) != "" { + t.Fatalf("looked %d times, sent %+v, marker %q", polls.Load(), o.sent, notified(h)) + } +} + +func TestTheWatcherStopsWithTheHub(t *testing.T) { + faster(t, 10) + dockerPoll = time.Hour // faster's cleanup puts it back + h, _, _ := newHub(t) + h.LastDockerRun = func() DockerRun { return DockerRun{Nonce: runA} } + ctx, cancel := context.WithCancel(context.Background()) + stopped := make(chan struct{}) + go func() { h.watchDocker(ctx); close(stopped) }() + + cancel() + + select { + case <-stopped: + case <-time.After(10 * time.Second): + t.Fatal("the watcher did not stop") + } +} + +func TestTheWatcherHasNothingToWaitForWithoutARun(t *testing.T) { + faster(t, 1000) + h, o, _ := newHub(t) + var polls atomic.Int32 + h.LastDockerRun = func() DockerRun { polls.Add(1); return DockerRun{} } + + h.watchDocker(context.Background()) + h.sending.Wait() + + if polls.Load() != 1 || len(o.sent) != 0 { + t.Fatalf("looked %d times, sent %+v", polls.Load(), o.sent) + } + // the default hub knows of no run at all + if got := New(t.TempDir()).LastDockerRun(); !reflect.DeepEqual(got, DockerRun{}) { + t.Fatalf("default run = %+v", got) + } +} + +// Whether or not a channel could be told, the run is told once: a channel added next week is not +// sent the news of a run of last week. +func TestARunNobodyCouldBeToldIsStillMarked(t *testing.T) { + h, o, _ := newHub(t) + channels := h.load() + saveConfig(t, h, defaults()) + + h.dockerEnded(finished(runA)) + saveConfig(t, h, channels) + h.dockerEnded(finished(runA)) + h.sending.Wait() + + if len(o.sent) != 0 || notified(h) != runA+"\n" { + t.Fatalf("sent %+v, marker %q", o.sent, notified(h)) + } +} + +// The updates category is the owner's switch for this message too. +func TestTheDockerMessageFollowsTheUpdatesCategory(t *testing.T) { + h, o, _ := newHub(t) + c := h.load() + c.Categories[Updates] = false + saveConfig(t, h, c) + + h.dockerEnded(finished(runA)) + h.sending.Wait() + + if len(o.sent) != 0 { + t.Fatalf("sent %+v with updates off", o.sent) + } +} + +// Run looks at the last run when the core starts, so a run that ended while the core was down is +// told even if nobody opens the dashboard. +func TestRunTellsARunThatEndedWhileTheCoreWasDown(t *testing.T) { + faster(t, 10) + h, o, _ := newHub(t) + h.LastDockerRun = func() DockerRun { return finished(runA) } + ctx, cancel := context.WithCancel(context.Background()) + stopped := make(chan struct{}) + go func() { h.Run(ctx); close(stopped) }() + defer func() { + cancel() + <-stopped + }() + + deadline := time.Now().Add(10 * time.Second) + for len(o.texts(phone)) == 0 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if got := o.texts(phone); len(got) != 1 || !strings.HasPrefix(got[0], "Docker was updated to 29.8.0.") { + t.Fatalf("sent %q", got) + } +} + +// WatchDockerUpdate is the button's hook: it returns at once and tells the run when it ends. +func TestWatchDockerUpdateReturnsAtOnceAndTellsTheEnd(t *testing.T) { + faster(t, 1000) + h, o, _ := newHub(t) + end := make(chan struct{}) + h.LastDockerRun = func() DockerRun { + select { + case <-end: + return finished(runB) + default: + return DockerRun{Nonce: runB} + } + } + + h.WatchDockerUpdate() + close(end) + + deadline := time.Now().Add(10 * time.Second) + for len(o.texts(phone)) == 0 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + h.sending.Wait() + if got := o.texts(phone); len(got) != 1 { + t.Fatalf("sent %q", got) + } +} 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()) diff --git a/pkg/dockerpkg/containers.go b/pkg/dockerpkg/containers.go new file mode 100644 index 00000000..f8189203 --- /dev/null +++ b/pkg/dockerpkg/containers.go @@ -0,0 +1,250 @@ +package dockerpkg + +import ( + "encoding/json" + "errors" + "path" + "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"` + // DockerSocket is a container with Docker's socket bind-mounted into it: it talks to the + // daemon directly, with a client of its own that a new Docker may no longer understand. + // Only there when it is true. + DockerSocket bool `json:"docker_socket,omitempty"` + // 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}},"mounts":{{json .Mounts}},"ports":{{json .NetworkSettings.Ports}}}` + +// dockerSocketSources are the host paths of Docker's socket that a container may have bind-mounted: +// /var/run is /run on every box this runs on. A bind of the directory that holds the socket is +// not looked for. +var dockerSocketSources = map[string]bool{"/var/run/docker.sock": true, "/run/docker.sock": true} + +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"` + // Mounts are docker inspect's own (Type, Source, Destination, ...): only the first two are read. + Mounts []struct { + Type string `json:"Type"` + Source string `json:"Source"` + } `json:"mounts"` + Ports map[string][]struct { + HostPort string `json:"HostPort"` + } `json:"ports"` +} + +// mountsDockerSocket says whether one of the mounts is a bind of Docker's socket. The source is +// third-party text, compared and never kept: a path with a // or a trailing / is the same path. +func (c inspectedContainer) mountsDockerSocket() bool { + for _, mount := range c.Mounts { + if mount.Type == "bind" && dockerSocketSources[path.Clean(mount.Source)] { + return true + } + } + return false +} + +// 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", + DockerSocket: raw.mountsDockerSocket(), + 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..51fd1525 --- /dev/null +++ b/pkg/dockerpkg/containers_test.go @@ -0,0 +1,424 @@ +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}, + "Mounts": []any{}, + } +} + +// mount is a MountPoint of docker inspect, with the fields it has besides the two that are read. +func mount(kind, source string) map[string]any { + return map[string]any{"Type": kind, "Source": source, "Destination": "/data", "Mode": "", "RW": true, "Propagation": "rprivate"} +} + +// withMounts is the container with those mounts. +func withMounts(c map[string]any, mounts ...map[string]any) map[string]any { + list := []any{} + for _, m := range mounts { + list = append(list, m) + } + c["Mounts"] = list + return c +} + +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), + // a container that talks to Docker, beside others that mount other things + withMounts(inspected("/traefik", "traefik:v3", "unless-stopped", "bridge", map[string]any{}), + mount("bind", "/srv/traefik"), mount("bind", "/var/run/docker.sock"), mount("volume", "/var/lib/docker/volumes/x/_data")), + } { + out.WriteString(dockerTemplate(t, ContainerInspectFormat, c)) + out.WriteString("\n") // docker inspect puts the new line itself + } + if n := strings.Count(out.String(), "\n"); n != 5 { + t.Fatalf("five 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: "traefik", Image: "traefik:v3", RestartPolicy: "unless-stopped", DockerSocket: 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)}}), + withMounts(inspected("/x", "x", "no", "bridge", nil), mount(hostile, hostile)), + withMounts(inspected("/x", "x", "no", "bridge", nil), mount("bind", hostile), mount(hostile, "/var/run/docker.sock")), + } { + 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 TestParseContainersFindsTheDockerSocket(t *testing.T) { + line := func(mounts string) string { + return `{"name":"/app","image":"x","restart":"always","network":"bridge","mounts":` + mounts + `,"ports":{}}` + } + bind := func(source string) string { + return `{"Type":"bind","Source":` + source + `,"Destination":"/var/run/docker.sock","Mode":"","RW":true,"Propagation":"rprivate"}` + } + for name, c := range map[string]struct { + mounts string + want bool + }{ + "no mounts": {`[]`, false}, + "null": {`null`, false}, + "the socket": {`[` + bind(`"/var/run/docker.sock"`) + `]`, true}, + "the socket under /run": {`[` + bind(`"/run/docker.sock"`) + `]`, true}, + "the socket, read only": {`[{"Type":"bind","Source":"/var/run/docker.sock","Destination":"/var/run/docker.sock","Mode":"ro","RW":false}]`, true}, + "the socket somewhere else": {`[{"Type":"bind","Source":"/var/run/docker.sock","Destination":"/tmp/d.sock"}]`, true}, + "the socket among others": {`[` + bind(`"/srv/app"`) + `,` + bind(`"/var/run/docker.sock"`) + `,{"Type":"volume","Name":"data","Source":"/var/lib/docker/volumes/data/_data"}]`, true}, + "written with a //": {`[` + bind(`"/var/run//docker.sock"`) + `]`, true}, + "written with a / at the end": {`[` + bind(`"/var/run/docker.sock/"`) + `]`, true}, + "written with a ..": {`[` + bind(`"/var/run/../run/docker.sock"`) + `]`, true}, + "written with a .": {`[` + bind(`"/run/./docker.sock"`) + `]`, true}, + "the directory that holds it": {`[` + bind(`"/var/run"`) + `,` + bind(`"/run"`) + `]`, false}, + "another socket": {`[` + bind(`"/var/run/docker.sock.bak"`) + `,` + bind(`"/var/run/containerd/containerd.sock"`) + `,` + bind(`"/var/run/podman/podman.sock"`) + `]`, false}, + "under another path": {`[` + bind(`"/data/var/run/docker.sock"`) + `,` + bind(`"/docker.sock"`) + `,` + bind(`"/var/run/docker.sock/x"`) + `]`, false}, + "a relative path": {`[` + bind(`"var/run/docker.sock"`) + `]`, false}, + "another case": {`[` + bind(`"/VAR/RUN/DOCKER.SOCK"`) + `]`, false}, + "a new line after it": {`[` + bind(`"/var/run/docker.sock\n"`) + `]`, false}, + "a space after it": {`[` + bind(`"/var/run/docker.sock "`) + `]`, false}, + "a named pipe": {`[` + bind(`"//./pipe/docker_engine"`) + `]`, false}, + "an empty source": {`[` + bind(`""`) + `]`, false}, + "a volume of that name": {`[{"Type":"volume","Name":"x","Source":"/var/run/docker.sock"}]`, false}, + "a tmpfs": {`[{"Type":"tmpfs","Source":"/var/run/docker.sock"}]`, false}, + "no type": {`[{"Source":"/var/run/docker.sock"}]`, false}, + } { + got := ParseContainers(line(c.mounts)) + if len(got) != 1 || got[0].DockerSocket != c.want { + t.Errorf("%s: ParseContainers() = %#v, want DockerSocket %v", name, got, c.want) + } + } + // a mount that is not shaped like one is a line that is not shaped like a container + if got := ParseContainers(line(`[{"Type":"bind","Source":7}]`)); len(got) != 0 { + t.Errorf("a source that is a number: %#v, want the line skipped", got) + } + + // a line that has no mounts at all (a format from before) is a container without the socket + if got := ParseContainers(`{"name":"/old","image":"x","restart":"no","network":"bridge","ports":{}}`); len(got) != 1 || got[0].DockerSocket { + t.Errorf("a line without mounts: %#v", got) + } + // ... and mounts that are not a list are a line that is not shaped like a container + if got := ParseContainers(line(`"/var/run/docker.sock"`)); len(got) != 0 { + t.Errorf("mounts that are a string: %#v", got) + } + + // what the API sends: only there when it is true, after host_network + encoded, _ := json.Marshal(Container{Name: "a", Image: "x", RestartPolicy: "no", DockerSocket: true, Ports: []Port{}}) + if string(encoded) != `{"name":"a","image":"x","restart_policy":"no","host_network":false,"docker_socket":true,"ports":[]}` { + t.Errorf("Container as JSON = %s", encoded) + } + encoded, _ = json.Marshal(Container{Name: "a", Image: "x", RestartPolicy: "no", Ports: []Port{}}) + if strings.Contains(string(encoded), "docker_socket") { + t.Errorf("Container as JSON = %s: docker_socket is only there when true", encoded) + } +} + +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) + } + } +} diff --git a/pkg/dockerpkg/engine.go b/pkg/dockerpkg/engine.go new file mode 100644 index 00000000..9ea88fd0 --- /dev/null +++ b/pkg/dockerpkg/engine.go @@ -0,0 +1,79 @@ +package dockerpkg + +import ( + "regexp" + "strings" +) + +// The "Update Docker" button upgrades the engine's own packages, and installs the packages +// the new engine depends on that the box does not have yet (Docker 29 needs nftables). This +// file is what says which packages are the engine's, 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 upgrade, sorted. An upgrade of any other +// package in the transaction apt would run, or a removal, makes it refuse; so does a new +// package that is not a dependency (see Plan.Offending). 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, and they conflict with it, +// so they are refused as new packages too. +// +// 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/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..d3c2c9b5 --- /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, FailStart} { + 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) + } +} diff --git a/pkg/dockerpkg/plan.go b/pkg/dockerpkg/plan.go new file mode 100644 index 00000000..76895fec --- /dev/null +++ b/pkg/dockerpkg/plan.go @@ -0,0 +1,223 @@ +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)" + +// MaxNewPackages is how many packages that are not installed yet an update of the engine may +// bring in. Docker 29 needs nftables, and nftables its libraries: a handful. Ten is room for +// that, and for a repository that moves a dependency or two, and not for an apt that has +// understood something else. +const MaxNewPackages = 10 + +// TooManyNew stands in, in Offending, for the count: a plan that installs more than +// MaxNewPackages packages that are not installed yet. It is not a name, and tells itself so. +const TooManyNew = "(more than 10 new packages)" + +// 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] at all (a + // pair of brackets with nothing in them is not that, and is refused). + New bool `json:"new,omitempty"` +} + +// IsDistributionDocker reports whether name is one of the distribution's own Docker packages +// (docker.io, containerd, docker-compose-v2, docker-buildx): of the Docker family and not of +// the engine's. They conflict with Docker's own packages, and apt bringing one in beside +// docker-ce would be apt choosing the other Docker. +func IsDistributionDocker(name string) bool { + return IsFamily(name) && !IsEngineName(name) +} + +// offending reports whether the button must refuse a transaction that holds this package. An +// upgrade must be of one of the engine's packages (the update must not drag the base system +// along), whatever its name or versions say. A package that is new may be any package a +// Docker version depends on, except a distribution's Docker package. Whatever it is, its name +// and versions must be text that may go near a command line. +func (pkg PlanPackage) offending() bool { + if !ValidName(pkg.Name) || !ValidVersion(pkg.Candidate) { + return true + } + if pkg.New { + return IsDistributionDocker(pkg.Name) + } + return !IsEngineName(pkg.Name) || !ValidVersion(pkg.Current) +} + +// 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 (the second group is the brackets, to tell none from empty + // ones), 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[3], im[4] + pkg.New = im[2] == "" + } + plan.Packages = append(plan.Packages, pkg) + } + return plan +} + +// Offending lists, sorted and once each, what makes the plan unacceptable to the button. A +// plan is acceptable when nothing is removed, ever; every upgrade is of one of EngineNames; +// every package that is new (not installed yet: a dependency of the new Docker, say) is not a +// distribution's Docker package; there are at most MaxNewPackages of those; and every name +// and version passes ValidName and ValidVersion. What is not acceptable is named: a package +// that would be removed, an upgrade of a package that is not the engine's, a new package that +// is a distribution's Docker, anything with a name or a version that fails validation. +// +// Every item is a valid name, or a fixed text that cannot be one: InvalidName, and TooManyNew +// for a plan with too many new packages (their names are not listed). 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) + } + var fresh int + for _, pkg := range p.Packages { + if pkg.New { + fresh++ + } + if pkg.offending() { + add(pkg.Name) + } + } + if fresh > MaxNewPackages { + seen[TooManyNew] = struct{}{} + } + 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 upgrade the packages of this plan to +// the versions that were confirmed, sorted. They are the upgrades only: with --only-upgrade +// apt ignores a named package that is not on the box, so a pin of a new package would pin +// nothing, and the new packages are the ones apt itself brings in as dependencies (Names is +// what bounds them). 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.New && !pkg.offending() { + pins = append(pins, pkg.Name+"="+pkg.Candidate) + } + } + sort.Strings(pins) + return pins +} + +// Names are the names of every package the plan allows apt to install, sorted: the upgrades +// of Pins and the packages that are new. It is the list the unit's guard holds each line of +// apt's simulation against, 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 +} + +// Dependencies are the names of the packages that are new and are not the engine's own, sorted: +// what the new Docker needs and the box does not have (nftables, say). Under the same rule as +// Pins. Never nil. +func (p Plan) Dependencies() []string { + names := []string{} + for _, pkg := range p.Packages { + if pkg.New && !pkg.offending() && !IsEngineName(pkg.Name) { + 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..52a9dfe6 --- /dev/null +++ b/pkg/dockerpkg/plan_test.go @@ -0,0 +1,558 @@ +package dockerpkg + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "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) + } + // a new package of the engine is acceptable: it is installed, and not pinned, since + // --only-upgrade would ignore a pin of a package that is not on the box + if bad := plan.Offending(); len(bad) != 0 { + t.Errorf("Offending() = %v, want none: a new package is acceptable", bad) + } + if got, want := plan.Pins(), []string{"containerd.io=2.3.6-1~ubuntu.22.04~jammy", "docker-ce=5:29.8.2-1~ubuntu.22.04~jammy"}; !reflect.DeepEqual(got, want) { + t.Errorf("Pins() = %v, want %v: the new package is not pinned", got, want) + } + if got, want := plan.Names(), []string{"containerd.io", "docker-ce", "docker-model-plugin"}; !reflect.DeepEqual(got, want) { + t.Errorf("Names() = %v, want %v", got, want) + } + if got := plan.Dependencies(); got == nil || len(got) != 0 { + t.Errorf("Dependencies() = %#v: a new package of the engine is not a dependency", got) + } + if from, to := plan.Engine(); from != "29.8.1" || to != "29.8.2" || MajorJump(from, to) { + t.Errorf("Engine() = %q, %q", from, to) + } +} + +// The owner's box for real: Docker 29 depends on nftables, which Docker 28 did not, so apt +// brings in the package and the libraries it needs, and containerd.io moves from 1.x to 2.x. +const debian11WithDependencies = `Reading package lists... +Building dependency tree... +Reading state information... +Inst libjansson4 (2.13.1-1 Debian:11.11/oldstable [amd64]) +Inst libedit2 (3.1-20191231-2+b1 Debian:11.11/oldstable [amd64]) +Inst libnftables1 (0.9.8-3.1+deb11u2 Debian-Security:11/oldstable-security [amd64]) +Inst nftables (0.9.8-3.1+deb11u2 Debian-Security:11/oldstable-security [amd64]) +Inst containerd.io [1.7.27-1] (2.1.5-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]) +Conf nftables (0.9.8-3.1+deb11u2 Debian-Security:11/oldstable-security [amd64]) +` + +func TestParsePlanOfTheOwnersBoxWithItsNewDependencies(t *testing.T) { + plan := ParsePlan(debian11WithDependencies) + want := []PlanPackage{ + {Name: "libjansson4", Candidate: "2.13.1-1", New: true}, + {Name: "libedit2", Candidate: "3.1-20191231-2+b1", New: true}, + {Name: "libnftables1", Candidate: "0.9.8-3.1+deb11u2", New: true}, + {Name: "nftables", Candidate: "0.9.8-3.1+deb11u2", New: true}, + {Name: "containerd.io", Current: "1.7.27-1", Candidate: "2.1.5-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"}, + } + if !reflect.DeepEqual(plan.Packages, want) { + t.Fatalf("Packages = %#v\nwant %#v", plan.Packages, want) + } + if bad := plan.Offending(); bad == nil || len(bad) != 0 { + t.Errorf("Offending() = %#v, want an empty, non-nil list: the owner's own box must be acceptable", bad) + } + if from, to := plan.Engine(); from != "28.0.4" || to != "29.8.0" || !MajorJump(from, to) { + t.Errorf("Engine() = %q, %q", from, to) + } + // what apt is told to upgrade is the upgrades alone; a new package is never pinned + wantPins := []string{"containerd.io=2.1.5-1~debian.11~bullseye", "docker-ce-cli=5:29.8.0-1~debian.11~bullseye", "docker-ce=5:29.8.0-1~debian.11~bullseye"} + if got := plan.Pins(); !reflect.DeepEqual(got, wantPins) { + t.Errorf("Pins() = %v, want %v", got, wantPins) + } + for _, pin := range plan.Pins() { + for _, fresh := range []string{"nftables", "libnftables1", "libjansson4", "libedit2"} { + if strings.HasPrefix(pin, fresh+"=") { + t.Errorf("Pins() holds the new package %s: %s", fresh, pin) + } + } + } + // ... and what the unit's guard may see apt install is both + wantNames := []string{"containerd.io", "docker-ce", "docker-ce-cli", "libedit2", "libjansson4", "libnftables1", "nftables"} + if got := plan.Names(); !reflect.DeepEqual(got, wantNames) { + t.Errorf("Names() = %v, want %v", got, wantNames) + } + if got, want := plan.Dependencies(), []string{"libedit2", "libjansson4", "libnftables1", "nftables"}; !reflect.DeepEqual(got, want) { + t.Errorf("Dependencies() = %v, want %v", got, want) + } + // the id covers the new packages: another dependency set is another plan, to be confirmed again + without := ParsePlan(strings.ReplaceAll(debian11WithDependencies, "Inst libedit2 (3.1-20191231-2+b1 Debian:11.11/oldstable [amd64])\n", "")) + if without.ID() == plan.ID() { + t.Error("ID() did not change with a dependency that is gone") + } + newer := ParsePlan(strings.Replace(debian11WithDependencies, "Inst nftables (0.9.8-3.1+deb11u2", "Inst nftables (0.9.8-3.1+deb11u3", 1)) + if newer.ID() == plan.ID() { + t.Error("ID() did not change with a newer version of a dependency") + } + sum := sha256.Sum256([]byte(strings.Join([]string{ + "containerd.io 1.7.27-1>2.1.5-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", + "libedit2 >3.1-20191231-2+b1", + "libjansson4 >2.13.1-1", + "libnftables1 >0.9.8-3.1+deb11u2", + "nftables >0.9.8-3.1+deb11u2", + }, "\n"))) + if want := hex.EncodeToString(sum[:]); plan.ID() != want { + t.Errorf("ID() = %s, want %s", plan.ID(), want) + } +} + +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 || plan.Dependencies() == nil || len(plan.Pins()) != 0 || len(plan.Names()) != 0 || len(plan.Dependencies()) != 0 { + t.Errorf("Pins/Names/Dependencies of an empty plan = %#v / %#v / %#v", plan.Pins(), plan.Names(), plan.Dependencies()) + } + 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"}}, + {"an empty current version", ok + "Inst docker-model-plugin [] (1.0.0-1 Docker CE:bullseye [amd64])\n", []string{"docker-model-plugin"}}, + {"an empty current version of a package that is not the engine's", ok + "Inst libseccomp2 [] (2.5.1 Debian [amd64])\n", []string{"libseccomp2"}}, + {"a current version that is not a version", ok + "Inst docker-model-plugin [1.0;reboot] (1.1.0-1 Docker CE:bullseye [amd64])\n", []string{"docker-model-plugin"}}, + // the distribution's own Docker packages conflict with Docker's: never installed as a new one + {"a new docker.io", ok + "Inst docker.io (26.1.5 Debian [amd64])\n", []string{"docker.io"}}, + {"a new containerd", ok + "Inst containerd (1.6.20 Debian [amd64])\n", []string{"containerd"}}, + {"a new docker-compose-v2", ok + "Inst docker-compose-v2 (2.2 Debian [amd64])\n", []string{"docker-compose-v2"}}, + {"a new docker-buildx", ok + "Inst docker-buildx (0.2 Debian [amd64])\n", []string{"docker-buildx"}}, + {"a new docker.io of another architecture", ok + "Inst docker.io:arm64 (26.1.5 Debian [arm64])\n", []string{"docker.io:arm64"}}, + {"a new package with a hostile candidate", ok + "Inst nftables (0.9.8;reboot Debian [amd64])\n", []string{"nftables"}}, + {"a new package with a candidate in backticks", ok + "Inst nftables (0.9.8`id` Debian [amd64])\n", []string{"nftables"}}, + {"a new package with an overlong candidate", ok + "Inst nftables (" + strings.Repeat("9", 120) + " Debian [amd64])\n", []string{"nftables"}}, + {"a new package with no candidate", ok + "Inst nftables\n", []string{"nftables"}}, + {"a new package with a name that is a command", ok + "Inst nftables;reboot (1 Debian [amd64])\n", []string{InvalidName}}, + {"a new package with a name that is a flag", ok + "Inst -oAPT::Foo=1 (1 Debian [amd64])\n", []string{InvalidName}}, + {"a new package with an upper case name", ok + "Inst NFTables (1 Debian [amd64])\n", []string{InvalidName}}, + // an upgrade of anything but the engine's own packages drags the base system along + {"an upgrade of libc6", ok + "Inst libc6 [2.31-13+deb11u5] (2.31-13+deb11u11 Debian-Security:11/stable-security [amd64])\n", []string{"libc6"}}, + {"an upgrade of systemd", ok + "Inst systemd [247.3-7] (247.3-7+deb11u6 Debian [amd64])\n", []string{"systemd"}}, + {"an upgrade of nftables, which is a dependency when it is new", ok + "Inst nftables [0.9.8-3] (0.9.8-3.1 Debian [amd64])\n", []string{"nftables"}}, + {"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 && name != TooManyNew && !ValidName(name) { + t.Errorf("%s: Offending() holds %q, which is neither a valid name nor a marker", c.name, name) + } + } + } +} + +// newLines are n lines of packages that are not installed yet, each of a valid name. +func newLines(n int) string { + var lines strings.Builder + for i := 1; i <= n; i++ { + fmt.Fprintf(&lines, "Inst libdep%d (1.%d Debian:11/stable [amd64])\n", i, i) + } + return lines.String() +} + +func TestOffendingAcceptsNewPackagesUpToTheLimit(t *testing.T) { + ok := "Inst docker-ce [5:28.0.4-1] (5:29.8.0-1 Docker CE:bullseye [amd64])\n" + if MaxNewPackages != 10 { + t.Fatalf("MaxNewPackages = %d, the spec says 10", MaxNewPackages) + } + if want := fmt.Sprintf("(more than %d new packages)", MaxNewPackages); TooManyNew != want { + t.Errorf("TooManyNew = %q, want %q", TooManyNew, want) + } + for _, c := range []struct { + name, simulation string + want []string + }{ + {"none", ok, []string{}}, + {"one dependency", ok + "Inst nftables (0.9.8 Debian [amd64])\n", []string{}}, + {"every package of the engine, new", ok + "Inst containerd.io (2.1.5-1 Docker CE [amd64])\nInst docker-ce-cli (5:29.8.0-1 Docker CE [amd64])\nInst docker-ce-rootless-extras (5:29.8.0-1 Docker CE [amd64])\nInst docker-buildx-plugin (0.30.1-1 Docker CE [amd64])\nInst docker-compose-plugin (5.0.0-1 Docker CE [amd64])\nInst docker-model-plugin (1.0.0-1 Docker CE [amd64])\n", []string{}}, + {"a dependency of another architecture", ok + "Inst libc6:i386 (2.31-13 Debian [i386])\n", []string{}}, + {"exactly the limit", ok + newLines(MaxNewPackages), []string{}}, + {"one more than the limit", ok + newLines(MaxNewPackages+1), []string{TooManyNew}}, + {"far more than the limit", ok + newLines(500), []string{TooManyNew}}, + {"more than the limit, and nothing else said of them", newLines(MaxNewPackages + 1), []string{TooManyNew}}, + // the limit counts the packages that are new, not the lines: an upgrade is not one + {"the limit of new packages and many upgrades", ok + newLines(MaxNewPackages) + "Inst containerd.io [1.7.27-1] (2.1.5-1 Docker CE [amd64])\nInst docker-ce-cli [5:28.0.4-1] (5:29.8.0-1 Docker CE [amd64])\n", []string{}}, + {"too many, and a removal", ok + newLines(MaxNewPackages+1) + "Remv docker-compose-v2 [2.1.0]\n", []string{TooManyNew, "docker-compose-v2"}}, + {"too many, one of them a hostile name", ok + newLines(MaxNewPackages) + "Inst evil$(reboot) (1 x [amd64])\n", []string{InvalidName, TooManyNew}}, + {"too many, one of them docker.io", ok + newLines(MaxNewPackages) + "Inst docker.io (26.1.5 Debian [amd64])\n", []string{TooManyNew, "docker.io"}}, + {"too many, and an upgrade of libc6", ok + newLines(MaxNewPackages+1) + "Inst libc6 [2.31] (2.32 Debian [amd64])\n", []string{TooManyNew, "libc6"}}, + } { + got := offendingOf(c.simulation) + if got == nil || !reflect.DeepEqual(got, c.want) { + t.Errorf("%s: Offending() = %#v, want %#v", c.name, got, c.want) + } + if !sort.StringsAreSorted(got) { + t.Errorf("%s: Offending() = %v is not sorted", c.name, got) + } + } +} + +func TestTheDistributionsDockerPackagesAreRefusedNewOrUpgraded(t *testing.T) { + // the four names of the addendum, exactly: Docker's own packages are not among them + refused := []string{"docker.io", "containerd", "docker-compose-v2", "docker-buildx"} + var denied []string + for name := range family { + if _, engine := engineSet[name]; !engine { + denied = append(denied, name) + } + } + sort.Strings(denied) + if want := []string{"containerd", "docker-buildx", "docker-compose-v2", "docker.io"}; !reflect.DeepEqual(denied, want) { + t.Fatalf("the family minus the engine = %v, want %v", denied, want) + } + for _, name := range refused { + if got := offendingOf("Inst " + name + " (1.0 Debian [amd64])\n"); !reflect.DeepEqual(got, []string{name}) { + t.Errorf("new %s: Offending() = %v", name, got) + } + if got := offendingOf("Inst " + name + " [0.9] (1.0 Debian [amd64])\n"); !reflect.DeepEqual(got, []string{name}) { + t.Errorf("upgraded %s: Offending() = %v", name, got) + } + } + for _, name := range EngineNames { + if got := offendingOf("Inst " + name + " (1.0 Docker CE [amd64])\n"); len(got) != 0 { + t.Errorf("new %s: Offending() = %v", name, got) + } + } +} + +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 docker.io (26.1.5 Debian [amd64])\n" + + "Inst nftables (0.9.8;reboot Debian [amd64])\n" + + "Inst libedit2 (3.1 Debian [amd64])\n" + + "Inst containerd.io:armhf [1.7.27-1] (2.1.4-1 Docker CE [armhf])\n") + // the upgrades only: the new packages are not pinned + 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) + } + // ... and the names are every package the guard may see, upgraded or new + if got, want := plan.Names(), []string{"containerd.io:armhf", "docker-ce", "docker-model-plugin", "libedit2"}; !reflect.DeepEqual(got, want) { + t.Errorf("Names() = %v, want %v", got, want) + } + if got, want := plan.Dependencies(), []string{"libedit2"}; !reflect.DeepEqual(got, want) { + t.Errorf("Dependencies() = %v, want %v", got, want) + } + if bad := plan.Offending(); len(bad) == 0 { + t.Error("the plan is not acceptable") + } +} + +func TestPinsNeverHoldANewPackageAndNamesAlwaysDo(t *testing.T) { + plan := ParsePlan(debian11WithDependencies + "Inst docker-model-plugin (1.0.0-1 Docker CE [amd64])\n") + pinned := map[string]bool{} + for _, pin := range plan.Pins() { + name, _, _ := strings.Cut(pin, "=") + pinned[name] = true + } + named := map[string]bool{} + for _, name := range plan.Names() { + named[name] = true + } + for _, pkg := range plan.Packages { + if pinned[pkg.Name] == pkg.New { + t.Errorf("%s: new = %v, pinned = %v: a package is pinned exactly when it is an upgrade", pkg.Name, pkg.New, pinned[pkg.Name]) + } + if !named[pkg.Name] { + t.Errorf("%s is not in Names()", pkg.Name) + } + } + for name := range pinned { + if !named[name] { + t.Errorf("%s is pinned and not in Names(): the guard would refuse the upgrade", name) + } + } + // the dependencies are the new packages that are not the engine's + if got, want := plan.Dependencies(), []string{"libedit2", "libjansson4", "libnftables1", "nftables"}; !reflect.DeepEqual(got, want) { + t.Errorf("Dependencies() = %v, want %v", got, want) + } +} + +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) + } + } +} diff --git a/pkg/dockerpkg/run.go b/pkg/dockerpkg/run.go new file mode 100644 index 00000000..cc6589d7 --- /dev/null +++ b/pkg/dockerpkg/run.go @@ -0,0 +1,308 @@ +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" + // FailStart is a unit that systemd could not start: the core writes it, in the last line of + // the log, since the unit never wrote anything. Nothing was changed. + FailStart = "start" +) + +// ReturnWaitSeconds is how long the unit waits, after the daemon is back, for the containers that +// start again by themselves. A container with a restart policy that is still not running after +// it is reported all the same, with its policy. +const ReturnWaitSeconds = 90 + +// Where a run is (Run.Phase). The page tells the owner what the apps are going through from it. +const ( + // PhasePreparing is the start of the unit, up to the snapshot of what is installed. + PhasePreparing = "preparing" + // PhaseDownloading is the check of the plan and the download of the packages: nothing is + // changed, and the apps run. + PhaseDownloading = "downloading" + // PhaseInstalling is the install, which restarts the daemon and stops the containers. + PhaseInstalling = "installing" + // PhaseWaitingDocker is the wait for the daemon to answer after the install. + PhaseWaitingDocker = "waiting_docker" + // PhaseWaitingContainers is the wait for the containers to come back. + PhaseWaitingContainers = "waiting_containers" +) + +// 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; DownloadedAt the + // end of the download and InstalledAt the end of the install, zero if it never got there; + // CompletedAt the terminal marker's. + QueuedAt, StartedAt, DownloadedAt, InstalledAt, CompletedAt time.Time + // PreviousSeen is whether the unit wrote its snapshot of the installed packages, which + // Previous may be empty of. + PreviousSeen bool + // 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, FailDaemon or FailStart, 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 +} + +// IsMarkerLine reports whether a line of the log starts like a marker. It is what a caller that +// reads a long log keeps of it for ParseRun, besides the first line: a line that does not start like +// a marker is never one. +func IsMarkerLine(line string) bool { return strings.HasPrefix(line, markerPrefix) } + +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 "DOWNLOADED": + if t, ok := oneTime(fields); ok && run.DownloadedAt.IsZero() { + run.DownloadedAt = t + } + case "INSTALLED": + if t, ok := oneTime(fields); ok && run.InstalledAt.IsZero() { + run.InstalledAt = t + } + case "PREVIOUS": + run.PreviousSeen = true + 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 +} + +// Phase is the step a run is at, from the markers it has written so far: PhasePreparing until the +// snapshot of what is installed, then PhaseDownloading until DOWNLOADED, PhaseInstalling until +// INSTALLED, PhaseWaitingDocker until DAEMON, and PhaseWaitingContainers until the terminal +// marker. It is "" for no run and for a run that has ended. The guard writes nothing when it +// passes, so it is part of the downloading: it is a few seconds, and the apps run through it. +func (r Run) Phase() string { + switch { + case r.Nonce == "" || r.Terminal != "": + return "" + case r.DaemonVersion != "": + return PhaseWaitingContainers + case !r.InstalledAt.IsZero(): + return PhaseWaitingDocker + case !r.DownloadedAt.IsZero(): + return PhaseInstalling + case r.PreviousSeen: + return PhaseDownloading + } + return PhasePreparing +} + +// 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, FailStart: + 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..ad9dc005 --- /dev/null +++ b/pkg/dockerpkg/run_test.go @@ -0,0 +1,499 @@ +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), DownloadedAt: t0.Add(60 * time.Second), InstalledAt: t0.Add(90 * time.Second), CompletedAt: t0.Add(100 * time.Second), + Terminal: TerminalSuccess, DaemonVersion: "29.8.0", PreviousSeen: true, + 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}, + {"failed start (the core's word, for a unit systemd did not start)", []string{marker("FAILED", ts(16)+" start")}, TerminalFailed, FailStart, 16}, + {"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) + } +} + +// The page tells the owner what the apps are going through from the phase: the markers the unit +// has written say where it is. +func TestRunPhase(t *testing.T) { + first := QueuedMarker(testNonce, t0)[:len(QueuedMarker(testNonce, t0))-1] + started := marker("STARTED", ts(1)) + previous := marker("PREVIOUS", "docker-ce=5:28.0.4-1") + downloaded := marker("DOWNLOADED", ts(60)) + installed := marker("INSTALLED", ts(90)) + daemon := marker("DAEMON", "29.8.0") + forged := func(kind, fields string) string { + return "CASAOS_DOCKER_UPDATE_" + kind + " " + otherNonce + " " + fields + } + for _, c := range []struct { + name string + lines []string + want string + }{ + {"queued, the unit has not written", []string{first}, PhasePreparing}, + {"started", []string{first, started}, PhasePreparing}, + {"the snapshot is taken", []string{first, started, previous}, PhaseDownloading}, + {"a snapshot of nothing is a snapshot", []string{first, started, marker("PREVIOUS", "")}, PhaseDownloading}, + {"the containers are listed, the guard is on", []string{first, started, previous, "container: web always", "Inst docker-ce [5:28.0.4-1] (5:29.8.0-1 Docker CE:bullseye [amd64])"}, PhaseDownloading}, + {"downloaded", []string{first, started, previous, downloaded}, PhaseInstalling}, + {"installed", []string{first, started, previous, downloaded, installed}, PhaseWaitingDocker}, + {"the daemon is back", []string{first, started, previous, downloaded, installed, daemon}, PhaseWaitingContainers}, + {"and some containers are not", []string{first, started, previous, downloaded, installed, daemon, marker("NOTRETURNED", "db always")}, PhaseWaitingContainers}, + {"success", []string{first, started, previous, downloaded, installed, daemon, marker("SUCCESS", ts(100))}, ""}, + {"restart pending", []string{first, started, previous, downloaded, installed, marker("DAEMON", "28.0.4"), marker("RESTART_PENDING", ts(100))}, ""}, + {"failed in the guard", []string{first, started, previous, marker("GUARD", "plan"), marker("FAILED", ts(5)+" guard")}, ""}, + {"failed in the install", []string{first, started, previous, downloaded, marker("FAILED", ts(70)+" install")}, ""}, + {"the unit could not be started", []string{first, marker("FAILED", ts(1)+" start")}, ""}, + // the unit brings Docker back after a failed install, before it says so: still the install + {"the install is failing", []string{first, started, previous, downloaded, "E: Sub-process /usr/bin/dpkg returned an error code (1)"}, PhaseInstalling}, + // a marker that is not the unit's moves nothing + {"another nonce", []string{first, started, previous, forged("DOWNLOADED", ts(60)), forged("INSTALLED", ts(90)), forged("DAEMON", "29.8.0")}, PhaseDownloading}, + {"in the middle of a line", []string{first, started, previous, "dpkg: " + downloaded}, PhaseDownloading}, + {"a daemon that is not a version", []string{first, started, previous, downloaded, installed, marker("DAEMON", "29.8.0;reboot")}, PhaseWaitingDocker}, + {"a time that is not one", []string{first, started, previous, marker("DOWNLOADED", "soon")}, PhaseDownloading}, + } { + if got := ParseRun(logOf(c.lines...)).Phase(); got != c.want { + t.Errorf("%s: Phase() = %q, want %q", c.name, got, c.want) + } + } + // no run is no phase + if got := (Run{}).Phase(); got != "" { + t.Errorf("the zero Run is in phase %q", got) + } + if got := ParseRun(logOf("Reading package lists...", started, previous)).Phase(); got != "" { + t.Errorf("a log that is not a run is in phase %q", got) + } +} + +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) + } +} + +func TestIsMarkerLineIsTheShapeParseRunLooksFor(t *testing.T) { + for line, want := range map[string]bool{ + "CASAOS_DOCKER_UPDATE_SUCCESS " + testNonce + " 2026-08-13T01:05:00Z": true, + "CASAOS_DOCKER_UPDATE_": true, + "casaos_docker_update_x": false, + " CASAOS_DOCKER_UPDATE_": false, + "zz CASAOS_DOCKER_UPDATE_SUCCESS x": false, + "container: web always": false, + "": false, + } { + if got := IsMarkerLine(line); got != want { + t.Errorf("IsMarkerLine(%q) = %v, want %v", line, got, want) + } + } + // ... and the lines the core and the unit write are all of that shape + if !IsMarkerLine(strings.TrimSuffix(QueuedMarker(testNonce, time.Now()), "\n")) || !IsMarkerLine(FailedMarker(testNonce, time.Now(), FailStart)) { + t.Error("a marker the core writes is not a marker line") + } +} diff --git a/route/docker_update_test.go b/route/docker_update_test.go new file mode 100644 index 00000000..26bbfa57 --- /dev/null +++ b/route/docker_update_test.go @@ -0,0 +1,221 @@ +package route + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ReCasaOS/CasaOS-Common/external" + "github.com/ReCasaOS/CasaOS-Common/utils/jwt" + "github.com/ReCasaOS/CasaOS/model" + "github.com/ReCasaOS/CasaOS/pkg/config" + "github.com/ReCasaOS/CasaOS/service" + "github.com/labstack/echo/v4" +) + +const dockerPlanID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + +func TestTheDockerUpdateRoutesAreSysRoutesBehindTheToken(t *testing.T) { + e, ok := InitV1Router().(*echo.Echo) + if !ok { + t.Fatal("InitV1Router() is not an *echo.Echo") + } + registered := map[string]bool{} + for _, r := range e.Routes() { + registered[r.Method+" "+r.Path] = true + } + for _, route := range [][2]string{ + {http.MethodGet, "/v1/sys/docker/containers"}, + {http.MethodPost, "/v1/sys/docker/update"}, + {http.MethodGet, "/v1/sys/docker/update/status"}, + } { + method, path := route[0], route[1] + if !registered[method+" "+path] { + t.Errorf("%s %s is not registered", method, path) + } + recorder := httptest.NewRecorder() + e.ServeHTTP(recorder, httptest.NewRequest(method, path, nil)) + if recorder.Code != http.StatusUnauthorized { + t.Errorf("%s %s without a token = %d, want 401", method, path, recorder.Code) + } + } +} + +// claimsOf is what the group's JWT check leaves in the context for a request with a token. +func claimsOf(issuer string) *jwt.Claims { + claims := &jwt.Claims{Username: "owner", ID: 1} + claims.Issuer = issuer + return claims +} + +func TestHeaderTokenOnly(t *testing.T) { + cases := []struct { + name string + header string + query string + claims any + hasClaims bool + want int + }{ + {"the access token in the header", "token", "", claimsOf("casaos"), true, http.StatusOK}, + {"a service of the box: no claims", "Internal secret", "", nil, false, http.StatusOK}, + {"the token in the query only", "", "token=abc", claimsOf("casaos"), true, http.StatusUnauthorized}, + {"no token at all", "", "", nil, false, http.StatusUnauthorized}, + {"no header, and no claims either", "", "token=abc", nil, false, http.StatusUnauthorized}, + {"the refresh token in the header", "token", "", claimsOf("refresh"), true, http.StatusUnauthorized}, + {"the refresh token, and the query besides", "token", "token=abc", claimsOf("refresh"), true, http.StatusUnauthorized}, + {"claims that are no claims", "token", "", "owner", true, http.StatusUnauthorized}, + {"claims that are a nil pointer", "token", "", (*jwt.Claims)(nil), true, http.StatusUnauthorized}, + {"an issuer that is only like it", "token", "", claimsOf("Refresh"), true, http.StatusOK}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + target := "/v1/sys/docker/update" + if c.query != "" { + target += "?" + c.query + } + request := httptest.NewRequest(http.MethodPost, target, nil) + if c.header != "" { + request.Header.Set(echo.HeaderAuthorization, c.header) + } + recorder := httptest.NewRecorder() + ctx := echo.New().NewContext(request, recorder) + if c.hasClaims { + ctx.Set("user", c.claims) + } + var reached bool + handler := headerTokenOnly(func(ctx echo.Context) error { + reached = true + return ctx.NoContent(http.StatusOK) + }) + if err := handler(ctx); err != nil { + t.Fatalf("error = %v", err) + } + if recorder.Code != c.want || reached != (c.want == http.StatusOK) { + t.Errorf("status = %d, reached = %v, want %d", recorder.Code, reached, c.want) + } + if c.want == http.StatusUnauthorized && !strings.Contains(recorder.Body.String(), `"success":401`) { + t.Errorf("body = %s", recorder.Body) + } + }) + } +} + +type fakeUpdateSystem struct { + service.SystemService + asked int +} + +func (f *fakeUpdateSystem) StartDockerUpdate(string) (service.SystemDockerUpdateStatus, error) { + f.asked++ + return service.SystemDockerUpdateStatus{Supported: true, State: "running"}, nil +} + +func (f *fakeUpdateSystem) DockerUpdateStatus() service.SystemDockerUpdateStatus { + return service.SystemDockerUpdateStatus{Supported: true, State: "idle"} +} + +type fakeUpdateRepository struct { + service.Repository + system service.SystemService +} + +func (f fakeUpdateRepository) System() service.SystemService { return f.system } + +// The real router, with real tokens: the key is served by a user-service of the test's. +func TestTheDockerUpdateStartsOnlyForTheAccessTokenInTheHeader(t *testing.T) { + runtimePath := t.TempDir() + private, public, err := jwt.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + jwks, err := jwt.GenerateJwksJSON(public) + if err != nil { + t.Fatal(err) + } + userService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(jwks) })) + t.Cleanup(userService.Close) + if err := os.WriteFile(filepath.Join(runtimePath, external.UserServiceAddressFilename), []byte(userService.URL), 0o600); err != nil { + t.Fatal(err) + } + if err := external.WriteInternalSecret(runtimePath); err != nil { + t.Fatal(err) + } + secret := external.InternalAuthorization(runtimePath) + if secret == "" { + t.Fatal("no internal secret") + } + previousConfig := config.CommonInfo + config.CommonInfo = &model.CommonModel{RuntimePath: runtimePath} + previousService := service.MyService + system := &fakeUpdateSystem{} + service.MyService = fakeUpdateRepository{system: system} + t.Cleanup(func() { config.CommonInfo, service.MyService = previousConfig, previousService }) + + access, err := jwt.GetAccessToken("owner", private, 1) + if err != nil { + t.Fatal(err) + } + refresh, err := jwt.GetRefreshToken("owner", private, 1) + if err != nil { + t.Fatal(err) + } + e := InitV1Router() + + post := func(target, authorization, remoteAddr string) int { + request := httptest.NewRequest(http.MethodPost, target, strings.NewReader(`{"plan_id":"`+dockerPlanID+`"}`)) + request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + if authorization != "" { + request.Header.Set(echo.HeaderAuthorization, authorization) + } + if remoteAddr != "" { + request.RemoteAddr = remoteAddr + } + recorder := httptest.NewRecorder() + e.ServeHTTP(recorder, request) + return recorder.Code + } + const update = "/v1/sys/docker/update" + cases := []struct { + name string + target, auth string + remote string + want, started int + }{ + {"the access token in the header", update, access, "", http.StatusOK, 1}, + {"a service of the box, with the secret", update, secret, "127.0.0.1:4000", http.StatusOK, 1}, + {"the secret from another host", update, secret, "192.168.1.20:4000", http.StatusUnauthorized, 0}, + {"the access token in the query", update + "?token=" + access, "", "", http.StatusUnauthorized, 0}, + {"the access token in the query, and a header that is not a token", update + "?token=" + access, "nonsense", "", http.StatusUnauthorized, 0}, + {"the refresh token in the header", update, refresh, "", http.StatusUnauthorized, 0}, + {"the refresh token in the query", update + "?token=" + refresh, "", "", http.StatusUnauthorized, 0}, + {"a header that is not a token", update, "nonsense", "", http.StatusUnauthorized, 0}, + {"nothing", update, "", "", http.StatusUnauthorized, 0}, + } + for _, c := range cases { + system.asked = 0 + if got := post(c.target, c.auth, c.remote); got != c.want || system.asked != c.started { + t.Errorf("%s: status = %d, updates started = %d; want %d and %d", c.name, got, system.asked, c.want, c.started) + } + } + + // the status asks no more of the token than the rest of the group + get := func(target, authorization string) int { + request := httptest.NewRequest(http.MethodGet, target, nil) + if authorization != "" { + request.Header.Set(echo.HeaderAuthorization, authorization) + } + recorder := httptest.NewRecorder() + e.ServeHTTP(recorder, request) + return recorder.Code + } + if got := get("/v1/sys/docker/update/status", access); got != http.StatusOK { + t.Errorf("status with the access token = %d", got) + } + if got := get("/v1/sys/docker/update/status", ""); got != http.StatusUnauthorized { + t.Errorf("status without a token = %d", got) + } +} diff --git a/route/header_token.go b/route/header_token.go new file mode 100644 index 00000000..82426d78 --- /dev/null +++ b/route/header_token.go @@ -0,0 +1,43 @@ +package route + +import ( + "net/http" + + "github.com/ReCasaOS/CasaOS-Common/utils/jwt" + "github.com/ReCasaOS/CasaOS/model" + "github.com/labstack/echo/v4" +) + +// refreshIssuer is the issuer of the long-lived token that only gets a new access token. +const refreshIssuer = "refresh" + +// headerTokenOnly is the middleware of a route that stops every container on the box. The group +// takes the token from the Authorization header, or from ?token= when there is no header, which +// a log, a history or a Referer can leak; and it takes the refresh token, which lives for a +// week, as an access token. This route wants neither: the access token, in the header. +// +// It runs after the group's JWT check, which puts the token's claims in the context. A request +// that has none is one of the box's own services (skipJWT), which the group let in on the secret +// of this boot: it carries the header "Authorization: Internal ". +func headerTokenOnly(next echo.HandlerFunc) echo.HandlerFunc { + return func(ctx echo.Context) error { + if ctx.Request().Header.Get(echo.HeaderAuthorization) == "" { + return unauthorized(ctx, "the token goes in the Authorization header") + } + switch claims := ctx.Get("user").(type) { + case nil: + // no claims: an internal request + case *jwt.Claims: + if claims == nil || claims.Issuer == refreshIssuer { + return unauthorized(ctx, "a refresh token cannot do this") + } + default: + return unauthorized(ctx, "the token is not understood") + } + return next(ctx) + } +} + +func unauthorized(ctx echo.Context, message string) error { + return ctx.JSON(http.StatusUnauthorized, model.Result{Success: http.StatusUnauthorized, Message: message}) +} diff --git a/route/v1.go b/route/v1.go index 7e989b2c..896832e5 100644 --- a/route/v1.go +++ b/route/v1.go @@ -112,6 +112,11 @@ 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) + // Updating Docker stops every container: the token goes in the header, and is not the + // refresh token. The status is read-only and takes what the group takes. + v1SysGroup.POST("/docker/update", v1.StartDockerUpdate, headerTokenOnly) + v1SysGroup.GET("/docker/update/status", v1.GetDockerUpdateStatus) v1SysGroup.GET("/hardware", v1.GetSystemHardwareInfo) // hardware/info diff --git a/route/v1/system.go b/route/v1/system.go index 15626bc5..061d56a0 100644 --- a/route/v1/system.go +++ b/route/v1/system.go @@ -157,6 +157,92 @@ 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(), + }) +} + +// dockerUpdateRequest is the body of the start of an update of Docker: the id of the plan that +// the owner confirmed (see the check's docker.update.plan_id), and nothing else. +type dockerUpdateRequest struct { + PlanID string `json:"plan_id"` +} + +// @Summary update the Docker engine, to the plan the check offered +// @Produce application/json +// @Accept application/json +// @Tags sys +// @Security ApiKeyAuth +// @Success 200 {object} model.Result +// @Failure 400 {object} model.Result "the body is not a plan_id" +// @Failure 409 {object} model.Result "refused: data.error_code says why" +// @Failure 501 {object} model.Result "this host cannot update packages" +// @Router /sys/docker/update [post] +func StartDockerUpdate(ctx echo.Context) error { + var request dockerUpdateRequest + decoder := json.NewDecoder(io.LimitReader(ctx.Request().Body, 4096)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil || decoder.More() { + return ctx.JSON(http.StatusBadRequest, model.Result{Success: http.StatusBadRequest, Message: `the body must be {"plan_id": "<64 hex digits>"} and nothing else`}) + } + + status, err := service.MyService.System().StartDockerUpdate(request.PlanID) + if err != nil { + var refusal *service.DockerUpdateRefusal + switch { + case errors.As(err, &refusal): + // the reason is in the data as well as in the message: the page clears its own error + // when it asks again + if status.Error == "" { + status.Error = refusal.Error() + } + if status.ErrorCode == "" { + status.ErrorCode = refusal.Code + } + return ctx.JSON(http.StatusConflict, model.Result{Success: http.StatusConflict, Message: status.Error, Data: status}) + case errors.Is(err, service.ErrSystemPackageUpdatesUnsupported): + if status.Error == "" { + status.Error = err.Error() + } + if status.ErrorCode == "" { + status.ErrorCode = service.DockerRefusalUnsupported + } + return ctx.JSON(http.StatusNotImplemented, model.Result{Success: http.StatusNotImplemented, Message: status.Error, Data: status}) + case errors.Is(err, service.ErrDockerUpdateBadPlanID): + return ctx.JSON(http.StatusBadRequest, model.Result{Success: http.StatusBadRequest, Message: err.Error()}) + } + return ctx.JSON(http.StatusInternalServerError, model.Result{Success: http.StatusInternalServerError, Message: err.Error(), Data: status}) + } + return ctx.JSON(http.StatusOK, model.Result{ + Success: common_err.SUCCESS, + Message: common_err.GetMsg(common_err.SUCCESS), + Data: status, + }) +} + +// @Summary how the update of Docker is going, or how it went +// @Produce application/json +// @Tags sys +// @Security ApiKeyAuth +// @Success 200 {object} model.Result +// @Router /sys/docker/update/status [get] +func GetDockerUpdateStatus(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().DockerUpdateStatus(), + }) +} + // @Summary get logs // @Produce application/json // @Accept application/json diff --git a/route/v1/system_docker_update_test.go b/route/v1/system_docker_update_test.go new file mode 100644 index 00000000..d0fffe0c --- /dev/null +++ b/route/v1/system_docker_update_test.go @@ -0,0 +1,200 @@ +package v1 + +import ( + "encoding/json" + "errors" + "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" +) + +const planID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + +type fakeDockerUpdateService struct { + service.SystemService + asked []string + status service.SystemDockerUpdateStatus + err error +} + +func (f *fakeDockerUpdateService) StartDockerUpdate(id string) (service.SystemDockerUpdateStatus, error) { + f.asked = append(f.asked, id) + return f.status, f.err +} + +func (f *fakeDockerUpdateService) DockerUpdateStatus() service.SystemDockerUpdateStatus { + return f.status +} + +func withDockerUpdateService(t *testing.T, fake *fakeDockerUpdateService) { + t.Helper() + original := service.MyService + t.Cleanup(func() { service.MyService = original }) + service.MyService = fakeSystemPackageRepository{system: fake} +} + +func postDockerUpdate(t *testing.T, body string) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest(http.MethodPost, "/v1/sys/docker/update", strings.NewReader(body)) + request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + recorder := httptest.NewRecorder() + if err := StartDockerUpdate(echo.New().NewContext(request, recorder)); err != nil { + t.Fatalf("StartDockerUpdate() error = %v", err) + } + return recorder +} + +func decodeResult(t *testing.T, recorder *httptest.ResponseRecorder) (model.Result, map[string]any) { + t.Helper() + var result model.Result + if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil { + t.Fatalf("decode %q: %v", recorder.Body.String(), err) + } + data, _ := result.Data.(map[string]any) + return result, data +} + +func TestStartDockerUpdatePassesThePlanIDAndAnswersWithTheStatus(t *testing.T) { + fake := &fakeDockerUpdateService{status: service.SystemDockerUpdateStatus{ + Supported: true, State: "running", Phase: "downloading", StartedAt: "2026-10-09T10:00:00Z", NotReturned: []dockerpkg.NotReturned{}, + }} + withDockerUpdateService(t, fake) + + recorder := postDockerUpdate(t, `{"plan_id":"`+planID+`"}`) + if recorder.Code != http.StatusOK || len(fake.asked) != 1 || fake.asked[0] != planID { + t.Fatalf("status = %d, asked = %v, body = %s", recorder.Code, fake.asked, recorder.Body) + } + want := `{"success":200,"message":"ok","data":{"supported":true,"state":"running","phase":"downloading","outcome":"","error":"","error_code":"","exit_code":null,"started_at":"2026-10-09T10:00:00Z","completed_at":"","from":"","to":"","not_returned":[],"rollback_command":"","log":""}}` + if got := strings.TrimSpace(recorder.Body.String()); got != want { + t.Errorf("body = %s\nwant %s", got, want) + } +} + +func TestStartDockerUpdateTakesOnlyThePlanID(t *testing.T) { + huge := `{"plan_id":"` + strings.Repeat("a", 10000) + `"}` + for name, body := range map[string]string{ + "nothing": "", + "not JSON": "plan_id=" + planID, + "an array": `[]`, + "a string": `"` + planID + `"`, + "a plan id that is a number": `{"plan_id":1}`, + "another field": `{"plan_id":"` + planID + `","force":true}`, + "only another field": `{"plan":"` + planID + `"}`, + "two objects": `{"plan_id":"` + planID + `"}{"plan_id":"` + planID + `"}`, + "junk after it": `{"plan_id":"` + planID + `"} x`, + "cut short": `{"plan_id":"` + planID, + "too much": huge, + } { + fake := &fakeDockerUpdateService{} + withDockerUpdateService(t, fake) + recorder := postDockerUpdate(t, body) + if recorder.Code != http.StatusBadRequest || len(fake.asked) != 0 { + t.Errorf("%s: status = %d, asked = %v", name, recorder.Code, fake.asked) + } + } + + // an id that is not an id is the service's to refuse, and it is a bad request too + for name, body := range map[string]string{"null": `null`, "empty object": `{}`, "short": `{"plan_id":"abc"}`} { + fake := &fakeDockerUpdateService{err: service.ErrDockerUpdateBadPlanID} + withDockerUpdateService(t, fake) + if recorder := postDockerUpdate(t, body); recorder.Code != http.StatusBadRequest || len(fake.asked) != 1 { + t.Errorf("%s: status = %d, asked = %v", name, recorder.Code, fake.asked) + } + } +} + +func TestStartDockerUpdateRefusalsAreConflictsThatCarryTheirReason(t *testing.T) { + for _, code := range []string{ + service.DockerRefusalOrigin, service.DockerRefusalHeld, service.DockerRefusalDpkg, service.DockerRefusalDaemon, service.DockerRefusalSwarm, + service.DockerRefusalPlan, service.DockerRefusalDisk, + service.DockerRefusalRunning, service.DockerRefusalMaintenance, service.DockerRefusalApps, + service.DockerRefusalChanged, service.DockerRefusalNothing, + } { + t.Run(code, func(t *testing.T) { + reason := "the reason for " + code + fake := &fakeDockerUpdateService{ + err: fmt.Errorf("wrapped: %w", &service.DockerUpdateRefusal{Code: code, Reason: reason, Detail: []string{"immich"}}), + // a status that says nothing: the handler puts the reason where the page reads it + status: service.SystemDockerUpdateStatus{Supported: true, State: "idle", NotReturned: []dockerpkg.NotReturned{}}, + } + withDockerUpdateService(t, fake) + + recorder := postDockerUpdate(t, `{"plan_id":"`+planID+`"}`) + if recorder.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409: %s", recorder.Code, recorder.Body) + } + result, data := decodeResult(t, recorder) + if result.Success != http.StatusConflict || data["error_code"] != code || data["error"] == "" || data["error"] != result.Message { + t.Errorf("result = %#v, data = %#v: the reason must be in the message and in data.error, the code in data.error_code", result, data) + } + }) + } + + // a status that has them already is left alone, detail and all + fake := &fakeDockerUpdateService{ + err: &service.DockerUpdateRefusal{Code: "apps", Reason: "An app is busy."}, + status: service.SystemDockerUpdateStatus{ + Supported: true, State: "idle", Error: "An app is busy.", ErrorCode: "apps", RefusalDetail: []string{"immich"}, NotReturned: []dockerpkg.NotReturned{}, + }, + } + withDockerUpdateService(t, fake) + _, data := decodeResult(t, postDockerUpdate(t, `{"plan_id":"`+planID+`"}`)) + if detail, _ := data["refusal_detail"].([]any); len(detail) != 1 || detail[0] != "immich" { + t.Errorf("data = %#v", data) + } +} + +func TestStartDockerUpdateOnAHostThatCannotIsNotImplemented(t *testing.T) { + fake := &fakeDockerUpdateService{ + err: fmt.Errorf("%w", service.ErrSystemPackageUpdatesUnsupported), + status: service.SystemDockerUpdateStatus{Error: "CasaOS must run as root to update system packages.", State: "idle", NotReturned: []dockerpkg.NotReturned{}}, + } + withDockerUpdateService(t, fake) + recorder := postDockerUpdate(t, `{"plan_id":"`+planID+`"}`) + result, data := decodeResult(t, recorder) + if recorder.Code != http.StatusNotImplemented || data["error_code"] != "unsupported" || data["error"] == "" || data["error"] != result.Message { + t.Errorf("status = %d, result = %#v, data = %#v", recorder.Code, result, data) + } +} + +func TestStartDockerUpdateFailuresOnTheBoxAreServerErrors(t *testing.T) { + fake := &fakeDockerUpdateService{ + err: errors.New("start Docker update: Access denied"), + status: service.SystemDockerUpdateStatus{Supported: true, State: "failed", Outcome: "failed", ErrorCode: "start", NotReturned: []dockerpkg.NotReturned{}}, + } + withDockerUpdateService(t, fake) + recorder := postDockerUpdate(t, `{"plan_id":"`+planID+`"}`) + result, data := decodeResult(t, recorder) + // systemd that would not start the unit is its own code: not a plan that changed + if recorder.Code != http.StatusInternalServerError || !strings.Contains(result.Message, "Access denied") || data["state"] != "failed" || data["error_code"] != "start" { + t.Errorf("status = %d, result = %#v", recorder.Code, result) + } +} + +func TestGetDockerUpdateStatusAnswersWithTheStatus(t *testing.T) { + fake := &fakeDockerUpdateService{status: service.SystemDockerUpdateStatus{ + Supported: true, State: "failed", Outcome: "failed", Error: "Docker did not come back after the update.", ErrorCode: "daemon", + StartedAt: "2026-10-09T10:00:00Z", CompletedAt: "2026-10-09T10:07:00Z", From: "28.0.4", To: "", + NotReturned: []dockerpkg.NotReturned{{Name: "job", RestartPolicy: "no"}}, + RollbackCommand: "sudo apt-get install --allow-downgrades docker-ce=5:28.0.4-1", + Log: "CASAOS_DOCKER_UPDATE_QUEUED x\n", + }} + withDockerUpdateService(t, fake) + + request := httptest.NewRequest(http.MethodGet, "/v1/sys/docker/update/status", nil) + recorder := httptest.NewRecorder() + if err := GetDockerUpdateStatus(echo.New().NewContext(request, recorder)); err != nil { + t.Fatal(err) + } + want := `{"success":200,"message":"ok","data":{"supported":true,"state":"failed","phase":"","outcome":"failed","error":"Docker did not come back after the update.","error_code":"daemon","exit_code":null,"started_at":"2026-10-09T10:00:00Z","completed_at":"2026-10-09T10:07:00Z","from":"28.0.4","to":"","not_returned":[{"name":"job","restart_policy":"no"}],"rollback_command":"sudo apt-get install --allow-downgrades docker-ce=5:28.0.4-1","log":"CASAOS_DOCKER_UPDATE_QUEUED x\n"}}` + if got := strings.TrimSpace(recorder.Body.String()); recorder.Code != http.StatusOK || got != want { + t.Errorf("status = %d\nbody = %s\nwant %s", recorder.Code, got, want) + } +} 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/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.go b/service/system.go index 474bc4ff..cff6fc16 100644 --- a/service/system.go +++ b/service/system.go @@ -44,6 +44,24 @@ type SystemService interface { GetSystemPackageUpdates() (SystemPackageUpdates, error) StartSystemPackageUpdate() (SystemPackageUpdateStatus, error) 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) + // DockerUpdateRun is DockerUpdateStatus and the nonce of the run it is about ("" with none). + DockerUpdateRun() (SystemDockerUpdateStatus, string) + // DockerUpdateMuted says whether the alerts about the apps' containers are to be kept quiet: + // the update of Docker runs, or ended less than ten minutes ago. It takes no lock and is cheap. + DockerUpdateMuted() bool + // OnDockerUpdateQueued sets what is called when a run of the Docker update has its log: it + // must return at once. + OnDockerUpdateQueued(queued func()) 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..d886bd05 --- /dev/null +++ b/service/system_docker_containers_test.go @@ -0,0 +1,184 @@ +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}" + // a container with Docker's socket mounted, as the format prints its mounts + inspectedTraefik = `{"name":"/traefik","image":"traefik:v3","restart":"unless-stopped","network":"bridge","mounts":[{"Type":"bind","Source":"/srv/traefik","Destination":"/etc/traefik","Mode":"","RW":true,"Propagation":"rprivate"},{"Type":"bind","Source":"/var/run/docker.sock","Destination":"/var/run/docker.sock","Mode":"ro","RW":false,"Propagation":"rprivate"}],"ports":{}}` +) + +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, inspectedTraefik, + "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", "traefik", "web"}) { + t.Fatalf("running = %v, containers = %s", got.Running, marshalled(t, got.Containers)) + } + // the containers that talk to Docker are said to, and only they: the key is not there for the others + if all := marshalled(t, got.Containers); strings.Count(all, "docker_socket") != 1 || !strings.Contains(all, `"name":"traefik","image":"traefik:v3","restart_policy":"unless-stopped","host_network":false,"docker_socket":true`) { + t.Errorf("containers = %s", all) + } + 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) + } +} diff --git a/service/system_package.go b/service/system_package.go index ab3e2118..2aa1c632 100644 --- a/service/system_package.go +++ b/service/system_package.go @@ -47,6 +47,10 @@ type SystemPackageUpdate struct { Name string `json:"name"` CurrentVersion string `json:"current_version"` CandidateVersion string `json:"candidate_version"` + // New is only ever set in the plan of the Docker update (docker.update.packages): a package + // that is not installed yet, a dependency of the new engine, whose CurrentVersion is "". It + // is absent from the JSON of an upgrade, and so from every list of updates the check makes. + New bool `json:"new,omitempty"` } type SystemPackageUpdates struct { @@ -83,6 +87,9 @@ type systemPackageSupport struct { type systemPackageUpdater struct { mu sync.Mutex + // checkMu makes the checks take turns, and nothing else wait for one: mu is never held across + // a check's apt-get update. + checkMu sync.Mutex command func(context.Context, string, ...string) ([]byte, error) start func(string, string, ...string) ([]byte, error) @@ -96,13 +103,33 @@ 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) + // appOperations says which apps have an operation in progress; nil means it cannot be asked. + appOperations AppOperationsFunc + // onDockerQueued is told, with mu held, that the log of a new Docker update was written; nil + // means no one is waiting for the end of one. + onDockerQueued func() + // readDir lists a directory: dpkg's journal is looked at with it. nil means it cannot be read, + // and there is nothing in it. + readDir func(string) ([]os.DirEntry, error) + // mute is the last answer of dockerMuted. + mute dockerMute +} + +// 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() }, @@ -113,8 +140,10 @@ func newSystemPackageUpdater() *systemPackageUpdater { writeFile: os.WriteFile, mkdirAll: os.MkdirAll, stat: os.Stat, + readDir: os.ReadDir, now: time.Now, dpkgLocked: dpkgLockHeld, + freeBytes: freeDiskBytes, } } @@ -187,9 +216,16 @@ func (u *systemPackageUpdater) check() (SystemPackageUpdates, error) { return result, nil } + // The check takes turns with other checks, and the lock only to ask whether an update runs: + // apt-get update, the simulations and the questions to Docker take minutes on a small box, + // and an update that starts, a status that is asked for or the other update must not wait + // for them. The page gives a request a minute. + u.checkMu.Lock() + defer u.checkMu.Unlock() u.mu.Lock() - defer u.mu.Unlock() - if u.isRunning() { + running := u.isRunning() + u.mu.Unlock() + if running { return result, ErrSystemPackageUpdateRunning } @@ -215,6 +251,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_alerts.go b/service/system_package_docker_alerts.go new file mode 100644 index 00000000..1c107eda --- /dev/null +++ b/service/system_package_docker_alerts.go @@ -0,0 +1,80 @@ +package service + +import ( + "sync" + "time" +) + +// What the alerts need to know of the update of Docker: whether to keep quiet about the apps' +// containers, and how a run ended. Both are read from the log of the run and from systemd, as +// the status is; nothing is kept in memory but the last answer, for a few seconds. + +const ( + // dockerMuteAfter is how long the alerts stay quiet after a run ends: the apps' watch is slow + // to say that a container is back, and some are late. + dockerMuteAfter = 10 * time.Minute + // dockerMuteAhead is how far ahead of the clock the end of a run may be and still count: a + // clock a little behind the one that dated the last line. A date that is further on is not an + // end that happened, and would keep the alerts quiet until it comes. + dockerMuteAhead = time.Minute + // dockerMuteCache is how long the answer to "quiet?" is reused. It is asked for each event of + // a container, and a Docker restart sends one per container; the answer costs a look at + // systemd and the log. + dockerMuteCache = 5 * time.Second +) + +// dockerMute is the last answer of dockerMuted. It has a lock of its own: the updater's is held +// for minutes by a long update of packages, and an event must not wait for that. +type dockerMute struct { + mu sync.Mutex + at time.Time + muted bool +} + +// DockerUpdateRun is DockerUpdateStatus and the nonce of the run it is about, "" when the log +// has none: what tells one run from the next, for a notification that is sent once. +func (s *systemService) DockerUpdateRun() (SystemDockerUpdateStatus, string) { + return s.systemPackageUpdater().dockerRun() +} + +// DockerUpdateMuted says whether the alerts about the apps' containers are to be kept quiet: the +// update of Docker runs, or ended less than ten minutes ago. +func (s *systemService) DockerUpdateMuted() bool { + return s.systemPackageUpdater().dockerMuted() +} + +// OnDockerUpdateQueued sets what is called when the log of a new run was written, whether or not +// systemd takes the unit after it. It is called with the updater's lock held and must return at +// once, without calling back into the update. +func (s *systemService) OnDockerUpdateQueued(queued func()) { + updater := s.systemPackageUpdater() + updater.mu.Lock() + defer updater.mu.Unlock() + updater.onDockerQueued = queued +} + +// dockerMuted is true while the unit runs (or its last line may be on its way) and for ten +// minutes after the run's own last line says it ended. A run that stopped without one has no +// end to count from: it is not muted. It takes no updater lock, and looks at systemd and the log +// once in a few seconds at most, whatever the number of questions. +func (u *systemPackageUpdater) dockerMuted() bool { + u.mute.mu.Lock() + defer u.mute.mu.Unlock() + now := u.now() + // a clock that went back makes the age negative: the answer is not trusted then + if age := now.Sub(u.mute.at); !u.mute.at.IsZero() && age >= 0 && age < dockerMuteCache { + return u.mute.muted + } + u.mute.at, u.mute.muted = now, dockerRunMutes(u.dockerStatus(), now) + return u.mute.muted +} + +func dockerRunMutes(status SystemDockerUpdateStatus, now time.Time) bool { + switch status.State { + case systemPackageUpdateStateRunning, systemPackageUpdateStateFinalizing: + return true + } + completed, err := time.Parse(time.RFC3339, status.CompletedAt) + age := now.Sub(completed) + return err == nil && age < dockerMuteAfter && age >= -dockerMuteAhead +} diff --git a/service/system_package_docker_alerts_test.go b/service/system_package_docker_alerts_test.go new file mode 100644 index 00000000..933d938a --- /dev/null +++ b/service/system_package_docker_alerts_test.go @@ -0,0 +1,236 @@ +package service + +import ( + "errors" + "os" + "strings" + "testing" + "time" + + "github.com/ReCasaOS/CasaOS/pkg/dockerpkg" +) + +// systemctlAsked is how many times the updater asked systemd about anything. +func systemctlAsked(box *aptBox) int { + n := 0 + for _, call := range box.calls { + if strings.HasPrefix(call, "systemctl ") { + n++ + } + } + return n +} + +// endedAgo writes the log of a run that ended that long before the updater's clock, as the +// terminal line says. +func (s *statusBox) endedAgo(t *testing.T, age time.Duration, terminal string, fields ...string) { + t.Helper() + at := s.updater.now().Add(-age).UTC().Format(time.RFC3339) + s.write(t, ownersRun(mark(terminal, append([]string{at}, fields...)...))...) +} + +func logText(t *testing.T, u *systemPackageUpdater) string { + t.Helper() + data, err := os.ReadFile(u.dockerLogPath()) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func TestDockerAlertsAreQuietWhileTheUnitRunsAndForTenMinutesAfter(t *testing.T) { + for name, tc := range map[string]struct { + set func(*testing.T, *statusBox) + want bool + }{ + "no run at all": {func(*testing.T, *statusBox) {}, false}, + "the unit runs": {func(t *testing.T, s *statusBox) { + s.box.activeUnits[systemDockerUpdateUnit] = true + s.write(t, ownersRun()...) + }, true}, + "the unit runs and the log is not ours": {func(t *testing.T, s *statusBox) { s.box.activeUnits[systemDockerUpdateUnit] = true }, true}, + "the unit is gone and its last line may be on its way": {func(t *testing.T, s *statusBox) { + s.write(t, ownersRun()...) + s.logAge(5 * time.Second) + }, true}, + "the unit stopped without a result": {func(t *testing.T, s *statusBox) { s.write(t, ownersRun()...) }, false}, + "succeeded a moment ago": {func(t *testing.T, s *statusBox) { s.endedAgo(t, time.Second, "SUCCESS") }, true}, + "succeeded 9 minutes 59 ago": {func(t *testing.T, s *statusBox) { s.endedAgo(t, 10*time.Minute-time.Second, "SUCCESS") }, true}, + "succeeded 10 minutes ago": {func(t *testing.T, s *statusBox) { s.endedAgo(t, 10*time.Minute, "SUCCESS") }, false}, + "succeeded an hour ago": {func(t *testing.T, s *statusBox) { s.endedAgo(t, time.Hour, "SUCCESS") }, false}, + "restart pending 5 minutes ago": {func(t *testing.T, s *statusBox) { s.endedAgo(t, 5*time.Minute, "RESTART_PENDING") }, true}, + "restart pending 11 minutes ago": {func(t *testing.T, s *statusBox) { s.endedAgo(t, 11*time.Minute, "RESTART_PENDING") }, false}, + "failed 5 minutes ago": {func(t *testing.T, s *statusBox) { s.endedAgo(t, 5*time.Minute, "FAILED", "daemon") }, true}, + "failed 11 minutes ago": {func(t *testing.T, s *statusBox) { s.endedAgo(t, 11*time.Minute, "FAILED", "daemon") }, false}, + "a last line a minute ahead of time": {func(t *testing.T, s *statusBox) { s.endedAgo(t, -time.Minute, "SUCCESS") }, true}, + // whoever can write the log can date its last line: a date that has not come must not keep + // the apps' alerts quiet until it does + "a last line two minutes ahead of time": {func(t *testing.T, s *statusBox) { s.endedAgo(t, -2*time.Minute, "SUCCESS") }, false}, + "a failed line a day ahead of time": {func(t *testing.T, s *statusBox) { s.endedAgo(t, -24*time.Hour, "FAILED", "daemon") }, false}, + "a last line fifty years ahead": {func(t *testing.T, s *statusBox) { s.endedAgo(t, -50*365*24*time.Hour, "SUCCESS") }, false}, + } { + t.Run(name, func(t *testing.T) { + s := newStatusBox(t) + tc.set(t, s) + if got := s.updater.dockerMuted(); got != tc.want { + t.Errorf("dockerMuted() = %v, want %v (the status says %#v)", got, tc.want, s.updater.dockerStatus()) + } + }) + } +} + +// The question is asked for each container event: it costs a look at systemd and the log at most +// once in a few seconds, and never waits for the updater's lock, which a slow update of packages +// can hold for minutes. +func TestDockerMutedIsCheapAndFree(t *testing.T) { + s := newStatusBox(t) + start := s.updater.now() + clock := start + s.updater.now = func() time.Time { return clock } + s.endedAgo(t, time.Minute, "SUCCESS") + + for i := 0; i < 20; i++ { + if !s.updater.dockerMuted() { + t.Fatal("not muted a minute after the end of a run") + } + } + if n := systemctlAsked(s.box); n != 1 { + t.Errorf("systemd was asked %d times for 20 questions", n) + } + + // the answer is kept for a few seconds, whatever the box says meanwhile + clock = start.Add(dockerMuteCache - time.Second) + s.endedAgo(t, time.Hour, "SUCCESS") + if !s.updater.dockerMuted() || systemctlAsked(s.box) != 1 { + t.Errorf("the answer of a moment ago was not kept (systemd asked %d times)", systemctlAsked(s.box)) + } + // and the box is looked at again after them + clock = start.Add(dockerMuteCache) + if s.updater.dockerMuted() || systemctlAsked(s.box) != 2 { + t.Errorf("the box was not looked at again (systemd asked %d times)", systemctlAsked(s.box)) + } + + // a clock that went back does not keep an answer for hours + clock = start.Add(-2 * time.Hour) + s.write(t, ownersRun()...) + s.box.activeUnits[systemDockerUpdateUnit] = true + if !s.updater.dockerMuted() { + t.Error("the answer from the future was kept") + } + + // and a lock held by the update of packages is not waited for + s.updater.mu.Lock() + defer s.updater.mu.Unlock() + clock = clock.Add(time.Hour) + s.box.activeUnits[systemDockerUpdateUnit] = false + answered := make(chan bool, 1) + go func() { answered <- s.updater.dockerMuted() }() + select { + case <-answered: + case <-time.After(10 * time.Second): + t.Fatal("dockerMuted waited for the updater's lock") + } +} + +func TestTheServiceAnswersForTheAlerts(t *testing.T) { + s := newStatusBox(t) + system := &systemService{packageUpdates: s.updater} + s.endedAgo(t, time.Minute, "FAILED", "daemon") + + if !system.DockerUpdateMuted() { + t.Error("DockerUpdateMuted() = false a minute after a run") + } + status, nonce := system.DockerUpdateRun() + if nonce != scriptNonce || status.State != "failed" || status.Outcome != "failed" || status.ErrorCode != "daemon" { + t.Errorf("DockerUpdateRun() = %#v, %q", status, nonce) + } + if again := system.DockerUpdateStatus(); again.State != status.State || again.CompletedAt != status.CompletedAt { + t.Errorf("the run is not the status: %#v and %#v", status, again) + } + + // no run in the log: no nonce, whatever the unit does + s = newStatusBox(t) + s.box.activeUnits[systemDockerUpdateUnit] = true + system = &systemService{packageUpdates: s.updater} + if status, nonce := system.DockerUpdateRun(); nonce != "" || status.State != "running" { + t.Errorf("DockerUpdateRun() = %#v, %q", status, nonce) + } +} + +// What the notification needs of a run: its nonce, how it ended, the version that runs, and the +// containers that did not come back. +func TestTheRunCarriesWhatTheNotificationSays(t *testing.T) { + s := newStatusBox(t) + system := &systemService{packageUpdates: s.updater} + s.write(t, ownersRun( + mark("DAEMON", "29.8.0"), + mark("NOTRETURNED", "job", "no"), + mark("SUCCESS", "2026-08-13T01:05:00Z"), + )...) + + status, nonce := system.DockerUpdateRun() + + if nonce != scriptNonce || status.Outcome != "success" || status.To != "29.8.0" || len(status.NotReturned) != 1 || status.NotReturned[0].Name != "job" { + t.Fatalf("DockerUpdateRun() = %#v, %q", status, nonce) + } + if !dockerpkg.ValidNonce(nonce) { + t.Errorf("nonce = %q", nonce) + } +} + +// The alerts are told that a run was queued once its first line is in the log, whatever becomes of +// the unit; a start that did not happen tells them nothing. +func TestTheAlertsAreToldOfARunWhenItsLogIsWritten(t *testing.T) { + s := newStartBox(t, nil) + system := &systemService{packageUpdates: s.updater} + var told, logged int + system.OnDockerUpdateQueued(func() { + told++ + if dockerpkg.ParseRun(logText(t, s.updater)).Nonce != "" { + logged++ + } + }) + if _, err := system.StartDockerUpdate(s.planID); err != nil { + t.Fatal(err) + } + if told != 1 || logged != 1 { + t.Fatalf("told %d times, %d with the log written", told, logged) + } + + // systemd-run refuses: the log is a whole run that ended, and the alerts hear of it too + f := newStartBox(t, nil) + f.updater.start = func(string, string, ...string) ([]byte, error) { + return []byte("Failed to start"), errors.New("exit status 1") + } + told = 0 + system = &systemService{packageUpdates: f.updater} + system.OnDockerUpdateQueued(func() { told++ }) + if _, err := system.StartDockerUpdate(f.planID); err == nil || told != 1 { + t.Fatalf("error = %v, told %d times", err, told) + } + if run := dockerpkg.ParseRun(logText(t, f.updater)); run.Terminal != dockerpkg.TerminalFailed { + t.Errorf("run = %#v", run) + } + + // refused: nothing is told, nothing was written + r := newStartBox(t, nil) + told = 0 + system = &systemService{packageUpdates: r.updater} + system.OnDockerUpdateQueued(func() { told++ }) + for _, planID := range []string{strings.Repeat("0", 64), "nonsense"} { + if _, err := system.StartDockerUpdate(planID); err == nil { + t.Fatalf("plan %q was started", planID) + } + } + if told != 0 { + t.Errorf("told %d times of a start that did not happen", told) + } + r.noLog(t) + + // and with no one to tell the update starts all the same + n := newStartBox(t, nil) + system = &systemService{packageUpdates: n.updater} + if _, err := system.StartDockerUpdate(n.planID); err != nil { + t.Errorf("error = %v", err) + } +} diff --git a/service/system_package_docker_run.go b/service/system_package_docker_run.go new file mode 100644 index 00000000..4c64fdba --- /dev/null +++ b/service/system_package_docker_run.go @@ -0,0 +1,531 @@ +package service + +import ( + "bufio" + "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 do more than upgrade Docker's own packages and install what they need.", + 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.", + DockerRefusalDpkg: "A previous package operation was left unfinished.", +} + +// 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 + // systemDockerSilenceLimit is how long a log may be quiet while systemd does not answer + // before the run is called lost: more than the wait for the daemon and for the containers + // together, and as much as a download on a slow line. + systemDockerSilenceLimit = 30 * time.Minute + // systemDockerMarkerLineBytes is the longest line of the log that can be a marker, and how much + // of any other is looked at: a marker is a kind, a nonce and a few fields. + systemDockerMarkerLineBytes = 4096 +) + +// systemDockerMarkerScanBytes is how much of the log the markers are looked for in: the whole of it +// up to 16 MiB, and the first and the last half of that beyond, since the unit writes its markers +// at the start (before the install) and at the end, and what apt prints is in between. A var, for +// the tests. +var systemDockerMarkerScanBytes int64 = 16 << 20 + +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.", + dockerpkg.FailStart: "The update could not be started. Nothing was changed.", +} + +// 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"` + // Phase is the step a run that is going on is at: preparing, downloading (the apps run), + // installing (Docker restarts), waiting_docker (for the daemon), waiting_containers (for the + // apps). It is "" for a run that has ended and when nothing runs: see dockerpkg.Run.Phase. + Phase string `json:"phase"` + // 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, start 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 { + if current := u.dockerStatus(); current.State == systemPackageUpdateStateRunning || current.State == systemPackageUpdateStateFinalizing || u.unitActive(systemDockerUpdateUnit) { + status = current + } + } + 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. Itself is the unit, and the log of a run that has not ended: systemd +// queues the start of a unit, which reads inactive until it takes it up, and a start in that +// moment would write the log of a second run over the first's. +func (u *systemPackageUpdater) dockerBlocked(support systemPackageSupport) (SystemDockerUpdateStatus, error) { + if state := u.dockerStatus().State; state == systemPackageUpdateStateRunning || state == systemPackageUpdateStateFinalizing || 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) + } + // From here the log is a run's, whatever systemd says next: whoever waits for the end is told. + if u.onDockerQueued != nil { + u.onDockerQueued() + } + 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.FailStart) + _ = 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, + Phase: dockerpkg.PhasePreparing, + 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 { + active, _ := u.unitState(unit) + return active +} + +// unitState is unitActive and whether systemd answered at all: a failed question (PID 1 busy, +// the ten seconds that are all it is given) says nothing of the unit. +func (u *systemPackageUpdater) unitState(unit string) (active, answered 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, false + } + switch strings.TrimSpace(string(output)) { + case "active", "activating", "deactivating": + return true, true + } + return false, true +} + +// logWrittenWithin says whether the log was written within that long ago (or, for a clock that +// was put right, a little in the future), by the updater's clock. +func (u *systemPackageUpdater) logWrittenWithin(logPath string, window time.Duration) bool { + info, err := u.stat(logPath) + if err != nil { + return false + } + age := u.now().UTC().Sub(info.ModTime().UTC()) + return age >= -systemPackageResultGracePeriod && age <= window +} + +// 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 { + status, _ := u.dockerRun() + return status +} + +// dockerRun is dockerStatus and the nonce of the run it describes, "" when the log holds none. +func (u *systemPackageUpdater) dockerRun() (SystemDockerUpdateStatus, string) { + 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 + } + // The log that is shown is its end. The markers are read from all of it: the ones the rollback + // and the phase rest on are written before the install, and the install's output comes after. + markers, _ := readDockerMarkers(logPath) + run := dockerpkg.ParseRun(markers) + active, answered := u.unitState(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, status.Phase = systemPackageUpdateStateRunning, run.Phase() + case !answered && u.logWrittenWithin(logPath, systemDockerSilenceLimit): + // Systemd did not say that the unit is gone, and the unit is quiet for minutes at a time + // (the wait for the daemon, the wait for the containers): not a verdict, and the page + // and the push stop at the first end they read. A log that has been quiet for this + // long is another matter. + status.State, status.Phase = systemPackageUpdateStateRunning, run.Phase() + case u.isWithinResultGrace(logPath): + // systemd can say the unit is gone just before the last line reached the log + status.State, status.Phase = systemPackageUpdateStateFinalizing, run.Phase() + 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, run.Nonce +} + +func formatDockerTime(times ...time.Time) string { + for _, t := range times { + if !t.IsZero() { + return t.UTC().Format(time.RFC3339) + } + } + return "" +} + +// readDockerMarkers is the log reduced to what dockerpkg.ParseRun reads of it: the first line, which +// holds the run's nonce, whatever it is, and then the lines that start like a marker. The log is read +// as a stream, systemDockerMarkerScanBytes of it at most, and no more of a line than +// systemDockerMarkerLineBytes is held at a time. +func readDockerMarkers(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return "", err + } + var markers strings.Builder + size, limit := info.Size(), systemDockerMarkerScanBytes + if size <= limit { + // what was there when it was looked at, not what an update that goes on adds meanwhile + err = collectDockerMarkers(&markers, io.LimitReader(file, size), true, false, false) + return markers.String(), err + } + // The first half of the bound, whose last line is cut unless it ends the window, and the last half, + // which is read from one byte before: that byte is a new line if the window starts on a line, and + // if it is not the line it ends is cut. + half := limit / 2 + if err := collectDockerMarkers(&markers, io.LimitReader(file, half), true, false, true); err != nil { + return "", err + } + if _, err := file.Seek(size-half-1, io.SeekStart); err != nil { + return "", err + } + err = collectDockerMarkers(&markers, file, false, true, false) + return markers.String(), err +} + +// collectDockerMarkers adds to out the lines of r that ParseRun may read, each ended by a new line: +// the first line of the log (first), and the lines that are no longer than systemDockerMarkerLineBytes +// and start with the markers' prefix. startsInLine says r begins inside a line, whose end is skipped; +// endsInLine that r ends where its reader was stopped, not where the log ends, so that a last line +// that has no new line is cut and left out. +func collectDockerMarkers(out *strings.Builder, r io.Reader, first, startsInLine, endsInLine bool) error { + reader := bufio.NewReaderSize(r, systemDockerMarkerLineBytes) + atStart := !startsInLine + for { + fragment, err := reader.ReadSlice('\n') + if err != nil && err != bufio.ErrBufferFull && err != io.EOF { + return err + } + ended := err == nil + if atStart { + // a fragment that fills the buffer is the start of a line that is too long to be a marker + whole := ended || (err == io.EOF && !endsInLine) + line := strings.TrimRight(string(fragment), "\r\n") + if (first && (len(fragment) > 0 || err != io.EOF)) || (whole && dockerpkg.IsMarkerLine(line)) { + out.WriteString(line) + out.WriteByte('\n') + } + first = false + } + // after a fragment that did not end the line, the next ones are the rest of the same line + atStart = ended + if err == io.EOF { + return 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..8423839a --- /dev/null +++ b/service/system_package_docker_run_test.go @@ -0,0 +1,898 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "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 unit has not written anything yet: it is getting ready, as the status says of it + if status.Phase != "preparing" { + t.Errorf("phase = %q", status.Phase) + } + + // 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", dockerUpdateScriptArg(), + } + 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) + } + } +} + +// newStartBoxWithDependencies is the owner's box as it really is, where Docker 29 brings nftables +// and its libraries, with the plan the page confirmed. +func newStartBoxWithDependencies(t *testing.T) *startedBox { + t.Helper() + s := newStartBox(t, func(b *aptBox) { + b.upgradeSimulation = simLibc + b.enginePlans["containerd.io"] = ownerNewDependencies + ownerContaind + }) + update := checkBox(t, ownerBoxWithDependencies(t), nil).Docker.Update + if update == nil || update.PlanID == "" || update.PlanID == s.planID { + t.Fatalf("update = %#v: want a plan of its own", update) + } + s.planID = update.PlanID + return s +} + +func TestStartDockerUpdateWithTheNewDependencies(t *testing.T) { + s := newStartBoxWithDependencies(t) + if _, err := s.updater.startDockerUpdate(s.planID); err != nil { + t.Fatalf("startDockerUpdate() error = %v", err) + } + if s.started.count != 1 { + t.Fatalf("started %d units", s.started.count) + } + // the pins are the upgrades: apt is never told to install a package that is not on the box + if got, want := s.env("CASAOS_DU_PINS"), "containerd.io=2.1.4-1 docker-ce-cli="+debian29+" docker-ce="+debian29; got != want { + t.Errorf("CASAOS_DU_PINS = %q, want %q", got, want) + } + // the names are every package that may be installed, the new ones too, sorted + if got, want := s.env("CASAOS_DU_NAMES"), "containerd.io docker-ce docker-ce-cli libedit2 libjansson4 libnftables1 nftables"; got != want { + t.Errorf("CASAOS_DU_NAMES = %q, want %q", got, want) + } + if got := s.env("CASAOS_DU_TO"); got != "29.8.0" { + t.Errorf("CASAOS_DU_TO = %q", got) + } + // the script is still the constant one, as systemd is to be given it + if script := s.started.args[len(s.started.args)-1]; script != dockerUpdateScriptArg() { + t.Error("the script is not the constant") + } +} + +func TestStartDockerUpdateSaysChangedWhenTheDependenciesAreNotTheConfirmedOnes(t *testing.T) { + var refusal *DockerUpdateRefusal + + // confirmed without nftables, and Docker now needs it: the owner reconfirms + s := newStartBoxWithDependencies(t) + s.planID = ownerPlanID(t) + status, err := s.updater.startDockerUpdate(s.planID) + if !errors.As(err, &refusal) || refusal.Code != "changed" || status.ErrorCode != "changed" || s.started.count != 0 { + t.Fatalf("a dependency appeared: error = %v, status = %#v, started %d", err, status, s.started.count) + } + s.noLog(t) + + // confirmed with the dependencies, and one is gone + s = newStartBoxWithDependencies(t) + s.box.enginePlans["containerd.io"] = strings.Replace(ownerNewDependencies, "Inst libedit2 (3.1-20191231-2+b1 Debian:11.11/oldstable [amd64])\n", "", 1) + ownerContaind + if _, err := s.updater.startDockerUpdate(s.planID); !errors.As(err, &refusal) || refusal.Code != "changed" || s.started.count != 0 { + t.Fatalf("a dependency went: error = %v, started %d", err, s.started.count) + } + + // ... and one has a newer version + s = newStartBoxWithDependencies(t) + s.box.enginePlans["containerd.io"] = strings.Replace(ownerNewDependencies, "Inst nftables (0.9.8-3.1+deb11u2", "Inst nftables (0.9.8-3.1+deb11u3", 1) + ownerContaind + if _, err := s.updater.startDockerUpdate(s.planID); !errors.As(err, &refusal) || refusal.Code != "changed" || s.started.count != 0 { + t.Fatalf("a dependency moved: error = %v, started %d", err, s.started.count) + } + + // and a dependency that Docker's update must not bring is a refusal of the plan, not a change + s = newStartBoxWithDependencies(t) + s.box.enginePlans["containerd.io"] += "Inst docker.io (26.1.5 Debian:11/stable [amd64])\n" + if _, err := s.updater.startDockerUpdate(s.planID); !errors.As(err, &refusal) || refusal.Code != "plan" || !reflect.DeepEqual(refusal.Detail, []string{"docker.io"}) || s.started.count != 0 { + t.Fatalf("a docker.io: error = %v, started %d", err, s.started.count) + } +} + +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 TestStartDockerUpdateRefusesAnUnfinishedPackageOperationUntilItIsFinished(t *testing.T) { + const reason = "A previous package operation was left unfinished." + for name, c := range map[string]struct { + change func(*aptBox) + tweak func(*systemPackageUpdater) + }{ + "dpkg --audit has something to say": {change: func(b *aptBox) { b.dpkgAudit = dpkgAuditHalfConfigured }}, + "dpkg's journal holds a number": {tweak: dpkgJournal("0001")}, + } { + // the page's check saw a clean box, and the box was interrupted since + s := newStartBox(t, nil) + 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 != "dpkg" || refusal.Reason != reason || len(refusal.Detail) != 0 { + t.Errorf("%s: error = %v, want the refusal dpkg with its reason", name, err) + continue + } + // the reason travels in the data too, and there is no detail to show + if status.ErrorCode != "dpkg" || status.Error != reason || len(status.RefusalDetail) != 0 || status.State != "idle" { + t.Errorf("%s: status = %#v", name, status) + } + if s.started.count != 0 { + t.Errorf("%s: a unit was started", name) + } + s.noLog(t) + + // finished (dpkg --configure -a): the same plan starts, with no other change + s.box.dpkgAudit = "" + s.updater.readDir = nil + if _, err := s.updater.startDockerUpdate(s.planID); err != nil || s.started.count != 1 { + t.Errorf("%s: after the repair: error = %v, started %d", name, err, s.started.count) + } + } +} + +func TestStartDockerUpdateSaysMaintenanceAndNotDpkgWhileAPackageManagerWorks(t *testing.T) { + // what a running apt shows to dpkg --audit is not an interrupted operation + s := newStartBox(t, nil) + s.box.dpkgAudit = dpkgAuditHalfConfigured + s.updater.dpkgLocked = func() bool { return true } + _, err := s.updater.startDockerUpdate(s.planID) + var refusal *DockerUpdateRefusal + if !errors.As(err, &refusal) || refusal.Code != "maintenance" || s.started.count != 0 { + t.Fatalf("error = %v, started %d, want the refusal maintenance", err, s.started.count) + } + s.noLog(t) +} + +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)", "