socaity-cli 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- socaity_cli/__init__.py +30 -0
- socaity_cli/__main__.py +4 -0
- socaity_cli/apipod_commands.py +82 -0
- socaity_cli/auth.py +197 -0
- socaity_cli/backend_client.py +23 -0
- socaity_cli/cli.py +352 -0
- socaity_cli/clients/__init__.py +18 -0
- socaity_cli/clients/analytics.py +60 -0
- socaity_cli/clients/catalog.py +147 -0
- socaity_cli/clients/deployment.py +129 -0
- socaity_cli/clients/files.py +82 -0
- socaity_cli/clients/jobs.py +80 -0
- socaity_cli/clients/projects.py +67 -0
- socaity_cli/clients/sdk_install.py +16 -0
- socaity_cli/credentials.py +128 -0
- socaity_cli/deployment.py +349 -0
- socaity_cli/errors.py +35 -0
- socaity_cli/http.py +105 -0
- socaity_cli/prompts.py +17 -0
- socaity_cli/registry_push.py +122 -0
- socaity_cli/requires.py +47 -0
- socaity_cli/sdk_commands.py +28 -0
- socaity_cli-0.1.1.dist-info/METADATA +73 -0
- socaity_cli-0.1.1.dist-info/RECORD +27 -0
- socaity_cli-0.1.1.dist-info/WHEEL +5 -0
- socaity_cli-0.1.1.dist-info/entry_points.txt +2 -0
- socaity_cli-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""XDG credentials storage for the Socaity CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
DEFAULT_BACKEND_URL = "https://webapi.socaity.ai"
|
|
13
|
+
DEFAULT_FRONTEND_URL = "https://socaity.ai"
|
|
14
|
+
|
|
15
|
+
_LEGACY_TOKEN_FILE = Path.home() / ".apipod" / "token"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Credentials:
|
|
20
|
+
api_key: str
|
|
21
|
+
user_id: Optional[str] = None
|
|
22
|
+
user_email: Optional[str] = None
|
|
23
|
+
backend_url: str = DEFAULT_BACKEND_URL
|
|
24
|
+
logged_in_at: Optional[str] = None
|
|
25
|
+
|
|
26
|
+
def apply_to_env(self) -> None:
|
|
27
|
+
# setdefault: an explicitly set env var (e.g. pointing at a local
|
|
28
|
+
# backend) always wins over stored credentials.
|
|
29
|
+
os.environ.setdefault("SOCAITY_API_KEY", self.api_key)
|
|
30
|
+
os.environ.setdefault("SOCAITY_BACKEND_URL", self.backend_url.rstrip("/") + "/")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def config_dir() -> Path:
|
|
34
|
+
xdg = os.environ.get("XDG_CONFIG_HOME", "").strip()
|
|
35
|
+
base = Path(xdg) if xdg else Path.home() / ".config"
|
|
36
|
+
return base / "socaity"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def credentials_path() -> Path:
|
|
40
|
+
return config_dir() / "credentials.json"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_credentials() -> Optional[Credentials]:
|
|
44
|
+
path = credentials_path()
|
|
45
|
+
if path.is_file():
|
|
46
|
+
try:
|
|
47
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
48
|
+
api_key = (data.get("api_key") or "").strip()
|
|
49
|
+
if api_key:
|
|
50
|
+
return Credentials(
|
|
51
|
+
api_key=api_key,
|
|
52
|
+
user_id=data.get("user_id"),
|
|
53
|
+
user_email=data.get("user_email"),
|
|
54
|
+
backend_url=(data.get("backend_url") or DEFAULT_BACKEND_URL).rstrip("/"),
|
|
55
|
+
logged_in_at=data.get("logged_in_at"),
|
|
56
|
+
)
|
|
57
|
+
except (json.JSONDecodeError, OSError):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
legacy = _load_legacy_token()
|
|
61
|
+
if legacy:
|
|
62
|
+
save_credentials(legacy)
|
|
63
|
+
return legacy
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _load_legacy_token() -> Optional[Credentials]:
|
|
68
|
+
if not _LEGACY_TOKEN_FILE.is_file():
|
|
69
|
+
return None
|
|
70
|
+
try:
|
|
71
|
+
token = _LEGACY_TOKEN_FILE.read_text(encoding="utf-8").strip()
|
|
72
|
+
if not token:
|
|
73
|
+
return None
|
|
74
|
+
backend = os.environ.get("SOCAITY_BACKEND_URL", DEFAULT_BACKEND_URL).rstrip("/")
|
|
75
|
+
return Credentials(
|
|
76
|
+
api_key=token,
|
|
77
|
+
backend_url=backend,
|
|
78
|
+
logged_in_at=datetime.now(timezone.utc).isoformat(),
|
|
79
|
+
)
|
|
80
|
+
except OSError:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def save_credentials(credentials: Credentials) -> Path:
|
|
85
|
+
directory = config_dir()
|
|
86
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
path = credentials_path()
|
|
88
|
+
payload: dict[str, Any] = asdict(credentials)
|
|
89
|
+
payload["backend_url"] = credentials.backend_url.rstrip("/")
|
|
90
|
+
if not payload.get("logged_in_at"):
|
|
91
|
+
payload["logged_in_at"] = datetime.now(timezone.utc).isoformat()
|
|
92
|
+
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
93
|
+
path.chmod(0o600)
|
|
94
|
+
credentials.apply_to_env()
|
|
95
|
+
return path
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def clear_credentials() -> bool:
|
|
99
|
+
path = credentials_path()
|
|
100
|
+
if path.is_file():
|
|
101
|
+
path.unlink()
|
|
102
|
+
return True
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def get_api_key() -> Optional[str]:
|
|
107
|
+
"""Return the active API key. Explicit ``SOCAITY_API_KEY`` wins over login file."""
|
|
108
|
+
env_key = os.environ.get("SOCAITY_API_KEY", "").strip()
|
|
109
|
+
if env_key:
|
|
110
|
+
return env_key
|
|
111
|
+
creds = load_credentials()
|
|
112
|
+
if creds:
|
|
113
|
+
creds.apply_to_env()
|
|
114
|
+
return creds.api_key
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def require_credentials() -> Credentials:
|
|
119
|
+
stored = load_credentials()
|
|
120
|
+
if stored:
|
|
121
|
+
stored.apply_to_env()
|
|
122
|
+
return stored
|
|
123
|
+
env_key = os.environ.get("SOCAITY_API_KEY", "").strip()
|
|
124
|
+
if env_key:
|
|
125
|
+
backend = os.environ.get("SOCAITY_BACKEND_URL", DEFAULT_BACKEND_URL).rstrip("/")
|
|
126
|
+
return Credentials(api_key=env_key, backend_url=backend)
|
|
127
|
+
print("Not logged in. Run: socaity login (or set SOCAITY_API_KEY).")
|
|
128
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""Socaity platform deployment helpers (analyze, draft, HF token prompts).
|
|
2
|
+
|
|
3
|
+
Used by ``apipod deploy`` and ``socaity deploy``.
|
|
4
|
+
Requires login; apipod scan/build/start work without the backend.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
from socaity_cli.backend_client import SocaityBackendClient
|
|
12
|
+
from socaity_cli.credentials import require_credentials
|
|
13
|
+
from socaity_cli.errors import PrivateSlotLimitError
|
|
14
|
+
from socaity_cli.prompts import input_yes_no
|
|
15
|
+
from socaity_cli.registry_push import (
|
|
16
|
+
CredentialsExpired,
|
|
17
|
+
DockerNotAvailable,
|
|
18
|
+
PushFailed,
|
|
19
|
+
image_size_bytes,
|
|
20
|
+
push_image,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_TERMINAL_STATUSES = frozenset({
|
|
24
|
+
"live",
|
|
25
|
+
"failed_push",
|
|
26
|
+
"failed_validation",
|
|
27
|
+
"failed_provision",
|
|
28
|
+
"cancelled",
|
|
29
|
+
})
|
|
30
|
+
_POLL_INTERVAL_S = 5.0
|
|
31
|
+
_DEFAULT_TIMEOUT_S = 3600.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def print_analysis_report(report: Dict[str, Any]) -> None:
|
|
35
|
+
"""Render a backend analysis payload for the terminal."""
|
|
36
|
+
for model in report.get("models", []):
|
|
37
|
+
ref = model.get("ref", "?")
|
|
38
|
+
status = model.get("hf_status", "unknown")
|
|
39
|
+
line = f" {ref}: {status}"
|
|
40
|
+
catalog = model.get("catalog_match")
|
|
41
|
+
if catalog:
|
|
42
|
+
vram = (catalog.get("min_gpu_vram_gb") and f", needs ~{catalog['min_gpu_vram_gb']} GB VRAM") or ""
|
|
43
|
+
line += f" | catalog: {catalog.get('name')}{vram}"
|
|
44
|
+
print(line)
|
|
45
|
+
for warning in model.get("warnings", []):
|
|
46
|
+
print(f" warning: {warning}")
|
|
47
|
+
gpu = report.get("recommended_gpu")
|
|
48
|
+
if gpu:
|
|
49
|
+
print(f" Recommended GPU tier: {gpu}")
|
|
50
|
+
for service in report.get("similar_services", []):
|
|
51
|
+
name = service.get("display_name") or service.get("name")
|
|
52
|
+
print(f" Similar public service: {name} (id={service.get('id')})")
|
|
53
|
+
for warning in report.get("warnings", []):
|
|
54
|
+
print(f" warning: {warning}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def analyze_deployment(config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
58
|
+
"""Run backend analysis. Returns None on failure."""
|
|
59
|
+
require_credentials()
|
|
60
|
+
client = SocaityBackendClient()
|
|
61
|
+
print("Analyzing deployment against the Socaity catalog...")
|
|
62
|
+
report = client.analyze_deployment(config)
|
|
63
|
+
if report is None:
|
|
64
|
+
print("Analysis failed. Check your connection and login (socaity login), then retry.")
|
|
65
|
+
return None
|
|
66
|
+
print_analysis_report(report)
|
|
67
|
+
return report
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def prompt_model_resolutions(report: Dict[str, Any]) -> Dict[str, Optional[str]]:
|
|
71
|
+
"""Ask the user to confirm fuzzy catalog matches for declared HF models.
|
|
72
|
+
|
|
73
|
+
Returns HF ref -> catalog model id (confirmed candidate) or None (create
|
|
74
|
+
a new catalog entry). Exact matches need no confirmation and are omitted.
|
|
75
|
+
"""
|
|
76
|
+
resolutions: Dict[str, Optional[str]] = {}
|
|
77
|
+
for model in report.get("models", []):
|
|
78
|
+
ref = model.get("ref")
|
|
79
|
+
if not ref or model.get("catalog_match") or model.get("hf_status") in ("invalid", "not_found"):
|
|
80
|
+
continue
|
|
81
|
+
candidates = model.get("candidates") or []
|
|
82
|
+
picked = None
|
|
83
|
+
for candidate in candidates:
|
|
84
|
+
label = candidate.get("display_name") or candidate.get("name")
|
|
85
|
+
hf_url = candidate.get("hugging_face_url")
|
|
86
|
+
hint = f" ({hf_url})" if hf_url else ""
|
|
87
|
+
if input_yes_no(f" {ref}: we found '{label}'{hint} in our catalog. Is this your model?"):
|
|
88
|
+
picked = candidate.get("id")
|
|
89
|
+
break
|
|
90
|
+
if picked is None:
|
|
91
|
+
print(f" {ref}: will be added to the Socaity model catalog.")
|
|
92
|
+
resolutions[ref] = picked
|
|
93
|
+
return resolutions
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def prompt_gated_hf_token(client: SocaityBackendClient, report: Dict[str, Any]) -> None:
|
|
97
|
+
"""Offer to store an HF token when gated repos were detected."""
|
|
98
|
+
gated = [
|
|
99
|
+
m["ref"]
|
|
100
|
+
for m in report.get("models", [])
|
|
101
|
+
if m.get("hf_status") == "gated" and not report.get("has_hf_token")
|
|
102
|
+
]
|
|
103
|
+
if not gated:
|
|
104
|
+
return
|
|
105
|
+
print(f"\nGated Hugging Face repos detected: {', '.join(gated)}")
|
|
106
|
+
if not input_yes_no("Store a Hugging Face access token now?"):
|
|
107
|
+
return
|
|
108
|
+
token = input("HF token (hf_...): ").strip()
|
|
109
|
+
if token and client.upsert_hf_token(token):
|
|
110
|
+
print("Token stored. It will be attached to your deployment.")
|
|
111
|
+
else:
|
|
112
|
+
print("Token not stored; gated repos will fail to download on deploy.")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def create_deployment_draft(config: Dict[str, Any], report: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
|
|
116
|
+
"""Create a deployment draft after optional confirmation."""
|
|
117
|
+
require_credentials()
|
|
118
|
+
client = SocaityBackendClient()
|
|
119
|
+
if report is None:
|
|
120
|
+
report = client.analyze_deployment(config)
|
|
121
|
+
if report is None:
|
|
122
|
+
print("Analysis failed. Draft not created.")
|
|
123
|
+
return None
|
|
124
|
+
print_analysis_report(report)
|
|
125
|
+
|
|
126
|
+
resolutions = prompt_model_resolutions(report)
|
|
127
|
+
prompt_gated_hf_token(client, report)
|
|
128
|
+
|
|
129
|
+
if not input_yes_no("\nCreate a deployment draft on Socaity?"):
|
|
130
|
+
print("Aborted. Nothing was created.")
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
draft = client.create_deployment_draft(config, model_resolutions=resolutions)
|
|
135
|
+
except PrivateSlotLimitError as exc:
|
|
136
|
+
print(f"\n{exc}")
|
|
137
|
+
print("Tip: publish a private service from the service page, or upgrade your plan.")
|
|
138
|
+
raise
|
|
139
|
+
if draft is None:
|
|
140
|
+
print("Draft creation failed.")
|
|
141
|
+
return None
|
|
142
|
+
print(
|
|
143
|
+
f"Draft created: service_id={draft.get('service_id')} deployment_id={draft.get('deployment_id')}\n"
|
|
144
|
+
"Next: the image will be pushed to the Socaity registry and provisioned automatically."
|
|
145
|
+
)
|
|
146
|
+
return draft
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def push_image_to_registry(
|
|
150
|
+
deployment_id: str,
|
|
151
|
+
local_tag: str,
|
|
152
|
+
*,
|
|
153
|
+
client: Optional[SocaityBackendClient] = None,
|
|
154
|
+
) -> Optional[str]:
|
|
155
|
+
"""Push a local image to the deployment staging repo. Returns the manifest digest."""
|
|
156
|
+
client = client or SocaityBackendClient()
|
|
157
|
+
estimated_size = image_size_bytes(local_tag)
|
|
158
|
+
creds = client.get_push_credentials(deployment_id, estimated_size_bytes=estimated_size)
|
|
159
|
+
if creds is None:
|
|
160
|
+
print("Could not obtain registry push credentials.")
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
for attempt in range(2):
|
|
164
|
+
try:
|
|
165
|
+
digest, _size = push_image(
|
|
166
|
+
registry=creds["registry"],
|
|
167
|
+
repository=creds["repository"],
|
|
168
|
+
tag=creds.get("tag", "build"),
|
|
169
|
+
username=creds["username"],
|
|
170
|
+
password=creds["password"],
|
|
171
|
+
local_tag=local_tag,
|
|
172
|
+
)
|
|
173
|
+
return digest
|
|
174
|
+
except CredentialsExpired:
|
|
175
|
+
if attempt == 0:
|
|
176
|
+
print("Push credentials expired; requesting fresh credentials...")
|
|
177
|
+
creds = client.get_push_credentials(deployment_id, estimated_size_bytes=estimated_size)
|
|
178
|
+
if creds is None:
|
|
179
|
+
print("Could not refresh registry push credentials.")
|
|
180
|
+
return None
|
|
181
|
+
continue
|
|
182
|
+
print("Registry rejected the push credentials again.")
|
|
183
|
+
return None
|
|
184
|
+
except DockerNotAvailable as exc:
|
|
185
|
+
print(str(exc))
|
|
186
|
+
return None
|
|
187
|
+
except PushFailed as exc:
|
|
188
|
+
print(str(exc))
|
|
189
|
+
return None
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def poll_deployment_until_terminal(
|
|
194
|
+
deployment_id: str,
|
|
195
|
+
*,
|
|
196
|
+
timeout_s: float = _DEFAULT_TIMEOUT_S,
|
|
197
|
+
client: Optional[SocaityBackendClient] = None,
|
|
198
|
+
) -> Optional[Dict[str, Any]]:
|
|
199
|
+
"""Poll pipeline status until live or a terminal failure."""
|
|
200
|
+
client = client or SocaityBackendClient()
|
|
201
|
+
deadline = time.monotonic() + timeout_s
|
|
202
|
+
last_status: Optional[Dict[str, Any]] = None
|
|
203
|
+
|
|
204
|
+
while time.monotonic() < deadline:
|
|
205
|
+
status = client.get_deployment_pipeline_status(deployment_id)
|
|
206
|
+
if status is None:
|
|
207
|
+
print("Pipeline status poll failed; retrying...")
|
|
208
|
+
time.sleep(_POLL_INTERVAL_S)
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
last_status = status
|
|
212
|
+
phase = status.get("phase")
|
|
213
|
+
state = str(status.get("status", "")).lower()
|
|
214
|
+
label = f"{state}" + (f" ({phase})" if phase else "")
|
|
215
|
+
print(f" deployment status: {label}")
|
|
216
|
+
|
|
217
|
+
if state in _TERMINAL_STATUSES:
|
|
218
|
+
return status
|
|
219
|
+
time.sleep(_POLL_INTERVAL_S)
|
|
220
|
+
|
|
221
|
+
print(f"Timed out after {int(timeout_s)}s waiting for deployment {deployment_id}.")
|
|
222
|
+
return last_status
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _create_draft_with_prompts(
|
|
226
|
+
config: Dict[str, Any],
|
|
227
|
+
*,
|
|
228
|
+
client: SocaityBackendClient,
|
|
229
|
+
assume_yes: bool,
|
|
230
|
+
) -> Optional[Dict[str, Any]]:
|
|
231
|
+
report = client.analyze_deployment(config)
|
|
232
|
+
if report is None:
|
|
233
|
+
print("Analysis failed. Draft not created.")
|
|
234
|
+
return None
|
|
235
|
+
print_analysis_report(report)
|
|
236
|
+
|
|
237
|
+
if assume_yes:
|
|
238
|
+
resolutions: Dict[str, Optional[str]] = {}
|
|
239
|
+
for model in report.get("models", []):
|
|
240
|
+
ref = model.get("ref")
|
|
241
|
+
if not ref or model.get("catalog_match"):
|
|
242
|
+
continue
|
|
243
|
+
candidates = model.get("candidates") or []
|
|
244
|
+
resolutions[ref] = candidates[0]["id"] if candidates else None
|
|
245
|
+
else:
|
|
246
|
+
resolutions = prompt_model_resolutions(report)
|
|
247
|
+
|
|
248
|
+
prompt_gated_hf_token(client, report)
|
|
249
|
+
|
|
250
|
+
if not assume_yes and not input_yes_no("\nCreate a deployment draft on Socaity?"):
|
|
251
|
+
print("Aborted. Nothing was created.")
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
draft = client.create_deployment_draft(config, model_resolutions=resolutions)
|
|
256
|
+
except PrivateSlotLimitError as exc:
|
|
257
|
+
print(f"\n{exc}")
|
|
258
|
+
print("Tip: publish a private service from the service page, or upgrade your plan.")
|
|
259
|
+
raise
|
|
260
|
+
if draft is None:
|
|
261
|
+
print("Draft creation failed.")
|
|
262
|
+
return None
|
|
263
|
+
print(
|
|
264
|
+
f"Draft created: service_id={draft.get('service_id')} "
|
|
265
|
+
f"deployment_id={draft.get('deployment_id')}"
|
|
266
|
+
)
|
|
267
|
+
return draft
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def run_full_deploy(
|
|
271
|
+
config: Dict[str, Any],
|
|
272
|
+
*,
|
|
273
|
+
local_tag: str,
|
|
274
|
+
resume_deployment_id: Optional[str] = None,
|
|
275
|
+
assume_yes: bool = False,
|
|
276
|
+
) -> Optional[Dict[str, Any]]:
|
|
277
|
+
"""Analyze → draft (unless resuming) → push → poll until live or failure."""
|
|
278
|
+
require_credentials()
|
|
279
|
+
client = SocaityBackendClient()
|
|
280
|
+
|
|
281
|
+
deployment_id = resume_deployment_id
|
|
282
|
+
if deployment_id:
|
|
283
|
+
status = client.get_deployment_pipeline_status(deployment_id)
|
|
284
|
+
if status is None:
|
|
285
|
+
print(f"Deployment {deployment_id} not found or not accessible.")
|
|
286
|
+
return None
|
|
287
|
+
state = str(status.get("status", "")).lower()
|
|
288
|
+
if state == "live":
|
|
289
|
+
print(f"Deployment {deployment_id} is already live.")
|
|
290
|
+
return status
|
|
291
|
+
if state in _TERMINAL_STATUSES - {"live"}:
|
|
292
|
+
print(
|
|
293
|
+
f"Deployment {deployment_id} is in terminal state '{state}'. "
|
|
294
|
+
"Create a new draft or fix the issue before resuming."
|
|
295
|
+
)
|
|
296
|
+
return status
|
|
297
|
+
print(f"Resuming deployment {deployment_id} (status={state}).")
|
|
298
|
+
else:
|
|
299
|
+
draft = _create_draft_with_prompts(config, client=client, assume_yes=assume_yes)
|
|
300
|
+
if draft is None:
|
|
301
|
+
return None
|
|
302
|
+
deployment_id = draft.get("deployment_id")
|
|
303
|
+
if not deployment_id:
|
|
304
|
+
print("Draft response did not include deployment_id.")
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
current = client.get_deployment_pipeline_status(deployment_id) or {}
|
|
308
|
+
state = str(current.get("status", "")).lower()
|
|
309
|
+
if state in {"draft", "awaiting_image", "pushing", "failed_push"}:
|
|
310
|
+
print(f"Pushing image '{local_tag}' to the Socaity registry...")
|
|
311
|
+
digest = push_image_to_registry(deployment_id, local_tag, client=client)
|
|
312
|
+
if digest is None:
|
|
313
|
+
return None
|
|
314
|
+
size_bytes = image_size_bytes(local_tag)
|
|
315
|
+
pushed = client.confirm_image_pushed(deployment_id, digest=digest, size_bytes=size_bytes)
|
|
316
|
+
if pushed is None:
|
|
317
|
+
print("Backend did not accept the image push confirmation.")
|
|
318
|
+
return None
|
|
319
|
+
print(f"Image pushed (digest={digest}). Validation and provisioning started.")
|
|
320
|
+
elif state in {"validating", "promoting", "provisioning"}:
|
|
321
|
+
print(f"Image already pushed; waiting for pipeline (status={state}).")
|
|
322
|
+
elif state == "live":
|
|
323
|
+
print(f"Deployment {deployment_id} is already live.")
|
|
324
|
+
return current
|
|
325
|
+
|
|
326
|
+
print("Waiting for deployment to go live (this may take several minutes)...")
|
|
327
|
+
final = poll_deployment_until_terminal(deployment_id, client=client)
|
|
328
|
+
if final is None:
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
final_state = str(final.get("status", "")).lower()
|
|
332
|
+
if final_state == "live":
|
|
333
|
+
print(f"\nDeployment live: deployment_id={deployment_id}")
|
|
334
|
+
if final.get("service_id"):
|
|
335
|
+
print(f" service_id={final['service_id']}")
|
|
336
|
+
print(" https://www.socaity.ai")
|
|
337
|
+
elif final.get("error"):
|
|
338
|
+
print(f"\nDeployment failed: {final['error']}")
|
|
339
|
+
else:
|
|
340
|
+
print(f"\nDeployment ended with status={final_state}.")
|
|
341
|
+
return final
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def run_platform_deploy(config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
345
|
+
"""Full analyze → optional HF token → optional draft flow."""
|
|
346
|
+
report = analyze_deployment(config)
|
|
347
|
+
if report is None:
|
|
348
|
+
return None
|
|
349
|
+
return create_deployment_draft(config, report=report)
|
socaity_cli/errors.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""CLI-facing structured API errors."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BackendApiError(Exception):
|
|
8
|
+
"""HTTP error from the Socaity backend with optional structured detail."""
|
|
9
|
+
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
status_code: int,
|
|
13
|
+
message: str,
|
|
14
|
+
*,
|
|
15
|
+
code: Optional[str] = None,
|
|
16
|
+
detail: Optional[Any] = None,
|
|
17
|
+
):
|
|
18
|
+
self.status_code = status_code
|
|
19
|
+
self.code = code
|
|
20
|
+
self.detail = detail
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PrivateSlotLimitError(BackendApiError):
|
|
25
|
+
"""Private deployment slot limit reached (plan quota)."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, detail: dict):
|
|
28
|
+
used = detail.get("used")
|
|
29
|
+
limit = detail.get("limit")
|
|
30
|
+
plan = detail.get("plan") or "free"
|
|
31
|
+
message = detail.get("message") or (
|
|
32
|
+
f"Private deployment limit reached ({used}/{limit} on plan '{plan}'). "
|
|
33
|
+
"Publish an existing private service or upgrade your plan to free a slot."
|
|
34
|
+
)
|
|
35
|
+
super().__init__(403, message, code="PRIVATE_SLOT_LIMIT", detail=detail)
|
socaity_cli/http.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Shared HTTP transport for platform backend clients."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from socaity_cli.credentials import DEFAULT_BACKEND_URL, get_api_key
|
|
9
|
+
from socaity_cli.errors import BackendApiError, PrivateSlotLimitError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BackendHttp:
|
|
13
|
+
"""Base HTTP client: URL, Bearer auth, query flattening, JSON requests."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, backend_url: Optional[str] = None, api_key: Optional[str] = None):
|
|
16
|
+
default_backend = DEFAULT_BACKEND_URL.rstrip("/") + "/"
|
|
17
|
+
self.backend_url = (backend_url or os.getenv("SOCAITY_BACKEND_URL", default_backend)).rstrip("/") + "/"
|
|
18
|
+
self.api_key = api_key if api_key is not None else get_api_key()
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def _auth_headers(self) -> Optional[Dict]:
|
|
22
|
+
return {"Authorization": f"Bearer {self.api_key}"} if self.api_key else None
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def _raise_for_structured_error(status_code: int, body_text: str) -> None:
|
|
26
|
+
try:
|
|
27
|
+
body = json.loads(body_text) if body_text else None
|
|
28
|
+
except Exception:
|
|
29
|
+
body = None
|
|
30
|
+
detail = body.get("detail") if isinstance(body, dict) else None
|
|
31
|
+
if isinstance(detail, dict) and detail.get("code") == "PRIVATE_SLOT_LIMIT":
|
|
32
|
+
raise PrivateSlotLimitError(detail)
|
|
33
|
+
if isinstance(detail, dict) and detail.get("code"):
|
|
34
|
+
raise BackendApiError(
|
|
35
|
+
status_code,
|
|
36
|
+
detail.get("message") or str(detail),
|
|
37
|
+
code=detail.get("code"),
|
|
38
|
+
detail=detail,
|
|
39
|
+
)
|
|
40
|
+
# Auth / ACL denials often use a plain string detail (e.g. private install).
|
|
41
|
+
if status_code in (401, 403):
|
|
42
|
+
if isinstance(detail, str) and detail.strip():
|
|
43
|
+
message = detail.strip()
|
|
44
|
+
elif isinstance(detail, dict):
|
|
45
|
+
message = str(detail.get("message") or detail)
|
|
46
|
+
else:
|
|
47
|
+
message = (body_text or f"HTTP {status_code}")[:300]
|
|
48
|
+
raise BackendApiError(status_code, message, detail=detail)
|
|
49
|
+
|
|
50
|
+
def _request(self, method: str, path: str, payload=None, params: Dict = None) -> Optional[Any]:
|
|
51
|
+
params = self._catalog_params(params or {})
|
|
52
|
+
try:
|
|
53
|
+
with httpx.Client() as client:
|
|
54
|
+
response = client.request(
|
|
55
|
+
method,
|
|
56
|
+
self.backend_url + path,
|
|
57
|
+
json=payload,
|
|
58
|
+
params=params,
|
|
59
|
+
headers=self._auth_headers,
|
|
60
|
+
timeout=400,
|
|
61
|
+
)
|
|
62
|
+
if response.status_code in (200, 202):
|
|
63
|
+
return response.json() if response.content else {}
|
|
64
|
+
self._raise_for_structured_error(response.status_code, response.text)
|
|
65
|
+
print(f"Request to {path} failed with status {response.status_code}: {response.text[:300]}")
|
|
66
|
+
return None
|
|
67
|
+
except (PrivateSlotLimitError, BackendApiError):
|
|
68
|
+
raise
|
|
69
|
+
except Exception as e:
|
|
70
|
+
print(f"Request to {path} failed: {e}")
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def _catalog_params(params: Dict) -> Dict:
|
|
75
|
+
"""Flatten list params for FastAPI repeated query keys."""
|
|
76
|
+
flat: Dict[str, Any] = {}
|
|
77
|
+
for key, value in params.items():
|
|
78
|
+
if value is None:
|
|
79
|
+
continue
|
|
80
|
+
if isinstance(value, list):
|
|
81
|
+
if value:
|
|
82
|
+
flat[key] = value
|
|
83
|
+
else:
|
|
84
|
+
flat[key] = value
|
|
85
|
+
return flat
|
|
86
|
+
|
|
87
|
+
def _post(self, path: str, payload=None, params: Dict = None) -> Optional[Any]:
|
|
88
|
+
return self._request("POST", path, payload=payload, params=params)
|
|
89
|
+
|
|
90
|
+
def _get(self, path: str, params: Dict = None) -> Optional[Any]:
|
|
91
|
+
return self._request("GET", path, params=params)
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _unwrap_list(payload: Any) -> list:
|
|
95
|
+
"""Accept bare arrays or ``{ids, entities}`` query envelopes."""
|
|
96
|
+
if payload is None:
|
|
97
|
+
return []
|
|
98
|
+
if isinstance(payload, list):
|
|
99
|
+
return payload
|
|
100
|
+
if isinstance(payload, dict):
|
|
101
|
+
if "entities" in payload:
|
|
102
|
+
return payload.get("entities") or []
|
|
103
|
+
if "data" in payload and isinstance(payload["data"], list):
|
|
104
|
+
return payload["data"]
|
|
105
|
+
return []
|
socaity_cli/prompts.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Interactive CLI prompts shared across socaity and apipod commands."""
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
_VALID_YES_NO = {"yes": True, "y": True, "ye": True, "no": False, "n": False}
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def input_yes_no(question: str, default: bool = True) -> bool:
|
|
8
|
+
"""Prompt for yes/no. Empty input returns ``default``."""
|
|
9
|
+
prompt = " [Y/n] " if default else " [y/N] "
|
|
10
|
+
while True:
|
|
11
|
+
sys.stdout.write(question + prompt)
|
|
12
|
+
choice = input().lower()
|
|
13
|
+
if choice == "":
|
|
14
|
+
return default
|
|
15
|
+
if choice in _VALID_YES_NO:
|
|
16
|
+
return _VALID_YES_NO[choice]
|
|
17
|
+
sys.stdout.write("Please respond with 'yes' or 'no' (or 'y'/'n').\n")
|