godfather-cli 2.1.0__tar.gz → 2.2.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.
- {godfather_cli-2.1.0/godfather_cli.egg-info → godfather_cli-2.2.0}/PKG-INFO +22 -14
- {godfather_cli-2.1.0 → godfather_cli-2.2.0}/README.md +21 -13
- {godfather_cli-2.1.0 → godfather_cli-2.2.0}/godfather_cli/__init__.py +1 -1
- {godfather_cli-2.1.0 → godfather_cli-2.2.0}/godfather_cli/auth.py +105 -12
- {godfather_cli-2.1.0 → godfather_cli-2.2.0}/godfather_cli/cli.py +84 -33
- godfather_cli-2.2.0/godfather_cli/login_server.py +72 -0
- {godfather_cli-2.1.0 → godfather_cli-2.2.0}/godfather_cli/picker.py +18 -13
- {godfather_cli-2.1.0 → godfather_cli-2.2.0}/godfather_cli/pod_manager.py +111 -14
- {godfather_cli-2.1.0 → godfather_cli-2.2.0}/godfather_cli/ssh_connector.py +18 -10
- {godfather_cli-2.1.0 → godfather_cli-2.2.0}/godfather_cli/update_checker.py +18 -20
- {godfather_cli-2.1.0 → godfather_cli-2.2.0/godfather_cli.egg-info}/PKG-INFO +22 -14
- {godfather_cli-2.1.0 → godfather_cli-2.2.0}/godfather_cli.egg-info/SOURCES.txt +4 -1
- {godfather_cli-2.1.0 → godfather_cli-2.2.0}/pyproject.toml +14 -2
- godfather_cli-2.2.0/tests/test_cli.py +262 -0
- godfather_cli-2.2.0/tests/test_login.py +151 -0
- godfather_cli-2.2.0/tests/test_sessions.py +109 -0
- godfather_cli-2.1.0/tests/test_cli.py +0 -102
- {godfather_cli-2.1.0 → godfather_cli-2.2.0}/LICENSE +0 -0
- {godfather_cli-2.1.0 → godfather_cli-2.2.0}/MANIFEST.in +0 -0
- {godfather_cli-2.1.0 → godfather_cli-2.2.0}/godfather_cli/ui.py +1 -1
- {godfather_cli-2.1.0 → godfather_cli-2.2.0}/godfather_cli.egg-info/dependency_links.txt +0 -0
- {godfather_cli-2.1.0 → godfather_cli-2.2.0}/godfather_cli.egg-info/entry_points.txt +0 -0
- {godfather_cli-2.1.0 → godfather_cli-2.2.0}/godfather_cli.egg-info/requires.txt +0 -0
- {godfather_cli-2.1.0 → godfather_cli-2.2.0}/godfather_cli.egg-info/top_level.txt +0 -0
- {godfather_cli-2.1.0 → godfather_cli-2.2.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: godfather-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: Free compute for ASU students: connect to AI Society at ASU GPU pods
|
|
5
5
|
Author-email: AI Society ASU <theaisocietyasu@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -62,10 +62,13 @@ pip install -e .
|
|
|
62
62
|
|
|
63
63
|
1. Run `godfather` with no arguments. On first run you won't be logged in
|
|
64
64
|
yet, so it'll walk you into the login flow.
|
|
65
|
-
2. It opens the
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
2. It opens the sign-in page in your browser and prints the link. Sign in
|
|
66
|
+
with Discord. The browser sends the login back to the CLI, which checks it
|
|
67
|
+
and stores the token in `~/.godfather/config.json`.
|
|
68
|
+
3. If the browser cannot reach the CLI (no browser on this computer, an
|
|
69
|
+
older server, or no sign-in within 5 minutes), the CLI asks you to paste
|
|
70
|
+
the token that the sign-in page shows. `godfather auth --paste` goes
|
|
71
|
+
straight to that, for a browser on another computer.
|
|
69
72
|
4. From the menu (or `godfather connect`), pick a pod. The CLI creates an SSH
|
|
70
73
|
key on your machine the first time, asks the server for a 12-hour
|
|
71
74
|
certificate for that pod, and opens the connection.
|
|
@@ -81,12 +84,14 @@ Windows 10+ ship it.
|
|
|
81
84
|
### Interactive menu
|
|
82
85
|
|
|
83
86
|
Running `godfather` with no arguments opens a menu. Use the arrow keys to
|
|
84
|
-
choose Connect to a pod, List pods, Status, Log out or Exit, and
|
|
85
|
-
Ctrl-C quits.
|
|
87
|
+
choose Connect to a pod, List pods, Sessions, Status, Log out or Exit, and
|
|
88
|
+
press Enter. Ctrl-C quits.
|
|
86
89
|
|
|
87
90
|
Connect to a pod shows a list of your pods with their status, access and
|
|
88
91
|
pod ID. A pod that is not running shows with the reason and cannot be
|
|
89
92
|
chosen. If only one pod is running, the CLI asks for a yes instead.
|
|
93
|
+
If none of your pods is running, the CLI shows the next session on your
|
|
94
|
+
pods. A pod starts 10 minutes before its session.
|
|
90
95
|
|
|
91
96
|
When input is not a terminal (for example in a script), the menu and the pod
|
|
92
97
|
list use numbered prompts.
|
|
@@ -97,8 +102,11 @@ list use numbered prompts.
|
|
|
97
102
|
godfather list # List pods you can connect to
|
|
98
103
|
godfather connect # Connect to a pod, picking from a list
|
|
99
104
|
godfather connect <pod-id> # Connect to a specific pod
|
|
105
|
+
godfather connect --admin # Ask for a root shell (officers, when pod admin is on)
|
|
106
|
+
godfather sessions # Upcoming sessions on your pods, in local time
|
|
100
107
|
godfather status # Show server, org, login, token expiry and version
|
|
101
|
-
godfather auth # Log in
|
|
108
|
+
godfather auth # Log in with Discord in your browser
|
|
109
|
+
godfather auth --paste # Log in by pasting the token
|
|
102
110
|
godfather logout # Clear the stored session
|
|
103
111
|
godfather update # Update the CLI to the latest version
|
|
104
112
|
|
|
@@ -141,23 +149,23 @@ another server or org; the ones you last logged in with are remembered.
|
|
|
141
149
|
## Development
|
|
142
150
|
|
|
143
151
|
The code is in `godfather_cli/`: `cli.py` (commands and menu), `picker.py` (arrow-key menu and pod picker), `auth.py`
|
|
144
|
-
(platform sign-in), `pod_manager.py` (API calls), `ssh_connector.py` (keys,
|
|
152
|
+
(platform sign-in), `login_server.py` (the sign-in callback), `pod_manager.py` (API calls), `ssh_connector.py` (keys,
|
|
145
153
|
certificate, running ssh), `update_checker.py` (PyPI version check), `ui.py`
|
|
146
154
|
(shared console styling). Tests are in `tests/`:
|
|
147
155
|
|
|
148
156
|
```bash
|
|
149
|
-
|
|
150
|
-
pytest -q tests
|
|
157
|
+
uv sync --group dev
|
|
158
|
+
uv run pytest -q tests
|
|
151
159
|
```
|
|
152
160
|
|
|
161
|
+
`make check-cli` at the repo root runs ruff, the tests and a build, as CI does.
|
|
162
|
+
|
|
153
163
|
Releases go to PyPI when a version bump is merged to main; see the
|
|
154
164
|
repo README.
|
|
155
165
|
|
|
156
166
|
## Contributing
|
|
157
167
|
|
|
158
|
-
|
|
159
|
-
2. Create a feature branch (`git checkout -b feature/your-feature`).
|
|
160
|
-
3. Commit your changes and open a Pull Request.
|
|
168
|
+
See [CONTRIBUTING.md](../CONTRIBUTING.md).
|
|
161
169
|
|
|
162
170
|
## License
|
|
163
171
|
|
|
@@ -33,10 +33,13 @@ pip install -e .
|
|
|
33
33
|
|
|
34
34
|
1. Run `godfather` with no arguments. On first run you won't be logged in
|
|
35
35
|
yet, so it'll walk you into the login flow.
|
|
36
|
-
2. It opens the
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
2. It opens the sign-in page in your browser and prints the link. Sign in
|
|
37
|
+
with Discord. The browser sends the login back to the CLI, which checks it
|
|
38
|
+
and stores the token in `~/.godfather/config.json`.
|
|
39
|
+
3. If the browser cannot reach the CLI (no browser on this computer, an
|
|
40
|
+
older server, or no sign-in within 5 minutes), the CLI asks you to paste
|
|
41
|
+
the token that the sign-in page shows. `godfather auth --paste` goes
|
|
42
|
+
straight to that, for a browser on another computer.
|
|
40
43
|
4. From the menu (or `godfather connect`), pick a pod. The CLI creates an SSH
|
|
41
44
|
key on your machine the first time, asks the server for a 12-hour
|
|
42
45
|
certificate for that pod, and opens the connection.
|
|
@@ -52,12 +55,14 @@ Windows 10+ ship it.
|
|
|
52
55
|
### Interactive menu
|
|
53
56
|
|
|
54
57
|
Running `godfather` with no arguments opens a menu. Use the arrow keys to
|
|
55
|
-
choose Connect to a pod, List pods, Status, Log out or Exit, and
|
|
56
|
-
Ctrl-C quits.
|
|
58
|
+
choose Connect to a pod, List pods, Sessions, Status, Log out or Exit, and
|
|
59
|
+
press Enter. Ctrl-C quits.
|
|
57
60
|
|
|
58
61
|
Connect to a pod shows a list of your pods with their status, access and
|
|
59
62
|
pod ID. A pod that is not running shows with the reason and cannot be
|
|
60
63
|
chosen. If only one pod is running, the CLI asks for a yes instead.
|
|
64
|
+
If none of your pods is running, the CLI shows the next session on your
|
|
65
|
+
pods. A pod starts 10 minutes before its session.
|
|
61
66
|
|
|
62
67
|
When input is not a terminal (for example in a script), the menu and the pod
|
|
63
68
|
list use numbered prompts.
|
|
@@ -68,8 +73,11 @@ list use numbered prompts.
|
|
|
68
73
|
godfather list # List pods you can connect to
|
|
69
74
|
godfather connect # Connect to a pod, picking from a list
|
|
70
75
|
godfather connect <pod-id> # Connect to a specific pod
|
|
76
|
+
godfather connect --admin # Ask for a root shell (officers, when pod admin is on)
|
|
77
|
+
godfather sessions # Upcoming sessions on your pods, in local time
|
|
71
78
|
godfather status # Show server, org, login, token expiry and version
|
|
72
|
-
godfather auth # Log in
|
|
79
|
+
godfather auth # Log in with Discord in your browser
|
|
80
|
+
godfather auth --paste # Log in by pasting the token
|
|
73
81
|
godfather logout # Clear the stored session
|
|
74
82
|
godfather update # Update the CLI to the latest version
|
|
75
83
|
|
|
@@ -112,23 +120,23 @@ another server or org; the ones you last logged in with are remembered.
|
|
|
112
120
|
## Development
|
|
113
121
|
|
|
114
122
|
The code is in `godfather_cli/`: `cli.py` (commands and menu), `picker.py` (arrow-key menu and pod picker), `auth.py`
|
|
115
|
-
(platform sign-in), `pod_manager.py` (API calls), `ssh_connector.py` (keys,
|
|
123
|
+
(platform sign-in), `login_server.py` (the sign-in callback), `pod_manager.py` (API calls), `ssh_connector.py` (keys,
|
|
116
124
|
certificate, running ssh), `update_checker.py` (PyPI version check), `ui.py`
|
|
117
125
|
(shared console styling). Tests are in `tests/`:
|
|
118
126
|
|
|
119
127
|
```bash
|
|
120
|
-
|
|
121
|
-
pytest -q tests
|
|
128
|
+
uv sync --group dev
|
|
129
|
+
uv run pytest -q tests
|
|
122
130
|
```
|
|
123
131
|
|
|
132
|
+
`make check-cli` at the repo root runs ruff, the tests and a build, as CI does.
|
|
133
|
+
|
|
124
134
|
Releases go to PyPI when a version bump is merged to main; see the
|
|
125
135
|
repo README.
|
|
126
136
|
|
|
127
137
|
## Contributing
|
|
128
138
|
|
|
129
|
-
|
|
130
|
-
2. Create a feature branch (`git checkout -b feature/your-feature`).
|
|
131
|
-
3. Commit your changes and open a Pull Request.
|
|
139
|
+
See [CONTRIBUTING.md](../CONTRIBUTING.md).
|
|
132
140
|
|
|
133
141
|
## License
|
|
134
142
|
|
|
@@ -1,21 +1,30 @@
|
|
|
1
1
|
"""CLI authentication: token storage, login flow, and token verification."""
|
|
2
2
|
|
|
3
|
+
import base64
|
|
3
4
|
import datetime
|
|
5
|
+
import hashlib
|
|
4
6
|
import json
|
|
5
7
|
import os
|
|
6
|
-
import
|
|
8
|
+
import secrets
|
|
7
9
|
import webbrowser
|
|
8
10
|
from pathlib import Path
|
|
9
11
|
from typing import Dict, Optional
|
|
12
|
+
from urllib.parse import urlencode
|
|
13
|
+
|
|
14
|
+
import requests
|
|
10
15
|
from rich.panel import Panel
|
|
11
16
|
from rich.prompt import Prompt
|
|
12
17
|
|
|
13
|
-
from .
|
|
18
|
+
from .login_server import LoopbackReceiver
|
|
19
|
+
from .ui import BORDER, BOX, console, error, spinner, success, warning
|
|
14
20
|
|
|
15
21
|
TOKEN_PREFIX = 'plat_'
|
|
16
22
|
# The platform issues CLI tokens for 90 days.
|
|
17
23
|
TOKEN_DAYS = 90
|
|
18
24
|
|
|
25
|
+
# How long the CLI waits for the browser sign-in.
|
|
26
|
+
LOGIN_WAIT_SECONDS = 300
|
|
27
|
+
|
|
19
28
|
# Token states that check_token returns.
|
|
20
29
|
TOKEN_OK = 'ok'
|
|
21
30
|
TOKEN_MISSING = 'missing'
|
|
@@ -24,6 +33,17 @@ TOKEN_UNREACHABLE = 'unreachable'
|
|
|
24
33
|
TOKEN_REFUSED = 'refused'
|
|
25
34
|
|
|
26
35
|
|
|
36
|
+
def make_verifier() -> str:
|
|
37
|
+
"""A PKCE code verifier (RFC 7636): 64 URL-safe characters."""
|
|
38
|
+
return secrets.token_urlsafe(48)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def challenge_for(verifier: str) -> str:
|
|
42
|
+
"""The S256 code challenge of a verifier: base64url SHA-256 without padding."""
|
|
43
|
+
digest = hashlib.sha256(verifier.encode('ascii')).digest()
|
|
44
|
+
return base64.urlsafe_b64encode(digest).rstrip(b'=').decode('ascii')
|
|
45
|
+
|
|
46
|
+
|
|
27
47
|
class CLIAuthenticator:
|
|
28
48
|
"""Handle CLI authentication."""
|
|
29
49
|
|
|
@@ -61,8 +81,84 @@ class CLIAuthenticator:
|
|
|
61
81
|
"""URL of a platform compute route for the configured org."""
|
|
62
82
|
return f"{self.api_base}/api/compute/{self.org}{path}"
|
|
63
83
|
|
|
64
|
-
def authenticate(self) -> bool:
|
|
65
|
-
"""
|
|
84
|
+
def authenticate(self, paste: bool = False) -> bool:
|
|
85
|
+
"""Sign in with Discord. The browser hands the token back; the paste flow is the fallback."""
|
|
86
|
+
if not paste:
|
|
87
|
+
if self.supports_loopback():
|
|
88
|
+
token = self._browser_login()
|
|
89
|
+
if token:
|
|
90
|
+
return self._verify_and_save(token)
|
|
91
|
+
else:
|
|
92
|
+
console.print("[dim]This server does not support browser sign-in yet. Paste the token instead.[/dim]")
|
|
93
|
+
return self._paste_login()
|
|
94
|
+
|
|
95
|
+
def supports_loopback(self) -> bool:
|
|
96
|
+
"""True when the server has the cli/token route. It answers 400 to an empty body."""
|
|
97
|
+
try:
|
|
98
|
+
response = requests.post(self.compute_url('/cli/token'), json={}, timeout=10)
|
|
99
|
+
except requests.RequestException:
|
|
100
|
+
return False
|
|
101
|
+
return response.status_code == 400
|
|
102
|
+
|
|
103
|
+
def _browser_login(self) -> Optional[str]:
|
|
104
|
+
"""The token from the localhost callback with PKCE, or None after printing why not."""
|
|
105
|
+
state = secrets.token_urlsafe(16)
|
|
106
|
+
verifier = make_verifier()
|
|
107
|
+
try:
|
|
108
|
+
receiver = LoopbackReceiver(state)
|
|
109
|
+
except OSError:
|
|
110
|
+
console.print("[dim]Could not listen on 127.0.0.1. Paste the token instead.[/dim]")
|
|
111
|
+
return None
|
|
112
|
+
try:
|
|
113
|
+
query = urlencode(
|
|
114
|
+
{
|
|
115
|
+
'port': receiver.port,
|
|
116
|
+
'state': state,
|
|
117
|
+
'code_challenge': challenge_for(verifier),
|
|
118
|
+
'code_challenge_method': 'S256',
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
login_url = f"{self.compute_url('/cli/login')}?{query}"
|
|
122
|
+
try:
|
|
123
|
+
opened = webbrowser.open(login_url)
|
|
124
|
+
except webbrowser.Error:
|
|
125
|
+
opened = False
|
|
126
|
+
if not opened:
|
|
127
|
+
console.print("[dim]No browser opened on this computer. Paste the token instead.[/dim]")
|
|
128
|
+
return None
|
|
129
|
+
console.print(f"Sign in with Discord in your browser. If it did not open, go to:\n{login_url}")
|
|
130
|
+
try:
|
|
131
|
+
with spinner("Waiting for the browser sign-in..."):
|
|
132
|
+
code = receiver.wait(LOGIN_WAIT_SECONDS)
|
|
133
|
+
except KeyboardInterrupt:
|
|
134
|
+
console.print("[dim]Cancelled[/dim]")
|
|
135
|
+
return None
|
|
136
|
+
finally:
|
|
137
|
+
receiver.close()
|
|
138
|
+
if not code:
|
|
139
|
+
console.print("[dim]The browser sign-in did not finish in time. Paste the token instead.[/dim]")
|
|
140
|
+
return None
|
|
141
|
+
return self._redeem(code, verifier)
|
|
142
|
+
|
|
143
|
+
def _redeem(self, code: str, verifier: str) -> Optional[str]:
|
|
144
|
+
"""Exchange the one-time code and the PKCE verifier for a token."""
|
|
145
|
+
try:
|
|
146
|
+
with spinner("Getting your token..."):
|
|
147
|
+
response = requests.post(
|
|
148
|
+
self.compute_url('/cli/token'),
|
|
149
|
+
json={'code': code, 'code_verifier': verifier},
|
|
150
|
+
timeout=10,
|
|
151
|
+
)
|
|
152
|
+
token = str(response.json().get('token') or '') if response.status_code == 200 else ''
|
|
153
|
+
except (requests.RequestException, ValueError, AttributeError):
|
|
154
|
+
token = ''
|
|
155
|
+
if not token.startswith(TOKEN_PREFIX):
|
|
156
|
+
console.print("[dim]The server did not return a token. Paste the token instead.[/dim]")
|
|
157
|
+
return None
|
|
158
|
+
return token
|
|
159
|
+
|
|
160
|
+
def _paste_login(self) -> bool:
|
|
161
|
+
"""Open the sign-in page and take the token the member pastes."""
|
|
66
162
|
login_url = self.compute_url('/cli/login')
|
|
67
163
|
panel = Panel(
|
|
68
164
|
f"[bold]1.[/bold] Open [bold]{login_url}[/bold] and sign in with Discord\n"
|
|
@@ -91,13 +187,14 @@ class CLIAuthenticator:
|
|
|
91
187
|
error("That doesn't look like a valid Godfather token")
|
|
92
188
|
console.print(f"[dim]Tokens start with {TOKEN_PREFIX}. Get a fresh one from {login_url}[/dim]")
|
|
93
189
|
return False
|
|
190
|
+
return self._verify_and_save(token)
|
|
94
191
|
|
|
192
|
+
def _verify_and_save(self, token: str) -> bool:
|
|
193
|
+
"""Check the token with GET /me/pods, then save it with the server and org."""
|
|
95
194
|
try:
|
|
96
195
|
with spinner("Verifying token..."):
|
|
97
196
|
response = requests.get(
|
|
98
|
-
self.compute_url('/me/pods'),
|
|
99
|
-
headers={'Authorization': f'Bearer {token}'},
|
|
100
|
-
timeout=10
|
|
197
|
+
self.compute_url('/me/pods'), headers={'Authorization': f'Bearer {token}'}, timeout=10
|
|
101
198
|
)
|
|
102
199
|
except requests.ConnectionError:
|
|
103
200
|
error(f"Couldn't reach {self.api_base}")
|
|
@@ -153,11 +250,7 @@ class CLIAuthenticator:
|
|
|
153
250
|
return TOKEN_MISSING
|
|
154
251
|
|
|
155
252
|
try:
|
|
156
|
-
response = requests.get(
|
|
157
|
-
self.compute_url('/me/pods'),
|
|
158
|
-
headers=self.auth_headers(),
|
|
159
|
-
timeout=5
|
|
160
|
-
)
|
|
253
|
+
response = requests.get(self.compute_url('/me/pods'), headers=self.auth_headers(), timeout=5)
|
|
161
254
|
except requests.RequestException:
|
|
162
255
|
return TOKEN_UNREACHABLE
|
|
163
256
|
if response.status_code == 200:
|
|
@@ -8,19 +8,24 @@ from pathlib import Path
|
|
|
8
8
|
from typing import Dict, List, Optional
|
|
9
9
|
|
|
10
10
|
from rich.panel import Panel
|
|
11
|
-
from rich.table import Table
|
|
12
11
|
from rich.prompt import Prompt
|
|
12
|
+
from rich.table import Table
|
|
13
13
|
|
|
14
|
-
from . import picker
|
|
14
|
+
from . import __version__, picker
|
|
15
15
|
from .auth import (
|
|
16
|
-
|
|
16
|
+
TOKEN_DAYS,
|
|
17
|
+
TOKEN_EXPIRED,
|
|
18
|
+
TOKEN_MISSING,
|
|
19
|
+
TOKEN_OK,
|
|
20
|
+
TOKEN_REFUSED,
|
|
21
|
+
TOKEN_UNREACHABLE,
|
|
22
|
+
CLIAuthenticator,
|
|
17
23
|
)
|
|
18
24
|
from .picker import ask_menu, ask_pod, ask_yes, connectable, is_interactive
|
|
19
|
-
from .pod_manager import
|
|
25
|
+
from .pod_manager import EXPIRED, ApiError, PodManager, report
|
|
20
26
|
from .ssh_connector import SSHConnector
|
|
21
|
-
from .
|
|
22
|
-
from .
|
|
23
|
-
from . import __version__
|
|
27
|
+
from .ui import BORDER, BOX, PURPLE, console, error, info, spinner, warning
|
|
28
|
+
from .update_checker import check_for_updates, perform_update, show_update_warning
|
|
24
29
|
|
|
25
30
|
# The AI Society platform API and org. Override with --api-url/--org or GODFATHER_API_URL/GODFATHER_ORG.
|
|
26
31
|
DEFAULT_API_URL = 'https://zs6k5wi0boaaax-8000.proxy.runpod.net'
|
|
@@ -60,7 +65,8 @@ class GodfatherCLI:
|
|
|
60
65
|
|
|
61
66
|
def print_banner(self):
|
|
62
67
|
banner = Panel.fit(
|
|
63
|
-
f"[bold {PURPLE}]Godfather CLI[/bold {PURPLE}]\n
|
|
68
|
+
f"[bold {PURPLE}]Godfather CLI[/bold {PURPLE}]\n"
|
|
69
|
+
f"[dim]Free compute from AI Society at ASU v{__version__}[/dim]",
|
|
64
70
|
border_style=BORDER,
|
|
65
71
|
box=BOX,
|
|
66
72
|
)
|
|
@@ -126,6 +132,36 @@ class GodfatherCLI:
|
|
|
126
132
|
pods = self.load_pods()
|
|
127
133
|
if pods is not None:
|
|
128
134
|
self.pod_manager.show_pods(pods)
|
|
135
|
+
if not pods:
|
|
136
|
+
self.print_next_session()
|
|
137
|
+
|
|
138
|
+
def list_sessions(self):
|
|
139
|
+
"""Print the upcoming sessions on the member's pods."""
|
|
140
|
+
if not self.ensure_authenticated():
|
|
141
|
+
return
|
|
142
|
+
try:
|
|
143
|
+
with spinner("Fetching sessions..."):
|
|
144
|
+
sessions = self.pod_manager.fetch_sessions()
|
|
145
|
+
except ApiError as e:
|
|
146
|
+
report(e)
|
|
147
|
+
return
|
|
148
|
+
if sessions is None:
|
|
149
|
+
info("This server does not list sessions yet.")
|
|
150
|
+
elif not sessions:
|
|
151
|
+
info("No upcoming sessions on your pods.")
|
|
152
|
+
else:
|
|
153
|
+
console.print(self.pod_manager.sessions_table(sessions))
|
|
154
|
+
console.print("[dim]A pod starts 10 minutes before its session.[/dim]")
|
|
155
|
+
|
|
156
|
+
def print_next_session(self) -> None:
|
|
157
|
+
"""Print the soonest session on the member's pods. Prints nothing when there is none or on an error."""
|
|
158
|
+
try:
|
|
159
|
+
sessions = self.pod_manager.fetch_sessions()
|
|
160
|
+
except ApiError:
|
|
161
|
+
return
|
|
162
|
+
line = self.pod_manager.next_session_line(sessions or [])
|
|
163
|
+
if line:
|
|
164
|
+
console.print(line)
|
|
129
165
|
|
|
130
166
|
def choose_pod(self) -> Optional[str]:
|
|
131
167
|
"""Fetch pods and let the user pick one. None when there is none or the user cancels."""
|
|
@@ -134,12 +170,14 @@ class GodfatherCLI:
|
|
|
134
170
|
return None
|
|
135
171
|
if not pods:
|
|
136
172
|
self.pod_manager.show_no_pods()
|
|
173
|
+
self.print_next_session()
|
|
137
174
|
return None
|
|
138
175
|
if not is_interactive():
|
|
139
176
|
return self.pod_manager.prompt_pod_number(pods)
|
|
140
177
|
if not connectable(pods):
|
|
141
178
|
error("None of your pods are running.")
|
|
142
179
|
console.print("[dim]Ask an officer to start one, then run 'godfather connect' again.[/dim]")
|
|
180
|
+
self.print_next_session()
|
|
143
181
|
return None
|
|
144
182
|
try:
|
|
145
183
|
return ask_pod(pods)
|
|
@@ -147,7 +185,7 @@ class GodfatherCLI:
|
|
|
147
185
|
console.print("[dim]Cancelled[/dim]")
|
|
148
186
|
return None
|
|
149
187
|
|
|
150
|
-
def connect_to_pod(self, pod_id: str = None):
|
|
188
|
+
def connect_to_pod(self, pod_id: str = None, admin: bool = False):
|
|
151
189
|
if not self.ensure_authenticated():
|
|
152
190
|
return
|
|
153
191
|
|
|
@@ -164,7 +202,7 @@ class GodfatherCLI:
|
|
|
164
202
|
for attempt in range(2):
|
|
165
203
|
try:
|
|
166
204
|
with spinner(f"Getting a certificate for pod {pod_id[:12]}..."):
|
|
167
|
-
ssh_info = self.pod_manager.fetch_connection_info(pod_id, public_key)
|
|
205
|
+
ssh_info = self.pod_manager.fetch_connection_info(pod_id, public_key, admin=admin)
|
|
168
206
|
break
|
|
169
207
|
except ApiError as e:
|
|
170
208
|
if e.kind == EXPIRED and attempt == 0:
|
|
@@ -178,6 +216,8 @@ class GodfatherCLI:
|
|
|
178
216
|
|
|
179
217
|
if not self.ssh_connector.save_certificate(ssh_info.get('certificate', '')):
|
|
180
218
|
return
|
|
219
|
+
if admin and not ssh_info.get('is_admin'):
|
|
220
|
+
warning("The server gave you a member shell, not root.")
|
|
181
221
|
|
|
182
222
|
self.ssh_connector.connect(ssh_info)
|
|
183
223
|
|
|
@@ -231,8 +271,8 @@ class GodfatherCLI:
|
|
|
231
271
|
def logout(self):
|
|
232
272
|
self.authenticator.logout()
|
|
233
273
|
|
|
234
|
-
def authenticate(self):
|
|
235
|
-
self.authenticator.authenticate()
|
|
274
|
+
def authenticate(self, paste: bool = False):
|
|
275
|
+
self.authenticator.authenticate(paste=paste)
|
|
236
276
|
|
|
237
277
|
def update(self):
|
|
238
278
|
perform_update()
|
|
@@ -243,6 +283,8 @@ class GodfatherCLI:
|
|
|
243
283
|
self.connect_to_pod()
|
|
244
284
|
elif action == picker.LIST:
|
|
245
285
|
self.list_pods()
|
|
286
|
+
elif action == picker.SESSIONS:
|
|
287
|
+
self.list_sessions()
|
|
246
288
|
elif action == picker.STATUS:
|
|
247
289
|
self.status()
|
|
248
290
|
elif action == picker.LOGIN:
|
|
@@ -272,7 +314,14 @@ class GodfatherCLI:
|
|
|
272
314
|
|
|
273
315
|
def plain_menu(self):
|
|
274
316
|
"""The numbered menu for input that is not a terminal."""
|
|
275
|
-
actions = {
|
|
317
|
+
actions = {
|
|
318
|
+
'1': picker.LIST,
|
|
319
|
+
'2': picker.CONNECT,
|
|
320
|
+
'3': picker.STATUS,
|
|
321
|
+
'4': picker.LOGOUT,
|
|
322
|
+
'5': picker.SESSIONS,
|
|
323
|
+
'6': picker.EXIT,
|
|
324
|
+
}
|
|
276
325
|
while True:
|
|
277
326
|
console.print()
|
|
278
327
|
menu = Table.grid(padding=(0, 2))
|
|
@@ -282,7 +331,8 @@ class GodfatherCLI:
|
|
|
282
331
|
menu.add_row("2.", "Connect to a pod")
|
|
283
332
|
menu.add_row("3.", "Show status")
|
|
284
333
|
menu.add_row("4.", "Log out")
|
|
285
|
-
menu.add_row("5.", "
|
|
334
|
+
menu.add_row("5.", "Show sessions")
|
|
335
|
+
menu.add_row("6.", "Exit")
|
|
286
336
|
console.print(Panel(menu, title="What would you like to do?", border_style=BORDER, box=BOX))
|
|
287
337
|
|
|
288
338
|
try:
|
|
@@ -318,36 +368,35 @@ Examples:
|
|
|
318
368
|
godfather list List available pods
|
|
319
369
|
godfather connect Connect to a pod, picking from a list
|
|
320
370
|
godfather connect <pod-id> Connect to a specific pod
|
|
371
|
+
godfather connect --admin Connect as root (officers, on a pod with pod admin on)
|
|
372
|
+
godfather sessions Show upcoming sessions on your pods
|
|
321
373
|
godfather status Show login and configuration status
|
|
322
|
-
godfather auth Log in
|
|
374
|
+
godfather auth Log in with Discord in your browser
|
|
375
|
+
godfather auth --paste Log in by pasting the token (browser on another computer)
|
|
323
376
|
godfather logout Clear the stored session
|
|
324
377
|
godfather update Update the CLI to the latest version
|
|
325
378
|
|
|
326
379
|
Questions or issues: https://discord.gg/fXWXwz6fEG
|
|
327
|
-
"""
|
|
380
|
+
""",
|
|
328
381
|
)
|
|
329
382
|
|
|
330
383
|
parser.add_argument(
|
|
331
384
|
'command',
|
|
332
385
|
nargs='?',
|
|
333
|
-
choices=['list', 'connect', 'status', 'logout', 'auth', 'update'],
|
|
334
|
-
help='Command to run (omit to open the interactive menu)'
|
|
335
|
-
)
|
|
336
|
-
parser.add_argument(
|
|
337
|
-
'pod_id',
|
|
338
|
-
nargs='?',
|
|
339
|
-
help='Pod ID to connect to (only used with "connect")'
|
|
340
|
-
)
|
|
341
|
-
parser.add_argument(
|
|
342
|
-
'--api-url',
|
|
343
|
-
help='Use a specific platform API URL instead of the default'
|
|
344
|
-
)
|
|
345
|
-
parser.add_argument(
|
|
346
|
-
'--org',
|
|
347
|
-
help='Organization prefix on the platform (default: ais)'
|
|
386
|
+
choices=['list', 'connect', 'sessions', 'status', 'logout', 'auth', 'update'],
|
|
387
|
+
help='Command to run (omit to open the interactive menu)',
|
|
348
388
|
)
|
|
389
|
+
parser.add_argument('pod_id', nargs='?', help='Pod ID to connect to (only used with "connect")')
|
|
390
|
+
parser.add_argument('--api-url', help='Use a specific platform API URL instead of the default')
|
|
391
|
+
parser.add_argument('--org', help='Organization prefix on the platform (default: ais)')
|
|
392
|
+
parser.add_argument('--paste', action='store_true', help='With "auth": paste the token, no browser sign-in')
|
|
393
|
+
parser.add_argument('--admin', action='store_true', help='With "connect": ask for a root shell')
|
|
349
394
|
|
|
350
395
|
args = parser.parse_args()
|
|
396
|
+
if args.paste and args.command != 'auth':
|
|
397
|
+
parser.error('--paste works only with "auth"')
|
|
398
|
+
if args.admin and args.command != 'connect':
|
|
399
|
+
parser.error('--admin works only with "connect"')
|
|
351
400
|
|
|
352
401
|
if args.api_url:
|
|
353
402
|
os.environ['GODFATHER_API_URL'] = args.api_url
|
|
@@ -370,13 +419,15 @@ Questions or issues: https://discord.gg/fXWXwz6fEG
|
|
|
370
419
|
if args.command == 'list':
|
|
371
420
|
cli.list_pods()
|
|
372
421
|
elif args.command == 'connect':
|
|
373
|
-
cli.connect_to_pod(args.pod_id)
|
|
422
|
+
cli.connect_to_pod(args.pod_id, admin=args.admin)
|
|
423
|
+
elif args.command == 'sessions':
|
|
424
|
+
cli.list_sessions()
|
|
374
425
|
elif args.command == 'status':
|
|
375
426
|
cli.status()
|
|
376
427
|
elif args.command == 'logout':
|
|
377
428
|
cli.logout()
|
|
378
429
|
elif args.command == 'auth':
|
|
379
|
-
cli.authenticate()
|
|
430
|
+
cli.authenticate(paste=args.paste)
|
|
380
431
|
elif args.command == 'update':
|
|
381
432
|
cli.update()
|
|
382
433
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""One-request HTTP server on 127.0.0.1 that receives the sign-in code from the browser."""
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
import time
|
|
5
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from urllib.parse import parse_qs, urlparse
|
|
8
|
+
|
|
9
|
+
DONE_PAGE = (
|
|
10
|
+
b'<!doctype html><meta charset="utf-8"><title>Godfather</title>'
|
|
11
|
+
b'<p>Signed in. You can close this tab and go back to the terminal.</p>'
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
16
|
+
server: '_Server'
|
|
17
|
+
|
|
18
|
+
def do_GET(self) -> None:
|
|
19
|
+
url = urlparse(self.path)
|
|
20
|
+
if url.path != '/callback':
|
|
21
|
+
self._answer(404, b'Not found')
|
|
22
|
+
return
|
|
23
|
+
query = parse_qs(url.query)
|
|
24
|
+
state = (query.get('state') or [''])[0]
|
|
25
|
+
code = (query.get('code') or [''])[0]
|
|
26
|
+
if not code or not hmac.compare_digest(state.encode(), self.server.expected_state.encode()):
|
|
27
|
+
self._answer(400, b'This sign-in link is not for this terminal.')
|
|
28
|
+
return
|
|
29
|
+
self.server.code = code
|
|
30
|
+
self._answer(200, DONE_PAGE, 'text/html; charset=utf-8')
|
|
31
|
+
|
|
32
|
+
def _answer(self, status: int, body: bytes, content_type: str = 'text/plain; charset=utf-8') -> None:
|
|
33
|
+
self.send_response(status)
|
|
34
|
+
self.send_header('Content-Type', content_type)
|
|
35
|
+
self.send_header('Content-Length', str(len(body)))
|
|
36
|
+
self.send_header('Cache-Control', 'no-store')
|
|
37
|
+
self.end_headers()
|
|
38
|
+
self.wfile.write(body)
|
|
39
|
+
|
|
40
|
+
def log_message(self, format: str, *args) -> None:
|
|
41
|
+
"""Request lines carry the code, so nothing is logged."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _Server(HTTPServer):
|
|
45
|
+
expected_state = ''
|
|
46
|
+
code: Optional[str] = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LoopbackReceiver:
|
|
50
|
+
"""Listens on 127.0.0.1 on a port the OS picks and keeps the code from GET /callback."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, state: str):
|
|
53
|
+
self._server = _Server(('127.0.0.1', 0), _Handler)
|
|
54
|
+
self._server.expected_state = state
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def port(self) -> int:
|
|
58
|
+
return self._server.server_address[1]
|
|
59
|
+
|
|
60
|
+
def wait(self, timeout: float) -> Optional[str]:
|
|
61
|
+
"""The code from the browser, or None when timeout seconds pass first."""
|
|
62
|
+
deadline = time.monotonic() + timeout
|
|
63
|
+
while self._server.code is None:
|
|
64
|
+
left = deadline - time.monotonic()
|
|
65
|
+
if left <= 0:
|
|
66
|
+
return None
|
|
67
|
+
self._server.timeout = min(left, 1.0)
|
|
68
|
+
self._server.handle_request()
|
|
69
|
+
return self._server.code
|
|
70
|
+
|
|
71
|
+
def close(self) -> None:
|
|
72
|
+
self._server.server_close()
|