granny-devops 0.4.0__py3-none-any.whl
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/__init__.py +19 -0
- granny/analyze/__init__.py +6 -0
- granny/analyze/lambdas.py +59 -0
- granny/analyze/vpcs.py +57 -0
- granny/cdn/__init__.py +9 -0
- granny/cdn/bunny.py +231 -0
- granny/cli/__init__.py +0 -0
- granny/cli/analyze.py +66 -0
- granny/cli/cdn.py +210 -0
- granny/cli/create.py +94 -0
- granny/cli/credentials.py +99 -0
- granny/cli/dns.py +290 -0
- granny/cli/docker.py +165 -0
- granny/cli/edge.py +106 -0
- granny/cli/email.py +224 -0
- granny/cli/main.py +98 -0
- granny/cli/serverless.py +278 -0
- granny/cli/storage.py +249 -0
- granny/create/__init__.py +4 -0
- granny/create/auto_certificate.py +1899 -0
- granny/create/cloudfront-security-headers.js +53 -0
- granny/create/manage-dns.sh +321 -0
- granny/create/manage_mailjet_contacts.py +619 -0
- granny/create/registrars.py +363 -0
- granny/create/setup_aws_cloudfront.py +2808 -0
- granny/create/setup_bunny_edge_script.py +923 -0
- granny/create/setup_bunny_storage.py +1719 -0
- granny/create/setup_cognito_identity_pool.py +740 -0
- granny/create/setup_hetzner_bunny.py +1482 -0
- granny/create/setup_mailjet_dns.py +1103 -0
- granny/create/setup_private_cdn.py +547 -0
- granny/create/setup_s3_website.py +1512 -0
- granny/create/setup_scaleway_faas.py +1165 -0
- granny/create/setup_workmail.py +1217 -0
- granny/create/www-redirect-function.js +17 -0
- granny/credentials/__init__.py +15 -0
- granny/credentials/secrets.py +403 -0
- granny/dns/__init__.py +22 -0
- granny/dns/base.py +113 -0
- granny/dns/bunny.py +150 -0
- granny/dns/cloudflare.py +192 -0
- granny/dns/cloudns.py +162 -0
- granny/dns/desec.py +152 -0
- granny/dns/factory.py +72 -0
- granny/dns/hetzner.py +165 -0
- granny/dns/manual.py +64 -0
- granny/dns/records.py +29 -0
- granny/docker/__init__.py +5 -0
- granny/docker/build_base.py +204 -0
- granny/edge/__init__.py +5 -0
- granny/edge/bunny.py +147 -0
- granny/email/__init__.py +7 -0
- granny/email/mailjet.py +119 -0
- granny/email/mailjet_contacts.py +115 -0
- granny/email/ses_forwarding.py +281 -0
- granny/email/workmail.py +145 -0
- granny/report.py +128 -0
- granny/serverless/__init__.py +5 -0
- granny/serverless/scaleway.py +264 -0
- granny/storage/__init__.py +7 -0
- granny/storage/aws.py +113 -0
- granny/storage/bunny.py +98 -0
- granny/storage/hetzner.py +118 -0
- granny_devops-0.4.0.dist-info/METADATA +445 -0
- granny_devops-0.4.0.dist-info/RECORD +68 -0
- granny_devops-0.4.0.dist-info/WHEEL +4 -0
- granny_devops-0.4.0.dist-info/entry_points.txt +2 -0
- granny_devops-0.4.0.dist-info/licenses/LICENSE +21 -0
granny/cli/storage.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""``granny storage`` — object storage management across providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.group()
|
|
9
|
+
def storage() -> None:
|
|
10
|
+
"""Object storage management (Bunny, Hetzner S3, AWS S3)."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# -- Bunny Storage subgroup ---------------------------------------------------
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@storage.group("bunny")
|
|
17
|
+
def bunny_storage() -> None:
|
|
18
|
+
"""Bunny Storage zone management."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@bunny_storage.command("list")
|
|
22
|
+
@click.option("--customer", "-c", default=None, help="Bunny customer profile.")
|
|
23
|
+
def bunny_list(customer: str | None) -> None:
|
|
24
|
+
"""List all Bunny storage zones."""
|
|
25
|
+
from granny.storage.bunny import BunnyStorageClient
|
|
26
|
+
|
|
27
|
+
c = BunnyStorageClient(customer=customer)
|
|
28
|
+
zones = c.list_storage_zones()
|
|
29
|
+
if not zones:
|
|
30
|
+
click.echo("No storage zones found.")
|
|
31
|
+
return
|
|
32
|
+
for z in zones:
|
|
33
|
+
click.echo(
|
|
34
|
+
f" {z['Id']:<10} {z['Name']:<30} "
|
|
35
|
+
f"Region={z.get('Region', '?')} "
|
|
36
|
+
f"Files={z.get('FilesStored', 0)}"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@bunny_storage.command("create")
|
|
41
|
+
@click.argument("name")
|
|
42
|
+
@click.option(
|
|
43
|
+
"--region",
|
|
44
|
+
default="DE",
|
|
45
|
+
show_default=True,
|
|
46
|
+
type=click.Choice(
|
|
47
|
+
["DE", "UK", "NY", "LA", "SG", "SE", "BR", "JH", "SYD"],
|
|
48
|
+
case_sensitive=False,
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
@click.option("--customer", "-c", default=None)
|
|
52
|
+
def bunny_create(name: str, region: str, customer: str | None) -> None:
|
|
53
|
+
"""Create a Bunny storage zone."""
|
|
54
|
+
from granny.storage.bunny import BunnyStorageClient
|
|
55
|
+
|
|
56
|
+
c = BunnyStorageClient(customer=customer)
|
|
57
|
+
zone = c.create_storage_zone(name, region=region.upper())
|
|
58
|
+
click.echo(f"Storage zone created: ID={zone['Id']} Name={zone['Name']}")
|
|
59
|
+
click.echo(f"Password: {zone.get('Password', 'N/A')}")
|
|
60
|
+
click.echo(f"Hostname: {zone.get('StorageHostname', 'N/A')}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@bunny_storage.command("upload")
|
|
64
|
+
@click.argument("zone_name")
|
|
65
|
+
@click.argument("local_path", type=click.Path(exists=True))
|
|
66
|
+
@click.option("--remote-path", default=None, help="Remote path (default: filename).")
|
|
67
|
+
@click.option("--password", envvar="BUNNY_STORAGE_PASSWORD", required=True,
|
|
68
|
+
help="Storage zone password.")
|
|
69
|
+
@click.option("--hostname", default="storage.bunnycdn.com", show_default=True,
|
|
70
|
+
help="Storage endpoint hostname.")
|
|
71
|
+
@click.option("--content-type", default=None, help="Override Content-Type.")
|
|
72
|
+
def bunny_upload(
|
|
73
|
+
zone_name: str,
|
|
74
|
+
local_path: str,
|
|
75
|
+
remote_path: str | None,
|
|
76
|
+
password: str,
|
|
77
|
+
hostname: str,
|
|
78
|
+
content_type: str | None,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Upload a single file to a Bunny storage zone."""
|
|
81
|
+
import os
|
|
82
|
+
|
|
83
|
+
from granny.storage.bunny import BunnyStorageClient
|
|
84
|
+
|
|
85
|
+
if remote_path is None:
|
|
86
|
+
remote_path = os.path.basename(local_path)
|
|
87
|
+
|
|
88
|
+
if content_type is None:
|
|
89
|
+
content_type = _guess_content_type(local_path)
|
|
90
|
+
|
|
91
|
+
c = BunnyStorageClient()
|
|
92
|
+
with open(local_path, "rb") as f:
|
|
93
|
+
c.upload_file(hostname, password, zone_name, remote_path, f.read(), content_type)
|
|
94
|
+
click.echo(f"Uploaded {local_path} -> {zone_name}/{remote_path}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@bunny_storage.command("upload-dir")
|
|
98
|
+
@click.argument("zone_name")
|
|
99
|
+
@click.argument("directory", type=click.Path(exists=True, file_okay=False))
|
|
100
|
+
@click.option("--password", envvar="BUNNY_STORAGE_PASSWORD", required=True,
|
|
101
|
+
help="Storage zone password.")
|
|
102
|
+
@click.option("--hostname", default="storage.bunnycdn.com", show_default=True,
|
|
103
|
+
help="Storage endpoint hostname.")
|
|
104
|
+
@click.option("--cache-control", default="public, max-age=31536000, immutable",
|
|
105
|
+
show_default=True,
|
|
106
|
+
help="Cache-Control for non-HTML files (hashed assets).")
|
|
107
|
+
@click.option("--html-cache-control", default="no-cache, no-store, must-revalidate",
|
|
108
|
+
show_default=True,
|
|
109
|
+
help="Cache-Control for *.html (so SPA entry is never stale).")
|
|
110
|
+
def bunny_upload_dir(
|
|
111
|
+
zone_name: str,
|
|
112
|
+
directory: str,
|
|
113
|
+
password: str,
|
|
114
|
+
hostname: str,
|
|
115
|
+
cache_control: str,
|
|
116
|
+
html_cache_control: str,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Upload an entire directory to a Bunny storage zone.
|
|
119
|
+
|
|
120
|
+
Recursively uploads all files, preserving directory structure.
|
|
121
|
+
Content-Type is auto-detected from file extensions.
|
|
122
|
+
HTML files get ``--html-cache-control`` (default: no-cache) so SPA entry
|
|
123
|
+
reloads fetch the latest asset hashes; everything else gets ``--cache-control``
|
|
124
|
+
(default: immutable long-cache).
|
|
125
|
+
"""
|
|
126
|
+
import os
|
|
127
|
+
|
|
128
|
+
from granny.storage.bunny import BunnyStorageClient
|
|
129
|
+
|
|
130
|
+
c = BunnyStorageClient()
|
|
131
|
+
uploaded = 0
|
|
132
|
+
errors = 0
|
|
133
|
+
|
|
134
|
+
for root, _dirs, files in os.walk(directory):
|
|
135
|
+
for fname in files:
|
|
136
|
+
local_path = os.path.join(root, fname)
|
|
137
|
+
remote_path = os.path.relpath(local_path, directory).replace("\\", "/")
|
|
138
|
+
ct = _guess_content_type(local_path)
|
|
139
|
+
cc = html_cache_control if fname.lower().endswith(".html") else cache_control
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
with open(local_path, "rb") as f:
|
|
143
|
+
c.upload_file(
|
|
144
|
+
hostname, password, zone_name, remote_path,
|
|
145
|
+
f.read(), ct, cache_control=cc,
|
|
146
|
+
)
|
|
147
|
+
uploaded += 1
|
|
148
|
+
if uploaded % 10 == 0:
|
|
149
|
+
click.echo(f" Uploaded {uploaded} files...")
|
|
150
|
+
except Exception as e:
|
|
151
|
+
click.echo(f" ERROR: {remote_path}: {e}", err=True)
|
|
152
|
+
errors += 1
|
|
153
|
+
|
|
154
|
+
click.echo(f"Done: {uploaded} uploaded, {errors} errors.")
|
|
155
|
+
if errors:
|
|
156
|
+
raise SystemExit(1)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _guess_content_type(path: str) -> str:
|
|
160
|
+
"""Guess Content-Type from file extension."""
|
|
161
|
+
ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
|
|
162
|
+
return {
|
|
163
|
+
"html": "text/html; charset=utf-8",
|
|
164
|
+
"css": "text/css; charset=utf-8",
|
|
165
|
+
"js": "application/javascript; charset=utf-8",
|
|
166
|
+
"json": "application/json; charset=utf-8",
|
|
167
|
+
"png": "image/png",
|
|
168
|
+
"jpg": "image/jpeg",
|
|
169
|
+
"jpeg": "image/jpeg",
|
|
170
|
+
"gif": "image/gif",
|
|
171
|
+
"svg": "image/svg+xml",
|
|
172
|
+
"webp": "image/webp",
|
|
173
|
+
"ico": "image/x-icon",
|
|
174
|
+
"woff": "font/woff",
|
|
175
|
+
"woff2": "font/woff2",
|
|
176
|
+
"ttf": "font/ttf",
|
|
177
|
+
"eot": "application/vnd.ms-fontobject",
|
|
178
|
+
"xml": "application/xml",
|
|
179
|
+
"txt": "text/plain",
|
|
180
|
+
"map": "application/json",
|
|
181
|
+
}.get(ext, "application/octet-stream")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# -- Hetzner S3 subgroup ------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@storage.group("hetzner")
|
|
188
|
+
def hetzner_storage() -> None:
|
|
189
|
+
"""Hetzner S3-compatible object storage."""
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@hetzner_storage.command("create")
|
|
193
|
+
@click.argument("bucket_name")
|
|
194
|
+
@click.option(
|
|
195
|
+
"--region",
|
|
196
|
+
default="fsn1",
|
|
197
|
+
show_default=True,
|
|
198
|
+
type=click.Choice(["fsn1", "nbg1", "hel1"]),
|
|
199
|
+
)
|
|
200
|
+
@click.option("--public/--private", default=True, show_default=True)
|
|
201
|
+
@click.option("--cors", is_flag=True, help="Enable CORS for web access.")
|
|
202
|
+
def hetzner_create(
|
|
203
|
+
bucket_name: str, region: str, public: bool, cors: bool
|
|
204
|
+
) -> None:
|
|
205
|
+
"""Create a Hetzner S3 bucket."""
|
|
206
|
+
from granny.storage.hetzner import HetznerS3Client
|
|
207
|
+
|
|
208
|
+
c = HetznerS3Client(region=region)
|
|
209
|
+
c.create_bucket(bucket_name)
|
|
210
|
+
if public:
|
|
211
|
+
c.set_bucket_policy_public(bucket_name)
|
|
212
|
+
if cors:
|
|
213
|
+
c.set_cors_config(bucket_name)
|
|
214
|
+
click.echo(f"Bucket created: {c.get_bucket_url(bucket_name)}")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# -- AWS S3 subgroup -----------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@storage.group("aws")
|
|
221
|
+
def aws_storage() -> None:
|
|
222
|
+
"""AWS S3 bucket management."""
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@aws_storage.command("create")
|
|
226
|
+
@click.argument("bucket_name")
|
|
227
|
+
@click.option("--region", default=None, help="AWS region (default from profile).")
|
|
228
|
+
@click.option("--profile", "aws_profile", default=None, help="AWS profile name.")
|
|
229
|
+
@click.option(
|
|
230
|
+
"--website/--no-website",
|
|
231
|
+
default=False,
|
|
232
|
+
help="Enable static website hosting.",
|
|
233
|
+
)
|
|
234
|
+
def aws_create(
|
|
235
|
+
bucket_name: str,
|
|
236
|
+
region: str | None,
|
|
237
|
+
aws_profile: str | None,
|
|
238
|
+
website: bool,
|
|
239
|
+
) -> None:
|
|
240
|
+
"""Create an AWS S3 bucket."""
|
|
241
|
+
from granny.storage.aws import AWSS3Client
|
|
242
|
+
|
|
243
|
+
c = AWSS3Client(aws_profile=aws_profile, region=region)
|
|
244
|
+
c.create_bucket(bucket_name)
|
|
245
|
+
if website:
|
|
246
|
+
c.enable_website_hosting(bucket_name)
|
|
247
|
+
click.echo(f"Website endpoint: {c.get_website_endpoint(bucket_name)}")
|
|
248
|
+
else:
|
|
249
|
+
click.echo(f"Bucket created: {bucket_name} in {c.region}")
|