granny-devops 0.18.0__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.18.0 → granny_devops-0.18.1}/PKG-INFO +10 -1
  2. {granny_devops-0.18.0 → granny_devops-0.18.1}/README.md +9 -0
  3. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/__init__.py +1 -1
  4. granny_devops-0.18.1/granny/cli/oauth.py +164 -0
  5. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/credentials/secrets.py +1 -0
  6. granny_devops-0.18.1/granny/oauth/__init__.py +19 -0
  7. granny_devops-0.18.1/granny/oauth/productivity.py +237 -0
  8. {granny_devops-0.18.0 → granny_devops-0.18.1}/pyproject.toml +1 -1
  9. granny_devops-0.18.0/granny/cli/oauth.py +0 -96
  10. {granny_devops-0.18.0 → granny_devops-0.18.1}/.gitignore +0 -0
  11. {granny_devops-0.18.0 → granny_devops-0.18.1}/LICENSE +0 -0
  12. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/airtable/__init__.py +0 -0
  13. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/airtable/client.py +0 -0
  14. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/analyze/__init__.py +0 -0
  15. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/analyze/costs.py +0 -0
  16. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/analyze/credits.py +0 -0
  17. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/analyze/gpu_pricing.py +0 -0
  18. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/analyze/gpus.py +0 -0
  19. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/analyze/kubernetes.py +0 -0
  20. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/analyze/lambdas.py +0 -0
  21. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/analyze/vpcs.py +0 -0
  22. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/authentik/__init__.py +0 -0
  23. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/authentik/client.py +0 -0
  24. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/authentik/provision.py +0 -0
  25. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/aws/__init__.py +0 -0
  26. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/aws/bedrock.py +0 -0
  27. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/aws/quota.py +0 -0
  28. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/azure/__init__.py +0 -0
  29. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/azure/_client.py +0 -0
  30. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/azure/account.py +0 -0
  31. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/azure/deployment.py +0 -0
  32. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/azure/openai.py +0 -0
  33. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/azure/quota.py +0 -0
  34. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/azure/resourcegroup.py +0 -0
  35. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/azure/role.py +0 -0
  36. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/azure/sku.py +0 -0
  37. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/azure/vm.py +0 -0
  38. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/azure/webapp.py +0 -0
  39. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cdn/__init__.py +0 -0
  40. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cdn/bunny.py +0 -0
  41. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/__init__.py +0 -0
  42. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/airtable.py +0 -0
  43. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/analyze.py +0 -0
  44. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/authentik.py +0 -0
  45. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/aws.py +0 -0
  46. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/azure.py +0 -0
  47. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/cdn.py +0 -0
  48. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/cloudflare.py +0 -0
  49. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/create.py +0 -0
  50. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/credentials.py +0 -0
  51. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/dns.py +0 -0
  52. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/docker.py +0 -0
  53. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/edge.py +0 -0
  54. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/elk.py +0 -0
  55. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/email.py +0 -0
  56. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/indexing.py +0 -0
  57. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/knative.py +0 -0
  58. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/main.py +0 -0
  59. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/serverless.py +0 -0
  60. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/storage.py +0 -0
  61. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cli/zitadel.py +0 -0
  62. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cloudflare/__init__.py +0 -0
  63. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cloudflare/d1.py +0 -0
  64. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cloudflare/r2.py +0 -0
  65. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/cloudflare/workers.py +0 -0
  66. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/__init__.py +0 -0
  67. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/auto_certificate.py +0 -0
  68. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/cloudfront-security-headers.js +0 -0
  69. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/letsencrypt_cert.py +0 -0
  70. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/manage-dns.sh +0 -0
  71. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/manage_mailjet_contacts.py +0 -0
  72. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/registrars.py +0 -0
  73. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/setup_aws_cloudfront.py +0 -0
  74. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/setup_bunny_edge_script.py +0 -0
  75. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/setup_bunny_storage.py +0 -0
  76. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/setup_cognito_identity_pool.py +0 -0
  77. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/setup_hetzner_bunny.py +0 -0
  78. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/setup_mailjet_dns.py +0 -0
  79. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/setup_private_cdn.py +0 -0
  80. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/setup_s3_website.py +0 -0
  81. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/setup_scaleway_container.py +0 -0
  82. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/setup_scaleway_faas.py +0 -0
  83. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/setup_workmail.py +0 -0
  84. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/create/www-redirect-function.js +0 -0
  85. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/credentials/__init__.py +0 -0
  86. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/dns/__init__.py +0 -0
  87. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/dns/base.py +0 -0
  88. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/dns/bunny.py +0 -0
  89. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/dns/cloudflare.py +0 -0
  90. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/dns/cloudns.py +0 -0
  91. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/dns/desec.py +0 -0
  92. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/dns/factory.py +0 -0
  93. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/dns/hetzner.py +0 -0
  94. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/dns/inwx.py +0 -0
  95. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/dns/manual.py +0 -0
  96. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/dns/records.py +0 -0
  97. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/docker/__init__.py +0 -0
  98. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/docker/build_base.py +0 -0
  99. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/edge/__init__.py +0 -0
  100. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/edge/bunny.py +0 -0
  101. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/elk/__init__.py +0 -0
  102. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/elk/client.py +0 -0
  103. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/email/__init__.py +0 -0
  104. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/email/mailjet.py +0 -0
  105. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/email/mailjet_contacts.py +0 -0
  106. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/email/ses_forwarding.py +0 -0
  107. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/email/stalwart.py +0 -0
  108. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/email/workmail.py +0 -0
  109. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/indexing/__init__.py +0 -0
  110. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/indexing/google.py +0 -0
  111. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/indexing/indexnow.py +0 -0
  112. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/knative/__init__.py +0 -0
  113. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/knative/client.py +0 -0
  114. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/report.py +0 -0
  115. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/serverless/__init__.py +0 -0
  116. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/serverless/scaleway.py +0 -0
  117. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/storage/__init__.py +0 -0
  118. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/storage/aws.py +0 -0
  119. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/storage/bunny.py +0 -0
  120. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/storage/hetzner.py +0 -0
  121. {granny_devops-0.18.0 → granny_devops-0.18.1}/granny/zitadel/__init__.py +0 -0
  122. {granny_devops-0.18.0 → 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.18.0
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
@@ -220,6 +220,7 @@ Common keys:
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
222
  | Knative FaaS API | `OBACHAN_API_KEY`, `OBACHAN_API_BASE_URL` |
223
+ | Obachan OpenBao | `OBACHAN_BAO_ADDR`, `OBACHAN_BAO_TOKEN` |
223
224
  | Mailjet | `MAILJET_API_KEY`, `MAILJET_SECRET_KEY` |
224
225
  | deSEC | `DESEC_API_TOKEN` |
225
226
  | ClouDNS | `CLOUDNS_AUTH_ID`/`_PASSWORD` (or `_SUB_AUTH_ID`/`_SUB_AUTH_USER`) |
@@ -235,6 +236,12 @@ what's configured at any time.
235
236
  ```shell
236
237
  granny --help # see every command group
237
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"
238
245
  ```
239
246
 
240
247
  The CLI is organized by capability, not by provider. You pick the
@@ -413,6 +420,7 @@ granny indexing google https://example.com/job-posting --action URL_UPDATED
413
420
  | Observability admin | Elasticsearch / Kibana native-user management |
414
421
  | Azure ops | Identity, subscriptions, ARM deployments, Cognitive Services (Azure OpenAI), App Service, VM sizes, VM inventory/deallocate/delete, RBAC role assignment, compute quota |
415
422
  | Search indexing | IndexNow (Bing/Yandex/Seznam/Naver/Yep), Google Indexing API |
423
+ | Productivity OAuth | Google Cloud and Microsoft Entra mail/calendar client provisioning |
416
424
 
417
425
  ## As a library
418
426
 
@@ -456,6 +464,7 @@ granny/
456
464
  email/ Mailjet, WorkMail, SES forwarding
457
465
  indexing/ IndexNow + Google Indexing API
458
466
  knative/ Knative FaaS control API client
467
+ oauth/ Google/Microsoft productivity OAuth provisioning
459
468
  serverless/ Scaleway FaaS
460
469
  storage/ Object storage (AWS / Bunny / Hetzner)
461
470
  ```
@@ -86,6 +86,7 @@ Common keys:
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
88
  | Knative FaaS API | `OBACHAN_API_KEY`, `OBACHAN_API_BASE_URL` |
89
+ | Obachan OpenBao | `OBACHAN_BAO_ADDR`, `OBACHAN_BAO_TOKEN` |
89
90
  | Mailjet | `MAILJET_API_KEY`, `MAILJET_SECRET_KEY` |
90
91
  | deSEC | `DESEC_API_TOKEN` |
91
92
  | ClouDNS | `CLOUDNS_AUTH_ID`/`_PASSWORD` (or `_SUB_AUTH_ID`/`_SUB_AUTH_USER`) |
@@ -101,6 +102,12 @@ what's configured at any time.
101
102
  ```shell
102
103
  granny --help # see every command group
103
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"
104
111
  ```
105
112
 
106
113
  The CLI is organized by capability, not by provider. You pick the
@@ -279,6 +286,7 @@ granny indexing google https://example.com/job-posting --action URL_UPDATED
279
286
  | Observability admin | Elasticsearch / Kibana native-user management |
280
287
  | Azure ops | Identity, subscriptions, ARM deployments, Cognitive Services (Azure OpenAI), App Service, VM sizes, VM inventory/deallocate/delete, RBAC role assignment, compute quota |
281
288
  | Search indexing | IndexNow (Bing/Yandex/Seznam/Naver/Yep), Google Indexing API |
289
+ | Productivity OAuth | Google Cloud and Microsoft Entra mail/calendar client provisioning |
282
290
 
283
291
  ## As a library
284
292
 
@@ -322,6 +330,7 @@ granny/
322
330
  email/ Mailjet, WorkMail, SES forwarding
323
331
  indexing/ IndexNow + Google Indexing API
324
332
  knative/ Knative FaaS control API client
333
+ oauth/ Google/Microsoft productivity OAuth provisioning
325
334
  serverless/ Scaleway FaaS
326
335
  storage/ Object storage (AWS / Bunny / Hetzner)
327
336
  ```
@@ -1,6 +1,6 @@
1
1
  """Granny -- Cloud tools collection for AWS infrastructure and DevOps automation."""
2
2
 
3
- __version__ = "0.18.0"
3
+ __version__ = "0.18.1"
4
4
  __all__ = [
5
5
  "get_secret",
6
6
  "load_secrets_into_env",
@@ -0,0 +1,164 @@
1
+ """OAuth provisioning commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from granny.oauth import (
10
+ ProductivityOAuthCredentials,
11
+ load_google_client_json,
12
+ provision_productivity_oauth,
13
+ update_env_file,
14
+ )
15
+
16
+
17
+ @click.group()
18
+ def oauth() -> None:
19
+ """Provision OAuth clients for applications."""
20
+
21
+
22
+ @oauth.command("provision-productivity")
23
+ @click.option(
24
+ "--env-file",
25
+ type=click.Path(path_type=Path),
26
+ default=None,
27
+ help="Dashboard dotenv file to update.",
28
+ )
29
+ @click.option(
30
+ "--obachan-env",
31
+ default=None,
32
+ help="Obachan environment whose OpenBao application secret is updated.",
33
+ )
34
+ @click.option(
35
+ "--obachan-app",
36
+ default="dashboard",
37
+ show_default=True,
38
+ help="Application name used in the Obachan secret path.",
39
+ )
40
+ @click.option(
41
+ "--google-client-json",
42
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
43
+ default=None,
44
+ help="OAuth client JSON downloaded from Google Cloud.",
45
+ )
46
+ @click.option("--google-client-id", default=None, help="Google OAuth client ID.")
47
+ @click.option(
48
+ "--google-client-secret",
49
+ default=None,
50
+ help="Google OAuth client secret (not echoed).",
51
+ )
52
+ @click.option("--azure-client-id", default=None, help="Microsoft Entra client ID.")
53
+ @click.option(
54
+ "--azure-client-secret",
55
+ default=None,
56
+ help="Microsoft Entra client secret (not echoed).",
57
+ )
58
+ @click.option(
59
+ "--dry-run", is_flag=True, help="Validate and show destinations without writing."
60
+ )
61
+ def provision_productivity(
62
+ env_file: Path | None,
63
+ obachan_env: str | None,
64
+ obachan_app: str,
65
+ google_client_json: Path | None,
66
+ google_client_id: str | None,
67
+ google_client_secret: str | None,
68
+ azure_client_id: str | None,
69
+ azure_client_secret: str | None,
70
+ dry_run: bool,
71
+ ) -> None:
72
+ """Add Google and Microsoft mail/calendar OAuth clients.
73
+
74
+ Set OBACHAN_BAO_ADDR and OBACHAN_BAO_TOKEN when --obachan-env is used.
75
+ Google Cloud OAuth client downloads can be passed with --google-client-json.
76
+ """
77
+ if google_client_json:
78
+ if google_client_id or google_client_secret:
79
+ raise click.UsageError(
80
+ "Use --google-client-json or explicit Google values, not both."
81
+ )
82
+ try:
83
+ google_client_id, google_client_secret = load_google_client_json(
84
+ google_client_json
85
+ )
86
+ except ValueError as exc:
87
+ raise click.ClickException(str(exc)) from exc
88
+
89
+ credentials = ProductivityOAuthCredentials(
90
+ google_client_id=google_client_id,
91
+ google_client_secret=google_client_secret,
92
+ azure_client_id=azure_client_id,
93
+ azure_client_secret=azure_client_secret,
94
+ )
95
+ try:
96
+ result = provision_productivity_oauth(
97
+ credentials,
98
+ env_file=env_file,
99
+ obachan_env=obachan_env,
100
+ obachan_app=obachan_app,
101
+ dry_run=dry_run,
102
+ )
103
+ except (ValueError, RuntimeError) as exc:
104
+ raise click.ClickException(str(exc)) from exc
105
+
106
+ for key in result.keys:
107
+ click.echo(f"would set {key}" if dry_run else f"set {key}")
108
+ if result.env_file:
109
+ action = "Would update" if dry_run else "Updated"
110
+ click.echo(f"{action} {result.env_file}")
111
+ if result.obachan_path:
112
+ action = "Would update" if dry_run else "Updated"
113
+ click.echo(f"{action} OpenBao path {result.obachan_path}")
114
+ if dry_run:
115
+ click.echo("Dry run; no destinations written.")
116
+
117
+
118
+ @oauth.command("configure-productivity-local")
119
+ @click.option(
120
+ "--env-file",
121
+ type=click.Path(path_type=Path),
122
+ default=Path(".deploy.env"),
123
+ show_default=True,
124
+ )
125
+ @click.option("--google-client-id", default=None)
126
+ @click.option("--google-client-secret", default=None)
127
+ @click.option("--azure-client-id", default=None)
128
+ @click.option("--azure-client-secret", default=None)
129
+ @click.option("--dry-run", is_flag=True, help="Validate without writing the env file.")
130
+ def configure_productivity_local(
131
+ env_file: Path,
132
+ google_client_id: str | None,
133
+ google_client_secret: str | None,
134
+ azure_client_id: str | None,
135
+ azure_client_secret: str | None,
136
+ dry_run: bool,
137
+ ) -> None:
138
+ """Configure a local dashboard (compatibility alias)."""
139
+ credentials = ProductivityOAuthCredentials(
140
+ google_client_id=google_client_id,
141
+ google_client_secret=google_client_secret,
142
+ azure_client_id=azure_client_id,
143
+ azure_client_secret=azure_client_secret,
144
+ )
145
+ values = credentials.as_env()
146
+ if not values:
147
+ raise click.UsageError("Provide at least one client id or secret value.")
148
+ if not dry_run:
149
+ update_env_file(env_file, values)
150
+ click.echo(f"Env file: {env_file}")
151
+ for key in sorted(values):
152
+ value = values[key]
153
+ display = _mask(value) if key.endswith("SECRET") else value
154
+ click.echo(f" set {key}={display}")
155
+ if dry_run:
156
+ click.echo("Dry run; no file written.")
157
+ else:
158
+ click.echo("Updated local OAuth configuration.")
159
+
160
+
161
+ def _mask(value: str) -> str:
162
+ if len(value) <= 8:
163
+ return "****"
164
+ return f"{value[:4]}…{value[-4:]}"
@@ -193,6 +193,7 @@ SECRET_MAP: dict[str, str] = {
193
193
  # Knative FaaS control API. The base URL is vault-distributed configuration.
194
194
  "OBACHAN_API_KEY": "obachan-api-key",
195
195
  "OBACHAN_API_BASE_URL": "obachan-api-base-url",
196
+ "OBACHAN_BAO_TOKEN": "obachan-bao-token",
196
197
  }
197
198
 
198
199
  # Module-level cache so vault is only contacted once per process
@@ -0,0 +1,19 @@
1
+ """OAuth client provisioning helpers."""
2
+
3
+ from granny.oauth.productivity import (
4
+ ObachanSecretClient,
5
+ ProductivityOAuthCredentials,
6
+ ProvisionResult,
7
+ load_google_client_json,
8
+ provision_productivity_oauth,
9
+ update_env_file,
10
+ )
11
+
12
+ __all__ = [
13
+ "ObachanSecretClient",
14
+ "ProductivityOAuthCredentials",
15
+ "ProvisionResult",
16
+ "load_google_client_json",
17
+ "provision_productivity_oauth",
18
+ "update_env_file",
19
+ ]
@@ -0,0 +1,237 @@
1
+ """Provision Google and Microsoft productivity OAuth client credentials.
2
+
3
+ Credentials can be written to an application's local dotenv file and to an
4
+ Obachan OpenBao KV v2 path. Provider-side application registration remains a
5
+ cloud-admin operation; Google client JSON downloaded from Google Cloud is
6
+ accepted directly.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any
17
+ from urllib.parse import quote
18
+
19
+ import requests
20
+
21
+ from granny.credentials import get_secret
22
+
23
+ _SEGMENT_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$")
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ProductivityOAuthCredentials:
28
+ """Google and Microsoft OAuth client credentials."""
29
+
30
+ google_client_id: str | None = None
31
+ google_client_secret: str | None = None
32
+ azure_client_id: str | None = None
33
+ azure_client_secret: str | None = None
34
+
35
+ def as_env(self) -> dict[str, str]:
36
+ """Return configured values using dashboard runtime variable names."""
37
+ values = {
38
+ "GOOGLE_CLIENT_ID": self.google_client_id,
39
+ "GOOGLE_CLIENT_SECRET": self.google_client_secret,
40
+ "AZURE_CLIENT_ID": self.azure_client_id,
41
+ "AZURE_CLIENT_SECRET": self.azure_client_secret,
42
+ }
43
+ return {key: value for key, value in values.items() if value}
44
+
45
+ def validate(self) -> None:
46
+ """Require complete client ID/secret pairs for each selected provider."""
47
+ values = self.as_env()
48
+ if not values:
49
+ raise ValueError("At least one Google or Azure OAuth client is required")
50
+ for provider in ("GOOGLE", "AZURE"):
51
+ client_id = values.get(f"{provider}_CLIENT_ID")
52
+ client_secret = values.get(f"{provider}_CLIENT_SECRET")
53
+ if bool(client_id) != bool(client_secret):
54
+ raise ValueError(
55
+ f"{provider}_CLIENT_ID and {provider}_CLIENT_SECRET must be provided together"
56
+ )
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class ProvisionResult:
61
+ """Locations updated by a productivity OAuth provisioning run."""
62
+
63
+ keys: tuple[str, ...]
64
+ env_file: Path | None = None
65
+ obachan_path: str | None = None
66
+
67
+
68
+ def load_google_client_json(path: Path) -> tuple[str, str]:
69
+ """Read a Google Cloud OAuth client download and return ID and secret."""
70
+ try:
71
+ document = json.loads(path.read_text(encoding="utf-8"))
72
+ except (OSError, json.JSONDecodeError) as exc:
73
+ raise ValueError(f"Cannot read Google OAuth client JSON {path}: {exc}") from exc
74
+
75
+ config = document.get("web") or document.get("installed") or document
76
+ if not isinstance(config, dict):
77
+ raise ValueError(f"Invalid Google OAuth client JSON: {path}")
78
+ client_id = config.get("client_id")
79
+ client_secret = config.get("client_secret")
80
+ if not isinstance(client_id, str) or not isinstance(client_secret, str):
81
+ raise ValueError(
82
+ f"Google OAuth client JSON has no client_id/client_secret: {path}"
83
+ )
84
+ return client_id, client_secret
85
+
86
+
87
+ class ObachanSecretClient:
88
+ """Small OpenBao KV v2 client for Obachan application secrets."""
89
+
90
+ def __init__(
91
+ self,
92
+ base_url: str | None = None,
93
+ token: str | None = None,
94
+ *,
95
+ mount: str = "kv",
96
+ ) -> None:
97
+ self.base_url = (base_url or os.environ.get("OBACHAN_BAO_ADDR") or "").rstrip(
98
+ "/"
99
+ )
100
+ self.token = token or get_secret("OBACHAN_BAO_TOKEN")
101
+ self.mount = mount.strip("/")
102
+
103
+ def _url(self, path: str) -> str:
104
+ if not self.base_url:
105
+ raise ValueError("OBACHAN_BAO_ADDR is not set")
106
+ if not self.token:
107
+ raise ValueError("OBACHAN_BAO_TOKEN is not set (env or vault)")
108
+ encoded = "/".join(quote(segment, safe="") for segment in path.split("/"))
109
+ return f"{self.base_url}/v1/{quote(self.mount, safe='')}/data/{encoded}"
110
+
111
+ def merge(self, path: str, values: dict[str, str]) -> None:
112
+ """Merge fields into one Obachan OpenBao KV v2 secret using CAS."""
113
+ url = self._url(path)
114
+ headers = {"X-Vault-Token": self.token or ""}
115
+ try:
116
+ response = requests.get(url, headers=headers, timeout=30)
117
+ except requests.RequestException as exc:
118
+ raise RuntimeError(f"Obachan OpenBao GET {path} failed: {exc}") from exc
119
+
120
+ version = 0
121
+ if response.status_code == 404:
122
+ existing: dict[str, Any] = {}
123
+ elif response.status_code == 200:
124
+ try:
125
+ body = response.json()
126
+ data = body.get("data", {})
127
+ existing = data.get("data", {})
128
+ metadata = data.get("metadata", {})
129
+ version = metadata.get("version", 0)
130
+ except (AttributeError, TypeError, ValueError) as exc:
131
+ raise RuntimeError(
132
+ f"Obachan OpenBao GET {path} returned invalid KV v2 JSON"
133
+ ) from exc
134
+ if not isinstance(existing, dict) or not isinstance(version, int):
135
+ raise RuntimeError(
136
+ f"Obachan OpenBao GET {path} returned invalid KV v2 data"
137
+ )
138
+ else:
139
+ _raise_bao_error("GET", path, response)
140
+
141
+ try:
142
+ response = requests.post(
143
+ url,
144
+ headers={**headers, "Content-Type": "application/json"},
145
+ json={
146
+ "data": {**existing, **values},
147
+ "options": {"cas": version},
148
+ },
149
+ timeout=30,
150
+ )
151
+ except requests.RequestException as exc:
152
+ raise RuntimeError(f"Obachan OpenBao POST {path} failed: {exc}") from exc
153
+ if response.status_code not in (200, 204):
154
+ _raise_bao_error("POST", path, response)
155
+
156
+
157
+ def provision_productivity_oauth(
158
+ credentials: ProductivityOAuthCredentials,
159
+ *,
160
+ env_file: Path | None = None,
161
+ obachan_env: str | None = None,
162
+ obachan_app: str = "dashboard",
163
+ obachan_client: ObachanSecretClient | None = None,
164
+ dry_run: bool = False,
165
+ ) -> ProvisionResult:
166
+ """Validate and provision OAuth credentials to selected destinations."""
167
+ credentials.validate()
168
+ values = credentials.as_env()
169
+
170
+ if env_file is None and obachan_env is None:
171
+ raise ValueError("Select an env_file or obachan_env destination")
172
+
173
+ if env_file is not None and not dry_run:
174
+ update_env_file(env_file, values)
175
+
176
+ obachan_path = None
177
+ if obachan_env is not None:
178
+ _validate_segment("Obachan environment", obachan_env)
179
+ _validate_segment("Obachan application", obachan_app)
180
+ obachan_path = f"obachan/{obachan_env}/apps/{obachan_app}/productivity-oauth"
181
+ if not dry_run:
182
+ (obachan_client or ObachanSecretClient()).merge(obachan_path, values)
183
+
184
+ return ProvisionResult(
185
+ keys=tuple(sorted(values)), env_file=env_file, obachan_path=obachan_path
186
+ )
187
+
188
+
189
+ def update_env_file(path: Path, updates: dict[str, str]) -> None:
190
+ """Update dotenv assignments while preserving comments and unrelated lines."""
191
+ lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
192
+ remaining = dict(updates)
193
+ replaced: set[str] = set()
194
+ output: list[str] = []
195
+ assignment = re.compile(r"^(\s*)(export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=")
196
+ for line in lines:
197
+ match = assignment.match(line)
198
+ key = match.group(3) if match else None
199
+ if key in updates:
200
+ if key not in replaced:
201
+ prefix = f"{match.group(1)}{match.group(2) or ''}"
202
+ output.append(f"{prefix}{key}={_quote_env(updates[key])}")
203
+ replaced.add(key)
204
+ remaining.pop(key, None)
205
+ continue
206
+ output.append(line)
207
+ if output and remaining and output[-1]:
208
+ output.append("")
209
+ output.extend(
210
+ f"{key}={_quote_env(value)}" for key, value in sorted(remaining.items())
211
+ )
212
+ path.parent.mkdir(parents=True, exist_ok=True)
213
+ path.write_text("\n".join(output) + "\n", encoding="utf-8")
214
+
215
+
216
+ def _validate_segment(label: str, value: str) -> None:
217
+ if not _SEGMENT_RE.fullmatch(value):
218
+ raise ValueError(
219
+ f"{label} must contain only letters, digits, '.', '_', and '-'"
220
+ )
221
+
222
+
223
+ def _quote_env(value: str) -> str:
224
+ if (
225
+ not value
226
+ or any(ch.isspace() for ch in value)
227
+ or any(ch in value for ch in "\"'#$")
228
+ ):
229
+ return f'"{value.replace(chr(34), chr(92) + chr(34))}"'
230
+ return value
231
+
232
+
233
+ def _raise_bao_error(method: str, path: str, response: requests.Response) -> None:
234
+ raise RuntimeError(
235
+ f"Obachan OpenBao {method} {path} failed: "
236
+ f"{response.status_code} {response.text[:1000]}"
237
+ )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "granny-devops"
3
- version = "0.18.0"
3
+ version = "0.18.1"
4
4
  description = "Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -1,96 +0,0 @@
1
- """OAuth configuration commands."""
2
-
3
- from __future__ import annotations
4
-
5
- from pathlib import Path
6
-
7
- import click
8
-
9
-
10
- @click.group()
11
- def oauth() -> None:
12
- """Configure OAuth client settings for local apps."""
13
-
14
-
15
- @oauth.command("configure-productivity-local")
16
- @click.option(
17
- "--env-file",
18
- type=click.Path(path_type=Path),
19
- default=Path(".deploy.env"),
20
- show_default=True,
21
- help="Env file to update in the current project.",
22
- )
23
- @click.option("--google-client-id", default=None, help="Google OAuth client ID.")
24
- @click.option("--google-client-secret", default=None, help="Google OAuth client secret (optional; not echoed).")
25
- @click.option("--azure-client-id", default=None, help="Microsoft Entra application/client ID.")
26
- @click.option("--azure-client-secret", default=None, help="Microsoft Entra client secret (optional; not echoed).")
27
- @click.option("--dry-run", is_flag=True, help="Print planned keys without writing the env file.")
28
- def configure_productivity_local(
29
- env_file: Path,
30
- google_client_id: str | None,
31
- google_client_secret: str | None,
32
- azure_client_id: str | None,
33
- azure_client_secret: str | None,
34
- dry_run: bool,
35
- ) -> None:
36
- """Write real Google/Microsoft OAuth IDs into a local dashboard env file.
37
-
38
- Provider application creation is intentionally not automated here: Google
39
- Cloud OAuth consent screens and Microsoft Entra app registrations require
40
- tenant-admin policy choices. Use this after real provider apps exist.
41
- """
42
- updates = {
43
- "GOOGLE_CLIENT_ID": google_client_id,
44
- "GOOGLE_CLIENT_SECRET": google_client_secret,
45
- "AZURE_CLIENT_ID": azure_client_id,
46
- "AZURE_CLIENT_SECRET": azure_client_secret,
47
- }
48
- updates = {key: value for key, value in updates.items() if value}
49
- if not updates:
50
- raise click.UsageError("Provide at least one client id or secret value.")
51
-
52
- existing = _read_env(env_file)
53
- merged = {**existing, **updates}
54
-
55
- click.echo(f"Env file: {env_file}")
56
- for key in sorted(updates):
57
- display = _mask(updates[key]) if key.endswith("SECRET") else updates[key]
58
- click.echo(f" set {key}={display}")
59
-
60
- if dry_run:
61
- click.echo("Dry run; no file written.")
62
- return
63
-
64
- _write_env(env_file, merged)
65
- click.echo("Updated local OAuth configuration.")
66
-
67
-
68
- def _read_env(path: Path) -> dict[str, str]:
69
- if not path.exists():
70
- return {}
71
- values: dict[str, str] = {}
72
- for line in path.read_text(encoding="utf-8").splitlines():
73
- stripped = line.strip()
74
- if not stripped or stripped.startswith("#") or "=" not in stripped:
75
- continue
76
- key, value = stripped.split("=", 1)
77
- values[key.strip()] = value.strip().strip('"').strip("'")
78
- return values
79
-
80
-
81
- def _write_env(path: Path, values: dict[str, str]) -> None:
82
- path.parent.mkdir(parents=True, exist_ok=True)
83
- lines = [f"{key}={_quote_env(value)}" for key, value in sorted(values.items())]
84
- path.write_text("\n".join(lines) + "\n", encoding="utf-8")
85
-
86
-
87
- def _quote_env(value: str) -> str:
88
- if not value or any(ch.isspace() for ch in value) or any(ch in value for ch in '"\'#$'):
89
- return f'"{value.replace(chr(34), chr(92) + chr(34))}"'
90
- return value
91
-
92
-
93
- def _mask(value: str) -> str:
94
- if len(value) <= 8:
95
- return "****"
96
- return f"{value[:4]}…{value[-4:]}"
File without changes