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
|
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.
|
empty, no compose project of the same name exists, and the hostnames resolve.
|
||||||
It reports every problem at once, not the first.
|
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.
|
question, and is required when there is no terminal to ask on.
|
||||||
3. **Writes the deployment directory**: `compose.yaml`, the `Caddyfile`, and
|
3. **Writes the deployment directory**: `compose.yaml`, the `Caddyfile`, and
|
||||||
`.env` holding a freshly generated `APP_SECRET`.
|
`.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
|
5. **Starts Stalwart in bootstrap mode** with a one-time administrator whose
|
||||||
password exists only in the tool's memory.
|
password exists only in the tool's memory.
|
||||||
6. **Completes Stalwart's setup** through its API, the same setup its web UI
|
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
|
==> a local pair for example.test, in /home/you/ihasmail-example-test
|
||||||
ihasmail http://127.0.0.1:8080
|
ihasmail http://127.0.0.1:8080
|
||||||
Stalwart http://127.0.0.1:8081 (admin UI; no mail ports published)
|
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
|
mailboxes alice
|
||||||
==> preflight
|
==> preflight
|
||||||
docker 29.8.0, compose 5.5.1
|
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
|
deploy this? [y/N] y
|
||||||
==> writing /home/you/ihasmail-example-test
|
==> writing /home/you/ihasmail-example-test
|
||||||
==> pulling images
|
==> pulling images
|
||||||
@@ -219,7 +224,7 @@ deploy this? [y/N] y
|
|||||||
ihasmail (172.31.253.11) exempt from Stalwart's auto-ban
|
ihasmail (172.31.253.11) exempt from Stalwart's auto-ban
|
||||||
restarting Stalwart to apply them
|
restarting Stalwart to apply them
|
||||||
mailbox [email protected] created
|
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
|
==> done
|
||||||
webmail http://127.0.0.1:8080
|
webmail http://127.0.0.1:8080
|
||||||
Stalwart http://127.0.0.1:8081/admin
|
Stalwart http://127.0.0.1:8081/admin
|
||||||
@@ -416,14 +421,17 @@ The containers restart by themselves after a crash or a reboot
|
|||||||
|
|
||||||
### Upgrading
|
### 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
|
```bash
|
||||||
docker compose pull && docker compose up -d
|
docker compose pull && docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
- **ihasmail** is safe to move to any newer release that supports your
|
- **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
|
- **Stalwart**: read its upgrade notes before changing versions. Check that the
|
||||||
ihasmail version you run supports the new Stalwart release first, since
|
ihasmail version you run supports the new Stalwart release first, since
|
||||||
ihasmail validates against one Stalwart release at a time. Back up
|
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 |
|
| `--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 |
|
| `--project` | `ihasmail-DOMAIN` (dots as dashes) | Compose project name, which prefixes containers, network and volumes |
|
||||||
| `--stalwart-image` | `stalwartlabs/stalwart:v0.16.22` | Stalwart image |
|
| `--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 |
|
| `--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 |
|
| `--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 |
|
| `--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
|
## Versions and releases
|
||||||
|
|
||||||
Releases are tagged by date, like ihasmail's: `v2026.9.13`, with `.1`, `.2`
|
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,
|
added for another release the same day. Each release pins the Stalwart and
|
||||||
ihasmail and Caddy images it was tested with as its defaults. A newer release
|
Caddy images it was tested with as its defaults. A newer release of the tool
|
||||||
of the tool generally means newer tested versions.
|
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
|
Binaries for `linux/amd64` and `linux/arm64` and a `SHA256SUMS` file are
|
||||||
attached to every [release](https://github.com/Coffey-Labs/ihasmail-oneshot/releases).
|
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.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.Project, "project", "", "compose project name (default ihasmail-DOMAIN, dots as dashes)")
|
||||||
fs.StringVar(&o.StalwartImage, "stalwart-image", config.DefaultStalwartImage, "Stalwart image")
|
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.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.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)")
|
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 {
|
for _, w := range warnings {
|
||||||
log.Warn("%s", w)
|
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 !yes {
|
||||||
if err := confirm("deploy this?"); err != nil {
|
if err := confirm("deploy this?"); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -162,7 +166,11 @@ func describe(p config.Plan, log deploy.Log) {
|
|||||||
log.Info("ACME Let's Encrypt, contact %s", p.Email)
|
log.Info("ACME Let's Encrypt, contact %s", p.Email)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Info("images %s, %s", p.StalwartImage, p.IhasmailImage)
|
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 {
|
if !p.Local {
|
||||||
log.Info(" %s", p.CaddyImage)
|
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"
|
grep -q "certificate issued by CN=Pebble" "$WORK/deploy.log" || die "deploy did not report Stalwart's certificate"
|
||||||
ok "deploy reported 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
|
# 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
|
# 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.
|
# first handshake for any one of them can come a few seconds after deploy.
|
||||||
|
|||||||
@@ -22,16 +22,47 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Versions this release was tested with, end to end. Stalwart is pinned
|
// Stalwart is pinned to the release this version of the tool was tested with,
|
||||||
// because ihasmail validates against one Stalwart release at a time; the
|
// because a Stalwart upgrade migrates its store with no way back and ihasmail
|
||||||
// ihasmail tag is the newest release at the time; Caddy is pinned so that a
|
// validates against one Stalwart release at a time. Caddy is pinned so that a
|
||||||
// redeploy months from now renders the same proxy.
|
// 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 (
|
const (
|
||||||
DefaultStalwartImage = "stalwartlabs/stalwart:v0.16.22"
|
DefaultStalwartImage = "stalwartlabs/stalwart:v0.16.22"
|
||||||
DefaultIhasmailImage = "ghcr.io/coffey-labs/ihasmail:2026.9.10-pr328"
|
|
||||||
DefaultCaddyImage = "caddy:2.11.4"
|
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
|
// 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
|
// 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.
|
// 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.StalwartImage = orDefault(o.StalwartImage, DefaultStalwartImage)
|
||||||
p.IhasmailImage = orDefault(o.IhasmailImage, DefaultIhasmailImage)
|
p.IhasmailImage = orDefault(o.IhasmailImage, NewestIhasmail)
|
||||||
p.CaddyImage = orDefault(o.CaddyImage, DefaultCaddyImage)
|
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} {
|
for flag, img := range map[string]string{"--stalwart-image": p.StalwartImage, "--ihasmail-image": p.IhasmailImage, "--caddy-image": p.CaddyImage} {
|
||||||
if !imageRE.MatchString(img) {
|
if !imageRE.MatchString(img) {
|
||||||
|
|||||||
@@ -98,3 +98,41 @@ func TestEveryProblemAtOnce(t *testing.T) {
|
|||||||
t.Errorf("got %d errors, want 3: %v", len(lines), err)
|
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...)
|
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
|
// 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
|
// user asking about a low port, which says nothing about whether Docker can
|
||||||
// have it, so it is not reported.
|
// 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
|
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
|
// Versions returns the engine and compose versions, which is also the check
|
||||||
// that both are installed and this user may use them.
|
// that both are installed and this user may use them.
|
||||||
func Versions(ctx context.Context) (engine, compose string, err error) {
|
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}}
|
ipv4_address: {{.Plan.StalwartIP}}
|
||||||
|
|
||||||
ihasmail:
|
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}}
|
image: {{.Plan.IhasmailImage}}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on: [stalwart]
|
depends_on: [stalwart]
|
||||||
|
|||||||
Reference in New Issue
Block a user