urun-cli 0.1.1__tar.gz → 0.2.0__tar.gz
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_cli-0.1.1 → urun_cli-0.2.0}/.gitignore +4 -0
- urun_cli-0.2.0/CHANGELOG.md +14 -0
- urun_cli-0.2.0/PKG-INFO +154 -0
- urun_cli-0.2.0/README.md +132 -0
- {urun_cli-0.1.1 → urun_cli-0.2.0}/SECURITY.md +2 -6
- {urun_cli-0.1.1 → urun_cli-0.2.0}/pyproject.toml +12 -2
- urun_cli-0.2.0/src/urun/__init__.py +22 -0
- {urun_cli-0.1.1 → urun_cli-0.2.0}/src/urun/api.py +3 -0
- urun_cli-0.2.0/src/urun/cli.py +229 -0
- urun_cli-0.2.0/src/urun/config.py +58 -0
- urun_cli-0.2.0/src/urun/deps.py +13 -0
- urun_cli-0.2.0/src/urun/discovery.py +170 -0
- urun_cli-0.1.1/.dagger-pipeline.yaml +0 -5
- urun_cli-0.1.1/.env.example +0 -11
- urun_cli-0.1.1/.envrc +0 -44
- urun_cli-0.1.1/.github/CODEOWNERS +0 -3
- urun_cli-0.1.1/.github/actionlint.yaml +0 -4
- urun_cli-0.1.1/.github/dependabot.yml +0 -10
- urun_cli-0.1.1/.github/workflows/ci.yml +0 -87
- urun_cli-0.1.1/.github/workflows/precommit.yaml +0 -80
- urun_cli-0.1.1/.github/workflows/publish.yaml +0 -31
- urun_cli-0.1.1/.github/workflows/release.yaml +0 -46
- urun_cli-0.1.1/.markdownlint-cli2.yaml +0 -15
- urun_cli-0.1.1/.pre-commit-config.yaml +0 -64
- urun_cli-0.1.1/CHANGELOG.md +0 -7
- urun_cli-0.1.1/CONTRIBUTING.md +0 -51
- urun_cli-0.1.1/PKG-INFO +0 -130
- urun_cli-0.1.1/README.md +0 -108
- urun_cli-0.1.1/SPEC.md +0 -467
- urun_cli-0.1.1/SPEC_EXTERNAL.md +0 -773
- urun_cli-0.1.1/SPEC_TRACKING.md +0 -88
- urun_cli-0.1.1/compose.minio.yml +0 -21
- urun_cli-0.1.1/flake.lock +0 -82
- urun_cli-0.1.1/flake.nix +0 -33
- urun_cli-0.1.1/src/urun/__init__.py +0 -1
- urun_cli-0.1.1/src/urun/cli.py +0 -148
- urun_cli-0.1.1/src/urun/config.py +0 -115
- urun_cli-0.1.1/src/urun/deps.py +0 -92
- urun_cli-0.1.1/src/urun/discovery.py +0 -204
- urun_cli-0.1.1/tests/test_api.py +0 -17
- urun_cli-0.1.1/tests/test_api_coverage.py +0 -188
- urun_cli-0.1.1/tests/test_api_errors.py +0 -96
- urun_cli-0.1.1/tests/test_cli_coverage.py +0 -136
- urun_cli-0.1.1/tests/test_config_coverage.py +0 -55
- urun_cli-0.1.1/tests/test_config_deps.py +0 -34
- urun_cli-0.1.1/tests/test_deps_coverage.py +0 -50
- urun_cli-0.1.1/tests/test_discovery.py +0 -37
- urun_cli-0.1.1/tests/test_discovery_coverage.py +0 -175
- urun_cli-0.1.1/tests/test_http_flow.py +0 -209
- urun_cli-0.1.1/tests/test_manifest.py +0 -20
- urun_cli-0.1.1/tests/test_minio_integration.py +0 -247
- urun_cli-0.1.1/tests/test_path_policy.py +0 -58
- urun_cli-0.1.1/tests/test_prod_hardening.py +0 -214
- urun_cli-0.1.1/uv.lock +0 -1085
- {urun_cli-0.1.1 → urun_cli-0.2.0}/LICENSE +0 -0
- {urun_cli-0.1.1 → urun_cli-0.2.0}/src/urun/errors.py +0 -0
- {urun_cli-0.1.1 → urun_cli-0.2.0}/src/urun/manifest.py +0 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- Bumped past 0.1.1/0.1.2 because both filenames were occupied by previously-deleted PyPI artifacts.
|
|
6
|
+
- Drop the org-id requirement from `urun deploy`; authentication now relies solely on the API key.
|
|
7
|
+
- Add `urun login`, `urun run`, and `urun org`/`urun auth` subcommands.
|
|
8
|
+
|
|
9
|
+
## 0.1.0
|
|
10
|
+
|
|
11
|
+
- Initial public `urun deploy` CLI.
|
|
12
|
+
- Deploy from a Python app file with local Python imports included automatically.
|
|
13
|
+
- Source manifest generation, dependency declaration upload, and API deploy flow.
|
|
14
|
+
- Public docs for the config-free v1 CLI surface.
|
urun_cli-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: urun-cli
|
|
3
|
+
Version: 0.2.0
|
|
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
|
+
Deploy Python apps to urun from your terminal.
|
|
26
|
+
|
|
27
|
+
[](https://pypi.org/project/urun-cli/)
|
|
28
|
+
[](https://pypi.org/project/urun-cli/)
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uv tool install urun-cli
|
|
34
|
+
# or
|
|
35
|
+
pip install urun-cli
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The package installs the `urun` command:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
urun --version
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
For one-off `uvx` usage:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
uvx --from urun-cli urun --version
|
|
48
|
+
# or the package-matching command alias
|
|
49
|
+
uvx urun-cli --version
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
Today, an operator manually vends an org-scoped deploy API key. Save it locally
|
|
55
|
+
with `urun login`:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
urun login --api-key urun_<32hex>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`urun login` verifies the key with the urun API and stores credentials for later
|
|
62
|
+
commands. The future browser-based login flow is not available in this CLI
|
|
63
|
+
release.
|
|
64
|
+
|
|
65
|
+
For CI or one-off commands, you can still use the environment variable:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
export URUN_API_KEY=urun_<32hex>
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Create `app.py`:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
import urun
|
|
75
|
+
from urun import App
|
|
76
|
+
|
|
77
|
+
app = App("hello-h100")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.function(gpus="h100:1")
|
|
81
|
+
def hello(ctx: urun.Context):
|
|
82
|
+
print(f"running on {ctx.device}")
|
|
83
|
+
return {"device": str(ctx.device)}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Run it:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
urun run app.py
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
In this release, `urun run` uses the same deploy pipeline as `urun deploy`.
|
|
93
|
+
`deploy` remains available as the lower-level command while the full
|
|
94
|
+
deploy/run/monitor workflow is being built.
|
|
95
|
+
|
|
96
|
+
## What gets deployed
|
|
97
|
+
|
|
98
|
+
`urun deploy` creates a source manifest from your Python entrypoint:
|
|
99
|
+
|
|
100
|
+
| Entrypoint | Included source |
|
|
101
|
+
| --- | --- |
|
|
102
|
+
| `urun deploy app.py` | `app.py` and local Python files it imports |
|
|
103
|
+
|
|
104
|
+
Dependencies are declared in your urun app code. Project-level files such as
|
|
105
|
+
`pyproject.toml` and `requirements.txt` are not uploaded as dependency
|
|
106
|
+
declarations by the CLI.
|
|
107
|
+
|
|
108
|
+
Generated/cache content such as `.git`, dotfiles, `__pycache__`, and `.pyc`
|
|
109
|
+
files is excluded. Add `.urunignore` to exclude additional paths.
|
|
110
|
+
|
|
111
|
+
Non-Python assets such as templates, static files, and data files are not
|
|
112
|
+
auto-included yet.
|
|
113
|
+
|
|
114
|
+
## Common options
|
|
115
|
+
|
|
116
|
+
Shared by `run` and `deploy`:
|
|
117
|
+
|
|
118
|
+
| Option | Description |
|
|
119
|
+
| --- | --- |
|
|
120
|
+
| `--name` | Override the derived app name. |
|
|
121
|
+
| `--api-url` | Override the API URL; defaults to `URUN_API_URL`, saved login credentials, or `https://api.urun.sh/v1`. |
|
|
122
|
+
| `--api-key` | Deploy API key; defaults to `URUN_API_KEY` or saved login credentials. |
|
|
123
|
+
| `--no-wait` | Finalize but do not poll for readiness. |
|
|
124
|
+
| `--poll-interval`, `--timeout` | Control readiness polling. |
|
|
125
|
+
|
|
126
|
+
## Troubleshooting
|
|
127
|
+
|
|
128
|
+
| Error | Fix |
|
|
129
|
+
| --- | --- |
|
|
130
|
+
| `missing API key` | Run `urun login`, set `URUN_API_KEY`, or pass `--api-key`. |
|
|
131
|
+
| `invalid API key format` | Use `urun_<32 lowercase hex chars>`. |
|
|
132
|
+
| `entrypoint not found` | Run from the project root or pass the entrypoint path. |
|
|
133
|
+
| `path is outside the project root` | Move the file under the project before deploying. |
|
|
134
|
+
| Expected files are missing | Import local Python files from `app.py`; non-Python assets are not auto-included yet. |
|
|
135
|
+
|
|
136
|
+
## Development
|
|
137
|
+
|
|
138
|
+
Contributing and test instructions are in [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
139
|
+
|
|
140
|
+
## License
|
|
141
|
+
|
|
142
|
+
MIT.
|
|
143
|
+
|
|
144
|
+
## Development environment
|
|
145
|
+
|
|
146
|
+
This repo has a Nix/direnv/devcontainer baseline:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
direnv allow
|
|
150
|
+
just sync
|
|
151
|
+
just check
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Use VS Code Dev Containers to open the repository with the same toolchain in a container. Copy `devcontainer.env.example` to `.devcontainer.env` if you need to pass local git identity or other non-secret development settings into the container.
|
urun_cli-0.2.0/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# urun CLI
|
|
2
|
+
|
|
3
|
+
Deploy Python apps to urun from your terminal.
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/urun-cli/)
|
|
6
|
+
[](https://pypi.org/project/urun-cli/)
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
uv tool install urun-cli
|
|
12
|
+
# or
|
|
13
|
+
pip install urun-cli
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The package installs the `urun` command:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
urun --version
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
For one-off `uvx` usage:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
uvx --from urun-cli urun --version
|
|
26
|
+
# or the package-matching command alias
|
|
27
|
+
uvx urun-cli --version
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
Today, an operator manually vends an org-scoped deploy API key. Save it locally
|
|
33
|
+
with `urun login`:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
urun login --api-key urun_<32hex>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`urun login` verifies the key with the urun API and stores credentials for later
|
|
40
|
+
commands. The future browser-based login flow is not available in this CLI
|
|
41
|
+
release.
|
|
42
|
+
|
|
43
|
+
For CI or one-off commands, you can still use the environment variable:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
export URUN_API_KEY=urun_<32hex>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Create `app.py`:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import urun
|
|
53
|
+
from urun import App
|
|
54
|
+
|
|
55
|
+
app = App("hello-h100")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.function(gpus="h100:1")
|
|
59
|
+
def hello(ctx: urun.Context):
|
|
60
|
+
print(f"running on {ctx.device}")
|
|
61
|
+
return {"device": str(ctx.device)}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Run it:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
urun run app.py
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
In this release, `urun run` uses the same deploy pipeline as `urun deploy`.
|
|
71
|
+
`deploy` remains available as the lower-level command while the full
|
|
72
|
+
deploy/run/monitor workflow is being built.
|
|
73
|
+
|
|
74
|
+
## What gets deployed
|
|
75
|
+
|
|
76
|
+
`urun deploy` creates a source manifest from your Python entrypoint:
|
|
77
|
+
|
|
78
|
+
| Entrypoint | Included source |
|
|
79
|
+
| --- | --- |
|
|
80
|
+
| `urun deploy app.py` | `app.py` and local Python files it imports |
|
|
81
|
+
|
|
82
|
+
Dependencies are declared in your urun app code. Project-level files such as
|
|
83
|
+
`pyproject.toml` and `requirements.txt` are not uploaded as dependency
|
|
84
|
+
declarations by the CLI.
|
|
85
|
+
|
|
86
|
+
Generated/cache content such as `.git`, dotfiles, `__pycache__`, and `.pyc`
|
|
87
|
+
files is excluded. Add `.urunignore` to exclude additional paths.
|
|
88
|
+
|
|
89
|
+
Non-Python assets such as templates, static files, and data files are not
|
|
90
|
+
auto-included yet.
|
|
91
|
+
|
|
92
|
+
## Common options
|
|
93
|
+
|
|
94
|
+
Shared by `run` and `deploy`:
|
|
95
|
+
|
|
96
|
+
| Option | Description |
|
|
97
|
+
| --- | --- |
|
|
98
|
+
| `--name` | Override the derived app name. |
|
|
99
|
+
| `--api-url` | Override the API URL; defaults to `URUN_API_URL`, saved login credentials, or `https://api.urun.sh/v1`. |
|
|
100
|
+
| `--api-key` | Deploy API key; defaults to `URUN_API_KEY` or saved login credentials. |
|
|
101
|
+
| `--no-wait` | Finalize but do not poll for readiness. |
|
|
102
|
+
| `--poll-interval`, `--timeout` | Control readiness polling. |
|
|
103
|
+
|
|
104
|
+
## Troubleshooting
|
|
105
|
+
|
|
106
|
+
| Error | Fix |
|
|
107
|
+
| --- | --- |
|
|
108
|
+
| `missing API key` | Run `urun login`, set `URUN_API_KEY`, or pass `--api-key`. |
|
|
109
|
+
| `invalid API key format` | Use `urun_<32 lowercase hex chars>`. |
|
|
110
|
+
| `entrypoint not found` | Run from the project root or pass the entrypoint path. |
|
|
111
|
+
| `path is outside the project root` | Move the file under the project before deploying. |
|
|
112
|
+
| Expected files are missing | Import local Python files from `app.py`; non-Python assets are not auto-included yet. |
|
|
113
|
+
|
|
114
|
+
## Development
|
|
115
|
+
|
|
116
|
+
Contributing and test instructions are in [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT.
|
|
121
|
+
|
|
122
|
+
## Development environment
|
|
123
|
+
|
|
124
|
+
This repo has a Nix/direnv/devcontainer baseline:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
direnv allow
|
|
128
|
+
just sync
|
|
129
|
+
just check
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Use VS Code Dev Containers to open the repository with the same toolchain in a container. Copy `devcontainer.env.example` to `.devcontainer.env` if you need to pass local git identity or other non-secret development settings into the container.
|
|
@@ -12,10 +12,6 @@ Please report suspected vulnerabilities privately to the urun maintainers. Do no
|
|
|
12
12
|
|
|
13
13
|
## Source upload behavior
|
|
14
14
|
|
|
15
|
-
`urun deploy` uploads selected source files and dependency declaration files to urun via presigned object-store URLs. Review `.urunignore`
|
|
15
|
+
`urun deploy` uploads selected source files and dependency declaration files to urun via presigned object-store URLs. Review `.urunignore` before deploying sensitive repositories.
|
|
16
16
|
|
|
17
|
-
The CLI enforces project-root containment for entrypoints
|
|
18
|
-
|
|
19
|
-
## Local MinIO
|
|
20
|
-
|
|
21
|
-
MinIO is optional for local S3-compatible integration tests. The documented `minioadmin` credentials are development-only and must not be used for production services.
|
|
17
|
+
The CLI enforces project-root containment for entrypoints and dependency files. Symlinks are resolved before that containment check.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "urun-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "End-user CLI for deploying apps to urun"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -74,9 +74,19 @@ type = "github"
|
|
|
74
74
|
ignore_token_for_push = false
|
|
75
75
|
|
|
76
76
|
[tool.semantic_release.publish]
|
|
77
|
-
dist_glob_patterns = ["dist
|
|
77
|
+
dist_glob_patterns = ["dist/urun_cli-*"]
|
|
78
78
|
upload_to_vcs_release = true
|
|
79
79
|
|
|
80
|
+
[tool.hatch.build.targets.sdist]
|
|
81
|
+
include = [
|
|
82
|
+
"/src",
|
|
83
|
+
"/README.md",
|
|
84
|
+
"/LICENSE",
|
|
85
|
+
"/SECURITY.md",
|
|
86
|
+
"/CHANGELOG.md",
|
|
87
|
+
"/pyproject.toml",
|
|
88
|
+
]
|
|
89
|
+
|
|
80
90
|
[tool.hatch.build.targets.wheel]
|
|
81
91
|
packages = ["src/urun"]
|
|
82
92
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _source_tree_version() -> str:
|
|
9
|
+
for parent in Path(__file__).resolve().parents:
|
|
10
|
+
pyproject = parent / "pyproject.toml"
|
|
11
|
+
if pyproject.is_file():
|
|
12
|
+
data = tomllib.loads(pyproject.read_text())
|
|
13
|
+
project_version = (data.get("project") or {}).get("version")
|
|
14
|
+
if isinstance(project_version, str) and project_version:
|
|
15
|
+
return project_version
|
|
16
|
+
return "0.0.0"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
__version__ = version("urun-cli")
|
|
21
|
+
except PackageNotFoundError: # pragma: no cover - source tree fallback
|
|
22
|
+
__version__ = _source_tree_version()
|
|
@@ -32,6 +32,9 @@ class ApiClient:
|
|
|
32
32
|
def deployment_status(self, manifest_hash: str) -> dict[str, Any]:
|
|
33
33
|
return self._json("GET", f"/deployment-status/{manifest_hash}", None)
|
|
34
34
|
|
|
35
|
+
def org_config(self) -> dict[str, Any]:
|
|
36
|
+
return self._json("GET", "/org-config", None)
|
|
37
|
+
|
|
35
38
|
def _json(self, method: str, path: str, body: dict[str, Any] | None) -> dict[str, Any]:
|
|
36
39
|
data = (
|
|
37
40
|
None
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import getpass
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .api import DEFAULT_API_URL, ApiClient, poll_until_done, upload_missing_blobs
|
|
14
|
+
from .config import load_credentials, save_credentials
|
|
15
|
+
from .deps import resolve_deps
|
|
16
|
+
from .discovery import derive_app_name, discover_main_files
|
|
17
|
+
from .errors import UrunError
|
|
18
|
+
from .manifest import manifest_hash, unique_blobs
|
|
19
|
+
|
|
20
|
+
API_KEY_RE = re.compile(r"^urun_[0-9a-f]{32}$")
|
|
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 in {"deploy", "run"}:
|
|
28
|
+
return deploy(args)
|
|
29
|
+
if args.command == "login":
|
|
30
|
+
return login(args)
|
|
31
|
+
parser.print_help()
|
|
32
|
+
return 2
|
|
33
|
+
except UrunError as exc:
|
|
34
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
35
|
+
return 1
|
|
36
|
+
except KeyboardInterrupt:
|
|
37
|
+
print("aborted", file=sys.stderr)
|
|
38
|
+
return 130
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
42
|
+
parser = argparse.ArgumentParser(prog="urun")
|
|
43
|
+
parser.add_argument("--version", action="version", version=f"urun {__version__}")
|
|
44
|
+
sub = parser.add_subparsers(dest="command")
|
|
45
|
+
|
|
46
|
+
login_parser = sub.add_parser(
|
|
47
|
+
"login",
|
|
48
|
+
help="Save manually vended deploy credentials",
|
|
49
|
+
description=(
|
|
50
|
+
"Save a manually vended URUN deploy API key. "
|
|
51
|
+
"Browser-based login is planned but not available in this CLI release."
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
add_login_args(login_parser)
|
|
55
|
+
|
|
56
|
+
deploy = sub.add_parser("deploy", help="Deploy a Python app to urun")
|
|
57
|
+
add_deploy_args(deploy)
|
|
58
|
+
|
|
59
|
+
run = sub.add_parser(
|
|
60
|
+
"run",
|
|
61
|
+
help="Run a Python app on urun",
|
|
62
|
+
description=(
|
|
63
|
+
"Run a Python app on urun. In this release, run uses the deploy pipeline "
|
|
64
|
+
"while the full deploy/run/monitor workflow is still being built."
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
add_deploy_args(run)
|
|
68
|
+
return parser
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def add_login_args(parser: argparse.ArgumentParser) -> None:
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--api-url",
|
|
74
|
+
help=f"API base URL; defaults to URUN_API_URL or {DEFAULT_API_URL}",
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--api-key",
|
|
78
|
+
help="Deploy API key; defaults to URUN_API_KEY, otherwise prompts interactively",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def add_api_args(parser: argparse.ArgumentParser) -> None:
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--api-url",
|
|
85
|
+
help=(
|
|
86
|
+
f"API base URL; defaults to URUN_API_URL, saved login credentials, or {DEFAULT_API_URL}"
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
parser.add_argument(
|
|
90
|
+
"--api-key",
|
|
91
|
+
help="Deploy API key; defaults to URUN_API_KEY or saved login credentials",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def add_deploy_args(parser: argparse.ArgumentParser) -> None:
|
|
96
|
+
parser.add_argument("entrypoint", nargs="?", help="Python app file, e.g. app.py")
|
|
97
|
+
parser.add_argument("--name", help="App name (defaults from entrypoint file)")
|
|
98
|
+
add_api_args(parser)
|
|
99
|
+
parser.add_argument(
|
|
100
|
+
"--no-wait", action="store_true", help="Finalize deployment but do not poll for readiness"
|
|
101
|
+
)
|
|
102
|
+
parser.add_argument("--poll-interval", type=float, default=2.0)
|
|
103
|
+
parser.add_argument("--timeout", type=float, default=1800.0)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def login(args: argparse.Namespace) -> int:
|
|
107
|
+
api_url = args.api_url or os.getenv("URUN_API_URL") or DEFAULT_API_URL
|
|
108
|
+
api_key = args.api_key or os.getenv("URUN_API_KEY")
|
|
109
|
+
if not api_key:
|
|
110
|
+
if not sys.stdin.isatty():
|
|
111
|
+
raise UrunError("missing API key; pass --api-key or set URUN_API_KEY")
|
|
112
|
+
api_key = getpass.getpass("URUN API key: ").strip()
|
|
113
|
+
|
|
114
|
+
validate_api_key(api_key)
|
|
115
|
+
config = ApiClient(api_url, api_key).org_config()
|
|
116
|
+
org_id = config_value(config, "org_id")
|
|
117
|
+
key_id = config_value(config, "key_id")
|
|
118
|
+
path = save_credentials(
|
|
119
|
+
{
|
|
120
|
+
"api_url": api_url,
|
|
121
|
+
"api_key": api_key,
|
|
122
|
+
"org_id": org_id,
|
|
123
|
+
"key_id": key_id,
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
print("Authenticated successfully. Credentials saved.")
|
|
128
|
+
print("Login currently uses a manually vended deploy API key.")
|
|
129
|
+
print(f"Org: {org_id}")
|
|
130
|
+
print(f"Key ID: {key_id}")
|
|
131
|
+
print(f"Credentials: {path}")
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def deploy(args: argparse.Namespace) -> int:
|
|
136
|
+
api_url, api_key = resolve_api_credentials(args)
|
|
137
|
+
if args.poll_interval <= 0:
|
|
138
|
+
raise UrunError("--poll-interval must be greater than 0")
|
|
139
|
+
if args.timeout <= 0:
|
|
140
|
+
raise UrunError("--timeout must be greater than 0")
|
|
141
|
+
|
|
142
|
+
project_root = Path.cwd()
|
|
143
|
+
entrypoint, files = discover_main_files(args.entrypoint, project_root)
|
|
144
|
+
deps, deps_blob, python_version = resolve_deps(project_root)
|
|
145
|
+
|
|
146
|
+
app_name = args.name or derive_app_name(args.entrypoint)
|
|
147
|
+
all_blobs = files + ([deps_blob] if deps_blob is not None else [])
|
|
148
|
+
manifest = {
|
|
149
|
+
"version": 1,
|
|
150
|
+
"app_name": app_name,
|
|
151
|
+
"entrypoint": entrypoint,
|
|
152
|
+
"python_version": python_version,
|
|
153
|
+
"files": [b.manifest_entry() for b in sorted(files, key=lambda b: b.path)],
|
|
154
|
+
"deps": deps,
|
|
155
|
+
"created_at": datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
|
156
|
+
"client_version": f"urun-cli/{__version__}",
|
|
157
|
+
}
|
|
158
|
+
mh = manifest_hash(manifest)
|
|
159
|
+
|
|
160
|
+
print(f"Deploying {app_name} ({mh[:12]})")
|
|
161
|
+
print(f"Files: {len(files)} app, deps: {deps['kind']}")
|
|
162
|
+
|
|
163
|
+
client = ApiClient(api_url, api_key)
|
|
164
|
+
register = client.register_manifest(manifest)
|
|
165
|
+
server_hash = register.get("manifest_hash")
|
|
166
|
+
if server_hash and server_hash != mh:
|
|
167
|
+
raise UrunError(f"server manifest hash mismatch: local {mh}, server {server_hash}")
|
|
168
|
+
missing = register.get("missing_blobs", [])
|
|
169
|
+
print(f"Uploading {len(missing)} missing blob(s)")
|
|
170
|
+
upload_missing_blobs(missing, unique_blobs(all_blobs))
|
|
171
|
+
|
|
172
|
+
final = client.finalize(mh)
|
|
173
|
+
print(f"Deployment {final.get('status', 'queued')}: {mh}")
|
|
174
|
+
if args.no_wait:
|
|
175
|
+
status_url = register.get("status_url")
|
|
176
|
+
if status_url:
|
|
177
|
+
print(f"Status: {status_url}")
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
status = poll_until_done(client, mh, args.poll_interval, args.timeout)
|
|
181
|
+
if status.get("status") == "failed":
|
|
182
|
+
err = status.get("error") or {}
|
|
183
|
+
code = err.get("code")
|
|
184
|
+
message = err.get("message")
|
|
185
|
+
detail = f"{code}: {message}" if code and message else (message or code or "unknown error")
|
|
186
|
+
raise UrunError(f"deployment failed: {detail}")
|
|
187
|
+
print_ready(status)
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def resolve_api_credentials(args: argparse.Namespace) -> tuple[str, str]:
|
|
192
|
+
api_key = args.api_key or os.getenv("URUN_API_KEY")
|
|
193
|
+
api_url = args.api_url or os.getenv("URUN_API_URL")
|
|
194
|
+
if not api_key or not api_url:
|
|
195
|
+
saved = load_credentials()
|
|
196
|
+
api_key = api_key or string_value(saved.get("api_key"))
|
|
197
|
+
api_url = api_url or string_value(saved.get("api_url"))
|
|
198
|
+
|
|
199
|
+
api_url = api_url or DEFAULT_API_URL
|
|
200
|
+
if not api_key:
|
|
201
|
+
raise UrunError("missing API key; run `urun login`, set URUN_API_KEY, or pass --api-key")
|
|
202
|
+
validate_api_key(api_key)
|
|
203
|
+
return api_url, api_key
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def validate_api_key(api_key: str) -> None:
|
|
207
|
+
if not API_KEY_RE.fullmatch(api_key):
|
|
208
|
+
raise UrunError("invalid API key format; expected urun_<32 lowercase hex chars>")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def config_value(config: dict[str, Any], key: str) -> str:
|
|
212
|
+
value = string_value(config.get(key))
|
|
213
|
+
if not value:
|
|
214
|
+
raise UrunError(f"org-config response did not include {key}")
|
|
215
|
+
return value
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def string_value(value: Any) -> str | None:
|
|
219
|
+
return value if isinstance(value, str) and value else None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def print_ready(status: dict[str, Any]) -> None:
|
|
223
|
+
print("Deployment ready")
|
|
224
|
+
if status.get("url"):
|
|
225
|
+
print(f"URL: {status['url']}")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
if __name__ == "__main__":
|
|
229
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .errors import UrunError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def config_dir() -> Path:
|
|
13
|
+
override = os.getenv("URUN_CONFIG_HOME")
|
|
14
|
+
if override:
|
|
15
|
+
return Path(override).expanduser()
|
|
16
|
+
|
|
17
|
+
xdg = os.getenv("XDG_CONFIG_HOME")
|
|
18
|
+
if xdg:
|
|
19
|
+
return Path(xdg).expanduser() / "urun"
|
|
20
|
+
|
|
21
|
+
return Path.home() / ".config" / "urun"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def credentials_path() -> Path:
|
|
25
|
+
return config_dir() / "credentials.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_credentials() -> dict[str, Any]:
|
|
29
|
+
path = credentials_path()
|
|
30
|
+
if not path.exists():
|
|
31
|
+
return {}
|
|
32
|
+
try:
|
|
33
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
34
|
+
except OSError as exc:
|
|
35
|
+
raise UrunError(f"could not read credentials file: {path}") from exc
|
|
36
|
+
except json.JSONDecodeError as exc:
|
|
37
|
+
raise UrunError(f"credentials file is not valid JSON: {path}") from exc
|
|
38
|
+
if not isinstance(data, dict):
|
|
39
|
+
raise UrunError(f"credentials file must contain a JSON object: {path}")
|
|
40
|
+
return data
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def save_credentials(credentials: dict[str, Any]) -> Path:
|
|
44
|
+
path = credentials_path()
|
|
45
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
payload = {
|
|
47
|
+
**credentials,
|
|
48
|
+
"updated_at": datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
|
49
|
+
}
|
|
50
|
+
tmp = path.with_suffix(".json.tmp")
|
|
51
|
+
try:
|
|
52
|
+
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
53
|
+
tmp.chmod(0o600)
|
|
54
|
+
tmp.replace(path)
|
|
55
|
+
path.chmod(0o600)
|
|
56
|
+
except OSError as exc:
|
|
57
|
+
raise UrunError(f"could not write credentials file: {path}") from exc
|
|
58
|
+
return path
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
DEFAULT_PYTHON_VERSION = "3.12"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def resolve_deps(_project_root: Path) -> tuple[dict[str, Any], None, str]:
|
|
10
|
+
# Dependencies are declared by the urun app in app.py, not by project-level
|
|
11
|
+
# pyproject.toml or requirements.txt files.
|
|
12
|
+
python_version = DEFAULT_PYTHON_VERSION
|
|
13
|
+
return {"kind": "app", "python_version": python_version}, None, python_version
|