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.
Files changed (78) hide show
  1. {granny_devops-0.7.0 → granny_devops-0.8.0}/PKG-INFO +5 -1
  2. {granny_devops-0.7.0 → granny_devops-0.8.0}/README.md +4 -0
  3. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/__init__.py +1 -1
  4. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/analyze/__init__.py +2 -0
  5. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/analyze/gpus.py +110 -1
  6. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cli/analyze.py +111 -8
  7. {granny_devops-0.7.0 → granny_devops-0.8.0}/pyproject.toml +1 -1
  8. {granny_devops-0.7.0 → granny_devops-0.8.0}/.gitignore +0 -0
  9. {granny_devops-0.7.0 → granny_devops-0.8.0}/LICENSE +0 -0
  10. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/analyze/costs.py +0 -0
  11. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/analyze/credits.py +0 -0
  12. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/analyze/lambdas.py +0 -0
  13. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/analyze/vpcs.py +0 -0
  14. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cdn/__init__.py +0 -0
  15. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cdn/bunny.py +0 -0
  16. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cli/__init__.py +0 -0
  17. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cli/cdn.py +0 -0
  18. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cli/cloudflare.py +0 -0
  19. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cli/create.py +0 -0
  20. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cli/credentials.py +0 -0
  21. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cli/dns.py +0 -0
  22. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cli/docker.py +0 -0
  23. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cli/edge.py +0 -0
  24. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cli/email.py +0 -0
  25. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cli/main.py +0 -0
  26. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cli/serverless.py +0 -0
  27. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cli/storage.py +0 -0
  28. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cloudflare/__init__.py +0 -0
  29. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cloudflare/d1.py +0 -0
  30. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cloudflare/r2.py +0 -0
  31. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/cloudflare/workers.py +0 -0
  32. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/__init__.py +0 -0
  33. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/auto_certificate.py +0 -0
  34. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/cloudfront-security-headers.js +0 -0
  35. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/manage-dns.sh +0 -0
  36. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/manage_mailjet_contacts.py +0 -0
  37. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/registrars.py +0 -0
  38. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/setup_aws_cloudfront.py +0 -0
  39. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/setup_bunny_edge_script.py +0 -0
  40. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/setup_bunny_storage.py +0 -0
  41. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/setup_cognito_identity_pool.py +0 -0
  42. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/setup_hetzner_bunny.py +0 -0
  43. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/setup_mailjet_dns.py +0 -0
  44. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/setup_private_cdn.py +0 -0
  45. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/setup_s3_website.py +0 -0
  46. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/setup_scaleway_container.py +0 -0
  47. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/setup_scaleway_faas.py +0 -0
  48. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/setup_workmail.py +0 -0
  49. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/create/www-redirect-function.js +0 -0
  50. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/credentials/__init__.py +0 -0
  51. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/credentials/secrets.py +0 -0
  52. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/dns/__init__.py +0 -0
  53. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/dns/base.py +0 -0
  54. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/dns/bunny.py +0 -0
  55. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/dns/cloudflare.py +0 -0
  56. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/dns/cloudns.py +0 -0
  57. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/dns/desec.py +0 -0
  58. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/dns/factory.py +0 -0
  59. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/dns/hetzner.py +0 -0
  60. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/dns/inwx.py +0 -0
  61. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/dns/manual.py +0 -0
  62. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/dns/records.py +0 -0
  63. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/docker/__init__.py +0 -0
  64. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/docker/build_base.py +0 -0
  65. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/edge/__init__.py +0 -0
  66. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/edge/bunny.py +0 -0
  67. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/email/__init__.py +0 -0
  68. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/email/mailjet.py +0 -0
  69. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/email/mailjet_contacts.py +0 -0
  70. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/email/ses_forwarding.py +0 -0
  71. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/email/workmail.py +0 -0
  72. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/report.py +0 -0
  73. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/serverless/__init__.py +0 -0
  74. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/serverless/scaleway.py +0 -0
  75. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/storage/__init__.py +0 -0
  76. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/storage/aws.py +0 -0
  77. {granny_devops-0.7.0 → granny_devops-0.8.0}/granny/storage/bunny.py +0 -0
  78. {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.7.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
@@ -1,6 +1,6 @@
1
1
  """Granny -- Cloud tools collection for AWS infrastructure and DevOps automation."""
2
2
 
3
- __version__ = "0.7.0"
3
+ __version__ = "0.8.0"
4
4
  __all__ = [
5
5
  "get_secret",
6
6
  "load_secrets_into_env",
@@ -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=list(profiles) or None,
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=list(profiles) or None,
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=list(projects) or None,
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=list(projects) or None)
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=list(subscriptions) or None,
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",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "granny-devops"
3
- version = "0.7.0"
3
+ version = "0.8.0"
4
4
  description = "Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
File without changes
File without changes