compose2pod 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compose2pod
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Convert a Docker Compose file into a script that runs its services as a single Podman pod
5
5
  Keywords: podman,docker-compose,compose,pod,ci,testing,containers
6
6
  Author: Artur Shiriev
@@ -48,19 +48,18 @@ Convert a Docker Compose file into a POSIX `sh` script that runs its services as
48
48
 
49
49
  Built for CI and test environments where you can't use `docker compose` or `podman kube play`:
50
50
 
51
- - **No bridge networking / netavark.** Unprivileged CI containers often have a read-only `/proc/sys`, so netavark fails to create bridge networks. A single pod shares one network namespace with no bridge: services talk over `127.0.0.1`, and names resolve via `--add-host`.
51
+ - **No bridge networking / netavark.** Unprivileged CI containers often have a read-only `/proc/sys`, so netavark fails to create bridge networks. A single pod shares one network namespace with no bridge: services talk over `127.0.0.1`, and names resolve via a generated `/etc/hosts` the script owns.
52
52
  - **No systemd.** Podman healthchecks are normally scheduled by systemd timers. compose2pod gates startup by polling `podman healthcheck run` directly, so `depends_on: service_healthy` works without systemd.
53
53
  - **No heavy runtime.** The core is stdlib-only — no dependencies, no compiled wheels — so it installs and runs in minimal Python images.
54
54
 
55
55
  ## Requirements
56
56
 
57
- **Podman >= 6.0.0.** Earlier releases have a bug where a container stopping
58
- inside a multi-container pod wipes `/etc/hosts` for every container in that
59
- pod, not just the one that stopped — fixed in 6.0.0. compose2pod's generated
60
- scripts rely on one shared `--add-host`-populated `/etc/hosts` for the whole
61
- pod (see `architecture/supported-subset.md`), so a `service_completed_successfully`
62
- dependency (a container that runs and exits, e.g. a migration step) can wipe
63
- name resolution for everything started after it on a pre-6.0.0 Podman.
57
+ compose2pod's generated scripts own `/etc/hosts`: they write it to a temp
58
+ file and bind-mount it read-only into every container under `--no-hosts`
59
+ (see `architecture/supported-subset.md`), so pod-internal name resolution
60
+ works on any Podman version. `host.containers.internal` /
61
+ `host.docker.internal` are not provided — add an explicit `extra_hosts`
62
+ entry if you need them.
64
63
 
65
64
  ## Install
66
65
 
@@ -83,15 +82,35 @@ sh ./run.sh
83
82
 
84
83
  ## Supported compose subset
85
84
 
86
- compose2pod supports an honest subset and errors clearly on anything outside
87
- it: `image`/`build`, `command`, `environment`/`env_file`, short-form bind
88
- `volumes`, `healthcheck` (CMD/CMD-SHELL), `depends_on` (all conditions), and
89
- network `aliases`. Compose extension fields (any `x-`-prefixed key) and YAML
90
- anchors are accepted as-is, so a top-level `x-*` anchor block for shared
91
- config just works. `${VAR}`-style variable interpolation is left live in the
92
- generated script, resolved by its shell against the environment present
93
- when the script runs (no `.env` file support). See
94
- `architecture/supported-subset.md` for the full accept/ignore/reject matrix.
85
+ compose2pod refuses **every document `docker compose config` refuses** — a
86
+ measured property, checked continuously by a differential conformance harness
87
+ that runs the real Docker CLI and the real compose2pod pipeline over the same
88
+ YAML. So a file that compiles is a file Docker would run; where compose2pod
89
+ still refuses a form Docker accepts, it is because Podman genuinely cannot
90
+ express it (each such case is documented, not guessed).
91
+
92
+ Within that boundary it covers most of what real compose files use:
93
+
94
+ - **Services** — `image`/`build`, `command`/`entrypoint`, `environment` and
95
+ `env_file` (string and long-form `{path, required, format}`), `volumes`
96
+ (short-form and long-form `--mount`, including the `bind`/`volume`/`tmpfs`
97
+ option maps), `tmpfs`, `healthcheck`, `depends_on` (all conditions), network
98
+ `aliases`, `hostname`/`container_name`.
99
+ - **Confinement & metadata** — `user`, `working_dir`, `read_only`, `init`,
100
+ `privileged`, `cap_add`/`cap_drop`, `security_opt`, `devices`, `group_add`,
101
+ `platform`, `labels`, `annotations`, `pull_policy` (the quoted-boolean and
102
+ YAML-1.1 spellings Docker accepts, too).
103
+ - **Resources** — the legacy keys (`mem_limit`, `cpus`, `pids_limit`,
104
+ `ulimits`, …) and the modern `deploy.resources` block.
105
+ - **Pod-wide** — `dns`/`dns_search`/`dns_opt`, `sysctls`, `extra_hosts`.
106
+ - **Composition** — same-file `extends`, `secrets`/`configs`, `profiles`.
107
+
108
+ Compose extension fields (any `x-`-prefixed key) and YAML anchors are accepted
109
+ as-is, so a top-level `x-*` anchor block for shared config just works.
110
+ `${VAR}`-style variable interpolation is left live in the generated script,
111
+ resolved by its shell against the environment present when the script runs (no
112
+ `.env` file support). See `architecture/supported-subset.md` for the full
113
+ accept/ignore/reject matrix and `planning/decisions/` for the boundary rulings.
95
114
 
