From d5b7605da549b8d9942c0efece49490b47e04743 Mon Sep 17 00:00:00 2001 From: Anatolii Hrynchuk Date: Sat, 17 Oct 2026 23:26:27 +0300 Subject: [PATCH] fix(kubernetes): support localized template schema Update migration, runtime grants, sandbox seed, and live upgrade guidance for the normalized email template schema. Ref: IT-1601 --- .../kubernetes/shared-notification/README.md | 29 ++++++++++---- .../artifacts.example.json | 2 +- .../shared-notification/notification.py | 40 ++++++++++++++----- 3 files changed, 52 insertions(+), 19 deletions(-) diff --git a/docker/kubernetes/shared-notification/README.md b/docker/kubernetes/shared-notification/README.md index e4bc400..26ca0d5 100644 --- a/docker/kubernetes/shared-notification/README.md +++ b/docker/kubernetes/shared-notification/README.md @@ -167,7 +167,8 @@ 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, +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. @@ -255,13 +256,15 @@ python3 notification.py render --config config.local.json --artifacts artifacts. 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`. +5. 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 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: + `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: ```sh python3 notification.py deploy --config config.local.json --artifacts artifacts.local.json --migrate @@ -320,6 +323,18 @@ a snapshot of RabbitMQ's queue files. Migration does not upgrade/recreate the br 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: diff --git a/docker/kubernetes/shared-notification/artifacts.example.json b/docker/kubernetes/shared-notification/artifacts.example.json index 0caa16f..8c6d561 100644 --- a/docker/kubernetes/shared-notification/artifacts.example.json +++ b/docker/kubernetes/shared-notification/artifacts.example.json @@ -1,7 +1,7 @@ { "sourceCommit": "REPLACE_WITH_CHECKED_SOURCE_COMMIT", "sourceArchiveSha256": "REPLACE_WITH_VERIFIED_SOURCE_ARCHIVE_SHA256", - "migrations": ["20260501214629_InitialCreate", "20260502154249_PendingChanges"], + "migrations": ["20260501214629_InitialCreate", "20260502154249_PendingChanges", "20261017072410_EmailTemplateLocalizations"], "images": { "web": {"image": "registry.hrynco.com/hrynco.notificationservice.web@sha256:REPLACE", "sourceArchiveSha256": "REPLACE_WITH_VERIFIED_SOURCE_ARCHIVE_SHA256"}, "worker": {"image": "registry.hrynco.com/hrynco.notificationservice.worker@sha256:REPLACE", "sourceArchiveSha256": "REPLACE_WITH_VERIFIED_SOURCE_ARCHIVE_SHA256"}, diff --git a/docker/kubernetes/shared-notification/notification.py b/docker/kubernetes/shared-notification/notification.py index 67fcba3..badafcd 100644 --- a/docker/kubernetes/shared-notification/notification.py +++ b/docker/kubernetes/shared-notification/notification.py @@ -18,7 +18,8 @@ import time NAMESPACE = "shared-notification" MANAGED = "hrynco-shared-notification" -MIGRATIONS = ["20260501214629_InitialCreate", "20260502154249_PendingChanges"] +MIGRATIONS = ["20260501214629_InitialCreate", "20260502154249_PendingChanges", + "20261017072410_EmailTemplateLocalizations"] IMAGE = re.compile(r"registry\.hrynco\.com/[a-z0-9._/-]+@sha256:[a-f0-9]{64}$") SHA = re.compile(r"[a-f0-9]{64}$") PRODUCTION_RESULT_QUEUE = "invemory.production.notification-results" @@ -53,7 +54,8 @@ REVOKE ALL ON DATABASE notification_service FROM PUBLIC; GRANT CONNECT ON DATABASE notification_service TO notification_runtime; REVOKE CREATE ON SCHEMA public FROM PUBLIC; GRANT USAGE ON SCHEMA public TO notification_runtime; -GRANT SELECT, INSERT, UPDATE, DELETE ON email_channels, email_templates, email_channel_usage TO notification_runtime; +GRANT SELECT, INSERT, UPDATE, DELETE ON email_channels, email_templates, email_channel_usage, + services, service_languages, email_template_definitions TO notification_runtime; ''' SANDBOX = r''' @@ -62,11 +64,24 @@ INSERT INTO email_channels (id, service_name, priority, provider_type, settings, SELECT gen_random_uuid(), 'Invemory.Staging.Sandbox', 1, 1, '{"EmailChannelType":1,"Host":"mailpit","Port":1025,"Username":"","Password":"","UseSsl":false,"FromEmail":"no-reply@local.hrynco.test","FromName":"Invemory Sandbox"}'::jsonb, 90, true, now() WHERE NOT EXISTS (SELECT 1 FROM email_channels WHERE service_name = 'Invemory.Staging.Sandbox'); -INSERT INTO email_templates (id, service_name, key, language_code, subject, html_body, text_body, variables, created) -VALUES (gen_random_uuid(), 'Invemory.Staging.Sandbox', 'TestEmail', 'en', 'Test notification', - '

Hello {{RecipientName}},

{{Message}}

', 'Hello {{RecipientName}}, {{Message}}', - '[{"name":"RecipientName","required":true},{"name":"Message","required":true}]'::jsonb, now()) -ON CONFLICT (service_name, key, language_code) DO NOTHING; +INSERT INTO services (id, name, revision, created) +VALUES (gen_random_uuid(), 'Invemory.Staging.Sandbox', gen_random_uuid(), now()) +ON CONFLICT (name) DO NOTHING; +INSERT INTO service_languages (id, service_id, code, created) +SELECT gen_random_uuid(), s.id, 'en', now() FROM services s WHERE s.name = 'Invemory.Staging.Sandbox' +ON CONFLICT (service_id, code) DO NOTHING; +INSERT INTO email_template_definitions (id, service_id, key, revision, variables, created) +SELECT gen_random_uuid(), s.id, 'TestEmail', gen_random_uuid(), + '[{"name":"RecipientName","required":true},{"name":"Message","required":true}]'::jsonb, now() +FROM services s WHERE s.name = 'Invemory.Staging.Sandbox' +ON CONFLICT (service_id, key) DO NOTHING; +INSERT INTO email_templates (id, definition_id, service_id, service_language_id, subject, html_body, text_body, created) +SELECT gen_random_uuid(), d.id, s.id, l.id, 'Test notification', + '

Hello {{RecipientName}},

{{Message}}

', 'Hello {{RecipientName}}, {{Message}}', now() +FROM services s JOIN service_languages l ON l.service_id = s.id AND l.code = 'en' + JOIN email_template_definitions d ON d.service_id = s.id AND d.key = 'TestEmail' +WHERE s.name = 'Invemory.Staging.Sandbox' +ON CONFLICT (definition_id, service_language_id) DO NOTHING; COMMIT; ''' @@ -75,9 +90,12 @@ SANDBOX_CHECK = """SELECT settings->>'Host' = 'mailpit' AND settings->>'Port' = '1025' AND settings->>'UseSsl' = 'false' AND settings->>'Username' = '' AND settings->>'Password' = '') FROM email_channels WHERE service_name = 'Invemory.Staging.Sandbox') AND - EXISTS (SELECT 1 FROM email_templates WHERE service_name = 'Invemory.Staging.Sandbox' - AND key = 'TestEmail' AND language_code = 'en' - AND variables @> '[{"name":"RecipientName","required":true},{"name":"Message","required":true}]'::jsonb)""" + EXISTS (SELECT 1 FROM email_templates t + JOIN email_template_definitions d ON d.id = t.definition_id AND d.service_id = t.service_id + JOIN service_languages l ON l.id = t.service_language_id AND l.service_id = t.service_id + JOIN services s ON s.id = t.service_id + WHERE s.name = 'Invemory.Staging.Sandbox' AND d.key = 'TestEmail' AND l.code = 'en' + AND d.variables @> '[{"name":"RecipientName","required":true},{"name":"Message","required":true}]'::jsonb)""" def read_json(path): @@ -640,7 +658,7 @@ def deploy(cluster, config, artifacts, args): except Exception: cluster.scale("worker", 0) raise - print("deployment complete; SMTP is sandbox-only, Invemory publisher cutover is separate") + print("deployment complete; verify configured SMTP channels and publisher routes separately") def main(argv=None):