Persist Postfix queue and ship self-contained full backups.

Move the mail queue under /data so recreate no longer drops deferred mail, and archive data/, compose, .env, and certs/ together for restore on a fresh host.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-17 14:36:55 +03:00
parent c6a75ce775
commit 41c3e6e896
23 changed files with 408 additions and 170 deletions
+4
View File
@@ -33,6 +33,8 @@ var documentedInternal = []string{
"SASL_REALM",
"POSTFIX_DIR",
"POSTFIX_SENDER_LOGIN_MAPS",
"POSTFIX_QUEUE_DIR",
"SELFPOST_DEPLOY_ROOT",
"MILTER_CONNECT_TIMEOUT",
"MILTER_COMMAND_TIMEOUT",
"MILTER_CONTENT_TIMEOUT",
@@ -70,6 +72,7 @@ var loadConfigKeys = []string{
"SASL_DB_PATH",
"SASL_REALM",
"POSTFIX_DIR",
"SELFPOST_DEPLOY_ROOT",
}
// buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh
@@ -84,6 +87,7 @@ var buildScriptKeys = []string{
"JOURNAL_MILTER_SOCKET",
"MAIL_LOG",
"POSTFIX_SENDER_LOGIN_MAPS",
"POSTFIX_QUEUE_DIR",
"SASL_DB_PATH",
"SUBMISSION_ENABLE",
"MILTER_CONNECT_TIMEOUT",
+1
View File
@@ -73,6 +73,7 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
MailLogPath: cfg.mailLog,
DataDir: cfg.dataDir,
DBPath: cfg.dbPath,
DeployRoot: cfg.deployRoot,
Version: buildinfo.Version,
TrustedProxyCIDRs: cfg.trustedProxies,
TLSCertFile: cfg.tlsCertFile,
+2
View File
@@ -78,6 +78,7 @@ type config struct {
saslDBPath string
saslRealm string
postfixDir string
deployRoot string
}
func loadConfig() config {
@@ -138,6 +139,7 @@ func loadConfig() config {
saslDBPath: envDefault("SASL_DB_PATH", filepath.Join(dataDir, "sasl", "sasldb2")),
saslRealm: saslRealm(),
postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")),
deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
}
}
+60 -26
View File
@@ -22,7 +22,7 @@ import (
)
// Restoring a SelfPost backup is not a code path in the panel: the operator
// extracts the archive into the /data bind mount and starts the image, and the
// extracts the archive into a project directory and starts the image, and the
// panel is expected to come up on it (architecture.md § Persistence). Nothing
// below stubs that story out — the archive is downloaded from a running panel
// through /backup, unpacked the way `tar -xzf` unpacks it, and a second panel
@@ -37,9 +37,10 @@ const (
// restored is the outcome of a full backup-and-restore round trip.
type restored struct {
panel http.Handler // panel booted on the restored data directory
dataDir string // the restored /data
session *http.Cookie // a session opened before the backup was taken
panel http.Handler // panel booted on the restored data directory
deployRoot string // the restored project directory
dataDir string // the restored /data
session *http.Cookie // a session opened before the backup was taken
}
// restoreFromOwnBackup runs the operator's path end to end: seed a panel that
@@ -50,7 +51,7 @@ type restored struct {
func restoreFromOwnBackup(t *testing.T, password string) restored {
t.Helper()
live := seedPanelData(t)
live := seedPanelProject(t)
panel := bootPanel(t, live)
session := signIn(t, panel)
archive := downloadBackup(t, panel, session, password)
@@ -58,7 +59,12 @@ func restoreFromOwnBackup(t *testing.T, password string) restored {
target := t.TempDir()
extract(t, archive, target)
return restored{panel: bootPanel(t, target), dataDir: target, session: session}
return restored{
panel: bootPanel(t, target),
deployRoot: target,
dataDir: filepath.Join(target, "data"),
session: session,
}
}
// The panel has to come up on the restored directory and show the state that
@@ -91,6 +97,12 @@ func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
t.Errorf("%s = %q, want %q", path, got, want)
}
}
for _, name := range []string{backup.ComposeFileName, backup.EnvFileName} {
if _, err := os.Stat(filepath.Join(r.deployRoot, name)); err != nil {
t.Errorf("the restored project directory has no %s: %v", name, err)
}
}
}
// The one-time setup link is closed by the presence of a panel user, and the
@@ -138,8 +150,9 @@ func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) {
// A restore boot runs one Resync from SQLite. If the archive's Postfix map
// drifted from the database, that step puts it back before mail flows.
func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) {
dataDir := seedPanelData(t)
cfg := panelConfig(t, dataDir)
deployRoot := seedPanelProject(t)
dataDir := filepath.Join(deployRoot, "data")
cfg := panelConfig(t, deployRoot)
mapPath := filepath.Join(dataDir, "postfix", "sender_login_maps")
if err := os.WriteFile(mapPath, []byte("stale map\n"), 0o640); err != nil {
@@ -191,12 +204,14 @@ func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) {
// stays put on a mismatch: the operator's next move is to start the image the
// backup names, and it has to be there when they do.
func TestPanelRefusesADataDirectoryRestoredFromAnotherVersion(t *testing.T) {
live := seedPanelData(t)
deployRoot := seedPanelProject(t)
dataDir := filepath.Join(deployRoot, "data")
var archive bytes.Buffer
if err := backup.Create(&archive, backup.Params{
DataDir: live,
DBPath: filepath.Join(live, "selfpost.db"),
Version: "9.9.9",
DataDir: dataDir,
DBPath: filepath.Join(dataDir, "selfpost.db"),
Version: "9.9.9",
DeployRoot: deployRoot,
}); err != nil {
t.Fatalf("create backup: %v", err)
}
@@ -218,13 +233,15 @@ func TestPanelRefusesADataDirectoryRestoredFromAnotherVersion(t *testing.T) {
}
}
// seedPanelData builds the /data tree of a panel that has been in use: an
// administrator, a sending domain with an application and one logged message,
// and the daemon state the mail path needs (a DKIM key, the SASL database and
// Postfix's sender map).
func seedPanelData(t *testing.T) string {
// seedPanelProject builds an operator project tree: data/ with a panel that has
// been in use, plus docker-compose.yml, .env, and certs/ for full backups.
func seedPanelProject(t *testing.T) string {
t.Helper()
dataDir := t.TempDir()
deployRoot := t.TempDir()
dataDir := filepath.Join(deployRoot, "data")
if err := os.MkdirAll(dataDir, 0o750); err != nil {
t.Fatalf("mkdir data: %v", err)
}
st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
if err != nil {
@@ -268,16 +285,33 @@ func seedPanelData(t *testing.T) string {
t.Fatalf("write %s: %v", full, err)
}
}
return dataDir
writeDeployFile(t, filepath.Join(deployRoot, backup.ComposeFileName), "services:\n selfpost:\n image: test\n")
writeDeployFile(t, filepath.Join(deployRoot, backup.EnvFileName), "SELFPOST_HOSTNAME=mail.example.ru\n")
writeDeployFile(t, filepath.Join(deployRoot, backup.CertsDirName, "fullchain.pem"), "CERT")
writeDeployFile(t, filepath.Join(deployRoot, backup.CertsDirName, "privkey.pem"), "KEY")
return deployRoot
}
// panelConfig resolves the panel's own configuration for a data directory, so
func writeDeployFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
// panelConfig resolves the panel's own configuration for a project directory, so
// the test finds the files where the running binary would look for them rather
// than where it put them. Cookies are marked insecure for the same reason the
// e2e stand does it: the test client speaks plain HTTP.
func panelConfig(t *testing.T, dataDir string) config {
func panelConfig(t *testing.T, deployRoot string) config {
t.Helper()
dataDir := filepath.Join(deployRoot, "data")
t.Setenv("SELFPOST_DATA_DIR", dataDir)
t.Setenv("SELFPOST_DEPLOY_ROOT", deployRoot)
t.Setenv("PANEL_COOKIE_SECURE", "false")
t.Setenv("SELFPOST_HOSTNAME", "mail.example.ru")
// MAIL_LOG's default is an absolute path, not one derived from the data
@@ -288,13 +322,13 @@ func panelConfig(t *testing.T, dataDir string) config {
// bootPanel performs the startup sequence run() performs, in the same order,
// and returns the panel's HTTP handler.
func bootPanel(t *testing.T, dataDir string) http.Handler {
func bootPanel(t *testing.T, deployRoot string) http.Handler {
t.Helper()
cfg := panelConfig(t, dataDir)
cfg := panelConfig(t, deployRoot)
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
if err != nil {
t.Fatalf("the panel refused to start on %s: %v", dataDir, err)
t.Fatalf("the panel refused to start on %s: %v", deployRoot, err)
}
if restored {
if _, err := os.Stat(cfg.manifestPath); err == nil {
@@ -380,8 +414,8 @@ func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password
return plain
}
// extract unpacks a backup archive into dir, as `tar -xzf` does onto the /data
// bind mount before the image is started.
// extract unpacks a backup archive into dir, as `tar -xzf` does onto the project
// directory before the image is started.
func extract(t *testing.T, archive []byte, dir string) {
t.Helper()
gz, err := gzip.NewReader(bytes.NewReader(archive))
+10 -6
View File
@@ -6,9 +6,9 @@
//
// docker exec <container> selfpost-backup > selfpost-backup.tar.gz
//
// Use -o to write to a file instead. The resulting archive contains DKIM private
// keys, the admin password hash and SASL credentials — treat it as a secret
// (architecture.md § Persistence).
// Use -o to write to a file instead. The resulting archive is self-contained:
// data/, docker-compose.yml, .env, and certs/ when present — treat it as a
// secret (architecture.md § Persistence).
//
// Given a password (SELFPOST_BACKUP_PASSWORD or -password-file, never an
// argument, which would show up in the process list) the archive is written as
@@ -87,9 +87,13 @@ func run(outPath, password string) error {
}
if err := backup.Create(sink, backup.Params{
DataDir: dataDir,
DBPath: dbPath,
Version: buildinfo.Version,
DataDir: dataDir,
DBPath: dbPath,
Version: buildinfo.Version,
DeployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
OnWarn: func(msg string) {
fmt.Fprintf(os.Stderr, "selfpost-backup: %s\n", msg)
},
}); err != nil {
return err
}
+32 -7
View File
@@ -9,13 +9,18 @@ import (
"strings"
"testing"
"github.com/mixeme/selfpost/internal/backup"
"github.com/mixeme/selfpost/internal/store"
)
// seedDataDir builds the minimum /data tree a backup can be taken from.
func seedDataDir(t *testing.T) string {
// seedProject builds the minimum operator project tree a backup can be taken from.
func seedProject(t *testing.T) (deployRoot, dataDir string) {
t.Helper()
dataDir := t.TempDir()
deployRoot = t.TempDir()
dataDir = filepath.Join(deployRoot, "data")
if err := os.MkdirAll(dataDir, 0o750); err != nil {
t.Fatalf("mkdir data: %v", err)
}
st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
if err != nil {
t.Fatalf("open store: %v", err)
@@ -26,16 +31,31 @@ func seedDataDir(t *testing.T) string {
if err := st.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
writeFile(t, filepath.Join(deployRoot, backup.ComposeFileName), "services:\n selfpost:\n image: test\n")
writeFile(t, filepath.Join(deployRoot, backup.EnvFileName), "SELFPOST_HOSTNAME=mail.example.com\n")
writeFile(t, filepath.Join(deployRoot, backup.CertsDirName, "fullchain.pem"), "CERT")
writeFile(t, filepath.Join(deployRoot, backup.CertsDirName, "privkey.pem"), "KEY")
t.Setenv("SELFPOST_DATA_DIR", dataDir)
t.Setenv("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db"))
return dataDir
t.Setenv("SELFPOST_DEPLOY_ROOT", deployRoot)
return deployRoot, dataDir
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
// An encrypted backup is only worth having if the container it came from can
// hand it back as an ordinary archive during a restore, so the two halves of
// the CLI are tested as the one round trip an operator actually performs.
func TestEncryptedBackupRoundTrip(t *testing.T) {
seedDataDir(t)
seedProject(t)
dir := t.TempDir()
encrypted := filepath.Join(dir, "backup.spbk")
plain := filepath.Join(dir, "backup.tar.gz")
@@ -81,7 +101,12 @@ func TestEncryptedBackupRoundTrip(t *testing.T) {
}
names[hdr.Name] = true
}
for _, want := range []string{"manifest.json", "selfpost.db"} {
for _, want := range []string{
backup.DataArchivePrefix + backup.ManifestName,
backup.DataArchivePrefix + "selfpost.db",
backup.ComposeFileName,
backup.EnvFileName,
} {
if !names[want] {
t.Errorf("decrypted archive has no %s (entries: %v)", want, names)
}
@@ -91,7 +116,7 @@ func TestEncryptedBackupRoundTrip(t *testing.T) {
// Without a password the CLI keeps producing the plain archive that existing
// backup scripts consume.
func TestUnencryptedBackupStaysPlain(t *testing.T) {
seedDataDir(t)
seedProject(t)
out := filepath.Join(t.TempDir(), "backup.tar.gz")
if err := run(out, ""); err != nil {
t.Fatalf("create backup: %v", err)