96
115
  ## Status
97
116
 
@@ -22,19 +22,18 @@ Convert a Docker Compose file into a POSIX `sh` script that runs its services as
22
22
 
23
23
  Built for CI and test environments where you can't use `docker compose` or `podman kube play`:
24
24
 
25
- - **No bridge networking / netavark.** Unprivileged CI containers often have a read-only `/proc/sys`, so netavark fails to create bridge networks. A single pod shares one network namespace with no bridge: services talk over `127.0.0.1`, and names resolve via `--add-host`.
25
+ - **No bridge networking / netavark.** Unprivileged CI containers often have a read-only `/proc/sys`, so netavark fails to create bridge networks. A single pod shares one network namespace with no bridge: services talk over `127.0.0.1`, and names resolve via a generated `/etc/hosts` the script owns.
26
26
  - **No systemd.** Podman healthchecks are normally scheduled by systemd timers. compose2pod gates startup by polling `podman healthcheck run` directly, so `depends_on: service_healthy` works without systemd.
27
27
  - **No heavy runtime.** The core is stdlib-only — no dependencies, no compiled wheels — so it installs and runs in minimal Python images.
28
28
 
29
29
  ## Requirements
30
30
 
31
- **Podman >= 6.0.0.** Earlier releases have a bug where a container stopping
32
- inside a multi-container pod wipes `/etc/hosts` for every container in that
33
- pod, not just the one that stopped — fixed in 6.0.0. compose2pod's generated
34
- scripts rely on one shared `--add-host`-populated `/etc/hosts` for the whole
35
- pod (see `architecture/supported-subset.md`), so a `service_completed_successfully`
36
- dependency (a container that runs and exits, e.g. a migration step) can wipe
37
- name resolution for everything started after it on a pre-6.0.0 Podman.
31
+ compose2pod's generated scripts own `/etc/hosts`: they write it to a temp
32
+ file and bind-mount it read-only into every container under `--no-hosts`
33
+ (see `architecture/supported-subset.md`), so pod-internal name resolution
34
+ works on any Podman version. `host.containers.internal` /
35
+ `host.docker.internal` are not provided — add an explicit `extra_hosts`
36
+ entry if you need them.
38
37
 
39
38
  ## Install
40
39
 
@@ -57,15 +56,35 @@ sh ./run.sh
57
56
 
58
57
  ## Supported compose subset
59
58
 
