8d9ed96f2c
Preserve production broker definitions and allow bounded SMTP delivery. Ref: IT-1508
382 lines
23 KiB
Markdown
382 lines
23 KiB
Markdown
# Shared Notification Service on MicroK8s
|
|
|
|
This package deploys one shared Web/Worker, PostgreSQL 17, RabbitMQ 4 and a temporary
|
|
Mailpit sandbox in `shared-notification`, on `mini-pc-2`. It does not expose public
|
|
Ingress, NodePort or unauthenticated administration. Application administration,
|
|
RabbitMQ management and the sandbox inbox are available through explicit local
|
|
`kubectl port-forward` sessions over SSH.
|
|
|
|
The initial sandbox channel is `Invemory.Staging.Sandbox`, template `TestEmail`, exact
|
|
language `en`. Its SMTP egress uses Mailpit:1025, which has no relay configuration.
|
|
The production add-on also permits the Worker to reach external SMTP on TCP 587.
|
|
Only its temporary inbox is ephemeral. Channels, templates, SMTP settings and usage
|
|
counters are persisted in PostgreSQL; durable broker queues, messages, users and
|
|
permissions are retained on the RabbitMQ volume. Never put real SMTP credentials
|
|
into this sandbox package.
|
|
|
|
Mailpit runs as UID/GID `1000:1000` with dropped capabilities and writes its temporary
|
|
SQLite inbox under `/tmp` on the writable container filesystem. Exact manifest flags
|
|
and `/livez` were verified with the pinned image in an isolated local container.
|
|
Adding `readOnlyRootFilesystem` requires a separate writable `/tmp` mount; the image
|
|
exits immediately without it. The current manifest keeps that filesystem writable.
|
|
|
|
## Verified code contract
|
|
|
|
The current Worker consumes the fixed durable queue `notification.send-email` using
|
|
the default exchange. `ServiceName` selects channels and exact-language templates;
|
|
`CorrelationContext.ReplyTo` selects a result queue. These are logical routing
|
|
boundaries, not authorization of the message's `ServiceName`. Separate credentials
|
|
and queue permissions cannot restrict the JSON service name inside that shared queue.
|
|
There is one consumer and at-least-once processing; a redelivery can produce duplicates.
|
|
See [the consumer contract](../../../docs/itemtracker-outbox-email-consumer.md).
|
|
|
|
This initial vhost is `/notification`. Separate broker users are generated for
|
|
administration, the Notification Worker and the future staging publisher. Worker
|
|
read permission covers only `notification.send-email`; staging reads only its result
|
|
queue. Both require write access to RabbitMQ's authorization name `amq.default` for
|
|
default-exchange publication. Resource ACLs constrain exchange/queue names, not the
|
|
routing key of a publish operation: access to the default exchange permits routing
|
|
to other existing queues within the same vhost. This MVP shared-queue design therefore
|
|
does not provide security isolation between message ServiceNames or routing keys.
|
|
The production publisher credential, result queue, network policies and SMTP egress
|
|
are provisioned separately below.
|
|
Additional environments need separately reviewed routing, users, permissions, exact
|
|
templates, provider settings and egress before they can join the shared service.
|
|
|
|
## Invemory production publisher credential
|
|
|
|
The additive `activate-production-publisher.py` provisions the dedicated
|
|
`invemory_production` user in `/notification`, durable result queue
|
|
`invemory.production.notification-results`, and the Notification Worker permissions
|
|
for that result queue. It preserves the staging user and queue. Run it on mini-pc-2
|
|
as `devops` from its reviewed copy; repeated runs verify the existing password and
|
|
ACLs. The generated password is retained only in
|
|
`/home/devops/shared-notification-private/invemory-production-rabbitmq-password.txt`
|
|
with mode `0600`. It is never printed by the provisioning script.
|
|
|
|
On mini-pc-2, the owner can display the password in their own terminal with:
|
|
|
|
```sh
|
|
sudo -u devops cat /home/devops/shared-notification-private/invemory-production-rabbitmq-password.txt
|
|
```
|
|
|
|
Use the `invemory_production` user, that password, broker
|
|
`rabbitmq.shared-notification.svc.cluster.local:5672`, vhost `/notification`, send
|
|
queue `notification.send-email`, result queue
|
|
`invemory.production.notification-results`, and the existing technical service name
|
|
`StoreMate-Prod` in the Invemory production notification settings. The visible sender
|
|
and `TestEmail` text still contain legacy branding; IT-1510 tracks that copy change.
|
|
|
|
### Live activation — 2026-10-03
|
|
|
|
`production-network.yaml` adds only production API/Outbox egress and RabbitMQ ingress
|
|
on TCP 5672, plus shared Worker egress to public SMTP on TCP 587 (private and cluster
|
|
address ranges excluded). The existing sandbox Mailpit allowance stays intact. The
|
|
file passed Kubernetes server dry-run and was applied on mini-pc-2. The API result
|
|
consumer and both Workers are running at one replica; RabbitMQ has one consumer on
|
|
`notification.send-email` and one on
|
|
`invemory.production.notification-results`. Two owned production test messages were
|
|
published, accepted by the Gmail SMTP channel, acknowledged as delivery successes in
|
|
Invemory Outbox, and confirmed in the recipient's Gmail conversation. Today's channel
|
|
usage increased by two. Both queues ended with zero ready and unacknowledged messages.
|
|
|
|
The activation helper also persists the production user, queue and expanded Worker ACL
|
|
in the `rabbit-definitions` Secret. The private password and matching RabbitMQ hash
|
|
are saved with mode `0600`; updated `notification.py` reproduces that Secret during
|
|
bootstrap without rotating passwords. The reviewed source is installed at
|
|
`/home/devops/notification-package/notification.py`, with its pre-activation copy
|
|
retained beside it. A broker restart should therefore reload production access;
|
|
verify the two consumers and an owned delivery afterward. The SMTP password remains
|
|
outside the repository. Apply `production-network.yaml` separately when reconciling
|
|
network policies. Do not use the initial sandbox-only render as a complete production
|
|
delivery configuration.
|
|
|
|
Web/API administration has no built-in authentication in this source revision. Its
|
|
Service stays ClusterIP and default-deny ingress; access through SSH/port-forward is
|
|
restricted to operators. The worker exposes no health HTTP endpoint. Pod rollout is
|
|
a process-start check; the explicit consumer gate also verifies one broker consumer.
|
|
An actual owned smoke message and Seq event remain required after startup.
|
|
|
|
## Storage preparation
|
|
|
|
Owner decision on 2026-10-02: Notification PostgreSQL and RabbitMQ use the external
|
|
XFS disk. Invemory staging PostgreSQL remains on its existing internal disk.
|
|
|
|
| Service | Data directory | Owner | Mode | Retain capacity |
|
|
| --- | --- | --- | --- | --- |
|
|
| PostgreSQL | `/mnt/shared/notification-service/postgres` (`pgdata` child) | `999:999` | `0700` | `10Gi` |
|
|
| RabbitMQ | `/mnt/shared/notification-service/rabbitmq` | `100:101` | `0700` | `5Gi` |
|
|
|
|
`/mnt/shared` is the bind mount of `/mnt/files/shared`; both reach the same original
|
|
data. They are not backups. The required filesystem UUID is
|
|
`c2c31640-a247-4988-bc7b-9ee9789464d1`. Static local PVs use `Retain`, fixed node
|
|
affinity and prebound PVCs. Capacity declarations are scheduling/accounting budgets;
|
|
local PVs do not enforce filesystem byte quotas.
|
|
|
|
Transfer the package to `/home/devops/notification-package`, then the owner runs
|
|
one command on mini-pc-2:
|
|
|
|
```sh
|
|
sudo python3 /home/devops/notification-package/prepare-storage.py
|
|
```
|
|
|
|
The script verifies exact `/mnt/files` and `/mnt/shared` XFS mount targets and UUID
|
|
before creating anything. It accepts the already provisioned exact empty directories,
|
|
or creates missing directories under a root-owned `0755` parent. It writes root-owned
|
|
`0644` `.invemory-storage.json` markers with the UUID and roles
|
|
`shared-notification/postgres` / `shared-notification/rabbitmq`. Repeated execution
|
|
verifies markers without replacing them. It refuses symlinks, incorrect permissions,
|
|
unexpected parent contents and unmarked nonempty directories. It never deletes data.
|
|
|
|
The deployment host preflight runs `findmnt` for each PV directory and checks actual
|
|
UUID, filesystem and ownership. Each Pod has a nonroot startup guard that checks
|
|
XFS `statfs` and the exact role/UUID marker on its mounted PVC. The marker is an
|
|
owner-provisioned assertion of UUID, not a live block-device query; the host preflight
|
|
supplies that check. A missing external mount falls onto ext4 and fails the guard.
|
|
These checks and `Retain` do not protect against direct manual deletion.
|
|
|
|
Storage Pods deliberately omit `fsGroup` so kubelet does not recursively change
|
|
the owner-provisioned directory and marker permissions. PostgreSQL uses the pinned
|
|
image's writable `/var/run/postgresql` directory on its container filesystem;
|
|
RabbitMQ writes its cookie/state in its UID-owned retained data directory. Both run
|
|
with explicit nonroot UID/GID, dropped capabilities and restricted Pod security.
|
|
|
|
RabbitMQ diagnostics probes allow 15 seconds per call: a healthy call on the
|
|
resource-limited node took seven seconds. Startup keeps a three-minute failure
|
|
budget (nine attempts, every 20 seconds); readiness runs every 20 seconds and
|
|
liveness every 30 seconds. Startup must succeed before the other probes run.
|
|
|
|
Never remove files directly from these data directories. Storage path/UUID/node/
|
|
capacity changes and database/broker image changes are rejected by repeated deploys.
|
|
A future storage move requires a separate controlled procedure: stop publishers and
|
|
both service writers, verify database and broker backups, stop storage processes,
|
|
copy and verify offline data, provision target markers/ownership, bind new retained
|
|
claims, test restoration and perform rollback checks before retiring old storage.
|
|
This package does not perform that migration.
|
|
|
|
## Artifacts and private state
|
|
|
|
Copy `config.example.json` to `config.local.json` and verify actual Seq/publisher Pod
|
|
selectors. The example reply queue `invemory.staging.notification-results` is isolated
|
|
from the old client fallback. Its exact name must be agreed and configured in the
|
|
Invemory publisher/result consumer before that later cutover. The current publisher
|
|
remains `LogOnly`, and its result consumer remains disabled.
|
|
|
|
Build Web, Worker and Migrator from the same verified source archive, using each
|
|
existing Dockerfile. Record the checked commit and archive SHA256, including any
|
|
explicitly recorded uncommitted delta. Mirror PostgreSQL, RabbitMQ, Mailpit and a
|
|
Python 3 Linux image to `registry.hrynco.com`. Copy `artifacts.example.json` to
|
|
`artifacts.local.json` and record all seven immutable image digests, source hashes
|
|
and the two reviewed migration IDs. The script rejects placeholder/tag references,
|
|
other registries, mismatched application/migrator archives and unsupported DB/broker
|
|
major versions. The archive hash fields are an operator provenance record; independently
|
|
verify the build inputs and registry digests before live application.
|
|
|
|
Provision a **dedicated** Seq ingestion key for Notification Service, with its own
|
|
identity/permissions, in a private file. Do not reuse the staging application's stamped
|
|
key. Store registry authentication as a private Docker `config.json` with only
|
|
`registry.hrynco.com` in `auths`. Never print key values, full Secrets or credentials.
|
|
|
|
Run the dedicated helper as devops on mini-pc-2 after package review:
|
|
|
|
```sh
|
|
python3 /home/devops/notification-package/prepare-seq-key.py
|
|
```
|
|
|
|
It reads the existing private `/home/devops/seq-first/credentials.json` silently and
|
|
uses the checked session/CSRF API pattern at the trusted Seq LAN endpoint. It creates
|
|
only `HrynCo Notification Service`, granting only `Ingest` and stamping
|
|
`Environment=SharedNotification`, `Service=HrynCo.NotificationService`. `Application`
|
|
remains supplied by each application. It stores the token only in
|
|
`/home/devops/notification-private/seq-key.txt` (`0600`, parent `0700`) before the
|
|
create request, so an unknown POST outcome can be retried with the same token.
|
|
Repeats verify existing permissions/properties and preserve the token; mismatches,
|
|
duplicate keys or a missing local token for an existing key fail without rotation.
|
|
The installed Seq does not expose the original token after creation, and its
|
|
EventEntity has no `ApiKeyId`. The helper therefore adds the server-applied property
|
|
`NotificationIngestKeyId=<dedicated key ID>` through a guarded PUT of **only** the
|
|
dedicated key (`apikey-79` at the initial checkpoint). It preserves its token,
|
|
permissions, all other input settings and entity fields. It verifies that no other
|
|
key has the same identity marker before and after updating using explicit
|
|
`api/apikeys?shared=true` lists (including shared keys), and verifies exact
|
|
readback. Repeats with the correct marker perform no PUT. An unknown PUT outcome is
|
|
recovered by reading the existing marker on the next run, without token rotation or
|
|
another key. A marker mismatch/collision stops without changing another key.
|
|
The [official Seq API update contract](https://github.com/datalust/seq-api/blob/dev/src/Seq.Api/ResourceGroups/ApiKeysResourceGroup.cs)
|
|
states that updating settings cannot change the API key token.
|
|
HTTP redirects are denied before forwarding credentials; environment proxies are
|
|
disabled for this fixed LAN operation. Each run verifies token ingestion using one
|
|
technical canary event without user payload or credentials, then reads that exact
|
|
`ProvisioningCheckId` through the admin session with bounded polling. It verifies
|
|
the actual server-stamped `NotificationIngestKeyId`, Environment and Service. The
|
|
canary body omits all three stamps, so a valid staging/foreign token cannot supply
|
|
the dedicated identity marker. If `ApiKeyId` is exposed by a server version, it must
|
|
also agree. Missing or conflicting identity proof fails closed without rotation.
|
|
No event payload/token is printed. Back up the private file.
|
|
|
|
The key's stamped properties take precedence over application fields. Verify the first
|
|
Web/Worker event has `Environment=SharedNotification` before accepting delivery.
|
|
The helper never changes staging keys, signals, retention or Seq deployment.
|
|
The existing shared Seq retention/storage policy remains in force; changing retention
|
|
is a separate observability decision. Dedicated key and event verification remain
|
|
mandatory live gates if the helper has not yet been run.
|
|
|
|
`bootstrap` generates random passwords once in
|
|
`/home/devops/shared-notification-private/credentials.json` (`0600`, parent `0700`)
|
|
and reconciles Kubernetes Secrets. Keep and back up this private file outside Git.
|
|
Repeats reuse it, compare live Secret data and refuse implicit password/key/routing
|
|
rotation. If the file is missing while a live Secret exists, recover the original
|
|
file; generating new credentials would not change an existing PostgreSQL role.
|
|
Registry authentication can be refreshed independently. `.local.json`, rendered files
|
|
and `private/` are ignored in this package, but prefer keeping secrets outside the repo.
|
|
|
|
## Ordered installation and repeat deployment
|
|
|
|
All mutating commands run as devops on mini-pc-2. `prepare-storage.py` is the only
|
|
owner-run root operation. Scripts use Python's standard library, MicroK8s kubectl,
|
|
`/home/devops/.kube/cluster-admin.conf` and bounded timeouts. No command prints Secrets.
|
|
|
|
1. Review source changes, artifacts and manifests independently. Measure live node
|
|
requests/limits and usage; inspect namespace/node pressure. The namespace budget is
|
|
8 Pods, 5 Deployments, 3 Jobs, 2 PVCs, 15Gi storage, requests1600m/2Gi and limits4CPU/4Gi.
|
|
This is a separate budget; existing `invemory-staging` quotas are not expanded.
|
|
2. Run owner storage preparation and privately supply registry/Seq files.
|
|
3. Bootstrap the namespace and preserved Secrets:
|
|
|
|
```sh
|
|
python3 notification.py bootstrap --config config.local.json --artifacts artifacts.local.json --seq-key-file /home/devops/notification-private/seq-key.txt --registry-auth-file /home/devops/notification-private/registry-auth.json
|
|
```
|
|
|
|
4. Render for review and run server-side dry-run. `check` also rejects resource
|
|
adoption, wrong mounted disk, Pod selector mismatches and storage/image changes:
|
|
|
|
```sh
|
|
python3 notification.py render --config config.local.json --artifacts artifacts.local.json --output /home/devops/notification-manifests.json
|
|
python3 notification.py check --config config.local.json --artifacts artifacts.local.json
|
|
```
|
|
|
|
5. First install applies storage/isolated foundation and runs migrations with Web and
|
|
Worker stopped. The migration target is exactly `InitialCreate` + `PendingChanges`.
|
|
After migration success, the script verifies history, grants a separate
|
|
`notification_runtime` role only DML on the three application tables, and idempotently
|
|
seeds the Mailpit channel and neutral English `TestEmail`. Existing matching-service
|
|
channels/templates are left unchanged. It resumes Web after these gates; Worker
|
|
remains stopped for inspection:
|
|
|
|
```sh
|
|
python3 notification.py deploy --config config.local.json --artifacts artifacts.local.json --migrate
|
|
```
|
|
|
|
6. Inspect channel/template, queue backlog/permissions, DB role and Seq identity.
|
|
Start the Worker only after those checks. The script verifies the sandbox SMTP
|
|
settings, required template, empty incoming queue and exactly one consumer:
|
|
|
|
```sh
|
|
python3 notification.py deploy --config config.local.json --artifacts artifacts.local.json --enable-worker --sandbox-verified
|
|
```
|
|
|
|
7. Send one owned neutral test message to Mailpit; inspect its matching result,
|
|
usage counter, ACK and redacted technical logs. Test malformed metadata and
|
|
missing template/variable failure with owned safe fixtures. Remove only owned
|
|
test data through application/broker APIs. No arbitrary live email or client backlog
|
|
is authorized by this smoke test.
|
|
|
|
Ordinary repeat `deploy` verifies target schema and preserves the current Worker
|
|
replica count. It reapplies canonical manifests with the same persistent claims and
|
|
Secret references. When Worker remains enabled, it waits for its rollout and verifies
|
|
exactly one broker consumer before reporting completion. Both explicit enable and
|
|
ordinary redeploy allow up to 60 seconds after rollout for a zero consumer count to
|
|
become one, polling with a two-second pause and bounded broker commands. Extra or
|
|
unreadable consumer counts fail immediately. Rollout failure, verification failure
|
|
or timeout scales Worker to zero and stops the deployment without reporting success.
|
|
It does not recreate Secrets
|
|
or reseed provider settings. A later
|
|
`--migrate` stops Web/Worker, takes a verified local PostgreSQL custom-format dump,
|
|
runs the Migrator and history/grant/seed gates, then resumes Web. Worker stays stopped
|
|
unless `--enable-worker --sandbox-verified` is supplied and all gates pass. Once
|
|
writers have been stopped, backup, migration, history or grants failure leaves them
|
|
stopped. An initial history mismatch on an already ready database is rejected before
|
|
scaling writers, without changing their state. Failed Jobs remain for
|
|
private inspection; the next run replaces only its own named Job. Migration locking
|
|
uses a private host file and is local to this deployment host.
|
|
|
|
An interrupted first install can leave the PostgreSQL Deployment present with no
|
|
ready Pod (for example, before owner storage markers exist). A retry with `--migrate`
|
|
first verifies ownership/storage/images, stops both writers and reconciles only the
|
|
foundation/storage workloads. It waits for PostgreSQL, RabbitMQ and Mailpit readiness
|
|
before querying actual migration history. Every migration cycle backs up the actual
|
|
initialized database, including an empty first database: Deployment absence/existence
|
|
never establishes that the retained volume has no data. Backup/history/storage
|
|
failure keeps writers stopped and prevents migration. An ordinary deploy against an
|
|
unready database requires this explicit recovery path and performs no mutation.
|
|
Stopping an already empty Deployment is supported: collection reads require a JSON
|
|
PodList, and an empty `items` list needs no Pod-termination wait. Named resource
|
|
lookups alone use `--ignore-not-found`.
|
|
|
|
Backups are retained under the private directory with a SHA256/byte-count/history
|
|
manifest. `pg_restore --list` validates dump structure; a full restore test and offsite
|
|
copy are separate gates. This backup covers the Notification database only. It is not
|
|
a snapshot of RabbitMQ's queue files. Migration does not upgrade/recreate the broker
|
|
volume; durable queues remain on that retained volume. Broker disk backup/recovery
|
|
must be verified before a broker upgrade or storage migration.
|
|
|
|
## Access and networking
|
|
|
|
Use SSH to the deployment host, then forward one local port when needed:
|
|
|
|
```sh
|
|
/snap/microk8s/current/kubectl --kubeconfig=/home/devops/.kube/cluster-admin.conf -n shared-notification port-forward service/web 5200:8080 --address=127.0.0.1
|
|
```
|
|
|
|
Apply equivalent explicit forwards for `service/rabbitmq`15672 and `service/mailpit`8025.
|
|
Keep them bound to loopback. ClusterIP services expose no LAN/public ports.
|
|
|
|
Default-deny policies allow namespace-scoped DB5432, shared Worker/bounded staging
|
|
publisher AMQP5672, sandbox SMTP1025, cluster DNS53 and Web/Worker Seq8080 only.
|
|
The package adds its own `notification-seq-ingress` policy in `observability` and
|
|
`notification-broker-egress` in `invemory-staging`; existing policies stay owned by
|
|
their original packages. Verify these exact selectors in the rendered diff.
|
|
No SMTP internet egress or broad namespace allowance is included.
|
|
|
|
The Invemory publisher/result-consumer cutover remains a separate operation after
|
|
Notification acceptance, exact staged settings and backlog inspection. The package
|
|
does not change Invemory configuration, production routes or CI deployment.
|
|
|
|
## Local validation
|
|
|
|
```powershell
|
|
python -m unittest discover -s docker/kubernetes/shared-notification/tests -v
|
|
dotnet build HrynCo.NotificationService.slnx --no-restore
|
|
dotnet test HrynCo.NotificationService.slnx --no-build --no-restore
|
|
```
|
|
|
|
Unit tests cover immutable artifacts, Secret preservation structure, isolated network
|
|
and storage contracts, failure ordering and no automatic Worker activation. Passing
|
|
local tests does not establish live storage, image startup, Seq access, mail delivery,
|
|
backups/restoration or repeat-deploy acceptance; record these after actual testing.
|
|
|
|
## Verified mini-pc-2 sandbox checkpoint (2026-10-02)
|
|
|
|
The initial installation from source commit `a44b98292f9001f28fed425be131a2c993a85847`
|
|
completed on mini-pc-2. The external XFS storage guards passed; PostgreSQL, RabbitMQ,
|
|
Mailpit, Web and one Worker are Ready with zero restarts. The namespace has no Ingress
|
|
or NodePort. `InitialCreate` and `PendingChanges`, runtime grants and sandbox seed
|
|
completed. Two private local PostgreSQL backups were created and validated by the
|
|
deployment script before the initial and repeated migration runs. Neither backup is
|
|
an offsite copy or a RabbitMQ backup; full restoration was not tested.
|
|
|
|
The dedicated Seq key was verified on live ingestion and Web/Worker events carry
|
|
`Environment=SharedNotification`. A single owned `TestEmail` message reached only
|
|
Mailpit, was ACKed, produced a matching successful result and incremented the usage
|
|
counter to one. An unsupported message type was rejected without an email; a missing
|
|
template produced a matching negative result without an email. The owned messages and
|
|
results were removed afterward. Both queues are empty and the incoming queue has one
|
|
consumer. Ordinary repeat deploy with the Worker running and a repeated no-op
|
|
migration passed; the Worker was enabled again afterward. The external volume data
|
|
measured about 47MiB for PostgreSQL and 292KiB for RabbitMQ at this checkpoint.
|
|
|
|
Invemory staging still publishes Outbox entries in `LogOnly` mode and does not
|
|
consume results. Connecting its publisher/result consumer and proving a real staging
|
|
application flow remain separate gated work. Other environments and production SMTP
|
|
are not part of this sandbox checkpoint.
|