granny-devops 0.9.3__tar.gz → 0.11.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 (102) hide show
  1. {granny_devops-0.9.3 → granny_devops-0.11.0}/.gitignore +1 -0
  2. {granny_devops-0.9.3 → granny_devops-0.11.0}/PKG-INFO +22 -1
  3. {granny_devops-0.9.3 → granny_devops-0.11.0}/README.md +21 -0
  4. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/__init__.py +1 -1
  5. granny_devops-0.11.0/granny/analyze/gpu_pricing.py +199 -0
  6. granny_devops-0.11.0/granny/aws/__init__.py +1 -0
  7. granny_devops-0.11.0/granny/aws/quota.py +163 -0
  8. granny_devops-0.11.0/granny/azure/__init__.py +1 -0
  9. granny_devops-0.11.0/granny/azure/_client.py +81 -0
  10. granny_devops-0.11.0/granny/azure/account.py +90 -0
  11. granny_devops-0.11.0/granny/azure/deployment.py +95 -0
  12. granny_devops-0.11.0/granny/azure/openai.py +147 -0
  13. granny_devops-0.11.0/granny/azure/quota.py +205 -0
  14. granny_devops-0.11.0/granny/azure/sku.py +73 -0
  15. granny_devops-0.11.0/granny/azure/webapp.py +124 -0
  16. granny_devops-0.11.0/granny/cli/aws.py +205 -0
  17. granny_devops-0.11.0/granny/cli/azure.py +543 -0
  18. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/cli/email.py +126 -18
  19. granny_devops-0.11.0/granny/cli/indexing.py +247 -0
  20. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/cli/main.py +15 -0
  21. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/email/mailjet.py +21 -7
  22. granny_devops-0.11.0/granny/indexing/__init__.py +41 -0
  23. granny_devops-0.11.0/granny/indexing/google.py +134 -0
  24. granny_devops-0.11.0/granny/indexing/indexnow.py +237 -0
  25. {granny_devops-0.9.3 → granny_devops-0.11.0}/pyproject.toml +1 -1
  26. {granny_devops-0.9.3 → granny_devops-0.11.0}/LICENSE +0 -0
  27. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/analyze/__init__.py +0 -0
  28. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/analyze/costs.py +0 -0
  29. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/analyze/credits.py +0 -0
  30. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/analyze/gpus.py +0 -0
  31. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/analyze/lambdas.py +0 -0
  32. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/analyze/vpcs.py +0 -0
  33. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/authentik/__init__.py +0 -0
  34. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/authentik/client.py +0 -0
  35. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/authentik/provision.py +0 -0
  36. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/cdn/__init__.py +0 -0
  37. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/cdn/bunny.py +0 -0
  38. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/cli/__init__.py +0 -0
  39. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/cli/analyze.py +0 -0
  40. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/cli/authentik.py +0 -0
  41. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/cli/cdn.py +0 -0
  42. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/cli/cloudflare.py +0 -0
  43. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/cli/create.py +0 -0
  44. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/cli/credentials.py +0 -0
  45. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/cli/dns.py +0 -0
  46. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/cli/docker.py +0 -0
  47. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/cli/edge.py +0 -0
  48. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/cli/elk.py +0 -0
  49. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/cli/serverless.py +0 -0
  50. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/cli/storage.py +0 -0
  51. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/cloudflare/__init__.py +0 -0
  52. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/cloudflare/d1.py +0 -0
  53. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/cloudflare/r2.py +0 -0
  54. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/cloudflare/workers.py +0 -0
  55. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/create/__init__.py +0 -0
  56. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/create/auto_certificate.py +0 -0
  57. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/create/cloudfront-security-headers.js +0 -0
  58. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/create/manage-dns.sh +0 -0
  59. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/create/manage_mailjet_contacts.py +0 -0
  60. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/create/registrars.py +0 -0
  61. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/create/setup_aws_cloudfront.py +0 -0
  62. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/create/setup_bunny_edge_script.py +0 -0
  63. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/create/setup_bunny_storage.py +0 -0
  64. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/create/setup_cognito_identity_pool.py +0 -0
  65. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/create/setup_hetzner_bunny.py +0 -0
  66. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/create/setup_mailjet_dns.py +0 -0
  67. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/create/setup_private_cdn.py +0 -0
  68. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/create/setup_s3_website.py +0 -0
  69. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/create/setup_scaleway_container.py +0 -0
  70. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/create/setup_scaleway_faas.py +0 -0
  71. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/create/setup_workmail.py +0 -0
  72. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/create/www-redirect-function.js +0 -0
  73. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/credentials/__init__.py +0 -0
  74. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/credentials/secrets.py +0 -0
  75. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/dns/__init__.py +0 -0
  76. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/dns/base.py +0 -0
  77. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/dns/bunny.py +0 -0
  78. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/dns/cloudflare.py +0 -0
  79. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/dns/cloudns.py +0 -0
  80. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/dns/desec.py +0 -0
  81. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/dns/factory.py +0 -0
  82. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/dns/hetzner.py +0 -0
  83. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/dns/inwx.py +0 -0
  84. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/dns/manual.py +0 -0
  85. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/dns/records.py +0 -0
  86. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/docker/__init__.py +0 -0
  87. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/docker/build_base.py +0 -0
  88. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/edge/__init__.py +0 -0
  89. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/edge/bunny.py +0 -0
  90. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/elk/__init__.py +0 -0
  91. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/elk/client.py +0 -0
  92. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/email/__init__.py +0 -0
  93. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/email/mailjet_contacts.py +0 -0
  94. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/email/ses_forwarding.py +0 -0
  95. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/email/workmail.py +0 -0
  96. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/report.py +0 -0
  97. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/serverless/__init__.py +0 -0
  98. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/serverless/scaleway.py +0 -0
  99. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/storage/__init__.py +0 -0
  100. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/storage/aws.py +0 -0
  101. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/storage/bunny.py +0 -0
  102. {granny_devops-0.9.3 → granny_devops-0.11.0}/granny/storage/hetzner.py +0 -0