60
- compose2pod supports an honest subset and errors clearly on anything outside
61
- it: `image`/`build`, `command`, `environment`/`env_file`, short-form bind
62
- `volumes`, `healthcheck` (CMD/CMD-SHELL), `depends_on` (all conditions), and
63
- network `aliases`. Compose extension fields (any `x-`-prefixed key) and YAML
64
- anchors are accepted as-is, so a top-level `x-*` anchor block for shared
65
- config just works. `${VAR}`-style variable interpolation is left live in the
66
- generated script, resolved by its shell against the environment present
67
- when the script runs (no `.env` file support). See
68
- `architecture/supported-subset.md` for the full accept/ignore/reject matrix.
59
+ compose2pod refuses **every document `docker compose config` refuses** — a
60
+ measured property, checked continuously by a differential conformance harness
61
+ that runs the real Docker CLI and the real compose2pod pipeline over the same
62
+ YAML. So a file that compiles is a file Docker would run; where compose2pod
63
+ still refuses a form Docker accepts, it is because Podman genuinely cannot
64
+ express it (each such case is documented, not guessed).
65
+
66
+ Within that boundary it covers most of what real compose files use:
67
+
68
+ - **Services** — `image`/`build`, `command`/`entrypoint`, `environment` and
69
+ `env_file` (string and long-form `{path, required, format}`), `volumes`
70
+ (short-form and long-form `--mount`, including the `bind`/`volume`/`tmpfs`
71
+ option maps), `tmpfs`, `healthcheck`, `depends_on` (all conditions), network
72
+ `aliases`, `hostname`/`container_name`.
73
+ - **Confinement & metadata** — `user`, `working_dir`, `read_only`, `init`,
74
+ `privileged`, `cap_add`/`cap_drop`, `security_opt`, `devices`, `group_add`,
75
+ `platform`, `labels`, `annotations`, `pull_policy` (the quoted-boolean and
76
+ YAML-1.1 spellings Docker accepts, too).
77
+ - **Resources** — the legacy keys (`mem_limit`, `cpus`, `pids_limit`,
78
+ `ulimits`, …) and the modern `deploy.resources` block.
79
+ - **Pod-wide** — `dns`/`dns_search`/`dns_opt`, `sysctls`, `extra_hosts`.
80
+ - **Composition** — same-file `extends`, `secrets`/`configs`, `profiles`.
81
+
82
+ Compose extension fields (any `x-`-prefixed key) and YAML anchors are accepted
83
+ as-is, so a top-level `x-*` anchor block for shared config just works.
84
+ `${VAR}`-style variable interpolation is left live in the generated script,
85
+ resolved by its shell against the environment present when the script runs (no
86
+ `.env` file support). See `architecture/supported-subset.md` for the full
87
+ accept/ignore/reject matrix and `planning/decisions/` for the boundary rulings.
69
88
 
70
89
  ## Status
71
90
 
@@ -12,7 +12,7 @@ from compose2pod.graph import depends_on, hostnames, startup_order
12
12
  from compose2pod.healthcheck import health_cmd, interval_seconds
13
13
  from compose2pod.keys import SERVICE_KEYS, Expand, GuardedEnvFile, Token
14
14
  from compose2pod.parsing import validate
15
- from compose2pod.pod import pod_create_flags
15
+ from compose2pod.pod import hosts_file_tokens, pod_create_flags
16
16
  from compose2pod.resources import deploy_resource_flags
17
17
  from compose2pod.shell import to_shell, variable_names
18
18
  from compose2pod.values import as_bool
@@ -95,15 +95,15 @@ def _env_file_flags(svc: dict[str, Any], project_dir: str) -> list[Token]:
95
95
 
96
96
 
97
97
  def _nested_mount_parts(vtype: str, options: dict[str, Any]) -> list[str]:
98
- """Map a long-form volume entry's validated `bind`/`volume`/`tmpfs` sub-map to `--mount` options."""
98
+ """Map a long-form volume entry's validated `bind`/`volume`/`tmpfs`/`image` sub-map to `--mount` options."""
99
99
  if vtype == "bind":
100
100
  parts = [f"bind-propagation={options['propagation']}"] if "propagation" in options else []
101
101
  if "selinux" in options:
102
102
  parts.append(f"relabel={'shared' if options['selinux'] == 'z' else 'private'}")
103
103
  return parts
104
- if vtype == "volume":
104
+ if vtype in ("volume", "image"):
105
105
  return [f"subpath={options['subpath']}"] if "subpath" in options else []
