aircloud-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,99 @@
1
+ # aircloud-cli
2
+
3
+ CLI for the [AirCloud](https://aieev.com) platform — endpoint management,
4
+ SSH access, and container shell exec.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pip install aircloud-cli
10
+ ```
11
+
12
+ Requires Python 3.9+.
13
+
14
+ ## Quick start
15
+
16
+ ```bash
17
+ # one-time setup
18
+ aircloud config set api-key <YOUR_API_KEY>
19
+ aircloud config set api-base-url <API_BASE_URL>
20
+
21
+ # inspect endpoints
22
+ aircloud endpoints list
23
+ aircloud endpoints get <endpoint_id>
24
+
25
+ # open a shell in a container
26
+ aircloud ssh <endpoint_id> # public-key auth, sshd-based
27
+ aircloud exec <endpoint_id> # docker exec over WS, no sshd needed
28
+ ```
29
+
30
+ ## Commands
31
+
32
+ ### `aircloud config`
33
+
34
+ Manage CLI configuration in `~/.aircloud/config.json`.
35
+
36
+ ```bash
37
+ aircloud config set api-key <key>
38
+ aircloud config set api-base-url <url>
39
+ aircloud config list
40
+ aircloud config get <key>
41
+ ```
42
+
43
+ ### `aircloud endpoints`
44
+
45
+ ```bash
46
+ aircloud endpoints list # paged list
47
+ aircloud endpoints get <id> # detail
48
+ aircloud endpoints start <id>
49
+ aircloud endpoints stop <id>
50
+ aircloud endpoints scale <id> --replicas N
51
+ aircloud endpoints patch <id> --command "..." --port 8080 --env KEY=VALUE
52
+ aircloud endpoints replicas <id> # current replicas
53
+ aircloud endpoints logs <id> [--replica-id X] [--start-line N --end-line M]
54
+ ```
55
+
56
+ ### `aircloud ssh`
57
+
58
+ SSH into a container via WebSocket tunnel using public-key authentication.
59
+
60
+ ```bash
61
+ aircloud ssh <endpoint_id> # auto-discover ~/.ssh/id_*
62
+ aircloud ssh <endpoint_id> -i ~/.ssh/aircloud_key
63
+ aircloud ssh <endpoint_id> -r <replica_id> # pin to specific replica
64
+ aircloud ssh <endpoint_id> --tunnel-only # open tunnel only
65
+ ```
66
+
67
+ Auto-discovers identity files in this order: `~/.ssh/id_ed25519`,
68
+ `~/.ssh/id_ecdsa`, `~/.ssh/id_rsa`. Override with `-i / --identity-file`.
69
+
70
+ The container image must have `sshd` running and consume the
71
+ `AIRCLOUD_AUTHORIZED_KEYS` environment variable (AirCloud-provided template
72
+ images do this by default).
73
+
74
+ ### `aircloud exec`
75
+
76
+ Open an interactive shell in the container via `docker exec` over a
77
+ WebSocket. Works on any image — no `sshd` required.
78
+
79
+ ```bash
80
+ aircloud exec <endpoint_id>
81
+ aircloud exec <endpoint_id> -r <replica_id> # pin replica
82
+ aircloud exec <endpoint_id> -c "/bin/sh" # custom command
83
+ ```
84
+
85
+ ### `aircloud whoami`
86
+
87
+ Print the identity associated with the current API key.
88
+
89
+ ```bash
90
+ aircloud whoami
91
+ ```
92
+
93
+ ## Status
94
+
95
+ This is an early functional release. Interfaces may change before `1.0.0`.
96
+
97
+ ## License
98
+
99
+ Proprietary. © AIEEV / AirCloud.
File without changes
@@ -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
+ )
@@ -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