refactor(web-app-dashboard): bake the port-ui content catalogs into the image

A bind source only exists on the node that rendered it, so swarm rejected
the service with "bind source path does not exist: .../volumes/i18n-content".
The catalogs are generated from core.po at deploy time and no operator ever
edits them, so they are image content, not host state.

files/Dockerfile copies the build context's i18n-content/ into
/app/i18n/content, the path port-ui derives from app/utils/i18n.py. The
upstream image ships no catalog of its own, so nothing is shadowed.
meta/services.yml declares custom: true, which makes the image lookup
resolve dashboard_custom and leaves image:/version: as the Dockerfile's
FROM. The compose template takes the shared build block from
sys-svc-container rather than spelling one out, so it also gets the
compose-only pull_policy: never.

The catalogs move from directories.volumes to directories.instance because
that is the build context. Only the stack host has one, so the render drops
render_replicated_templates for stack_host_template, and the filter plugin
that produced the replicated-template entries goes with it.

A find/absent pair removes the catalog of a language core no longer
translates. In the mounted design a stale file was host state an operator
could delete; baked into an image it would survive every rebuild.

The render also notifies container-recreate. The tag is constant across
catalog changes, and docker stack deploy --resolve-image never computes no
diff from a re-pushed identical tag, so in swarm only a forced service
update moves tasks onto the new image. In compose the topic resolves to the
same compose up --force-recreate handler that compose-up already triggers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-09-27 00:50:52 +02:00
1 parent 50091194d5
commit 0e4ed57c3c
9 files changed
+47 -43

No files matched your search

