granny-devops 0.17.4__tar.gz → 0.18.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. {granny_devops-0.17.4 → granny_devops-0.18.0}/PKG-INFO +14 -2
  2. {granny_devops-0.17.4 → granny_devops-0.18.0}/README.md +13 -1
  3. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/__init__.py +1 -1
  4. granny_devops-0.18.0/granny/cli/knative.py +378 -0
  5. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/cli/main.py +26 -0
  6. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/cli/serverless.py +46 -0
  7. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/credentials/secrets.py +8 -1
  8. granny_devops-0.18.0/granny/knative/__init__.py +5 -0
  9. granny_devops-0.18.0/granny/knative/client.py +339 -0
  10. {granny_devops-0.17.4 → granny_devops-0.18.0}/pyproject.toml +1 -1
  11. {granny_devops-0.17.4 → granny_devops-0.18.0}/.gitignore +0 -0
  12. {granny_devops-0.17.4 → granny_devops-0.18.0}/LICENSE +0 -0
  13. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/airtable/__init__.py +0 -0
  14. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/airtable/client.py +0 -0
  15. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/analyze/__init__.py +0 -0
  16. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/analyze/costs.py +0 -0
  17. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/analyze/credits.py +0 -0
  18. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/analyze/gpu_pricing.py +0 -0
  19. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/analyze/gpus.py +0 -0
  20. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/analyze/kubernetes.py +0 -0
  21. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/analyze/lambdas.py +0 -0
  22. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/analyze/vpcs.py +0 -0
  23. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/authentik/__init__.py +0 -0
  24. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/authentik/client.py +0 -0
  25. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/authentik/provision.py +0 -0
  26. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/aws/__init__.py +0 -0
  27. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/aws/bedrock.py +0 -0
  28. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/aws/quota.py +0 -0
  29. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/azure/__init__.py +0 -0
  30. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/azure/_client.py +0 -0
  31. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/azure/account.py +0 -0
  32. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/azure/deployment.py +0 -0
  33. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/azure/openai.py +0 -0
  34. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/azure/quota.py +0 -0
  35. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/azure/resourcegroup.py +0 -0
  36. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/azure/role.py +0 -0
  37. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/azure/sku.py +0 -0
  38. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/azure/vm.py +0 -0
  39. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/azure/webapp.py +0 -0
  40. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/cdn/__init__.py +0 -0
  41. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/cdn/bunny.py +0 -0
  42. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/cli/__init__.py +0 -0
  43. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/cli/airtable.py +0 -0
  44. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/cli/analyze.py +0 -0
  45. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/cli/authentik.py +0 -0
  46. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/cli/aws.py +0 -0
  47. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/cli/azure.py +0 -0
  48. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/cli/cdn.py +0 -0
  49. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/cli/cloudflare.py +0 -0
  50. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/cli/create.py +0 -0
  51. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/cli/credentials.py +0 -0
  52. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/cli/dns.py +0 -0
  53. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/cli/docker.py +0 -0
  54. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/cli/edge.py +0 -0
  55. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/cli/elk.py +0 -0
  56. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/cli/email.py +0 -0
  57. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/cli/indexing.py +0 -0
  58. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/cli/oauth.py +0 -0
  59. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/cli/storage.py +0 -0
  60. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/cli/zitadel.py +0 -0
  61. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/cloudflare/__init__.py +0 -0
  62. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/cloudflare/d1.py +0 -0
  63. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/cloudflare/r2.py +0 -0
  64. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/cloudflare/workers.py +0 -0
  65. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/create/__init__.py +0 -0
  66. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/create/auto_certificate.py +0 -0
  67. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/create/cloudfront-security-headers.js +0 -0
  68. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/create/letsencrypt_cert.py +0 -0
  69. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/create/manage-dns.sh +0 -0
  70. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/create/manage_mailjet_contacts.py +0 -0
  71. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/create/registrars.py +0 -0
  72. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/create/setup_aws_cloudfront.py +0 -0
  73. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/create/setup_bunny_edge_script.py +0 -0
  74. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/create/setup_bunny_storage.py +0 -0
  75. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/create/setup_cognito_identity_pool.py +0 -0
  76. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/create/setup_hetzner_bunny.py +0 -0
  77. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/create/setup_mailjet_dns.py +0 -0
  78. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/create/setup_private_cdn.py +0 -0
  79. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/create/setup_s3_website.py +0 -0
  80. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/create/setup_scaleway_container.py +0 -0
  81. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/create/setup_scaleway_faas.py +0 -0
  82. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/create/setup_workmail.py +0 -0
  83. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/create/www-redirect-function.js +0 -0
  84. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/credentials/__init__.py +0 -0
  85. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/dns/__init__.py +0 -0
  86. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/dns/base.py +0 -0
  87. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/dns/bunny.py +0 -0
  88. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/dns/cloudflare.py +0 -0
  89. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/dns/cloudns.py +0 -0
  90. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/dns/desec.py +0 -0
  91. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/dns/factory.py +0 -0
  92. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/dns/hetzner.py +0 -0
  93. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/dns/inwx.py +0 -0
  94. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/dns/manual.py +0 -0
  95. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/dns/records.py +0 -0
  96. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/docker/__init__.py +0 -0
  97. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/docker/build_base.py +0 -0
  98. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/edge/__init__.py +0 -0
  99. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/edge/bunny.py +0 -0
  100. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/elk/__init__.py +0 -0
  101. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/elk/client.py +0 -0
  102. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/email/__init__.py +0 -0
  103. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/email/mailjet.py +0 -0
  104. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/email/mailjet_contacts.py +0 -0
  105. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/email/ses_forwarding.py +0 -0
  106. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/email/stalwart.py +0 -0
  107. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/email/workmail.py +0 -0
  108. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/indexing/__init__.py +0 -0
  109. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/indexing/google.py +0 -0
  110. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/indexing/indexnow.py +0 -0
  111. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/report.py +0 -0
  112. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/serverless/__init__.py +0 -0
  113. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/serverless/scaleway.py +0 -0
  114. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/storage/__init__.py +0 -0
  115. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/storage/aws.py +0 -0
  116. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/storage/bunny.py +0 -0
  117. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/storage/hetzner.py +0 -0
  118. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/zitadel/__init__.py +0 -0
  119. {granny_devops-0.17.4 → granny_devops-0.18.0}/granny/zitadel/client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: granny-devops
