refactor(roles): keep a container's environment in one file

A container's environment had two homes. sys-svc-container's base.yml.j2
already emits env_file for any role that ships templates/env.j2, and 89
roles do, yet fifty compose templates still opened an environment: block of
their own. The half written there is the half nobody greps when they go
looking for what a container is configured with, and a secret placed there
also shows up in docker inspect, where an env file does not.

Nine roles move their keys across: odoo twelve, jenkins five, zammad three,
friendica and peertube two each, and one apiece for lmstudio, ollama,
registry-cache and registry-docker. Four of them gain their first env.j2.
The keys are carried over unchanged except where the new home demands it:
YAML quotes are dropped, because an env file would take them as part of the
value, and odoo's database password gains dotenv_quote, which the inline
list never gave it.

The remaining forty-one blocks keep a nocheck that names the services the
role's single env.j2 would otherwise reach. Most are mcp sidecars whose
ADAPTER_BEARER must not land in the application container beside them;
gitlab's six services disagree about CONFIG_TEMPLATE_DIRECTORY; xwiki
re-renders its compose template a second time with the auth switches
flipped, which an env file rendered once cannot follow. Nine plus forty-one
accounts for all fifty.

A lint holds the line from here. It flags an environment: that opens a block
mapping in any role's compose template and takes a marker on that line, on
the comment block above it, or in the file's leading comment block. The
leading-comment scope is deliberate: a raw line window would let a marker
written for one service silence every later block in the same file, and a
unit test pins exactly that, failing against the window and passing against
the header.

Measured on the tree: the key set each role moves is identical before and
after, with no key lost or gained in any of the nine, and no moved value
contained a dollar sign, so nothing changed meaning between compose
interpolation and an env file that performs none. Dotted keys such as
zammad's discovery.type survive both docker compose config and docker stack
config unaltered.

Not yet deployed. Eight of the nine cannot spread a key at all, being single
service or, for jenkins, having the second service override
docker_compose_env. zammad is the exception: three elasticsearch keys now
reach its siblings, where ES_JAVA_OPTS is read only by a JVM entrypoint and
the dotted names are not valid shell identifiers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-09-27 17:42:22 +02:00
1 parent 44260b8786
commit 37e80b957e
46 files changed
+261 -85

No files matched your search

