granny-devops 0.9.2__tar.gz → 0.10.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 (99) hide show
  1. {granny_devops-0.9.2 → granny_devops-0.10.0}/PKG-INFO +31 -1
  2. {granny_devops-0.9.2 → granny_devops-0.10.0}/README.md +30 -0
  3. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/__init__.py +1 -1
  4. granny_devops-0.10.0/granny/analyze/gpu_pricing.py +199 -0
  5. granny_devops-0.10.0/granny/azure/__init__.py +1 -0
  6. granny_devops-0.10.0/granny/azure/_client.py +81 -0
  7. granny_devops-0.10.0/granny/azure/account.py +90 -0
  8. granny_devops-0.10.0/granny/azure/deployment.py +95 -0
  9. granny_devops-0.10.0/granny/azure/openai.py +147 -0
  10. granny_devops-0.10.0/granny/azure/quota.py +205 -0
  11. granny_devops-0.10.0/granny/azure/sku.py +73 -0
  12. granny_devops-0.10.0/granny/azure/webapp.py +124 -0
  13. granny_devops-0.10.0/granny/cli/azure.py +543 -0
  14. granny_devops-0.10.0/granny/cli/elk.py +192 -0
  15. granny_devops-0.10.0/granny/cli/indexing.py +247 -0
  16. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cli/main.py +15 -0
  17. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/credentials/secrets.py +4 -0
  18. granny_devops-0.10.0/granny/elk/__init__.py +5 -0
  19. granny_devops-0.10.0/granny/elk/client.py +149 -0
  20. granny_devops-0.10.0/granny/indexing/__init__.py +41 -0
  21. granny_devops-0.10.0/granny/indexing/google.py +134 -0
  22. granny_devops-0.10.0/granny/indexing/indexnow.py +237 -0
  23. {granny_devops-0.9.2 → granny_devops-0.10.0}/pyproject.toml +1 -1
  24. {granny_devops-0.9.2 → granny_devops-0.10.0}/.gitignore +0 -0
  25. {granny_devops-0.9.2 → granny_devops-0.10.0}/LICENSE +0 -0
  26. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/analyze/__init__.py +0 -0
  27. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/analyze/costs.py +0 -0
  28. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/analyze/credits.py +0 -0
  29. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/analyze/gpus.py +0 -0
  30. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/analyze/lambdas.py +0 -0
  31. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/analyze/vpcs.py +0 -0
  32. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/authentik/__init__.py +0 -0
  33. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/authentik/client.py +0 -0
  34. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/authentik/provision.py +0 -0
  35. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cdn/__init__.py +0 -0
  36. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cdn/bunny.py +0 -0
  37. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cli/__init__.py +0 -0
  38. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cli/analyze.py +0 -0
  39. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cli/authentik.py +0 -0
  40. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cli/cdn.py +0 -0
  41. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cli/cloudflare.py +0 -0
  42. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cli/create.py +0 -0
  43. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cli/credentials.py +0 -0
  44. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cli/dns.py +0 -0
  45. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cli/docker.py +0 -0
  46. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cli/edge.py +0 -0
  47. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cli/email.py +0 -0
  48. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cli/serverless.py +0 -0
  49. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cli/storage.py +0 -0
  50. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cloudflare/__init__.py +0 -0
  51. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cloudflare/d1.py +0 -0
  52. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cloudflare/r2.py +0 -0
  53. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cloudflare/workers.py +0 -0
  54. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/__init__.py +0 -0
  55. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/auto_certificate.py +0 -0
  56. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/cloudfront-security-headers.js +0 -0
  57. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/manage-dns.sh +0 -0
  58. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/manage_mailjet_contacts.py +0 -0
  59. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/registrars.py +0 -0
  60. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/setup_aws_cloudfront.py +0 -0
  61. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/setup_bunny_edge_script.py +0 -0
  62. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/setup_bunny_storage.py +0 -0
  63. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/setup_cognito_identity_pool.py +0 -0
  64. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/setup_hetzner_bunny.py +0 -0
  65. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/setup_mailjet_dns.py +0 -0
  66. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/setup_private_cdn.py +0 -0
  67. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/setup_s3_website.py +0 -0
  68. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/setup_scaleway_container.py +0 -0
  69. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/setup_scaleway_faas.py +0 -0
  70. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/setup_workmail.py +0 -0
  71. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/www-redirect-function.js +0 -0
  72. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/credentials/__init__.py +0 -0
  73. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/dns/__init__.py +0 -0
  74. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/dns/base.py +0 -0
  75. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/dns/bunny.py +0 -0
  76. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/dns/cloudflare.py +0 -0
  77. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/dns/cloudns.py +0 -0
  78. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/dns/desec.py +0 -0
  79. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/dns/factory.py +0 -0
  80. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/dns/hetzner.py +0 -0
  81. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/dns/inwx.py +0 -0
  82. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/dns/manual.py +0 -0
  83. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/dns/records.py +0 -0
  84. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/docker/__init__.py +0 -0
  85. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/docker/build_base.py +0 -0
  86. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/edge/__init__.py +0 -0
  87. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/edge/bunny.py +0 -0
  88. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/email/__init__.py +0 -0
  89. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/email/mailjet.py +0 -0
  90. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/email/mailjet_contacts.py +0 -0
  91. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/email/ses_forwarding.py +0 -0
  92. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/email/workmail.py +0 -0
  93. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/report.py +0 -0
  94. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/serverless/__init__.py +0 -0
  95. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/serverless/scaleway.py +0 -0
  96. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/storage/__init__.py +0 -0
  97. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/storage/aws.py +0 -0
  98. {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/storage/bunny.py +0 -0
  99. {granny_devops-0.9.2 → granny_devops-0.10.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.9.2
3
+ Version: 0.10.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
@@ -221,6 +221,7 @@ Common keys:
221
221
  | ClouDNS | `CLOUDNS_AUTH_ID`/`_PASSWORD` (or `_SUB_AUTH_ID`/`_SUB_AUTH_USER`) |
222
222
  | INWX | `INWX_USERNAME`, `INWX_PASSWORD`, `INWX_SHARED_SECRET` (only with 2FA) |
223
223
  | Docker Hub | `DOCKER_HUB_USER`, `DOCKER_HUB_TOKEN` |
224
+ | Elasticsearch / Kibana | `ELASTICSEARCH_URL`, `ELASTICSEARCH_API_KEY` or `ELASTICSEARCH_USERNAME` + `ELASTICSEARCH_PASSWORD` |
224
225
 
225
226
  Set only the ones you need. Use `granny credentials status` to verify
226
227
  what's configured at any time.
@@ -297,6 +298,28 @@ granny authentik list providers
297
298
  granny authentik rotate-secret my-oauth-provider
298
299
  granny authentik add-user-to-group user@example.com # defaults to dash_admins
299
300
  granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
301
+
302
+ # Elasticsearch / Kibana users
303
+ granny elk add-user user@example.com \
304
+ --email user@example.com \
305
+ --full-name "Example User" \
306
+ --role kibana_admin \
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
300
323
  ```
301
324
 
302
325
  ## Capability matrix
@@ -314,6 +337,9 @@ granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
314
337
  | Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
315
338
  | SSL automation | Bunny, Cloudflare, ACM |
316
339
  | SSO / IdP | Authentik (provider, application, group, and user operations) |
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 |
317
343
 
318
344
  ## As a library
319
345
 
@@ -343,6 +369,8 @@ load_secrets_into_env()
343
369
  granny/
344
370
  cli/ Click command groups (granny <group> <verb>)
345
371
  analyze/ Cross-cloud inventory (AWS, GCP, Azure)
372
+ authentik/ Authentik admin
373
+ azure/ Azure account, ARM deployments, Cognitive, App Service, VM sizes, quota
346
374
  cdn/ Bunny CDN
347
375
  cloudflare/ Cloudflare Workers / D1 / R2 / KV
348
376
  create/ Standalone setup scripts (granny create <name>)
@@ -350,7 +378,9 @@ granny/
350
378
  dns/ Provider-agnostic DNS CRUD
351
379
  docker/ Multi-arch image builds
352
380
  edge/ Bunny Edge Scripting
381
+ elk/ Elasticsearch / Kibana security user management
353
382
  email/ Mailjet, WorkMail, SES forwarding
383
+ indexing/ IndexNow + Google Indexing API
354
384
  serverless/ Scaleway FaaS
355
385
  storage/ Object storage (AWS / Bunny / Hetzner)
356
386
  ```
@@ -89,6 +89,7 @@ Common keys:
89
89
  | ClouDNS | `CLOUDNS_AUTH_ID`/`_PASSWORD` (or `_SUB_AUTH_ID`/`_SUB_AUTH_USER`) |
90
90
  | INWX | `INWX_USERNAME`, `INWX_PASSWORD`, `INWX_SHARED_SECRET` (only with 2FA) |
91
91
  | Docker Hub | `DOCKER_HUB_USER`, `DOCKER_HUB_TOKEN` |
92
+ | Elasticsearch / Kibana | `ELASTICSEARCH_URL`, `ELASTICSEARCH_API_KEY` or `ELASTICSEARCH_USERNAME` + `ELASTICSEARCH_PASSWORD` |
92
93
 
93
94
  Set only the ones you need. Use `granny credentials status` to verify
94
95
  what's configured at any time.
@@ -165,6 +166,28 @@ granny authentik list providers
165
166
  granny authentik rotate-secret my-oauth-provider
166
167
  granny authentik add-user-to-group user@example.com # defaults to dash_admins
167
168
  granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
169
+
170
+ # Elasticsearch / Kibana users
171
+ granny elk add-user user@example.com \
172
+ --email user@example.com \
173
+ --full-name "Example User" \
174
+ --role kibana_admin \
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
168
191
  ```
169
192
 
170
193
  ## Capability matrix
@@ -182,6 +205,9 @@ granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
182
205
  | Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
183
206
  | SSL automation | Bunny, Cloudflare, ACM |
184
207
  | SSO / IdP | Authentik (provider, application, group, and user operations) |
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 |
185
211
 
186
212
  ## As a library
187
213
 
@@ -211,6 +237,8 @@ load_secrets_into_env()
211
237
  granny/
212
238
  cli/ Click command groups (granny <group> <verb>)
213
239
  analyze/ Cross-cloud inventory (AWS, GCP, Azure)
240
+ authentik/ Authentik admin
241
+ azure/ Azure account, ARM deployments, Cognitive, App Service, VM sizes, quota
214
242
  cdn/ Bunny CDN
215
243
  cloudflare/ Cloudflare Workers / D1 / R2 / KV
216
244
  create/ Standalone setup scripts (granny create <name>)
@@ -218,7 +246,9 @@ granny/
218
246
  dns/ Provider-agnostic DNS CRUD
219
247
  docker/ Multi-arch image builds
220
248
  edge/ Bunny Edge Scripting
249
+ elk/ Elasticsearch / Kibana security user management
221
250
  email/ Mailjet, WorkMail, SES forwarding
251
+ indexing/ IndexNow + Google Indexing API
222
252
  serverless/ Scaleway FaaS
223
253
  storage/ Object storage (AWS / Bunny / Hetzner)
224
254
  ```
@@ -1,6 +1,6 @@
1
1
  """Granny -- Cloud tools collection for AWS infrastructure and DevOps automation."""
2
2
 
3
- __version__ = "0.9.2"
3
+ __version__ = "0.10.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
+ """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
+ )
@@ -0,0 +1,95 @@
1
+ """Azure Resource Manager deployment listing."""
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
+ _API_VERSION = "2024-03-01"
11
+
12
+
13
+ @dataclass
14
+ class AzureDeployment:
15
+ subscription: str
16
+ resource_group: str | None
17
+ name: str
18
+ provisioning_state: str | None
19
+ timestamp: str | None
20
+ mode: str | None
21
+ template_hash: str | None
22
+ correlation_id: str | None
23
+ id: str
24
+ raw: dict[str, Any]
25
+
26
+
27
+ def list_deployments(
28
+ subscription: str,
29
+ resource_group: str | None = None,
30
+ ) -> list[AzureDeployment]:
31
+ """List ARM deployments at subscription or resource-group scope."""
32
+ if resource_group:
33
+ url = (
34
+ f"{_client.MANAGEMENT_URL}/subscriptions/{subscription}"
35
+ f"/resourceGroups/{resource_group}"
36
+ f"/providers/Microsoft.Resources/deployments?api-version={_API_VERSION}"
37
+ )
38
+ else:
39
+ url = (
40
+ f"{_client.MANAGEMENT_URL}/subscriptions/{subscription}"
41
+ f"/providers/Microsoft.Resources/deployments?api-version={_API_VERSION}"
42
+ )
43
+ return [
44
+ _parse_deployment(subscription=subscription, item=item)
45
+ for item in _client.paginate(url)
46
+ ]
47
+
48
+
49
+ def list_deployments_all_groups(subscription: str) -> list[AzureDeployment]:
50
+ """List ARM deployments across every resource group in a subscription."""
51
+ deployments: list[AzureDeployment] = []
52
+ for group in _list_resource_groups(subscription):
53
+ deployments.extend(list_deployments(subscription, resource_group=group))
54
+ deployments.extend(list_deployments(subscription))
55
+ return deployments
56
+
57
+
58
+ def _list_resource_groups(subscription: str) -> list[str]:
59
+ url = (
60
+ f"{_client.MANAGEMENT_URL}/subscriptions/{subscription}"
61
+ f"/resourcegroups?api-version=2022-09-01"
62
+ )
63
+ return [
64
+ item.get("name", "")
65
+ for item in _client.paginate(url)
66
+ if item.get("name")
67
+ ]
68
+
69
+
70
+ def _parse_deployment(subscription: str, item: dict[str, Any]) -> AzureDeployment:
71
+ properties = item.get("properties", {}) if isinstance(item, dict) else {}
72
+ resource_id = item.get("id", "")
73
+ return AzureDeployment(
74
+ subscription=subscription,
75
+ resource_group=_resource_group_from_id(resource_id),
76
+ name=item.get("name", ""),
77
+ provisioning_state=properties.get("provisioningState"),
78
+ timestamp=properties.get("timestamp"),
79
+ mode=properties.get("mode"),
80
+ template_hash=properties.get("templateHash"),
81
+ correlation_id=properties.get("correlationId"),
82
+ id=resource_id,
83
+ raw=item,
84
+ )
85
+
86
+
87
+ def _resource_group_from_id(resource_id: str) -> str | None:
88
+ parts = resource_id.lower().split("/")
89
+ try:
90
+ idx = parts.index("resourcegroups")
91
+ except ValueError:
92
+ return None
93
+ if idx + 1 < len(parts):
94
+ return resource_id.split("/")[idx + 1]
95
+ return None