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 ADDED
File without changes
compman/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from compman.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
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
@@ -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
@@ -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