granny-devops 0.7.0__tar.gz → 0.9.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 (82) hide show
  1. {granny_devops-0.7.0 → granny_devops-0.9.0}/PKG-INFO +13 -1
  2. {granny_devops-0.7.0 → granny_devops-0.9.0}/README.md +12 -0
  3. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/__init__.py +1 -1
  4. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/analyze/__init__.py +2 -0
  5. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/analyze/gpus.py +110 -1
  6. granny_devops-0.9.0/granny/authentik/__init__.py +33 -0
  7. granny_devops-0.9.0/granny/authentik/client.py +258 -0
  8. granny_devops-0.9.0/granny/authentik/provision.py +235 -0
  9. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cli/analyze.py +111 -8
  10. granny_devops-0.9.0/granny/cli/authentik.py +229 -0
  11. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cli/main.py +5 -0
  12. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/credentials/secrets.py +4 -0
  13. {granny_devops-0.7.0 → granny_devops-0.9.0}/pyproject.toml +127 -127
  14. {granny_devops-0.7.0 → granny_devops-0.9.0}/.gitignore +0 -0
  15. {granny_devops-0.7.0 → granny_devops-0.9.0}/LICENSE +0 -0
  16. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/analyze/costs.py +0 -0
  17. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/analyze/credits.py +0 -0
  18. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/analyze/lambdas.py +0 -0
  19. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/analyze/vpcs.py +0 -0
  20. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cdn/__init__.py +0 -0
  21. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cdn/bunny.py +0 -0
  22. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cli/__init__.py +0 -0
  23. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cli/cdn.py +0 -0
  24. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cli/cloudflare.py +0 -0
  25. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cli/create.py +0 -0
  26. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cli/credentials.py +0 -0
  27. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cli/dns.py +0 -0
  28. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cli/docker.py +0 -0
  29. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cli/edge.py +0 -0
  30. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cli/email.py +0 -0
  31. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cli/serverless.py +0 -0
  32. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cli/storage.py +0 -0
  33. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cloudflare/__init__.py +0 -0
  34. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cloudflare/d1.py +0 -0
  35. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cloudflare/r2.py +0 -0
  36. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cloudflare/workers.py +0 -0
  37. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/__init__.py +0 -0
  38. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/auto_certificate.py +0 -0
  39. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/cloudfront-security-headers.js +0 -0
  40. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/manage-dns.sh +0 -0
  41. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/manage_mailjet_contacts.py +0 -0
  42. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/registrars.py +0 -0
  43. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/setup_aws_cloudfront.py +0 -0
  44. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/setup_bunny_edge_script.py +0 -0
  45. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/setup_bunny_storage.py +0 -0
  46. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/setup_cognito_identity_pool.py +0 -0
  47. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/setup_hetzner_bunny.py +0 -0
  48. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/setup_mailjet_dns.py +0 -0
  49. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/setup_private_cdn.py +0 -0
  50. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/setup_s3_website.py +0 -0
  51. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/setup_scaleway_container.py +0 -0
  52. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/setup_scaleway_faas.py +0 -0
  53. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/setup_workmail.py +0 -0
  54. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/www-redirect-function.js +0 -0
  55. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/credentials/__init__.py +0 -0
  56. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/dns/__init__.py +0 -0
  57. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/dns/base.py +0 -0
  58. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/dns/bunny.py +0 -0
  59. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/dns/cloudflare.py +0 -0
  60. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/dns/cloudns.py +0 -0
  61. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/dns/desec.py +0 -0
  62. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/dns/factory.py +0 -0
  63. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/dns/hetzner.py +0 -0
  64. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/dns/inwx.py +0 -0
  65. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/dns/manual.py +0 -0
  66. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/dns/records.py +0 -0
  67. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/docker/__init__.py +0 -0
  68. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/docker/build_base.py +0 -0
  69. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/edge/__init__.py +0 -0
  70. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/edge/bunny.py +0 -0
  71. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/email/__init__.py +0 -0
  72. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/email/mailjet.py +0 -0
  73. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/email/mailjet_contacts.py +0 -0
  74. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/email/ses_forwarding.py +0 -0
  75. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/email/workmail.py +0 -0
  76. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/report.py +0 -0
  77. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/serverless/__init__.py +0 -0
  78. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/serverless/scaleway.py +0 -0
  79. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/storage/__init__.py +0 -0
  80. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/storage/aws.py +0 -0
  81. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/storage/bunny.py +0 -0
  82. {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/storage/hetzner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: granny-devops
3
- Version: 0.7.0
3
+ Version: 0.9.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
@@ -263,6 +263,10 @@ granny analyze gpus --provider aws --profile prod --profile dev
263
263
  granny analyze credits # available balances
264
264
  granny analyze costs # MTD + month-end forecast
265
265
 
266
+ # AWS Capacity Blocks for ML -- discover available H100/H200/A100 blocks
267
+ granny analyze capacity-blocks --instance-type p5.48xlarge --hours 24
268
+ granny analyze capacity-blocks --instance-type p5e.48xlarge --count 2 --hours 168
269
+
266
270
  # Cloudflare account resources (Workers, D1, R2, KV)
267
271
  granny cloudflare d1 create my-app
268
272
  granny cloudflare r2 create my-app-media
@@ -282,6 +286,13 @@ granny email workmail create-user example.com --email user@example.com
282
286
  granny create s3-website example.com --help
283
287
  granny create scaleway-container --name my-app --port 3000
284
288
  granny create mailjet-dns example.com
289
+
290
+ # Authentik admin (provider + application + group plumbing)
291
+ granny authentik provision-stoz3n-dash development # idempotent per-stage setup
292
+ granny authentik list providers
293
+ granny authentik rotate-secret stoz3n-dash-staging
294
+ granny authentik add-user-to-group martin # defaults to dash_admins
295
+ granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
285
296
  ```
286
297
 
287
298
  ## Capability matrix
@@ -298,6 +309,7 @@ granny create mailjet-dns example.com
298
309
  | AWS inventory | VPCs, Lambdas |
299
310
  | Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
300
311
  | SSL automation | Bunny, Cloudflare, ACM |
312
+ | SSO / IdP | Authentik (provider + application + group provisioning, per-stage stoz3n-dash workflow) |
301
313
 
302
314
  ## As a library
303
315
 
@@ -131,6 +131,10 @@ granny analyze gpus --provider aws --profile prod --profile dev
131
131
  granny analyze credits # available balances
132
132
  granny analyze costs # MTD + month-end forecast
133
133
 
134
+ # AWS Capacity Blocks for ML -- discover available H100/H200/A100 blocks
135
+ granny analyze capacity-blocks --instance-type p5.48xlarge --hours 24
136
+ granny analyze capacity-blocks --instance-type p5e.48xlarge --count 2 --hours 168
137
+
134
138
  # Cloudflare account resources (Workers, D1, R2, KV)
135
139
  granny cloudflare d1 create my-app
136
140
  granny cloudflare r2 create my-app-media
@@ -150,6 +154,13 @@ granny email workmail create-user example.com --email user@example.com
150
154
  granny create s3-website example.com --help
151
155
  granny create scaleway-container --name my-app --port 3000
152
156
  granny create mailjet-dns example.com
157
+
158
+ # Authentik admin (provider + application + group plumbing)
159
+ granny authentik provision-stoz3n-dash development # idempotent per-stage setup
160
+ granny authentik list providers
161
+ granny authentik rotate-secret stoz3n-dash-staging
162
+ granny authentik add-user-to-group martin # defaults to dash_admins
163
+ granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
153
164
  ```
154
165
 
155
166
  ## Capability matrix
@@ -166,6 +177,7 @@ granny create mailjet-dns example.com
166
177
  | AWS inventory | VPCs, Lambdas |
167
178
  | Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
168
179
  | SSL automation | Bunny, Cloudflare, ACM |
180
+ | SSO / IdP | Authentik (provider + application + group provisioning, per-stage stoz3n-dash workflow) |
169
181
 
170
182
  ## As a library
171
183
 
@@ -1,6 +1,6 @@
1
1
  """Granny -- Cloud tools collection for AWS infrastructure and DevOps automation."""
2
2
 
3
- __version__ = "0.7.0"
3
+ __version__ = "0.8.0"
4
4
  __all__ = [
5
5
  "get_secret",
6
6
  "load_secrets_into_env",
@@ -17,9 +17,11 @@ def __getattr__(name: str):
17
17
  if name in {
18
18
  "GpuInstance",
19
19
  "GpuReservation",
20
+ "CapacityBlockOffering",
20
21
  "aws_profiles",
21
22
  "list_aws_gpu_instances",
22
23
  "list_aws_gpu_reservations",
24
+ "list_aws_capacity_block_offerings",
23
25
  "gcp_projects",
24
26
  "list_gcp_gpu_instances",
25
27
  "list_gcp_gpu_reservations",
@@ -16,7 +16,7 @@ from __future__ import annotations
16
16
  import logging
17
17
  import re
18
18
  from dataclasses import dataclass, field
19
- from datetime import datetime, timezone
19
+ from datetime import datetime, timedelta, timezone
20
20
 
21
21
  logger = logging.getLogger(__name__)
22
22
 
@@ -148,6 +148,24 @@ class GpuReservation:
148
148
  commitment_term: str | None
149
149
 
150
150
 
151
+ @dataclass
152
+ class CapacityBlockOffering:
153
+ """An available EC2 Capacity Block for ML offering (not yet purchased)."""
154
+
155
+ account: str
156
+ region: str
157
+ zone: str
158
+ offering_id: str
159
+ instance_type: str
160
+ instance_count: int
161
+ duration_hours: int
162
+ start_time: str
163
+ end_time: str
164
+ upfront_fee: float
165
+ currency: str
166
+ tenancy: str
167
+
168
+
151
169
  # ---------------------------------------------------------------------------
152
170
  # AWS
153
171
  # ---------------------------------------------------------------------------
@@ -315,6 +333,97 @@ def list_aws_gpu_reservations(
315
333
  return results
316
334
 
317
335
 
336
+ # Regions where Capacity Blocks for ML are generally available.
337
+ # describe-capacity-block-offerings only works here; other regions return
338
+ # UnsupportedOperation. Update as AWS rolls out to new regions.
339
+ _AWS_CAPACITY_BLOCK_REGIONS: tuple[str, ...] = (
340
+ "us-east-1",
341
+ "us-east-2",
342
+ "us-west-2",
343
+ "eu-west-1",
344
+ "eu-west-2",
345
+ "eu-north-1",
346
+ "eu-central-1",
347
+ "ap-northeast-1",
348
+ "ap-southeast-2",
349
+ )
350
+
351
+
352
+ def list_aws_capacity_block_offerings(
353
+ instance_type: str,
354
+ instance_count: int = 1,
355
+ duration_hours: int = 24,
356
+ start_window_days: int = 30,
357
+ profiles: list[str] | None = None,
358
+ regions: list[str] | None = None,
359
+ ) -> list[CapacityBlockOffering]:
360
+ """Find available EC2 Capacity Block offerings for ML GPU instances.
361
+
362
+ Args:
363
+ instance_type: GPU instance type, e.g. ``p5.48xlarge`` or ``p5e.48xlarge``.
364
+ instance_count: How many instances the block must hold.
365
+ duration_hours: Block length in hours (AWS supports 1, 6, 12, 24, 48,
366
+ 72, 168, 336, 504, 672 ... — pass what you need).
367
+ start_window_days: Search for blocks starting between now and this many
368
+ days out.
369
+ profiles: AWS profiles to query (default: all configured profiles).
370
+ regions: Regions to query (default: the known Capacity-Block-enabled
371
+ regions; pass an explicit list to override).
372
+
373
+ Returns:
374
+ Offerings sorted cheapest-first.
375
+ """
376
+ import boto3
377
+ from botocore.exceptions import BotoCoreError, ClientError
378
+
379
+ profiles = profiles or aws_profiles()
380
+ regions = regions or list(_AWS_CAPACITY_BLOCK_REGIONS)
381
+ now = datetime.now(timezone.utc)
382
+ end = now + timedelta(days=start_window_days)
383
+
384
+ out: list[CapacityBlockOffering] = []
385
+ for profile in profiles:
386
+ for region in regions:
387
+ try:
388
+ session = boto3.Session(profile_name=profile if profile != "default" else None)
389
+ ec2 = session.client("ec2", region_name=region)
390
+ paginator = ec2.get_paginator("describe_capacity_block_offerings")
391
+ for page in paginator.paginate(
392
+ InstanceType=instance_type,
393
+ InstanceCount=instance_count,
394
+ CapacityDurationHours=duration_hours,
395
+ StartDateRange=now,
396
+ EndDateRange=end,
397
+ ):
398
+ for o in page.get("CapacityBlockOfferings", []):
399
+ try:
400
+ fee = float(o.get("UpfrontFee", 0) or 0)
401
+ except (TypeError, ValueError):
402
+ fee = 0.0
403
+ out.append(
404
+ CapacityBlockOffering(
405
+ account=profile,
406
+ region=region,
407
+ zone=o.get("AvailabilityZone", ""),
408
+ offering_id=o.get("CapacityBlockOfferingId", ""),
409
+ instance_type=o.get("InstanceType", instance_type),
410
+ instance_count=o.get("InstanceCount", instance_count),
411
+ duration_hours=o.get(
412
+ "CapacityBlockDurationHours", duration_hours
413
+ ),
414
+ start_time=_iso(o.get("StartDate")) or "",
415
+ end_time=_iso(o.get("EndDate")) or "",
416
+ upfront_fee=fee,
417
+ currency=o.get("CurrencyCode", "USD"),
418
+ tenancy=o.get("Tenancy", "default"),
419
+ )
420
+ )
421
+ except (BotoCoreError, ClientError) as e:
422
+ logger.debug("AWS %s/%s capacity-block offerings: %s", profile, region, e)
423
+ out.sort(key=lambda o: (o.upfront_fee, o.start_time))
424
+ return out
425
+
426
+
318
427
  # ---------------------------------------------------------------------------
319
428
  # GCP
320
429
  # ---------------------------------------------------------------------------
@@ -0,0 +1,33 @@
1
+ """Authentik management — REST API wrapper + per-stage stoz3n-dash provisioning.
2
+
3
+ Powered by the ``AUTHENTIK_API_TOKEN`` secret (resolves via env var or
4
+ Vaultwarden under ``granny/infra/authentik-api-token``) and the
5
+ non-secret ``AUTHENTIK_URL`` (default ``https://auth.pseekoo.io``).
6
+
7
+ Public API::
8
+
9
+ from granny.authentik import AuthentikClient, STOZ3N_DASH_STAGES, provision_stoz3n_dash
10
+
11
+ client = AuthentikClient.from_environment()
12
+ result = provision_stoz3n_dash(client, stage="development")
13
+ print(result.client_id, result.client_secret)
14
+ """
15
+
16
+ from granny.authentik.client import AuthentikClient, AuthentikError
17
+ from granny.authentik.provision import (
18
+ ADMIN_GROUP_NAME,
19
+ OIDC_SCOPES,
20
+ STOZ3N_DASH_STAGES,
21
+ ProvisionResult,
22
+ provision_stoz3n_dash,
23
+ )
24
+
25
+ __all__ = [
26
+ "ADMIN_GROUP_NAME",
27
+ "AuthentikClient",
28
+ "AuthentikError",
29
+ "OIDC_SCOPES",
30
+ "ProvisionResult",
31
+ "STOZ3N_DASH_STAGES",
32
+ "provision_stoz3n_dash",
33
+ ]
@@ -0,0 +1,258 @@
1
+ """Thin wrapper around the Authentik REST API.
2
+
3
+ Uses ``requests`` (already a granny core dependency) so this module does
4
+ not require any optional extras.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import Any
11
+
12
+ import requests
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class AuthentikError(RuntimeError):
18
+ """Raised on non-2xx responses from the Authentik API."""
19
+
20
+ def __init__(self, status: int, body: str) -> None:
21
+ super().__init__(f"HTTP {status}: {body[:400]}")
22
+ self.status = status
23
+ self.body = body
24
+
25
+
26
+ class AuthentikClient:
27
+ """Authenticated client for the Authentik v3 REST API.
28
+
29
+ Construct directly when you already have the credentials, or use
30
+ :meth:`from_environment` to resolve via granny's normal env/vault
31
+ pipeline.
32
+ """
33
+
34
+ def __init__(self, base_url: str, token: str, *, timeout: float = 30.0) -> None:
35
+ self.base_url = base_url.rstrip("/")
36
+ self._token = token
37
+ self._timeout = timeout
38
+ self._session = requests.Session()
39
+ self._session.headers.update(
40
+ {
41
+ "Authorization": f"Bearer {token}",
42
+ "Accept": "application/json",
43
+ }
44
+ )
45
+
46
+ @classmethod
47
+ def from_environment(cls) -> "AuthentikClient":
48
+ """Build a client from env vars + Vaultwarden fallback.
49
+
50
+ Resolution order for the token (explicit env always wins so a
51
+ rotated value in ``.env`` is picked up immediately without having
52
+ to invalidate the vault session cache):
53
+
54
+ 1. ``AK_TOKEN`` (env / .env / .deploy.env) — short alias matching
55
+ the standalone Authentik scripts.
56
+ 2. ``AUTHENTIK_API_TOKEN`` (env / .env / .deploy.env or
57
+ Vaultwarden at ``granny/infra/authentik-api-token``, via the
58
+ normal :func:`granny.credentials.get_secret` chain).
59
+
60
+ For the base URL, ``AUTHENTIK_URL`` wins; default
61
+ ``https://auth.pseekoo.io``.
62
+ """
63
+ import os
64
+
65
+ from granny.credentials import get_secret
66
+
67
+ token = os.environ.get("AK_TOKEN") or get_secret("AUTHENTIK_API_TOKEN")
68
+ if not token:
69
+ raise AuthentikError(
70
+ 0,
71
+ "AUTHENTIK_API_TOKEN (or AK_TOKEN) is not set. "
72
+ "Either export it, add it to .env/.deploy.env, or push the "
73
+ "token to Vaultwarden under granny/infra/authentik-api-token "
74
+ "and install the [vault] extra.",
75
+ )
76
+ base_url = os.environ.get("AUTHENTIK_URL") or "https://auth.pseekoo.io"
77
+ return cls(base_url=base_url, token=token)
78
+
79
+ # ── HTTP plumbing ────────────────────────────────────────────────────
80
+
81
+ def request(
82
+ self,
83
+ method: str,
84
+ path: str,
85
+ *,
86
+ body: Any = None,
87
+ params: dict[str, Any] | None = None,
88
+ ) -> Any:
89
+ """Execute one API request and return the decoded JSON body.
90
+
91
+ Returns ``None`` on 204 / empty bodies.
92
+ """
93
+ if not path.startswith("/"):
94
+ path = "/" + path
95
+ url = self.base_url + path
96
+ clean_params = {k: v for k, v in (params or {}).items() if v is not None}
97
+ resp = self._session.request(
98
+ method=method.upper(),
99
+ url=url,
100
+ params=clean_params or None,
101
+ json=body,
102
+ timeout=self._timeout,
103
+ )
104
+ if resp.status_code >= 400:
105
+ raise AuthentikError(resp.status_code, resp.text)
106
+ if not resp.content:
107
+ return None
108
+ try:
109
+ return resp.json()
110
+ except ValueError:
111
+ return resp.text
112
+
113
+ def paginate(self, path: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
114
+ """Walk an Authentik list endpoint, returning all pages concatenated."""
115
+ out: list[dict[str, Any]] = []
116
+ page = 1
117
+ while True:
118
+ p = dict(params or {})
119
+ p["page"] = page
120
+ data = self.request("GET", path, params=p) or {}
121
+ out.extend(data.get("results", []))
122
+ if not data.get("pagination", {}).get("next"):
123
+ break
124
+ page += 1
125
+ return out
126
+
127
+ # ── High-level lookups ───────────────────────────────────────────────
128
+
129
+ def find_flow_pk(self, slug: str) -> str:
130
+ results = self.request("GET", "/api/v3/flows/instances/", params={"slug": slug}).get(
131
+ "results", []
132
+ )
133
+ for f in results:
134
+ if f["slug"] == slug:
135
+ return f["pk"]
136
+ raise AuthentikError(404, f"No flow with slug={slug!r}")
137
+
138
+ def find_signing_key_pk(self, *, prefer_self_signed: bool = True) -> str:
139
+ """Return the pk of a key pair that can sign tokens."""
140
+ keys = self.paginate("/api/v3/crypto/certificatekeypairs/")
141
+ candidates = [k for k in keys if k.get("private_key_available")]
142
+ if not candidates:
143
+ raise AuthentikError(404, "No usable signing key pairs found")
144
+ if prefer_self_signed:
145
+ for k in candidates:
146
+ if "self-signed" in k["name"].lower():
147
+ return k["pk"]
148
+ return candidates[0]["pk"]
149
+
150
+ def find_scope_mappings(self, scope_names: tuple[str, ...] | list[str]) -> list[str]:
151
+ """Resolve scope-property-mapping pks by ``scope_name``."""
152
+ mappings = self.paginate("/api/v3/propertymappings/provider/scope/")
153
+ by_scope = {m["scope_name"]: m["pk"] for m in mappings}
154
+ try:
155
+ return [by_scope[scope] for scope in scope_names]
156
+ except KeyError as exc:
157
+ raise AuthentikError(404, f"Scope mapping for {exc.args[0]!r} not found") from exc
158
+
159
+ def find_group_by_name(self, name: str) -> dict[str, Any] | None:
160
+ for g in self.request("GET", "/api/v3/core/groups/", params={"name": name}).get(
161
+ "results", []
162
+ ):
163
+ if g["name"] == name:
164
+ return g
165
+ return None
166
+
167
+ def find_user_by_username(self, username: str) -> dict[str, Any] | None:
168
+ for u in self.request("GET", "/api/v3/core/users/", params={"username": username}).get(
169
+ "results", []
170
+ ):
171
+ if u["username"] == username:
172
+ return u
173
+ return None
174
+
175
+ def find_provider_by_name(self, name: str) -> dict[str, Any] | None:
176
+ for p in self.request("GET", "/api/v3/providers/oauth2/", params={"name": name}).get(
177
+ "results", []
178
+ ):
179
+ if p["name"] == name:
180
+ return p
181
+ return None
182
+
183
+ def find_application_by_slug(self, slug: str) -> dict[str, Any] | None:
184
+ """Look up an application by slug.
185
+
186
+ Uses a direct GET on the slug-keyed detail endpoint instead of the
187
+ list endpoint. Authentik's application list quietly hides records
188
+ whose policy bindings restrict ``view`` permissions, even for
189
+ superusers — direct GET by slug bypasses that filter.
190
+ """
191
+ try:
192
+ return self.request("GET", f"/api/v3/core/applications/{slug}/")
193
+ except AuthentikError as exc:
194
+ if exc.status == 404:
195
+ return None
196
+ raise
197
+
198
+ def find_provider_by_pk(self, pk: int | str) -> dict[str, Any] | None:
199
+ """Look up an OAuth2 provider by primary key (integer)."""
200
+ try:
201
+ return self.request("GET", f"/api/v3/providers/oauth2/{pk}/")
202
+ except AuthentikError as exc:
203
+ if exc.status == 404:
204
+ return None
205
+ raise
206
+
207
+ # ── Mutating helpers ─────────────────────────────────────────────────
208
+
209
+ def ensure_group(self, name: str) -> dict[str, Any]:
210
+ """Create a group if missing; return the (existing or new) group dict."""
211
+ existing = self.find_group_by_name(name)
212
+ if existing:
213
+ return existing
214
+ return self.request("POST", "/api/v3/core/groups/", body={"name": name})
215
+
216
+ def add_user_to_group(self, username: str, group_name: str) -> None:
217
+ user = self.find_user_by_username(username)
218
+ if not user:
219
+ raise AuthentikError(404, f"User {username!r} not found")
220
+ group = self.find_group_by_name(group_name)
221
+ if not group:
222
+ raise AuthentikError(
223
+ 404, f"Group {group_name!r} not found — create it with ensure_group first"
224
+ )
225
+ self.request(
226
+ "POST",
227
+ f"/api/v3/core/groups/{group['pk']}/add_user/",
228
+ body={"pk": user["pk"]},
229
+ )
230
+
231
+ def remove_user_from_group(self, username: str, group_name: str) -> None:
232
+ user = self.find_user_by_username(username)
233
+ if not user:
234
+ raise AuthentikError(404, f"User {username!r} not found")
235
+ group = self.find_group_by_name(group_name)
236
+ if not group:
237
+ raise AuthentikError(404, f"Group {group_name!r} not found")
238
+ self.request(
239
+ "POST",
240
+ f"/api/v3/core/groups/{group['pk']}/remove_user/",
241
+ body={"pk": user["pk"]},
242
+ )
243
+
244
+ def rotate_client_secret(self, provider_name: str) -> dict[str, Any]:
245
+ """Rotate ``client_secret`` on an existing OAuth2 provider.
246
+
247
+ Authentik regenerates the secret server-side when we PATCH it to
248
+ an empty string. The returned dict carries the new value (when
249
+ the API echoes it back — varies by version).
250
+ """
251
+ provider = self.find_provider_by_name(provider_name)
252
+ if not provider:
253
+ raise AuthentikError(404, f"Provider {provider_name!r} not found")
254
+ return self.request(
255
+ "PATCH",
256
+ f"/api/v3/providers/oauth2/{provider['pk']}/",
257
+ body={"client_secret": ""},
258
+ )