granny-devops 0.15.0__tar.gz → 0.17.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 (111) hide show
  1. {granny_devops-0.15.0 → granny_devops-0.17.0}/PKG-INFO +22 -3
  2. {granny_devops-0.15.0 → granny_devops-0.17.0}/README.md +20 -2
  3. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/__init__.py +1 -1
  4. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/create.py +1 -0
  5. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/zitadel.py +62 -0
  6. granny_devops-0.17.0/granny/create/letsencrypt_cert.py +192 -0
  7. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/zitadel/client.py +47 -0
  8. {granny_devops-0.15.0 → granny_devops-0.17.0}/pyproject.toml +2 -1
  9. {granny_devops-0.15.0 → granny_devops-0.17.0}/.gitignore +0 -0
  10. {granny_devops-0.15.0 → granny_devops-0.17.0}/LICENSE +0 -0
  11. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/analyze/__init__.py +0 -0
  12. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/analyze/costs.py +0 -0
  13. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/analyze/credits.py +0 -0
  14. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/analyze/gpu_pricing.py +0 -0
  15. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/analyze/gpus.py +0 -0
  16. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/analyze/lambdas.py +0 -0
  17. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/analyze/vpcs.py +0 -0
  18. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/authentik/__init__.py +0 -0
  19. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/authentik/client.py +0 -0
  20. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/authentik/provision.py +0 -0
  21. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/aws/__init__.py +0 -0
  22. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/aws/bedrock.py +0 -0
  23. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/aws/quota.py +0 -0
  24. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/azure/__init__.py +0 -0
  25. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/azure/_client.py +0 -0
  26. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/azure/account.py +0 -0
  27. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/azure/deployment.py +0 -0
  28. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/azure/openai.py +0 -0
  29. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/azure/quota.py +0 -0
  30. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/azure/resourcegroup.py +0 -0
  31. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/azure/role.py +0 -0
  32. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/azure/sku.py +0 -0
  33. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/azure/vm.py +0 -0
  34. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/azure/webapp.py +0 -0
  35. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cdn/__init__.py +0 -0
  36. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cdn/bunny.py +0 -0
  37. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/__init__.py +0 -0
  38. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/analyze.py +0 -0
  39. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/authentik.py +0 -0
  40. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/aws.py +0 -0
  41. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/azure.py +0 -0
  42. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/cdn.py +0 -0
  43. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/cloudflare.py +0 -0
  44. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/credentials.py +0 -0
  45. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/dns.py +0 -0
  46. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/docker.py +0 -0
  47. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/edge.py +0 -0
  48. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/elk.py +0 -0
  49. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/email.py +0 -0
  50. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/indexing.py +0 -0
  51. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/main.py +0 -0
  52. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/serverless.py +0 -0
  53. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cli/storage.py +0 -0
  54. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cloudflare/__init__.py +0 -0
  55. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cloudflare/d1.py +0 -0
  56. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cloudflare/r2.py +0 -0
  57. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/cloudflare/workers.py +0 -0
  58. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/__init__.py +0 -0
  59. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/auto_certificate.py +0 -0
  60. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/cloudfront-security-headers.js +0 -0
  61. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/manage-dns.sh +0 -0
  62. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/manage_mailjet_contacts.py +0 -0
  63. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/registrars.py +0 -0
  64. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/setup_aws_cloudfront.py +0 -0
  65. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/setup_bunny_edge_script.py +0 -0
  66. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/setup_bunny_storage.py +0 -0
  67. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/setup_cognito_identity_pool.py +0 -0
  68. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/setup_hetzner_bunny.py +0 -0
  69. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/setup_mailjet_dns.py +0 -0
  70. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/setup_private_cdn.py +0 -0
  71. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/setup_s3_website.py +0 -0
  72. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/setup_scaleway_container.py +0 -0
  73. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/setup_scaleway_faas.py +0 -0
  74. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/setup_workmail.py +0 -0
  75. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/create/www-redirect-function.js +0 -0
  76. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/credentials/__init__.py +0 -0
  77. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/credentials/secrets.py +0 -0
  78. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/dns/__init__.py +0 -0
  79. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/dns/base.py +0 -0
  80. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/dns/bunny.py +0 -0
  81. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/dns/cloudflare.py +0 -0
  82. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/dns/cloudns.py +0 -0
  83. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/dns/desec.py +0 -0
  84. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/dns/factory.py +0 -0
  85. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/dns/hetzner.py +0 -0
  86. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/dns/inwx.py +0 -0
  87. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/dns/manual.py +0 -0
  88. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/dns/records.py +0 -0
  89. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/docker/__init__.py +0 -0
  90. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/docker/build_base.py +0 -0
  91. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/edge/__init__.py +0 -0
  92. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/edge/bunny.py +0 -0
  93. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/elk/__init__.py +0 -0
  94. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/elk/client.py +0 -0
  95. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/email/__init__.py +0 -0
  96. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/email/mailjet.py +0 -0
  97. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/email/mailjet_contacts.py +0 -0
  98. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/email/ses_forwarding.py +0 -0
  99. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/email/stalwart.py +0 -0
  100. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/email/workmail.py +0 -0
  101. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/indexing/__init__.py +0 -0
  102. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/indexing/google.py +0 -0
  103. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/indexing/indexnow.py +0 -0
  104. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/report.py +0 -0
  105. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/serverless/__init__.py +0 -0
  106. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/serverless/scaleway.py +0 -0
  107. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/storage/__init__.py +0 -0
  108. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/storage/aws.py +0 -0
  109. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/storage/bunny.py +0 -0
  110. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/storage/hetzner.py +0 -0
  111. {granny_devops-0.15.0 → granny_devops-0.17.0}/granny/zitadel/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: granny-devops