3
- Version: 0.17.4
3
+ Version: 0.18.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
@@ -219,6 +219,7 @@ Common keys:
219
219
  | Cloudflare | `CLOUDFLARE_API_TOKEN` |
220
220
  | Hetzner | `HETZNER_S3_ACCESS_KEY`, `HETZNER_S3_SECRET_KEY`, `HETZNER_DNS_API_TOKEN` |
221
221
  | Scaleway | `SCW_ACCESS_KEY`, `SCW_SECRET_KEY`, `SCW_DEFAULT_PROJECT_ID` |
222
+ | Knative FaaS API | `OBACHAN_API_KEY`, `OBACHAN_API_BASE_URL` |
222
223
  | Mailjet | `MAILJET_API_KEY`, `MAILJET_SECRET_KEY` |
223
224
  | deSEC | `DESEC_API_TOKEN` |
224
225
  | ClouDNS | `CLOUDNS_AUTH_ID`/`_PASSWORD` (or `_SUB_AUTH_ID`/`_SUB_AUTH_USER`) |
@@ -301,6 +302,16 @@ granny docker build-base --image myapp-base --hash-file requirements.txt
301
302
 
302
303
  # Scaleway FaaS
303
304
  granny serverless deploy my-fn --source-dir ./dist --namespace my-app
305
+ granny serverless delete-function my-fn --namespace my-app --yes
306
+
307
+ # Knative FaaS control API (`granny faas` is an alias)
308
+ granny knative list
309
+ granny knative deploy my-fn --image registry.example.com/my-fn:latest \
310
+ --env LOG_LEVEL=info --min-scale 0 --max-scale 5
311
+ granny knative scale my-fn --max-scale 10
312
+ granny knative logs my-fn --tail 100
313
+ granny knative invoke my-fn --method POST --path /process --body '{"job": 42}'
314
+ granny knative remove my-fn --yes
304
315
 
305
316
  # Mailjet + WorkMail
306
317
  granny email mailjet setup-dns example.com
@@ -391,7 +402,7 @@ granny indexing google https://example.com/job-posting --action URL_UPDATED
391
402
  | CDN | Bunny |
392
403
  | Edge scripting | Bunny |
393
404
  | Object storage | AWS S3, Bunny Storage, Hetzner S3 |
394
- | Serverless functions | Scaleway FaaS, Scaleway Containers |
405
+ | Serverless functions | Scaleway FaaS, Scaleway Containers, Knative FaaS control API |
395
406
  | Workers / KV / D1 / R2 | Cloudflare |
