Skip to content

Commit c628826

Browse files
authored
Merge pull request #517 from avinxshKD/fix/run-docker-compose-513
2 parents 91bd9d9 + 4f1454c commit c628826

4 files changed

Lines changed: 255 additions & 5 deletions

File tree

concore_cli/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,21 @@ Generates and optionally builds a workflow from a GraphML file.
6262
- `-o, --output <dir>` - Output directory (default: out)
6363
- `-t, --type <type>` - Execution type: windows, posix, or docker (default: windows)
6464
- `--auto-build` - Automatically run build script after generation
65+
- `--compose` - Generate `docker-compose.yml` (only valid with `--type docker`)
6566

6667
**Example:**
6768
```bash
6869
concore run workflow.graphml --source ./src --output ./build --auto-build
6970
```
7071

72+
Docker compose example:
73+
74+
```bash
75+
concore run workflow.graphml --source ./src --output ./out --type docker --compose
76+
cd out
77+
docker compose up
78+
```
79+
7180
### `concore validate <workflow_file>`
7281

7382
Validates a GraphML workflow file before running.

concore_cli/cli.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,23 @@ def init(name, template, interactive):
7070
@click.option(
7171
"--auto-build", is_flag=True, help="Automatically run build after generation"
7272
)
73-
def run(workflow_file, source, output, type, auto_build):
73+
@click.option(
74+
"--compose",
75+
is_flag=True,
76+
help="Generate docker-compose.yml in output directory (docker type only)",
77+
)
78+
def run(workflow_file, source, output, type, auto_build, compose):
7479
"""Run a concore workflow"""
7580
try:
76-
run_workflow(workflow_file, source, output, type, auto_build, console)
81+
run_workflow(
82+
workflow_file,
83+
source,
84+
output,
85+
type,
86+
auto_build,
87+
console,
88+
compose=compose,
89+
)
7790
except Exception as e:
7891
console.print(f"[red]Error:[/red] {str(e)}")
7992
sys.exit(1)

concore_cli/commands/run.py

Lines changed: 132 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import sys
1+
import re
2+
import shlex
23
import subprocess
4+
import sys
35
from pathlib import Path
46
from rich.panel import Panel
57
from rich.progress import Progress, SpinnerColumn, TextColumn
@@ -15,7 +17,113 @@ def _find_mkconcore_path():
1517
return None
1618

1719

