granny-devops 0.12.1__tar.gz → 0.13.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 (107) hide show
  1. {granny_devops-0.12.1 → granny_devops-0.13.0}/PKG-INFO +6 -3
  2. {granny_devops-0.12.1 → granny_devops-0.13.0}/README.md +5 -2
  3. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/__init__.py +1 -1
  4. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/cli/zitadel.py +51 -3
  5. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/credentials/secrets.py +4 -3
  6. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/zitadel/client.py +32 -3
  7. {granny_devops-0.12.1 → granny_devops-0.13.0}/pyproject.toml +1 -1
  8. {granny_devops-0.12.1 → granny_devops-0.13.0}/.gitignore +0 -0
  9. {granny_devops-0.12.1 → granny_devops-0.13.0}/LICENSE +0 -0
  10. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/analyze/__init__.py +0 -0
  11. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/analyze/costs.py +0 -0
  12. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/analyze/credits.py +0 -0
  13. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/analyze/gpu_pricing.py +0 -0
  14. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/analyze/gpus.py +0 -0
  15. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/analyze/lambdas.py +0 -0
  16. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/analyze/vpcs.py +0 -0
  17. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/authentik/__init__.py +0 -0
  18. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/authentik/client.py +0 -0
  19. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/authentik/provision.py +0 -0
  20. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/aws/__init__.py +0 -0
  21. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/aws/quota.py +0 -0
  22. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/azure/__init__.py +0 -0
  23. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/azure/_client.py +0 -0
  24. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/azure/account.py +0 -0
  25. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/azure/deployment.py +0 -0
  26. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/azure/openai.py +0 -0
  27. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/azure/quota.py +0 -0
  28. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/azure/role.py +0 -0
  29. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/azure/sku.py +0 -0
  30. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/azure/vm.py +0 -0
  31. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/azure/webapp.py +0 -0
  32. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/cdn/__init__.py +0 -0
  33. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/cdn/bunny.py +0 -0
  34. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/cli/__init__.py +0 -0
  35. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/cli/analyze.py +0 -0
  36. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/cli/authentik.py +0 -0
  37. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/cli/aws.py +0 -0
  38. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/cli/azure.py +0 -0
  39. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/cli/cdn.py +0 -0
  40. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/cli/cloudflare.py +0 -0
  41. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/cli/create.py +0 -0
  42. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/cli/credentials.py +0 -0
  43. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/cli/dns.py +0 -0
  44. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/cli/docker.py +0 -0
  45. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/cli/edge.py +0 -0
  46. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/cli/elk.py +0 -0
  47. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/cli/email.py +0 -0
  48. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/cli/indexing.py +0 -0
  49. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/cli/main.py +0 -0
  50. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/cli/serverless.py +0 -0
  51. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/cli/storage.py +0 -0
  52. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/cloudflare/__init__.py +0 -0
  53. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/cloudflare/d1.py +0 -0
  54. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/cloudflare/r2.py +0 -0
  55. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/cloudflare/workers.py +0 -0
  56. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/create/__init__.py +0 -0
  57. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/create/auto_certificate.py +0 -0
  58. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/create/cloudfront-security-headers.js +0 -0
  59. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/create/manage-dns.sh +0 -0
  60. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/create/manage_mailjet_contacts.py +0 -0
  61. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/create/registrars.py +0 -0
  62. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/create/setup_aws_cloudfront.py +0 -0
  63. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/create/setup_bunny_edge_script.py +0 -0
  64. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/create/setup_bunny_storage.py +0 -0
  65. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/create/setup_cognito_identity_pool.py +0 -0
  66. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/create/setup_hetzner_bunny.py +0 -0
  67. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/create/setup_mailjet_dns.py +0 -0
  68. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/create/setup_private_cdn.py +0 -0
  69. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/create/setup_s3_website.py +0 -0
  70. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/create/setup_scaleway_container.py +0 -0
  71. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/create/setup_scaleway_faas.py +0 -0
  72. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/create/setup_workmail.py +0 -0
  73. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/create/www-redirect-function.js +0 -0
  74. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/credentials/__init__.py +0 -0
  75. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/dns/__init__.py +0 -0
  76. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/dns/base.py +0 -0
  77. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/dns/bunny.py +0 -0
  78. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/dns/cloudflare.py +0 -0
  79. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/dns/cloudns.py +0 -0
  80. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/dns/desec.py +0 -0
  81. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/dns/factory.py +0 -0
  82. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/dns/hetzner.py +0 -0
  83. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/dns/inwx.py +0 -0
  84. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/dns/manual.py +0 -0
  85. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/dns/records.py +0 -0
  86. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/docker/__init__.py +0 -0
  87. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/docker/build_base.py +0 -0
  88. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/edge/__init__.py +0 -0
  89. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/edge/bunny.py +0 -0
  90. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/elk/__init__.py +0 -0
  91. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/elk/client.py +0 -0
  92. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/email/__init__.py +0 -0
  93. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/email/mailjet.py +0 -0
  94. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/email/mailjet_contacts.py +0 -0
  95. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/email/ses_forwarding.py +0 -0
  96. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/email/workmail.py +0 -0
  97. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/indexing/__init__.py +0 -0
  98. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/indexing/google.py +0 -0
  99. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/indexing/indexnow.py +0 -0
  100. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/report.py +0 -0
  101. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/serverless/__init__.py +0 -0
  102. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/serverless/scaleway.py +0 -0
  103. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/storage/__init__.py +0 -0
  104. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/storage/aws.py +0 -0
  105. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/storage/bunny.py +0 -0
  106. {granny_devops-0.12.1 → granny_devops-0.13.0}/granny/storage/hetzner.py +0 -0
  107. {granny_devops-0.12.1 → granny_devops-0.13.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.12.1
3
+ Version: 0.13.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
@@ -301,10 +301,13 @@ granny authentik add-user-to-group user@example.com # defaults to dash_admin
301
301
  granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
302
302
 
303
303
  # Zitadel admin (self-hosted OIDC server — same shape as authentik)
304
- # Point granny at your Zitadel: set ZITADEL_DOMAIN + a machine credential:
304
+ # ZITADEL_DOMAIN (e.g. https://id.example.com) plain config, NOT a secret;
305
+ # set it via env or .env/.deploy.env (granny auto-loads those). Not vaulted.
306
+ # Plus one machine credential:
305
307
  # ZITADEL_API_TOKEN (a PAT, used as a bearer), OR
306
308
  # ZITADEL_SA_KEY / ZITADEL_SA_KEY_FILE (service-account key JSON; JWT-profile auth)
307
- # Vault keys (with the [vault] extra): granny/infra/zitadel-domain, -api-token, -sa-key
309
+ # Secret vault keys (with the [vault] extra): granny/infra/zitadel-api-token, -sa-key
310
+ # Mint a PAT reproducibly: granny zitadel create-pat iam-admin --expiration 2027-06-27T00:00:00Z
308
311
  granny zitadel create-project platform
309
312
  granny zitadel create-oidc-app platform my-app \
310
313
  --redirect-uri https://app.example.com/oidc/callback
@@ -168,10 +168,13 @@ granny authentik add-user-to-group user@example.com # defaults to dash_admin
168
168
  granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
169
169
 
170
170
  # Zitadel admin (self-hosted OIDC server — same shape as authentik)
171
- # Point granny at your Zitadel: set ZITADEL_DOMAIN + a machine credential:
171
+ # ZITADEL_DOMAIN (e.g. https://id.example.com) plain config, NOT a secret;
172
+ # set it via env or .env/.deploy.env (granny auto-loads those). Not vaulted.
173
+ # Plus one machine credential:
172
174
  # ZITADEL_API_TOKEN (a PAT, used as a bearer), OR
173
175
  # ZITADEL_SA_KEY / ZITADEL_SA_KEY_FILE (service-account key JSON; JWT-profile auth)
174
- # Vault keys (with the [vault] extra): granny/infra/zitadel-domain, -api-token, -sa-key
176
+ # Secret vault keys (with the [vault] extra): granny/infra/zitadel-api-token, -sa-key
177
+ # Mint a PAT reproducibly: granny zitadel create-pat iam-admin --expiration 2027-06-27T00:00:00Z
175
178
  granny zitadel create-project platform
176
179
  granny zitadel create-oidc-app platform my-app \
177
180
  --redirect-uri https://app.example.com/oidc/callback
@@ -1,6 +1,6 @@
1
1
  """Granny -- Cloud tools collection for AWS infrastructure and DevOps automation."""
2
2
 
3
- __version__ = "0.12.1"
3
+ __version__ = "0.13.0"
4
4
  __all__ = [
5
5
  "get_secret",
6
6
  "load_secrets_into_env",
@@ -275,19 +275,61 @@ def delete_user_cmd(user: str, dry_run: bool) -> None:
275
275
  click.echo(json.dumps({"user": user, "deleted": True}, indent=2))
276
276
 
277
277
 
278
+ # ── personal access tokens ─────────────────────────────────────────────────
279
+
280
+
281
+ @zitadel.command("create-pat")
282
+ @click.argument("user")
283
+ @click.option(
284
+ "--expiration",
285
+ default=None,
286
+ help="RFC3339 expiry, e.g. 2027-06-27T00:00:00Z (omit for non-expiring).",
287
+ )
288
+ @click.option("--dry-run", is_flag=True, help="Change nothing.")
289
+ def create_pat_cmd(user: str, expiration: str | None, dry_run: bool) -> None:
290
+ """Mint a personal access token for machine USER (userName or id).
291
+
292
+ Prints {tokenId, token}. The token is shown ONCE — capture it and store it
293
+ at vault `granny/infra/zitadel-api-token` (or export ZITADEL_API_TOKEN).
294
+ """
295
+ if dry_run:
296
+ click.echo(f"[DRY RUN] Would create a PAT for {user!r} (expiration={expiration})")
297
+ return
298
+ client = _client()
299
+ out = client.create_pat(_resolve_user(client, user), expiration_date=expiration)
300
+ click.echo(json.dumps({"tokenId": out.get("tokenId"), "token": out.get("token")}, indent=2))
301
+
302
+
303
+ @zitadel.command("revoke-pat")
304
+ @click.argument("user")
305
+ @click.argument("token_id")
306
+ @click.option("--dry-run", is_flag=True, help="Change nothing.")
307
+ def revoke_pat_cmd(user: str, token_id: str, dry_run: bool) -> None:
308
+ """Revoke PAT TOKEN_ID belonging to machine USER (userName or id)."""
309
+ if dry_run:
310
+ click.echo(f"[DRY RUN] Would revoke PAT {token_id} of {user!r}")
311
+ return
312
+ client = _client()
313
+ client.delete_pat(_resolve_user(client, user), token_id)
314
+ click.echo(json.dumps({"user": user, "tokenId": token_id, "revoked": True}, indent=2))
315
+
316
+
278
317
  # ── list ─────────────────────────────────────────────────────────────────
279
318
 
280
319
 
281
320
  @zitadel.command("list")
282
- @click.argument("kind", type=click.Choice(["users", "projects", "apps", "roles"]))
321
+ @click.argument("kind", type=click.Choice(["users", "projects", "apps", "roles", "pats"]))
283
322
  @click.option(
284
323
  "--project", default=None, help="Project (id or name) — required for apps/roles."
285
324
  )
325
+ @click.option(
326
+ "--user", default=None, help="User (userName or id) — required for pats."
327
+ )
286
328
  @click.option(
287
329
  "--json", "as_json", is_flag=True, help="Emit full JSON instead of a table."
288
330
  )
289
- def list_cmd(kind: str, project: str | None, as_json: bool) -> None:
290
- """List Zitadel objects of KIND (users | projects | apps | roles)."""
331
+ def list_cmd(kind: str, project: str | None, user: str | None, as_json: bool) -> None:
332
+ """List Zitadel objects of KIND (users | projects | apps | roles | pats)."""
291
333
  client = _client()
292
334
  if kind == "users":
293
335
  rows = client.list_users()
@@ -295,6 +337,12 @@ def list_cmd(kind: str, project: str | None, as_json: bool) -> None:
295
337
  elif kind == "projects":
296
338
  rows = client.list_projects()
297
339
  cols = ("id", "name", "state")
340
+ elif kind == "pats":
341
+ if not user:
342
+ click.echo("--user is required for `list pats`", err=True)
343
+ sys.exit(1)
344
+ rows = client.list_pats(_resolve_user(client, user))
345
+ cols = ("id", "expirationDate", "creationDate")
298
346
  else:
299
347
  if not project:
300
348
  click.echo(f"--project is required for `list {kind}`", err=True)
@@ -172,9 +172,10 @@ SECRET_MAP: dict[str, str] = {
172
172
  # superuser-issued token (Authentik UI → Directory → Tokens). Falls back
173
173
  # to AK_TOKEN env var in granny.authentik.AuthentikClient.from_environment.
174
174
  "AUTHENTIK_API_TOKEN": "authentik-api-token",
175
- # Zitadel OIDC server (`granny zitadel …`): the IdP base URL, an optional
176
- # PAT, and the service-account key JSON used for JWT-profile machine auth.
177
- "ZITADEL_DOMAIN": "zitadel-domain",
175
+ # Zitadel OIDC server (`granny zitadel …`): an optional PAT and the
176
+ # service-account key JSON used for JWT-profile machine auth. The IdP base
177
+ # URL (ZITADEL_DOMAIN) is plain config, NOT a secret — set it via env /
178
+ # .env / .deploy.env, so it is deliberately not mapped to the vault.
178
179
  "ZITADEL_API_TOKEN": "zitadel-api-token",
179
180
  "ZITADEL_SA_KEY": "zitadel-sa-key",
180
181
  # Elasticsearch / Kibana security API credentials for `granny elk …`.
@@ -109,7 +109,9 @@ class ZitadelClient:
109
109
  def from_environment(cls) -> "ZitadelClient":
110
110
  """Build a client from env vars + granny's secret pipeline.
111
111
 
112
- Base URL: ``ZITADEL_DOMAIN`` (e.g. ``https://id.pseekoo.io``).
112
+ Base URL: ``ZITADEL_DOMAIN`` (e.g. ``https://id.pseekoo.io``) — this is
113
+ plain config, NOT a secret, so set it via env / .env / .deploy.env; it
114
+ is deliberately not vault-resolved.
113
115
 
114
116
  Credential resolution (first match wins):
115
117
 
@@ -121,10 +123,15 @@ class ZitadelClient:
121
123
  """
122
124
  from granny.credentials import get_secret
123
125
 
124
- base_url = os.environ.get("ZITADEL_DOMAIN") or get_secret("ZITADEL_DOMAIN")
126
+ # The IdP base URL is config, not a secret — env / .env / .deploy.env
127
+ # only (deliberately not vault-resolved).
128
+ base_url = os.environ.get("ZITADEL_DOMAIN")
125
129
  if not base_url:
126
130
  raise ZitadelError(
127
- 0, "ZITADEL_DOMAIN is not set (e.g. https://id.pseekoo.io)."
131
+ 0,
132
+ "ZITADEL_DOMAIN is not set. It is config, not a secret — set it "
133
+ "via env or .env/.deploy.env, e.g. "
134
+ "ZITADEL_DOMAIN=https://id.pseekoo.io",
128
135
  )
129
136
 
130
137
  pat = os.environ.get("ZITADEL_API_TOKEN") or get_secret("ZITADEL_API_TOKEN")
@@ -431,6 +438,28 @@ class ZitadelClient:
431
438
  """Delete a user by id."""
432
439
  self.request("DELETE", f"/management/v1/users/{user_id}")
433
440
 
441
+ # ── Personal access tokens (machine-user bearer tokens) ───────────────
442
+ # A PAT is a long-lived bearer for a machine user — what other projects
443
+ # store at vault ``granny/infra/zitadel-api-token`` so granny authenticates
444
+ # without the service-account key. The token value is returned ONCE.
445
+
446
+ def list_pats(self, user_id: str) -> list[dict[str, Any]]:
447
+ data = self.request(
448
+ "POST", f"/management/v1/users/{user_id}/pats/_search", body={"queries": []}
449
+ )
450
+ return (data or {}).get("result", []) or []
451
+
452
+ def create_pat(self, user_id: str, *, expiration_date: str | None = None) -> dict[str, Any]:
453
+ """Create a PAT for ``user_id``. Returns {tokenId, token} (token shown once)."""
454
+ body: dict[str, Any] = {}
455
+ if expiration_date:
456
+ body["expirationDate"] = expiration_date
457
+ return self.request("POST", f"/management/v1/users/{user_id}/pats", body=body)
458
+
459
+ def delete_pat(self, user_id: str, token_id: str) -> None:
460
+ """Revoke a PAT by its token id."""
461
+ self.request("DELETE", f"/management/v1/users/{user_id}/pats/{token_id}")
462
+
434
463
  # ── Project roles + user grants (Zitadel's analog to Authentik groups) ─
435
464
  # Zitadel has no flat "groups": authorization is modeled as project ROLES
436
465
  # (defined on a project) plus user GRANTS (a user granted a set of role
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "granny-devops"
3
- version = "0.12.1"
3
+ version = "0.13.0"
4
4
  description = "Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
File without changes