3
- Version: 0.15.0
3
+ Version: 0.17.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
@@ -26,6 +26,7 @@ License: MIT License
26
26
  SOFTWARE.
27
27
  License-File: LICENSE
28
28
  Requires-Python: >=3.13
29
+ Requires-Dist: acme>=2.11
29
30
  Requires-Dist: boto3>=1.38
30
31
  Requires-Dist: click>=8.1
31
32
  Requires-Dist: pyjwt[crypto]>=2.8
@@ -257,6 +258,10 @@ granny storage aws create my-website --website
257
258
  granny analyze vpcs --json-output
258
259
  granny analyze lambdas --region us-east-1 eu-west-1
259
260
 
261
+ # AWS Bedrock -- foundation models (read-only; on-demand access auto-enables on first invoke)
262
+ granny aws bedrock list-models --profile lularge --region eu-central-1 --by-provider Anthropic
263
+ granny aws bedrock check-access --model-id anthropic.claude-sonnet-4-20250514-v1:0 --profile lularge --region eu-central-1
264
+
260
265
  # Cross-cloud GPU / credit / cost inventory (AWS + GCP + Azure)
261
266
  granny analyze gpus # running GPUs everywhere
262
267
  granny analyze gpus --filter h100,h200 # find Hopper clusters
@@ -288,6 +293,10 @@ granny email workmail create-user example.com --email user@example.com
288
293
  granny create s3-website example.com --help
289
294
  granny create scaleway-container --name my-app --port 3000
290
295
  granny create mailjet-dns example.com
296
+ granny create letsencrypt-cert \
297
+ --domain '*.example.com' --domain example.com \
298
+ --dns inwx --zone example.com \
299
+ --email ops@example.com --out-dir ./certs # real LE cert via ACME dns-01, any granny DNS provider
291
300
 
292
301
  # Authentik admin (provider + application + group plumbing)
293
302
  granny authentik provision-oauth-app my-app \
@@ -311,6 +320,9 @@ granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
311
320
  granny zitadel create-project platform
312
321
  granny zitadel create-oidc-app platform my-app \
313
322
  --redirect-uri https://app.example.com/oidc/callback
323
+ granny zitadel set-redirect-uris --project platform --app my-app \
324
+ --redirect-uri https://app.example.com/oidc/callback \
325
+ --redirect-uri https://app.example.com/oidc/callback2 # merges by default; --replace to set exactly
314
326
  granny zitadel rotate-secret platform my-app
315
327
  granny zitadel create-user alice --email alice@example.com --password 's3cret'
316
328
  granny zitadel ensure-role platform admin # Zitadel uses project roles, not groups
@@ -329,8 +341,15 @@ granny elk add-user user@example.com \
329
341
  granny azure account whoami # signed-in identity
330
342
  granny azure account list # subscriptions
331
343
  granny azure deployment list --subscription <id> --all-groups
344
+ granny azure group create --subscription <id> --name <rg> --location swedencentral
332
345
  granny azure openai accounts --subscription <id>
