fix(sys-svc-compose-ca): escape only the image's argv in the CA override

render_override takes a service's entrypoint and command from the
output of `docker compose config`, which already prints a literal `$`
as `$$`, and the image's Entrypoint and Cmd from `docker image inspect`,
which are raw. It escaped the combined argv once more, so a service
command reading a variable at runtime (`$$REDIS_PASSWORD` in
compose.yml) reached the override as `$$$$` and the container as `$$`,
which the shell expands to its PID. redis then required
"<pid>REDIS_PASSWORD" and every authenticated ping failed with
WRONGPASS.

Only the image-derived parts are escaped now; the service's own argv
is written as compose config printed it. A new test keeps a service
command's `$$` intact next to an image entrypoint; the existing tests
still require an image Cmd's `$VAR` to become `$$VAR`.

Local Nextcloud update deploy: the redis override holds `$$`, the
container Cmd `$REDIS_PASSWORD`, and the authenticated ping answers
PONG on the first attempt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-28 12:50:26 +01:00
1 parent 82a695257f
commit e53a8c7d75
2 files changed
+38 -6

No files matched your search

@@ -211,8 +211,9 @@ def escape_compose_vars(argv: list[str]) -> list[str]:
Compose escaping rule:
- $$ => literal $ (no interpolation)
Therefore: replace every "$" with "$$" in exec-form argv strings that we write into
compose override YAML (entrypoint/command).
Therefore: replace every "$" with "$$" in the image's exec-form argv before it is
written into compose override YAML; a service's own argv comes from `compose config`,
which already prints a literal "$" as "$$".
"""
return [s.replace("$", "$$") for s in argv]
@@ -581,7 +582,7 @@ def render_override(
IMPORTANT:
Docker Compose interpolates $VARS in YAML strings on the host.
We escape any $ in the command argv with $$ so container-side expansion works.
We escape any $ in the image's argv with $$ so container-side expansion works.
"""
out_services: dict[str, Any] = {}
extra_ca_host = _cache_frontend_ca_host()
@@ -677,8 +678,8 @@ def render_override(
img_ep = normalize_entrypoint(raw_ep)
img_cmd = normalize_cmd(raw_cmd)
final_ep = svc_ep or img_ep
final_cmd = svc_cmd or img_cmd
final_ep = svc_ep or escape_compose_vars(img_ep)
final_cmd = svc_cmd or escape_compose_vars(img_cmd)
if _is_shell_form(final_ep):
final_cmd = [_shell_payload(final_cmd)]
@@ -687,7 +688,7 @@ def render_override(
if has_sh and effective_cmd:
override_svc["entrypoint"] = [wrapper_container]
override_svc["command"] = escape_compose_vars(effective_cmd)
override_svc["command"] = effective_cmd
out_services[name] = override_svc
@@ -978,6 +978,37 @@ class TestComposeCaInject(unittest.TestCase):
["sh", "-lc", 'exec "$$CHESS_ENTRYPOINT_INT"'],
)
def test_render_override_keeps_the_compose_config_escape_of_a_service_command(self):
services = {
"svc": {
"image": "img:1",
"command": ["sh", "-c", 'exec server --pass "$$SERVER_PASSWORD"'],
}
}
service_to_cmd = {"svc": ["docker", "compose", "-p", "p", "-f", "compose.yml"]}
with patch.object(
self.m,
"gather_image_meta",
return_value={"img:1": (True, ["/entry.sh"], ["serve"], True)},
):
doc = self.m.render_override(
services,
service_to_cmd,
cwd=Path("/tmp"),
env={},
ca_host="/host/ca.crt",
wrapper_host="/host/with-ca-trust.sh",
ca_container=CA_CERT_CONTAINER,
wrapper_container=CA_WRAPPER_CONTAINER,
trust_name="infinito.local",
)
self.assertEqual(
doc["services"]["svc"].get("command"),
["/entry.sh", "sh", "-c", 'exec server --pass "$$SERVER_PASSWORD"'],
)
def test_render_override_points_the_replacing_vars_at_the_bundle(self):
"""A host that has a CA bundle gets the verifying vars pointed at it.