Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ it: `image`/`build`, `command`, `environment`/`env_file`, short-form bind
`volumes`, `healthcheck` (CMD/CMD-SHELL), `depends_on` (all conditions), and
network `aliases`. Compose extension fields (any `x-`-prefixed key) and YAML
anchors are accepted as-is, so a top-level `x-*` anchor block for shared
config just works. `${VAR}`-style variable interpolation is resolved against
the process environment (no `.env` file support). See
config just works. `${VAR}`-style variable interpolation is left live in the
generated script, resolved by its shell against the environment present
when the script runs (no `.env` file support). See
`architecture/supported-subset.md` for the full accept/ignore/reject matrix.

## Status
Expand Down
38 changes: 26 additions & 12 deletions architecture/supported-subset.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,18 +80,32 @@ colon-less entry that is not absolute (e.g. `./cache`) is malformed and raises.

## Variable interpolation

`interpolate()` (`compose2pod/interpolate.py`) resolves Compose Spec
`${VAR}` references against the process environment (`os.environ` in the
CLI) before `validate()` sees the document, in every string leaf of the
compose document — dict keys are left untouched. Supported forms:
`$VAR`, `${VAR}`, `${VAR:-default}`, `${VAR-default}`, `${VAR:?msg}`,
`${VAR?msg}`, `${VAR:+alt}`, `${VAR+alt}`, and `$$` for a literal `$`. An
unset `$VAR`/`${VAR}` with no default resolves to an empty string and
emits a warning rather than failing; `${VAR:?msg}`/`${VAR?msg}` raises
`UnsupportedComposeError(msg)` instead. There is no `.env` file support —
only the environment the caller already has is consulted; export the
values first (`set -a; . .env; set +a`) if a project relies on a `.env`
file.
compose2pod does not resolve Compose Spec `${VAR}` references at generation
time. `to_shell()` (`compose2pod/shell.py`) instead re-encodes every
compose-derived string leaf (`environment`, `image`, `command`, `volumes`,
`tmpfs`, `env_file`, healthcheck `test`) into a double-quoted POSIX-shell
fragment with the variable references left live, so the generated script's
own shell expands them against its runtime environment when the script
runs. Only those field values pass through `to_shell()`; other document
fields (service and network names, ports, and the like) are never
interpolated. Supported forms: `$VAR`, `${VAR}`,
`${VAR:-default}`, `${VAR-default}`, `${VAR:?msg}`, `${VAR?msg}`,
`${VAR:+alt}`, `${VAR+alt}`, and `$$` for a literal `$`. The operator forms
map onto identical POSIX `sh` parameter expansion; bare `$VAR`/`${VAR}` is
emitted as `${VAR-}` so an unset variable expands to empty under the
script's `set -eu` (matching Compose semantics) instead of aborting on
`nounset`. `${VAR:?msg}`/`${VAR?msg}` fails the script at run time — with
`msg` — if the variable is unset or empty; there is no generation-time
check. Tool/CLI-supplied values (`--project-dir`, `--image`, the pod name,
the `--command` override) are literal and never interpolated. The CLI
prints one informational stderr note listing the variable names the
generated script actually expands at run time — `referenced_variables()`
(`compose2pod/emit.py`) collects these from the same tokens `to_shell()`
renders, so a `${VAR}` sitting in a literally-emitted field (or in the
`--command` override) never appears in the note. There is no `.env` file
support — only the environment present when the generated script runs is
consulted; export the values first (`set -a; . .env; set +a`) if a project
relies on a `.env` file.

## depends_on

Expand Down
4 changes: 2 additions & 2 deletions compose2pod/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

from compose2pod.emit import EmitOptions, emit_script
from compose2pod.exceptions import UnsupportedComposeError
from compose2pod.interpolate import interpolate
from compose2pod.parsing import validate
from compose2pod.shell import to_shell


__all__ = [
"EmitOptions",
"UnsupportedComposeError",
"emit_script",
"interpolate",
"to_shell",
"validate",
]
32 changes: 15 additions & 17 deletions compose2pod/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,14 @@
import argparse
import contextlib
import json
import os
import re
import sys
from pathlib import Path
from types import ModuleType
from typing import Any

