diff --git a/docs/stack-files.md b/docs/stack-files.md index ef096cc..66fdfa3 100644 --- a/docs/stack-files.md +++ b/docs/stack-files.md @@ -371,4 +371,10 @@ Every service in a deployment is reachable from every other service by its servi `gitea` service (located in another pod in the same deployment) at the hostname `gitea`. This works the same way whether the deployment target is Docker Compose or Kubernetes: on Kubernetes each deployment -gets its own namespace, so the unqualified service name resolves within it. \ No newline at end of file +gets its own namespace, so the unqualified service name resolves within it. + +Declaring `ports:` is not what makes a service addressable. A service with no `ports:` at all -- a database whose +port nothing outside the deployment should reach, say -- still answers to its own name on both targets; on Kubernetes +it gets a headless Service (no cluster IP, no ports) whose name resolves to the pod. So there is no need to publish +a port merely to obtain a hostname, which on the Compose target would also publish it on the host under +`--map-ports-to-host`. \ No newline at end of file diff --git a/src/stack/deploy/k8s/cluster_info.py b/src/stack/deploy/k8s/cluster_info.py index 5a5ceea..d763349 100644 --- a/src/stack/deploy/k8s/cluster_info.py +++ b/src/stack/deploy/k8s/cluster_info.py @@ -243,22 +243,27 @@ def get_services(self): services = pod["services"] for service_name in services: service_info = services[service_name] - if "ports" in service_info: - int_ports = [int(p.split(":")[-1].replace("/udp", "")) for p in service_info["ports"]] - svc_ports = [client.V1ServicePort(port=p, target_port=p, name=f"{service_name}-{p}") for p in int_ports] - service = client.V1Service( - metadata=client.V1ObjectMeta( - name=service_name, - labels={"app": self.app_name, "service": service_name}, - ), - spec=client.V1ServiceSpec( - type="ClusterIP", - ports=svc_ports, - # TODO: For balancing, we should use some sort of shared tag among pods of the same type - selector={"app": self.app_name, "service": service_name}, - ), - ) - ret.append(service) + int_ports = [int(p.split(":")[-1].replace("/udp", "")) for p in service_info.get("ports", [])] + svc_ports = [client.V1ServicePort(port=p, target_port=p, name=f"{service_name}-{p}") for p in int_ports] + # A service that declares no ports still needs a name: every service + # reaches every other by service name on both targets, and on compose + # that holds whether or not a port is published. A headless Service + # (no cluster IP, no ports) resolves the name to the pod's own IP, + # which is the closest k8s equivalent. + service = client.V1Service( + metadata=client.V1ObjectMeta( + name=service_name, + labels={"app": self.app_name, "service": service_name}, + ), + spec=client.V1ServiceSpec( + type="ClusterIP", + cluster_ip=None if svc_ports else "None", + ports=svc_ports if svc_ports else None, + # TODO: For balancing, we should use some sort of shared tag among pods of the same type + selector={"app": self.app_name, "service": service_name}, + ), + ) + ret.append(service) return ret def get_pvcs(self): diff --git a/tests/unit/test_k8s_objects.py b/tests/unit/test_k8s_objects.py index a522b5a..30b8502 100644 --- a/tests/unit/test_k8s_objects.py +++ b/tests/unit/test_k8s_objects.py @@ -109,7 +109,15 @@ def test_service_strips_udp_suffix_from_port(tmp_path): assert svc["spec"]["ports"] == [{"name": "dns-53", "port": 53, "targetPort": 53}] -def test_service_omitted_when_no_ports(tmp_path): +def test_service_is_headless_when_no_ports(tmp_path): + """A service with no ports is still addressable by name (issue #280). + + Compose gives every container on the network a DNS name whether or not a + port is published, and the stack file contract says a service reaches every + other by service name. On k8s that costs a headless Service; without one + the name simply does not resolve, and the failure surfaces as a DNS error + inside whichever container tried to connect. + """ pod = """\ services: worker: @@ -117,7 +125,11 @@ def test_service_omitted_when_no_ports(tmp_path): """ cluster_info = make_cluster_info(tmp_path, pod, k8s_spec()) - assert cluster_info.get_services() == [] + svc = k8s_dict(cluster_info.get_services()[0]) + assert svc["metadata"]["name"] == "worker" + assert svc["spec"]["clusterIP"] == "None" + assert svc["spec"].get("ports") is None + assert svc["spec"]["selector"] == {"app": TEST_CLUSTER_ID, "service": "worker"} # ---------------------------------------------------------------------------