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.
- {granny_devops-0.7.0 → granny_devops-0.9.0}/PKG-INFO +13 -1
- {granny_devops-0.7.0 → granny_devops-0.9.0}/README.md +12 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/__init__.py +1 -1
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/analyze/__init__.py +2 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/analyze/gpus.py +110 -1
- granny_devops-0.9.0/granny/authentik/__init__.py +33 -0
- granny_devops-0.9.0/granny/authentik/client.py +258 -0
- granny_devops-0.9.0/granny/authentik/provision.py +235 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cli/analyze.py +111 -8
- granny_devops-0.9.0/granny/cli/authentik.py +229 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cli/main.py +5 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/credentials/secrets.py +4 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/pyproject.toml +127 -127
- {granny_devops-0.7.0 → granny_devops-0.9.0}/.gitignore +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/LICENSE +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/analyze/costs.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/analyze/credits.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/analyze/lambdas.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/analyze/vpcs.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cdn/__init__.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cdn/bunny.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cli/__init__.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cli/cdn.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cli/cloudflare.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cli/create.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cli/credentials.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cli/dns.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cli/docker.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cli/edge.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cli/email.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cli/serverless.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cli/storage.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cloudflare/__init__.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cloudflare/d1.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cloudflare/r2.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/cloudflare/workers.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/__init__.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/auto_certificate.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/cloudfront-security-headers.js +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/manage-dns.sh +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/manage_mailjet_contacts.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/registrars.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/setup_aws_cloudfront.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/setup_bunny_edge_script.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/setup_bunny_storage.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/setup_cognito_identity_pool.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/setup_hetzner_bunny.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/setup_mailjet_dns.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/setup_private_cdn.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/setup_s3_website.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/setup_scaleway_container.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/setup_scaleway_faas.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/setup_workmail.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/create/www-redirect-function.js +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/credentials/__init__.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/dns/__init__.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/dns/base.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/dns/bunny.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/dns/cloudflare.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/dns/cloudns.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/dns/desec.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/dns/factory.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/dns/hetzner.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/dns/inwx.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/dns/manual.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/dns/records.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/docker/__init__.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/docker/build_base.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/edge/__init__.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/edge/bunny.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/email/__init__.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/email/mailjet.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/email/mailjet_contacts.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/email/ses_forwarding.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/email/workmail.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/report.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/serverless/__init__.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/serverless/scaleway.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/storage/__init__.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/storage/aws.py +0 -0
- {granny_devops-0.7.0 → granny_devops-0.9.0}/granny/storage/bunny.py +0 -0
- {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.
|
|
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
|
|
|
@@ -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
|
+
)
|