106
- # vtype == "tmpfs": the gate validates `type` to one of bind/volume/tmpfs, so no other branch is reachable.
106
+ # vtype == "tmpfs": the gate validates `type` to bind/volume/tmpfs/image, so no other branch is reachable.
107
107
  parts = [f"tmpfs-size={options['size']}"] if "size" in options else []
108
108
  if "mode" in options:
109
109
  parts.append(f"tmpfs-mode={options['mode']}")
@@ -119,7 +119,7 @@ def _mount_flag(entry: dict[str, Any], project_dir: str) -> list[Token]:
119
119
  source = str(Path(project_dir, source))
120
120
  parts.append(f"source={source}")
121
121
  parts.append(f"target={entry['target']}")
122
- if as_bool(entry.get("read_only", False)):
122
+ if entry["type"] != "image" and as_bool(entry.get("read_only", False)):
123
123
  parts.append("ro")
124
124
  vtype = entry["type"]
125
125
  parts += _nested_mount_parts(vtype, entry.get(vtype, {}))
@@ -161,23 +161,16 @@ def run_flags(name: str, svc: dict[str, Any], pod: str, project_dir: str) -> lis
161
161
  return flags
162
162
 
163
163
 
164
+ # Every container mounts the script-owned /etc/hosts read-only. `--no-hosts`
165
+ # stops podman managing the file (so the pre-6.0.0 stop-wipe never runs); `z`
166
+ # relabels for SELinux (mandatory on Fedora, a no-op elsewhere).
167
+ _HOSTS_MOUNT = '--no-hosts -v "$hostsfile":/etc/hosts:ro,z'
168
+
164
169
  _SCRIPT_HEADER = """\
165
170
  #!/bin/sh
166
171
  # Generated by compose2pod -- do not edit, regenerate instead.
167
172
  set -eu
168
173
 
169
- podman_version=$(podman version --format '{{.Client.Version}}' 2>/dev/null) || podman_version="unknown"
170
- podman_major=$(echo "$podman_version" | cut -d. -f1)
171
- case "$podman_major" in
172
- ''|*[!0-9]*) ;; # unparseable -- skip rather than false-positive
173
- *) if [ "$podman_major" -lt 6 ]; then
174
- echo "warning: podman $podman_version detected; compose2pod requires podman >= 6.0.0" >&2
175
- echo "warning: podman < 6.0.0 has a bug where a container stopping in a multi-container" >&2
176
- echo "pod wipes /etc/hosts for the whole pod (fixed in 6.0.0) -- name resolution between" >&2
177
- echo "services may fail unpredictably" >&2
178
- fi ;;
179
- esac
180
-
181
174
  wait_healthy() {
182
175
  ctr=$1
183
176
  attempts=$2
@@ -277,7 +270,7 @@ def _emit_target(lines: list[str], tokens: list[Token], options: EmitOptions) ->
277
270
  target_ctr = shlex.quote(f"{options.pod}-{options.target}")
278
271
  lines.append("rc=0")
279
272
  lines.extend(_env_file_preludes(tokens))
280
- lines.append(f"podman run {_render(tokens)} || rc=$?")
273
+ lines.append(f"podman run {_HOSTS_MOUNT} {_render(tokens)} || rc=$?")
281
274
  for artifact in options.artifacts:
282
275
  source, destination = artifact.split(":", 1)
283
276
  lines.append(f"podman cp {target_ctr}:{shlex.quote(source)} {shlex.quote(destination)} || true")
@@ -343,6 +336,7 @@ def _plan(compose: dict[str, Any], options: EmitOptions) -> PlannedScript:
343
336
  # pointing at 127.0.0.1 for a service that never runs would turn an honest
344
337
  # resolution failure into a connection-refused.
345
338
  hosts = hostnames({name: services[name] for name in order})
339
+ host_tokens = hosts_file_tokens(services, order, hosts)
346
340
  completion_gated = {
347
341
  dep
348
342
  for svc in services.values()
@@ -352,17 +346,21 @@ def _plan(compose: dict[str, Any], options: EmitOptions) -> PlannedScript:
352
346
  names: set[str] = set()
353
347
 
354
348
  lines = [_SCRIPT_HEADER]
349
+ lines.append("hostsfile=$(mktemp)")
355
350
  teardown = f"podman pod rm -f {shlex.quote(options.pod)} >/dev/null 2>&1 || true"
356
351
  store_teardown = stores.teardown_line(compose, order, options.pod)
357
352
  if store_teardown:
358
353
  teardown += f"; {store_teardown}"
354
+ teardown += '; rm -f "$hostsfile"'
359
355
  lines.append(f"trap '{teardown}' EXIT")
360
- pod_flags = pod_create_flags(services, order, hosts)
356
+ pod_flags = pod_create_flags(services, order)
361
357
  _collect_vars(pod_flags, names)
362
- pod_create = f"podman pod create --name {shlex.quote(options.pod)}"
358
+ pod_create = f"podman pod create --name {shlex.quote(options.pod)} --no-hosts"
363
359
  if pod_flags:
364
360
  pod_create += " " + _render(pod_flags)
365
361
  lines.append(pod_create)
362
+ _collect_vars(host_tokens, names)
363
+ lines.append(f"printf '%s\\n' {_render(host_tokens)} > \"$hostsfile\"")
366
364
  lines.extend(stores.create_lines(compose, order, options.pod, options.project_dir))
367
365
  names |= stores.referenced_variables(compose, order, options.project_dir)
368
366
  waited: set[str] = set()
@@ -379,10 +377,10 @@ def _plan(compose: dict[str, Any], options: EmitOptions) -> PlannedScript:
379
377
  _emit_target(lines, tokens, options)
380
378
  elif name in completion_gated:
381
379
  lines.extend(_env_file_preludes(tokens))
382
- lines.append(f"podman run --rm {_render(tokens)}")
380
+ lines.append(f"podman run --rm {_HOSTS_MOUNT} {_render(tokens)}")
383
381
  else:
384
382
  lines.extend(_env_file_preludes(tokens))
385
- lines.append(f"podman run -d {_render(tokens)}")
383
+ lines.append(f"podman run -d {_HOSTS_MOUNT} {_render(tokens)}")
386
384
  return PlannedScript(script="\n".join(lines) + "\n", variables=sorted(names))
387
385
 
388
386
 
@@ -160,7 +160,7 @@ def _classify_volume(volume: str) -> tuple[str, str | None]:
160
160
  return "bind", None
161
161
 
162
162
 
163
- _VOLUME_LONG_TYPES = ("bind", "volume", "tmpfs")
163
+ _VOLUME_LONG_TYPES = ("bind", "volume", "tmpfs", "image")
164
164
  _VOLUME_LONG_KEYS = {"type", "source", "target", "read_only", "consistency"}
165
165
  # Docker's own per-type nested option map keys (measured, docker compose config
166
166
  # v5.1.2). `create_host_path`/`nocopy` are real docker keys, so they land here
@@ -171,6 +171,7 @@ _VOLUME_OPTION_KEYS = {
171
171
  "bind": {"propagation", "selinux", "create_host_path"},
172
172
  "volume": {"subpath", "nocopy"},
173
173
  "tmpfs": {"size", "mode"},
174
+ "image": {"subpath"},
174
175
  }
175
176
  _PROPAGATION_VALUES = {"private", "rprivate", "shared", "rshared", "slave", "rslave"}
176
177
  _SELINUX_VALUES = {"z", "Z"}
@@ -205,10 +206,11 @@ def _validate_service_volumes(name: str, svc: dict[str, Any]) -> None:
205
206
  def _validate_volume_long_form(name: str, entry: dict[str, Any]) -> None:
206
207
  """Check one long-syntax volume mapping against Docker's strict schema (measured, v5.1.2).
