Skip to content

Commit c85233a

Browse files
committed
feat: improve docker-compose generation with restart, depends_on, network, and zmq mode
1 parent ef006ca commit c85233a

3 files changed

Lines changed: 134 additions & 14 deletions

File tree

concore_cli/cli.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,16 @@ def init(name, template, interactive):
7575
is_flag=True,
7676
help="Generate docker-compose.yml in output directory (docker type only)",
7777
)
78-
def build(workflow_file, source, output, type, auto_build, compose):
78+
@click.option(
79+
"--zmq",
80+
is_flag=True,
81+
help="Configure compose for ZMQ networking mode (requires --compose)",
82+
)
83+
def build(workflow_file, source, output, type, auto_build, compose, zmq):
7984
"""Compile a concore workflow into executable scripts"""
85+
if zmq and not compose:
86+
console.print("[red]Error:[/red] --zmq requires --compose")
87+
sys.exit(1)
8088
try:
8189
build_workflow(
8290
workflow_file,
@@ -86,6 +94,7 @@ def build(workflow_file, source, output, type, auto_build, compose):
8694
auto_build,
8795
console,
8896
compose=compose,
97+
zmq_mode=zmq,
8998
)
9099
except Exception as e:
91100
console.print(f"[red]Error:[/red] {str(e)}")

concore_cli/commands/build.py

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,17 @@ def _parse_docker_run_line(line):
7575
}
7676

7777

78-
def _write_docker_compose(output_path):
78+
def _write_docker_compose(output_path, console, zmq_mode=False):
7979
run_script = output_path / "run"
8080
if not run_script.exists():
81+
console.print(
82+
"[yellow]Warning:[/yellow] No docker run script found "
83+
f"in {output_path}."
84+
)
85+
console.print(
86+
"[dim]Tip: run concore build --type docker first, "
87+
"then use --compose[/dim]"
88+
)
8189
return None
8290

8391
services = []
@@ -89,15 +97,10 @@ def _write_docker_compose(output_path):
8997
if not services:
9098
return None
9199

92-
compose_lines = [
93-
"networks:",
94-
" concore-net:",
95-
" driver: bridge",
96-
"",
97-
"services:",
98-
]
100+
compose_lines = ["services:"]
99101

