compose-farm 0.1.0__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.
@@ -0,0 +1,9 @@
1
+ """Compose Farm - run docker compose commands across multiple hosts."""
2
+
3
+ try:
4
+ from compose_farm._version import __version__, __version_tuple__
5
+ except ImportError:
6
+ __version__ = "0.0.0"
7
+ __version_tuple__ = (0, 0, 0)
8
+
9
+ __all__ = ["__version__", "__version_tuple__"]
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.1.0'
32
+ __version_tuple__ = version_tuple = (0, 1, 0)
33
+
34
+ __commit_id__ = commit_id = None
compose_farm/cli.py ADDED
@@ -0,0 +1,247 @@
1
+ """CLI interface using Typer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Annotated, TypeVar
8
+
9
+ import typer
10
+ import yaml
11
+
12
+ from . import __version__
13
+ from .config import Config, load_config
14
+ from .logs import snapshot_services
15
+ from .ssh import (
16
+ CommandResult,
17
+ run_on_services,
18
+ run_sequential_on_services,
19
+ )
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import Coroutine
23
+
24
+ T = TypeVar("T")
25
+
26
+ def _version_callback(value: bool) -> None:
27
+ """Print version and exit."""
28
+ if value:
29
+ typer.echo(f"compose-farm {__version__}")
30
+ raise typer.Exit
31
+
32
+
33
+ app = typer.Typer(
34
+ name="compose-farm",
35
+ help="Compose Farm - run docker compose commands across multiple hosts",
36
+ no_args_is_help=True,
37
+ )
38
+
39
+
40
+ @app.callback()
41
+ def main(
42
+ version: Annotated[
43
+ bool,
44
+ typer.Option(
45
+ "--version",
46
+ "-v",
47
+ help="Show version and exit",
48
+ callback=_version_callback,
49
+ is_eager=True,
50
+ ),
51
+ ] = False,
52
+ ) -> None:
53
+ """Compose Farm - run docker compose commands across multiple hosts."""
54
+
55
+
56
+ def _get_services(
57
+ services: list[str],
58
+ all_services: bool,
59
+ config_path: Path | None,
60
+ ) -> tuple[list[str], Config]:
61
+ """Resolve service list and load config."""
62
+ config = load_config(config_path)
63
+
64
+ if all_services:
65
+ return list(config.services.keys()), config
66
+ if not services:
67
+ typer.echo("Error: Specify services or use --all", err=True)
68
+ raise typer.Exit(1)
69
+ return list(services), config
70
+
71
+
72
+ def _run_async(coro: Coroutine[None, None, T]) -> T:
73
+ """Run async coroutine."""
74
+ return asyncio.run(coro)
75
+
76
+
77
+ def _report_results(results: list[CommandResult]) -> None:
78
+ """Report command results and exit with appropriate code."""
79
+ failed = [r for r in results if not r.success]
80
+ if failed:
81
+ for r in failed:
82
+ typer.echo(f"[{r.service}] Failed with exit code {r.exit_code}", err=True)
83
+ raise typer.Exit(1)
84
+
85
+
86
+ ServicesArg = Annotated[
87
+ list[str] | None,
88
+ typer.Argument(help="Services to operate on"),
89
+ ]
90
+ AllOption = Annotated[
91
+ bool,
92
+ typer.Option("--all", "-a", help="Run on all services"),
93
+ ]
94
+ ConfigOption = Annotated[
95
+ Path | None,
96
+ typer.Option("--config", "-c", help="Path to config file"),
97
+ ]
98
+ LogPathOption = Annotated[
99
+ Path | None,
100
+ typer.Option("--log-path", "-l", help="Path to Dockerfarm TOML log"),
101
+ ]
102
+
103
+
104
+ @app.command()
105
+ def up(
106
+ services: ServicesArg = None,
107
+ all_services: AllOption = False,
108
+ config: ConfigOption = None,
109
+ ) -> None:
110
+ """Start services (docker compose up -d)."""
111
+ svc_list, cfg = _get_services(services or [], all_services, config)
112
+ results = _run_async(run_on_services(cfg, svc_list, "up -d"))
113
+ _report_results(results)
114
+
115
+
116
+ @app.command()
117
+ def down(
118
+ services: ServicesArg = None,
119
+ all_services: AllOption = False,
120
+ config: ConfigOption = None,
121
+ ) -> None:
122
+ """Stop services (docker compose down)."""
123
+ svc_list, cfg = _get_services(services or [], all_services, config)
124
+ results = _run_async(run_on_services(cfg, svc_list, "down"))
125
+ _report_results(results)
126
+
127
+
128
+ @app.command()
129
+ def pull(
130
+ services: ServicesArg = None,
131
+ all_services: AllOption = False,
132
+ config: ConfigOption = None,
133
+ ) -> None:
134
+ """Pull latest images (docker compose pull)."""
135
+ svc_list, cfg = _get_services(services or [], all_services, config)
136
+ results = _run_async(run_on_services(cfg, svc_list, "pull"))
137
+ _report_results(results)
138
+
139
+
140
+ @app.command()
141
+ def restart(
142
+ services: ServicesArg = None,
143
+ all_services: AllOption = False,
144
+ config: ConfigOption = None,
145
+ ) -> None:
146
+ """Restart services (down + up)."""
147
+ svc_list, cfg = _get_services(services or [], all_services, config)
148
+ results = _run_async(run_sequential_on_services(cfg, svc_list, ["down", "up -d"]))
149
+ _report_results(results)
150
+
151
+
152
+ @app.command()
153
+ def update(
154
+ services: ServicesArg = None,
155
+ all_services: AllOption = False,
156
+ config: ConfigOption = None,
157
+ ) -> None:
158
+ """Update services (pull + down + up)."""
159
+ svc_list, cfg = _get_services(services or [], all_services, config)
160
+ results = _run_async(run_sequential_on_services(cfg, svc_list, ["pull", "down", "up -d"]))
161
+ _report_results(results)
162
+
163
+
164
+ @app.command()
165
+ def logs(
166
+ services: ServicesArg = None,
167
+ all_services: AllOption = False,
168
+ follow: Annotated[bool, typer.Option("--follow", "-f", help="Follow logs")] = False,
169
+ tail: Annotated[int, typer.Option("--tail", "-n", help="Number of lines")] = 100,
170
+ config: ConfigOption = None,
171
+ ) -> None:
172
+ """Show service logs."""
173
+ svc_list, cfg = _get_services(services or [], all_services, config)
174
+ cmd = f"logs --tail {tail}"
175
+ if follow:
176
+ cmd += " -f"
177
+ results = _run_async(run_on_services(cfg, svc_list, cmd))
178
+ _report_results(results)
179
+
180
+
181
+ @app.command()
182
+ def ps(
183
+ config: ConfigOption = None,
184
+ ) -> None:
185
+ """Show status of all services."""
186
+ cfg = load_config(config)
187
+ results = _run_async(run_on_services(cfg, list(cfg.services.keys()), "ps"))
188
+ _report_results(results)
189
+
190
+
191
+ @app.command()
192
+ def snapshot(
193
+ services: ServicesArg = None,
194
+ all_services: AllOption = False,
195
+ log_path: LogPathOption = None,
196
+ config: ConfigOption = None,
197
+ ) -> None:
198
+ """Record current image digests into the Dockerfarm TOML log."""
199
+ svc_list, cfg = _get_services(services or [], all_services, config)
200
+ try:
201
+ path = _run_async(snapshot_services(cfg, svc_list, log_path=log_path))
202
+ except RuntimeError as exc: # pragma: no cover - error path
203
+ typer.echo(str(exc), err=True)
204
+ raise typer.Exit(1) from exc
205
+
206
+ typer.echo(f"Snapshot written to {path}")
207
+
208
+
209
+ @app.command("traefik-file")
210
+ def traefik_file(
211
+ services: ServicesArg = None,
212
+ all_services: AllOption = False,
213
+ output: Annotated[
214
+ Path | None,
215
+ typer.Option(
216
+ "--output",
217
+ "-o",
218
+ help="Write Traefik file-provider YAML to this path (stdout if omitted)",
219
+ ),
220
+ ] = None,
221
+ config: ConfigOption = None,
222
+ ) -> None:
223
+ """Generate a Traefik file-provider fragment from compose Traefik labels."""
224
+ from .traefik import generate_traefik_config
225
+
226
+ svc_list, cfg = _get_services(services or [], all_services, config)
227
+ try:
228
+ dynamic, warnings = generate_traefik_config(cfg, svc_list)
229
+ except (FileNotFoundError, ValueError) as exc:
230
+ typer.echo(str(exc), err=True)
231
+ raise typer.Exit(1) from exc
232
+
233
+ rendered = yaml.safe_dump(dynamic, sort_keys=False)
234
+
235
+ if output:
236
+ output.parent.mkdir(parents=True, exist_ok=True)
237
+ output.write_text(rendered)
238
+ typer.echo(f"Traefik config written to {output}")
239
+ else:
240
+ typer.echo(rendered)
241
+
242
+ for warning in warnings:
243
+ typer.echo(warning, err=True)
244
+
245
+
246
+ if __name__ == "__main__":
247
+ app()
compose_farm/config.py ADDED
@@ -0,0 +1,93 @@
1
+ """Configuration loading and Pydantic models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+ from pydantic import BaseModel, Field, model_validator
10
+
11
+
12
+ class Host(BaseModel):
13
+ """SSH host configuration."""
14
+
15
+ address: str
16
+ user: str = Field(default_factory=getpass.getuser)
17
+ port: int = 22
18
+
19
+
20
+ class Config(BaseModel):
21
+ """Main configuration."""
22
+
23
+ compose_dir: Path = Path("/opt/compose")
24
+ hosts: dict[str, Host]
25
+ services: dict[str, str] # service_name -> host_name
26
+
27
+ @model_validator(mode="after")
28
+ def validate_service_hosts(self) -> Config:
29
+ """Ensure all services reference valid hosts."""
30
+ for service, host_name in self.services.items():
31
+ if host_name not in self.hosts:
32
+ msg = f"Service '{service}' references unknown host '{host_name}'"
33
+ raise ValueError(msg)
34
+ return self
35
+
36
+ def get_host(self, service: str) -> Host:
37
+ """Get host config for a service."""
38
+ if service not in self.services:
39
+ msg = f"Unknown service: {service}"
40
+ raise ValueError(msg)
41
+ return self.hosts[self.services[service]]
42
+
43
+ def get_compose_path(self, service: str) -> Path:
44
+ """Get compose file path for a service."""
45
+ return self.compose_dir / service / "docker-compose.yml"
46
+
47
+
48
+ def _parse_hosts(raw_hosts: dict[str, str | dict[str, str | int]]) -> dict[str, Host]:
49
+ """Parse hosts from config, handling both simple and full forms."""
50
+ hosts = {}
51
+ for name, value in raw_hosts.items():
52
+ if isinstance(value, str):
53
+ # Simple form: hostname: address
54
+ hosts[name] = Host(address=value)
55
+ else:
56
+ # Full form: hostname: {address: ..., user: ..., port: ...}
57
+ hosts[name] = Host(**value)
58
+ return hosts
59
+
60
+
61
+ def load_config(path: Path | None = None) -> Config:
62
+ """Load configuration from YAML file.
63
+
64
+ Search order:
65
+ 1. Explicit path if provided
66
+ 2. ./compose-farm.yaml
67
+ 3. ~/.config/compose-farm/compose-farm.yaml
68
+ """
69
+ search_paths = [
70
+ Path("compose-farm.yaml"),
71
+ Path.home() / ".config" / "compose-farm" / "compose-farm.yaml",
72
+ ]
73
+
74
+ if path:
75
+ config_path = path
76
+ else:
77
+ config_path = None
78
+ for p in search_paths:
79
+ if p.exists():
80
+ config_path = p
81
+ break
82
+
83
+ if config_path is None or not config_path.exists():
84
+ msg = f"Config file not found. Searched: {', '.join(str(p) for p in search_paths)}"
85
+ raise FileNotFoundError(msg)
86
+
87
+ with config_path.open() as f:
88
+ raw = yaml.safe_load(f)
89
+
90
+ # Parse hosts with flexible format support
91
+ raw["hosts"] = _parse_hosts(raw.get("hosts", {}))
92
+
93
+ return Config(**raw)
compose_farm/logs.py ADDED
@@ -0,0 +1,231 @@
1
+ """Snapshot current compose images into a TOML log."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import tomllib
7
+ from dataclasses import dataclass
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from .ssh import run_compose
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Awaitable, Callable, Iterable
16
+
17
+ from .config import Config
18
+ from .ssh import CommandResult
19
+
20
+
21
+ DEFAULT_LOG_PATH = Path.home() / ".config" / "compose-farm" / "dockerfarm-log.toml"
22
+ DIGEST_HEX_LENGTH = 64
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class SnapshotEntry:
27
+ """Normalized image snapshot for a single service."""
28
+
29
+ service: str
30
+ host: str
31
+ compose_file: Path
32
+ image: str
33
+ digest: str
34
+ captured_at: datetime
35
+
36
+ def as_dict(self, first_seen: str, last_seen: str) -> dict[str, str]:
37
+ """Render snapshot as a TOML-friendly dict."""
38
+ return {
39
+ "service": self.service,
40
+ "host": self.host,
41
+ "compose_file": str(self.compose_file),
42
+ "image": self.image,
43
+ "digest": self.digest,
44
+ "first_seen": first_seen,
45
+ "last_seen": last_seen,
46
+ }
47
+
48
+
49
+ def _isoformat(dt: datetime) -> str:
50
+ return dt.astimezone(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
51
+
52
+
53
+ def _escape(value: str) -> str:
54
+ return value.replace("\\", "\\\\").replace('"', '\\"')
55
+
56
+
57
+ def _parse_images_output(raw: str) -> list[dict[str, Any]]:
58
+ """Parse `docker compose images --format json` output.
59
+
60
+ Handles both a JSON array and newline-separated JSON objects for robustness.
61
+ """
62
+ raw = raw.strip()
63
+ if not raw:
64
+ return []
65
+
66
+ try:
67
+ parsed = json.loads(raw)
68
+ except json.JSONDecodeError:
69
+ objects = []
70
+ for line in raw.splitlines():
71
+ if not line.strip():
72
+ continue
73
+ objects.append(json.loads(line))
74
+ return objects
75
+
76
+ if isinstance(parsed, list):
77
+ return parsed
78
+ if isinstance(parsed, dict):
79
+ return [parsed]
80
+ return []
81
+
82
+
83
+ def _extract_image_fields(record: dict[str, Any]) -> tuple[str, str]:
84
+ """Extract image name and digest with fallbacks."""
85
+ image = record.get("Image") or record.get("Repository") or record.get("Name") or ""
86
+ tag = record.get("Tag") or record.get("Version")
87
+ if tag and ":" not in image.rsplit("/", 1)[-1]:
88
+ image = f"{image}:{tag}"
89
+
90
+ digest = (
91
+ record.get("Digest")
92
+ or record.get("Image ID")
93
+ or record.get("ImageID")
94
+ or record.get("ID")
95
+ or ""
96
+ )
97
+
98
+ if digest and not digest.startswith("sha256:") and len(digest) == DIGEST_HEX_LENGTH:
99
+ digest = f"sha256:{digest}"
100
+
101
+ return image, digest
102
+
103
+
104
+ async def _collect_service_entries(
105
+ config: Config,
106
+ service: str,
107
+ *,
108
+ now: datetime,
109
+ run_compose_fn: Callable[..., Awaitable[CommandResult]] = run_compose,
110
+ ) -> list[SnapshotEntry]:
111
+ """Run `docker compose images` for a service and normalize results."""
112
+ result = await run_compose_fn(config, service, "images --format json", stream=False)
113
+ if not result.success:
114
+ msg = result.stderr or f"compose images exited with {result.exit_code}"
115
+ error = f"[{service}] Unable to read images: {msg}"
116
+ raise RuntimeError(error)
117
+
118
+ records = _parse_images_output(result.stdout)
119
+ host_name = config.services[service]
120
+ compose_path = config.get_compose_path(service)
121
+
122
+ entries: list[SnapshotEntry] = []
123
+ for record in records:
124
+ image, digest = _extract_image_fields(record)
125
+ if not digest:
126
+ continue
127
+ entries.append(
128
+ SnapshotEntry(
129
+ service=service,
130
+ host=host_name,
131
+ compose_file=compose_path,
132
+ image=image,
133
+ digest=digest,
134
+ captured_at=now,
135
+ )
136
+ )
137
+ return entries
138
+
139
+
140
+ def _load_existing_entries(log_path: Path) -> list[dict[str, str]]:
141
+ if not log_path.exists():
142
+ return []
143
+ data = tomllib.loads(log_path.read_text())
144
+ return list(data.get("entries", []))
145
+
146
+
147
+ def _merge_entries(
148
+ existing: Iterable[dict[str, str]],
149
+ new_entries: Iterable[SnapshotEntry],
150
+ *,
151
+ now_iso: str,
152
+ ) -> list[dict[str, str]]:
153
+ merged: dict[tuple[str, str, str], dict[str, str]] = {
154
+ (e["service"], e["host"], e["digest"]): dict(e) for e in existing
155
+ }
156
+
157
+ for entry in new_entries:
158
+ key = (entry.service, entry.host, entry.digest)
159
+ first_seen = merged.get(key, {}).get("first_seen", now_iso)
160
+ merged[key] = entry.as_dict(first_seen, now_iso)
161
+
162
+ return list(merged.values())
163
+
164
+
165
+ def _write_toml(log_path: Path, *, meta: dict[str, str], entries: list[dict[str, str]]) -> None:
166
+ lines: list[str] = ["[meta]"]
167
+ lines.extend(f'{key} = "{_escape(meta[key])}"' for key in sorted(meta))
168
+
169
+ if entries:
170
+ lines.append("")
171
+
172
+ for entry in sorted(entries, key=lambda e: (e["service"], e["host"], e["digest"])):
173
+ lines.append("[[entries]]")
174
+ for field in [
175
+ "service",
176
+ "host",
177
+ "compose_file",
178
+ "image",
179
+ "digest",
180
+ "first_seen",
181
+ "last_seen",
182
+ ]:
183
+ value = entry[field]
184
+ lines.append(f'{field} = "{_escape(str(value))}"')
185
+ lines.append("")
186
+
187
+ content = "\n".join(lines).rstrip() + "\n"
188
+ log_path.parent.mkdir(parents=True, exist_ok=True)
189
+ log_path.write_text(content)
190
+
191
+
192
+ async def snapshot_services(
193
+ config: Config,
194
+ services: list[str],
195
+ *,
196
+ log_path: Path | None = None,
197
+ now: datetime | None = None,
198
+ run_compose_fn: Callable[..., Awaitable[CommandResult]] = run_compose,
199
+ ) -> Path:
200
+ """Capture current image digests for services and write them to a TOML log.
201
+
202
+ - Preserves the earliest `first_seen` per (service, host, digest)
203
+ - Updates `last_seen` for digests observed in this snapshot
204
+ - Leaves untouched digests that were not part of this run (history is kept)
205
+ """
206
+ if not services:
207
+ error = "No services specified for snapshot"
208
+ raise RuntimeError(error)
209
+
210
+ log_path = log_path or DEFAULT_LOG_PATH
211
+ now_dt = now or datetime.now(UTC)
212
+ now_iso = _isoformat(now_dt)
213
+
214
+ existing_entries = _load_existing_entries(log_path)
215
+
216
+ snapshot_entries: list[SnapshotEntry] = []
217
+ for service in services:
218
+ snapshot_entries.extend(
219
+ await _collect_service_entries(
220
+ config, service, now=now_dt, run_compose_fn=run_compose_fn
221
+ )
222
+ )
223
+
224
+ if not snapshot_entries:
225
+ error = "No image digests were captured"
226
+ raise RuntimeError(error)
227
+
228
+ merged_entries = _merge_entries(existing_entries, snapshot_entries, now_iso=now_iso)
229
+ meta = {"generated_at": now_iso, "compose_dir": str(config.compose_dir)}
230
+ _write_toml(log_path, meta=meta, entries=merged_entries)
231
+ return log_path
compose_farm/py.typed ADDED
File without changes