207
208
 
208
- type (bind/volume/tmpfs), source, target, read_only, consistency, plus the
209
- one nested option map matching `type` (a mismatched sub-map is refused --
210
- a deliberate stricter-than-docker check; docker accepts-and-ignores it).
211
- cluster/npipe/image types are refused (podman cannot express them).
209
+ type (bind/volume/tmpfs/image), source, target, read_only, consistency,
210
+ plus the one nested option map matching `type` (a mismatched sub-map is
211
+ refused -- a deliberate stricter-than-docker check; docker
212
+ accepts-and-ignores it). cluster/npipe types are refused (podman cannot
213
+ express them).
212
214
 
213
215
  `type` is validated before the unknown-key check (unlike every other field
214
216
  here) because the check itself needs `vtype` to know which sub-map key --
@@ -303,10 +305,26 @@ def _validate_tmpfs_options(name: str, options: dict[str, Any]) -> None:
303
305
  raise UnsupportedComposeError(msg)
304
306
 
305
307
 
308
+ def _validate_image_options(name: str, options: dict[str, Any]) -> None:
309
+ """Check an image entry's nested `image:` option map (measured: strict, `subpath` only, absolute)."""
310
+ if "subpath" not in options:
311
+ return
312
+ subpath = options["subpath"]
313
+ if not isinstance(subpath, str):
314
+ msg = f"service {name!r}: image 'subpath' must be a string"
315
+ raise UnsupportedComposeError(msg)
316
+ if not subpath.startswith("/") and not values.has_variable(subpath):
317
+ # podman requires an absolute image subpath (`must be an absolute path`);
318
+ # docker accepts a relative one -> a rule-two narrowing. A ${VAR} is host-dependent.
319
+ msg = f"service {name!r}: image 'subpath' must be an absolute path"
320
+ raise UnsupportedComposeError(msg)
321
+
322
+
306
323
  _VOLUME_OPTION_VALIDATORS: dict[str, Callable[[str, dict[str, Any]], None]] = {
307
324
  "bind": _validate_bind_options,
308
325
  "volume": _validate_volume_type_options,
309
326
  "tmpfs": _validate_tmpfs_options,
327
+ "image": _validate_image_options,
310
328
  }