+2 -2
View File
@@ -15,7 +15,7 @@ The consumer repositories change afterwards and are not part of this requirement
- One built version needs about 2.3 GB. Building every translated language up front multiplies that per language; `min_storage` MUST say so. `min_storage` also gates which roles a host deploys at all, so it counts the sites every deployment builds and names the growth per further language.
- The documentation is built from the git mirror of `DOCS_SOURCE_REPO`. Catalogs that exist only in a local working tree never reach it.
- [logout-panel.js.j2](../../roles/web-app-keycloak/templates/logout-panel.js.j2) inlines [logout_i18n.yml](../../roles/web-app-keycloak/files/logout_i18n.yml) as a `{language: {key: text, dir}}` object, and [logout-panel.js](../../roles/web-app-keycloak/files/javascript/logout-panel.js) picks the language from `<html lang>` and `navigator.language`. Its 29 non-English blocks are the only human translations core owns.
- port-ui translates the rendered `config.yaml` through `app/i18n/content/<code>.yaml`, a flat mapping from the English source string to its translation for the keys `description`, `info`, `name`, `subtitel`, `text`, `title` and `warning`. [web-app-dashboard](../../roles/web-app-dashboard/meta/volumes.yml) mounts only `config.yaml` today, so every card and menu text stays English.
- port-ui translates the rendered `config.yaml` through `app/i18n/content/<code>.yaml`, a flat mapping from the English source string to its translation for the keys `description`, `info`, `name`, `subtitel`, `text`, `title` and `warning`. [web-app-dashboard](../../roles/web-app-dashboard/meta/volumes.yml) mounts only `config.yaml`, and the upstream image ships no catalog, so every card and menu text stays English until the role builds them in.
- [csp_filters.py](../../plugins/filter/csp_filters.py) adds a provider origin to `connect-src` only for known feature flags such as `simpleicons`. [web-app-mig](../../roles/web-app-mig/templates/compose.yml.j2) renders no environment and no API origin.
## Acceptance Criteria
@@ -29,7 +29,7 @@ The consumer repositories change afterwards and are not part of this requirement
- [x] `logout_i18n.yml` holds only the English source strings, and the logout panel reads every other language from `core.po`.
- [ ] For each of the 29 languages of the former `logout_i18n.yml`, the rendered panel catalogue holds the same strings as before the migration.
- [x] The logout panel takes the text direction of every language from `meta/languages.yml`.
- [x] web-app-dashboard mounts one port-ui content catalog per language that has a translated dashboard string, generated from `core.po` at deploy time.
- [x] web-app-dashboard bakes one port-ui content catalog per language that has a translated dashboard string into its image, generated from `core.po` at deploy time.
- [x] The dashboard at `/de/` shows the German translation of at least one card description and one menu category.
- [x] A role that enables the `api` service gets the web-svc-api origin in its `connect-src`, and a role without it does not.
- [x] web-app-mig enables the `api` service when web-svc-api is deployed and renders the API base URL into its environment.
+5
View File
@@ -0,0 +1,5 @@
ARG DASHBOARD_IMAGE
ARG DASHBOARD_VERSION
FROM ${DASHBOARD_IMAGE}:${DASHBOARD_VERSION}
COPY i18n-content/ /app/i18n/content/
@@ -1,27 +0,0 @@
from __future__ import annotations
def i18n_content_templates(
catalogues: dict, directory: str, template: str
) -> list[dict]:
"""Return one ``render_replicated_templates`` entry per content catalog.
Args:
catalogues: the ``lookup('i18n_content', ...)`` result, keyed by language.
directory: host directory port-ui mounts as ``i18n/content``.
template: template rendering the catalog named by ``language``.
"""
return [
{
"src": template,
"dest": f"{directory.rstrip('/')}/{code}.yaml",
"language": code,
"mode": "0644",
}
for code in sorted(catalogues)
]
class FilterModule:
def filters(self):
return {"i18n_content_templates": i18n_content_templates}
@@ -40,6 +40,7 @@ dashboard:
name: dashboard
image: ghcr.io/kevinveenbirkenbach/port-ui
version: 2.2.2
custom: true
min_storage: 549MB
cpus: "0.3"
mem_reservation: 256m
-6
View File
@@ -6,9 +6,3 @@ config:
mounts:
- service: dashboard
target: /app/config.yaml
i18n_content:
type: bind
source: "{{ [lookup('container', 'web-app-dashboard', 'directories.volumes'), 'i18n-content'] | path_join }}"
mounts:
- service: dashboard
target: /app/i18n/content
+31 -6
View File
@@ -18,17 +18,42 @@
dashboard_i18n_content: "{{ lookup('i18n_content', 'role:', 'menu:') }}"
run_once: true
- name: "📁 Ensure the port-ui content catalog directory exists on every node"
- name: "📁 Ensure the port-ui content catalog directory exists in the build context"
ansible.builtin.file:
path: "{{ DASHBOARD_I18N_CONTENT_DIR }}"
state: directory
mode: "0755"
when: IS_STACK_HOST | bool
- name: "🌐 Render the port-ui content catalogs (local + swarm peers)"
ansible.builtin.include_tasks:
file: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/render_replicated_templates.yml') }}"
vars:
role_templates: "{{ dashboard_i18n_content | i18n_content_templates(DASHBOARD_I18N_CONTENT_DIR, 'i18n-content.yaml.j2') }}"
- name: "🌐 Render the port-ui content catalogs into the build context"
stack_host_template:
src: "i18n-content.yaml.j2"
dest: "{{ [ DASHBOARD_I18N_CONTENT_DIR, item ~ '.yaml' ] | path_join }}"
loop: "{{ dashboard_i18n_content | list | sort }}"
notify:
- compose-build
- compose-up
- container-recreate
- name: "🔎 Find the port-ui content catalogs already in the build context"
ansible.builtin.find:
paths: "{{ DASHBOARD_I18N_CONTENT_DIR }}"
patterns: "*.yaml"
file_type: file
register: dashboard_i18n_rendered
- name: "🗑️ Drop the catalog of a language core no longer translates"
ansible.builtin.file:
path: "{{ item.path }}"
state: absent
loop: "{{ dashboard_i18n_rendered.files }}"
loop_control:
label: "{{ item.path | basename }}"
when: item.path | basename | regex_replace('[.]yaml$', '') not in dashboard_i18n_content
notify:
- compose-build
- compose-up
- container-recreate
- name: "👔 Dashboard manager-side operations (file deploys + container exec)"
include_tasks: 02_manager_ops.yml
@@ -2,6 +2,10 @@
{% set service_name = 'dashboard' %}
{{ service_name }}:
{% include 'roles/sys-svc-container/templates/deploy.yml.j2' %}
{{ lookup('template', 'roles/sys-svc-container/templates/build.yml.j2') | indent(4) }}
args:
DASHBOARD_IMAGE: "{{ DASHBOARD_IMAGE }}"
DASHBOARD_VERSION: "{{ DASHBOARD_VERSION }}"
{{ lookup('container_image', application_id, 'dashboard') }}
{{ lookup('compose_only', 'container_name', DASHBOARD_CONTAINER) }}
{{ lookup('container_ports', ['dashboard', 'http', DOCKER_BIND_HOST]) | indent(4) }}
@@ -1 +1 @@
{{ dashboard_i18n_content[_tpl.language] | to_nice_yaml(width=100000) }}
{{ dashboard_i18n_content[item] | to_nice_yaml(width=100000) }}
+3 -1
View File
@@ -21,7 +21,9 @@ DASHBOARD_LOCATION_SILENT_CHECK: "/silent-check-sso.html"
DASHBOARD_CONFIG_YML_SRC_FILE: "config.yaml.j2"
DASHBOARD_CONFIG_INV_PATH: "{{ [ inventory_dir, 'files', inventory_hostname ,'docker', DASHBOARD_APPLICATION_ID, DASHBOARD_CONFIG_YML_SRC_FILE] | path_join }}"
DASHBOARD_CONFIG_YML_DEST: "{{ [ lookup('container', DASHBOARD_APPLICATION_ID, 'directories.volumes'), 'config.yaml' ] | path_join }}"
DASHBOARD_I18N_CONTENT_DIR: "{{ [ lookup('container', DASHBOARD_APPLICATION_ID, 'directories.volumes'), 'i18n-content' ] | path_join }}"
DASHBOARD_I18N_CONTENT_DIR: "{{ [ lookup('container', DASHBOARD_APPLICATION_ID, 'directories.instance'), 'i18n-content' ] | path_join }}"
DASHBOARD_IMAGE: "{{ lookup('config', DASHBOARD_APPLICATION_ID, 'services.dashboard.image') }}"
DASHBOARD_VERSION: "{{ lookup('config', DASHBOARD_APPLICATION_ID, 'services.dashboard.version') }}"
DASHBOARD_APP_BASE_URL: "{{ lookup('tls', DASHBOARD_APPLICATION_ID, 'url.base') | regex_replace('/+$', '') }}/"
DASHBOARD_KEYCLOAK_LOGIN_URL: >-