Files
stalwart-migrator/internal/service/service.go
T
jcoffey-dev 4b0bec8956 Add SPDX headers to every Go file
GPLv3's "How to Apply These Terms" asks for a notice in each source file;
this is the modern two-line SPDX form of it rather than the full paragraph.
82 files, including tests.

The blank line after the header is load-bearing. In Go a comment block
immediately preceding `package X` becomes the package doc comment, so
without the separator the SPDX lines would be absorbed into the doc for the
eleven packages whose doc.go (or main.go) opens with one, and `go doc` would
print them. Verified it doesn't.
2026-08-23 18:03:15 -07:00

196 lines
7.2 KiB
Go

// SPDX-FileCopyrightText: 2026 LINUXexpert-org
// SPDX-License-Identifier: GPL-3.0-or-later
package service
import (
"context"
"fmt"
"os/exec"
"strings"
"time"
)
// Kind is how a Stalwart instance is run, and therefore how it has to be
// stopped and started. Preflight detects it (preflight.DetectDeploymentKind
// is an alias for this type's detector-side constants) and records it in
// the checkpoint's Topology, so a phase running later controls the same
// thing preflight observed rather than re-guessing.
type Kind string
const (
Systemd Kind = "systemd"
Docker Kind = "docker"
Unknown Kind = "unknown"
)
// Options names the thing to control. Only the field matching Kind is used.
type Options struct {
Kind Kind
UnitName string // systemd; defaults to "stalwart"
ContainerName string // docker; defaults to "stalwart"
}
// Controller stops and starts one Stalwart deployment. It is deliberately
// the only thing in this tool that shells out to systemctl or docker, for
// the same reason stalwartapi is the only thing that speaks JMAP: the
// commands that can take mail delivery down belong in one auditable place,
// not scattered across the phases that happen to need them.
type Controller interface {
// Stop stops the service and returns once the command reports success.
// It does not wait for the process to actually be gone - use WaitFor
// for that, since both systemd and docker can report success while the
// unit is still shutting down.
Stop(ctx context.Context) error
// Start starts the service.
Start(ctx context.Context) error
// Active reports whether the service is currently running (or still
// transitioning into or out of running). An error means the state
// couldn't be determined at all - which is different from, and must
// never be silently collapsed into, "not running".
Active(ctx context.Context) (bool, error)
// ReloadConfig re-reads unit/service definitions after one has been
// rewritten on disk. It's a no-op for deployments that don't have such
// a step, so callers never need to branch on Kind.
ReloadConfig(ctx context.Context) error
// Target describes what this controller acts on, for operator-facing
// messages ("stopped systemd unit stalwart").
Target() string
}
// New returns a Controller for the given deployment. It refuses an Unknown
// (or unrecognized) kind rather than guessing: picking the wrong mechanism
// here means a phase that reports "service stopped" while the instance is
// still running and holding the data directory open, which is exactly the
// kind of quiet wrongness this tool exists to avoid.
func New(o Options) (Controller, error) {
switch o.Kind {
case Systemd:
unit := o.UnitName
if unit == "" {
unit = "stalwart"
}
return &systemdController{unit: unit}, nil
case Docker:
name := o.ContainerName
if name == "" {
name = "stalwart"
}
return &dockerController{container: name}, nil
case Unknown, "":
return nil, fmt.Errorf("service: deployment kind is unknown - this tool won't guess how to stop Stalwart; re-run preflight, or name the systemd unit or docker container explicitly")
default:
return nil, fmt.Errorf("service: unsupported deployment kind %q", o.Kind)
}
}
// WaitFor polls c.Active until it reports want, or timeout elapses. Both
// systemctl and docker return as soon as the *request* to stop succeeded,
// so without this a caller would move on to overwriting the data directory
// while the old process still had it open.
func WaitFor(ctx context.Context, c Controller, want bool, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
var lastErr error
for {
active, err := c.Active(ctx)
if err != nil {
lastErr = err
} else if active == want {
return nil
}
if !time.Now().Before(deadline) {
break
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(250 * time.Millisecond):
}
}
state := "stopped"
if want {
state = "running"
}
if lastErr != nil {
return fmt.Errorf("service: %s was still not %s after %s, and its state couldn't be read: %w", c.Target(), state, timeout, lastErr)
}
return fmt.Errorf("service: %s was still not %s after %s", c.Target(), state, timeout)
}
type systemdController struct{ unit string }
func (s *systemdController) Target() string { return "systemd unit " + s.unit }
func (s *systemdController) Stop(ctx context.Context) error { return s.run(ctx, "stop") }
func (s *systemdController) Start(ctx context.Context) error { return s.run(ctx, "start") }
func (s *systemdController) ReloadConfig(ctx context.Context) error {
return runCommand(ctx, "systemctl", "daemon-reload")
}
func (s *systemdController) run(ctx context.Context, verb string) error {
return runCommand(ctx, "systemctl", verb, s.unit)
}
// Active maps `systemctl is-active` output rather than its exit status:
// the command exits non-zero for every not-active state, so treating a
// non-zero exit as a failure to read the state would make "inactive" -
// the answer we most want - look like an error.
func (s *systemdController) Active(ctx context.Context) (bool, error) {
out, err := exec.CommandContext(ctx, "systemctl", "is-active", s.unit).Output()
state := strings.TrimSpace(string(out))
switch state {
case "active", "activating", "reloading", "deactivating":
// deactivating counts as active on purpose: it means the old
// process is still there, which is precisely what a caller waiting
// for a clean stop must not mistake for "gone".
return true, nil
case "inactive", "failed":
return false, nil
}
if err != nil {
return false, fmt.Errorf("service: systemctl is-active %s: %w (output: %q)", s.unit, err, state)
}
return false, fmt.Errorf("service: systemctl is-active %s returned unrecognized state %q", s.unit, state)
}
type dockerController struct{ container string }
func (d *dockerController) Target() string { return "docker container " + d.container }
func (d *dockerController) Stop(ctx context.Context) error {
return runCommand(ctx, "docker", "stop", d.container)
}
func (d *dockerController) Start(ctx context.Context) error {
return runCommand(ctx, "docker", "start", d.container)
}
// ReloadConfig is a no-op: a container has no equivalent of daemon-reload -
// a changed Compose file takes effect when the container is recreated, and
// recreating containers is beyond what this controller does.
func (d *dockerController) ReloadConfig(context.Context) error { return nil }
func (d *dockerController) Active(ctx context.Context) (bool, error) {
out, err := exec.CommandContext(ctx, "docker", "inspect", "-f", "{{.State.Running}}", d.container).Output()
state := strings.TrimSpace(string(out))
switch state {
case "true":
return true, nil
case "false":
return false, nil
}
if err != nil {
return false, fmt.Errorf("service: docker inspect %s: %w (output: %q)", d.container, err, state)
}
return false, fmt.Errorf("service: docker inspect %s returned unrecognized state %q", d.container, state)
}
func runCommand(ctx context.Context, name string, args ...string) error {
out, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
if err != nil {
return fmt.Errorf("service: %s %s: %w (output: %s)", name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
}
return nil
}