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 +3 -0
- aayi/cli/ci.py +76 -0
- aayi/cli/init.py +130 -0
- aayi/cli/main.py +276 -0
- aayi/compose/parser.py +198 -0
- aayi/config/loader.py +84 -0
- aayi/config/models.py +155 -0
- aayi/docker/builder.py +63 -0
- aayi/docker/remote.py +204 -0
- aayi/docker/remote_builder.py +82 -0
- aayi/docker/tar_builder.py +121 -0
- aayi/orchestrator/accessories.py +91 -0
- aayi/orchestrator/deployer.py +284 -0
- aayi/orchestrator/rollback.py +63 -0
- aayi/proxy/caddy.py +133 -0
- aayi/ssh/runner.py +163 -0
- aayi-0.1.1.dist-info/METADATA +185 -0
- aayi-0.1.1.dist-info/RECORD +20 -0
- aayi-0.1.1.dist-info/WHEEL +4 -0
- aayi-0.1.1.dist-info/entry_points.txt +3 -0
aayi/__init__.py
ADDED
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)
|