feat: restart to apply update, privileged helper, rollback
This commit is contained in:
1 parent
70460e4170
commit
54e8fd5fed
63 files changed
+8635
-164
No files matched your search
@@ -0,0 +1,466 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/update"
|
||||
infraUpdate "RenewCMS/internal/infrastructure/update"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeMachineProbe struct {
|
||||
os string
|
||||
systemd bool
|
||||
root bool
|
||||
}
|
||||
|
||||
var _ update.MachineProbe = &fakeMachineProbe{}
|
||||
|
||||
func (f *fakeMachineProbe) OS() string { return f.os }
|
||||
func (f *fakeMachineProbe) SystemdRunning() bool { return f.systemd }
|
||||
func (f *fakeMachineProbe) IsRoot() bool { return f.root }
|
||||
|
||||
type fakeServiceLocator struct {
|
||||
description update.ServiceDescription
|
||||
found bool
|
||||
port string
|
||||
hasPort bool
|
||||
}
|
||||
|
||||
var _ update.ServiceLocator = &fakeServiceLocator{}
|
||||
|
||||
func (f *fakeServiceLocator) Locate(name string) (update.ServiceDescription, bool) {
|
||||
description := f.description
|
||||
description.Name = name
|
||||
return description, f.found
|
||||
}
|
||||
|
||||
func (f *fakeServiceLocator) PortFromEnvironmentFile(string) (string, bool) {
|
||||
return f.port, f.hasPort
|
||||
}
|
||||
|
||||
type fakeUnitWriter struct {
|
||||
units map[string]string
|
||||
writes int
|
||||
failNth int
|
||||
events *[]string
|
||||
}
|
||||
|
||||
var _ update.UnitWriter = &fakeUnitWriter{}
|
||||
|
||||
func (f *fakeUnitWriter) Write(name string, content string) error {
|
||||
f.writes++
|
||||
*f.events = append(*f.events, "write "+name)
|
||||
if f.failNth == f.writes {
|
||||
return errors.New("disk full")
|
||||
}
|
||||
f.units[name] = content
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeUnitWriter) Remove(name string) (bool, error) {
|
||||
_, existed := f.units[name]
|
||||
delete(f.units, name)
|
||||
*f.events = append(*f.events, "remove "+name)
|
||||
return existed, nil
|
||||
}
|
||||
|
||||
type fakeUnitManager struct {
|
||||
reloadErr error
|
||||
enableErr error
|
||||
events *[]string
|
||||
}
|
||||
|
||||
var _ update.UnitManager = &fakeUnitManager{}
|
||||
|
||||
func (f *fakeUnitManager) DaemonReload() error {
|
||||
*f.events = append(*f.events, "daemon-reload")
|
||||
return f.reloadErr
|
||||
}
|
||||
|
||||
func (f *fakeUnitManager) EnableNow(unit string) error {
|
||||
*f.events = append(*f.events, "enable "+unit)
|
||||
return f.enableErr
|
||||
}
|
||||
|
||||
func (f *fakeUnitManager) DisableNow(unit string) error {
|
||||
*f.events = append(*f.events, "disable "+unit)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeHelperStatusWriter struct {
|
||||
status map[string]update.HelperStatus
|
||||
events *[]string
|
||||
}
|
||||
|
||||
var _ update.HelperStatusWriter = &fakeHelperStatusWriter{}
|
||||
|
||||
func (f *fakeHelperStatusWriter) Write(dataDir string, status update.HelperStatus) error {
|
||||
*f.events = append(*f.events, "status")
|
||||
f.status[dataDir] = status
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeHelperStatusWriter) Remove(dataDir string) (bool, error) {
|
||||
_, existed := f.status[dataDir]
|
||||
delete(f.status, dataDir)
|
||||
*f.events = append(*f.events, "status-removed")
|
||||
return existed, nil
|
||||
}
|
||||
|
||||
type installRig struct {
|
||||
probe *fakeMachineProbe
|
||||
locator *fakeServiceLocator
|
||||
units *fakeUnitWriter
|
||||
manager *fakeUnitManager
|
||||
status *fakeHelperStatusWriter
|
||||
events []string
|
||||
enable *useCases.EnableBrowserUpdatesUseCase
|
||||
disable *useCases.DisableBrowserUpdatesUseCase
|
||||
}
|
||||
|
||||
func newInstallRig() *installRig {
|
||||
rig := &installRig{
|
||||
probe: &fakeMachineProbe{os: "linux", systemd: true, root: true},
|
||||
locator: &fakeServiceLocator{
|
||||
found: true,
|
||||
description: update.ServiceDescription{
|
||||
BinaryPath: "/opt/renewcms/renewcms",
|
||||
DataDir: "/var/lib/renewcms",
|
||||
EnvironmentFile: "/etc/renewcms/renewcms.env",
|
||||
},
|
||||
},
|
||||
}
|
||||
rig.units = &fakeUnitWriter{units: map[string]string{}, events: &rig.events}
|
||||
rig.manager = &fakeUnitManager{events: &rig.events}
|
||||
rig.status = &fakeHelperStatusWriter{status: map[string]update.HelperStatus{}, events: &rig.events}
|
||||
rig.rebuild()
|
||||
return rig
|
||||
}
|
||||
|
||||
func (r *installRig) rebuild() {
|
||||
dependencies := useCases.ManageBrowserUpdatesDependencies{
|
||||
Probe: r.probe, Locator: r.locator, Units: r.units, Manager: r.manager, Status: r.status,
|
||||
Now: func() time.Time { return restartBaseTime },
|
||||
}
|
||||
r.enable = useCases.NewEnableBrowserUpdatesUseCase(dependencies)
|
||||
r.disable = useCases.NewDisableBrowserUpdatesUseCase(dependencies)
|
||||
}
|
||||
|
||||
func TestEnableBrowserUpdatesRefusals(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
prepare func(*installRig)
|
||||
message string
|
||||
}{
|
||||
{"the OS is not Linux", func(r *installRig) { r.probe.os = "windows" }, "Browser updates need Linux with systemd."},
|
||||
{"Linux without systemd", func(r *installRig) { r.probe.systemd = false }, "Browser updates need systemd, which this machine is not running."},
|
||||
{"not run as root", func(r *installRig) { r.probe.root = false }, "Run this command as root, for example with sudo."},
|
||||
{"no unit for the service and no flags", func(r *installRig) { r.locator.found = false; r.locator.description = update.ServiceDescription{} }, "Could not find the RenewCMS service. Pass --service, --binary and --data-dir."},
|
||||
{"the data directory cannot be resolved", func(r *installRig) { r.locator.description.DataDir = "" }, "Could not find the data directory. Pass --data-dir."},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run("enable-browser-updates refuses when it cannot work: "+tc.name, func(t *testing.T) {
|
||||
rig := newInstallRig()
|
||||
tc.prepare(rig)
|
||||
|
||||
_, err := rig.enable.Enable(update.InstallOptions{})
|
||||
|
||||
if err == nil || err.Error() != tc.message {
|
||||
t.Errorf("err = %v, want %q", err, tc.message)
|
||||
}
|
||||
if len(rig.events) != 0 || len(rig.units.units) != 0 || len(rig.status.status) != 0 {
|
||||
t.Errorf("nothing may be written: %v", rig.events)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnableBrowserUpdates(t *testing.T) {
|
||||
t.Run("enable-browser-updates installs the units", func(t *testing.T) {
|
||||
rig := newInstallRig()
|
||||
rig.locator.port, rig.locator.hasPort = "9090", true
|
||||
|
||||
message, err := rig.enable.Enable(update.InstallOptions{})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := rig.units.units[update.ServiceUnitName]
|
||||
path := rig.units.units[update.PathUnitName]
|
||||
for _, want := range []string{"Type=oneshot", "User=root", "ExecStart=/opt/renewcms/renewcms apply-update", "--service renewcms", "--binary /opt/renewcms/renewcms", "--data-dir /var/lib/renewcms", "--port 9090"} {
|
||||
if !strings.Contains(service, want) {
|
||||
t.Errorf("service unit lacks %q:\n%s", want, service)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(path, "PathExists=/var/lib/renewcms/update-restart-request.json") || !strings.Contains(path, "Unit=renewcms-update.service") {
|
||||
t.Errorf("path unit:\n%s", path)
|
||||
}
|
||||
assertInOrder(t, rig.events, "write", "write", "daemon-reload", "enable renewcms-update.path", "status")
|
||||
if rig.events[len(rig.events)-1] != "status" {
|
||||
t.Errorf("the status file must be written last: %v", rig.events)
|
||||
}
|
||||
status := rig.status.status["/var/lib/renewcms"]
|
||||
if status.Version != 1 || status.Service != "renewcms" || status.Binary != "/opt/renewcms/renewcms" || status.DataDir != "/var/lib/renewcms" || !status.InstalledAt.Equal(restartBaseTime) {
|
||||
t.Errorf("status = %+v", status)
|
||||
}
|
||||
if !strings.Contains(message, "renewcms disable-browser-updates") || !strings.Contains(message, update.PathUnitName) {
|
||||
t.Errorf("message = %q", message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the port comes from --port, else the environment file, else 8080", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
flag, env string
|
||||
hasEnv bool
|
||||
want string
|
||||
}{
|
||||
{"7000", "9090", true, "--port 7000"},
|
||||
{"", "9090", true, "--port 9090"},
|
||||
{"", "", false, "--port 8080"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
rig := newInstallRig()
|
||||
rig.locator.port, rig.locator.hasPort = tc.env, tc.hasEnv
|
||||
|
||||
if _, err := rig.enable.Enable(update.InstallOptions{Port: tc.flag}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !strings.Contains(rig.units.units[update.ServiceUnitName], tc.want) {
|
||||
t.Errorf("want %q in:\n%s", tc.want, rig.units.units[update.ServiceUnitName])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("flags override the unit", func(t *testing.T) {
|
||||
rig := newInstallRig()
|
||||
|
||||
_, err := rig.enable.Enable(update.InstallOptions{Service: "cms", Binary: "/srv/cms bin/renewcms", DataDir: "/srv/data", Port: "81"})
|
||||
|
||||
service := rig.units.units[update.ServiceUnitName]
|
||||
if err != nil || !strings.Contains(service, `"/srv/cms bin/renewcms" apply-update --service cms --binary "/srv/cms bin/renewcms" --data-dir /srv/data --port 81`) {
|
||||
t.Errorf("err = %v, unit:\n%s", err, service)
|
||||
}
|
||||
if _, found := rig.status.status["/srv/data"]; !found {
|
||||
t.Error("the status file goes to the overridden data directory")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an invalid port is refused", func(t *testing.T) {
|
||||
rig := newInstallRig()
|
||||
|
||||
_, err := rig.enable.Enable(update.InstallOptions{Port: "http"})
|
||||
|
||||
if !errors.Is(err, update.ErrInvalidPort) || len(rig.units.units) != 0 {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("enable-browser-updates undoes its own partial work", func(t *testing.T) {
|
||||
cases := map[string]func(*installRig){
|
||||
"writing the second unit": func(r *installRig) { r.units.failNth = 2 },
|
||||
"daemon-reload": func(r *installRig) { r.manager.reloadErr = errors.New("reload failed") },
|
||||
"enable": func(r *installRig) { r.manager.enableErr = errors.New("enable failed") },
|
||||
}
|
||||
for name, fail := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
rig := newInstallRig()
|
||||
fail(rig)
|
||||
|
||||
_, err := rig.enable.Enable(update.InstallOptions{})
|
||||
|
||||
if err == nil || len(rig.units.units) != 0 || len(rig.status.status) != 0 {
|
||||
t.Errorf("err = %v, units = %v, status = %v", err, rig.units.units, rig.status.status)
|
||||
}
|
||||
for _, event := range rig.events {
|
||||
if event == "status" {
|
||||
t.Error("update-helper.json must not be written")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("enable-browser-updates is idempotent", func(t *testing.T) {
|
||||
rig := newInstallRig()
|
||||
if _, err := rig.enable.Enable(update.InstallOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := map[string]string{}
|
||||
for name, content := range rig.units.units {
|
||||
first[name] = content
|
||||
}
|
||||
|
||||
if _, err := rig.enable.Enable(update.InstallOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rig.units.units) != 2 || rig.units.units[update.ServiceUnitName] != first[update.ServiceUnitName] || rig.units.units[update.PathUnitName] != first[update.PathUnitName] {
|
||||
t.Error("a second run must rewrite identical units")
|
||||
}
|
||||
|
||||
rig.locator.description.BinaryPath = "/usr/local/bin/renewcms"
|
||||
if _, err := rig.enable.Enable(update.InstallOptions{Service: "renewcms2"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rig.units.units) != 2 || !strings.Contains(rig.units.units[update.ServiceUnitName], "/usr/local/bin/renewcms") || !strings.Contains(rig.units.units[update.ServiceUnitName], "--service renewcms2") {
|
||||
t.Errorf("units must follow the new values: %v", rig.units.units)
|
||||
}
|
||||
if rig.status.status["/var/lib/renewcms"].Binary != "/usr/local/bin/renewcms" {
|
||||
t.Error("the status file follows the new values")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBrowserUpdatesOverRealFiles(t *testing.T) {
|
||||
setup := func(t *testing.T) (*installRig, string, string, string) {
|
||||
t.Helper()
|
||||
directory := t.TempDir()
|
||||
unitDirectory := filepath.Join(directory, "units")
|
||||
dataDir := filepath.Join(directory, "data")
|
||||
envFile := filepath.Join(directory, "renewcms.env")
|
||||
for _, path := range []string{unitDirectory, dataDir} {
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
environment := "ENVIRONMENT=production\nPORT=9191\nJWT_SECRET=sentinel-secret\nSMTP_PASSWORD=sentinel-smtp\n"
|
||||
if err := os.WriteFile(envFile, []byte(environment), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unit := "[Service]\nUser=renewcms\nEnvironmentFile=-" + envFile + "\nExecStart=/opt/renewcms/renewcms\nReadWritePaths=" + dataDir + "\n"
|
||||
if err := os.WriteFile(filepath.Join(unitDirectory, "renewcms.service"), []byte(unit), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rig := newInstallRig()
|
||||
units := infraUpdate.NewSystemdUnitsIn(unitDirectory)
|
||||
rig.locator = nil
|
||||
dependencies := useCases.ManageBrowserUpdatesDependencies{
|
||||
Probe: rig.probe, Locator: units, Units: units, Manager: rig.manager,
|
||||
Status: infraUpdate.NewJSONHelperStatusWriter(), Now: func() time.Time { return restartBaseTime },
|
||||
}
|
||||
rig.enable = useCases.NewEnableBrowserUpdatesUseCase(dependencies)
|
||||
rig.disable = useCases.NewDisableBrowserUpdatesUseCase(dependencies)
|
||||
return rig, unitDirectory, dataDir, envFile
|
||||
}
|
||||
|
||||
t.Run("it reads the unit and writes no secret and no environment value", func(t *testing.T) {
|
||||
rig, unitDirectory, dataDir, _ := setup(t)
|
||||
|
||||
if _, err := rig.enable.Enable(update.InstallOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, name := range []string{update.ServiceUnitName, update.PathUnitName} {
|
||||
content, err := os.ReadFile(filepath.Join(unitDirectory, name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, forbidden := range []string{"JWT_SECRET", "sentinel-secret", "sentinel-smtp", "SMTP_PASSWORD", "ENVIRONMENT"} {
|
||||
if strings.Contains(string(content), forbidden) {
|
||||
t.Errorf("%s contains %q", name, forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
service, _ := os.ReadFile(filepath.Join(unitDirectory, update.ServiceUnitName))
|
||||
if !strings.Contains(string(service), "--port 9191") || !strings.Contains(string(service), "--data-dir ") {
|
||||
t.Errorf("service unit:\n%s", service)
|
||||
}
|
||||
if !infraUpdate.NewFileHelperDetector(filepath.Join(dataDir, update.HelperStatusFileName)).Installed() {
|
||||
t.Error("can_apply must read true afterwards")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("disable-browser-updates", func(t *testing.T) {
|
||||
rig, unitDirectory, dataDir, _ := setup(t)
|
||||
if _, err := rig.enable.Enable(update.InstallOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kept := []string{update.RestartRequestFileName, update.ResultFileName, "updates/v2.3.0-beta/binary", "backups/renewcms-1.db"}
|
||||
for _, name := range kept {
|
||||
full := filepath.Join(dataDir, filepath.FromSlash(name))
|
||||
_ = os.MkdirAll(filepath.Dir(full), 0o755)
|
||||
if err := os.WriteFile(full, []byte("keep"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
rig.events = nil
|
||||
|
||||
message, err := rig.disable.Disable(update.InstallOptions{})
|
||||
|
||||
if err != nil || !strings.Contains(message, "disabled") {
|
||||
t.Fatalf("message = %q, err = %v", message, err)
|
||||
}
|
||||
for _, name := range []string{update.PathUnitName, update.ServiceUnitName} {
|
||||
if _, statErr := os.Stat(filepath.Join(unitDirectory, name)); statErr == nil {
|
||||
t.Errorf("%s must be removed", name)
|
||||
}
|
||||
}
|
||||
assertInOrder(t, rig.events, "disable renewcms-update.path", "daemon-reload")
|
||||
for _, name := range kept {
|
||||
if _, statErr := os.Stat(filepath.Join(dataDir, filepath.FromSlash(name))); statErr != nil {
|
||||
t.Errorf("%s must be left in place", name)
|
||||
}
|
||||
}
|
||||
if infraUpdate.NewFileHelperDetector(filepath.Join(dataDir, update.HelperStatusFileName)).Installed() {
|
||||
t.Error("can_apply must read false afterwards")
|
||||
}
|
||||
|
||||
again, err := rig.disable.Disable(update.InstallOptions{})
|
||||
if err != nil || !strings.Contains(again, "not enabled") {
|
||||
t.Errorf("a second run: message = %q, err = %v", again, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("disable-browser-updates where it was never enabled exits cleanly", func(t *testing.T) {
|
||||
rig, _, _, _ := setup(t)
|
||||
|
||||
message, err := rig.disable.Disable(update.InstallOptions{})
|
||||
|
||||
if err != nil || !strings.Contains(message, "not enabled") {
|
||||
t.Errorf("message = %q, err = %v", message, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("disable-browser-updates on a machine without systemd is a no-op", func(t *testing.T) {
|
||||
rig := newInstallRig()
|
||||
rig.probe.os = "windows"
|
||||
|
||||
message, err := rig.disable.Disable(update.InstallOptions{})
|
||||
|
||||
if err != nil || !strings.Contains(message, "not enabled") || len(rig.events) != 0 {
|
||||
t.Errorf("message = %q, err = %v, events = %v", message, err, rig.events)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRenderServiceUnitEscaping(t *testing.T) {
|
||||
t.Run("backslashes, quotes, percent and dollar in a value cannot alter the unit", func(t *testing.T) {
|
||||
unit := update.RenderServiceUnit(update.InstallOptions{
|
||||
Service: `svc\x`,
|
||||
Binary: `/opt/a\b"c%d$e/bin`,
|
||||
DataDir: "/data\nUser=evil",
|
||||
Port: "80",
|
||||
})
|
||||
|
||||
want := `ExecStart="/opt/a\\b\"c%%d$$e/bin" apply-update --service "svc\\x" --binary "/opt/a\\b\"c%%d$$e/bin" --data-dir "/data\nUser=evil" --port 80`
|
||||
if !strings.Contains(unit, want) {
|
||||
t.Errorf("unit:\n%s\nwant line %s", unit, want)
|
||||
}
|
||||
if strings.Count(unit, "\nUser=") != 1 {
|
||||
t.Errorf("an injected directive must not appear:\n%s", unit)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a plain value stays unquoted", func(t *testing.T) {
|
||||
unit := update.RenderServiceUnit(update.InstallOptions{Service: "renewcms", Binary: "/opt/renewcms/renewcms", DataDir: "/var/lib/renewcms", Port: "8080"})
|
||||
|
||||
if !strings.Contains(unit, "ExecStart=/opt/renewcms/renewcms apply-update --service renewcms") {
|
||||
t.Errorf("unit:\n%s", unit)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user