granny-devops 0.17.4__tar.gz → 0.18.1__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 (122) hide show
  1. {granny_devops-0.17.4 → granny_devops-0.18.1}/PKG-INFO +23 -2
  2. {granny_devops-0.17.4 → granny_devops-0.18.1}/README.md +22 -1
  3. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/__init__.py +1 -1
  4. granny_devops-0.18.1/granny/cli/knative.py +378 -0
  5. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/main.py +26 -0
  6. granny_devops-0.18.1/granny/cli/oauth.py +164 -0
  7. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/serverless.py +46 -0
  8. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/credentials/secrets.py +9 -1
  9. granny_devops-0.18.1/granny/knative/__init__.py +5 -0
  10. granny_devops-0.18.1/granny/knative/client.py +339 -0
  11. granny_devops-0.18.1/granny/oauth/__init__.py +19 -0
  12. granny_devops-0.18.1/granny/oauth/productivity.py +237 -0
  13. {granny_devops-0.17.4 → granny_devops-0.18.1}/pyproject.toml +1 -1
  14. granny_devops-0.17.4/granny/cli/oauth.py +0 -96
  15. {granny_devops-0.17.4 → granny_devops-0.18.1}/.gitignore +0 -0
  16. {granny_devops-0.17.4 → granny_devops-0.18.1}/LICENSE +0 -0
  17. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/airtable/__init__.py +0 -0
  18. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/airtable/client.py +0 -0
  19. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/analyze/__init__.py +0 -0
  20. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/analyze/costs.py +0 -0
  21. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/analyze/credits.py +0 -0
  22. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/analyze/gpu_pricing.py +0 -0
  23. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/analyze/gpus.py +0 -0
  24. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/analyze/kubernetes.py +0 -0
  25. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/analyze/lambdas.py +0 -0
  26. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/analyze/vpcs.py +0 -0
  27. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/authentik/__init__.py +0 -0
  28. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/authentik/client.py +0 -0
  29. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/authentik/provision.py +0 -0
  30. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/aws/__init__.py +0 -0
  31. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/aws/bedrock.py +0 -0
  32. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/aws/quota.py +0 -0
  33. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/azure/__init__.py +0 -0
  34. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/azure/_client.py +0 -0
  35. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/azure/account.py +0 -0
  36. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/azure/deployment.py +0 -0
  37. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/azure/openai.py +0 -0
  38. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/azure/quota.py +0 -0
  39. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/azure/resourcegroup.py +0 -0
  40. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/azure/role.py +0 -0
  41. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/azure/sku.py +0 -0
  42. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/azure/vm.py +0 -0
  43. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/azure/webapp.py +0 -0
  44. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cdn/__init__.py +0 -0
  45. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cdn/bunny.py +0 -0
  46. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/__init__.py +0 -0
  47. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/airtable.py +0 -0
  48. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/analyze.py +0 -0
  49. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/authentik.py +0 -0
  50. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/aws.py +0 -0
  51. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/azure.py +0 -0
  52. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/cdn.py +0 -0
  53. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/cloudflare.py +0 -0
  54. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/create.py +0 -0
  55. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/credentials.py +0 -0
  56. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/dns.py +0 -0
  57. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/docker.py +0 -0
  58. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/edge.py +0 -0
  59. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/elk.py +0 -0
  60. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/email.py +0 -0
  61. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/indexing.py +0 -0
  62. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/storage.py +0 -0
  63. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/zitadel.py +0 -0
  64. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cloudflare/__init__.py +0 -0
  65. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cloudflare/d1.py +0 -0
  66. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cloudflare/r2.py +0 -0
  67. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cloudflare/workers.py +0 -0
  68. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/__init__.py +0 -0
  69. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/auto_certificate.py +0 -0
  70. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/cloudfront-security-headers.js +0 -0
  71. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/letsencrypt_cert.py +0 -0
  72. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/manage-dns.sh +0 -0
  73. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/manage_mailjet_contacts.py +0 -0
  74. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/registrars.py +0 -0
  75. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/setup_aws_cloudfront.py +0 -0
  76. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/setup_bunny_edge_script.py +0 -0
  77. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/setup_bunny_storage.py +0 -0
  78. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/setup_cognito_identity_pool.py +0 -0
  79. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/setup_hetzner_bunny.py +0 -0
  80. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/setup_mailjet_dns.py +0 -0
  81. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/setup_private_cdn.py +0 -0
  82. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/setup_s3_website.py +0 -0
  83. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/setup_scaleway_container.py +0 -0
  84. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/setup_scaleway_faas.py +0 -0
  85. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/setup_workmail.py +0 -0
  86. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/www-redirect-function.js +0 -0
  87. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/credentials/__init__.py +0 -0
  88. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/dns/__init__.py +0 -0
  89. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/dns/base.py +0 -0
  90. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/dns/bunny.py +0 -0
  91. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/dns/cloudflare.py +0 -0
  92. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/dns/cloudns.py +0 -0
  93. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/dns/desec.py +0 -0
  94. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/dns/factory.py +0 -0
  95. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/dns/hetzner.py +0 -0
  96. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/dns/inwx.py +0 -0
  97. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/dns/manual.py +0 -0
  98. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/dns/records.py +0 -0
  99. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/docker/__init__.py +0 -0
  100. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/docker/build_base.py +0 -0
  101. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/edge/__init__.py +0 -0
  102. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/edge/bunny.py +0 -0
  103. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/elk/__init__.py +0 -0
  104. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/elk/client.py +0 -0
  105. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/email/__init__.py +0 -0
  106. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/email/mailjet.py +0 -0
  107. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/email/mailjet_contacts.py +0 -0
  108. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/email/ses_forwarding.py +0 -0
  109. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/email/stalwart.py +0 -0
  110. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/email/workmail.py +0 -0
  111. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/indexing/__init__.py +0 -0
  112. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/indexing/google.py +0 -0
  113. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/indexing/indexnow.py +0 -0
  114. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/report.py +0 -0
  115. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/serverless/__init__.py +0 -0
  116. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/serverless/scaleway.py +0 -0
  117. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/storage/__init__.py +0 -0
  118. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/storage/aws.py +0 -0
  119. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/storage/bunny.py +0 -0
  120. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/storage/hetzner.py +0 -0
  121. {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/zitadel/__init__.py +0 -0
  122. {granny_devops-0.17.4 → granny_devops-0.18.1}/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.1
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,8 @@ 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` |
223
+ | Obachan OpenBao | `OBACHAN_BAO_ADDR`, `OBACHAN_BAO_TOKEN` |
222
224
  | Mailjet | `MAILJET_API_KEY`, `MAILJET_SECRET_KEY` |
223
225
  | deSEC | `DESEC_API_TOKEN` |
224
226
  | ClouDNS | `CLOUDNS_AUTH_ID`/`_PASSWORD` (or `_SUB_AUTH_ID`/`_SUB_AUTH_USER`) |
@@ -234,6 +236,12 @@ what's configured at any time.
234
236
  ```shell
235
237
  granny --help # see every command group
236
238
  granny <group> --help # drill into one
239
+
240
+ # Provision mail/calendar OAuth clients to a dashboard env and Obachan OpenBao
241
+ granny oauth provision-productivity --env-file ../dashboard/.deploy.env \
242
+ --obachan-env staging --obachan-app dashboard \
243
+ --google-client-json client_secret.json \
244
+ --azure-client-id "$AZURE_CLIENT_ID" --azure-client-secret "$AZURE_CLIENT_SECRET"
237
245
  ```
238
246
 
239
247
  The CLI is organized by capability, not by provider. You pick the
@@ -301,6 +309,16 @@ granny docker build-base --image myapp-base --hash-file requirements.txt
301
309
 
302
310
  # Scaleway FaaS
303
311
  granny serverless deploy my-fn --source-dir ./dist --namespace my-app
312
+ granny serverless delete-function my-fn --namespace my-app --yes
313
+
314
+ # Knative FaaS control API (`granny faas` is an alias)
315
+ granny knative list
316
+ granny knative deploy my-fn --image registry.example.com/my-fn:latest \
317
+ --env LOG_LEVEL=info --min-scale 0 --max-scale 5
318
+ granny knative scale my-fn --max-scale 10
319
+ granny knative logs my-fn --tail 100
320
+ granny knative invoke my-fn --method POST --path /process --body '{"job": 42}'
321
+ granny knative remove my-fn --yes
304
322
 
305
323
  # Mailjet + WorkMail
306
324
  granny email mailjet setup-dns example.com
@@ -391,7 +409,7 @@ granny indexing google https://example.com/job-posting --action URL_UPDATED
391
409
  | CDN | Bunny |
392
410
  | Edge scripting | Bunny |
393
411
  | Object storage | AWS S3, Bunny Storage, Hetzner S3 |
394
- | Serverless functions | Scaleway FaaS, Scaleway Containers |
412
+ | Serverless functions | Scaleway FaaS, Scaleway Containers, Knative FaaS control API |
395
413
  | Workers / KV / D1 / R2 | Cloudflare |
396
414
  | Record search and endpoint maintenance | Airtable |
397
415
  | Email send infra | Mailjet, AWS SES, AWS WorkMail |
@@ -402,6 +420,7 @@ granny indexing google https://example.com/job-posting --action URL_UPDATED
402
420
  | Observability admin | Elasticsearch / Kibana native-user management |
403
421
  | Azure ops | Identity, subscriptions, ARM deployments, Cognitive Services (Azure OpenAI), App Service, VM sizes, VM inventory/deallocate/delete, RBAC role assignment, compute quota |
404
422
  | Search indexing | IndexNow (Bing/Yandex/Seznam/Naver/Yep), Google Indexing API |
423
+ | Productivity OAuth | Google Cloud and Microsoft Entra mail/calendar client provisioning |
405
424
 
406
425
  ## As a library
407
426
 
@@ -444,6 +463,8 @@ granny/
444
463
  elk/ Elasticsearch / Kibana security user management
445
464
  email/ Mailjet, WorkMail, SES forwarding
446
465
  indexing/ IndexNow + Google Indexing API
466
+ knative/ Knative FaaS control API client
467
+ oauth/ Google/Microsoft productivity OAuth provisioning
447
468
  serverless/ Scaleway FaaS
448
469
  storage/ Object storage (AWS / Bunny / Hetzner)
449
470
  ```
@@ -85,6 +85,8 @@ 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` |
89
+ | Obachan OpenBao | `OBACHAN_BAO_ADDR`, `OBACHAN_BAO_TOKEN` |
88
90
  | Mailjet | `MAILJET_API_KEY`, `MAILJET_SECRET_KEY` |
89
91
  | deSEC | `DESEC_API_TOKEN` |
90
92
  | ClouDNS | `CLOUDNS_AUTH_ID`/`_PASSWORD` (or `_SUB_AUTH_ID`/`_SUB_AUTH_USER`) |
@@ -100,6 +102,12 @@ what's configured at any time.
100
102
  ```shell
101
103
  granny --help # see every command group
102
104
  granny <group> --help # drill into one
105
+
106
+ # Provision mail/calendar OAuth clients to a dashboard env and Obachan OpenBao
107
+ granny oauth provision-productivity --env-file ../dashboard/.deploy.env \
108
+ --obachan-env staging --obachan-app dashboard \
109
+ --google-client-json client_secret.json \
110
+ --azure-client-id "$AZURE_CLIENT_ID" --azure-client-secret "$AZURE_CLIENT_SECRET"
103
111
  ```
104
112
 
105
113
  The CLI is organized by capability, not by provider. You pick the
@@ -167,6 +175,16 @@ granny docker build-base --image myapp-base --hash-file requirements.txt
167
175
 
168
176
  # Scaleway FaaS
169
177
  granny serverless deploy my-fn --source-dir ./dist --namespace my-app
178
+ granny serverless delete-function my-fn --namespace my-app --yes
179
+
180
+ # Knative FaaS control API (`granny faas` is an alias)
181
+ granny knative list
182
+ granny knative deploy my-fn --image registry.example.com/my-fn:latest \
183
+ --env LOG_LEVEL=info --min-scale 0 --max-scale 5
184
+ granny knative scale my-fn --max-scale 10
185
+ granny knative logs my-fn --tail 100
186
+ granny knative invoke my-fn --method POST --path /process --body '{"job": 42}'
187
+ granny knative remove my-fn --yes
170
188
 
171
189
  # Mailjet + WorkMail
172
190
  granny email mailjet setup-dns example.com
@@ -257,7 +275,7 @@ granny indexing google https://example.com/job-posting --action URL_UPDATED
257
275
  | CDN | Bunny |
258
276
  | Edge scripting | Bunny |
259
277
  | Object storage | AWS S3, Bunny Storage, Hetzner S3 |
260
- | Serverless functions | Scaleway FaaS, Scaleway Containers |
278
+ | Serverless functions | Scaleway FaaS, Scaleway Containers, Knative FaaS control API |
261
279
  | Workers / KV / D1 / R2 | Cloudflare |
262
280
  | Record search and endpoint maintenance | Airtable |
263
281
  | Email send infra | Mailjet, AWS SES, AWS WorkMail |
@@ -268,6 +286,7 @@ granny indexing google https://example.com/job-posting --action URL_UPDATED
268
286
  | Observability admin | Elasticsearch / Kibana native-user management |
269
287
  | Azure ops | Identity, subscriptions, ARM deployments, Cognitive Services (Azure OpenAI), App Service, VM sizes, VM inventory/deallocate/delete, RBAC role assignment, compute quota |
270
288
  | Search indexing | IndexNow (Bing/Yandex/Seznam/Naver/Yep), Google Indexing API |
289
+ | Productivity OAuth | Google Cloud and Microsoft Entra mail/calendar client provisioning |
271
290
 
272
291
  ## As a library
273
292
 
@@ -310,6 +329,8 @@ granny/
310
329
  elk/ Elasticsearch / Kibana security user management
311
330
  email/ Mailjet, WorkMail, SES forwarding
312
331
  indexing/ IndexNow + Google Indexing API
332
+ knative/ Knative FaaS control API client
333
+ oauth/ Google/Microsoft productivity OAuth provisioning
313
334
  serverless/ Scaleway FaaS
314
335
  storage/ Object storage (AWS / Bunny / Hetzner)
315
336
  ```
@@ -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.1"
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()