dayhoff-tools 1.9.26__py3-none-any.whl → 1.10.1__py3-none-any.whl
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.
- dayhoff_tools/cli/engine/__init__.py +1 -323
- dayhoff_tools/cli/engine/coffee.py +110 -0
- dayhoff_tools/cli/engine/config_ssh.py +113 -0
- dayhoff_tools/cli/engine/debug.py +79 -0
- dayhoff_tools/cli/engine/gami.py +160 -0
- dayhoff_tools/cli/engine/idle.py +148 -0
- dayhoff_tools/cli/engine/launch.py +101 -0
- dayhoff_tools/cli/engine/list.py +116 -0
- dayhoff_tools/cli/engine/repair.py +128 -0
- dayhoff_tools/cli/engine/resize.py +195 -0
- dayhoff_tools/cli/engine/ssh.py +62 -0
- dayhoff_tools/cli/engine/{engine_core.py → status.py} +6 -201
- dayhoff_tools/cli/engine_studio_commands.py +323 -0
- dayhoff_tools/cli/engine_studio_utils/__init__.py +1 -0
- dayhoff_tools/cli/engine_studio_utils/api_utils.py +47 -0
- dayhoff_tools/cli/engine_studio_utils/aws_utils.py +102 -0
- dayhoff_tools/cli/engine_studio_utils/constants.py +21 -0
- dayhoff_tools/cli/engine_studio_utils/formatting.py +210 -0
- dayhoff_tools/cli/engine_studio_utils/ssh_utils.py +141 -0
- dayhoff_tools/cli/main.py +1 -2
- dayhoff_tools/cli/studio/__init__.py +1 -0
- dayhoff_tools/cli/studio/attach.py +314 -0
- dayhoff_tools/cli/studio/create.py +48 -0
- dayhoff_tools/cli/studio/delete.py +71 -0
- dayhoff_tools/cli/studio/detach.py +56 -0
- dayhoff_tools/cli/studio/list.py +81 -0
- dayhoff_tools/cli/studio/reset.py +90 -0
- dayhoff_tools/cli/studio/resize.py +134 -0
- dayhoff_tools/cli/studio/status.py +78 -0
- {dayhoff_tools-1.9.26.dist-info → dayhoff_tools-1.10.1.dist-info}/METADATA +1 -1
- dayhoff_tools-1.10.1.dist-info/RECORD +61 -0
- dayhoff_tools/cli/engine/engine_maintenance.py +0 -431
- dayhoff_tools/cli/engine/engine_management.py +0 -505
- dayhoff_tools/cli/engine/shared.py +0 -501
- dayhoff_tools/cli/engine/studio_commands.py +0 -825
- dayhoff_tools-1.9.26.dist-info/RECORD +0 -39
- /dayhoff_tools/cli/engine/{engine_lifecycle.py → lifecycle.py} +0 -0
- {dayhoff_tools-1.9.26.dist-info → dayhoff_tools-1.10.1.dist-info}/WHEEL +0 -0
- {dayhoff_tools-1.9.26.dist-info → dayhoff_tools-1.10.1.dist-info}/entry_points.txt +0 -0
@@ -1,501 +0,0 @@
|
|
1
|
-
"""Shared utilities, constants, and helper functions for engine and studio commands."""
|
2
|
-
|
3
|
-
import json
|
4
|
-
import os
|
5
|
-
import re
|
6
|
-
import shutil
|
7
|
-
import subprocess
|
8
|
-
import sys
|
9
|
-
import time
|
10
|
-
from datetime import datetime, timedelta, timezone
|
11
|
-
from pathlib import Path
|
12
|
-
from typing import Any, Dict, List, Optional, Tuple
|
13
|
-
|
14
|
-
import boto3
|
15
|
-
import requests
|
16
|
-
import typer
|
17
|
-
from botocore.exceptions import ClientError, NoCredentialsError
|
18
|
-
from rich.console import Console
|
19
|
-
from rich.prompt import Confirm, IntPrompt
|
20
|
-
|
21
|
-
console = Console()
|
22
|
-
|
23
|
-
# Cost information
|
24
|
-
HOURLY_COSTS = {
|
25
|
-
"cpu": 0.50, # r6i.2xlarge
|
26
|
-
"cpumax": 2.02, # r7i.8xlarge
|
27
|
-
"t4": 0.75, # g4dn.2xlarge
|
28
|
-
"a10g": 1.50, # g5.2xlarge
|
29
|
-
"a100": 21.96, # p4d.24xlarge
|
30
|
-
"4_t4": 3.91, # g4dn.12xlarge
|
31
|
-
"8_t4": 7.83, # g4dn.metal
|
32
|
-
"4_a10g": 6.24, # g5.12xlarge
|
33
|
-
"8_a10g": 16.29, # g5.48xlarge
|
34
|
-
}
|
35
|
-
|
36
|
-
# SSH config management
|
37
|
-
SSH_MANAGED_COMMENT = "# Managed by dh engine"
|
38
|
-
|
39
|
-
|
40
|
-
# --------------------------------------------------------------------------------
|
41
|
-
# Bootstrap stage helpers
|
42
|
-
# --------------------------------------------------------------------------------
|
43
|
-
|
44
|
-
|
45
|
-
def _colour_stage(stage: str) -> str:
|
46
|
-
"""Return colourised stage name for table output."""
|
47
|
-
if not stage:
|
48
|
-
return "[dim]-[/dim]"
|
49
|
-
low = stage.lower()
|
50
|
-
if low.startswith("error"):
|
51
|
-
return f"[red]{stage}[/red]"
|
52
|
-
if low == "finished":
|
53
|
-
return f"[green]{stage}[/green]"
|
54
|
-
return f"[yellow]{stage}[/yellow]"
|
55
|
-
|
56
|
-
|
57
|
-
def _fetch_init_stages(instance_ids: List[str]) -> Dict[str, str]:
|
58
|
-
"""Fetch DayhoffInitStage tag for many instances in one call."""
|
59
|
-
if not instance_ids:
|
60
|
-
return {}
|
61
|
-
ec2 = boto3.client("ec2", region_name="us-east-1")
|
62
|
-
stages: Dict[str, str] = {}
|
63
|
-
try:
|
64
|
-
paginator = ec2.get_paginator("describe_instances")
|
65
|
-
for page in paginator.paginate(InstanceIds=instance_ids):
|
66
|
-
for res in page["Reservations"]:
|
67
|
-
for inst in res["Instances"]:
|
68
|
-
iid = inst["InstanceId"]
|
69
|
-
tag_val = next(
|
70
|
-
(
|
71
|
-
t["Value"]
|
72
|
-
for t in inst.get("Tags", [])
|
73
|
-
if t["Key"] == "DayhoffInitStage"
|
74
|
-
),
|
75
|
-
None,
|
76
|
-
)
|
77
|
-
if tag_val:
|
78
|
-
stages[iid] = tag_val
|
79
|
-
except Exception:
|
80
|
-
pass # best-effort
|
81
|
-
return stages
|
82
|
-
|
83
|
-
|
84
|
-
def check_aws_sso() -> str:
|
85
|
-
"""Check AWS SSO status and return username."""
|
86
|
-
try:
|
87
|
-
sts = boto3.client("sts")
|
88
|
-
identity = sts.get_caller_identity()
|
89
|
-
# Parse username from assumed role ARN
|
90
|
-
# Format: arn:aws:sts::123456789012:assumed-role/AWSReservedSSO_DeveloperAccess_xxxx/username
|
91
|
-
arn = identity["Arn"]
|
92
|
-
if "assumed-role" in arn:
|
93
|
-
username = arn.split("/")[-1]
|
94
|
-
return username
|
95
|
-
else:
|
96
|
-
# Fallback for other auth methods
|
97
|
-
return identity["UserId"].split(":")[-1]
|
98
|
-
except (NoCredentialsError, ClientError):
|
99
|
-
console.print("[red]❌ Not logged in to AWS SSO[/red]")
|
100
|
-
console.print("Please run: [cyan]aws sso login[/cyan]")
|
101
|
-
if Confirm.ask("Would you like to login now?"):
|
102
|
-
try:
|
103
|
-
result = subprocess.run(
|
104
|
-
["aws", "sso", "login"],
|
105
|
-
capture_output=True,
|
106
|
-
text=True,
|
107
|
-
check=True,
|
108
|
-
)
|
109
|
-
if result.returncode == 0:
|
110
|
-
console.print("[green]✓ Successfully logged in![/green]")
|
111
|
-
return check_aws_sso()
|
112
|
-
except subprocess.CalledProcessError as e:
|
113
|
-
console.print(f"[red]Login failed: {e}[/red]")
|
114
|
-
raise typer.Exit(1)
|
115
|
-
|
116
|
-
|
117
|
-
def get_api_url() -> str:
|
118
|
-
"""Get Studio Manager API URL from SSM Parameter Store."""
|
119
|
-
ssm = boto3.client("ssm", region_name="us-east-1")
|
120
|
-
try:
|
121
|
-
response = ssm.get_parameter(Name="/dev/studio-manager/api-url")
|
122
|
-
return response["Parameter"]["Value"]
|
123
|
-
except ClientError as e:
|
124
|
-
if e.response["Error"]["Code"] == "ParameterNotFound":
|
125
|
-
console.print(
|
126
|
-
"[red]❌ API URL parameter not found in SSM Parameter Store[/red]"
|
127
|
-
)
|
128
|
-
console.print(
|
129
|
-
"Please ensure the Studio Manager infrastructure is deployed."
|
130
|
-
)
|
131
|
-
else:
|
132
|
-
console.print(f"[red]❌ Error retrieving API URL: {e}[/red]")
|
133
|
-
raise typer.Exit(1)
|
134
|
-
|
135
|
-
|
136
|
-
def make_api_request(
|
137
|
-
method: str,
|
138
|
-
endpoint: str,
|
139
|
-
json_data: Optional[Dict] = None,
|
140
|
-
params: Optional[Dict] = None,
|
141
|
-
) -> requests.Response:
|
142
|
-
"""Make an API request with error handling."""
|
143
|
-
api_url = get_api_url()
|
144
|
-
url = f"{api_url}{endpoint}"
|
145
|
-
|
146
|
-
try:
|
147
|
-
if method == "GET":
|
148
|
-
response = requests.get(url, params=params)
|
149
|
-
elif method == "POST":
|
150
|
-
response = requests.post(url, json=json_data)
|
151
|
-
elif method == "DELETE":
|
152
|
-
response = requests.delete(url)
|
153
|
-
else:
|
154
|
-
raise ValueError(f"Unsupported HTTP method: {method}")
|
155
|
-
|
156
|
-
return response
|
157
|
-
except requests.exceptions.RequestException as e:
|
158
|
-
console.print(f"[red]❌ API request failed: {e}[/red]")
|
159
|
-
raise typer.Exit(1)
|
160
|
-
|
161
|
-
|
162
|
-
def format_duration(duration: timedelta) -> str:
|
163
|
-
"""Format a duration as a human-readable string."""
|
164
|
-
total_seconds = int(duration.total_seconds())
|
165
|
-
hours = total_seconds // 3600
|
166
|
-
minutes = (total_seconds % 3600) // 60
|
167
|
-
|
168
|
-
if hours > 0:
|
169
|
-
return f"{hours}h {minutes}m"
|
170
|
-
else:
|
171
|
-
return f"{minutes}m"
|
172
|
-
|
173
|
-
|
174
|
-
def get_disk_usage_via_ssm(instance_id: str) -> Optional[str]:
|
175
|
-
"""Get disk usage for an engine via SSM.
|
176
|
-
|
177
|
-
Returns:
|
178
|
-
String like "17/50 GB" or None if failed
|
179
|
-
"""
|
180
|
-
try:
|
181
|
-
ssm = boto3.client("ssm", region_name="us-east-1")
|
182
|
-
|
183
|
-
# Run df command to get disk usage
|
184
|
-
response = ssm.send_command(
|
185
|
-
InstanceIds=[instance_id],
|
186
|
-
DocumentName="AWS-RunShellScript",
|
187
|
-
Parameters={
|
188
|
-
"commands": [
|
189
|
-
# Get root filesystem usage in GB
|
190
|
-
'df -BG / | tail -1 | awk \'{gsub(/G/, "", $2); gsub(/G/, "", $3); print $3 "/" $2 " GB"}\''
|
191
|
-
],
|
192
|
-
"executionTimeout": ["10"],
|
193
|
-
},
|
194
|
-
)
|
195
|
-
|
196
|
-
command_id = response["Command"]["CommandId"]
|
197
|
-
|
198
|
-
# Wait for command to complete (with timeout)
|
199
|
-
for _ in range(5): # 5 second timeout
|
200
|
-
time.sleep(1)
|
201
|
-
result = ssm.get_command_invocation(
|
202
|
-
CommandId=command_id,
|
203
|
-
InstanceId=instance_id,
|
204
|
-
)
|
205
|
-
if result["Status"] in ["Success", "Failed"]:
|
206
|
-
break
|
207
|
-
|
208
|
-
if result["Status"] == "Success":
|
209
|
-
output = result["StandardOutputContent"].strip()
|
210
|
-
return output if output else None
|
211
|
-
|
212
|
-
return None
|
213
|
-
|
214
|
-
except Exception as e:
|
215
|
-
# logger.debug(f"Failed to get disk usage for {instance_id}: {e}") # Original code had this line commented out
|
216
|
-
return None
|
217
|
-
|
218
|
-
|
219
|
-
def get_studio_disk_usage_via_ssm(instance_id: str, username: str) -> Optional[str]:
|
220
|
-
"""Get disk usage for a studio via SSM.
|
221
|
-
|
222
|
-
Returns:
|
223
|
-
String like "333/500 GB" or None if failed
|
224
|
-
"""
|
225
|
-
try:
|
226
|
-
ssm = boto3.client("ssm", region_name="us-east-1")
|
227
|
-
|
228
|
-
# Run df command to get studio disk usage
|
229
|
-
response = ssm.send_command(
|
230
|
-
InstanceIds=[instance_id],
|
231
|
-
DocumentName="AWS-RunShellScript",
|
232
|
-
Parameters={
|
233
|
-
"commands": [
|
234
|
-
# Get studio filesystem usage in GB
|
235
|
-
f'df -BG /studios/{username} 2>/dev/null | tail -1 | awk \'{{gsub(/G/, "", $2); gsub(/G/, "", $3); print $3 "/" $2 " GB"}}\''
|
236
|
-
],
|
237
|
-
"executionTimeout": ["10"],
|
238
|
-
},
|
239
|
-
)
|
240
|
-
|
241
|
-
command_id = response["Command"]["CommandId"]
|
242
|
-
|
243
|
-
# Wait for command to complete (with timeout)
|
244
|
-
for _ in range(5): # 5 second timeout
|
245
|
-
time.sleep(1)
|
246
|
-
result = ssm.get_command_invocation(
|
247
|
-
CommandId=command_id,
|
248
|
-
InstanceId=instance_id,
|
249
|
-
)
|
250
|
-
if result["Status"] in ["Success", "Failed"]:
|
251
|
-
break
|
252
|
-
|
253
|
-
if result["Status"] == "Success":
|
254
|
-
output = result["StandardOutputContent"].strip()
|
255
|
-
return output if output else None
|
256
|
-
|
257
|
-
return None
|
258
|
-
|
259
|
-
except Exception:
|
260
|
-
return None
|
261
|
-
|
262
|
-
|
263
|
-
def parse_launch_time(launch_time_str: str) -> datetime:
|
264
|
-
"""Parse launch time from API response."""
|
265
|
-
# Try different datetime formats
|
266
|
-
formats = [
|
267
|
-
"%Y-%m-%dT%H:%M:%S.%fZ",
|
268
|
-
"%Y-%m-%dT%H:%M:%SZ",
|
269
|
-
"%Y-%m-%dT%H:%M:%S%z", # ISO format with timezone
|
270
|
-
"%Y-%m-%dT%H:%M:%S+00:00", # Explicit UTC offset
|
271
|
-
"%Y-%m-%d %H:%M:%S",
|
272
|
-
]
|
273
|
-
|
274
|
-
# First try parsing with fromisoformat for better timezone handling
|
275
|
-
try:
|
276
|
-
# Handle the ISO format properly
|
277
|
-
return datetime.fromisoformat(launch_time_str.replace("Z", "+00:00"))
|
278
|
-
except (ValueError, AttributeError):
|
279
|
-
pass
|
280
|
-
|
281
|
-
# Fallback to manual format parsing
|
282
|
-
for fmt in formats:
|
283
|
-
try:
|
284
|
-
parsed = datetime.strptime(launch_time_str, fmt)
|
285
|
-
# If no timezone info, assume UTC
|
286
|
-
if parsed.tzinfo is None:
|
287
|
-
parsed = parsed.replace(tzinfo=timezone.utc)
|
288
|
-
return parsed
|
289
|
-
except ValueError:
|
290
|
-
continue
|
291
|
-
|
292
|
-
# Fallback: assume it's recent
|
293
|
-
return datetime.now(timezone.utc)
|
294
|
-
|
295
|
-
|
296
|
-
def format_status(state: str, ready: Optional[bool]) -> str:
|
297
|
-
"""Format engine status with ready indicator."""
|
298
|
-
if state.lower() == "running":
|
299
|
-
if ready is True:
|
300
|
-
return "[green]Running ✓[/green]"
|
301
|
-
elif ready is False:
|
302
|
-
return "[yellow]Running ⚠ (Bootstrapping...)[/yellow]"
|
303
|
-
else:
|
304
|
-
return "[green]Running[/green]"
|
305
|
-
elif state.lower() == "stopped":
|
306
|
-
return "[dim]Stopped[/dim]"
|
307
|
-
elif state.lower() == "stopping":
|
308
|
-
return "[yellow]Stopping...[/yellow]"
|
309
|
-
elif state.lower() == "pending":
|
310
|
-
return "[yellow]Starting...[/yellow]"
|
311
|
-
else:
|
312
|
-
return state
|
313
|
-
|
314
|
-
|
315
|
-
def resolve_engine(name_or_id: str, engines: List[Dict]) -> Dict:
|
316
|
-
"""Resolve engine by name or ID with interactive selection."""
|
317
|
-
# Exact ID match
|
318
|
-
exact_id = [e for e in engines if e["instance_id"] == name_or_id]
|
319
|
-
if exact_id:
|
320
|
-
return exact_id[0]
|
321
|
-
|
322
|
-
# Exact name match
|
323
|
-
exact_name = [e for e in engines if e["name"] == name_or_id]
|
324
|
-
if len(exact_name) == 1:
|
325
|
-
return exact_name[0]
|
326
|
-
|
327
|
-
# Prefix matches
|
328
|
-
matches = [
|
329
|
-
e
|
330
|
-
for e in engines
|
331
|
-
if e["name"].startswith(name_or_id) or e["instance_id"].startswith(name_or_id)
|
332
|
-
]
|
333
|
-
|
334
|
-
if len(matches) == 0:
|
335
|
-
console.print(f"[red]❌ No engine found matching '{name_or_id}'[/red]")
|
336
|
-
raise typer.Exit(1)
|
337
|
-
elif len(matches) == 1:
|
338
|
-
return matches[0]
|
339
|
-
else:
|
340
|
-
# Interactive selection
|
341
|
-
console.print(f"Multiple engines match '{name_or_id}':")
|
342
|
-
for i, engine in enumerate(matches, 1):
|
343
|
-
cost = HOURLY_COSTS.get(engine["engine_type"], 0)
|
344
|
-
console.print(
|
345
|
-
f" {i}. [cyan]{engine['name']}[/cyan] ({engine['instance_id']}) "
|
346
|
-
f"- {engine['engine_type']} - {engine['state']} - ${cost:.2f}/hr"
|
347
|
-
)
|
348
|
-
|
349
|
-
while True:
|
350
|
-
try:
|
351
|
-
choice = IntPrompt.ask(
|
352
|
-
"Select engine",
|
353
|
-
default=1,
|
354
|
-
choices=[str(i) for i in range(1, len(matches) + 1)],
|
355
|
-
)
|
356
|
-
return matches[choice - 1]
|
357
|
-
except (ValueError, IndexError):
|
358
|
-
console.print("[red]Invalid selection, please try again[/red]")
|
359
|
-
|
360
|
-
|
361
|
-
def get_ssh_public_key() -> str:
|
362
|
-
"""Get the user's SSH public key.
|
363
|
-
|
364
|
-
Discovery order (container-friendly):
|
365
|
-
1) DHT_SSH_PUBLIC_KEY env var (direct key content)
|
366
|
-
2) DHT_SSH_PUBLIC_KEY_PATH env var (path to a .pub file)
|
367
|
-
3) ssh-agent via `ssh-add -L` (requires SSH_AUTH_SOCK)
|
368
|
-
4) Conventional files: ~/.ssh/id_ed25519.pub, ~/.ssh/id_rsa.pub
|
369
|
-
|
370
|
-
Raises:
|
371
|
-
FileNotFoundError: If no public key can be discovered.
|
372
|
-
"""
|
373
|
-
# 1) Direct env var content
|
374
|
-
env_key = os.environ.get("DHT_SSH_PUBLIC_KEY")
|
375
|
-
if env_key and env_key.strip():
|
376
|
-
return env_key.strip()
|
377
|
-
|
378
|
-
# 2) Env var path
|
379
|
-
env_path = os.environ.get("DHT_SSH_PUBLIC_KEY_PATH")
|
380
|
-
if env_path:
|
381
|
-
p = Path(env_path).expanduser()
|
382
|
-
if p.is_file():
|
383
|
-
try:
|
384
|
-
return p.read_text().strip()
|
385
|
-
except Exception:
|
386
|
-
pass
|
387
|
-
|
388
|
-
# 3) Agent lookup (ssh-add -L)
|
389
|
-
try:
|
390
|
-
if shutil.which("ssh-add") is not None:
|
391
|
-
proc = subprocess.run(["ssh-add", "-L"], capture_output=True, text=True)
|
392
|
-
if proc.returncode == 0 and proc.stdout:
|
393
|
-
keys = [
|
394
|
-
line.strip() for line in proc.stdout.splitlines() if line.strip()
|
395
|
-
]
|
396
|
-
# Prefer ed25519, then rsa
|
397
|
-
for pref in ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256"):
|
398
|
-
for k in keys:
|
399
|
-
if k.startswith(pref + " "):
|
400
|
-
return k
|
401
|
-
# Fallback to first key if types not matched
|
402
|
-
if keys:
|
403
|
-
return keys[0]
|
404
|
-
except Exception:
|
405
|
-
pass
|
406
|
-
|
407
|
-
# 4) Conventional files
|
408
|
-
home = Path.home()
|
409
|
-
key_paths = [home / ".ssh" / "id_ed25519.pub", home / ".ssh" / "id_rsa.pub"]
|
410
|
-
for key_path in key_paths:
|
411
|
-
if key_path.is_file():
|
412
|
-
try:
|
413
|
-
return key_path.read_text().strip()
|
414
|
-
except Exception:
|
415
|
-
continue
|
416
|
-
|
417
|
-
raise FileNotFoundError(
|
418
|
-
"No SSH public key found. Please create one with 'ssh-keygen' first."
|
419
|
-
)
|
420
|
-
|
421
|
-
|
422
|
-
def check_session_manager_plugin():
|
423
|
-
"""Check if AWS Session Manager Plugin is available and warn if not."""
|
424
|
-
if shutil.which("session-manager-plugin") is None:
|
425
|
-
console.print(
|
426
|
-
"[bold red]⚠️ AWS Session Manager Plugin not found![/bold red]\n"
|
427
|
-
"SSH connections to engines require the Session Manager Plugin.\n"
|
428
|
-
"Please install it following the setup guide:\n"
|
429
|
-
"[link]https://github.com/dayhofflabs/nutshell/blob/main/REFERENCE/setup_guides/new-laptop.md[/link]"
|
430
|
-
)
|
431
|
-
return False
|
432
|
-
return True
|
433
|
-
|
434
|
-
|
435
|
-
def update_ssh_config_entry(
|
436
|
-
engine_name: str, instance_id: str, ssh_user: str, idle_timeout: int = 600
|
437
|
-
):
|
438
|
-
"""Add or update a single SSH config entry for the given SSH user.
|
439
|
-
|
440
|
-
Args:
|
441
|
-
engine_name: Host alias to write into ~/.ssh/config
|
442
|
-
instance_id: EC2 instance-id (used by the proxy command)
|
443
|
-
ssh_user: Username to place into the SSH stanza
|
444
|
-
idle_timeout: Idle timeout **in seconds** to pass to the SSM port-forward. 600 = 10 min.
|
445
|
-
"""
|
446
|
-
config_path = Path.home() / ".ssh" / "config"
|
447
|
-
config_path.parent.mkdir(mode=0o700, exist_ok=True)
|
448
|
-
|
449
|
-
# Touch the file if it doesn't exist
|
450
|
-
if not config_path.exists():
|
451
|
-
config_path.touch(mode=0o600)
|
452
|
-
|
453
|
-
# Read existing config
|
454
|
-
content = config_path.read_text()
|
455
|
-
lines = content.splitlines() if content else []
|
456
|
-
|
457
|
-
# Remove any existing entry for this engine
|
458
|
-
new_lines = []
|
459
|
-
skip_until_next_host = False
|
460
|
-
for line in lines:
|
461
|
-
# Check if this is our managed host
|
462
|
-
if (
|
463
|
-
line.strip().startswith(f"Host {engine_name}")
|
464
|
-
and SSH_MANAGED_COMMENT in line
|
465
|
-
):
|
466
|
-
skip_until_next_host = True
|
467
|
-
elif line.strip().startswith("Host ") and skip_until_next_host:
|
468
|
-
skip_until_next_host = False
|
469
|
-
# This is a different host entry, keep it
|
470
|
-
new_lines.append(line)
|
471
|
-
elif not skip_until_next_host:
|
472
|
-
new_lines.append(line)
|
473
|
-
|
474
|
-
# Add the new entry
|
475
|
-
if new_lines and new_lines[-1].strip(): # Add blank line if needed
|
476
|
-
new_lines.append("")
|
477
|
-
|
478
|
-
new_lines.extend(
|
479
|
-
[
|
480
|
-
f"Host {engine_name} {SSH_MANAGED_COMMENT}",
|
481
|
-
f" HostName {instance_id}",
|
482
|
-
f" User {ssh_user}",
|
483
|
-
f" ProxyCommand sh -c \"AWS_SSM_IDLE_TIMEOUT={idle_timeout} aws ssm start-session --target %h --document-name AWS-StartSSHSession --parameters 'portNumber=%p'\"",
|
484
|
-
]
|
485
|
-
)
|
486
|
-
|
487
|
-
# Write back
|
488
|
-
config_path.write_text("\n".join(new_lines))
|
489
|
-
config_path.chmod(0o600)
|
490
|
-
|
491
|
-
|
492
|
-
def get_user_studio(username: str) -> Optional[Dict]:
|
493
|
-
"""Get the current user's studio."""
|
494
|
-
response = make_api_request("GET", "/studios")
|
495
|
-
if response.status_code != 200:
|
496
|
-
return None
|
497
|
-
|
498
|
-
studios = response.json().get("studios", [])
|
499
|
-
user_studios = [s for s in studios if s["user"] == username]
|
500
|
-
|
501
|
-
return user_studios[0] if user_studios else None
|