Compose In, Service Out
The last chapter closed on the finished machine: a door, a yard, a living service, and a promise that the next box was the deploy loop — how a compose file becomes a service the yard can show. That loop cannot start yet. What it will be handed is a docker-compose.yml: text a person wrote, in a format defined by another tool, describing containers in a vocabulary the platform does not speak.
Something has to turn that text into the platform’s own service model before anything can be deployed, routed, or health-checked. That something is the compose processor, and it is the whole of this chapter. It reads the YAML, keeps the services worth deploying, and normalizes the handful of fields the rest of the platform acts on into fixed shapes. The question it has to answer correctly is narrow and sharp: when a port line says "8080:80", does the model that comes out point the traffic the right way?

ebb760b2.1 From YAML to a service model
A compose file can carry dozens of keys per service — build args, depends-on, restart policies, healthchecks. The platform acts on four of them. Parsing, here, is as much about what to drop as what to keep.
def parse_compose(content: str) -> dict: data = yaml.safe_load(content) or {} if not isinstance(data, dict): return {} services = data.get("services", {}) if not isinstance(services, dict): return {}
result: dict[str, dict] = {} for name, spec in services.items(): if not isinstance(spec, dict): continue result[name] = { "image": spec.get("image"), "command": _normalize_command(spec.get("command")), "ports": _normalize_ports(spec.get("ports") or []), "environment": _normalize_environment(spec.get("environment")), } return resultListing 2.1 · the top-level walk — load the YAML, then reduce each service to a fixed four-field shape.
Read the guards first, because they are the reason a hostile or empty file cannot crash the deploy loop. yaml.safe_load(content) or {} turns an empty file into an empty mapping instead of None. The two isinstance checks refuse anything that is not a dict where a dict is required — a file whose top level is a list, or a services: that was left blank — and return {} rather than raising. Inside the loop, if not isinstance(spec, dict): continue skips a malformed service without taking the others down with it.
What survives is copied into one shape and only one: image, command, ports, environment. A service with no image — a build-only entry — is not rejected here; it is kept with image set to None, and the decision about whether it can actually deploy is made later, by the caller. The load-bearing line is the dict literal: every service, however it was written, leaves this function as the same four keys, each already handed to a normalizer. Three of those four are trivial. The one that is not is ports.
2.2 Which way does a port point
A port mapping has a direction. "8080:80" means publish container port 80 on host port 8080 — the number the outside world connects to on the left, the number the process inside the container listens on the right. Get that backwards and the service looks deployed and answers nothing. Compose also lets the same mapping be written three ways, and the normalizer has to collapse all of them to one.
def _normalize_ports(ports) -> list[dict]: result: list[dict] = [] for port in ports: if isinstance(port, dict): target = port.get("target") published = port.get("published", target) if target is not None: result.append({"host": int(published), "forwarded": int(target)}) continue parts = str(port).split(":") try: if len(parts) >= 2: result.append({"host": int(parts[-2]), "forwarded": int(parts[-1])}) elif len(parts) == 1: result.append({"host": int(parts[0]), "forwarded": int(parts[0])}) except ValueError: continue return resultListing 2.2 · every accepted port form reduced to forwarded.
The dict branch handles the long form — {target: 80, published: 8080} — and published = port.get("published", target) gives it a default: a long-form entry with a target but no published publishes on the same number it targets. The string branch handles the short form by splitting on :. The direction lives in two index choices: int(parts[-2]) is the host, int(parts[-1]) is the forwarded port. Reading from the end is what lets a three-field string like "127.0.0.1:8443:443" work — the optional host-IP prefix falls off the front, and -2/-1 still name the two ports. A single field, "6379", maps a port to itself, host equal to forwarded. Anything that will not parse to an integer is dropped inside the except, not raised.
: and read from the end — the last field is the container port, the one before it the host — so an optional leading 127.0.0.1 falls off the front, and a lone 6379 maps to itself.2.3 Environment: two spellings, one dict
Environment variables arrive in two shapes too — a YAML mapping (KEY: value) or a YAML list of KEY=value strings — and both have to become one dict of strings.
def _normalize_environment(environment) -> dict: result: dict[str, str] = {} if isinstance(environment, dict): for key, value in environment.items(): result[key] = "" if value is None else str(value) elif isinstance(environment, list): for item in environment: key, sep, value = str(item).partition("=") if sep: result[key] = value return resultListing 2.3 · the mapping form and the list form, folded into one dict of strings.
The mapping branch coerces every value to a string and turns a null value — SENTRY_DSN: with nothing after it — into "" rather than the string "None". The list branch is where the subtle line sits: str(item).partition("=") splits KEY=value into three parts, and if sep: keeps the pair only when the = was actually present. A bare PATH_PASSTHROUGH with no = produces an empty separator, fails that guard, and is dropped — not stored as an empty string, and not crashed on. That single if sep: is the difference between a clean model and one seeded with junk keys.
2.4 Correct, and pointing the right way
The companion driver, compose_check.py, runs eight compose files — the canonical two-service stack, both command forms, all three port forms, a null environment value, a bare environment word, a build-only service, and an empty file — through the platform’s parse_compose and through an independent parser written from the same rules in reference.py beside it. It compares the two models field for field and counts every service on which they disagree.
Before the numbers: both parsers apply the same direction and the same guards, so decide now whether the count of field mismatches comes back at zero or whether one of the three port forms slips through with its host and container reversed — then read the last two lines.
$ python compose_check.py
web+cache services 2 ports 1 DIFF command-list services 1 ports 0 match ports-longform services 2 ports 2 match ports-strings services 1 ports 3 DIFF env-mapping services 1 ports 0 match env-bareword services 1 ports 0 match build-only services 1 ports 0 match empty services 0 ports 0 match services parsed 9 ports mapped 6 field mismatches 2 (tolerance 0) compose 6/8 PARITY FAILED
The count is 0, and compose 8/8 — every one of the eight files produces the identical model under both parsers, across nine services and six port mappings. That is what “the same rule, twice, independently” buys: not that the parser runs, but that its most reversible decision — which number is the host and which is the container — lands the same way as a parser that shares none of its code. Six ports mapped, six pointing the direction the reference agrees they should.
The compose processor now turns a file into a list of typed services with their ports pointing the right way and their environment cleaned of junk. What it does not do is run anything — no image is pulled, no container starts, nothing is routed. That is the next box: the deploy — taking one of these service models and turning it into a container the yard can watch go healthy.