granny-devops 0.4.0__tar.gz → 0.5.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 (73) hide show
  1. {granny_devops-0.4.0 → granny_devops-0.5.0}/PKG-INFO +1 -1
  2. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/__init__.py +1 -1
  3. granny_devops-0.5.0/granny/cli/cloudflare.py +225 -0
  4. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/cli/docker.py +4 -2
  5. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/cli/main.py +5 -0
  6. granny_devops-0.5.0/granny/cloudflare/__init__.py +109 -0
  7. granny_devops-0.5.0/granny/cloudflare/d1.py +42 -0
  8. granny_devops-0.5.0/granny/cloudflare/r2.py +33 -0
  9. granny_devops-0.5.0/granny/cloudflare/workers.py +44 -0
  10. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/email/mailjet.py +31 -1
  11. {granny_devops-0.4.0 → granny_devops-0.5.0}/pyproject.toml +13 -4
  12. {granny_devops-0.4.0 → granny_devops-0.5.0}/.gitignore +0 -0
  13. {granny_devops-0.4.0 → granny_devops-0.5.0}/LICENSE +0 -0
  14. {granny_devops-0.4.0 → granny_devops-0.5.0}/README.md +0 -0
  15. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/analyze/__init__.py +0 -0
  16. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/analyze/lambdas.py +0 -0
  17. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/analyze/vpcs.py +0 -0
  18. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/cdn/__init__.py +0 -0
  19. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/cdn/bunny.py +0 -0
  20. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/cli/__init__.py +0 -0
  21. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/cli/analyze.py +0 -0
  22. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/cli/cdn.py +0 -0
  23. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/cli/create.py +0 -0
  24. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/cli/credentials.py +0 -0
  25. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/cli/dns.py +0 -0
  26. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/cli/edge.py +0 -0
  27. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/cli/email.py +0 -0
  28. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/cli/serverless.py +0 -0
  29. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/cli/storage.py +0 -0
  30. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/__init__.py +0 -0
  31. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/auto_certificate.py +0 -0
  32. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/cloudfront-security-headers.js +0 -0
  33. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/manage-dns.sh +0 -0
  34. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/manage_mailjet_contacts.py +0 -0
  35. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/registrars.py +0 -0
  36. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/setup_aws_cloudfront.py +0 -0
  37. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/setup_bunny_edge_script.py +0 -0
  38. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/setup_bunny_storage.py +0 -0
  39. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/setup_cognito_identity_pool.py +0 -0
  40. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/setup_hetzner_bunny.py +0 -0
  41. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/setup_mailjet_dns.py +0 -0
  42. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/setup_private_cdn.py +0 -0
  43. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/setup_s3_website.py +0 -0
  44. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/setup_scaleway_faas.py +0 -0
  45. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/setup_workmail.py +0 -0
  46. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/www-redirect-function.js +0 -0
  47. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/credentials/__init__.py +0 -0
  48. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/credentials/secrets.py +0 -0
  49. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/dns/__init__.py +0 -0
  50. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/dns/base.py +0 -0
  51. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/dns/bunny.py +0 -0
  52. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/dns/cloudflare.py +0 -0
  53. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/dns/cloudns.py +0 -0
  54. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/dns/desec.py +0 -0
  55. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/dns/factory.py +0 -0
  56. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/dns/hetzner.py +0 -0
  57. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/dns/manual.py +0 -0
  58. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/dns/records.py +0 -0
  59. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/docker/__init__.py +0 -0
  60. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/docker/build_base.py +0 -0
  61. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/edge/__init__.py +0 -0
  62. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/edge/bunny.py +0 -0
  63. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/email/__init__.py +0 -0
  64. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/email/mailjet_contacts.py +0 -0
  65. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/email/ses_forwarding.py +0 -0
  66. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/email/workmail.py +0 -0
  67. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/report.py +0 -0
  68. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/serverless/__init__.py +0 -0
  69. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/serverless/scaleway.py +0 -0
  70. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/storage/__init__.py +0 -0
  71. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/storage/aws.py +0 -0
  72. {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/storage/bunny.py +0 -0
  73. {granny_devops-0.4.0 → granny_devops-0.5.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.4.0
3
+ Version: 0.5.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
@@ -1,6 +1,6 @@
1
1
  """Granny -- Cloud tools collection for AWS infrastructure and DevOps automation."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.5.0"
4
4
  __all__ = [
5
5
  "get_secret",
6
6
  "load_secrets_into_env",
@@ -0,0 +1,225 @@
1
+ """``granny cloudflare`` — Workers / D1 / R2 resource management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as _json
6
+
7
+ import click
8
+
9
+
10
+ @click.group("cloudflare")
11
+ def cloudflare() -> None:
12
+ """Cloudflare Workers / D1 / R2 / KV resource management.
13
+
14
+ For DNS-only operations see ``granny dns`` (Cloudflare is one of the
15
+ supported providers there). This group handles the account-level
16
+ resources that Workers-based apps depend on.
17
+ """
18
+
19
+
20
+ # -- D1 -----------------------------------------------------------------------
21
+
22
+
23
+ @cloudflare.group("d1")
24
+ def d1_group() -> None:
25
+ """D1 database management."""
26
+
27
+
28
+ @d1_group.command("list")
29
+ def d1_list() -> None:
30
+ """List D1 databases in the account."""
31
+ from granny.cloudflare.d1 import D1Client
32
+
33
+ for db in D1Client().list_databases():
34
+ click.echo(f" {db.get('uuid', '?'):<38} {db.get('name', '?')}")
35
+
36
+
37
+ @d1_group.command("create")
38
+ @click.argument("name")
39
+ @click.option("--location", default=None, help="Primary location hint (e.g. weur).")
40
+ def d1_create(name: str, location: str | None) -> None:
41
+ """Create a D1 database (idempotent)."""
42
+ from granny.cloudflare.d1 import D1Client
43
+
44
+ db = D1Client().create_database(name, primary_location_hint=location)
45
+ click.echo(f"D1 database: {db['name']} uuid={db['uuid']}")
46
+
47
+
48
+ # -- R2 -----------------------------------------------------------------------
49
+
50
+
51
+ @cloudflare.group("r2")
52
+ def r2_group() -> None:
53
+ """R2 bucket management."""
54
+
55
+
56
+ @r2_group.command("list")
57
+ def r2_list() -> None:
58
+ """List R2 buckets in the account."""
59
+ from granny.cloudflare.r2 import R2Client
60
+
61
+ for b in R2Client().list_buckets():
62
+ click.echo(f" {b.get('name'):<40} created={b.get('creation_date', '?')}")
63
+
64
+
65
+ @r2_group.command("create")
66
+ @click.argument("name")
67
+ @click.option("--location", default=None, help="Location hint (e.g. WEUR).")
68
+ def r2_create(name: str, location: str | None) -> None:
69
+ """Create an R2 bucket (idempotent)."""
70
+ from granny.cloudflare.r2 import R2Client
71
+
72
+ b = R2Client().create_bucket(name, location=location)
73
+ click.echo(f"R2 bucket: {b.get('name')}")
74
+
75
+
76
+ # -- Workers secrets ----------------------------------------------------------
77
+
78
+
79
+ @cloudflare.group("workers")
80
+ def workers_group() -> None:
81
+ """Workers script + secrets management."""
82
+
83
+
84
+ @workers_group.command("list")
85
+ def workers_list() -> None:
86
+ """List Worker scripts in the account."""
87
+ from granny.cloudflare.workers import WorkersClient
88
+
89
+ for s in WorkersClient().list_scripts():
90
+ click.echo(f" {s.get('id'):<40} modified={s.get('modified_on', '?')}")
91
+
92
+
93
+ @workers_group.command("secret-put")
94
+ @click.argument("script_name")
95
+ @click.argument("secret_name")
96
+ @click.option(
97
+ "--from-vault",
98
+ is_flag=True,
99
+ help="Resolve SECRET_NAME via granny credentials (vault/env) instead of prompting.",
100
+ )
101
+ @click.option("--value", default=None, help="Plaintext value (avoid in shell history).")
102
+ def workers_secret_put(
103
+ script_name: str, secret_name: str, from_vault: bool, value: str | None
104
+ ) -> None:
105
+ """Create or update a Worker secret."""
106
+ from granny.cloudflare.workers import WorkersClient
107
+
108
+ if value is None:
109
+ if from_vault:
110
+ from granny.credentials.secrets import get_secret
111
+
112
+ value = get_secret(secret_name)
113
+ if not value:
114
+ raise click.ClickException(f"Vault lookup for {secret_name} returned nothing.")
115
+ else:
116
+ value = click.prompt(f"Value for {secret_name}", hide_input=True)
117
+ WorkersClient().put_secret(script_name, secret_name, value)
118
+ click.echo(f"Secret set: {script_name}.{secret_name}")
119
+
120
+
121
+ @workers_group.command("secret-list")
122
+ @click.argument("script_name")
123
+ def workers_secret_list(script_name: str) -> None:
124
+ """List Worker secret names (values are not retrievable)."""
125
+ from granny.cloudflare.workers import WorkersClient
126
+
127
+ for s in WorkersClient().list_secrets(script_name):
128
+ click.echo(f" {s.get('name'):<32} type={s.get('type', '?')}")
129
+
130
+
131
+ # -- Composite: Workers site provisioning -------------------------------------
132
+
133
+
134
+ @cloudflare.group("site")
135
+ def site_group() -> None:
136
+ """Workers site provisioning (D1 + R2 + secrets in one pass)."""
137
+
138
+
139
+ @site_group.command("provision")
140
+ @click.argument("site_name")
141
+ @click.option(
142
+ "--media-bucket",
143
+ default=None,
144
+ help="R2 bucket name (default: <site>-media).",
145
+ )
146
+ @click.option(
147
+ "--d1-location",
148
+ default=None,
149
+ help="D1 primary location hint (e.g. weur).",
150
+ )
151
+ @click.option(
152
+ "--secret",
153
+ "secrets",
154
+ multiple=True,
155
+ help="Secret to push (format: NAME or NAME=VALUE). Repeatable.",
156
+ )
157
+ @click.option(
158
+ "--secret-from-vault",
159
+ "vault_secrets",
160
+ multiple=True,
161
+ help="Secret name to resolve via granny vault and push. Repeatable.",
162
+ )
163
+ @click.option("--json", "json_out", is_flag=True, help="Emit JSON summary.")
164
+ def site_provision(
165
+ site_name: str,
166
+ media_bucket: str | None,
167
+ d1_location: str | None,
168
+ secrets: tuple[str, ...],
169
+ vault_secrets: tuple[str, ...],
170
+ json_out: bool,
171
+ ) -> None:
172
+ """One-shot provisioning for a Workers site.
173
+
174
+ Creates (idempotently) the D1 database, R2 bucket, and pushes the
175
+ requested secrets to the Worker. The Worker script itself must already
176
+ have been deployed once via ``wrangler deploy`` -- this command does not
177
+ upload code. Typical flow::
178
+
179
+ wrangler deploy # first deploy
180
+ granny cloudflare site provision my-site \\
181
+ --secret-from-vault MAILJET_API_KEY \\
182
+ --secret-from-vault MAILJET_SECRET_KEY \\
183
+ --secret AUTH_SECRET=$(openssl rand -hex 32)
184
+ """
185
+ from granny.cloudflare.d1 import D1Client
186
+ from granny.cloudflare.r2 import R2Client
187
+ from granny.cloudflare.workers import WorkersClient
188
+ from granny.credentials.secrets import get_secret
189
+
190
+ bucket = media_bucket or f"{site_name}-media"
191
+
192
+ summary: dict[str, object] = {"site": site_name}
193
+
194
+ db = D1Client().create_database(site_name, primary_location_hint=d1_location)
195
+ summary["d1"] = {"name": db["name"], "uuid": db["uuid"]}
196
+
197
+ b = R2Client().create_bucket(bucket)
198
+ summary["r2"] = {"name": b.get("name", bucket)}
199
+
200
+ pushed: list[str] = []
201
+ w = WorkersClient()
202
+ for spec in secrets:
203
+ if "=" in spec:
204
+ name, _, val = spec.partition("=")
205
+ else:
206
+ name = spec
207
+ val = click.prompt(f"Value for {name}", hide_input=True)
208
+ w.put_secret(site_name, name, val)
209
+ pushed.append(name)
210
+ for name in vault_secrets:
211
+ val = get_secret(name)
212
+ if not val:
213
+ click.echo(f"warn: vault lookup for {name} returned empty; skipping", err=True)
214
+ continue
215
+ w.put_secret(site_name, name, val)
216
+ pushed.append(name)
217
+ summary["secrets_pushed"] = pushed
218
+
219
+ if json_out:
220
+ click.echo(_json.dumps(summary, indent=2))
221
+ else:
222
+ click.echo(f"D1: {db['name']} ({db['uuid']})")
223
+ click.echo(f"R2: {summary['r2']['name']}")
224
+ click.echo(f"Secrets: {', '.join(pushed) if pushed else '(none)'}")
225
+ click.echo("Now paste the D1 uuid into your wrangler.jsonc and redeploy.")
@@ -120,8 +120,10 @@ def build_base(
120
120
  missing = [k for k, ok in results.items() if not ok]
121
121
  if missing:
122
122
  click.echo(
123
- f"Missing secrets (not in env): {', '.join(sorted(missing))}\n"
124
- f"Set them in .env / .deploy.env or export them in your shell.",
123
+ f"Missing secrets (not in env or vault): {', '.join(sorted(missing))}\n"
124
+ f"Set them in .env / .deploy.env, export them in your shell, "
125
+ f"or — with the [vault] extra installed — import via "
126
+ f"`locke vault import kv --folder granny/infra --from-file .locke/vault-import.json`.",
125
127
  err=True,
126
128
  )
127
129
  sys.exit(1)
@@ -70,6 +70,11 @@ def _register_commands() -> None:
70
70
  cli.add_command(dns)
71
71
  except ImportError:
72
72
  pass
73
+ try:
74
+ from granny.cli.cloudflare import cloudflare
75
+ cli.add_command(cloudflare)
76
+ except ImportError:
77
+ pass
73
78
  try:
74
79
  from granny.cli.storage import storage
75
80
  cli.add_command(storage)
@@ -0,0 +1,109 @@
1
+ """Cloudflare provider — Workers / D1 / R2 / KV resource management.
2
+
3
+ Separate from ``granny.dns.cloudflare`` which handles only zone + DNS records.
4
+ This package covers the *account-level* resources that Workers-based apps
5
+ depend on.
6
+
7
+ All resources share a single ``CloudflareAccountClient`` whose token comes
8
+ from the granny credential chain (``CLOUDFLARE_API_TOKEN`` via vault/env).
9
+ The token must have at least the following scopes for a full Workers deploy:
10
+
11
+ - Account → Workers Scripts → Edit
12
+ - Account → D1 → Edit
13
+ - Account → Workers R2 Storage → Edit
14
+ - Account → Workers KV Storage → Edit
15
+ - Zone → Workers Routes → Edit (for the target zone)
16
+ - Zone → DNS → Edit (for custom-domain attach; also covered by granny.dns)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import os
23
+
24
+ import requests
25
+
26
+ from granny.credentials.secrets import get_secret
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ API_BASE = "https://api.cloudflare.com/client/v4"
31
+
32
+
33
+ class CloudflareAccountClient:
34
+ """Thin wrapper over the Cloudflare v4 REST API, scoped to one account."""
35
+
36
+ def __init__(
37
+ self,
38
+ api_token: str | None = None,
39
+ account_id: str | None = None,
40
+ ) -> None:
41
+ api_token = api_token or get_secret("CLOUDFLARE_API_TOKEN")
42
+ if not api_token:
43
+ raise ValueError("CLOUDFLARE_API_TOKEN not set (env or vault).")
44
+ account_id = account_id or os.environ.get("CLOUDFLARE_ACCOUNT_ID")
45
+ if not account_id:
46
+ # Try to resolve via /accounts (requires Account:List).
47
+ resp = requests.get(
48
+ f"{API_BASE}/accounts",
49
+ headers={"Authorization": f"Bearer {api_token}"},
50
+ timeout=15,
51
+ )
52
+ results = resp.json().get("result") or []
53
+ if len(results) == 1:
54
+ account_id = results[0]["id"]
55
+ else:
56
+ raise ValueError(
57
+ "CLOUDFLARE_ACCOUNT_ID not set and couldn't auto-resolve "
58
+ "(token lacks Account:List or multiple accounts visible). "
59
+ "Set CLOUDFLARE_ACCOUNT_ID explicitly."
60
+ )
61
+ self.account_id = account_id
62
+ self.session = requests.Session()
63
+ self.session.headers.update(
64
+ {
65
+ "Authorization": f"Bearer {api_token}",
66
+ "Content-Type": "application/json",
67
+ }
68
+ )
69
+
70
+ def _request(
71
+ self,
72
+ method: str,
73
+ path: str,
74
+ *,
75
+ params: dict | None = None,
76
+ json: dict | None = None,
77
+ raw_body: bytes | None = None,
78
+ extra_headers: dict | None = None,
79
+ ) -> dict:
80
+ url = f"{API_BASE}{path}"
81
+ headers = dict(self.session.headers)
82
+ if extra_headers:
83
+ headers.update(extra_headers)
84
+ if raw_body is not None:
85
+ # Caller is sending a non-JSON payload (e.g. multipart upload).
86
+ headers.pop("Content-Type", None)
87
+ resp = requests.request(
88
+ method, url, headers=headers, params=params, data=raw_body, timeout=60
89
+ )
90
+ else:
91
+ resp = self.session.request(
92
+ method, url, params=params, json=json, timeout=60
93
+ )
94
+ if resp.status_code >= 400:
95
+ raise RuntimeError(
96
+ f"Cloudflare {method} {path} failed: {resp.status_code} {resp.text}"
97
+ )
98
+ body = resp.json()
99
+ if not body.get("success", True):
100
+ raise RuntimeError(
101
+ f"Cloudflare {method} {path} error: {body.get('errors')}"
102
+ )
103
+ return body
104
+
105
+ # -- Account scoping helper -----------------------------------------------
106
+
107
+ def acct(self, subpath: str) -> str:
108
+ """Build a path rooted at /accounts/<id>."""
109
+ return f"/accounts/{self.account_id}{subpath}"
@@ -0,0 +1,42 @@
1
+ """Cloudflare D1 — create, list, execute SQL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from granny.cloudflare import CloudflareAccountClient
8
+
9
+
10
+ class D1Client:
11
+ def __init__(self, client: CloudflareAccountClient | None = None) -> None:
12
+ self.c = client or CloudflareAccountClient()
13
+
14
+ def list_databases(self) -> list[dict[str, Any]]:
15
+ body = self.c._request("GET", self.c.acct("/d1/database"))
16
+ return body.get("result") or []
17
+
18
+ def get_database(self, name: str) -> dict[str, Any] | None:
19
+ for db in self.list_databases():
20
+ if db.get("name") == name:
21
+ return db
22
+ return None
23
+
24
+ def create_database(self, name: str, primary_location_hint: str | None = None) -> dict[str, Any]:
25
+ """Create a D1 database. Returns the full DB record including `uuid`."""
26
+ existing = self.get_database(name)
27
+ if existing:
28
+ return existing
29
+ payload: dict[str, Any] = {"name": name}
30
+ if primary_location_hint:
31
+ payload["primary_location_hint"] = primary_location_hint
32
+ body = self.c._request("POST", self.c.acct("/d1/database"), json=payload)
33
+ return body["result"]
34
+
35
+ def execute_sql(self, database_id: str, sql: str) -> list[dict[str, Any]]:
36
+ """Execute one or more SQL statements against a D1 database."""
37
+ body = self.c._request(
38
+ "POST",
39
+ self.c.acct(f"/d1/database/{database_id}/query"),
40
+ json={"sql": sql},
41
+ )
42
+ return body.get("result") or []
@@ -0,0 +1,33 @@
1
+ """Cloudflare R2 — bucket management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from granny.cloudflare import CloudflareAccountClient
8
+
9
+
10
+ class R2Client:
11
+ def __init__(self, client: CloudflareAccountClient | None = None) -> None:
12
+ self.c = client or CloudflareAccountClient()
13
+
14
+ def list_buckets(self) -> list[dict[str, Any]]:
15
+ body = self.c._request("GET", self.c.acct("/r2/buckets"))
16
+ return body.get("result", {}).get("buckets") or []
17
+
18
+ def get_bucket(self, name: str) -> dict[str, Any] | None:
19
+ for b in self.list_buckets():
20
+ if b.get("name") == name:
21
+ return b
22
+ return None
23
+
24
+ def create_bucket(self, name: str, location: str | None = None) -> dict[str, Any]:
25
+ """Idempotent bucket create — returns the bucket record."""
26
+ existing = self.get_bucket(name)
27
+ if existing:
28
+ return existing
29
+ payload: dict[str, Any] = {"name": name}
30
+ if location:
31
+ payload["locationHint"] = location
32
+ body = self.c._request("POST", self.c.acct("/r2/buckets"), json=payload)
33
+ return body.get("result") or {"name": name}
@@ -0,0 +1,44 @@
1
+ """Cloudflare Workers — script metadata + secrets management.
2
+
3
+ Deploying the actual Worker bundle is left to ``wrangler deploy`` which the
4
+ user already runs from their project directory. Granny's job here is to
5
+ manage the surrounding infrastructure: secrets, custom domains, D1/R2/KV
6
+ bindings that wrangler's config file references.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from granny.cloudflare import CloudflareAccountClient
14
+
15
+
16
+ class WorkersClient:
17
+ def __init__(self, client: CloudflareAccountClient | None = None) -> None:
18
+ self.c = client or CloudflareAccountClient()
19
+
20
+ def list_scripts(self) -> list[dict[str, Any]]:
21
+ body = self.c._request("GET", self.c.acct("/workers/scripts"))
22
+ return body.get("result") or []
23
+
24
+ def put_secret(self, script_name: str, name: str, value: str) -> dict[str, Any]:
25
+ """Create or update a plaintext secret binding on a Worker."""
26
+ body = self.c._request(
27
+ "PUT",
28
+ self.c.acct(f"/workers/scripts/{script_name}/secrets"),
29
+ json={"name": name, "text": value, "type": "secret_text"},
30
+ )
31
+ return body.get("result") or {}
32
+
33
+ def delete_secret(self, script_name: str, name: str) -> None:
34
+ self.c._request(
35
+ "DELETE",
36
+ self.c.acct(f"/workers/scripts/{script_name}/secrets/{name}"),
37
+ )
38
+
39
+ def list_secrets(self, script_name: str) -> list[dict[str, Any]]:
40
+ """Returns metadata only (names + types), not the plaintext values."""
41
+ body = self.c._request(
42
+ "GET", self.c.acct(f"/workers/scripts/{script_name}/secrets")
43
+ )
44
+ return body.get("result") or []
@@ -80,8 +80,38 @@ def parse_spf(spf_record: str) -> tuple[list[str], str]:
80
80
  """Parse an SPF record into (mechanisms, qualifier).
81
81
 
82
82
  Returns e.g. (["include:_spf.google.com", "include:spf.mailjet.com"], "~all").
83
+
84
+ Some DNS providers (notably Cloudflare) return TXT record content with
85
+ surrounding quotes (``"v=spf1 ..."``) and split long records into
86
+ multiple quoted strings (``"v=spf1 ..." "include:other.com -all"``).
87
+ Strip those before tokenizing so callers can pass raw provider values.
83
88
  """
84
- parts = spf_record.strip().split()
89
+ raw = spf_record.strip()
90
+ # Concatenate adjacent quoted strings (RFC 1035 multi-string TXT)
91
+ # and unwrap any single wrapping pair: `"foo" "bar"` -> `foo bar`,
92
+ # `"foo"` -> `foo`. Naive unquote -- SPF values don't contain literal
93
+ # backslash-escaped quotes in practice.
94
+ if '"' in raw:
95
+ chunks: list[str] = []
96
+ in_quote = False
97
+ buf: list[str] = []
98
+ for ch in raw:
99
+ if ch == '"':
100
+ in_quote = not in_quote
101
+ continue
102
+ if in_quote:
103
+ buf.append(ch)
104
+ elif ch.isspace() and buf:
105
+ chunks.append("".join(buf))
106
+ buf = []
107
+ if buf:
108
+ chunks.append("".join(buf))
109
+ if chunks:
110
+ # SPF tokens are whitespace-separated; rejoin with a space so a
111
+ # multi-string TXT split between tokens reconstructs cleanly.
112
+ raw = " ".join(chunks)
113
+
114
+ parts = raw.split()
85
115
  mechanisms: list[str] = []
86
116
  qualifier = "~all"
87
117
  for part in parts:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "granny-devops"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -22,9 +22,10 @@ dev = [
22
22
  "ruff>=0.11",
23
23
  "twine>=6.1",
24
24
  ]
25
- # Vaultwarden-backed credential resolution via Locke is intentionally not
26
- # declared here because PyPI rejects direct Git dependencies. Install Locke
27
- # separately from its repository when vault-backed local secrets are needed.
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.
28
29
  vault = []
29
30
  # CDN and DNS providers
30
31
  cdn = [
@@ -100,3 +101,11 @@ packages = ["granny"]
100
101
 
101
102
  [tool.hatch.build.targets.sdist]
102
103
  include = ["granny"]
104
+
105
+ # uv-only configuration. PyPI publishes ignore this section -- it activates
106
+ # vault support (Locke) for local editable installs via `uv sync`, without
107
+ # polluting the published wheel's metadata with a Git URL that PyPI rejects.
108
+ [tool.uv]
109
+ dev-dependencies = [
110
+ "locke @ git+https://gitlab.com/martin-wieser/locke.git#subdirectory=python",
111
+ ]
File without changes
File without changes
File without changes