release: cut 1.0.0
Pin compose and local trial to ghcr.io/mixeme/selfpost:1.0.0, close the CHANGELOG cut, and retire implementation-plan / v1.x-closure-plan. Includes the post-cut startup fixes needed for a green release e2e gate: root-owned TLS copies for postfix check, maillog_file_prefixes for /data, hostname gate and traversable /data, panel /healthz before setup, and setup-token / TempDir reclaim via docker exec. Co-Authored-By: Composer <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,7 +7,7 @@ name: release
|
|||||||
# can never drift apart (the invariant restore's version check in spec 7.5.A
|
# can never drift apart (the invariant restore's version check in spec 7.5.A
|
||||||
# depends on).
|
# depends on).
|
||||||
#
|
#
|
||||||
# Native per-architecture builds (plan implementation-plan.md C.4), not qemu:
|
# Native per-architecture builds (see docs/development.md), not qemu:
|
||||||
# running the full Postfix/OpenDKIM stack under emulation for the e2e gate
|
# running the full Postfix/OpenDKIM stack under emulation for the e2e gate
|
||||||
# below is impractically slow. Each arch builds, e2e-gates and pushes its own
|
# below is impractically slow. Each arch builds, e2e-gates and pushes its own
|
||||||
# tag on its own native runner; a merge job then combines them into the one
|
# tag on its own native runner; a merge job then combines them into the one
|
||||||
@@ -68,7 +68,7 @@ jobs:
|
|||||||
go-version: "1.26"
|
go-version: "1.26"
|
||||||
cache-dependency-path: test/e2e/go.sum
|
cache-dependency-path: test/e2e/go.sum
|
||||||
|
|
||||||
- name: e2e (gates publishing — see docs/implementation-plan.md C.4)
|
- name: e2e (gates publishing — see docs/development.md)
|
||||||
run: cd test/e2e && go test -v -timeout 20m ./...
|
run: cd test/e2e && go test -v -timeout 20m ./...
|
||||||
|
|
||||||
- name: Log in to ghcr.io
|
- name: Log in to ghcr.io
|
||||||
|
|||||||
+33
-4
@@ -5,6 +5,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-08-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- A **Delivery log** on each delivery's page (`/deliveries/{id}`): the
|
- A **Delivery log** on each delivery's page (`/deliveries/{id}`): the
|
||||||
@@ -30,6 +32,28 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Postfix config: copy TLS cert/key from the (often `:ro`, host-owned) mount
|
||||||
|
into `/etc/postfix/tls-internal` as `root:root` before `postconf` /
|
||||||
|
`postfix check`. Bind-mounted keys owned by the CI/host UID made
|
||||||
|
`postfix check` fail and the container exit before supervisord started.
|
||||||
|
- Postfix config: allow `maillog_file` under `/data` via
|
||||||
|
`maillog_file_prefixes=/var,/dev/stdout,/data`, and pin the `postlog`
|
||||||
|
master.cf service. After mail.log moved to `/data/log`, `postfix check`
|
||||||
|
fatally rejected the path (default prefixes are only `/var` and
|
||||||
|
`/dev/stdout`) and often left stderr/mail.log empty.
|
||||||
|
- E2e: read `/data/setup-token` via `docker compose exec` (file is `0600`
|
||||||
|
panel-owned; host `ReadFile` got permission denied on CI). Reclaim `/data`
|
||||||
|
ownership before TempDir/stage cleanup so panel/postfix UIDs do not fail
|
||||||
|
Go's `RemoveAll`.
|
||||||
|
- Entrypoint: check `SELFPOST_HOSTNAME` before `/data` setup (so a bad identity
|
||||||
|
fails with the FATAL text, not an earlier `set -e` abort), and `chmod 755
|
||||||
|
/data` after chown so OpenDKIM/Postfix can traverse bind mounts that arrive
|
||||||
|
as mode `0700` (Go `TempDir`, some host umasks) — otherwise KeyTable is
|
||||||
|
unreachable and the container crash-loops. E2e stand uses `restart: "no"` and
|
||||||
|
surfaces selfpost logs from the supervisor readiness check.
|
||||||
|
- E2e gate: wait for host-published `/healthz` before panel setup, and stop
|
||||||
|
ordered `TestE2E` subtests after a failure so a nil panel client cannot panic
|
||||||
|
and mask the real error (release CI on both amd64 and arm64).
|
||||||
- A send-log row could stay `queued` forever after the container was recreated.
|
- A send-log row could stay `queued` forever after the container was recreated.
|
||||||
`mail.log` moved from the ephemeral `/var/log` into the data volume
|
`mail.log` moved from the ephemeral `/var/log` into the data volume
|
||||||
(`/data/log/mail.log`, `./data/log/` on the host), so the delivery lines that
|
(`/data/log/mail.log`, `./data/log/` on the host), so the delivery lines that
|
||||||
@@ -61,10 +85,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
layering and route table match `web`→`store` and `POST /domains/import`;
|
layering and route table match `web`→`store` and `POST /domains/import`;
|
||||||
OpenDKIM drops to `opendkim` via `UserID`; guide drops the archived
|
OpenDKIM drops to `opendkim` via `UserID`; guide drops the archived
|
||||||
"spec 7.5" pointer, clarifies `POSTFIX_SENDER_LOGIN_MAPS` vs panel writes,
|
"spec 7.5" pointer, clarifies `POSTFIX_SENDER_LOGIN_MAPS` vs panel writes,
|
||||||
and states logrotate keeps 14 daily files. Intermediate CHANGELOG cuts vs
|
and states logrotate keeps 14 daily files. Intermediate CHANGELOG cuts from
|
||||||
the still-pinned compose `0.1.0` image are called out in the guide and
|
before the published `1.0.0` image are called out in the guide and
|
||||||
`development.md`. Roadmap / implementation-plan point at CHANGELOG
|
`development.md`. Roadmap points at CHANGELOG `[0.5.0]` Security and
|
||||||
`[0.5.0]` Security and refreshed `internal/web` size / symbol links.
|
refreshed `internal/web` size / symbol links.
|
||||||
- Full backups no longer carry `/data/log`. It is Postfix's raw log plus its
|
- Full backups no longer carry `/data/log`. It is Postfix's raw log plus its
|
||||||
fourteen rotated copies — diagnostic output rather than state to restore, and
|
fourteen rotated copies — diagnostic output rather than state to restore, and
|
||||||
otherwise by far the largest thing in the archive.
|
otherwise by far the largest thing in the archive.
|
||||||
@@ -94,6 +118,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
dev-host-specific workflow and `mixfed.ru` references removed from docs.
|
dev-host-specific workflow and `mixfed.ru` references removed from docs.
|
||||||
- `docs/development.md` and `.cursor/rules/agent-rules.mdc` translated to
|
- `docs/development.md` and `.cursor/rules/agent-rules.mdc` translated to
|
||||||
English; `roadmap.md` remains Russian (internal tracker).
|
English; `roadmap.md` remains Russian (internal tracker).
|
||||||
|
- Deploy pin and local-trial image tag set to `ghcr.io/mixeme/selfpost:1.0.0`
|
||||||
|
(compose and git tag `v1.0.0` cut together). Retired
|
||||||
|
`docs/implementation-plan.md` and `docs/v1.x-closure-plan.md`; Makefile,
|
||||||
|
`release.yml`, and e2e comments point at `docs/development.md`. Roadmap
|
||||||
|
v1.x documentation/deploy tail closed.
|
||||||
|
|
||||||
## [0.6.0] - 2026-08-08
|
## [0.6.0] - 2026-08-08
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ test:
|
|||||||
clean:
|
clean:
|
||||||
rm -rf bin
|
rm -rf bin
|
||||||
|
|
||||||
# Hermetic container e2e (plan implementation-plan.md C.4): separate Go module
|
# Hermetic container e2e (see docs/development.md): separate Go module
|
||||||
# under test/e2e so its test-only dependencies (DKIM verification) never enter
|
# under test/e2e so its test-only dependencies (DKIM verification) never enter
|
||||||
# this module's build graph. Builds the image fresh from this checkout, brings
|
# this module's build graph. Builds the image fresh from this checkout, brings
|
||||||
# up deploy/docker-compose.yml plus a test-only override on high ports and an
|
# up deploy/docker-compose.yml plus a test-only override on high ports and an
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ docker run --rm -d --name selfpost-try \
|
|||||||
-e SELFPOST_HOSTNAME=mail.local.test \
|
-e SELFPOST_HOSTNAME=mail.local.test \
|
||||||
-e PANEL_COOKIE_SECURE=false \
|
-e PANEL_COOKIE_SECURE=false \
|
||||||
-v selfpost-try-data:/data \
|
-v selfpost-try-data:/data \
|
||||||
ghcr.io/mixeme/selfpost:0.1.0
|
ghcr.io/mixeme/selfpost:1.0.0
|
||||||
```
|
```
|
||||||
|
|
||||||
**Get the setup URL** (pick one):
|
**Get the setup URL** (pick one):
|
||||||
|
|||||||
+57
-47
@@ -1,14 +1,69 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Container entrypoint (runs as root, PID 1 until it execs supervisord).
|
# Container entrypoint (runs as root, PID 1 until it execs supervisord).
|
||||||
#
|
#
|
||||||
|
# SELFPOST_HOSTNAME is checked first (plan B.3): it must fail fast with a clear
|
||||||
|
# message before /data normalisation or Postfix config, so a bad identity never
|
||||||
|
# looks like a permissions or packaging problem (and so the e2e hostname-gate
|
||||||
|
# tests see the FATAL text rather than an earlier set -e abort).
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# SELFPOST_HOSTNAME is an identity, not a setting with a safe default: it must
|
||||||
|
# simultaneously match the PTR/rDNS record, the certificate CN/SAN, and the
|
||||||
|
# Cyrus SASL realm (spec 5.2 p.3, 8). The panel (main.go saslRealm()) and
|
||||||
|
# postfix-config.sh each fall back independently when it's unset — to
|
||||||
|
# `localhost` and to the container hostname respectively — so accounts get
|
||||||
|
# written under one realm and looked up under another and authentication
|
||||||
|
# silently fails for every application, while HELO also stops matching the
|
||||||
|
# PTR record and mail that does go out lands in spam. No fallback can be
|
||||||
|
# correct, so fail loudly here, before either side of that split has a chance
|
||||||
|
# to run, rather than leave a green panel with broken mail.
|
||||||
|
if [ -z "$SELFPOST_HOSTNAME" ]; then
|
||||||
|
cat >&2 <<'EOF'
|
||||||
|
FATAL: SELFPOST_HOSTNAME is not set.
|
||||||
|
|
||||||
|
This is the mail server's identity: it becomes the Postfix HELO/EHLO name,
|
||||||
|
the Cyrus SASL realm that application passwords are looked up under, and it
|
||||||
|
must match the TLS certificate's CN/SAN as well as this server's PTR (reverse
|
||||||
|
DNS) record. There is no safe default — guessing any one of these wrong
|
||||||
|
breaks authentication for every application or sends outgoing mail to spam,
|
||||||
|
silently.
|
||||||
|
|
||||||
|
Set it to the mail server's fully-qualified domain name, e.g.:
|
||||||
|
|
||||||
|
SELFPOST_HOSTNAME=mail.example.com
|
||||||
|
|
||||||
|
in the .env file next to your docker-compose.yml (see deploy/.env.example).
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$SELFPOST_HOSTNAME" in
|
||||||
|
*[\ \ ]* | *://* | *:* )
|
||||||
|
echo "FATAL: SELFPOST_HOSTNAME must be a bare hostname (no scheme, port, or spaces): \"$SELFPOST_HOSTNAME\"" >&2
|
||||||
|
echo 'Example: SELFPOST_HOSTNAME=mail.example.com' >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
*.*)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "FATAL: SELFPOST_HOSTNAME must be a fully-qualified domain name (at least one dot): \"$SELFPOST_HOSTNAME\"" >&2
|
||||||
|
echo 'Example: SELFPOST_HOSTNAME=mail.example.com' >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
# The persistent root /data is a host bind mount (spec 9), so it arrives owned
|
# The persistent root /data is a host bind mount (spec 9), so it arrives owned
|
||||||
# by the host user (typically root), not by the unprivileged panel user that
|
# by the host user (typically root), not by the unprivileged panel user that
|
||||||
# actually writes the SQLite database, setup token and DKIM keys (spec 7.6.8).
|
# actually writes the SQLite database, setup token and DKIM keys (spec 7.6.8).
|
||||||
# Fix its ownership here — the one place still running as root — before handing
|
# Fix its ownership here — the one place still running as root — before handing
|
||||||
# off to supervisord, which starts the panel as the panel user.
|
# off to supervisord, which starts the panel as the panel user.
|
||||||
set -e
|
#
|
||||||
|
# Mode must stay world-traversable (0755): OpenDKIM and Postfix reach their
|
||||||
|
# trees under /data as other users. Go's testing.TempDir is 0700, and a bare
|
||||||
|
# chown would leave that mode in place — opendkim then cannot read KeyTable and
|
||||||
|
# the container crash-loops (e2e TestHostnameGate/valid_hostname_starts).
|
||||||
chown panel:panel /data
|
chown panel:panel /data
|
||||||
|
chmod 755 /data
|
||||||
# Restored backups or previously-created state may contain panel-owned files
|
# Restored backups or previously-created state may contain panel-owned files
|
||||||
# under /data; make sure they stay writable without disturbing anything that a
|
# under /data; make sure they stay writable without disturbing anything that a
|
||||||
# later phase deliberately hands to another service. /data/log is exempt: it is
|
# later phase deliberately hands to another service. /data/log is exempt: it is
|
||||||
@@ -83,51 +138,6 @@ chown opendkim:selfpost /run/opendkim
|
|||||||
chown panel:selfpost /run/selfpost
|
chown panel:selfpost /run/selfpost
|
||||||
chmod 2750 /run/opendkim /run/selfpost
|
chmod 2750 /run/opendkim /run/selfpost
|
||||||
|
|
||||||
# SELFPOST_HOSTNAME is an identity, not a setting with a safe default: it must
|
|
||||||
# simultaneously match the PTR/rDNS record, the certificate CN/SAN, and the
|
|
||||||
# Cyrus SASL realm (spec 5.2 p.3, 8). The panel (main.go saslRealm()) and
|
|
||||||
# postfix-config.sh each fall back independently when it's unset — to
|
|
||||||
# `localhost` and to the container hostname respectively — so accounts get
|
|
||||||
# written under one realm and looked up under another and authentication
|
|
||||||
# silently fails for every application, while HELO also stops matching the
|
|
||||||
# PTR record and mail that does go out lands in spam. No fallback can be
|
|
||||||
# correct, so fail loudly here, before either side of that split has a chance
|
|
||||||
# to run, rather than leave a green panel with broken mail.
|
|
||||||
if [ -z "$SELFPOST_HOSTNAME" ]; then
|
|
||||||
cat >&2 <<'EOF'
|
|
||||||
FATAL: SELFPOST_HOSTNAME is not set.
|
|
||||||
|
|
||||||
This is the mail server's identity: it becomes the Postfix HELO/EHLO name,
|
|
||||||
the Cyrus SASL realm that application passwords are looked up under, and it
|
|
||||||
must match the TLS certificate's CN/SAN as well as this server's PTR (reverse
|
|
||||||
DNS) record. There is no safe default — guessing any one of these wrong
|
|
||||||
breaks authentication for every application or sends outgoing mail to spam,
|
|
||||||
silently.
|
|
||||||
|
|
||||||
Set it to the mail server's fully-qualified domain name, e.g.:
|
|
||||||
|
|
||||||
SELFPOST_HOSTNAME=mail.example.com
|
|
||||||
|
|
||||||
in the .env file next to your docker-compose.yml (see deploy/.env.example).
|
|
||||||
EOF
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "$SELFPOST_HOSTNAME" in
|
|
||||||
*[\ \ ]* | *://* | *:* )
|
|
||||||
echo "FATAL: SELFPOST_HOSTNAME must be a bare hostname (no scheme, port, or spaces): \"$SELFPOST_HOSTNAME\"" >&2
|
|
||||||
echo 'Example: SELFPOST_HOSTNAME=mail.example.com' >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
*.*)
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "FATAL: SELFPOST_HOSTNAME must be a fully-qualified domain name (at least one dot): \"$SELFPOST_HOSTNAME\"" >&2
|
|
||||||
echo 'Example: SELFPOST_HOSTNAME=mail.example.com' >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Generate the outbound-relay Postfix configuration from the environment (spec
|
# Generate the outbound-relay Postfix configuration from the environment (spec
|
||||||
# 5). Kept out of the image build so cert paths, rate limits, hostname and the
|
# 5). Kept out of the image build so cert paths, rate limits, hostname and the
|
||||||
# optional 587 service are all driven by env at run time, and re-derived on every
|
# optional 587 service are all driven by env at run time, and re-derived on every
|
||||||
|
|||||||
+38
-3
@@ -23,8 +23,26 @@ HOSTNAME_VALUE="${SELFPOST_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}"
|
|||||||
# TLS material supplied by the reverse-proxy through a read-only bind mount
|
# TLS material supplied by the reverse-proxy through a read-only bind mount
|
||||||
# (spec 5.2). The relay requires TLS on 465; if these files are absent the
|
# (spec 5.2). The relay requires TLS on 465; if these files are absent the
|
||||||
# master still starts but TLS handshakes on 465 fail until they appear.
|
# master still starts but TLS handshakes on 465 fail until they appear.
|
||||||
TLS_CERT="${TLS_CERT_FILE:-/etc/postfix/tls/fullchain.pem}"
|
#
|
||||||
TLS_KEY="${TLS_KEY_FILE:-/etc/postfix/tls/privkey.pem}"
|
# Postfix insists the private key is root-owned and mode 0600. The bind mount
|
||||||
|
# is often :ro and owned by the host user (e2e TempDir / CI runner UID), so
|
||||||
|
# copy into a writable internal dir and normalise ownership before postconf
|
||||||
|
# and `postfix check`.
|
||||||
|
TLS_CERT_SRC="${TLS_CERT_FILE:-/etc/postfix/tls/fullchain.pem}"
|
||||||
|
TLS_KEY_SRC="${TLS_KEY_FILE:-/etc/postfix/tls/privkey.pem}"
|
||||||
|
TLS_INTERNAL_DIR=/etc/postfix/tls-internal
|
||||||
|
TLS_CERT="$TLS_CERT_SRC"
|
||||||
|
TLS_KEY="$TLS_KEY_SRC"
|
||||||
|
if [ -f "$TLS_CERT_SRC" ] && [ -f "$TLS_KEY_SRC" ]; then
|
||||||
|
mkdir -p "$TLS_INTERNAL_DIR"
|
||||||
|
cp -f "$TLS_CERT_SRC" "$TLS_INTERNAL_DIR/fullchain.pem"
|
||||||
|
cp -f "$TLS_KEY_SRC" "$TLS_INTERNAL_DIR/privkey.pem"
|
||||||
|
chown root:root "$TLS_INTERNAL_DIR/fullchain.pem" "$TLS_INTERNAL_DIR/privkey.pem"
|
||||||
|
chmod 0644 "$TLS_INTERNAL_DIR/fullchain.pem"
|
||||||
|
chmod 0600 "$TLS_INTERNAL_DIR/privkey.pem"
|
||||||
|
TLS_CERT="$TLS_INTERNAL_DIR/fullchain.pem"
|
||||||
|
TLS_KEY="$TLS_INTERNAL_DIR/privkey.pem"
|
||||||
|
fi
|
||||||
|
|
||||||
# Level-1 rate limit (native Postfix anvil, spec 5 p.5 / 7.4). Conservative
|
# Level-1 rate limit (native Postfix anvil, spec 5 p.5 / 7.4). Conservative
|
||||||
# defaults, sensible during IP warm-up (spec 10).
|
# defaults, sensible during IP warm-up (spec 10).
|
||||||
@@ -54,14 +72,23 @@ SUBMISSION_ENABLE="${SUBMISSION_ENABLE:-false}"
|
|||||||
MAIL_LOG_PATH="${MAIL_LOG:-/data/log/mail.log}"
|
MAIL_LOG_PATH="${MAIL_LOG:-/data/log/mail.log}"
|
||||||
|
|
||||||
# --- main.cf -----------------------------------------------------------------
|
# --- main.cf -----------------------------------------------------------------
|
||||||
|
# maillog_file lives under the persistent /data bind mount (architecture.md §
|
||||||
|
# Log tailer). Postfix's default maillog_file_prefixes are only /var and
|
||||||
|
# /dev/stdout — without /data, `postfix check` rejects the path.
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"myhostname=${HOSTNAME_VALUE}" \
|
"myhostname=${HOSTNAME_VALUE}" \
|
||||||
"maillog_file=${MAIL_LOG_PATH}" \
|
"maillog_file=${MAIL_LOG_PATH}" \
|
||||||
|
"maillog_file_prefixes=/var,/dev/stdout,/data" \
|
||||||
"mydestination=" \
|
"mydestination=" \
|
||||||
"relayhost=" \
|
"relayhost=" \
|
||||||
"inet_interfaces=all" \
|
"inet_interfaces=all" \
|
||||||
"inet_protocols=all"
|
"inet_protocols=all"
|
||||||
|
|
||||||
|
# postlogd is mandatory whenever maillog_file is set (MAILLOG_README). Debian's
|
||||||
|
# stock master.cf usually has it; pin it explicitly so a stripped/upgraded
|
||||||
|
# image cannot lose the service.
|
||||||
|
postconf -M "postlog/unix-dgram=postlog unix-dgram n - n - 1 postlogd"
|
||||||
|
|
||||||
# This is an outbound relay: no local delivery, no per-user aliases. Empty
|
# This is an outbound relay: no local delivery, no per-user aliases. Empty
|
||||||
# these so a misfiled recipient never gets delivered locally.
|
# these so a misfiled recipient never gets delivered locally.
|
||||||
postconf -e \
|
postconf -e \
|
||||||
@@ -189,4 +216,12 @@ EOF
|
|||||||
|
|
||||||
# Validate the generated configuration; fail loudly if postconf produced
|
# Validate the generated configuration; fail loudly if postconf produced
|
||||||
# anything Postfix rejects, before the wrapper tries to start it.
|
# anything Postfix rejects, before the wrapper tries to start it.
|
||||||
postfix check
|
set +e
|
||||||
|
check_out=$(postfix check 2>&1)
|
||||||
|
ec=$?
|
||||||
|
set -e
|
||||||
|
if [ "$ec" -ne 0 ]; then
|
||||||
|
echo "postfix-config: postfix check failed exit $ec" >&2
|
||||||
|
printf '%s\n' "$check_out" >&2
|
||||||
|
exit "$ec"
|
||||||
|
fi
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
selfpost:
|
selfpost:
|
||||||
image: ghcr.io/mixeme/selfpost:0.1.0
|
image: ghcr.io/mixeme/selfpost:1.0.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
||||||
|
|||||||
+12
-23
@@ -1,8 +1,7 @@
|
|||||||
# SelfPost — development
|
# SelfPost — development
|
||||||
|
|
||||||
**What this file is.** How to build, test, document, and ship changes. Open
|
**What this file is.** How to build, test, document, and ship changes. Open
|
||||||
work for v1.x and 2.x lives in [roadmap.md](roadmap.md) (and, until the tag,
|
work for 2.x lives in [roadmap.md](roadmap.md). Product boundaries:
|
||||||
[v1.x-closure-plan.md](v1.x-closure-plan.md)). Product boundaries:
|
|
||||||
[product.md](product.md). As-built layout: [architecture.md](architecture.md).
|
[product.md](product.md). As-built layout: [architecture.md](architecture.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -12,10 +11,8 @@ work for v1.x and 2.x lives in [roadmap.md](roadmap.md) (and, until the tag,
|
|||||||
After `/clear` or a fresh chat:
|
After `/clear` or a fresh chat:
|
||||||
|
|
||||||
1. Read this file (process, docs rules, model routing).
|
1. Read this file (process, docs rules, model routing).
|
||||||
2. Open [roadmap.md](roadmap.md) for open work; until `v1.0.0`, also
|
2. Open [roadmap.md](roadmap.md) for open work. Accepted risks —
|
||||||
[v1.x-closure-plan.md](v1.x-closure-plan.md) for the remaining closure
|
[security.md](security.md); as-built — [architecture.md](architecture.md).
|
||||||
checklist. Accepted risks — [security.md](security.md); as-built —
|
|
||||||
[architecture.md](architecture.md).
|
|
||||||
3. Skim [product.md](product.md) if scope is in doubt.
|
3. Skim [product.md](product.md) if scope is in doubt.
|
||||||
4. Continue from the next unchecked step in the active plan.
|
4. Continue from the next unchecked step in the active plan.
|
||||||
|
|
||||||
@@ -31,7 +28,7 @@ not duplicated here.
|
|||||||
| Security, infra, file permissions, Postfix/`postqueue`, open-relay risk | **Opus** | `mail.log` under `/data`, entrypoint permissions, queue reconcile |
|
| Security, infra, file permissions, Postfix/`postqueue`, open-relay risk | **Opus** | `mail.log` under `/data`, entrypoint permissions, queue reconcile |
|
||||||
| UI / JS / CSS, templates, documentation (English), README | **Sonnet** | adaptive polling, this file's Documentation section |
|
| UI / JS / CSS, templates, documentation (English), README | **Sonnet** | adaptive polling, this file's Documentation section |
|
||||||
| Trivial mechanics: retarget links, grep, compose bump, CHANGELOG cut | **Haiku** | Makefile / release.yml comment fixes, deleting closed plan files |
|
| Trivial mechanics: retarget links, grep, compose bump, CHANGELOG cut | **Haiku** | Makefile / release.yml comment fixes, deleting closed plan files |
|
||||||
| Security **review** (not authorship) | **Fable** | pre-release checklist pass ([implementation-plan.md](implementation-plan.md) § D — done) |
|
| Security **review** (not authorship) | **Fable** | pre-release checklist (CHANGELOG `[0.5.0]` Security / [security.md](security.md) — done) |
|
||||||
|
|
||||||
Default rule: risk-critical → Opus; UI / docs / boilerplate → Sonnet; trivial
|
Default rule: risk-critical → Opus; UI / docs / boilerplate → Sonnet; trivial
|
||||||
mechanics → Haiku. Reviewers must not be the author of the code under review.
|
mechanics → Haiku. Reviewers must not be the author of the code under review.
|
||||||
@@ -154,20 +151,16 @@ The release image is published **only on tag** `vX.Y.Z` (not on every push to
|
|||||||
|
|
||||||
**Steps (on explicit request):**
|
**Steps (on explicit request):**
|
||||||
|
|
||||||
1. Close `[Unreleased]` in [CHANGELOG.md](../CHANGELOG.md).
|
1. Close `[Unreleased]` in [CHANGELOG.md](../CHANGELOG.md) and bump the pinned
|
||||||
2. Create and push git tag `vX.Y.Z`.
|
tag in [deploy/docker-compose.yml](../deploy/docker-compose.yml) (and any
|
||||||
|
local-trial image references) in the **same** release commit.
|
||||||
|
2. Create and push git tag `vX.Y.Z` on that commit.
|
||||||
3. Workflow [release.yml](../.github/workflows/release.yml) builds, e2e-gates,
|
3. Workflow [release.yml](../.github/workflows/release.yml) builds, e2e-gates,
|
||||||
and publishes `ghcr.io/mixeme/selfpost:X.Y.Z`.
|
and publishes `ghcr.io/mixeme/selfpost:X.Y.Z`.
|
||||||
4. Update the pinned tag in
|
|
||||||
[deploy/docker-compose.yml](../deploy/docker-compose.yml) in the **same**
|
|
||||||
commit as the tag (see [roadmap.md](roadmap.md) § «v1.x — documentation and
|
|
||||||
deploy tail»).
|
|
||||||
|
|
||||||
Ordinary commits **do not** publish an image. Intermediate CHANGELOG version
|
Ordinary commits **do not** publish an image. The compose pin and the git tag
|
||||||
cuts (`0.2.0`…`0.6.0`) document history on `main`; the compose pin and the only
|
must match (`1.0.0` / `v1.0.0` for the first published release). Intermediate
|
||||||
git release tag may lag until an explicit image publish (today: compose
|
CHANGELOG sections (`0.2.0`…`0.6.0`) record development history before that cut.
|
||||||
`0.1.0`, tag `v0.0.1` — see [roadmap.md](roadmap.md) § «v1.x — documentation
|
|
||||||
and deploy tail»).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -280,13 +273,9 @@ There is no `docs/archive/` directory.
|
|||||||
| As-built design | [architecture.md](architecture.md) |
|
| As-built design | [architecture.md](architecture.md) |
|
||||||
| Development process (this file) | [development.md](development.md) |
|
| Development process (this file) | [development.md](development.md) |
|
||||||
| Security requirements and accepted risks | [security.md](security.md) |
|
| Security requirements and accepted risks | [security.md](security.md) |
|
||||||
| Internal roadmap (v1.x tail, 2.x) | [roadmap.md](roadmap.md) |
|
| Internal roadmap (2.x) | [roadmap.md](roadmap.md) |
|
||||||
| Release history | [CHANGELOG.md](../CHANGELOG.md) |
|
| Release history | [CHANGELOG.md](../CHANGELOG.md) |
|
||||||
|
|
||||||
`implementation-plan.md` remains only until the release cut (describes the
|
|
||||||
closed release gate); delete it in the release commit. Temporary
|
|
||||||
[v1.x-closure-plan.md](v1.x-closure-plan.md) goes away with that cut too.
|
|
||||||
|
|
||||||
### User-facing deliverables
|
### User-facing deliverables
|
||||||
|
|
||||||
| Artefact | Role |
|
| Artefact | Role |
|
||||||
|
|||||||
+8
-9
@@ -375,12 +375,11 @@ but it can look like an open port in external scans.
|
|||||||
## Fixed image tag
|
## Fixed image tag
|
||||||
|
|
||||||
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
|
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
|
||||||
deliberately never `:latest`. Until the `v1.0.0` cut the shipped pin is still
|
deliberately never `:latest`. The current pin is `1.0.0`. Intermediate
|
||||||
`0.1.0` — intermediate CHANGELOG sections (`0.2.0`…`0.6.0`) record development
|
CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that
|
||||||
cuts and do not imply a published image of that tag. Pinning matters because of
|
image was published. Pinning matters because of the backup version check above:
|
||||||
the backup version check above: the panel binary's embedded version and the
|
the panel binary's embedded version and the image tag that produced it are the
|
||||||
image tag that produced it are the same value by construction (the release CI
|
same value by construction (the release CI stamps both from one git tag — see
|
||||||
stamps both from one git tag — see `.github/workflows/release.yml`), so the
|
`.github/workflows/release.yml`), so the pin is what makes "restore into the
|
||||||
pin is what makes "restore into the same version" a checkable fact rather than
|
same version" a checkable fact rather than a guess. Upgrade by bumping the tag
|
||||||
a guess. Upgrade by bumping the tag deliberately, not by riding a moving
|
deliberately, not by riding a moving target.
|
||||||
target.
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
# План реализации: SelfPost
|
|
||||||
|
|
||||||
**Статус:** линия v1.0/v1.x до тега релиза **закрыта** — предрелизная ревизия
|
|
||||||
безопасности (§ D) выполнена 2026-08-06, релизный гейт (e2e + ревизия) открыт.
|
|
||||||
B.1–B.3 и C.4 закрыты — as-built в [architecture.md](architecture.md),
|
|
||||||
e2e/CI в [development.md](development.md), принятые риски в
|
|
||||||
[security.md](security.md). Текущее состояние и следующий шаг:
|
|
||||||
[roadmap.md](roadmap.md) и [v1.x-closure-plan.md](v1.x-closure-plan.md).
|
|
||||||
Объём 2.x.x — [roadmap.md](roadmap.md).
|
|
||||||
|
|
||||||
**Основа:** [product.md](product.md) v1.0.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. Предрелизная ревизия безопасности — ВЫПОЛНЕНО (2026-08-06)
|
|
||||||
|
|
||||||
**Проверка моделью Fable** по дифу от аудита v1.0 (Фаза 11, `bd64e80` — тега
|
|
||||||
`v1.0.0` в репозитории нет, это его фактический эквивалент) до HEAD, плюс
|
|
||||||
полный повторный проход по чек-листу безопасности (бывшее ТЗ 7.6, теперь
|
|
||||||
[security.md](security.md)). Приоритеты из плана покрыты: аутентификация и
|
|
||||||
сессии, валидация ввода, запись в конфиги/map-файлы, `os/exec`, права в
|
|
||||||
`/data`, секреты.
|
|
||||||
|
|
||||||
**Результат.** Эксплуатируемых уязвимостей (high/medium) не найдено. Одна
|
|
||||||
находка defence-in-depth закрыта правкой до тега: логин приложения передаётся
|
|
||||||
в `saslpasswd2` после `--`, чтобы значение, начинающееся с `-` (допустимо
|
|
||||||
whitelist'ом), не могло быть разобрано getopt как флаг
|
|
||||||
([internal/app/sasl.go](../internal/app/sasl.go)). Принятые риски в
|
|
||||||
[security.md](security.md) не пополнились — существующие записи (origin-check
|
|
||||||
fallback, отсутствие CSRF-токенов, ложно-отрицательный `bounced` при сверке
|
|
||||||
с `postqueue`) покрывают всё найденное.
|
|
||||||
Сводка ревизии — в записи `Security` CHANGELOG `[0.5.0]`.
|
|
||||||
+12
-71
@@ -11,84 +11,25 @@
|
|||||||
решением.
|
решением.
|
||||||
|
|
||||||
**Основа:** [product.md](product.md) v1.0. Процесс и правила документации —
|
**Основа:** [product.md](product.md) v1.0. Процесс и правила документации —
|
||||||
[development.md](development.md). Несделанное для v1.0/v1.x до тега — в
|
[development.md](development.md). История закрытых фаз v1.x — в `git log` и
|
||||||
[implementation-plan.md](implementation-plan.md) и
|
[CHANGELOG.md](../CHANGELOG.md).
|
||||||
[v1.x-closure-plan.md](v1.x-closure-plan.md). Хвост закрытого
|
|
||||||
документационного прохода (D1–D9) — в секции ниже.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.x — хвост документации и деплоя
|
## v1.x — хвост документации и деплоя
|
||||||
|
|
||||||
**Статус:** не блокирует релизный тег; бывший хвост закрытого
|
**Статус: закрыто** в релизе `1.0.0` / git-тег `v1.0.0`
|
||||||
документационного прохода (D1–D9). Делать по желанию или в релизном коммите,
|
(`ghcr.io/mixeme/selfpost:1.0.0`). План закрытия и `implementation-plan.md`
|
||||||
где указано. Сводка чек-листов до тега —
|
удалены — история в git и CHANGELOG; `docs/archive/` не храним.
|
||||||
[v1.x-closure-plan.md](v1.x-closure-plan.md).
|
|
||||||
|
|
||||||
**Тег образа в compose + git tag — один релизный коммит (R1).** В
|
| Тема | Итог |
|
||||||
[deploy/docker-compose.yml](../deploy/docker-compose.yml) поле `image:` бампить
|
|---|---|
|
||||||
до версии релиза **в том же коммите**, что и git-тег `vX.Y.Z` — не раньше.
|
| Адаптивный опрос мониторинга | 5 с / 30 с / 0 (скрытая вкладка) в `panel.js` |
|
||||||
Сейчас там `0.1.0`, то есть отстаёт от целевой версии; несовпадение мешает
|
| `mail.log` + reconcile | `/data/log/mail.log`; сверка с `postqueue -p` |
|
||||||
только до первого выката по тегу. Сам тег — последний шаг релизного гейта:
|
| Docs consolidation | процесс в [development.md](development.md); README Documentation |
|
||||||
содержательная часть (e2e C.4, ревизия § D) закрыта, режется по явной команде
|
| Compose pin + git tag | `1.0.0` / `v1.0.0` в одном релизном коммите |
|
||||||
оператора ([development.md](development.md) § Commits and release build). После
|
|
||||||
тега `release.yml` собирает и публикует `ghcr.io/mixeme/selfpost:X.Y.Z`,
|
|
||||||
поэтому compose с новым тегом и сам тег обязаны появиться вместе — иначе
|
|
||||||
compose неделю ссылается на несуществующий образ.
|
|
||||||
|
|
||||||
**Убрать `implementation-plan.md` — в релизном коммите.** Документ закрыт:
|
Открытая работа дальше — только секции 2.x ниже.
|
||||||
уникального содержания в нём нет, § D (предрелизная ревизия безопасности)
|
|
||||||
продублирован в [security.md](security.md) и CHANGELOG `[0.5.0]/Security`,
|
|
||||||
а разделы B.1–B.3 и C.4 вырезаны ещё в `22f86d1`. Держится до тега только
|
|
||||||
потому, что описывает релизный гейт, пока тот формально не закрыт. При резке
|
|
||||||
версии:
|
|
||||||
|
|
||||||
1. Удалить файл (история § D — в git и CHANGELOG; `docs/archive/` не храним).
|
|
||||||
2. Перецелить ссылки из кода и CI ([Makefile](../Makefile),
|
|
||||||
[.github/workflows/release.yml](../.github/workflows/release.yml),
|
|
||||||
[test/e2e/main_test.go](../test/e2e/main_test.go)) — они ссылаются на «план
|
|
||||||
C.4», секцию, которой в файле уже нет; актуальное описание e2e — в
|
|
||||||
[development.md](development.md).
|
|
||||||
3. Перецелить оставшиеся ссылки из документации на
|
|
||||||
[development.md](development.md) / [security.md](security.md) /
|
|
||||||
[roadmap.md](roadmap.md).
|
|
||||||
4. Удалить [v1.x-closure-plan.md](v1.x-closure-plan.md) в том же или следующем
|
|
||||||
коммите.
|
|
||||||
|
|
||||||
**Готово, когда:** тег образа в compose совпадает с релизом и рядом стоит
|
|
||||||
git-тег `vX.Y.Z`; `implementation-plan.md` и `v1.x-closure-plan.md` удалены,
|
|
||||||
ссылок на них в активных документах и в коде/CI не осталось.
|
|
||||||
|
|
||||||
(Закрыто и действия не требует: `docs/logo` как каталога нет — критерию «либо
|
|
||||||
содержит файлы, либо отсутствует» удовлетворяет; Quick start в
|
|
||||||
[README.md](../README.md) тянет `docker-compose.yml` и `.env.example` с
|
|
||||||
`raw.githubusercontent.com` — это и есть единственная площадка проекта, зеркал
|
|
||||||
больше нет.)
|
|
||||||
|
|
||||||
**Сводный индекс документации в README.** ~~Ссылки на `docs/` разбросаны по
|
|
||||||
тексту README…~~ **Закрыто (v1.x-closure Фаза 3):** секция Documentation в
|
|
||||||
[README.md](../README.md) — единый список operator docs + roadmap.
|
|
||||||
|
|
||||||
**Опрос мониторинга у открытой, но незанятой вкладки.** ~~Скрытая вкладка уже не
|
|
||||||
опрашивает сервер (фильтр на `htmx:beforeRequest` в
|
|
||||||
[panel.js](../internal/web/static/panel.js)). Остаток: вкладка на переднем
|
|
||||||
плане, с которой не работают, всё равно ходит раз в 5 с. Кандидат — адаптивный
|
|
||||||
интервал (5 с при активности, 30 с при простое) по `htmx:afterRequest` без
|
|
||||||
изменения `hx-trigger`. Ценность низкая: нагрузка — один SQL-запрос и рендер
|
|
||||||
фрагмента, так что это скорее гигиена, чем экономия. Допустимый исход —
|
|
||||||
осознанно не делать.~~ **Закрыто (v1.x-closure Фаза 1):** адаптивный интервал
|
|
||||||
5 с / 30 с / 0 (скрытая вкладка) в `panel.js` через `data-poll`.
|
|
||||||
|
|
||||||
**Send-log vs `mail.log`.** ~~Persist позиции чтения сделан (таблица
|
|
||||||
`logtail_state`, миграция `0003`): после рестарта панели log-tailer дочитывает
|
|
||||||
пропущенный хвост. Остаётся пересоздание контейнера — `mail.log` не в `/data` и
|
|
||||||
теряется вместе с ним, такие строки навсегда останутся `queued`. Кандидаты, если
|
|
||||||
станет больно: volume для лога, сверка зависших строк через `postqueue`.~~
|
|
||||||
**Закрыто (v1.x-closure Фаза 2):** сделаны оба кандидата — `mail.log` переехал в
|
|
||||||
`/data/log/`, а строки, чьи delivery-строки потеряны безвозвратно, закрываются
|
|
||||||
сверкой с `postqueue -p` (grace 2 мин → `bounced`). As-built и оставшийся риск
|
|
||||||
(ложный `bounced`): [architecture.md](architecture.md) § Log tailer,
|
|
||||||
[security.md](security.md).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -94,8 +94,8 @@ Hardening сверх обязательного (security-заголовки, п
|
|||||||
|
|
||||||
## Принятые риски
|
## Принятые риски
|
||||||
|
|
||||||
Здесь, а не в [implementation-plan.md](implementation-plan.md): план — про
|
Принятый риск — решение с условием возврата, а не отложенная задача из
|
||||||
несделанную работу, принятый риск — решение с условием возврата.
|
дорожной карты.
|
||||||
|
|
||||||
- **`POST` без `Sec-Fetch-Site` и без `Origin` пропускается.**
|
- **`POST` без `Sec-Fetch-Site` и без `Origin` пропускается.**
|
||||||
Клиент, не посылающий ни одного из двух — по-настоящему старый браузер или
|
Клиент, не посылающий ни одного из двух — по-настоящему старый браузер или
|
||||||
@@ -171,7 +171,7 @@ Origin-проверка закрывает это без изменения ни
|
|||||||
|
|
||||||
## Как этот список пополняется
|
## Как этот список пополняется
|
||||||
|
|
||||||
Предрелизная проверка на уязвимости ([implementation-plan.md](implementation-plan.md)
|
Предрелизная проверка на уязвимости (модель Fable; история — CHANGELOG
|
||||||
§ D, модель Fable) закрывает каждую находку одним из двух способов: правка до
|
`[0.5.0]` Security) закрывает каждую находку одним из двух способов: правка до
|
||||||
тега — либо запись сюда, с обоснованием и условием возврата, как у пунктов выше.
|
тега — либо запись сюда, с обоснованием и условием возврата, как у пунктов выше.
|
||||||
Третьего варианта («посмотрели и ладно») нет.
|
Третьего варианта («посмотрели и ладно») нет.
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
# План закрытия хвоста v1.x
|
|
||||||
|
|
||||||
**Статус:** в работе. **Целевой релиз:** `v1.0.0` / `ghcr.io/mixeme/selfpost:1.0.0`.
|
|
||||||
|
|
||||||
**Временный файл:** после закрытия v1.x удалить (история — git + CHANGELOG). Не часть постоянного пакета docs.
|
|
||||||
|
|
||||||
**Контекст:** релизный гейт по коду закрыт (B.1–B.3, C.4 e2e, § D Fable). Остаток — [roadmap.md](roadmap.md) § «v1.x — хвост документации и деплоя» + код из того же секции.
|
|
||||||
|
|
||||||
**Политика docs:** `docs/archive/` не храним; устаревшие планы удаляем после переноса полезного в [development.md](development.md).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Рекомендация по модели
|
|
||||||
|
|
||||||
Общее правило (переносится в development.md):
|
|
||||||
|
|
||||||
| Тип работы | Модель | Примеры в этом плане |
|
|
||||||
|------------|--------|---------------------|
|
|
||||||
| Безопасность, инфра, права файлов, postfix/postqueue, open-relay-риски | **Opus** | mail.log в `/data`, entrypoint permissions, postqueue reconcile |
|
|
||||||
| UI/JS/CSS, шаблоны, документация (English), README | **Sonnet** | adaptive polling, development.md § Documentation, README index |
|
|
||||||
| Тривиальная механика: retarget ссылок, grep, compose bump, CHANGELOG cut | **Haiku** | Makefile/release.yml комментарии, удаление файлов |
|
|
||||||
| Ревизия безопасности (**review**, не authorship) | **Fable** | не в этом плане (§ D уже выполнен) |
|
|
||||||
|
|
||||||
| Фаза / коммит | Модель | Почему |
|
|
||||||
|---------------|--------|--------|
|
|
||||||
| 1. Polling | Sonnet | panel.js + HTMX, CSP |
|
|
||||||
| 2. mail.log + reconcile | Opus (+ Sonnet на unit-тесты) | postfix path, logrotate, entrypoint, postqueue |
|
|
||||||
| 3. Docs consolidation | Sonnet | development.md, README, agent-rules |
|
|
||||||
| 4. Release `1.0.0` | Haiku / Sonnet | механика релиза, без новой логики |
|
|
||||||
| 5. Tag / push | **Оператор** | явная команда |
|
|
||||||
|
|
||||||
Сообщение коммита: трейлер `Co-Authored-By: Claude <модель> <noreply@anthropic.com>` с моделью, которая делала шаг.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Общий чек-лист прогресса
|
|
||||||
|
|
||||||
- [x] **Фаза 1** — адаптивный опрос мониторинга
|
|
||||||
- [x] **Фаза 2** — mail.log в `/data` + postqueue reconcile
|
|
||||||
- [x] **Фаза 3** — docs: development.md, README, удаление планов и `docs/archive/`
|
|
||||||
- [ ] **Фаза 4** — релизный коммит `1.0.0` (по явной команде)
|
|
||||||
- [ ] **Фаза 5** — tag `v1.0.0` + push (по явной команде)
|
|
||||||
- [ ] **Фаза 6** — выкат на прод (оператор)
|
|
||||||
|
|
||||||
**Гейт перед тегом:**
|
|
||||||
|
|
||||||
- [ ] `gofmt -l .` чистый
|
|
||||||
- [ ] `go vet ./...` чистый
|
|
||||||
- [ ] `go test ./...` чистый (Windows-падения domain/logtail — известны, не блокер)
|
|
||||||
- [ ] `make e2e` зелёный (если Docker доступен)
|
|
||||||
- [ ] grep: нет ссылок на удалённые docs в живых файлах
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Фаза 1 — Адаптивный опрос мониторинга
|
|
||||||
|
|
||||||
**Модель:** Sonnet.
|
|
||||||
|
|
||||||
**Цель:** 5 s при активности, 30 s при простое; скрытая вкладка — 0 запросов. Без `hx-trigger="every 5s [expr]"` (CSP / `unsafe-eval`).
|
|
||||||
|
|
||||||
### Чек-лист
|
|
||||||
|
|
||||||
- [x] `status_body.html`, `mail_queue_body.html`, `system_log_body.html`, `deliveries_rows.html` — `hx-trigger="load"` + маркер polling
|
|
||||||
- [x] `panel.js` — `lastActivity`, schedule после `htmx:afterRequest` (5s / 30s)
|
|
||||||
- [x] `htmx:afterSwap` — реинициализация polling-элементов
|
|
||||||
- [x] `beforeRequest` для hidden tab — оставлен
|
|
||||||
- [x] `architecture.md` — одна строка про polling
|
|
||||||
- [x] CHANGELOG `[Unreleased]`
|
|
||||||
- [ ] Стенд (опционально): idle → ~30s; активность → ~5s; hidden → 0 запросов
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Фаза 2 — Send-log vs `mail.log`
|
|
||||||
|
|
||||||
**Модель:** Opus (infra); Sonnet (тесты).
|
|
||||||
|
|
||||||
### 2.1 `mail.log` → `/data/log/mail.log`
|
|
||||||
|
|
||||||
- [x] `build/postfix-config.sh` — `maillog_file` из `MAIL_LOG`
|
|
||||||
- [x] `build/logrotate-mail.conf` — путь + `create 0640 postfix selfpost`
|
|
||||||
- [x] `build/entrypoint.sh` — `mkdir`, права (`2750 postfix:selfpost`, файл `0640`),
|
|
||||||
исключение `/data/log` из общего `chown` на `panel`
|
|
||||||
- [x] `cmd/panel/main.go` — default `MAIL_LOG`
|
|
||||||
- [x] `internal/backup` — исключить `log/` из архива (+ тест)
|
|
||||||
- [x] `test/e2e/mail_helpers.go` — путь в контейнере
|
|
||||||
- [x] `guide.md` — `MAIL_LOG`, System log, бэкап `./data`; README — рост диска
|
|
||||||
- [x] `envdoc_test` — `MAIL_LOG` в `buildScriptKeys`
|
|
||||||
|
|
||||||
### 2.2 Postqueue reconcile
|
|
||||||
|
|
||||||
- [x] `internal/postfix` — `QueueIDs` / парсер queue-id из `postqueue -p` + тест
|
|
||||||
- [x] `internal/store` — `ListQueuedOlderThan(cutoff)`
|
|
||||||
- [x] `internal/logtail` — periodic sweep (5 min), grace 2 min → `bounced`,
|
|
||||||
старт только после того, как tailer дочитал лог до конца
|
|
||||||
- [x] Тесты reconcile
|
|
||||||
|
|
||||||
### 2.3 Документация и риски
|
|
||||||
|
|
||||||
- [x] `architecture.md` — `/data/log`, reconcile; gap «container recreate» убран
|
|
||||||
- [x] `security.md` — риск «вечный queued» снят; на его месте — ложный `bounced`
|
|
||||||
и права на лог
|
|
||||||
- [x] CHANGELOG `[Unreleased]`
|
|
||||||
|
|
||||||
**Стенд (остаток, для оператора):** Docker на машине разработки недоступен —
|
|
||||||
сборка образа и старт контейнера не проверены. Проверить при выкате: `/data/log`
|
|
||||||
создаётся с нужными правами, панель читает `mail.log`, logrotate проворачивает
|
|
||||||
файл, `postqueue -p` читается из-под `panel`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Фаза 3 — Документация
|
|
||||||
|
|
||||||
**Модель:** Sonnet.
|
|
||||||
|
|
||||||
### 3.1 `documentation-plan.md` → development.md, затем delete
|
|
||||||
|
|
||||||
- [x] development.md § **Documentation**:
|
|
||||||
- [x] Documentation map (без archive; история = git + CHANGELOG)
|
|
||||||
- [x] User-facing deliverables
|
|
||||||
- [x] Maintaining documentation (§3 правила)
|
|
||||||
- [x] Verifying docs against code (полная таблица §2)
|
|
||||||
- [x] `architecture.md` шапка → development.md
|
|
||||||
- [x] Удалить `documentation-plan.md`
|
|
||||||
- [x] Retarget `roadmap.md`
|
|
||||||
|
|
||||||
### 3.2 `progress.md` → development.md, затем delete
|
|
||||||
|
|
||||||
- [x] development.md § **Resuming work**
|
|
||||||
- [x] development.md § **Model routing**
|
|
||||||
- [x] development.md § **Commits** (слить с Release build)
|
|
||||||
- [x] development.md § **Phase closure** (roadmap, не progress)
|
|
||||||
- [x] `agent-rules.mdc` → development.md
|
|
||||||
- [x] Удалить `progress.md`
|
|
||||||
- [x] Retarget все ссылки
|
|
||||||
|
|
||||||
### 3.3 Удалить `docs/archive/`
|
|
||||||
|
|
||||||
- [x] Удалить `docs/archive/specification-v1.0.md`
|
|
||||||
- [x] Удалить каталог `docs/archive/`
|
|
||||||
- [x] Убрать ссылки на archive из живых docs (CHANGELOG историю ниже 0.6.0 не трогать)
|
|
||||||
|
|
||||||
### 3.4 README
|
|
||||||
|
|
||||||
- [x] § Documentation: operator docs + roadmap (internal, Russian)
|
|
||||||
- [x] Без секции Archive
|
|
||||||
|
|
||||||
### 3.5 Проверка
|
|
||||||
|
|
||||||
- [x] `gofmt` / `vet` / `test`
|
|
||||||
- [x] grep живых ссылок на удалённые файлы
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Фаза 4 — Релизный коммит R1
|
|
||||||
|
|
||||||
**Модель:** Haiku / Sonnet. **Только по явной команде оператора.**
|
|
||||||
|
|
||||||
- [ ] CHANGELOG: `[Unreleased]` → `[1.0.0] - дата`, новая `[Unreleased]`
|
|
||||||
- [ ] `deploy/docker-compose.yml` → `ghcr.io/mixeme/selfpost:1.0.0`
|
|
||||||
- [ ] Удалить `implementation-plan.md` (если ещё есть)
|
|
||||||
- [ ] Makefile, `.github/workflows/release.yml`, `test/e2e/main_test.go` → development.md
|
|
||||||
- [ ] `roadmap.md` — секция v1.x **закрыта**; убрать «переместить в archive»
|
|
||||||
- [ ] Финальный grep ссылок
|
|
||||||
- [ ] Удалить этот файл (`v1.x-closure-plan.md`) в том же или следующем коммите
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Фаза 5 — Tag и push
|
|
||||||
|
|
||||||
**Оператор.** Явная команда.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git tag v1.0.0
|
|
||||||
git push origin v1.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] `release.yml` — build amd64+arm64, e2e gate, push GHCR `1.0.0`
|
|
||||||
- [ ] compose tag и git tag совпадают
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Фаза 6 — Прод (оператор)
|
|
||||||
|
|
||||||
- [ ] `docker compose pull` → `1.0.0`
|
|
||||||
- [ ] Разлогин (cookie `__Host-`)
|
|
||||||
- [ ] Reverse-proxy передаёт `Host`
|
|
||||||
- [ ] `./data/log/` создался после upgrade
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Критерий «хвост v1.x закрыт»
|
|
||||||
|
|
||||||
- [ ] Все чек-листы фаз 1–4 отмечены
|
|
||||||
- [ ] `compose` = `1.0.0`, тег `v1.0.0` в репо
|
|
||||||
- [ ] Нет `implementation-plan.md`, `documentation-plan.md`, `progress.md`, `docs/archive/`, `v1.x-closure-plan.md`
|
|
||||||
- [ ] Нет активных ссылок на удалённые пути
|
|
||||||
- [ ] `development.md` — процесс + Documentation + model routing
|
|
||||||
- [ ] `roadmap.md` — единственный internal tracker
|
|
||||||
- [ ] Polling 5s/30s; mail.log в `/data`; reconcile; риск security снят
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Разбивка коммитов
|
|
||||||
|
|
||||||
1. **Polling** — panel.js, templates, architecture (+ CHANGELOG)
|
|
||||||
2. **mail.log + reconcile** — build, logtail, store, postfix, docs (+ CHANGELOG)
|
|
||||||
3. **Docs** — development.md, README, agent-rules; delete documentation-plan, progress, archive; retarget (+ CHANGELOG)
|
|
||||||
4. **Release 1.0.0** — CHANGELOG cut, compose, delete implementation-plan, roadmap closure, delete `v1.x-closure-plan.md`
|
|
||||||
5. **Tag/push** — оператор
|
|
||||||
@@ -16,6 +16,10 @@
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
selfpost:
|
selfpost:
|
||||||
|
# Crash-looping with unless-stopped makes `compose exec` fail with
|
||||||
|
# "Container is restarting" and hides the entrypoint/supervisord exit
|
||||||
|
# reason; keep the stand exited so logs stay attached to one attempt.
|
||||||
|
restart: "no"
|
||||||
build:
|
build:
|
||||||
# Resolved relative to --project-directory (.stage), NOT this file's own
|
# Resolved relative to --project-directory (.stage), NOT this file's own
|
||||||
# directory — compose build.context paths follow the project directory,
|
# directory — compose build.context paths follow the project directory,
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ import (
|
|||||||
func runEntrypoint(t *testing.T, hostnameEnv string) (output string, exitedZero bool) {
|
func runEntrypoint(t *testing.T, hostnameEnv string) (output string, exitedZero bool) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dataDir := t.TempDir()
|
dataDir := t.TempDir()
|
||||||
|
// Entrypoint also chmod 755 /data; mirror that here so a pre-fix image
|
||||||
|
// still gets a traversable bind mount under Go's 0700 TempDir.
|
||||||
|
if err := os.Chmod(dataDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("chmod data dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
args := []string{
|
args := []string{
|
||||||
"run", "--rm",
|
"run", "--rm",
|
||||||
@@ -72,13 +77,22 @@ func TestHostnameGate(t *testing.T) {
|
|||||||
func runEntrypointBackground(t *testing.T, hostnameEnv string) (output string, started bool) {
|
func runEntrypointBackground(t *testing.T, hostnameEnv string) (output string, started bool) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dataDir := t.TempDir()
|
dataDir := t.TempDir()
|
||||||
|
if err := os.Chmod(dataDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("chmod data dir: %v", err)
|
||||||
|
}
|
||||||
certDir := t.TempDir()
|
certDir := t.TempDir()
|
||||||
if err := writeSelfSignedCert(certDir+"/fullchain.pem", certDir+"/privkey.pem"); err != nil {
|
if err := writeSelfSignedCert(certDir+"/fullchain.pem", certDir+"/privkey.pem"); err != nil {
|
||||||
t.Fatalf("generate throwaway TLS cert: %v", err)
|
t.Fatalf("generate throwaway TLS cert: %v", err)
|
||||||
}
|
}
|
||||||
name := "selfpost-e2e-hostname-check"
|
name := "selfpost-e2e-hostname-check"
|
||||||
_ = exec.Command("docker", "rm", "-f", name).Run()
|
_ = exec.Command("docker", "rm", "-f", name).Run()
|
||||||
defer exec.Command("docker", "rm", "-f", name).Run()
|
defer func() {
|
||||||
|
// Make bind-mounted /data deletable by Go's TempDir cleanup: the panel
|
||||||
|
// leaves setup-token/db/opendkim owned by container UIDs.
|
||||||
|
_ = exec.Command("docker", "exec", name, "sh", "-c",
|
||||||
|
"chown -R root:root /data && chmod -R a+rwX /data").Run()
|
||||||
|
_ = exec.Command("docker", "rm", "-f", name).Run()
|
||||||
|
}()
|
||||||
|
|
||||||
up := exec.Command("docker", "run", "-d", "--name", name,
|
up := exec.Command("docker", "run", "-d", "--name", name,
|
||||||
"-e", "SELFPOST_HOSTNAME="+hostnameEnv,
|
"-e", "SELFPOST_HOSTNAME="+hostnameEnv,
|
||||||
|
|||||||
+36
-19
@@ -1,13 +1,13 @@
|
|||||||
// Package e2e is the hermetic container e2e gate (plan C.4): it drives the
|
// Package e2e is the hermetic container e2e gate: it drives the shipped
|
||||||
// shipped deploy/docker-compose.yml (plus a test-only override) exactly as an
|
// deploy/docker-compose.yml (plus a test-only override) exactly as an
|
||||||
// administrator and their applications would, so the class of failure unit
|
// administrator and their applications would, so the class of failure unit
|
||||||
// tests cannot see — broken container wiring — has one place to be caught
|
// tests cannot see — broken container wiring — has one place to be caught
|
||||||
// before an image is published.
|
// before an image is published.
|
||||||
//
|
//
|
||||||
// It is a separate module on purpose (see ../../docs/implementation-plan.md,
|
// It is a separate module on purpose (see ../../docs/development.md):
|
||||||
// item C.4): `go test ./...` in the main module never pulls this in, and its
|
// `go test ./...` in the main module never pulls this in, and its test-only
|
||||||
// test-only dependencies (DKIM verification) never enter the shipped
|
// dependencies (DKIM verification) never enter the shipped binaries' build
|
||||||
// binaries' build graph.
|
// graph.
|
||||||
package e2e
|
package e2e
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -54,6 +54,10 @@ func TestMain(m *testing.M) {
|
|||||||
fmt.Fprintf(os.Stderr, "\n==== logs: %s ====\n%s\n", svc, s.logs(svc))
|
fmt.Fprintf(os.Stderr, "\n==== logs: %s ====\n%s\n", svc, s.logs(svc))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Panel/postfix-owned files under the /data bind mount outlive the
|
||||||
|
// container; reclaim ownership while selfpost is still up so a later
|
||||||
|
// prepareStage RemoveAll (or a local re-run) is not stuck on EACCES.
|
||||||
|
s.reclaimData()
|
||||||
s.down()
|
s.down()
|
||||||
os.Exit(code)
|
os.Exit(code)
|
||||||
}
|
}
|
||||||
@@ -87,14 +91,27 @@ type scenario struct {
|
|||||||
func TestE2E(t *testing.T) {
|
func TestE2E(t *testing.T) {
|
||||||
sc := &scenario{}
|
sc := &scenario{}
|
||||||
|
|
||||||
t.Run("startup_processes_running", func(t *testing.T) {
|
// Ordered scenario: each step needs state from earlier ones. A failed
|
||||||
|
// subtest must stop the rest — otherwise sc.panel stays nil and the next
|
||||||
|
// step panics, masking the real failure (as seen on the v1.0.0 release CI).
|
||||||
|
run := func(name string, fn func(*testing.T)) {
|
||||||
|
if t.Failed() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Run(name, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
run("startup_processes_running", func(t *testing.T) {
|
||||||
if err := checkSupervisorProcesses(h); err != nil {
|
if err := checkSupervisorProcesses(h); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if err := waitForPanelReady(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("setup_and_login", func(t *testing.T) {
|
run("setup_and_login", func(t *testing.T) {
|
||||||
token, err := readSetupToken(h.stageDir)
|
token, err := readSetupToken(h)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read setup token: %v", err)
|
t.Fatalf("read setup token: %v", err)
|
||||||
}
|
}
|
||||||
@@ -111,7 +128,7 @@ func TestE2E(t *testing.T) {
|
|||||||
sc.panel = p
|
sc.panel = p
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("add_domain_and_publish_dkim", func(t *testing.T) {
|
run("add_domain_and_publish_dkim", func(t *testing.T) {
|
||||||
id, err := sc.panel.addDomain(senderDomain)
|
id, err := sc.panel.addDomain(senderDomain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("add domain: %v", err)
|
t.Fatalf("add domain: %v", err)
|
||||||
@@ -137,7 +154,7 @@ func TestE2E(t *testing.T) {
|
|||||||
sc.zoneRecords = records
|
sc.zoneRecords = records
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("add_application", func(t *testing.T) {
|
run("add_application", func(t *testing.T) {
|
||||||
login, password, err := sc.panel.addApplication(sc.domainID, "app1", "wildcard", "")
|
login, password, err := sc.panel.addApplication(sc.domainID, "app1", "wildcard", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("add application: %v", err)
|
t.Fatalf("add application: %v", err)
|
||||||
@@ -145,7 +162,7 @@ func TestE2E(t *testing.T) {
|
|||||||
sc.appLogin, sc.appPassword = login, password
|
sc.appLogin, sc.appPassword = login, password
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("send_verify_dkim_and_status", func(t *testing.T) {
|
run("send_verify_dkim_and_status", func(t *testing.T) {
|
||||||
token := uniqueToken("positive")
|
token := uniqueToken("positive")
|
||||||
res := attemptSend(sendAttempt{
|
res := attemptSend(sendAttempt{
|
||||||
authLogin: sc.appLogin, authPassword: sc.appPassword,
|
authLogin: sc.appLogin, authPassword: sc.appPassword,
|
||||||
@@ -179,25 +196,25 @@ func TestE2E(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("negative_level2_ratelimit_via_panel", func(t *testing.T) {
|
run("negative_level2_ratelimit_via_panel", func(t *testing.T) {
|
||||||
testLevel2RateLimit(t, sc)
|
testLevel2RateLimit(t, sc)
|
||||||
})
|
})
|
||||||
t.Run("negative_sender_login_mismatch", func(t *testing.T) {
|
run("negative_sender_login_mismatch", func(t *testing.T) {
|
||||||
testSenderLoginMismatch(t, sc)
|
testSenderLoginMismatch(t, sc)
|
||||||
})
|
})
|
||||||
t.Run("negative_no_auth_rejected", func(t *testing.T) {
|
run("negative_no_auth_rejected", func(t *testing.T) {
|
||||||
testNoAuthRejected(t, sc)
|
testNoAuthRejected(t, sc)
|
||||||
})
|
})
|
||||||
t.Run("negative_foreign_relay_rejected", func(t *testing.T) {
|
run("negative_foreign_relay_rejected", func(t *testing.T) {
|
||||||
testForeignRelayRejected(t, sc)
|
testForeignRelayRejected(t, sc)
|
||||||
})
|
})
|
||||||
t.Run("negative_journal_milter_fail_open", func(t *testing.T) {
|
run("negative_journal_milter_fail_open", func(t *testing.T) {
|
||||||
testJournalMilterFailOpen(t, sc)
|
testJournalMilterFailOpen(t, sc)
|
||||||
})
|
})
|
||||||
t.Run("session_survives_restart", func(t *testing.T) {
|
run("session_survives_restart", func(t *testing.T) {
|
||||||
testSessionSurvivesRestart(t, sc)
|
testSessionSurvivesRestart(t, sc)
|
||||||
})
|
})
|
||||||
t.Run("negative_level1_ratelimit", func(t *testing.T) {
|
run("negative_level1_ratelimit", func(t *testing.T) {
|
||||||
testLevel1RateLimit(t, sc)
|
testLevel1RateLimit(t, sc)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-11
@@ -7,8 +7,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cookiejar"
|
"net/http/cookiejar"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -39,22 +37,48 @@ func newPanelClient() (*panelClient, error) {
|
|||||||
}}, nil
|
}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// readSetupToken reads the one-time setup URL SelfPost wrote to /data (bind
|
// waitForPanelReady polls the host-published panel port until /healthz
|
||||||
// mounted at stageDir/data/setup-token) and returns just the token.
|
// returns 200. Supervisor reporting the panel process RUNNING is not enough:
|
||||||
func readSetupToken(stageDir string) (string, error) {
|
// the setup-token file is written in Start() before ListenAndServe, and
|
||||||
var raw []byte
|
// Docker's host-port publish can lag the in-container bind — either race
|
||||||
err := waitFor("setup-token to appear", 30*time.Second, 300*time.Millisecond, func() (bool, error) {
|
// makes the first setup POST fail and leaves sc.panel nil for later steps.
|
||||||
b, err := os.ReadFile(filepath.Join(stageDir, "data", "setup-token"))
|
func waitForPanelReady() error {
|
||||||
|
client := &http.Client{Timeout: 2 * time.Second}
|
||||||
|
return waitFor("panel /healthz on "+panelBaseURL, 60*time.Second, 200*time.Millisecond, func() (bool, error) {
|
||||||
|
resp, err := client.Get(panelBaseURL + "/healthz")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
raw = b
|
defer resp.Body.Close()
|
||||||
return len(b) > 0, nil
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Errorf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// readSetupToken reads the one-time setup URL from /data/setup-token inside
|
||||||
|
// the running selfpost container. The file is mode 0600 owned by the panel
|
||||||
|
// UID (security.md / setup.go); on a typical CI bind mount that is not the
|
||||||
|
// host runner's UID, so a host-side os.ReadFile returns permission denied
|
||||||
|
// even though the panel already wrote the token (CI: setup_and_login).
|
||||||
|
// Reading via compose exec matches docs/guide.md ("docker compose exec
|
||||||
|
// selfpost cat /data/setup-token").
|
||||||
|
func readSetupToken(s *stack) (string, error) {
|
||||||
|
var raw string
|
||||||
|
err := waitFor("setup-token to appear", 30*time.Second, 300*time.Millisecond, func() (bool, error) {
|
||||||
|
out, err := s.execIn("selfpost", "cat", "/data/setup-token")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
raw = out
|
||||||
|
return strings.TrimSpace(out) != "", nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
u, err := url.Parse(strings.TrimSpace(string(raw)))
|
u, err := url.Parse(strings.TrimSpace(raw))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("parse setup token file: %w", err)
|
return "", fmt.Errorf("parse setup token file: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ func checkSupervisorProcesses(s *stack) error {
|
|||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
return err
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w\n==== selfpost logs ====\n%s", err, s.logs("selfpost"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseSupervisorStatus(out string) map[string]string {
|
func parseSupervisorStatus(out string) map[string]string {
|
||||||
|
|||||||
@@ -106,6 +106,14 @@ func (s *stack) down() {
|
|||||||
_, _ = s.compose("down", "-v", "--remove-orphans")
|
_, _ = 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.
|
// logs returns a service's combined stdout/stderr, for failure diagnostics.
|
||||||
func (s *stack) logs(service string) string {
|
func (s *stack) logs(service string) string {
|
||||||
out, _ := s.compose("logs", "--no-color", service)
|
out, _ := s.compose("logs", "--no-color", service)
|
||||||
|
|||||||
+13
-1
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -23,7 +24,7 @@ const selfpostHostname = "mail.e2e.test"
|
|||||||
// CoreDNS is authoritative for, and the sink-MX's dump directory. Called once
|
// CoreDNS is authoritative for, and the sink-MX's dump directory. Called once
|
||||||
// per run before `docker compose up`, so every run starts from a clean slate.
|
// per run before `docker compose up`, so every run starts from a clean slate.
|
||||||
func prepareStage(s *stack) error {
|
func prepareStage(s *stack) error {
|
||||||
if err := os.RemoveAll(s.stageDir); err != nil {
|
if err := removeAllBestEffort(s.stageDir); err != nil {
|
||||||
return fmt.Errorf("clean stage dir: %w", err)
|
return fmt.Errorf("clean stage dir: %w", err)
|
||||||
}
|
}
|
||||||
dirs := []string{"data", "certs", "dns-stage", "mail-stage"}
|
dirs := []string{"data", "certs", "dns-stage", "mail-stage"}
|
||||||
@@ -48,6 +49,17 @@ func prepareStage(s *stack) error {
|
|||||||
return writeZone(s.stageDir, nil)
|
return writeZone(s.stageDir, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
if err := os.RemoveAll(path); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_ = exec.Command("docker", "run", "--rm", "-v", path+":/wipe", "alpine:3.20",
|
||||||
|
"sh", "-c", "rm -rf /wipe/..?* /wipe/.[!.]* /wipe/*").Run()
|
||||||
|
return os.RemoveAll(path)
|
||||||
|
}
|
||||||
|
|
||||||
// writeSelfSignedCert generates a throwaway RSA key + self-signed certificate
|
// writeSelfSignedCert generates a throwaway RSA key + self-signed certificate
|
||||||
// for selfpostHostname, valid for a day — this stand never outlives that.
|
// for selfpostHostname, valid for a day — this stand never outlives that.
|
||||||
// Postfix's smtpd_tls_security_level is "may" (opportunistic), not enforced,
|
// Postfix's smtpd_tls_security_level is "may" (opportunistic), not enforced,
|
||||||
|
|||||||
Reference in New Issue
Block a user