monarch-cloud 0.1.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.
- monarch_cloud-0.1.0/PKG-INFO +76 -0
- monarch_cloud-0.1.0/README.md +60 -0
- monarch_cloud-0.1.0/pyproject.toml +30 -0
- monarch_cloud-0.1.0/setup.cfg +4 -0
- monarch_cloud-0.1.0/src/monarch_cloud/__init__.py +1 -0
- monarch_cloud-0.1.0/src/monarch_cloud/cli.py +843 -0
- monarch_cloud-0.1.0/src/monarch_cloud.egg-info/PKG-INFO +76 -0
- monarch_cloud-0.1.0/src/monarch_cloud.egg-info/SOURCES.txt +10 -0
- monarch_cloud-0.1.0/src/monarch_cloud.egg-info/dependency_links.txt +1 -0
- monarch_cloud-0.1.0/src/monarch_cloud.egg-info/entry_points.txt +2 -0
- monarch_cloud-0.1.0/src/monarch_cloud.egg-info/requires.txt +1 -0
- monarch_cloud-0.1.0/src/monarch_cloud.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: monarch-cloud
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Public developer CLI for Monarch Cloud
|
|
5
|
+
Author: Africa Compute Fund
|
|
6
|
+
Project-URL: Homepage, https://app.africacomputefund.com
|
|
7
|
+
Project-URL: Documentation, https://app.africacomputefund.com/docs
|
|
8
|
+
Keywords: monarch,cloud,compute,developer-tools,cli
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: httpx<1,>=0.24
|
|
16
|
+
|
|
17
|
+
# Monarch Cloud CLI
|
|
18
|
+
|
|
19
|
+
A lightweight public developer client for the existing Monarch REST Platform API.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
python -m pip install monarch-cloud
|
|
23
|
+
monarch login
|
|
24
|
+
monarch whoami
|
|
25
|
+
monarch catalog
|
|
26
|
+
monarch gpus
|
|
27
|
+
monarch models --scope catalog
|
|
28
|
+
monarch jobs
|
|
29
|
+
monarch wallet
|
|
30
|
+
monarch init "My app" --blueprint backend
|
|
31
|
+
monarch status
|
|
32
|
+
monarch dev
|
|
33
|
+
monarch deploy hello ./hello.js
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Authentication:** `monarch login` prompts for an existing Monarch REST API key
|
|
37
|
+
without echoing it, validates it against `GET /v1/account/identity`, then saves
|
|
38
|
+
it in a mode-0600 file under your user configuration directory. For ephemeral
|
|
39
|
+
environments set `MONARCH_API_KEY` instead; this takes precedence. This is REST
|
|
40
|
+
API-key login, **not** the OAuth browser flow used by the MCP connector. Key
|
|
41
|
+
creation and account access are provided through Monarch Cloud.
|
|
42
|
+
|
|
43
|
+
`monarch mcp` prints the public OAuth MCP endpoint for compatible AI clients;
|
|
44
|
+
it does not bundle an MCP server or impersonate an OAuth token.
|
|
45
|
+
|
|
46
|
+
## Command surfaces
|
|
47
|
+
|
|
48
|
+
- Discovery: `catalog`, `gpus`, `models`, `datasets`, `jobs`, `job ID`,
|
|
49
|
+
`wallet`, `inference-models`, `whoami`.
|
|
50
|
+
- Projects: `init`, `status`, `dev`, `deploy NAME SOURCE_JS`.
|
|
51
|
+
- Migration: `migrate`, `migrate-storage`, `migrate-postgres`,
|
|
52
|
+
`migrate-gcs`, `import-documents`.
|
|
53
|
+
- Account: `login`, `logout`, `mcp`.
|
|
54
|
+
|
|
55
|
+
`deploy` deploys one JavaScript **Project Function**, not arbitrary whole-app
|
|
56
|
+
infrastructure. Some public REST APIs and the MCP's purpose-built tools are
|
|
57
|
+
not yet wrapped as CLI commands; Cloud, REST and MCP remain the source of truth.
|
|
58
|
+
|
|
59
|
+
Project selection is stored in `.monarch/project.json`. The one-time Project
|
|
60
|
+
server key is saved in `.monarch/server-key` and must never be committed.
|
|
61
|
+
Source migration secrets are only read from environment variables or named
|
|
62
|
+
credential files, not CLI arguments. The server enforces the same account,
|
|
63
|
+
scope, billing, quotas, and approvals as its other public clients.
|
|
64
|
+
|
|
65
|
+
## For maintainers
|
|
66
|
+
|
|
67
|
+
The public release is built from the **canonical** implementation at
|
|
68
|
+
`scripts/monarch_platform.py` using
|
|
69
|
+
`python scripts/package_monarch_cloud.py --build`. The staging tool copies
|
|
70
|
+
only this public module, the package metadata, and this README into an isolated
|
|
71
|
+
temporary build directory. The private `./monarch` operator CLI, admin scripts,
|
|
72
|
+
deployment infrastructure, and internal MCP server are not packaged.
|
|
73
|
+
|
|
74
|
+
There is no CI publisher and no GitHub Actions workflow. The owner publishes
|
|
75
|
+
the reviewed files to PyPI explicitly after checking the build. See
|
|
76
|
+
`docs/operations/MONARCH_CLOUD_PYPI.md` for release instructions.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Monarch Cloud CLI
|
|
2
|
+
|
|
3
|
+
A lightweight public developer client for the existing Monarch REST Platform API.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
python -m pip install monarch-cloud
|
|
7
|
+
monarch login
|
|
8
|
+
monarch whoami
|
|
9
|
+
monarch catalog
|
|
10
|
+
monarch gpus
|
|
11
|
+
monarch models --scope catalog
|
|
12
|
+
monarch jobs
|
|
13
|
+
monarch wallet
|
|
14
|
+
monarch init "My app" --blueprint backend
|
|
15
|
+
monarch status
|
|
16
|
+
monarch dev
|
|
17
|
+
monarch deploy hello ./hello.js
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
**Authentication:** `monarch login` prompts for an existing Monarch REST API key
|
|
21
|
+
without echoing it, validates it against `GET /v1/account/identity`, then saves
|
|
22
|
+
it in a mode-0600 file under your user configuration directory. For ephemeral
|
|
23
|
+
environments set `MONARCH_API_KEY` instead; this takes precedence. This is REST
|
|
24
|
+
API-key login, **not** the OAuth browser flow used by the MCP connector. Key
|
|
25
|
+
creation and account access are provided through Monarch Cloud.
|
|
26
|
+
|
|
27
|
+
`monarch mcp` prints the public OAuth MCP endpoint for compatible AI clients;
|
|
28
|
+
it does not bundle an MCP server or impersonate an OAuth token.
|
|
29
|
+
|
|
30
|
+
## Command surfaces
|
|
31
|
+
|
|
32
|
+
- Discovery: `catalog`, `gpus`, `models`, `datasets`, `jobs`, `job ID`,
|
|
33
|
+
`wallet`, `inference-models`, `whoami`.
|
|
34
|
+
- Projects: `init`, `status`, `dev`, `deploy NAME SOURCE_JS`.
|
|
35
|
+
- Migration: `migrate`, `migrate-storage`, `migrate-postgres`,
|
|
36
|
+
`migrate-gcs`, `import-documents`.
|
|
37
|
+
- Account: `login`, `logout`, `mcp`.
|
|
38
|
+
|
|
39
|
+
`deploy` deploys one JavaScript **Project Function**, not arbitrary whole-app
|
|
40
|
+
infrastructure. Some public REST APIs and the MCP's purpose-built tools are
|
|
41
|
+
not yet wrapped as CLI commands; Cloud, REST and MCP remain the source of truth.
|
|
42
|
+
|
|
43
|
+
Project selection is stored in `.monarch/project.json`. The one-time Project
|
|
44
|
+
server key is saved in `.monarch/server-key` and must never be committed.
|
|
45
|
+
Source migration secrets are only read from environment variables or named
|
|
46
|
+
credential files, not CLI arguments. The server enforces the same account,
|
|
47
|
+
scope, billing, quotas, and approvals as its other public clients.
|
|
48
|
+
|
|
49
|
+
## For maintainers
|
|
50
|
+
|
|
51
|
+
The public release is built from the **canonical** implementation at
|
|
52
|
+
`scripts/monarch_platform.py` using
|
|
53
|
+
`python scripts/package_monarch_cloud.py --build`. The staging tool copies
|
|
54
|
+
only this public module, the package metadata, and this README into an isolated
|
|
55
|
+
temporary build directory. The private `./monarch` operator CLI, admin scripts,
|
|
56
|
+
deployment infrastructure, and internal MCP server are not packaged.
|
|
57
|
+
|
|
58
|
+
There is no CI publisher and no GitHub Actions workflow. The owner publishes
|
|
59
|
+
the reviewed files to PyPI explicitly after checking the build. See
|
|
60
|
+
`docs/operations/MONARCH_CLOUD_PYPI.md` for release instructions.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "monarch-cloud"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Public developer CLI for Monarch Cloud"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = ["httpx>=0.24,<1"]
|
|
12
|
+
authors = [{name = "Africa Compute Fund"}]
|
|
13
|
+
keywords = ["monarch", "cloud", "compute", "developer-tools", "cli"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://app.africacomputefund.com"
|
|
23
|
+
Documentation = "https://app.africacomputefund.com/docs"
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
monarch = "monarch_cloud.cli:entrypoint"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["src"]
|
|
30
|
+
include = ["monarch_cloud*"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Monarch Cloud public developer client."""
|
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Unified Monarch Platform CLI for developers and agents.
|
|
3
|
+
|
|
4
|
+
The CLI is intentionally a thin client over Monarch's public Platform API. It
|
|
5
|
+
never bypasses server-side policy, billing, readiness, or authority checks.
|
|
6
|
+
|
|
7
|
+
Secrets:
|
|
8
|
+
- MONARCH_API_KEY supplies the developer/account credential.
|
|
9
|
+
- One-time Project server keys are written to .monarch/server-key with mode 0600.
|
|
10
|
+
- Source migration credentials stay in environment variables, never argv.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import getpass
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import stat
|
|
21
|
+
import sys
|
|
22
|
+
import uuid
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
from urllib.parse import urlsplit
|
|
26
|
+
|
|
27
|
+
import httpx
|
|
28
|
+
|
|
29
|
+
DEFAULT_API_BASE = "https://app.africacomputefund.com"
|
|
30
|
+
DEFAULT_STATE_DIR = Path(".monarch")
|
|
31
|
+
PUBLIC_MCP_URL = "https://app.africacomputefund.com/mcp"
|
|
32
|
+
PROJECT_FILE = "project.json"
|
|
33
|
+
SERVER_KEY_FILE = "server-key"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MonarchCLIError(RuntimeError):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _api_base(value: str | None) -> str:
|
|
41
|
+
base = str(value or os.getenv("MONARCH_API_BASE_URL") or DEFAULT_API_BASE).rstrip("/")
|
|
42
|
+
url = urlsplit(base)
|
|
43
|
+
allowed_local = url.scheme == "http" and url.hostname in {"localhost", "127.0.0.1", "::1"}
|
|
44
|
+
if not (url.scheme == "https" or allowed_local) or not url.hostname:
|
|
45
|
+
raise MonarchCLIError("Monarch API URL must use HTTPS (HTTP allowed only for localhost)")
|
|
46
|
+
if url.username or url.password or url.query or url.fragment:
|
|
47
|
+
raise MonarchCLIError("Invalid Monarch API URL: credentials, query and fragment are not accepted")
|
|
48
|
+
return base
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _credentials_path() -> Path:
|
|
52
|
+
override = os.getenv("MONARCH_CONFIG_HOME")
|
|
53
|
+
if override:
|
|
54
|
+
return Path(override).expanduser() / "credentials.json"
|
|
55
|
+
return Path(os.getenv("XDG_CONFIG_HOME") or Path.home() / ".config").expanduser() / "monarch" / "credentials.json"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _token(required: bool = True) -> str:
|
|
59
|
+
value = str(os.getenv("MONARCH_API_KEY") or "").strip()
|
|
60
|
+
if not value:
|
|
61
|
+
path = _credentials_path()
|
|
62
|
+
if path.is_symlink():
|
|
63
|
+
raise MonarchCLIError("Refusing to read a symlinked credentials file")
|
|
64
|
+
if path.exists():
|
|
65
|
+
if os.name != "nt" and stat.S_IMODE(path.stat().st_mode) & 0o077:
|
|
66
|
+
raise MonarchCLIError("Credentials must have private permissions (0600)")
|
|
67
|
+
try:
|
|
68
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
69
|
+
value = str(payload.get("api_key") or "").strip() if isinstance(payload, dict) else ""
|
|
70
|
+
except (ValueError, OSError) as exc:
|
|
71
|
+
raise MonarchCLIError("Credentials file is unreadable") from exc
|
|
72
|
+
if required and not value:
|
|
73
|
+
raise MonarchCLIError("Run 'monarch login' or set MONARCH_API_KEY")
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _required_env(name: str) -> str:
|
|
78
|
+
value = str(os.getenv(name) or "").strip()
|
|
79
|
+
if not value:
|
|
80
|
+
raise MonarchCLIError(f"{name} is required")
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _json_print(value: Any) -> None:
|
|
85
|
+
print(json.dumps(value, indent=2, sort_keys=True, default=str))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _compact_print(value: Any) -> None:
|
|
89
|
+
print(json.dumps(value, separators=(",", ":"), default=str))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _request(
|
|
93
|
+
method: str,
|
|
94
|
+
path: str,
|
|
95
|
+
*,
|
|
96
|
+
api_base: str,
|
|
97
|
+
token: str | None = None,
|
|
98
|
+
body: dict[str, Any] | None = None,
|
|
99
|
+
timeout: float = 120.0,
|
|
100
|
+
idempotency_key: str | None = None,
|
|
101
|
+
) -> dict[str, Any]:
|
|
102
|
+
headers = {"Accept": "application/json", "X-Request-ID": str(uuid.uuid4())}
|
|
103
|
+
if token:
|
|
104
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
105
|
+
if body is not None:
|
|
106
|
+
headers["Content-Type"] = "application/json"
|
|
107
|
+
if idempotency_key:
|
|
108
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
109
|
+
try:
|
|
110
|
+
with httpx.Client(timeout=httpx.Timeout(timeout, connect=30.0), follow_redirects=False) as client:
|
|
111
|
+
response = client.request(method.upper(), api_base + path, headers=headers, json=body)
|
|
112
|
+
except httpx.HTTPError as exc:
|
|
113
|
+
raise MonarchCLIError("Monarch could not be reached") from exc
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
payload: Any = response.json()
|
|
117
|
+
except Exception:
|
|
118
|
+
payload = {"message": response.text[:1000]}
|
|
119
|
+
|
|
120
|
+
if response.status_code >= 400:
|
|
121
|
+
message = "Monarch request failed"
|
|
122
|
+
code = None
|
|
123
|
+
if isinstance(payload, dict):
|
|
124
|
+
detail = payload.get("detail") or payload.get("error") or payload
|
|
125
|
+
if isinstance(detail, dict):
|
|
126
|
+
message = str(detail.get("error") or detail.get("message") or message)
|
|
127
|
+
code = detail.get("code")
|
|
128
|
+
elif isinstance(detail, str):
|
|
129
|
+
message = detail
|
|
130
|
+
suffix = f" [{code}]" if code else ""
|
|
131
|
+
raise MonarchCLIError(f"{message}{suffix} (HTTP {response.status_code})")
|
|
132
|
+
if not isinstance(payload, (dict, list)):
|
|
133
|
+
raise MonarchCLIError("Monarch returned an unexpected response")
|
|
134
|
+
return payload
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _state_dir(value: str | None) -> Path:
|
|
138
|
+
return Path(value or DEFAULT_STATE_DIR).expanduser()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _project_path(state_dir: Path) -> Path:
|
|
142
|
+
return state_dir / PROJECT_FILE
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _server_key_path(state_dir: Path) -> Path:
|
|
146
|
+
return state_dir / SERVER_KEY_FILE
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _read_project(state_dir: Path) -> dict[str, Any]:
|
|
150
|
+
path = _project_path(state_dir)
|
|
151
|
+
if not path.exists():
|
|
152
|
+
return {}
|
|
153
|
+
try:
|
|
154
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
155
|
+
except Exception as exc:
|
|
156
|
+
raise MonarchCLIError(f"Project state is unreadable: {path}") from exc
|
|
157
|
+
if not isinstance(value, dict):
|
|
158
|
+
raise MonarchCLIError(f"Project state must be a JSON object: {path}")
|
|
159
|
+
return value
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _project_id(args: argparse.Namespace, state_dir: Path) -> str:
|
|
163
|
+
explicit = str(getattr(args, "project_id", "") or "").strip()
|
|
164
|
+
if explicit:
|
|
165
|
+
return explicit
|
|
166
|
+
stored = str(_read_project(state_dir).get("project_id") or "").strip()
|
|
167
|
+
if stored:
|
|
168
|
+
return stored
|
|
169
|
+
raise MonarchCLIError("No Project selected. Run 'monarch init' or pass --project-id.")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _write_private(path: Path, value: str) -> None:
|
|
173
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
174
|
+
if path.is_symlink():
|
|
175
|
+
raise MonarchCLIError("Refusing to write a symlinked private file")
|
|
176
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
177
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
178
|
+
flags |= os.O_NOFOLLOW
|
|
179
|
+
fd = os.open(path, flags, 0o600)
|
|
180
|
+
if hasattr(os, "fchmod"):
|
|
181
|
+
os.fchmod(fd, 0o600)
|
|
182
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
183
|
+
stream.write(value.rstrip("\n") + "\n")
|
|
184
|
+
try:
|
|
185
|
+
path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
186
|
+
except OSError:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _write_project_state(state_dir: Path, payload: dict[str, Any]) -> None:
|
|
191
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
_project_path(state_dir).write_text(
|
|
193
|
+
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
|
194
|
+
encoding="utf-8",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def cmd_login(args: argparse.Namespace) -> int:
|
|
199
|
+
key = getpass.getpass("Monarch REST API key (hidden): ").strip()
|
|
200
|
+
if not key:
|
|
201
|
+
raise MonarchCLIError("No API key entered")
|
|
202
|
+
identity = _request("GET", "/v1/account/identity", api_base=_api_base(args.api_base_url), token=key)
|
|
203
|
+
_write_private(_credentials_path(), json.dumps({"api_key": key}))
|
|
204
|
+
_json_print({"ok": True, "identity": identity})
|
|
205
|
+
return 0
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def cmd_logout(args: argparse.Namespace) -> int:
|
|
209
|
+
path = _credentials_path()
|
|
210
|
+
if path.is_symlink():
|
|
211
|
+
raise MonarchCLIError("Refusing to remove symlinked credentials")
|
|
212
|
+
path.unlink(missing_ok=True)
|
|
213
|
+
_json_print({"ok": True, "note": "MONARCH_API_KEY environment variables are unaffected"})
|
|
214
|
+
return 0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def cmd_whoami(args: argparse.Namespace) -> int:
|
|
218
|
+
_json_print(_request("GET", "/v1/account/identity", api_base=_api_base(args.api_base_url), token=_token()))
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def cmd_discover(args: argparse.Namespace) -> int:
|
|
223
|
+
paths = {"gpus": "/v1/gpus", "models": "/v1/models", "datasets": "/v1/datasets",
|
|
224
|
+
"jobs": "/v1/jobs", "wallet": "/v1/account/balance",
|
|
225
|
+
"inference-models": "/v1/inference/catalog"}
|
|
226
|
+
path = paths[args.command]
|
|
227
|
+
if args.command == "models" and args.scope:
|
|
228
|
+
path += "?scope=" + args.scope
|
|
229
|
+
_json_print(_request("GET", path, api_base=_api_base(args.api_base_url), token=_token()))
|
|
230
|
+
return 0
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def cmd_job(args: argparse.Namespace) -> int:
|
|
234
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", args.job_id):
|
|
235
|
+
raise MonarchCLIError("Invalid training job identifier")
|
|
236
|
+
_json_print(_request("GET", f"/v1/jobs/{args.job_id}", api_base=_api_base(args.api_base_url), token=_token()))
|
|
237
|
+
return 0
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def cmd_mcp(args: argparse.Namespace) -> int:
|
|
241
|
+
_json_print({"url": PUBLIC_MCP_URL, "authentication": "OAuth",
|
|
242
|
+
"note": "Connect this URL in an MCP-compatible client; no operator tools are exposed."})
|
|
243
|
+
return 0
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
247
|
+
api_base = _api_base(args.api_base_url)
|
|
248
|
+
token = _token()
|
|
249
|
+
state_dir = _state_dir(args.state_dir)
|
|
250
|
+
existing = _read_project(state_dir)
|
|
251
|
+
if existing and not args.force:
|
|
252
|
+
raise MonarchCLIError(
|
|
253
|
+
f"{_project_path(state_dir)} already selects Project {existing.get('project_id')}; use --force to replace local selection"
|
|
254
|
+
)
|
|
255
|
+
body = {
|
|
256
|
+
"name": args.name,
|
|
257
|
+
"blueprint_id": args.blueprint,
|
|
258
|
+
"region": args.region,
|
|
259
|
+
"key_name": args.key_name,
|
|
260
|
+
}
|
|
261
|
+
if args.org_id:
|
|
262
|
+
body["org_id"] = args.org_id
|
|
263
|
+
result = _request("POST", "/v1/platform/launch", api_base=api_base, token=token, body=body)
|
|
264
|
+
project = result.get("project") if isinstance(result.get("project"), dict) else {}
|
|
265
|
+
key = result.get("server_key") if isinstance(result.get("server_key"), dict) else {}
|
|
266
|
+
project_id = str(project.get("project_id") or "").strip()
|
|
267
|
+
if not project_id:
|
|
268
|
+
raise MonarchCLIError("Monarch launch response did not include project_id")
|
|
269
|
+
secret = str(key.get("secret") or "").strip()
|
|
270
|
+
state = {
|
|
271
|
+
"api_base_url": api_base,
|
|
272
|
+
"project_id": project_id,
|
|
273
|
+
"name": project.get("name"),
|
|
274
|
+
"blueprint_id": project.get("blueprint_id"),
|
|
275
|
+
"region": project.get("region"),
|
|
276
|
+
}
|
|
277
|
+
_write_project_state(state_dir, state)
|
|
278
|
+
if secret:
|
|
279
|
+
_write_private(_server_key_path(state_dir), secret)
|
|
280
|
+
output = {
|
|
281
|
+
"ok": True,
|
|
282
|
+
"project": state,
|
|
283
|
+
"server_key_file": str(_server_key_path(state_dir)) if secret else None,
|
|
284
|
+
"bindings": result.get("bindings") or [],
|
|
285
|
+
"warnings": result.get("warnings") or [],
|
|
286
|
+
"next": [
|
|
287
|
+
"monarch status",
|
|
288
|
+
"monarch dev",
|
|
289
|
+
"monarch deploy-function <name> <source.js>",
|
|
290
|
+
],
|
|
291
|
+
}
|
|
292
|
+
_json_print(output)
|
|
293
|
+
return 0
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
297
|
+
state_dir = _state_dir(args.state_dir)
|
|
298
|
+
stored = _read_project(state_dir)
|
|
299
|
+
api_base = _api_base(args.api_base_url or stored.get("api_base_url"))
|
|
300
|
+
token = _token()
|
|
301
|
+
project_id = _project_id(args, state_dir)
|
|
302
|
+
runtime = _request("GET", f"/v1/projects/{project_id}/runtime", api_base=api_base, token=token)
|
|
303
|
+
usage = _request("GET", f"/v1/projects/{project_id}/usage", api_base=api_base, token=token)
|
|
304
|
+
bindings = _request("GET", f"/v1/projects/{project_id}/bindings", api_base=api_base, token=token)
|
|
305
|
+
_json_print({"project_id": project_id, "runtime": runtime, "usage": usage, "bindings": bindings})
|
|
306
|
+
return 0
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def cmd_dev(args: argparse.Namespace) -> int:
|
|
310
|
+
state_dir = _state_dir(args.state_dir)
|
|
311
|
+
stored = _read_project(state_dir)
|
|
312
|
+
api_base = _api_base(args.api_base_url or stored.get("api_base_url"))
|
|
313
|
+
token = _token()
|
|
314
|
+
project_id = _project_id(args, state_dir)
|
|
315
|
+
config = _request("GET", f"/v1/projects/{project_id}/client-config", api_base=api_base, token=token)
|
|
316
|
+
runtime = _request("GET", f"/v1/projects/{project_id}/runtime", api_base=api_base, token=token)
|
|
317
|
+
_json_print({
|
|
318
|
+
"project_id": project_id,
|
|
319
|
+
"client_config": config,
|
|
320
|
+
"runtime": runtime,
|
|
321
|
+
"server_key_file": str(_server_key_path(state_dir)) if _server_key_path(state_dir).exists() else None,
|
|
322
|
+
"note": "Use the public client config in browser/mobile code. Keep the server key on trusted backend infrastructure only.",
|
|
323
|
+
})
|
|
324
|
+
return 0
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def cmd_deploy_function(args: argparse.Namespace) -> int:
|
|
328
|
+
state_dir = _state_dir(args.state_dir)
|
|
329
|
+
stored = _read_project(state_dir)
|
|
330
|
+
api_base = _api_base(args.api_base_url or stored.get("api_base_url"))
|
|
331
|
+
token = _token()
|
|
332
|
+
project_id = _project_id(args, state_dir)
|
|
333
|
+
source_path = Path(args.source).expanduser()
|
|
334
|
+
source = source_path.read_text(encoding="utf-8")
|
|
335
|
+
body = {
|
|
336
|
+
"name": args.name,
|
|
337
|
+
"source": source,
|
|
338
|
+
"scopes": args.scope or [],
|
|
339
|
+
}
|
|
340
|
+
result = _request(
|
|
341
|
+
"POST",
|
|
342
|
+
f"/v1/projects/{project_id}/functions",
|
|
343
|
+
api_base=api_base,
|
|
344
|
+
token=token,
|
|
345
|
+
body=body,
|
|
346
|
+
timeout=180,
|
|
347
|
+
idempotency_key=args.idempotency_key or str(uuid.uuid4()),
|
|
348
|
+
)
|
|
349
|
+
_json_print(result)
|
|
350
|
+
return 0
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _services(value: str) -> list[str]:
|
|
354
|
+
return [item.strip() for item in str(value or "").split(",") if item.strip()]
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def cmd_migrate(args: argparse.Namespace) -> int:
|
|
358
|
+
state_dir = _state_dir(args.state_dir)
|
|
359
|
+
stored = _read_project(state_dir)
|
|
360
|
+
api_base = _api_base(args.api_base_url or stored.get("api_base_url"))
|
|
361
|
+
token = _token()
|
|
362
|
+
services = _services(args.services)
|
|
363
|
+
analyze = _request(
|
|
364
|
+
"POST",
|
|
365
|
+
"/v1/platform/migrations/analyze",
|
|
366
|
+
api_base=api_base,
|
|
367
|
+
token=token,
|
|
368
|
+
body={"provider": args.provider, "services": services},
|
|
369
|
+
)
|
|
370
|
+
manifest = _request(
|
|
371
|
+
"POST",
|
|
372
|
+
"/v1/platform/migrations/manifest",
|
|
373
|
+
api_base=api_base,
|
|
374
|
+
token=token,
|
|
375
|
+
body={"provider": args.provider, "services": services, "project_name": args.name},
|
|
376
|
+
)
|
|
377
|
+
output: dict[str, Any] = {"analysis": analyze, "manifest": manifest}
|
|
378
|
+
if args.prepare:
|
|
379
|
+
body: dict[str, Any] = {
|
|
380
|
+
"provider": args.provider,
|
|
381
|
+
"services": services,
|
|
382
|
+
"name": args.name,
|
|
383
|
+
"region": args.region,
|
|
384
|
+
"key_name": args.key_name,
|
|
385
|
+
}
|
|
386
|
+
if args.org_id:
|
|
387
|
+
body["org_id"] = args.org_id
|
|
388
|
+
prepared = _request(
|
|
389
|
+
"POST",
|
|
390
|
+
"/v1/platform/migrations/prepare",
|
|
391
|
+
api_base=api_base,
|
|
392
|
+
token=token,
|
|
393
|
+
body=body,
|
|
394
|
+
timeout=180,
|
|
395
|
+
)
|
|
396
|
+
output["prepared"] = prepared
|
|
397
|
+
project = prepared.get("project") if isinstance(prepared.get("project"), dict) else {}
|
|
398
|
+
key = prepared.get("server_key") if isinstance(prepared.get("server_key"), dict) else {}
|
|
399
|
+
project_id = str(project.get("project_id") or "").strip()
|
|
400
|
+
if project_id:
|
|
401
|
+
_write_project_state(state_dir, {
|
|
402
|
+
"api_base_url": api_base,
|
|
403
|
+
"project_id": project_id,
|
|
404
|
+
"name": project.get("name"),
|
|
405
|
+
"blueprint_id": project.get("blueprint_id"),
|
|
406
|
+
"region": project.get("region"),
|
|
407
|
+
"migration_source": args.provider,
|
|
408
|
+
})
|
|
409
|
+
secret = str(key.get("secret") or "").strip()
|
|
410
|
+
if secret:
|
|
411
|
+
_write_private(_server_key_path(state_dir), secret)
|
|
412
|
+
if isinstance(output.get("prepared"), dict) and secret:
|
|
413
|
+
output["prepared"] = dict(output["prepared"])
|
|
414
|
+
server_key = output["prepared"].get("server_key")
|
|
415
|
+
if isinstance(server_key, dict):
|
|
416
|
+
output["prepared"]["server_key"] = {k: v for k, v in server_key.items() if k != "secret"}
|
|
417
|
+
output["prepared"]["server_key_file"] = str(_server_key_path(state_dir))
|
|
418
|
+
_json_print(output)
|
|
419
|
+
return 0
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def cmd_migrate_storage(args: argparse.Namespace) -> int:
|
|
423
|
+
state_dir = _state_dir(args.state_dir)
|
|
424
|
+
stored = _read_project(state_dir)
|
|
425
|
+
api_base = _api_base(args.api_base_url or stored.get("api_base_url"))
|
|
426
|
+
token = _token()
|
|
427
|
+
project_id = _project_id(args, state_dir)
|
|
428
|
+
source_access = _required_env("MONARCH_MIGRATION_SOURCE_ACCESS_KEY_ID")
|
|
429
|
+
source_secret = _required_env("MONARCH_MIGRATION_SOURCE_SECRET_ACCESS_KEY")
|
|
430
|
+
source_session = str(os.getenv("MONARCH_MIGRATION_SOURCE_SESSION_TOKEN") or "").strip() or None
|
|
431
|
+
|
|
432
|
+
cursor = str(args.start_after or "")
|
|
433
|
+
totals = {"copied": 0, "skipped": 0, "bytes_copied": 0}
|
|
434
|
+
batch_number = 0
|
|
435
|
+
while True:
|
|
436
|
+
batch_number += 1
|
|
437
|
+
body: dict[str, Any] = {
|
|
438
|
+
"source_bucket": args.bucket,
|
|
439
|
+
"source_access_key_id": source_access,
|
|
440
|
+
"source_secret_access_key": source_secret,
|
|
441
|
+
"source_prefix": args.prefix,
|
|
442
|
+
"start_after": cursor,
|
|
443
|
+
"max_objects": args.max_objects,
|
|
444
|
+
"max_batch_bytes": args.max_batch_bytes,
|
|
445
|
+
"dry_run": bool(args.dry_run),
|
|
446
|
+
}
|
|
447
|
+
if source_session:
|
|
448
|
+
body["source_session_token"] = source_session
|
|
449
|
+
if args.endpoint:
|
|
450
|
+
body["source_endpoint"] = args.endpoint
|
|
451
|
+
if args.source_region:
|
|
452
|
+
body["source_region"] = args.source_region
|
|
453
|
+
if args.target_region:
|
|
454
|
+
body["target_region"] = args.target_region
|
|
455
|
+
|
|
456
|
+
result = _request(
|
|
457
|
+
"POST",
|
|
458
|
+
f"/v1/projects/{project_id}/migrations/object-storage/s3",
|
|
459
|
+
api_base=api_base,
|
|
460
|
+
token=token,
|
|
461
|
+
body=body,
|
|
462
|
+
timeout=300,
|
|
463
|
+
idempotency_key=str(uuid.uuid4()),
|
|
464
|
+
)
|
|
465
|
+
totals["copied"] += int(result.get("copied") or 0)
|
|
466
|
+
totals["skipped"] += int(result.get("skipped") or 0)
|
|
467
|
+
totals["bytes_copied"] += int(result.get("bytes_copied") or 0)
|
|
468
|
+
_compact_print({
|
|
469
|
+
"batch": batch_number,
|
|
470
|
+
"copied": result.get("copied"),
|
|
471
|
+
"skipped": result.get("skipped"),
|
|
472
|
+
"bytes_copied": result.get("bytes_copied"),
|
|
473
|
+
"complete": result.get("complete"),
|
|
474
|
+
"next_start_after": result.get("next_start_after"),
|
|
475
|
+
})
|
|
476
|
+
if bool(result.get("complete")) or args.one_batch:
|
|
477
|
+
_json_print({
|
|
478
|
+
"ok": bool(result.get("complete")) or bool(args.one_batch),
|
|
479
|
+
"dry_run": bool(args.dry_run),
|
|
480
|
+
"batches": batch_number,
|
|
481
|
+
**totals,
|
|
482
|
+
"complete": bool(result.get("complete")),
|
|
483
|
+
"resume_after": result.get("next_start_after"),
|
|
484
|
+
})
|
|
485
|
+
return 0
|
|
486
|
+
next_cursor = str(result.get("next_start_after") or "")
|
|
487
|
+
if not next_cursor or next_cursor == cursor:
|
|
488
|
+
raise MonarchCLIError("Migration cursor did not advance; refusing an infinite retry loop")
|
|
489
|
+
cursor = next_cursor
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def cmd_migrate_postgres(args: argparse.Namespace) -> int:
|
|
493
|
+
state_dir = _state_dir(args.state_dir)
|
|
494
|
+
stored = _read_project(state_dir)
|
|
495
|
+
api_base = _api_base(args.api_base_url or stored.get("api_base_url"))
|
|
496
|
+
token = _token()
|
|
497
|
+
project_id = _project_id(args, state_dir)
|
|
498
|
+
dsn = _required_env("MONARCH_MIGRATION_POSTGRES_DSN")
|
|
499
|
+
cursor = str(args.start_after or "")
|
|
500
|
+
totals = {"imported": 0, "skipped": 0, "rows_considered": 0}
|
|
501
|
+
batches = 0
|
|
502
|
+
while True:
|
|
503
|
+
batches += 1
|
|
504
|
+
body = {
|
|
505
|
+
"source_dsn": dsn,
|
|
506
|
+
"table": args.table,
|
|
507
|
+
"schema_name": args.schema,
|
|
508
|
+
"primary_key": args.primary_key,
|
|
509
|
+
"collection": args.collection,
|
|
510
|
+
"start_after": cursor or None,
|
|
511
|
+
"max_rows": args.max_rows,
|
|
512
|
+
"mode": args.mode,
|
|
513
|
+
"dry_run": bool(args.dry_run),
|
|
514
|
+
}
|
|
515
|
+
result = _request(
|
|
516
|
+
"POST",
|
|
517
|
+
f"/v1/projects/{project_id}/migrations/database/postgres",
|
|
518
|
+
api_base=api_base,
|
|
519
|
+
token=token,
|
|
520
|
+
body=body,
|
|
521
|
+
timeout=300,
|
|
522
|
+
idempotency_key=str(uuid.uuid4()),
|
|
523
|
+
)
|
|
524
|
+
totals["imported"] += int(result.get("imported") or 0)
|
|
525
|
+
totals["skipped"] += int(result.get("skipped") or 0)
|
|
526
|
+
totals["rows_considered"] += int(result.get("rows_considered") or 0)
|
|
527
|
+
_compact_print({
|
|
528
|
+
"batch": batches,
|
|
529
|
+
"imported": result.get("imported"),
|
|
530
|
+
"skipped": result.get("skipped"),
|
|
531
|
+
"rows_considered": result.get("rows_considered"),
|
|
532
|
+
"complete": result.get("complete"),
|
|
533
|
+
"next_start_after": result.get("next_start_after"),
|
|
534
|
+
})
|
|
535
|
+
if bool(result.get("complete")) or args.one_batch:
|
|
536
|
+
_json_print({
|
|
537
|
+
"ok": bool(result.get("complete")) or bool(args.one_batch),
|
|
538
|
+
"dry_run": bool(args.dry_run),
|
|
539
|
+
"batches": batches,
|
|
540
|
+
**totals,
|
|
541
|
+
"complete": bool(result.get("complete")),
|
|
542
|
+
"resume_after": result.get("next_start_after"),
|
|
543
|
+
"semantic_target": "project_documents",
|
|
544
|
+
"relational_semantics_preserved": False,
|
|
545
|
+
})
|
|
546
|
+
return 0
|
|
547
|
+
next_cursor = str(result.get("next_start_after") or "")
|
|
548
|
+
if not next_cursor or next_cursor == cursor:
|
|
549
|
+
raise MonarchCLIError("PostgreSQL migration cursor did not advance")
|
|
550
|
+
cursor = next_cursor
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _gcs_credential(args: argparse.Namespace) -> str:
|
|
554
|
+
if args.credentials_file:
|
|
555
|
+
return Path(args.credentials_file).expanduser().read_text(encoding="utf-8")
|
|
556
|
+
return _required_env("MONARCH_MIGRATION_GCS_SERVICE_ACCOUNT_JSON")
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def cmd_migrate_gcs(args: argparse.Namespace) -> int:
|
|
560
|
+
state_dir = _state_dir(args.state_dir)
|
|
561
|
+
stored = _read_project(state_dir)
|
|
562
|
+
api_base = _api_base(args.api_base_url or stored.get("api_base_url"))
|
|
563
|
+
token = _token()
|
|
564
|
+
project_id = _project_id(args, state_dir)
|
|
565
|
+
credential = _gcs_credential(args)
|
|
566
|
+
cursor = str(args.start_after or "")
|
|
567
|
+
totals = {"copied": 0, "skipped": 0, "bytes_copied": 0}
|
|
568
|
+
batches = 0
|
|
569
|
+
while True:
|
|
570
|
+
batches += 1
|
|
571
|
+
body = {
|
|
572
|
+
"source_bucket": args.bucket,
|
|
573
|
+
"source_service_account_json": credential,
|
|
574
|
+
"source_prefix": args.prefix,
|
|
575
|
+
"start_after": cursor,
|
|
576
|
+
"max_objects": args.max_objects,
|
|
577
|
+
"max_batch_bytes": args.max_batch_bytes,
|
|
578
|
+
"target_region": args.target_region,
|
|
579
|
+
"dry_run": bool(args.dry_run),
|
|
580
|
+
}
|
|
581
|
+
result = _request(
|
|
582
|
+
"POST",
|
|
583
|
+
f"/v1/projects/{project_id}/migrations/object-storage/gcs",
|
|
584
|
+
api_base=api_base,
|
|
585
|
+
token=token,
|
|
586
|
+
body=body,
|
|
587
|
+
timeout=300,
|
|
588
|
+
idempotency_key=str(uuid.uuid4()),
|
|
589
|
+
)
|
|
590
|
+
totals["copied"] += int(result.get("copied") or 0)
|
|
591
|
+
totals["skipped"] += int(result.get("skipped") or 0)
|
|
592
|
+
totals["bytes_copied"] += int(result.get("bytes_copied") or 0)
|
|
593
|
+
_compact_print({
|
|
594
|
+
"batch": batches,
|
|
595
|
+
"copied": result.get("copied"),
|
|
596
|
+
"skipped": result.get("skipped"),
|
|
597
|
+
"bytes_copied": result.get("bytes_copied"),
|
|
598
|
+
"complete": result.get("complete"),
|
|
599
|
+
"next_start_after": result.get("next_start_after"),
|
|
600
|
+
})
|
|
601
|
+
if bool(result.get("complete")) or args.one_batch:
|
|
602
|
+
_json_print({
|
|
603
|
+
"ok": bool(result.get("complete")) or bool(args.one_batch),
|
|
604
|
+
"dry_run": bool(args.dry_run),
|
|
605
|
+
"batches": batches,
|
|
606
|
+
**totals,
|
|
607
|
+
"complete": bool(result.get("complete")),
|
|
608
|
+
"resume_after": result.get("next_start_after"),
|
|
609
|
+
})
|
|
610
|
+
return 0
|
|
611
|
+
next_cursor = str(result.get("next_start_after") or "")
|
|
612
|
+
if not next_cursor or next_cursor == cursor:
|
|
613
|
+
raise MonarchCLIError("GCS migration cursor did not advance")
|
|
614
|
+
cursor = next_cursor
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _load_document_items(path: Path, collection_override: str | None) -> list[dict[str, Any]]:
|
|
618
|
+
raw = path.read_text(encoding="utf-8")
|
|
619
|
+
if path.suffix.lower() in {".jsonl", ".ndjson"}:
|
|
620
|
+
values = [json.loads(line) for line in raw.splitlines() if line.strip()]
|
|
621
|
+
else:
|
|
622
|
+
parsed = json.loads(raw)
|
|
623
|
+
if isinstance(parsed, dict) and isinstance(parsed.get("items"), list):
|
|
624
|
+
values = parsed["items"]
|
|
625
|
+
elif isinstance(parsed, list):
|
|
626
|
+
values = parsed
|
|
627
|
+
else:
|
|
628
|
+
raise MonarchCLIError("Document import JSON must be an array or an object with an items array")
|
|
629
|
+
items: list[dict[str, Any]] = []
|
|
630
|
+
for index, value in enumerate(values):
|
|
631
|
+
if not isinstance(value, dict):
|
|
632
|
+
raise MonarchCLIError(f"Document item {index} must be an object")
|
|
633
|
+
collection = str(collection_override or value.get("collection") or "").strip()
|
|
634
|
+
document_id = str(value.get("document_id") or value.get("id") or "").strip()
|
|
635
|
+
if not collection or not document_id:
|
|
636
|
+
raise MonarchCLIError(f"Document item {index} needs collection and document_id/id")
|
|
637
|
+
if isinstance(value.get("data"), dict):
|
|
638
|
+
data = dict(value["data"])
|
|
639
|
+
elif collection_override:
|
|
640
|
+
data = {
|
|
641
|
+
str(key): item
|
|
642
|
+
for key, item in value.items()
|
|
643
|
+
if key not in {"collection", "document_id", "id"}
|
|
644
|
+
}
|
|
645
|
+
else:
|
|
646
|
+
raise MonarchCLIError(f"Document item {index} needs a data object")
|
|
647
|
+
items.append({"collection": collection, "document_id": document_id, "data": data})
|
|
648
|
+
return items
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def cmd_import_documents(args: argparse.Namespace) -> int:
|
|
652
|
+
state_dir = _state_dir(args.state_dir)
|
|
653
|
+
stored = _read_project(state_dir)
|
|
654
|
+
api_base = _api_base(args.api_base_url or stored.get("api_base_url"))
|
|
655
|
+
token = _token()
|
|
656
|
+
project_id = _project_id(args, state_dir)
|
|
657
|
+
source_path = Path(args.source).expanduser()
|
|
658
|
+
items = _load_document_items(source_path, args.collection)
|
|
659
|
+
imported = skipped = 0
|
|
660
|
+
batches = 0
|
|
661
|
+
for offset in range(0, len(items), args.batch_size):
|
|
662
|
+
batches += 1
|
|
663
|
+
batch = items[offset : offset + args.batch_size]
|
|
664
|
+
result = _request(
|
|
665
|
+
"POST",
|
|
666
|
+
f"/v1/projects/{project_id}/migrations/documents",
|
|
667
|
+
api_base=api_base,
|
|
668
|
+
token=token,
|
|
669
|
+
body={
|
|
670
|
+
"items": batch,
|
|
671
|
+
"mode": args.mode,
|
|
672
|
+
"dry_run": bool(args.dry_run),
|
|
673
|
+
"source": args.source_label,
|
|
674
|
+
},
|
|
675
|
+
timeout=180,
|
|
676
|
+
idempotency_key=str(uuid.uuid4()),
|
|
677
|
+
)
|
|
678
|
+
imported += int(result.get("imported") or 0)
|
|
679
|
+
skipped += int(result.get("skipped") or 0)
|
|
680
|
+
_compact_print({
|
|
681
|
+
"batch": batches,
|
|
682
|
+
"items": len(batch),
|
|
683
|
+
"imported": result.get("imported"),
|
|
684
|
+
"skipped": result.get("skipped"),
|
|
685
|
+
"dry_run": result.get("dry_run"),
|
|
686
|
+
})
|
|
687
|
+
_json_print({
|
|
688
|
+
"ok": True,
|
|
689
|
+
"source_file": str(source_path),
|
|
690
|
+
"dry_run": bool(args.dry_run),
|
|
691
|
+
"batches": batches,
|
|
692
|
+
"items": len(items),
|
|
693
|
+
"imported": imported,
|
|
694
|
+
"skipped": skipped,
|
|
695
|
+
})
|
|
696
|
+
return 0
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def cmd_catalog(args: argparse.Namespace) -> int:
|
|
700
|
+
api_base = _api_base(args.api_base_url)
|
|
701
|
+
payload = _request("GET", "/v1/platform/catalog", api_base=api_base, token=None)
|
|
702
|
+
_json_print(payload)
|
|
703
|
+
return 0
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
707
|
+
parser = argparse.ArgumentParser(prog="monarch", description="Monarch Platform developer and agent CLI")
|
|
708
|
+
parser.add_argument("--api-base-url", default=None)
|
|
709
|
+
parser.add_argument("--state-dir", default=str(DEFAULT_STATE_DIR))
|
|
710
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
711
|
+
|
|
712
|
+
login = sub.add_parser("login", help="Validate and securely save your Monarch REST API key")
|
|
713
|
+
login.set_defaults(func=cmd_login)
|
|
714
|
+
logout = sub.add_parser("logout", help="Delete the local developer API key")
|
|
715
|
+
logout.set_defaults(func=cmd_logout)
|
|
716
|
+
whoami = sub.add_parser("whoami", help="Show your authenticated Monarch identity")
|
|
717
|
+
whoami.set_defaults(func=cmd_whoami)
|
|
718
|
+
for name, help_text in (
|
|
719
|
+
("gpus", "List customer-visible GPU offerings"),
|
|
720
|
+
("datasets", "List accessible datasets"),
|
|
721
|
+
("jobs", "List your training jobs"),
|
|
722
|
+
("wallet", "Read account wallet balance"),
|
|
723
|
+
("inference-models", "List hosted inference models"),
|
|
724
|
+
):
|
|
725
|
+
public = sub.add_parser(name, help=help_text)
|
|
726
|
+
public.set_defaults(func=cmd_discover)
|
|
727
|
+
models = sub.add_parser("models", help="List available or owned models")
|
|
728
|
+
models.add_argument("--scope", choices=["catalog", "mine"], default=None)
|
|
729
|
+
models.set_defaults(func=cmd_discover)
|
|
730
|
+
job = sub.add_parser("job", help="Inspect one training job")
|
|
731
|
+
job.add_argument("job_id")
|
|
732
|
+
job.set_defaults(func=cmd_job)
|
|
733
|
+
mcp = sub.add_parser("mcp", help="Show the public OAuth MCP endpoint")
|
|
734
|
+
mcp.set_defaults(func=cmd_mcp)
|
|
735
|
+
|
|
736
|
+
init = sub.add_parser("init", help="Launch a Project from a Blueprint")
|
|
737
|
+
init.add_argument("name")
|
|
738
|
+
init.add_argument("--blueprint", default="backend")
|
|
739
|
+
init.add_argument("--region", default="auto")
|
|
740
|
+
init.add_argument("--org-id", default=None)
|
|
741
|
+
init.add_argument("--key-name", default="local backend")
|
|
742
|
+
init.add_argument("--force", action="store_true")
|
|
743
|
+
init.set_defaults(func=cmd_init)
|
|
744
|
+
|
|
745
|
+
status = sub.add_parser("status", help="Inspect Project runtime, usage and bindings")
|
|
746
|
+
status.add_argument("--project-id", default=None)
|
|
747
|
+
status.set_defaults(func=cmd_status)
|
|
748
|
+
|
|
749
|
+
dev = sub.add_parser("dev", help="Show public client configuration and runtime readiness")
|
|
750
|
+
dev.add_argument("--project-id", default=None)
|
|
751
|
+
dev.set_defaults(func=cmd_dev)
|
|
752
|
+
|
|
753
|
+
deploy = sub.add_parser("deploy", aliases=["deploy-function"], help="Deploy or replace one Project Function")
|
|
754
|
+
deploy.add_argument("name")
|
|
755
|
+
deploy.add_argument("source")
|
|
756
|
+
deploy.add_argument("--project-id", default=None)
|
|
757
|
+
deploy.add_argument("--scope", action="append", default=[])
|
|
758
|
+
deploy.add_argument("--idempotency-key", default=None)
|
|
759
|
+
deploy.set_defaults(func=cmd_deploy_function)
|
|
760
|
+
|
|
761
|
+
migrate = sub.add_parser("migrate", help="Analyze and optionally prepare a migration target")
|
|
762
|
+
migrate.add_argument("provider")
|
|
763
|
+
migrate.add_argument("--services", default="")
|
|
764
|
+
migrate.add_argument("--name", default="Migrated app")
|
|
765
|
+
migrate.add_argument("--region", default="auto")
|
|
766
|
+
migrate.add_argument("--org-id", default=None)
|
|
767
|
+
migrate.add_argument("--key-name", default="migration backend")
|
|
768
|
+
migrate.add_argument("--prepare", action="store_true")
|
|
769
|
+
migrate.set_defaults(func=cmd_migrate)
|
|
770
|
+
|
|
771
|
+
migrate_storage = sub.add_parser("migrate-storage", help="Copy S3-compatible objects into the selected Project")
|
|
772
|
+
migrate_storage.add_argument("bucket")
|
|
773
|
+
migrate_storage.add_argument("--project-id", default=None)
|
|
774
|
+
migrate_storage.add_argument("--endpoint", default=None)
|
|
775
|
+
migrate_storage.add_argument("--source-region", default=None)
|
|
776
|
+
migrate_storage.add_argument("--target-region", default=None)
|
|
777
|
+
migrate_storage.add_argument("--prefix", default="")
|
|
778
|
+
migrate_storage.add_argument("--start-after", default="")
|
|
779
|
+
migrate_storage.add_argument("--max-objects", type=int, default=25)
|
|
780
|
+
migrate_storage.add_argument("--max-batch-bytes", type=int, default=128 * 1024 * 1024)
|
|
781
|
+
migrate_storage.add_argument("--dry-run", action="store_true")
|
|
782
|
+
migrate_storage.add_argument("--one-batch", action="store_true")
|
|
783
|
+
migrate_storage.set_defaults(func=cmd_migrate_storage)
|
|
784
|
+
|
|
785
|
+
migrate_postgres = sub.add_parser("migrate-postgres", help="Copy PostgreSQL table rows into Project documents")
|
|
786
|
+
migrate_postgres.add_argument("table")
|
|
787
|
+
migrate_postgres.add_argument("--project-id", default=None)
|
|
788
|
+
migrate_postgres.add_argument("--schema", default="public")
|
|
789
|
+
migrate_postgres.add_argument("--primary-key", default=None)
|
|
790
|
+
migrate_postgres.add_argument("--collection", default=None)
|
|
791
|
+
migrate_postgres.add_argument("--start-after", default="")
|
|
792
|
+
migrate_postgres.add_argument("--max-rows", type=int, default=100)
|
|
793
|
+
migrate_postgres.add_argument("--mode", choices=["upsert", "skip_existing"], default="upsert")
|
|
794
|
+
migrate_postgres.add_argument("--dry-run", action="store_true")
|
|
795
|
+
migrate_postgres.add_argument("--one-batch", action="store_true")
|
|
796
|
+
migrate_postgres.set_defaults(func=cmd_migrate_postgres)
|
|
797
|
+
|
|
798
|
+
migrate_gcs = sub.add_parser("migrate-gcs", help="Copy GCS/Firebase Storage objects into the selected Project")
|
|
799
|
+
migrate_gcs.add_argument("bucket")
|
|
800
|
+
migrate_gcs.add_argument("--project-id", default=None)
|
|
801
|
+
migrate_gcs.add_argument("--credentials-file", default=None)
|
|
802
|
+
migrate_gcs.add_argument("--target-region", default=None)
|
|
803
|
+
migrate_gcs.add_argument("--prefix", default="")
|
|
804
|
+
migrate_gcs.add_argument("--start-after", default="")
|
|
805
|
+
migrate_gcs.add_argument("--max-objects", type=int, default=25)
|
|
806
|
+
migrate_gcs.add_argument("--max-batch-bytes", type=int, default=128 * 1024 * 1024)
|
|
807
|
+
migrate_gcs.add_argument("--dry-run", action="store_true")
|
|
808
|
+
migrate_gcs.add_argument("--one-batch", action="store_true")
|
|
809
|
+
migrate_gcs.set_defaults(func=cmd_migrate_gcs)
|
|
810
|
+
|
|
811
|
+
import_documents = sub.add_parser("import-documents", help="Import exported JSON/JSONL documents into the selected Project")
|
|
812
|
+
import_documents.add_argument("source")
|
|
813
|
+
import_documents.add_argument("--project-id", default=None)
|
|
814
|
+
import_documents.add_argument("--collection", default=None)
|
|
815
|
+
import_documents.add_argument("--source-label", default="local_export")
|
|
816
|
+
import_documents.add_argument("--batch-size", type=int, default=100, choices=range(1, 501), metavar="1..500")
|
|
817
|
+
import_documents.add_argument("--mode", choices=["upsert", "skip_existing"], default="upsert")
|
|
818
|
+
import_documents.add_argument("--dry-run", action="store_true")
|
|
819
|
+
import_documents.set_defaults(func=cmd_import_documents)
|
|
820
|
+
|
|
821
|
+
catalog = sub.add_parser("catalog", help="Read public Platform services and pricing")
|
|
822
|
+
catalog.set_defaults(func=cmd_catalog)
|
|
823
|
+
return parser
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def main(argv: list[str] | None = None) -> int:
|
|
827
|
+
parser = build_parser()
|
|
828
|
+
args = parser.parse_args(argv)
|
|
829
|
+
return int(args.func(args))
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def entrypoint() -> int:
|
|
833
|
+
try:
|
|
834
|
+
return main()
|
|
835
|
+
except KeyboardInterrupt:
|
|
836
|
+
return 130
|
|
837
|
+
except (MonarchCLIError, OSError, ValueError) as exc:
|
|
838
|
+
print(str(exc), file=sys.stderr)
|
|
839
|
+
return 1
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
if __name__ == "__main__":
|
|
843
|
+
raise SystemExit(entrypoint())
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: monarch-cloud
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Public developer CLI for Monarch Cloud
|
|
5
|
+
Author: Africa Compute Fund
|
|
6
|
+
Project-URL: Homepage, https://app.africacomputefund.com
|
|
7
|
+
Project-URL: Documentation, https://app.africacomputefund.com/docs
|
|
8
|
+
Keywords: monarch,cloud,compute,developer-tools,cli
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: httpx<1,>=0.24
|
|
16
|
+
|
|
17
|
+
# Monarch Cloud CLI
|
|
18
|
+
|
|
19
|
+
A lightweight public developer client for the existing Monarch REST Platform API.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
python -m pip install monarch-cloud
|
|
23
|
+
monarch login
|
|
24
|
+
monarch whoami
|
|
25
|
+
monarch catalog
|
|
26
|
+
monarch gpus
|
|
27
|
+
monarch models --scope catalog
|
|
28
|
+
monarch jobs
|
|
29
|
+
monarch wallet
|
|
30
|
+
monarch init "My app" --blueprint backend
|
|
31
|
+
monarch status
|
|
32
|
+
monarch dev
|
|
33
|
+
monarch deploy hello ./hello.js
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Authentication:** `monarch login` prompts for an existing Monarch REST API key
|
|
37
|
+
without echoing it, validates it against `GET /v1/account/identity`, then saves
|
|
38
|
+
it in a mode-0600 file under your user configuration directory. For ephemeral
|
|
39
|
+
environments set `MONARCH_API_KEY` instead; this takes precedence. This is REST
|
|
40
|
+
API-key login, **not** the OAuth browser flow used by the MCP connector. Key
|
|
41
|
+
creation and account access are provided through Monarch Cloud.
|
|
42
|
+
|
|
43
|
+
`monarch mcp` prints the public OAuth MCP endpoint for compatible AI clients;
|
|
44
|
+
it does not bundle an MCP server or impersonate an OAuth token.
|
|
45
|
+
|
|
46
|
+
## Command surfaces
|
|
47
|
+
|
|
48
|
+
- Discovery: `catalog`, `gpus`, `models`, `datasets`, `jobs`, `job ID`,
|
|
49
|
+
`wallet`, `inference-models`, `whoami`.
|
|
50
|
+
- Projects: `init`, `status`, `dev`, `deploy NAME SOURCE_JS`.
|
|
51
|
+
- Migration: `migrate`, `migrate-storage`, `migrate-postgres`,
|
|
52
|
+
`migrate-gcs`, `import-documents`.
|
|
53
|
+
- Account: `login`, `logout`, `mcp`.
|
|
54
|
+
|
|
55
|
+
`deploy` deploys one JavaScript **Project Function**, not arbitrary whole-app
|
|
56
|
+
infrastructure. Some public REST APIs and the MCP's purpose-built tools are
|
|
57
|
+
not yet wrapped as CLI commands; Cloud, REST and MCP remain the source of truth.
|
|
58
|
+
|
|
59
|
+
Project selection is stored in `.monarch/project.json`. The one-time Project
|
|
60
|
+
server key is saved in `.monarch/server-key` and must never be committed.
|
|
61
|
+
Source migration secrets are only read from environment variables or named
|
|
62
|
+
credential files, not CLI arguments. The server enforces the same account,
|
|
63
|
+
scope, billing, quotas, and approvals as its other public clients.
|
|
64
|
+
|
|
65
|
+
## For maintainers
|
|
66
|
+
|
|
67
|
+
The public release is built from the **canonical** implementation at
|
|
68
|
+
`scripts/monarch_platform.py` using
|
|
69
|
+
`python scripts/package_monarch_cloud.py --build`. The staging tool copies
|
|
70
|
+
only this public module, the package metadata, and this README into an isolated
|
|
71
|
+
temporary build directory. The private `./monarch` operator CLI, admin scripts,
|
|
72
|
+
deployment infrastructure, and internal MCP server are not packaged.
|
|
73
|
+
|
|
74
|
+
There is no CI publisher and no GitHub Actions workflow. The owner publishes
|
|
75
|
+
the reviewed files to PyPI explicitly after checking the build. See
|
|
76
|
+
`docs/operations/MONARCH_CLOUD_PYPI.md` for release instructions.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/monarch_cloud/__init__.py
|
|
4
|
+
src/monarch_cloud/cli.py
|
|
5
|
+
src/monarch_cloud.egg-info/PKG-INFO
|
|
6
|
+
src/monarch_cloud.egg-info/SOURCES.txt
|
|
7
|
+
src/monarch_cloud.egg-info/dependency_links.txt
|
|
8
|
+
src/monarch_cloud.egg-info/entry_points.txt
|
|
9
|
+
src/monarch_cloud.egg-info/requires.txt
|
|
10
|
+
src/monarch_cloud.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httpx<1,>=0.24
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
monarch_cloud
|