mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-07 19:32:17 +00:00
SSL_CERT_FILE, CURL_CA_BUNDLE and REQUESTS_CA_BUNDLE replace the trust store;
only NODE_EXTRA_CA_CERTS extends it. compose_ca.py pointed all four at the
deployment own root CA, so every OpenSSL and rustls client in every container
was blind to every public certificate. Measured here, one variable, same URL:
unset curl https://registry.npmjs.org/ -> 200
a single unrelated CA -> 000 unable to get local issuer certificate
that CA + the system bundle -> 200
PeerTube was the one process that hit it: its plugin install fetches from
registry.npmjs.org at runtime through a client that honours SSL_CERT_FILE, and
it failed identically in compose and in swarm with
invalid peer certificate: UnknownIssuer
Everything else reaches its artefacts at image build time, or through Node,
whose NODE_EXTRA_CA_CERTS is additive and therefore kept working.
The replacement was never a posture. e7e8cf8c03 calls these variables an
"env-based CA trust fallback", f1ea036bd2 adds them "instead of falling back to
a plain docker run without CA trust", e79183f96a wires the extra CA in "so npm
traffic through the cache validates". Every commit framed them as adding trust.
sys-ca-selfsigned now builds ca-bundle.crt beside the root CA on every node,
from that node own files: the stack host mints the CA and workers fetch it, so
there is no cross-node dependency. The bundle is the root CA concatenated with
the first present CA_BUNDLE_CANDIDATES entry, the SPOT fc0881ff33 introduced.
The cat argument list is assembled in Jinja, so a host with no public bundle
emits the root CA alone, which is exactly the previous behaviour, rather than an
empty argument that would fail. bundle_host joins the existing stray-directory
sweep and the build runs after it.
compose_ca.py uses the bundle only when the file exists and is non-empty, so a
host that has not built one keeps the previous behaviour instead of binding a
missing path that Docker would materialise as a directory. The php ini, msmtprc
and the Matrix flavour may name the container path with no fallback because the
bundle is written at task 99 and the php ini at task 123: no host can carry the
ini without the bundle.
CA_TRUST_CERT and NODE_EXTRA_CA_CERTS stay on the single certificate. keytool,
occ security:certificates:import and update-ca-certificates each take one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1098 lines
41 KiB
Python
1098 lines
41 KiB
Python
# tests/unit/python/roles/sys-svc-compose-ca/files/test_compose_ca_inject.py
|
|
import importlib.util
|
|
import json
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from utils.cache.yaml import load_yaml, load_yaml_str
|
|
|
|
from . import PROJECT_ROOT
|
|
|
|
_CA_TRUST = load_yaml(str(PROJECT_ROOT / "group_vars" / "all" / "02_tls.yml"))[
|
|
"CA_TRUST"
|
|
]
|
|
CA_CERT_CONTAINER = _CA_TRUST["inject_cert_container"]
|
|
CA_WRAPPER_CONTAINER = _CA_TRUST["inject_wrapper_container"]
|
|
|
|
|
|
def _load_module(rel_path: str, name: str):
|
|
path = PROJECT_ROOT / rel_path
|
|
spec = importlib.util.spec_from_file_location(name, str(path))
|
|
mod = importlib.util.module_from_spec(spec)
|
|
assert spec and spec.loader
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
class TestComposeCaInject(unittest.TestCase):
|
|
def setUp(self):
|
|
self.m = _load_module(
|
|
"roles/sys-svc-compose-ca/files/python/compose_ca.py",
|
|
"compose_ca_inject_mod",
|
|
)
|
|
|
|
def test_normalize_cmd(self):
|
|
self.assertEqual(self.m.normalize_cmd(["a", "b"]), ["a", "b"])
|
|
self.assertEqual(self.m.normalize_cmd("echo hi"), ["/bin/sh", "-lc", "echo hi"])
|
|
self.assertEqual(
|
|
self.m.normalize_cmd(["/bin/sh", "-c", "if", "!", "grep", "foo"]),
|
|
["/bin/sh", "-c", "if ! grep foo"],
|
|
)
|
|
self.assertEqual(
|
|
self.m.normalize_cmd(
|
|
[
|
|
"/bin/sh",
|
|
"-c",
|
|
"if",
|
|
"!",
|
|
"grep",
|
|
"-qF",
|
|
"INFINITO_TAIGA_AUTH_CONFIG", # nocheck: test-fixture
|
|
"/taiga-back/settings/config.py",
|
|
";",
|
|
"then",
|
|
]
|
|
),
|
|
[
|
|
"/bin/sh",
|
|
"-c",
|
|
"if ! grep -qF INFINITO_TAIGA_AUTH_CONFIG /taiga-back/settings/config.py ; then",
|
|
],
|
|
)
|
|
self.assertEqual(self.m.normalize_cmd(None), [])
|
|
with self.assertRaises(SystemExit):
|
|
self.m.normalize_cmd(123)
|
|
|
|
def test_shell_payload_single_string_preserved(self):
|
|
self.assertEqual(
|
|
self.m._shell_payload(
|
|
[
|
|
"if ! grep -qF INFINITO_TAIGA_AUTH_CONFIG /taiga-back/settings/config.py; then cat /taiga-back/settings/config.append.py >> /taiga-back/settings/config.py; fi; exec /taiga-back/docker/entrypoint.sh"
|
|
]
|
|
),
|
|
"if ! grep -qF INFINITO_TAIGA_AUTH_CONFIG /taiga-back/settings/config.py; then cat /taiga-back/settings/config.append.py >> /taiga-back/settings/config.py; fi; exec /taiga-back/docker/entrypoint.sh",
|
|
)
|
|
|
|
def test_normalize_entrypoint(self):
|
|
self.assertEqual(self.m.normalize_entrypoint(["a", "b"]), ["a", "b"])
|
|
self.assertEqual(
|
|
self.m.normalize_entrypoint("echo hi"), ["/bin/sh", "-lc", "echo hi"]
|
|
)
|
|
self.assertEqual(
|
|
self.m.normalize_entrypoint(["/bin/sh", "-c", "echo", "hi"]),
|
|
["/bin/sh", "-c", "echo hi"],
|
|
)
|
|
self.assertEqual(self.m.normalize_entrypoint(None), [])
|
|
with self.assertRaises(SystemExit):
|
|
self.m.normalize_entrypoint(123)
|
|
|
|
def test_parse_yaml_requires_mapping(self):
|
|
doc = self.m.parse_yaml("a: 1\n", "x")
|
|
self.assertEqual(doc["a"], 1)
|
|
|
|
with self.assertRaises(SystemExit):
|
|
self.m.parse_yaml("- 1\n- 2\n", "x")
|
|
|
|
def test_has_build(self):
|
|
self.assertTrue(self.m._has_build({"build": {"context": "."}}))
|
|
self.assertTrue(self.m._has_build({"build": "./"}))
|
|
self.assertFalse(self.m._has_build({}))
|
|
self.assertFalse(self.m._has_build({"build": None}))
|
|
|
|
def test_find_builder_service_for_image(self):
|
|
services = {
|
|
"app": {"image": "custom:1", "build": {"context": "."}},
|
|
"worker": {"image": "custom:1"},
|
|
"other": {"image": "other:2", "build": {"context": "."}},
|
|
}
|
|
self.assertEqual(
|
|
self.m._find_builder_service_for_image(image="custom:1", services=services),
|
|
"app",
|
|
)
|
|
self.assertEqual(
|
|
self.m._find_builder_service_for_image(
|
|
image="missing:tag", services=services
|
|
),
|
|
"",
|
|
)
|
|
self.assertEqual(
|
|
self.m._find_builder_service_for_image(image="", services=services),
|
|
"",
|
|
)
|
|
|
|
def test_ensure_image_available_builds_self_when_build_present(self):
|
|
"""
|
|
If the service has build:, ensure_image_available should run
|
|
`compose ... build <service>` (not pull).
|
|
"""
|
|
calls = []
|
|
|
|
def fake_run(cmd, *, cwd, env, timeout=None, capture=True):
|
|
calls.append(cmd)
|
|
|
|
if cmd[:3] == ["docker", "image", "inspect"]:
|
|
inspect_calls = [
|
|
c for c in calls if c[:3] == ["docker", "image", "inspect"]
|
|
]
|
|
if len(inspect_calls) == 1:
|
|
return 1, "", "no such image"
|
|
return 0, json.dumps([{"Config": {"Entrypoint": [], "Cmd": []}}]), ""
|
|
|
|
if cmd[:2] == ["docker", "compose"] and cmd[-2:] == ["build", "app"]:
|
|
return 0, "built", ""
|
|
|
|
return 1, "", "unexpected"
|
|
|
|
base_cmd = ["docker", "compose", "-p", "p", "-f", "compose.yml"]
|
|
|
|
with patch.object(self.m, "run", side_effect=fake_run):
|
|
self.m.ensure_image_available(
|
|
service_name="app",
|
|
svc={"image": "custom:1", "build": {"context": "."}},
|
|
image="custom:1",
|
|
services={"app": {"image": "custom:1", "build": {"context": "."}}},
|
|
service_to_compose_cmd={"app": base_cmd},
|
|
compose_base_cmd=base_cmd,
|
|
cwd=Path("/tmp"),
|
|
env={},
|
|
)
|
|
|
|
self.assertTrue(any(c[-2:] == ["build", "app"] for c in calls))
|
|
self.assertFalse(any(c[-2:] == ["pull", "app"] for c in calls))
|
|
|
|
def test_ensure_image_available_builds_builder_for_shared_image(self):
|
|
"""
|
|
New robust logic:
|
|
- 'worker' has no build but references image 'custom:1'
|
|
- 'app' has build and same image 'custom:1'
|
|
=> should run `compose ... build app` and not pull worker.
|
|
"""
|
|
calls = []
|
|
|
|
services = {
|
|
"app": {"image": "custom:1", "build": {"context": "."}},
|
|
"worker": {"image": "custom:1"},
|
|
}
|
|
|
|
base_cmd = ["docker", "compose", "-p", "p", "-f", "compose.yml"]
|
|
service_to_cmd = {"app": base_cmd, "worker": base_cmd}
|
|
|
|
def fake_run(cmd, *, cwd, env, timeout=None, capture=True):
|
|
calls.append(cmd)
|
|
|
|
if cmd[:3] == ["docker", "image", "inspect"]:
|
|
inspect_calls = [
|
|
c for c in calls if c[:3] == ["docker", "image", "inspect"]
|
|
]
|
|
if len(inspect_calls) == 1:
|
|
return 1, "", "no such image"
|
|
return 0, json.dumps([{"Config": {"Entrypoint": [], "Cmd": []}}]), ""
|
|
|
|
if cmd[:2] == ["docker", "compose"] and cmd[-2:] == ["build", "app"]:
|
|
return 0, "built", ""
|
|
|
|
if cmd[:2] == ["docker", "compose"] and cmd[-2:] == ["pull", "worker"]:
|
|
return 0, "pulled", ""
|
|
|
|
return 1, "", "unexpected"
|
|
|
|
with patch.object(self.m, "run", side_effect=fake_run):
|
|
self.m.ensure_image_available(
|
|
service_name="worker",
|
|
svc=services["worker"],
|
|
image="custom:1",
|
|
services=services,
|
|
service_to_compose_cmd=service_to_cmd,
|
|
compose_base_cmd=base_cmd,
|
|
cwd=Path("/tmp"),
|
|
env={},
|
|
)
|
|
|
|
self.assertTrue(any(c[-2:] == ["build", "app"] for c in calls))
|
|
self.assertFalse(any(c[-2:] == ["pull", "worker"] for c in calls))
|
|
|
|
def test_ensure_image_available_falls_back_to_pull_when_no_builder(self):
|
|
"""
|
|
If there is no builder service for an image, fall back to pull(service).
|
|
"""
|
|
calls = []
|
|
|
|
services = {"worker": {"image": "registry.example/worker:1"}}
|
|
base_cmd = ["docker", "compose", "-p", "p", "-f", "compose.yml"]
|
|
service_to_cmd = {"worker": base_cmd}
|
|
|
|
def fake_run(cmd, *, cwd, env, timeout=None, capture=True):
|
|
calls.append(cmd)
|
|
|
|
if cmd[:3] == ["docker", "image", "inspect"]:
|
|
inspect_calls = [
|
|
c for c in calls if c[:3] == ["docker", "image", "inspect"]
|
|
]
|
|
if len(inspect_calls) == 1:
|
|
return 1, "", "no such image"
|
|
return 0, json.dumps([{"Config": {"Entrypoint": [], "Cmd": []}}]), ""
|
|
|
|
if cmd[:2] == ["docker", "compose"] and cmd[-2:] == ["pull", "worker"]:
|
|
return 0, "pulled", ""
|
|
|
|
return 1, "", "unexpected"
|
|
|
|
with patch.object(self.m, "run", side_effect=fake_run):
|
|
self.m.ensure_image_available(
|
|
service_name="worker",
|
|
svc=services["worker"],
|
|
image=services["worker"]["image"],
|
|
services=services,
|
|
service_to_compose_cmd=service_to_cmd,
|
|
compose_base_cmd=base_cmd,
|
|
cwd=Path("/tmp"),
|
|
env={},
|
|
)
|
|
|
|
self.assertTrue(any(c[-2:] == ["pull", "worker"] for c in calls))
|
|
|
|
@patch.object(Path, "exists", autospec=True, return_value=True)
|
|
@patch.object(Path, "is_dir", autospec=True, return_value=True)
|
|
@patch.object(Path, "read_text", autospec=True)
|
|
@patch.object(Path, "write_text", autospec=True)
|
|
@patch.object(Path, "mkdir", autospec=True)
|
|
def test_main_generates_override(
|
|
self, _mkdir, _write_text, _read_text, _is_dir, _exists
|
|
):
|
|
"""
|
|
main():
|
|
- parses compose files to discover profiles
|
|
- runs `compose ... config`
|
|
- inspects images via `container image inspect`
|
|
- writes override with CA_TRUST_* envs
|
|
"""
|
|
_read_text.return_value = "services:\n app:\n image: myimage:latest\n"
|
|
|
|
def fake_run(cmd, *, cwd, env, timeout=None, capture=True):
|
|
if (
|
|
len(cmd) >= 3
|
|
and cmd[0:2] == ["docker", "compose"]
|
|
and cmd[-1] == "config"
|
|
):
|
|
yml = "services:\n app:\n image: myimage:latest\n"
|
|
return 0, yml, ""
|
|
|
|
if cmd[:3] == ["docker", "image", "inspect"]:
|
|
json_out = json.dumps(
|
|
[{"Config": {"Entrypoint": ["/entry"], "Cmd": ["run"]}}]
|
|
)
|
|
return 0, json_out, ""
|
|
|
|
return 1, "", "unexpected"
|
|
|
|
with patch.object(self.m, "run", side_effect=fake_run):
|
|
argv = [
|
|
"compose_ca.py",
|
|
"--chdir",
|
|
"/tmp/app",
|
|
"--project",
|
|
"p",
|
|
"--compose-files",
|
|
"-f compose.yml -f compose.override.yml",
|
|
"--out",
|
|
"compose.ca.override.yml",
|
|
"--ca-host",
|
|
"/etc/infinito/ca/root-ca.crt",
|
|
"--wrapper-host",
|
|
"/etc/infinito/bin/with-ca-trust.sh",
|
|
"--ca-container",
|
|
CA_CERT_CONTAINER,
|
|
"--wrapper-container",
|
|
CA_WRAPPER_CONTAINER,
|
|
"--trust-name",
|
|
"infinito.local",
|
|
]
|
|
with patch("sys.argv", argv):
|
|
rc = self.m.main()
|
|
|
|
self.assertEqual(rc, 0)
|
|
self.assertTrue(_write_text.called)
|
|
|
|
args, _kwargs = _write_text.call_args
|
|
written = args[1] if len(args) > 1 else ""
|
|
self.assertIn("CA_TRUST_NAME", written)
|
|
self.assertIn("infinito.local", written)
|
|
|
|
@patch.object(Path, "exists", autospec=True, return_value=True)
|
|
@patch.object(Path, "is_dir", autospec=True, return_value=True)
|
|
@patch.object(Path, "read_text", autospec=True)
|
|
@patch.object(Path, "write_text", autospec=True)
|
|
@patch.object(Path, "mkdir", autospec=True)
|
|
def test_main_empty_services_writes_noop_override(
|
|
self, _mkdir, _write_text, _read_text, _is_dir, _exists
|
|
):
|
|
"""main(): an app whose merged compose has no services (a launcher-based
|
|
app such as discourse with all backends shared) writes an empty override
|
|
and exits 0 instead of dying with 'No services found'."""
|
|
_read_text.return_value = "services: {}\n"
|
|
|
|
def fake_run(cmd, *, cwd, env):
|
|
if (
|
|
len(cmd) >= 3
|
|
and cmd[0:2] == ["docker", "compose"]
|
|
and cmd[-1] == "config"
|
|
):
|
|
return 0, "services: {}\n", ""
|
|
return 1, "", "unexpected"
|
|
|
|
with patch.object(self.m, "run", side_effect=fake_run):
|
|
argv = [
|
|
"compose_ca.py",
|
|
"--chdir",
|
|
"/tmp/app",
|
|
"--project",
|
|
"p",
|
|
"--compose-files",
|
|
"-f compose.yml",
|
|
"--out",
|
|
"compose.ca.override.yml",
|
|
"--ca-host",
|
|
"/etc/infinito/ca/root-ca.crt",
|
|
"--wrapper-host",
|
|
"/etc/infinito/bin/with-ca-trust.sh",
|
|
"--ca-container",
|
|
CA_CERT_CONTAINER,
|
|
"--wrapper-container",
|
|
CA_WRAPPER_CONTAINER,
|
|
"--trust-name",
|
|
"infinito.local",
|
|
]
|
|
with patch("sys.argv", argv):
|
|
rc = self.m.main()
|
|
|
|
self.assertEqual(rc, 0)
|
|
self.assertTrue(_write_text.called)
|
|
args, _kwargs = _write_text.call_args
|
|
written = args[1] if len(args) > 1 else ""
|
|
self.assertIn("services", written)
|
|
self.assertNotIn("CA_TRUST_NAME", written)
|
|
|
|
@patch.object(Path, "exists", autospec=True, return_value=True)
|
|
@patch.object(Path, "is_dir", autospec=True, return_value=True)
|
|
def test_main_requires_trust_name(self, _is_dir, _exists):
|
|
argv = [
|
|
"compose_ca.py",
|
|
"--chdir",
|
|
"/tmp/app",
|
|
"--project",
|
|
"p",
|
|
"--compose-files",
|
|
"-f compose.yml",
|
|
"--out",
|
|
"compose.ca.override.yml",
|
|
"--ca-host",
|
|
"/etc/infinito/ca/root-ca.crt",
|
|
"--wrapper-host",
|
|
"/etc/infinito/bin/with-ca-trust.sh",
|
|
"--ca-container",
|
|
CA_CERT_CONTAINER,
|
|
"--wrapper-container",
|
|
CA_WRAPPER_CONTAINER,
|
|
]
|
|
with patch("sys.argv", argv), self.assertRaises(SystemExit):
|
|
self.m.main()
|
|
|
|
def test_docker_image_has_bin_sh_true(self):
|
|
"""
|
|
docker_image_has_bin_sh(): returns True when container run succeeds.
|
|
"""
|
|
|
|
def fake_run(cmd, *, cwd, env, timeout=None, capture=True):
|
|
self.assertEqual(
|
|
cmd[:6],
|
|
["docker", "run", "--rm", "--entrypoint", "/bin/sh", "img:1"],
|
|
)
|
|
self.assertEqual(cmd[6:], ["-c", "exit 0"])
|
|
return 0, "", ""
|
|
|
|
with patch.object(self.m, "run", side_effect=fake_run):
|
|
ok = self.m.docker_image_has_bin_sh("img:1", cwd=Path("/tmp"), env={})
|
|
self.assertTrue(ok)
|
|
|
|
def test_docker_image_has_bin_sh_false(self):
|
|
"""
|
|
docker_image_has_bin_sh(): returns False when container run fails (distroless-like).
|
|
"""
|
|
|
|
def fake_run(cmd, *, cwd, env, timeout=None, capture=True):
|
|
return 1, "", 'exec: "/bin/sh": stat /bin/sh: no such file or directory'
|
|
|
|
with patch.object(self.m, "run", side_effect=fake_run):
|
|
ok = self.m.docker_image_has_bin_sh("img:1", cwd=Path("/tmp"), env={})
|
|
self.assertFalse(ok)
|
|
|
|
def test_docker_image_has_bin_sh_ambiguous_on_timeout(self):
|
|
def fake_run(cmd, *, cwd, env, timeout=None, capture=True):
|
|
return self.m.TIMEOUT_RC, "", "timed out after 60s"
|
|
|
|
with patch.object(self.m, "run", side_effect=fake_run):
|
|
state = self.m.docker_image_has_bin_sh("img:1", cwd=Path("/tmp"), env={})
|
|
self.assertIsNone(state)
|
|
|
|
def test_render_override_wraps_when_probe_ambiguous(self):
|
|
services = {"svc": {"image": "img:1"}}
|
|
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"], ["run"], None)},
|
|
):
|
|
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",
|
|
)
|
|
|
|
out = doc["services"]["svc"]
|
|
self.assertEqual(out.get("entrypoint"), [CA_WRAPPER_CONTAINER])
|
|
self.assertEqual(out.get("command"), ["/entry", "run"])
|
|
|
|
def test_render_override_wraps_when_inspect_ambiguous(self):
|
|
services = {
|
|
"svc": {
|
|
"image": "img:1",
|
|
"entrypoint": ["/svc-entry"],
|
|
"command": ["svc-run"],
|
|
}
|
|
}
|
|
service_to_cmd = {"svc": ["docker", "compose", "-p", "p", "-f", "compose.yml"]}
|
|
|
|
with patch.object(
|
|
self.m,
|
|
"gather_image_meta",
|
|
return_value={"img:1": (False, [], [], None)},
|
|
):
|
|
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",
|
|
)
|
|
|
|
out = doc["services"]["svc"]
|
|
self.assertEqual(out.get("entrypoint"), [CA_WRAPPER_CONTAINER])
|
|
self.assertEqual(out.get("command"), ["/svc-entry", "svc-run"])
|
|
|
|
def test_render_override_env_only_when_probe_clean_negative(self):
|
|
services = {"svc": {"image": "img:1"}}
|
|
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"], ["run"], False)},
|
|
):
|
|
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",
|
|
)
|
|
|
|
out = doc["services"]["svc"]
|
|
self.assertNotIn("entrypoint", out)
|
|
self.assertNotIn("command", out)
|
|
self.assertIn("environment", out)
|
|
self.assertIn("volumes", out)
|
|
|
|
def test_gather_one_image_inspect_timeout_is_ambiguous(self):
|
|
def fake_run(cmd, *, cwd, env, timeout=None, capture=True):
|
|
if cmd[:3] == ["docker", "image", "inspect"]:
|
|
return self.m.TIMEOUT_RC, "", "timed out after 90s"
|
|
return 1, "", "unexpected"
|
|
|
|
with patch.object(self.m, "run", side_effect=fake_run):
|
|
meta = self.m._gather_one_image("img:1", cwd=Path("/tmp"), env={})
|
|
|
|
self.assertEqual(meta, (False, [], [], None))
|
|
|
|
def test_gather_one_image_inspect_clean_negative_no_shell(self):
|
|
def fake_run(cmd, *, cwd, env, timeout=None, capture=True):
|
|
if cmd[:3] == ["docker", "image", "inspect"]:
|
|
return 1, "", "no such image"
|
|
return 1, "", "unexpected"
|
|
|
|
with patch.object(self.m, "run", side_effect=fake_run):
|
|
meta = self.m._gather_one_image("img:1", cwd=Path("/tmp"), env={})
|
|
|
|
self.assertEqual(meta, (False, [], [], False))
|
|
|
|
def test_render_override_sets_env_and_mounts_always(self):
|
|
"""
|
|
render_override(): must always set CA env vars + fallback envs and always mount CA+wrapper.
|
|
This must hold even for images without /bin/sh (no entrypoint override in that case).
|
|
"""
|
|
services = {"svc": {"image": "img:1"}}
|
|
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"], ["run"], False)},
|
|
):
|
|
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.assertIn("services", doc)
|
|
self.assertIn("svc", doc["services"])
|
|
out = doc["services"]["svc"]
|
|
|
|
self.assertIn("volumes", out)
|
|
self.assertIn(f"/host/ca.crt:{CA_CERT_CONTAINER}:ro", out["volumes"])
|
|
self.assertIn(
|
|
f"/host/with-ca-trust.sh:{CA_WRAPPER_CONTAINER}:ro",
|
|
out["volumes"],
|
|
)
|
|
|
|
env = out.get("environment", {})
|
|
self.assertEqual(env.get("CA_TRUST_CERT"), CA_CERT_CONTAINER)
|
|
self.assertEqual(env.get("CA_TRUST_NAME"), "infinito.local")
|
|
self.assertEqual(env.get("SSL_CERT_FILE"), CA_CERT_CONTAINER)
|
|
self.assertEqual(env.get("REQUESTS_CA_BUNDLE"), CA_CERT_CONTAINER)
|
|
self.assertEqual(env.get("CURL_CA_BUNDLE"), CA_CERT_CONTAINER)
|
|
self.assertEqual(env.get("NODE_EXTRA_CA_CERTS"), CA_CERT_CONTAINER)
|
|
|
|
self.assertNotIn("entrypoint", out)
|
|
self.assertNotIn("command", out)
|
|
|
|
def test_render_override_wraps_when_sh_exists(self):
|
|
"""
|
|
render_override(): when /bin/sh exists, it must override entrypoint to wrapper
|
|
and set command to the effective command (final entrypoint + final cmd).
|
|
"""
|
|
services = {"svc": {"image": "img:1"}}
|
|
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"], ["run", "--flag"], 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",
|
|
)
|
|
|
|
out = doc["services"]["svc"]
|
|
self.assertEqual(out.get("entrypoint"), [CA_WRAPPER_CONTAINER])
|
|
self.assertEqual(out.get("command"), ["/entry", "run", "--flag"])
|
|
|
|
def test_render_override_uses_service_entrypoint_command_over_image(self):
|
|
"""
|
|
If the composed config explicitly sets entrypoint/command, those must win over image defaults.
|
|
effective_cmd = svc_entrypoint + svc_command
|
|
"""
|
|
services = {
|
|
"svc": {
|
|
"image": "img:1",
|
|
"entrypoint": ["/svc-entry"],
|
|
"command": ["svc-run", "x"],
|
|
}
|
|
}
|
|
service_to_cmd = {"svc": ["docker", "compose", "-p", "p", "-f", "compose.yml"]}
|
|
|
|
with patch.object(
|
|
self.m,
|
|
"gather_image_meta",
|
|
return_value={"img:1": (True, ["/img-entry"], ["img-run"], 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",
|
|
)
|
|
|
|
out = doc["services"]["svc"]
|
|
self.assertEqual(out["command"], ["/svc-entry", "svc-run", "x"])
|
|
|
|
def test_render_override_collapses_shell_command_when_entrypoint_is_shell(self):
|
|
"""
|
|
If the composed service already uses a shell entrypoint, the command must stay
|
|
a single shell payload so /bin/sh -c receives exactly one argument.
|
|
"""
|
|
shell_tokens = [
|
|
"if",
|
|
"!",
|
|
"grep",
|
|
"-qF",
|
|
"INFINITO_TAIGA_AUTH_CONFIG", # nocheck: test-fixture
|
|
"/taiga-back/settings/config.py",
|
|
";",
|
|
"then",
|
|
"cat",
|
|
"/taiga-back/settings/config.append.py",
|
|
">>",
|
|
"/taiga-back/settings/config.py",
|
|
";",
|
|
"fi",
|
|
";",
|
|
"exec",
|
|
"/taiga-back/docker/entrypoint.sh",
|
|
]
|
|
services = {
|
|
"svc": {
|
|
"image": "img:1",
|
|
"entrypoint": ["/bin/sh", "-c"],
|
|
"command": shell_tokens,
|
|
}
|
|
}
|
|
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"], ["run"], 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",
|
|
)
|
|
|
|
out = doc["services"]["svc"]
|
|
self.assertEqual(out.get("entrypoint"), [CA_WRAPPER_CONTAINER])
|
|
self.assertEqual(out.get("command")[:2], ["/bin/sh", "-c"])
|
|
self.assertEqual(
|
|
out.get("command")[2],
|
|
"if ! grep -qF INFINITO_TAIGA_AUTH_CONFIG /taiga-back/settings/config.py ; then cat /taiga-back/settings/config.append.py >> /taiga-back/settings/config.py ; fi ; exec /taiga-back/docker/entrypoint.sh",
|
|
)
|
|
|
|
def test_render_override_gathers_each_unique_image_once(self):
|
|
"""Image metadata is gathered once per unique image, not per service."""
|
|
services = {
|
|
"a": {"image": "img:1"},
|
|
"b": {"image": "img:1"},
|
|
}
|
|
service_to_cmd = {
|
|
"a": ["docker", "compose", "-p", "p", "-f", "compose.yml"],
|
|
"b": ["docker", "compose", "-p", "p", "-f", "compose.yml"],
|
|
}
|
|
|
|
with patch.object(
|
|
self.m,
|
|
"gather_image_meta",
|
|
return_value={"img:1": (True, ["/entry"], ["run"], True)},
|
|
) as p_gather:
|
|
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",
|
|
)
|
|
|
|
p_gather.assert_called_once()
|
|
self.assertEqual(p_gather.call_args.args[0], ["img:1"])
|
|
|
|
def test_main_generates_override_includes_entrypoint_when_sh_exists(self):
|
|
"""
|
|
main(): when /bin/sh exists for the image, the written override should include entrypoint wrapper.
|
|
"""
|
|
with (
|
|
patch.object(Path, "exists", autospec=True, return_value=True),
|
|
patch.object(Path, "is_dir", autospec=True, return_value=True),
|
|
patch.object(
|
|
Path,
|
|
"read_text",
|
|
autospec=True,
|
|
return_value="services:\n app:\n image: myimage:latest\n",
|
|
),
|
|
patch.object(Path, "mkdir", autospec=True),
|
|
patch.object(Path, "write_text", autospec=True) as p_write,
|
|
):
|
|
|
|
def fake_run(cmd, *, cwd, env, timeout=None, capture=True):
|
|
if (
|
|
len(cmd) >= 3
|
|
and cmd[0:2] == ["docker", "compose"]
|
|
and cmd[-1] == "config"
|
|
):
|
|
yml = "services:\n app:\n image: myimage:latest\n"
|
|
return 0, yml, ""
|
|
|
|
if cmd[:3] == ["docker", "image", "inspect"]:
|
|
json_out = json.dumps(
|
|
[{"Config": {"Entrypoint": ["/entry"], "Cmd": ["run"]}}]
|
|
)
|
|
return 0, json_out, ""
|
|
|
|
if cmd[:6] == [
|
|
"docker",
|
|
"run",
|
|
"--rm",
|
|
"--entrypoint",
|
|
"/bin/sh",
|
|
"myimage:latest",
|
|
]:
|
|
return 0, "", ""
|
|
|
|
return 1, "", "unexpected"
|
|
|
|
with patch.object(self.m, "run", side_effect=fake_run):
|
|
argv = [
|
|
"compose_ca.py",
|
|
"--chdir",
|
|
"/tmp/app",
|
|
"--project",
|
|
"p",
|
|
"--compose-files",
|
|
"-f compose.yml -f compose.override.yml",
|
|
"--out",
|
|
"compose.ca.override.yml",
|
|
"--ca-host",
|
|
"/etc/infinito/ca/root-ca.crt",
|
|
"--wrapper-host",
|
|
"/etc/infinito/bin/with-ca-trust.sh",
|
|
"--ca-container",
|
|
CA_CERT_CONTAINER,
|
|
"--wrapper-container",
|
|
CA_WRAPPER_CONTAINER,
|
|
"--trust-name",
|
|
"infinito.local",
|
|
]
|
|
with patch("sys.argv", argv):
|
|
rc = self.m.main()
|
|
|
|
self.assertEqual(rc, 0)
|
|
self.assertTrue(p_write.called)
|
|
|
|
args, _kwargs = p_write.call_args
|
|
written = args[1] if len(args) > 1 else ""
|
|
parsed = load_yaml_str(written)
|
|
self.assertIn("services", parsed)
|
|
self.assertIn("app", parsed["services"])
|
|
self.assertEqual(
|
|
parsed["services"]["app"].get("entrypoint"),
|
|
[CA_WRAPPER_CONTAINER],
|
|
)
|
|
|
|
def test_escape_compose_vars_replaces_single_dollar(self):
|
|
"""
|
|
escape_compose_vars(): must replace $ with $$ so Compose does not interpolate on host.
|
|
"""
|
|
self.assertEqual(
|
|
self.m.escape_compose_vars(['exec "$FOO"']),
|
|
['exec "$$FOO"'],
|
|
)
|
|
self.assertEqual(
|
|
self.m.escape_compose_vars(["$FOO", "${FOO}", "x$y", "$", "plain"]),
|
|
["$$FOO", "$${FOO}", "x$$y", "$$", "plain"],
|
|
)
|
|
|
|
def test_escape_compose_vars_keeps_strings_without_dollar_unchanged(self):
|
|
"""
|
|
escape_compose_vars(): must not modify strings without '$'.
|
|
"""
|
|
argv = ["/entry", "run", "--flag", "exec /bin/app", "x_y-z.1"]
|
|
self.assertEqual(self.m.escape_compose_vars(argv), argv)
|
|
|
|
def test_render_override_escapes_dollar_in_command_when_wrapping(self):
|
|
"""
|
|
render_override(): when wrapping is enabled (has /bin/sh),
|
|
command argv must have $ escaped to $$ to prevent host-side Compose interpolation.
|
|
"""
|
|
services = {"svc": {"image": "img:1"}}
|
|
service_to_cmd = {"svc": ["docker", "compose", "-p", "p", "-f", "compose.yml"]}
|
|
|
|
with patch.object(
|
|
self.m,
|
|
"gather_image_meta",
|
|
return_value={
|
|
"img:1": (True, [], ["sh", "-lc", 'exec "$CHESS_ENTRYPOINT_INT"'], 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",
|
|
)
|
|
|
|
out = doc["services"]["svc"]
|
|
self.assertEqual(out.get("entrypoint"), [CA_WRAPPER_CONTAINER])
|
|
self.assertEqual(
|
|
out.get("command"),
|
|
["sh", "-lc", 'exec "$$CHESS_ENTRYPOINT_INT"'],
|
|
)
|
|
|
|
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.
|
|
|
|
SSL_CERT_FILE, CURL_CA_BUNDLE and REQUESTS_CA_BUNDLE replace the trust
|
|
store, so leaving them on the single root CA is what stops a container
|
|
from reaching any public HTTPS endpoint. NODE_EXTRA_CA_CERTS is
|
|
additive and stays on the root CA, and CA_TRUST_CERT stays single
|
|
because the anchor installers take one certificate.
|
|
"""
|
|
services = {"svc": {"image": "img:1"}}
|
|
service_to_cmd = {"svc": ["docker", "compose", "-p", "p", "-f", "compose.yml"]}
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
ca_host = Path(tmp) / "root-ca.crt"
|
|
ca_host.write_text("root\n")
|
|
(Path(tmp) / "ca-bundle.crt").write_text("root\npublic\n")
|
|
|
|
with (
|
|
patch.object(self.m, "ensure_image_available"),
|
|
patch.object(self.m, "docker_image_inspect"),
|
|
patch.object(self.m, "docker_image_has_bin_sh"),
|
|
):
|
|
doc = self.m.render_override(
|
|
services,
|
|
service_to_cmd,
|
|
cwd=Path("/tmp"),
|
|
env={},
|
|
ca_host=str(ca_host),
|
|
wrapper_host="/host/with-ca-trust.sh",
|
|
ca_container=CA_CERT_CONTAINER,
|
|
wrapper_container=CA_WRAPPER_CONTAINER,
|
|
trust_name="infinito.local",
|
|
wrap=False,
|
|
)
|
|
|
|
out = doc["services"]["svc"]
|
|
bundle_container = str(Path(CA_CERT_CONTAINER).parent / "ca-bundle.crt")
|
|
self.assertIn(
|
|
f"{Path(tmp) / 'ca-bundle.crt'}:{bundle_container}:ro", out["volumes"]
|
|
)
|
|
for key in ("SSL_CERT_FILE", "CURL_CA_BUNDLE", "REQUESTS_CA_BUNDLE"):
|
|
self.assertEqual(out["environment"].get(key), bundle_container)
|
|
self.assertEqual(
|
|
out["environment"].get("NODE_EXTRA_CA_CERTS"), CA_CERT_CONTAINER
|
|
)
|
|
self.assertEqual(out["environment"].get("CA_TRUST_CERT"), CA_CERT_CONTAINER)
|
|
|
|
def test_render_override_no_wrapper_emits_env_and_mounts_only(self):
|
|
"""
|
|
render_override(wrap=False): swarm path must emit only volumes + environment,
|
|
never entrypoint/command (docker stack deploy does not un-escape $$), and must
|
|
not probe/inspect/pull the image (images need not exist at generation time).
|
|
"""
|
|
services = {"svc": {"image": "img:1"}}
|
|
service_to_cmd = {"svc": ["docker", "compose", "-p", "p", "-f", "compose.yml"]}
|
|
|
|
with (
|
|
patch.object(self.m, "ensure_image_available") as p_ensure,
|
|
patch.object(self.m, "docker_image_inspect") as p_inspect,
|
|
patch.object(self.m, "docker_image_has_bin_sh") as p_has_sh,
|
|
):
|
|
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",
|
|
wrap=False,
|
|
)
|
|
|
|
out = doc["services"]["svc"]
|
|
self.assertIn("volumes", out)
|
|
self.assertEqual(out["environment"].get("SSL_CERT_FILE"), CA_CERT_CONTAINER)
|
|
self.assertNotIn("entrypoint", out)
|
|
self.assertNotIn("command", out)
|
|
p_ensure.assert_not_called()
|
|
p_inspect.assert_not_called()
|
|
p_has_sh.assert_not_called()
|
|
|
|
def test_render_override_does_not_escape_when_not_wrapping(self):
|
|
"""
|
|
render_override(): when /bin/sh does NOT exist (distroless-like),
|
|
we must not set 'command' at all (and therefore no escaping occurs).
|
|
"""
|
|
services = {"svc": {"image": "img:1"}}
|
|
service_to_cmd = {"svc": ["docker", "compose", "-p", "p", "-f", "compose.yml"]}
|
|
|
|
with patch.object(
|
|
self.m,
|
|
"gather_image_meta",
|
|
return_value={
|
|
"img:1": (
|
|
True,
|
|
[],
|
|
["sh", "-lc", 'exec "$CHESS_ENTRYPOINT_INT"'],
|
|
False,
|
|
)
|
|
},
|
|
):
|
|
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",
|
|
)
|
|
|
|
out = doc["services"]["svc"]
|
|
self.assertNotIn("entrypoint", out)
|
|
self.assertNotIn("command", out)
|
|
|
|
def test_main_writes_override_with_escaped_dollar_when_image_cmd_contains_dollar(
|
|
self,
|
|
):
|
|
"""
|
|
main(): if the image Cmd contains '$VAR', the written override must contain '$$VAR'
|
|
(Compose-escape) so the container shell can expand it at runtime.
|
|
"""
|
|
with (
|
|
patch.object(Path, "exists", autospec=True, return_value=True),
|
|
patch.object(Path, "is_dir", autospec=True, return_value=True),
|
|
patch.object(
|
|
Path,
|
|
"read_text",
|
|
autospec=True,
|
|
return_value="services:\n app:\n image: myimage:latest\n",
|
|
),
|
|
patch.object(Path, "mkdir", autospec=True),
|
|
patch.object(Path, "write_text", autospec=True) as p_write,
|
|
):
|
|
|
|
def fake_run(cmd, *, cwd, env, timeout=None, capture=True):
|
|
if (
|
|
len(cmd) >= 3
|
|
and cmd[0:2] == ["docker", "compose"]
|
|
and cmd[-1] == "config"
|
|
):
|
|
yml = "services:\n app:\n image: myimage:latest\n"
|
|
return 0, yml, ""
|
|
|
|
if cmd[:3] == ["docker", "image", "inspect"]:
|
|
json_out = json.dumps(
|
|
[
|
|
{
|
|
"Config": {
|
|
"Entrypoint": [],
|
|
"Cmd": [
|
|
"sh",
|
|
"-lc",
|
|
'exec "$CHESS_ENTRYPOINT_INT"',
|
|
],
|
|
}
|
|
}
|
|
]
|
|
)
|
|
return 0, json_out, ""
|
|
|
|
if cmd[:6] == [
|
|
"docker",
|
|
"run",
|
|
"--rm",
|
|
"--entrypoint",
|
|
"/bin/sh",
|
|
"myimage:latest",
|
|
]:
|
|
return 0, "", ""
|
|
|
|
return 1, "", "unexpected"
|
|
|
|
with patch.object(self.m, "run", side_effect=fake_run):
|
|
argv = [
|
|
"compose_ca.py",
|
|
"--chdir",
|
|
"/tmp/app",
|
|
"--project",
|
|
"p",
|
|
"--compose-files",
|
|
"-f compose.yml",
|
|
"--out",
|
|
"compose.ca.override.yml",
|
|
"--ca-host",
|
|
"/etc/infinito/ca/root-ca.crt",
|
|
"--wrapper-host",
|
|
"/etc/infinito/bin/with-ca-trust.sh",
|
|
"--ca-container",
|
|
CA_CERT_CONTAINER,
|
|
"--wrapper-container",
|
|
CA_WRAPPER_CONTAINER,
|
|
"--trust-name",
|
|
"infinito.local",
|
|
]
|
|
with patch("sys.argv", argv):
|
|
rc = self.m.main()
|
|
|
|
self.assertEqual(rc, 0)
|
|
self.assertTrue(p_write.called)
|
|
|
|
args, _kwargs = p_write.call_args
|
|
written = args[1] if len(args) > 1 else ""
|
|
parsed = load_yaml_str(written)
|
|
|
|
self.assertIn("services", parsed)
|
|
self.assertIn("app", parsed["services"])
|
|
self.assertEqual(
|
|
parsed["services"]["app"].get("command"),
|
|
["sh", "-lc", 'exec "$$CHESS_ENTRYPOINT_INT"'],
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|