333
- granny azure openai deployments --subscription <id> --kind OpenAI
346
+ granny azure openai account create --subscription <id> --resource-group <rg> \
347
+ --name <acct> --location swedencentral # create an Azure OpenAI account
348
+ granny azure openai keys --subscription <id> --resource-group <rg> --account <acct>
349
+ granny azure openai deployments --subscription <id> --kind OpenAI --with-usage # + created_at & last-used (Azure Monitor)
350
+ granny azure openai deployment create --subscription <id> --resource-group <rg> \
351
+ --account <acct> --name gpt-5.4 --model gpt-5.4 --model-version 2026-03-05 \
352
+ --sku GlobalStandard --capacity 50 # create a model deployment
334
353
  granny azure webapp list --subscription <id>
335
354
  granny azure vm-sizes list --subscription <id> --location polandcentral
336
355
  granny azure vm list --subscription <id> # running VMs + power state
@@ -359,7 +378,7 @@ granny indexing google https://example.com/job-posting --action URL_UPDATED
359
378
  | Email send infra | Mailjet, AWS SES, AWS WorkMail |
360
379
  | AWS inventory | VPCs, Lambdas |
361
380
  | Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
362
- | SSL automation | Bunny, Cloudflare, ACM |
381
+ | SSL automation | Bunny, Cloudflare, ACM, Let's Encrypt (ACME dns-01 over any granny DNS provider) |
363
382
  | SSO / IdP | Authentik (provider, application, group, and user operations) |
364
383
  | Observability admin | Elasticsearch / Kibana native-user management |
365
384
  | Azure ops | Identity, subscriptions, ARM deployments, Cognitive Services (Azure OpenAI), App Service, VM sizes, VM inventory/deallocate/delete, RBAC role assignment, compute quota |
@@ -124,6 +124,10 @@ granny storage aws create my-website --website
124
124
  granny analyze vpcs --json-output
125
125
  granny analyze lambdas --region us-east-1 eu-west-1
126
126
 
127
+ # AWS Bedrock -- foundation models (read-only; on-demand access auto-enables on first invoke)
128
+ granny aws bedrock list-models --profile lularge --region eu-central-1 --by-provider Anthropic
129
+ granny aws bedrock check-access --model-id anthropic.claude-sonnet-4-20250514-v1:0 --profile lularge --region eu-central-1
130
+
127
131
  # Cross-cloud GPU / credit / cost inventory (AWS + GCP + Azure)
128
132
  granny analyze gpus # running GPUs everywhere
129
133
  granny analyze gpus --filter h100,h200 # find Hopper clusters
@@ -155,6 +159,10 @@ granny email workmail create-user example.com --email user@example.com
155
159
  granny create s3-website example.com --help
156
160
  granny create scaleway-container --name my-app --port 3000
157
161
  granny create mailjet-dns example.com
162
+ granny create letsencrypt-cert \
163
+ --domain '*.example.com' --domain example.com \
164
+ --dns inwx --zone example.com \
165
+ --email ops@example.com --out-dir ./certs # real LE cert via ACME dns-01, any granny DNS provider
158
166
 
159
167
  # Authentik admin (provider + application + group plumbing)
160
168
  granny authentik provision-oauth-app my-app \
@@ -178,6 +186,9 @@ granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
178
186
  granny zitadel create-project platform
179
187
  granny zitadel create-oidc-app platform my-app \
180
188
  --redirect-uri https://app.example.com/oidc/callback
189
+ granny zitadel set-redirect-uris --project platform --app my-app \
190
+ --redirect-uri https://app.example.com/oidc/callback \
191
+ --redirect-uri https://app.example.com/oidc/callback2 # merges by default; --replace to set exactly
181
192
  granny zitadel rotate-secret platform my-app
182
193
  granny zitadel create-user alice --email alice@example.com --password 's3cret'
183
194
  granny zitadel ensure-role platform admin # Zitadel uses project roles, not groups
@@ -196,8 +207,15 @@ granny elk add-user user@example.com \
196
207
  granny azure account whoami # signed-in identity
197
208
  granny azure account list # subscriptions
198
209
  granny azure deployment list --subscription <id> --all-groups
210
+ granny azure group create --subscription <id> --name <rg> --location swedencentral
199
211
  granny azure openai accounts --subscription <id>
