godfather-cli 2.2.0__tar.gz → 2.3.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.2.0/godfather_cli.egg-info → godfather_cli-2.3.0}/PKG-INFO +15 -2
- {godfather_cli-2.2.0 → godfather_cli-2.3.0}/README.md +14 -1
- {godfather_cli-2.2.0 → godfather_cli-2.3.0}/godfather_cli/__init__.py +1 -1
- {godfather_cli-2.2.0 → godfather_cli-2.3.0}/godfather_cli/cli.py +74 -19
- {godfather_cli-2.2.0 → godfather_cli-2.3.0}/godfather_cli/picker.py +2 -0
- {godfather_cli-2.2.0 → godfather_cli-2.3.0}/godfather_cli/ssh_connector.py +78 -2
- {godfather_cli-2.2.0 → godfather_cli-2.3.0/godfather_cli.egg-info}/PKG-INFO +15 -2
- {godfather_cli-2.2.0 → godfather_cli-2.3.0}/godfather_cli.egg-info/SOURCES.txt +2 -1
- {godfather_cli-2.2.0 → godfather_cli-2.3.0}/pyproject.toml +1 -1
- {godfather_cli-2.2.0 → godfather_cli-2.3.0}/tests/test_cli.py +1 -1
- {godfather_cli-2.2.0 → godfather_cli-2.3.0}/tests/test_sessions.py +1 -1
- godfather_cli-2.3.0/tests/test_vscode.py +98 -0
- {godfather_cli-2.2.0 → godfather_cli-2.3.0}/LICENSE +0 -0
- {godfather_cli-2.2.0 → godfather_cli-2.3.0}/MANIFEST.in +0 -0
- {godfather_cli-2.2.0 → godfather_cli-2.3.0}/godfather_cli/auth.py +0 -0
- {godfather_cli-2.2.0 → godfather_cli-2.3.0}/godfather_cli/login_server.py +0 -0
- {godfather_cli-2.2.0 → godfather_cli-2.3.0}/godfather_cli/pod_manager.py +0 -0
- {godfather_cli-2.2.0 → godfather_cli-2.3.0}/godfather_cli/ui.py +0 -0
- {godfather_cli-2.2.0 → godfather_cli-2.3.0}/godfather_cli/update_checker.py +0 -0
- {godfather_cli-2.2.0 → godfather_cli-2.3.0}/godfather_cli.egg-info/dependency_links.txt +0 -0
- {godfather_cli-2.2.0 → godfather_cli-2.3.0}/godfather_cli.egg-info/entry_points.txt +0 -0
- {godfather_cli-2.2.0 → godfather_cli-2.3.0}/godfather_cli.egg-info/requires.txt +0 -0
- {godfather_cli-2.2.0 → godfather_cli-2.3.0}/godfather_cli.egg-info/top_level.txt +0 -0
- {godfather_cli-2.2.0 → godfather_cli-2.3.0}/setup.cfg +0 -0
- {godfather_cli-2.2.0 → godfather_cli-2.3.0}/tests/test_login.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: godfather-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.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
|
|
@@ -84,7 +84,7 @@ Windows 10+ ship it.
|
|
|
84
84
|
### Interactive menu
|
|
85
85
|
|
|
86
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
|
|
87
|
+
choose Connect to a pod, List pods, Sessions, Set up VS Code, Status, Log out or Exit, and
|
|
88
88
|
press Enter. Ctrl-C quits.
|
|
89
89
|
|
|
90
90
|
Connect to a pod shows a list of your pods with their status, access and
|
|
@@ -104,6 +104,8 @@ godfather connect # Connect to a pod, picking from a list
|
|
|
104
104
|
godfather connect <pod-id> # Connect to a specific pod
|
|
105
105
|
godfather connect --admin # Ask for a root shell (officers, when pod admin is on)
|
|
106
106
|
godfather sessions # Upcoming sessions on your pods, in local time
|
|
107
|
+
godfather vscode # Set up VS Code Remote-SSH for a pod, picking from a list
|
|
108
|
+
godfather vscode <pod-id> # The same for one pod; run it again to renew the certificate
|
|
107
109
|
godfather status # Show server, org, login, token expiry and version
|
|
108
110
|
godfather auth # Log in with Discord in your browser
|
|
109
111
|
godfather auth --paste # Log in by pasting the token
|
|
@@ -114,6 +116,16 @@ godfather update # Update the CLI to the latest version
|
|
|
114
116
|
godfather --api-url https://platform.example.com --org soda list
|
|
115
117
|
```
|
|
116
118
|
|
|
119
|
+
### VS Code
|
|
120
|
+
|
|
121
|
+
`godfather vscode` sets up a pod for the VS Code Remote - SSH extension:
|
|
122
|
+
|
|
123
|
+
1. It gets a 12-hour certificate for the pod and saves it as `~/.godfather/ssh/<pod-id>-cert.pub`.
|
|
124
|
+
2. It writes a `Host godfather-<pod-id>` block to `~/.godfather/ssh/config` and adds one `Include` line for that file at the top of `~/.ssh/config`.
|
|
125
|
+
3. If the `code` command is on your PATH, it opens VS Code on `/workspace/users/<you>` on the pod. Otherwise run **Remote-SSH: Connect to Host...** in VS Code and pick `godfather-<pod-id>`.
|
|
126
|
+
|
|
127
|
+
VS Code runs as your pod account (`godfather_<you>`), with the same files and limits as `godfather connect`. After 12 hours VS Code cannot reconnect; run `godfather vscode <pod-id>` again. The pod must run image 2.3.0 or later. `godfather vscode --admin` gives officers a root login on a pod with pod admin on.
|
|
128
|
+
|
|
117
129
|
## Files it keeps
|
|
118
130
|
|
|
119
131
|
Everything lives in `~/.godfather/`:
|
|
@@ -121,6 +133,7 @@ Everything lives in `~/.godfather/`:
|
|
|
121
133
|
- `config.json`: your token (mode 600). Treat it like a password.
|
|
122
134
|
- `ssh/id_ed25519`, `ssh/id_ed25519.pub`: your SSH key, created on first connect. The private key never leaves your machine.
|
|
123
135
|
- `ssh/id_ed25519-cert.pub`: the certificate for the last pod you connected to.
|
|
136
|
+
- `ssh/<pod-id>-cert.pub`, `ssh/config`: the certificate and Host block of each pod set up for VS Code.
|
|
124
137
|
|
|
125
138
|
Delete the folder to reset everything.
|
|
126
139
|
|
|
@@ -55,7 +55,7 @@ Windows 10+ ship it.
|
|
|
55
55
|
### Interactive menu
|
|
56
56
|
|
|
57
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
|
|
58
|
+
choose Connect to a pod, List pods, Sessions, Set up VS Code, Status, Log out or Exit, and
|
|
59
59
|
press Enter. Ctrl-C quits.
|
|
60
60
|
|
|
61
61
|
Connect to a pod shows a list of your pods with their status, access and
|
|
@@ -75,6 +75,8 @@ godfather connect # Connect to a pod, picking from a list
|
|
|
75
75
|
godfather connect <pod-id> # Connect to a specific pod
|
|
76
76
|
godfather connect --admin # Ask for a root shell (officers, when pod admin is on)
|
|
77
77
|
godfather sessions # Upcoming sessions on your pods, in local time
|
|
78
|
+
godfather vscode # Set up VS Code Remote-SSH for a pod, picking from a list
|
|
79
|
+
godfather vscode <pod-id> # The same for one pod; run it again to renew the certificate
|
|
78
80
|
godfather status # Show server, org, login, token expiry and version
|
|
79
81
|
godfather auth # Log in with Discord in your browser
|
|
80
82
|
godfather auth --paste # Log in by pasting the token
|
|
@@ -85,6 +87,16 @@ godfather update # Update the CLI to the latest version
|
|
|
85
87
|
godfather --api-url https://platform.example.com --org soda list
|
|
86
88
|
```
|
|
87
89
|
|
|
90
|
+
### VS Code
|
|
91
|
+
|
|
92
|
+
`godfather vscode` sets up a pod for the VS Code Remote - SSH extension:
|
|
93
|
+
|
|
94
|
+
1. It gets a 12-hour certificate for the pod and saves it as `~/.godfather/ssh/<pod-id>-cert.pub`.
|
|
95
|
+
2. It writes a `Host godfather-<pod-id>` block to `~/.godfather/ssh/config` and adds one `Include` line for that file at the top of `~/.ssh/config`.
|
|
96
|
+
3. If the `code` command is on your PATH, it opens VS Code on `/workspace/users/<you>` on the pod. Otherwise run **Remote-SSH: Connect to Host...** in VS Code and pick `godfather-<pod-id>`.
|
|
97
|
+
|
|
98
|
+
VS Code runs as your pod account (`godfather_<you>`), with the same files and limits as `godfather connect`. After 12 hours VS Code cannot reconnect; run `godfather vscode <pod-id>` again. The pod must run image 2.3.0 or later. `godfather vscode --admin` gives officers a root login on a pod with pod admin on.
|
|
99
|
+
|
|
88
100
|
## Files it keeps
|
|
89
101
|
|
|
90
102
|
Everything lives in `~/.godfather/`:
|
|
@@ -92,6 +104,7 @@ Everything lives in `~/.godfather/`:
|
|
|
92
104
|
- `config.json`: your token (mode 600). Treat it like a password.
|
|
93
105
|
- `ssh/id_ed25519`, `ssh/id_ed25519.pub`: your SSH key, created on first connect. The private key never leaves your machine.
|
|
94
106
|
- `ssh/id_ed25519-cert.pub`: the certificate for the last pod you connected to.
|
|
107
|
+
- `ssh/<pod-id>-cert.pub`, `ssh/config`: the certificate and Host block of each pod set up for VS Code.
|
|
95
108
|
|
|
96
109
|
Delete the folder to reset everything.
|
|
97
110
|
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
import argparse
|
|
5
5
|
import datetime
|
|
6
6
|
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
7
9
|
from pathlib import Path
|
|
8
10
|
from typing import Dict, List, Optional
|
|
9
11
|
|
|
@@ -23,7 +25,7 @@ from .auth import (
|
|
|
23
25
|
)
|
|
24
26
|
from .picker import ask_menu, ask_pod, ask_yes, connectable, is_interactive
|
|
25
27
|
from .pod_manager import EXPIRED, ApiError, PodManager, report
|
|
26
|
-
from .ssh_connector import SSHConnector
|
|
28
|
+
from .ssh_connector import USERNAME_RE, SSHConnector
|
|
27
29
|
from .ui import BORDER, BOX, PURPLE, console, error, info, spinner, warning
|
|
28
30
|
from .update_checker import check_for_updates, perform_update, show_update_warning
|
|
29
31
|
|
|
@@ -194,32 +196,77 @@ class GodfatherCLI:
|
|
|
194
196
|
if not pod_id:
|
|
195
197
|
return
|
|
196
198
|
|
|
197
|
-
|
|
198
|
-
if not
|
|
199
|
+
ssh_info = self.fetch_certificate(pod_id, admin)
|
|
200
|
+
if not ssh_info:
|
|
201
|
+
return
|
|
202
|
+
if not self.ssh_connector.save_certificate(ssh_info.get('certificate', '')):
|
|
199
203
|
return
|
|
204
|
+
if admin and not ssh_info.get('is_admin'):
|
|
205
|
+
warning("The server gave you a member shell, not root.")
|
|
206
|
+
|
|
207
|
+
self.ssh_connector.connect(ssh_info)
|
|
200
208
|
|
|
201
|
-
|
|
209
|
+
def fetch_certificate(self, pod_id: str, admin: bool = False) -> Optional[Dict]:
|
|
210
|
+
"""The connection details with a fresh certificate, or None after printing why not."""
|
|
211
|
+
public_key = self.ssh_connector.ensure_keypair()
|
|
212
|
+
if not public_key:
|
|
213
|
+
return None
|
|
202
214
|
for attempt in range(2):
|
|
203
215
|
try:
|
|
204
216
|
with spinner(f"Getting a certificate for pod {pod_id[:12]}..."):
|
|
205
|
-
|
|
206
|
-
break
|
|
217
|
+
return self.pod_manager.fetch_connection_info(pod_id, public_key, admin=admin)
|
|
207
218
|
except ApiError as e:
|
|
208
219
|
if e.kind == EXPIRED and attempt == 0:
|
|
209
220
|
if self._offer_login(e):
|
|
210
221
|
continue
|
|
211
|
-
return
|
|
222
|
+
return None
|
|
212
223
|
report(e)
|
|
224
|
+
return None
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
def setup_vscode(self, pod_id: str = None, admin: bool = False):
|
|
228
|
+
"""Write an SSH config entry for a pod and open it in VS Code Remote-SSH."""
|
|
229
|
+
if not self.ensure_authenticated():
|
|
230
|
+
return
|
|
231
|
+
if not pod_id:
|
|
232
|
+
pod_id = self.choose_pod()
|
|
233
|
+
if not pod_id:
|
|
213
234
|
return
|
|
235
|
+
|
|
236
|
+
ssh_info = self.fetch_certificate(pod_id, admin)
|
|
214
237
|
if not ssh_info:
|
|
215
238
|
return
|
|
216
|
-
|
|
217
|
-
if not self.ssh_connector.save_certificate(ssh_info.get('certificate', '')):
|
|
239
|
+
cert_file = self.ssh_connector.pod_cert_file(pod_id)
|
|
240
|
+
if not self.ssh_connector.save_certificate(ssh_info.get('certificate', ''), cert_file):
|
|
241
|
+
return
|
|
242
|
+
alias = self.ssh_connector.write_host(pod_id, ssh_info, Path.home() / '.ssh' / 'config')
|
|
243
|
+
if not alias:
|
|
218
244
|
return
|
|
219
|
-
if admin and not ssh_info.get('is_admin'):
|
|
220
|
-
warning("The server gave you a member shell, not root.")
|
|
221
245
|
|
|
222
|
-
|
|
246
|
+
is_admin = bool(ssh_info.get('is_admin'))
|
|
247
|
+
if admin and not is_admin:
|
|
248
|
+
warning("The server gave you a member certificate, not root.")
|
|
249
|
+
user_folder = str(ssh_info.get('user_folder', ''))
|
|
250
|
+
folder = f"/workspace/users/{user_folder}" if USERNAME_RE.match(user_folder) else '/workspace'
|
|
251
|
+
steps = (
|
|
252
|
+
f"[bold]Host[/bold] {alias} [dim](in ~/.ssh/config)[/dim]\n"
|
|
253
|
+
f"[bold]Folder[/bold] {folder}\n"
|
|
254
|
+
f"[bold]Valid[/bold] 12 hours. Run [bold]godfather vscode {pod_id}[/bold] again to renew.\n\n"
|
|
255
|
+
"In VS Code: install the Remote - SSH extension, run [bold]Remote-SSH: Connect to Host...[/bold],\n"
|
|
256
|
+
f"pick [bold]{alias}[/bold], then open {folder}."
|
|
257
|
+
)
|
|
258
|
+
if is_admin:
|
|
259
|
+
steps += "\n[yellow]This is a root login. Changes to system files affect every user of the pod.[/yellow]"
|
|
260
|
+
console.print(Panel(steps, title="VS Code is set up", border_style=BORDER, box=BOX))
|
|
261
|
+
|
|
262
|
+
code = shutil.which('code')
|
|
263
|
+
if code:
|
|
264
|
+
uri = f'vscode-remote://ssh-remote+{alias}{folder}'
|
|
265
|
+
try:
|
|
266
|
+
subprocess.Popen([code, '--folder-uri', uri])
|
|
267
|
+
info("Opening VS Code...")
|
|
268
|
+
except OSError:
|
|
269
|
+
pass
|
|
223
270
|
|
|
224
271
|
def status(self):
|
|
225
272
|
"""Print the server, org, login, token and version details."""
|
|
@@ -285,6 +332,8 @@ class GodfatherCLI:
|
|
|
285
332
|
self.list_pods()
|
|
286
333
|
elif action == picker.SESSIONS:
|
|
287
334
|
self.list_sessions()
|
|
335
|
+
elif action == picker.VSCODE:
|
|
336
|
+
self.setup_vscode()
|
|
288
337
|
elif action == picker.STATUS:
|
|
289
338
|
self.status()
|
|
290
339
|
elif action == picker.LOGIN:
|
|
@@ -320,7 +369,8 @@ class GodfatherCLI:
|
|
|
320
369
|
'3': picker.STATUS,
|
|
321
370
|
'4': picker.LOGOUT,
|
|
322
371
|
'5': picker.SESSIONS,
|
|
323
|
-
'6': picker.
|
|
372
|
+
'6': picker.VSCODE,
|
|
373
|
+
'7': picker.EXIT,
|
|
324
374
|
}
|
|
325
375
|
while True:
|
|
326
376
|
console.print()
|
|
@@ -332,7 +382,8 @@ class GodfatherCLI:
|
|
|
332
382
|
menu.add_row("3.", "Show status")
|
|
333
383
|
menu.add_row("4.", "Log out")
|
|
334
384
|
menu.add_row("5.", "Show sessions")
|
|
335
|
-
menu.add_row("6.", "
|
|
385
|
+
menu.add_row("6.", "Set up VS Code")
|
|
386
|
+
menu.add_row("7.", "Exit")
|
|
336
387
|
console.print(Panel(menu, title="What would you like to do?", border_style=BORDER, box=BOX))
|
|
337
388
|
|
|
338
389
|
try:
|
|
@@ -370,6 +421,8 @@ Examples:
|
|
|
370
421
|
godfather connect <pod-id> Connect to a specific pod
|
|
371
422
|
godfather connect --admin Connect as root (officers, on a pod with pod admin on)
|
|
372
423
|
godfather sessions Show upcoming sessions on your pods
|
|
424
|
+
godfather vscode Set up VS Code Remote-SSH for a pod, picking from a list
|
|
425
|
+
godfather vscode <pod-id> Set up VS Code for a pod, or renew its 12-hour certificate
|
|
373
426
|
godfather status Show login and configuration status
|
|
374
427
|
godfather auth Log in with Discord in your browser
|
|
375
428
|
godfather auth --paste Log in by pasting the token (browser on another computer)
|
|
@@ -383,20 +436,20 @@ Questions or issues: https://discord.gg/fXWXwz6fEG
|
|
|
383
436
|
parser.add_argument(
|
|
384
437
|
'command',
|
|
385
438
|
nargs='?',
|
|
386
|
-
choices=['list', 'connect', 'sessions', 'status', 'logout', 'auth', 'update'],
|
|
439
|
+
choices=['list', 'connect', 'vscode', 'sessions', 'status', 'logout', 'auth', 'update'],
|
|
387
440
|
help='Command to run (omit to open the interactive menu)',
|
|
388
441
|
)
|
|
389
|
-
parser.add_argument('pod_id', nargs='?', help='Pod ID
|
|
442
|
+
parser.add_argument('pod_id', nargs='?', help='Pod ID (with "connect" or "vscode")')
|
|
390
443
|
parser.add_argument('--api-url', help='Use a specific platform API URL instead of the default')
|
|
391
444
|
parser.add_argument('--org', help='Organization prefix on the platform (default: ais)')
|
|
392
445
|
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
|
|
446
|
+
parser.add_argument('--admin', action='store_true', help='With "connect" or "vscode": ask for root')
|
|
394
447
|
|
|
395
448
|
args = parser.parse_args()
|
|
396
449
|
if args.paste and args.command != 'auth':
|
|
397
450
|
parser.error('--paste works only with "auth"')
|
|
398
|
-
if args.admin and args.command
|
|
399
|
-
parser.error('--admin works only with "connect"')
|
|
451
|
+
if args.admin and args.command not in ('connect', 'vscode'):
|
|
452
|
+
parser.error('--admin works only with "connect" and "vscode"')
|
|
400
453
|
|
|
401
454
|
if args.api_url:
|
|
402
455
|
os.environ['GODFATHER_API_URL'] = args.api_url
|
|
@@ -420,6 +473,8 @@ Questions or issues: https://discord.gg/fXWXwz6fEG
|
|
|
420
473
|
cli.list_pods()
|
|
421
474
|
elif args.command == 'connect':
|
|
422
475
|
cli.connect_to_pod(args.pod_id, admin=args.admin)
|
|
476
|
+
elif args.command == 'vscode':
|
|
477
|
+
cli.setup_vscode(args.pod_id, admin=args.admin)
|
|
423
478
|
elif args.command == 'sessions':
|
|
424
479
|
cli.list_sessions()
|
|
425
480
|
elif args.command == 'status':
|
|
@@ -40,6 +40,7 @@ STATUS_CLASS = {
|
|
|
40
40
|
CONNECT = 'connect'
|
|
41
41
|
LIST = 'list'
|
|
42
42
|
SESSIONS = 'sessions'
|
|
43
|
+
VSCODE = 'vscode'
|
|
43
44
|
STATUS = 'status'
|
|
44
45
|
LOGIN = 'login'
|
|
45
46
|
LOGOUT = 'logout'
|
|
@@ -117,6 +118,7 @@ def menu_options(logged_in: bool) -> List[Tuple[str, str, str]]:
|
|
|
117
118
|
(CONNECT, 'Connect to a pod', 'Pick a running pod and open an SSH session'),
|
|
118
119
|
(LIST, 'List pods', 'Show every pod you can use, with its status and ID'),
|
|
119
120
|
(SESSIONS, 'Sessions', 'Upcoming sessions on your pods'),
|
|
121
|
+
(VSCODE, 'Set up VS Code', 'Open a pod in VS Code Remote-SSH'),
|
|
120
122
|
(STATUS, 'Status', 'Server, org, login, token expiry and CLI version'),
|
|
121
123
|
account,
|
|
122
124
|
(EXIT, 'Exit', 'Quit Godfather'),
|
|
@@ -11,6 +11,55 @@ from rich.panel import Panel
|
|
|
11
11
|
from .ui import BORDER, BOX, console, error, success
|
|
12
12
|
|
|
13
13
|
USERNAME_RE = re.compile(r'^[a-z0-9_][a-z0-9._-]{0,21}$')
|
|
14
|
+
POD_ID_RE = re.compile(r'^[A-Za-z0-9_-]{1,64}$')
|
|
15
|
+
# The line in ~/.ssh/config that loads the Host blocks this CLI writes.
|
|
16
|
+
INCLUDE_MARK = '# Added by godfather-cli for VS Code Remote-SSH'
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def host_alias(pod_id: str) -> str:
|
|
20
|
+
"""The ssh_config Host name of a pod."""
|
|
21
|
+
return f'godfather-{pod_id}'
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def host_block(alias: str, ssh_info: Dict, key_file: Path, cert_file: Path) -> str:
|
|
25
|
+
"""One ssh_config Host block for a pod, with the CLI key and the pod certificate."""
|
|
26
|
+
return '\n'.join(
|
|
27
|
+
[
|
|
28
|
+
f'Host {alias}',
|
|
29
|
+
f' HostName {ssh_info["host"]}',
|
|
30
|
+
f' Port {ssh_info.get("port", 22)}',
|
|
31
|
+
f' User {ssh_info.get("username", "root")}',
|
|
32
|
+
f' IdentityFile "{key_file.as_posix()}"',
|
|
33
|
+
f' CertificateFile "{cert_file.as_posix()}"',
|
|
34
|
+
' IdentitiesOnly yes',
|
|
35
|
+
' StrictHostKeyChecking no',
|
|
36
|
+
' UserKnownHostsFile /dev/null',
|
|
37
|
+
' LogLevel ERROR',
|
|
38
|
+
'',
|
|
39
|
+
]
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def replace_block(text: str, alias: str, block: str) -> str:
|
|
44
|
+
"""The config text with the Host block of alias replaced by block, or block appended."""
|
|
45
|
+
kept: List[str] = []
|
|
46
|
+
skipping = False
|
|
47
|
+
for line in text.splitlines():
|
|
48
|
+
if line.startswith('Host '):
|
|
49
|
+
skipping = line.split()[1:] == [alias]
|
|
50
|
+
if not skipping:
|
|
51
|
+
kept.append(line)
|
|
52
|
+
body = '\n'.join(kept).strip()
|
|
53
|
+
return (body + '\n\n' if body else '') + block
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def with_include(text: str, include_file: Path) -> str:
|
|
57
|
+
"""The ~/.ssh/config text with an Include of include_file at the top, added once."""
|
|
58
|
+
line = f'Include "{include_file.as_posix()}"'
|
|
59
|
+
if line in text.splitlines():
|
|
60
|
+
return text
|
|
61
|
+
# Include must come before the first Host block to apply to every host.
|
|
62
|
+
return f'{INCLUDE_MARK}\n{line}\n\n{text}'
|
|
14
63
|
|
|
15
64
|
|
|
16
65
|
class SSHConnector:
|
|
@@ -57,13 +106,17 @@ class SSHConnector:
|
|
|
57
106
|
error(f"Failed to read SSH public key: {e}")
|
|
58
107
|
return None
|
|
59
108
|
|
|
60
|
-
def
|
|
109
|
+
def pod_cert_file(self, pod_id: str) -> Path:
|
|
110
|
+
"""Where the certificate for one pod is kept for VS Code. Each pod has its own."""
|
|
111
|
+
return self.ssh_key_dir / f'{pod_id}-cert.pub'
|
|
112
|
+
|
|
113
|
+
def save_certificate(self, certificate: str, path: Optional[Path] = None) -> bool:
|
|
61
114
|
"""Store the certificate the API issued for this connection."""
|
|
62
115
|
if not certificate:
|
|
63
116
|
error("The server did not issue an SSH certificate. Update the CLI: pip install -U godfather-cli")
|
|
64
117
|
return False
|
|
65
118
|
try:
|
|
66
|
-
self.cert_file.write_text(certificate.strip() + '\n')
|
|
119
|
+
(path or self.cert_file).write_text(certificate.strip() + '\n')
|
|
67
120
|
return True
|
|
68
121
|
except OSError as e:
|
|
69
122
|
error(f"Failed to save SSH certificate: {e}")
|
|
@@ -145,3 +198,26 @@ class SSHConnector:
|
|
|
145
198
|
|
|
146
199
|
success("Disconnected")
|
|
147
200
|
return 0
|
|
201
|
+
|
|
202
|
+
def write_host(self, pod_id: str, ssh_info: Dict, ssh_config: Path) -> Optional[str]:
|
|
203
|
+
"""Write the Host block of a pod and include it from ssh_config. Returns the Host name, or None."""
|
|
204
|
+
if not POD_ID_RE.match(pod_id) or not ssh_info.get('host'):
|
|
205
|
+
error("Incomplete connection details from the server")
|
|
206
|
+
return None
|
|
207
|
+
alias = host_alias(pod_id)
|
|
208
|
+
own_config = self.ssh_key_dir / 'config'
|
|
209
|
+
block = host_block(alias, ssh_info, self.key_file, self.pod_cert_file(pod_id))
|
|
210
|
+
try:
|
|
211
|
+
old = own_config.read_text() if own_config.exists() else ''
|
|
212
|
+
own_config.write_text(replace_block(old, alias, block))
|
|
213
|
+
os.chmod(own_config, 0o600)
|
|
214
|
+
ssh_config.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
215
|
+
text = ssh_config.read_text() if ssh_config.exists() else ''
|
|
216
|
+
updated = with_include(text, own_config)
|
|
217
|
+
if updated != text:
|
|
218
|
+
ssh_config.write_text(updated)
|
|
219
|
+
os.chmod(ssh_config, 0o600)
|
|
220
|
+
except OSError as e:
|
|
221
|
+
error(f"Failed to write the SSH config: {e}")
|
|
222
|
+
return None
|
|
223
|
+
return alias
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: godfather-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.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
|
|
@@ -84,7 +84,7 @@ Windows 10+ ship it.
|
|
|
84
84
|
### Interactive menu
|
|
85
85
|
|
|
86
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
|
|
87
|
+
choose Connect to a pod, List pods, Sessions, Set up VS Code, Status, Log out or Exit, and
|
|
88
88
|
press Enter. Ctrl-C quits.
|
|
89
89
|
|
|
90
90
|
Connect to a pod shows a list of your pods with their status, access and
|
|
@@ -104,6 +104,8 @@ godfather connect # Connect to a pod, picking from a list
|
|
|
104
104
|
godfather connect <pod-id> # Connect to a specific pod
|
|
105
105
|
godfather connect --admin # Ask for a root shell (officers, when pod admin is on)
|
|
106
106
|
godfather sessions # Upcoming sessions on your pods, in local time
|
|
107
|
+
godfather vscode # Set up VS Code Remote-SSH for a pod, picking from a list
|
|
108
|
+
godfather vscode <pod-id> # The same for one pod; run it again to renew the certificate
|
|
107
109
|
godfather status # Show server, org, login, token expiry and version
|
|
108
110
|
godfather auth # Log in with Discord in your browser
|
|
109
111
|
godfather auth --paste # Log in by pasting the token
|
|
@@ -114,6 +116,16 @@ godfather update # Update the CLI to the latest version
|
|
|
114
116
|
godfather --api-url https://platform.example.com --org soda list
|
|
115
117
|
```
|
|
116
118
|
|
|
119
|
+
### VS Code
|
|
120
|
+
|
|
121
|
+
`godfather vscode` sets up a pod for the VS Code Remote - SSH extension:
|
|
122
|
+
|
|
123
|
+
1. It gets a 12-hour certificate for the pod and saves it as `~/.godfather/ssh/<pod-id>-cert.pub`.
|
|
124
|
+
2. It writes a `Host godfather-<pod-id>` block to `~/.godfather/ssh/config` and adds one `Include` line for that file at the top of `~/.ssh/config`.
|
|
125
|
+
3. If the `code` command is on your PATH, it opens VS Code on `/workspace/users/<you>` on the pod. Otherwise run **Remote-SSH: Connect to Host...** in VS Code and pick `godfather-<pod-id>`.
|
|
126
|
+
|
|
127
|
+
VS Code runs as your pod account (`godfather_<you>`), with the same files and limits as `godfather connect`. After 12 hours VS Code cannot reconnect; run `godfather vscode <pod-id>` again. The pod must run image 2.3.0 or later. `godfather vscode --admin` gives officers a root login on a pod with pod admin on.
|
|
128
|
+
|
|
117
129
|
## Files it keeps
|
|
118
130
|
|
|
119
131
|
Everything lives in `~/.godfather/`:
|
|
@@ -121,6 +133,7 @@ Everything lives in `~/.godfather/`:
|
|
|
121
133
|
- `config.json`: your token (mode 600). Treat it like a password.
|
|
122
134
|
- `ssh/id_ed25519`, `ssh/id_ed25519.pub`: your SSH key, created on first connect. The private key never leaves your machine.
|
|
123
135
|
- `ssh/id_ed25519-cert.pub`: the certificate for the last pod you connected to.
|
|
136
|
+
- `ssh/<pod-id>-cert.pub`, `ssh/config`: the certificate and Host block of each pod set up for VS Code.
|
|
124
137
|
|
|
125
138
|
Delete the folder to reset everything.
|
|
126
139
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "godfather-cli"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.3.0"
|
|
8
8
|
description = "Free compute for ASU students: connect to AI Society at ASU GPU pods"
|
|
9
9
|
authors = [
|
|
10
10
|
{name = "AI Society ASU", email = "theaisocietyasu@gmail.com"}
|
|
@@ -145,7 +145,7 @@ def test_menu_offers_login_or_logout():
|
|
|
145
145
|
from godfather_cli.picker import LOGIN, LOGOUT, menu_options
|
|
146
146
|
|
|
147
147
|
actions = [action for action, _, _ in menu_options(True)]
|
|
148
|
-
assert actions == ['connect', 'list', 'sessions', 'status', LOGOUT, 'exit']
|
|
148
|
+
assert actions == ['connect', 'list', 'sessions', 'vscode', 'status', LOGOUT, 'exit']
|
|
149
149
|
assert (LOGIN, 'Log in', 'Sign in with Discord in your browser') in menu_options(False)
|
|
150
150
|
|
|
151
151
|
|
|
@@ -92,7 +92,7 @@ def test_menus_offer_sessions(tmp_path, monkeypatch):
|
|
|
92
92
|
monkeypatch.setattr(cli_module.Path, 'home', lambda: tmp_path)
|
|
93
93
|
monkeypatch.delenv('GODFATHER_API_URL', raising=False)
|
|
94
94
|
godfather = cli_module.GodfatherCLI()
|
|
95
|
-
answers = iter(['5', '
|
|
95
|
+
answers = iter(['5', '7'])
|
|
96
96
|
ran = []
|
|
97
97
|
monkeypatch.setattr(cli_module.Prompt, 'ask', lambda *a, **kw: next(answers))
|
|
98
98
|
monkeypatch.setattr(godfather, 'run_action', ran.append)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from godfather_cli import cli as cli_module
|
|
4
|
+
from godfather_cli.ssh_connector import INCLUDE_MARK, SSHConnector, host_alias, host_block, replace_block, with_include
|
|
5
|
+
|
|
6
|
+
INFO = {
|
|
7
|
+
'host': '203.0.113.7',
|
|
8
|
+
'port': 22041,
|
|
9
|
+
'username': 'root',
|
|
10
|
+
'user_folder': 'alice',
|
|
11
|
+
'is_admin': False,
|
|
12
|
+
'certificate': 'ssh-ed25519-cert-v01@openssh.com AAAA cert',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_host_block_points_at_the_pod_certificate(tmp_path):
|
|
17
|
+
block = host_block('godfather-pod1', INFO, tmp_path / 'id_ed25519', tmp_path / 'pod1-cert.pub')
|
|
18
|
+
lines = block.splitlines()
|
|
19
|
+
assert lines[0] == 'Host godfather-pod1'
|
|
20
|
+
assert ' HostName 203.0.113.7' in lines
|
|
21
|
+
assert ' Port 22041' in lines
|
|
22
|
+
assert ' User root' in lines
|
|
23
|
+
assert f' CertificateFile "{(tmp_path / "pod1-cert.pub").as_posix()}"' in lines
|
|
24
|
+
assert ' IdentitiesOnly yes' in lines
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_replace_block_keeps_other_pods_and_replaces_the_same_pod():
|
|
28
|
+
first = replace_block('', 'godfather-a', 'Host godfather-a\n Port 1\n')
|
|
29
|
+
both = replace_block(first, 'godfather-b', 'Host godfather-b\n Port 2\n')
|
|
30
|
+
renewed = replace_block(both, 'godfather-a', 'Host godfather-a\n Port 3\n')
|
|
31
|
+
assert renewed.count('Host godfather-a') == 1
|
|
32
|
+
assert 'Port 1' not in renewed
|
|
33
|
+
assert 'Port 2' in renewed and 'Port 3' in renewed
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_with_include_goes_first_and_only_once(tmp_path):
|
|
37
|
+
own = tmp_path / 'config'
|
|
38
|
+
text = with_include('Host work\n User me\n', own)
|
|
39
|
+
assert text.splitlines()[0] == INCLUDE_MARK
|
|
40
|
+
assert text.splitlines()[1] == f'Include "{own.as_posix()}"'
|
|
41
|
+
assert text.endswith('Host work\n User me\n')
|
|
42
|
+
assert with_include(text, own) == text
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_write_host_writes_both_files(tmp_path):
|
|
46
|
+
connector = SSHConnector(tmp_path / '.godfather')
|
|
47
|
+
connector.ssh_key_dir.mkdir(parents=True)
|
|
48
|
+
ssh_config = tmp_path / '.ssh' / 'config'
|
|
49
|
+
assert connector.write_host('pod1', INFO, ssh_config) == host_alias('pod1')
|
|
50
|
+
assert 'Host godfather-pod1' in (connector.ssh_key_dir / 'config').read_text()
|
|
51
|
+
assert ssh_config.read_text().count('Include') == 1
|
|
52
|
+
connector.write_host('pod1', INFO, ssh_config)
|
|
53
|
+
assert ssh_config.read_text().count('Include') == 1
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_write_host_refuses_a_strange_pod_id(tmp_path):
|
|
57
|
+
connector = SSHConnector(tmp_path / '.godfather')
|
|
58
|
+
connector.ssh_key_dir.mkdir(parents=True)
|
|
59
|
+
assert connector.write_host('pod1\nHost *', INFO, tmp_path / '.ssh' / 'config') is None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_setup_vscode_saves_a_certificate_for_each_pod(tmp_path, monkeypatch):
|
|
63
|
+
monkeypatch.setattr(cli_module.Path, 'home', lambda: tmp_path)
|
|
64
|
+
monkeypatch.delenv('GODFATHER_API_URL', raising=False)
|
|
65
|
+
monkeypatch.setattr(cli_module.shutil, 'which', lambda name: None)
|
|
66
|
+
godfather = cli_module.GodfatherCLI()
|
|
67
|
+
monkeypatch.setattr(godfather, 'ensure_authenticated', lambda: True)
|
|
68
|
+
monkeypatch.setattr(godfather.ssh_connector, 'ensure_keypair', lambda: 'ssh-ed25519 AAAA')
|
|
69
|
+
asked = []
|
|
70
|
+
|
|
71
|
+
def fetch(pod_id, public_key, admin=False):
|
|
72
|
+
asked.append((pod_id, admin))
|
|
73
|
+
return INFO
|
|
74
|
+
|
|
75
|
+
monkeypatch.setattr(godfather.pod_manager, 'fetch_connection_info', fetch)
|
|
76
|
+
godfather.ssh_connector.ssh_key_dir.mkdir(parents=True)
|
|
77
|
+
godfather.setup_vscode('pod1')
|
|
78
|
+
godfather.setup_vscode('pod2', admin=True)
|
|
79
|
+
|
|
80
|
+
assert asked == [('pod1', False), ('pod2', True)]
|
|
81
|
+
key_dir = Path(godfather.ssh_connector.ssh_key_dir)
|
|
82
|
+
assert (key_dir / 'pod1-cert.pub').read_text().startswith('ssh-ed25519-cert')
|
|
83
|
+
assert (key_dir / 'pod2-cert.pub').exists()
|
|
84
|
+
own = (key_dir / 'config').read_text()
|
|
85
|
+
assert 'Host godfather-pod1' in own and 'Host godfather-pod2' in own
|
|
86
|
+
assert (tmp_path / '.ssh' / 'config').exists()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_admin_flag_works_with_vscode(monkeypatch):
|
|
90
|
+
calls = []
|
|
91
|
+
monkeypatch.setattr(
|
|
92
|
+
cli_module,
|
|
93
|
+
'GodfatherCLI',
|
|
94
|
+
lambda: type('C', (), {'setup_vscode': lambda s, p, admin: calls.append((p, admin))})(),
|
|
95
|
+
)
|
|
96
|
+
monkeypatch.setattr('sys.argv', ['godfather', 'vscode', 'pod1', '--admin'])
|
|
97
|
+
cli_module.main()
|
|
98
|
+
assert calls == [('pod1', True)]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|