100102
named_volumes = set()
103+
previous_service_name = None
101104
for index, service in enumerate(services, start=1):
102105
service_name = re.sub(r"[^A-Za-z0-9_.-]", "-", service["container_name"]).strip(
103106
"-."
@@ -107,14 +110,11 @@ def _write_docker_compose(output_path):
107110
elif not service_name[0].isalpha():
108111
service_name = f"service-{service_name}"
109112

110-
compose_lines.append(f" {_yaml_quote(service_name)}:")
113+
compose_lines.append(f" {service_name}:")
111114
compose_lines.append(f" image: {_yaml_quote(service['image'])}")
112115
compose_lines.append(
113116
f" container_name: {_yaml_quote(service['container_name'])}"
114117
)
115-
compose_lines.append(" restart: on-failure")
116-
compose_lines.append(" networks:")
117-
compose_lines.append(" - concore-net")
118118

119119
if service["volumes"]:
120120
compose_lines.append(" volumes:")
@@ -124,12 +124,28 @@ def _write_docker_compose(output_path):
124124
if re.match(r"^[a-zA-Z0-9_-]+$", part1):
125125
named_volumes.add(part1)
126126

127+
compose_lines.append(" restart: on-failure")
128+
if zmq_mode:
129+
compose_lines.append(" environment:")
130+
compose_lines.append(" - CONCORE_TRANSPORT=zmq")
131+
if index > 1 and previous_service_name:
132+
compose_lines.append(" depends_on:")
133+
compose_lines.append(f" - {previous_service_name}")
134+
compose_lines.append(" networks:")
135+
compose_lines.append(" - concore_net")
136+
previous_service_name = service_name
137+
127138
if named_volumes:
128139
compose_lines.append("")
129140
compose_lines.append("volumes:")
130141
for v in sorted(named_volumes):
131142
compose_lines.append(f" {v}:")
132143

144+
compose_lines.append("")
145+
compose_lines.append("networks:")
146+
compose_lines.append(" concore_net:")
147+
compose_lines.append(" driver: bridge")
148+
133149
compose_lines.append("")
134150
compose_path = output_path / "docker-compose.yml"
135151
compose_path.write_text("\n".join(compose_lines), encoding="utf-8")
@@ -144,6 +160,7 @@ def build_workflow(
144160
auto_build,
145161
console,
146162
compose=False,
163+
zmq_mode=False,
147164
):
148165
workflow_path = Path(workflow_file).resolve()
149166
source_path = Path(source).resolve()
@@ -238,7 +255,9 @@ def build_workflow(
238255
)
239256

240257
if compose:
241-
compose_path = _write_docker_compose(output_path)
258+
compose_path = _write_docker_compose(
259+
output_path, console, zmq_mode=zmq_mode
260+
)
242261
if compose_path is not None:
243262
console.print(
244263
f"[green]✓[/green] Compose file written to [cyan]{compose_path}[/cyan]"

tests/test_compose_generation.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from concore_cli.commands.build import _write_docker_compose
2+
from rich.console import Console
3+
from pathlib import Path
4+
5+
6+
def _fake_run_script(output_dir, services):
7+
lines = [
8+
f"docker run --name {s['name']} -v /study:/study {s['image']} &"
9+
for s in services
10+
]
11+
(Path(output_dir) / "run").write_text("\n".join(lines))
12+
13+
14+
def test_compose_has_restart_policy(tmp_path):
15+
_fake_run_script(tmp_path, [{"name": "node1", "image": "concore/py"}])
16+
path = _write_docker_compose(tmp_path, Console(quiet=True))
17+
assert path is not None
18+
content = path.read_text()
19+
assert "restart: on-failure" in content
20+
21+
22+
def test_compose_has_network_section(tmp_path):
23+
_fake_run_script(tmp_path, [{"name": "node1", "image": "concore/py"}])
24+
path = _write_docker_compose(tmp_path, Console(quiet=True))
25+
content = path.read_text()
26+
assert "concore_net" in content
27+
assert "networks:" in content
28+
29+
30+
def test_compose_depends_on_second_service(tmp_path):
31+
_fake_run_script(
32+
tmp_path,
33+
[
34+
{"name": "controller", "image": "concore/py"},
35+
{"name": "plant", "image": "concore/cpp"},
36+
],
37+
)
38+
path = _write_docker_compose(tmp_path, Console(quiet=True))
39+
content = path.read_text()
40+
assert "depends_on" in content
41+
assert "controller" in content
42+
43+
44+
def test_compose_first_service_has_no_depends_on(tmp_path):
45+
_fake_run_script(
46+
tmp_path,
47+
[
48+
{"name": "controller", "image": "concore/py"},
49+
{"name": "plant", "image": "concore/cpp"},
50+
],
51+
)
52+
path = _write_docker_compose(tmp_path, Console(quiet=True))
53+
lines = path.read_text().splitlines()
54+
controller_idx = next(
55+
i for i, line in enumerate(lines) if "controller:" in line
56+
)
57+
plant_idx = next(
58+
i for i, line in enumerate(lines) if "plant:" in line
59+
)
60+
section = lines[controller_idx:plant_idx]
61+
assert not any("depends_on" in line for line in section)
62+
63+
64+
def test_zmq_mode_adds_env(tmp_path):
65+
_fake_run_script(tmp_path, [{"name": "node1", "image": "concore/py"}])
66+
path = _write_docker_compose(
67+
tmp_path, Console(quiet=True), zmq_mode=True
68+
)
69+
content = path.read_text()
70+
assert "CONCORE_TRANSPORT=zmq" in content
71+
72+
73+
def test_no_zmq_env_in_default_mode(tmp_path):
74+
_fake_run_script(tmp_path, [{"name": "node1", "image": "concore/py"}])
75+
path = _write_docker_compose(
76+
tmp_path, Console(quiet=True), zmq_mode=False
77+
)
78+
content = path.read_text()
79+
assert "CONCORE_TRANSPORT" not in content
80+
81+
82+
def test_missing_run_script_returns_none(tmp_path):
83+
result = _write_docker_compose(tmp_path, Console(quiet=True))
84+
assert result is None
85+
86+
87+
def test_zmq_without_compose_errors():
88+
from click.testing import CliRunner
89+
from concore_cli.cli import cli
90+
runner = CliRunner()
91+
result = runner.invoke(cli, ["build", "wf.graphml", "--zmq"])
92+
assert result.exit_code != 0

0 commit comments

Comments
 (0)