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.
196 lines
7.2 KiB
Go
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
|
|
}
|