Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 68 additions & 19 deletions .github/workflows/publish-private-mail-bootstrap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,27 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false

env:
IMAGE: ghcr.io/devantler-tech/platform-private-mail-bootstrap
IMAGE_TAG: v0.16.27-bootstrap-${{ github.sha }}

jobs:
verify:
name: Verify and build bootstrap without publishing
runs-on: ubuntu-latest
strategy:
matrix: &bootstrap_profiles
include:
- runtime: "0.16.27"
server: v0.16.27@sha256:c429b311c93db5606598abc1c68225a85a6953183848ae01bee9c6a98885836c
cli: 1.0.13@sha256:9a1e07307d6f890a193322c4b737fc0868405f0f8486a19922cbf362f43884fd
image: ghcr.io/devantler-tech/platform-private-mail-bootstrap
- runtime: "1.0.3"
server: v1.0.3@sha256:73c684a84c24f5b4206ec266a8deb8986fe44a9f623997e07f084388d943c8bd
cli: 1.0.14@sha256:5b70732cb69cdab3ff0894c501544cf0bfa180fa75bcd0eeba4eebe04c1064e1
image: ghcr.io/devantler-tech/platform-private-mail-bootstrap-candidate
env: &build_inputs
STALWART_RUNTIME: ${{ matrix.runtime }}
STALWART_SERVER_PIN: ${{ matrix.server }}
STALWART_CLI_PIN: ${{ matrix.cli }}
IMAGE: ${{ matrix.image }}
IMAGE_TAG: v${{ matrix.runtime }}-bootstrap-${{ github.sha }}
permissions:
contents: read
steps:
Expand All @@ -47,22 +60,64 @@ jobs:
with:
go-version-file: go.mod
cache: false
- name: Test and build static initializer
run: |
- name: Run Email configuration tests
run: go test -race ./scripts/private-mail-config
- name: Build static initializer
run: &stage_bootstrap |
set -euo pipefail
go test -race ./scripts/private-mail-config
mkdir -p "${RUNNER_TEMP}/private-mail-build"
build_dir="${RUNNER_TEMP}/private-mail-build"
dockerfile=scripts/private-mail-config/Dockerfile
mkdir -p "${build_dir}"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' \
-o "${RUNNER_TEMP}/private-mail-build/private-mail-bootstrap" ./scripts/private-mail-config
cp scripts/private-mail-config/Dockerfile "${RUNNER_TEMP}/private-mail-build/Dockerfile"
bash scripts/private-mail-config/stage-webui.sh "${RUNNER_TEMP}/private-mail-build"
docker buildx build --platform linux/amd64 "${RUNNER_TEMP}/private-mail-build"
-o "${build_dir}/private-mail-bootstrap" ./scripts/private-mail-config
# Platform listeners bind unprivileged ports. Linux refuses to exec
# the candidate with its upstream effective file capability and no capabilities.
awk -v runtime="${STALWART_RUNTIME}" -v server="${STALWART_SERVER_PIN}" -v cli="${STALWART_CLI_PIN}" '
/^FROM ghcr\.io\/stalwartlabs\/stalwart:[^ ]+$/ {
print "FROM ghcr.io/stalwartlabs/stalwart:" server
server_count++
next
}
/^FROM ghcr\.io\/stalwartlabs\/cli:[^ ]+ AS cli$/ {
print "FROM ghcr.io/stalwartlabs/cli:" cli " AS cli"
cli_count++
next
}
/^USER 0$/ && runtime == "1.0.3" && server_count == 1 {
print
print "RUN setcap -r /usr/local/bin/stalwart && capability=\"$(getcap /usr/local/bin/stalwart)\" && test -z \"$capability\""
capability_count++
next
}
{ print }
END {
if (server_count != 1 || cli_count != 1 || (runtime == "1.0.3" && capability_count != 1)) exit 1
}
' "${dockerfile}" > "${build_dir}/Dockerfile"
if [[ "${STALWART_RUNTIME}" == '0.16.27' ]]; then
cmp -s "${dockerfile}" "${build_dir}/Dockerfile"
fi
bash scripts/private-mail-config/stage-webui.sh "${build_dir}" "${STALWART_RUNTIME}"
- name: Build the selected image locally
run: docker buildx build --platform linux/amd64 --load --tag email-bootstrap-verify "${RUNNER_TEMP}/private-mail-build"
- name: Verify candidate server and CLI in the built image
if: matrix.runtime == '1.0.3'
run: |
set -euo pipefail
isolated=(--rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges)
server=$(docker run "${isolated[@]}" --entrypoint /usr/local/bin/stalwart email-bootstrap-verify --version)
cli=$(docker run "${isolated[@]}" --entrypoint /usr/local/bin/stalwart-cli email-bootstrap-verify --version)
test "${server}" = "${STALWART_RUNTIME}"
test "${cli}" = 'stalwart-cli 1.0.14'

