test / test (push) Canceled after 0s
Code and config: - C-M7: a DMARC report's explicit <pct>0</pct> stays 0; only an absent pct takes RFC 7489 § 6.3's default 100 (Pct *int). - C-D1: docs/development.md now tables the Debian versions the citations were read at (postfix 3.7.11-0+deb12u1, opendkim and libopendkim11 2.11.0~beta2-8+deb12u1, libmilter1.0.1 8.17.1.9-2+deb12u2, libsasl2-2 and sasl2-bin 2.1.28+dfsg-10, supervisor 4.2.5-1, logrotate 3.21.0-1 - read off the image with dpkg-query, 2026-10-15); the e2e stand's new checkCitedPackageVersions fails when the image it built differs. - C-E2: the e2e zone's SOA serial only grows (CoreDNS v1.14.6's file plugin reloads on a grown serial, not on mtime); Corefile comment says so; TestZoneSerialGrows. - C-K8, C-K9: the e2e compose and stage comments say how --project-directory and .env resolve. - D-arch:154: postfix-config.sh's "Postfix insists" comment says what TLS_README advises and that smtpd reads the key as root. - B-G7: inflight.go's reservationTTL comment: a dropped client does give ABORT (smtpd_proto() -> mail_reset() -> milter_abort()); the TTL is for smtpd dying or the milter connection breaking. - D-guide:301: the nginx fragment's first-certificate command now reaches certbot (--entrypoint certbot, --standalone, port 80 free), and the renewal loop passes --webroot -w /var/www/certbot - a standalone certificate would otherwise renew standalone while nginx holds port 80 (certbot docs, "Renewing certificates": the issuing plugin is used "unless you specify other plugins or options", and options of a successful renewal are saved). The register's proposal missed that. Documents: every D-arch, D-guide and D-sec claim the register's Answers E found false or partly true, rewritten - by three Sonnet agents, one per document, then reviewed: two proposals were obsolete after this stage's fixes and not used (a bounce's From: line - extract_from = "smtp" now; "Origin: null" - Referrer-Policy is same-origin now); a stand observation the agent had dated from a commit is now tied to the e2e test that checks it; the SQLite writers paragraph describes _txlock=immediate; the log tailer's rotation paragraph describes the minute's grace; the guide's nginx steps were put in working order (links and modes before `up`, no reconfigure step). "Deferred" is kept for Postfix's queue in the guide too. guide.md's CRLF from the agent normalised. Stand (2026-10-15, make e2e, exit 0, the version gate and zone serial test included); go vet and go test ./... pass on the server (24 packages). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
146 lines
5.0 KiB
Go
146 lines
5.0 KiB
Go
package e2e
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
)
|
|
|
|
// project isolates this stand's containers/network from anything else on the
|
|
// host (in particular a prod SelfPost stack in an unrelated compose project —
|
|
// plan C.4). stageDir holds everything a single e2e run writes: the generated
|
|
// TLS cert, the /data bind mount, the DNS zone CoreDNS serves, and the mail
|
|
// sink's dump directory. It is never reused across runs.
|
|
const project = "selfpost-e2e"
|
|
|
|
// stack drives the compose lifecycle. baseCompose is the shipped
|
|
// deploy/docker-compose.yml — deliberately the real one, not a parallel test
|
|
// compose, so cap_drop/cap_add/no-new-privileges are exercised as documented
|
|
// rather than a laxer stand-in (plan C.4).
|
|
type stack struct {
|
|
repoRoot string
|
|
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) {
|
|
wd, err := os.Getwd() // test/e2e, where go.mod lives
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
repoRoot, err := filepath.Abs(filepath.Join(wd, "..", ".."))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stageDir, err := filepath.Abs(filepath.Join(wd, ".stage"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &stack{
|
|
repoRoot: repoRoot,
|
|
baseComposeFile: filepath.Join(repoRoot, "deploy", "docker-compose.yml"),
|
|
overrideFile: filepath.Join(wd, "compose.override.yml"),
|
|
stageDir: stageDir,
|
|
}, nil
|
|
}
|
|
|
|
// compose runs `docker compose <args...>` against this stand's two files and
|
|
// project directory/name, streaming output to the test log on failure.
|
|
func (s *stack) compose(args ...string) ([]byte, error) {
|
|
full := append([]string{
|
|
"compose",
|
|
"-p", project,
|
|
"-f", s.baseComposeFile,
|
|
"-f", s.overrideFile,
|
|
"--project-directory", s.stageDir,
|
|
}, args...)
|
|
cmd := exec.Command("docker", full...)
|
|
// deploy/docker-compose.yml interpolates ${SELFPOST_HOSTNAME:?...} at
|
|
// compose-file parse time, from the shell's environment or the project
|
|
// directory's .env, the shell first (docs.docker.com/compose/how-tos/
|
|
// environment-variables/variable-interpolation/; assumptions C-K9) - the
|
|
// override file's `environment:` mapping only reaches the container, not
|
|
// compose's own interpolation - so it is set here, where it wins.
|
|
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
|
|
err := cmd.Run()
|
|
if err != nil {
|
|
return out.Bytes(), fmt.Errorf("docker %v: %w\n%s", full, err, out.String())
|
|
}
|
|
return out.Bytes(), nil
|
|
}
|
|
|
|
// execIn runs a command inside the running service container via `docker
|
|
// compose exec -T`, the same way the harness checks supervisord/mail.log
|
|
// state without going through the panel (so those checks work even before an
|
|
// administrator account exists).
|
|
func (s *stack) execIn(service string, args ...string) (string, error) {
|
|
full := append([]string{"exec", "-T", service}, args...)
|
|
out, err := s.compose(full...)
|
|
return string(out), err
|
|
}
|
|
|
|
// expectedVersion is the version the stand's image is built with: the
|
|
// release's X.Y.Z when release.yml runs the suite, "e2e" for a local run. Its
|
|
// default must stay the default of VERSION in compose.override.yml.
|
|
func expectedVersion() string {
|
|
if v := os.Getenv("SELFPOST_E2E_VERSION"); v != "" {
|
|
return v
|
|
}
|
|
return "e2e"
|
|
}
|
|
|
|
func (s *stack) build() error {
|
|
_, err := s.compose("build")
|
|
return err
|
|
}
|
|
|
|
func (s *stack) up() error {
|
|
_, err := s.compose("up", "-d", "--force-recreate")
|
|
return err
|
|
}
|
|
|
|
// restartSelfpost restarts just the selfpost container the way `docker
|
|
// restart` would (plan C.4 check 8: a session must survive that), without
|
|
// tearing down the DNS/sink sidecars or their state.
|
|
func (s *stack) restartSelfpost() error {
|
|
_, err := s.compose("restart", "selfpost")
|
|
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")
|
|
}
|
|
|
|
// reclaimData chowns/chmods the /data bind mount from inside the still-running
|
|
// selfpost container so the host test user can delete it afterwards. Files
|
|
// written as panel/postfix (setup-token 0600, opendkim tree, sqlite) otherwise
|
|
// leave EACCES on TempDir/stage cleanup (CI hostname-gate + prepareStage).
|
|
func (s *stack) reclaimData() {
|
|
_, _ = s.execIn("selfpost", "sh", "-c", "chown -R root:root /data && chmod -R a+rwX /data")
|
|
}
|
|
|
|
// logs returns a service's combined stdout/stderr, for failure diagnostics.
|
|
func (s *stack) logs(service string) string {
|
|
out, _ := s.compose("logs", "--no-color", service)
|
|
return string(out)
|
|
}
|