@@ -67,3 +67,4 @@ vaultwarden-import-*.csv
67
67
  /dist/
68
68
  /build/
69
69
 
70
+ /.claude/scheduled_*.lock
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: granny-devops
3
- Version: 0.9.3
3
+ Version: 0.11.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
@@ -305,6 +305,21 @@ granny elk add-user user@example.com \
305
305
  --full-name "Example User" \
306
306
  --role kibana_admin \
307
307
  --generate-password
308
+
309
+ # Azure -- subscriptions, ARM deployments, OpenAI, App Service, VM sizes
310
+ granny azure account whoami # signed-in identity
311
+ granny azure account list # subscriptions
312
+ granny azure deployment list --subscription <id> --all-groups
313
+ granny azure openai accounts --subscription <id>
314
+ granny azure openai deployments --subscription <id> --kind OpenAI
315
+ granny azure webapp list --subscription <id>
316
+ granny azure vm-sizes list --subscription <id> --location polandcentral
317
+
318
+ # Search-engine indexing (IndexNow + Google Indexing API)
319
+ granny indexing generate-key # mint an IndexNow key
320
+ granny indexing submit https://example.com/new-page # notify every engine
321
+ granny indexing indexnow https://example.com/p1 https://example.com/p2
322
+ granny indexing google https://example.com/job-posting --action URL_UPDATED
308
323
  ```
309
324
 
310
325
  ## Capability matrix
@@ -323,6 +338,8 @@ granny elk add-user user@example.com \
323
338
  | SSL automation | Bunny, Cloudflare, ACM |
324
339
  | SSO / IdP | Authentik (provider, application, group, and user operations) |
325
340
  | Observability admin | Elasticsearch / Kibana native-user management |
341
+ | Azure ops | Identity, subscriptions, ARM deployments, Cognitive Services (Azure OpenAI), App Service, VM sizes, compute quota |
342
+ | Search indexing | IndexNow (Bing/Yandex/Seznam/Naver/Yep), Google Indexing API |
326
343
 
327
344
  ## As a library
328
345
 
@@ -352,6 +369,8 @@ load_secrets_into_env()
352
369
  granny/
353
370
  cli/ Click command groups (granny <group> <verb>)
354
371
  analyze/ Cross-cloud inventory (AWS, GCP, Azure)
372
+ authentik/ Authentik admin
373
+ azure/ Azure account, ARM deployments, Cognitive, App Service, VM sizes, quota
355
374
  cdn/ Bunny CDN
356
375
  cloudflare/ Cloudflare Workers / D1 / R2 / KV
357
376
  create/ Standalone setup scripts (granny create <name>)
@@ -359,7 +378,9 @@ granny/
359
378
  dns/ Provider-agnostic DNS CRUD
360
379
  docker/ Multi-arch image builds
361
380
  edge/ Bunny Edge Scripting
381
+ elk/ Elasticsearch / Kibana security user management
362
382
  email/ Mailjet, WorkMail, SES forwarding
383
+ indexing/ IndexNow + Google Indexing API
363
384
  serverless/ Scaleway FaaS
364
385
  storage/ Object storage (AWS / Bunny / Hetzner)
365
386
  ```
