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
10 changes: 9 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package main
import (
"context"
_ "embed"
"errors"
"flag"
"fmt"
"net"
Expand Down Expand Up @@ -129,7 +130,14 @@ func main() {
// 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.
autoupdate.Default.Releases = service.MyService.Casa()
autoupdate.Default.Start = service.MyService.System().UpdateSystemVersion
autoupdate.Default.Start = func(release string) error {
err := service.MyService.System().UpdateSystemVersion(release)
if errors.Is(err, service.ErrSystemMaintenanceBusy) {
return fmt.Errorf("%w: %v", autoupdate.ErrBusy, err)
}
return err
}
autoupdate.Default.Busy = service.MyService.System().MaintenanceBusy
go autoupdate.Default.Run(context.Background())
v1Router := route.InitV1Router()

Expand Down
28 changes: 26 additions & 2 deletions pkg/autoupdate/autoupdate.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package autoupdate
import (
"context"
"encoding/json"
"errors"
"math/rand/v2"
"net/http"
"os"
Expand Down Expand Up @@ -53,6 +54,10 @@ type Releases interface {
FetchCasaosVersion() model.Version
}

// ErrBusy is what Start returns, wrapped, when something else is changing the box and the
// update was not started: that is not a failed attempt, and the next check tries again.
var ErrBusy = errors.New("the box is being changed by something else")

// AutoUpdate is the automatic updates of one box.
type AutoUpdate struct {
// Root is where autoupdate.json is kept: "/" on a box, a temporary
Expand All @@ -71,6 +76,10 @@ type AutoUpdate struct {
// AppsBusy reports whether AppManagement lists an operation in progress,
// or did not answer.
AppsBusy func(ctx context.Context) bool
// Busy, when set, reports whether something else is changing the box: a
// package update, an update of Docker, a package manager holding dpkg's lock.
// Nothing starts while it does.
Busy func() bool
// Notify, when set, hears how an attempt ended: succeeded, failed, or
// paused on its second failure. main sets it to the push alerts' hook.
Notify func(result, version string)
Expand Down Expand Up @@ -148,7 +157,7 @@ func (a *AutoUpdate) Check(ctx context.Context) {
if !decide(f).start {
return
}
f.unitActive, f.appsBusy = a.unitActive(), a.AppsBusy(ctx)
f.unitActive, f.appsBusy = a.unitActive(), a.AppsBusy(ctx) || (a.Busy != nil && a.Busy())
if v := decide(f); v.start {
a.start(v.next.Version, now)
}
Expand All @@ -159,12 +168,27 @@ func (a *AutoUpdate) Check(ctx context.Context) {
// that fails is failed at once.
func (a *AutoUpdate) start(release string, now time.Time) {
last := Last{Version: release, StartedAt: now.UTC().Truncate(time.Second), Result: resultRunning}
if err := a.modify(func(s *State) bool { s.Last = &last; return true }); err != nil {
var before *Last
if err := a.modify(func(s *State) bool { before = s.Last; s.Last = &last; return true }); err != nil {
logger.Info("automatic update: cannot save autoupdate.json, not starting", zap.String("version", release), zap.Error(err))
return
}
logger.Info("automatic update: starting", zap.String("version", release))
if err := a.Start(release); err != nil {
if errors.Is(err, ErrBusy) {
// nothing was started: the attempt is taken back, not counted
logger.Info("automatic update: the box is busy, not starting", zap.String("version", release), zap.Error(err))
if err := a.modify(func(s *State) bool {
if s.Last == nil || s.Last.Result != resultRunning || s.Last.Version != release {
return false
}
s.Last = before
return true
}); err != nil {
logger.Info("automatic update: cannot save autoupdate.json", zap.Error(err))
}
return
}
logger.Info("automatic update: cannot start", zap.String("version", release), zap.Error(err))
if err := a.modify(func(s *State) bool {
if s.Last == nil || s.Last.Result != resultRunning {
Expand Down
54 changes: 54 additions & 0 deletions pkg/autoupdate/autoupdate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package autoupdate
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
Expand Down Expand Up @@ -551,3 +552,56 @@ func TestAppManagementIsAskedAsAnInternalRequest(t *testing.T) {
}
})
}

func TestAPackageUpdateOrDpkgsLockHoldsTheStart(t *testing.T) {
a, b := newBox(t)
saveState(t, a, on())
busy := true
a.Busy = func() bool { return busy }
before := stateFile(t, a)

a.Check(context.Background())

if len(b.started) != 0 {
t.Fatalf("started %v while the box was being changed", b.started)
}
if after := stateFile(t, a); after != before {
t.Fatalf("autoupdate.json = %s, want it untouched: the next check tries again", after)
}

busy = false
a.Check(context.Background())
if len(b.started) != 1 {
t.Fatalf("started %v once the box was free, want one start", b.started)
}
}

func TestABusyRefusalFromStartIsNotAFailedAttempt(t *testing.T) {
a, b := newBox(t)
saveState(t, a, on())
refuse := true
a.Start = func(version string) error {
b.started = append(b.started, version)
if refuse {
return fmt.Errorf("%w: a package update is running", ErrBusy)
}
return nil
}
before := stateFile(t, a)

a.Check(context.Background())
a.Check(context.Background())

if len(b.started) != 2 {
t.Fatalf("started %v: each check should have tried again", b.started)
}
if after := stateFile(t, a); after != before {
t.Fatalf("autoupdate.json = %s, want it as it was: a refusal is not an attempt, and two of them must not pause the release", after)
}

refuse = false
a.Check(context.Background())
if len(b.started) != 3 {
t.Fatalf("started %v once the box was free", b.started)
}
}
206 changes: 206 additions & 0 deletions pkg/dockerpkg/dockerpkg.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
// Package dockerpkg knows which of a Debian-family box's packages are Docker's, where
// the installed Docker came from, and what an apt simulation says it would do to them.
//
// ReCasaOS updates system packages with an apt install of a list, and an upgrade of
// docker-ce or containerd.io restarts the Docker daemon, which stops every container until
// it is back. The System packages update therefore leaves this family out and shows it on a
// line of its own; this package is what tells the two apart. It runs nothing: callers give
// it text apt printed, and it gives back what is in it. Nothing here is ever built into a
// shell command from a version string, and a package name reaches a command only if it is
// one of the fixed names below.
package dockerpkg

import (
"bufio"
"regexp"
"sort"
"strings"
)

// family is every package whose upgrade replaces, or restarts, the Docker engine or the
// parts ReCasaOS uses with it.
var family = map[string]struct{}{
"docker-ce": {},
"docker-ce-cli": {},
"docker-ce-rootless-extras": {},
"containerd.io": {},
"docker-buildx-plugin": {},
"docker-compose-plugin": {},
"docker-model-plugin": {},
"docker.io": {},
"docker-compose-v2": {},
"docker-buildx": {},
"containerd": {},
}

// restarts are the packages whose upgrade restarts the daemon (or containerd under it).
var restarts = map[string]struct{}{
"docker-ce": {},
"containerd.io": {},
"docker.io": {},
"containerd": {},
}

// base is a package name without the architecture apt adds to a foreign one:
// "containerd.io:armhf" is "containerd.io".
func base(name string) string {
b, _, _ := strings.Cut(name, ":")
return b
}

// IsFamily reports whether name is one of the Docker engine's packages.
func IsFamily(name string) bool {
_, ok := family[base(name)]
return ok
}

// RestartsEngine reports whether upgrading any of names restarts Docker.
func RestartsEngine(names []string) bool {
for _, name := range names {
if _, ok := restarts[base(name)]; ok {
return true
}
}
return false
}

var namePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9+.\-]*(:[a-z0-9]+)?$`)

// ValidName reports whether name looks like a Debian package name (with an optional
// architecture), the only kind of text that may be put into an apt command line.
func ValidName(name string) bool {
return len(name) <= 128 && namePattern.MatchString(name)
}

// Origin is where the installed Docker came from.
type Origin string

const (
// OriginDockerRepo is docker-ce from Docker's own apt repository, the one the
// installer sets up: https://download.docker.com/linux/.
OriginDockerRepo Origin = "docker-repository"
// OriginDistribution is the docker.io package of Debian, Ubuntu or Raspberry Pi OS.
OriginDistribution Origin = "distribution"
// OriginSnap is the snap.
OriginSnap Origin = "snap"
// OriginUnknown is a Docker whose source this does not recognise: a mirror, a plain
// http repository, a package installed by hand.
OriginUnknown Origin = "unknown"
)

// CanSeeUpdates reports whether apt can tell of an update for an installation from this
// origin: of a snap, or of a Docker whose source is not known, it cannot.
func (o Origin) CanSeeUpdates() bool {
return o == OriginDockerRepo || o == OriginDistribution
}

const dockerRepositoryPrefix = "https://download.docker.com/linux/"

var policySourcePattern = regexp.MustCompile(`^\s+(?:\*\*\*\s+)?\d+\s+(https?://\S+)\s`)

// OriginFromPolicy tells where docker-ce comes from, from the output of
// `apt-cache policy docker-ce`: Docker's repository when some version of the package is
// offered by https://download.docker.com/linux/, anything else is unknown.
func OriginFromPolicy(policy string) Origin {
scanner := bufio.NewScanner(strings.NewReader(policy))
for scanner.Scan() {
if m := policySourcePattern.FindStringSubmatch(scanner.Text()); m != nil && strings.HasPrefix(m[1], dockerRepositoryPrefix) {
return OriginDockerRepo
}
}
return OriginUnknown
}

// defaultPackages is what to update by hand when apt has not said which packages are
// behind.
var defaultPackages = map[Origin][]string{
OriginDockerRepo: {"docker-ce", "docker-ce-cli", "containerd.io", "docker-buildx-plugin", "docker-compose-plugin"},
OriginDistribution: {"docker.io"},
}

// ManualCommand is what an owner types to update Docker themselves, for an origin, or ""
// when there is no command this can stand behind. It names the packages apt says are
// pending when it said some, so that running it leaves nothing behind, and only names from
// the fixed family: nothing apt printed reaches it unless it is one of them.
func ManualCommand(origin Origin, pending []string) string {
if origin == OriginSnap {
return "sudo snap refresh docker"
}
defaults, ok := defaultPackages[origin]
if !ok {
return ""
}
chosen := map[string]struct{}{}
for _, name := range pending {
if b := base(name); IsFamily(name) {
chosen[b] = struct{}{}
}
}
if len(chosen) == 0 {
for _, name := range defaults {
chosen[name] = struct{}{}
}
}
names := make([]string, 0, len(chosen))
for name := range chosen {
names = append(names, name)
}
sort.Strings(names)
return "sudo apt-get update && sudo apt-get install --only-upgrade " + strings.Join(names, " ")
}

var engineVersionPattern = regexp.MustCompile(`^(?:\d+:)?(\d+(?:\.\d+)*)`)

// EngineVersion is the upstream version in a dpkg version: "5:29.8.1-1~ubuntu.22.04~jammy"
// is "29.8.1". It is "" for text that does not start with one.
func EngineVersion(dpkgVersion string) string {
if m := engineVersionPattern.FindStringSubmatch(strings.TrimSpace(dpkgVersion)); m != nil {
return m[1]
}
return ""
}

var (
simulationInstPattern = regexp.MustCompile(`^Inst\s+(\S+)`)
simulationRemvPattern = regexp.MustCompile(`^Remv\s+(\S+)`)
)

// Touched is what an apt simulation (`apt-get -s`) says it would do.
type Touched struct {
// Docker is the packages of the Docker family it would install or upgrade.
Docker []string
// Removed is every package it would remove.
Removed []string
}

// Touches reads the Inst and Remv lines of an apt simulation.
func Touches(simulation string) Touched {
var touched Touched
scanner := bufio.NewScanner(strings.NewReader(simulation))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if m := simulationInstPattern.FindStringSubmatch(line); m != nil && IsFamily(m[1]) {
touched.Docker = append(touched.Docker, m[1])
} else if m := simulationRemvPattern.FindStringSubmatch(line); m != nil {
touched.Removed = append(touched.Removed, m[1])
}
}
return touched
}

// ContractPattern is an extended regular expression (for grep -E) that matches a line of
// an apt simulation that breaks the contract of the update: any removal, and, when the box
// has a Docker engine to protect, an install or upgrade of a package of the Docker family.
// The unit's shell line runs the simulation again just before it installs, and installs
// nothing if a line matches.
func ContractPattern(protectDocker bool) string {
if !protectDocker {
return `^Remv `
}
names := make([]string, 0, len(family))
for name := range family {
names = append(names, strings.ReplaceAll(name, ".", `\.`))
}
sort.Strings(names)
return `^(Inst (` + strings.Join(names, "|") + `)[: ]|Remv )`
}
Loading
Loading