aayi 0.1.1__py3-none-any.whl

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.
aayi/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Aayi: A Python-native container deployment tool with zero-downtime Caddy proxying."""
2
+
3
+ __version__ = "0.1.1"
aayi/cli/ci.py ADDED
@@ -0,0 +1,76 @@
1
+ """GitHub Actions CI/CD workflow generator for Aayi."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from rich.console import Console
8
+
9
+ console = Console()
10
+
11
+ GITHUB_WORKFLOW_TEMPLATE = """name: Deploy Application
12
+
13
+ on:
14
+ push:
15
+ branches:
16
+ - main
17
+
18
+ jobs:
19
+ deploy:
20
+ name: Deploy to Production
21
+ runs-on: ubuntu-latest
22
+ steps:
23
+ - name: Checkout Repository
24
+ uses: actions/checkout@v4
25
+
26
+ - name: Install uv
27
+ uses: astral-sh/setup-uv@v5
28
+ with:
29
+ version: "latest"
30
+
31
+ - name: Set up Docker Buildx
32
+ uses: docker/setup-buildx-action@v3
33
+
34
+ - name: Log in to Docker Registry
35
+ uses: docker/login-action@v3
36
+ with:
37
+ registry: ${{ secrets.DOCKER_REGISTRY || 'docker.io' }}
38
+ username: ${{ secrets.DOCKER_USERNAME }}
39
+ password: ${{ secrets.DOCKER_PASSWORD }}
40
+
41
+ - name: Set up SSH Private Key
42
+ uses: webfactory/ssh-agent@v0.9.0
43
+ with:
44
+ ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
45
+
46
+ - name: Add Known Hosts
47
+ run: |
48
+ mkdir -p ~/.ssh
49
+ # You can add: ssh-keyscan -H <YOUR_SERVER_IP> >> ~/.ssh/known_hosts
50
+
51
+ - name: Deploy with Aayi
52
+ env:
53
+ DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
54
+ run: |
55
+ uvx aayi deploy
56
+ """
57
+
58
+
59
+ def init_github_actions(force: bool = False) -> Path:
60
+ """Generate .github/workflows/deploy.yml for automated deployments."""
61
+ workflows_dir = Path(".github") / "workflows"
62
+ workflows_dir.mkdir(parents=True, exist_ok=True)
63
+ workflow_file = workflows_dir / "deploy.yml"
64
+
65
+ if workflow_file.exists() and not force:
66
+ console.print(f"[yellow]Skipped {workflow_file} (already exists). Use --force to overwrite.[/yellow]")
67
+ else:
68
+ workflow_file.write_text(GITHUB_WORKFLOW_TEMPLATE, encoding="utf-8")
69
+ console.print(f"[green]✓ Created GitHub Actions workflow at {workflow_file}[/green]")
70
+
71
+ console.print("\n[bold cyan]GitHub Actions setup tips:[/bold cyan]")
72
+ console.print(" Configure these repository secrets in GitHub ([bold]Settings > Secrets and variables > Actions[/bold]):")
73
+ console.print(" • [yellow]SSH_PRIVATE_KEY[/yellow]: The private SSH key authorized on your target servers.")
74
+ console.print(" • [yellow]DOCKER_USERNAME[/yellow]: Your Docker Hub or container registry username.")
75
+ console.print(" • [yellow]DOCKER_PASSWORD[/yellow]: Your container registry password or access token.\n")
76
+ return workflow_file
aayi/cli/init.py ADDED
@@ -0,0 +1,130 @@
1
+ """Project initialization and scaffolding generator for Aayi."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from rich.console import Console
8
+
9
+ console = Console()
10
+
11
+ DEPLOY_YML_TEMPLATE = """# Aayi Deployment Configuration
12
+ service: myapp
13
+ image: your-docker-user/myapp
14
+
15
+ # Server topology
16
+ servers:
17
+ web:
18
+ - 192.168.1.10
19
+ workers:
20
+ - 192.168.1.11
21
+ user: root
22
+ ssh_port: 22
23
+
24
+ # Docker container registry
25
+ registry:
26
+ server: docker.io
27
+ username: your-docker-user
28
+ password: ${DOCKER_REGISTRY_PASSWORD}
29
+
30
+ # Caddy Reverse Proxy & Zero-Downtime Traffic Routing
31
+ proxy:
32
+ domain: app.example.com
33
+ app_port: 8000
34
+ healthcheck_path: /healthz
35
+ healthcheck_timeout: 30
36
+ drain_timeout: 10
37
+ tls: true
38
+ email: admin@example.com
39
+
40
+ # Environment variables
41
+ env:
42
+ clear:
43
+ PORT: "8000"
44
+ ENVIRONMENT: production
45
+ secret:
46
+ - SECRET_KEY_BASE
47
+ - DATABASE_URL
48
+
49
+ # Attached stateful accessories (optional)
50
+ accessories:
51
+ postgres:
52
+ image: postgres:16-alpine
53
+ host: 192.168.1.10
54
+ port: "5432:5432"
55
+ env:
56
+ POSTGRES_DB: myapp_production
57
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
58
+ volumes:
59
+ - postgres_data:/var/lib/postgresql/data
60
+
61
+ redis:
62
+ image: redis:7-alpine
63
+ host: 192.168.1.10
64
+ port: "6379:6379"
65
+ volumes:
66
+ - redis_data:/data
67
+ """
68
+
69
+ DOCKERFILE_TEMPLATE = """# Multi-stage Dockerfile optimized with uv
70
+ FROM python:3.13-slim AS builder
71
+
72
+ WORKDIR /app
73
+ COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
74
+
75
+ COPY pyproject.toml uv.lock* ./
76
+ RUN uv sync --frozen --no-dev --no-install-project
77
+
78
+ COPY . .
79
+ RUN uv sync --frozen --no-dev
80
+
81
+ FROM python:3.13-slim
82
+
83
+ WORKDIR /app
84
+ COPY --from=builder /app/.venv /app/.venv
85
+ COPY . .
86
+
87
+ ENV PATH="/app/.venv/bin:$PATH"
88
+ EXPOSE 8000
89
+
90
+ CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
91
+ """
92
+
93
+ DOCKERIGNORE_TEMPLATE = """.git
94
+ .venv
95
+ __pycache__
96
+ *.pyc
97
+ .env
98
+ .pytest_cache
99
+ dist
100
+ build
101
+ """
102
+
103
+
104
+ def init_project(force: bool = False) -> None:
105
+ """Scaffold initial deployment configuration and Dockerfile."""
106
+ config_dir = Path("config")
107
+ config_dir.mkdir(exist_ok=True)
108
+ deploy_yml = config_dir / "deploy.yml"
109
+
110
+ if deploy_yml.exists() and not force:
111
+ console.print(f"[yellow]Skipped {deploy_yml} (already exists). Use --force to overwrite.[/yellow]")
112
+ else:
113
+ deploy_yml.write_text(DEPLOY_YML_TEMPLATE, encoding="utf-8")
114
+ console.print(f"[green]✓ Created {deploy_yml}[/green]")
115
+
116
+ dockerfile = Path("Dockerfile")
117
+ if not dockerfile.exists() or force:
118
+ dockerfile.write_text(DOCKERFILE_TEMPLATE, encoding="utf-8")
119
+ console.print(f"[green]✓ Created {dockerfile}[/green]")
120
+
121
+ dockerignore = Path(".dockerignore")
122
+ if not dockerignore.exists() or force:
123
+ dockerignore.write_text(DOCKERIGNORE_TEMPLATE, encoding="utf-8")
124
+ console.print(f"[green]✓ Created {dockerignore}[/green]")
125
+
126
+ console.print("\n[bold green]Project initialized for Aayi![/bold green]")
127
+ console.print("Next steps:")
128
+ console.print(" 1. Edit [cyan]config/deploy.yml[/cyan] with your server IP and domain.")
129
+ console.print(" 2. Set up target servers: [bold]aayi setup[/bold]")
130
+ console.print(" 3. Deploy your application: [bold]aayi deploy[/bold]\n")
aayi/cli/main.py ADDED
@@ -0,0 +1,276 @@
1
+ """Aayi CLI entrypoint powered by Typer and Rich."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import typer
10
+ from rich.console import Console
11
+
12
+ from aayi import __version__
13
+ from aayi.cli.ci import init_github_actions
14
+ from aayi.cli.init import init_project
15
+ from aayi.compose.parser import convert_compose_to_deploy_yaml, parse_compose_file
16
+ from aayi.config.loader import load_config
17
+ from aayi.orchestrator.accessories import AccessoryManager
18
+ from aayi.orchestrator.deployer import Deployer
19
+ from aayi.orchestrator.rollback import RollbackManager
20
+ from aayi.proxy.caddy import CaddyManager
21
+ from aayi.ssh.runner import SSHFleetRunner
22
+
23
+ app = typer.Typer(
24
+ name="aayi",
25
+ help="Modern Python container deployment orchestrator with zero-downtime Caddy proxying.",
26
+ add_completion=True,
27
+ rich_markup_mode="rich",
28
+ )
29
+ proxy_app = typer.Typer(help="Manage Caddy reverse proxy on remote hosts.")
30
+ accessory_app = typer.Typer(help="Manage attached stateful accessories (Postgres, Redis, etc.).")
31
+ ci_app = typer.Typer(help="Manage CI/CD workflows (GitHub Actions, etc.).")
32
+ compose_app = typer.Typer(help="Docker Compose file management and conversion.")
33
+
34
+ app.add_typer(proxy_app, name="proxy")
35
+ app.add_typer(accessory_app, name="accessory")
36
+ app.add_typer(ci_app, name="ci")
37
+ app.add_typer(compose_app, name="compose")
38
+
39
+ console = Console()
40
+
41
+
42
+ @app.command()
43
+ def init(
44
+ force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing configuration files."),
45
+ ci: bool = typer.Option(False, "--ci", help="Also generate GitHub Actions deployment workflow."),
46
+ ) -> None:
47
+ """Initialize Aayi deployment configuration (config/deploy.yml) and Dockerfile."""
48
+ init_project(force=force)
49
+ if ci:
50
+ init_github_actions(force=force)
51
+
52
+
53
+ @ci_app.command("init")
54
+ def ci_init(
55
+ force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing workflow files."),
56
+ ) -> None:
57
+ """Generate .github/workflows/deploy.yml for GitHub Actions CI/CD."""
58
+ init_github_actions(force=force)
59
+
60
+
61
+ @app.command()
62
+ def setup(
63
+ config_path: Path = typer.Option("config/deploy.yml", "--config", "-c", help="Path to deploy.yml"),
64
+ ) -> None:
65
+ """Prepare target servers: verify Docker CE and start Caddy reverse proxy."""
66
+ cfg = load_config(config_path)
67
+ runner = SSHFleetRunner(
68
+ default_user=cfg.servers.user,
69
+ default_port=cfg.servers.ssh_port,
70
+ default_key=cfg.servers.ssh_key,
71
+ )
72
+ deployer = Deployer(cfg)
73
+
74
+ async def _run() -> None:
75
+ console.rule("[bold blue]Setting up target hosts[/bold blue]")
76
+ await deployer.remote_docker.ensure_docker(cfg.servers.all_hosts)
77
+ if cfg.servers.web:
78
+ await deployer.caddy.ensure_running(cfg.servers.web)
79
+ console.rule("[bold green]✓ Server setup complete[/bold green]")
80
+
81
+ asyncio.run(_run())
82
+
83
+
84
+ @app.command()
85
+ def deploy(
86
+ config_path: Path = typer.Option("config/deploy.yml", "--config", "-c", help="Path to deploy.yml"),
87
+ compose: Optional[Path] = typer.Option(None, "--compose", help="Deploy directly from a docker-compose.yml file"),
88
+ service: Optional[str] = typer.Option(None, "--service", "-s", help="Deploy only a specific service container"),
89
+ tag: Optional[str] = typer.Option(None, "--tag", "-t", help="Custom image tag / git revision to deploy"),
90
+ build: bool = typer.Option(True, "--build/--no-build", help="Build image locally before deployment"),
91
+ push: bool = typer.Option(True, "--push/--no-push", help="Push image to registry after building"),
92
+ remote_build: bool = typer.Option(False, "--remote-build", "-r", help="Stream source in memory via tar and build natively on the VPS (no registry needed)."),
93
+ ) -> None:
94
+ """Run full zero-downtime deployment: build, push, healthcheck, and swap Caddy traffic."""
95
+ if compose:
96
+ cfg = parse_compose_file(compose)
97
+ else:
98
+ cfg = load_config(config_path)
99
+
100
+ deployer = Deployer(cfg)
101
+ asyncio.run(deployer.deploy(tag=tag, build=build, push=push, target_service=service, remote_build=remote_build))
102
+
103
+
104
+ @app.command()
105
+ def redeploy(
106
+ config_path: Path = typer.Option("config/deploy.yml", "--config", "-c", help="Path to deploy.yml"),
107
+ compose: Optional[Path] = typer.Option(None, "--compose", help="Deploy directly from a docker-compose.yml file"),
108
+ service: Optional[str] = typer.Option(None, "--service", "-s", help="Redeploy only a specific service container"),
109
+ tag: Optional[str] = typer.Option(None, "--tag", "-t", help="Tag to redeploy without rebuilding"),
110
+ ) -> None:
111
+ """Redeploy an existing image tag on remote servers without rebuilding."""
112
+ if compose:
113
+ cfg = parse_compose_file(compose)
114
+ else:
115
+ cfg = load_config(config_path)
116
+
117
+ deployer = Deployer(cfg)
118
+ asyncio.run(deployer.deploy(tag=tag, build=False, push=False, target_service=service))
119
+
120
+
121
+ @compose_app.command("convert")
122
+ def compose_convert(
123
+ compose_path: Path = typer.Option("docker-compose.yml", "--compose", "-f", help="Path to docker-compose.yml"),
124
+ host: str = typer.Option("192.168.1.10", "--host", "-h", help="Target server IP for deploy.yml"),
125
+ output: Optional[Path] = typer.Option(None, "--output", "-o", help="Write to file instead of stdout (e.g. config/deploy.yml)"),
126
+ ) -> None:
127
+ """Convert an existing docker-compose.yml into an Aayi config/deploy.yml."""
128
+ yaml_text = convert_compose_to_deploy_yaml(compose_path, web_host=host)
129
+ if output:
130
+ output.parent.mkdir(parents=True, exist_ok=True)
131
+ output.write_text(yaml_text, encoding="utf-8")
132
+ console.print(f"[green]✓ Successfully generated {output}[/green]")
133
+ else:
134
+ console.print(yaml_text)
135
+
136
+
137
+ @app.command()
138
+ def rollback(
139
+ config_path: Path = typer.Option("config/deploy.yml", "--config", "-c", help="Path to deploy.yml"),
140
+ host: Optional[str] = typer.Option(None, "--host", "-h", help="Specific host to rollback (defaults to all web hosts)"),
141
+ ) -> None:
142
+ """Roll back Caddy traffic upstream to the previous healthy container."""
143
+ cfg = load_config(config_path)
144
+ manager = RollbackManager(cfg)
145
+ asyncio.run(manager.rollback(target_host=host))
146
+
147
+
148
+ @app.command()
149
+ def logs(
150
+ config_path: Path = typer.Option("config/deploy.yml", "--config", "-c", help="Path to deploy.yml"),
151
+ role: str = typer.Option("web", "--role", "-r", help="Target server role ('web', 'workers', 'all')"),
152
+ tail: int = typer.Option(100, "--tail", "-n", help="Number of recent log lines to display"),
153
+ follow: bool = typer.Option(True, "--follow/--no-follow", help="Stream live logs in real time"),
154
+ ) -> None:
155
+ """Stream application logs concurrently across all target servers."""
156
+ cfg = load_config(config_path)
157
+ hosts = cfg.servers.hosts_for_role(role)
158
+ if not hosts:
159
+ console.print(f"[yellow]No hosts found for role '{role}'.[/yellow]")
160
+ return
161
+
162
+ runner = SSHFleetRunner(
163
+ default_user=cfg.servers.user,
164
+ default_port=cfg.servers.ssh_port,
165
+ default_key=cfg.servers.ssh_key,
166
+ )
167
+ cmd = f"docker logs {'-f' if follow else ''} --tail {tail} $(docker ps -q --filter 'label=aayi.service={cfg.service}' | head -n 1)"
168
+
169
+ async def _run() -> None:
170
+ if follow:
171
+ await runner.stream_logs(hosts, cmd)
172
+ else:
173
+ await runner.run_on_hosts(hosts, cmd)
174
+
175
+ asyncio.run(_run())
176
+
177
+
178
+ @app.command()
179
+ def exec(
180
+ command: str = typer.Argument(..., help="Command to execute inside the running service container"),
181
+ config_path: Path = typer.Option("config/deploy.yml", "--config", "-c", help="Path to deploy.yml"),
182
+ host: Optional[str] = typer.Option(None, "--host", "-h", help="Specific host (defaults to first web host)"),
183
+ ) -> None:
184
+ """Execute a command inside the currently running application container."""
185
+ cfg = load_config(config_path)
186
+ target_host = host or (cfg.servers.web[0] if cfg.servers.web else None)
187
+ if not target_host:
188
+ console.print("[red]No target host available.[/red]")
189
+ raise typer.Exit(1)
190
+
191
+ runner = SSHFleetRunner(
192
+ default_user=cfg.servers.user,
193
+ default_port=cfg.servers.ssh_port,
194
+ default_key=cfg.servers.ssh_key,
195
+ )
196
+ docker_cmd = f"docker exec -it $(docker ps -q --filter 'label=aayi.service={cfg.service}' | head -n 1) {command}"
197
+ asyncio.run(runner.run_on_host(target_host, docker_cmd))
198
+
199
+
200
+ @proxy_app.command("status")
201
+ def proxy_status(
202
+ config_path: Path = typer.Option("config/deploy.yml", "--config", "-c", help="Path to deploy.yml"),
203
+ ) -> None:
204
+ """View current Caddy proxy status and routing across web hosts."""
205
+ cfg = load_config(config_path)
206
+ runner = SSHFleetRunner(default_user=cfg.servers.user, default_port=cfg.servers.ssh_port, default_key=cfg.servers.ssh_key)
207
+ caddy = CaddyManager(runner, cfg.proxy)
208
+ asyncio.run(caddy.get_status(cfg.servers.web))
209
+
210
+
211
+ @proxy_app.command("reload")
212
+ def proxy_reload(
213
+ config_path: Path = typer.Option("config/deploy.yml", "--config", "-c", help="Path to deploy.yml"),
214
+ port: int = typer.Option(..., "--port", "-p", help="Target upstream port to route traffic to"),
215
+ ) -> None:
216
+ """Force Caddy to route traffic to a specific upstream port."""
217
+ cfg = load_config(config_path)
218
+ runner = SSHFleetRunner(default_user=cfg.servers.user, default_port=cfg.servers.ssh_port, default_key=cfg.servers.ssh_key)
219
+ caddy = CaddyManager(runner, cfg.proxy)
220
+ asyncio.run(caddy.update_upstream(cfg.servers.web, target_port=port))
221
+
222
+
223
+ @accessory_app.command("boot")
224
+ def accessory_boot(
225
+ name: str = typer.Argument(..., help="Name of the accessory to boot (e.g. postgres, redis)"),
226
+ config_path: Path = typer.Option("config/deploy.yml", "--config", "-c", help="Path to deploy.yml"),
227
+ ) -> None:
228
+ """Start an accessory container defined in deploy.yml."""
229
+ cfg = load_config(config_path)
230
+ manager = AccessoryManager(cfg)
231
+ asyncio.run(manager.boot(name))
232
+
233
+
234
+ @accessory_app.command("stop")
235
+ def accessory_stop(
236
+ name: str = typer.Argument(..., help="Name of the accessory to stop"),
237
+ config_path: Path = typer.Option("config/deploy.yml", "--config", "-c", help="Path to deploy.yml"),
238
+ ) -> None:
239
+ """Stop an accessory container."""
240
+ cfg = load_config(config_path)
241
+ manager = AccessoryManager(cfg)
242
+ asyncio.run(manager.stop(name))
243
+
244
+
245
+ @accessory_app.command("reboot")
246
+ def accessory_reboot(
247
+ name: str = typer.Argument(..., help="Name of the accessory to reboot"),
248
+ config_path: Path = typer.Option("config/deploy.yml", "--config", "-c", help="Path to deploy.yml"),
249
+ ) -> None:
250
+ """Restart an accessory container."""
251
+ cfg = load_config(config_path)
252
+ manager = AccessoryManager(cfg)
253
+ asyncio.run(manager.reboot(name))
254
+
255
+
256
+ @accessory_app.command("logs")
257
+ def accessory_logs(
258
+ name: str = typer.Argument(..., help="Name of the accessory to stream logs for"),
259
+ config_path: Path = typer.Option("config/deploy.yml", "--config", "-c", help="Path to deploy.yml"),
260
+ tail: int = typer.Option(100, "--tail", "-n", help="Number of lines to tail"),
261
+ follow: bool = typer.Option(True, "--follow/--no-follow", help="Stream logs in real-time"),
262
+ ) -> None:
263
+ """View or stream logs from an accessory container."""
264
+ cfg = load_config(config_path)
265
+ manager = AccessoryManager(cfg)
266
+ asyncio.run(manager.logs(name, follow=follow, tail=tail))
267
+
268
+
269
+ @app.command()
270
+ def version() -> None:
271
+ """Display Aayi version information."""
272
+ console.print(f"[bold cyan]Aayi[/bold cyan] version [bold green]{__version__}[/bold green]")
273
+
274
+
275
+ if __name__ == "__main__":
276
+ app()
aayi/compose/parser.py ADDED
@@ -0,0 +1,198 @@
1
+ """Docker Compose file parser and adapter for Aayi."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ import yaml
9
+
10
+ from aayi.config.models import DeployConfig, EnvConfig, ServerConfig, ServiceDefinition
11
+
12
+
13
+ def _parse_compose_env(env_data: Any) -> EnvConfig:
14
+ """Parse environment block from Docker Compose into clear and secret env vars."""
15
+ clear: Dict[str, str] = {}
16
+ secret: List[str] = []
17
+
18
+ if isinstance(env_data, list):
19
+ for item in env_data:
20
+ if isinstance(item, str):
21
+ if "=" in item:
22
+ k, v = item.split("=", 1)
23
+ clear[k.strip()] = v.strip().strip("'\"")
24
+ else:
25
+ secret.append(item.strip())
26
+ elif isinstance(env_data, dict):
27
+ for k, v in env_data.items():
28
+ if v is None or v == "":
29
+ secret.append(str(k))
30
+ else:
31
+ clear[str(k)] = str(v)
32
+
33
+ return EnvConfig(clear=clear, secret=secret)
34
+
35
+
36
+ def _parse_compose_labels(labels_data: Any) -> Dict[str, str]:
37
+ """Parse labels block from Docker Compose into a dictionary."""
38
+ labels: Dict[str, str] = {}
39
+ if isinstance(labels_data, list):
40
+ for item in labels_data:
41
+ if isinstance(item, str) and "=" in item:
42
+ k, v = item.split("=", 1)
43
+ labels[k.strip()] = v.strip().strip("'\"")
44
+ elif isinstance(labels_data, dict):
45
+ for k, v in labels_data.items():
46
+ labels[str(k)] = str(v)
47
+ return labels
48
+
49
+
50
+ def parse_compose_file(
51
+ compose_path: Path | str,
52
+ servers: Optional[ServerConfig] = None,
53
+ ) -> DeployConfig:
54
+ """Parse a docker-compose.yml into an Aayi DeployConfig with multi-service definitions."""
55
+ path = Path(compose_path)
56
+ if not path.is_file():
57
+ raise FileNotFoundError(f"Docker Compose file not found: {path}")
58
+
59
+ with open(path, "r", encoding="utf-8") as f:
60
+ data: Dict[str, Any] = yaml.safe_load(f) or {}
61
+
62
+ raw_services: Dict[str, Any] = data.get("services", {})
63
+ if not raw_services:
64
+ raise ValueError(f"No services found in compose file: {path}")
65
+
66
+ parsed_services: Dict[str, ServiceDefinition] = {}
67
+
68
+ for service_name, s_data in raw_services.items():
69
+ if not isinstance(s_data, dict):
70
+ continue
71
+
72
+ labels = _parse_compose_labels(s_data.get("labels", []))
73
+ env = _parse_compose_env(s_data.get("environment", {}))
74
+
75
+ # Check domain configuration
76
+ domain = labels.get("aayi.domain")
77
+ extra_domains_raw = labels.get("aayi.domains", "")
78
+ extra_domains = [d.strip() for d in extra_domains_raw.split(",") if d.strip()]
79
+
80
+ # Determine if public: default to True if domain is specified, or if aayi.public=true
81
+ if "aayi.public" in labels:
82
+ is_public = labels["aayi.public"].lower() in ("true", "1", "yes")
83
+ else:
84
+ is_public = bool(domain or extra_domains)
85
+
86
+ # Determine app port
87
+ port = 8000
88
+ if "aayi.port" in labels:
89
+ try:
90
+ port = int(labels["aayi.port"])
91
+ except ValueError:
92
+ port = 8000
93
+ elif s_data.get("ports"):
94
+ first_port = str(s_data["ports"][0])
95
+ # handle '8000:8000' or '8000'
96
+ parts = first_port.split(":")
97
+ try:
98
+ port = int(parts[-1].split("/")[0])
99
+ except ValueError:
100
+ port = 8000
101
+
102
+ # Build context / dockerfile
103
+ build_context: Optional[str] = None
104
+ dockerfile: Optional[str] = None
105
+ if "build" in s_data:
106
+ build_raw = s_data["build"]
107
+ if isinstance(build_raw, str):
108
+ build_context = build_raw
109
+ dockerfile = "Dockerfile"
110
+ elif isinstance(build_raw, dict):
111
+ build_context = build_raw.get("context", ".")
112
+ dockerfile = build_raw.get("dockerfile", "Dockerfile")
113
+
114
+ # Healthcheck
115
+ healthcheck_path = labels.get("aayi.healthcheck", "/")
116
+
117
+ # Roles
118
+ roles_raw = labels.get("aayi.roles", "web")
119
+ roles = [r.strip() for r in roles_raw.split(",") if r.strip()]
120
+
121
+ # Depends on
122
+ depends_on: List[str] = []
123
+ raw_deps = s_data.get("depends_on", [])
124
+ if isinstance(raw_deps, list):
125
+ depends_on = [str(d) for d in raw_deps]
126
+ elif isinstance(raw_deps, dict):
127
+ depends_on = [str(d) for d in raw_deps.keys()]
128
+
129
+ command = s_data.get("command")
130
+ if isinstance(command, list):
131
+ command = " ".join(command)
132
+
133
+ parsed_services[service_name] = ServiceDefinition(
134
+ name=service_name,
135
+ image=s_data.get("image"),
136
+ build_context=build_context,
137
+ dockerfile=dockerfile,
138
+ public=is_public,
139
+ domain=domain,
140
+ domains=extra_domains,
141
+ app_port=port,
142
+ healthcheck_path=healthcheck_path,
143
+ env=env,
144
+ roles=roles,
145
+ command=command,
146
+ depends_on=depends_on,
147
+ )
148
+
149
+ # If servers are not provided, default to localhost or placeholder
150
+ server_cfg = servers or ServerConfig(web=["127.0.0.1"])
151
+
152
+ return DeployConfig(
153
+ service=next(iter(parsed_services.keys())),
154
+ servers=server_cfg,
155
+ services=parsed_services,
156
+ )
157
+
158
+
159
+ def convert_compose_to_deploy_yaml(
160
+ compose_path: Path | str,
161
+ web_host: str = "192.168.1.10",
162
+ ) -> str:
163
+ """Generate a clean config/deploy.yml string from a Docker Compose file."""
164
+ cfg = parse_compose_file(compose_path, servers=ServerConfig(web=[web_host]))
165
+
166
+ export_dict: Dict[str, Any] = {
167
+ "servers": {
168
+ "web": [web_host],
169
+ "user": "root",
170
+ "ssh_port": 22,
171
+ },
172
+ "services": {},
173
+ }
174
+
175
+ for s_name, s_def in cfg.services.items():
176
+ s_dict: Dict[str, Any] = {
177
+ "public": s_def.public,
178
+ "app_port": s_def.app_port,
179
+ }
180
+ if s_def.image:
181
+ s_dict["image"] = s_def.image
182
+ if s_def.build_context:
183
+ s_dict["build_context"] = s_def.build_context
184
+ if s_def.public and s_def.all_domains:
185
+ s_dict["domain"] = s_def.domain
186
+ if s_def.domains:
187
+ s_dict["domains"] = s_def.domains
188
+ s_dict["healthcheck_path"] = s_def.healthcheck_path
189
+ if s_def.env.clear:
190
+ s_dict["env"] = {"clear": s_def.env.clear}
191
+ if s_def.depends_on:
192
+ s_dict["depends_on"] = s_def.depends_on
193
+ if s_def.command:
194
+ s_dict["command"] = s_def.command
195
+
196
+ export_dict["services"][s_name] = s_dict
197
+
198
+ return yaml.dump(export_dict, sort_keys=False)