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.
- {granny_devops-0.9.2 → granny_devops-0.10.0}/PKG-INFO +31 -1
- {granny_devops-0.9.2 → granny_devops-0.10.0}/README.md +30 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/__init__.py +1 -1
- granny_devops-0.10.0/granny/analyze/gpu_pricing.py +199 -0
- granny_devops-0.10.0/granny/azure/__init__.py +1 -0
- granny_devops-0.10.0/granny/azure/_client.py +81 -0
- granny_devops-0.10.0/granny/azure/account.py +90 -0
- granny_devops-0.10.0/granny/azure/deployment.py +95 -0
- granny_devops-0.10.0/granny/azure/openai.py +147 -0
- granny_devops-0.10.0/granny/azure/quota.py +205 -0
- granny_devops-0.10.0/granny/azure/sku.py +73 -0
- granny_devops-0.10.0/granny/azure/webapp.py +124 -0
- granny_devops-0.10.0/granny/cli/azure.py +543 -0
- granny_devops-0.10.0/granny/cli/elk.py +192 -0
- granny_devops-0.10.0/granny/cli/indexing.py +247 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cli/main.py +15 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/credentials/secrets.py +4 -0
- granny_devops-0.10.0/granny/elk/__init__.py +5 -0
- granny_devops-0.10.0/granny/elk/client.py +149 -0
- granny_devops-0.10.0/granny/indexing/__init__.py +41 -0
- granny_devops-0.10.0/granny/indexing/google.py +134 -0
- granny_devops-0.10.0/granny/indexing/indexnow.py +237 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/pyproject.toml +1 -1
- {granny_devops-0.9.2 → granny_devops-0.10.0}/.gitignore +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/LICENSE +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/analyze/__init__.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/analyze/costs.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/analyze/credits.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/analyze/gpus.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/analyze/lambdas.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/analyze/vpcs.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/authentik/__init__.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/authentik/client.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/authentik/provision.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cdn/__init__.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cdn/bunny.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cli/__init__.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cli/analyze.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cli/authentik.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cli/cdn.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cli/cloudflare.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cli/create.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cli/credentials.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cli/dns.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cli/docker.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cli/edge.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cli/email.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cli/serverless.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cli/storage.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cloudflare/__init__.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cloudflare/d1.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cloudflare/r2.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/cloudflare/workers.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/__init__.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/auto_certificate.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/cloudfront-security-headers.js +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/manage-dns.sh +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/manage_mailjet_contacts.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/registrars.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/setup_aws_cloudfront.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/setup_bunny_edge_script.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/setup_bunny_storage.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/setup_cognito_identity_pool.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/setup_hetzner_bunny.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/setup_mailjet_dns.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/setup_private_cdn.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/setup_s3_website.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/setup_scaleway_container.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/setup_scaleway_faas.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/setup_workmail.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/create/www-redirect-function.js +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/credentials/__init__.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/dns/__init__.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/dns/base.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/dns/bunny.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/dns/cloudflare.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/dns/cloudns.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/dns/desec.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/dns/factory.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/dns/hetzner.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/dns/inwx.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/dns/manual.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/dns/records.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/docker/__init__.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/docker/build_base.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/edge/__init__.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/edge/bunny.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/email/__init__.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/email/mailjet.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/email/mailjet_contacts.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/email/ses_forwarding.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/email/workmail.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/report.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/serverless/__init__.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/serverless/scaleway.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/storage/__init__.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/storage/aws.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.10.0}/granny/storage/bunny.py +0 -0
- {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.
|
|
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
|
```
|
|
@@ -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
|