compman 1.9.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.
- compman/__init__.py +0 -0
- compman/__main__.py +4 -0
- compman/_proc.py +26 -0
- compman/_seed_assets.py +39 -0
- compman/archive.py +79 -0
- compman/archive_source.py +43 -0
- compman/backup_store.py +253 -0
- compman/cli.py +762 -0
- compman/completion.py +124 -0
- compman/config.py +312 -0
- compman/deploy.py +183 -0
- compman/diagnostics.py +363 -0
- compman/docker.py +496 -0
- compman/env_source.py +121 -0
- compman/errors.py +14 -0
- compman/http_source.py +98 -0
- compman/i18n.py +1136 -0
- compman/init_cmd.py +68 -0
- compman/ops/__init__.py +0 -0
- compman/ops/common.py +307 -0
- compman/ops/container.py +124 -0
- compman/ops/image.py +115 -0
- compman/ops/schedule.py +193 -0
- compman/ops/seed.py +77 -0
- compman/ops/service.py +116 -0
- compman/ops/stack.py +98 -0
- compman/ops/volume.py +309 -0
- compman/s3_source.py +111 -0
- compman/scaffold.py +137 -0
- compman/scheduling/__init__.py +41 -0
- compman/scheduling/cadence.py +124 -0
- compman/scheduling/crontab.py +84 -0
- compman/scheduling/launchd.py +100 -0
- compman/scheduling/pick.py +36 -0
- compman/scheduling/registry.py +96 -0
- compman/scheduling/resolve.py +30 -0
- compman/scheduling/schtasks.py +54 -0
- compman/scheduling/systemd.py +96 -0
- compman-1.9.0.dist-info/METADATA +593 -0
- compman-1.9.0.dist-info/RECORD +44 -0
- compman-1.9.0.dist-info/WHEEL +5 -0
- compman-1.9.0.dist-info/entry_points.txt +2 -0
- compman-1.9.0.dist-info/licenses/LICENSE +21 -0
- compman-1.9.0.dist-info/top_level.txt +1 -0
compman/__init__.py
ADDED
|
File without changes
|
compman/__main__.py
ADDED
compman/_proc.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Shared subprocess timeout helpers.
|
|
2
|
+
|
|
3
|
+
Kept free of heavy imports so that lazily loaded command modules can use it
|
|
4
|
+
without pulling in AWS SDKs or the container-runtime layer.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
# Sentinel for streaming passthru commands: omit the subprocess timeout kwarg
|
|
12
|
+
# entirely so `docker logs -f`, `exec -it`, and `stats` (follow) never expire.
|
|
13
|
+
PASSTHRU_UNBOUNDED = float("inf")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _env_timeout() -> float:
|
|
17
|
+
raw = os.environ.get("COMPMAN_TIMEOUT")
|
|
18
|
+
if raw is None:
|
|
19
|
+
return 300.0
|
|
20
|
+
try:
|
|
21
|
+
value = float(raw)
|
|
22
|
+
except ValueError:
|
|
23
|
+
return 300.0
|
|
24
|
+
if value <= 0:
|
|
25
|
+
return 300.0
|
|
26
|
+
return value
|
compman/_seed_assets.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Static assets embedded into generated seed projects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
SEED_INDEX_HTML = (
|
|
6
|
+
'<!DOCTYPE html>\n'
|
|
7
|
+
'<html lang="en">\n'
|
|
8
|
+
'<head>\n'
|
|
9
|
+
' <meta charset="UTF-8">\n'
|
|
10
|
+
' <meta name="viewport" content="width=device-width, initial-scale=1.0">\n'
|
|
11
|
+
' <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">\n'
|
|
12
|
+
' <meta http-equiv="Pragma" content="no-cache">\n'
|
|
13
|
+
' <meta http-equiv="Expires" content="0">\n'
|
|
14
|
+
' <title>compman Seed App</title>\n'
|
|
15
|
+
' <style>\n'
|
|
16
|
+
' body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #f8fafc; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }\n'
|
|
17
|
+
' .card { background: #1e293b; border-radius: 1rem; padding: 2.5rem; border: 1px solid #334155; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5); text-align: center; max-width: 480px; width: 100%; }\n'
|
|
18
|
+
' h1 { color: #38bdf8; margin-top: 0; font-size: 1.75rem; }\n'
|
|
19
|
+
' .badge { background: #0284c7; color: white; padding: 0.25rem 0.75rem; border-radius: 9999px; font-size: 0.875rem; font-weight: 600; }\n'
|
|
20
|
+
' .time { font-size: 1.15rem; font-family: monospace; color: #a5f3fc; margin: 1.5rem 0; background: #0f172a; padding: 0.75rem; border-radius: 0.5rem; border: 1px solid #1e293b; }\n'
|
|
21
|
+
' </style>\n'
|
|
22
|
+
'</head>\n'
|
|
23
|
+
'<body>\n'
|
|
24
|
+
' <div class="card">\n'
|
|
25
|
+
' <h1>compman Seed App</h1>\n'
|
|
26
|
+
' <p><span class="badge">Nginx Alpine High-Performance</span></p>\n'
|
|
27
|
+
' <div class="time" id="clock">Loading time...</div>\n'
|
|
28
|
+
' <p style="color: #94a3b8; font-size: 0.875rem;">Instant Sub-Millisecond Container Response</p>\n'
|
|
29
|
+
' </div>\n'
|
|
30
|
+
' <script>\n'
|
|
31
|
+
' function updateTime() {\n'
|
|
32
|
+
' document.getElementById("clock").innerText = new Date().toLocaleString();\n'
|
|
33
|
+
' }\n'
|
|
34
|
+
' setInterval(updateTime, 1000);\n'
|
|
35
|
+
' updateTime();\n'
|
|
36
|
+
' </script>\n'
|
|
37
|
+
'</body>\n'
|
|
38
|
+
'</html>\n'
|
|
39
|
+
)
|
compman/archive.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tarfile
|
|
4
|
+
import zipfile
|
|
5
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
6
|
+
|
|
7
|
+
from compman.errors import CommandError
|
|
8
|
+
from compman.i18n import t
|
|
9
|
+
|
|
10
|
+
ZSTD_SUFFIX = ".tar.zst"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _load_zstd(import_module=None):
|
|
14
|
+
import importlib
|
|
15
|
+
|
|
16
|
+
loader = import_module or importlib.import_module
|
|
17
|
+
try:
|
|
18
|
+
return loader("compression.zstd") # Python 3.14+
|
|
19
|
+
except ImportError as exc:
|
|
20
|
+
raise CommandError(t("msg.zstd_requires_py314")) from exc
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def create_tar(source_dir: Path, dest: Path, *, zstd_format: bool = False, gzip_level: int = 6) -> None:
|
|
24
|
+
"""Write ``source_dir`` as a tarball at ``dest`` (gzip default, zstd opt-in)."""
|
|
25
|
+
if not zstd_format:
|
|
26
|
+
with tarfile.open(dest, "w:gz", compresslevel=gzip_level) as tar:
|
|
27
|
+
tar.add(source_dir, arcname=".")
|
|
28
|
+
return
|
|
29
|
+
zstd = _load_zstd()
|
|
30
|
+
with zstd.open(dest, "wb") as zout:
|
|
31
|
+
with tarfile.open(fileobj=zout, mode="w") as tar:
|
|
32
|
+
tar.add(source_dir, arcname=".")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def open_tarball(path: Path):
|
|
36
|
+
"""Open a local backup archive for extraction, gating zstd on 3.14+."""
|
|
37
|
+
if path.name.lower().endswith(ZSTD_SUFFIX):
|
|
38
|
+
_load_zstd()
|
|
39
|
+
return tarfile.open(path, "r:*")
|
|
40
|
+
return tarfile.open(path, "r:gz")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def extract_tar(archive: tarfile.TarFile, destination: Path, max_bytes: int | None = None) -> None:
|
|
44
|
+
members = archive.getmembers()
|
|
45
|
+
total = 0
|
|
46
|
+
for member in members:
|
|
47
|
+
_validate_path(destination, member.name)
|
|
48
|
+
if member.issym() or member.islnk():
|
|
49
|
+
raise ValueError(f"Archive links are not allowed: {member.name}")
|
|
50
|
+
if member.isdev() or member.isfifo():
|
|
51
|
+
raise ValueError(f"Unsupported archive member type: {member.name}")
|
|
52
|
+
total += member.size
|
|
53
|
+
if max_bytes is not None and total > max_bytes:
|
|
54
|
+
raise CommandError(t("msg.deploy_limit_exceeded", limit=_limit_mb(max_bytes), size=total))
|
|
55
|
+
for member in members:
|
|
56
|
+
archive.extract(member, destination)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def extract_zip(archive: zipfile.ZipFile, destination: Path, max_bytes: int | None = None) -> None:
|
|
60
|
+
if max_bytes is not None:
|
|
61
|
+
total = sum(member.file_size for member in archive.infolist())
|
|
62
|
+
if total > max_bytes:
|
|
63
|
+
raise CommandError(t("msg.deploy_limit_exceeded", limit=_limit_mb(max_bytes), size=total))
|
|
64
|
+
for member in archive.infolist():
|
|
65
|
+
_validate_path(destination, member.filename)
|
|
66
|
+
archive.extract(member, destination)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _limit_mb(max_bytes: int) -> int:
|
|
70
|
+
return (max_bytes + 1024 * 1024 - 1) // (1024 * 1024)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _validate_path(destination: Path, name: str) -> None:
|
|
74
|
+
if not name or PurePosixPath(name).is_absolute() or PureWindowsPath(name).is_absolute():
|
|
75
|
+
raise ValueError(f"Unsafe archive path: {name}")
|
|
76
|
+
root = destination.resolve()
|
|
77
|
+
target = (destination / name).resolve()
|
|
78
|
+
if target != root and root not in target.parents:
|
|
79
|
+
raise ValueError(f"Unsafe archive path: {name}")
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import tarfile
|
|
5
|
+
import zipfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from compman.archive import extract_tar, extract_zip
|
|
9
|
+
from compman.errors import CommandError
|
|
10
|
+
from compman.i18n import t
|
|
11
|
+
|
|
12
|
+
ARCHIVE_SUFFIXES = (".tar.gz", ".tgz", ".zip")
|
|
13
|
+
_HASH_CHUNK_SIZE = 1024 * 1024
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def has_archive_suffix(path: str) -> bool:
|
|
17
|
+
return path.lower().endswith(ARCHIVE_SUFFIXES)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def sha256_file(path: Path) -> str:
|
|
21
|
+
digest = hashlib.sha256()
|
|
22
|
+
with path.open("rb") as source:
|
|
23
|
+
for chunk in iter(lambda: source.read(_HASH_CHUNK_SIZE), b""):
|
|
24
|
+
digest.update(chunk)
|
|
25
|
+
return digest.hexdigest()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def ensure_digest(actual: str, expected: str) -> None:
|
|
29
|
+
if actual.lower() != expected.lower():
|
|
30
|
+
raise CommandError(t("msg.deploy_checksum_mismatch", expected=expected, actual=actual))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def extract_archive(archive_path: Path, extract_dir: Path, max_bytes: int | None = None) -> Path:
|
|
34
|
+
extract_dir.mkdir()
|
|
35
|
+
if archive_path.name.lower().endswith(".zip"):
|
|
36
|
+
with zipfile.ZipFile(archive_path) as zip_source:
|
|
37
|
+
extract_zip(zip_source, extract_dir, max_bytes=max_bytes)
|
|
38
|
+
else:
|
|
39
|
+
with tarfile.open(archive_path) as tar_source:
|
|
40
|
+
extract_tar(tar_source, extract_dir, max_bytes=max_bytes)
|
|
41
|
+
|
|
42
|
+
contents = [path for path in extract_dir.iterdir() if path.name != ".gitkeep"]
|
|
43
|
+
return contents[0] if len(contents) == 1 and contents[0].is_dir() else extract_dir
|
compman/backup_store.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import tempfile
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from compman.errors import CommandError, ConfigError
|
|
14
|
+
from compman.i18n import t
|
|
15
|
+
from compman.s3_source import create_client, s3_error_hint
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class LocalBackupStore:
|
|
20
|
+
"""Backups stored in a directory inside the config tree."""
|
|
21
|
+
|
|
22
|
+
root: Path
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def is_remote(self) -> bool:
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class S3BackupStore:
|
|
31
|
+
"""Backups stored under ``s3://bucket/prefix``."""
|
|
32
|
+
|
|
33
|
+
bucket: str
|
|
34
|
+
prefix: str
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def is_remote(self) -> bool:
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
BackupStore = LocalBackupStore | S3BackupStore
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_backup_store(value: str) -> BackupStore:
|
|
45
|
+
"""Parse a ``dirs.backup`` value into a typed backup store."""
|
|
46
|
+
scheme, sep, rest = value.partition("://")
|
|
47
|
+
if not sep:
|
|
48
|
+
return LocalBackupStore(root=Path(value))
|
|
49
|
+
if scheme != "s3":
|
|
50
|
+
raise ConfigError(
|
|
51
|
+
f"'dirs.backup' must be a relative path or an 's3://bucket/prefix' URI: {value}"
|
|
52
|
+
)
|
|
53
|
+
bucket, _, prefix = rest.partition("/")
|
|
54
|
+
if not bucket:
|
|
55
|
+
raise ConfigError(f"'dirs.backup' S3 URI is missing a bucket name: {value}")
|
|
56
|
+
return S3BackupStore(bucket=bucket, prefix=prefix.strip("/"))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def local_root(store: BackupStore) -> Path:
|
|
60
|
+
"""Return the filesystem root of a local store."""
|
|
61
|
+
if not isinstance(store, LocalBackupStore):
|
|
62
|
+
raise ValueError("backup store is not local")
|
|
63
|
+
return store.root
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def archive_location(store: BackupStore, name: str) -> str:
|
|
67
|
+
"""Return the user-facing location of an archive in the store."""
|
|
68
|
+
if isinstance(store, LocalBackupStore):
|
|
69
|
+
return str(store.root / name)
|
|
70
|
+
prefix = f"{store.prefix}/" if store.prefix else ""
|
|
71
|
+
return f"s3://{store.bucket}/{prefix}{name}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _object_key(store: S3BackupStore, name: str) -> str:
|
|
75
|
+
return f"{store.prefix}/{name}" if store.prefix else name
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def new_backup_paths(store: BackupStore, stack: str, kind: str, *, zstd_format: bool = False) -> tuple[Path, Path]:
|
|
79
|
+
"""Return a fresh (directory, tarball) pair for a new backup archive."""
|
|
80
|
+
if isinstance(store, LocalBackupStore):
|
|
81
|
+
root = store.root
|
|
82
|
+
now = datetime.now()
|
|
83
|
+
timestamp = now.strftime("%Y%m%d_%H%M%S")
|
|
84
|
+
backup_name = f"{stack}.{kind}.{timestamp}"
|
|
85
|
+
backup_dir = root / backup_name
|
|
86
|
+
tarball = root / f"{backup_name}{_suffix_for(zstd_format)}"
|
|
87
|
+
if backup_dir.exists() or tarball.exists():
|
|
88
|
+
timestamp = now.strftime("%Y%m%d_%H%M%S_%f")
|
|
89
|
+
backup_name = f"{stack}.{kind}.{timestamp}"
|
|
90
|
+
backup_dir = root / backup_name
|
|
91
|
+
tarball = root / f"{backup_name}{_suffix_for(zstd_format)}"
|
|
92
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
return backup_dir, tarball
|
|
94
|
+
workdir = Path(tempfile.mkdtemp(prefix="compman-"))
|
|
95
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
|
96
|
+
tarball = workdir / f"{stack}.{kind}.{timestamp}{_suffix_for(zstd_format)}"
|
|
97
|
+
return workdir, tarball
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def put_archive(store: BackupStore, name: str, local_path: Path) -> str:
|
|
101
|
+
"""Publish the finished archive and return its user-facing location.
|
|
102
|
+
|
|
103
|
+
A local store keeps archives in place. A remote store uploads the archive,
|
|
104
|
+
verifies the stored size, and then deletes the staged copy together with
|
|
105
|
+
its staging directory; on failure the staged archive is kept and named in
|
|
106
|
+
the error.
|
|
107
|
+
"""
|
|
108
|
+
if isinstance(store, LocalBackupStore):
|
|
109
|
+
return str(local_path)
|
|
110
|
+
uri = archive_location(store, name)
|
|
111
|
+
local_size = local_path.stat().st_size
|
|
112
|
+
try:
|
|
113
|
+
key = _object_key(store, name)
|
|
114
|
+
s3 = create_client()
|
|
115
|
+
s3.upload_file(
|
|
116
|
+
Filename=str(local_path),
|
|
117
|
+
Bucket=store.bucket,
|
|
118
|
+
Key=key,
|
|
119
|
+
ExtraArgs={"ContentType": "application/zstd" if name.endswith(".tar.zst") else "application/gzip"},
|
|
120
|
+
)
|
|
121
|
+
remote_size = int(s3.head_object(Bucket=store.bucket, Key=key)["ContentLength"])
|
|
122
|
+
except Exception as exc:
|
|
123
|
+
hint = s3_error_hint(exc, uri) or exc
|
|
124
|
+
detail = f"{hint}; staged archive kept at {local_path}"
|
|
125
|
+
raise CommandError(t("msg.backup_store_error", detail=detail)) from exc
|
|
126
|
+
if remote_size != local_size:
|
|
127
|
+
detail = (
|
|
128
|
+
f"uploaded size {remote_size} != local size {local_size}; "
|
|
129
|
+
f"staged archive kept at {local_path}"
|
|
130
|
+
)
|
|
131
|
+
raise CommandError(t("msg.backup_store_error", detail=detail))
|
|
132
|
+
workdir = local_path.parent
|
|
133
|
+
local_path.unlink(missing_ok=True)
|
|
134
|
+
shutil.rmtree(workdir, ignore_errors=True)
|
|
135
|
+
return uri
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def fetch_archive(store: BackupStore, name: str, dest: Path) -> None:
|
|
139
|
+
"""Materialize ``name`` at ``dest``.
|
|
140
|
+
|
|
141
|
+
Local archives are read in place, so fetching is a no-op; remote stores
|
|
142
|
+
download the object to ``dest``.
|
|
143
|
+
"""
|
|
144
|
+
if isinstance(store, LocalBackupStore):
|
|
145
|
+
return
|
|
146
|
+
key = _object_key(store, name)
|
|
147
|
+
typer.echo(t("msg.backup_downloading", name=name, path=f"s3://{store.bucket}/{key}"))
|
|
148
|
+
try:
|
|
149
|
+
create_client().download_file(store.bucket, key, str(dest))
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
hint = s3_error_hint(exc, f"s3://{store.bucket}/{key}") or exc
|
|
152
|
+
raise CommandError(t("msg.backup_store_error", detail=str(hint))) from exc
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def find_archive(store: BackupStore, stack: str, kind: str, timestamp: str) -> str | None:
|
|
156
|
+
"""Return the stored base name (with suffix) for ``timestamp``, or None."""
|
|
157
|
+
for suffix in (".tar.gz", ".tar.zst"):
|
|
158
|
+
candidate = f"{stack}.{kind}.{timestamp}{suffix}"
|
|
159
|
+
if isinstance(store, LocalBackupStore):
|
|
160
|
+
if (store.root / candidate).is_file():
|
|
161
|
+
return candidate
|
|
162
|
+
continue
|
|
163
|
+
try:
|
|
164
|
+
create_client().head_object(Bucket=store.bucket, Key=_object_key(store, candidate))
|
|
165
|
+
return candidate
|
|
166
|
+
except Exception as exc:
|
|
167
|
+
from botocore.exceptions import ClientError as _ClientError
|
|
168
|
+
|
|
169
|
+
if isinstance(exc, _ClientError) and str(
|
|
170
|
+
exc.response.get("Error", {}).get("Code", "")
|
|
171
|
+
) in ("404", "NoSuchKey", "NotFound"):
|
|
172
|
+
continue
|
|
173
|
+
raise
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def delete_archive(store: BackupStore, name: str) -> None:
|
|
178
|
+
"""Delete the archive ``name`` (a base name without extension) from the store.
|
|
179
|
+
|
|
180
|
+
Both gzip and zstd variants are removed when present; S3 deletions are
|
|
181
|
+
idempotent, so a missing object is not an error.
|
|
182
|
+
"""
|
|
183
|
+
if isinstance(store, LocalBackupStore):
|
|
184
|
+
for suffix in (".tar.gz", ".tar.zst"):
|
|
185
|
+
(store.root / f"{name}{suffix}").unlink(missing_ok=True)
|
|
186
|
+
return
|
|
187
|
+
client = create_client()
|
|
188
|
+
for suffix in (".tar.gz", ".tar.zst"):
|
|
189
|
+
try:
|
|
190
|
+
client.delete_object(
|
|
191
|
+
Bucket=store.bucket, Key=_object_key(store, f"{name}{suffix}")
|
|
192
|
+
)
|
|
193
|
+
except Exception as exc:
|
|
194
|
+
uri = archive_location(store, f"{name}{suffix}")
|
|
195
|
+
hint = s3_error_hint(exc, uri) or exc
|
|
196
|
+
raise CommandError(t("msg.backup_store_error", detail=str(hint))) from exc
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def list_archives(store: BackupStore, stack: str, kind: str) -> list[str]:
|
|
200
|
+
"""Return backup timestamps for ``stack`` and ``kind``, most recent first."""
|
|
201
|
+
if isinstance(store, LocalBackupStore):
|
|
202
|
+
pattern = f"{stack}.{kind}."
|
|
203
|
+
names = [
|
|
204
|
+
entry.name[len(pattern):]
|
|
205
|
+
for entry in store.root.glob(f"{pattern}*")
|
|
206
|
+
if entry.name.endswith((".tar.gz", ".tar.zst"))
|
|
207
|
+
]
|
|
208
|
+
return sorted((_strip_suffix(n) for n in names), reverse=True)
|
|
209
|
+
prefix = f"{store.prefix}/" if store.prefix else ""
|
|
210
|
+
marker = f"{prefix}{stack}.{kind}."
|
|
211
|
+
timestamps: list[str] = []
|
|
212
|
+
try:
|
|
213
|
+
paginator = create_client().get_paginator("list_objects_v2")
|
|
214
|
+
pages = paginator.paginate(Bucket=store.bucket, Prefix=prefix)
|
|
215
|
+
for page in pages:
|
|
216
|
+
for obj in page.get("Contents", []):
|
|
217
|
+
key = obj["Key"]
|
|
218
|
+
if key.startswith(marker) and key.endswith((".tar.gz", ".tar.zst")):
|
|
219
|
+
timestamps.append(_strip_suffix(key[len(marker):]))
|
|
220
|
+
except Exception as exc:
|
|
221
|
+
hint = s3_error_hint(exc, f"s3://{store.bucket}/{prefix}") or exc
|
|
222
|
+
raise CommandError(t("msg.backup_store_error", detail=str(hint))) from exc
|
|
223
|
+
return sorted(timestamps, reverse=True)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@contextmanager
|
|
227
|
+
def staged_archive(store: BackupStore, name: str) -> Iterator[Path]:
|
|
228
|
+
"""Yield a readable path for ``name``, staging remote archives in a tempdir.
|
|
229
|
+
|
|
230
|
+
Local stores yield the archive where it lies; remote stores download into
|
|
231
|
+
a private staging directory that is removed on exit.
|
|
232
|
+
"""
|
|
233
|
+
if isinstance(store, LocalBackupStore):
|
|
234
|
+
yield store.root / name
|
|
235
|
+
return
|
|
236
|
+
stage = Path(tempfile.mkdtemp(prefix="compman-"))
|
|
237
|
+
try:
|
|
238
|
+
tarball = stage / name
|
|
239
|
+
fetch_archive(store, name, tarball)
|
|
240
|
+
yield tarball
|
|
241
|
+
finally:
|
|
242
|
+
shutil.rmtree(stage, ignore_errors=True)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _suffix_for(zstd_format: bool) -> str:
|
|
246
|
+
return ".tar.zst" if zstd_format else ".tar.gz"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _strip_suffix(name: str) -> str:
|
|
250
|
+
for suffix in (".tar.zst", ".tar.gz"):
|
|
251
|
+
if name.endswith(suffix):
|
|
252
|
+
return name[: -len(suffix)]
|
|
253
|
+
return name
|