@@ -13,8 +13,6 @@
{{ lookup('compose_only', 'container_name', LMSTUDIO_CONTAINER) }}
expose:
- "{{ LMSTUDIO_PORT }}"
environment:
LMS_SERVER_HOST: "0.0.0.0"
{{ lookup('container_volumes', service_name) | indent(4) }}
{{ lookup('container_healthcheck', service_name) | indent(4) }}
{{ lookup('container_networks') | indent(4) }}
+3
View File
@@ -0,0 +1,3 @@
{% include 'roles/sys-svc-container/templates/env.j2' %}
LMS_SERVER_HOST=0.0.0.0
@@ -8,8 +8,6 @@
{{ lookup('compose_only', 'container_name', OLLAMA_CONTAINER) }}
expose:
- "{{ OLLAMA_PORT }}"
environment:
OLLAMA_KEEP_ALIVE: "{{ OLLAMA_KEEP_ALIVE }}"
{{ lookup('container_volumes', service_name) | indent(4) }}
{{ lookup('container_networks') | indent(4) }}
+3
View File
@@ -0,0 +1,3 @@
{% include 'roles/sys-svc-container/templates/env.j2' %}
OLLAMA_KEEP_ALIVE={{ OLLAMA_KEEP_ALIVE }}
+1 -1
View File
@@ -29,7 +29,7 @@
- ALL
security_opt:
- no-new-privileges:true
environment:
environment: # nocheck: compose-environment-from-env-file qdrantmcp is not the service env.j2 covers; that single env file is also loaded by the primary qdrant container, so moving these would hand qdrant ADAPTER_BEARER and ADAPTER_UPSTREAM_KEY
ADAPTER_CONTRACT: {{ QDRANT_MCP_CONTRACT | to_json | to_json | replace('$', '$$') }}
ADAPTER_BEARER: {{ QDRANT_MCP_BEARER | to_json }}
ADAPTER_PORT: "{{ QDRANT_MCP_PORT }}"
@@ -5,8 +5,6 @@
{% if IS_COMPOSE_MODE %}
{{ lookup('compose_only', 'container_name', REGISTRY_CACHE_CONTAINER) }}
{% endif %}
environment:
REGISTRY_PROXY_REMOTEURL: "{{ REGISTRY_CACHE_PROXY_REMOTE_URL }}"
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
{{ lookup('container_healthcheck', service_name) | indent(4) }}
{{ lookup('container_ports', ['cache', 'registry']) | indent(4) }}
@@ -0,0 +1,3 @@
{% include 'roles/sys-svc-container/templates/env.j2' %}
REGISTRY_PROXY_REMOTEURL={{ REGISTRY_CACHE_PROXY_REMOTE_URL }}
@@ -4,10 +4,7 @@
image: "{{ REGISTRY_IMAGE }}:{{ REGISTRY_VERSION }}" # nocheck: container-image-lookup
{{ lookup('compose_only', 'container_name', REGISTRY_CONTAINER) }}
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
{% if IS_SWARM_MODE %}
environment:
REGISTRY_HTTP_ADDR: "{{ REGISTRY_BIND_IP }}:{{ REGISTRY_PORT }}"
{% else %}
{% if not IS_SWARM_MODE %}
{{ lookup('container_healthcheck', service_name) | indent(4) }}
{{ lookup('container_ports', ['docker', 'registry']) | indent(4) }}
{% endif %}
@@ -0,0 +1,5 @@
{% include 'roles/sys-svc-container/templates/env.j2' %}
{% if IS_SWARM_MODE %}
REGISTRY_HTTP_ADDR={{ REGISTRY_BIND_IP }}:{{ REGISTRY_PORT }}
{% endif %}
@@ -31,7 +31,7 @@
- ALL
security_opt:
- no-new-privileges:true
environment:
environment: # nocheck: compose-environment-from-env-file baserowmcp-only keys; this role sets no docker_compose_env, so its single env.j2 is loaded by the primary baserow container too and would receive ADAPTER_BEARER, and swarm does not unescape the $$ of ADAPTER_CONTRACT in an env file
ADAPTER_CONTRACT: {{ BASEROW_MCP_CONTRACT | to_json | to_json | replace('$', '$$') }}
ADAPTER_BEARER: {{ BASEROW_MCP_BEARER | to_json }}
ADAPTER_PORT: "{{ BASEROW_MCP_PORT }}"
@@ -81,7 +81,7 @@ services:
{{ service_name }}:
{{ lookup('container_image', application_id, service_name) }}
{{ lookup('container_volumes', service_name) | indent(4) }}
environment:
environment: # nocheck: compose-environment-from-env-file MS_WEBRTC_LISTEN_IPS relies on compose expanding ${EXTERNAL_IPv4} from --env-file, which env_file values never get; compose_mods.py:39 also pins env.j2 onto every bbb service, so these mediasoup keys would reach all ~20 containers instead of webrtc-sfu
MS_WORKERS: "1"
MS_ENABLE_IPV6: "{{ BBB_IP6_ENABLED | bool | lower }}"
MS_WEBRTC_LISTEN_IPS: >-
@@ -40,7 +40,7 @@
{{ lookup('compose_only', 'pull_policy', 'never') }}
{{ lookup('compose_restart') }}
{{ lookup('container_ports', [BLUESKY_BROKER_SERVICE, 'http', DOCKER_BIND_HOST]) | indent(4) }}
environment:
environment: # nocheck: compose-environment-from-env-file broker has no env_file (only pds includes the container base), and PDS_ADMIN_PASSWORD carries compose $$ escaping that an env_file would not unescape
BROKER_PORT: "{{ BLUESKY_BROKER_INTERNAL_PORT }}"
SOCIAL_APP_URL: "http://web:{{ BLUESKY_WEB_INTERNAL_PORT }}"
PDS_URL: "{{ lookup('tls', application_id, 'protocols.web') }}://{{ BLUESKY_API_DOMAIN }}"
@@ -28,7 +28,7 @@
- ALL
security_opt:
- no-new-privileges:true
environment:
environment: # nocheck: compose-environment-from-env-file checkmkmcp-only keys; the role's single env_file also feeds the primary checkmk container, which must not receive ADAPTER_BEARER or ADAPTER_UPSTREAM_KEY
ADAPTER_CONTRACT: {{ CHECKMK_MCP_CONTRACT | to_json | to_json | replace('$', '$$') }}
ADAPTER_BEARER: {{ CHECKMK_MCP_BEARER | to_json }}
ADAPTER_PORT: "{{ CHECKMK_MCP_PORT }}"
@@ -40,7 +40,7 @@
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
command: ["bash", "-c", "{{ ERPNEXT_BACKEND_READY_GATE }} exec nginx-entrypoint.sh"]
{{ lookup('depends_on', {'backend': 'service_started', 'websocket': 'service_started'}) }}
environment:
environment: # nocheck: compose-environment-from-env-file nginx-entrypoint.sh inputs for frontend only; this role sets no docker_compose_env, so its one env_file reaches all 7 services
BACKEND: "backend:{{ ERPNEXT_BACKEND_PORT }}"
SOCKETIO: "websocket:{{ ERPNEXT_WEBSOCKET_PORT }}"
FRAPPE_SITE_NAME_HEADER: "{{ ERPNEXT_SITE_NAME }}"
@@ -52,7 +52,7 @@
stop_signal: SIGTERM
stop_grace_period: 30s
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
environment:
environment: # nocheck: compose-environment-from-env-file websocket-only keys; this role sets no docker_compose_env, so its single env.j2 is read by the espocrm and daemon services too, and files/shell/sidecar_boot.sh calls applyConfigEnv, which would then write the websocket listener's SUBSCRIBER_DSN bind address into the shared config.php from all three containers
- ESPOCRM_CONFIG_USE_WEB_SOCKET=true
- ESPOCRM_CONFIG_WEB_SOCKET_URL={{ lookup('tls', application_id, 'protocols.websocket') }}://{{ lookup('domain',application_id) }}/ws
- ESPOCRM_CONFIG_WEB_SOCKET_ZERO_M_Q_SUBSCRIBER_DSN=tcp://*:7777
+1 -1
View File
@@ -28,7 +28,7 @@
- ALL
security_opt:
- no-new-privileges:true
environment:
environment: # nocheck: compose-environment-from-env-file fidermcp-only keys; the role sets no docker_compose_env, so its single env.j2 is also the env_file of the primary fider container, which must not receive ADAPTER_BEARER
ADAPTER_CONTRACT: {{ FIDER_MCP_CONTRACT | to_json | to_json | replace('$', '$$') }}
ADAPTER_BEARER: {{ FIDER_MCP_BEARER | to_json }}
ADAPTER_PORT: "{{ FIDER_MCP_PORT }}"
@@ -7,11 +7,6 @@
args:
FRIENDICA_IMAGE: "{{ FRIENDICA_IMAGE }}"
FRIENDICA_VERSION: "{{ FRIENDICA_VERSION }}"
environment:
FRIENDICA_CONFIG_FILE: "{{ FRIENDICA_CONFIG_FILE }}"
{% if IS_SWARM_MODE %}
TASK_SLOT: "{% raw %}{{.Task.Slot}}{% endraw %}"
{% endif %}
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
{{ lookup('container_volumes', service_name) | indent(4) }}
{{ lookup('container_ports', ['friendica', 'http', DOCKER_BIND_HOST]) | indent(4) }}
+5
View File
@@ -30,3 +30,8 @@ SMTP_FROM=no-reply
FRIENDICA_ADMIN_MAIL={{ lookup('users', 'administrator').email }}
MAILNAME={{ lookup('users', 'administrator').email }}
FRIENDICA_CONFIG_FILE={{ FRIENDICA_CONFIG_FILE }}
{% if IS_SWARM_MODE %}
TASK_SLOT={% raw %}{{.Task.Slot}}{% endraw %}{{"\n"}}
{% endif %}
@@ -4,7 +4,7 @@
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
{{ lookup('container_image', application_id, 'api') }}
command: celery -A funkwhale_api.taskapp worker -l INFO --concurrency={{FUNKWHALE_CELERYD_CONCURRENCY}}
environment:
environment: # nocheck: compose-environment-from-env-file celeryworker-only key; no service here overrides docker_compose_env, so the role's single env.j2 is loaded by celerybeat, api, front, typesense and migrate too, none of which has C_FORCE_ROOT today
- C_FORCE_ROOT=true
{{ lookup('container_volumes', service_name) | indent(4) }}
{{ lookup('container_depends_on', application_id, extra={
@@ -44,7 +44,7 @@
{{ lookup('container_image', application_id, 'funkwhale') }}
{{ lookup('container_healthcheck', 'funkwhale') | indent(4) }}
{{ lookup('depends_on', ['api']) }}
environment:
environment: # nocheck: compose-environment-from-env-file front-only key read by the funkwhale image's nginx entrypoint; the role's single env.j2 also feeds celeryworker, celerybeat, api, typesense and migrate, none of which has NGINX_MAX_BODY_SIZE today
- "NGINX_MAX_BODY_SIZE=100M"
{{ lookup('container_volumes', service_name) | indent(4) }}
{{ lookup('container_ports', ['funkwhale', 'http', DOCKER_BIND_HOST]) | indent(4) }}
+2 -2
View File
@@ -23,7 +23,7 @@
{{ lookup('container_image', application_id, 'giteamcpupstream') }}
{{ lookup('compose_only', 'container_name', GITEA_MCP_SIDECAR_CONTAINER) }}
command: {{ GITEA_MCP_COMMAND | to_json }}
environment:
environment: # nocheck: compose-environment-from-env-file the sidecar shares the role's single env_file with the gitea server, so these keys would land in gitea too
GITEA_HOST: "{{ GITEA_MCP_UPSTREAM_URL }}"
GITEA_SCOPES: "{{ GITEA_MCP_TOOL_SCOPES }}"
GITEA_READONLY: "true"
@@ -48,7 +48,7 @@
- ALL
security_opt:
- no-new-privileges:true
environment:
environment: # nocheck: compose-environment-from-env-file docker_compose_env points this service at the generated upstream.env, so env.j2 never reaches it
ADAPTER_CONTRACT: {{ GITEA_MCP_CONTRACT | to_json | to_json | replace('$', '$$') }}
ADAPTER_BEARER: {{ GITEA_MCP_BEARER | to_json }}
ADAPTER_PORT: "{{ GITEA_MCP_PORT }}"
@@ -4,7 +4,7 @@
{{ lookup('compose_only', 'container_name', GITLAB_WEBSERVICE_CONTAINER) }}
{{ lookup('container_image', application_id, service_name) }}
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
environment:
environment: # nocheck: compose-environment-from-env-file six services share this role's single env.j2; CONFIG_TEMPLATE_DIRECTORY is /etc/gitaly/templates in gitaly and /srv/gitlab-config in shell, and GITLAB_WEBSERVER/ACTION_CABLE_IN_APP belong to webservice alone
CONFIG_TEMPLATE_DIRECTORY: "/var/opt/gitlab/config/templates"
CONFIG_DIRECTORY: "/srv/gitlab/config"
GITLAB_WEBSERVER: "PUMA"
@@ -22,7 +22,7 @@
{{ lookup('compose_only', 'container_name', GITLAB_WORKHORSE_CONTAINER) }}
{{ lookup('container_image', application_id, service_name) }}
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
environment:
environment: # nocheck: compose-environment-from-env-file six services share this role's single env.j2; CONFIG_TEMPLATE_DIRECTORY differs in gitaly and shell, and the GITLAB_WORKHORSE_* keys belong to workhorse alone
CONFIG_TEMPLATE_DIRECTORY: "/var/opt/gitlab/config/templates"
CONFIG_DIRECTORY: "/srv/gitlab/config"
GITLAB_WORKHORSE_AUTH_BACKEND: "http://{{ GITLAB_WEBSERVICE_SERVICE_NAME }}:{{ GITLAB_WEBSERVICE_PORT }}"
@@ -42,7 +42,7 @@
{{ lookup('compose_only', 'container_name', GITLAB_SIDEKIQ_CONTAINER) }}
{{ lookup('container_image', application_id, service_name) }}
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
environment:
environment: # nocheck: compose-environment-from-env-file six services share this role's single env.j2; CONFIG_TEMPLATE_DIRECTORY is /etc/gitaly/templates in gitaly and /srv/gitlab-config in shell, and gitaly carries no CONFIG_DIRECTORY at all
CONFIG_TEMPLATE_DIRECTORY: "/var/opt/gitlab/config/templates"
CONFIG_DIRECTORY: "/srv/gitlab/config"
command: ["/scripts/wait-for-deps", "/scripts/process-wrapper"]
@@ -59,7 +59,7 @@
{{ lookup('compose_only', 'container_name', GITLAB_GITALY_CONTAINER) }}
{{ lookup('container_image', application_id, service_name) }}
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
environment:
environment: # nocheck: compose-environment-from-env-file gitaly's CONFIG_TEMPLATE_DIRECTORY differs from the five sibling services that share this role's single env.j2
CONFIG_TEMPLATE_DIRECTORY: "/etc/gitaly/templates"
{{ lookup('container_healthcheck', service_name) | indent(4) }}
{{ lookup('container_depends_on', application_id) | indent(4) }}
@@ -71,7 +71,7 @@
{{ lookup('compose_only', 'container_name', GITLAB_SHELL_CONTAINER) }}
{{ lookup('container_image', application_id, service_name) }}
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
environment:
environment: # nocheck: compose-environment-from-env-file shell's CONFIG_TEMPLATE_DIRECTORY and CONFIG_DIRECTORY differ from the five sibling services that share this role's single env.j2, and SSH_DAEMON belongs to shell alone
CONFIG_TEMPLATE_DIRECTORY: "/srv/gitlab-config"
CONFIG_DIRECTORY: "/srv/gitlab-shell"
SSH_DAEMON: "gitlab-sshd"
@@ -93,7 +93,7 @@
{{ lookup('compose_only', 'container_name', GITLAB_MIGRATIONS_CONTAINER) }}
{{ lookup('container_image', application_id, service_name) }}
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
environment:
environment: # nocheck: compose-environment-from-env-file six services share this role's single env.j2; CONFIG_TEMPLATE_DIRECTORY differs in gitaly and shell, and BYPASS_SCHEMA_VERSION belongs to the migrations job alone
CONFIG_TEMPLATE_DIRECTORY: "/var/opt/gitlab/config/templates"
CONFIG_DIRECTORY: "/srv/gitlab/config"
BYPASS_SCHEMA_VERSION: "true"
@@ -120,7 +120,7 @@
- ALL
security_opt:
- no-new-privileges:true
environment:
environment: # nocheck: compose-environment-from-env-file gitlabmcp overrides docker_compose_env with mcp/upstream.env, so this role's env.j2 never reaches it and these keys would instead leak into the six GitLab services
ADAPTER_CONTRACT: {{ GITLAB_MCP_CONTRACT | to_json | to_json | replace('$', '$$') }}
ADAPTER_BEARER: {{ GITLAB_MCP_BEARER | to_json }}
ADAPTER_PORT: "{{ GITLAB_MCP_PORT }}"
@@ -29,7 +29,7 @@
- ALL
security_opt:
- no-new-privileges:true
environment:
environment: # nocheck: compose-environment-from-env-file jellyfinmcp overrides docker_compose_env with mcp/upstream.env, so the role's templates/env.j2 never reaches it and these keys would instead land in the primary jellyfin container, which must not receive ADAPTER_BEARER
ADAPTER_CONTRACT: {{ JELLYFIN_MCP_CONTRACT | to_json | to_json | replace('$', '$$') }}
ADAPTER_BEARER: {{ JELLYFIN_MCP_BEARER | to_json }}
ADAPTER_PORT: "{{ JELLYFIN_MCP_PORT }}"
@@ -8,14 +8,6 @@
{{ lookup('container_image', application_id, 'jenkins') }}
{{ lookup('compose_only', 'container_name', JENKINS_CONTAINER) }}
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
environment:
JAVA_OPTS: "-Djenkins.install.runSetupWizard=false"
CASC_JENKINS_CONFIG: {{ lookup('volume', 'web-app-jenkins', 'casc').target }}
{% if JENKINS_MCP_ENABLED | bool %}
JENKINS_MCP_TOKEN_FILE: "{{ JENKINS_MCP_TOKEN_FILE }}"
JENKINS_MCP_TOKEN_NAME: "{{ JENKINS_MCP_TOKEN_NAME }}"
JENKINS_MCP_USERNAME: "{{ JENKINS_MCP_USERNAME }}"
{% endif %}
{{ lookup('container_ports', ['jenkins', 'http', DOCKER_BIND_HOST]) | indent(4) }}
{{ lookup('container_volumes', service_name) | indent(4) }}
{{ lookup('container_healthcheck', service_name) | indent(4) }}
@@ -39,7 +31,7 @@
- ALL
security_opt:
- no-new-privileges:true
environment:
environment: # nocheck: compose-environment-from-env-file jenkinsmcp-only keys; this service overrides env_file with upstream.env, and the role's templates/env.j2 feeds the primary jenkins container, which must not receive ADAPTER_BEARER
ADAPTER_CONTRACT: {{ JENKINS_MCP_CONTRACT | to_json | to_json | replace('$', '$$') }}
ADAPTER_BEARER: {{ JENKINS_MCP_BEARER | to_json }}
ADAPTER_PORT: "{{ JENKINS_MCP_PORT }}"
+9
View File
@@ -0,0 +1,9 @@
{% include 'roles/sys-svc-container/templates/env.j2' %}
JAVA_OPTS=-Djenkins.install.runSetupWizard=false
CASC_JENKINS_CONFIG={{ lookup('volume', 'web-app-jenkins', 'casc').target }}
{% if JENKINS_MCP_ENABLED | bool %}
JENKINS_MCP_TOKEN_FILE={{ JENKINS_MCP_TOKEN_FILE }}
JENKINS_MCP_TOKEN_NAME={{ JENKINS_MCP_TOKEN_NAME }}
JENKINS_MCP_USERNAME={{ JENKINS_MCP_USERNAME }}
{% endif %}
+1 -1
View File
@@ -24,7 +24,7 @@
user: "110010100"
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
{{ lookup('depends_on', {KIX_BACKEND_SERVICE: 'service_healthy'} | combine({'redis': 'service_healthy'} if lookup('engine', 'redis', application_id, 'local') else {})) }}
environment:
environment: # nocheck: compose-environment-from-env-file frontend-only keys; the role's single env.j2 is shared by the backend and proxy services, so moving them would inject REDIS_CACHE_PASSWORD and the frontend URLs into both
- USE_SSL=0
- REDIS_CACHE_HOST={{ lookup('engine', 'redis', application_id, 'host') }}
- REDIS_CACHE_PORT={{ lookup('engine', 'redis', application_id, 'port') }}
@@ -28,7 +28,7 @@
- ALL
security_opt:
- no-new-privileges:true
environment:
environment: # nocheck: compose-environment-from-env-file listmonkmcp-only keys; this role sets no docker_compose_env, so its single templates/env.j2 is also the env_file of the primary listmonk container, which must not receive ADAPTER_BEARER or ADAPTER_UPSTREAM_KEY
ADAPTER_CONTRACT: {{ LISTMONK_MCP_CONTRACT | to_json | to_json | replace('$', '$$') }}
ADAPTER_BEARER: {{ LISTMONK_MCP_BEARER | to_json }}
ADAPTER_PORT: "{{ LISTMONK_MCP_PORT }}"
@@ -34,7 +34,7 @@
{{ lookup('container_image', application_id, 'search') }}
{{ lookup('compose_only', 'container_name', MAGENTO_SEARCH_CONTAINER) }}
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
environment:
environment: # nocheck: compose-environment-from-env-file search-only keys; this role sets no docker_compose_env, so its single templates/env.j2 is also the env_file of the nginx and php containers, which must not receive the OpenSearch daemon's discovery.type, plugins.security.disabled or OPENSEARCH_JAVA_OPTS
- discovery.type=single-node
- plugins.security.disabled=true
- OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m
@@ -36,7 +36,7 @@
- ALL
security_opt:
- no-new-privileges:true
environment:
environment: # nocheck: compose-environment-from-env-file mattermostmcp overrides docker_compose_env with mcp/upstream.env, so this role's env.j2 never reaches it and these keys would instead land in the primary mattermost container, which must not receive ADAPTER_BEARER; ADAPTER_CONTRACT also carries compose $$ escaping that an env_file would not unescape
ADAPTER_CONTRACT: {{ MATTERMOST_MCP_CONTRACT | to_json | to_json | replace('$', '$$') }}
ADAPTER_BEARER: {{ MATTERMOST_MCP_BEARER | to_json }}
ADAPTER_PORT: "{{ MATTERMOST_MCP_PORT }}"
@@ -24,7 +24,7 @@
{{ lookup('container_volumes', service_name) | indent(4) }}
{% if IS_SWARM_MODE %}
environment:
environment: # nocheck: compose-environment-from-env-file the value is a swarm task-template placeholder, and this role's single env.j2 is also the env_file of context_agent, hpb, whiteboard and talk_recording
TASK_SLOT: "{% raw %}{{.Task.Slot}}{% endraw %}"
{% endif %}
sysctls:
@@ -51,7 +51,7 @@
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
{{ lookup('container_image', application_id, 'context_agent') }}
{{ lookup('compose_only', 'container_name', NEXTCLOUD_CONTEXT_AGENT_CONTAINER) }}
environment:
environment: # nocheck: compose-environment-from-env-file non-primary service on the shared env.j2; APP_SECRET would reach nextcloud, hpb, whiteboard and talk_recording, and env.j2 already binds NEXTCLOUD_URL to the public URL
APP_ID: {{ NEXTCLOUD_CONTEXT_AGENT_SERVICE }}
APP_VERSION: "{{ NEXTCLOUD_CONTEXT_AGENT_VERSION }}"
APP_HOST: 0.0.0.0
@@ -104,7 +104,7 @@
{{ lookup('container_healthcheck', service_name) | indent(4) }}
{{ lookup('compose_only', 'container_name', NEXTCLOUD_WHITEBOARD_CONTAINER) }}
{{ lookup('container_volumes', service_name) | indent(4) }}
environment:
environment: # nocheck: compose-environment-from-env-file non-primary service on the shared env.j2; NODE_OPTIONS would reach the nextcloud, context_agent, hpb and talk_recording containers
- NODE_OPTIONS=--max-old-space-size={{ NEXTCLOUD_WHITEBOARD_MAX_OLD_SPACE_SIZE }}
expose:
- "{{ container_port }}"
@@ -145,7 +145,7 @@
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
{{ lookup('container_image', application_id, 'talk_recording') }}
{{ lookup('compose_only', 'container_name', NEXTCLOUD_RECORDING_CONTAINER) }}
environment:
environment: # nocheck: compose-environment-from-env-file non-primary service on the shared env.j2; RECORDING_SECRET would reach nextcloud, context_agent, hpb and whiteboard
- HPB_DOMAIN={{ NEXTCLOUD_HPB_DOMAIN }}
- NC_DOMAIN={{ NEXTCLOUD_DOMAIN }}
- RECORDING_SECRET={{ NEXTCLOUD_RECORDING_SECRET }}
@@ -176,7 +176,7 @@
- ALL
security_opt:
- no-new-privileges:true
environment:
environment: # nocheck: compose-environment-from-env-file this service sets docker_compose_env to upstream.env, so env.j2 never reaches it and the keys would land only in the five services that do read it
ADAPTER_CONTRACT: {{ NEXTCLOUD_MCP_ADAPTER_CONTRACT | to_json | to_json | replace('$', '$$') }}
ADAPTER_BEARER: {{ NEXTCLOUD_MCP_ADAPTER_BEARER | to_json }}
ADAPTER_PORT: "{{ container_port }}"
@@ -19,21 +19,6 @@
{{ lookup('container_ports',
['odoo', 'http', DOCKER_BIND_HOST],
['odoo', 'websocket', DOCKER_BIND_HOST]) | indent(4) }}
environment:
- HOST={{ ODOO_DB_HOST }}
- USER={{ ODOO_DB_USER }}
- PASSWORD={{ ODOO_DB_PASSWORD }}
- ODOO_URL={{ ODOO_URL }}
- ODOO_INIT_MODULES={{ ODOO_INIT_MODULES }}
- ODOO_UPDATE_MARKER={{ ODOO_UPDATE_MARKER }}
{% if ODOO_OIDC_ENABLED | bool %}
- ODOO_OIDC_CLIENT_ID={{ ODOO_OIDC_CLIENT_ID }}
- ODOO_OIDC_CLIENT_SECRET={{ ODOO_OIDC_CLIENT_SECRET }}
- ODOO_OIDC_ISSUER_URL={{ ODOO_OIDC_ISSUER_URL }}
- ODOO_OIDC_TOKEN_URL={{ ODOO_OIDC_TOKEN_URL }}
- ODOO_OIDC_JWKS_URI={{ ODOO_OIDC_JWKS_URI }}
- ODOO_OIDC_UID_FIELD={{ ODOO_OIDC_UID_FIELD }}
{% endif %}
{{ lookup('container_depends_on', application_id) | indent(4) }}
{{ lookup('container_networks') | indent(4) }}
{{ lookup('container_volumes', service_name) | indent(4) }}
+16
View File
@@ -0,0 +1,16 @@
{% include 'roles/sys-svc-container/templates/env.j2' %}
HOST={{ ODOO_DB_HOST }}
USER={{ ODOO_DB_USER }}
PASSWORD={{ ODOO_DB_PASSWORD | dotenv_quote }}
ODOO_URL={{ ODOO_URL }}
ODOO_INIT_MODULES={{ ODOO_INIT_MODULES }}
ODOO_UPDATE_MARKER={{ ODOO_UPDATE_MARKER }}
{% if ODOO_OIDC_ENABLED | bool %}
ODOO_OIDC_CLIENT_ID={{ ODOO_OIDC_CLIENT_ID }}
ODOO_OIDC_CLIENT_SECRET={{ ODOO_OIDC_CLIENT_SECRET }}
ODOO_OIDC_ISSUER_URL={{ ODOO_OIDC_ISSUER_URL }}
ODOO_OIDC_TOKEN_URL={{ ODOO_OIDC_TOKEN_URL }}
ODOO_OIDC_JWKS_URI={{ ODOO_OIDC_JWKS_URI }}
ODOO_OIDC_UID_FIELD={{ ODOO_OIDC_UID_FIELD }}
{% endif %}
@@ -23,7 +23,7 @@ x-op-app: &app
{{ lookup('compose_only', 'pull_policy', 'never') }}
command: "./docker/prod/proxy"
{{ lookup('container_ports', ['openproject', 'http', DOCKER_BIND_HOST]) | indent(4) }}
environment:
environment: # nocheck: compose-environment-from-env-file proxy-only key read by ./docker/prod/proxy; this role sets no docker_compose_env, so its single env.j2 is loaded by cache, web, worker, cron and seeder too, none of which has APP_HOST today
APP_HOST: {{ OPENPROJECT_WEB_SERVICE }}
{{ lookup('depends_on', [OPENPROJECT_WEB_SERVICE]) }}
{{ lookup('container_volumes', service_name) | indent(4) }}
@@ -40,7 +40,7 @@
{{ lookup('container_image', application_id, 'rabbit') }}
{{ lookup('compose_only', 'container_name', OPENTALK_RABBIT_CONTAINER) }}
{{ lookup('compose_restart') }}
environment:
environment: # nocheck: compose-environment-from-env-file rabbit includes deploy.yml.j2 only, so it has no env_file; env.j2 would drop this key here and add it to opentalk and controller
RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS: "-rabbit consumer_timeout 30000"
{{ lookup('container_healthcheck', service_name) | indent(4) }}
{{ lookup('container_volumes', service_name) | indent(4) }}
@@ -14,9 +14,6 @@
{{ lookup('container_ports', ['peertube', 'http', DOCKER_BIND_HOST]) | indent(4) }}
{% endif %}
{{ lookup('container_volumes', service_name) | indent(4) }}
environment:
- NODE_OPTIONS=--max-old-space-size={{ PEERTUBE_MAX_OLD_SPACE_SIZE }}
- PEERTUBE_TRANSCODING_CONCURRENCY={{ PEERTUBE_TRANSCODING_CONCURRENCY }}
shm_size: "512m"
tmpfs:
- /tmp:size=1g,exec
+3
View File
@@ -1,5 +1,8 @@
{% include 'roles/sys-svc-container/templates/env.j2' %}
NODE_OPTIONS=--max-old-space-size={{ PEERTUBE_MAX_OLD_SPACE_SIZE }}
PEERTUBE_TRANSCODING_CONCURRENCY={{ PEERTUBE_TRANSCODING_CONCURRENCY }}
POSTGRES_USER={{ lookup('database', application_id, 'username') }}
POSTGRES_PASSWORD={{ lookup('database', application_id, 'password') | dotenv_quote }}
POSTGRES_DB={{ lookup('database', application_id, 'name') }}
@@ -15,7 +15,7 @@
{{ lookup('compose_only', 'container_name', PIXELFED_WORKER_CONTAINER) }}
{{ lookup('container_image', application_id, 'pixelfed') }}
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
environment:
environment: # nocheck: compose-environment-from-env-file worker-only override; no service here sets docker_compose_env, so the role's single env.j2 is also the env_file of the primary pixelfed container, where these same four keys are already set to true
AUTORUN_LARAVEL_EVENT_CACHE: "false"
AUTORUN_LARAVEL_ROUTE_CACHE: "false"
AUTORUN_LARAVEL_VIEW_CACHE: "false"
@@ -34,7 +34,7 @@
- ALL
security_opt:
- no-new-privileges:true
environment:
environment: # nocheck: compose-environment-from-env-file pretixmcp-only keys; this role sets no docker_compose_env, so its single templates/env.j2 is also the env_file of the primary pretix container, which must not receive ADAPTER_BEARER or ADAPTER_UPSTREAM_KEY, and swarm does not unescape the $$ of ADAPTER_CONTRACT in an env file
ADAPTER_CONTRACT: {{ PRETIX_MCP_CONTRACT | to_json | to_json | replace('$', '$$') }}
ADAPTER_BEARER: {{ PRETIX_MCP_BEARER | to_json }}
ADAPTER_PORT: "{{ PRETIX_MCP_PORT }}"
@@ -87,6 +87,7 @@
- ALL
security_opt:
- no-new-privileges:true
# nocheck: compose-environment-from-env-file the role's single env_file reaches all six services, so ADAPTER_BEARER would leak into prometheus, blackbox-exporter, alertmanager, cadvisor and node-exporter
environment:
ADAPTER_CONTRACT: {{ PROMETHEUS_MCP_CONTRACT | to_json | to_json | replace('$', '$$') }}
ADAPTER_BEARER: {{ PROMETHEUS_MCP_BEARER | to_json }}
@@ -78,7 +78,7 @@ x-environment: &shopware
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
{{ lookup('container_image', application_id, 'opensearch') }}
{{ lookup('compose_only', 'container_name', SHOPWARE_OPENSEARCH_CONTAINER) }}
environment:
environment: # nocheck: compose-environment-from-env-file opensearch is not the service the role's single env_file targets; the role sets no docker_compose_env, so env.j2 is shared by init/web/worker/scheduler too and these keys would leak into all four
- discovery.type=single-node
- plugins.security.disabled=true
- bootstrap.memory_lock=true
@@ -33,7 +33,7 @@
- ALL
security_opt:
- no-new-privileges:true
environment:
environment: # nocheck: compose-environment-from-env-file snipeitmcp overrides docker_compose_env with mcp/upstream.env, so the role's templates/env.j2 never reaches it and these keys would instead land in the primary snipe-it container, which must not receive ADAPTER_BEARER; ADAPTER_CONTRACT also carries compose $$ escaping that an env_file would not unescape
ADAPTER_CONTRACT: {{ SNIPEIT_MCP_CONTRACT | to_json | to_json | replace('$', '$$') }}
ADAPTER_BEARER: {{ SNIPEIT_MCP_BEARER | to_json }}
ADAPTER_PORT: "{{ SNIPEIT_MCP_PORT }}"
+1 -1
View File
@@ -131,7 +131,7 @@ build:
{{ lookup('compose_only', 'container_name', TAIGA_MANAGER_CONTAINER) }}
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
{{ lookup('container_image', application_id, 'manager', image='taigaio/taiga-back', version=TAIGA_VERSION) }}
environment:
environment: # nocheck: compose-environment-from-env-file manager-only key; this role sets no docker_compose_env, so its single env.j2 is the env_file of all nine services, and CELERY_ENABLED=False there would also reach the taiga back and async worker containers, which carry no such key today and run celery
CELERY_ENABLED: "False"
entrypoint: ["sleep", "infinity"]
{{ lookup('container_depends_on', application_id, extra={
+1 -1
View File
@@ -9,7 +9,7 @@
{{ lookup('compose_only', 'container_name', XWIKI_CONTAINER) }}
hostname: '{{ XWIKI_HOSTNAME}}'
{{ lookup('container_ports', ['xwiki', 'http', DOCKER_BIND_HOST]) | indent(4) }}
environment:
environment: # nocheck: compose-environment-from-env-file JAVA_OPTS follows xwiki_oidc_enabled_switch/xwiki_ldap_enabled_switch, and tasks/02_manager_ops.yml flips those for a second render of compose.yml.j2 alone; env.j2 renders once under tasks/00_core.yml with both pinned false, so an env-file JAVA_OPTS would freeze the standard-auth branch and OIDC/LDAP would never reach the container
JAVA_OPTS: >-
{% if xwiki_oidc_enabled_switch| bool %}
-Dxwiki.authentication.authservice=oidc
@@ -47,10 +47,6 @@
{{ lookup('container_image', application_id, 'search') }}
{{ lookup('compose_only', 'container_name', ZAMMAD_SEARCH_CONTAINER) }}
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- ES_JAVA_OPTS=-Xms512m -Xmx512m
{{ lookup('container_volumes', service_name) | indent(4) }}
{{ lookup('container_networks') | indent(4) }}
@@ -98,7 +94,7 @@
- ALL
security_opt:
- no-new-privileges:true
environment:
environment: # nocheck: compose-environment-from-env-file mcp-adapter-only keys; the shared env.j2 would leak ADAPTER_BEARER into every zammad service, and swarm does not unescape the $$ of ADAPTER_CONTRACT in an env file
ADAPTER_CONTRACT: {{ ZAMMAD_MCP_CONTRACT | to_json | to_json | replace('$', '$$') }}
ADAPTER_BEARER: {{ ZAMMAD_MCP_BEARER | to_json }}
ADAPTER_PORT: "{{ ZAMMAD_MCP_PORT }}"
+3
View File
@@ -16,6 +16,9 @@ ELASTICSEARCH_PORT={{ ZAMMAD_SEARCH_PORT }}
ELASTICSEARCH_SCHEMA=http
ELASTICSEARCH_NAMESPACE=zammad
ELASTICSEARCH_REINDEX=true
discovery.type=single-node
xpack.security.enabled=false
ES_JAVA_OPTS=-Xms512m -Xmx512m
RAILS_ENV=production
RAILS_LOG_TO_STDOUT=true
@@ -37,7 +37,7 @@
- ALL
security_opt:
- no-new-privileges:true
environment:
environment: # nocheck: compose-environment-from-env-file libretranslatemcp-only keys; this role sets no docker_compose_env, so its single templates/env.j2 is also the env_file of the primary libretranslate container, which must not receive ADAPTER_BEARER or ADAPTER_UPSTREAM_KEY
ADAPTER_CONTRACT: {{ LIBRETRANSLATE_MCP_CONTRACT | to_json | to_json | replace('$', '$$') }}
ADAPTER_BEARER: {{ LIBRETRANSLATE_MCP_BEARER | to_json }}
ADAPTER_PORT: "{{ LIBRETRANSLATE_MCP_PORT }}"
@@ -0,0 +1,169 @@
"""Lint guard: a compose template MUST NOT declare ``environment:`` inline.
A container's environment belongs in ``roles/<role>/templates/env.j2``. The
shared container base wires it in on its own: ``sys-svc-container``'s
base.yml.j2 emits ``env_file`` whenever the role ``has_env``, so a role that
ships ``env.j2`` needs no ``environment:`` key at all. 89 roles already carry
one.
An inline mapping in the compose template splits the same contract over two
files, and the half in the compose template is the half nobody greps when
they go looking for what a container is configured with. A secret written
there also lands in ``docker inspect`` output, where the env file does not.
Allowed:
* No ``environment:`` at all, with the values in ``templates/env.j2``.
* ``environment:`` whose value is on the same line, which is a variable or
an empty mapping rather than a declaration.
Forbidden:
* A block mapping of keys written inline under ``environment:``.
Suppress with a ``# nocheck: compose-environment-from-env-file`` marker on
the ``environment:`` line, on the contiguous comment lines above it, or in
the template's leading comment block. Use it where a key genuinely cannot
live in the env file, for example one a Jinja loop varies per service
instance.
"""
from __future__ import annotations
import re
import unittest
from dataclasses import dataclass
from utils.cache.files import read_text
from utils.roles.mapping import ROLE_FILE_TEMPL_COMPOSE
from . import PROJECT_ROOT
RULE = "compose-environment-from-env-file"
COMPOSE_GLOB = f"roles/*/{ROLE_FILE_TEMPL_COMPOSE}"
EXTRA_COMPOSE_GLOB = "roles/*/templates/*compose*.yml.j2"
_ENVIRONMENT_RE = re.compile(r"^(?P<indent>\s*)environment:(?P<rest>.*)$")
_NOCHECK_RE = re.compile(rf"#\s*nocheck:\s*{re.escape(RULE)}\b")
_COMMENT_RE = re.compile(r"#.*$")
@dataclass(frozen=True)
class Violation:
file: str
line_no: int
def _header(lines: list[str]) -> list[str]:
"""Return the file's leading comment block.
Args:
lines: the template's lines.
"""
header = []
for line in lines:
stripped = line.strip()
if not stripped:
header.append(line)
continue
if stripped.startswith(("#", "{#")):
header.append(line)
continue
break
return header
def _suppressed(lines: list[str], index: int) -> bool:
"""Whether a nocheck naming this rule covers the line at *index*."""
if _NOCHECK_RE.search(lines[index]):
return True
if any(_NOCHECK_RE.search(line) for line in _header(lines)):
return True
above = index - 1
while above >= 0 and lines[above].lstrip().startswith("#"):
if _NOCHECK_RE.search(lines[above]):
return True
above -= 1
return False
def _opens_block(lines: list[str], index: int, indent: str) -> bool:
"""Whether the ``environment:`` at *index* opens a block mapping."""
for line in lines[index + 1 :]:
if not line.strip() or line.lstrip().startswith(("#", "{#", "{%")):
continue
return len(line) - len(line.lstrip()) > len(indent)
return False
def _violations(path) -> list[Violation]:
rel = str(path.relative_to(PROJECT_ROOT))
lines = read_text(str(path)).splitlines()
found = []
for index, line in enumerate(lines):
match = _ENVIRONMENT_RE.match(line)
if not match:
continue
if _COMMENT_RE.sub("", match.group("rest")).strip():
continue
if not _opens_block(lines, index, match.group("indent")):
continue
if _suppressed(lines, index):
continue
found.append(Violation(rel, index + 1))
return found
class TestComposeEnvironmentFromEnvFile(unittest.TestCase):
def test_compose_templates_keep_their_environment_in_env_j2(self) -> None:
paths = set(PROJECT_ROOT.glob(COMPOSE_GLOB)) | set(
PROJECT_ROOT.glob(EXTRA_COMPOSE_GLOB)
)
offenders: list[Violation] = []
for path in sorted(paths):
offenders.extend(_violations(path))
report = "\n".join(f" - {v.file}:{v.line_no}" for v in offenders)
self.assertFalse(
offenders,
f"{len(offenders)} compose template(s) declare environment: inline "
"instead of keeping it in templates/env.j2:\n"
f"{report}\n\n"
"Move the keys to roles/<role>/templates/env.j2. The container base "
"emits env_file for a role that has one, so the compose template "
"needs no environment: key.\n"
f"Suppress with `# nocheck: {RULE}` where a key cannot live there.",
)
class TestSuppression(unittest.TestCase):
MARKER = f"# nocheck: {RULE} reason"
def test_a_marker_on_the_environment_line_suppresses_it(self) -> None:
lines = ["services:", " a:", f" environment: {self.MARKER}", " K: v"]
self.assertTrue(_suppressed(lines, 2))
def test_a_marker_in_the_leading_comment_block_suppresses_the_file(self) -> None:
lines = [f"{{# {self.MARKER} #}}", "services:", " a:", " environment:"]
self.assertTrue(_suppressed(lines, 3))
def test_a_marker_below_the_leading_block_covers_only_its_own_line(self) -> None:
lines = [
"services:",
" a:",
f" environment: {self.MARKER}",
" K: v",
" b:",
" environment:",
" K: v",
]
self.assertFalse(
_suppressed(lines, 5),
"a marker written for one service must not silence a later block",
)
if __name__ == "__main__":
unittest.main()