396
407
  | Record search and endpoint maintenance | Airtable |
397
408
  | Email send infra | Mailjet, AWS SES, AWS WorkMail |
@@ -444,6 +455,7 @@ granny/
444
455
  elk/ Elasticsearch / Kibana security user management
445
456
  email/ Mailjet, WorkMail, SES forwarding
446
457
  indexing/ IndexNow + Google Indexing API
458
+ knative/ Knative FaaS control API client
447
459
  serverless/ Scaleway FaaS
448
460
  storage/ Object storage (AWS / Bunny / Hetzner)
449
461
  ```
@@ -85,6 +85,7 @@ Common keys:
85
85
  | Cloudflare | `CLOUDFLARE_API_TOKEN` |
86
86
  | Hetzner | `HETZNER_S3_ACCESS_KEY`, `HETZNER_S3_SECRET_KEY`, `HETZNER_DNS_API_TOKEN` |
87
87
  | Scaleway | `SCW_ACCESS_KEY`, `SCW_SECRET_KEY`, `SCW_DEFAULT_PROJECT_ID` |
88
+ | Knative FaaS API | `OBACHAN_API_KEY`, `OBACHAN_API_BASE_URL` |
88
89
  | Mailjet | `MAILJET_API_KEY`, `MAILJET_SECRET_KEY` |
89
90
  | deSEC | `DESEC_API_TOKEN` |
90
91
  | ClouDNS | `CLOUDNS_AUTH_ID`/`_PASSWORD` (or `_SUB_AUTH_ID`/`_SUB_AUTH_USER`) |
@@ -167,6 +168,16 @@ granny docker build-base --image myapp-base --hash-file requirements.txt
167
168
 
168
169
  # Scaleway FaaS
169
170
  granny serverless deploy my-fn --source-dir ./dist --namespace my-app
171
+ granny serverless delete-function my-fn --namespace my-app --yes
172
+
173
+ # Knative FaaS control API (`granny faas` is an alias)
174
+ granny knative list
175
+ granny knative deploy my-fn --image registry.example.com/my-fn:latest \
176
+ --env LOG_LEVEL=info --min-scale 0 --max-scale 5
177
+ granny knative scale my-fn --max-scale 10
178
+ granny knative logs my-fn --tail 100
179
+ granny knative invoke my-fn --method POST --path /process --body '{"job": 42}'
180
+ granny knative remove my-fn --yes
170
181
 
171
182
  # Mailjet + WorkMail
172
183
  granny email mailjet setup-dns example.com
@@ -257,7 +268,7 @@ granny indexing google https://example.com/job-posting --action URL_UPDATED
257
268
  | CDN | Bunny |
258
269
  | Edge scripting | Bunny |
259
270
  | Object storage | AWS S3, Bunny Storage, Hetzner S3 |
260
- | Serverless functions | Scaleway FaaS, Scaleway Containers |
271
+ | Serverless functions | Scaleway FaaS, Scaleway Containers, Knative FaaS control API |
261
272
  | Workers / KV / D1 / R2 | Cloudflare |
262
273
  | Record search and endpoint maintenance | Airtable |
263
274
  | Email send infra | Mailjet, AWS SES, AWS WorkMail |
@@ -310,6 +321,7 @@ granny/
310
321
  elk/ Elasticsearch / Kibana security user management
311
322
  email/ Mailjet, WorkMail, SES forwarding
312
323
  indexing/ IndexNow + Google Indexing API
324
+ knative/ Knative FaaS control API client
313
325
  serverless/ Scaleway FaaS
314
326
  storage/ Object storage (AWS / Bunny / Hetzner)
315
327
  ```
@@ -1,6 +1,6 @@
1
1
  """Granny -- Cloud tools collection for AWS infrastructure and DevOps automation."""
2
2
 
