Commit Graph
12 Commits
Author SHA1 Message Date
Paul LütznerandClaude Opus 5.5 ffbf7bdd9b Clean up the compose files (#160)
Compose files keep only a short header; the reasoning lives in
docs/install. truenas.compose.yaml is gone (the TrueNAS catalog app
replaces it) and so is compose.prod.yaml (compose.yaml already runs the
published prod image). compose.override.yaml.dist becomes compose.dev.yaml,
passed with -f, so there is no untracked copy that compose loads on its
own. Docs, CODESTYLE, CONTRIBUTING and the entrypoint's error text follow.

Every remaining file resolves to the same `docker compose config` as
before.

Closes #30

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:43:22 +02:00
Paul LütznerandClaude Opus 5.5 1ff8c81f4b Let the image run as a user that is not root
Nothing here needs root at run time, and a platform that chooses the uid
itself and drops every capability could not start it. Three things were
in the way, found by running it as uid 568 with nothing granted:

  frankenphp     carried cap_net_bind_service as a file capability, and
                 a process without that capability may not execute such
                 a binary at all. Removed: in a container the
                 unprivileged port range starts at 0 anyway.
  var/           cache, log and share were root's. Writable by anyone
                 now, since the uid is not known when the image is built.
  /data, /config root's as well, so the hub could not open its database
                 on a fresh volume. Same answer.

The entrypoint's setfacl step is skipped for a non-root user, who can
grant nothing and has no name for whoami to print.

Installs that run as root, which is every compose file here, are
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:06:02 +02:00
Paul LütznerandClaude Opus 5.5 a8364e34d2 feat(deploy): one worker container runs every background process
The stack was nine long-running containers: the web server, the
database, the Mercure hub, and six of plMail's own image, one per
background process. It is three now: php, worker and database, with
secrets-init still running once and exiting.

The worker runs `app:work`, a small supervisor keeping seven processes
alive the way Docker's restart policy kept seven containers alive: the
IMAP supervisor, one consumer per queue, the scheduler and the hub. A
clean exit (the hourly recycle, the restart signal) comes straight back;
a crash backs off from one second to thirty. Output passes through a
whole line at a time, so two processes' JSON records never interleave,
and SIGTERM reaches every child at once with 25s to finish. Each queue
keeps a process of its own, which is the part the split was for: a
consumer inside a long handler cannot take a send. And each process
keeps its container's old name as APP_CONTAINER_NAME, so heartbeats,
/healthz and the log filters are unchanged.

The hub is the official Mercure 1.x binary, copied into the image and
run as one of those processes. Not FrankenPHP's built-in hub, which is
0.24 and refuses the RFC 9068 tokens plMail has minted since v0.2.31.
The worker answers to `mercure` on the network, so the Caddyfile proxy
and MERCURE_URL reach it where they reached the hub container, and older
compose files keep working with this image unchanged. It is configured
from the application's own settings, including MERCURE_COOKIE_NAME, so a
plain-HTTP install names the cookie once for both halves. A 5s shutdown
grace stops it waiting on open subscriptions, which made every stop take
the whole 25s.

The dev override loses its per-worker blocks, one of which (worker-bulk)
had been missing, so that queue ran the published image's code in
development. The demo overlay runs app:work --without=imap-supervisor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 16:20:31 +02:00
Paul LütznerandClaude Opus 5.5 4cbdb4f511 feat(admin): a version checker, with a channel to follow
Admin → Updates: an administrator picks which builds count as an update
(Releases, the `latest` image; Main, the `main` image; or Off), and
every hour app:updates:check asks whether that channel has a newer one.
Until somebody picks, an installation follows the tag its own build
came from, so a `main` image follows main and an unbuilt checkout asks
nothing of anyone.

The registry decides what exists: the channel's image is read the way
`docker pull` finds it (OCI distribution API, anonymous token from the
401's challenge, the index entry for this architecture, the config's
labels), so a commit that builds no image is never announced. GitHub's
compare decides which way two builds differ, so a main build following
releases is not told to go back to an older release, and its commit
list is the page's "what's new". An image from anywhere but GitHub falls
back to what the version numbers alone can say.

Each new build is announced once: a push to every administrator's
registered devices (a new UpdateAvailable payload the service worker
shows), a badge beside the build chip in the admin header, and a dot on
the nav entry. A check that gets no answer keeps the last one and says
why, and the hourly command exits 0 for it: an installation behind a
firewall is not a fault.

Images are stamped with where they are published (APP_IMAGE), so a fork
that publishes its own checks its own. The channel travels in config
backups; what the last check found does not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 14:57:14 +02:00
Paul LütznerandClaude Opus 5 3234fecbf6 Stamp the build after the vendors, not before them
The Dockerfile writes APP_VERSION and APP_COMMIT into the prod image so
the admin panel can say which build it is. Both sat above the vendor
install, and every layer after an ENV is keyed on that ENV's value — so
a commit hash, which is different on every commit by construction, meant
`composer install` was never once served from cache. The comment on the
line directly below promises exactly the opposite:

    # prevent the reinstallation of vendors at every changes in the source code

Every build of every branch and tag therefore re-resolved and
re-downloaded 111 packages, and the per-architecture gha cache
docker.yml is careful to scope could only ever hit the layers above that
point.

The cost was minutes; the exposure is what found it. A step that runs on
every build is a step that needs the network on every build, and the
`main` image build failed twice at that line while the release tag of
the same commit went through on a retry — the difference being which one
happened to ask a package host during a bad minute. Self-hosters follow
`:main`, so that image had been stale since.

Moved below the install, keyed on composer.lock alone. It cannot go
further: `composer dump-env prod` below snapshots the environment, and
these have to be in it by then.

Verified under BuildKit, which is what CI builds with — podman cannot
answer the question, since its handling of `COPY --link` busts that
layer whatever the ENVs do:

  before   change only APP_COMMIT -> "Installing dependencies from lock file"
  after    change only APP_COMMIT -> "#15 CACHED"

and the stamp still reaches the image: APP_VERSION and APP_COMMIT are
both in its environment, where AppVersion reads them.

This removes the exposure rather than explaining the upstream failure —
the log for it is behind an authenticated endpoint and says only that
composer exited 100.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 10:56:33 +02:00
Paul LütznerandClaude Opus 5 25f1cf74c8 Say which build this is, in the admin header
Migrations run on boot and `latest` moves, so "is this the build I think
it is?" is a question with real consequences and no cheap answer. The
release and the commit now sit in the admin header — in the header
rather than in a panel, because it is asked while looking at something
else.

Stamped at image build and never discovered at runtime. A container has
no .git: the source is copied in and the history stays behind, so
anything that shelled out to git in production would answer from a
mounted volume or from nothing. The release workflow passes both values
from the metadata action it already computes; a plain `docker build`
passes neither, and an image nobody stamped says "development" rather
than guessing, which is also why the chip is absent entirely on a
checkout — a permanent "development" badge is furniture.

There is deliberately no test that a real checkout describes itself.
That would assert a property of wherever the suite runs rather than of
the class: the test container has the source but not the history, and
where it has both, git refuses the directory for dubious ownership — so
it passes on a laptop, fails in CI, and says nothing either way. This
suite has been bitten by that exact shape twice already (an id sequence
gap that only existed on a well-used database, a sidebar state that only
existed after another spec ran). The contract is tested instead: an
unreadable repository means "development".

config/reference.php is gitignored and untracked. Symfony regenerates it
on every cache warm-up and not deterministically — two keys inside one
@psalm-type block swap places — so a tracked copy showed a phantom diff
after any command that booted the kernel, all session. Its own header
says it is auto-generated and for apps only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 12:56:23 +02:00
Paul LütznerandClaude Opus 5 9833695a27 Add /healthz and app:backup
Two gaps that only matter on the day something goes wrong, which is the
worst day to discover them.

Every compose service but Mercure had `healthcheck: disable: true`, and
the image's own HEALTHCHECK probed Caddy's metrics port — which answers
as soon as the web server is listening, well before PHP can reach the
database. A stack whose database was unreachable reported itself healthy,
and `depends_on: service_healthy` waited for nothing worth waiting for.
/healthz answers the question that was actually being asked, and the
image now probes it.

It is unauthenticated, because a probe needing a session is useless to
Docker and to every monitoring tool, and therefore deliberately vague: it
returns verdicts and nothing else. HealthTest asserts that shape, so a
well-meant "add the counts, they're useful" fails the suite rather than
quietly turning a public endpoint into a window on the instance.

app:backup makes the README's oldest warning actionable. It has always
said to back up the database and the encryption key separately and then
left the reader to work out how — the most dangerous instruction in the
project to get wrong, because both halves fail silently. A dump alone
restores every mailbox with credentials nothing can decrypt.

The command checks rather than assumes: on an install that supplies
APP_ENCRYPTION_KEY from the environment there is nothing to copy, so it
says the key is *not* in the backup instead of printing a reassuring
warning about a file that does not contain it. That case is the one where
a silent success would be worst.

pg_dump comes from PGDG pinned to the server's major version. Debian's
postgresql-client trails 18 and pg_dump refuses to dump a newer server,
so the distro package would install cleanly and fail at the only moment
it was ever needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 15:36:37 +02:00
Paul Lützner 122006460d onboarding. this is a lot 2026-07-30 16:40:31 +02:00
Paul Lützner 92593a756a small fixes. 2026-07-30 12:30:15 +02:00
Paul Lützner a243636a34 this is now a PWA und has push 2026-07-27 23:29:39 +02:00
Paul Lützner 23dc43255d update 2026-07-26 11:18:08 +02:00
Paul Lützner 76f890a49e init 2026-07-02 13:12:28 +02:00