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.
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ aircloud = aircloud_cli.main:main
@@ -0,0 +1 @@
1
+ aircloud_cli