aircloud-cli 0.1.0__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.
- aircloud_cli/__init__.py +0 -0
- aircloud_cli/client.py +166 -0
- aircloud_cli/config.py +35 -0
- aircloud_cli/container_exec.py +149 -0
- aircloud_cli/main.py +508 -0
- aircloud_cli/ssh_tunnel.py +380 -0
- aircloud_cli-0.1.0.dist-info/METADATA +130 -0
- aircloud_cli-0.1.0.dist-info/RECORD +11 -0
- aircloud_cli-0.1.0.dist-info/WHEEL +5 -0
- aircloud_cli-0.1.0.dist-info/entry_points.txt +2 -0
- aircloud_cli-0.1.0.dist-info/top_level.txt +1 -0
aircloud_cli/__init__.py
ADDED
|
File without changes
|
aircloud_cli/client.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AirCloudError(Exception):
|
|
8
|
+
"""Base exception for AirCloud CLI client."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AirCloudAPIError(AirCloudError):
|
|
12
|
+
"""Raised when the AirCloud API returns an error response."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.status_code = status_code
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class AirCloudClient:
|
|
21
|
+
base_url: str
|
|
22
|
+
api_key: str
|
|
23
|
+
timeout: int = 30
|
|
24
|
+
_http: httpx.Client = field(init=False, repr=False)
|
|
25
|
+
|
|
26
|
+
def __post_init__(self):
|
|
27
|
+
self._http = httpx.Client(
|
|
28
|
+
base_url=self.base_url.rstrip("/"),
|
|
29
|
+
headers={
|
|
30
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
31
|
+
"Accept": "application/json",
|
|
32
|
+
},
|
|
33
|
+
timeout=self.timeout,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def _request_json(
|
|
37
|
+
self,
|
|
38
|
+
method: str,
|
|
39
|
+
path: str,
|
|
40
|
+
*,
|
|
41
|
+
query: dict[str, Any] | None = None,
|
|
42
|
+
body: dict[str, Any] | None = None,
|
|
43
|
+
) -> Any:
|
|
44
|
+
params = {k: v for k, v in (query or {}).items() if v is not None} or None
|
|
45
|
+
try:
|
|
46
|
+
response = self._http.request(
|
|
47
|
+
method.upper(),
|
|
48
|
+
path,
|
|
49
|
+
params=params,
|
|
50
|
+
json=body,
|
|
51
|
+
)
|
|
52
|
+
response.raise_for_status()
|
|
53
|
+
except httpx.HTTPStatusError as exc:
|
|
54
|
+
detail = exc.response.text
|
|
55
|
+
message = f"API request failed with HTTP {exc.response.status_code}"
|
|
56
|
+
if detail:
|
|
57
|
+
try:
|
|
58
|
+
error_payload = exc.response.json()
|
|
59
|
+
message = error_payload.get("detail") or error_payload.get("message") or message
|
|
60
|
+
except Exception:
|
|
61
|
+
message = detail
|
|
62
|
+
raise AirCloudAPIError(message, status_code=exc.response.status_code) from exc
|
|
63
|
+
except httpx.ConnectError as exc:
|
|
64
|
+
raise AirCloudError(f"Failed to reach AirCloud API: {exc}") from exc
|
|
65
|
+
|
|
66
|
+
if not response.text:
|
|
67
|
+
return None
|
|
68
|
+
return response.json()
|
|
69
|
+
|
|
70
|
+
def get_me(self) -> dict[str, Any]:
|
|
71
|
+
return self._request_json("GET", "/external/api/v1/me")
|
|
72
|
+
|
|
73
|
+
def list_endpoints(
|
|
74
|
+
self,
|
|
75
|
+
*,
|
|
76
|
+
search: str | None = None,
|
|
77
|
+
is_active: bool | None = None,
|
|
78
|
+
page: int = 1,
|
|
79
|
+
size: int = 50,
|
|
80
|
+
) -> dict[str, Any]:
|
|
81
|
+
return self._request_json(
|
|
82
|
+
"GET",
|
|
83
|
+
"/external/api/v1/endpoints",
|
|
84
|
+
query={
|
|
85
|
+
"search": search,
|
|
86
|
+
"is_active": str(is_active).lower() if is_active is not None else None,
|
|
87
|
+
"page": page,
|
|
88
|
+
"size": size,
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def get_endpoint(self, endpoint_id: str) -> dict[str, Any]:
|
|
93
|
+
return self._request_json("GET", f"/external/api/v1/endpoints/{endpoint_id}")
|
|
94
|
+
|
|
95
|
+
def get_replicas(self, endpoint_id: str) -> list[dict[str, Any]]:
|
|
96
|
+
return self._request_json(
|
|
97
|
+
"GET",
|
|
98
|
+
f"/external/api/v1/endpoints/{endpoint_id}/replicas",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def list_logs(
|
|
102
|
+
self,
|
|
103
|
+
endpoint_id: str,
|
|
104
|
+
*,
|
|
105
|
+
replica_id: str | None = None,
|
|
106
|
+
include_history: bool = True,
|
|
107
|
+
) -> list[dict[str, Any]]:
|
|
108
|
+
return self._request_json(
|
|
109
|
+
"GET",
|
|
110
|
+
f"/external/api/v1/endpoints/{endpoint_id}/logs",
|
|
111
|
+
query={
|
|
112
|
+
"replica_id": replica_id,
|
|
113
|
+
"include_history": str(include_history).lower(),
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def read_log_file(
|
|
118
|
+
self,
|
|
119
|
+
endpoint_id: str,
|
|
120
|
+
*,
|
|
121
|
+
node_id: str,
|
|
122
|
+
filename: str,
|
|
123
|
+
start_line: int = 1,
|
|
124
|
+
end_line: int = 200,
|
|
125
|
+
) -> dict[str, Any]:
|
|
126
|
+
return self._request_json(
|
|
127
|
+
"GET",
|
|
128
|
+
f"/external/api/v1/endpoints/{endpoint_id}/logs/file",
|
|
129
|
+
query={
|
|
130
|
+
"node_id": node_id,
|
|
131
|
+
"filename": filename,
|
|
132
|
+
"start_line": start_line,
|
|
133
|
+
"end_line": end_line,
|
|
134
|
+
},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def start_endpoint(self, endpoint_id: str) -> dict[str, Any]:
|
|
138
|
+
return self._request_json(
|
|
139
|
+
"POST",
|
|
140
|
+
f"/external/api/v1/endpoints/{endpoint_id}/start",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def stop_endpoint(self, endpoint_id: str) -> dict[str, Any]:
|
|
144
|
+
return self._request_json(
|
|
145
|
+
"POST",
|
|
146
|
+
f"/external/api/v1/endpoints/{endpoint_id}/stop",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def scale_endpoint(self, endpoint_id: str, num_replicas: int) -> dict[str, Any]:
|
|
150
|
+
return self._request_json(
|
|
151
|
+
"POST",
|
|
152
|
+
f"/external/api/v1/endpoints/{endpoint_id}/scale",
|
|
153
|
+
body={"num_replicas": num_replicas},
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def patch_endpoint(
|
|
157
|
+
self,
|
|
158
|
+
endpoint_id: str,
|
|
159
|
+
**kwargs: Any,
|
|
160
|
+
) -> dict[str, Any]:
|
|
161
|
+
body = {k: v for k, v in kwargs.items() if v is not None}
|
|
162
|
+
return self._request_json(
|
|
163
|
+
"PATCH",
|
|
164
|
+
f"/external/api/v1/endpoints/{endpoint_id}",
|
|
165
|
+
body=body,
|
|
166
|
+
)
|
aircloud_cli/config.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
CONFIG_DIR = Path.home() / ".aircloud"
|
|
5
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
6
|
+
DEFAULT_API_BASE_URL = "https://your-aircloud-host.example.com"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _load() -> dict:
|
|
10
|
+
if CONFIG_FILE.exists():
|
|
11
|
+
return json.loads(CONFIG_FILE.read_text())
|
|
12
|
+
return {}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _save(data: dict):
|
|
16
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
CONFIG_FILE.write_text(json.dumps(data, indent=2))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get(key: str) -> str | None:
|
|
21
|
+
return _load().get(key)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def set(key: str, value: str):
|
|
25
|
+
data = _load()
|
|
26
|
+
data[key] = value
|
|
27
|
+
_save(data)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_api_base_url() -> str:
|
|
31
|
+
return get("api-base-url") or DEFAULT_API_BASE_URL
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_api_key() -> str | None:
|
|
35
|
+
return get("api-key")
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import termios
|
|
6
|
+
import tty
|
|
7
|
+
|
|
8
|
+
import websockets
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def _run_exec(ws_url: str, api_key: str, cmd: list[str]):
|
|
12
|
+
headers = {"Authorization": f"Bearer {api_key}"}
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
ws = await websockets.connect(
|
|
16
|
+
ws_url,
|
|
17
|
+
additional_headers=headers,
|
|
18
|
+
ping_interval=None,
|
|
19
|
+
max_size=None,
|
|
20
|
+
)
|
|
21
|
+
except websockets.exceptions.InvalidStatusCode as e:
|
|
22
|
+
if e.status_code in (401, 403):
|
|
23
|
+
print(f" Access denied (HTTP {e.status_code}). Check API key.")
|
|
24
|
+
elif e.status_code == 302:
|
|
25
|
+
print(" Authentication failed (redirected to login).")
|
|
26
|
+
else:
|
|
27
|
+
print(f" WebSocket handshake failed: HTTP {e.status_code}")
|
|
28
|
+
return
|
|
29
|
+
except Exception as e:
|
|
30
|
+
print(f" Failed to connect: {e}")
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
if not sys.stdin.isatty():
|
|
34
|
+
print(" exec requires an interactive TTY (stdin).")
|
|
35
|
+
await ws.close()
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
fd = sys.stdin.fileno()
|
|
39
|
+
old_attr = termios.tcgetattr(fd)
|
|
40
|
+
tty.setraw(fd)
|
|
41
|
+
|
|
42
|
+
loop = asyncio.get_event_loop()
|
|
43
|
+
stopping = asyncio.Event()
|
|
44
|
+
|
|
45
|
+
input_buffer = bytearray()
|
|
46
|
+
exit_task: asyncio.Task | None = None
|
|
47
|
+
|
|
48
|
+
def schedule_close():
|
|
49
|
+
nonlocal exit_task
|
|
50
|
+
if exit_task and not exit_task.done():
|
|
51
|
+
exit_task.cancel()
|
|
52
|
+
|
|
53
|
+
async def _close():
|
|
54
|
+
try:
|
|
55
|
+
await asyncio.sleep(2.0)
|
|
56
|
+
try:
|
|
57
|
+
await ws.close(code=1000, reason="client-initiated close")
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
stopping.set()
|
|
61
|
+
except asyncio.CancelledError:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
exit_task = asyncio.create_task(_close())
|
|
65
|
+
|
|
66
|
+
def inspect_input(data: bytes):
|
|
67
|
+
for b in data:
|
|
68
|
+
if b == 0x04: # Ctrl-D
|
|
69
|
+
schedule_close()
|
|
70
|
+
input_buffer.clear()
|
|
71
|
+
elif b in (0x0D, 0x0A): # CR / LF
|
|
72
|
+
line = (
|
|
73
|
+
bytes(input_buffer)
|
|
74
|
+
.decode("utf-8", errors="replace")
|
|
75
|
+
.strip()
|
|
76
|
+
)
|
|
77
|
+
if line == "exit" or line.endswith(" exit"):
|
|
78
|
+
schedule_close()
|
|
79
|
+
input_buffer.clear()
|
|
80
|
+
elif b == 0x7F: # Backspace
|
|
81
|
+
if input_buffer:
|
|
82
|
+
input_buffer.pop()
|
|
83
|
+
else:
|
|
84
|
+
input_buffer.append(b)
|
|
85
|
+
|
|
86
|
+
async def send_bytes(data: bytes):
|
|
87
|
+
try:
|
|
88
|
+
await ws.send(data)
|
|
89
|
+
except websockets.exceptions.ConnectionClosed:
|
|
90
|
+
stopping.set()
|
|
91
|
+
except Exception:
|
|
92
|
+
stopping.set()
|
|
93
|
+
|
|
94
|
+
def on_stdin_readable():
|
|
95
|
+
try:
|
|
96
|
+
data = os.read(fd, 1024)
|
|
97
|
+
except OSError:
|
|
98
|
+
stopping.set()
|
|
99
|
+
return
|
|
100
|
+
if not data:
|
|
101
|
+
stopping.set()
|
|
102
|
+
return
|
|
103
|
+
inspect_input(data)
|
|
104
|
+
asyncio.ensure_future(send_bytes(data))
|
|
105
|
+
|
|
106
|
+
async def ws_to_stdout():
|
|
107
|
+
try:
|
|
108
|
+
async for msg in ws:
|
|
109
|
+
if isinstance(msg, bytes):
|
|
110
|
+
os.write(sys.stdout.fileno(), msg)
|
|
111
|
+
elif isinstance(msg, str):
|
|
112
|
+
os.write(sys.stdout.fileno(), msg.encode())
|
|
113
|
+
except (asyncio.CancelledError, websockets.exceptions.ConnectionClosed):
|
|
114
|
+
pass
|
|
115
|
+
finally:
|
|
116
|
+
stopping.set()
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
await ws.send(json.dumps({"cmd": cmd, "tty": True}))
|
|
120
|
+
loop.add_reader(fd, on_stdin_readable)
|
|
121
|
+
ws_task = asyncio.create_task(ws_to_stdout())
|
|
122
|
+
|
|
123
|
+
await stopping.wait()
|
|
124
|
+
|
|
125
|
+
ws_task.cancel()
|
|
126
|
+
try:
|
|
127
|
+
await ws_task
|
|
128
|
+
except (asyncio.CancelledError, Exception):
|
|
129
|
+
pass
|
|
130
|
+
finally:
|
|
131
|
+
try:
|
|
132
|
+
loop.remove_reader(fd)
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_attr)
|
|
136
|
+
if exit_task and not exit_task.done():
|
|
137
|
+
exit_task.cancel()
|
|
138
|
+
try:
|
|
139
|
+
await ws.close()
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def start_exec(ws_url: str, api_key: str, cmd: list[str]):
|
|
145
|
+
"""Open a docker exec session to an endpoint's container over WS."""
|
|
146
|
+
try:
|
|
147
|
+
asyncio.run(_run_exec(ws_url, api_key, cmd))
|
|
148
|
+
except KeyboardInterrupt:
|
|
149
|
+
pass
|
aircloud_cli/main.py
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Any
|
|
4
|
+
from urllib.parse import urlparse, urlunparse
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from aircloud_cli import config
|
|
9
|
+
from aircloud_cli.client import AirCloudAPIError, AirCloudClient, AirCloudError
|
|
10
|
+
from aircloud_cli.container_exec import start_exec
|
|
11
|
+
from aircloud_cli.ssh_tunnel import start_ssh, start_tunnel
|
|
12
|
+
|
|
13
|
+
DEFAULT_SSH_HOST = "your-aircloud-host.example.com"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _mask_value(key: str, value: str) -> str:
|
|
17
|
+
if key == "api-key":
|
|
18
|
+
return value[:8] + "..." if value else value
|
|
19
|
+
return value
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _format_timestamp(value: str | None) -> str:
|
|
23
|
+
if not value:
|
|
24
|
+
return "-"
|
|
25
|
+
try:
|
|
26
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
27
|
+
return parsed.strftime("%Y-%m-%d %H:%M:%S")
|
|
28
|
+
except ValueError:
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_client(api_key: str | None = None, base_url: str | None = None) -> AirCloudClient:
|
|
33
|
+
resolved_api_key = api_key or config.get_api_key()
|
|
34
|
+
if not resolved_api_key:
|
|
35
|
+
raise click.ClickException(
|
|
36
|
+
"API key not set. Run: aircloud config set api-key <key>"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
resolved_base_url = base_url or config.get_api_base_url()
|
|
40
|
+
return AirCloudClient(
|
|
41
|
+
base_url=resolved_base_url,
|
|
42
|
+
api_key=resolved_api_key,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _render_json(value: Any) -> None:
|
|
47
|
+
click.echo(json.dumps(value, indent=2, sort_keys=False))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _pick_default_log_file(logs: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
51
|
+
if not logs:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
live_logs = [log for log in logs if not log.get("is_historical")]
|
|
55
|
+
candidates = live_logs or logs
|
|
56
|
+
return sorted(
|
|
57
|
+
candidates,
|
|
58
|
+
key=lambda item: (
|
|
59
|
+
item.get("is_historical", False),
|
|
60
|
+
item.get("start_time") or "",
|
|
61
|
+
item.get("filename") or "",
|
|
62
|
+
),
|
|
63
|
+
)[-1]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _render_log_content(content: str, raw: bool) -> None:
|
|
67
|
+
if raw:
|
|
68
|
+
click.echo(content.rstrip("\n"))
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
for line in content.splitlines():
|
|
72
|
+
if not line.strip():
|
|
73
|
+
continue
|
|
74
|
+
try:
|
|
75
|
+
payload = json.loads(line)
|
|
76
|
+
except json.JSONDecodeError:
|
|
77
|
+
click.echo(line)
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
timestamp = payload.get("timestamp")
|
|
81
|
+
message = payload.get("log") or payload.get("message") or line
|
|
82
|
+
click.echo(f"{timestamp or '-'} {message}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@click.group()
|
|
86
|
+
@click.version_option(version="0.1.0")
|
|
87
|
+
def cli():
|
|
88
|
+
"""AirCloud CLI."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@cli.group("config")
|
|
92
|
+
def config_cmd():
|
|
93
|
+
"""Manage CLI configuration."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@config_cmd.command("set")
|
|
97
|
+
@click.argument("key")
|
|
98
|
+
@click.argument("value")
|
|
99
|
+
def config_set(key: str, value: str):
|
|
100
|
+
"""Set a config value."""
|
|
101
|
+
config.set(key, value)
|
|
102
|
+
click.echo(f"{key} = {_mask_value(key, value)}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@config_cmd.command("get")
|
|
106
|
+
@click.argument("key")
|
|
107
|
+
def config_get(key: str):
|
|
108
|
+
"""Get a config value."""
|
|
109
|
+
value = config.get(key)
|
|
110
|
+
if value is None:
|
|
111
|
+
click.echo(f"{key} is not set")
|
|
112
|
+
return
|
|
113
|
+
click.echo(f"{key} = {_mask_value(key, value)}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@config_cmd.command("list")
|
|
117
|
+
def config_list():
|
|
118
|
+
"""List common config values."""
|
|
119
|
+
values = {
|
|
120
|
+
"api-base-url": config.get_api_base_url(),
|
|
121
|
+
"api-key": config.get_api_key(),
|
|
122
|
+
"host": config.get("host") or DEFAULT_SSH_HOST,
|
|
123
|
+
}
|
|
124
|
+
for key, value in values.items():
|
|
125
|
+
if value is None:
|
|
126
|
+
click.echo(f"{key} = <not set>")
|
|
127
|
+
else:
|
|
128
|
+
click.echo(f"{key} = {_mask_value(key, value)}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@cli.command("whoami")
|
|
132
|
+
@click.option("--base-url", default=None, help="External API base URL")
|
|
133
|
+
@click.option("--api-key", default=None, help="API key")
|
|
134
|
+
def whoami(base_url: str | None, api_key: str | None):
|
|
135
|
+
"""Show API key context."""
|
|
136
|
+
client = _get_client(api_key=api_key, base_url=base_url)
|
|
137
|
+
_render_json(client.get_me())
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@cli.group("endpoints")
|
|
141
|
+
def endpoints_cmd():
|
|
142
|
+
"""Inspect and manage endpoints."""
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@endpoints_cmd.command("list")
|
|
146
|
+
@click.option("--search", default=None, help="Search by endpoint name")
|
|
147
|
+
@click.option(
|
|
148
|
+
"--active/--all",
|
|
149
|
+
"active_only",
|
|
150
|
+
default=False,
|
|
151
|
+
help="Show only active endpoints",
|
|
152
|
+
)
|
|
153
|
+
@click.option("--page", default=1, show_default=True, help="Page number")
|
|
154
|
+
@click.option("--size", default=50, show_default=True, help="Page size")
|
|
155
|
+
@click.option("--base-url", default=None, help="External API base URL")
|
|
156
|
+
@click.option("--api-key", default=None, help="API key")
|
|
157
|
+
def endpoints_list(
|
|
158
|
+
search: str | None,
|
|
159
|
+
active_only: bool,
|
|
160
|
+
page: int,
|
|
161
|
+
size: int,
|
|
162
|
+
base_url: str | None,
|
|
163
|
+
api_key: str | None,
|
|
164
|
+
):
|
|
165
|
+
"""List accessible endpoints."""
|
|
166
|
+
client = _get_client(api_key=api_key, base_url=base_url)
|
|
167
|
+
payload = client.list_endpoints(
|
|
168
|
+
search=search,
|
|
169
|
+
is_active=True if active_only else None,
|
|
170
|
+
page=page,
|
|
171
|
+
size=size,
|
|
172
|
+
)
|
|
173
|
+
items = payload.get("items", [])
|
|
174
|
+
if not items:
|
|
175
|
+
click.echo("No endpoints found.")
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
header = f"{'ID':36} {'NAME':28} {'ACTIVE':6} {'STATUS':12} {'REPLICAS':8}"
|
|
179
|
+
click.echo(header)
|
|
180
|
+
click.echo("-" * len(header))
|
|
181
|
+
for item in items:
|
|
182
|
+
click.echo(
|
|
183
|
+
f"{item['id'][:36]:36} "
|
|
184
|
+
f"{item['name'][:28]:28} "
|
|
185
|
+
f"{str(item['is_active']):6} "
|
|
186
|
+
f"{item['status'][:12]:12} "
|
|
187
|
+
f"{str(item['num_replicas']):8}"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@endpoints_cmd.command("get")
|
|
192
|
+
@click.argument("endpoint_id")
|
|
193
|
+
@click.option("--base-url", default=None, help="External API base URL")
|
|
194
|
+
@click.option("--api-key", default=None, help="API key")
|
|
195
|
+
def endpoints_get(endpoint_id: str, base_url: str | None, api_key: str | None):
|
|
196
|
+
"""Get endpoint details."""
|
|
197
|
+
client = _get_client(api_key=api_key, base_url=base_url)
|
|
198
|
+
_render_json(client.get_endpoint(endpoint_id))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@endpoints_cmd.command("start")
|
|
202
|
+
@click.argument("endpoint_id")
|
|
203
|
+
@click.option("--base-url", default=None, help="External API base URL")
|
|
204
|
+
@click.option("--api-key", default=None, help="API key")
|
|
205
|
+
def endpoints_start(endpoint_id: str, base_url: str | None, api_key: str | None):
|
|
206
|
+
"""Start an endpoint."""
|
|
207
|
+
client = _get_client(api_key=api_key, base_url=base_url)
|
|
208
|
+
_render_json(client.start_endpoint(endpoint_id))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@endpoints_cmd.command("stop")
|
|
212
|
+
@click.argument("endpoint_id")
|
|
213
|
+
@click.option("--base-url", default=None, help="External API base URL")
|
|
214
|
+
@click.option("--api-key", default=None, help="API key")
|
|
215
|
+
def endpoints_stop(endpoint_id: str, base_url: str | None, api_key: str | None):
|
|
216
|
+
"""Stop an endpoint."""
|
|
217
|
+
client = _get_client(api_key=api_key, base_url=base_url)
|
|
218
|
+
_render_json(client.stop_endpoint(endpoint_id))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@endpoints_cmd.command("scale")
|
|
222
|
+
@click.argument("endpoint_id")
|
|
223
|
+
@click.option("--replicas", required=True, type=int, help="Target replica count")
|
|
224
|
+
@click.option("--base-url", default=None, help="External API base URL")
|
|
225
|
+
@click.option("--api-key", default=None, help="API key")
|
|
226
|
+
def endpoints_scale(endpoint_id: str, replicas: int, base_url: str | None, api_key: str | None):
|
|
227
|
+
"""Scale endpoint replicas."""
|
|
228
|
+
client = _get_client(api_key=api_key, base_url=base_url)
|
|
229
|
+
_render_json(client.scale_endpoint(endpoint_id, replicas))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@endpoints_cmd.command("patch")
|
|
233
|
+
@click.argument("endpoint_id")
|
|
234
|
+
@click.option("--command", "start_command", default=None, help="Container start command")
|
|
235
|
+
@click.option("--port", "container_port", default=None, type=click.IntRange(1, 65535), help="Container port (1-65535)")
|
|
236
|
+
@click.option("--health-check-path", default=None, help="Health check path")
|
|
237
|
+
@click.option("--health-check-timeout", default=None, type=click.IntRange(min=1), help="Container health check timeout (seconds, >=1)")
|
|
238
|
+
@click.option("--endpoint-health-timeout", default=None, type=click.IntRange(min=1), help="Endpoint-level health check timeout (seconds, >=1)")
|
|
239
|
+
@click.option("--metric-path", default=None, help="Container metric path")
|
|
240
|
+
@click.option("--env", "env_vars", multiple=True, help="Environment variable (KEY=VALUE), repeatable")
|
|
241
|
+
@click.option("--base-url", default=None, help="External API base URL")
|
|
242
|
+
@click.option("--api-key", default=None, help="API key")
|
|
243
|
+
def endpoints_patch(
|
|
244
|
+
endpoint_id: str,
|
|
245
|
+
start_command: str | None,
|
|
246
|
+
container_port: int | None,
|
|
247
|
+
health_check_path: str | None,
|
|
248
|
+
health_check_timeout: int | None,
|
|
249
|
+
endpoint_health_timeout: int | None,
|
|
250
|
+
metric_path: str | None,
|
|
251
|
+
env_vars: tuple[str, ...],
|
|
252
|
+
base_url: str | None,
|
|
253
|
+
api_key: str | None,
|
|
254
|
+
):
|
|
255
|
+
"""Patch endpoint settings (endpoint must be inactive)."""
|
|
256
|
+
parsed_env = None
|
|
257
|
+
if env_vars:
|
|
258
|
+
parsed_env = []
|
|
259
|
+
for entry in env_vars:
|
|
260
|
+
if "=" not in entry:
|
|
261
|
+
raise click.ClickException(f"Invalid env format: {entry!r} (expected KEY=VALUE)")
|
|
262
|
+
key, value = entry.split("=", 1)
|
|
263
|
+
parsed_env.append({"key": key, "value": value})
|
|
264
|
+
|
|
265
|
+
updates = {
|
|
266
|
+
"container_start_command": start_command,
|
|
267
|
+
"container_port": container_port,
|
|
268
|
+
"container_health_check_path": health_check_path,
|
|
269
|
+
"container_health_check_timeout": health_check_timeout,
|
|
270
|
+
"health_check_timeout": endpoint_health_timeout,
|
|
271
|
+
"container_metric_path": metric_path,
|
|
272
|
+
"container_env_vars": parsed_env,
|
|
273
|
+
}
|
|
274
|
+
if all(v is None for v in updates.values()):
|
|
275
|
+
raise click.ClickException("Provide at least one field to patch.")
|
|
276
|
+
|
|
277
|
+
client = _get_client(api_key=api_key, base_url=base_url)
|
|
278
|
+
_render_json(client.patch_endpoint(endpoint_id, **updates))
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@endpoints_cmd.command("replicas")
|
|
282
|
+
@click.argument("endpoint_id")
|
|
283
|
+
@click.option("--base-url", default=None, help="External API base URL")
|
|
284
|
+
@click.option("--api-key", default=None, help="API key")
|
|
285
|
+
def endpoints_replicas(endpoint_id: str, base_url: str | None, api_key: str | None):
|
|
286
|
+
"""List replicas for an endpoint."""
|
|
287
|
+
client = _get_client(api_key=api_key, base_url=base_url)
|
|
288
|
+
items = client.get_replicas(endpoint_id)
|
|
289
|
+
if not items:
|
|
290
|
+
click.echo("No live replicas found.")
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
header = f"{'REPLICA':12} {'STATE':12} {'NODE':24} {'ACTOR':24}"
|
|
294
|
+
click.echo(header)
|
|
295
|
+
click.echo("-" * len(header))
|
|
296
|
+
for item in items:
|
|
297
|
+
click.echo(
|
|
298
|
+
f"{item['replica_id'][:12]:12} "
|
|
299
|
+
f"{item['state'][:12]:12} "
|
|
300
|
+
f"{(item.get('node_id') or '-')[:24]:24} "
|
|
301
|
+
f"{(item.get('actor_id') or '-')[:24]:24}"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@endpoints_cmd.command("logs")
|
|
306
|
+
@click.argument("endpoint_id")
|
|
307
|
+
@click.option("--replica-id", default=None, help="Filter logs by replica ID")
|
|
308
|
+
@click.option("--history/--no-history", default=True, help="Include historical log files")
|
|
309
|
+
@click.option("--list-files", is_flag=True, help="List matching log files instead of reading one")
|
|
310
|
+
@click.option("--raw", is_flag=True, help="Print raw log content")
|
|
311
|
+
@click.option("--start-line", default=1, show_default=True, help="Start line")
|
|
312
|
+
@click.option("--end-line", default=200, show_default=True, help="End line")
|
|
313
|
+
@click.option("--base-url", default=None, help="External API base URL")
|
|
314
|
+
@click.option("--api-key", default=None, help="API key")
|
|
315
|
+
def endpoints_logs(
|
|
316
|
+
endpoint_id: str,
|
|
317
|
+
replica_id: str | None,
|
|
318
|
+
history: bool,
|
|
319
|
+
list_files: bool,
|
|
320
|
+
raw: bool,
|
|
321
|
+
start_line: int,
|
|
322
|
+
end_line: int,
|
|
323
|
+
base_url: str | None,
|
|
324
|
+
api_key: str | None,
|
|
325
|
+
):
|
|
326
|
+
"""Read logs for an endpoint."""
|
|
327
|
+
client = _get_client(api_key=api_key, base_url=base_url)
|
|
328
|
+
log_files = client.list_logs(
|
|
329
|
+
endpoint_id,
|
|
330
|
+
replica_id=replica_id,
|
|
331
|
+
include_history=history,
|
|
332
|
+
)
|
|
333
|
+
if not log_files:
|
|
334
|
+
click.echo("No log files found.")
|
|
335
|
+
return
|
|
336
|
+
|
|
337
|
+
if list_files:
|
|
338
|
+
header = f"{'REPLICA':12} {'LIVE':4} {'START':19} {'FILE'}"
|
|
339
|
+
click.echo(header)
|
|
340
|
+
click.echo("-" * len(header))
|
|
341
|
+
for item in log_files:
|
|
342
|
+
click.echo(
|
|
343
|
+
f"{item['replica_id'][:12]:12} "
|
|
344
|
+
f"{('yes' if not item.get('is_historical') else 'no'):4} "
|
|
345
|
+
f"{_format_timestamp(item.get('start_time')):19} "
|
|
346
|
+
f"{item['filename']}"
|
|
347
|
+
)
|
|
348
|
+
return
|
|
349
|
+
|
|
350
|
+
selected_log = _pick_default_log_file(log_files)
|
|
351
|
+
if not selected_log:
|
|
352
|
+
raise click.ClickException("Failed to select a log file.")
|
|
353
|
+
|
|
354
|
+
payload = client.read_log_file(
|
|
355
|
+
endpoint_id,
|
|
356
|
+
node_id=selected_log["node_id"],
|
|
357
|
+
filename=selected_log["filename"],
|
|
358
|
+
start_line=start_line,
|
|
359
|
+
end_line=end_line,
|
|
360
|
+
)
|
|
361
|
+
_render_log_content(payload.get("content", ""), raw=raw)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@cli.command()
|
|
365
|
+
@click.argument("endpoint_id")
|
|
366
|
+
@click.option("--port", "-p", default=2222, help="Local port for SSH tunnel")
|
|
367
|
+
@click.option(
|
|
368
|
+
"--host",
|
|
369
|
+
"-h",
|
|
370
|
+
default=None,
|
|
371
|
+
help="Override host in the derived WS URL (default: inferred from endpoint)",
|
|
372
|
+
)
|
|
373
|
+
@click.option("--api-key", "-k", default=None, help="API key (default: from config)")
|
|
374
|
+
@click.option("--user", "-u", default="root", help="SSH user (default: root)")
|
|
375
|
+
@click.option(
|
|
376
|
+
"--replica-id",
|
|
377
|
+
"-r",
|
|
378
|
+
default=None,
|
|
379
|
+
help="Pin the SSH tunnel to a specific replica (default: load-balanced)",
|
|
380
|
+
)
|
|
381
|
+
@click.option(
|
|
382
|
+
"--identity-file",
|
|
383
|
+
"-i",
|
|
384
|
+
default=None,
|
|
385
|
+
help="Path to SSH private key. Defaults to ~/.ssh/id_ed25519, id_ecdsa, id_rsa.",
|
|
386
|
+
)
|
|
387
|
+
@click.option("--tunnel-only", is_flag=True, help="Open tunnel without auto SSH connect")
|
|
388
|
+
def ssh(
|
|
389
|
+
endpoint_id: str,
|
|
390
|
+
port: int,
|
|
391
|
+
host: str | None,
|
|
392
|
+
api_key: str | None,
|
|
393
|
+
user: str,
|
|
394
|
+
replica_id: str | None,
|
|
395
|
+
identity_file: str | None,
|
|
396
|
+
tunnel_only: bool,
|
|
397
|
+
):
|
|
398
|
+
"""SSH into a container."""
|
|
399
|
+
resolved_api_key = api_key or config.get_api_key()
|
|
400
|
+
if not resolved_api_key:
|
|
401
|
+
raise click.ClickException(
|
|
402
|
+
"API key not set. Run: aircloud config set api-key <key>"
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
client = _get_client(api_key=resolved_api_key)
|
|
406
|
+
endpoint = client.get_endpoint(endpoint_id)
|
|
407
|
+
serving_url = endpoint.get("serving_endpoint_url")
|
|
408
|
+
if not serving_url:
|
|
409
|
+
raise click.ClickException(
|
|
410
|
+
"Endpoint is missing serving_endpoint_url; cannot build SSH tunnel URL."
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
parsed = urlparse(serving_url)
|
|
414
|
+
scheme = "wss" if parsed.scheme == "https" else "ws"
|
|
415
|
+
netloc = host or parsed.netloc
|
|
416
|
+
path = parsed.path.rstrip("/") + "/ssh/tunnel"
|
|
417
|
+
ws_url = urlunparse((scheme, netloc, path, "", "", ""))
|
|
418
|
+
|
|
419
|
+
if tunnel_only:
|
|
420
|
+
click.echo("AirCloud SSH Tunnel")
|
|
421
|
+
click.echo(f" Endpoint: {endpoint_id}")
|
|
422
|
+
if replica_id:
|
|
423
|
+
click.echo(f" Replica: {replica_id}")
|
|
424
|
+
click.echo(f" Listening on 127.0.0.1:{port}")
|
|
425
|
+
identity_hint = f" -i {identity_file}" if identity_file else ""
|
|
426
|
+
click.echo(f" Run: ssh -p {port}{identity_hint} {user}@127.0.0.1")
|
|
427
|
+
click.echo("")
|
|
428
|
+
start_tunnel(ws_url, resolved_api_key, port, replica_id)
|
|
429
|
+
else:
|
|
430
|
+
start_ssh(
|
|
431
|
+
ws_url,
|
|
432
|
+
resolved_api_key,
|
|
433
|
+
port,
|
|
434
|
+
user,
|
|
435
|
+
replica_id,
|
|
436
|
+
identity_file=identity_file,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
@cli.command("exec")
|
|
441
|
+
@click.argument("endpoint_id")
|
|
442
|
+
@click.option(
|
|
443
|
+
"--replica-id",
|
|
444
|
+
"-r",
|
|
445
|
+
default=None,
|
|
446
|
+
help="Pin the exec session to a specific replica (default: load-balanced)",
|
|
447
|
+
)
|
|
448
|
+
@click.option(
|
|
449
|
+
"--cmd",
|
|
450
|
+
"-c",
|
|
451
|
+
default="/bin/bash",
|
|
452
|
+
show_default=True,
|
|
453
|
+
help="Command to run inside the container",
|
|
454
|
+
)
|
|
455
|
+
@click.option("--api-key", "-k", default=None, help="API key (default: from config)")
|
|
456
|
+
def exec_cmd(
|
|
457
|
+
endpoint_id: str,
|
|
458
|
+
replica_id: str | None,
|
|
459
|
+
cmd: str,
|
|
460
|
+
api_key: str | None,
|
|
461
|
+
):
|
|
462
|
+
"""Open an interactive shell in the container via docker exec."""
|
|
463
|
+
resolved_api_key = api_key or config.get_api_key()
|
|
464
|
+
if not resolved_api_key:
|
|
465
|
+
raise click.ClickException(
|
|
466
|
+
"API key not set. Run: aircloud config set api-key <key>"
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
client = _get_client(api_key=resolved_api_key)
|
|
470
|
+
endpoint = client.get_endpoint(endpoint_id)
|
|
471
|
+
serving_url = endpoint.get("serving_endpoint_url")
|
|
472
|
+
if not serving_url:
|
|
473
|
+
raise click.ClickException(
|
|
474
|
+
"Endpoint is missing serving_endpoint_url; cannot build exec URL."
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
parsed = urlparse(serving_url)
|
|
478
|
+
scheme = "wss" if parsed.scheme == "https" else "ws"
|
|
479
|
+
path = parsed.path.rstrip("/") + "/exec"
|
|
480
|
+
query = f"replica_id={replica_id}" if replica_id else ""
|
|
481
|
+
ws_url = urlunparse((scheme, parsed.netloc, path, "", query, ""))
|
|
482
|
+
|
|
483
|
+
click.echo("AirCloud Exec")
|
|
484
|
+
click.echo(f" Endpoint: {endpoint_id}")
|
|
485
|
+
if replica_id:
|
|
486
|
+
click.echo(f" Replica: {replica_id}")
|
|
487
|
+
click.echo(f" Command: {cmd}")
|
|
488
|
+
click.echo("")
|
|
489
|
+
|
|
490
|
+
start_exec(ws_url, resolved_api_key, cmd.split())
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@cli.result_callback()
|
|
494
|
+
def handle_errors(*_args, **_kwargs):
|
|
495
|
+
"""Let click handle command lifecycle."""
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def main():
|
|
499
|
+
try:
|
|
500
|
+
cli()
|
|
501
|
+
except AirCloudAPIError as exc:
|
|
502
|
+
raise click.ClickException(str(exc)) from exc
|
|
503
|
+
except AirCloudError as exc:
|
|
504
|
+
raise click.ClickException(str(exc)) from exc
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
if __name__ == "__main__":
|
|
508
|
+
main()
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import select
|
|
4
|
+
import signal
|
|
5
|
+
import sys
|
|
6
|
+
import termios
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import tty
|
|
10
|
+
|
|
11
|
+
import paramiko
|
|
12
|
+
import websockets
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_DEFAULT_IDENTITY_CANDIDATES = (
|
|
16
|
+
"~/.ssh/id_ed25519",
|
|
17
|
+
"~/.ssh/id_ecdsa",
|
|
18
|
+
"~/.ssh/id_rsa",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# 우리가 시도할 키 클래스. DSA 는 cryptography 44+ 와 호환 깨지는 케이스가 있어
|
|
22
|
+
# 의도적으로 제외.
|
|
23
|
+
_PKEY_CLASSES: tuple[type, ...] = (
|
|
24
|
+
paramiko.Ed25519Key,
|
|
25
|
+
paramiko.ECDSAKey,
|
|
26
|
+
paramiko.RSAKey,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _resolve_identity_files(explicit: str | None) -> list[str]:
|
|
31
|
+
"""Return absolute paths to existing private key files.
|
|
32
|
+
|
|
33
|
+
If `explicit` is given, only that path is returned (and validated).
|
|
34
|
+
Otherwise, common defaults are probed in order.
|
|
35
|
+
"""
|
|
36
|
+
if explicit:
|
|
37
|
+
path = os.path.expanduser(explicit)
|
|
38
|
+
if not os.path.exists(path):
|
|
39
|
+
raise FileNotFoundError(f"Identity file not found: {path}")
|
|
40
|
+
return [path]
|
|
41
|
+
|
|
42
|
+
found: list[str] = []
|
|
43
|
+
for candidate in _DEFAULT_IDENTITY_CANDIDATES:
|
|
44
|
+
path = os.path.expanduser(candidate)
|
|
45
|
+
if os.path.exists(path):
|
|
46
|
+
found.append(path)
|
|
47
|
+
return found
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _load_pkey(path: str) -> paramiko.PKey:
|
|
51
|
+
"""Load a private key from disk, trying known key classes in order.
|
|
52
|
+
|
|
53
|
+
paramiko's own ``key_filename`` auto-detection plus ``allow_agent`` can
|
|
54
|
+
pick up unrelated keys (e.g. DSA keys in the agent that newer
|
|
55
|
+
``cryptography`` versions refuse to sign with). We bypass that by
|
|
56
|
+
parsing the file ourselves and handing a single ``pkey`` object to
|
|
57
|
+
``client.connect()``.
|
|
58
|
+
"""
|
|
59
|
+
last_err: Exception | None = None
|
|
60
|
+
for klass in _PKEY_CLASSES:
|
|
61
|
+
try:
|
|
62
|
+
return klass.from_private_key_file(path)
|
|
63
|
+
except paramiko.PasswordRequiredException:
|
|
64
|
+
raise
|
|
65
|
+
except paramiko.SSHException as e:
|
|
66
|
+
last_err = e
|
|
67
|
+
continue
|
|
68
|
+
raise paramiko.SSHException(
|
|
69
|
+
f"Unable to parse private key at {path}: {last_err}"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def _run_tunnel(
|
|
74
|
+
ws_url: str,
|
|
75
|
+
api_key: str,
|
|
76
|
+
local_port: int,
|
|
77
|
+
ready: threading.Event,
|
|
78
|
+
replica_id: str | None = None,
|
|
79
|
+
):
|
|
80
|
+
"""Open local TCP port and relay to WebSocket SSH tunnel."""
|
|
81
|
+
|
|
82
|
+
async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
|
83
|
+
ws_conn = None
|
|
84
|
+
try:
|
|
85
|
+
headers = {"Authorization": f"Bearer {api_key}"}
|
|
86
|
+
if replica_id:
|
|
87
|
+
headers["serve_multiplexed_model_id"] = replica_id
|
|
88
|
+
ws_conn = await websockets.connect(
|
|
89
|
+
ws_url,
|
|
90
|
+
additional_headers=headers,
|
|
91
|
+
ping_interval=5,
|
|
92
|
+
ping_timeout=30,
|
|
93
|
+
max_size=None,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
async def tcp_to_ws():
|
|
97
|
+
try:
|
|
98
|
+
while True:
|
|
99
|
+
data = await reader.read(4096)
|
|
100
|
+
if not data:
|
|
101
|
+
break
|
|
102
|
+
await ws_conn.send(data)
|
|
103
|
+
except (ConnectionError, asyncio.CancelledError):
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
async def ws_to_tcp():
|
|
107
|
+
try:
|
|
108
|
+
async for message in ws_conn:
|
|
109
|
+
if isinstance(message, bytes):
|
|
110
|
+
writer.write(message)
|
|
111
|
+
elif isinstance(message, str):
|
|
112
|
+
writer.write(message.encode())
|
|
113
|
+
await writer.drain()
|
|
114
|
+
except (ConnectionError, asyncio.CancelledError):
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
await asyncio.gather(tcp_to_ws(), ws_to_tcp())
|
|
118
|
+
|
|
119
|
+
except websockets.exceptions.InvalidStatusCode as e:
|
|
120
|
+
if e.status_code == 302:
|
|
121
|
+
print(f" Authentication failed (redirected to login)")
|
|
122
|
+
elif e.status_code == 403:
|
|
123
|
+
print(f" Access denied. Check your API key.")
|
|
124
|
+
else:
|
|
125
|
+
print(f" WebSocket connection failed: HTTP {e.status_code}")
|
|
126
|
+
except websockets.exceptions.ConnectionClosed as e:
|
|
127
|
+
print(f" WebSocket closed: {e.code} {e.reason}")
|
|
128
|
+
except Exception as e:
|
|
129
|
+
print(f" Connection error: {e}")
|
|
130
|
+
finally:
|
|
131
|
+
writer.close()
|
|
132
|
+
if ws_conn:
|
|
133
|
+
await ws_conn.close()
|
|
134
|
+
|
|
135
|
+
server = await asyncio.start_server(handle_client, "127.0.0.1", local_port)
|
|
136
|
+
ready.set()
|
|
137
|
+
try:
|
|
138
|
+
await server.serve_forever()
|
|
139
|
+
except asyncio.CancelledError:
|
|
140
|
+
pass
|
|
141
|
+
finally:
|
|
142
|
+
server.close()
|
|
143
|
+
await server.wait_closed()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _start_tunnel_thread(
|
|
147
|
+
ws_url: str,
|
|
148
|
+
api_key: str,
|
|
149
|
+
local_port: int,
|
|
150
|
+
replica_id: str | None = None,
|
|
151
|
+
) -> threading.Event:
|
|
152
|
+
"""Start the WebSocket tunnel in a background daemon thread."""
|
|
153
|
+
ready = threading.Event()
|
|
154
|
+
|
|
155
|
+
def run():
|
|
156
|
+
asyncio.run(_run_tunnel(ws_url, api_key, local_port, ready, replica_id))
|
|
157
|
+
|
|
158
|
+
t = threading.Thread(target=run, daemon=True)
|
|
159
|
+
t.start()
|
|
160
|
+
return ready
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _interactive_shell(channel: paramiko.Channel):
|
|
164
|
+
"""Forward stdin/stdout to the SSH channel.
|
|
165
|
+
|
|
166
|
+
Local stdin is put into raw mode so that every keystroke (incl. Tab,
|
|
167
|
+
arrow keys, Ctrl-C, Ctrl-D, backspace) flows to the remote shell
|
|
168
|
+
instead of being processed by the local terminal. The remote terminal
|
|
169
|
+
is also kept in sync with local terminal size, including SIGWINCH-
|
|
170
|
+
driven resizes during the session.
|
|
171
|
+
"""
|
|
172
|
+
if not sys.stdin.isatty():
|
|
173
|
+
# Non-interactive stdin: just forward bytes both ways without
|
|
174
|
+
# touching termios.
|
|
175
|
+
channel.settimeout(0.0)
|
|
176
|
+
try:
|
|
177
|
+
while True:
|
|
178
|
+
r, _, _ = select.select([channel, sys.stdin], [], [])
|
|
179
|
+
if channel in r:
|
|
180
|
+
data = channel.recv(4096)
|
|
181
|
+
if not data:
|
|
182
|
+
return
|
|
183
|
+
sys.stdout.buffer.write(data)
|
|
184
|
+
sys.stdout.flush()
|
|
185
|
+
if sys.stdin in r:
|
|
186
|
+
chunk = sys.stdin.buffer.read1(4096)
|
|
187
|
+
if not chunk:
|
|
188
|
+
return
|
|
189
|
+
channel.send(chunk)
|
|
190
|
+
except Exception:
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
fd = sys.stdin.fileno()
|
|
194
|
+
old_attr = termios.tcgetattr(fd)
|
|
195
|
+
|
|
196
|
+
def _on_winch(_signum, _frame):
|
|
197
|
+
try:
|
|
198
|
+
size = os.get_terminal_size(fd)
|
|
199
|
+
channel.resize_pty(width=size.columns, height=size.lines)
|
|
200
|
+
except Exception:
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
prev_winch = signal.getsignal(signal.SIGWINCH)
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
# Sync initial terminal size with remote pty.
|
|
207
|
+
try:
|
|
208
|
+
size = os.get_terminal_size(fd)
|
|
209
|
+
channel.resize_pty(width=size.columns, height=size.lines)
|
|
210
|
+
except Exception:
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
signal.signal(signal.SIGWINCH, _on_winch)
|
|
214
|
+
tty.setraw(fd)
|
|
215
|
+
channel.settimeout(0.0)
|
|
216
|
+
|
|
217
|
+
while True:
|
|
218
|
+
r, _, _ = select.select([channel, sys.stdin], [], [])
|
|
219
|
+
if channel in r:
|
|
220
|
+
try:
|
|
221
|
+
data = channel.recv(4096)
|
|
222
|
+
except Exception:
|
|
223
|
+
break
|
|
224
|
+
if not data:
|
|
225
|
+
break
|
|
226
|
+
os.write(sys.stdout.fileno(), data)
|
|
227
|
+
if sys.stdin in r:
|
|
228
|
+
try:
|
|
229
|
+
chunk = os.read(fd, 4096)
|
|
230
|
+
except OSError:
|
|
231
|
+
break
|
|
232
|
+
if not chunk:
|
|
233
|
+
break
|
|
234
|
+
channel.send(chunk)
|
|
235
|
+
finally:
|
|
236
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_attr)
|
|
237
|
+
try:
|
|
238
|
+
signal.signal(signal.SIGWINCH, prev_winch)
|
|
239
|
+
except Exception:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def start_ssh(
|
|
244
|
+
ws_url: str,
|
|
245
|
+
api_key: str,
|
|
246
|
+
local_port: int,
|
|
247
|
+
user: str,
|
|
248
|
+
replica_id: str | None = None,
|
|
249
|
+
identity_file: str | None = None,
|
|
250
|
+
):
|
|
251
|
+
"""Start tunnel + SSH connection (public-key auth).
|
|
252
|
+
|
|
253
|
+
Auth flow:
|
|
254
|
+
1. If ``identity_file`` is given, that key is used.
|
|
255
|
+
2. Otherwise common paths (~/.ssh/id_ed25519, id_ecdsa, id_rsa) are tried.
|
|
256
|
+
3. ssh-agent is also consulted if available.
|
|
257
|
+
|
|
258
|
+
The WS layer is gated by our external-auth (API key); the SSH layer
|
|
259
|
+
inside the tunnel verifies the user's public key against the keys
|
|
260
|
+
injected into the container via AIRCLOUD_AUTHORIZED_KEYS.
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
print("AirCloud SSH")
|
|
264
|
+
print(f" Opening tunnel on 127.0.0.1:{local_port}...")
|
|
265
|
+
|
|
266
|
+
ready = _start_tunnel_thread(ws_url, api_key, local_port, replica_id)
|
|
267
|
+
if not ready.wait(timeout=10):
|
|
268
|
+
print(" Failed to start tunnel.")
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
identity_files = _resolve_identity_files(identity_file)
|
|
273
|
+
except FileNotFoundError as e:
|
|
274
|
+
print(f" {e}")
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
pkeys: list[tuple[str, paramiko.PKey]] = []
|
|
278
|
+
for path in identity_files:
|
|
279
|
+
try:
|
|
280
|
+
pkeys.append((path, _load_pkey(path)))
|
|
281
|
+
except paramiko.PasswordRequiredException:
|
|
282
|
+
print(
|
|
283
|
+
f" {path} is encrypted. Add it to ssh-agent first: "
|
|
284
|
+
f"ssh-add {path}"
|
|
285
|
+
)
|
|
286
|
+
except paramiko.SSHException as e:
|
|
287
|
+
print(f" Skipping {path}: {e}")
|
|
288
|
+
|
|
289
|
+
if not pkeys:
|
|
290
|
+
print(
|
|
291
|
+
" No usable SSH private key. Provide one with -i, generate one "
|
|
292
|
+
"with `ssh-keygen -t ed25519`, or add it to ssh-agent."
|
|
293
|
+
)
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
used = ", ".join(p for p, _ in pkeys)
|
|
297
|
+
print(f" Using identity: {used}")
|
|
298
|
+
print(f" Connecting as {user}...")
|
|
299
|
+
|
|
300
|
+
client = paramiko.SSHClient()
|
|
301
|
+
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
302
|
+
|
|
303
|
+
retries = 3
|
|
304
|
+
last_err: Exception | None = None
|
|
305
|
+
|
|
306
|
+
# 명시적으로 로드한 pkey 들을 순서대로 시도. paramiko 의 key_filename /
|
|
307
|
+
# allow_agent 자동 탐색은 의도치 않은 키 (예: agent 에 남아있는 DSA) 를
|
|
308
|
+
# 잡아 sign 시도하다 cryptography 호환성 문제로 실패할 수 있어 사용 안 함.
|
|
309
|
+
for path, pkey in pkeys:
|
|
310
|
+
last_err = None
|
|
311
|
+
for attempt in range(retries):
|
|
312
|
+
try:
|
|
313
|
+
client.connect(
|
|
314
|
+
"127.0.0.1",
|
|
315
|
+
port=local_port,
|
|
316
|
+
username=user,
|
|
317
|
+
pkey=pkey,
|
|
318
|
+
look_for_keys=False,
|
|
319
|
+
allow_agent=False,
|
|
320
|
+
timeout=10,
|
|
321
|
+
)
|
|
322
|
+
last_err = None
|
|
323
|
+
break
|
|
324
|
+
except paramiko.AuthenticationException as e:
|
|
325
|
+
last_err = e
|
|
326
|
+
break # auth 실패는 재시도해도 의미 없음
|
|
327
|
+
except Exception as e:
|
|
328
|
+
last_err = e
|
|
329
|
+
if attempt < retries - 1:
|
|
330
|
+
time.sleep(1)
|
|
331
|
+
|
|
332
|
+
if last_err is None:
|
|
333
|
+
break # 이 키로 인증 성공
|
|
334
|
+
|
|
335
|
+
if last_err is not None:
|
|
336
|
+
if isinstance(last_err, paramiko.AuthenticationException):
|
|
337
|
+
print(" SSH authentication failed.")
|
|
338
|
+
print(
|
|
339
|
+
" Make sure your public key is registered in the AirCloud "
|
|
340
|
+
"console (Project → SSH Keys) and applied to this endpoint, "
|
|
341
|
+
"and that the endpoint has been (re)started since the key "
|
|
342
|
+
"was added."
|
|
343
|
+
)
|
|
344
|
+
else:
|
|
345
|
+
print(f" SSH connection failed: {last_err}")
|
|
346
|
+
return
|
|
347
|
+
|
|
348
|
+
print(" Connected!\n")
|
|
349
|
+
|
|
350
|
+
try:
|
|
351
|
+
try:
|
|
352
|
+
size = os.get_terminal_size(sys.stdin.fileno())
|
|
353
|
+
channel = client.invoke_shell(
|
|
354
|
+
term=os.environ.get("TERM", "xterm-256color"),
|
|
355
|
+
width=size.columns,
|
|
356
|
+
height=size.lines,
|
|
357
|
+
)
|
|
358
|
+
except (OSError, ValueError):
|
|
359
|
+
channel = client.invoke_shell(term="xterm-256color")
|
|
360
|
+
_interactive_shell(channel)
|
|
361
|
+
except KeyboardInterrupt:
|
|
362
|
+
pass
|
|
363
|
+
finally:
|
|
364
|
+
client.close()
|
|
365
|
+
print("\n Connection closed.")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def start_tunnel(
|
|
369
|
+
ws_url: str,
|
|
370
|
+
api_key: str,
|
|
371
|
+
local_port: int,
|
|
372
|
+
replica_id: str | None = None,
|
|
373
|
+
):
|
|
374
|
+
"""Start tunnel only (no auto SSH). For manual SSH connection."""
|
|
375
|
+
try:
|
|
376
|
+
asyncio.run(
|
|
377
|
+
_run_tunnel(ws_url, api_key, local_port, threading.Event(), replica_id)
|
|
378
|
+
)
|
|
379
|
+
except KeyboardInterrupt:
|
|
380
|
+
print("\n Tunnel closed.")
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aircloud-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AirCloud CLI — endpoint management, SSH, and container exec
|
|
5
|
+
Author-email: AirCloud Team <support@aieev.com>
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://aieev.com
|
|
8
|
+
Project-URL: Repository, https://github.com/aieev/aircloud-cli
|
|
9
|
+
Keywords: aircloud,ssh,cli,endpoint,exec
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Software Development
|
|
22
|
+
Classifier: Topic :: System :: Shells
|
|
23
|
+
Classifier: Topic :: Utilities
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
Requires-Dist: websockets>=12.0
|
|
27
|
+
Requires-Dist: click>=8.0
|
|
28
|
+
Requires-Dist: httpx>=0.27.0
|
|
29
|
+
Requires-Dist: paramiko>=3.0
|
|
30
|
+
Requires-Dist: cryptography>=3.0
|
|
31
|
+
|
|
32
|
+
# aircloud-cli
|
|
33
|
+
|
|
34
|
+
CLI for the [AirCloud](https://aieev.com) platform — endpoint management,
|
|
35
|
+
SSH access, and container shell exec.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install aircloud-cli
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Requires Python 3.9+.
|
|
44
|
+
|
|
45
|
+
## Quick start
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# one-time setup
|
|
49
|
+
aircloud config set api-key <YOUR_API_KEY>
|
|
50
|
+
aircloud config set api-base-url <API_BASE_URL>
|
|
51
|
+
|
|
52
|
+
# inspect endpoints
|
|
53
|
+
aircloud endpoints list
|
|
54
|
+
aircloud endpoints get <endpoint_id>
|
|
55
|
+
|
|
56
|
+
# open a shell in a container
|
|
57
|
+
aircloud ssh <endpoint_id> # public-key auth, sshd-based
|
|
58
|
+
aircloud exec <endpoint_id> # docker exec over WS, no sshd needed
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Commands
|
|
62
|
+
|
|
63
|
+
### `aircloud config`
|
|
64
|
+
|
|
65
|
+
Manage CLI configuration in `~/.aircloud/config.json`.
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
aircloud config set api-key <key>
|
|
69
|
+
aircloud config set api-base-url <url>
|
|
70
|
+
aircloud config list
|
|
71
|
+
aircloud config get <key>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### `aircloud endpoints`
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
aircloud endpoints list # paged list
|
|
78
|
+
aircloud endpoints get <id> # detail
|
|
79
|
+
aircloud endpoints start <id>
|
|
80
|
+
aircloud endpoints stop <id>
|
|
81
|
+
aircloud endpoints scale <id> --replicas N
|
|
82
|
+
aircloud endpoints patch <id> --command "..." --port 8080 --env KEY=VALUE
|
|
83
|
+
aircloud endpoints replicas <id> # current replicas
|
|
84
|
+
aircloud endpoints logs <id> [--replica-id X] [--start-line N --end-line M]
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### `aircloud ssh`
|
|
88
|
+
|
|
89
|
+
SSH into a container via WebSocket tunnel using public-key authentication.
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
aircloud ssh <endpoint_id> # auto-discover ~/.ssh/id_*
|
|
93
|
+
aircloud ssh <endpoint_id> -i ~/.ssh/aircloud_key
|
|
94
|
+
aircloud ssh <endpoint_id> -r <replica_id> # pin to specific replica
|
|
95
|
+
aircloud ssh <endpoint_id> --tunnel-only # open tunnel only
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Auto-discovers identity files in this order: `~/.ssh/id_ed25519`,
|
|
99
|
+
`~/.ssh/id_ecdsa`, `~/.ssh/id_rsa`. Override with `-i / --identity-file`.
|
|
100
|
+
|
|
101
|
+
The container image must have `sshd` running and consume the
|
|
102
|
+
`AIRCLOUD_AUTHORIZED_KEYS` environment variable (AirCloud-provided template
|
|
103
|
+
images do this by default).
|
|
104
|
+
|
|
105
|
+
### `aircloud exec`
|
|
106
|
+
|
|
107
|
+
Open an interactive shell in the container via `docker exec` over a
|
|
108
|
+
WebSocket. Works on any image — no `sshd` required.
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
aircloud exec <endpoint_id>
|
|
112
|
+
aircloud exec <endpoint_id> -r <replica_id> # pin replica
|
|
113
|
+
aircloud exec <endpoint_id> -c "/bin/sh" # custom command
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### `aircloud whoami`
|
|
117
|
+
|
|
118
|
+
Print the identity associated with the current API key.
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
aircloud whoami
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Status
|
|
125
|
+
|
|
126
|
+
This is an early functional release. Interfaces may change before `1.0.0`.
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
Proprietary. © AIEEV / AirCloud.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
aircloud_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
aircloud_cli/client.py,sha256=m3RSgW50RROL9jdoOCRPdZpM4DZ4YBRfzpiS03uYJ2s,5016
|
|
3
|
+
aircloud_cli/config.py,sha256=qD5qcbFswbqBlpA6kMJ4XOZvfoT3OTEkBp8UbdKF8Jo,731
|
|
4
|
+
aircloud_cli/container_exec.py,sha256=vopx8xKVq6gCH-vuhLWpnJ2eSZ8VopH4JpLkcOQkGk4,4262
|
|
5
|
+
aircloud_cli/main.py,sha256=9cU8GqnBAk4DqMAPdRQSIWYGQ6ysTgvAlwi1hukgRSc,16782
|
|
6
|
+
aircloud_cli/ssh_tunnel.py,sha256=67w5F0Vw1SKzuiQ2XtJe_edHjWgMGk2oiT5MPKIva04,12089
|
|
7
|
+
aircloud_cli-0.1.0.dist-info/METADATA,sha256=3UEENw5mYvZosvCE4rAtSLSfAW7DkyQOVyHoTE_i7pw,3723
|
|
8
|
+
aircloud_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
aircloud_cli-0.1.0.dist-info/entry_points.txt,sha256=zZ7qUGBgOIrb1EbOxGmUFg8LN7Zggok8rHY_95pamok,52
|
|
10
|
+
aircloud_cli-0.1.0.dist-info/top_level.txt,sha256=xH15vUuUKOaY23w1psZXdidHbtLiZa9eshvPjMLBJgQ,13
|
|
11
|
+
aircloud_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aircloud_cli
|