200
- granny azure openai deployments --subscription <id> --kind OpenAI
212
+ granny azure openai account create --subscription <id> --resource-group <rg> \
213
+ --name <acct> --location swedencentral # create an Azure OpenAI account
214
+ granny azure openai keys --subscription <id> --resource-group <rg> --account <acct>
215
+ granny azure openai deployments --subscription <id> --kind OpenAI --with-usage # + created_at & last-used (Azure Monitor)
216
+ granny azure openai deployment create --subscription <id> --resource-group <rg> \
217
+ --account <acct> --name gpt-5.4 --model gpt-5.4 --model-version 2026-03-05 \
218
+ --sku GlobalStandard --capacity 50 # create a model deployment
201
219
  granny azure webapp list --subscription <id>
202
220
  granny azure vm-sizes list --subscription <id> --location polandcentral
203
221
  granny azure vm list --subscription <id> # running VMs + power state
@@ -226,7 +244,7 @@ granny indexing google https://example.com/job-posting --action URL_UPDATED
226
244
  | Email send infra | Mailjet, AWS SES, AWS WorkMail |
227
245
  | AWS inventory | VPCs, Lambdas |
228
246
  | Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
229
- | SSL automation | Bunny, Cloudflare, ACM |
247
+ | SSL automation | Bunny, Cloudflare, ACM, Let's Encrypt (ACME dns-01 over any granny DNS provider) |
230
248
  | SSO / IdP | Authentik (provider, application, group, and user operations) |
231
249
  | Observability admin | Elasticsearch / Kibana native-user management |
232
250
  | Azure ops | Identity, subscriptions, ARM deployments, Cognitive Services (Azure OpenAI), App Service, VM sizes, VM inventory/deallocate/delete, RBAC role assignment, compute quota |
@@ -1,6 +1,6 @@
1
1
  """Granny -- Cloud tools collection for AWS infrastructure and DevOps automation."""
2
2
 
