urun-cli 0.1.1__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.
- urun/__init__.py +1 -0
- urun/api.py +189 -0
- urun/cli.py +148 -0
- urun/config.py +115 -0
- urun/deps.py +92 -0
- urun/discovery.py +204 -0
- urun/errors.py +10 -0
- urun/manifest.py +78 -0
- urun_cli-0.1.1.dist-info/METADATA +130 -0
- urun_cli-0.1.1.dist-info/RECORD +13 -0
- urun_cli-0.1.1.dist-info/WHEEL +4 -0
- urun_cli-0.1.1.dist-info/entry_points.txt +3 -0
- urun_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
urun/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
urun/api.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .errors import ApiError, UrunError
|
|
13
|
+
from .manifest import FileBlob, hash_file
|
|
14
|
+
|
|
15
|
+
DEFAULT_API_URL = "https://api.urun.sh/v1"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ApiClient:
|
|
19
|
+
def __init__(self, base_url: str, api_key: str, timeout: float = 30.0, attempts: int = 3):
|
|
20
|
+
require_safe_url(base_url, "API URL")
|
|
21
|
+
self.base_url = base_url.rstrip("/")
|
|
22
|
+
self.api_key = api_key
|
|
23
|
+
self.timeout = timeout
|
|
24
|
+
self.attempts = attempts
|
|
25
|
+
|
|
26
|
+
def register_manifest(self, manifest: dict[str, Any]) -> dict[str, Any]:
|
|
27
|
+
return self._json("POST", "/register-manifest", manifest)
|
|
28
|
+
|
|
29
|
+
def finalize(self, manifest_hash: str) -> dict[str, Any]:
|
|
30
|
+
return self._json("POST", f"/finalize/{manifest_hash}", None)
|
|
31
|
+
|
|
32
|
+
def deployment_status(self, manifest_hash: str) -> dict[str, Any]:
|
|
33
|
+
return self._json("GET", f"/deployment-status/{manifest_hash}", None)
|
|
34
|
+
|
|
35
|
+
def _json(self, method: str, path: str, body: dict[str, Any] | None) -> dict[str, Any]:
|
|
36
|
+
data = (
|
|
37
|
+
None
|
|
38
|
+
if body is None
|
|
39
|
+
else json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
40
|
+
)
|
|
41
|
+
payload = b""
|
|
42
|
+
for attempt in range(1, self.attempts + 1):
|
|
43
|
+
req = urllib.request.Request(
|
|
44
|
+
self.base_url + path,
|
|
45
|
+
data=data,
|
|
46
|
+
method=method,
|
|
47
|
+
headers={
|
|
48
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
49
|
+
"Accept": "application/json",
|
|
50
|
+
"Content-Type": "application/json",
|
|
51
|
+
"User-Agent": f"urun-cli/{__version__}",
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
try:
|
|
55
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
56
|
+
payload = resp.read()
|
|
57
|
+
break
|
|
58
|
+
except urllib.error.HTTPError as exc:
|
|
59
|
+
payload = exc.read()
|
|
60
|
+
code = "http_error"
|
|
61
|
+
message = payload.decode("utf-8", "replace") or exc.reason
|
|
62
|
+
try:
|
|
63
|
+
parsed = json.loads(payload.decode("utf-8"))
|
|
64
|
+
err = parsed.get("error") or {}
|
|
65
|
+
code = err.get("code") or code
|
|
66
|
+
message = err.get("message") or message
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
if exc.code not in {429, 500, 502, 503, 504} or attempt == self.attempts:
|
|
70
|
+
raise ApiError(exc.code, code, message) from exc
|
|
71
|
+
except (urllib.error.URLError, TimeoutError) as exc:
|
|
72
|
+
if attempt == self.attempts:
|
|
73
|
+
reason = getattr(exc, "reason", exc)
|
|
74
|
+
raise UrunError(f"network error: {reason}") from exc
|
|
75
|
+
time.sleep(0.25 * attempt)
|
|
76
|
+
if not payload:
|
|
77
|
+
return {}
|
|
78
|
+
try:
|
|
79
|
+
return json.loads(payload.decode("utf-8"))
|
|
80
|
+
except json.JSONDecodeError as exc:
|
|
81
|
+
raise UrunError("invalid JSON response from urun API") from exc
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def upload_missing_blobs(
|
|
85
|
+
missing_blobs: list[dict[str, Any]], by_sha: dict[str, FileBlob], concurrency: int = 8
|
|
86
|
+
) -> None:
|
|
87
|
+
if not missing_blobs:
|
|
88
|
+
return
|
|
89
|
+
if concurrency < 1:
|
|
90
|
+
raise UrunError("upload concurrency must be at least 1")
|
|
91
|
+
with ThreadPoolExecutor(max_workers=concurrency) as executor:
|
|
92
|
+
futures = []
|
|
93
|
+
seen: set[str] = set()
|
|
94
|
+
for item in missing_blobs:
|
|
95
|
+
if not isinstance(item, dict):
|
|
96
|
+
raise UrunError("invalid missing_blobs item from server")
|
|
97
|
+
sha = item.get("sha256")
|
|
98
|
+
upload_url = item.get("upload_url")
|
|
99
|
+
if not isinstance(sha, str) or not isinstance(upload_url, str):
|
|
100
|
+
raise UrunError("invalid missing_blobs item from server")
|
|
101
|
+
if sha in seen:
|
|
102
|
+
continue
|
|
103
|
+
seen.add(sha)
|
|
104
|
+
if sha not in by_sha:
|
|
105
|
+
raise UrunError(f"server requested unknown blob: {sha}")
|
|
106
|
+
futures.append(executor.submit(upload_blob, upload_url, by_sha[sha]))
|
|
107
|
+
for fut in as_completed(futures):
|
|
108
|
+
fut.result()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def upload_blob(upload_url: str, blob: FileBlob, attempts: int = 3) -> None:
|
|
112
|
+
require_safe_url(upload_url, "upload URL")
|
|
113
|
+
current_sha, current_size = hash_file(blob.source)
|
|
114
|
+
if current_sha != blob.sha256 or current_size != blob.size:
|
|
115
|
+
raise UrunError(f"file changed after manifest generation: {blob.path}")
|
|
116
|
+
last_error: Exception | None = None
|
|
117
|
+
for attempt in range(1, attempts + 1):
|
|
118
|
+
try:
|
|
119
|
+
with blob.source.open("rb") as f:
|
|
120
|
+
req = urllib.request.Request(
|
|
121
|
+
upload_url,
|
|
122
|
+
data=f,
|
|
123
|
+
method="PUT",
|
|
124
|
+
headers={
|
|
125
|
+
"Content-Length": str(blob.size),
|
|
126
|
+
"If-None-Match": "*",
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
130
|
+
if resp.status >= 300:
|
|
131
|
+
raise UrunError(f"upload failed for {blob.path}: HTTP {resp.status}")
|
|
132
|
+
return
|
|
133
|
+
except urllib.error.HTTPError as exc:
|
|
134
|
+
if exc.code == 412:
|
|
135
|
+
# Another identical deploy may have won the content-addressed race.
|
|
136
|
+
return
|
|
137
|
+
if exc.code < 500 or attempt == attempts:
|
|
138
|
+
raise UrunError(
|
|
139
|
+
f"upload failed for {blob.path}: HTTP {exc.code} {exc.reason}"
|
|
140
|
+
) from exc
|
|
141
|
+
last_error = exc
|
|
142
|
+
except (urllib.error.URLError, TimeoutError) as exc:
|
|
143
|
+
if attempt == attempts:
|
|
144
|
+
reason = getattr(exc, "reason", exc)
|
|
145
|
+
raise UrunError(f"upload failed for {blob.path}: {reason}") from exc
|
|
146
|
+
last_error = exc
|
|
147
|
+
time.sleep(0.25 * attempt)
|
|
148
|
+
if last_error is not None:
|
|
149
|
+
raise UrunError(f"upload failed for {blob.path}: {last_error}") from last_error
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def require_safe_url(raw_url: str, label: str) -> None:
|
|
153
|
+
parsed = urllib.parse.urlparse(raw_url)
|
|
154
|
+
host = (parsed.hostname or "").lower()
|
|
155
|
+
if parsed.scheme == "https":
|
|
156
|
+
return
|
|
157
|
+
if parsed.scheme == "http" and host in {"127.0.0.1", "localhost", "::1"}:
|
|
158
|
+
return
|
|
159
|
+
raise UrunError(f"{label} must use HTTPS except for localhost development")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def poll_until_done(
|
|
163
|
+
client: ApiClient,
|
|
164
|
+
manifest_hash: str,
|
|
165
|
+
interval: float = 2.0,
|
|
166
|
+
timeout: float = 1800.0,
|
|
167
|
+
not_found_grace: float = 30.0,
|
|
168
|
+
) -> dict[str, Any]:
|
|
169
|
+
deadline = time.time() + timeout
|
|
170
|
+
not_found_deadline = time.time() + min(not_found_grace, timeout)
|
|
171
|
+
last_state = "unknown"
|
|
172
|
+
while True:
|
|
173
|
+
try:
|
|
174
|
+
status = client.deployment_status(manifest_hash)
|
|
175
|
+
except ApiError as exc:
|
|
176
|
+
if exc.status == 404 and time.time() < not_found_deadline:
|
|
177
|
+
last_state = "not_found"
|
|
178
|
+
time.sleep(interval)
|
|
179
|
+
continue
|
|
180
|
+
raise
|
|
181
|
+
state = status.get("status")
|
|
182
|
+
last_state = str(state)
|
|
183
|
+
if state in {"ready", "failed"}:
|
|
184
|
+
return status
|
|
185
|
+
if time.time() >= deadline:
|
|
186
|
+
raise UrunError(
|
|
187
|
+
f"timed out waiting for deployment {manifest_hash} (last status: {last_state})"
|
|
188
|
+
)
|
|
189
|
+
time.sleep(interval)
|
urun/cli.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .api import DEFAULT_API_URL, ApiClient, poll_until_done, upload_missing_blobs
|
|
13
|
+
from .config import load_config
|
|
14
|
+
from .deps import resolve_deps
|
|
15
|
+
from .discovery import derive_app_name, discover_main_files, discover_mounts
|
|
16
|
+
from .errors import UrunError
|
|
17
|
+
from .manifest import manifest_hash, unique_blobs
|
|
18
|
+
|
|
19
|
+
API_KEY_RE = re.compile(r"^urun_[0-9a-f]{32}$")
|
|
20
|
+
ORG_ID_RE = re.compile(r"^org_[A-Za-z0-9_-]+$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main(argv: list[str] | None = None) -> int:
|
|
24
|
+
parser = build_parser()
|
|
25
|
+
args = parser.parse_args(argv)
|
|
26
|
+
try:
|
|
27
|
+
if args.command == "deploy":
|
|
28
|
+
return deploy(args)
|
|
29
|
+
parser.print_help()
|
|
30
|
+
return 2
|
|
31
|
+
except UrunError as exc:
|
|
32
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
33
|
+
return 1
|
|
34
|
+
except KeyboardInterrupt:
|
|
35
|
+
print("aborted", file=sys.stderr)
|
|
36
|
+
return 130
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
40
|
+
parser = argparse.ArgumentParser(prog="urun")
|
|
41
|
+
parser.add_argument("--version", action="version", version=f"urun {__version__}")
|
|
42
|
+
sub = parser.add_subparsers(dest="command")
|
|
43
|
+
deploy = sub.add_parser("deploy", help="Deploy a Python app to urun")
|
|
44
|
+
target = deploy.add_mutually_exclusive_group()
|
|
45
|
+
target.add_argument("entrypoint", nargs="?", help="Python file entrypoint, e.g. app.py")
|
|
46
|
+
target.add_argument("-m", "--module", help="Python module entrypoint, e.g. myapp.main")
|
|
47
|
+
deploy.add_argument("--name", help="App name (defaults from entrypoint/module)")
|
|
48
|
+
deploy.add_argument("--config", type=Path, help="Path to urun.toml")
|
|
49
|
+
deploy.add_argument(
|
|
50
|
+
"--api-url", default=os.getenv("URUN_API_URL", DEFAULT_API_URL), help="API base URL"
|
|
51
|
+
)
|
|
52
|
+
deploy.add_argument(
|
|
53
|
+
"--api-key", default=os.getenv("URUN_API_KEY"), help="API key (or URUN_API_KEY)"
|
|
54
|
+
)
|
|
55
|
+
deploy.add_argument(
|
|
56
|
+
"--org-id", default=os.getenv("URUN_ORG_ID"), help="Org id (or URUN_ORG_ID)"
|
|
57
|
+
)
|
|
58
|
+
deploy.add_argument(
|
|
59
|
+
"--no-wait", action="store_true", help="Finalize deployment but do not poll for readiness"
|
|
60
|
+
)
|
|
61
|
+
deploy.add_argument("--poll-interval", type=float, default=2.0)
|
|
62
|
+
deploy.add_argument("--timeout", type=float, default=1800.0)
|
|
63
|
+
return parser
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def deploy(args: argparse.Namespace) -> int:
|
|
67
|
+
if not args.api_key:
|
|
68
|
+
raise UrunError("missing API key; set URUN_API_KEY or pass --api-key")
|
|
69
|
+
if not API_KEY_RE.fullmatch(args.api_key):
|
|
70
|
+
raise UrunError("invalid API key format; expected urun_<32 lowercase hex chars>")
|
|
71
|
+
if not args.org_id:
|
|
72
|
+
raise UrunError("missing org id; set URUN_ORG_ID or pass --org-id")
|
|
73
|
+
if not ORG_ID_RE.fullmatch(args.org_id):
|
|
74
|
+
raise UrunError("invalid org id format; expected org_<id>")
|
|
75
|
+
if args.poll_interval <= 0:
|
|
76
|
+
raise UrunError("--poll-interval must be greater than 0")
|
|
77
|
+
if args.timeout <= 0:
|
|
78
|
+
raise UrunError("--timeout must be greater than 0")
|
|
79
|
+
|
|
80
|
+
project_root = args.config.resolve().parent if args.config else Path.cwd()
|
|
81
|
+
config = load_config(args.config)
|
|
82
|
+
entrypoint, files = discover_main_files(config, args.entrypoint, args.module, project_root)
|
|
83
|
+
mount_entries, mount_blobs = discover_mounts(config.mounts, project_root)
|
|
84
|
+
deps, deps_blob, python_version = resolve_deps(config, project_root)
|
|
85
|
+
|
|
86
|
+
app_name = (
|
|
87
|
+
args.name
|
|
88
|
+
or config.app.name
|
|
89
|
+
or derive_app_name(
|
|
90
|
+
args.entrypoint or config.app.entrypoint, args.module or config.app.module
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
all_blobs = files + mount_blobs + ([deps_blob] if deps_blob is not None else [])
|
|
94
|
+
manifest = {
|
|
95
|
+
"version": 1,
|
|
96
|
+
"org_id": args.org_id,
|
|
97
|
+
"app_name": app_name,
|
|
98
|
+
"entrypoint": entrypoint,
|
|
99
|
+
"python_version": python_version,
|
|
100
|
+
"files": [b.manifest_entry() for b in sorted(files, key=lambda b: b.path)],
|
|
101
|
+
"mounts": mount_entries,
|
|
102
|
+
"deps": deps,
|
|
103
|
+
"created_at": datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
|
104
|
+
"client_version": f"urun-cli/{__version__}",
|
|
105
|
+
}
|
|
106
|
+
mh = manifest_hash(manifest)
|
|
107
|
+
|
|
108
|
+
print(f"Deploying {app_name} ({mh[:12]})")
|
|
109
|
+
print(f"Files: {len(files)} app, {len(mount_blobs)} mounted, deps: {deps['kind']}")
|
|
110
|
+
|
|
111
|
+
client = ApiClient(args.api_url, args.api_key)
|
|
112
|
+
register = client.register_manifest(manifest)
|
|
113
|
+
server_hash = register.get("manifest_hash")
|
|
114
|
+
if server_hash and server_hash != mh:
|
|
115
|
+
raise UrunError(f"server manifest hash mismatch: local {mh}, server {server_hash}")
|
|
116
|
+
missing = register.get("missing_blobs", [])
|
|
117
|
+
print(f"Uploading {len(missing)} missing blob(s)")
|
|
118
|
+
upload_missing_blobs(missing, unique_blobs(all_blobs))
|
|
119
|
+
|
|
120
|
+
final = client.finalize(mh)
|
|
121
|
+
print(f"Deployment {final.get('status', 'queued')}: {mh}")
|
|
122
|
+
if args.no_wait:
|
|
123
|
+
status_url = register.get("status_url")
|
|
124
|
+
if status_url:
|
|
125
|
+
print(f"Status: {status_url}")
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
status = poll_until_done(client, mh, args.poll_interval, args.timeout)
|
|
129
|
+
if status.get("status") == "failed":
|
|
130
|
+
err = status.get("error") or {}
|
|
131
|
+
code = err.get("code")
|
|
132
|
+
message = err.get("message")
|
|
133
|
+
detail = f"{code}: {message}" if code and message else (message or code or "unknown error")
|
|
134
|
+
raise UrunError(f"deployment failed: {detail}")
|
|
135
|
+
print_ready(status)
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def print_ready(status: dict[str, Any]) -> None:
|
|
140
|
+
print("Deployment ready")
|
|
141
|
+
if status.get("zerofs_path"):
|
|
142
|
+
print(f"ZeroFS: {status['zerofs_path']}")
|
|
143
|
+
if status.get("url"):
|
|
144
|
+
print(f"URL: {status['url']}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
raise SystemExit(main())
|
urun/config.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import tomllib
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path, PurePosixPath
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .errors import UrunError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class AppConfig:
|
|
14
|
+
name: str | None = None
|
|
15
|
+
entrypoint: str | None = None
|
|
16
|
+
module: str | None = None
|
|
17
|
+
python_version: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class MountConfig:
|
|
22
|
+
local_path: str | None = None
|
|
23
|
+
local_file: str | None = None
|
|
24
|
+
python_source: str | None = None
|
|
25
|
+
remote_path: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class DepsConfig:
|
|
30
|
+
requirements: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class UrunConfig:
|
|
35
|
+
app: AppConfig = field(default_factory=AppConfig)
|
|
36
|
+
mounts: list[MountConfig] = field(default_factory=list)
|
|
37
|
+
deps: DepsConfig = field(default_factory=DepsConfig)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_config(path: Path | None) -> UrunConfig:
|
|
41
|
+
if path is None:
|
|
42
|
+
path = Path("urun.toml")
|
|
43
|
+
if not path.exists():
|
|
44
|
+
return UrunConfig()
|
|
45
|
+
try:
|
|
46
|
+
data = tomllib.loads(path.read_text())
|
|
47
|
+
except tomllib.TOMLDecodeError as exc:
|
|
48
|
+
raise UrunError(f"Invalid TOML in {path}: {exc}") from exc
|
|
49
|
+
return parse_config(data, path)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_config(data: dict[str, Any], path: Path = Path("urun.toml")) -> UrunConfig:
|
|
53
|
+
app_data = data.get("app", {}) or {}
|
|
54
|
+
if not isinstance(app_data, dict):
|
|
55
|
+
raise UrunError(f"{path}: [app] must be a table")
|
|
56
|
+
app = AppConfig(
|
|
57
|
+
name=_str(app_data, "name", path),
|
|
58
|
+
entrypoint=_str(app_data, "entrypoint", path),
|
|
59
|
+
module=_str(app_data, "module", path),
|
|
60
|
+
python_version=_str(app_data, "python_version", path),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
mounts: list[MountConfig] = []
|
|
64
|
+
for idx, mount_data in enumerate(data.get("mounts", []) or []):
|
|
65
|
+
if not isinstance(mount_data, dict):
|
|
66
|
+
raise UrunError(f"{path}: [[mounts]] item {idx} must be a table")
|
|
67
|
+
sources = [k for k in ("local_path", "local_file", "python_source") if mount_data.get(k)]
|
|
68
|
+
if len(sources) != 1:
|
|
69
|
+
raise UrunError(f"{path}: [[mounts]] item {idx} must set exactly one source")
|
|
70
|
+
remote_path = _str(mount_data, "remote_path", path)
|
|
71
|
+
if not remote_path:
|
|
72
|
+
raise UrunError(f"{path}: [[mounts]] item {idx} must set remote_path")
|
|
73
|
+
validate_remote_path(remote_path, path, idx)
|
|
74
|
+
mounts.append(
|
|
75
|
+
MountConfig(
|
|
76
|
+
local_path=_str(mount_data, "local_path", path),
|
|
77
|
+
local_file=_str(mount_data, "local_file", path),
|
|
78
|
+
python_source=_str(mount_data, "python_source", path),
|
|
79
|
+
remote_path=remote_path,
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
deps_data = data.get("deps", {}) or {}
|
|
84
|
+
if not isinstance(deps_data, dict):
|
|
85
|
+
raise UrunError(f"{path}: [deps] must be a table")
|
|
86
|
+
return UrunConfig(
|
|
87
|
+
app=app, mounts=mounts, deps=DepsConfig(requirements=_str(deps_data, "requirements", path))
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def validate_remote_path(remote_path: str, path: Path, idx: int) -> None:
|
|
92
|
+
parts = PurePosixPath(remote_path).parts
|
|
93
|
+
invalid = (
|
|
94
|
+
not remote_path.startswith("/")
|
|
95
|
+
or remote_path == "/"
|
|
96
|
+
or "\\" in remote_path
|
|
97
|
+
or "//" in remote_path
|
|
98
|
+
or any(part in {".", ".."} for part in parts)
|
|
99
|
+
or any(part in {".", ".."} for part in remote_path.split("/"))
|
|
100
|
+
or bool(re.search(r"[\x00-\x1f\x7f]", remote_path))
|
|
101
|
+
or len(remote_path) > 512
|
|
102
|
+
)
|
|
103
|
+
if invalid:
|
|
104
|
+
raise UrunError(
|
|
105
|
+
f"{path}: [[mounts]] item {idx} remote_path must be an absolute, normalized POSIX path"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _str(data: dict[str, Any], key: str, path: Path) -> str | None:
|
|
110
|
+
value = data.get(key)
|
|
111
|
+
if value is None:
|
|
112
|
+
return None
|
|
113
|
+
if not isinstance(value, str):
|
|
114
|
+
raise UrunError(f"{path}: {key} must be a string")
|
|
115
|
+
return value
|
urun/deps.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import tomllib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .config import UrunConfig
|
|
9
|
+
from .discovery import ensure_under_root
|
|
10
|
+
from .errors import UrunError
|
|
11
|
+
from .manifest import FileBlob, file_blob
|
|
12
|
+
|
|
13
|
+
DEFAULT_PYTHON_VERSION = "3.12"
|
|
14
|
+
CREDENTIAL_URL_RE = re.compile(r"https?://[^\s/@:]+:[^\s/@]+@", re.IGNORECASE)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resolve_deps(
|
|
18
|
+
config: UrunConfig, project_root: Path
|
|
19
|
+
) -> tuple[dict[str, Any], FileBlob | None, str]:
|
|
20
|
+
python_version = config.app.python_version or DEFAULT_PYTHON_VERSION
|
|
21
|
+
if config.deps.requirements:
|
|
22
|
+
path = ensure_under_root(
|
|
23
|
+
project_root / config.deps.requirements, project_root, "requirements file"
|
|
24
|
+
)
|
|
25
|
+
if not path.is_file():
|
|
26
|
+
raise UrunError(f"requirements file not found: {path}")
|
|
27
|
+
reject_embedded_dependency_credentials(path)
|
|
28
|
+
blob = file_blob(path, project_root)
|
|
29
|
+
return (
|
|
30
|
+
{
|
|
31
|
+
"kind": "requirements_txt",
|
|
32
|
+
"blob_sha256": blob.sha256,
|
|
33
|
+
"python_version": python_version,
|
|
34
|
+
},
|
|
35
|
+
blob,
|
|
36
|
+
python_version,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
pyproject = project_root / "pyproject.toml"
|
|
40
|
+
if pyproject.is_file():
|
|
41
|
+
reject_embedded_dependency_credentials(pyproject)
|
|
42
|
+
blob = file_blob(pyproject, project_root)
|
|
43
|
+
try:
|
|
44
|
+
data = tomllib.loads(pyproject.read_text())
|
|
45
|
+
except tomllib.TOMLDecodeError as exc:
|
|
46
|
+
raise UrunError(f"Invalid TOML in pyproject.toml: {exc}") from exc
|
|
47
|
+
requires = (data.get("project") or {}).get("requires-python")
|
|
48
|
+
if not config.app.python_version and isinstance(requires, str):
|
|
49
|
+
python_version = python_from_requires(requires) or python_version
|
|
50
|
+
return (
|
|
51
|
+
{
|
|
52
|
+
"kind": "pyproject_toml",
|
|
53
|
+
"blob_sha256": blob.sha256,
|
|
54
|
+
"python_version": python_version,
|
|
55
|
+
},
|
|
56
|
+
blob,
|
|
57
|
+
python_version,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
req = project_root / "requirements.txt"
|
|
61
|
+
if req.is_file():
|
|
62
|
+
reject_embedded_dependency_credentials(req)
|
|
63
|
+
blob = file_blob(req, project_root)
|
|
64
|
+
return (
|
|
65
|
+
{
|
|
66
|
+
"kind": "requirements_txt",
|
|
67
|
+
"blob_sha256": blob.sha256,
|
|
68
|
+
"python_version": python_version,
|
|
69
|
+
},
|
|
70
|
+
blob,
|
|
71
|
+
python_version,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return {"kind": "none", "python_version": python_version}, None, python_version
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def reject_embedded_dependency_credentials(path: Path) -> None:
|
|
78
|
+
text = path.read_text(errors="ignore")
|
|
79
|
+
if CREDENTIAL_URL_RE.search(text):
|
|
80
|
+
raise UrunError(
|
|
81
|
+
f"dependency file contains an embedded credential URL: {path.name}; "
|
|
82
|
+
"use token-free package references before deploying"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def python_from_requires(spec: str) -> str | None:
|
|
87
|
+
# Conservative extraction for common specs such as >=3.11 or ==3.12.*.
|
|
88
|
+
versions = re.findall(r"(\d+)\.(\d+)", spec)
|
|
89
|
+
if not versions:
|
|
90
|
+
return None
|
|
91
|
+
major, minor = versions[0]
|
|
92
|
+
return f"{major}.{minor}"
|
urun/discovery.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import importlib.util
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .config import MountConfig, UrunConfig
|
|
9
|
+
from .errors import UrunError
|
|
10
|
+
from .manifest import FileBlob, file_blob
|
|
11
|
+
|
|
12
|
+
MAX_AUTO_INCLUDE_BYTES = 50 * 1024 * 1024
|
|
13
|
+
DEFAULT_PYTHON_VERSION = "3.12"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def derive_app_name(entrypoint: str | None, module: str | None) -> str:
|
|
17
|
+
if module:
|
|
18
|
+
return module.split(".")[0]
|
|
19
|
+
if not entrypoint:
|
|
20
|
+
raise UrunError("missing entrypoint")
|
|
21
|
+
p = Path(entrypoint)
|
|
22
|
+
if p.name == "__main__.py" and p.parent.name:
|
|
23
|
+
return p.parent.name
|
|
24
|
+
if p.parent != Path("."):
|
|
25
|
+
return p.parent.name
|
|
26
|
+
return p.stem
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def package_root_for(entrypoint: Path) -> Path | None:
|
|
30
|
+
current = entrypoint.resolve().parent
|
|
31
|
+
if not (current / "__init__.py").exists():
|
|
32
|
+
return None
|
|
33
|
+
root = current
|
|
34
|
+
while (root.parent / "__init__.py").exists():
|
|
35
|
+
root = root.parent
|
|
36
|
+
return root
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_urunignore(root: Path) -> list[str]:
|
|
40
|
+
ignore = root / ".urunignore"
|
|
41
|
+
if not ignore.exists():
|
|
42
|
+
return []
|
|
43
|
+
return [
|
|
44
|
+
line.strip()
|
|
45
|
+
for line in ignore.read_text().splitlines()
|
|
46
|
+
if line.strip() and not line.lstrip().startswith("#")
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_ignored(path: Path, project_root: Path, patterns: list[str]) -> bool:
|
|
51
|
+
try:
|
|
52
|
+
rel = path.resolve().relative_to(project_root.resolve()).as_posix()
|
|
53
|
+
except ValueError as exc:
|
|
54
|
+
raise UrunError(f"path is outside the project root: {path}") from exc
|
|
55
|
+
parts = rel.split("/")
|
|
56
|
+
if any(part == "__pycache__" for part in parts):
|
|
57
|
+
return True
|
|
58
|
+
if any(part == ".git" for part in parts):
|
|
59
|
+
return True
|
|
60
|
+
if path.name.startswith(".") or any(part.startswith(".") for part in parts):
|
|
61
|
+
return True
|
|
62
|
+
if path.suffix == ".pyc":
|
|
63
|
+
return True
|
|
64
|
+
return any(
|
|
65
|
+
fnmatch.fnmatch(rel, pattern) or fnmatch.fnmatch(path.name, pattern) for pattern in patterns
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def auto_include(entrypoint: Path, project_root: Path) -> list[FileBlob]:
|
|
70
|
+
if not entrypoint.exists() or not entrypoint.is_file():
|
|
71
|
+
raise UrunError(f"entrypoint not found: {entrypoint}")
|
|
72
|
+
if entrypoint.suffix != ".py":
|
|
73
|
+
raise UrunError("entrypoint must be a .py file")
|
|
74
|
+
patterns = load_urunignore(project_root)
|
|
75
|
+
pkg = package_root_for(entrypoint)
|
|
76
|
+
files: list[Path]
|
|
77
|
+
if pkg:
|
|
78
|
+
files = sorted(
|
|
79
|
+
p
|
|
80
|
+
for p in pkg.rglob("*.py")
|
|
81
|
+
if p.is_file() and not is_ignored(p, project_root, patterns)
|
|
82
|
+
)
|
|
83
|
+
total = sum(p.stat().st_size for p in files)
|
|
84
|
+
if total > MAX_AUTO_INCLUDE_BYTES:
|
|
85
|
+
raise UrunError(
|
|
86
|
+
f"auto-included package {pkg} is {total} bytes, above 50 MB; "
|
|
87
|
+
"use urun.toml to configure explicit mounts"
|
|
88
|
+
)
|
|
89
|
+
else:
|
|
90
|
+
if is_ignored(entrypoint, project_root, patterns):
|
|
91
|
+
raise UrunError(f"entrypoint is ignored by defaults or .urunignore: {entrypoint}")
|
|
92
|
+
files = [entrypoint]
|
|
93
|
+
blobs: list[FileBlob] = []
|
|
94
|
+
for p in files:
|
|
95
|
+
try:
|
|
96
|
+
blobs.append(file_blob(p, project_root))
|
|
97
|
+
except ValueError as exc:
|
|
98
|
+
raise UrunError(f"path is outside the project root: {p}") from exc
|
|
99
|
+
return blobs
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def discover_main_files(
|
|
103
|
+
config: UrunConfig, entrypoint_arg: str | None, module_arg: str | None, project_root: Path
|
|
104
|
+
) -> tuple[str, list[FileBlob]]:
|
|
105
|
+
ensure_project_on_path(project_root)
|
|
106
|
+
module = module_arg or config.app.module
|
|
107
|
+
entrypoint = entrypoint_arg or config.app.entrypoint
|
|
108
|
+
if module:
|
|
109
|
+
spec = importlib.util.find_spec(module)
|
|
110
|
+
if spec is None or spec.origin is None:
|
|
111
|
+
raise UrunError(f"module not found: {module}")
|
|
112
|
+
entrypoint_path = ensure_under_root(Path(spec.origin), project_root, "module")
|
|
113
|
+
elif entrypoint:
|
|
114
|
+
entrypoint_path = Path(entrypoint)
|
|
115
|
+
else:
|
|
116
|
+
raise UrunError("provide an entrypoint, -m module, or [app].entrypoint in urun.toml")
|
|
117
|
+
if not entrypoint_path.is_absolute():
|
|
118
|
+
entrypoint_path = project_root / entrypoint_path
|
|
119
|
+
files = auto_include(entrypoint_path, project_root)
|
|
120
|
+
try:
|
|
121
|
+
manifest_entrypoint = (
|
|
122
|
+
module
|
|
123
|
+
if module
|
|
124
|
+
else entrypoint_path.resolve().relative_to(project_root.resolve()).as_posix()
|
|
125
|
+
)
|
|
126
|
+
except ValueError as exc:
|
|
127
|
+
raise UrunError(f"entrypoint is outside the project root: {entrypoint_path}") from exc
|
|
128
|
+
return manifest_entrypoint, files
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def discover_mounts(
|
|
132
|
+
mounts: list[MountConfig], project_root: Path
|
|
133
|
+
) -> tuple[list[dict], list[FileBlob]]:
|
|
134
|
+
ensure_project_on_path(project_root)
|
|
135
|
+
manifest_mounts: list[dict] = []
|
|
136
|
+
all_blobs: list[FileBlob] = []
|
|
137
|
+
patterns = load_urunignore(project_root)
|
|
138
|
+
seen_remote_paths: set[str] = set()
|
|
139
|
+
for mount in mounts:
|
|
140
|
+
if mount.remote_path in seen_remote_paths:
|
|
141
|
+
raise UrunError(f"duplicate mount remote_path: {mount.remote_path}")
|
|
142
|
+
seen_remote_paths.add(str(mount.remote_path))
|
|
143
|
+
source = _mount_source(mount, project_root)
|
|
144
|
+
assert mount.remote_path is not None
|
|
145
|
+
if mount.local_file:
|
|
146
|
+
if not source.is_file():
|
|
147
|
+
raise UrunError(f"mount file not found: {source}")
|
|
148
|
+
blob = file_blob(source, source.parent, source.name)
|
|
149
|
+
entries = [blob]
|
|
150
|
+
else:
|
|
151
|
+
if not source.is_dir():
|
|
152
|
+
raise UrunError(f"mount directory not found: {source}")
|
|
153
|
+
paths = sorted(
|
|
154
|
+
p
|
|
155
|
+
for p in source.rglob("*")
|
|
156
|
+
if p.is_file() and not is_ignored(p, project_root, patterns)
|
|
157
|
+
)
|
|
158
|
+
entries = [file_blob(p, source) for p in paths]
|
|
159
|
+
all_blobs.extend(entries)
|
|
160
|
+
manifest_mounts.append(
|
|
161
|
+
{
|
|
162
|
+
"remote_path": mount.remote_path,
|
|
163
|
+
"files": [b.manifest_entry() for b in sorted(entries, key=lambda b: b.path)],
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
return manifest_mounts, all_blobs
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def ensure_project_on_path(project_root: Path) -> None:
|
|
170
|
+
candidates = [project_root.resolve()]
|
|
171
|
+
src = project_root / "src"
|
|
172
|
+
if src.is_dir():
|
|
173
|
+
candidates.append(src.resolve())
|
|
174
|
+
for candidate in reversed(candidates):
|
|
175
|
+
path = str(candidate)
|
|
176
|
+
if path not in sys.path:
|
|
177
|
+
sys.path.insert(0, path)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def ensure_under_root(path: Path, project_root: Path, label: str = "path") -> Path:
|
|
181
|
+
resolved = path.resolve()
|
|
182
|
+
try:
|
|
183
|
+
resolved.relative_to(project_root.resolve())
|
|
184
|
+
except ValueError as exc:
|
|
185
|
+
raise UrunError(f"{label} is outside the project root: {path}") from exc
|
|
186
|
+
return resolved
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _mount_source(mount: MountConfig, project_root: Path) -> Path:
|
|
190
|
+
if mount.local_path:
|
|
191
|
+
return ensure_under_root(project_root / mount.local_path, project_root, "mount path")
|
|
192
|
+
if mount.local_file:
|
|
193
|
+
return ensure_under_root(project_root / mount.local_file, project_root, "mount file")
|
|
194
|
+
if mount.python_source:
|
|
195
|
+
spec = importlib.util.find_spec(mount.python_source)
|
|
196
|
+
if spec is None:
|
|
197
|
+
raise UrunError(f"python_source not found: {mount.python_source}")
|
|
198
|
+
if spec.submodule_search_locations:
|
|
199
|
+
return ensure_under_root(
|
|
200
|
+
Path(next(iter(spec.submodule_search_locations))), project_root, "python_source"
|
|
201
|
+
)
|
|
202
|
+
if spec.origin:
|
|
203
|
+
return ensure_under_root(Path(spec.origin), project_root, "python_source")
|
|
204
|
+
raise UrunError("invalid mount source")
|
urun/errors.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class UrunError(Exception):
|
|
2
|
+
"""User-facing CLI error."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ApiError(UrunError):
|
|
6
|
+
def __init__(self, status: int, code: str, message: str):
|
|
7
|
+
super().__init__(f"{status} {code}: {message}")
|
|
8
|
+
self.status = status
|
|
9
|
+
self.code = code
|
|
10
|
+
self.message = message
|
urun/manifest.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class FileBlob:
|
|
13
|
+
path: str
|
|
14
|
+
source: Path
|
|
15
|
+
sha256: str
|
|
16
|
+
size: int
|
|
17
|
+
mode: int
|
|
18
|
+
|
|
19
|
+
def manifest_entry(self) -> dict[str, Any]:
|
|
20
|
+
return {
|
|
21
|
+
"path": self.path,
|
|
22
|
+
"sha256": self.sha256,
|
|
23
|
+
"size": self.size,
|
|
24
|
+
"mode": self.mode,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def posix_rel(path: Path, root: Path) -> str:
|
|
29
|
+
return path.resolve().relative_to(root.resolve()).as_posix()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def normalized_mode(path: Path) -> int:
|
|
33
|
+
st_mode = path.stat().st_mode
|
|
34
|
+
return 0o100755 if (st_mode & 0o111) else 0o100644
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def hash_file(path: Path, chunk_size: int = 1024 * 1024) -> tuple[str, int]:
|
|
38
|
+
h = hashlib.sha256()
|
|
39
|
+
size = 0
|
|
40
|
+
with path.open("rb") as f:
|
|
41
|
+
while True:
|
|
42
|
+
chunk = f.read(chunk_size)
|
|
43
|
+
if not chunk:
|
|
44
|
+
break
|
|
45
|
+
size += len(chunk)
|
|
46
|
+
h.update(chunk)
|
|
47
|
+
return h.hexdigest(), size
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def file_blob(path: Path, root: Path, manifest_path: str | None = None) -> FileBlob:
|
|
51
|
+
sha, size = hash_file(path)
|
|
52
|
+
return FileBlob(
|
|
53
|
+
path=manifest_path or posix_rel(path, root),
|
|
54
|
+
source=path.resolve(),
|
|
55
|
+
sha256=sha,
|
|
56
|
+
size=size,
|
|
57
|
+
mode=normalized_mode(path),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def canonical_json(data: dict[str, Any]) -> bytes:
|
|
62
|
+
"""Canonical JSON: sorted keys, compact separators, UTF-8, no trailing whitespace."""
|
|
63
|
+
return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
|
|
64
|
+
"utf-8"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def manifest_hash(manifest_without_hash: dict[str, Any]) -> str:
|
|
69
|
+
data = dict(manifest_without_hash)
|
|
70
|
+
data.pop("manifest_hash", None)
|
|
71
|
+
return hashlib.sha256(canonical_json(data)).hexdigest()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def unique_blobs(files: Iterable[FileBlob]) -> dict[str, FileBlob]:
|
|
75
|
+
out: dict[str, FileBlob] = {}
|
|
76
|
+
for blob in files:
|
|
77
|
+
out.setdefault(blob.sha256, blob)
|
|
78
|
+
return out
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: urun-cli
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: End-user CLI for deploying apps to urun
|
|
5
|
+
Project-URL: Homepage, https://urun.sh
|
|
6
|
+
Project-URL: Repository, https://github.com/urun-sh/urun-cli
|
|
7
|
+
Project-URL: Issues, https://github.com/urun-sh/urun-cli/issues
|
|
8
|
+
Author: urun
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: cli,deploy,urun
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# urun CLI
|
|
24
|
+
|
|
25
|
+
End-user command line client for deploying Python apps to urun.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uv tool install urun-cli
|
|
31
|
+
# or
|
|
32
|
+
pip install urun-cli
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The installed executable is still `urun`:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
urun --version
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
PyPI does not allow the distribution name `urun`, so ephemeral uv runs need the
|
|
42
|
+
package name explicitly:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uvx --from urun-cli urun --version
|
|
46
|
+
# or use the package-matching alias
|
|
47
|
+
uvx urun-cli --version
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Deploy
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
export URUN_API_KEY=urun_<32hex>
|
|
54
|
+
export URUN_ORG_ID=org_... # bootstrap-era requirement for v1 manifests
|
|
55
|
+
urun deploy app.py
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Development with uv / Nix
|
|
59
|
+
|
|
60
|
+
This repo includes `flake.nix` and `.envrc` for a reproducible dev shell with uv,
|
|
61
|
+
Python 3.12, Dagger, actionlint, and pre-commit.
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
direnv allow # optional, if you use direnv/nix
|
|
65
|
+
uv sync
|
|
66
|
+
uv run pytest
|
|
67
|
+
uv run urun --version
|
|
68
|
+
uv build
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
CI uses the same centralized Dagger pre-commit pipeline pattern as `urun`:
|
|
72
|
+
`precommit.yaml` checks out `urun-sh/dagger-pipeline` and runs `recipes.precommit`.
|
|
73
|
+
Normal `ci` remains self-contained and runs on public/fork PRs.
|
|
74
|
+
|
|
75
|
+
Optional local MinIO integration test:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
docker compose -f compose.minio.yml up -d
|
|
79
|
+
URUN_TEST_MINIO_ENDPOINT=127.0.0.1:9000 \
|
|
80
|
+
URUN_TEST_MINIO_ACCESS_KEY=minioadmin \
|
|
81
|
+
URUN_TEST_MINIO_SECRET_KEY=minioadmin \
|
|
82
|
+
uv run pytest -q -m minio
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
See `SPEC_TRACKING.md` for spec coverage and remaining gaps.
|
|
86
|
+
|
|
87
|
+
## Configuration
|
|
88
|
+
|
|
89
|
+
Configuration is read from `urun.toml` when present. CLI flags override config values.
|
|
90
|
+
|
|
91
|
+
```toml
|
|
92
|
+
[app]
|
|
93
|
+
name = "my-app"
|
|
94
|
+
entrypoint = "src/myapp/main.py"
|
|
95
|
+
python_version = "3.12"
|
|
96
|
+
|
|
97
|
+
[[mounts]]
|
|
98
|
+
local_path = "./assets"
|
|
99
|
+
remote_path = "/app/assets"
|
|
100
|
+
|
|
101
|
+
[deps]
|
|
102
|
+
requirements = "requirements.txt"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Common options:
|
|
106
|
+
|
|
107
|
+
| Option | Description |
|
|
108
|
+
| --- | --- |
|
|
109
|
+
| `-m, --module` | Deploy a Python module entrypoint, e.g. `myapp.main`. |
|
|
110
|
+
| `--name` | Override the derived app name. |
|
|
111
|
+
| `--config` | Use a specific `urun.toml`; relative paths resolve from its directory. |
|
|
112
|
+
| `--api-url` | Override the API URL. |
|
|
113
|
+
| `--api-key` | API key; prefer `URUN_API_KEY` for shell history safety. |
|
|
114
|
+
| `--org-id` | Bootstrap-era org ID required in v1 manifests. |
|
|
115
|
+
| `--no-wait` | Finalize but do not poll for readiness. |
|
|
116
|
+
| `--poll-interval`, `--timeout` | Control readiness polling. |
|
|
117
|
+
|
|
118
|
+
## Troubleshooting
|
|
119
|
+
|
|
120
|
+
| Error | Fix |
|
|
121
|
+
| --- | --- |
|
|
122
|
+
| `missing API key` | Set `URUN_API_KEY` or pass `--api-key`. |
|
|
123
|
+
| `invalid API key format` | Use `urun_<32 lowercase hex chars>`. |
|
|
124
|
+
| `missing org id` | Set `URUN_ORG_ID` while v1 bootstrap is manual. |
|
|
125
|
+
| `entrypoint not found` | Run from the project root or pass `--config`. |
|
|
126
|
+
| `path is outside the project root` | Move the file under the project or adjust `urun.toml`. |
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT. Local MinIO is used only as an optional S3-compatible test service.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
urun/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
urun/api.py,sha256=NOti71DwAvV-SPvcDoQgt4Y0w4yIQX2QzYWjsRi3o4U,7468
|
|
3
|
+
urun/cli.py,sha256=3OUW2DX1mZ1Cv6UIUEfSZGdSJ-8XijLoWGxAWGZiBk0,5727
|
|
4
|
+
urun/config.py,sha256=uT-1NDs7ZB1rx15X-_n6XPrxvNF10yDjIQZRlhQXIcA,3769
|
|
5
|
+
urun/deps.py,sha256=ZI7T1OlerKfYGdFNqVrsCF69B0GVCBydFt76XrYx7VI,3088
|
|
6
|
+
urun/discovery.py,sha256=0xDgyjgh66aEK93kraZIm8x8r6naTYbn1GVAhZRQnzw,7446
|
|
7
|
+
urun/errors.py,sha256=xd1UrFrqn5rQXcUX02_vfzHwHgpDA0RbwrbM_pnhaZY,293
|
|
8
|
+
urun/manifest.py,sha256=YLpryYCFSu-w8yONj3oohBHpLNP-dPRYAny1JAe9pfI,2043
|
|
9
|
+
urun_cli-0.1.1.dist-info/METADATA,sha256=Gw_DfOtO3-QhXdhcPbP2weDPZYrM1W2jx9QW0jDcPzk,3568
|
|
10
|
+
urun_cli-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
11
|
+
urun_cli-0.1.1.dist-info/entry_points.txt,sha256=Hm1XEwW2k-vTyABcFkd8FevCaQHPz9CTOHxvXkiMiOs,64
|
|
12
|
+
urun_cli-0.1.1.dist-info/licenses/LICENSE,sha256=e54RugXp4xrrinIGvXfGB3UBq__CoP32EEGPaju_G8I,1061
|
|
13
|
+
urun_cli-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 urun
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|