From 105ab39c00cbacd53c32dca99d8f6f2ec5b5f39f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Jun 2026 03:49:07 +0000 Subject: [PATCH 1/3] Initial plan From d213724bdec60c5b16bb6281abdfb1fe9c09417d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Jun 2026 03:53:18 +0000 Subject: [PATCH 2/3] Preserve service networks aliases and healthcheck in generate output --- src/autocompose.py | 65 ++++++++++++++-- tests/__init__.py | 0 tests/test_autocompose.py | 155 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 213 insertions(+), 7 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_autocompose.py diff --git a/src/autocompose.py b/src/autocompose.py index 92c677e0..be8911e8 100644 --- a/src/autocompose.py +++ b/src/autocompose.py @@ -13,6 +13,56 @@ IGNORE_VALUES = [None, "", [], "null", {}, "default", 0, ",", "no"] +def format_compose_duration(value): + if isinstance(value, int): + return f"{value}ns" + return value + + +def build_service_networks(network_settings, default_networks): + custom_networks = {} + + for network_name, network_attributes in network_settings.items(): + if network_name in default_networks: + continue + + network_values = {} + aliases = network_attributes.get("Aliases") + if aliases: + network_values["aliases"] = aliases + + custom_networks[network_name] = network_values + + if not custom_networks: + return None, set() + + if any(custom_networks.values()): + return {name: values if values else {} for name, values in custom_networks.items()}, set(custom_networks.keys()) + + return sorted(custom_networks.keys()), set(custom_networks.keys()) + + +def build_healthcheck(config): + healthcheck = config.get("Healthcheck") + if healthcheck in IGNORE_VALUES: + return None + + values = {} + + if healthcheck.get("Test") not in IGNORE_VALUES: + values["test"] = healthcheck.get("Test") + if healthcheck.get("Interval") not in IGNORE_VALUES: + values["interval"] = format_compose_duration(healthcheck.get("Interval")) + if healthcheck.get("Timeout") not in IGNORE_VALUES: + values["timeout"] = format_compose_duration(healthcheck.get("Timeout")) + if healthcheck.get("Retries") not in IGNORE_VALUES: + values["retries"] = healthcheck.get("Retries") + if healthcheck.get("StartPeriod") not in IGNORE_VALUES: + values["start_period"] = format_compose_duration(healthcheck.get("StartPeriod")) + + return values + + def shell_escape_string(input_string): # Currently known issues: # - Basic Auth strings (e.g. set via Træfik labels) contain $ characters, which must be doubled. See https://stackoverflow.com/a/40621373/5885325 @@ -169,6 +219,8 @@ def generate(cname, createvolumes=False): ct = cfile[cattrs.get("Name")[1:]] default_networks = ["bridge", "host", "none"] + network_settings = cattrs.get("NetworkSettings", {}).get("Networks", {}) + service_networks, attached_network_names = build_service_networks(network_settings, default_networks) values = { "cap_drop": cattrs.get("HostConfig", {}).get("CapDrop", None), @@ -188,9 +240,8 @@ def generate(cname, createvolumes=False): "driver": cattrs.get("HostConfig", {}).get("LogConfig", {}).get("Type", None), "options": cattrs.get("HostConfig", {}).get("LogConfig", {}).get("Config", None), }, - "networks": { - x for x in cattrs.get("NetworkSettings", {}).get("Networks", {}).keys() if x not in default_networks - }, + "networks": service_networks, + "healthcheck": build_healthcheck(cattrs.get("Config", {})), "security_opt": cattrs.get("HostConfig", {}).get("SecurityOpt"), "ulimits": cattrs.get("HostConfig", {}).get("Ulimits"), # the line below would not handle type bind @@ -228,17 +279,17 @@ def generate(cname, createvolumes=False): ] networks = {} - if values["networks"] == set(): + if not attached_network_names: del values["networks"] - if len(cattrs.get("NetworkSettings", {}).get("Networks", {}).keys()) > 0: - assumed_default_network = list(cattrs.get("NetworkSettings", {}).get("Networks", {}).keys())[0] + if len(network_settings.keys()) > 0: + assumed_default_network = list(network_settings.keys())[0] values["network_mode"] = assumed_default_network networks = None else: networklist = c.networks.list() for network in networklist: - if network.attrs["Name"] in values["networks"]: + if network.attrs["Name"] in attached_network_names: networks[network.attrs["Name"]] = { "external": (not network.attrs["Internal"]), "name": network.attrs["Name"], diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_autocompose.py b/tests/test_autocompose.py new file mode 100644 index 00000000..9c757581 --- /dev/null +++ b/tests/test_autocompose.py @@ -0,0 +1,155 @@ +import unittest +from unittest.mock import patch + +from src import autocompose + + +class FakeContainerSummary: + def __init__(self, name, short_id): + self.name = name + self.short_id = short_id + + +class FakeContainerWithAttrs: + def __init__(self, attrs): + self.attrs = attrs + + +class FakeContainers: + def __init__(self, attrs, name="svc", short_id="abc123"): + self._attrs = attrs + self._summary = FakeContainerSummary(name=name, short_id=short_id) + + def list(self, all=True): + return [self._summary] + + def get(self, _cid): + return FakeContainerWithAttrs(self._attrs) + + +class FakeNetwork: + def __init__(self, name, internal=False): + self.attrs = {"Name": name, "Internal": internal} + + +class FakeNetworks: + def __init__(self, networks): + self._networks = [FakeNetwork(name, internal) for name, internal in networks] + + def list(self): + return self._networks + + +class FakeDockerClient: + def __init__(self, attrs, networks): + self.containers = FakeContainers(attrs) + self.networks = FakeNetworks(networks) + + +def make_attrs(networks, healthcheck=None): + return { + "Name": "/svc", + "HostConfig": { + "CapDrop": None, + "CgroupParent": None, + "Dns": None, + "DnsSearch": None, + "ExtraHosts": None, + "Links": None, + "LogConfig": {"Type": None, "Config": None}, + "SecurityOpt": None, + "Ulimits": None, + "VolumeDriver": None, + "VolumesFrom": None, + "IpcMode": None, + "Privileged": None, + "RestartPolicy": {"Name": None}, + "ReadonlyRootfs": None, + "Devices": None, + "PortBindings": {}, + }, + "Config": { + "Env": None, + "Image": "test:latest", + "Labels": {}, + "Entrypoint": None, + "User": None, + "WorkingDir": None, + "Domainname": None, + "Hostname": None, + "OpenStdin": None, + "Tty": None, + "Cmd": None, + "ExposedPorts": {}, + "Healthcheck": healthcheck, + }, + "NetworkSettings": {"Networks": networks, "MacAddress": None}, + "Mounts": [], + } + + +class GenerateTests(unittest.TestCase): + def test_preserves_network_aliases_and_healthcheck(self): + attrs = make_attrs( + { + "custom_net": { + "Aliases": ["svc", "db"], + } + }, + healthcheck={ + "Test": ["CMD-SHELL", "echo ok"], + "Interval": 1000000000, + "Timeout": 2000000000, + "Retries": 3, + "StartPeriod": 3000000000, + }, + ) + fake_client = FakeDockerClient(attrs, networks=[("custom_net", False)]) + + with patch("src.autocompose.docker.from_env", return_value=fake_client): + cfile, c_networks, _ = autocompose.generate("svc") + + service = cfile["svc"] + self.assertEqual(service["networks"], {"custom_net": {"aliases": ["svc", "db"]}}) + self.assertEqual( + service["healthcheck"], + { + "test": ["CMD-SHELL", "echo ok"], + "interval": "1000000000ns", + "timeout": "2000000000ns", + "retries": 3, + "start_period": "3000000000ns", + }, + ) + self.assertEqual( + c_networks, + {"custom_net": {"external": True, "name": "custom_net"}}, + ) + + def test_simple_custom_network_uses_list_form(self): + attrs = make_attrs({"custom_net": {}}) + fake_client = FakeDockerClient(attrs, networks=[("custom_net", True)]) + + with patch("src.autocompose.docker.from_env", return_value=fake_client): + cfile, c_networks, _ = autocompose.generate("svc") + + self.assertEqual(cfile["svc"]["networks"], ["custom_net"]) + self.assertEqual( + c_networks, + {"custom_net": {"external": False, "name": "custom_net"}}, + ) + + def test_default_network_still_uses_network_mode(self): + attrs = make_attrs({"bridge": {}}) + fake_client = FakeDockerClient(attrs, networks=[("bridge", False)]) + + with patch("src.autocompose.docker.from_env", return_value=fake_client): + cfile, c_networks, _ = autocompose.generate("svc") + + self.assertEqual(cfile["svc"]["network_mode"], "bridge") + self.assertNotIn("networks", cfile["svc"]) + self.assertIsNone(c_networks) + + +if __name__ == "__main__": + unittest.main() From 4479f0674ffdc378663b251e0765d1e948c3ef18 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Jun 2026 03:56:39 +0000 Subject: [PATCH 3/3] Add regression coverage for network aliases and healthcheck output --- src/autocompose.py | 7 ++++--- tests/test_autocompose.py | 40 +++++++++++++++++++++++---------------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/src/autocompose.py b/src/autocompose.py index be8911e8..f3c7b328 100644 --- a/src/autocompose.py +++ b/src/autocompose.py @@ -36,15 +36,16 @@ def build_service_networks(network_settings, default_networks): if not custom_networks: return None, set() - if any(custom_networks.values()): - return {name: values if values else {} for name, values in custom_networks.items()}, set(custom_networks.keys()) + has_network_config = any(network_values for network_values in custom_networks.values()) + if has_network_config: + return custom_networks, set(custom_networks.keys()) return sorted(custom_networks.keys()), set(custom_networks.keys()) def build_healthcheck(config): healthcheck = config.get("Healthcheck") - if healthcheck in IGNORE_VALUES: + if not isinstance(healthcheck, dict): return None values = {} diff --git a/tests/test_autocompose.py b/tests/test_autocompose.py index 9c757581..ab44c25e 100644 --- a/tests/test_autocompose.py +++ b/tests/test_autocompose.py @@ -5,9 +5,8 @@ class FakeContainerSummary: - def __init__(self, name, short_id): - self.name = name - self.short_id = short_id + name = "svc" + short_id = "abc123" class FakeContainerWithAttrs: @@ -16,11 +15,11 @@ def __init__(self, attrs): class FakeContainers: - def __init__(self, attrs, name="svc", short_id="abc123"): + def __init__(self, attrs): self._attrs = attrs - self._summary = FakeContainerSummary(name=name, short_id=short_id) + self._summary = FakeContainerSummary() - def list(self, all=True): + def list(self, **_kwargs): return [self._summary] def get(self, _cid): @@ -89,13 +88,28 @@ def make_attrs(networks, healthcheck=None): class GenerateTests(unittest.TestCase): - def test_preserves_network_aliases_and_healthcheck(self): + def test_preserves_network_aliases(self): attrs = make_attrs( { "custom_net": { "Aliases": ["svc", "db"], } - }, + } + ) + fake_client = FakeDockerClient(attrs, networks=[("custom_net", False)]) + + with patch("src.autocompose.docker.from_env", return_value=fake_client): + cfile, c_networks, _ = autocompose.generate("svc") + + self.assertEqual(cfile["svc"]["networks"], {"custom_net": {"aliases": ["svc", "db"]}}) + self.assertEqual( + c_networks, + {"custom_net": {"external": True, "name": "custom_net"}}, + ) + + def test_serializes_healthcheck_config(self): + attrs = make_attrs( + {"custom_net": {}}, healthcheck={ "Test": ["CMD-SHELL", "echo ok"], "Interval": 1000000000, @@ -107,12 +121,10 @@ def test_preserves_network_aliases_and_healthcheck(self): fake_client = FakeDockerClient(attrs, networks=[("custom_net", False)]) with patch("src.autocompose.docker.from_env", return_value=fake_client): - cfile, c_networks, _ = autocompose.generate("svc") + cfile, _, _ = autocompose.generate("svc") - service = cfile["svc"] - self.assertEqual(service["networks"], {"custom_net": {"aliases": ["svc", "db"]}}) self.assertEqual( - service["healthcheck"], + cfile["svc"]["healthcheck"], { "test": ["CMD-SHELL", "echo ok"], "interval": "1000000000ns", @@ -121,10 +133,6 @@ def test_preserves_network_aliases_and_healthcheck(self): "start_period": "3000000000ns", }, ) - self.assertEqual( - c_networks, - {"custom_net": {"external": True, "name": "custom_net"}}, - ) def test_simple_custom_network_uses_list_form(self): attrs = make_attrs({"custom_net": {}})