Files

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.

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:

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:

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 three reviewed migration IDs, ending with 20261017072410_EmailTemplateLocalizations. 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:

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 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:
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
  1. Render for review and run server-side dry-run. check also rejects resource adoption, wrong mounted disk, Pod selector mismatches and storage/image changes:
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
  1. First install or a schema upgrade applies storage/isolated foundation and runs migrations with Web and Worker stopped. The migration target is exactly InitialCreate + PendingChanges + EmailTemplateLocalizations. After migration success, the script verifies history, grants a separate notification_runtime role only DML on the application tables, including the service/language/definition catalog, and idempotently seeds the Mailpit channel, sandbox service/catalog and neutral English TestEmail. Existing matching-service channels/templates and shared variable contracts are left unchanged. It resumes Web after these gates; Worker remains stopped for inspection:
python3 notification.py deploy --config config.local.json --artifacts artifacts.local.json --migrate
  1. 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:
python3 notification.py deploy --config config.local.json --artifacts artifacts.local.json --enable-worker --sandbox-verified
  1. 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.

Before upgrading a live flat database to EmailTemplateLocalizations, inspect the exact source columns/indexes/history and audit every template's identities and JSON variable shape. Versions of one service/key must have the same variable contract after sorting by variable name. Rehearse the current Migrator and sandbox/grants SQL on a disposable restored PostgreSQL 17 copy, then verify preserved content and a repeat no-op migration. The live deploy --migrate creates its own final backup after stopping Web and Worker. Do not use the old Web/Worker images with the new schema. Rollback requires restoring a verified pre-upgrade database backup with the matching old application images; migration Down is not a general rollback path. The production network add-on and production broker access remain separate, preserved resources; verify them and the existing production queues before re-enabling Worker.

Access and networking

Use SSH to the deployment host, then forward one local port when needed:

/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/rabbitmq15672 and service/mailpit8025. 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

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.