granny-devops 0.7.0__tar.gz → 0.8.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.
- {granny_devops-0.7.0 → granny_devops-0.8.0}/PKG-INFO +5 -1
- {granny_devops-0.7.0 → granny_devops-0.8.0}/README.md +4 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/__init__.py +1 -1
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/analyze/__init__.py +2 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/analyze/gpus.py +110 -1
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cli/analyze.py +111 -8
- {granny_devops-0.7.0 → granny_devops-0.8.0}/pyproject.toml +1 -1
- {granny_devops-0.7.0 → granny_devops-0.8.0}/.gitignore +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/LICENSE +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/analyze/costs.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/analyze/credits.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/analyze/lambdas.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/analyze/vpcs.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cdn/__init__.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cdn/bunny.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cli/__init__.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cli/cdn.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cli/cloudflare.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cli/create.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cli/credentials.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cli/dns.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cli/docker.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cli/edge.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cli/email.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cli/main.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cli/serverless.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cli/storage.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cloudflare/__init__.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cloudflare/d1.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cloudflare/r2.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cloudflare/workers.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/__init__.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/auto_certificate.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/cloudfront-security-headers.js +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/manage-dns.sh +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/manage_mailjet_contacts.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/registrars.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/setup_aws_cloudfront.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/setup_bunny_edge_script.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/setup_bunny_storage.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/setup_cognito_identity_pool.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/setup_hetzner_bunny.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/setup_mailjet_dns.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/setup_private_cdn.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/setup_s3_website.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/setup_scaleway_container.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/setup_scaleway_faas.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/setup_workmail.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/www-redirect-function.js +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/credentials/__init__.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/credentials/secrets.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/dns/__init__.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/dns/base.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/dns/bunny.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/dns/cloudflare.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/dns/cloudns.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/dns/desec.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/dns/factory.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/dns/hetzner.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/dns/inwx.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/dns/manual.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/dns/records.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/docker/__init__.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/docker/build_base.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/edge/__init__.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/edge/bunny.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/email/__init__.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/email/mailjet.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/email/mailjet_contacts.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/email/ses_forwarding.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/email/workmail.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/report.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/serverless/__init__.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/serverless/scaleway.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/storage/__init__.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/storage/aws.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/storage/bunny.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/storage/hetzner.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: granny-devops
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation
|
|
5
5
|
Author-email: Martin Wieser <martin.wieser@pseekoo.com>
|
|
6
6
|
License: MIT License
|
|
@@ -263,6 +263,10 @@ granny analyze gpus --provider aws --profile prod --profile dev
|
|
|
263
263
|
granny analyze credits # available balances
|
|
264
264
|
granny analyze costs # MTD + month-end forecast
|
|
265
265
|
|
|
266
|
+
# AWS Capacity Blocks for ML -- discover available H100/H200/A100 blocks
|
|
267
|
+
granny analyze capacity-blocks --instance-type p5.48xlarge --hours 24
|
|
268
|
+
granny analyze capacity-blocks --instance-type p5e.48xlarge --count 2 --hours 168
|
|
269
|
+
|
|
266
270
|
# Cloudflare account resources (Workers, D1, R2, KV)
|
|
267
271
|
granny cloudflare d1 create my-app
|
|
268
272
|
granny cloudflare r2 create my-app-media
|
|
@@ -131,6 +131,10 @@ granny analyze gpus --provider aws --profile prod --profile dev
|
|
|
131
131
|
granny analyze credits # available balances
|
|
132
132
|
granny analyze costs # MTD + month-end forecast
|
|
133
133
|
|
|
134
|
+
# AWS Capacity Blocks for ML -- discover available H100/H200/A100 blocks
|
|
135
|
+
granny analyze capacity-blocks --instance-type p5.48xlarge --hours 24
|
|
136
|
+
granny analyze capacity-blocks --instance-type p5e.48xlarge --count 2 --hours 168
|
|
137
|
+
|
|
134
138
|
# Cloudflare account resources (Workers, D1, R2, KV)
|
|
135
139
|
granny cloudflare d1 create my-app
|
|
136
140
|
granny cloudflare r2 create my-app-media
|
|
@@ -17,9 +17,11 @@ def __getattr__(name: str):
|
|
|
17
17
|
if name in {
|
|
18
18
|
"GpuInstance",
|
|
19
19
|
"GpuReservation",
|
|
20
|
+
"CapacityBlockOffering",
|
|
20
21
|
"aws_profiles",
|
|
21
22
|
"list_aws_gpu_instances",
|
|
22
23
|
"list_aws_gpu_reservations",
|
|
24
|
+
"list_aws_capacity_block_offerings",
|
|
23
25
|
"gcp_projects",
|
|
24
26
|
"list_gcp_gpu_instances",
|
|
25
27
|
"list_gcp_gpu_reservations",
|
|
@@ -16,7 +16,7 @@ from __future__ import annotations
|
|
|
16
16
|
import logging
|
|
17
17
|
import re
|
|
18
18
|
from dataclasses import dataclass, field
|
|
19
|
-
from datetime import datetime, timezone
|
|
19
|
+
from datetime import datetime, timedelta, timezone
|
|
20
20
|
|
|
21
21
|
logger = logging.getLogger(__name__)
|
|
22
22
|
|
|
@@ -148,6 +148,24 @@ class GpuReservation:
|
|
|
148
148
|
commitment_term: str | None
|
|
149
149
|
|
|
150
150
|
|
|
151
|
+
@dataclass
|
|
152
|
+
class CapacityBlockOffering:
|
|
153
|
+
"""An available EC2 Capacity Block for ML offering (not yet purchased)."""
|
|
154
|
+
|
|
155
|
+
account: str
|
|
156
|
+
region: str
|
|
157
|
+
zone: str
|
|
158
|
+
offering_id: str
|
|
159
|
+
instance_type: str
|
|
160
|
+
instance_count: int
|
|
161
|
+
duration_hours: int
|
|
162
|
+
start_time: str
|
|
163
|
+
end_time: str
|
|
164
|
+
upfront_fee: float
|
|
165
|
+
currency: str
|
|
166
|
+
tenancy: str
|
|
167
|
+
|
|
168
|
+
|
|
151
169
|
# ---------------------------------------------------------------------------
|
|
152
170
|
# AWS
|
|
153
171
|
# ---------------------------------------------------------------------------
|
|
@@ -315,6 +333,97 @@ def list_aws_gpu_reservations(
|
|
|
315
333
|
return results
|
|
316
334
|
|
|
317
335
|
|
|
336
|
+
# Regions where Capacity Blocks for ML are generally available.
|
|
337
|
+
# describe-capacity-block-offerings only works here; other regions return
|
|
338
|
+
# UnsupportedOperation. Update as AWS rolls out to new regions.
|
|
339
|
+
_AWS_CAPACITY_BLOCK_REGIONS: tuple[str, ...] = (
|
|
340
|
+
"us-east-1",
|
|
341
|
+
"us-east-2",
|
|
342
|
+
"us-west-2",
|
|
343
|
+
"eu-west-1",
|
|
344
|
+
"eu-west-2",
|
|
345
|
+
"eu-north-1",
|
|
346
|
+
"eu-central-1",
|
|
347
|
+
"ap-northeast-1",
|
|
348
|
+
"ap-southeast-2",
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def list_aws_capacity_block_offerings(
|
|
353
|
+
instance_type: str,
|
|
354
|
+
instance_count: int = 1,
|
|
355
|
+
duration_hours: int = 24,
|
|
356
|
+
start_window_days: int = 30,
|
|
357
|
+
profiles: list[str] | None = None,
|
|
358
|
+
regions: list[str] | None = None,
|
|
359
|
+
) -> list[CapacityBlockOffering]:
|
|
360
|
+
"""Find available EC2 Capacity Block offerings for ML GPU instances.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
instance_type: GPU instance type, e.g. ``p5.48xlarge`` or ``p5e.48xlarge``.
|
|
364
|
+
instance_count: How many instances the block must hold.
|
|
365
|
+
duration_hours: Block length in hours (AWS supports 1, 6, 12, 24, 48,
|
|
366
|
+
72, 168, 336, 504, 672 ... — pass what you need).
|
|
367
|
+
start_window_days: Search for blocks starting between now and this many
|
|
368
|
+
days out.
|
|
369
|
+
profiles: AWS profiles to query (default: all configured profiles).
|
|
370
|
+
regions: Regions to query (default: the known Capacity-Block-enabled
|
|
371
|
+
regions; pass an explicit list to override).
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
Offerings sorted cheapest-first.
|
|
375
|
+
"""
|
|
376
|
+
import boto3
|
|
377
|
+
from botocore.exceptions import BotoCoreError, ClientError
|
|
378
|
+
|
|
379
|
+
profiles = profiles or aws_profiles()
|
|
380
|
+
regions = regions or list(_AWS_CAPACITY_BLOCK_REGIONS)
|
|
381
|
+
now = datetime.now(timezone.utc)
|
|
382
|
+
end = now + timedelta(days=start_window_days)
|
|
383
|
+
|
|
384
|
+
out: list[CapacityBlockOffering] = []
|
|
385
|
+
for profile in profiles:
|
|
386
|
+
for region in regions:
|
|
387
|
+
try:
|
|
388
|
+
session = boto3.Session(profile_name=profile if profile != "default" else None)
|
|
389
|
+
ec2 = session.client("ec2", region_name=region)
|
|
390
|
+
paginator = ec2.get_paginator("describe_capacity_block_offerings")
|
|
391
|
+
for page in paginator.paginate(
|
|
392
|
+
InstanceType=instance_type,
|
|
393
|
+
InstanceCount=instance_count,
|
|
394
|
+
CapacityDurationHours=duration_hours,
|
|
395
|
+
StartDateRange=now,
|
|
396
|
+
EndDateRange=end,
|
|
397
|
+
):
|
|
398
|
+
for o in page.get("CapacityBlockOfferings", []):
|
|
399
|
+
try:
|
|
400
|
+
fee = float(o.get("UpfrontFee", 0) or 0)
|
|
401
|
+
except (TypeError, ValueError):
|
|
402
|
+
fee = 0.0
|
|
403
|
+
out.append(
|
|
404
|
+
CapacityBlockOffering(
|
|
405
|
+
account=profile,
|
|
406
|
+
region=region,
|
|
407
|
+
zone=o.get("AvailabilityZone", ""),
|
|
408
|
+
offering_id=o.get("CapacityBlockOfferingId", ""),
|
|
409
|
+
instance_type=o.get("InstanceType", instance_type),
|
|
410
|
+
instance_count=o.get("InstanceCount", instance_count),
|
|
411
|
+
duration_hours=o.get(
|
|
412
|
+
"CapacityBlockDurationHours", duration_hours
|
|
413
|
+
),
|
|
414
|
+
start_time=_iso(o.get("StartDate")) or "",
|
|
415
|
+
end_time=_iso(o.get("EndDate")) or "",
|
|
416
|
+
upfront_fee=fee,
|
|
417
|
+
currency=o.get("CurrencyCode", "USD"),
|
|
418
|
+
tenancy=o.get("Tenancy", "default"),
|
|
419
|
+
)
|
|
420
|
+
)
|
|
421
|
+
except (BotoCoreError, ClientError) as e:
|
|
422
|
+
logger.debug("AWS %s/%s capacity-block offerings: %s", profile, region, e)
|
|
423
|
+
out.sort(key=lambda o: (o.upfront_fee, o.start_time))
|
|
424
|
+
return out
|
|
425
|
+
|
|
426
|
+
|
|
318
427
|
# ---------------------------------------------------------------------------
|
|
319
428
|
# GCP
|
|
320
429
|
# ---------------------------------------------------------------------------
|
|
@@ -125,13 +125,16 @@ def gpus(
|
|
|
125
125
|
|
|
126
126
|
instances: list = []
|
|
127
127
|
reservations: list = []
|
|
128
|
+
scanned: dict[str, list[str]] = {}
|
|
128
129
|
|
|
129
130
|
for p in targets:
|
|
130
131
|
try:
|
|
131
132
|
if p == "aws":
|
|
133
|
+
aws_targets = list(profiles) or g.aws_profiles()
|
|
134
|
+
scanned["aws"] = aws_targets
|
|
132
135
|
instances.extend(
|
|
133
136
|
g.list_aws_gpu_instances(
|
|
134
|
-
profiles=
|
|
137
|
+
profiles=aws_targets,
|
|
135
138
|
regions=list(regions) or None,
|
|
136
139
|
include_stopped=include_stopped,
|
|
137
140
|
)
|
|
@@ -139,33 +142,35 @@ def gpus(
|
|
|
139
142
|
if include_reserved:
|
|
140
143
|
reservations.extend(
|
|
141
144
|
g.list_aws_gpu_reservations(
|
|
142
|
-
profiles=
|
|
145
|
+
profiles=aws_targets,
|
|
143
146
|
regions=list(regions) or None,
|
|
144
147
|
)
|
|
145
148
|
)
|
|
146
149
|
elif p == "gcp":
|
|
150
|
+
gcp_targets = list(projects) or g.gcp_projects()
|
|
151
|
+
scanned["gcp"] = gcp_targets
|
|
147
152
|
instances.extend(
|
|
148
153
|
g.list_gcp_gpu_instances(
|
|
149
|
-
projects=
|
|
154
|
+
projects=gcp_targets,
|
|
150
155
|
include_stopped=include_stopped,
|
|
151
156
|
)
|
|
152
157
|
)
|
|
153
158
|
if include_reserved:
|
|
154
159
|
reservations.extend(
|
|
155
|
-
g.list_gcp_gpu_reservations(projects=
|
|
160
|
+
g.list_gcp_gpu_reservations(projects=gcp_targets)
|
|
156
161
|
)
|
|
157
162
|
elif p == "azure":
|
|
163
|
+
az_targets = list(subscriptions) or [sid for sid, _ in g.azure_subscriptions()]
|
|
164
|
+
scanned["azure"] = az_targets
|
|
158
165
|
instances.extend(
|
|
159
166
|
g.list_azure_gpu_instances(
|
|
160
|
-
subscriptions=
|
|
167
|
+
subscriptions=az_targets,
|
|
161
168
|
include_stopped=include_stopped,
|
|
162
169
|
)
|
|
163
170
|
)
|
|
164
171
|
if include_reserved:
|
|
165
172
|
reservations.extend(
|
|
166
|
-
g.list_azure_gpu_reservations(
|
|
167
|
-
subscriptions=list(subscriptions) or None
|
|
168
|
-
)
|
|
173
|
+
g.list_azure_gpu_reservations(subscriptions=az_targets)
|
|
169
174
|
)
|
|
170
175
|
except RuntimeError as e:
|
|
171
176
|
click.echo(f"[{p}] skipped: {e}", err=True)
|
|
@@ -178,6 +183,7 @@ def gpus(
|
|
|
178
183
|
click.echo(
|
|
179
184
|
json.dumps(
|
|
180
185
|
{
|
|
186
|
+
"scanned": scanned,
|
|
181
187
|
"instances": [asdict(i) for i in instances],
|
|
182
188
|
"reservations": [asdict(r) for r in reservations],
|
|
183
189
|
},
|
|
@@ -187,6 +193,13 @@ def gpus(
|
|
|
187
193
|
)
|
|
188
194
|
return
|
|
189
195
|
|
|
196
|
+
scope_labels = {"aws": "AWS profiles", "gcp": "GCP projects", "azure": "Azure subscriptions"}
|
|
197
|
+
for p in targets:
|
|
198
|
+
items = scanned.get(p)
|
|
199
|
+
if items is None:
|
|
200
|
+
continue
|
|
201
|
+
click.echo(f"Scanned {scope_labels[p]}: {', '.join(items) if items else '(none)'}")
|
|
202
|
+
|
|
190
203
|
if not instances and not reservations:
|
|
191
204
|
click.echo("No GPU resources found.")
|
|
192
205
|
return
|
|
@@ -227,6 +240,96 @@ def gpus(
|
|
|
227
240
|
)
|
|
228
241
|
|
|
229
242
|
|
|
243
|
+
@analyze.command("capacity-blocks")
|
|
244
|
+
@click.option(
|
|
245
|
+
"--instance-type",
|
|
246
|
+
"instance_type",
|
|
247
|
+
required=True,
|
|
248
|
+
help="GPU instance type, e.g. p5.48xlarge, p5e.48xlarge, p5en.48xlarge, p4d.24xlarge",
|
|
249
|
+
)
|
|
250
|
+
@click.option("--count", "instance_count", default=1, show_default=True, help="Instances per block")
|
|
251
|
+
@click.option(
|
|
252
|
+
"--hours",
|
|
253
|
+
"duration_hours",
|
|
254
|
+
default=24,
|
|
255
|
+
show_default=True,
|
|
256
|
+
help="Block duration in hours (1, 6, 12, 24, 48, 72, 168, 336, 504, 672, ...)",
|
|
257
|
+
)
|
|
258
|
+
@click.option(
|
|
259
|
+
"--start-window-days",
|
|
260
|
+
default=30,
|
|
261
|
+
show_default=True,
|
|
262
|
+
help="Search for blocks starting within this many days from now",
|
|
263
|
+
)
|
|
264
|
+
@click.option("--profile", "profiles", multiple=True, help="AWS profile (repeatable; default: all)")
|
|
265
|
+
@click.option(
|
|
266
|
+
"--region",
|
|
267
|
+
"regions",
|
|
268
|
+
multiple=True,
|
|
269
|
+
help="AWS region (repeatable; default: known Capacity-Block regions)",
|
|
270
|
+
)
|
|
271
|
+
@click.option("--json-output", "as_json", is_flag=True, help="Output as JSON")
|
|
272
|
+
def capacity_blocks(
|
|
273
|
+
instance_type: str,
|
|
274
|
+
instance_count: int,
|
|
275
|
+
duration_hours: int,
|
|
276
|
+
start_window_days: int,
|
|
277
|
+
profiles: tuple[str, ...],
|
|
278
|
+
regions: tuple[str, ...],
|
|
279
|
+
as_json: bool,
|
|
280
|
+
) -> None:
|
|
281
|
+
"""Find available AWS Capacity Blocks for ML (H100/H200/A100 reservations).
|
|
282
|
+
|
|
283
|
+
Scans regions where Capacity Blocks are sold and returns open offerings
|
|
284
|
+
sorted cheapest-first. Use the offering ID with the AWS CLI to purchase:
|
|
285
|
+
|
|
286
|
+
aws ec2 purchase-capacity-block --capacity-block-offering-id <id>
|
|
287
|
+
--instance-platform Linux/UNIX
|
|
288
|
+
"""
|
|
289
|
+
from dataclasses import asdict
|
|
290
|
+
|
|
291
|
+
from granny.analyze.gpus import list_aws_capacity_block_offerings
|
|
292
|
+
|
|
293
|
+
offerings = list_aws_capacity_block_offerings(
|
|
294
|
+
instance_type=instance_type,
|
|
295
|
+
instance_count=instance_count,
|
|
296
|
+
duration_hours=duration_hours,
|
|
297
|
+
start_window_days=start_window_days,
|
|
298
|
+
profiles=list(profiles) or None,
|
|
299
|
+
regions=list(regions) or None,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
if as_json:
|
|
303
|
+
click.echo(json.dumps([asdict(o) for o in offerings], indent=2, default=str))
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
if not offerings:
|
|
307
|
+
click.echo(
|
|
308
|
+
f"No Capacity Block offerings found for {instance_type} x{instance_count} "
|
|
309
|
+
f"({duration_hours}h) in the next {start_window_days} days.\n"
|
|
310
|
+
"Try: a longer --start-window-days, fewer instances, a different "
|
|
311
|
+
"duration, or another --instance-type."
|
|
312
|
+
)
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
click.echo(
|
|
316
|
+
f"\n{'Profile':<14} {'Region':<14} {'AZ':<16} {'Type':<16} "
|
|
317
|
+
f"{'#':>3} {'Hrs':>4} {'Start (UTC)':<22} {'Upfront':>12} {'Cur':<4} {'Offering ID'}"
|
|
318
|
+
)
|
|
319
|
+
click.echo("-" * 140)
|
|
320
|
+
for o in offerings:
|
|
321
|
+
click.echo(
|
|
322
|
+
f"{o.account[:13]:<14} {o.region:<14} {o.zone:<16} "
|
|
323
|
+
f"{o.instance_type:<16} {o.instance_count:>3} {o.duration_hours:>4} "
|
|
324
|
+
f"{o.start_time[:19]:<22} {o.upfront_fee:>12,.2f} {o.currency:<4} "
|
|
325
|
+
f"{o.offering_id}"
|
|
326
|
+
)
|
|
327
|
+
click.echo(
|
|
328
|
+
"\nPurchase with: aws ec2 purchase-capacity-block "
|
|
329
|
+
"--capacity-block-offering-id <id> --instance-platform Linux/UNIX"
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
230
333
|
@analyze.command()
|
|
231
334
|
@click.option(
|
|
232
335
|
"--provider",
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|