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.
- {granny_devops-0.4.0 → granny_devops-0.5.0}/PKG-INFO +1 -1
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/__init__.py +1 -1
- granny_devops-0.5.0/granny/cli/cloudflare.py +225 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/cli/docker.py +4 -2
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/cli/main.py +5 -0
- granny_devops-0.5.0/granny/cloudflare/__init__.py +109 -0
- granny_devops-0.5.0/granny/cloudflare/d1.py +42 -0
- granny_devops-0.5.0/granny/cloudflare/r2.py +33 -0
- granny_devops-0.5.0/granny/cloudflare/workers.py +44 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/email/mailjet.py +31 -1
- {granny_devops-0.4.0 → granny_devops-0.5.0}/pyproject.toml +13 -4
- {granny_devops-0.4.0 → granny_devops-0.5.0}/.gitignore +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/LICENSE +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/README.md +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/analyze/__init__.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/analyze/lambdas.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/analyze/vpcs.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/cdn/__init__.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/cdn/bunny.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/cli/__init__.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/cli/analyze.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/cli/cdn.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/cli/create.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/cli/credentials.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/cli/dns.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/cli/edge.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/cli/email.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/cli/serverless.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/cli/storage.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/__init__.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/auto_certificate.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/cloudfront-security-headers.js +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/manage-dns.sh +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/manage_mailjet_contacts.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/registrars.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/setup_aws_cloudfront.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/setup_bunny_edge_script.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/setup_bunny_storage.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/setup_cognito_identity_pool.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/setup_hetzner_bunny.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/setup_mailjet_dns.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/setup_private_cdn.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/setup_s3_website.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/setup_scaleway_faas.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/setup_workmail.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/create/www-redirect-function.js +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/credentials/__init__.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/credentials/secrets.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/dns/__init__.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/dns/base.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/dns/bunny.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/dns/cloudflare.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/dns/cloudns.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/dns/desec.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/dns/factory.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/dns/hetzner.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/dns/manual.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/dns/records.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/docker/__init__.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/docker/build_base.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/edge/__init__.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/edge/bunny.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/email/__init__.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/email/mailjet_contacts.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/email/ses_forwarding.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/email/workmail.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/report.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/serverless/__init__.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/serverless/scaleway.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/storage/__init__.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/storage/aws.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/storage/bunny.py +0 -0
- {granny_devops-0.4.0 → granny_devops-0.5.0}/granny/storage/hetzner.py +0 -0
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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
|
|
26
|
-
#
|
|
27
|
-
#
|
|
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
|
|
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
|