18-
def run_workflow(workflow_file, source, output, exec_type, auto_build, console):
20+
def _yaml_quote(value):
21+
return "'" + value.replace("'", "''") + "'"
22+
23+
24+
def _parse_docker_run_line(line):
25+
text = line.strip()
26+
if not text or text.startswith("#"):
27+
return None
28+
29+
if text.endswith("&"):
30+
text = text[:-1].strip()
31+
32+
try:
33+
tokens = shlex.split(text)
34+
except ValueError:
35+
return None
36+
37+
if "run" not in tokens:
38+
return None
39+
40+
run_index = tokens.index("run")
41+
args = tokens[run_index + 1 :]
42+
43+
container_name = None
44+
volumes = []
45+
image = None
46+
47+
i = 0
48+
while i < len(args):
49+
token = args[i]
50+
if token.startswith("--name="):
51+
container_name = token.split("=", 1)[1]
52+
elif token == "--name" and i + 1 < len(args):
53+
container_name = args[i + 1]
54+
i += 1
55+
elif token in ("-v", "--volume") and i + 1 < len(args):
56+
volumes.append(args[i + 1])
57+
i += 1
58+
elif token.startswith("--volume="):
59+
volumes.append(token.split("=", 1)[1])
60+
elif token.startswith("-"):
61+
pass
62+
else:
63+
image = token
64+
break
65+
i += 1
66+
67+
if not container_name or not image:
68+
return None
69+
70+
return {
71+
"container_name": container_name,
72+
"volumes": volumes,
73+
"image": image,
74+
}
75+
76+
77+
def _write_docker_compose(output_path):
78+
run_script = output_path / "run"
79+
if not run_script.exists():
80+
return None
81+
82+
services = []
83+
for line in run_script.read_text(encoding="utf-8").splitlines():
84+
parsed = _parse_docker_run_line(line)
85+
if parsed is not None:
86+
services.append(parsed)
87+
88+
if not services:
89+
return None
90+
91+
compose_lines = ["services:"]
92+
93+
for index, service in enumerate(services, start=1):
94+
service_name = re.sub(r"[^A-Za-z0-9_.-]", "-", service["container_name"]).strip(
95+
"-."
96+
)
97+
if not service_name:
98+
service_name = f"service-{index}"
99+
elif not service_name[0].isalnum():
100+
service_name = f"service-{service_name}"
101+
102+
compose_lines.append(f" {service_name}:")
103+
compose_lines.append(f" image: {_yaml_quote(service['image'])}")
104+
compose_lines.append(
105+
f" container_name: {_yaml_quote(service['container_name'])}"
106+
)
107+
if service["volumes"]:
108+
compose_lines.append(" volumes:")
109+
for volume_spec in service["volumes"]:
110+
compose_lines.append(f" - {_yaml_quote(volume_spec)}")
111+
112+
compose_lines.append("")
113+
compose_path = output_path / "docker-compose.yml"
114+
compose_path.write_text("\n".join(compose_lines), encoding="utf-8")
115+
return compose_path
116+
117+
118+
def run_workflow(
119+
workflow_file,
120+
source,
121+
output,
122+
exec_type,
123+
auto_build,
124+
console,
125+
compose=False,
126+
):
19127
workflow_path = Path(workflow_file).resolve()
20128
source_path = Path(source).resolve()
21129
output_path = Path(output).resolve()
@@ -34,8 +142,13 @@ def run_workflow(workflow_file, source, output, exec_type, auto_build, console):
34142
console.print(f"[cyan]Source:[/cyan] {source_path}")
35143
console.print(f"[cyan]Output:[/cyan] {output_path}")
36144
console.print(f"[cyan]Type:[/cyan] {exec_type}")
145+
if compose:
146+
console.print("[cyan]Compose:[/cyan] enabled")
37147
console.print()
38148