3
- __version__ = "0.15.0"
3
+ __version__ = "0.17.0"
4
4
  __all__ = [
5
5
  "get_secret",
6
6
  "load_secrets_into_env",
@@ -33,6 +33,7 @@ COMMANDS: dict[str, str] = {
33
33
  "scaleway-container": "setup_scaleway_container.py",
34
34
  "workmail": "setup_workmail.py",
35
35
  "auto-certificate": "auto_certificate.py",
36
+ "letsencrypt-cert": "letsencrypt_cert.py",
36
37
  "mailjet-contacts": "manage_mailjet_contacts.py",
37
38
  }
38
39
 
@@ -128,6 +128,68 @@ def create_oidc_app_cmd(
128
128
  )
129
129
 
130
130
 
131
+ @zitadel.command("set-redirect-uris")
132
+ @click.option("--project", required=True, help="Project name or id owning the app.")
133
+ @click.option("--app", "app_name", required=True, help="OIDC app name.")
134
+ @click.option(
135
+ "--redirect-uri",
136
+ "redirect_uris",
137
+ multiple=True,
138
+ required=True,
139
+ help="Allowed OIDC redirect URI (repeatable).",
140
+ )
141
+ @click.option(
142
+ "--post-logout-uri",
143
+ "post_logout_uris",
144
+ multiple=True,
145
+ help="Post-logout redirect URI to add (repeatable).",
146
+ )
147
+ @click.option(
148
+ "--replace",
149
+ is_flag=True,
150
+ help="Replace the redirect-URI set instead of merging with the existing one.",
151
+ )
152
+ @click.option("--dry-run", is_flag=True)
153
+ def set_redirect_uris_cmd(
154
+ project: str,
155
+ app_name: str,
156
+ redirect_uris: tuple[str, ...],
157
+ post_logout_uris: tuple[str, ...],
158
+ replace: bool,
159
+ dry_run: bool,
160
+ ) -> None:
161
+ """Set an existing OIDC app's allowed redirect URIs (idempotent).
162
+
163
+ Merges with the app's current redirect URIs by default (union, de-duped),
164
+ so re-running with the same set is a no-op. Use ``--replace`` to set exactly
165
+ the URIs given.
166
+ """
167
+ client = _client()
168
+ found = client.find_project(project)
169
+ project_id = found["id"] if found else project
170
+ app = client.find_oidc_app(project_id, app_name)
171
+ if not app:
172
+ raise click.ClickException(
173
+ f"OIDC app {app_name!r} not found in project {project!r}"
174
+ )
175
+ app_id = app.get("id")
176
+ if dry_run:
177
+ verb = "replace with" if replace else "merge in"
178
+ click.echo(
179
+ f"[DRY RUN] Would {verb} redirect URIs on app {app_name!r} "
180
+ f"({app_id}): {list(redirect_uris)}"
181
+ )
182
+ return
183
+ result = client.set_oidc_app_redirects(
184
+ project_id,
185
+ app_id, # type: ignore[arg-type]
186
+ list(redirect_uris),
187
+ replace=replace,
188
+ post_logout_uris=list(post_logout_uris),
189
+ )
190
+ click.echo(json.dumps(result, indent=2))
191
+
192
+
131
193
  @zitadel.command("enforce-mfa")
132
194
  @click.option(
133
195
  "--no-passkey",
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env python3
2
+ """Let's Encrypt certificate issuance via ACME DNS-01, driven by granny's DNS
3
+ providers.
4
+
5
+ Unlike ``auto_certificate.py`` (AWS ACM / Bunny), this issues a real
6
+ Let's Encrypt certificate (fullchain + private key PEM) usable by Caddy,
7
+ nginx, Kubernetes Secrets, etc. The ACME ``dns-01`` challenge is solved by
8
+ creating ``_acme-challenge`` TXT records through **any** granny DNS provider
9
+ (``--dns inwx`` / ``cloudflare`` / ``hetzner`` / ...), so it works for zones
10
+ like ``obachan.dev`` (INWX) where no cert-manager webhook exists.
11
+
12
+ Example::
13
+
14
+ granny create letsencrypt-cert \\
15
+ --domain '*.obachan.dev' --domain obachan.dev \\
16
+ --dns inwx --zone obachan.dev \\
17
+ --email ops@example.com --out-dir ./certs
18
+
19
+ Credentials for the DNS provider resolve through granny's normal vault/env
20
+ pipeline (e.g. INWX_USERNAME / INWX_PASSWORD / INWX_SHARED_SECRET).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import sys
27
+ import time
28
+ from pathlib import Path
29
+
30
+ import requests
31
+
32
+ try: # granny's vault→env pipeline (same as auto_certificate.py)
33
+ from granny.credentials import load_secrets_into_env
34
+
35
+ load_secrets_into_env()
36
+ except Exception: # pragma: no cover - best effort
37
+ pass
38
+
39
+ _LE_PROD = "https://acme-v02.api.letsencrypt.org/directory"
40
+ _LE_STAGING = "https://acme-staging-v02.api.letsencrypt.org/directory"
41
+ _DOH = "https://dns.google/resolve"
42
+
43
+
44
+ def _new_rsa_key(): # noqa: ANN202
45
+ from cryptography.hazmat.primitives.asymmetric import rsa
46
+
47
+ return rsa.generate_private_key(public_exponent=65537, key_size=2048)
48
+
49
+
50
+ def _key_pem(key) -> bytes: # noqa: ANN001
51
+ from cryptography.hazmat.primitives import serialization
52
+
53
+ return key.private_bytes(
54
+ serialization.Encoding.PEM,
55
+ serialization.PrivateFormat.TraditionalOpenSSL,
56
+ serialization.NoEncryption(),
57
+ )
58
+
59
+
60
+ def _relative_name(fqdn: str, zone: str) -> str:
61
+ """Strip the trailing ``.<zone>`` so provider APIs get the short name."""
62
+ fqdn = fqdn.rstrip(".")
63
+ suffix = "." + zone.rstrip(".")
64
+ return fqdn[: -len(suffix)] if fqdn.endswith(suffix) else fqdn
65
+
66
+
67
+ def _txt_propagated(fqdn: str, value: str, *, attempts: int, delay: int) -> bool:
68
+ """Poll public DNS (DoH) until the challenge TXT value is visible."""
69
+ for i in range(attempts):
70
+ try:
71
+ resp = requests.get(
72
+ _DOH, params={"name": fqdn, "type": "TXT"}, timeout=10
73
+ ).json()
74
+ answers = [a.get("data", "").strip('"') for a in resp.get("Answer", [])]
75
+ if value in answers:
76
+ return True
77
+ except Exception: # noqa: BLE001 - transient resolver errors are fine
78
+ pass
79
+ if i < attempts - 1:
80
+ time.sleep(delay)
81
+ return False
82
+
83
+
84
+ def issue_certificate(
85
+ domains: list[str],
86
+ *,
87
+ dns: str,
88
+ zone: str,
89
+ email: str,
90
+ staging: bool = False,
91
+ propagation_attempts: int = 20,
92
+ propagation_delay: int = 15,
93
+ ) -> tuple[str, str]:
94
+ """Issue a Let's Encrypt cert for ``domains``; return (fullchain_pem, key_pem).
95
+
96
+ ``dns`` is a granny DNS provider name; ``zone`` is the registrable zone the
97
+ challenge records live in (e.g. ``obachan.dev``).
98
+ """
99
+ import josepy as jose
100
+ from acme import challenges, client, crypto_util, messages
101
+
102
+ from granny.dns.factory import get_provider
103
+
104
+ provider = get_provider(dns)
105
+ zone_id = provider.get_zone_id(zone)
106
+
107
+ directory_url = _LE_STAGING if staging else _LE_PROD
108
+ account_key = jose.JWKRSA(key=_new_rsa_key())
109
+ net = client.ClientNetwork(account_key, user_agent="granny-letsencrypt")
110
+ directory = client.ClientV2.get_directory(directory_url, net)
111
+ acme = client.ClientV2(directory, net)
112
+ acme.new_account(
113
+ messages.NewRegistration.from_data(email=email, terms_of_service_agreed=True)
114
+ )
115
+
116
+ cert_key = _new_rsa_key()
117
+ cert_key_pem = _key_pem(cert_key)
118
+ csr_pem = crypto_util.make_csr(cert_key_pem, domains)
119
+ order = acme.new_order(csr_pem)
120
+
121
+ created: list[tuple[str, str, str]] = [] # (fqdn, value, record_id) for cleanup
122
+ try:
123
+ # 1. Publish every dns-01 TXT record first (a base domain + its wildcard
124
+ # share `_acme-challenge.<domain>`, so multiple values can coexist).
125
+ pending = []
126
+ for authz in order.authorizations:
127
+ dom = authz.body.identifier.value
128
+ chall = next(
129
+ c for c in authz.body.challenges if isinstance(c.chall, challenges.DNS01)
130
+ )
131
+ response, validation = chall.response_and_validation(account_key)
132
+ fqdn = chall.chall.validation_domain_name(dom)
133
+ rec = provider.create_record(
134
+ zone_id, _relative_name(fqdn, zone), "TXT", validation
135
+ )
136
+ created.append((fqdn, validation, rec.id))
137
+ pending.append((fqdn, validation, chall, response))
138
+ print(f" published TXT {fqdn} = {validation[:12]}…", file=sys.stderr)
139
+
140
+ # 2. Wait for propagation, then answer each challenge.
141
+ for fqdn, validation, chall, response in pending:
142
+ if not _txt_propagated(
143
+ fqdn, validation, attempts=propagation_attempts, delay=propagation_delay
144
+ ):
145
+ raise TimeoutError(f"TXT for {fqdn} did not propagate in time")
146
+ acme.answer_challenge(chall, response)
147
+ print(f" answered challenge for {fqdn}", file=sys.stderr)
148
+
149
+ # 3. Finalize + download.
150
+ order = acme.poll_and_finalize(order)
151
+ finally:
152
+ for _fqdn, _val, rec_id in created:
153
+ try:
154
+ provider.delete_record(zone_id, rec_id)
155
+ except Exception: # noqa: BLE001 - best-effort cleanup
156
+ pass
157
+
158
+ return order.fullchain_pem, cert_key_pem.decode("utf-8")
159
+
160
+
161
+ def main(argv: list[str] | None = None) -> int:
162
+ parser = argparse.ArgumentParser(description="Issue a Let's Encrypt cert via DNS-01.")
163
+ parser.add_argument("--domain", action="append", required=True, help="Domain (repeatable; wildcards ok).")
164
+ parser.add_argument("--dns", required=True, help="granny DNS provider (e.g. inwx, cloudflare).")
165
+ parser.add_argument("--zone", required=True, help="Registrable zone the challenge records live in.")
166
+ parser.add_argument("--email", required=True, help="ACME account contact email.")
167
+ parser.add_argument("--out-dir", default=".", help="Directory to write fullchain.pem + key.pem.")
168
+ parser.add_argument("--staging", action="store_true", help="Use the LE staging CA (no rate limits).")
169
+ parser.add_argument("--propagation-attempts", type=int, default=20)
170
+ parser.add_argument("--propagation-delay", type=int, default=15)
171
+ args = parser.parse_args(argv)
172
+
173
+ fullchain, key = issue_certificate(
174
+ args.domain,
175
+ dns=args.dns,
176
+ zone=args.zone,
177
+ email=args.email,
178
+ staging=args.staging,
179
+ propagation_attempts=args.propagation_attempts,
180
+ propagation_delay=args.propagation_delay,
181
+ )
182
+
183
+ out = Path(args.out_dir)
184
+ out.mkdir(parents=True, exist_ok=True)
185
+ (out / "fullchain.pem").write_text(fullchain, encoding="utf-8")
186
+ (out / "key.pem").write_text(key, encoding="utf-8")
187
+ print(f"wrote {out / 'fullchain.pem'} and {out / 'key.pem'}")
188
+ return 0
189
+
190
+
191
+ if __name__ == "__main__":
192
+ raise SystemExit(main())
@@ -262,6 +262,53 @@ class ZitadelClient:
262
262
  "POST", f"/management/v1/projects/{project_id}/apps/oidc", body=body
263
263
  )
264
264
 
265
+ def set_oidc_app_redirects(
266
+ self,
267
+ project_id: str,
268
+ app_id: str,
269
+ redirect_uris: list[str],
270
+ *,
271
+ replace: bool = False,
272
+ post_logout_uris: list[str] | None = None,
273
+ ) -> dict[str, Any]:
274
+ """Set an existing OIDC app's redirect URIs (idempotent).
275
+
276
+ Reads the app's current ``oidcConfig`` and PUTs it back with the merged
277
+ redirect URIs (Zitadel's update endpoint replaces the whole config, so
278
+ every field is round-tripped). With ``replace=False`` (default) the new
279
+ URIs are unioned with the existing set — order-stable, de-duplicated —
280
+ so re-running with the same URIs is a no-op. ``replace=True`` sets
281
+ exactly ``redirect_uris``.
282
+ """
283
+ oidc = dict(self.get_app(project_id, app_id).get("oidcConfig") or {})
284
+ existing = list(oidc.get("redirectUris") or [])
285
+ merged = (
286
+ list(dict.fromkeys(redirect_uris))
287
+ if replace
288
+ else list(dict.fromkeys([*existing, *redirect_uris]))
289
+ )
290
+ merged_logout = list(
291
+ dict.fromkeys([*(oidc.get("postLogoutRedirectUris") or []), *(post_logout_uris or [])])
292
+ )
293
+ body = {
294
+ "redirectUris": merged,
295
+ "responseTypes": oidc.get("responseTypes") or ["OIDC_RESPONSE_TYPE_CODE"],
296
+ "grantTypes": oidc.get("grantTypes")
297
+ or ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"],
298
+ "appType": oidc.get("appType") or "OIDC_APP_TYPE_WEB",
299
+ "authMethodType": oidc.get("authMethodType") or "OIDC_AUTH_METHOD_TYPE_BASIC",
300
+ "postLogoutRedirectUris": merged_logout,
301
+ "version": oidc.get("version") or "OIDC_VERSION_1_0",
302
+ "devMode": oidc.get("devMode", False),
303
+ "accessTokenType": oidc.get("accessTokenType") or "OIDC_TOKEN_TYPE_JWT",
304
+ }
305
+ self.request(
306
+ "PUT",
307
+ f"/management/v1/projects/{project_id}/apps/{app_id}/oidc_config",
308
+ body=body,
309
+ )
310
+ return {"appId": app_id, "redirectUris": merged}
311
+
265
312
  # ── Login policy / MFA ───────────────────────────────────────────────
266
313
 
267
314
  def get_login_policy(self) -> dict[str, Any]:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "granny-devops"
3
- version = "0.15.0"
3
+ version = "0.17.0"
4
4
  description = "Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -12,6 +12,7 @@ dependencies = [
12
12
  "python-dotenv>=1.1.0",
13
13
  "requests>=2.32",
14
14
  "pyjwt[crypto]>=2.8", # Zitadel service-account JWT-profile auth (`granny zitadel`)
15
+ "acme>=2.11", # Let's Encrypt ACME DNS-01 issuance (granny create letsencrypt-cert)
15
16
  ]
16
17
 
17
18
  [project.optional-dependencies]
File without changes