e2e: the run ends on port 25, with a report, junk and inbound mail
Until now the suite only read back the configuration generated for the two optional port-25 features. DMARC ingest shipped in 1.7.0 with a delivery service Postfix could not start, and nothing sent a report through it until a real one arrived on the test server on 2026-10-11. The last steps recreate the container with INBOUND_RELAY_ENABLE and DMARC_REPORTS_ENABLE on, over the same /data, and talk to port 25 as a stranger's mail server: - the container log has no postconf warning, the session survives; - a domain takes the SelfPost-hosted report address; a gzip report mailed to it appears under Outbound > DMARC reports; an address nobody chose is refused at RCPT; - a report on another domain and a message with no report are accepted, dropped with "dropped: ..." on their delivery lines, counted as two parse failures, absent from the queue and from the reports; - an inbound domain with the sink as upstream and one listed recipient: mail for it reaches the sink, another recipient and another domain are refused at RCPT. The stage directory now holds docker-compose.yml and .env, as an operator's project directory does (a full backup needs them; the restore scenario that uses it is not in this commit - the panel's full backup turned out not to work, see the next one). make e2e on the test server: ok. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+35
-2
@@ -14,8 +14,41 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
||||
time. It is now accepted and discarded - no retry, and no bounce, since the
|
||||
envelope sender of such a message is whatever its author wrote. It counts
|
||||
as one parse failure and the reason is in the mail log, on the message's
|
||||
own delivery line (Server › System log, `dropped: …`). Only a failure of the database is still
|
||||
retried.
|
||||
own delivery line (Server › System log, `dropped: …`). Only a failure of
|
||||
the database is still retried.
|
||||
- DMARC reports are kept 500 **for each domain** (and 90 days), not 500 for
|
||||
the whole server - see Security.
|
||||
|
||||
### Security
|
||||
|
||||
Review of the DMARC report path (Fable, 2026-10-11), which started to run for
|
||||
the first time in 2.0.0. Nothing Critical or High.
|
||||
|
||||
- **A report is bounded before it is stored.** The XML comes from anyone who
|
||||
knows a report address, and its 8 MiB limit left room for some 160 000
|
||||
records or a single name of megabytes - rows in the database and cells of
|
||||
the report page. A report holds at most 5 000 records (a larger one is
|
||||
dropped), every text field is cut to 255 bytes, and a record's count is
|
||||
capped so the sums cannot wrap.
|
||||
- **One domain's reports cannot push out another's.** The cap of 500 was for
|
||||
the whole server, so a stranger filing reports under one domain's address
|
||||
could evict every other domain's history. It is counted per domain now.
|
||||
- The delivery command runs one at a time, not up to a hundred in parallel.
|
||||
- The reason written to the mail log for a dropped message is cut to 200
|
||||
bytes and anything outside printable ASCII is replaced: parts of it are the
|
||||
sender's own bytes (a header line, a file name inside an archive).
|
||||
- `SELFPOST_DATA_DIR` / `SELFPOST_DB_PATH` with whitespace or `$` are refused
|
||||
at start when DMARC ingest is on, instead of handing the delivery command
|
||||
another path and so an empty database of its own.
|
||||
|
||||
### Added
|
||||
|
||||
- e2e: the run now ends on port 25 with both optional features on. A DMARC
|
||||
report is mailed to a domain's hosted address and has to appear under
|
||||
Outbound › DMARC reports; a report on another domain and a message with no
|
||||
report are accepted and dropped; mail for an inbound domain reaches its
|
||||
upstream and other recipients are refused. Until now the suite only read
|
||||
back the generated configuration of these features.
|
||||
|
||||
## [2.0.0] - 2026-10-11
|
||||
|
||||
|
||||
+22
-2
@@ -70,6 +70,12 @@ const (
|
||||
recipient = "rcpt@sink.e2e.test"
|
||||
)
|
||||
|
||||
// The one administrator of the run.
|
||||
const (
|
||||
adminUser = "e2eadmin"
|
||||
adminPassword = "correct-horse-battery-staple"
|
||||
)
|
||||
|
||||
// scenario carries state forward between the ordered subtests of TestE2E:
|
||||
// each later step needs something an earlier one produced (the domain id, the
|
||||
// DKIM record, the application credentials, an observed client IP).
|
||||
@@ -131,10 +137,10 @@ func TestE2E(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := p.setup(token, "e2eadmin", "correct-horse-battery-staple"); err != nil {
|
||||
if err := p.setup(token, adminUser, adminPassword); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
if err := p.login("e2eadmin", "correct-horse-battery-staple"); err != nil {
|
||||
if err := p.login(adminUser, adminPassword); err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
sc.panel = p
|
||||
@@ -268,6 +274,20 @@ func TestE2E(t *testing.T) {
|
||||
run("negative_level1_ratelimit", func(t *testing.T) {
|
||||
testLevel1RateLimit(t, sc)
|
||||
})
|
||||
|
||||
// From here on the stand accepts mail on port 25.
|
||||
run("enable_port25_features", func(t *testing.T) {
|
||||
testEnablePort25Features(t, sc)
|
||||
})
|
||||
run("dmarc_report_reaches_the_panel", func(t *testing.T) {
|
||||
testDMARCReportReachesThePanel(t, sc)
|
||||
})
|
||||
run("dmarc_junk_is_dropped", func(t *testing.T) {
|
||||
testDMARCJunkIsDropped(t, sc)
|
||||
})
|
||||
run("inbound_relay_delivers_and_refuses", func(t *testing.T) {
|
||||
testInboundRelay(t, sc)
|
||||
})
|
||||
}
|
||||
|
||||
// trimZoneSuffix strips the "e2e.test" suffix a fully-qualified DKIM record
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"time"
|
||||
)
|
||||
|
||||
// port25Addr is where compose.override.yml publishes port 25: no TLS required,
|
||||
// no AUTH offered - a stranger's mail server talking to this one.
|
||||
const port25Addr = "127.0.0.1:20025"
|
||||
|
||||
// deliverPort25 is one transaction on port 25 the way another mail server makes
|
||||
// it: HELO, MAIL, RCPT, DATA with message as it stands. The result says at
|
||||
// which stage it was refused, like attemptSend's.
|
||||
func deliverPort25(from, to, message string) sendResult {
|
||||
var res sendResult
|
||||
conn, err := net.DialTimeout("tcp", port25Addr, 10*time.Second)
|
||||
if err != nil {
|
||||
res.dialErr = fmt.Errorf("dial %s: %w", port25Addr, err)
|
||||
return res
|
||||
}
|
||||
c, err := smtp.NewClient(conn, selfpostHostname)
|
||||
if err != nil {
|
||||
res.dialErr = fmt.Errorf("smtp handshake: %w", err)
|
||||
_ = conn.Close()
|
||||
return res
|
||||
}
|
||||
defer c.Close()
|
||||
if err := c.Hello("mx.stranger.e2e.test"); err != nil {
|
||||
res.dialErr = err
|
||||
return res
|
||||
}
|
||||
if err := c.Mail(from); err != nil {
|
||||
res.mailErr = err
|
||||
_ = c.Quit()
|
||||
return res
|
||||
}
|
||||
if err := c.Rcpt(to); err != nil {
|
||||
res.rcptErr = err
|
||||
_ = c.Quit()
|
||||
return res
|
||||
}
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
res.dataErr = err
|
||||
_ = c.Quit()
|
||||
return res
|
||||
}
|
||||
if _, err := w.Write([]byte(message)); err != nil {
|
||||
res.dataErr = err
|
||||
} else if err := w.Close(); err != nil {
|
||||
res.dataErr = err
|
||||
}
|
||||
_ = c.Quit()
|
||||
return res
|
||||
}
|
||||
|
||||
// waitForPort25Ready polls port 25 until Postfix greets on it. A bare connect
|
||||
// is not enough: Docker's proxy accepts on the published port whether or not
|
||||
// anything listens behind it.
|
||||
func waitForPort25Ready(timeout time.Duration) error {
|
||||
return waitFor("port 25 to greet", timeout, 500*time.Millisecond, func() (bool, error) {
|
||||
conn, err := net.DialTimeout("tcp", port25Addr, 2*time.Second)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
buf := make([]byte, 4)
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if string(buf[:n]) != "220 " {
|
||||
return false, fmt.Errorf("greeting starts with %q", buf[:n])
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The stand starts with both port-25 features off, as a new installation does.
|
||||
// The last part of the run switches them on and uses them from outside, the
|
||||
// way a stranger's mail server would: until 2.0.1 the suite only read back the
|
||||
// generated configuration, and DMARC ingest shipped in 1.7.0 with a delivery
|
||||
// service Postfix could not start - nothing ever sent a report through it.
|
||||
|
||||
// testEnablePort25Features recreates the container with the inbound relay and
|
||||
// DMARC ingest on, over the same /data.
|
||||
func testEnablePort25Features(t *testing.T, sc *scenario) {
|
||||
h.env = append(h.env, "INBOUND_RELAY_ENABLE=true", "DMARC_REPORTS_ENABLE=true")
|
||||
if err := h.recreateSelfpost(); err != nil {
|
||||
t.Fatalf("recreate selfpost with the port-25 features on: %v", err)
|
||||
}
|
||||
if err := waitForPanelReady(); err != nil {
|
||||
t.Fatalf("panel after enabling the port-25 features: %v\n%s", err, h.logs("selfpost"))
|
||||
}
|
||||
if err := waitForSMTPSReady(60 * time.Second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := waitForPort25Ready(60 * time.Second); err != nil {
|
||||
t.Fatalf("%v\n%s", err, h.logs("selfpost"))
|
||||
}
|
||||
if err := checkSupervisorProcesses(h); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Nothing in the container's own output may complain about the generated
|
||||
// configuration: the broken delivery service of 1.7.0-1.9.5 was announced
|
||||
// there at every start ("unmatched service_name/type") and went unread.
|
||||
logs := h.logs("selfpost")
|
||||
for _, bad := range []string{"unmatched service_name", "postconf: warning", "postconf: fatal"} {
|
||||
if strings.Contains(logs, bad) {
|
||||
t.Fatalf("the container log has %q with the port-25 features on:\n%s", bad, logs)
|
||||
}
|
||||
}
|
||||
resp, err := sc.panel.status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Request.URL.Path != "/overview" {
|
||||
t.Fatalf("the session did not survive the recreate: landed on %s", resp.Request.URL.Path)
|
||||
}
|
||||
}
|
||||
|
||||
const reportReporter = "reporter.e2e.test"
|
||||
|
||||
// aggregateXML is a DMARC aggregate report on domain, as a receiver sends it.
|
||||
func aggregateXML(domain, reportID string) string {
|
||||
return `<?xml version="1.0"?>
|
||||
<feedback>
|
||||
<report_metadata>
|
||||
<org_name>` + reportReporter + `</org_name>
|
||||
<email>noreply@` + reportReporter + `</email>
|
||||
<report_id>` + reportID + `</report_id>
|
||||
<date_range><begin>1723593600</begin><end>1723680000</end></date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>` + domain + `</domain>
|
||||
<p>none</p><sp>none</sp><pct>100</pct>
|
||||
<adkim>r</adkim><aspf>r</aspf>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>203.0.113.10</source_ip>
|
||||
<count>7</count>
|
||||
<policy_evaluated>
|
||||
<disposition>none</disposition>
|
||||
<dkim>pass</dkim><spf>pass</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers><header_from>` + domain + `</header_from></identifiers>
|
||||
</record>
|
||||
</feedback>
|
||||
`
|
||||
}
|
||||
|
||||
// reportMessage is the mail a receiver sends a report in: one gzip attachment,
|
||||
// base64 in 76-column lines.
|
||||
func reportMessage(t *testing.T, to, xml string) string {
|
||||
t.Helper()
|
||||
var gz bytes.Buffer
|
||||
zw := gzip.NewWriter(&gz)
|
||||
if _, err := zw.Write([]byte(xml)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encoded := base64.StdEncoding.EncodeToString(gz.Bytes())
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "From: noreply@%s\r\nTo: %s\r\nSubject: Report domain: %s\r\nDate: %s\r\nMIME-Version: 1.0\r\n",
|
||||
reportReporter, to, senderDomain, time.Now().UTC().Format(time.RFC1123Z))
|
||||
b.WriteString("Content-Type: application/gzip; name=\"report.xml.gz\"\r\nContent-Transfer-Encoding: base64\r\n\r\n")
|
||||
for len(encoded) > 76 {
|
||||
b.WriteString(encoded[:76] + "\r\n")
|
||||
encoded = encoded[76:]
|
||||
}
|
||||
b.WriteString(encoded + "\r\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// testDMARCReportReachesThePanel gives the sending domain a SelfPost-hosted
|
||||
// report address and mails a report to it on port 25. It has to show up under
|
||||
// Outbound > DMARC reports.
|
||||
func testDMARCReportReachesThePanel(t *testing.T, sc *scenario) {
|
||||
address := "dmarc-reports+" + senderDomain + "@" + selfpostHostname
|
||||
resp, body, err := sc.panel.postForm("/outbound/domains/"+sc.domainID+"/settings/reports", url.Values{"dmarc_rua_mode": {"hosted"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != 200 || resp.Request.URL.Path != "/outbound/domains/"+sc.domainID {
|
||||
t.Fatalf("choosing the hosted report address: status %d, landed on %s:\n%.800s", resp.StatusCode, resp.Request.URL.Path, body)
|
||||
}
|
||||
|
||||
// An address nobody chose is refused before DATA, hosted form or not.
|
||||
res := deliverPort25("noreply@"+reportReporter, "dmarc-reports+nobody.e2e.test@"+selfpostHostname, "Subject: x\r\n\r\nx\r\n")
|
||||
if res.rcptErr == nil {
|
||||
t.Fatalf("port 25 accepted a report address no domain uses (first error: %v)", res.firstErr())
|
||||
}
|
||||
|
||||
res = deliverPort25("noreply@"+reportReporter, address, reportMessage(t, address, aggregateXML(senderDomain, "e2e-report-1")))
|
||||
if !res.ok() {
|
||||
t.Fatalf("port 25 did not accept a report at %s: %v", address, res.firstErr())
|
||||
}
|
||||
if err := waitFor("the report to appear under Outbound > DMARC reports", 30*time.Second, 500*time.Millisecond, func() (bool, error) {
|
||||
resp, body, err := sc.panel.get("/outbound/dmarc")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return false, fmt.Errorf("GET /outbound/dmarc = %d", resp.StatusCode)
|
||||
}
|
||||
if strings.Contains(body, reportReporter) {
|
||||
return true, nil
|
||||
}
|
||||
return false, fmt.Errorf("no report from %s on the page yet; mail log:\n%s", reportReporter, mailLogTail(h, "dmarc-ingest"))
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// testDMARCJunkIsDropped mails two things that are not reports this relay
|
||||
// keeps to the same address: a report on somebody else's domain, and a message
|
||||
// with no report in it. Each is accepted, dropped at once with the reason in
|
||||
// the mail log, and counted; neither stays in the queue to be tried again and
|
||||
// neither becomes a report.
|
||||
func testDMARCJunkIsDropped(t *testing.T, sc *scenario) {
|
||||
address := "dmarc-reports+" + senderDomain + "@" + selfpostHostname
|
||||
junk := map[string]string{
|
||||
"a report on another domain": reportMessage(t, address, aggregateXML("stranger.e2e.test", "e2e-report-2")),
|
||||
"a message with no report": "From: someone@stranger.e2e.test\r\nTo: " + address + "\r\nSubject: hello\r\n\r\nnot a report\r\n",
|
||||
}
|
||||
for what, msg := range junk {
|
||||
if res := deliverPort25("someone@stranger.e2e.test", address, msg); !res.ok() {
|
||||
t.Fatalf("%s was refused on port 25 instead of being accepted and dropped: %v", what, res.firstErr())
|
||||
}
|
||||
}
|
||||
|
||||
dropped := regexp.MustCompile(`status=sent \(delivered via dmarc-ingest service \(dropped: [^)]`)
|
||||
if err := waitFor("both messages to be dropped", 30*time.Second, 500*time.Millisecond, func() (bool, error) {
|
||||
log := mailLogTail(h, "dmarc-ingest")
|
||||
if n := len(dropped.FindAllString(log, -1)); n != len(junk) {
|
||||
return false, fmt.Errorf("%d of %d dropped so far; mail log:\n%s", n, len(junk), log)
|
||||
}
|
||||
return true, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
queue, err := h.execIn("selfpost", "postqueue", "-p")
|
||||
if err != nil {
|
||||
t.Fatalf("postqueue -p: %v\n%s", err, queue)
|
||||
}
|
||||
if !strings.Contains(queue, "Mail queue is empty") {
|
||||
t.Fatalf("a dropped message is still in the queue:\n%s", queue)
|
||||
}
|
||||
|
||||
_, body, err := sc.panel.get("/outbound/dmarc")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(body, "2 parse failures") {
|
||||
t.Fatalf("the DMARC page does not count the two dropped messages as parse failures:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "stranger.e2e.test") {
|
||||
t.Fatal("the report on another domain was stored")
|
||||
}
|
||||
}
|
||||
|
||||
// mailLogTail returns the lines of the end of the mail log that contain filter.
|
||||
func mailLogTail(s *stack, filter string) string {
|
||||
out, err := s.execIn("selfpost", "tail", "-n", "400", "/data/log/mail.log")
|
||||
if err != nil {
|
||||
return "(mail log unreadable: " + err.Error() + ")"
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.Contains(line, filter) {
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
const (
|
||||
inboundDomain = "in.e2e.test"
|
||||
inboundRecipient = "user@" + inboundDomain
|
||||
)
|
||||
|
||||
// testInboundRelay sets up an inbound domain with the sink as its upstream and
|
||||
// one listed recipient, then talks to port 25 as a stranger: mail for the
|
||||
// listed recipient reaches the upstream, mail for anyone else at the domain
|
||||
// and for a domain that is not configured is refused before DATA.
|
||||
func testInboundRelay(t *testing.T, sc *scenario) {
|
||||
resp, body, err := sc.panel.postForm("/inbound/domains", url.Values{"name": {inboundDomain}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := regexp.MustCompile(`^/inbound/domains/(\d+)$`).FindStringSubmatch(resp.Request.URL.Path)
|
||||
if m == nil {
|
||||
t.Fatalf("add inbound domain: landed on %s:\n%.800s", resp.Request.URL.Path, body)
|
||||
}
|
||||
base := "/inbound/domains/" + m[1]
|
||||
|
||||
// The sink by address: an upstream named by an IP is delivered to as it
|
||||
// stands, with no MX lookup in between.
|
||||
resp, body, err = sc.panel.postForm(base+"/upstream", url.Values{"host": {"10.77.0.11"}, "port": {"25"}, "tls_mode": {"none"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != 200 || resp.Request.URL.Path != base {
|
||||
t.Fatalf("set the upstream: status %d, landed on %s:\n%.800s", resp.StatusCode, resp.Request.URL.Path, body)
|
||||
}
|
||||
resp, body, err = sc.panel.postForm(base+"/recipients", url.Values{"recipient_mode": {"list"}, "addresses": {inboundRecipient}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != 200 || resp.Request.URL.Path != base {
|
||||
t.Fatalf("set the recipients: status %d, landed on %s:\n%.800s", resp.StatusCode, resp.Request.URL.Path, body)
|
||||
}
|
||||
|
||||
message := func(to, token string) string {
|
||||
return fmt.Sprintf("From: someone@stranger.e2e.test\r\nTo: %s\r\nSubject: e2e %s\r\nDate: %s\r\n\r\nmail for the inbound domain\r\n",
|
||||
to, token, time.Now().UTC().Format(time.RFC1123Z))
|
||||
}
|
||||
|
||||
token := uniqueToken("inbound")
|
||||
if res := deliverPort25("someone@stranger.e2e.test", inboundRecipient, message(inboundRecipient, token)); !res.ok() {
|
||||
t.Fatalf("port 25 did not accept mail for %s: %v", inboundRecipient, res.firstErr())
|
||||
}
|
||||
if _, err := findSinkMessage(h.stageDir, token, 30*time.Second); err != nil {
|
||||
t.Fatalf("mail for the inbound domain never reached its upstream: %v\n%s", err, mailLogTail(h, "postfix/"))
|
||||
}
|
||||
|
||||
for _, to := range []string{"nobody@" + inboundDomain, "someone@elsewhere.e2e.test"} {
|
||||
res := deliverPort25("someone@stranger.e2e.test", to, message(to, uniqueToken("refused")))
|
||||
if res.rcptErr == nil {
|
||||
t.Fatalf("port 25 accepted the recipient %s (first error: %v)", to, res.firstErr())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,10 @@ type stack struct {
|
||||
baseComposeFile string
|
||||
overrideFile string
|
||||
stageDir string
|
||||
// env is added to compose's own environment. The shipped compose file takes
|
||||
// the optional features from there (${INBOUND_RELAY_ENABLE:-false} and the
|
||||
// like), so this is how a later part of the run switches them on.
|
||||
env []string
|
||||
}
|
||||
|
||||
func newStack() (*stack, error) {
|
||||
@@ -64,6 +68,7 @@ func (s *stack) compose(args ...string) ([]byte, error) {
|
||||
// looked at (that mapping only reaches the container, not compose's own
|
||||
// variable interpolation), so it has to be set here too.
|
||||
cmd.Env = append(os.Environ(), "SELFPOST_HOSTNAME="+selfpostHostname)
|
||||
cmd.Env = append(cmd.Env, s.env...)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
@@ -102,6 +107,14 @@ func (s *stack) restartSelfpost() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// recreateSelfpost replaces the selfpost container with one started from the
|
||||
// same /data and the environment as it is now (see env), leaving the DNS and
|
||||
// sink sidecars and their state alone.
|
||||
func (s *stack) recreateSelfpost() error {
|
||||
_, err := s.compose("up", "-d", "--force-recreate", "--no-deps", "selfpost")
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *stack) down() {
|
||||
_, _ = s.compose("down", "-v", "--remove-orphans")
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ func prepareStage(s *stack) error {
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeDeployFiles(s); err != nil {
|
||||
return err
|
||||
}
|
||||
corefile, err := os.ReadFile(filepath.Join("dns", "Corefile"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read Corefile: %w", err)
|
||||
@@ -49,6 +52,22 @@ func prepareStage(s *stack) error {
|
||||
return writeZone(s.stageDir, nil)
|
||||
}
|
||||
|
||||
// writeDeployFiles puts docker-compose.yml and .env into the stage directory.
|
||||
// The stage directory is what the container sees as /selfpost-deploy, the
|
||||
// operator's project directory, and a full backup refuses to run unless both
|
||||
// files are there to be archived. compose reads neither: the stand names its
|
||||
// files with -f, and the .env repeats what compose is given anyway.
|
||||
func writeDeployFiles(s *stack) error {
|
||||
compose, err := os.ReadFile(s.baseComposeFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read the shipped compose file: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(s.stageDir, "docker-compose.yml"), compose, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(s.stageDir, ".env"), []byte("SELFPOST_HOSTNAME="+selfpostHostname+"\n"), 0o644)
|
||||
}
|
||||
|
||||
// removeAllBestEffort deletes path; if a previous run left container-UID files
|
||||
// on the bind mount, a root alpine one-shot removes them first.
|
||||
func removeAllBestEffort(path string) error {
|
||||
|
||||
Reference in New Issue
Block a user