Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion pkg/alerts/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ type DockerRun struct {
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 is the engine version the status says: the one that runs after a run that succeeded, the
// plan's for a run that did not. Only a success tells it.
To string
// NotReturned are the containers that were running before the update and were not after,
// already validated by whoever read the log.
Expand Down
10 changes: 10 additions & 0 deletions pkg/alerts/docker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,16 @@ func TestTheDockerUpdateEndsWithOneMessage(t *testing.T) {
DockerRun{Outcome: "failed", ErrorCode: "daemon"},
"The Docker update failed: Docker did not come back after the update. Details are in the dashboard.",
},
// the status gives the version the plan goes to for a run that did not succeed: it is not told,
// and nor is a way back (there is none after a major jump)
"failed, with the version the plan went to": {
DockerRun{Outcome: "failed", ErrorCode: "daemon", To: "29.8.0"},
"The Docker update failed: Docker did not come back after the update. Details are in the dashboard.",
},
"restart pending, with the version the plan went to": {
DockerRun{Outcome: "restart_pending", To: "29.8.0"},
"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, start": {
DockerRun{Outcome: "failed", ErrorCode: "start"},
"The Docker update failed: the update could not be started, nothing was changed. Details are in the dashboard.",
Expand Down
68 changes: 67 additions & 1 deletion pkg/dockerpkg/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package dockerpkg
import (
"crypto/rand"
"encoding/hex"
"regexp"
"strings"
"time"
)
Expand Down Expand Up @@ -84,6 +85,59 @@ func QueuedMarker(nonce string, at time.Time) string {
return markerPrefix + "QUEUED " + nonce + " " + at.UTC().Format(time.RFC3339) + "\n"
}

// PlanMarker is the second line of the log, which the core writes in the same write as the first:
// "CASAOS_DOCKER_UPDATE_PLAN <nonce> <from> <to> <0|1>", the engine versions the plan goes from and
// to (digits and dots, "-" for one that is not known) and whether the plan changes the engine's
// major version. A version that is not digits and dots is written as unknown, so that the line
// holds nothing else. The unit never writes one: the core tells the page of a major jump from it.
func PlanMarker(nonce, from, to string, majorJump bool) string {
jump := "0"
if majorJump {
jump = "1"
}
return markerPrefix + "PLAN " + nonce + " " + planVersion(from) + " " + planVersion(to) + " " + jump + "\n"
}

var planVersionPattern = regexp.MustCompile(`^[0-9]+(?:\.[0-9]+)*$`)

func validPlanVersion(v string) bool {
return len(v) <= maxVersionLen && planVersionPattern.MatchString(v)
}

// planVersion is how a version is written on the PLAN line.
func planVersion(v string) string {
if validPlanVersion(v) {
return v
}
return "-"
}

// planFields reads the fields of a PLAN marker: two versions, each digits and dots or "-" (unknown,
// which is ""), and 0 or 1.
func planFields(fields []string) (from, to string, majorJump, ok bool) {
if len(fields) != 3 {
return "", "", false, false
}
versions := [2]string{}
for i, field := range fields[:2] {
switch {
case field == "-":
case validPlanVersion(field):
versions[i] = field
default:
return "", "", false, false
}
}
switch fields[2] {
case "0":
case "1":
majorJump = true
default:
return "", "", false, false
}
return versions[0], versions[1], majorJump, true
}

// NotReturned is a container that was running before the update and is not running after it.
type NotReturned struct {
Name string `json:"name"`
Expand Down Expand Up @@ -114,6 +168,11 @@ type Run struct {
DaemonVersion string
// NotReturned are the containers that were running before and were not after.
NotReturned []NotReturned
// From and To are the engine versions the plan goes from and to (digits and dots), "" when the
// plan did not know one or the log has no valid PLAN line; MajorJump is the plan's own word on
// whether it changes the major version, and is false with no PLAN line.
From, To string
MajorJump bool
}

// markerOf splits a line that is a marker of this run: "CASAOS_DOCKER_UPDATE_<KIND> <nonce>"
Expand Down Expand Up @@ -160,7 +219,8 @@ func validPin(pin string) bool {
// is read only if it is "CASAOS_DOCKER_UPDATE_<KIND> <that nonce>" 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.
// terminal marker wins, the first STARTED, INSTALLED, PREVIOUS and PLAN, the last DAEMON. A PLAN
// is the core's, written on line 2, so the first valid one is the plan and no later one changes it.
//
// 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 {
Expand All @@ -174,6 +234,7 @@ func ParseRun(log string) Run {
return Run{}
}
run := Run{Nonce: first[1], QueuedAt: queued}
planSeen := false
for _, line := range strings.Split(tail, "\n") {
kind, fields, ok := markerOf(strings.TrimRight(line, "\r"), run.Nonce)
if !ok {
Expand All @@ -197,6 +258,11 @@ func ParseRun(log string) Run {
if run.Previous == nil {
run.Previous = validPins(fields)
}
case "PLAN":
if from, to, majorJump, ok := planFields(fields); ok && !planSeen {
planSeen = true
run.From, run.To, run.MajorJump = from, to, majorJump
}
case "DAEMON":
if len(fields) == 1 && validDaemonVersion(fields[0]) {
run.DaemonVersion = fields[0]
Expand Down
148 changes: 148 additions & 0 deletions pkg/dockerpkg/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -497,3 +497,151 @@ func TestIsMarkerLineIsTheShapeParseRunLooksFor(t *testing.T) {
t.Error("a marker the core writes is not a marker line")
}
}

// The core writes the plan on the second line, in the same write as the first. What the page says of a
// major jump (and what it does not offer after one) rests on this line, so it is checked from both ends.
func TestPlanMarker(t *testing.T) {
line := func(fields string) string { return "CASAOS_DOCKER_UPDATE_PLAN " + testNonce + " " + fields + "\n" }
for _, c := range []struct {
from, to string
jump bool
want string
}{
{"28.0.4", "29.8.0", true, line("28.0.4 29.8.0 1")},
{"28.0.4", "28.1.0", false, line("28.0.4 28.1.0 0")},
{"29.8.0", "29.8.0", false, line("29.8.0 29.8.0 0")},
{"", "", false, line("- - 0")},
{"", "29.8.0", false, line("- 29.8.0 0")},
{"28.0.4", "", false, line("28.0.4 - 0")},
// anything that is not digits and dots is written as unknown: the line holds no other text
{"5:28.0.4-1~debian.11~bullseye", "29.8.0;reboot", true, line("- - 1")},
{"28.0.4\n", " 29.8.0", false, line("- - 0")},
{"28 0", "$(id)", false, line("- - 0")},
{"28.", ".29", false, line("- - 0")},
{"28..0", "-", false, line("- - 0")},
{"v28", "29.8.0-rc1", false, line("- - 0")},
{strings.Repeat("1", 100), strings.Repeat("1", 101), false, line(strings.Repeat("1", 100) + " - 0")},
} {
got := PlanMarker(testNonce, c.from, c.to, c.jump)
if got != c.want {
t.Errorf("PlanMarker(%q, %q, %v) = %q, want %q", c.from, c.to, c.jump, got, c.want)
}
if !strings.HasSuffix(got, "\n") || strings.Count(got, "\n") != 1 || !IsMarkerLine(strings.TrimSuffix(got, "\n")) {
t.Errorf("PlanMarker(%q, %q, %v) = %q, want one marker line", c.from, c.to, c.jump, got)
}
}
}

func TestParseRunReadsThePlanTheCoreWrote(t *testing.T) {
for _, c := range []struct {
from, to string
jump bool
}{
{"28.0.4", "29.8.0", true},
{"28.0.4", "28.1.0", false},
{"28.0.4", "28.0.4", false},
{"", "", false},
{"", "29.8.0", false},
} {
got := ParseRun(QueuedMarker(testNonce, t0) + PlanMarker(testNonce, c.from, c.to, c.jump))
want := Run{Nonce: testNonce, QueuedAt: t0, From: c.from, To: c.to, MajorJump: c.jump}
if !reflect.DeepEqual(got, want) {
t.Errorf("ParseRun(plan %q %q %v) =\n%#v\nwant\n%#v", c.from, c.to, c.jump, got, want)
}
}
// the plan is no step of the run: the phase and the rest are the unit's
got := ParseRun(QueuedMarker(testNonce, t0) + PlanMarker(testNonce, "28.0.4", "29.8.0", true) + marker("STARTED", ts(1)) + "\n")
if got.Phase() != PhasePreparing || got.Terminal != "" || !got.StartedAt.Equal(t0.Add(time.Second)) {
t.Errorf("a run with a plan = %#v", got)
}
// a log with no plan (an older core, or a forged one) is no major jump and knows no versions
got = ParseRun(QueuedMarker(testNonce, t0))
if got.MajorJump || got.From != "" || got.To != "" {
t.Errorf("a run with no plan = %#v", got)
}
}

func TestParseRunValidatesPlan(t *testing.T) {
first := strings.TrimSuffix(QueuedMarker(testNonce, t0), "\n")
for fields, want := range map[string]Run{
"28.0.4 29.8.0 1": {From: "28.0.4", To: "29.8.0", MajorJump: true},
"28.0.4 28.1.0 0": {From: "28.0.4", To: "28.1.0"},
"29 30 1": {From: "29", To: "30", MajorJump: true},
"- - 0": {},
"- 29.8.0 0": {To: "29.8.0"},
"28.0.4 - 0": {From: "28.0.4"},
"28.0.4 29.8.0 1\r": {From: "28.0.4", To: "29.8.0", MajorJump: true},
strings.Repeat("1", 100) + " - 0": {From: strings.Repeat("1", 100)},
} {
want.Nonce, want.QueuedAt = testNonce, t0
if got := ParseRun(logOf(first, marker("PLAN", fields))); !reflect.DeepEqual(got, want) {
t.Errorf("PLAN %q: ParseRun() =\n%#v\nwant\n%#v", fields, got, want)
}
}
for _, fields := range []string{
"", "28.0.4", "28.0.4 29.8.0", "28.0.4 29.8.0 1 extra", "- - ", "-",
// the flag is 0 or 1 and nothing else
"28.0.4 29.8.0 2", "28.0.4 29.8.0 true", "28.0.4 29.8.0 yes", "28.0.4 29.8.0 -1", "28.0.4 29.8.0 01", "28.0.4 29.8.0 1;reboot", "28.0.4 29.8.0 \u0661", "28.0.4 29.8.0 -",
// a version is digits and dots, or "-"
"5:28.0.4-1 29.8.0 1", "28.0.4 29.8.0-1~debian.11~bullseye 1", "28.0.4;reboot 29.8.0 1", "28.0.4 `id` 1", "$(id) 29.8.0 1",
"28..4 29.8.0 1", ".4 29.8.0 1", "28. 29.8.0 1", "v28 29 1", "28.0.4-rc1 29.8.0 1", "\u0662\u0668 29.8.0 1",
strings.Repeat("1", 101) + " 29 1", "28 " + strings.Repeat("1", 101) + " 1",
} {
want := Run{Nonce: testNonce, QueuedAt: t0}
if got := ParseRun(logOf(first, marker("PLAN", fields))); !reflect.DeepEqual(got, want) {
t.Errorf("PLAN %q: ParseRun() =\n%#v\nwant no plan", fields, got)
}
}
// a PLAN line that has no nonce at all
for _, line := range []string{"CASAOS_DOCKER_UPDATE_PLAN", "CASAOS_DOCKER_UPDATE_PLAN 28.0.4 29.8.0 1", "CASAOS_DOCKER_UPDATE_PLAN " + testNonce} {
if got := ParseRun(logOf(first, line)); !reflect.DeepEqual(got, Run{Nonce: testNonce, QueuedAt: t0}) {
t.Errorf("%q gave %#v", line, got)
}
}
}

func TestParseRunTakesThePlanOfTheCoreAndNoOther(t *testing.T) {
first := strings.TrimSuffix(QueuedMarker(testNonce, t0), "\n")
plan := strings.TrimSuffix(PlanMarker(testNonce, "28.0.4", "29.8.0", true), "\n")
forged := "CASAOS_DOCKER_UPDATE_PLAN " + otherNonce + " 28.0.4 28.1.0 0"
elsewhere := "28.0.4 28.1.0 0"
for name, c := range map[string]struct {
lines []string
want Run
}{
"a wrong nonce is not the plan": {[]string{first, forged}, Run{}},
"a wrong nonce leaves the plan": {[]string{first, plan, forged}, Run{From: "28.0.4", To: "29.8.0", MajorJump: true}},
"a wrong nonce first leaves it too": {[]string{first, forged, plan}, Run{From: "28.0.4", To: "29.8.0", MajorJump: true}},
"the first valid plan wins": {[]string{first, plan, marker("PLAN", elsewhere)}, Run{From: "28.0.4", To: "29.8.0", MajorJump: true}},
"the first, if it says no jump": {[]string{first, marker("PLAN", elsewhere), plan}, Run{From: "28.0.4", To: "28.1.0"}},
"a malformed one is no plan, and the next one is": {[]string{first, marker("PLAN", "28.0.4 29.8.0 maybe"), marker("PLAN", "29.8.0;reboot 29.8.0 0"), plan}, Run{From: "28.0.4", To: "29.8.0", MajorJump: true}},
"a malformed one after does not undo": {[]string{first, plan, marker("PLAN", "- - 0"), marker("PLAN", "28.0.4 29.8.0 2")}, Run{From: "28.0.4", To: "29.8.0", MajorJump: true}},
// what apt, dpkg or Docker print is not a line that starts a marker, whatever it holds
"in the middle of apt output": {[]string{first, "Setting up docker-ce (5:29.8.0-1) ... " + marker("PLAN", elsewhere)}, Run{}},
"after a prefix": {[]string{first, "container: " + marker("PLAN", elsewhere), "x" + marker("PLAN", elsewhere), " " + marker("PLAN", elsewhere), "\t" + marker("PLAN", elsewhere), "> " + marker("PLAN", elsewhere)}, Run{}},
"in the log that apt wrote, after the real one": {[]string{first, plan, "Get:1 https://download.docker.com bullseye/stable docker-ce 5:29.8.0-1", marker("PLAN", elsewhere), marker("DAEMON", "29.8.0"), marker("SUCCESS", ts(9))}, Run{From: "28.0.4", To: "29.8.0", MajorJump: true, DaemonVersion: "29.8.0", Terminal: TerminalSuccess, CompletedAt: t0.Add(9 * time.Second)}},
"the wrong case, kind or spacing": {[]string{first, strings.ToLower(marker("PLAN", elsewhere)), "CASAOS_DOCKER_UPDATE_PLANX " + testNonce + " " + elsewhere, "CASAOS_DOCKER_UPDATE_PLA " + testNonce + " " + elsewhere, "CASAOS_DOCKER_UPDATE_PLAN\t" + testNonce + " " + elsewhere, "CASAOS_DOCKER_UPDATE_PLAN" + testNonce + " " + elsewhere, "CASAOS_PACKAGE_UPDATE_PLAN " + testNonce + " " + elsewhere}, Run{}},
} {
want := c.want
want.Nonce, want.QueuedAt = testNonce, t0
if got := ParseRun(logOf(c.lines...)); !reflect.DeepEqual(got, want) {
t.Errorf("%s: ParseRun() =\n%#v\nwant\n%#v", name, got, want)
}
}
}

// A plan that comes before the first line is not the run's: line 1 is the run, and the core writes it first.
func TestParseRunIgnoresAPlanBeforeTheFirstLine(t *testing.T) {
first := strings.TrimSuffix(QueuedMarker(testNonce, t0), "\n")
plan := marker("PLAN", "28.0.4 29.8.0 1")
for name, lines := range map[string][]string{
"the plan alone": {plan},
"the plan, then the first line": {plan, first, marker("SUCCESS", ts(5))},
"apt output, the plan, the first": {"Reading package lists...", plan, first},
"the plan with a first line that is not one": {plan, "CASAOS_DOCKER_UPDATE_QUEUED " + testNonce},
} {
if got := ParseRun(logOf(lines...)); !reflect.DeepEqual(got, Run{}) {
t.Errorf("%s: ParseRun() = %#v, want the zero Run", name, got)
}
}
}
42 changes: 40 additions & 2 deletions route/v1/system_docker_update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ func TestStartDockerUpdatePassesThePlanIDAndAnswersWithTheStatus(t *testing.T) {
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":""}}`
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":"","major_jump":false,"not_returned":[],"rollback_command":"","log":""}}`
if got := strings.TrimSpace(recorder.Body.String()); got != want {
t.Errorf("body = %s\nwant %s", got, want)
}
Expand Down Expand Up @@ -193,8 +193,46 @@ func TestGetDockerUpdateStatusAnswersWithTheStatus(t *testing.T) {
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"}}`
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":"","major_jump":false,"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)
}
}

// After a major jump the status says so and gives no command to go back: the page reads major_jump,
// from and to, and an empty rollback_command, whatever the state.
func TestDockerUpdateStatusOfAMajorJumpCarriesTheJumpAndNoRollback(t *testing.T) {
failed := 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: "29.8.0", MajorJump: true,
NotReturned: []dockerpkg.NotReturned{{Name: "job", RestartPolicy: "no"}},
Log: "CASAOS_DOCKER_UPDATE_QUEUED x\n",
}
running := service.SystemDockerUpdateStatus{
Supported: true, State: "running", Phase: "downloading", StartedAt: "2026-10-09T10:00:00Z", From: "28.0.4", To: "29.8.0", MajorJump: true,
NotReturned: []dockerpkg.NotReturned{},
}
for name, c := range map[string]struct {
status service.SystemDockerUpdateStatus
want string
}{
"failed": {failed, `{"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":"29.8.0","major_jump":true,"not_returned":[{"name":"job","restart_policy":"no"}],"rollback_command":"","log":"CASAOS_DOCKER_UPDATE_QUEUED x\n"}}`},
"running": {running, `{"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":"28.0.4","to":"29.8.0","major_jump":true,"not_returned":[],"rollback_command":"","log":""}}`},
} {
withDockerUpdateService(t, &fakeDockerUpdateService{status: c.status})
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)
}
if got := strings.TrimSpace(recorder.Body.String()); recorder.Code != http.StatusOK || got != c.want {
t.Errorf("%s: status = %d\nbody = %s\nwant %s", name, recorder.Code, got, c.want)
}
// ... and the start of the update answers with the same object
fake := &fakeDockerUpdateService{status: c.status}
withDockerUpdateService(t, fake)
if got := strings.TrimSpace(postDockerUpdate(t, `{"plan_id":"`+planID+`"}`).Body.String()); got != c.want {
t.Errorf("%s: the start answered\n%s\nwant\n%s", name, got, c.want)
}
}
}
Loading
Loading