311
329
 
312
330
 
@@ -324,10 +342,10 @@ def _validate_volume_options(name: str, vtype: str, options: Any) -> None: # no
324
342
 
325
343
 
326
344
  def _validate_volume_long_form_source(name: str, vtype: str, source: Any) -> None: # noqa: ANN401 - Compose values are untyped YAML/JSON
327
- """Check a long-form volume entry's 'source': required for bind, refused for tmpfs, optional string for volume."""
328
- if vtype == "bind":
345
+ """Check a long-form volume entry's 'source': required for bind/image, refused for tmpfs, optional for volume."""
346
+ if vtype in ("bind", "image"):
329
347
  if not isinstance(source, str):
330
- msg = f"service {name!r}: bind volume 'source' must be a string"
348
+ msg = f"service {name!r}: {vtype} volume 'source' must be a string"
331
349
  raise UnsupportedComposeError(msg)
332
350
  elif vtype == "tmpfs":
333
351
  if source is not None:
@@ -47,8 +47,8 @@ def _check_extra_host_separators(name: str, value: Any) -> None: # noqa: ANN401
47
47
  """Check each list-form entry actually divides into a host and an address.
48
48
 
49
49
  An entry with neither separator has no address to emit, and would render as
50
- a malformed `--add-host "no-separator:"`. The mapping form cannot have this
51
- problem: its key and value are already separate.
50
+ a malformed hosts-file line (a name with no address). The mapping form cannot
51
+ have this problem: its key and value are already separate.
52
52
  """
53
53
  if not isinstance(value, list):
54
54
  return
@@ -65,7 +65,7 @@ def _check_extra_host_value_types(name: str, value: Any) -> None: # noqa: ANN40
65
65
  number, boolean, or null -- correct for `labels`/`annotations`' bare-key-
66
66
  means-null semantics, but too loose for `extra_hosts`: an int/bool/null
67
67
  address would otherwise reach `keys.extra_host_entries` and get coerced
68
- into a bogus `--add-host` value. Measured against `docker compose config`
68
+ into a bogus hosts-file line. Measured against `docker compose config`
69
69
  v5.1.2: `extra_hosts: {h: 3}` and `extra_hosts: {h: true}` are both
70
70
  refused ("services.<svc>.extra_hosts.h must be a string"). The list form
71
71
  cannot have this problem: `validate_map` already requires every list
@@ -131,14 +131,19 @@ def _sysctl_flags(services: dict[str, Any], order: list[str]) -> list[Token]:
131
131
  return tokens