from compose2pod.emit import EmitOptions, emit_script
from compose2pod.emit import EmitOptions, emit_script, referenced_variables
from compose2pod.exceptions import UnsupportedComposeError
from compose2pod.interpolate import interpolate
from compose2pod.parsing import validate


Expand Down Expand Up @@ -89,24 +87,24 @@ def main(argv: list[str] | None = None) -> int:
except (json.JSONDecodeError, UnsupportedComposeError) as error:
sys.stderr.write(f"compose2pod: error: could not parse compose input: {error}\n")
return 2
options = EmitOptions(
target=args.target,
ci_image=args.image,
command=args.command,
pod=args.pod_name,
project_dir=args.project_dir,
artifacts=args.artifact,
allow_exit_codes=args.allow_exit_code,
)
try:
compose, interpolation_warnings = interpolate(compose, os.environ)
warnings = [*interpolation_warnings, *validate(compose)]
script = emit_script(
compose=compose,
options=EmitOptions(
target=args.target,
ci_image=args.image,
command=args.command,
pod=args.pod_name,
project_dir=args.project_dir,
artifacts=args.artifact,
allow_exit_codes=args.allow_exit_code,
),
)
warnings = validate(compose)
script = emit_script(compose=compose, options=options)
except UnsupportedComposeError as error:
sys.stderr.write(f"compose2pod: error: {error}\n")
return 2
referenced = referenced_variables(compose, options)
if referenced:
sys.stderr.write("compose2pod: note: script references variables at run time: " + ", ".join(referenced) + "\n")
for warning in warnings:
sys.stderr.write(f"compose2pod: {warning}\n")
sys.stdout.write(script)
Expand Down
65 changes: 47 additions & 18 deletions compose2pod/emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,44 @@

from compose2pod.graph import depends_on, hostnames, startup_order
from compose2pod.healthcheck import health_cmd, interval_seconds
from compose2pod.shell import to_shell, variable_names


HEALTHY_WAIT_BUDGET_SECONDS = 120


def image_for(svc: dict[str, Any], ci_image: str) -> str:
@dataclasses.dataclass(frozen=True)
class _Expand:
"""A token whose Compose variable references expand at script-run time."""

value: str


Token = str | _Expand


def image_for(svc: dict[str, Any], ci_image: str) -> Token:
"""Services with a build section run the freshly built CI image."""
if "build" in svc:
return ci_image
return svc["image"]
return _Expand(svc["image"])


def command_tokens(svc: dict[str, Any]) -> list[str]:
def command_tokens(svc: dict[str, Any]) -> list[Token]:
"""Service command as argv tokens; compose string form means shell form."""
command = svc.get("command")
if command is None:
return []
if isinstance(command, str):
return ["/bin/sh", "-c", command]
return list(command)
return ["/bin/sh", "-c", _Expand(command)]
return [_Expand(str(token)) for token in command]


def _add_health_flags(flags: list[str], healthcheck: dict[str, Any]) -> None:
def _add_health_flags(flags: list[Token], healthcheck: dict[str, Any]) -> None:
"""Add healthcheck flags to the flags list."""
cmd = health_cmd(healthcheck.get("test"))
if cmd is not None:
flags += ["--health-cmd", cmd]
flags += ["--health-cmd", _Expand(cmd)]
if "timeout" in healthcheck:
flags += ["--health-timeout", str(healthcheck["timeout"])]
if "start_period" in healthcheck:
Expand All @@ -42,37 +53,37 @@ def _add_health_flags(flags: list[str], healthcheck: dict[str, Any]) -> None:
flags += ["--health-retries", str(healthcheck["retries"])]


