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.
- compose_farm/__init__.py +9 -0
- compose_farm/_version.py +34 -0
- compose_farm/cli.py +247 -0
- compose_farm/config.py +93 -0
- compose_farm/logs.py +231 -0
- compose_farm/py.typed +0 -0
- compose_farm/ssh.py +208 -0
- compose_farm/traefik.py +479 -0
- compose_farm-0.1.0.dist-info/METADATA +196 -0
- compose_farm-0.1.0.dist-info/RECORD +12 -0
- compose_farm-0.1.0.dist-info/WHEEL +4 -0
- compose_farm-0.1.0.dist-info/entry_points.txt +2 -0
compose_farm/__init__.py
ADDED
|
@@ -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__"]
|
compose_farm/_version.py
ADDED
|
@@ -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
|