132
132
 
133
133
 
134
- def _add_host_flags(services: dict[str, Any], order: list[str], hosts: list[str]) -> list[Token]:
135
- """Merge alias/hostname hosts (fixed 127.0.0.1) with extra_hosts (order-scoped) into one add-host set.
134
+ _LOCALHOST_LINES: tuple[str, str] = ("127.0.0.1 localhost", "::1 localhost")
136
135
 
137
- A host name landing on two different addresses -- across services' extra_hosts,
138
- or against an alias's fixed 127.0.0.1 -- is refused rather than guessed at, matching
139
- the sysctls conflict rule below. Alias entries render as plain tokens (unquoted, as
140
- before this move); extra_hosts entries render via `Expand` (as before, quoted/interpolated)
141
- -- relocating the flags changes nothing else observable about either source.
136
+
137
+ def hosts_file_tokens(services: dict[str, Any], order: list[str], hosts: list[str]) -> list[Token]:
138
+ """Render the pod's /etc/hosts as `IP NAME` line tokens (localhost lines first).
139
+
140
+ compose2pod owns /etc/hosts: the generated script bind-mounts these lines
141
+ into every container under `--no-hosts`, replacing the former pod-level
142
+ `--add-host` set (which conflicts with `--no-hosts`). Merge/conflict rules
143
+ are unchanged from the add-host era: alias/hostname names are fixed at
144
+ 127.0.0.1, `extra_hosts` is order-scoped, and a name landing on two
145
+ addresses is refused. Alias lines are literal tokens; `extra_hosts` lines
146
+ render via `Expand` so a `${VAR}` address stays live at run time.
142
147
  """
143
148
  merged: dict[str, str] = {}
144
149
  from_extra_hosts: set[str] = set()
@@ -154,18 +159,19 @@ def _add_host_flags(services: dict[str, Any], order: list[str], hosts: list[str]
154
159
  raise UnsupportedComposeError(msg)
155
160
  merged[host] = addr
156
161
  from_extra_hosts.add(host)
157
- tokens: list[Token] = []
162
+ tokens: list[Token] = [*_LOCALHOST_LINES]
158
163
  for host, addr in merged.items():
159
- value = Expand(value=f"{host}:{addr}") if host in from_extra_hosts else f"{host}:{addr}"
160
- tokens += ["--add-host", value]
164
+ line = f"{addr} {host}"
165
+ tokens.append(Expand(value=line) if host in from_extra_hosts else line)
161
166
  return tokens
162
167
 
163
168
 
164
- def pod_create_flags(services: dict[str, Any], order: list[str], hosts: list[str]) -> list[Token]:
165
- """Pod-create flag tokens aggregated across the closure `order`, plus `hosts` for add-host.
169
+ def pod_create_flags(services: dict[str, Any], order: list[str]) -> list[Token]:
170
+ """Pod-create flag tokens aggregated across the closure `order`.
166
171
 
167
- Add-host is merged from alias/hostname resolution (`hosts`, fixed 127.0.0.1) and each
168
- closure service's `extra_hosts`, conflict-checked (see `_add_host_flags`). dns/dns_search/
169
- dns_opt are unioned (dedup, first-seen order); sysctls are unioned by key.
172
+ dns/dns_search/dns_opt are unioned (dedup, first-seen order); sysctls are
173
+ unioned by key. Name resolution no longer rides on `--add-host` (it conflicts
174
+ with the `--no-hosts` the script now passes); it lives in the bind-mounted
175
+ hosts file -- see `hosts_file_tokens`.
170
176
  """
171
- return _add_host_flags(services, order, hosts) + _dns_flags(services, order) + _sysctl_flags(services, order)
177
+ return _dns_flags(services, order) + _sysctl_flags(services, order)
@@ -18,7 +18,7 @@ classifiers = [
18
18
  "Topic :: Software Development :: Libraries",
19
19
  "Topic :: Software Development :: Testing",
20
20
  ]
21
- version = "0.3.0"
21
+ version = "0.4.0"
22
22
 
23
23
  [project.optional-dependencies]
24
24
  yaml = ["PyYAML>=6"]