ci: use idiomatic Dagger service bindings for Stalwart
Refactor the CI pipeline to use WithServiceBinding for the Stalwart mail server, replacing legacy shell scripts and manual port management. Introduces pre-seeded data for the Stalwart service to avoid network hits and improves headless UI testing with Xvfb.
This commit is contained in:
+34
-4
@@ -1,13 +1,23 @@
|
|||||||
# Dagger context ignore file.
|
# Dagger context ignore file.
|
||||||
# Since we use explicit inclusion in ci/main.go (Base function),
|
|
||||||
# we only need to ignore large or sensitive directories here to
|
|
||||||
# avoid unnecessary upload overhead to the Dagger engine.
|
|
||||||
|
|
||||||
|
# Version control
|
||||||
.git/
|
.git/
|
||||||
|
.gitmodules
|
||||||
|
|
||||||
|
# Build artifacts and tools
|
||||||
build/
|
build/
|
||||||
.dart_tool/
|
.dart_tool/
|
||||||
.fvm/
|
.fvm/
|
||||||
.pub-cache/
|
.pub-cache/
|
||||||
|
.dart-cli-completion/
|
||||||
|
.dartServer/
|
||||||
|
.direnv/
|
||||||
|
.dotnet/
|
||||||
|
.rustup/
|
||||||
|
.task/
|
||||||
|
.vscode/
|
||||||
|
.vscode-server/
|
||||||
|
coverage/
|
||||||
node_modules/
|
node_modules/
|
||||||
ios/Pods/
|
ios/Pods/
|
||||||
macos/Pods/
|
macos/Pods/
|
||||||
@@ -15,6 +25,26 @@ linux/flutter/ephemeral/
|
|||||||
website/public/
|
website/public/
|
||||||
website/resources/
|
website/resources/
|
||||||
|
|
||||||
# Sensitive files
|
# Sensitive or system files
|
||||||
.env*
|
.env*
|
||||||
.ssh/
|
.ssh/
|
||||||
|
.local/
|
||||||
|
.cache/
|
||||||
|
.config/
|
||||||
|
.atuin/
|
||||||
|
.gemini/
|
||||||
|
.bash*
|
||||||
|
.profile
|
||||||
|
.zsh*
|
||||||
|
.zcompdump
|
||||||
|
.tmux.conf
|
||||||
|
.lesshst
|
||||||
|
.metadata
|
||||||
|
.emulator_console_auth_token
|
||||||
|
snap/
|
||||||
|
.gitconfig
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
.flutter
|
||||||
|
.forgejo
|
||||||
|
.fvmrc
|
||||||
|
.envrc
|
||||||
|
|||||||
+15
-12
@@ -172,22 +172,25 @@ tasks:
|
|||||||
- fvm flutter test
|
- fvm flutter test
|
||||||
|
|
||||||
test-backend:
|
test-backend:
|
||||||
desc: Backend tests against a local Stalwart mail server
|
desc: Backend tests against a local Stalwart mail server (via Dagger)
|
||||||
deps: [_flutter-check]
|
|
||||||
sources:
|
|
||||||
- lib/**/*.dart
|
|
||||||
- test/backend/**/*.dart
|
|
||||||
cmds:
|
cmds:
|
||||||
- stalwart-dev/test.sh
|
- dagger call -m ci test-backend --source .
|
||||||
|
|
||||||
integration-ui:
|
integration-ui:
|
||||||
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed
|
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed (via Dagger)
|
||||||
deps: [_preflight, _linux-deps-check, _pub-get]
|
|
||||||
sources:
|
|
||||||
- lib/**/*.dart
|
|
||||||
- integration_test/app_e2e_test.dart
|
|
||||||
cmds:
|
cmds:
|
||||||
- stalwart-dev/integration_ui_test.sh
|
- dagger call -m ci test-integration --source .
|
||||||
|
|
||||||
|
sync-reliability:
|
||||||
|
desc: Run sync reliability runner (via Dagger)
|
||||||
|
cmds:
|
||||||
|
- dagger call -m ci test-sync-reliability --source .
|
||||||
|
|
||||||
|
stalwart:
|
||||||
|
desc: Start a Stalwart instance for local development (via Dagger)
|
||||||
|
cmds:
|
||||||
|
- echo "Starting Stalwart on default ports (JMAP=8080, IMAP=1430, SMTP=1025, SIEVE=4190)"
|
||||||
|
- dagger call -m ci stalwart up --ports 8080:8080 --ports 1430:1430 --ports 1025:1025 --ports 4190:4190
|
||||||
|
|
||||||
integration-android:
|
integration-android:
|
||||||
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
|
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
|
||||||
|
|||||||
+58
-24
@@ -41,7 +41,7 @@ func (m *Ci) Base() *dagger.Container {
|
|||||||
return dag.Container().
|
return dag.Container().
|
||||||
From("ghcr.io/cirruslabs/flutter:3.41.6").
|
From("ghcr.io/cirruslabs/flutter:3.41.6").
|
||||||
WithExec([]string{"apt-get", "update"}).
|
WithExec([]string{"apt-get", "update"}).
|
||||||
WithExec([]string{"apt-get", "install", "-y", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "curl", "python3", "iproute2", "netcat-openbsd"}).
|
WithExec([]string{"apt-get", "install", "-y", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "curl", "python3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libgles2-mesa", "libegl1"}).
|
||||||
WithMountedCache("/root/.pub-cache", dag.CacheVolume("flutter-pub-cache")).
|
WithMountedCache("/root/.pub-cache", dag.CacheVolume("flutter-pub-cache")).
|
||||||
WithMountedCache("/root/.gradle", dag.CacheVolume("gradle-cache")).
|
WithMountedCache("/root/.gradle", dag.CacheVolume("gradle-cache")).
|
||||||
WithEnvVariable("PUB_CACHE", "/root/.pub-cache").
|
WithEnvVariable("PUB_CACHE", "/root/.pub-cache").
|
||||||
@@ -75,15 +75,35 @@ func (m *Ci) Stalwart() *dagger.Service {
|
|||||||
return dag.Container().
|
return dag.Container().
|
||||||
From("stalwartlabs/stalwart:v0.14.1").
|
From("stalwartlabs/stalwart:v0.14.1").
|
||||||
WithFile("/etc/stalwart/config.toml", config).
|
WithFile("/etc/stalwart/config.toml", config).
|
||||||
// Create data dir in /tmp where permissions are usually more relaxed.
|
// Pre-seed data directory and spam-filter version to avoid network hits on startup.
|
||||||
WithExec([]string{"/bin/sh", "-c", "mkdir -p /tmp/stalwart && chmod 777 /tmp/stalwart"}).
|
WithExec([]string{"/bin/sh", "-c", "mkdir -p /tmp/stalwart && chmod 777 /tmp/stalwart"}).
|
||||||
|
WithExec([]string{"sqlite3", "/tmp/stalwart/data.sqlite", "CREATE TABLE IF NOT EXISTS s (k BLOB PRIMARY KEY, v BLOB NOT NULL); INSERT OR REPLACE INTO s VALUES ('version.spam-filter', 'dev');"}).
|
||||||
WithExposedPort(8080). // JMAP
|
WithExposedPort(8080). // JMAP
|
||||||
WithExposedPort(1430). // IMAP
|
WithExposedPort(1430). // IMAP
|
||||||
WithExposedPort(1025). // SMTP
|
WithExposedPort(1025). // SMTP
|
||||||
WithExposedPort(4190). // ManageSieve
|
WithExposedPort(4190). // ManageSieve
|
||||||
|
// Explicitly run the binary with the config path to avoid bootstrap mode.
|
||||||
|
WithEntrypoint([]string{"stalwart", "--config", "/etc/stalwart/config.toml"}).
|
||||||
AsService()
|
AsService()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to bind Stalwart service and set up environment variables for tests
|
||||||
|
func (m *Ci) WithStalwart(container *dagger.Container) *dagger.Container {
|
||||||
|
stalwart := m.Stalwart()
|
||||||
|
return container.
|
||||||
|
WithServiceBinding("stalwart", stalwart).
|
||||||
|
WithEnvVariable("STALWART_IMAP_HOST", "stalwart").
|
||||||
|
WithEnvVariable("STALWART_SMTP_HOST", "stalwart").
|
||||||
|
WithEnvVariable("STALWART_URL", "http://stalwart:8080").
|
||||||
|
WithEnvVariable("STALWART_IMAP_PORT", "1430").
|
||||||
|
WithEnvVariable("STALWART_SMTP_PORT", "1025").
|
||||||
|
WithEnvVariable("STALWART_SIEVE_PORT", "4190").
|
||||||
|
WithEnvVariable("STALWART_USER_B", "alice@example.com").
|
||||||
|
WithEnvVariable("STALWART_PASS_B", "secret").
|
||||||
|
WithEnvVariable("STALWART_USER_C", "bob@example.com").
|
||||||
|
WithEnvVariable("STALWART_PASS_C", "secret")
|
||||||
|
}
|
||||||
|
|
||||||
// Setup environment: pub get and build_runner
|
// Setup environment: pub get and build_runner
|
||||||
func (m *Ci) Setup() *dagger.Container {
|
func (m *Ci) Setup() *dagger.Container {
|
||||||
return m.Base().
|
return m.Base().
|
||||||
@@ -129,15 +149,37 @@ func (m *Ci) CheckMocks(ctx context.Context) (string, error) {
|
|||||||
// Run coverage check
|
// Run coverage check
|
||||||
func (m *Ci) Coverage(ctx context.Context) (string, error) {
|
func (m *Ci) Coverage(ctx context.Context) (string, error) {
|
||||||
return m.Setup().
|
return m.Setup().
|
||||||
WithExec([]string{"flutter", "test", "test/unit", "--coverage"}).
|
WithExec([]string{"flutter", "test", "test/unit", "--coverage", "--reporter", "expanded"}).
|
||||||
WithExec([]string{"dart", "scripts/check_coverage.dart"}).
|
WithExec([]string{"dart", "scripts/check_coverage.dart"}).
|
||||||
Stdout(ctx)
|
Stdout(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run backend tests (IMAP/JMAP sync logic)
|
||||||
|
func (m *Ci) TestBackend(ctx context.Context) (string, error) {
|
||||||
|
return m.WithStalwart(m.Setup()).
|
||||||
|
WithExec([]string{"flutter", "test", "--concurrency=1", "--reporter", "expanded", "test/backend"}).
|
||||||
|
Stdout(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run UI integration tests via Xvfb
|
||||||
|
func (m *Ci) TestIntegration(ctx context.Context) (string, error) {
|
||||||
|
return m.WithStalwart(m.Setup()).
|
||||||
|
// Use xvfb-run for simpler X11 management.
|
||||||
|
// LIBGL_ALWAYS_SOFTWARE=1 ensures software rendering for Flutter in headless environments.
|
||||||
|
WithEnvVariable("LIBGL_ALWAYS_SOFTWARE", "1").
|
||||||
|
WithExec([]string{"xvfb-run", "-s", "-screen 0 1280x720x24", "flutter", "test", "integration_test/", "-d", "linux"}).
|
||||||
|
Stdout(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run sync reliability runner
|
||||||
|
func (m *Ci) TestSyncReliability(ctx context.Context) (string, error) {
|
||||||
|
return m.WithStalwart(m.Setup()).
|
||||||
|
WithExec([]string{"flutter", "test", "test/backend/sync_reliability_test.dart", "--reporter", "expanded", "--concurrency=1"}).
|
||||||
|
Stdout(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// Full check suite (equivalent to task check)
|
// Full check suite (equivalent to task check)
|
||||||
func (m *Ci) Check(ctx context.Context) (string, error) {
|
func (m *Ci) Check(ctx context.Context) (string, error) {
|
||||||
setup := m.Setup()
|
|
||||||
|
|
||||||
// Hygiene & Layers
|
// Hygiene & Layers
|
||||||
if _, err := m.CheckHygiene(ctx); err != nil {
|
if _, err := m.CheckHygiene(ctx); err != nil {
|
||||||
return "Hygiene check failed", err
|
return "Hygiene check failed", err
|
||||||
@@ -146,6 +188,8 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
|
|||||||
return "Layer check failed", err
|
return "Layer check failed", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setup := m.Setup()
|
||||||
|
|
||||||
// Format (Running after Setup/pub get ensures package resolution context)
|
// Format (Running after Setup/pub get ensures package resolution context)
|
||||||
if _, err := setup.WithExec([]string{"dart", "format", "--output=none", "--set-exit-if-changed", "lib", "test"}).Stdout(ctx); err != nil {
|
if _, err := setup.WithExec([]string{"dart", "format", "--output=none", "--set-exit-if-changed", "lib", "test"}).Stdout(ctx); err != nil {
|
||||||
return "Format check failed", err
|
return "Format check failed", err
|
||||||
@@ -163,29 +207,19 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
|
|||||||
return coverage, err
|
return coverage, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run backend tests (requires Stalwart Service)
|
// Run backend tests
|
||||||
stalwart := m.Stalwart()
|
testBackend, err := m.TestBackend(ctx)
|
||||||
testBackend, err := setup.
|
|
||||||
WithServiceBinding("stalwart", stalwart).
|
|
||||||
WithEnvVariable("STALWART_IMAP_HOST", "stalwart").
|
|
||||||
WithEnvVariable("STALWART_SMTP_HOST", "stalwart").
|
|
||||||
WithEnvVariable("STALWART_URL", "http://stalwart:8080").
|
|
||||||
WithEnvVariable("STALWART_IMAP_PORT", "1430").
|
|
||||||
WithEnvVariable("STALWART_SMTP_PORT", "1025").
|
|
||||||
WithEnvVariable("STALWART_SIEVE_PORT", "4190").
|
|
||||||
WithEnvVariable("STALWART_USER_B", "alice@example.com").
|
|
||||||
WithEnvVariable("STALWART_PASS_B", "secret").
|
|
||||||
WithEnvVariable("STALWART_USER_C", "bob@example.com").
|
|
||||||
WithEnvVariable("STALWART_PASS_C", "secret").
|
|
||||||
// Wait for Stalwart to be ready before running tests.
|
|
||||||
WithExec([]string{"/bin/bash", "-c", "for i in {1..30}; do nc -z stalwart 1430 && echo 'Stalwart is ready' && break; echo 'Waiting for Stalwart...'; sleep 1; done"}).
|
|
||||||
WithExec([]string{"flutter", "test", "--concurrency=1", "test/backend"}).
|
|
||||||
Stdout(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return testBackend, err
|
return testBackend, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("All checks passed!\n\nAnalysis:\n%s\n\n%s\n\nBackend Tests:\n%s\n", analyze, coverage, testBackend), nil
|
// Run UI integration tests
|
||||||
|
testIntegration, err := m.TestIntegration(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return testIntegration, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("All checks passed!\n\nAnalysis:\n%s\n\n%s\n\nBackend Tests:\n%s\n\nIntegration Tests:\n%s\n", analyze, coverage, testBackend, testIntegration), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate build history Hugo content by scanning the remote server
|
// Generate build history Hugo content by scanning the remote server
|
||||||
|
|||||||
+22
-19
@@ -6,16 +6,6 @@
|
|||||||
# STALWART_TMPDIR/ports.env for other scripts to source.
|
# STALWART_TMPDIR/ports.env for other scripts to source.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
command -v stalwart >/dev/null || {
|
|
||||||
echo "stalwart not in PATH — run inside nix develop"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
command -v ss >/dev/null || {
|
|
||||||
echo "ss not in PATH — cannot verify Stalwart ports"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ "${STALWART_RANDOM_PORTS:-0}" = "1" ] || [ "${STALWART_PORT:-0}" = "0" ]; then
|
if [ "${STALWART_RANDOM_PORTS:-0}" = "1" ] || [ "${STALWART_PORT:-0}" = "0" ]; then
|
||||||
command -v python3 >/dev/null || {
|
command -v python3 >/dev/null || {
|
||||||
echo "python3 not in PATH — cannot choose random Stalwart ports"
|
echo "python3 not in PATH — cannot choose random Stalwart ports"
|
||||||
@@ -61,17 +51,30 @@ export STALWART_SIEVE_PORT=${STALWART_SIEVE_PORT}
|
|||||||
export STALWART_URL=${STALWART_URL}
|
export STALWART_URL=${STALWART_URL}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
# Find a container runtime
|
||||||
|
if command -v podman >/dev/null 2>&1; then
|
||||||
|
RUNTIME="podman"
|
||||||
|
elif command -v docker >/dev/null 2>&1; then
|
||||||
|
RUNTIME="docker"
|
||||||
|
else
|
||||||
|
echo "No container runtime (podman or docker) found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Stalwart ports: JMAP=${STALWART_PORT} IMAP=${STALWART_IMAP_PORT} SMTP=${STALWART_SMTP_PORT} SIEVE=${STALWART_SIEVE_PORT}" >&2
|
echo "Stalwart ports: JMAP=${STALWART_PORT} IMAP=${STALWART_IMAP_PORT} SMTP=${STALWART_SMTP_PORT} SIEVE=${STALWART_SIEVE_PORT}" >&2
|
||||||
echo "Stalwart is running in the foreground. Press Ctrl+C to stop." >&2
|
echo "Stalwart is running in a container (${RUNTIME}). Press Ctrl+C to stop." >&2
|
||||||
echo "Connection info written to ${TMPDIR}/ports.env" >&2
|
echo "Connection info written to ${TMPDIR}/ports.env" >&2
|
||||||
|
|
||||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
|
||||||
sed -e "s|0.0.0.0:8080|0.0.0.0:${STALWART_PORT}|" \
|
# Run Stalwart in container, mapping the random host ports to the fixed container ports.
|
||||||
-e "s|0.0.0.0:1430|0.0.0.0:${STALWART_IMAP_PORT}|" \
|
# We mount the config.toml and use /tmp/stalwart for data (mapped to our local TMPDIR).
|
||||||
-e "s|0.0.0.0:1025|0.0.0.0:${STALWART_SMTP_PORT}|" \
|
exec "${RUNTIME}" run --rm -i \
|
||||||
-e "s|0.0.0.0:4190|0.0.0.0:${STALWART_SIEVE_PORT}|" \
|
-p "${STALWART_PORT}:8080" \
|
||||||
-e "s|/tmp/stalwart|${TMPDIR}|" \
|
-p "${STALWART_IMAP_PORT}:1430" \
|
||||||
"${REPO_ROOT}/stalwart-dev/config.toml" >"${TMPDIR}/config.toml"
|
-p "${STALWART_SMTP_PORT}:1025" \
|
||||||
|
-p "${STALWART_SIEVE_PORT}:4190" \
|
||||||
exec stalwart --config "${TMPDIR}/config.toml"
|
-v "${REPO_ROOT}/stalwart-dev/config.toml:/etc/stalwart/config.toml:ro" \
|
||||||
|
-v "${TMPDIR}:/tmp/stalwart:rw" \
|
||||||
|
docker.io/stalwartlabs/stalwart:v0.14.1 \
|
||||||
|
stalwart --config /etc/stalwart/config.toml
|
||||||
|
|||||||
@@ -12,11 +12,6 @@ export STALWART_RANDOM_PORTS=1
|
|||||||
STALWART_TMPDIR="$(mktemp -d /tmp/stalwart-dev-XXXXXX)"
|
STALWART_TMPDIR="$(mktemp -d /tmp/stalwart-dev-XXXXXX)"
|
||||||
export STALWART_TMPDIR
|
export STALWART_TMPDIR
|
||||||
|
|
||||||
command -v stalwart >/dev/null || {
|
|
||||||
echo "stalwart not in PATH — run inside nix develop"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Kill any stalwart left over from a previous run.
|
# Kill any stalwart left over from a previous run.
|
||||||
pkill -x stalwart 2>/dev/null && sleep 0.5 || true
|
pkill -x stalwart 2>/dev/null && sleep 0.5 || true
|
||||||
|
|
||||||
@@ -34,8 +29,8 @@ tmp=$(mktemp)
|
|||||||
STALWART_PID=$!
|
STALWART_PID=$!
|
||||||
trap 'kill "$STALWART_PID" 2>/dev/null || true; wait "$STALWART_PID" 2>/dev/null || true; rm -f "$tmp"' EXIT
|
trap 'kill "$STALWART_PID" 2>/dev/null || true; wait "$STALWART_PID" 2>/dev/null || true; rm -f "$tmp"' EXIT
|
||||||
|
|
||||||
# Wait until Stalwart is accepting connections (up to 10 s).
|
# Wait until Stalwart is accepting connections (up to 60 s).
|
||||||
for _i in $(seq 1 20); do
|
for _i in $(seq 1 120); do
|
||||||
# shellcheck source=/dev/null
|
# shellcheck source=/dev/null
|
||||||
[ -f "${STALWART_TMPDIR}/ports.env" ] && . "${STALWART_TMPDIR}/ports.env"
|
[ -f "${STALWART_TMPDIR}/ports.env" ] && . "${STALWART_TMPDIR}/ports.env"
|
||||||
grep -E "already in use" "$LOGFILE" >/dev/null 2>&1 && {
|
grep -E "already in use" "$LOGFILE" >/dev/null 2>&1 && {
|
||||||
|
|||||||
Reference in New Issue
Block a user