verulla-cli 0.0.2__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,106 @@
1
+ """Deterministic source tarball packaging for ``verulla deploy``.
2
+
3
+ Identical source trees produce byte-identical ``.tar.gz`` output (normalized
4
+ mtimes/owners/modes, sorted walk, fixed gzip header), so the server sees an
5
+ identical sha256 and can dedupe uploads.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import gzip
11
+ import io
12
+ import os
13
+ import stat
14
+ import tarfile
15
+ from pathlib import Path
16
+
17
+ __all__ = ["DEFAULT_EXCLUDES", "PackagingError", "build_source_tarball"]
18
+
19
+ # Matched against ANY path component (directory or file name).
20
+ # `.env` is excluded deliberately: local dotenv files commonly hold secrets and
21
+ # must never be shipped in source uploads — secrets go through
22
+ # `verulla secrets set` instead.
23
+ DEFAULT_EXCLUDES: frozenset[str] = frozenset(
24
+ {
25
+ ".git",
26
+ ".hg",
27
+ ".svn",
28
+ "node_modules",
29
+ "__pycache__",
30
+ ".venv",
31
+ "venv",
32
+ ".mypy_cache",
33
+ ".pytest_cache",
34
+ ".ruff_cache",
35
+ ".DS_Store",
36
+ ".env",
37
+ }
38
+ )
39
+
40
+
41
+ class PackagingError(Exception):
42
+ """Raised when the source directory cannot be packaged for upload."""
43
+
44
+
45
+ def _normalized(info: tarfile.TarInfo, mode: int) -> tarfile.TarInfo:
46
+ info.mtime = 0
47
+ info.uid = 0
48
+ info.gid = 0
49
+ info.uname = ""
50
+ info.gname = ""
51
+ info.mode = mode
52
+ return info
53
+
54
+
55
+ def build_source_tarball(
56
+ base_dir: Path, excludes: set[str] | frozenset[str] = DEFAULT_EXCLUDES
57
+ ) -> bytes:
58
+ """Package ``base_dir`` as a reproducible gzip tarball with verulla.yml at its root."""
59
+ base_dir = Path(base_dir)
60
+ if not (base_dir / "verulla.yml").is_file():
61
+ raise PackagingError(f"{base_dir / 'verulla.yml'} not found")
62
+
63
+ raw = io.BytesIO()
64
+ try:
65
+ with tarfile.open(fileobj=raw, mode="w", format=tarfile.PAX_FORMAT) as tar:
66
+ for root, dirnames, filenames in os.walk(base_dir, followlinks=False):
67
+ root_path = Path(root)
68
+ # Prune excluded dirs and sort in place for a deterministic walk.
69
+ dirnames[:] = sorted(d for d in dirnames if d not in excludes and not _is_generated_dockerfile(d))
70
+ entries = sorted(
71
+ [(d, True) for d in dirnames]
72
+ + [(f, False) for f in filenames if f not in excludes and not _is_generated_dockerfile(f)]
73
+ )
74
+ for name, _is_dir in entries:
75
+ full = root_path / name
76
+ st = os.lstat(full)
77
+ # Symlinks are skipped entirely (never followed, never
78
+ # added): the server rejects links escaping the root
79
+ # anyway, and skipping avoids surprising uploads.
80
+ # os.walk(followlinks=False) still lists symlinked dirs in
81
+ # dirnames, so drop them from further traversal too.
82
+ if stat.S_ISLNK(st.st_mode):
83
+ if name in dirnames:
84
+ dirnames.remove(name)
85
+ continue
86
+ arcname = full.relative_to(base_dir).as_posix()
87
+ if stat.S_ISDIR(st.st_mode):
88
+ info = tar.gettarinfo(str(full), arcname=arcname)
89
+ tar.addfile(_normalized(info, 0o755))
90
+ elif stat.S_ISREG(st.st_mode):
91
+ info = tar.gettarinfo(str(full), arcname=arcname)
92
+ mode = 0o755 if st.st_mode & 0o111 else 0o644
93
+ with open(full, "rb") as fh:
94
+ tar.addfile(_normalized(info, mode), fh)
95
+ # Other file types (fifos, sockets, devices) are ignored.
96
+ except OSError as exc:
97
+ raise PackagingError(f"could not package {base_dir}: {exc}") from exc
98
+
99
+ out = io.BytesIO()
100
+ with gzip.GzipFile(fileobj=out, mode="wb", mtime=0, filename="") as gz:
101
+ gz.write(raw.getvalue())
102
+ return out.getvalue()
103
+
104
+
105
+ def _is_generated_dockerfile(name: str) -> bool:
106
+ return name.startswith(".verulla.") and name.endswith("Dockerfile")
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ from urllib.parse import quote
4
+
5
+ import httpx
6
+
7
+
8
+ class SecretsError(Exception):
9
+ """Raised for any failure talking to the control plane's /v1/secrets API."""
10
+
11
+
12
+ def _auth_headers(access_token: str) -> dict[str, str]:
13
+ return {"Authorization": f"Bearer {access_token}"}
14
+
15
+
16
+ def list_secret_keys(
17
+ api_url: str, access_token: str, http_client: httpx.Client | None = None, *, app: str | None = None
18
+ ) -> list[str]:
19
+ owns_client = http_client is None
20
+ client = http_client or httpx.Client(timeout=10)
21
+ try:
22
+ response = client.get(f"{api_url}/v1/secrets", params={"app": app} if app else {}, headers=_auth_headers(access_token))
23
+ response.raise_for_status()
24
+ except httpx.HTTPError as exc:
25
+ raise SecretsError(f"could not list secrets: {exc}") from exc
26
+ finally:
27
+ if owns_client:
28
+ client.close()
29
+ return response.json()["keys"]
30
+
31
+
32
+ def set_secret(
33
+ api_url: str,
34
+ access_token: str,
35
+ key: str,
36
+ value: str,
37
+ http_client: httpx.Client | None = None,
38
+ *,
39
+ app: str | None = None,
40
+ ) -> None:
41
+ owns_client = http_client is None
42
+ client = http_client or httpx.Client(timeout=10)
43
+ try:
44
+ response = client.put(
45
+ f"{api_url}/v1/secrets",
46
+ params={"app": app} if app else {},
47
+ json={"key": key, "value": value},
48
+ headers=_auth_headers(access_token),
49
+ )
50
+ response.raise_for_status()
51
+ except httpx.HTTPError as exc:
52
+ raise SecretsError(f"could not set secret {key!r}: {exc}") from exc
53
+ finally:
54
+ if owns_client:
55
+ client.close()
56
+
57
+
58
+ def delete_secret(
59
+ api_url: str,
60
+ access_token: str,
61
+ key: str,
62
+ http_client: httpx.Client | None = None,
63
+ *,
64
+ app: str | None = None,
65
+ ) -> None:
66
+ owns_client = http_client is None
67
+ client = http_client or httpx.Client(timeout=10)
68
+ try:
69
+ response = client.delete(
70
+ f"{api_url}/v1/secrets/{quote(key, safe='')}",
71
+ params={"app": app} if app else {},
72
+ headers=_auth_headers(access_token),
73
+ )
74
+ response.raise_for_status()
75
+ except httpx.HTTPError as exc:
76
+ raise SecretsError(f"could not delete secret {key!r}: {exc}") from exc
77
+ finally:
78
+ if owns_client:
79
+ client.close()
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.5
2
+ Name: verulla-cli
3
+ Version: 0.0.2
4
+ Requires-Python: >=3.12
5
+ Requires-Dist: docker>=7
6
+ Requires-Dist: httpx>=0.27
7
+ Requires-Dist: typer>=0.12
8
+ Requires-Dist: verulla-manifest==0.0.2
@@ -0,0 +1,13 @@
1
+ verulla_cli/__init__.py,sha256=aoSghCOQZzeIBMmXqw0X_d3SULg1761U-j4WQRAmusk,171
2
+ verulla_cli/apps_client.py,sha256=0cIUwX8hD2oTyh1pQ8GlGmqWhS-jiGK5nbc9lf2RFMo,8254
3
+ verulla_cli/auth.py,sha256=d_xM7ycifUpST9hJbQ5Q6RgXO4FR9a7Jx1-D8LQb-Us,8349
4
+ verulla_cli/buildkit.py,sha256=lI16uwpCVK_I3CZkdOXJdLtmUZ2b_VG4U9o5KmD1IXU,4947
5
+ verulla_cli/deploy_client.py,sha256=0N-BiNssXufUpMUhIUK80yNvab_fB4Xj1rluV2NOpcA,1409
6
+ verulla_cli/emulate.py,sha256=6PgJw4oTr5SpTxUW6UL3lX-l7RvCm9HSfvGvOsJxKq0,4905
7
+ verulla_cli/main.py,sha256=Vqxr82P8-jBsaTFzs5BfGKBLYD2Ia4TFQ1S61cwDj8o,41052
8
+ verulla_cli/packaging.py,sha256=nfUA1Jnxxx7pslGMkLpKV0Yvtkw1z9xPFdZnxi8bIQo,4025
9
+ verulla_cli/secrets_client.py,sha256=MCvDdq9XjnSJG6q7N5vLJ820V01m3yavYESiSi5kcmI,2314
10
+ verulla_cli-0.0.2.dist-info/METADATA,sha256=J8yekoWK9l61Zu-vLdCA6oX80nttVSlyX87n5dzJyMA,197
11
+ verulla_cli-0.0.2.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
12
+ verulla_cli-0.0.2.dist-info/entry_points.txt,sha256=WbdxWlCLOF6V1oq9lWXpVI33zu-U8a0tbksdcUHzd5c,49
13
+ verulla_cli-0.0.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ verulla = verulla_cli.main:app