-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathprovision.py
More file actions
2034 lines (1782 loc) · 77.7 KB
/
Copy pathprovision.py
File metadata and controls
2034 lines (1782 loc) · 77.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""AWS GPU Instance Provisioner — CLI tool to provision GPU EC2 instances via Terraform."""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
import boto3
import requests
import yaml
from botocore.exceptions import ClientError
from rich.console import Console
from rich.table import Table
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
DEFAULT_REGION = "us-east-1"
DEFAULT_PRICING_SOURCE = "vantage"
PRICING_SOURCES = ("vantage", "aws-api", "none")
VANTAGE_INSTANCES_URL = "https://instances.vantage.sh/instances.json"
SCRIPT_DIR = Path(__file__).resolve().parent
TERRAFORM_TEMPLATE_DIR = SCRIPT_DIR / "terraform"
WORKSPACES_DIR = SCRIPT_DIR / "workspaces"
RECIPES_DIR = SCRIPT_DIR / "recipes"
PRICING_CACHE_DIR = SCRIPT_DIR / ".pricing_cache"
METADATA_FILE = "metadata.json"
USER_CONFIG_PATH = Path.home() / ".config" / "aws-terraform-provisioner" / "config.json"
console = Console()
# ---------------------------------------------------------------------------
# User config (persistent across runs)
# ---------------------------------------------------------------------------
def _load_user_config() -> dict:
"""Load persistent user config; return an empty dict if absent or invalid."""
try:
with open(USER_CONFIG_PATH) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {}
def _save_user_config(config: dict) -> None:
"""Persist user config to disk, creating the parent directory if needed.
Failures (read-only home, full disk, etc.) are logged as warnings — the
current run continues normally, the user can pass --region next time.
"""
try:
USER_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(USER_CONFIG_PATH, "w") as f:
json.dump(config, f, indent=2)
except OSError as e:
console.print(
f" [yellow]Could not persist user config to {USER_CONFIG_PATH} "
f"({type(e).__name__}: {e}). Pass --region <r> next time to skip the prompt.[/yellow]"
)
# Loose regex: AWS region codes are "<area>-<location>-<digit>", e.g. us-east-1,
# eu-west-2, ap-southeast-3. New regions (especially AWS Local Zones / Wavelength
# zones) sometimes break this pattern but the regex catches the common typos.
_REGION_RE = re.compile(r"^[a-z]{2,3}-[a-z]+-\d+$")
def _resolve_region(cli_region: str | None) -> str:
"""Determine the AWS region to use.
Precedence: CLI flag > saved user default > prompt (with hardcoded fallback).
The interactive prompt validates the shape against `_REGION_RE` and accepts
'q'/'quit' to exit. The selected region is persisted only after passing
validation, so a bad value can't poison future runs.
"""
if cli_region:
return cli_region
config = _load_user_config()
default = config.get("region", DEFAULT_REGION)
while True:
answer = console.input(
f"[bold]AWS region[/bold] [dim](default: {default}, 'q' to quit)[/dim]: "
).strip().lower()
if answer in ("q", "quit"):
console.print("Cancelled.")
sys.exit(0)
region = answer or default
if not _REGION_RE.match(region):
console.print(
f" [red]'{region}' doesn't look like an AWS region.[/red] "
"Examples: us-east-1, us-west-2, eu-west-1, ap-southeast-1."
)
continue
break
if region != config.get("region"):
config["region"] = region
_save_user_config(config)
console.print(f" [dim]Saved [bold]{region}[/bold] as your default region.[/dim]\n")
return region
# ---------------------------------------------------------------------------
# AWS credentials & error classification
# ---------------------------------------------------------------------------
def _aws_error_message(exc: Exception, action_hint: str = "") -> str:
"""Translate a boto3/botocore/requests exception into an actionable message.
Use this everywhere we catch AWS errors so the user sees the same shape of
explanation regardless of which API failed.
"""
name = type(exc).__name__
if name in ("NoCredentialsError", "PartialCredentialsError"):
return (
"AWS credentials are not configured. Run `aws configure` (or "
"`aws sso login` if you use SSO), or set AWS_ACCESS_KEY_ID + "
"AWS_SECRET_ACCESS_KEY in your environment."
)
if name in ("EndpointConnectionError", "ConnectTimeoutError", "ReadTimeoutError"):
return (
f"Cannot reach the AWS endpoint ({exc}). Check your network/VPN, "
"DNS, and that the region code is correct."
)
code = ""
try:
code = exc.response["Error"]["Code"] # type: ignore[attr-defined]
except Exception: # noqa: BLE001 - exc may not be a ClientError
pass
if code in ("InvalidClientTokenId", "ExpiredToken", "RequestExpired", "TokenRefreshRequired"):
return (
f"AWS session has expired ({code}). Run `aws sso login` (or refresh "
"your STS credentials) and retry."
)
if code in ("AuthFailure", "SignatureDoesNotMatch", "UnrecognizedClientException"):
return f"AWS authentication failed ({code}). Re-check your access keys / profile."
if code in ("AccessDenied", "AccessDeniedException", "UnauthorizedOperation"):
action = f" ({action_hint})" if action_hint else ""
msg = ""
try:
msg = exc.response["Error"].get("Message", "") # type: ignore[attr-defined]
except Exception: # noqa: BLE001
pass
return (
f"AWS denied the request{action}: {msg or code}. "
"Add the matching IAM permission to your user/role."
)
if code == "OptInRequired":
return (
"The chosen AWS region is not enabled for this account. Enable it at "
"https://console.aws.amazon.com/billing/home#/account, or pick a "
"different region."
)
if code in ("Throttling", "ThrottlingException", "RequestLimitExceeded"):
return f"AWS throttled the request ({code}). Retry in a few seconds."
if code:
return f"AWS error {code}: {exc}"
return f"Unexpected error ({name}): {exc}"
def _verify_aws_credentials(region: str) -> dict:
"""Confirm we have working AWS credentials in the given region.
Returns the STS GetCallerIdentity payload on success, or exits cleanly
with an actionable message on failure. Should be called once at the top
of every flow that touches AWS so the user gets one clear error instead
of a deep boto3 traceback.
"""
try:
ident = boto3.client("sts", region_name=region).get_caller_identity()
except Exception as e: # noqa: BLE001 - intentionally broad; classified below
console.print(f"[red]AWS credential check failed:[/red] {_aws_error_message(e)}")
sys.exit(1)
console.print(
f" [dim]AWS account: [cyan]{ident.get('Account')}[/cyan] "
f"principal: [cyan]{ident.get('Arn')}[/cyan] region: [cyan]{region}[/cyan][/dim]"
)
return ident
# ---------------------------------------------------------------------------
# Instance loading & filtering
# ---------------------------------------------------------------------------
def load_instances(json_path: str) -> list[dict]:
"""Load all GPU instances from the JSON file (all_instances_flat key)."""
with open(json_path) as f:
data = json.load(f)
return data.get("all_instances_flat", [])
def filter_instances(instances: list[dict]) -> list[dict]:
"""Remove fractional/shared GPU instances."""
return [i for i in instances if not i.get("shared_or_fractional_gpu", False)]
def pick_az_for_instance(instance_type: str, region: str) -> str:
"""Return an availability zone in the region that supports the given instance type.
Many GPU instance types are only available in a subset of AZs (e.g. g7e is
not offered in us-east-1a). Picking the alphabetical-first AZ leads to a
confusing terraform apply failure. This helper queries AWS for the actual
set of supported AZs and returns the first one. Exits cleanly with an
actionable message if no AZ supports the instance.
"""
ec2 = boto3.client("ec2", region_name=region)
try:
resp = ec2.describe_instance_type_offerings(
LocationType="availability-zone",
Filters=[{"Name": "instance-type", "Values": [instance_type]}],
)
except Exception as e: # noqa: BLE001
console.print(
f"[red]Failed to check AZ support for {instance_type}:[/red] "
f"{_aws_error_message(e, 'ec2:DescribeInstanceTypeOfferings')}"
)
sys.exit(1)
azs = sorted(o["Location"] for o in resp.get("InstanceTypeOfferings", []))
if not azs:
console.print(
f"[red]No availability zones in {region} support {instance_type}.[/red] "
"Pick a different instance type or region (try `aws ec2 describe-instance-type-offerings "
"--location-type availability-zone --filters Name=instance-type,Values=" + instance_type + "`)."
)
sys.exit(1)
return azs[0]
def check_availability(instances: list[dict], region: str) -> list[dict]:
"""Keep only instance types available in the given region via AWS API."""
ec2 = boto3.client("ec2", region_name=region)
type_names = list({i["instance_type"] for i in instances})
available_types: set[str] = set()
try:
# API only accepts 100 per call
for start in range(0, len(type_names), 100):
batch = type_names[start : start + 100]
paginator = ec2.get_paginator("describe_instance_type_offerings")
for page in paginator.paginate(
LocationType="region",
Filters=[{"Name": "instance-type", "Values": batch}],
):
for offering in page["InstanceTypeOfferings"]:
available_types.add(offering["InstanceType"])
except Exception as e: # noqa: BLE001
console.print(
f"[red]Failed to query EC2 availability in {region}:[/red] "
f"{_aws_error_message(e, 'ec2:DescribeInstanceTypeOfferings')}"
)
sys.exit(1)
available = []
for inst in instances:
if inst["instance_type"] in available_types:
available.append(inst)
else:
console.print(
f" [dim]Skipping {inst['instance_type']} — not available in {region}[/dim]"
)
return available
# ---------------------------------------------------------------------------
# Pricing
# ---------------------------------------------------------------------------
# Map region code to the location name used by the Pricing API
REGION_NAME_MAP = {
"us-east-1": "US East (N. Virginia)",
"us-east-2": "US East (Ohio)",
"us-west-1": "US West (N. California)",
"us-west-2": "US West (Oregon)",
"eu-west-1": "Europe (Ireland)",
"eu-central-1": "Europe (Frankfurt)",
"ap-southeast-1": "Asia Pacific (Singapore)",
"ap-northeast-1": "Asia Pacific (Tokyo)",
}
def _find_latest_cache(region: str, source: str) -> tuple[Path, datetime] | None:
"""Return (path, mtime_utc) for the most recent pricing cache file for (region, source).
Returns None if no matching cache file exists. The caller is responsible for
deciding whether the file is fresh enough to use.
"""
if not PRICING_CACHE_DIR.exists():
return None
prefix = f"pricing_{region}_{source}_"
latest: tuple[Path, datetime] | None = None
for f in PRICING_CACHE_DIR.iterdir():
if not f.name.startswith(prefix) or not f.name.endswith(".json"):
continue
# Extract timestamp from filename: pricing_<region>_<source>_<YYYYMMDD-HHMMSS>.json
ts_part = f.name[len(prefix):-len(".json")]
try:
file_time = datetime.strptime(ts_part, "%Y%m%d-%H%M%S").replace(tzinfo=timezone.utc)
except ValueError:
continue
if latest is None or file_time > latest[1]:
latest = (f, file_time)
return latest
def _prompt_yes_no(message: str, default_yes: bool = True) -> bool:
"""Prompt the user for a yes/no answer. Empty input takes the default."""
suffix = " [Y/n]: " if default_yes else " [y/N]: "
answer = console.input(message + suffix).strip().lower()
if not answer:
return default_yes
return answer in ("y", "yes")
def _save_pricing_cache(region: str, source: str, prices: dict[str, float | None]) -> None:
"""Write pricing data to a timestamped cache file (tagged with source).
Skips writing when every price is None — that indicates the upstream call
failed for every instance type (e.g. IAM AccessDenied for aws-api, or a
network error for vantage) and writing the all-null result would poison
the 24h cache window.
"""
if not any(v is not None for v in prices.values()):
return
PRICING_CACHE_DIR.mkdir(parents=True, exist_ok=True)
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
cache_path = PRICING_CACHE_DIR / f"pricing_{region}_{source}_{ts}.json"
with open(cache_path, "w") as f:
json.dump(prices, f, indent=2)
def _load_pricing_cache(cache_path: Path) -> dict[str, float | None]:
"""Load pricing data from a cache file.
If the file is corrupted, delete it and return an empty dict so the next
`fetch_pricing` call refreshes — one bad cache shouldn't crash the tool.
"""
try:
with open(cache_path) as f:
raw = json.load(f)
return {k: (float(v) if v is not None else None) for k, v in raw.items()}
except (OSError, json.JSONDecodeError, TypeError, ValueError) as e:
console.print(
f" [yellow]Pricing cache {cache_path.name} unreadable ({type(e).__name__}: {e}); "
"removing it and re-fetching.[/yellow]"
)
try:
cache_path.unlink()
except OSError:
pass
return {}
def fetch_pricing(
instance_types: list[str], region: str, source: str
) -> dict[str, float | None]:
"""Dispatch on-demand pricing fetch to the chosen source.
Sources:
- "vantage": public instances.json (no AWS auth required)
- "aws-api": AWS Pricing API via boto3 (requires pricing:GetProducts IAM permission)
- "none": skip pricing entirely (all values None)
"""
if source == "none":
return {it: None for it in instance_types}
if source == "vantage":
return fetch_pricing_vantage(instance_types, region)
if source == "aws-api":
return fetch_pricing_aws_api(instance_types, region)
raise ValueError(
f"Unknown pricing source {source!r}; expected one of {PRICING_SOURCES}"
)
def fetch_pricing_vantage(
instance_types: list[str], region: str
) -> dict[str, float | None]:
"""Fetch on-demand prices from Vantage's public instances.json.
No AWS auth needed. Downloads ~200 MB on cache miss; cached for 24h via
the standard cache layer.
"""
console.print(" Downloading public pricing from Vantage (~200 MB)...")
try:
resp = requests.get(VANTAGE_INSTANCES_URL, timeout=120)
resp.raise_for_status()
data = resp.json()
except Exception as e:
console.print(
f" [yellow]Vantage pricing fetch failed: {type(e).__name__}: {e}[/yellow]"
)
return {it: None for it in instance_types}
want = set(instance_types)
prices: dict[str, float | None] = {it: None for it in instance_types}
for inst in data:
itype = inst.get("instance_type")
if itype not in want:
continue
try:
prices[itype] = float(inst["pricing"][region]["linux"]["ondemand"])
except (KeyError, TypeError, ValueError):
prices[itype] = None
return prices
def fetch_pricing_aws_api(
instance_types: list[str], region: str
) -> dict[str, float | None]:
"""Fetch on-demand hourly pricing via the AWS Pricing API.
Requires the calling IAM principal to have ``pricing:GetProducts``.
Returns a dict mapping instance_type -> price_per_hour (USD), or None if
the price could not be determined.
"""
# The Pricing API is only available in us-east-1 and ap-south-1
pricing = boto3.client("pricing", region_name="us-east-1")
location = REGION_NAME_MAP.get(region)
if location is None:
console.print(
f" [yellow]Region {region!r} is not in REGION_NAME_MAP; "
"Pricing API would return wrong results. Skipping pricing for this run — "
"consider --pricing-source=vantage which supports all regions.[/yellow]"
)
return {it: None for it in instance_types}
prices: dict[str, float | None] = {}
first_error: Exception | None = None
for itype in instance_types:
try:
resp = pricing.get_products(
ServiceCode="AmazonEC2",
Filters=[
{"Type": "TERM_MATCH", "Field": "instanceType", "Value": itype},
{"Type": "TERM_MATCH", "Field": "location", "Value": location},
{"Type": "TERM_MATCH", "Field": "operatingSystem", "Value": "Linux"},
{"Type": "TERM_MATCH", "Field": "tenancy", "Value": "Shared"},
{"Type": "TERM_MATCH", "Field": "preInstalledSw", "Value": "NA"},
{"Type": "TERM_MATCH", "Field": "capacitystatus", "Value": "Used"},
],
MaxResults=1,
)
if resp["PriceList"]:
product = json.loads(resp["PriceList"][0])
on_demand = product.get("terms", {}).get("OnDemand", {})
for term in on_demand.values():
for dim in term.get("priceDimensions", {}).values():
usd = dim.get("pricePerUnit", {}).get("USD")
if usd:
prices[itype] = float(usd)
break
if itype in prices:
break
if itype not in prices:
prices[itype] = None
except Exception as e:
if first_error is None:
first_error = e
prices[itype] = None
if first_error is not None:
console.print(
f" [yellow]Pricing API call failed: {type(first_error).__name__}: {first_error}[/yellow]"
)
return prices
def _resolve_pricing(
instance_types: list[str], region: str, source: str
) -> dict[str, float | None]:
"""Return prices for the requested instance types, using the cache when appropriate.
Behavior by cache age:
- No cache → fetch fresh silently.
- Cache < 24h old → use silently; backfill any newly-requested instance types.
- Cache ≥ 24h old → prompt the user before refreshing. If the user declines,
the stale cache is used as-is.
"""
latest = _find_latest_cache(region, source)
now = datetime.now(timezone.utc)
if latest is None:
prices = fetch_pricing(instance_types, region, source)
_save_pricing_cache(region, source, prices)
return prices
cache_path, file_time = latest
age_hours = (now - file_time).total_seconds() / 3600
if age_hours < 24:
console.print(
f" Using cached pricing from {cache_path.name} ({age_hours:.1f}h old)"
)
prices = _load_pricing_cache(cache_path)
missing = [t for t in instance_types if t not in prices]
if missing:
console.print(f" Fetching {len(missing)} uncached prices...")
fresh = fetch_pricing(missing, region, source)
prices.update(fresh)
_save_pricing_cache(region, source, prices)
return prices
# Stale cache: ask the user before refreshing.
console.print(
f" [yellow]Cached pricing is {age_hours:.1f}h old "
f"(file: {cache_path.name}).[/yellow]"
)
if _prompt_yes_no(f" Refresh from '{source}' for the latest prices?", default_yes=True):
prices = fetch_pricing(instance_types, region, source)
_save_pricing_cache(region, source, prices)
return prices
console.print(f" [yellow]Keeping stale cache.[/yellow]")
return _load_pricing_cache(cache_path)
# ---------------------------------------------------------------------------
# Display
# ---------------------------------------------------------------------------
GPU_TYPE_SORT_ORDER = {
"NVIDIA K80": 0,
"NVIDIA M60": 1,
"NVIDIA T4": 2,
"NVIDIA T4g": 3,
"AMD Radeon Pro V520": 4,
"NVIDIA A10G": 5,
"NVIDIA V100": 6,
"NVIDIA L4": 7,
"NVIDIA L40S": 8,
"NVIDIA A100": 9,
"NVIDIA RTX PRO Server 6000": 10,
"NVIDIA H100": 11,
"NVIDIA H200": 12,
"NVIDIA B200": 13,
"NVIDIA B300": 14,
}
def _gpu_sort_key(inst: dict) -> tuple:
gpu_rank = GPU_TYPE_SORT_ORDER.get(inst.get("gpu_type", ""), 99)
return (gpu_rank, inst.get("gpu_memory_total_gib", 0), inst.get("vcpus", 0))
def display_table(instances: list[dict], prices: dict[str, float | None]) -> None:
"""Print a rich table of GPU instances with pricing."""
table = Table(title="Available GPU Instances", show_lines=False)
table.add_column("#", justify="right", style="cyan", no_wrap=True)
table.add_column("Instance Type", style="green")
table.add_column("Gen", justify="center")
table.add_column("GPU Type", style="magenta")
table.add_column("GPUs", justify="right")
table.add_column("GPU Mem (GiB)", justify="right")
table.add_column("vCPUs", justify="right")
table.add_column("RAM (GiB)", justify="right")
table.add_column("$/hr", justify="right", style="yellow")
last_gpu_type: str | None = None
for idx, inst in enumerate(instances, 1):
gpu_type = inst.get("gpu_type", "?")
if last_gpu_type is not None and gpu_type != last_gpu_type:
table.add_section()
last_gpu_type = gpu_type
gen_label = "prev" if inst.get("generation_status") == "previous" else "curr"
price = prices.get(inst["instance_type"])
price_str = f"{price:.2f}" if price is not None else "n/a"
table.add_row(
str(idx),
inst["instance_type"],
gen_label,
gpu_type,
str(inst.get("gpu_count", "?")),
str(inst.get("gpu_memory_total_gib", "?")),
str(inst.get("vcpus", "?")),
str(inst.get("system_memory_gib", "?")),
price_str,
)
console.print(table)
STORAGE_TYPES = ("s3", "ebs", "efs")
DEFAULT_STORAGE_TYPE = "ebs"
DEFAULT_STORAGE_SIZE_GB = 100
# Maps the user-friendly --ami choice to the actual AWS AMI Name filter pattern.
# "pytorch" → full DLAMI with PyTorch + CUDA + cuDNN + NCCL + drivers (recommended default)
# "tensorflow" → full DLAMI with TensorFlow + CUDA + cuDNN + NCCL + drivers
# "base" → minimal DLAMI: NVIDIA OSS drivers + CUDA + cuDNN, no frameworks
AMI_PATTERNS = {
"pytorch": "Deep Learning OSS Nvidia Driver AMI GPU PyTorch * (Ubuntu 22.04)*",
"tensorflow": "Deep Learning OSS Nvidia Driver AMI GPU TensorFlow * (Ubuntu 22.04)*",
"base": "Deep Learning Base OSS Nvidia Driver GPU AMI (Ubuntu 22.04)*",
}
DEFAULT_AMI = "pytorch"
def _probe_storage_permissions(storage_type: str, region: str) -> str | None:
"""Probe whether current AWS credentials can create the chosen storage type.
For S3 + IAM, actually attempts CreateBucket / CreateRole (and immediately
rolls back) since list/describe perms are not a reliable proxy for create
perms — some accounts grant read but not write. For EBS / EFS, uses
describe-API smoke tests because real create-and-delete probes have
measurable cost or take noticeably longer.
Returns None if the probe passes, or a human-readable explanation if not.
"""
if storage_type == "none":
return None
import uuid
from botocore.exceptions import ClientError
def _is_denied(exc: ClientError) -> bool:
code = exc.response.get("Error", {}).get("Code", "")
return code in (
"AccessDenied",
"AccessDeniedException",
"UnauthorizedOperation",
)
if storage_type == "s3":
# Real CreateBucket + DeleteBucket probe (S3 buckets are free).
# The app does not create or modify IAM — the user supplies their
# own existing instance profile with S3 access, so we don't probe IAM.
s3 = boto3.client("s3", region_name=region)
test_bucket = f"provisioner-probe-{uuid.uuid4().hex[:20]}"
created = False
try:
if region == "us-east-1":
s3.create_bucket(Bucket=test_bucket)
else:
s3.create_bucket(
Bucket=test_bucket,
CreateBucketConfiguration={"LocationConstraint": region},
)
created = True
except ClientError as e:
if _is_denied(e):
return (
"Missing s3:CreateBucket. Need S3 perms (s3:CreateBucket, "
"s3:DeleteBucket, s3:PutBucketTagging, s3:GetBucketLocation) "
"to create the workspace's storage bucket."
)
return _aws_error_message(e, "s3:CreateBucket")
except Exception as e: # noqa: BLE001 - credential/network/etc.
return _aws_error_message(e, "s3:CreateBucket")
if created:
try:
s3.delete_bucket(Bucket=test_bucket)
except Exception as e: # noqa: BLE001
console.print(
f" [yellow]Probe bucket {test_bucket} could not be deleted "
f"({type(e).__name__}). Clean up manually: "
f"aws s3 rb s3://{test_bucket} --region {region}[/yellow]"
)
return None
if storage_type == "ebs":
try:
boto3.client("ec2", region_name=region).describe_volumes(MaxResults=5)
except ClientError as e:
if _is_denied(e):
return (
"EBS access is denied. Need ec2:CreateVolume, ec2:AttachVolume, "
"ec2:DescribeVolumes, ec2:DeleteVolume."
)
return _aws_error_message(e, "ec2:DescribeVolumes")
except Exception as e: # noqa: BLE001
return _aws_error_message(e, "ec2:DescribeVolumes")
return None
if storage_type == "efs":
try:
boto3.client("efs", region_name=region).describe_file_systems(MaxItems=1)
except ClientError as e:
if _is_denied(e):
return (
"EFS access is denied. Need elasticfilesystem:CreateFileSystem, "
"CreateMountTarget, DescribeFileSystems, DescribeMountTargets, "
"DeleteFileSystem, DeleteMountTarget."
)
return _aws_error_message(e, "elasticfilesystem:DescribeFileSystems")
except Exception as e: # noqa: BLE001
return _aws_error_message(e, "elasticfilesystem:DescribeFileSystems")
return None
return None
def prompt_storage_options(region: str = DEFAULT_REGION) -> tuple[str, int]:
"""Ask the user for storage type and size.
Probes permissions for every type upfront, then shows availability next to
each option in the prompt and defaults to the first available type
(preferring s3 > ebs > efs > none). The user can still pick an unavailable
type and will see the specific error before re-prompting.
This function does NOT ask about IAM — that is the user's responsibility.
If you want an IAM instance profile attached (e.g. for mountpoint-s3 to
authenticate when storage_type=s3), pass it via the --iam-instance-profile
CLI flag.
Returns (storage_type, storage_size_gb). storage_size_gb is honored for
EBS data volumes; for S3 and EFS it is informational.
"""
console.print()
console.print(" Checking AWS permissions for each storage type...")
availability: dict[str, str | None] = {}
for t in STORAGE_TYPES:
availability[t] = _probe_storage_permissions(t, region)
# Default to the first available type in preference order. The default
# storage type comes first; if it's unavailable we fall through to the
# other supported types.
preference = (DEFAULT_STORAGE_TYPE,) + tuple(t for t in STORAGE_TYPES if t != DEFAULT_STORAGE_TYPE)
default_type = next(
(t for t in preference if availability.get(t) is None), DEFAULT_STORAGE_TYPE
)
# Format option list with availability markers.
option_strs = []
for t in STORAGE_TYPES:
if availability[t] is None:
option_strs.append(f"[green]{t}[/green]")
else:
option_strs.append(f"[red]{t}[/red] (no perms)")
while True:
console.print()
raw_type = console.input(
f"[bold]Storage type[/bold] ({', '.join(option_strs)}) "
f"[dim]default: {default_type}, 'q' to quit[/dim]: "
).strip().lower()
if raw_type in ("q", "quit"):
console.print("Cancelled.")
sys.exit(0)
storage_type = raw_type or default_type
if storage_type not in STORAGE_TYPES:
console.print(
f" [yellow]Unknown storage type {raw_type!r}; pick one of "
f"{', '.join(STORAGE_TYPES)}.[/yellow]"
)
continue
if availability[storage_type] is not None:
console.print(f" [red]Permission check failed:[/red] {availability[storage_type]}")
console.print(
" [yellow]Pick a different storage type.[/yellow]"
)
continue
break
while True:
raw_size = console.input(
f"[bold]Storage size in GB[/bold] "
f"[dim]default: {DEFAULT_STORAGE_SIZE_GB}[/dim]: "
).strip()
if not raw_size:
size_gb = DEFAULT_STORAGE_SIZE_GB
break
try:
size_gb = int(raw_size)
except ValueError:
console.print(
f" [yellow]Invalid size {raw_size!r} — enter a positive integer "
f"(or press Enter for {DEFAULT_STORAGE_SIZE_GB} GB).[/yellow]"
)
continue
if size_gb <= 0:
console.print(" [yellow]Size must be positive.[/yellow]")
continue
if size_gb > 16000:
console.print(
f" [yellow]{size_gb} GB exceeds the gp3 maximum (16 TB). "
"Pick a smaller value.[/yellow]"
)
continue
break
console.print(f" Selected: [cyan]{storage_type}[/cyan], [cyan]{size_gb} GB[/cyan]\n")
return storage_type, size_gb
def get_user_selection(instances: list[dict]) -> dict:
"""Prompt the user to pick an instance by number."""
while True:
try:
choice = console.input(
"\n[bold]Enter instance number (or 'q' to quit): [/bold]"
)
if choice.strip().lower() == "q":
console.print("Cancelled.")
sys.exit(0)
idx = int(choice)
if 1 <= idx <= len(instances):
return instances[idx - 1]
console.print(f"[red]Please enter a number between 1 and {len(instances)}[/red]")
except ValueError:
console.print("[red]Invalid input — enter a number.[/red]")
# ---------------------------------------------------------------------------
# Workspace helpers
# ---------------------------------------------------------------------------
def _normalize_instance_type(instance_type: str) -> str:
return instance_type.replace(".", "-")
def create_workspace(instance_type: str) -> Path:
"""Create a new workspace directory for a provisioned instance."""
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
name = f"{_normalize_instance_type(instance_type)}-{ts}"
ws = WORKSPACES_DIR / name
ws.mkdir(parents=True, exist_ok=True)
# Copy terraform templates into workspace (.tf configs + any .tpl files
# referenced by templatefile() calls).
for pattern in ("*.tf", "*.tpl"):
for src in TERRAFORM_TEMPLATE_DIR.glob(pattern):
shutil.copy2(src, ws / src.name)
return ws
def create_key_pair(workspace_name: str, workspace_dir: Path) -> tuple[str, str]:
"""Generate an SSH key pair locally and return (key_name, public_key).
The private key is written to <workspace_dir>/<name>.pem (mode 0o400). The
OpenSSH public key is returned so Terraform can register it as an
aws_key_pair resource, which ties the key pair's lifecycle to
`terraform destroy` — no key pair is left behind in AWS.
"""
key_name = workspace_name
pem_path = workspace_dir / f"{key_name}.pem"
if shutil.which("ssh-keygen") is None:
console.print(
"[red]ssh-keygen not found. Install OpenSSH to generate the SSH key pair.[/red]"
)
sys.exit(1)
try:
subprocess.run(
[
"ssh-keygen", "-t", "rsa", "-b", "4096", "-m", "PEM",
"-f", str(pem_path), "-N", "", "-q", "-C", key_name,
],
check=True,
)
except (subprocess.CalledProcessError, OSError) as e:
console.print(f"[red]Failed to generate SSH key pair: {e}[/red]")
sys.exit(1)
# ssh-keygen writes the private key 0o600; tighten to 0o400 to match prior behavior.
try:
os.chmod(pem_path, 0o400)
except OSError:
pass
pub_path = Path(f"{pem_path}.pub")
public_key = pub_path.read_text().strip()
pub_path.unlink(missing_ok=True)
return key_name, public_key
def delete_key_pair(key_name: str, region: str) -> None:
"""Delete an AWS key pair. Idempotent on NotFound; warns loudly on permission errors."""
ec2 = boto3.client("ec2", region_name=region)
try:
ec2.delete_key_pair(KeyName=key_name)
except ClientError as e:
code = e.response.get("Error", {}).get("Code", "")
if code == "InvalidKeyPair.NotFound":
return # already gone
if code in ("AccessDenied", "UnauthorizedOperation"):
console.print(
f"[red]Could not delete key pair '{key_name}' (missing "
f"ec2:DeleteKeyPair). Delete manually: "
f"aws ec2 delete-key-pair --key-name {key_name} --region {region}[/red]"
)
return
console.print(
f"[yellow]Warning: could not delete key pair '{key_name}' "
f"({code or type(e).__name__}: {e})[/yellow]"
)
except Exception as e: # noqa: BLE001
console.print(f"[yellow]Warning: could not delete key pair '{key_name}': {e}[/yellow]")
def get_my_public_ip() -> str:
"""Fetch the caller's public IP, with a manual fallback prompt on failure.
Tries checkip.amazonaws.com first, then ifconfig.me as a backup, then asks
the user to enter their public IP / CIDR. Returns the IP as a plain string
(no /32 suffix — the caller appends that). Validates the result is a real
IPv4 address so a captive-portal HTML response can't leak into the SG rule.
"""
import ipaddress
candidates = ("https://checkip.amazonaws.com", "https://ifconfig.me/ip")
last_err = ""
for url in candidates:
try:
resp = requests.get(url, timeout=10)
resp.raise_for_status()
candidate = resp.text.strip()
ipaddress.IPv4Address(candidate)
return candidate
except Exception as e: # noqa: BLE001
last_err = f"{type(e).__name__}: {e}"
continue
console.print(
f" [yellow]Could not auto-detect your public IP ({last_err}). "
"Enter it manually below (e.g. 1.2.3.4) or type 'q' to cancel.[/yellow]"
)
while True:
answer = console.input("[bold]Your public IP[/bold]: ").strip()
if answer.lower() in ("q", "quit"):
console.print("Cancelled.")
sys.exit(0)
# Accept either bare IP or CIDR — strip the netmask for validation.
ip_only = answer.split("/")[0]
try:
ipaddress.IPv4Address(ip_only)
return ip_only
except ValueError:
console.print(f" [red]'{answer}' is not a valid IPv4 address — try again.[/red]")
def _ami_arch_for_instance(instance: dict) -> str:
"""Return 'arm64' or 'x86_64' based on the instance's CPU architecture."""
arch = instance.get("cpu_architecture", "")
if "arm64" in arch.lower() or "graviton" in arch.lower() or "grace" in arch.lower():
return "arm64"
return "x86_64"
def write_tfvars(workspace_dir: Path, variables: dict) -> None:
"""Write a terraform.tfvars.json file."""
tfvars_path = workspace_dir / "terraform.tfvars.json"
with open(tfvars_path, "w") as f:
json.dump(variables, f, indent=2)
# ---------------------------------------------------------------------------
# Terraform execution
# ---------------------------------------------------------------------------
def _check_terraform() -> str:
"""Verify terraform is available and return its path."""
tf = shutil.which("terraform")
if not tf:
console.print(
"[red]Error: 'terraform' not found on PATH. "
"Install it from https://developer.hashicorp.com/terraform/install[/red]"
)
sys.exit(1)
return tf
def _check_ssh_tools() -> None:
"""Verify ssh + scp are on PATH. Exit cleanly with an actionable message if not.
Required for the recipe-install path (_wait_for_ssh, _wait_for_cloud_init,
install_recipe_on_instance). Without this check, subprocess.run raises a
bare FileNotFoundError on hosts without OpenSSH (some minimal containers /
Windows without OpenSSH client).
"""
missing = [tool for tool in ("ssh", "scp") if shutil.which(tool) is None]
if missing:
console.print(
f"[red]Error: {', '.join(missing)} not found on PATH.[/red] "
"Install an OpenSSH client (macOS: built-in; Ubuntu/Debian: "
"`sudo apt install openssh-client`; Windows: `winget install OpenSSH.Client` "
"or use WSL)."
)
sys.exit(1)
_INCOMPLETE_LOCK_WARNING_RE = re.compile(
r"╷\s*\n(?:│[^\n]*\n)*?│\s*Warning: Incomplete lock file information"
r"(?:[^╵])*?╵\s*\n?",
re.DOTALL,
)
def _filter_terraform_init_output(text: str) -> str:
"""Strip the cosmetic 'Incomplete lock file information' warning emitted by
terraform init when using our filesystem_mirror (the mirror only ships the
darwin_arm64 binary, so terraform helpfully warns the lock file is
platform-incomplete — expected and not actionable for end users here).
"""