orkestr-cli 0.2.0__tar.gz → 0.3.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.
- {orkestr_cli-0.2.0 → orkestr_cli-0.3.0}/PKG-INFO +15 -8
- {orkestr_cli-0.2.0 → orkestr_cli-0.3.0}/README.md +14 -7
- {orkestr_cli-0.2.0 → orkestr_cli-0.3.0}/orkestr_cli/api.py +13 -1
- orkestr_cli-0.3.0/orkestr_cli/gitinfo.py +108 -0
- orkestr_cli-0.3.0/orkestr_cli/localconfig.py +62 -0
- {orkestr_cli-0.2.0 → orkestr_cli-0.3.0}/orkestr_cli/main.py +270 -11
- {orkestr_cli-0.2.0 → orkestr_cli-0.3.0}/orkestr_cli.egg-info/PKG-INFO +15 -8
- {orkestr_cli-0.2.0 → orkestr_cli-0.3.0}/orkestr_cli.egg-info/SOURCES.txt +4 -1
- {orkestr_cli-0.2.0 → orkestr_cli-0.3.0}/orkestr_cli.egg-info/top_level.txt +0 -1
- {orkestr_cli-0.2.0 → orkestr_cli-0.3.0}/pyproject.toml +2 -1
- orkestr_cli-0.3.0/tests/test_gitinfo.py +110 -0
- {orkestr_cli-0.2.0 → orkestr_cli-0.3.0}/orkestr_cli/__init__.py +0 -0
- {orkestr_cli-0.2.0 → orkestr_cli-0.3.0}/orkestr_cli/config.py +0 -0
- {orkestr_cli-0.2.0 → orkestr_cli-0.3.0}/orkestr_cli/output.py +0 -0
- {orkestr_cli-0.2.0 → orkestr_cli-0.3.0}/orkestr_cli.egg-info/dependency_links.txt +0 -0
- {orkestr_cli-0.2.0 → orkestr_cli-0.3.0}/orkestr_cli.egg-info/entry_points.txt +0 -0
- {orkestr_cli-0.2.0 → orkestr_cli-0.3.0}/orkestr_cli.egg-info/requires.txt +0 -0
- {orkestr_cli-0.2.0 → orkestr_cli-0.3.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: orkestr-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: CLI for the Orkestr deployment platform
|
|
5
5
|
Author-email: Orkestr <hello@orkestr.eu>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -39,28 +39,35 @@ pip install orkestr-cli
|
|
|
39
39
|
# Authenticate with your API token
|
|
40
40
|
orkestr login
|
|
41
41
|
|
|
42
|
-
#
|
|
43
|
-
orkestr
|
|
42
|
+
# From inside your app's git repo: detect name/repo/branch and create the project
|
|
43
|
+
orkestr init
|
|
44
44
|
|
|
45
|
-
#
|
|
45
|
+
# Build, deploy, and wait for the live URL
|
|
46
|
+
orkestr deploy .
|
|
47
|
+
|
|
48
|
+
# ...or deploy a named project
|
|
46
49
|
orkestr deploy my-api
|
|
47
50
|
|
|
48
|
-
# View logs
|
|
51
|
+
# View logs / check health
|
|
49
52
|
orkestr logs my-api
|
|
50
|
-
|
|
51
|
-
# Check health
|
|
52
53
|
orkestr health my-api
|
|
53
54
|
```
|
|
54
55
|
|
|
56
|
+
`orkestr init` writes a gitignored `.orkestr/config.json` linking the
|
|
57
|
+
directory to the project, so `orkestr deploy .` knows what to ship. It needs
|
|
58
|
+
an orkestr account and the repo's git provider (GitHub, GitLab, Bitbucket, or
|
|
59
|
+
Codeberg) connected to that account.
|
|
60
|
+
|
|
55
61
|
## Commands
|
|
56
62
|
|
|
57
63
|
| Command | Description |
|
|
58
64
|
|---|---|
|
|
59
65
|
| `orkestr login` / `logout` / `whoami` | Auth |
|
|
66
|
+
| `orkestr init` | Link the current directory to a new project (interactive) |
|
|
60
67
|
| `orkestr projects list\|create\|show\|update\|delete\|analyze` | Manage projects |
|
|
61
68
|
| `orkestr projects env-vars\|webhook\|webhook-toggle <project>` | Project env vars + git webhook |
|
|
62
69
|
| `orkestr envs list\|create\|update\|delete\|env-vars` | Manage environments |
|
|
63
|
-
| `orkestr deploy
|
|
70
|
+
| `orkestr deploy [project]` | Build + deploy, stream the pipeline, print the live URL (`.` = linked dir; `--detach` to not wait) |
|
|
64
71
|
| `orkestr deployments list\|show\|logs\|build-log\|rollback` | Deployment history |
|
|
65
72
|
| `orkestr functions list\|show\|create\|update\|deploy\|delete` | Serverless functions |
|
|
66
73
|
| `orkestr functions logs\|invoke\|env-vars\|metrics\|rotate-key` | Function ops |
|
|
@@ -14,28 +14,35 @@ pip install orkestr-cli
|
|
|
14
14
|
# Authenticate with your API token
|
|
15
15
|
orkestr login
|
|
16
16
|
|
|
17
|
-
#
|
|
18
|
-
orkestr
|
|
17
|
+
# From inside your app's git repo: detect name/repo/branch and create the project
|
|
18
|
+
orkestr init
|
|
19
19
|
|
|
20
|
-
#
|
|
20
|
+
# Build, deploy, and wait for the live URL
|
|
21
|
+
orkestr deploy .
|
|
22
|
+
|
|
23
|
+
# ...or deploy a named project
|
|
21
24
|
orkestr deploy my-api
|
|
22
25
|
|
|
23
|
-
# View logs
|
|
26
|
+
# View logs / check health
|
|
24
27
|
orkestr logs my-api
|
|
25
|
-
|
|
26
|
-
# Check health
|
|
27
28
|
orkestr health my-api
|
|
28
29
|
```
|
|
29
30
|
|
|
31
|
+
`orkestr init` writes a gitignored `.orkestr/config.json` linking the
|
|
32
|
+
directory to the project, so `orkestr deploy .` knows what to ship. It needs
|
|
33
|
+
an orkestr account and the repo's git provider (GitHub, GitLab, Bitbucket, or
|
|
34
|
+
Codeberg) connected to that account.
|
|
35
|
+
|
|
30
36
|
## Commands
|
|
31
37
|
|
|
32
38
|
| Command | Description |
|
|
33
39
|
|---|---|
|
|
34
40
|
| `orkestr login` / `logout` / `whoami` | Auth |
|
|
41
|
+
| `orkestr init` | Link the current directory to a new project (interactive) |
|
|
35
42
|
| `orkestr projects list\|create\|show\|update\|delete\|analyze` | Manage projects |
|
|
36
43
|
| `orkestr projects env-vars\|webhook\|webhook-toggle <project>` | Project env vars + git webhook |
|
|
37
44
|
| `orkestr envs list\|create\|update\|delete\|env-vars` | Manage environments |
|
|
38
|
-
| `orkestr deploy
|
|
45
|
+
| `orkestr deploy [project]` | Build + deploy, stream the pipeline, print the live URL (`.` = linked dir; `--detach` to not wait) |
|
|
39
46
|
| `orkestr deployments list\|show\|logs\|build-log\|rollback` | Deployment history |
|
|
40
47
|
| `orkestr functions list\|show\|create\|update\|deploy\|delete` | Serverless functions |
|
|
41
48
|
| `orkestr functions logs\|invoke\|env-vars\|metrics\|rotate-key` | Function ops |
|
|
@@ -97,7 +97,7 @@ def list_projects(skip: int = 0, limit: int = 20) -> dict:
|
|
|
97
97
|
|
|
98
98
|
def create_project(name: str, repo_url: str, branch: str = "main",
|
|
99
99
|
display_name: str | None = None, description: str | None = None,
|
|
100
|
-
env_vars: dict | None = None) -> dict:
|
|
100
|
+
env_vars: dict | None = None, build_context: str | None = None) -> dict:
|
|
101
101
|
payload = {"name": name, "repo_url": repo_url, "repo_branch": branch}
|
|
102
102
|
if display_name:
|
|
103
103
|
payload["display_name"] = display_name
|
|
@@ -105,9 +105,21 @@ def create_project(name: str, repo_url: str, branch: str = "main",
|
|
|
105
105
|
payload["description"] = description
|
|
106
106
|
if env_vars:
|
|
107
107
|
payload["env_vars"] = env_vars
|
|
108
|
+
if build_context:
|
|
109
|
+
payload["build_context"] = build_context
|
|
108
110
|
return _post("/api/v1/projects", json=payload)
|
|
109
111
|
|
|
110
112
|
|
|
113
|
+
def detect_services(repo_url: str, branch: str = "main") -> dict:
|
|
114
|
+
"""Detect deployable services in a repo (monorepo support).
|
|
115
|
+
|
|
116
|
+
Note: detect-services is served under /api/projects (not the /api/v1
|
|
117
|
+
prefix the rest of these calls use) — confirmed against the live API.
|
|
118
|
+
"""
|
|
119
|
+
return _post("/api/projects/detect-services",
|
|
120
|
+
params={"repo_url": repo_url, "branch": branch})
|
|
121
|
+
|
|
122
|
+
|
|
111
123
|
def get_project(project_id: str) -> dict:
|
|
112
124
|
return _get(f"/api/v1/projects/{project_id}")
|
|
113
125
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Read repo metadata straight from a local .git directory.
|
|
2
|
+
|
|
3
|
+
No git binary required — init only needs the origin URL and current branch,
|
|
4
|
+
both of which are plain text in .git/config and .git/HEAD. Parsing them
|
|
5
|
+
ourselves keeps init working in containers and minimal images that ship
|
|
6
|
+
without the git CLI.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def find_git_root(start: Path | None = None) -> Path | None:
|
|
14
|
+
"""Walk up from start (default cwd) to the directory containing .git."""
|
|
15
|
+
path = (start or Path.cwd()).resolve()
|
|
16
|
+
for candidate in [path, *path.parents]:
|
|
17
|
+
if (candidate / ".git").exists():
|
|
18
|
+
return candidate
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def normalize_remote_url(url: str) -> str:
|
|
23
|
+
"""Normalize any git remote URL to an https:// clone URL.
|
|
24
|
+
|
|
25
|
+
Handles the three forms a remote shows up as:
|
|
26
|
+
git@github.com:user/repo.git (scp-like ssh)
|
|
27
|
+
ssh://git@github.com/user/repo.git (ssh url)
|
|
28
|
+
https://github.com/user/repo.git (already https)
|
|
29
|
+
The trailing .git and any embedded credentials are stripped.
|
|
30
|
+
"""
|
|
31
|
+
url = url.strip()
|
|
32
|
+
|
|
33
|
+
# scp-like: [user@]host:path
|
|
34
|
+
m = re.match(r"^(?:ssh://)?(?:[^@/]+@)?([^:/]+):(.+?)(?:\.git)?/?$", url)
|
|
35
|
+
if m and "://" not in url:
|
|
36
|
+
return f"https://{m.group(1)}/{m.group(2)}"
|
|
37
|
+
|
|
38
|
+
# ssh://[user@]host[:port]/path
|
|
39
|
+
m = re.match(r"^ssh://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/?$", url)
|
|
40
|
+
if m:
|
|
41
|
+
return f"https://{m.group(1)}/{m.group(2)}"
|
|
42
|
+
|
|
43
|
+
# http(s)://[user[:pass]@]host/path — strip creds + trailing .git
|
|
44
|
+
m = re.match(r"^(https?://)(?:[^@/]+@)?(.+?)(?:\.git)?/?$", url)
|
|
45
|
+
if m:
|
|
46
|
+
return f"{m.group(1)}{m.group(2)}"
|
|
47
|
+
|
|
48
|
+
return url
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def read_origin_url(git_root: Path) -> str | None:
|
|
52
|
+
"""Return the normalized origin remote URL, or the first remote found.
|
|
53
|
+
|
|
54
|
+
Parses .git/config by hand rather than via configparser: git indents
|
|
55
|
+
entries with tabs, which configparser misreads as value continuations.
|
|
56
|
+
"""
|
|
57
|
+
cfg = git_root / ".git" / "config"
|
|
58
|
+
if not cfg.exists():
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
remotes: dict[str, str] = {}
|
|
62
|
+
current_remote: str | None = None
|
|
63
|
+
for raw in cfg.read_text(errors="replace").splitlines():
|
|
64
|
+
line = raw.strip()
|
|
65
|
+
if line.startswith("[") and line.endswith("]"):
|
|
66
|
+
section = line[1:-1].strip()
|
|
67
|
+
if section.startswith("remote "):
|
|
68
|
+
# [remote "origin"] -> origin
|
|
69
|
+
current_remote = section.split('"')[1] if '"' in section else section[7:].strip()
|
|
70
|
+
else:
|
|
71
|
+
current_remote = None
|
|
72
|
+
continue
|
|
73
|
+
if current_remote and "=" in line:
|
|
74
|
+
key, _, value = line.partition("=")
|
|
75
|
+
if key.strip().lower() == "url" and value.strip():
|
|
76
|
+
remotes[current_remote] = normalize_remote_url(value.strip())
|
|
77
|
+
|
|
78
|
+
return remotes.get("origin") or next(iter(remotes.values()), None)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def read_current_branch(git_root: Path) -> str | None:
|
|
82
|
+
"""Return the checked-out branch name, or None if detached."""
|
|
83
|
+
head = git_root / ".git" / "HEAD"
|
|
84
|
+
if not head.exists():
|
|
85
|
+
return None
|
|
86
|
+
text = head.read_text(errors="replace").strip()
|
|
87
|
+
if text.startswith("ref:"):
|
|
88
|
+
# ref: refs/heads/main -> main
|
|
89
|
+
return text.split("refs/heads/", 1)[-1] if "refs/heads/" in text else None
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def slugify(name: str) -> str:
|
|
94
|
+
"""Derive a valid orkestr project slug from a directory name.
|
|
95
|
+
|
|
96
|
+
Slugs must match ^[a-z0-9][a-z0-9-]*$ and be 2-100 chars. Lowercases,
|
|
97
|
+
replaces runs of non-alphanumerics with a single hyphen, trims hyphens,
|
|
98
|
+
and pads to the 2-char minimum.
|
|
99
|
+
"""
|
|
100
|
+
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
|
101
|
+
slug = re.sub(r"-{2,}", "-", slug)
|
|
102
|
+
if not slug:
|
|
103
|
+
slug = "app"
|
|
104
|
+
if not slug[0].isalnum():
|
|
105
|
+
slug = "a" + slug
|
|
106
|
+
if len(slug) < 2:
|
|
107
|
+
slug = (slug + "-app")[:100]
|
|
108
|
+
return slug[:100]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Project-local config: ./.orkestr/config.json.
|
|
2
|
+
|
|
3
|
+
`orkestr init` writes this file to tie the current directory to an orkestr
|
|
4
|
+
project, so `orkestr deploy .` knows what "." means. It is gitignored
|
|
5
|
+
(local-only, per-developer) — distinct from the global ~/.orkestr/config.json
|
|
6
|
+
that holds credentials and the API URL.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
LOCAL_DIR_NAME = ".orkestr"
|
|
13
|
+
LOCAL_FILE_NAME = "config.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def local_dir(root: Path | None = None) -> Path:
|
|
17
|
+
return (root or Path.cwd()) / LOCAL_DIR_NAME
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def find_local_config(start: Path | None = None) -> Path | None:
|
|
21
|
+
"""Walk up from start (default cwd) to find a .orkestr/config.json."""
|
|
22
|
+
path = (start or Path.cwd()).resolve()
|
|
23
|
+
for candidate in [path, *path.parents]:
|
|
24
|
+
cfg = candidate / LOCAL_DIR_NAME / LOCAL_FILE_NAME
|
|
25
|
+
if cfg.exists():
|
|
26
|
+
return cfg
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_local(start: Path | None = None) -> dict | None:
|
|
31
|
+
"""Load the nearest project-local config, or None if there isn't one."""
|
|
32
|
+
path = find_local_config(start)
|
|
33
|
+
if not path:
|
|
34
|
+
return None
|
|
35
|
+
try:
|
|
36
|
+
return json.loads(path.read_text())
|
|
37
|
+
except (json.JSONDecodeError, OSError):
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def write_local(data: dict, root: Path | None = None) -> Path:
|
|
42
|
+
"""Write .orkestr/config.json under root (default cwd) and gitignore it."""
|
|
43
|
+
root = root or Path.cwd()
|
|
44
|
+
directory = local_dir(root)
|
|
45
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
path = directory / LOCAL_FILE_NAME
|
|
47
|
+
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
48
|
+
_ensure_gitignored(root)
|
|
49
|
+
return path
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _ensure_gitignored(root: Path) -> None:
|
|
53
|
+
"""Add `.orkestr/` to the repo's .gitignore if not already ignored."""
|
|
54
|
+
gitignore = root / ".gitignore"
|
|
55
|
+
entry = f"{LOCAL_DIR_NAME}/"
|
|
56
|
+
lines = gitignore.read_text().splitlines() if gitignore.exists() else []
|
|
57
|
+
if any(line.strip() in (entry, LOCAL_DIR_NAME) for line in lines):
|
|
58
|
+
return
|
|
59
|
+
with gitignore.open("a") as f:
|
|
60
|
+
if lines and lines[-1].strip():
|
|
61
|
+
f.write("\n")
|
|
62
|
+
f.write(f"# orkestr CLI local project config\n{entry}\n")
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
"""Orkestr CLI — manage your projects, deployments, and infrastructure."""
|
|
2
2
|
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
3
6
|
import click
|
|
4
7
|
|
|
5
|
-
from orkestr_cli import api, config
|
|
8
|
+
from orkestr_cli import api, config, gitinfo, localconfig
|
|
6
9
|
from orkestr_cli.output import (
|
|
7
10
|
console,
|
|
8
11
|
format_dt,
|
|
@@ -22,7 +25,7 @@ from orkestr_cli.output import (
|
|
|
22
25
|
@click.option("--api-url", envvar="ORKESTR_API_URL", help="Override API base URL.")
|
|
23
26
|
@click.option("--token", envvar="ORKESTR_API_TOKEN", help="API token for auth.")
|
|
24
27
|
@click.option("--json", "use_json", is_flag=True, help="Output raw JSON.")
|
|
25
|
-
@click.version_option("0.
|
|
28
|
+
@click.version_option("0.3.0", prog_name="orkestr")
|
|
26
29
|
@click.pass_context
|
|
27
30
|
def cli(ctx, api_url, token, use_json):
|
|
28
31
|
"""Orkestr CLI — deploy and manage your applications."""
|
|
@@ -464,22 +467,278 @@ def addons_delete_backup(project, addon_id, backup_id):
|
|
|
464
467
|
# ---------------------------------------------------------------------------
|
|
465
468
|
|
|
466
469
|
@cli.command()
|
|
467
|
-
@click.argument("project")
|
|
470
|
+
@click.argument("project", required=False)
|
|
468
471
|
@click.option("--env", "environment_id", help="Environment ID (defaults to production).")
|
|
472
|
+
@click.option("--detach", "-d", is_flag=True,
|
|
473
|
+
help="Trigger and return immediately, without waiting (for CI).")
|
|
474
|
+
@click.option("--timeout", default=600, show_default=True,
|
|
475
|
+
help="Max seconds to wait for the deploy to finish.")
|
|
469
476
|
@click.pass_context
|
|
470
|
-
def deploy(ctx, project, environment_id):
|
|
471
|
-
"""
|
|
472
|
-
|
|
477
|
+
def deploy(ctx, project, environment_id, detach, timeout):
|
|
478
|
+
"""Build and deploy a project, waiting until it's live.
|
|
479
|
+
|
|
480
|
+
PROJECT is a project name. Pass "." (or omit it) to deploy the project
|
|
481
|
+
linked to the current directory by 'orkestr init'. Use --detach to fire
|
|
482
|
+
the deploy and return immediately without streaming the pipeline.
|
|
483
|
+
"""
|
|
484
|
+
project_id = _resolve_deploy_target(project)
|
|
473
485
|
data = api.trigger_deployment(project_id, environment_id)
|
|
486
|
+
deployment_id = data["id"]
|
|
487
|
+
|
|
488
|
+
if detach:
|
|
489
|
+
if ctx.obj["json"]:
|
|
490
|
+
print_json(data)
|
|
491
|
+
return
|
|
492
|
+
click.secho("Deployment triggered.", fg="green")
|
|
493
|
+
print_kv([
|
|
494
|
+
("Deployment ID:", deployment_id),
|
|
495
|
+
("Status:", data.get("status", "-")),
|
|
496
|
+
])
|
|
497
|
+
click.echo(f"\nTrack progress: orkestr deployments show {deployment_id}")
|
|
498
|
+
return
|
|
499
|
+
|
|
500
|
+
final = _wait_for_deployment(deployment_id, timeout, json_mode=ctx.obj["json"])
|
|
501
|
+
|
|
474
502
|
if ctx.obj["json"]:
|
|
475
|
-
print_json(
|
|
503
|
+
print_json(final)
|
|
504
|
+
if final.get("status") != "live":
|
|
505
|
+
raise SystemExit(1)
|
|
476
506
|
return
|
|
477
|
-
|
|
507
|
+
|
|
508
|
+
click.echo()
|
|
509
|
+
if final.get("status") == "live":
|
|
510
|
+
domain = _project_domain(project_id)
|
|
511
|
+
if domain:
|
|
512
|
+
click.secho(f" Live at https://{domain}", fg="green", bold=True)
|
|
513
|
+
else:
|
|
514
|
+
click.secho(" Deployment live.", fg="green", bold=True)
|
|
515
|
+
else:
|
|
516
|
+
click.secho(" Deployment failed.", fg="red", bold=True)
|
|
517
|
+
click.echo(f" See logs: orkestr deployments logs {deployment_id}")
|
|
518
|
+
raise SystemExit(1)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _resolve_deploy_target(project: str | None) -> str:
|
|
522
|
+
"""Resolve a deploy target to a project id, honoring ./.orkestr/config.json.
|
|
523
|
+
|
|
524
|
+
`.` (or no argument) means "the project linked to this directory". This
|
|
525
|
+
short-circuits before resolve_project_id, which would otherwise treat "."
|
|
526
|
+
as a name and fail to find it.
|
|
527
|
+
"""
|
|
528
|
+
if project in (None, "", "."):
|
|
529
|
+
cfg = localconfig.load_local()
|
|
530
|
+
if not cfg or not cfg.get("project"):
|
|
531
|
+
click.secho(
|
|
532
|
+
"No orkestr project linked to this directory. "
|
|
533
|
+
"Run 'orkestr init' first, or pass a project name.",
|
|
534
|
+
fg="red", err=True,
|
|
535
|
+
)
|
|
536
|
+
raise SystemExit(1)
|
|
537
|
+
# The stored project_id is canonical — use it directly rather than
|
|
538
|
+
# round-tripping through the name/UUID heuristic in resolve_project_id.
|
|
539
|
+
if cfg.get("project_id"):
|
|
540
|
+
return cfg["project_id"]
|
|
541
|
+
return api.resolve_project_id(cfg["project"])
|
|
542
|
+
return api.resolve_project_id(project)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _wait_for_deployment(deployment_id: str, timeout: int, json_mode: bool) -> dict:
|
|
546
|
+
"""Poll a deployment until it is live or failed, streaming step transitions."""
|
|
547
|
+
seen: dict[str, str] = {}
|
|
548
|
+
waited = 0
|
|
549
|
+
interval = 2
|
|
550
|
+
while True:
|
|
551
|
+
data = api.get_deployment(deployment_id)
|
|
552
|
+
if not json_mode:
|
|
553
|
+
_render_step_transitions(data.get("steps", []), seen)
|
|
554
|
+
if data.get("status") in ("live", "failed"):
|
|
555
|
+
return data
|
|
556
|
+
if waited >= timeout:
|
|
557
|
+
if not json_mode:
|
|
558
|
+
click.secho(f"\n Timed out after {timeout}s.", fg="yellow")
|
|
559
|
+
return data
|
|
560
|
+
time.sleep(interval)
|
|
561
|
+
waited += interval
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _render_step_transitions(steps: list, seen: dict) -> None:
|
|
565
|
+
"""Print a line each time a pipeline step changes state."""
|
|
566
|
+
for s in steps:
|
|
567
|
+
name = s.get("step_name", "")
|
|
568
|
+
status = s.get("status", "")
|
|
569
|
+
if not name or seen.get(name) == status:
|
|
570
|
+
continue
|
|
571
|
+
seen[name] = status
|
|
572
|
+
if status == "running":
|
|
573
|
+
click.secho(f" > {name} ...", fg="cyan")
|
|
574
|
+
elif status == "completed":
|
|
575
|
+
click.secho(f" OK {name}", fg="green")
|
|
576
|
+
elif status == "failed":
|
|
577
|
+
click.secho(f" FAIL {name}", fg="red")
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _project_domain(project_id: str) -> str | None:
|
|
581
|
+
"""Best-effort fetch of a project's live domain (not on the deployment payload)."""
|
|
582
|
+
try:
|
|
583
|
+
return api.get_project(project_id).get("domain")
|
|
584
|
+
except SystemExit:
|
|
585
|
+
return None
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
# ---------------------------------------------------------------------------
|
|
589
|
+
# Init (interactive project setup)
|
|
590
|
+
# ---------------------------------------------------------------------------
|
|
591
|
+
|
|
592
|
+
@cli.command()
|
|
593
|
+
@click.option("--dir", "directory", default=".", type=click.Path(file_okay=False),
|
|
594
|
+
help="Project directory (default: current).")
|
|
595
|
+
@click.option("--name", help="Project name (slug). Default: derived from the directory.")
|
|
596
|
+
@click.option("--repo", help="Git repository URL. Default: the origin remote in .git.")
|
|
597
|
+
@click.option("--branch", help="Branch to deploy. Default: the current branch.")
|
|
598
|
+
@click.option("--yes", "-y", "assume_yes", is_flag=True,
|
|
599
|
+
help="Accept all detected defaults without prompting.")
|
|
600
|
+
@click.pass_context
|
|
601
|
+
def init(ctx, directory, name, repo, branch, assume_yes):
|
|
602
|
+
"""Link this directory to a new orkestr project, interactively.
|
|
603
|
+
|
|
604
|
+
Detects the project name, git repo, and branch from the directory, lets
|
|
605
|
+
you confirm each with Enter, picks the service for monorepos, creates the
|
|
606
|
+
project, and writes .orkestr/config.json so you can then 'orkestr deploy .'.
|
|
607
|
+
"""
|
|
608
|
+
if not config.is_logged_in():
|
|
609
|
+
click.secho("Not logged in. Run: orkestr login", fg="red", err=True)
|
|
610
|
+
raise SystemExit(1)
|
|
611
|
+
user = api.me()
|
|
612
|
+
|
|
613
|
+
root = Path(directory).resolve()
|
|
614
|
+
if not root.is_dir():
|
|
615
|
+
click.secho(f"Not a directory: {root}", fg="red", err=True)
|
|
616
|
+
raise SystemExit(1)
|
|
617
|
+
|
|
618
|
+
existing = localconfig.load_local(root)
|
|
619
|
+
if existing and existing.get("project"):
|
|
620
|
+
click.secho(f"This directory is already linked to '{existing['project']}'.",
|
|
621
|
+
fg="yellow")
|
|
622
|
+
if not assume_yes and not click.confirm("Re-initialize and overwrite?", default=False):
|
|
623
|
+
raise SystemExit(0)
|
|
624
|
+
|
|
625
|
+
git_root = gitinfo.find_git_root(root)
|
|
626
|
+
default_name = gitinfo.slugify(name or root.name)
|
|
627
|
+
default_repo = repo or (gitinfo.read_origin_url(git_root) if git_root else None)
|
|
628
|
+
default_branch = (
|
|
629
|
+
branch or (gitinfo.read_current_branch(git_root) if git_root else None) or "main"
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
click.secho(f"Initializing orkestr project in {root}", bold=True)
|
|
633
|
+
click.echo(f"Logged in as {user.get('username', 'unknown')}.\n")
|
|
634
|
+
|
|
635
|
+
project_name = _prompt_project_name(name or default_name, assume_yes)
|
|
636
|
+
|
|
637
|
+
project_repo = repo or default_repo
|
|
638
|
+
if not assume_yes:
|
|
639
|
+
project_repo = click.prompt(
|
|
640
|
+
"Git repository", default=default_repo or "",
|
|
641
|
+
show_default=bool(default_repo),
|
|
642
|
+
).strip()
|
|
643
|
+
if not project_repo:
|
|
644
|
+
click.secho("A git repository URL is required (no origin remote found).",
|
|
645
|
+
fg="red", err=True)
|
|
646
|
+
raise SystemExit(1)
|
|
647
|
+
|
|
648
|
+
project_branch = branch or default_branch
|
|
649
|
+
if not assume_yes:
|
|
650
|
+
project_branch = click.prompt("Branch", default=default_branch).strip()
|
|
651
|
+
|
|
652
|
+
build_context = _pick_service(project_repo, project_branch, assume_yes)
|
|
653
|
+
|
|
654
|
+
dns = f"{project_name}.orkestr.run"
|
|
655
|
+
click.echo()
|
|
478
656
|
print_kv([
|
|
479
|
-
("
|
|
480
|
-
("
|
|
657
|
+
("Name:", project_name),
|
|
658
|
+
("Repo:", project_repo),
|
|
659
|
+
("Branch:", project_branch),
|
|
660
|
+
("Service:", build_context or "(repo root)"),
|
|
661
|
+
("DNS:", dns),
|
|
481
662
|
])
|
|
482
|
-
click.
|
|
663
|
+
if not assume_yes and not click.confirm("\nCreate this project?", default=True):
|
|
664
|
+
click.secho("Aborted.", fg="yellow")
|
|
665
|
+
raise SystemExit(0)
|
|
666
|
+
|
|
667
|
+
# display_name must be >= 2 chars server-side; a 1-char dir name would 422.
|
|
668
|
+
display_name = root.name if len(root.name) >= 2 else project_name
|
|
669
|
+
data = api.create_project(
|
|
670
|
+
name=project_name,
|
|
671
|
+
repo_url=project_repo,
|
|
672
|
+
branch=project_branch,
|
|
673
|
+
display_name=display_name,
|
|
674
|
+
build_context=build_context,
|
|
675
|
+
)
|
|
676
|
+
localconfig.write_local({
|
|
677
|
+
"project": data["name"],
|
|
678
|
+
"project_id": data["id"],
|
|
679
|
+
"branch": project_branch,
|
|
680
|
+
"build_context": build_context or ".",
|
|
681
|
+
}, root=root)
|
|
682
|
+
|
|
683
|
+
click.echo()
|
|
684
|
+
click.secho(f"Project '{data['name']}' created and linked.", fg="green")
|
|
685
|
+
print_kv([
|
|
686
|
+
("Domain:", data.get("domain") or dns),
|
|
687
|
+
("Config:", ".orkestr/config.json"),
|
|
688
|
+
])
|
|
689
|
+
click.echo("\nNext: orkestr deploy .")
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _prompt_project_name(default_name: str, assume_yes: bool) -> str:
|
|
693
|
+
"""Prompt for a project name and ensure the slug is valid and available."""
|
|
694
|
+
name = gitinfo.slugify(default_name)
|
|
695
|
+
while True:
|
|
696
|
+
if not assume_yes:
|
|
697
|
+
name = gitinfo.slugify(click.prompt("Project name", default=name))
|
|
698
|
+
check = api.project_check_name(name)
|
|
699
|
+
if check.get("available"):
|
|
700
|
+
return name
|
|
701
|
+
reason = "reserved" if check.get("reason") == "reserved" else "already taken"
|
|
702
|
+
click.secho(f" '{name}' is {reason}.", fg="yellow")
|
|
703
|
+
if assume_yes:
|
|
704
|
+
for i in range(2, 100):
|
|
705
|
+
candidate = gitinfo.slugify(f"{default_name}-{i}")
|
|
706
|
+
if api.project_check_name(candidate).get("available"):
|
|
707
|
+
click.secho(f" Using '{candidate}'.", fg="cyan")
|
|
708
|
+
return candidate
|
|
709
|
+
click.secho("Could not find an available name.", fg="red", err=True)
|
|
710
|
+
raise SystemExit(1)
|
|
711
|
+
name = gitinfo.slugify(f"{name}-2")
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _pick_service(repo_url: str, branch: str, assume_yes: bool) -> str | None:
|
|
715
|
+
"""Detect deployable services; return the chosen build_context (None = root)."""
|
|
716
|
+
click.echo(" Scanning the repo for services ...")
|
|
717
|
+
try:
|
|
718
|
+
resp = api.detect_services(repo_url, branch)
|
|
719
|
+
except SystemExit:
|
|
720
|
+
click.secho(" Could not scan the repo; defaulting to the repo root.",
|
|
721
|
+
fg="yellow")
|
|
722
|
+
return None
|
|
723
|
+
services = resp.get("services", []) if isinstance(resp, dict) else []
|
|
724
|
+
|
|
725
|
+
if len(services) <= 1:
|
|
726
|
+
only = services[0] if services else None
|
|
727
|
+
if only and only.get("path") not in (".", "", None):
|
|
728
|
+
return only.get("path")
|
|
729
|
+
return None
|
|
730
|
+
|
|
731
|
+
click.echo("\n Multiple services detected:")
|
|
732
|
+
for i, s in enumerate(services, 1):
|
|
733
|
+
click.echo(f" {i}. {s.get('path')} ({s.get('framework') or 'unknown'})")
|
|
734
|
+
if assume_yes:
|
|
735
|
+
chosen = services[0]
|
|
736
|
+
click.secho(f" Using '{chosen.get('path')}' (first match).", fg="cyan")
|
|
737
|
+
path = chosen.get("path")
|
|
738
|
+
return None if path in (".", "") else path
|
|
739
|
+
idx = click.prompt(" Pick a service", type=click.IntRange(1, len(services)), default=1)
|
|
740
|
+
path = services[idx - 1].get("path")
|
|
741
|
+
return None if path in (".", "") else path
|
|
483
742
|
|
|
484
743
|
|
|
485
744
|
# ---------------------------------------------------------------------------
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: orkestr-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: CLI for the Orkestr deployment platform
|
|
5
5
|
Author-email: Orkestr <hello@orkestr.eu>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -39,28 +39,35 @@ pip install orkestr-cli
|
|
|
39
39
|
# Authenticate with your API token
|
|
40
40
|
orkestr login
|
|
41
41
|
|
|
42
|
-
#
|
|
43
|
-
orkestr
|
|
42
|
+
# From inside your app's git repo: detect name/repo/branch and create the project
|
|
43
|
+
orkestr init
|
|
44
44
|
|
|
45
|
-
#
|
|
45
|
+
# Build, deploy, and wait for the live URL
|
|
46
|
+
orkestr deploy .
|
|
47
|
+
|
|
48
|
+
# ...or deploy a named project
|
|
46
49
|
orkestr deploy my-api
|
|
47
50
|
|
|
48
|
-
# View logs
|
|
51
|
+
# View logs / check health
|
|
49
52
|
orkestr logs my-api
|
|
50
|
-
|
|
51
|
-
# Check health
|
|
52
53
|
orkestr health my-api
|
|
53
54
|
```
|
|
54
55
|
|
|
56
|
+
`orkestr init` writes a gitignored `.orkestr/config.json` linking the
|
|
57
|
+
directory to the project, so `orkestr deploy .` knows what to ship. It needs
|
|
58
|
+
an orkestr account and the repo's git provider (GitHub, GitLab, Bitbucket, or
|
|
59
|
+
Codeberg) connected to that account.
|
|
60
|
+
|
|
55
61
|
## Commands
|
|
56
62
|
|
|
57
63
|
| Command | Description |
|
|
58
64
|
|---|---|
|
|
59
65
|
| `orkestr login` / `logout` / `whoami` | Auth |
|
|
66
|
+
| `orkestr init` | Link the current directory to a new project (interactive) |
|
|
60
67
|
| `orkestr projects list\|create\|show\|update\|delete\|analyze` | Manage projects |
|
|
61
68
|
| `orkestr projects env-vars\|webhook\|webhook-toggle <project>` | Project env vars + git webhook |
|
|
62
69
|
| `orkestr envs list\|create\|update\|delete\|env-vars` | Manage environments |
|
|
63
|
-
| `orkestr deploy
|
|
70
|
+
| `orkestr deploy [project]` | Build + deploy, stream the pipeline, print the live URL (`.` = linked dir; `--detach` to not wait) |
|
|
64
71
|
| `orkestr deployments list\|show\|logs\|build-log\|rollback` | Deployment history |
|
|
65
72
|
| `orkestr functions list\|show\|create\|update\|deploy\|delete` | Serverless functions |
|
|
66
73
|
| `orkestr functions logs\|invoke\|env-vars\|metrics\|rotate-key` | Function ops |
|
|
@@ -3,6 +3,8 @@ pyproject.toml
|
|
|
3
3
|
orkestr_cli/__init__.py
|
|
4
4
|
orkestr_cli/api.py
|
|
5
5
|
orkestr_cli/config.py
|
|
6
|
+
orkestr_cli/gitinfo.py
|
|
7
|
+
orkestr_cli/localconfig.py
|
|
6
8
|
orkestr_cli/main.py
|
|
7
9
|
orkestr_cli/output.py
|
|
8
10
|
orkestr_cli.egg-info/PKG-INFO
|
|
@@ -10,4 +12,5 @@ orkestr_cli.egg-info/SOURCES.txt
|
|
|
10
12
|
orkestr_cli.egg-info/dependency_links.txt
|
|
11
13
|
orkestr_cli.egg-info/entry_points.txt
|
|
12
14
|
orkestr_cli.egg-info/requires.txt
|
|
13
|
-
orkestr_cli.egg-info/top_level.txt
|
|
15
|
+
orkestr_cli.egg-info/top_level.txt
|
|
16
|
+
tests/test_gitinfo.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "orkestr-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "CLI for the Orkestr deployment platform"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -40,3 +40,4 @@ orkestr = "orkestr_cli.main:cli"
|
|
|
40
40
|
|
|
41
41
|
[tool.setuptools.packages.find]
|
|
42
42
|
where = ["."]
|
|
43
|
+
include = ["orkestr_cli*"]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Tests for the pure git-metadata + slug helpers in gitinfo."""
|
|
2
|
+
|
|
3
|
+
from orkestr_cli import gitinfo
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestNormalizeRemoteUrl:
|
|
7
|
+
def test_scp_like_ssh(self):
|
|
8
|
+
assert (gitinfo.normalize_remote_url("git@github.com:user/repo.git")
|
|
9
|
+
== "https://github.com/user/repo")
|
|
10
|
+
|
|
11
|
+
def test_scp_like_no_dot_git(self):
|
|
12
|
+
assert (gitinfo.normalize_remote_url("git@gitlab.com:group/sub/repo")
|
|
13
|
+
== "https://gitlab.com/group/sub/repo")
|
|
14
|
+
|
|
15
|
+
def test_ssh_url(self):
|
|
16
|
+
assert (gitinfo.normalize_remote_url("ssh://git@github.com/user/repo.git")
|
|
17
|
+
== "https://github.com/user/repo")
|
|
18
|
+
|
|
19
|
+
def test_ssh_url_with_port(self):
|
|
20
|
+
assert (gitinfo.normalize_remote_url("ssh://git@example.com:2222/user/repo.git")
|
|
21
|
+
== "https://example.com/user/repo")
|
|
22
|
+
|
|
23
|
+
def test_https_strips_dot_git(self):
|
|
24
|
+
assert (gitinfo.normalize_remote_url("https://github.com/user/repo.git")
|
|
25
|
+
== "https://github.com/user/repo")
|
|
26
|
+
|
|
27
|
+
def test_https_already_clean(self):
|
|
28
|
+
assert (gitinfo.normalize_remote_url("https://github.com/user/repo")
|
|
29
|
+
== "https://github.com/user/repo")
|
|
30
|
+
|
|
31
|
+
def test_https_strips_embedded_credentials(self):
|
|
32
|
+
assert (gitinfo.normalize_remote_url("https://token@github.com/user/repo.git")
|
|
33
|
+
== "https://github.com/user/repo")
|
|
34
|
+
|
|
35
|
+
def test_trailing_slash(self):
|
|
36
|
+
assert (gitinfo.normalize_remote_url("https://github.com/user/repo/")
|
|
37
|
+
== "https://github.com/user/repo")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestSlugify:
|
|
41
|
+
def test_lowercases(self):
|
|
42
|
+
assert gitinfo.slugify("MyApp") == "myapp"
|
|
43
|
+
|
|
44
|
+
def test_spaces_and_underscores(self):
|
|
45
|
+
assert gitinfo.slugify("my cool_app") == "my-cool-app"
|
|
46
|
+
|
|
47
|
+
def test_collapses_separators(self):
|
|
48
|
+
assert gitinfo.slugify("my___app") == "my-app"
|
|
49
|
+
|
|
50
|
+
def test_trims_leading_trailing(self):
|
|
51
|
+
assert gitinfo.slugify("--app--") == "app"
|
|
52
|
+
|
|
53
|
+
def test_leading_non_alnum_gets_prefix(self):
|
|
54
|
+
assert gitinfo.slugify("_private")[0].isalnum()
|
|
55
|
+
|
|
56
|
+
def test_empty_falls_back(self):
|
|
57
|
+
assert gitinfo.slugify("!!!") == "app"
|
|
58
|
+
|
|
59
|
+
def test_min_length(self):
|
|
60
|
+
assert len(gitinfo.slugify("x")) >= 2
|
|
61
|
+
|
|
62
|
+
def test_valid_pattern(self):
|
|
63
|
+
import re
|
|
64
|
+
for name in ["My App!", "123-go", "___", "a", "Über-Service"]:
|
|
65
|
+
slug = gitinfo.slugify(name)
|
|
66
|
+
assert re.match(r"^[a-z0-9][a-z0-9-]*$", slug), slug
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TestReadGitConfig:
|
|
70
|
+
def _write_repo(self, tmp_path, config_text, head_text="ref: refs/heads/main\n"):
|
|
71
|
+
git = tmp_path / ".git"
|
|
72
|
+
git.mkdir()
|
|
73
|
+
(git / "config").write_text(config_text)
|
|
74
|
+
(git / "HEAD").write_text(head_text)
|
|
75
|
+
return tmp_path
|
|
76
|
+
|
|
77
|
+
def test_reads_origin_with_tabs(self, tmp_path):
|
|
78
|
+
# git indents entries with tabs — the parser must handle that.
|
|
79
|
+
cfg = (
|
|
80
|
+
'[core]\n\trepositoryformatversion = 0\n'
|
|
81
|
+
'[remote "origin"]\n\turl = git@github.com:user/repo.git\n'
|
|
82
|
+
'\tfetch = +refs/heads/*:refs/remotes/origin/*\n'
|
|
83
|
+
)
|
|
84
|
+
root = self._write_repo(tmp_path, cfg)
|
|
85
|
+
assert gitinfo.read_origin_url(root) == "https://github.com/user/repo"
|
|
86
|
+
|
|
87
|
+
def test_prefers_origin_over_other_remote(self, tmp_path):
|
|
88
|
+
cfg = (
|
|
89
|
+
'[remote "upstream"]\n\turl = https://github.com/up/stream.git\n'
|
|
90
|
+
'[remote "origin"]\n\turl = https://github.com/me/mine.git\n'
|
|
91
|
+
)
|
|
92
|
+
root = self._write_repo(tmp_path, cfg)
|
|
93
|
+
assert gitinfo.read_origin_url(root) == "https://github.com/me/mine"
|
|
94
|
+
|
|
95
|
+
def test_falls_back_to_first_remote(self, tmp_path):
|
|
96
|
+
cfg = '[remote "fork"]\n\turl = https://github.com/x/y.git\n'
|
|
97
|
+
root = self._write_repo(tmp_path, cfg)
|
|
98
|
+
assert gitinfo.read_origin_url(root) == "https://github.com/x/y"
|
|
99
|
+
|
|
100
|
+
def test_no_remote_returns_none(self, tmp_path):
|
|
101
|
+
root = self._write_repo(tmp_path, "[core]\n\tbare = false\n")
|
|
102
|
+
assert gitinfo.read_origin_url(root) is None
|
|
103
|
+
|
|
104
|
+
def test_current_branch(self, tmp_path):
|
|
105
|
+
root = self._write_repo(tmp_path, "[core]\n", head_text="ref: refs/heads/feature/x\n")
|
|
106
|
+
assert gitinfo.read_current_branch(root) == "feature/x"
|
|
107
|
+
|
|
108
|
+
def test_detached_head_returns_none(self, tmp_path):
|
|
109
|
+
root = self._write_repo(tmp_path, "[core]\n", head_text="abc123def456\n")
|
|
110
|
+
assert gitinfo.read_current_branch(root) is None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|