def run_flags(name: str, svc: dict[str, Any], pod: str, hosts: list[str], project_dir: str) -> list[str]:
def run_flags(name: str, svc: dict[str, Any], pod: str, hosts: list[str], project_dir: str) -> list[Token]:
"""Flag tokens (unquoted) for `podman run` of one service."""
flags = ["--pod", pod, "--name", f"{pod}-{name}"]
flags: list[Token] = ["--pod", pod, "--name", f"{pod}-{name}"]
for host in hosts:
flags += ["--add-host", f"{host}:127.0.0.1"]
environment = svc.get("environment") or {}
pairs = environment if isinstance(environment, list) else [f"{k}={v}" for k, v in environment.items()]
for pair in pairs:
flags += ["-e", pair]
flags += ["-e", _Expand(str(pair))]
env_files = svc.get("env_file") or []
if isinstance(env_files, str):
env_files = [env_files]
for env_file in env_files:
flags += ["--env-file", str(Path(project_dir, env_file))]
flags += ["--env-file", _Expand(str(Path(project_dir, env_file)))]
for volume in svc.get("volumes") or []:
if ":" not in volume:
# Anonymous volume: a bare container path, no host source to translate.
flags += ["-v", volume]
flags += ["-v", _Expand(volume)]
continue
source, destination = volume.split(":", 1)
if source.startswith("."):
# Relative bind mount: resolve against project_dir.
source = str(Path(project_dir, source))
# Absolute bind mount (starts with "/") and named volume (bare
# identifier) are both kept as-is — neither is a path to translate.
flags += ["-v", f"{source}:{destination}"]
flags += ["-v", _Expand(f"{source}:{destination}")]
tmpfs = svc.get("tmpfs") or []
if isinstance(tmpfs, str):
tmpfs = [tmpfs]
for mount in tmpfs:
flags += ["--tmpfs", mount]
flags += ["--tmpfs", _Expand(mount)]
healthcheck = svc.get("healthcheck") or {}
_add_health_flags(flags, healthcheck)
return flags
Expand Down Expand Up @@ -115,11 +126,11 @@ class EmitOptions:
allow_exit_codes: list[int]


def _render(tokens: list[str]) -> str:
return " ".join(shlex.quote(token) for token in tokens)
def _render(tokens: list[Token]) -> str:
return " ".join(to_shell(token.value) if isinstance(token, _Expand) else shlex.quote(token) for token in tokens)


def _run_tokens(name: str, services: dict[str, Any], options: EmitOptions, hosts: list[str]) -> list[str]:
def _run_tokens(name: str, services: dict[str, Any], options: EmitOptions, hosts: list[str]) -> list[Token]:
svc = services[name]
tokens = run_flags(name, svc, options.pod, hosts, options.project_dir)
tokens.append(image_for(svc, options.ci_image))
Expand All @@ -130,7 +141,7 @@ def _run_tokens(name: str, services: dict[str, Any], options: EmitOptions, hosts
return tokens


def _emit_target(lines: list[str], run_tokens: list[str], options: EmitOptions) -> None:
def _emit_target(lines: list[str], run_tokens: list[Token], options: EmitOptions) -> None:
target_ctr = shlex.quote(f"{options.pod}-{options.target}")
lines.append("rc=0")
lines.append(f"podman run {_render(run_tokens)} || rc=$?")
Expand Down Expand Up @@ -181,3 +192,21 @@ def emit_script(compose: dict[str, Any], options: EmitOptions) -> str:
else:
lines.append(f"podman run -d {_render(run_tokens)}")
return "\n".join(lines) + "\n"


def referenced_variables(compose: dict[str, Any], options: EmitOptions) -> list[str]:
"""Variable names the emitted script expands at run time (sorted, unique).

Reuses the same token construction as `emit_script`, so it counts exactly
the values that pass through `to_shell` -- never a `${VAR}` in a field that
is emitted literally, and never the `--command` override (a literal CLI value).
"""
services = compose["services"]
hosts = hostnames(services)
order = startup_order(services, options.target)
names: set[str] = set()
for name in order:
for token in _run_tokens(name, services, options, hosts):
if isinstance(token, _Expand):
names.update(variable_names(token.value))
return sorted(names)
72 changes: 0 additions & 72 deletions compose2pod/interpolate.py

This file was deleted.

Loading