Merge pull request #6 from Coffey-Labs/feat/resolve-latest-ihasmail
Deploy ihasmail's newest release, recorded by its dated tag
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
# The end-to-end test, every week, against ihasmail's newest release.
|
||||
#
|
||||
# A deploy with no --ihasmail-image takes whatever ihasmail release is newest
|
||||
# when it runs, rather than one this repository pinned. That is only safe if
|
||||
# something notices when a new release stops working with the Stalwart and
|
||||
# Caddy versions pinned here -- this is that something. It runs on Mondays
|
||||
# three hours after ihasmail's weekly release (09:00 UTC), so a release that
|
||||
# breaks the one shot shows up the same day, before most people deploy it.
|
||||
#
|
||||
# e2e/public.sh needs nothing from the internet but images: Pebble stands in for
|
||||
# Let's Encrypt and a DNS stub answers every name, and it publishes 25, 80, 443,
|
||||
# 465, 993, 995 and 4190 on the runner while it runs.
|
||||
name: End-to-end
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 12 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: e2e
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
public:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Vet and test
|
||||
run: |
|
||||
go vet ./...
|
||||
go test ./...
|
||||
- name: Deploy and check a public stack
|
||||
run: e2e/public.sh
|
||||
@@ -76,11 +76,14 @@ In order, in one run:
|
||||
and usable, every port it needs is free, the deployment directory is new or
|
||||
empty, no compose project of the same name exists, and the hostnames resolve.
|
||||
It reports every problem at once, not the first.
|
||||
2. **Shows you the plan and asks** before going ahead. `--yes` skips the
|
||||
2. **Finds ihasmail's newest release, shows you the plan and asks** before
|
||||
going ahead, so what you agree to is an exact ihasmail version. `--yes` skips the
|
||||
question, and is required when there is no terminal to ask on.
|
||||
3. **Writes the deployment directory**: `compose.yaml`, the `Caddyfile`, and
|
||||
`.env` holding a freshly generated `APP_SECRET`.
|
||||
4. **Pulls the images**, pinned to versions tested together.
|
||||
4. **Pulls the images**: Stalwart and Caddy at the versions this release of the
|
||||
tool was tested with, and the ihasmail release it found, written into
|
||||
`compose.yaml` by its dated tag so nothing moves it later.
|
||||
5. **Starts Stalwart in bootstrap mode** with a one-time administrator whose
|
||||
password exists only in the tool's memory.
|
||||
6. **Completes Stalwart's setup** through its API, the same setup its web UI
|
||||
@@ -204,10 +207,12 @@ ihasmail-oneshot deploy --local --user alice
|
||||
==> a local pair for example.test, in /home/you/ihasmail-example-test
|
||||
ihasmail http://127.0.0.1:8080
|
||||
Stalwart http://127.0.0.1:8081 (admin UI; no mail ports published)
|
||||
images stalwartlabs/stalwart:v0.16.22, ghcr.io/coffey-labs/ihasmail:2026.9.10-pr328
|
||||
images stalwartlabs/stalwart:v0.16.22, ihasmail's newest release
|
||||
mailboxes alice
|
||||
==> preflight
|
||||
docker 29.8.0, compose 5.5.1
|
||||
==> finding ihasmail's newest release
|
||||
ihasmail 2026.9.13+pr344, recorded as ghcr.io/coffey-labs/ihasmail:2026.9.13-pr344
|
||||
deploy this? [y/N] y
|
||||
==> writing /home/you/ihasmail-example-test
|
||||
==> pulling images
|
||||
@@ -219,7 +224,7 @@ deploy this? [y/N] y
|
||||
ihasmail (172.31.253.11) exempt from Stalwart's auto-ban
|
||||
restarting Stalwart to apply them
|
||||
mailbox [email protected] created
|
||||
signed in to ihasmail 2026.9.10+pr328 as [email protected]: linked
|
||||
signed in to ihasmail 2026.9.13+pr344 as [email protected]: linked
|
||||
==> done
|
||||
webmail http://127.0.0.1:8080
|
||||
Stalwart http://127.0.0.1:8081/admin
|
||||
@@ -416,14 +421,17 @@ The containers restart by themselves after a crash or a reboot
|
||||
|
||||
### Upgrading
|
||||
|
||||
Change the image tag in `compose.yaml` and apply it:
|
||||
Nothing upgrades on its own: every image in `compose.yaml` is a fixed version,
|
||||
ihasmail included. To upgrade, change the image tag there and apply it:
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
- **ihasmail** is safe to move to any newer release that supports your
|
||||
Stalwart version. Its release notes say which.
|
||||
Stalwart version. Its release notes say which. The newest is on
|
||||
[ihasmail's releases](https://github.com/Coffey-Labs/ihasmail/releases); its
|
||||
image tag is the version with `+` written as `-`, e.g. `2026.9.13-pr344`.
|
||||
- **Stalwart**: read its upgrade notes before changing versions. Check that the
|
||||
ihasmail version you run supports the new Stalwart release first, since
|
||||
ihasmail validates against one Stalwart release at a time. Back up
|
||||
@@ -477,7 +485,7 @@ ihasmail-oneshot deploy --local [flags]
|
||||
| `--dir` | `./PROJECT` | Deployment directory to write. Must be new or empty |
|
||||
| `--project` | `ihasmail-DOMAIN` (dots as dashes) | Compose project name, which prefixes containers, network and volumes |
|
||||
| `--stalwart-image` | `stalwartlabs/stalwart:v0.16.22` | Stalwart image |
|
||||
| `--ihasmail-image` | `ghcr.io/coffey-labs/ihasmail:2026.9.10-pr328` | ihasmail image |
|
||||
| `--ihasmail-image` | the newest release | ihasmail image. By default the tool looks up ihasmail's newest release and writes it into `compose.yaml` by its dated tag; name an image to use a particular one |
|
||||
| `--caddy-image` | `caddy:2.11.4` | Caddy image |
|
||||
| `--webmail-bind` | `127.0.0.1:8080` | Host address for ihasmail's own port, for reaching it without Caddy |
|
||||
| `--stalwart-bind` | `127.0.0.1:8081` | Host address for Stalwart's plain-HTTP port. The tool configures Stalwart through it |
|
||||
@@ -855,9 +863,16 @@ The code:
|
||||
## Versions and releases
|
||||
|
||||
Releases are tagged by date, like ihasmail's: `v2026.9.13`, with `.1`, `.2`
|
||||
added for another release the same day. Each release pins the Stalwart,
|
||||
ihasmail and Caddy images it was tested with as its defaults. A newer release
|
||||
of the tool generally means newer tested versions.
|
||||
added for another release the same day. Each release pins the Stalwart and
|
||||
Caddy images it was tested with as its defaults. A newer release of the tool
|
||||
generally means newer tested versions of those.
|
||||
|
||||
ihasmail is the exception: a deploy takes its newest release, so a new
|
||||
ihasmail needs no new release of this tool. What keeps that safe is the
|
||||
end-to-end test, which runs every Monday against ihasmail's newest release, a
|
||||
few hours after ihasmail publishes it. Stalwart is never taken this way — an
|
||||
upgrade migrates its data with no way back, so its version only changes in a
|
||||
release of this tool.
|
||||
|
||||
Binaries for `linux/amd64` and `linux/arm64` and a `SHA256SUMS` file are
|
||||
attached to every [release](https://github.com/Coffey-Labs/ihasmail-oneshot/releases).
|
||||
|
||||
@@ -101,7 +101,7 @@ Flags:
|
||||
fs.StringVar(&o.Dir, "dir", "", "deployment directory to write, new or empty (default ./PROJECT)")
|
||||
fs.StringVar(&o.Project, "project", "", "compose project name (default ihasmail-DOMAIN, dots as dashes)")
|
||||
fs.StringVar(&o.StalwartImage, "stalwart-image", config.DefaultStalwartImage, "Stalwart image")
|
||||
fs.StringVar(&o.IhasmailImage, "ihasmail-image", config.DefaultIhasmailImage, "ihasmail image")
|
||||
fs.StringVar(&o.IhasmailImage, "ihasmail-image", config.NewestIhasmail, "ihasmail image; the default is the newest release, written into compose.yaml as its dated tag")
|
||||
fs.StringVar(&o.CaddyImage, "caddy-image", config.DefaultCaddyImage, "Caddy image")
|
||||
fs.StringVar(&o.WebmailBind, "webmail-bind", "127.0.0.1:8080", "host address for ihasmail's own port")
|
||||
fs.StringVar(&o.StalwartBind, "stalwart-bind", "127.0.0.1:8081", "host address for Stalwart's plain-HTTP port (admin UI)")
|
||||
@@ -132,6 +132,10 @@ Flags:
|
||||
for _, w := range warnings {
|
||||
log.Warn("%s", w)
|
||||
}
|
||||
// Before the question, so the answer is about the exact ihasmail release.
|
||||
if plan, err = deploy.ResolveIhasmail(ctx, plan, log); err != nil {
|
||||
return err
|
||||
}
|
||||
if !yes {
|
||||
if err := confirm("deploy this?"); err != nil {
|
||||
return err
|
||||
@@ -162,7 +166,11 @@ func describe(p config.Plan, log deploy.Log) {
|
||||
log.Info("ACME Let's Encrypt, contact %s", p.Email)
|
||||
}
|
||||
}
|
||||
if p.FollowsNewestIhasmail() {
|
||||
log.Info("images %s, ihasmail's newest release", p.StalwartImage)
|
||||
} else {
|
||||
log.Info("images %s, %s", p.StalwartImage, p.IhasmailImage)
|
||||
}
|
||||
if !p.Local {
|
||||
log.Info(" %s", p.CaddyImage)
|
||||
}
|
||||
|
||||
@@ -96,6 +96,16 @@ ok "deploy completed and signed in through ihasmail"
|
||||
grep -q "certificate issued by CN=Pebble" "$WORK/deploy.log" || die "deploy did not report Stalwart's certificate"
|
||||
ok "deploy reported Stalwart's certificate"
|
||||
|
||||
# No --ihasmail-image, so the deploy took the newest release -- and must have
|
||||
# written it down as a dated tag, never the moving :latest.
|
||||
image=$(sed -n 's/^ image: \(ghcr.io\/coffey-labs\/ihasmail[:@].*\)$/\1/p' "$DEPLOY/compose.yaml")
|
||||
[[ "$image" =~ ^ghcr\.io/coffey-labs/ihasmail:[0-9]{4}\.[0-9]{1,2}\.[0-9]{1,2}-(pr[0-9]+|g[0-9a-f]+)$ ]] \
|
||||
|| die "compose.yaml does not pin ihasmail to a dated release: ${image:-none}"
|
||||
ok "compose.yaml pins ihasmail to $image"
|
||||
running=$(docker compose --project-directory "$DEPLOY" ps -q ihasmail | xargs docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^IHASMAIL_VERSION=//p')
|
||||
[ "${running/+/-}" = "${image##*:}" ] || die "ihasmail runs $running, but compose.yaml says ${image##*:}"
|
||||
ok "the running ihasmail is the release compose.yaml names ($running)"
|
||||
|
||||
# expect CODE curl-args...: retry for up to 30s until curl gets CODE. Caddy
|
||||
# obtains certificates for its names in parallel and in the background, so the
|
||||
# first handshake for any one of them can come a few seconds after deploy.
|
||||
|
||||
@@ -22,16 +22,47 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Versions this release was tested with, end to end. Stalwart is pinned
|
||||
// because ihasmail validates against one Stalwart release at a time; the
|
||||
// ihasmail tag is the newest release at the time; Caddy is pinned so that a
|
||||
// redeploy months from now renders the same proxy.
|
||||
// Stalwart is pinned to the release this version of the tool was tested with,
|
||||
// because a Stalwart upgrade migrates its store with no way back and ihasmail
|
||||
// validates against one Stalwart release at a time. Caddy is pinned so that a
|
||||
// deploy months from now renders the same proxy.
|
||||
//
|
||||
// ihasmail is not pinned here. A pin went stale within days of each release,
|
||||
// and ihasmail's image cleanup keeps ten releases, so an old default would in
|
||||
// time stop pulling at all. The default is the newest release instead, looked
|
||||
// up when the tool runs and written into compose.yaml as its dated tag -- see
|
||||
// deploy.ResolveIhasmail -- so what a deployment runs is still recorded and
|
||||
// nothing moves it afterwards.
|
||||
const (
|
||||
DefaultStalwartImage = "stalwartlabs/stalwart:v0.16.22"
|
||||
DefaultIhasmailImage = "ghcr.io/coffey-labs/ihasmail:2026.9.10-pr328"
|
||||
DefaultCaddyImage = "caddy:2.11.4"
|
||||
|
||||
IhasmailRepository = "ghcr.io/coffey-labs/ihasmail"
|
||||
// NewestIhasmail is the default --ihasmail-image. Only full releases move
|
||||
// this tag; prereleases never do.
|
||||
NewestIhasmail = IhasmailRepository + ":latest"
|
||||
)
|
||||
|
||||
// ihasmail's versions are the date of a commit and where it came from --
|
||||
// 2026.9.13+pr344, or 2026.9.13+g1fa6578 for a commit that arrived without a
|
||||
// pull request -- and its image tags are the same with the "+" as "-", since a
|
||||
// Docker tag may not contain "+".
|
||||
var ihasmailVersionRE = regexp.MustCompile(`^\d{4}\.\d{1,2}\.\d{1,2}\+(?:pr\d+|g[0-9a-f]{7,40})$`)
|
||||
|
||||
// IhasmailTag is the image tag an ihasmail version is published under. A
|
||||
// version that is not a release's -- empty, or the 0.0.0 of a build nobody
|
||||
// gave a version to -- has none.
|
||||
func IhasmailTag(version string) (string, bool) {
|
||||
if !ihasmailVersionRE.MatchString(version) {
|
||||
return "", false
|
||||
}
|
||||
return strings.Replace(version, "+", "-", 1), true
|
||||
}
|
||||
|
||||
// FollowsNewestIhasmail reports whether the plan still asks for the newest
|
||||
// ihasmail release rather than a particular image.
|
||||
func (p Plan) FollowsNewestIhasmail() bool { return p.IhasmailImage == NewestIhasmail }
|
||||
|
||||
// Stalwart's ACME order covers these next to the mail host, all under the mail
|
||||
// domain: it is what its own DNS zone points at the mail host as CNAMEs, and
|
||||
// Caddy has to answer for every one of them on port 80 or the order fails.
|
||||
@@ -225,7 +256,7 @@ func (o Options) Validate() (Plan, error) {
|
||||
}
|
||||
|
||||
p.StalwartImage = orDefault(o.StalwartImage, DefaultStalwartImage)
|
||||
p.IhasmailImage = orDefault(o.IhasmailImage, DefaultIhasmailImage)
|
||||
p.IhasmailImage = orDefault(o.IhasmailImage, NewestIhasmail)
|
||||
p.CaddyImage = orDefault(o.CaddyImage, DefaultCaddyImage)
|
||||
for flag, img := range map[string]string{"--stalwart-image": p.StalwartImage, "--ihasmail-image": p.IhasmailImage, "--caddy-image": p.CaddyImage} {
|
||||
if !imageRE.MatchString(img) {
|
||||
|
||||
@@ -98,3 +98,41 @@ func TestEveryProblemAtOnce(t *testing.T) {
|
||||
t.Errorf("got %d errors, want 3: %v", len(lines), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIhasmailFollowsTheNewestReleaseUnlessNamed(t *testing.T) {
|
||||
p, err := Options{Domain: "example.com"}.Validate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !p.FollowsNewestIhasmail() || p.IhasmailImage != "ghcr.io/coffey-labs/ihasmail:latest" {
|
||||
t.Errorf("default ihasmail image %q", p.IhasmailImage)
|
||||
}
|
||||
named, err := Options{Domain: "example.com", IhasmailImage: "ghcr.io/coffey-labs/ihasmail:2026.9.13-pr344"}.Validate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if named.FollowsNewestIhasmail() {
|
||||
t.Error("a named image is treated as the newest release")
|
||||
}
|
||||
byDigest := "ghcr.io/coffey-labs/ihasmail@sha256:" + strings.Repeat("a", 64)
|
||||
if _, err := (Options{Domain: "example.com", IhasmailImage: byDigest}).Validate(); err != nil {
|
||||
t.Errorf("a by-digest image is refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIhasmailTag(t *testing.T) {
|
||||
for version, want := range map[string]string{
|
||||
"2026.9.13+pr344": "2026.9.13-pr344",
|
||||
"2026.10.2+g1fa6578": "2026.10.2-g1fa6578",
|
||||
"0.0.0": "",
|
||||
"": "",
|
||||
"2026.9.13": "",
|
||||
"2026.9.13+pr344\n": "",
|
||||
"latest": "",
|
||||
} {
|
||||
got, ok := IhasmailTag(version)
|
||||
if got != want || ok != (want != "") {
|
||||
t.Errorf("IhasmailTag(%q) = %q, %v; want %q", version, got, ok, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,50 @@ func Preflight(ctx context.Context, p config.Plan, log Log) (warnings []string,
|
||||
return warnings, errors.Join(problems...)
|
||||
}
|
||||
|
||||
// ResolveIhasmail turns "the newest ihasmail release" into the image that is
|
||||
// the newest release right now, so compose.yaml records a version rather than
|
||||
// a tag that moves. A plan that names its own image is returned as it is.
|
||||
//
|
||||
// The dated tag is taken from the version the image itself carries, and used
|
||||
// only once the registry confirms that tag is the very same image; otherwise
|
||||
// the image is pinned by digest, which is exact but says less to a person
|
||||
// reading compose.yaml. Either way a later `docker compose pull` cannot move
|
||||
// the deployment onto a release nobody chose.
|
||||
func ResolveIhasmail(ctx context.Context, p config.Plan, log Log) (config.Plan, error) {
|
||||
if !p.FollowsNewestIhasmail() {
|
||||
return p, nil
|
||||
}
|
||||
log.Step("finding ihasmail's newest release")
|
||||
if err := docker.Pull(ctx, config.NewestIhasmail); err != nil {
|
||||
return p, fmt.Errorf("could not fetch ihasmail's newest release (%w); name an image with --ihasmail-image to use another", err)
|
||||
}
|
||||
newest, err := docker.ImageID(ctx, config.NewestIhasmail)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
version, err := docker.ImageEnv(ctx, config.NewestIhasmail, "IHASMAIL_VERSION")
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
if tag, ok := config.IhasmailTag(version); ok {
|
||||
dated := config.IhasmailRepository + ":" + tag
|
||||
if docker.Pull(ctx, dated) == nil {
|
||||
if id, err := docker.ImageID(ctx, dated); err == nil && id == newest {
|
||||
p.IhasmailImage = dated
|
||||
log.Info("ihasmail %s, recorded as %s", version, dated)
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
digest, err := docker.RepoDigest(ctx, config.NewestIhasmail, config.IhasmailRepository)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
log.Warn("ihasmail's newest release (version %q) has no matching dated tag; recording it by digest", version)
|
||||
p.IhasmailImage = digest
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// portFree tries to bind an address. A permission error means an unprivileged
|
||||
// user asking about a low port, which says nothing about whether Docker can
|
||||
// have it, so it is not reported.
|
||||
|
||||
@@ -33,6 +33,54 @@ func Output(ctx context.Context, args ...string) (string, error) {
|
||||
return strings.TrimSpace(stdout.String()), nil
|
||||
}
|
||||
|
||||
// Pull pulls one image. Quiet, because it runs before the plan is confirmed
|
||||
// and the step already says what it is fetching.
|
||||
func Pull(ctx context.Context, image string) error {
|
||||
_, err := Output(ctx, "pull", "--quiet", image)
|
||||
return err
|
||||
}
|
||||
|
||||
// ImageID is the local ID of an image, which is the same for two references
|
||||
// only when they are the same image.
|
||||
func ImageID(ctx context.Context, image string) (string, error) {
|
||||
return Output(ctx, "image", "inspect", "--format", "{{.Id}}", image)
|
||||
}
|
||||
|
||||
// ImageEnv is the value an image's configuration gives an environment
|
||||
// variable, or "" when it sets none.
|
||||
func ImageEnv(ctx context.Context, image, name string) (string, error) {
|
||||
out, err := Output(ctx, "image", "inspect", "--format", "{{range .Config.Env}}{{println .}}{{end}}", image)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return envValue(out, name), nil
|
||||
}
|
||||
|
||||
func envValue(env, name string) string {
|
||||
for _, line := range strings.Split(env, "\n") {
|
||||
if v, ok := strings.CutPrefix(line, name+"="); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RepoDigest is an image's by-digest reference in one repository, e.g.
|
||||
// ghcr.io/coffey-labs/ihasmail@sha256:..., which names exactly that image for
|
||||
// as long as the registry keeps it.
|
||||
func RepoDigest(ctx context.Context, image, repository string) (string, error) {
|
||||
out, err := Output(ctx, "image", "inspect", "--format", "{{range .RepoDigests}}{{println .}}{{end}}", image)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, d := range strings.Fields(out) {
|
||||
if strings.HasPrefix(d, repository+"@") {
|
||||
return d, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%s has no digest from %s", image, repository)
|
||||
}
|
||||
|
||||
// Versions returns the engine and compose versions, which is also the check
|
||||
// that both are installed and this user may use them.
|
||||
func Versions(ctx context.Context) (engine, compose string, err error) {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package docker
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEnvValue(t *testing.T) {
|
||||
env := "PATH=/usr/local/bin:/usr/bin\nNODE_ENV=production\nIHASMAIL_VERSION=2026.9.13+pr344\nEMPTY=\n"
|
||||
for name, want := range map[string]string{
|
||||
"IHASMAIL_VERSION": "2026.9.13+pr344",
|
||||
"NODE_ENV": "production",
|
||||
"EMPTY": "",
|
||||
"MISSING": "",
|
||||
"IHASMAIL": "", // a prefix of a name is not the name
|
||||
} {
|
||||
if got := envValue(env, name); got != want {
|
||||
t.Errorf("envValue(%q) = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ services:
|
||||
ipv4_address: {{.Plan.StalwartIP}}
|
||||
|
||||
ihasmail:
|
||||
# A fixed release: `docker compose pull` never moves it. To upgrade, change
|
||||
# the tag here, then `docker compose pull && docker compose up -d`.
|
||||
image: {{.Plan.IhasmailImage}}
|
||||
restart: unless-stopped
|
||||
depends_on: [stalwart]
|
||||
|
||||
Reference in New Issue
Block a user