publish:
name: Publish and sign immutable bootstrap
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: verify
runs-on: ubuntu-latest
strategy:
matrix: *bootstrap_profiles
env: *build_inputs
permissions:
contents: read
packages: write
Expand All @@ -82,13 +137,7 @@ jobs:
go-version-file: go.mod
cache: false
- name: Build static initializer
run: |
set -euo pipefail
mkdir -p "${RUNNER_TEMP}/private-mail-build"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' \
-o "${RUNNER_TEMP}/private-mail-build/private-mail-bootstrap" ./scripts/private-mail-config
cp scripts/private-mail-config/Dockerfile "${RUNNER_TEMP}/private-mail-build/Dockerfile"
bash scripts/private-mail-config/stage-webui.sh "${RUNNER_TEMP}/private-mail-build"
run: *stage_bootstrap
- name: Set up Buildx container driver
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
with:
Expand Down
9 changes: 7 additions & 2 deletions docs/adr/email.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,8 +296,13 @@ bash scripts/private-mail-config/stage-webui.sh "$build_dir" 1.0.3
```

The 1.0.3 profile supports the full OAuth scope needed for recovery administrator
sign-in through a registered Web Interface client. The default build and
serving image use the deployed baseline.
sign-in through a registered Web Interface client. The default container build
uses the deployed baseline. The publisher builds the 0.16.27 and 1.0.3 profiles
from their pinned server, CLI and Web Interface inputs. It checks the versions
inside the built 1.0.3 image without network access. Protected main publishes
the candidate to a separate image repository so the 0.16 recovery reader can
continue to require the legacy image repository. Selecting the candidate for
serving requires a separate deployment.

Selecting a schema does not migrate a datastore. Stalwart 1.0 refuses stores
created before 1.0. A coordinated upgrade requires the
Expand Down
77 changes: 55 additions & 22 deletions scripts/private-mail-config/publication_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"gopkg.in/yaml.v3"
Expand Down Expand Up @@ -44,12 +45,18 @@ func publicationFixture(t *testing.T) assetPublicationFixture {
t.Fatal(err)
}
write(filepath.Join(assetDir, "webui-pins.json"), string(pins), 0644)
write(filepath.Join(assetDir, "webui-v1-pins.json"), `{
"version": "2.0.4",
"url": "https://github.com/stalwartlabs/webui/releases/download/v2.0.4/webui.zip",
"sha256": "73dd270926cf548ff40a41a8f7a523667df1f2cd665ed680de45711a1c2d04b2",
"license_sha256": "a7c6bf8df7d8fb707af606599f275e0eb192cd28492c95593b0608ca966c8df1"
}`, 0644)
write(filepath.Join(f.root, "asset-source"), "verified admin asset fixture\n", 0644)
write(filepath.Join(f.root, "asset-v1-source"), "verified 1.0 admin asset fixture\n", 0644)
write(filepath.Join(f.root, "license-source"), "fixture license\n", 0644)
write(filepath.Join(f.bin, "go"), `#!/usr/bin/env bash
set -euo pipefail
case "$1" in
test) exit 0 ;;
build)
output=''
while [ "$#" -gt 0 ]; do
Expand All @@ -60,11 +67,6 @@ case "$1" in
;;
*) exit 64 ;;
esac
`, 0755)
write(filepath.Join(f.bin, "docker"), `#!/usr/bin/env bash
set -euo pipefail
[ "$*" = "buildx build --platform linux/amd64 $RUNNER_TEMP/private-mail-build" ]
test -s "$RUNNER_TEMP/private-mail-build/private-mail-bootstrap"
`, 0755)
write(filepath.Join(f.bin, "curl"), `#!/usr/bin/env bash
set -euo pipefail
Expand All @@ -82,7 +84,9 @@ case "$output" in "$RUNNER_TEMP/"*) ;; *) exit 64 ;; esac
case "$url" in
https://github.com/stalwartlabs/webui/releases/download/v1.0.11/webui.zip)
if [ "${FAKE_DOWNLOAD_BAD:-}" = zip ]; then printf 'altered' >"$output"; else cp "$ASSET_FIXTURE_ROOT/asset-source" "$output"; fi ;;
https://raw.githubusercontent.com/stalwartlabs/webui/v1.0.11/LICENSES/AGPL-3.0-only.txt)
https://github.com/stalwartlabs/webui/releases/download/v2.0.4/webui.zip)
cp "$ASSET_FIXTURE_ROOT/asset-v1-source" "$output" ;;
https://raw.githubusercontent.com/stalwartlabs/webui/v1.0.11/LICENSES/AGPL-3.0-only.txt|https://raw.githubusercontent.com/stalwartlabs/webui/v2.0.4/LICENSES/AGPL-3.0-only.txt)
if [ "${FAKE_DOWNLOAD_BAD:-}" = license ]; then printf 'altered' >"$output"; else cp "$ASSET_FIXTURE_ROOT/license-source" "$output"; fi ;;
*) exit 65 ;;
esac
Expand All @@ -106,34 +110,63 @@ func TestBothPublicationPathsStageTheAdministrationArtifacts(t *testing.T) {
}
var workflow struct {
Jobs map[string]struct {
Steps []struct{ Name, Run string }
Steps []struct{ Name, Run string }
Strategy struct {
Matrix struct {
Include []struct{ Runtime, Server, CLI, Image string }
}
}
}
}
if yaml.Unmarshal(data, &workflow) != nil {
t.Fatal("publication workflow could not be decoded")
}
for _, tc := range []struct{ job, step string }{
{"verify", "Test and build static initializer"}, {"publish", "Build static initializer"},
} {
t.Run(tc.job, func(t *testing.T) {
for _, job := range []string{"verify", "publish"} {
t.Run(job, func(t *testing.T) {
var script string
for _, step := range workflow.Jobs[tc.job].Steps {
if step.Name == tc.step {
for _, step := range workflow.Jobs[job].Steps {
if step.Name == "Build static initializer" {
script = step.Run
}
}
if script == "" {
t.Fatal("publication producer is missing")
}
f := publicationFixture(t)
if output, err := f.run(script); err != nil {
t.Fatalf("publication producer failed: %v: %s", err, output)
profiles := workflow.Jobs[job].Strategy.Matrix.Include
if len(profiles) != 2 {
t.Fatalf("publication must cover the deployed and candidate versions, got %d profiles", len(profiles))
}
for name, want := range map[string]string{"webui.zip": "verified admin asset fixture\n", "webui.LICENSE": "fixture license\n"} {
got, err := os.ReadFile(filepath.Join(f.runner, "private-mail-build", name))
if err != nil || string(got) != want {
t.Fatalf("publication did not stage the required administration artifact %s", name)
}
for i, runtime := range []struct{ version, cli, asset, image string }{
{"0.16.27", "1.0.13", "verified admin asset fixture\n", "ghcr.io/devantler-tech/platform-private-mail-bootstrap"},
{"1.0.3", "1.0.14", "verified 1.0 admin asset fixture\n", "ghcr.io/devantler-tech/platform-private-mail-bootstrap-candidate"},
} {
t.Run(runtime.version, func(t *testing.T) {
profile := profiles[i]
if profile.Runtime != runtime.version || !strings.HasPrefix(profile.Server, "v"+runtime.version+"@sha256:") ||
!strings.HasPrefix(profile.CLI, runtime.cli+"@sha256:") || profile.Image != runtime.image {
t.Fatal("runtime must use the matching pinned server, CLI and publication repository")
}
f := publicationFixture(t)
if output, err := f.run(script, "STALWART_RUNTIME="+profile.Runtime,
"STALWART_SERVER_PIN="+profile.Server, "STALWART_CLI_PIN="+profile.CLI); err != nil {
t.Fatalf("publication producer failed: %v: %s", err, output)
}
buildRecipe, err := os.ReadFile(filepath.Join(f.runner, "private-mail-build", "Dockerfile"))
if err != nil || !strings.Contains(string(buildRecipe), "FROM ghcr.io/stalwartlabs/stalwart:"+profile.Server+"\n") ||
!strings.Contains(string(buildRecipe), "FROM ghcr.io/stalwartlabs/cli:"+profile.CLI+" AS cli\n") {
t.Fatal("staged recipe does not select the required server and CLI")
}
stripsServerCapability := strings.Contains(string(buildRecipe), "\nUSER 0\nRUN setcap -r /usr/local/bin/stalwart")
if stripsServerCapability != (runtime.version == "1.0.3") {
t.Fatal("only the candidate recipe must remove Stalwart's privileged-port file capability as root")
}
for name, want := range map[string]string{"webui.zip": runtime.asset, "webui.LICENSE": "fixture license\n"} {
got, err := os.ReadFile(filepath.Join(f.runner, "private-mail-build", name))
if err != nil || string(got) != want {
t.Fatalf("publication did not stage the selected administration artifact %s", name)
}
}
})
}
})
}
Expand Down
Loading