@@ -173,6 +173,21 @@ granny elk add-user user@example.com \
173
173
  --full-name "Example User" \
174
174
  --role kibana_admin \
175
175
  --generate-password
176
+
177
+ # Azure -- subscriptions, ARM deployments, OpenAI, App Service, VM sizes
178
+ granny azure account whoami # signed-in identity
179
+ granny azure account list # subscriptions
180
+ granny azure deployment list --subscription <id> --all-groups
181
+ granny azure openai accounts --subscription <id>
182
+ granny azure openai deployments --subscription <id> --kind OpenAI
183
+ granny azure webapp list --subscription <id>
184
+ granny azure vm-sizes list --subscription <id> --location polandcentral
185
+
186
+ # Search-engine indexing (IndexNow + Google Indexing API)
187
+ granny indexing generate-key # mint an IndexNow key
188
+ granny indexing submit https://example.com/new-page # notify every engine
189
+ granny indexing indexnow https://example.com/p1 https://example.com/p2
190
+ granny indexing google https://example.com/job-posting --action URL_UPDATED
176
191
  ```
177
192
 
178
193
  ## Capability matrix
@@ -191,6 +206,8 @@ granny elk add-user user@example.com \
191
206
  | SSL automation | Bunny, Cloudflare, ACM |
192
207
  | SSO / IdP | Authentik (provider, application, group, and user operations) |
193
208
  | Observability admin | Elasticsearch / Kibana native-user management |
209
+ | Azure ops | Identity, subscriptions, ARM deployments, Cognitive Services (Azure OpenAI), App Service, VM sizes, compute quota |
210
+ | Search indexing | IndexNow (Bing/Yandex/Seznam/Naver/Yep), Google Indexing API |
194
211
 
195
212
  ## As a library
196
213
 
@@ -220,6 +237,8 @@ load_secrets_into_env()
220
237
  granny/
221
238
  cli/ Click command groups (granny <group> <verb>)
222
239
  analyze/ Cross-cloud inventory (AWS, GCP, Azure)
240
+ authentik/ Authentik admin
241
+ azure/ Azure account, ARM deployments, Cognitive, App Service, VM sizes, quota
223
242
  cdn/ Bunny CDN
224
243
  cloudflare/ Cloudflare Workers / D1 / R2 / KV
225
244
  create/ Standalone setup scripts (granny create <name>)
@@ -227,7 +246,9 @@ granny/
227
246
  dns/ Provider-agnostic DNS CRUD
228
247
  docker/ Multi-arch image builds
229
248
  edge/ Bunny Edge Scripting
249
+ elk/ Elasticsearch / Kibana security user management
230
250
  email/ Mailjet, WorkMail, SES forwarding
251
+ indexing/ IndexNow + Google Indexing API
231
252
  serverless/ Scaleway FaaS
232
253
  storage/ Object storage (AWS / Bunny / Hetzner)
233
254
  ```
@@ -1,6 +1,6 @@
1
1
  """Granny -- Cloud tools collection for AWS infrastructure and DevOps automation."""
2
2
 