149+
if compose and exec_type != "docker":
150+
raise ValueError("--compose can only be used with --type docker")
151+
39152
mkconcore_path = _find_mkconcore_path()
40153
if mkconcore_path is None:
41154
raise FileNotFoundError(
@@ -73,6 +186,18 @@ def run_workflow(workflow_file, source, output, exec_type, auto_build, console):
73186
console.print(
74187
f"[green]✓[/green] Workflow generated in [cyan]{output_path}[/cyan]"
75188
)
189+
190+
if compose:
191+
compose_path = _write_docker_compose(output_path)
192+
if compose_path is not None:
193+
console.print(
194+
f"[green]✓[/green] Compose file written to [cyan]{compose_path}[/cyan]"
195+
)
196+
else:
197+
console.print(
198+
"[yellow]Warning:[/yellow] Could not generate docker-compose.yml from run script"
199+
)
200+
76201
try:
77202
metadata_path = write_study_metadata(
78203
output_path,
@@ -128,14 +253,18 @@ def run_workflow(workflow_file, source, output, exec_type, auto_build, console):
128253
if e.stderr:
129254
console.print(e.stderr)
130255

256+
run_command = "docker compose up" if compose else "./run"
257+
if exec_type == "windows":
258+
run_command = "run.bat"
259+
131260
console.print()
132261
console.print(
133262
Panel.fit(
134263
f"[green]✓[/green] Workflow ready!\n\n"
135264
f"To run your workflow:\n"
136265
f" cd {output_path}\n"
137266
f" {'build.bat' if exec_type == 'windows' else './build'}\n"
138-
f" {'run.bat' if exec_type == 'windows' else './run'}",
267+
f" {run_command}",
139268
title="Next Steps",
140269
border_style="green",
141270
)

tests/test_cli.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,105 @@ def test_run_command_docker_subdir_source_build_paths(self):
233233
self.assertIn("cp ../src/subdir/script.iport concore.iport", build_script)
234234
self.assertIn("cd ..", build_script)
235235

236+
def test_run_command_compose_requires_docker_type(self):
237+
with self.runner.isolated_filesystem(temp_dir=self.temp_dir):
238+
result = self.runner.invoke(cli, ["init", "test-project"])
239+
self.assertEqual(result.exit_code, 0)
240+
241+
result = self.runner.invoke(
242+
cli,
243+
[
244+
"run",
245+
"test-project/workflow.graphml",
246+
"--source",
247+
"test-project/src",
248+
"--output",
249+
"out",
250+
"--type",
251+
"posix",
252+
"--compose",
253+
],
254+
)
255+
self.assertNotEqual(result.exit_code, 0)
256+
self.assertIn(
257+
"--compose can only be used with --type docker", result.output
258+
)
259+
260+
def test_run_command_docker_compose_single_node(self):
261+
with self.runner.isolated_filesystem(temp_dir=self.temp_dir):
262+
result = self.runner.invoke(cli, ["init", "test-project"])
263+
self.assertEqual(result.exit_code, 0)
264+
265+
result = self.runner.invoke(
266+
cli,
267+
[
268+
"run",
269+
"test-project/workflow.graphml",
270+
"--source",
271+
"test-project/src",
272+
"--output",
273+
"out",
274+
"--type",
275+
"docker",
276+
"--compose",
277+
],
278+
)
279+
self.assertEqual(result.exit_code, 0)
280+
281+
compose_path = Path("out/docker-compose.yml")
282+
self.assertTrue(compose_path.exists())
283+
compose_content = compose_path.read_text()
284+
self.assertIn("services:", compose_content)
285+
self.assertIn("container_name: 'N1'", compose_content)
286+
self.assertIn("image: 'docker-script'", compose_content)
287+
288+
metadata = json.loads(Path("out/STUDY.json").read_text())
289+
self.assertIn("docker-compose.yml", metadata["checksums"])
290+
291+
def test_run_command_docker_compose_multi_node(self):
292+
with self.runner.isolated_filesystem(temp_dir=self.temp_dir):
293+
Path("src").mkdir()
294+
Path("src/common.py").write_text(
295+
"import concore\n\ndef step():\n return None\n"
296+
)
297+
298+
workflow = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
299+
<graphml xmlns="http://graphml.graphdrawing.org/xmlns" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://graphml.graphdrawing.org/xmlns http://www.yworks.com/xml/schema/graphml/1.1/ygraphml.xsd" xmlns:y="http://www.yworks.com/xml/graphml">
300+
<key for="node" id="d6" yfiles.type="nodegraphics"/>
301+
<key for="edge" id="d10" yfiles.type="edgegraphics"/>
302+
<graph edgedefault="directed" id="G">
303+
<node id="n1"><data key="d6"><y:ShapeNode><y:NodeLabel>A:common.py</y:NodeLabel></y:ShapeNode></data></node>
304+
<node id="n2"><data key="d6"><y:ShapeNode><y:NodeLabel>B:common.py</y:NodeLabel></y:ShapeNode></data></node>
305+
<node id="n3"><data key="d6"><y:ShapeNode><y:NodeLabel>C:common.py</y:NodeLabel></y:ShapeNode></data></node>
306+
<edge source="n1" target="n2"><data key="d10"><y:PolyLineEdge><y:EdgeLabel>0x1000_AB</y:EdgeLabel></y:PolyLineEdge></data></edge>
307+
<edge source="n2" target="n3"><data key="d10"><y:PolyLineEdge><y:EdgeLabel>0x1001_BC</y:EdgeLabel></y:PolyLineEdge></data></edge>
308+
</graph>
309+
</graphml>
310+
"""
311+
Path("workflow.graphml").write_text(workflow)
312+
313+
result = self.runner.invoke(
314+
cli,
315+
[
316+
"run",
317+
"workflow.graphml",
318+
"--source",
319+
"src",
320+
"--output",
321+
"out",
322+
"--type",
323+
"docker",
324+
"--compose",
325+
],
326+
)
327+
self.assertEqual(result.exit_code, 0)
328+
329+
compose_content = Path("out/docker-compose.yml").read_text()
330+
self.assertIn("container_name: 'A'", compose_content)
331+
self.assertIn("container_name: 'B'", compose_content)
332+
self.assertIn("container_name: 'C'", compose_content)
333+
self.assertIn("image: 'docker-common'", compose_content)
334+
236335
def test_run_command_shared_source_specialization_merges_edge_params(self):
237336
with self.runner.isolated_filesystem(temp_dir=self.temp_dir):
238337
Path("src").mkdir()

0 commit comments

Comments
 (0)