godfather-cli 2.0.1__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.0.1/godfather_cli.egg-info → godfather_cli-2.2.0}/PKG-INFO +35 -17
- {godfather_cli-2.0.1 → godfather_cli-2.2.0}/README.md +33 -16
- {godfather_cli-2.0.1 → godfather_cli-2.2.0}/godfather_cli/__init__.py +1 -1
- godfather_cli-2.2.0/godfather_cli/auth.py +278 -0
- godfather_cli-2.2.0/godfather_cli/cli.py +436 -0
- godfather_cli-2.2.0/godfather_cli/login_server.py +72 -0
- godfather_cli-2.2.0/godfather_cli/picker.py +160 -0
- godfather_cli-2.2.0/godfather_cli/pod_manager.py +322 -0
- {godfather_cli-2.0.1 → godfather_cli-2.2.0}/godfather_cli/ssh_connector.py +18 -10
- {godfather_cli-2.0.1 → godfather_cli-2.2.0}/godfather_cli/update_checker.py +18 -20
- {godfather_cli-2.0.1 → godfather_cli-2.2.0/godfather_cli.egg-info}/PKG-INFO +35 -17
- {godfather_cli-2.0.1 → godfather_cli-2.2.0}/godfather_cli.egg-info/SOURCES.txt +5 -1
- {godfather_cli-2.0.1 → godfather_cli-2.2.0}/godfather_cli.egg-info/requires.txt +1 -0
- {godfather_cli-2.0.1 → godfather_cli-2.2.0}/pyproject.toml +15 -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.0.1/godfather_cli/auth.py +0 -150
- godfather_cli-2.0.1/godfather_cli/cli.py +0 -248
- godfather_cli-2.0.1/godfather_cli/pod_manager.py +0 -149
- godfather_cli-2.0.1/tests/test_cli.py +0 -92
- {godfather_cli-2.0.1 → godfather_cli-2.2.0}/LICENSE +0 -0
- {godfather_cli-2.0.1 → godfather_cli-2.2.0}/MANIFEST.in +0 -0
- {godfather_cli-2.0.1 → godfather_cli-2.2.0}/godfather_cli/ui.py +1 -1
- {godfather_cli-2.0.1 → godfather_cli-2.2.0}/godfather_cli.egg-info/dependency_links.txt +0 -0
- {godfather_cli-2.0.1 → godfather_cli-2.2.0}/godfather_cli.egg-info/entry_points.txt +0 -0
- {godfather_cli-2.0.1 → godfather_cli-2.2.0}/godfather_cli.egg-info/top_level.txt +0 -0
- {godfather_cli-2.0.1 → 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.0
|
|
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
|
|
@@ -24,6 +24,7 @@ License-File: LICENSE
|
|
|
24
24
|
Requires-Dist: requests>=2.25.0
|
|
25
25
|
Requires-Dist: rich>=13.0.0
|
|
26
26
|
Requires-Dist: packaging>=20.0
|
|
27
|
+
Requires-Dist: questionary>=2.0.0
|
|
27
28
|
Dynamic: license-file
|
|
28
29
|
|
|
29
30
|
# Godfather CLI
|
|
@@ -61,10 +62,13 @@ pip install -e .
|
|
|
61
62
|
|
|
62
63
|
1. Run `godfather` with no arguments. On first run you won't be logged in
|
|
63
64
|
yet, so it'll walk you into the login flow.
|
|
64
|
-
2. It opens the
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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.
|
|
68
72
|
4. From the menu (or `godfather connect`), pick a pod. The CLI creates an SSH
|
|
69
73
|
key on your machine the first time, asks the server for a 12-hour
|
|
70
74
|
certificate for that pod, and opens the connection.
|
|
@@ -79,8 +83,18 @@ Windows 10+ ship it.
|
|
|
79
83
|
|
|
80
84
|
### Interactive menu
|
|
81
85
|
|
|
82
|
-
Running `godfather` with no arguments opens a menu
|
|
83
|
-
|
|
86
|
+
Running `godfather` with no arguments opens a menu. Use the arrow keys to
|
|
87
|
+
choose Connect to a pod, List pods, Sessions, Status, Log out or Exit, and
|
|
88
|
+
press Enter. Ctrl-C quits.
|
|
89
|
+
|
|
90
|
+
Connect to a pod shows a list of your pods with their status, access and
|
|
91
|
+
pod ID. A pod that is not running shows with the reason and cannot be
|
|
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.
|
|
95
|
+
|
|
96
|
+
When input is not a terminal (for example in a script), the menu and the pod
|
|
97
|
+
list use numbered prompts.
|
|
84
98
|
|
|
85
99
|
### Commands
|
|
86
100
|
|
|
@@ -88,8 +102,11 @@ check status, or log out.
|
|
|
88
102
|
godfather list # List pods you can connect to
|
|
89
103
|
godfather connect # Connect to a pod, picking from a list
|
|
90
104
|
godfather connect <pod-id> # Connect to a specific pod
|
|
91
|
-
godfather
|
|
92
|
-
godfather
|
|
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
|
|
107
|
+
godfather status # Show server, org, login, token expiry and version
|
|
108
|
+
godfather auth # Log in with Discord in your browser
|
|
109
|
+
godfather auth --paste # Log in by pasting the token
|
|
93
110
|
godfather logout # Clear the stored session
|
|
94
111
|
godfather update # Update the CLI to the latest version
|
|
95
112
|
|
|
@@ -118,7 +135,8 @@ another server or org; the ones you last logged in with are remembered.
|
|
|
118
135
|
- **"Couldn't reach \<url\>"** — check your internet connection and that the
|
|
119
136
|
API URL is correct (`godfather status` shows what's currently configured).
|
|
120
137
|
- **"Your token is invalid or expired"** — run `godfather auth` and sign in
|
|
121
|
-
again.
|
|
138
|
+
again. In the menu, the CLI offers to do this for you.
|
|
139
|
+
- **"Pod is not running"** — the pod stopped. Ask an officer to start it.
|
|
122
140
|
- **"You are no longer a member of this organization"** — rejoin the club's
|
|
123
141
|
Discord server, then try again.
|
|
124
142
|
- **"SSH could not log in to the pod"** — the pod does not run the
|
|
@@ -130,24 +148,24 @@ another server or org; the ones you last logged in with are remembered.
|
|
|
130
148
|
|
|
131
149
|
## Development
|
|
132
150
|
|
|
133
|
-
The code is in `godfather_cli/`: `cli.py` (commands and menu), `auth.py`
|
|
134
|
-
(platform sign-in), `pod_manager.py` (API calls), `ssh_connector.py` (keys,
|
|
151
|
+
The code is in `godfather_cli/`: `cli.py` (commands and menu), `picker.py` (arrow-key menu and pod picker), `auth.py`
|
|
152
|
+
(platform sign-in), `login_server.py` (the sign-in callback), `pod_manager.py` (API calls), `ssh_connector.py` (keys,
|
|
135
153
|
certificate, running ssh), `update_checker.py` (PyPI version check), `ui.py`
|
|
136
154
|
(shared console styling). Tests are in `tests/`:
|
|
137
155
|
|
|
138
156
|
```bash
|
|
139
|
-
|
|
140
|
-
pytest -q tests
|
|
157
|
+
uv sync --group dev
|
|
158
|
+
uv run pytest -q tests
|
|
141
159
|
```
|
|
142
160
|
|
|
161
|
+
`make check-cli` at the repo root runs ruff, the tests and a build, as CI does.
|
|
162
|
+
|
|
143
163
|
Releases go to PyPI when a version bump is merged to main; see the
|
|
144
164
|
repo README.
|
|
145
165
|
|
|
146
166
|
## Contributing
|
|
147
167
|
|
|
148
|
-
|
|
149
|
-
2. Create a feature branch (`git checkout -b feature/your-feature`).
|
|
150
|
-
3. Commit your changes and open a Pull Request.
|
|
168
|
+
See [CONTRIBUTING.md](../CONTRIBUTING.md).
|
|
151
169
|
|
|
152
170
|
## License
|
|
153
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.
|
|
@@ -51,8 +54,18 @@ Windows 10+ ship it.
|
|
|
51
54
|
|
|
52
55
|
### Interactive menu
|
|
53
56
|
|
|
54
|
-
Running `godfather` with no arguments opens a menu
|
|
55
|
-
|
|
57
|
+
Running `godfather` with no arguments opens a menu. Use the arrow keys to
|
|
58
|
+
choose Connect to a pod, List pods, Sessions, Status, Log out or Exit, and
|
|
59
|
+
press Enter. Ctrl-C quits.
|
|
60
|
+
|
|
61
|
+
Connect to a pod shows a list of your pods with their status, access and
|
|
62
|
+
pod ID. A pod that is not running shows with the reason and cannot be
|
|
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.
|
|
66
|
+
|
|
67
|
+
When input is not a terminal (for example in a script), the menu and the pod
|
|
68
|
+
list use numbered prompts.
|
|
56
69
|
|
|
57
70
|
### Commands
|
|
58
71
|
|
|
@@ -60,8 +73,11 @@ check status, or log out.
|
|
|
60
73
|
godfather list # List pods you can connect to
|
|
61
74
|
godfather connect # Connect to a pod, picking from a list
|
|
62
75
|
godfather connect <pod-id> # Connect to a specific pod
|
|
63
|
-
godfather
|
|
64
|
-
godfather
|
|
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
|
|
78
|
+
godfather status # Show server, org, login, token expiry and version
|
|
79
|
+
godfather auth # Log in with Discord in your browser
|
|
80
|
+
godfather auth --paste # Log in by pasting the token
|
|
65
81
|
godfather logout # Clear the stored session
|
|
66
82
|
godfather update # Update the CLI to the latest version
|
|
67
83
|
|
|
@@ -90,7 +106,8 @@ another server or org; the ones you last logged in with are remembered.
|
|
|
90
106
|
- **"Couldn't reach \<url\>"** — check your internet connection and that the
|
|
91
107
|
API URL is correct (`godfather status` shows what's currently configured).
|
|
92
108
|
- **"Your token is invalid or expired"** — run `godfather auth` and sign in
|
|
93
|
-
again.
|
|
109
|
+
again. In the menu, the CLI offers to do this for you.
|
|
110
|
+
- **"Pod is not running"** — the pod stopped. Ask an officer to start it.
|
|
94
111
|
- **"You are no longer a member of this organization"** — rejoin the club's
|
|
95
112
|
Discord server, then try again.
|
|
96
113
|
- **"SSH could not log in to the pod"** — the pod does not run the
|
|
@@ -102,24 +119,24 @@ another server or org; the ones you last logged in with are remembered.
|
|
|
102
119
|
|
|
103
120
|
## Development
|
|
104
121
|
|
|
105
|
-
The code is in `godfather_cli/`: `cli.py` (commands and menu), `auth.py`
|
|
106
|
-
(platform sign-in), `pod_manager.py` (API calls), `ssh_connector.py` (keys,
|
|
122
|
+
The code is in `godfather_cli/`: `cli.py` (commands and menu), `picker.py` (arrow-key menu and pod picker), `auth.py`
|
|
123
|
+
(platform sign-in), `login_server.py` (the sign-in callback), `pod_manager.py` (API calls), `ssh_connector.py` (keys,
|
|
107
124
|
certificate, running ssh), `update_checker.py` (PyPI version check), `ui.py`
|
|
108
125
|
(shared console styling). Tests are in `tests/`:
|
|
109
126
|
|
|
110
127
|
```bash
|
|
111
|
-
|
|
112
|
-
pytest -q tests
|
|
128
|
+
uv sync --group dev
|
|
129
|
+
uv run pytest -q tests
|
|
113
130
|
```
|
|
114
131
|
|
|
132
|
+
`make check-cli` at the repo root runs ruff, the tests and a build, as CI does.
|
|
133
|
+
|
|
115
134
|
Releases go to PyPI when a version bump is merged to main; see the
|
|
116
135
|
repo README.
|
|
117
136
|
|
|
118
137
|
## Contributing
|
|
119
138
|
|
|
120
|
-
|
|
121
|
-
2. Create a feature branch (`git checkout -b feature/your-feature`).
|
|
122
|
-
3. Commit your changes and open a Pull Request.
|
|
139
|
+
See [CONTRIBUTING.md](../CONTRIBUTING.md).
|
|
123
140
|
|
|
124
141
|
## License
|
|
125
142
|
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""CLI authentication: token storage, login flow, and token verification."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import datetime
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
import webbrowser
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, Optional
|
|
12
|
+
from urllib.parse import urlencode
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
from rich.panel import Panel
|
|
16
|
+
from rich.prompt import Prompt
|
|
17
|
+
|
|
18
|
+
from .login_server import LoopbackReceiver
|
|
19
|
+
from .ui import BORDER, BOX, console, error, spinner, success, warning
|
|
20
|
+
|
|
21
|
+
TOKEN_PREFIX = 'plat_'
|
|
22
|
+
# The platform issues CLI tokens for 90 days.
|
|
23
|
+
TOKEN_DAYS = 90
|
|
24
|
+
|
|
25
|
+
# How long the CLI waits for the browser sign-in.
|
|
26
|
+
LOGIN_WAIT_SECONDS = 300
|
|
27
|
+
|
|
28
|
+
# Token states that check_token returns.
|
|
29
|
+
TOKEN_OK = 'ok'
|
|
30
|
+
TOKEN_MISSING = 'missing'
|
|
31
|
+
TOKEN_EXPIRED = 'expired'
|
|
32
|
+
TOKEN_UNREACHABLE = 'unreachable'
|
|
33
|
+
TOKEN_REFUSED = 'refused'
|
|
34
|
+
|
|
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
|
+
|
|
47
|
+
class CLIAuthenticator:
|
|
48
|
+
"""Handle CLI authentication."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, api_base: str, org: str, config_dir: Path):
|
|
51
|
+
self.api_base = api_base
|
|
52
|
+
self.org = org
|
|
53
|
+
self.config_dir = config_dir
|
|
54
|
+
self.config_file = config_dir / 'config.json'
|
|
55
|
+
self.config = self.load_config()
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def read_config(config_dir: Path) -> Dict:
|
|
59
|
+
"""The stored config in config_dir, or an empty dict."""
|
|
60
|
+
config_file = config_dir / 'config.json'
|
|
61
|
+
if config_file.exists():
|
|
62
|
+
try:
|
|
63
|
+
with open(config_file, 'r') as f:
|
|
64
|
+
data = json.load(f)
|
|
65
|
+
return data if isinstance(data, dict) else {}
|
|
66
|
+
except (json.JSONDecodeError, IOError):
|
|
67
|
+
pass
|
|
68
|
+
return {}
|
|
69
|
+
|
|
70
|
+
def load_config(self) -> Dict:
|
|
71
|
+
"""Load stored config from disk, if any."""
|
|
72
|
+
return self.read_config(self.config_dir)
|
|
73
|
+
|
|
74
|
+
def save_config(self):
|
|
75
|
+
self.config_dir.mkdir(exist_ok=True)
|
|
76
|
+
with open(self.config_file, 'w') as f:
|
|
77
|
+
json.dump(self.config, f, indent=2)
|
|
78
|
+
os.chmod(self.config_file, 0o600)
|
|
79
|
+
|
|
80
|
+
def compute_url(self, path: str = '') -> str:
|
|
81
|
+
"""URL of a platform compute route for the configured org."""
|
|
82
|
+
return f"{self.api_base}/api/compute/{self.org}{path}"
|
|
83
|
+
|
|
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."""
|
|
162
|
+
login_url = self.compute_url('/cli/login')
|
|
163
|
+
panel = Panel(
|
|
164
|
+
f"[bold]1.[/bold] Open [bold]{login_url}[/bold] and sign in with Discord\n"
|
|
165
|
+
f"[bold]2.[/bold] Copy the token shown there\n"
|
|
166
|
+
f"[bold]3.[/bold] Paste it below",
|
|
167
|
+
title="Log in to Godfather",
|
|
168
|
+
border_style=BORDER,
|
|
169
|
+
box=BOX,
|
|
170
|
+
)
|
|
171
|
+
console.print(panel)
|
|
172
|
+
console.print()
|
|
173
|
+
try:
|
|
174
|
+
webbrowser.open(login_url)
|
|
175
|
+
except webbrowser.Error:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
token = Prompt.ask("Token", password=True).strip()
|
|
180
|
+
except EOFError:
|
|
181
|
+
token = ''
|
|
182
|
+
if not token:
|
|
183
|
+
error("No token entered")
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
if not token.startswith(TOKEN_PREFIX):
|
|
187
|
+
error("That doesn't look like a valid Godfather token")
|
|
188
|
+
console.print(f"[dim]Tokens start with {TOKEN_PREFIX}. Get a fresh one from {login_url}[/dim]")
|
|
189
|
+
return False
|
|
190
|
+
return self._verify_and_save(token)
|
|
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."""
|
|
194
|
+
try:
|
|
195
|
+
with spinner("Verifying token..."):
|
|
196
|
+
response = requests.get(
|
|
197
|
+
self.compute_url('/me/pods'), headers={'Authorization': f'Bearer {token}'}, timeout=10
|
|
198
|
+
)
|
|
199
|
+
except requests.ConnectionError:
|
|
200
|
+
error(f"Couldn't reach {self.api_base}")
|
|
201
|
+
console.print("[dim]Check your internet connection, or that the API URL is correct.[/dim]")
|
|
202
|
+
return False
|
|
203
|
+
except requests.Timeout:
|
|
204
|
+
error("Request timed out")
|
|
205
|
+
console.print("[dim]The server took too long to respond. Try again in a moment.[/dim]")
|
|
206
|
+
return False
|
|
207
|
+
except requests.RequestException as e:
|
|
208
|
+
error(f"Connection error: {e}")
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
if response.status_code != 200:
|
|
212
|
+
try:
|
|
213
|
+
message = response.json().get('error', 'Authentication failed')
|
|
214
|
+
except ValueError:
|
|
215
|
+
message = 'Authentication failed'
|
|
216
|
+
error(message)
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
self.config = {'token': token, 'api_url': self.api_base, 'org': self.org}
|
|
220
|
+
self.save_config()
|
|
221
|
+
success(f"Logged in to {self.org}")
|
|
222
|
+
return True
|
|
223
|
+
|
|
224
|
+
def get_token(self) -> Optional[str]:
|
|
225
|
+
return self.config.get('token')
|
|
226
|
+
|
|
227
|
+
def auth_headers(self) -> Dict[str, str]:
|
|
228
|
+
"""Headers that authenticate an API request."""
|
|
229
|
+
return {'Authorization': f"Bearer {self.config.get('token', '')}"}
|
|
230
|
+
|
|
231
|
+
def is_authenticated(self) -> bool:
|
|
232
|
+
"""A platform token is stored for this server and org."""
|
|
233
|
+
return (
|
|
234
|
+
str(self.config.get('token', '')).startswith(TOKEN_PREFIX)
|
|
235
|
+
and self.config.get('api_url') == self.api_base
|
|
236
|
+
and self.config.get('org') == self.org
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def logout(self):
|
|
240
|
+
if 'token' in self.config:
|
|
241
|
+
del self.config['token']
|
|
242
|
+
self.save_config()
|
|
243
|
+
success("Logged out")
|
|
244
|
+
else:
|
|
245
|
+
warning("You weren't logged in")
|
|
246
|
+
|
|
247
|
+
def check_token(self) -> str:
|
|
248
|
+
"""Ask the server whether the stored token works. Returns one of the TOKEN_ states."""
|
|
249
|
+
if not self.is_authenticated():
|
|
250
|
+
return TOKEN_MISSING
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
response = requests.get(self.compute_url('/me/pods'), headers=self.auth_headers(), timeout=5)
|
|
254
|
+
except requests.RequestException:
|
|
255
|
+
return TOKEN_UNREACHABLE
|
|
256
|
+
if response.status_code == 200:
|
|
257
|
+
return TOKEN_OK
|
|
258
|
+
if response.status_code == 401:
|
|
259
|
+
return TOKEN_EXPIRED
|
|
260
|
+
return TOKEN_REFUSED
|
|
261
|
+
|
|
262
|
+
def verify_token(self) -> bool:
|
|
263
|
+
"""Check the stored token is still accepted by the backend."""
|
|
264
|
+
return self.check_token() == TOKEN_OK
|
|
265
|
+
|
|
266
|
+
def token_saved_at(self) -> Optional[datetime.datetime]:
|
|
267
|
+
"""When the stored token was saved, from the config file time. Only login writes a token."""
|
|
268
|
+
if not self.is_authenticated():
|
|
269
|
+
return None
|
|
270
|
+
try:
|
|
271
|
+
return datetime.datetime.fromtimestamp(self.config_file.stat().st_mtime)
|
|
272
|
+
except OSError:
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
def token_expires_at(self) -> Optional[datetime.datetime]:
|
|
276
|
+
"""About when the stored token expires: TOKEN_DAYS after it was saved."""
|
|
277
|
+
saved = self.token_saved_at()
|
|
278
|
+
return saved + datetime.timedelta(days=TOKEN_DAYS) if saved else None
|