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.
- verulla_cli/__init__.py +7 -0
- verulla_cli/apps_client.py +182 -0
- verulla_cli/auth.py +235 -0
- verulla_cli/buildkit.py +107 -0
- verulla_cli/deploy_client.py +47 -0
- verulla_cli/emulate.py +124 -0
- verulla_cli/main.py +1000 -0
- verulla_cli/packaging.py +106 -0
- verulla_cli/secrets_client.py +79 -0
- verulla_cli-0.0.2.dist-info/METADATA +8 -0
- verulla_cli-0.0.2.dist-info/RECORD +13 -0
- verulla_cli-0.0.2.dist-info/WHEEL +4 -0
- verulla_cli-0.0.2.dist-info/entry_points.txt +2 -0
verulla_cli/packaging.py
ADDED
|
@@ -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,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,,
|