granny-devops 0.8.0__tar.gz → 0.9.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.
- {granny_devops-0.8.0 → granny_devops-0.9.1}/PKG-INFO +8 -1
- {granny_devops-0.8.0 → granny_devops-0.9.1}/README.md +7 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/__init__.py +1 -1
- granny_devops-0.9.1/granny/authentik/__init__.py +8 -0
- granny_devops-0.9.1/granny/authentik/client.py +337 -0
- granny_devops-0.9.1/granny/cli/authentik.py +263 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cli/main.py +5 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cli/serverless.py +3 -3
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/credentials/secrets.py +4 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/pyproject.toml +127 -127
- {granny_devops-0.8.0 → granny_devops-0.9.1}/.gitignore +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/LICENSE +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/analyze/__init__.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/analyze/costs.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/analyze/credits.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/analyze/gpus.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/analyze/lambdas.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/analyze/vpcs.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cdn/__init__.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cdn/bunny.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cli/__init__.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cli/analyze.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cli/cdn.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cli/cloudflare.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cli/create.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cli/credentials.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cli/dns.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cli/docker.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cli/edge.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cli/email.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cli/storage.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cloudflare/__init__.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cloudflare/d1.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cloudflare/r2.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cloudflare/workers.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/__init__.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/auto_certificate.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/cloudfront-security-headers.js +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/manage-dns.sh +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/manage_mailjet_contacts.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/registrars.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/setup_aws_cloudfront.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/setup_bunny_edge_script.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/setup_bunny_storage.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/setup_cognito_identity_pool.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/setup_hetzner_bunny.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/setup_mailjet_dns.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/setup_private_cdn.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/setup_s3_website.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/setup_scaleway_container.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/setup_scaleway_faas.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/setup_workmail.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/www-redirect-function.js +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/credentials/__init__.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/dns/__init__.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/dns/base.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/dns/bunny.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/dns/cloudflare.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/dns/cloudns.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/dns/desec.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/dns/factory.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/dns/hetzner.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/dns/inwx.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/dns/manual.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/dns/records.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/docker/__init__.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/docker/build_base.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/edge/__init__.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/edge/bunny.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/email/__init__.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/email/mailjet.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/email/mailjet_contacts.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/email/ses_forwarding.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/email/workmail.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/report.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/serverless/__init__.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/serverless/scaleway.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/storage/__init__.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/storage/aws.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/storage/bunny.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.1}/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.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
|
|
@@ -286,6 +286,12 @@ granny email workmail create-user example.com --email user@example.com
|
|
|
286
286
|
granny create s3-website example.com --help
|
|
287
287
|
granny create scaleway-container --name my-app --port 3000
|
|
288
288
|
granny create mailjet-dns example.com
|
|
289
|
+
|
|
290
|
+
# Authentik admin (provider + application + group plumbing)
|
|
291
|
+
granny authentik list providers
|
|
292
|
+
granny authentik rotate-secret my-oauth-provider
|
|
293
|
+
granny authentik add-user-to-group user@example.com # defaults to dash_admins
|
|
294
|
+
granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
|
|
289
295
|
```
|
|
290
296
|
|
|
291
297
|
## Capability matrix
|
|
@@ -302,6 +308,7 @@ granny create mailjet-dns example.com
|
|
|
302
308
|
| AWS inventory | VPCs, Lambdas |
|
|
303
309
|
| Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
|
|
304
310
|
| SSL automation | Bunny, Cloudflare, ACM |
|
|
311
|
+
| SSO / IdP | Authentik (provider, application, group, and user operations) |
|
|
305
312
|
|
|
306
313
|
## As a library
|
|
307
314
|
|
|
@@ -154,6 +154,12 @@ granny email workmail create-user example.com --email user@example.com
|
|
|
154
154
|
granny create s3-website example.com --help
|
|
155
155
|
granny create scaleway-container --name my-app --port 3000
|
|
156
156
|
granny create mailjet-dns example.com
|
|
157
|
+
|
|
158
|
+
# Authentik admin (provider + application + group plumbing)
|
|
159
|
+
granny authentik list providers
|
|
160
|
+
granny authentik rotate-secret my-oauth-provider
|
|
161
|
+
granny authentik add-user-to-group user@example.com # defaults to dash_admins
|
|
162
|
+
granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
|
|
157
163
|
```
|
|
158
164
|
|
|
159
165
|
## Capability matrix
|
|
@@ -170,6 +176,7 @@ granny create mailjet-dns example.com
|
|
|
170
176
|
| AWS inventory | VPCs, Lambdas |
|
|
171
177
|
| Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
|
|
172
178
|
| SSL automation | Bunny, Cloudflare, ACM |
|
|
179
|
+
| SSO / IdP | Authentik (provider, application, group, and user operations) |
|
|
173
180
|
|
|
174
181
|
## As a library
|
|
175
182
|
|
|
@@ -0,0 +1,337 @@
|
|
|
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
|
+
)
|
|
259
|
+
|
|
260
|
+
def create_user(
|
|
261
|
+
self,
|
|
262
|
+
username: str,
|
|
263
|
+
*,
|
|
264
|
+
name: str | None = None,
|
|
265
|
+
email: str | None = None,
|
|
266
|
+
path: str = "users",
|
|
267
|
+
user_type: str = "internal",
|
|
268
|
+
is_active: bool = True,
|
|
269
|
+
attributes: dict[str, Any] | None = None,
|
|
270
|
+
) -> dict[str, Any]:
|
|
271
|
+
"""Create an internal Authentik user (idempotent on ``username``).
|
|
272
|
+
|
|
273
|
+
If a user with the same ``username`` already exists it is returned
|
|
274
|
+
unmodified — call ``request("PATCH", …)`` separately if you need to
|
|
275
|
+
update fields. The default ``path="users"`` puts the user in the
|
|
276
|
+
regular Authentik directory (not under any LDAP source path), so
|
|
277
|
+
the user can authenticate against an internal password rather than
|
|
278
|
+
being bound to Kanidm.
|
|
279
|
+
"""
|
|
280
|
+
existing = self.find_user_by_username(username)
|
|
281
|
+
if existing:
|
|
282
|
+
return existing
|
|
283
|
+
body: dict[str, Any] = {
|
|
284
|
+
"username": username,
|
|
285
|
+
"name": name or username,
|
|
286
|
+
"is_active": is_active,
|
|
287
|
+
"type": user_type,
|
|
288
|
+
"path": path,
|
|
289
|
+
"groups": [],
|
|
290
|
+
}
|
|
291
|
+
if email is not None:
|
|
292
|
+
body["email"] = email
|
|
293
|
+
if attributes:
|
|
294
|
+
body["attributes"] = attributes
|
|
295
|
+
return self.request("POST", "/api/v3/core/users/", body=body)
|
|
296
|
+
|
|
297
|
+
def set_user_password(self, username: str, password: str) -> None:
|
|
298
|
+
"""Set a user's password directly (admin reset, no email)."""
|
|
299
|
+
user = self.find_user_by_username(username)
|
|
300
|
+
if not user:
|
|
301
|
+
raise AuthentikError(404, f"User {username!r} not found")
|
|
302
|
+
self.request(
|
|
303
|
+
"POST",
|
|
304
|
+
f"/api/v3/core/users/{user['pk']}/set_password/",
|
|
305
|
+
body={"password": password},
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def delete_user(self, username: str) -> None:
|
|
309
|
+
"""Delete a user by username. No-op if absent."""
|
|
310
|
+
user = self.find_user_by_username(username)
|
|
311
|
+
if not user:
|
|
312
|
+
return
|
|
313
|
+
self.request("DELETE", f"/api/v3/core/users/{user['pk']}/")
|
|
314
|
+
|
|
315
|
+
def generate_recovery_link(self, username: str) -> str:
|
|
316
|
+
"""Mint a one-shot recovery URL for the given user.
|
|
317
|
+
|
|
318
|
+
Returns the absolute URL the user opens to set their own password
|
|
319
|
+
(and enroll MFA if the recovery flow is configured to require it).
|
|
320
|
+
Authentik invalidates the token after a single use; default TTL is
|
|
321
|
+
short, set in the recovery flow's stage configuration.
|
|
322
|
+
"""
|
|
323
|
+
user = self.find_user_by_username(username)
|
|
324
|
+
if not user:
|
|
325
|
+
raise AuthentikError(404, f"User {username!r} not found")
|
|
326
|
+
result = self.request(
|
|
327
|
+
"POST",
|
|
328
|
+
f"/api/v3/core/users/{user['pk']}/recovery/",
|
|
329
|
+
)
|
|
330
|
+
# Authentik returns {"link": "https://..."} on this endpoint.
|
|
331
|
+
if isinstance(result, dict) and "link" in result:
|
|
332
|
+
return result["link"]
|
|
333
|
+
raise AuthentikError(
|
|
334
|
+
500,
|
|
335
|
+
f"Unexpected recovery payload for {username!r}: {result!r}. "
|
|
336
|
+
"Make sure a recovery flow is configured on the tenant.",
|
|
337
|
+
)
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""``granny authentik`` — manage an Authentik instance via API.
|
|
2
|
+
|
|
3
|
+
Auth resolution: ``AUTHENTIK_API_TOKEN`` (env / .env / vault) or
|
|
4
|
+
``AK_TOKEN`` (env / .env). See :meth:`AuthentikClient.from_environment`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
from granny.authentik import AuthentikClient, AuthentikError
|
|
16
|
+
|
|
17
|
+
ADMIN_GROUP_NAME = "dash_admins"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.group()
|
|
21
|
+
def authentik() -> None:
|
|
22
|
+
"""Manage Authentik via its REST API."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _client() -> AuthentikClient:
|
|
26
|
+
try:
|
|
27
|
+
return AuthentikClient.from_environment()
|
|
28
|
+
except AuthentikError as exc:
|
|
29
|
+
click.echo(str(exc), err=True)
|
|
30
|
+
sys.exit(1)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_LIST_ENDPOINTS = {
|
|
34
|
+
"providers": "/api/v3/providers/oauth2/",
|
|
35
|
+
"applications": "/api/v3/core/applications/",
|
|
36
|
+
"groups": "/api/v3/core/groups/",
|
|
37
|
+
"flows": "/api/v3/flows/instances/",
|
|
38
|
+
"certs": "/api/v3/crypto/certificatekeypairs/",
|
|
39
|
+
"scopes": "/api/v3/propertymappings/provider/scope/",
|
|
40
|
+
"users": "/api/v3/core/users/",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_LIST_COLUMNS = {
|
|
44
|
+
"providers": ("pk", "name", "client_id"),
|
|
45
|
+
"applications": ("slug", "name", "provider"),
|
|
46
|
+
"groups": ("pk", "name", "users_obj"),
|
|
47
|
+
"flows": ("slug", "name", "designation"),
|
|
48
|
+
"certs": ("pk", "name", "private_key_available"),
|
|
49
|
+
"scopes": ("pk", "name", "scope_name"),
|
|
50
|
+
"users": ("pk", "username", "name"),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@authentik.command("list")
|
|
55
|
+
@click.argument("kind", type=click.Choice(sorted(_LIST_ENDPOINTS)))
|
|
56
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit full JSON instead of a table.")
|
|
57
|
+
def list_cmd(kind: str, as_json: bool) -> None:
|
|
58
|
+
"""List Authentik objects."""
|
|
59
|
+
client = _client()
|
|
60
|
+
items = client.paginate(_LIST_ENDPOINTS[kind])
|
|
61
|
+
if as_json:
|
|
62
|
+
click.echo(json.dumps(items, indent=2, default=str))
|
|
63
|
+
return
|
|
64
|
+
cols = _LIST_COLUMNS[kind]
|
|
65
|
+
for item in items:
|
|
66
|
+
row_parts = []
|
|
67
|
+
for col in cols:
|
|
68
|
+
if col == "users_obj":
|
|
69
|
+
row_parts.append(f"{len(item.get(col, []))} users")
|
|
70
|
+
else:
|
|
71
|
+
row_parts.append(str(item.get(col, ""))[:60])
|
|
72
|
+
click.echo(" | ".join(row_parts))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@authentik.command("get-provider")
|
|
76
|
+
@click.argument("name")
|
|
77
|
+
def get_provider_cmd(name: str) -> None:
|
|
78
|
+
"""Show one OAuth2 provider by name."""
|
|
79
|
+
client = _client()
|
|
80
|
+
provider = client.find_provider_by_name(name)
|
|
81
|
+
if not provider:
|
|
82
|
+
click.echo(f"Provider {name!r} not found", err=True)
|
|
83
|
+
sys.exit(1)
|
|
84
|
+
click.echo(json.dumps(provider, indent=2, default=str))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@authentik.command("rotate-secret")
|
|
88
|
+
@click.argument("name")
|
|
89
|
+
def rotate_secret_cmd(name: str) -> None:
|
|
90
|
+
"""Rotate a provider's client_secret."""
|
|
91
|
+
client = _client()
|
|
92
|
+
try:
|
|
93
|
+
updated = client.rotate_client_secret(name)
|
|
94
|
+
except AuthentikError as exc:
|
|
95
|
+
click.echo(f"Authentik error: {exc}", err=True)
|
|
96
|
+
sys.exit(1)
|
|
97
|
+
new_secret = updated.get("client_secret") or "(not echoed — view via admin UI)"
|
|
98
|
+
click.echo(f"Rotated. New client_secret: {new_secret}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@authentik.command("ensure-group")
|
|
102
|
+
@click.argument("name", default=ADMIN_GROUP_NAME)
|
|
103
|
+
def ensure_group_cmd(name: str) -> None:
|
|
104
|
+
"""Create a group if it doesn't exist (defaults to dash_admins)."""
|
|
105
|
+
client = _client()
|
|
106
|
+
existing = client.find_group_by_name(name)
|
|
107
|
+
if existing:
|
|
108
|
+
click.echo(f"Group {name} already exists (pk={existing['pk']})")
|
|
109
|
+
return
|
|
110
|
+
group = client.ensure_group(name)
|
|
111
|
+
click.echo(f"Created group {name} (pk={group['pk']})")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@authentik.command("add-user-to-group")
|
|
115
|
+
@click.argument("user")
|
|
116
|
+
@click.argument("group", default=ADMIN_GROUP_NAME)
|
|
117
|
+
def add_user_to_group_cmd(user: str, group: str) -> None:
|
|
118
|
+
"""Add a user to a group by username (group defaults to dash_admins)."""
|
|
119
|
+
client = _client()
|
|
120
|
+
try:
|
|
121
|
+
client.add_user_to_group(user, group)
|
|
122
|
+
except AuthentikError as exc:
|
|
123
|
+
click.echo(f"Authentik error: {exc}", err=True)
|
|
124
|
+
sys.exit(1)
|
|
125
|
+
click.echo(f"Added {user} to {group}")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@authentik.command("remove-user-from-group")
|
|
129
|
+
@click.argument("user")
|
|
130
|
+
@click.argument("group", default=ADMIN_GROUP_NAME)
|
|
131
|
+
def remove_user_from_group_cmd(user: str, group: str) -> None:
|
|
132
|
+
"""Remove a user from a group by username."""
|
|
133
|
+
client = _client()
|
|
134
|
+
try:
|
|
135
|
+
client.remove_user_from_group(user, group)
|
|
136
|
+
except AuthentikError as exc:
|
|
137
|
+
click.echo(f"Authentik error: {exc}", err=True)
|
|
138
|
+
sys.exit(1)
|
|
139
|
+
click.echo(f"Removed {user} from {group}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@authentik.command("create-user")
|
|
143
|
+
@click.argument("username")
|
|
144
|
+
@click.option("--name", default=None, help="Display name (defaults to username).")
|
|
145
|
+
@click.option("--email", default=None, help="Email address.")
|
|
146
|
+
@click.option(
|
|
147
|
+
"--group",
|
|
148
|
+
default=None,
|
|
149
|
+
help="Group to add the new user to after creation (e.g. dash_admins).",
|
|
150
|
+
)
|
|
151
|
+
@click.option(
|
|
152
|
+
"--password",
|
|
153
|
+
default=None,
|
|
154
|
+
help="Set this password directly. Suppresses --recovery-link.",
|
|
155
|
+
)
|
|
156
|
+
@click.option(
|
|
157
|
+
"--recovery-link/--no-recovery-link",
|
|
158
|
+
default=True,
|
|
159
|
+
help="Print a one-shot recovery URL the user opens to set their own password (default: yes).",
|
|
160
|
+
)
|
|
161
|
+
def create_user_cmd(
|
|
162
|
+
username: str,
|
|
163
|
+
name: str | None,
|
|
164
|
+
email: str | None,
|
|
165
|
+
group: str | None,
|
|
166
|
+
password: str | None,
|
|
167
|
+
recovery_link: bool,
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Create an internal Authentik user (idempotent on username).
|
|
170
|
+
|
|
171
|
+
Defaults to printing a one-shot recovery URL so the user sets their
|
|
172
|
+
own password — pass ``--password`` to skip that and set one inline.
|
|
173
|
+
"""
|
|
174
|
+
if password is not None:
|
|
175
|
+
recovery_link = False
|
|
176
|
+
client = _client()
|
|
177
|
+
try:
|
|
178
|
+
existed_before = client.find_user_by_username(username) is not None
|
|
179
|
+
user = client.create_user(username, name=name, email=email)
|
|
180
|
+
click.echo(
|
|
181
|
+
f"User {username} {'already exists' if existed_before else 'created'} (pk={user['pk']})"
|
|
182
|
+
)
|
|
183
|
+
if group:
|
|
184
|
+
client.add_user_to_group(username, group)
|
|
185
|
+
click.echo(f"Added {username} to {group}")
|
|
186
|
+
if password is not None:
|
|
187
|
+
client.set_user_password(username, password)
|
|
188
|
+
click.echo(f"Password set for {username}")
|
|
189
|
+
if recovery_link:
|
|
190
|
+
link = client.generate_recovery_link(username)
|
|
191
|
+
click.echo("")
|
|
192
|
+
click.echo("Recovery link (single-use, share over a secure channel):")
|
|
193
|
+
click.echo(f" {link}")
|
|
194
|
+
except AuthentikError as exc:
|
|
195
|
+
click.echo(f"Authentik error: {exc}", err=True)
|
|
196
|
+
sys.exit(1)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@authentik.command("recovery-link")
|
|
200
|
+
@click.argument("username")
|
|
201
|
+
def recovery_link_cmd(username: str) -> None:
|
|
202
|
+
"""Mint a one-shot recovery URL the user opens to set a password."""
|
|
203
|
+
client = _client()
|
|
204
|
+
try:
|
|
205
|
+
link = client.generate_recovery_link(username)
|
|
206
|
+
except AuthentikError as exc:
|
|
207
|
+
click.echo(f"Authentik error: {exc}", err=True)
|
|
208
|
+
sys.exit(1)
|
|
209
|
+
click.echo(link)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@authentik.command("set-password")
|
|
213
|
+
@click.argument("username")
|
|
214
|
+
@click.option("--password", required=True, help="New password to set.")
|
|
215
|
+
def set_password_cmd(username: str, password: str) -> None:
|
|
216
|
+
"""Set a user's password directly (admin reset)."""
|
|
217
|
+
client = _client()
|
|
218
|
+
try:
|
|
219
|
+
client.set_user_password(username, password)
|
|
220
|
+
except AuthentikError as exc:
|
|
221
|
+
click.echo(f"Authentik error: {exc}", err=True)
|
|
222
|
+
sys.exit(1)
|
|
223
|
+
click.echo(f"Password updated for {username}")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@authentik.command("delete-user")
|
|
227
|
+
@click.argument("username")
|
|
228
|
+
@click.confirmation_option(prompt="Really delete this Authentik user?")
|
|
229
|
+
def delete_user_cmd(username: str) -> None:
|
|
230
|
+
"""Delete an Authentik user. No-op if the user doesn't exist."""
|
|
231
|
+
client = _client()
|
|
232
|
+
try:
|
|
233
|
+
client.delete_user(username)
|
|
234
|
+
except AuthentikError as exc:
|
|
235
|
+
click.echo(f"Authentik error: {exc}", err=True)
|
|
236
|
+
sys.exit(1)
|
|
237
|
+
click.echo(f"User {username} deleted (or already absent)")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@authentik.command("api")
|
|
241
|
+
@click.argument("method")
|
|
242
|
+
@click.argument("path")
|
|
243
|
+
@click.option(
|
|
244
|
+
"--data",
|
|
245
|
+
default=None,
|
|
246
|
+
help="JSON body, or '-' to read from stdin.",
|
|
247
|
+
)
|
|
248
|
+
def api_cmd(method: str, path: str, data: str | None) -> None:
|
|
249
|
+
"""Generic API call — for anything not covered by the named commands."""
|
|
250
|
+
client = _client()
|
|
251
|
+
body: Any = None
|
|
252
|
+
if data is not None:
|
|
253
|
+
raw = sys.stdin.read() if data == "-" else data
|
|
254
|
+
body = json.loads(raw) if raw.strip() else None
|
|
255
|
+
try:
|
|
256
|
+
result = client.request(method, path, body=body)
|
|
257
|
+
except AuthentikError as exc:
|
|
258
|
+
click.echo(f"Authentik error: {exc}", err=True)
|
|
259
|
+
sys.exit(1)
|
|
260
|
+
if result is None:
|
|
261
|
+
click.echo("(no content)", err=True)
|
|
262
|
+
else:
|
|
263
|
+
click.echo(json.dumps(result, indent=2, default=str))
|
|
@@ -213,11 +213,11 @@ def deploy(
|
|
|
213
213
|
"""Zip ``--source-dir``, upload, sync env vars, and deploy.
|
|
214
214
|
|
|
215
215
|
Example:
|
|
216
|
-
granny serverless deploy
|
|
216
|
+
granny serverless deploy my-api \\
|
|
217
217
|
--source-dir backend/dist-bundle \\
|
|
218
|
-
--namespace
|
|
218
|
+
--namespace my-app \\
|
|
219
219
|
--env NODE_ENV=production \\
|
|
220
|
-
--secret
|
|
220
|
+
--secret API_TOKEN=$API_TOKEN
|
|
221
221
|
"""
|
|
222
222
|
c = _client(region)
|
|
223
223
|
|
|
@@ -168,6 +168,10 @@ SECRET_MAP: dict[str, str] = {
|
|
|
168
168
|
# GitLab PyPI / Container Registry (for fetching private packages and pushing images)
|
|
169
169
|
"GITLAB_PYPI_USER": "gitlab-pypi-user",
|
|
170
170
|
"GITLAB_PYPI_PASSWORD": "gitlab-pypi-password",
|
|
171
|
+
# Authentik admin API token for `granny authentik …` commands. Long-lived
|
|
172
|
+
# superuser-issued token (Authentik UI → Directory → Tokens). Falls back
|
|
173
|
+
# to AK_TOKEN env var in granny.authentik.AuthentikClient.from_environment.
|
|
174
|
+
"AUTHENTIK_API_TOKEN": "authentik-api-token",
|
|
171
175
|
}
|
|
172
176
|
|
|
173
177
|
# Module-level cache so vault is only contacted once per process
|
|
@@ -1,127 +1,127 @@
|
|
|
1
|
-
[project]
|
|
2
|
-
name = "granny-devops"
|
|
3
|
-
version = "0.
|
|
4
|
-
description = "Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation"
|
|
5
|
-
readme = "README.md"
|
|
6
|
-
license = { file = "LICENSE" }
|
|
7
|
-
authors = [{ name = "Martin Wieser", email = "martin.wieser@pseekoo.com" }]
|
|
8
|
-
requires-python = ">=3.13"
|
|
9
|
-
dependencies = [
|
|
10
|
-
"boto3>=1.38",
|
|
11
|
-
"click>=8.1",
|
|
12
|
-
"python-dotenv>=1.1.0",
|
|
13
|
-
"requests>=2.32",
|
|
14
|
-
]
|
|
15
|
-
|
|
16
|
-
[project.optional-dependencies]
|
|
17
|
-
# Development and release tooling
|
|
18
|
-
dev = [
|
|
19
|
-
"hatch>=1.14",
|
|
20
|
-
"prek>=0.2",
|
|
21
|
-
"pytest>=8.3",
|
|
22
|
-
"ruff>=0.11",
|
|
23
|
-
"twine>=6.1",
|
|
24
|
-
]
|
|
25
|
-
# Vaultwarden-backed credential resolution via Locke. The published wheel
|
|
26
|
-
# leaves this extra empty because PyPI rejects direct Git dependencies; for
|
|
27
|
-
# local editable installs Locke is wired in via `[tool.uv]` dev-dependencies
|
|
28
|
-
# below, so `uv sync` activates vault support automatically.
|
|
29
|
-
vault = []
|
|
30
|
-
# CDN and DNS providers
|
|
31
|
-
cdn = [
|
|
32
|
-
"cloudflare==4.3.1",
|
|
33
|
-
"hetzner-dns-api>=1.0.0",
|
|
34
|
-
]
|
|
35
|
-
# Google Cloud (cross-cloud analyze commands: GPUs, credits, costs)
|
|
36
|
-
gcp = [
|
|
37
|
-
"google-cloud-billing>=1.16",
|
|
38
|
-
"google-cloud-compute>=1.21",
|
|
39
|
-
"google-cloud-resource-manager>=1.12",
|
|
40
|
-
]
|
|
41
|
-
# Microsoft Azure (cross-cloud analyze commands: GPUs, credits, costs)
|
|
42
|
-
azure = [
|
|
43
|
-
"azure-identity>=1.23",
|
|
44
|
-
"azure-mgmt-billing>=6.1",
|
|
45
|
-
"azure-mgmt-compute>=33",
|
|
46
|
-
"azure-mgmt-consumption>=10.0",
|
|
47
|
-
"azure-mgmt-costmanagement>=4.0",
|
|
48
|
-
"azure-mgmt-reservations>=2.3",
|
|
49
|
-
"azure-mgmt-subscription>=3.1",
|
|
50
|
-
]
|
|
51
|
-
# Data processing and export
|
|
52
|
-
data = [
|
|
53
|
-
"beautifulsoup4~=4.13.4",
|
|
54
|
-
"gspread>=6.2.1",
|
|
55
|
-
"openpyxl==3.1.5",
|
|
56
|
-
"pandas>=2.3.0",
|
|
57
|
-
"pyairtable~=3.1.1",
|
|
58
|
-
"pymongo>=4.13",
|
|
59
|
-
]
|
|
60
|
-
# AI/ML integrations
|
|
61
|
-
ml = [
|
|
62
|
-
"anthropic>=0.54.0",
|
|
63
|
-
"google-generativeai>=0.8.5",
|
|
64
|
-
"langchain>=0.3.26",
|
|
65
|
-
"langchain-openai>=0.3.24",
|
|
66
|
-
"litellm>=1.73.0",
|
|
67
|
-
"ollama>=0.5.1",
|
|
68
|
-
"openai>=1.90.0",
|
|
69
|
-
"tiktoken>=0.9.0",
|
|
70
|
-
]
|
|
71
|
-
# Browser automation
|
|
72
|
-
browser = [
|
|
73
|
-
"playwright>=1.52.0",
|
|
74
|
-
"selenium>=4.33.0",
|
|
75
|
-
"webdriver-manager>=4.0.2",
|
|
76
|
-
]
|
|
77
|
-
# Full install (everything)
|
|
78
|
-
all = [
|
|
79
|
-
"granny-devops[cdn,data,ml,browser,gcp,azure]",
|
|
80
|
-
"azure-identity>=1.23.0",
|
|
81
|
-
"boto3-stubs>=1.40",
|
|
82
|
-
"certifi>=2025.6.15",
|
|
83
|
-
"earthengine-api>=1.5.20",
|
|
84
|
-
"elasticsearch>=8.0.0,<9.0.0",
|
|
85
|
-
"elasticsearch-dsl>=8.0.0,<9.0.0",
|
|
86
|
-
"flask>=3.1.1",
|
|
87
|
-
"flask-cors>=6.0.1",
|
|
88
|
-
"google-auth>=2.40.3",
|
|
89
|
-
"ipython~=9.3.0",
|
|
90
|
-
"json5>=0.12.0",
|
|
91
|
-
"jsonschema>=4.0.0",
|
|
92
|
-
"langdetect>=1.0.9",
|
|
93
|
-
"matplotlib>=3.10.3",
|
|
94
|
-
"msoffcrypto-tool>=5.4.2",
|
|
95
|
-
"numpy>=2.3.1",
|
|
96
|
-
"pdf2image>=1.17.0",
|
|
97
|
-
"pillow>=11.2.1",
|
|
98
|
-
"pydantic>=2.11.7",
|
|
99
|
-
"pymilvus>=2.5.11",
|
|
100
|
-
"pypdf>=5.6.1",
|
|
101
|
-
"pytesseract>=0.3.13",
|
|
102
|
-
"pyzbar>=0.1.9",
|
|
103
|
-
"sentinelhub>=3.11.1",
|
|
104
|
-
"sentry-sdk>=2.30.0",
|
|
105
|
-
"zxing>=1.0.3",
|
|
106
|
-
]
|
|
107
|
-
|
|
108
|
-
[project.scripts]
|
|
109
|
-
granny = "granny.cli.main:cli"
|
|
110
|
-
|
|
111
|
-
[build-system]
|
|
112
|
-
requires = ["hatchling"]
|
|
113
|
-
build-backend = "hatchling.build"
|
|
114
|
-
|
|
115
|
-
[tool.hatch.build.targets.wheel]
|
|
116
|
-
packages = ["granny"]
|
|
117
|
-
|
|
118
|
-
[tool.hatch.build.targets.sdist]
|
|
119
|
-
include = ["granny"]
|
|
120
|
-
|
|
121
|
-
# uv-only configuration. PyPI publishes ignore this section -- it activates
|
|
122
|
-
# vault support (Locke) for local editable installs via `uv sync`, without
|
|
123
|
-
# polluting the published wheel's metadata with a Git URL that PyPI rejects.
|
|
124
|
-
[tool.uv]
|
|
125
|
-
dev-dependencies = [
|
|
126
|
-
"locke @ git+https://gitlab.com/martin-wieser/locke.git#subdirectory=python",
|
|
127
|
-
]
|
|
1
|
+
[project]
|
|
2
|
+
name = "granny-devops"
|
|
3
|
+
version = "0.9.1"
|
|
4
|
+
description = "Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { file = "LICENSE" }
|
|
7
|
+
authors = [{ name = "Martin Wieser", email = "martin.wieser@pseekoo.com" }]
|
|
8
|
+
requires-python = ">=3.13"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"boto3>=1.38",
|
|
11
|
+
"click>=8.1",
|
|
12
|
+
"python-dotenv>=1.1.0",
|
|
13
|
+
"requests>=2.32",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
# Development and release tooling
|
|
18
|
+
dev = [
|
|
19
|
+
"hatch>=1.14",
|
|
20
|
+
"prek>=0.2",
|
|
21
|
+
"pytest>=8.3",
|
|
22
|
+
"ruff>=0.11",
|
|
23
|
+
"twine>=6.1",
|
|
24
|
+
]
|
|
25
|
+
# Vaultwarden-backed credential resolution via Locke. The published wheel
|
|
26
|
+
# leaves this extra empty because PyPI rejects direct Git dependencies; for
|
|
27
|
+
# local editable installs Locke is wired in via `[tool.uv]` dev-dependencies
|
|
28
|
+
# below, so `uv sync` activates vault support automatically.
|
|
29
|
+
vault = []
|
|
30
|
+
# CDN and DNS providers
|
|
31
|
+
cdn = [
|
|
32
|
+
"cloudflare==4.3.1",
|
|
33
|
+
"hetzner-dns-api>=1.0.0",
|
|
34
|
+
]
|
|
35
|
+
# Google Cloud (cross-cloud analyze commands: GPUs, credits, costs)
|
|
36
|
+
gcp = [
|
|
37
|
+
"google-cloud-billing>=1.16",
|
|
38
|
+
"google-cloud-compute>=1.21",
|
|
39
|
+
"google-cloud-resource-manager>=1.12",
|
|
40
|
+
]
|
|
41
|
+
# Microsoft Azure (cross-cloud analyze commands: GPUs, credits, costs)
|
|
42
|
+
azure = [
|
|
43
|
+
"azure-identity>=1.23",
|
|
44
|
+
"azure-mgmt-billing>=6.1",
|
|
45
|
+
"azure-mgmt-compute>=33",
|
|
46
|
+
"azure-mgmt-consumption>=10.0",
|
|
47
|
+
"azure-mgmt-costmanagement>=4.0",
|
|
48
|
+
"azure-mgmt-reservations>=2.3",
|
|
49
|
+
"azure-mgmt-subscription>=3.1",
|
|
50
|
+
]
|
|
51
|
+
# Data processing and export
|
|
52
|
+
data = [
|
|
53
|
+
"beautifulsoup4~=4.13.4",
|
|
54
|
+
"gspread>=6.2.1",
|
|
55
|
+
"openpyxl==3.1.5",
|
|
56
|
+
"pandas>=2.3.0",
|
|
57
|
+
"pyairtable~=3.1.1",
|
|
58
|
+
"pymongo>=4.13",
|
|
59
|
+
]
|
|
60
|
+
# AI/ML integrations
|
|
61
|
+
ml = [
|
|
62
|
+
"anthropic>=0.54.0",
|
|
63
|
+
"google-generativeai>=0.8.5",
|
|
64
|
+
"langchain>=0.3.26",
|
|
65
|
+
"langchain-openai>=0.3.24",
|
|
66
|
+
"litellm>=1.73.0",
|
|
67
|
+
"ollama>=0.5.1",
|
|
68
|
+
"openai>=1.90.0",
|
|
69
|
+
"tiktoken>=0.9.0",
|
|
70
|
+
]
|
|
71
|
+
# Browser automation
|
|
72
|
+
browser = [
|
|
73
|
+
"playwright>=1.52.0",
|
|
74
|
+
"selenium>=4.33.0",
|
|
75
|
+
"webdriver-manager>=4.0.2",
|
|
76
|
+
]
|
|
77
|
+
# Full install (everything)
|
|
78
|
+
all = [
|
|
79
|
+
"granny-devops[cdn,data,ml,browser,gcp,azure]",
|
|
80
|
+
"azure-identity>=1.23.0",
|
|
81
|
+
"boto3-stubs>=1.40",
|
|
82
|
+
"certifi>=2025.6.15",
|
|
83
|
+
"earthengine-api>=1.5.20",
|
|
84
|
+
"elasticsearch>=8.0.0,<9.0.0",
|
|
85
|
+
"elasticsearch-dsl>=8.0.0,<9.0.0",
|
|
86
|
+
"flask>=3.1.1",
|
|
87
|
+
"flask-cors>=6.0.1",
|
|
88
|
+
"google-auth>=2.40.3",
|
|
89
|
+
"ipython~=9.3.0",
|
|
90
|
+
"json5>=0.12.0",
|
|
91
|
+
"jsonschema>=4.0.0",
|
|
92
|
+
"langdetect>=1.0.9",
|
|
93
|
+
"matplotlib>=3.10.3",
|
|
94
|
+
"msoffcrypto-tool>=5.4.2",
|
|
95
|
+
"numpy>=2.3.1",
|
|
96
|
+
"pdf2image>=1.17.0",
|
|
97
|
+
"pillow>=11.2.1",
|
|
98
|
+
"pydantic>=2.11.7",
|
|
99
|
+
"pymilvus>=2.5.11",
|
|
100
|
+
"pypdf>=5.6.1",
|
|
101
|
+
"pytesseract>=0.3.13",
|
|
102
|
+
"pyzbar>=0.1.9",
|
|
103
|
+
"sentinelhub>=3.11.1",
|
|
104
|
+
"sentry-sdk>=2.30.0",
|
|
105
|
+
"zxing>=1.0.3",
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
[project.scripts]
|
|
109
|
+
granny = "granny.cli.main:cli"
|
|
110
|
+
|
|
111
|
+
[build-system]
|
|
112
|
+
requires = ["hatchling"]
|
|
113
|
+
build-backend = "hatchling.build"
|
|
114
|
+
|
|
115
|
+
[tool.hatch.build.targets.wheel]
|
|
116
|
+
packages = ["granny"]
|
|
117
|
+
|
|
118
|
+
[tool.hatch.build.targets.sdist]
|
|
119
|
+
include = ["granny"]
|
|
120
|
+
|
|
121
|
+
# uv-only configuration. PyPI publishes ignore this section -- it activates
|
|
122
|
+
# vault support (Locke) for local editable installs via `uv sync`, without
|
|
123
|
+
# polluting the published wheel's metadata with a Git URL that PyPI rejects.
|
|
124
|
+
[tool.uv]
|
|
125
|
+
dev-dependencies = [
|
|
126
|
+
"locke @ git+https://gitlab.com/martin-wieser/locke.git#subdirectory=python",
|
|
127
|
+
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|