3
- __version__ = "0.17.4"
3
+ __version__ = "0.18.0"
4
4
  __all__ = [
5
5
  "get_secret",
6
6
  "load_secrets_into_env",
@@ -0,0 +1,378 @@
1
+ """Knative function management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+
8
+ import click
9
+
10
+
11
+ @click.group()
12
+ def knative() -> None:
13
+ """Manage functions through a Knative FaaS control API."""
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Private helpers
18
+ # ---------------------------------------------------------------------------
19
+
20
+
21
+ def _guess_content_type(path: str) -> str:
22
+ """Guess Content-Type from file extension."""
23
+ ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
24
+ return {
25
+ "html": "text/html; charset=utf-8",
26
+ "css": "text/css; charset=utf-8",
27
+ "js": "application/javascript; charset=utf-8",
28
+ "json": "application/json; charset=utf-8",
29
+ "png": "image/png",
30
+ "jpg": "image/jpeg",
31
+ "jpeg": "image/jpeg",
32
+ "gif": "image/gif",
33
+ "svg": "image/svg+xml",
34
+ "webp": "image/webp",
35
+ "ico": "image/x-icon",
36
+ "woff": "font/woff",
37
+ "woff2": "font/woff2",
38
+ "ttf": "font/ttf",
39
+ "xml": "application/xml",
40
+ "txt": "text/plain",
41
+ }.get(ext, "application/octet-stream")
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Commands
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ @knative.command("deploy")
50
+ @click.argument("name")
51
+ @click.option(
52
+ "--image", required=True, help="Container image ref (e.g. registry/fn:latest)"
53
+ )
54
+ @click.option(
55
+ "--env",
56
+ "-e",
57
+ "env_vars",
58
+ multiple=True,
59
+ help="Environment variable in KEY=VAL format. Repeatable.",
60
+ )
61
+ @click.option("--min-scale", default=0, show_default=True, type=int)
62
+ @click.option("--max-scale", default=5, show_default=True, type=int)
63
+ @click.option(
64
+ "--api-url",
65
+ envvar="OBACHAN_API_BASE_URL",
66
+ default=None,
67
+ help="FaaS control API base URL. Defaults to OBACHAN_API_BASE_URL env or vault.",
68
+ )
69
+ @click.option(
70
+ "--api-key",
71
+ envvar="OBACHAN_API_KEY",
72
+ default=None,
73
+ help="API key. Defaults to OBACHAN_API_KEY env or vault.",
74
+ )
75
+ def knative_deploy(
76
+ name: str,
77
+ image: str,
78
+ env_vars: tuple,
79
+ min_scale: int,
80
+ max_scale: int,
81
+ api_url: str | None,
82
+ api_key: str | None,
83
+ ) -> None:
84
+ """Deploy (create or update) a Knative function."""
85
+ from granny.knative import KnativeClient
86
+
87
+ parsed: dict[str, str] = {}
88
+ for entry in env_vars:
89
+ k, sep, v = entry.partition("=")
90
+ if sep:
91
+ parsed[k.strip()] = v
92
+ else:
93
+ click.echo(f"Warning: skipping malformed env entry: {entry!r}", err=True)
94
+
95
+ try:
96
+ c = KnativeClient(base_url=api_url, api_key=api_key)
97
+ result = c.deploy(
98
+ name, image, env_vars=parsed, min_scale=min_scale, max_scale=max_scale
99
+ )
100
+ except (ValueError, RuntimeError) as exc:
101
+ click.echo(f"Error: {exc}", err=True)
102
+ raise SystemExit(1)
103
+
104
+ url = (result.get("status") or {}).get("url", "(not yet ready)")
105
+ click.echo(f"Deployed: {name}")
106
+ click.echo(f"URL: {url}")
107
+
108
+
109
+ @knative.command("list")
110
+ @click.option("--api-url", envvar="OBACHAN_API_BASE_URL", default=None)
111
+ @click.option("--api-key", envvar="OBACHAN_API_KEY", default=None)
112
+ def knative_list(api_url: str | None, api_key: str | None) -> None:
113
+ """List deployed Knative functions."""
114
+ from granny.knative import KnativeClient
115
+
116
+ try:
117
+ c = KnativeClient(base_url=api_url, api_key=api_key)
118
+ items = c.list()
119
+ except (ValueError, RuntimeError) as exc:
120
+ click.echo(f"Error: {exc}", err=True)
121
+ raise SystemExit(1)
122
+
123
+ if not items:
124
+ click.echo("No functions deployed.")
125
+ return
126
+
127
+ click.echo(f"{'NAME':<30} {'URL'}")
128
+ for item in items:
129
+ item_name = (item.get("metadata") or {}).get("name") or item.get("name") or "?"
130
+ url = (item.get("status") or {}).get("url", "-")
131
+ click.echo(f"{item_name:<30} {url}")
132
+
133
+
134
+ @knative.command("get")
135
+ @click.argument("name")
136
+ @click.option("--api-url", envvar="OBACHAN_API_BASE_URL", default=None)
137
+ @click.option("--api-key", envvar="OBACHAN_API_KEY", default=None)
138
+ def knative_get(name: str, api_url: str | None, api_key: str | None) -> None:
139
+ """Get details of a Knative function."""
140
+ from granny.knative import KnativeClient
141
+
142
+ try:
143
+ c = KnativeClient(base_url=api_url, api_key=api_key)
144
+ result = c.get(name)
145
+ except (ValueError, RuntimeError) as exc:
146
+ click.echo(f"Error: {exc}", err=True)
147
+ raise SystemExit(1)
148
+
149
+ if result is None:
150
+ click.echo(f"Function '{name}' not found.", err=True)
151
+ raise SystemExit(1)
152
+
153
+ click.echo(json.dumps(result, indent=2))
154
+
155
+
156
+ @knative.command("scale")
157
+ @click.argument("name")
158
+ @click.option("--min-scale", default=None, type=int)
159
+ @click.option("--max-scale", default=None, type=int)
160
+ @click.option("--api-url", envvar="OBACHAN_API_BASE_URL", default=None)
161
+ @click.option("--api-key", envvar="OBACHAN_API_KEY", default=None)
162
+ def knative_scale(
163
+ name: str,
164
+ min_scale: int | None,
165
+ max_scale: int | None,
166
+ api_url: str | None,
167
+ api_key: str | None,
168
+ ) -> None:
169
+ """Update the scale bounds of a Knative function."""
170
+ if min_scale is None and max_scale is None:
171
+ raise click.UsageError(
172
+ "At least one of --min-scale or --max-scale must be provided."
173
+ )
174
+
175
+ from granny.knative import KnativeClient
176
+
177
+ try:
178
+ c = KnativeClient(base_url=api_url, api_key=api_key)
179
+ c.scale(name, min_scale=min_scale, max_scale=max_scale)
180
+ except (ValueError, RuntimeError) as exc:
181
+ click.echo(f"Error: {exc}", err=True)
182
+ raise SystemExit(1)
183
+
184
+ click.echo(f"Scaled {name}: min={min_scale} max={max_scale}")
185
+
186
+
187
+ @knative.command("remove")
188
+ @click.argument("name")
189
+ @click.option(
190
+ "--yes",
191
+ is_flag=True,
192
+ help="Confirm deletion. Required to prevent accidental removal.",
193
+ )
194
+ @click.option("--api-url", envvar="OBACHAN_API_BASE_URL", default=None)
195
+ @click.option("--api-key", envvar="OBACHAN_API_KEY", default=None)
196
+ def knative_remove(
197
+ name: str,
198
+ yes: bool,
199
+ api_url: str | None,
200
+ api_key: str | None,
201
+ ) -> None:
202
+ """Remove a deployed Knative function."""
203
+ if not yes:
204
+ raise click.UsageError(
205
+ "Deletion requires --yes flag to confirm. "
206
+ "Pass --yes to confirm removal of the function."
207
+ )
208
+
209
+ from granny.knative import KnativeClient
210
+
211
+ try:
212
+ c = KnativeClient(base_url=api_url, api_key=api_key)
213
+ c.delete(name)
214
+ except (ValueError, RuntimeError) as exc:
215
+ click.echo(f"Error: {exc}", err=True)
216
+ raise SystemExit(1)
217
+
218
+ click.echo(f"Removed: {name}")
219
+
220
+
221
+ @knative.command("logs")
222
+ @click.argument("name")
223
+ @click.option("--tail", default=50, show_default=True, type=int)
224
+ @click.option("--api-url", envvar="OBACHAN_API_BASE_URL", default=None)
225
+ @click.option("--api-key", envvar="OBACHAN_API_KEY", default=None)
226
+ def knative_logs(
227
+ name: str,
228
+ tail: int,
229
+ api_url: str | None,
230
+ api_key: str | None,
231
+ ) -> None:
232
+ """Retrieve recent log lines for a Knative function."""
233
+ from granny.knative import KnativeClient
234
+
235
+ try:
236
+ c = KnativeClient(base_url=api_url, api_key=api_key)
237
+ log_text = c.logs(name, tail=tail)
238
+ except (ValueError, RuntimeError) as exc:
239
+ click.echo(f"Error: {exc}", err=True)
240
+ raise SystemExit(1)
241
+
242
+ click.echo(log_text)
243
+
244
+
245
+ @knative.command("invoke")
246
+ @click.argument("name")
247
+ @click.option("--method", default="GET", show_default=True)
248
+ @click.option("--path", "invoke_path", default="/", show_default=True)
249
+ @click.option("--body", default=None, help="Request body string")
250
+ @click.option("--api-url", envvar="OBACHAN_API_BASE_URL", default=None)
251
+ @click.option("--api-key", envvar="OBACHAN_API_KEY", default=None)
252
+ def knative_invoke(
253
+ name: str,
254
+ method: str,
255
+ invoke_path: str,
256
+ body: str | None,
257
+ api_url: str | None,
258
+ api_key: str | None,
259
+ ) -> None:
260
+ """Invoke a Knative function via the control-plane proxy."""
261
+ from granny.knative import KnativeClient
262
+
263
+ body_bytes = body.encode() if body else None
264
+
265
+ try:
266
+ c = KnativeClient(base_url=api_url, api_key=api_key)
267
+ result = c.invoke(name, method=method, path=invoke_path, body=body_bytes)
268
+ except (ValueError, RuntimeError) as exc:
269
+ click.echo(f"Error: {exc}", err=True)
270
+ raise SystemExit(1)
271
+
272
+ click.echo(result.decode("utf-8", errors="replace"))
273
+
274
+
275
+ @knative.command("deploy-ui")
276
+ @click.argument("name")
277
+ @click.argument(
278
+ "dist_dir",
279
+ type=click.Path(exists=True, file_okay=False, resolve_path=True),
280
+ )
281
+ @click.option("--image", required=True, help="Backend container image ref")
282
+ @click.option("--zone", required=True, help="Bunny Storage zone name")
283
+ @click.option(
284
+ "--storage-password",
285
+ envvar="BUNNY_STORAGE_PASSWORD",
286
+ required=True,
287
+ help="Bunny Storage zone password.",
288
+ )
289
+ @click.option("--hostname", default="storage.bunnycdn.com", show_default=True)
290
+ @click.option(
291
+ "--pull-zone-id",
292
+ default=None,
293
+ type=int,
294
+ help="Bunny CDN pull zone ID for cache purge after upload.",
295
+ )
296
+ @click.option(
297
+ "--env", "-e", "env_vars", multiple=True, help="Backend env vars KEY=VAL."
298
+ )
299
+ @click.option("--api-url", envvar="OBACHAN_API_BASE_URL", default=None)
300
+ @click.option("--api-key", envvar="OBACHAN_API_KEY", default=None)
301
+ def knative_deploy_ui(
302
+ name: str,
303
+ dist_dir: str,
304
+ image: str,
305
+ zone: str,
306
+ storage_password: str,
307
+ hostname: str,
308
+ pull_zone_id: int | None,
309
+ env_vars: tuple,
310
+ api_url: str | None,
311
+ api_key: str | None,
312
+ ) -> None:
313
+ """Deploy a UI to Bunny Storage and a backend function to Knative.
314
+
315
+ Uploads all files in DIST_DIR to Bunny Storage (with Cache-Control headers
316
+ appropriate for SPA assets), optionally purges the CDN cache, then deploys
317
+ the backend image as a Knative service.
318
+ """
319
+ from granny.storage.bunny import BunnyStorageClient
320
+
321
+ storage_client = BunnyStorageClient()
322
+ uploaded = 0
323
+ errors = 0
324
+
325
+ for root, _dirs, files in os.walk(dist_dir):
326
+ for fname in files:
327
+ local = os.path.join(root, fname)
328
+ remote = os.path.relpath(local, dist_dir).replace("\\", "/")
329
+ ct = _guess_content_type(local)
330
+ cc = (
331
+ "no-cache, no-store, must-revalidate"
332
+ if fname.lower().endswith(".html")
333
+ else "public, max-age=31536000, immutable"
334
+ )
335
+ try:
336
+ with open(local, "rb") as fh:
337
+ storage_client.upload_file(
338
+ hostname,
339
+ storage_password,
340
+ zone,
341
+ remote,
342
+ fh.read(),
343
+ ct,
344
+ cache_control=cc,
345
+ )
346
+ uploaded += 1
347
+ except Exception as exc:
348
+ click.echo(f" ERROR uploading {remote}: {exc}", err=True)
349
+ errors += 1
350
+
351
+ click.echo(f"Uploaded {uploaded} files to Bunny zone '{zone}' ({errors} errors).")
352
+ if errors:
353
+ raise SystemExit(1)
354
+
355
+ if pull_zone_id is not None:
356
+ from granny.cdn.bunny import BunnyCDNClient
357
+
358
+ BunnyCDNClient().purge_cache(pull_zone_id)
359
+ click.echo(f"CDN cache purged for pull zone {pull_zone_id}.")
360
+
361
+ parsed_env: dict[str, str] = {}
362
+ for entry in env_vars:
363
+ k, sep, v = entry.partition("=")
364
+ if sep:
365
+ parsed_env[k.strip()] = v
366
+
367
+ from granny.knative import KnativeClient
368
+
369
+ try:
370
+ c = KnativeClient(base_url=api_url, api_key=api_key)
371
+ result = c.deploy(name, image, env_vars=parsed_env)
372
+ except (ValueError, RuntimeError) as exc:
373
+ click.echo(f"Error deploying {name}: {exc}", err=True)
374
+ raise SystemExit(1)
375
+
376
+ url = (result.get("status") or {}).get("url", "(not yet ready)")
377
+ click.echo(f"Deployed: {name}")
378
+ click.echo(f"URL: {url}")
@@ -38,65 +38,78 @@ def cli(verbose: bool, quiet: bool) -> None:
38
38
 
39
39
  # Register command groups -------------------------------------------------
40
40
 
41
+
41
42
  def _register_commands() -> None:
42
43
  """Register available command groups."""
43
44
  try:
44
45
  from granny.cli.credentials import credentials
46
+
45
47
  cli.add_command(credentials)
46
48
  except ImportError:
47
49
  pass
48
50
  try:
49
51
  from granny.cli.analyze import analyze
52
+
50
53
  cli.add_command(analyze)
51
54
  except ImportError:
52
55
  pass
53
56
  try:
54
57
  from granny.cli.cdn import cdn
58
+
55
59
  cli.add_command(cdn)
56
60
  except ImportError:
57
61
  pass
58
62
  try:
59
63
  from granny.cli.create import create
64
+
60
65
  cli.add_command(create)
61
66
  except ImportError:
62
67
  pass
63
68
  try:
64
69
  from granny.cli.docker import docker
70
+
65
71
  cli.add_command(docker)
66
72
  except ImportError:
67
73
  pass
68
74
  try:
69
75
  from granny.cli.dns import dns
76
+
70
77
  cli.add_command(dns)
71
78
  except ImportError:
72
79
  pass
73
80
  try:
74
81
  from granny.cli.cloudflare import cloudflare
82
+
75
83
  cli.add_command(cloudflare)
76
84
  except ImportError:
77
85
  pass
78
86
  try:
79
87
  from granny.cli.storage import storage
88
+
80
89
  cli.add_command(storage)
81
90
  except ImportError:
82
91
  pass
83
92
  try:
84
93
  from granny.cli.edge import edge
94
+
85
95
  cli.add_command(edge)
86
96
  except ImportError:
87
97
  pass
88
98
  try:
89
99
  from granny.cli.serverless import serverless
100
+
90
101
  cli.add_command(serverless)
91
102
  except ImportError:
92
103
  pass
93
104
  try:
94
105
  from granny.cli.email import email
106
+
95
107
  cli.add_command(email)
96
108
  except ImportError:
97
109
  pass
98
110
  try:
99
111
  from granny.cli.authentik import authentik
112
+
100
113
  cli.add_command(authentik)
101
114
  except ImportError:
102
115
  pass
@@ -107,34 +120,47 @@ def _register_commands() -> None:
107
120
  pass
108
121
  try:
109
122
  from granny.cli.elk import elk
123
+
110
124
  cli.add_command(elk)
111
125
  except ImportError:
112
126
  pass
113
127
  try:
114
128
  from granny.cli.azure import azure
129
+
115
130
  cli.add_command(azure)
116
131
  except ImportError:
117
132
  pass
118
133
  try:
119
134
  from granny.cli.aws import aws
135
+
120
136
  cli.add_command(aws)
121
137
  except ImportError:
122
138
  pass
123
139
  try:
124
140
  from granny.cli.indexing import indexing
141
+
125
142
  cli.add_command(indexing)
126
143
  except ImportError:
127
144
  pass
128
145
  try:
129
146
  from granny.cli.zitadel import zitadel
147
+
130
148
  cli.add_command(zitadel)
131
149
  except ImportError:
132
150
  pass
133
151
  try:
134
152
  from granny.cli.oauth import oauth
153
+
135
154
  cli.add_command(oauth)
136
155
  except ImportError:
137
156
  pass
157
+ try:
158
+ from granny.cli.knative import knative
159
+
160
+ cli.add_command(knative)
161
+ cli.add_command(knative, name="faas")
162
+ except ImportError:
163
+ pass
138
164
 
139
165
 
140
166
  _register_commands()
@@ -110,6 +110,52 @@ def create_function(
110
110
  click.echo(f"Endpoint: https://{result['domain_name']}")
111
111
 
112
112
 
113
+ @serverless.command("delete-function")
114
+ @click.argument("function_name")
115
+ @click.option(
116
+ "--namespace", "namespace_name",
117
+ help="Namespace name. If omitted, the function must be uniquely named across all namespaces.",
118
+ )
119
+ @click.confirmation_option(prompt="Really delete this Scaleway function?")
120
+ @region_option
121
+ def delete_function(
122
+ function_name: str,
123
+ namespace_name: str | None,
124
+ region: str,
125
+ ) -> None:
126
+ """Delete a Scaleway function by name.
127
+
128
+ Resolves ``function_name`` to a function id within ``--namespace`` (or
129
+ across all namespaces if unique) and issues a DELETE against the
130
+ Functions API. Custom domains attached to the function are removed by
131
+ Scaleway as part of the cascade.
132
+ """
133
+ c = _client(region)
134
+
135
+ function = None
136
+ if namespace_name:
137
+ ns = c.find_namespace(namespace_name)
138
+ if not ns:
139
+ raise click.ClickException(f"Namespace '{namespace_name}' not found")
140
+ function = c.find_function(ns["id"], function_name)
141
+ else:
142
+ for ns in c.list_namespaces():
143
+ fn = c.find_function(ns["id"], function_name)
144
+ if fn:
145
+ if function:
146
+ raise click.ClickException(
147
+ f"Function '{function_name}' exists in multiple namespaces; use --namespace"
148
+ )
149
+ function = fn
150
+
151
+ if not function:
152
+ raise click.ClickException(f"Function '{function_name}' not found")
153
+
154
+ click.echo(f"Deleting {function['name']} (id={function['id']}, status={function.get('status', '?')})")
155
+ c.delete_function(function["id"])
156
+ click.echo("Deleted.")
157
+
158
+
113
159
  # Patterns that bloat a function package without contributing to runtime.
114
160
  # Matches both whole path segments and trailing-name globs.
115
161
  _DEFAULT_EXCLUDES = (
@@ -137,6 +137,7 @@ def _invalidate_vault_session() -> None:
137
137
  except Exception:
138
138
  pass
139
139
 
140
+
140
141
  # Map env var name -> vault secret name (kebab-case).
141
142
  # The full vault path is: {VAULT_FOLDER}/{secret_name}
142
143
  VAULT_FOLDER = "granny/infra"
@@ -189,6 +190,9 @@ SECRET_MAP: dict[str, str] = {
189
190
  # StalwartClient.from_environment() and is overridable via STALWART_URL.
190
191
  "STALWART_ADMIN_USER": "stalwart-admin-user",
191
192
  "STALWART_ADMIN_PASSWORD": "stalwart-admin-password",
193
+ # Knative FaaS control API. The base URL is vault-distributed configuration.
194
+ "OBACHAN_API_KEY": "obachan-api-key",
195
+ "OBACHAN_API_BASE_URL": "obachan-api-base-url",
192
196
  }
193
197
 
194
198
  # Module-level cache so vault is only contacted once per process
@@ -232,7 +236,10 @@ def _get_vault_client():
232
236
  logger.info("Reusing cached vault session")
233
237
  return _vault_client
234
238
  else:
235
- logger.debug("Cached vault token expired (HTTP %s), re-authenticating", resp.status_code)
239
+ logger.debug(
240
+ "Cached vault token expired (HTTP %s), re-authenticating",
241
+ resp.status_code,
242
+ )
236
243
  _invalidate_vault_session()
237
244
  _vault_client = None
238
245
  except Exception as exc:
@@ -0,0 +1,5 @@
1
+ """Knative FaaS control API client."""
2
+
3
+ from granny.knative.client import KnativeClient
4
+
5
+ __all__ = ["KnativeClient"]