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.
Files changed (125) hide show
  1. {granny_devops-0.19.2 → granny_devops-0.19.3}/PKG-INFO +1 -1
  2. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/analyze/costs.py +89 -0
  3. granny_devops-0.19.3/granny/bunny/__init__.py +7 -0
  4. granny_devops-0.19.3/granny/bunny/billing.py +104 -0
  5. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/analyze.py +16 -3
  6. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/serverless.py +447 -384
  7. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/storage.py +8 -1
  8. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/dns/bunny.py +19 -3
  9. granny_devops-0.19.3/granny/scaleway/__init__.py +6 -0
  10. granny_devops-0.19.3/granny/scaleway/billing.py +116 -0
  11. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/serverless/scaleway.py +58 -1
  12. {granny_devops-0.19.2 → granny_devops-0.19.3}/pyproject.toml +1 -1
  13. {granny_devops-0.19.2 → granny_devops-0.19.3}/.gitignore +0 -0
  14. {granny_devops-0.19.2 → granny_devops-0.19.3}/LICENSE +0 -0
  15. {granny_devops-0.19.2 → granny_devops-0.19.3}/README.md +0 -0
  16. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/__init__.py +0 -0
  17. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/airtable/__init__.py +0 -0
  18. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/airtable/client.py +0 -0
  19. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/analyze/__init__.py +0 -0
  20. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/analyze/credits.py +0 -0
  21. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/analyze/gpu_pricing.py +0 -0
  22. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/analyze/gpus.py +0 -0
  23. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/analyze/kubernetes.py +0 -0
  24. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/analyze/lambdas.py +0 -0
  25. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/analyze/vpcs.py +0 -0
  26. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/authentik/__init__.py +0 -0
  27. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/authentik/client.py +0 -0
  28. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/authentik/provision.py +0 -0
  29. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/aws/__init__.py +0 -0
  30. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/aws/bedrock.py +0 -0
  31. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/aws/quota.py +0 -0
  32. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/azure/__init__.py +0 -0
  33. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/azure/_client.py +0 -0
  34. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/azure/account.py +0 -0
  35. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/azure/deployment.py +0 -0
  36. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/azure/openai.py +0 -0
  37. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/azure/quota.py +0 -0
  38. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/azure/resourcegroup.py +0 -0
  39. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/azure/role.py +0 -0
  40. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/azure/sku.py +0 -0
  41. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/azure/vm.py +0 -0
  42. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/azure/webapp.py +0 -0
  43. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cdn/__init__.py +0 -0
  44. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cdn/bunny.py +0 -0
  45. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/__init__.py +0 -0
  46. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/airtable.py +0 -0
  47. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/authentik.py +0 -0
  48. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/aws.py +0 -0
  49. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/azure.py +0 -0
  50. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/cdn.py +0 -0
  51. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/cloudflare.py +0 -0
  52. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/create.py +0 -0
  53. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/credentials.py +0 -0
  54. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/dns.py +0 -0
  55. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/docker.py +0 -0
  56. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/edge.py +0 -0
  57. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/elk.py +0 -0
  58. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/email.py +0 -0
  59. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/indexing.py +0 -0
  60. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/knative.py +0 -0
  61. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/main.py +0 -0
  62. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/oauth.py +0 -0
  63. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cli/zitadel.py +0 -0
  64. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cloudflare/__init__.py +0 -0
  65. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cloudflare/d1.py +0 -0
  66. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cloudflare/r2.py +0 -0
  67. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/cloudflare/workers.py +0 -0
  68. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/__init__.py +0 -0
  69. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/auto_certificate.py +0 -0
  70. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/cloudfront-security-headers.js +0 -0
  71. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/letsencrypt_cert.py +0 -0
  72. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/manage-dns.sh +0 -0
  73. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/manage_mailjet_contacts.py +0 -0
  74. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/registrars.py +0 -0
  75. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/setup_aws_cloudfront.py +0 -0
  76. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/setup_bunny_edge_script.py +0 -0
  77. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/setup_bunny_storage.py +0 -0
  78. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/setup_cognito_identity_pool.py +0 -0
  79. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/setup_hetzner_bunny.py +0 -0
  80. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/setup_mailjet_dns.py +0 -0
  81. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/setup_private_cdn.py +0 -0
  82. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/setup_s3_website.py +0 -0
  83. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/setup_scaleway_container.py +0 -0
  84. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/setup_scaleway_faas.py +0 -0
  85. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/setup_workmail.py +0 -0
  86. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/create/www-redirect-function.js +0 -0
  87. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/credentials/__init__.py +0 -0
  88. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/credentials/secrets.py +0 -0
  89. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/dns/__init__.py +0 -0
  90. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/dns/base.py +0 -0
  91. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/dns/cloudflare.py +0 -0
  92. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/dns/cloudns.py +0 -0
  93. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/dns/desec.py +0 -0
  94. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/dns/factory.py +0 -0
  95. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/dns/hetzner.py +0 -0
  96. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/dns/inwx.py +0 -0
  97. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/dns/manual.py +0 -0
  98. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/dns/records.py +0 -0
  99. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/docker/__init__.py +0 -0
  100. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/docker/build_base.py +0 -0
  101. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/edge/__init__.py +0 -0
  102. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/edge/bunny.py +0 -0
  103. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/elk/__init__.py +0 -0
  104. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/elk/client.py +0 -0
  105. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/email/__init__.py +0 -0
  106. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/email/mailjet.py +0 -0
  107. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/email/mailjet_contacts.py +0 -0
  108. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/email/ses_forwarding.py +0 -0
  109. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/email/stalwart.py +0 -0
  110. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/email/workmail.py +0 -0
  111. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/indexing/__init__.py +0 -0
  112. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/indexing/google.py +0 -0
  113. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/indexing/indexnow.py +0 -0
  114. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/knative/__init__.py +0 -0
  115. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/knative/client.py +0 -0
  116. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/oauth/__init__.py +0 -0
  117. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/oauth/productivity.py +0 -0
  118. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/report.py +0 -0
  119. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/serverless/__init__.py +0 -0
  120. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/storage/__init__.py +0 -0
  121. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/storage/aws.py +0 -0
  122. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/storage/bunny.py +0 -0
  123. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/storage/hetzner.py +0 -0
  124. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/zitadel/__init__.py +0 -0
  125. {granny_devops-0.19.2 → granny_devops-0.19.3}/granny/zitadel/client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: granny-devops
3
- Version: 0.19.2
3
+ Version: 0.19.3
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
@@ -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([*_PROVIDERS, "all"]),
480
+ type=click.Choice([*_COST_PROVIDERS, "all"]),
476
481
  default="all",
477
- help="Restrict to one cloud (default: all three)",
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 = _PROVIDERS if provider == "all" else (provider,)
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