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.
- aircloud_cli-0.1.0/PKG-INFO +130 -0
- aircloud_cli-0.1.0/README.md +99 -0
- aircloud_cli-0.1.0/aircloud_cli/__init__.py +0 -0
- aircloud_cli-0.1.0/aircloud_cli/client.py +166 -0
- aircloud_cli-0.1.0/aircloud_cli/config.py +35 -0
- aircloud_cli-0.1.0/aircloud_cli/container_exec.py +149 -0
- aircloud_cli-0.1.0/aircloud_cli/main.py +508 -0
- aircloud_cli-0.1.0/aircloud_cli/ssh_tunnel.py +380 -0
- aircloud_cli-0.1.0/aircloud_cli.egg-info/PKG-INFO +130 -0
- aircloud_cli-0.1.0/aircloud_cli.egg-info/SOURCES.txt +14 -0
- aircloud_cli-0.1.0/aircloud_cli.egg-info/dependency_links.txt +1 -0
- aircloud_cli-0.1.0/aircloud_cli.egg-info/entry_points.txt +2 -0
- aircloud_cli-0.1.0/aircloud_cli.egg-info/requires.txt +5 -0
- aircloud_cli-0.1.0/aircloud_cli.egg-info/top_level.txt +1 -0
- aircloud_cli-0.1.0/pyproject.toml +49 -0
- aircloud_cli-0.1.0/setup.cfg +4 -0
|
@@ -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
|