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