3
- __version__ = "0.9.3"
3
+ __version__ = "0.11.0"
4
4
  __all__ = [
5
5
  "get_secret",
6
6
  "load_secrets_into_env",
@@ -0,0 +1,199 @@
1
+ """Forward-looking GPU cluster cost estimation for AWS.
2
+
3
+ Pulls live on-demand rates from the AWS Pricing API and effective hourly
4
+ rates from Capacity Block offerings via ``describe_capacity_block_offerings``,
5
+ then multiplies by hours and node count.
6
+
7
+ The Pricing API runs only in ``us-east-1`` and ``ap-south-1`` but returns
8
+ prices for every region. It does not need any non-default IAM permissions
9
+ in most accounts. Capacity Block discovery does need an authenticated
10
+ profile.
11
+
12
+ Compute dominates an H100/H200 cluster's monthly bill by 2+ orders of
13
+ magnitude (one ``p5en.48xlarge`` is ~$98/hr on-demand; the 100GB EBS root
14
+ is ~$8/mo). We do not surface storage/network/data-transfer here -- treat
15
+ the number as ``+/- 5%``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import logging
22
+ from dataclasses import dataclass
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ # AWS Pricing API uses human-readable "location" strings rather than
28
+ # region codes. This covers the regions where p5e / p5en (H200) are sold.
29
+ _PRICING_LOCATIONS: dict[str, str] = {
30
+ "us-east-1": "US East (N. Virginia)",
31
+ "us-east-2": "US East (Ohio)",
32
+ "us-west-1": "US West (N. California)",
33
+ "us-west-2": "US West (Oregon)",
34
+ "eu-west-1": "Europe (Ireland)",
35
+ "eu-west-2": "Europe (London)",
36
+ "eu-west-3": "Europe (Paris)",
37
+ "eu-central-1": "Europe (Frankfurt)",
38
+ "eu-north-1": "Europe (Stockholm)",
39
+ "ap-northeast-1": "Asia Pacific (Tokyo)",
40
+ "ap-northeast-2": "Asia Pacific (Seoul)",
41
+ "ap-southeast-1": "Asia Pacific (Singapore)",
42
+ "ap-southeast-2": "Asia Pacific (Sydney)",
43
+ "ap-south-1": "Asia Pacific (Mumbai)",
44
+ "ca-central-1": "Canada (Central)",
45
+ }
46
+
47
+
48
+ @dataclass
49
+ class GpuClusterCostEstimate:
50
+ """Per-region, per-pricing-plan estimate for a GPU cluster.
51
+
52
+ ``hourly_per_node`` is the canonical figure; ``monthly_*`` are derived
53
+ by multiplying by ``hours_per_month`` and ``node_count``. ``None`` means
54
+ the underlying API returned nothing (region unsupported, no capacity).
55
+ """
56
+
57
+ instance_type: str
58
+ region: str
59
+ node_count: int
60
+ plan: str # 'ondemand' or 'capacity-block'
61
+ hourly_per_node: float | None
62
+ monthly_per_node: float | None
63
+ monthly_total: float | None
64
+ hours_per_month: float
65
+ currency: str
66
+ notes: str
67
+
68
+
69
+ def get_aws_ondemand_hourly(
70
+ instance_type: str,
71
+ region: str,
72
+ profile: str | None = None,
73
+ ) -> float | None:
74
+ """Return the on-demand USD/hour price for a Linux instance in the region.
75
+
76
+ Returns ``None`` if the Pricing API has no matching offer (typically
77
+ means the instance type is not yet sold in that region).
78
+ """
79
+ import boto3
80
+ from botocore.exceptions import BotoCoreError, ClientError
81
+
82
+ location = _PRICING_LOCATIONS.get(region)
83
+ if not location:
84
+ raise ValueError(
85
+ f"Unmapped Pricing API location for region {region!r}; "
86
+ f"add it to _PRICING_LOCATIONS"
87
+ )
88
+
89
+ session = boto3.Session(profile_name=profile if profile and profile != "default" else None)
90
+ pricing = session.client("pricing", region_name="us-east-1")
91
+
92
+ filters = [
93
+ {"Type": "TERM_MATCH", "Field": "ServiceCode", "Value": "AmazonEC2"},
94
+ {"Type": "TERM_MATCH", "Field": "instanceType", "Value": instance_type},
95
+ {"Type": "TERM_MATCH", "Field": "location", "Value": location},
96
+ {"Type": "TERM_MATCH", "Field": "tenancy", "Value": "Shared"},
97
+ {"Type": "TERM_MATCH", "Field": "operatingSystem", "Value": "Linux"},
98
+ {"Type": "TERM_MATCH", "Field": "preInstalledSw", "Value": "NA"},
99
+ {"Type": "TERM_MATCH", "Field": "capacitystatus", "Value": "Used"},
100
+ {"Type": "TERM_MATCH", "Field": "marketoption", "Value": "OnDemand"},
101
+ ]
102
+ try:
103
+ resp = pricing.get_products(ServiceCode="AmazonEC2", Filters=filters, MaxResults=20)
104
+ except (BotoCoreError, ClientError) as e:
105
+ raise RuntimeError(f"AWS Pricing API call failed: {e}") from e
106
+
107
+ for raw in resp.get("PriceList", []):
108
+ doc = json.loads(raw)
109
+ for term in doc.get("terms", {}).get("OnDemand", {}).values():
110
+ for dim in term.get("priceDimensions", {}).values():
111
+ price = dim.get("pricePerUnit", {}).get("USD")
112
+ if price and float(price) > 0:
113
+ return float(price)
114
+ return None
115
+
116
+
117
+ def _capacity_block_hourly(
118
+ instance_type: str,
119
+ node_count: int,
120
+ region: str,
121
+ profile: str | None,
122
+ ) -> tuple[float | None, str]:
123
+ """Return ``(effective_hourly_per_node, note)`` from cheapest current block."""
124
+ from granny.analyze.gpus import list_aws_capacity_block_offerings
125
+
126
+ offerings = list_aws_capacity_block_offerings(
127
+ instance_type=instance_type,
128
+ instance_count=node_count,
129
+ duration_hours=24,
130
+ start_window_days=30,
131
+ profiles=[profile] if profile else None,
132
+ regions=[region],
133
+ )
134
+ if not offerings:
135
+ return None, "No Capacity Block offerings in the next 30 days"
136
+
137
+ cheapest = offerings[0]
138
+ hours = cheapest.duration_hours or 24
139
+ nodes = cheapest.instance_count or node_count or 1
140
+ per_node_hour = cheapest.upfront_fee / (nodes * hours)
141
+ note = (
142
+ f"From cheapest 24h block: ${cheapest.upfront_fee:,.2f} upfront for "
143
+ f"{cheapest.instance_count}x at {cheapest.start_time[:10]}"
144
+ )
145
+ return per_node_hour, note
146
+
147
+
148
+ def estimate_cluster_monthly(
149
+ instance_type: str,
150
+ node_count: int,
151
+ region: str,
152
+ plan: str = "ondemand",
153
+ hours_per_month: float = 730.0,
154
+ profile: str | None = None,
155
+ ) -> GpuClusterCostEstimate:
156
+ """Estimate the monthly cost of a GPU cluster in one region under one plan.
157
+
158
+ Args:
159
+ instance_type: EC2 instance type, e.g. ``p5en.48xlarge`` (H200 x8).
160
+ node_count: Number of nodes the cluster runs at full active load.
161
+ region: AWS region code.
162
+ plan: ``"ondemand"`` (Pricing API) or ``"capacity-block"`` (derives
163
+ an effective hourly from the cheapest 24h block currently sold).
164
+ hours_per_month: 730 = full 24/7 month; 168 = one week per month;
165
+ etc. Use this to model partial-month usage.
166
+ profile: AWS profile (needed only for ``"capacity-block"``).
167
+ """
168
+ notes: list[str] = []
169
+ hourly: float | None = None
170
+
171
+ if plan == "ondemand":
172
+ hourly = get_aws_ondemand_hourly(instance_type, region, profile)
173
+ if hourly is None:
174
+ notes.append(
175
+ f"Pricing API has no on-demand offer for {instance_type} in {region}"
176
+ )
177
+ elif plan == "capacity-block":
178
+ hourly, cb_note = _capacity_block_hourly(instance_type, node_count, region, profile)
179
+ notes.append(cb_note)
180
+ else:
181
+ raise ValueError(
182
+ f"plan must be 'ondemand' or 'capacity-block', got {plan!r}"
183
+ )
184
+
185
+ monthly_per_node = hourly * hours_per_month if hourly is not None else None
186
+ monthly_total = monthly_per_node * node_count if monthly_per_node is not None else None
187
+
188
+ return GpuClusterCostEstimate(
189
+ instance_type=instance_type,
190
+ region=region,
191
+ node_count=node_count,
192
+ plan=plan,
193
+ hourly_per_node=hourly,
194
+ monthly_per_node=monthly_per_node,
195
+ monthly_total=monthly_total,
196
+ hours_per_month=hours_per_month,
197
+ currency="USD",
198
+ notes="; ".join(n for n in notes if n),
199
+ )
@@ -0,0 +1 @@
1
+ """AWS helpers for granny (service quotas, ...)."""
@@ -0,0 +1,163 @@
1
+ """AWS Service Quotas inspection and increase-request helpers.
2
+
3
+ Thin boto3 wrapper around the `service-quotas` API. Read paths (`get`,
4
+ `status`) need `servicequotas:GetServiceQuota` /
5
+ `servicequotas:ListRequestedServiceQuotaChangeHistoryByQuota`; the write
6
+ path (`request`) needs `servicequotas:RequestServiceQuotaIncrease`.
7
+
8
+ A small registry of commonly-needed EC2 quota codes is included so callers
9
+ can say `--gpu` instead of memorising `L-DB2E81BA`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+
17
+ import boto3
18
+
19
+ # Friendly aliases -> (service_code, quota_code). EC2 on-demand vCPU quotas.
20
+ # https://docs.aws.amazon.com/ec2/latest/userguide/ec2-resource-limits.html
21
+ QUOTA_ALIASES: dict[str, tuple[str, str]] = {
22
+ # G and VT instances (NVIDIA L4 / A10G / T4 etc) -- the one that gates g6/g5.
23
+ "gpu": ("ec2", "L-DB2E81BA"),
24
+ "g-and-vt": ("ec2", "L-DB2E81BA"),
25
+ # P instances (A100/H100 etc).
26
+ "p": ("ec2", "L-417A185B"),
27
+ # Standard on-demand (A/C/D/H/I/M/R/T/Z).
28
+ "standard": ("ec2", "L-1216C47A"),
29
+ # Inf (Inferentia).
30
+ "inf": ("ec2", "L-1945791B"),
31
+ }
32
+
33
+
34
+ @dataclass
35
+ class ServiceQuota:
36
+ service_code: str
37
+ quota_code: str
38
+ quota_name: str
39
+ value: float | None
40
+ adjustable: bool
41
+ unit: str | None
42
+ region: str
43
+ raw: dict[str, Any]
44
+
45
+
46
+ @dataclass
47
+ class QuotaChangeRequest:
48
+ service_code: str
49
+ quota_code: str
50
+ quota_name: str
51
+ desired_value: float
52
+ status: str | None
53
+ request_id: str | None
54
+ case_id: str | None
55
+ region: str
56
+ raw: dict[str, Any]
57
+
58
+
59
+ def resolve_alias(alias_or_code: str) -> tuple[str, str]:
60
+ """Map a friendly alias (``gpu``) to ``(service_code, quota_code)``.
61
+
62
+ If the input already looks like a raw quota code (``L-...``) it is
63
+ returned against the default ``ec2`` service.
64
+ """
65
+ key = alias_or_code.strip().lower()
66
+ if key in QUOTA_ALIASES:
67
+ return QUOTA_ALIASES[key]
68
+ return ("ec2", alias_or_code.strip())
69
+
70
+
71
+ def _client(profile: str | None, region: str | None) -> Any:
72
+ session = boto3.Session(
73
+ profile_name=profile if profile and profile != "default" else None,
74
+ region_name=region,
75
+ )
76
+ return session.client("service-quotas")
77
+
78
+
79
+ def get_quota(
80
+ *,
81
+ service_code: str,
82
+ quota_code: str,
83
+ profile: str | None = None,
84
+ region: str | None = None,
85
+ ) -> ServiceQuota:
86
+ """Return the current (applied) value for one quota."""
87
+ client = _client(profile, region)
88
+ resolved_region = client.meta.region_name
89
+ resp = client.get_service_quota(ServiceCode=service_code, QuotaCode=quota_code)
90
+ q = resp.get("Quota", {})
91
+ return ServiceQuota(
92
+ service_code=service_code,
93
+ quota_code=quota_code,
94
+ quota_name=q.get("QuotaName", ""),
95
+ value=q.get("Value"),
96
+ adjustable=bool(q.get("Adjustable", False)),
97
+ unit=q.get("Unit"),
98
+ region=resolved_region,
99
+ raw=q,
100
+ )
101
+
102
+
103
+ def request_increase(
104
+ *,
105
+ service_code: str,
106
+ quota_code: str,
107
+ desired_value: float,
108
+ profile: str | None = None,
109
+ region: str | None = None,
110
+ ) -> QuotaChangeRequest:
111
+ """Submit a quota-increase request. Idempotency is AWS-side: a pending
112
+ request for the same quota will be rejected by the API."""
113
+ client = _client(profile, region)
114
+ resolved_region = client.meta.region_name
115
+ resp = client.request_service_quota_increase(
116
+ ServiceCode=service_code,
117
+ QuotaCode=quota_code,
118
+ DesiredValue=desired_value,
119
+ )
120
+ r = resp.get("RequestedQuota", {})
121
+ return QuotaChangeRequest(
122
+ service_code=service_code,
123
+ quota_code=quota_code,
124
+ quota_name=r.get("QuotaName", ""),
125
+ desired_value=r.get("DesiredValue", desired_value),
126
+ status=r.get("Status"),
127
+ request_id=r.get("Id"),
128
+ case_id=r.get("CaseId"),
129
+ region=resolved_region,
130
+ raw=r,
131
+ )
132
+
133
+
134
+ def list_requested_changes(
135
+ *,
136
+ service_code: str,
137
+ quota_code: str,
138
+ profile: str | None = None,
139
+ region: str | None = None,
140
+ ) -> list[QuotaChangeRequest]:
141
+ """Return the change-request history for one quota (most recent first)."""
142
+ client = _client(profile, region)
143
+ resolved_region = client.meta.region_name
144
+ resp = client.list_requested_service_quota_change_history_by_quota(
145
+ ServiceCode=service_code,
146
+ QuotaCode=quota_code,
147
+ )
148
+ out: list[QuotaChangeRequest] = []
149
+ for r in resp.get("RequestedQuotas", []):
150
+ out.append(
151
+ QuotaChangeRequest(
152
+ service_code=service_code,
153
+ quota_code=quota_code,
154
+ quota_name=r.get("QuotaName", ""),
155
+ desired_value=r.get("DesiredValue", 0.0),
156
+ status=r.get("Status"),
157
+ request_id=r.get("Id"),
158
+ case_id=r.get("CaseId"),
159
+ region=resolved_region,
160
+ raw=r,
161
+ )
162
+ )
163
+ return out
@@ -0,0 +1 @@
1
+ """Azure operational helpers."""
@@ -0,0 +1,81 @@
1
+ """Shared Azure Resource Manager HTTP helpers.
2
+
3
+ Used by every module under ``granny.azure`` except ``quota.py``, which keeps
4
+ its own copy for historical reasons. New modules should route through here.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import json
11
+ from collections.abc import Iterator
12
+ from typing import Any
13
+
14
+ import requests
15
+
16
+ MANAGEMENT_SCOPE = "https://management.azure.com/.default"
17
+ MANAGEMENT_URL = "https://management.azure.com"
18
+
19
+
20
+ def get_management_token() -> str:
21
+ """Return an ARM bearer token from the default credential chain."""
22
+ try:
23
+ from azure.identity import DefaultAzureCredential
24
+ except ImportError as e:
25
+ raise RuntimeError(
26
+ "Azure support requires the [azure] extra: "
27
+ "pip install 'granny-devops[azure]'"
28
+ ) from e
29
+
30
+ credential = DefaultAzureCredential()
31
+ return credential.get_token(MANAGEMENT_SCOPE).token
32
+
33
+
34
+ def headers(token: str | None = None) -> dict[str, str]:
35
+ """Authorization headers for ARM REST calls."""
36
+ bearer = token if token is not None else get_management_token()
37
+ return {
38
+ "Authorization": f"Bearer {bearer}",
39
+ "Content-Type": "application/json",
40
+ }
41
+
42
+
43
+ def get(url: str, *, token: str | None = None) -> dict[str, Any]:
44
+ """Issue a GET against ARM and return the JSON body."""
45
+ response = requests.get(url, headers=headers(token), timeout=60)
46
+ if response.status_code == 404:
47
+ raise KeyError(f"Azure resource not found: {url}")
48
+ if response.status_code != 200:
49
+ raise_response_error("GET", url, response)
50
+ return response.json()
51
+
52
+
53
+ def paginate(url: str, *, token: str | None = None) -> Iterator[dict[str, Any]]:
54
+ """Yield each item from a paginated ARM ``value``/``nextLink`` collection."""
55
+ bearer = token if token is not None else get_management_token()
56
+ next_url: str | None = url
57
+ while next_url:
58
+ data = get(next_url, token=bearer)
59
+ for item in data.get("value", []):
60
+ yield item
61
+ next_url = data.get("nextLink")
62
+
63
+
64
+ def decode_jwt_payload(token: str) -> dict[str, Any]:
65
+ """Return the unverified JWT payload claims for ``token``."""
66
+ parts = token.split(".")
67
+ if len(parts) < 2:
68
+ return {}
69
+ payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4)
70
+ try:
71
+ return json.loads(base64.urlsafe_b64decode(payload_b64))
72
+ except (ValueError, json.JSONDecodeError):
73
+ return {}
74
+
75
+
76
+ def raise_response_error(method: str, url: str, response: requests.Response) -> None:
77
+ """Wrap a non-OK ARM response in a uniform RuntimeError."""
78
+ snippet = response.text[:1000]
79
+ raise RuntimeError(
80
+ f"Azure ARM {method} {url} failed: {response.status_code} {snippet}"
81
+ )
@@ -0,0 +1,90 @@
1
+ """Azure account and subscription helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from granny.azure import _client
9
+
10
+ _SUBSCRIPTIONS_API_VERSION = "2022-12-01"
11
+ _TENANTS_API_VERSION = "2022-12-01"
12
+
13
+
14
+ @dataclass
15
+ class AzureIdentity:
16
+ object_id: str | None
17
+ tenant_id: str | None
18
+ user_principal_name: str | None
19
+ display_name: str | None
20
+ app_id: str | None
21
+ identity_type: str | None
22
+ audience: str | None
23
+ raw_claims: dict[str, Any]
24
+
25
+
26
+ @dataclass
27
+ class AzureSubscription:
28
+ subscription_id: str
29
+ display_name: str
30
+ state: str | None
31
+ tenant_id: str | None
32
+ raw: dict[str, Any]
33
+
34
+
35
+ @dataclass
36
+ class AzureTenant:
37
+ tenant_id: str
38
+ display_name: str | None
39
+ default_domain: str | None
40
+ raw: dict[str, Any]
41
+
42
+
43
+ def get_signed_in_identity() -> AzureIdentity:
44
+ """Return identity info derived from the current ARM access token."""
45
+ token = _client.get_management_token()
46
+ claims = _client.decode_jwt_payload(token)
47
+ return AzureIdentity(
48
+ object_id=claims.get("oid"),
49
+ tenant_id=claims.get("tid"),
50
+ user_principal_name=claims.get("upn") or claims.get("unique_name"),
51
+ display_name=claims.get("name"),
52
+ app_id=claims.get("appid") or claims.get("azp"),
53
+ identity_type=claims.get("idtyp"),
54
+ audience=claims.get("aud"),
55
+ raw_claims=claims,
56
+ )
57
+
58
+
59
+ def list_subscriptions() -> list[AzureSubscription]:
60
+ """List subscriptions visible to the signed-in identity."""
61
+ url = (
62
+ f"{_client.MANAGEMENT_URL}/subscriptions"
63
+ f"?api-version={_SUBSCRIPTIONS_API_VERSION}"
64
+ )
65
+ return [_parse_subscription(item) for item in _client.paginate(url)]
66
+
67
+
68
+ def list_tenants() -> list[AzureTenant]:
69
+ """List tenants visible to the signed-in identity."""
70
+ url = f"{_client.MANAGEMENT_URL}/tenants?api-version={_TENANTS_API_VERSION}"
71
+ return [_parse_tenant(item) for item in _client.paginate(url)]
72
+
73
+
74
+ def _parse_subscription(item: dict[str, Any]) -> AzureSubscription:
75
+ return AzureSubscription(
76
+ subscription_id=item.get("subscriptionId", ""),
77
+ display_name=item.get("displayName", ""),
78
+ state=item.get("state"),
79
+ tenant_id=item.get("tenantId"),
80
+ raw=item,
81
+ )
82
+
83
+
84
+ def _parse_tenant(item: dict[str, Any]) -> AzureTenant:
85
+ return AzureTenant(
86
+ tenant_id=item.get("tenantId", ""),
87
+ display_name=item.get("displayName"),
88
+ default_domain=item.get("defaultDomain"),
89
+ raw=item,
90
+ )