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.
Files changed (25) hide show
  1. {godfather_cli-2.2.0/godfather_cli.egg-info → godfather_cli-2.3.0}/PKG-INFO +15 -2
  2. {godfather_cli-2.2.0 → godfather_cli-2.3.0}/README.md +14 -1
  3. {godfather_cli-2.2.0 → godfather_cli-2.3.0}/godfather_cli/__init__.py +1 -1
  4. {godfather_cli-2.2.0 → godfather_cli-2.3.0}/godfather_cli/cli.py +74 -19
  5. {godfather_cli-2.2.0 → godfather_cli-2.3.0}/godfather_cli/picker.py +2 -0
  6. {godfather_cli-2.2.0 → godfather_cli-2.3.0}/godfather_cli/ssh_connector.py +78 -2
  7. {godfather_cli-2.2.0 → godfather_cli-2.3.0/godfather_cli.egg-info}/PKG-INFO +15 -2
  8. {godfather_cli-2.2.0 → godfather_cli-2.3.0}/godfather_cli.egg-info/SOURCES.txt +2 -1
  9. {godfather_cli-2.2.0 → godfather_cli-2.3.0}/pyproject.toml +1 -1
  10. {godfather_cli-2.2.0 → godfather_cli-2.3.0}/tests/test_cli.py +1 -1
  11. {godfather_cli-2.2.0 → godfather_cli-2.3.0}/tests/test_sessions.py +1 -1
  12. godfather_cli-2.3.0/tests/test_vscode.py +98 -0
  13. {godfather_cli-2.2.0 → godfather_cli-2.3.0}/LICENSE +0 -0
  14. {godfather_cli-2.2.0 → godfather_cli-2.3.0}/MANIFEST.in +0 -0
  15. {godfather_cli-2.2.0 → godfather_cli-2.3.0}/godfather_cli/auth.py +0 -0
  16. {godfather_cli-2.2.0 → godfather_cli-2.3.0}/godfather_cli/login_server.py +0 -0
  17. {godfather_cli-2.2.0 → godfather_cli-2.3.0}/godfather_cli/pod_manager.py +0 -0
  18. {godfather_cli-2.2.0 → godfather_cli-2.3.0}/godfather_cli/ui.py +0 -0
  19. {godfather_cli-2.2.0 → godfather_cli-2.3.0}/godfather_cli/update_checker.py +0 -0
  20. {godfather_cli-2.2.0 → godfather_cli-2.3.0}/godfather_cli.egg-info/dependency_links.txt +0 -0
  21. {godfather_cli-2.2.0 → godfather_cli-2.3.0}/godfather_cli.egg-info/entry_points.txt +0 -0
  22. {godfather_cli-2.2.0 → godfather_cli-2.3.0}/godfather_cli.egg-info/requires.txt +0 -0
  23. {godfather_cli-2.2.0 → godfather_cli-2.3.0}/godfather_cli.egg-info/top_level.txt +0 -0
  24. {godfather_cli-2.2.0 → godfather_cli-2.3.0}/setup.cfg +0 -0
  25. {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.2.0
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
 
@@ -2,6 +2,6 @@
2
2
  Godfather CLI package
3
3
  """
4
4
 
5
- __version__ = "2.2.0"
5
+ __version__ = "2.3.0"
6
6
  __author__ = "AI Society ASU"
7
7
  __email__ = "admin@ais-asu.com"
@@ -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
- public_key = self.ssh_connector.ensure_keypair()
198
- if not public_key:
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
- ssh_info = None
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
- ssh_info = self.pod_manager.fetch_connection_info(pod_id, public_key, admin=admin)
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
- self.ssh_connector.connect(ssh_info)
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.EXIT,
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.", "Exit")
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 to connect to (only used with "connect")')
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 a root shell')
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 != 'connect':
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 save_certificate(self, certificate: str) -> bool:
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.2.0
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
 
@@ -19,4 +19,5 @@ godfather_cli.egg-info/requires.txt
19
19
  godfather_cli.egg-info/top_level.txt
20
20
  tests/test_cli.py
21
21
  tests/test_login.py
22
- tests/test_sessions.py
22
+ tests/test_sessions.py
23
+ tests/test_vscode.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "godfather-cli"
7
- version = "2.2.0"
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', '6'])
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