granny-devops 0.19.2__tar.gz → 0.19.3__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.19.2 → granny_devops-0.19.3}/PKG-INFO +1 -1
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/analyze/costs.py +89 -0
- granny_devops-0.19.3/granny/bunny/__init__.py +7 -0
- granny_devops-0.19.3/granny/bunny/billing.py +104 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/analyze.py +16 -3
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/serverless.py +447 -384
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/storage.py +8 -1
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/dns/bunny.py +19 -3
- granny_devops-0.19.3/granny/scaleway/__init__.py +6 -0
- granny_devops-0.19.3/granny/scaleway/billing.py +116 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/serverless/scaleway.py +58 -1
- {granny_devops-0.19.2 → granny_devops-0.19.3}/pyproject.toml +1 -1
- {granny_devops-0.19.2 → granny_devops-0.19.3}/.gitignore +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/LICENSE +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/README.md +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/__init__.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/airtable/__init__.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/airtable/client.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/analyze/__init__.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/analyze/credits.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/analyze/gpu_pricing.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/analyze/gpus.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/analyze/kubernetes.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/analyze/lambdas.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/analyze/vpcs.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/authentik/__init__.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/authentik/client.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/authentik/provision.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/aws/__init__.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/aws/bedrock.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/aws/quota.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/azure/__init__.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/azure/_client.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/azure/account.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/azure/deployment.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/azure/openai.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/azure/quota.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/azure/resourcegroup.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/azure/role.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/azure/sku.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/azure/vm.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/azure/webapp.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cdn/__init__.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cdn/bunny.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/__init__.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/airtable.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/authentik.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/aws.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/azure.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/cdn.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/cloudflare.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/create.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/credentials.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/dns.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/docker.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/edge.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/elk.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/email.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/indexing.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/knative.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/main.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/oauth.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/zitadel.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cloudflare/__init__.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cloudflare/d1.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cloudflare/r2.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cloudflare/workers.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/__init__.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/auto_certificate.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/cloudfront-security-headers.js +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/letsencrypt_cert.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/manage-dns.sh +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/manage_mailjet_contacts.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/registrars.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/setup_aws_cloudfront.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/setup_bunny_edge_script.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/setup_bunny_storage.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/setup_cognito_identity_pool.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/setup_hetzner_bunny.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/setup_mailjet_dns.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/setup_private_cdn.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/setup_s3_website.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/setup_scaleway_container.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/setup_scaleway_faas.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/setup_workmail.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/www-redirect-function.js +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/credentials/__init__.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/credentials/secrets.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/dns/__init__.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/dns/base.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/dns/cloudflare.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/dns/cloudns.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/dns/desec.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/dns/factory.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/dns/hetzner.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/dns/inwx.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/dns/manual.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/dns/records.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/docker/__init__.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/docker/build_base.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/edge/__init__.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/edge/bunny.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/elk/__init__.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/elk/client.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/email/__init__.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/email/mailjet.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/email/mailjet_contacts.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/email/ses_forwarding.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/email/stalwart.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/email/workmail.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/indexing/__init__.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/indexing/google.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/indexing/indexnow.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/knative/__init__.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/knative/client.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/oauth/__init__.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/oauth/productivity.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/report.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/serverless/__init__.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/storage/__init__.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/storage/aws.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/storage/bunny.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/storage/hetzner.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/zitadel/__init__.py +0 -0
- {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/zitadel/client.py +0 -0
|
@@ -335,3 +335,92 @@ def azure_costs(subscriptions: list[str] | None = None) -> list[CostSummary]:
|
|
|
335
335
|
)
|
|
336
336
|
)
|
|
337
337
|
return out
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
# Usage-billed providers
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
#
|
|
344
|
+
# Bunny and Scaleway both expose real month-to-date spend, so neither needs the
|
|
345
|
+
# run-rate reconstruction Hetzner requires. Neither offers a forecast endpoint,
|
|
346
|
+
# so both derive one the same way: scale month-to-date by how much of the month
|
|
347
|
+
# has elapsed. That is a straight-line projection, not the provider's opinion,
|
|
348
|
+
# and the note on each row says so.
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _straight_line_eom(mtd: float, today: date | None = None) -> float:
|
|
352
|
+
"""Project month-to-date spend to month end, pro rata by elapsed days.
|
|
353
|
+
|
|
354
|
+
Counts today as elapsed, so the first of the month projects from one day of
|
|
355
|
+
data rather than dividing by zero.
|
|
356
|
+
"""
|
|
357
|
+
start, today, end = _month_bounds(today)
|
|
358
|
+
elapsed = (today - start).days + 1
|
|
359
|
+
days_in_month = (end - start).days
|
|
360
|
+
return mtd / elapsed * days_in_month
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def bunny_costs(customer: str | None = None) -> list[CostSummary]:
|
|
364
|
+
"""Month-to-date Bunny spend, from the prepaid balance drawdown.
|
|
365
|
+
|
|
366
|
+
Bunny is prepaid: ``ThisMonthCharges`` is what this month has drawn against
|
|
367
|
+
a balance already paid in. The remaining balance rides along in the note,
|
|
368
|
+
because on a prepaid account "how much is left" is the question that
|
|
369
|
+
actually causes an outage.
|
|
370
|
+
"""
|
|
371
|
+
from granny.bunny.billing import BunnyBillingClient
|
|
372
|
+
|
|
373
|
+
start, _today, end = _month_bounds()
|
|
374
|
+
try:
|
|
375
|
+
client = BunnyBillingClient(customer=customer)
|
|
376
|
+
billing = client.get_billing()
|
|
377
|
+
except Exception as e: # noqa: BLE001 - one provider must not abort a sweep
|
|
378
|
+
logger.warning("Bunny billing: %s", e)
|
|
379
|
+
return []
|
|
380
|
+
|
|
381
|
+
mtd = client.month_to_date(billing)
|
|
382
|
+
balance = client.balance(billing)
|
|
383
|
+
note = f"Prepaid; balance EUR {balance:,.2f} remaining. Forecast is straight-line."
|
|
384
|
+
return [
|
|
385
|
+
CostSummary(
|
|
386
|
+
provider="bunny",
|
|
387
|
+
account=customer or "default",
|
|
388
|
+
currency="EUR",
|
|
389
|
+
period_start=start.isoformat(),
|
|
390
|
+
period_end=end.isoformat(),
|
|
391
|
+
mtd_cost=mtd,
|
|
392
|
+
forecast_eom=_straight_line_eom(mtd),
|
|
393
|
+
note=note,
|
|
394
|
+
)
|
|
395
|
+
]
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def scaleway_costs(organization_id: str | None = None) -> list[CostSummary]:
|
|
399
|
+
"""Month-to-date Scaleway spend, net of free-tier discounts.
|
|
400
|
+
|
|
401
|
+
Scaleway reports consumption per SKU per project, so this total is real but
|
|
402
|
+
cannot be broken down per resource. The discount lines arrive negative and
|
|
403
|
+
are already included.
|
|
404
|
+
"""
|
|
405
|
+
from granny.scaleway.billing import ScalewayBillingClient
|
|
406
|
+
|
|
407
|
+
start, _today, end = _month_bounds()
|
|
408
|
+
try:
|
|
409
|
+
client = ScalewayBillingClient(organization_id=organization_id)
|
|
410
|
+
mtd, currency = client.month_to_date()
|
|
411
|
+
except Exception as e: # noqa: BLE001 - one provider must not abort a sweep
|
|
412
|
+
logger.warning("Scaleway billing: %s", e)
|
|
413
|
+
return []
|
|
414
|
+
|
|
415
|
+
return [
|
|
416
|
+
CostSummary(
|
|
417
|
+
provider="scaleway",
|
|
418
|
+
account=client.organization_id,
|
|
419
|
+
currency=currency,
|
|
420
|
+
period_start=start.isoformat(),
|
|
421
|
+
period_end=end.isoformat(),
|
|
422
|
+
mtd_cost=mtd,
|
|
423
|
+
forecast_eom=_straight_line_eom(mtd),
|
|
424
|
+
note="Per-SKU billing; no per-resource split. Forecast is straight-line.",
|
|
425
|
+
)
|
|
426
|
+
]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Bunny account-level clients that are not tied to one product.
|
|
2
|
+
|
|
3
|
+
Bunny's product clients already live under the layer they belong to:
|
|
4
|
+
``granny.cdn.bunny`` for pull zones, ``granny.storage.bunny`` for storage
|
|
5
|
+
zones, ``granny.dns.bunny`` for DNS. Billing spans all three, so it lives
|
|
6
|
+
here rather than picking one of them arbitrarily.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Bunny account billing client.
|
|
2
|
+
|
|
3
|
+
Bunny is prepaid: money goes in as a balance and the month draws it down. So
|
|
4
|
+
``ThisMonthCharges`` is real month-to-date spend, and ``Balance`` is the number
|
|
5
|
+
that decides whether the CDN keeps serving next week.
|
|
6
|
+
|
|
7
|
+
Per-product costs are only partly available. Each pull zone reports its own
|
|
8
|
+
``MonthlyCharges``, but storage, DNS and per-region traffic are billed as
|
|
9
|
+
account-level totals with no per-zone split. Callers that want a cost per
|
|
10
|
+
storage zone cannot have one.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
from granny.credentials.secrets import get_bunny_api_key
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
API_BASE = "https://api.bunny.net"
|
|
23
|
+
|
|
24
|
+
# The `/billing` payload carries ~30 MonthlyCharges* fields, nearly all zero for
|
|
25
|
+
# any given account. This maps the ones worth naming onto human labels; the
|
|
26
|
+
# rest are reported generically if they turn out to be non-zero, so a product
|
|
27
|
+
# we have never used does not silently vanish from the breakdown.
|
|
28
|
+
CHARGE_LABELS = {
|
|
29
|
+
"MonthlyChargesStorage": "Storage",
|
|
30
|
+
"MonthlyChargesDNS": "DNS",
|
|
31
|
+
"MonthlyChargesEUTraffic": "Traffic (EU)",
|
|
32
|
+
"MonthlyChargesUSTraffic": "Traffic (US)",
|
|
33
|
+
"MonthlyChargesASIATraffic": "Traffic (Asia)",
|
|
34
|
+
"MonthlyChargesSATraffic": "Traffic (South America)",
|
|
35
|
+
"MonthlyChargesAFTraffic": "Traffic (Africa)",
|
|
36
|
+
"MonthlyChargesOptimizer": "Optimizer",
|
|
37
|
+
"MonthlyChargesShield": "Shield",
|
|
38
|
+
"MonthlyChargesScripting": "Edge Scripting",
|
|
39
|
+
"MonthlyChargesLoadBalancer": "Load Balancer",
|
|
40
|
+
"MonthlyChargesMagicContainers": "Magic Containers",
|
|
41
|
+
"MonthlyChargesAiGateway": "AI Gateway",
|
|
42
|
+
"MonthlyChargesDB": "Database",
|
|
43
|
+
"MonthlyChargesExtraPullZones": "Extra pull zones",
|
|
44
|
+
"MonthlyChargesExtraStorageZones": "Extra storage zones",
|
|
45
|
+
"MonthlyChargesExtraDnsZones": "Extra DNS zones",
|
|
46
|
+
"MonthlyChargesTaxes": "Taxes",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# Not a charge -- it is the sum of them, and adding it to a breakdown would
|
|
50
|
+
# double the total.
|
|
51
|
+
_TOTAL_FIELD = "ThisMonthCharges"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class BunnyBillingClient:
|
|
55
|
+
"""Account balance and month-to-date charges."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, api_key: str | None = None, customer: str | None = None) -> None:
|
|
58
|
+
api_key = api_key or get_bunny_api_key(customer)
|
|
59
|
+
if not api_key:
|
|
60
|
+
label = f"BUNNY_API_KEY_{customer.upper()}" if customer else "BUNNY_API_KEY"
|
|
61
|
+
raise ValueError(f"{label} not set (env or vault).")
|
|
62
|
+
self.session = requests.Session()
|
|
63
|
+
self.session.headers.update({"AccessKey": api_key, "Content-Type": "application/json"})
|
|
64
|
+
|
|
65
|
+
def _request(self, method: str, path: str) -> Any:
|
|
66
|
+
url = f"{API_BASE}{path}"
|
|
67
|
+
resp = getattr(self.session, method.lower())(url, timeout=30)
|
|
68
|
+
if resp.status_code >= 400:
|
|
69
|
+
raise RuntimeError(f"Bunny {method} {path}: {resp.status_code} {resp.text}")
|
|
70
|
+
if resp.status_code == 204 or not resp.text:
|
|
71
|
+
return {}
|
|
72
|
+
return resp.json()
|
|
73
|
+
|
|
74
|
+
def get_billing(self) -> dict:
|
|
75
|
+
"""Raw ``/billing`` payload: balance, totals and per-product charges."""
|
|
76
|
+
return self._request("GET", "/billing")
|
|
77
|
+
|
|
78
|
+
def month_to_date(self, billing: dict | None = None) -> float:
|
|
79
|
+
billing = self.get_billing() if billing is None else billing
|
|
80
|
+
return float(billing.get(_TOTAL_FIELD) or 0.0)
|
|
81
|
+
|
|
82
|
+
def balance(self, billing: dict | None = None) -> float:
|
|
83
|
+
billing = self.get_billing() if billing is None else billing
|
|
84
|
+
return float(billing.get("Balance") or 0.0)
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def charge_breakdown(billing: dict) -> list[tuple[str, float]]:
|
|
88
|
+
"""Non-zero per-product charges, largest first.
|
|
89
|
+
|
|
90
|
+
Zero-valued products are dropped rather than rendered as ``0.00``: on
|
|
91
|
+
an account with thirty charge fields and six in use, listing them all
|
|
92
|
+
buries the six that matter.
|
|
93
|
+
"""
|
|
94
|
+
out: list[tuple[str, float]] = []
|
|
95
|
+
for key, value in billing.items():
|
|
96
|
+
if not key.startswith("MonthlyCharges") or not value:
|
|
97
|
+
continue
|
|
98
|
+
label = CHARGE_LABELS.get(key)
|
|
99
|
+
if label is None:
|
|
100
|
+
# An unmapped product we are apparently paying for. Better a
|
|
101
|
+
# clumsy label than a missing line.
|
|
102
|
+
label = key.removeprefix("MonthlyCharges")
|
|
103
|
+
out.append((label, float(value)))
|
|
104
|
+
return sorted(out, key=lambda pair: -pair[1])
|
|
@@ -153,6 +153,11 @@ def vpcs(region: tuple[str, ...], as_json: bool) -> None:
|
|
|
153
153
|
|
|
154
154
|
_PROVIDERS = ("aws", "gcp", "azure")
|
|
155
155
|
|
|
156
|
+
# Only `costs` understands Bunny and Scaleway. `gpus` and `credits` share
|
|
157
|
+
# _PROVIDERS and would offer choices they cannot answer -- neither provider
|
|
158
|
+
# rents GPUs or issues the kind of credit those commands report.
|
|
159
|
+
_COST_PROVIDERS = (*_PROVIDERS, "bunny", "scaleway")
|
|
160
|
+
|
|
156
161
|
|
|
157
162
|
def _parse_filter(filter_str: str | None) -> set[str] | None:
|
|
158
163
|
if not filter_str:
|
|
@@ -472,9 +477,9 @@ def credits(
|
|
|
472
477
|
@analyze.command()
|
|
473
478
|
@click.option(
|
|
474
479
|
"--provider",
|
|
475
|
-
type=click.Choice([*
|
|
480
|
+
type=click.Choice([*_COST_PROVIDERS, "all"]),
|
|
476
481
|
default="all",
|
|
477
|
-
help="Restrict to one cloud (default:
|
|
482
|
+
help="Restrict to one cloud (default: every cloud that reports spend)",
|
|
478
483
|
)
|
|
479
484
|
@click.option("--profile", "profiles", multiple=True, help="AWS profile (repeatable)")
|
|
480
485
|
@click.option(
|
|
@@ -486,12 +491,16 @@ def credits(
|
|
|
486
491
|
@click.option(
|
|
487
492
|
"--subscription", "subscriptions", multiple=True, help="Azure subscription ID (repeatable)"
|
|
488
493
|
)
|
|
494
|
+
@click.option(
|
|
495
|
+
"--customer", default=None, help="Bunny customer profile (see `granny cdn list-customers`)"
|
|
496
|
+
)
|
|
489
497
|
@click.option("--json-output", "as_json", is_flag=True, help="Output as JSON")
|
|
490
498
|
def costs(
|
|
491
499
|
provider: str,
|
|
492
500
|
profiles: tuple[str, ...],
|
|
493
501
|
billing_accounts: tuple[str, ...],
|
|
494
502
|
subscriptions: tuple[str, ...],
|
|
503
|
+
customer: str | None,
|
|
495
504
|
as_json: bool,
|
|
496
505
|
) -> None:
|
|
497
506
|
"""Show month-to-date spend and end-of-month forecast per account."""
|
|
@@ -499,7 +508,7 @@ def costs(
|
|
|
499
508
|
|
|
500
509
|
from granny.analyze import costs as co
|
|
501
510
|
|
|
502
|
-
targets =
|
|
511
|
+
targets = _COST_PROVIDERS if provider == "all" else (provider,)
|
|
503
512
|
rows: list = []
|
|
504
513
|
for p in targets:
|
|
505
514
|
try:
|
|
@@ -509,6 +518,10 @@ def costs(
|
|
|
509
518
|
rows.extend(co.gcp_costs(billing_accounts=list(billing_accounts) or None))
|
|
510
519
|
elif p == "azure":
|
|
511
520
|
rows.extend(co.azure_costs(subscriptions=list(subscriptions) or None))
|
|
521
|
+
elif p == "bunny":
|
|
522
|
+
rows.extend(co.bunny_costs(customer=customer))
|
|
523
|
+
elif p == "scaleway":
|
|
524
|
+
rows.extend(co.scaleway_costs())
|
|
512
525
|
except RuntimeError as e:
|
|
513
526
|
click.echo(f"[{p}] skipped: {e}", err=True)
|
|
514
527
|
|