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/edge.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""``granny edge`` — Bunny Edge Scripting management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _client(customer: str | None = None):
|
|
11
|
+
from granny.edge.bunny import BunnyEdgeScriptClient
|
|
12
|
+
|
|
13
|
+
return BunnyEdgeScriptClient(customer=customer)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
customer_option = click.option("--customer", "-c", default=None)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.group()
|
|
20
|
+
def edge() -> None:
|
|
21
|
+
"""Bunny Edge Scripting management."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@edge.command("list")
|
|
25
|
+
@customer_option
|
|
26
|
+
def list_cmd(customer: str | None) -> None:
|
|
27
|
+
"""List all edge scripts."""
|
|
28
|
+
c = _client(customer)
|
|
29
|
+
scripts = c.list_scripts()
|
|
30
|
+
if not scripts:
|
|
31
|
+
click.echo("No edge scripts found.")
|
|
32
|
+
return
|
|
33
|
+
for s in scripts:
|
|
34
|
+
click.echo(f" {s.get('Id', '?'):<10} {s.get('Name', '?'):<30} Type={s.get('ScriptType', '?')}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@edge.command("create")
|
|
38
|
+
@click.argument("name")
|
|
39
|
+
@click.option("--code", "code_path", type=click.Path(exists=True), default=None)
|
|
40
|
+
@click.option(
|
|
41
|
+
"--type",
|
|
42
|
+
"script_type",
|
|
43
|
+
type=click.Choice(["standalone", "middleware"]),
|
|
44
|
+
default="standalone",
|
|
45
|
+
show_default=True,
|
|
46
|
+
)
|
|
47
|
+
@customer_option
|
|
48
|
+
def create_cmd(
|
|
49
|
+
name: str, code_path: str | None, script_type: str, customer: str | None
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Create an edge script."""
|
|
52
|
+
c = _client(customer)
|
|
53
|
+
code = Path(code_path).read_text(encoding="utf-8") if code_path else ""
|
|
54
|
+
result = c.create_script(name, code=code, script_type=script_type)
|
|
55
|
+
click.echo(f"Script created: ID={result.get('Id')} Name={result.get('Name')}")
|
|
56
|
+
if result.get("LinkedPullZones"):
|
|
57
|
+
pz = result["LinkedPullZones"][0]
|
|
58
|
+
click.echo(f"Linked pull zone: ID={pz.get('Id')} Name={pz.get('Name')}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@edge.command("deploy")
|
|
62
|
+
@click.argument("script_id", type=int)
|
|
63
|
+
@click.argument("code_path", type=click.Path(exists=True))
|
|
64
|
+
@customer_option
|
|
65
|
+
def deploy_cmd(script_id: int, code_path: str, customer: str | None) -> None:
|
|
66
|
+
"""Deploy code to an edge script."""
|
|
67
|
+
c = _client(customer)
|
|
68
|
+
code = Path(code_path).read_text(encoding="utf-8")
|
|
69
|
+
c.deploy_code(script_id, code)
|
|
70
|
+
click.echo(f"Code deployed to script {script_id} ({len(code)} bytes).")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@edge.command("publish")
|
|
74
|
+
@click.argument("script_id", type=int)
|
|
75
|
+
@customer_option
|
|
76
|
+
def publish_cmd(script_id: int, customer: str | None) -> None:
|
|
77
|
+
"""Publish the current code as a live release."""
|
|
78
|
+
c = _client(customer)
|
|
79
|
+
c.publish(script_id)
|
|
80
|
+
click.echo(f"Script {script_id} published.")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@edge.command("env")
|
|
84
|
+
@click.argument("script_id", type=int)
|
|
85
|
+
@click.argument("pairs", nargs=-1)
|
|
86
|
+
@customer_option
|
|
87
|
+
def env_cmd(script_id: int, pairs: tuple[str, ...], customer: str | None) -> None:
|
|
88
|
+
"""Set environment variables (KEY=VALUE pairs).
|
|
89
|
+
|
|
90
|
+
If no pairs given, lists current variables.
|
|
91
|
+
"""
|
|
92
|
+
c = _client(customer)
|
|
93
|
+
if not pairs:
|
|
94
|
+
variables = c.list_variables(script_id)
|
|
95
|
+
if not variables:
|
|
96
|
+
click.echo("No variables set.")
|
|
97
|
+
return
|
|
98
|
+
for v in variables:
|
|
99
|
+
click.echo(f" {v.get('Name', '?')}={v.get('DefaultValue', '')}")
|
|
100
|
+
return
|
|
101
|
+
for pair in pairs:
|
|
102
|
+
if "=" not in pair:
|
|
103
|
+
raise click.UsageError(f"Invalid format: {pair!r}. Use KEY=VALUE.")
|
|
104
|
+
key, _, value = pair.partition("=")
|
|
105
|
+
c.upsert_variable(script_id, key, value)
|
|
106
|
+
click.echo(f" Set {key}")
|
granny/cli/email.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""``granny email`` — Mailjet + AWS WorkMail management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.group()
|
|
11
|
+
def email() -> None:
|
|
12
|
+
"""Email management (Mailjet, WorkMail)."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# -- Mailjet subgroup ---------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@email.group("mailjet")
|
|
19
|
+
def mailjet() -> None:
|
|
20
|
+
"""Mailjet sender/DNS and contact management."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@mailjet.command("setup-dns")
|
|
24
|
+
@click.argument("domain")
|
|
25
|
+
@click.option("--check", "check_only", is_flag=True, help="Read-only DNS status check.")
|
|
26
|
+
@click.option("--dry-run", is_flag=True)
|
|
27
|
+
def mailjet_setup_dns(domain: str, check_only: bool, dry_run: bool) -> None:
|
|
28
|
+
"""Register domain sender and retrieve DNS auth settings.
|
|
29
|
+
|
|
30
|
+
Prints the DKIM, SPF, and ownership values you need to create as TXT
|
|
31
|
+
records. Use ``granny dns`` to create them.
|
|
32
|
+
"""
|
|
33
|
+
from granny.email.mailjet import MailjetClient
|
|
34
|
+
|
|
35
|
+
c = MailjetClient()
|
|
36
|
+
|
|
37
|
+
if check_only:
|
|
38
|
+
sender = c.find_sender(domain)
|
|
39
|
+
if not sender:
|
|
40
|
+
click.echo(f"No sender registered for *@{domain}")
|
|
41
|
+
raise SystemExit(1)
|
|
42
|
+
dns = c.get_dns_settings(domain)
|
|
43
|
+
click.echo(f"DKIM status: {dns.get('DKIMStatus')}")
|
|
44
|
+
click.echo(f"SPF status: {dns.get('SPFStatus')}")
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
sender = c.get_or_create_sender(domain)
|
|
48
|
+
click.echo(f"Sender: *@{domain} (ID={sender.get('ID')}, Status={sender.get('Status')})")
|
|
49
|
+
|
|
50
|
+
dns = c.get_dns_settings(domain)
|
|
51
|
+
click.echo("")
|
|
52
|
+
click.echo("DNS records to create:")
|
|
53
|
+
click.echo(f" DKIM: TXT {dns.get('DKIMRecordName', 'mailjet._domainkey.' + domain)}")
|
|
54
|
+
click.echo(f" {dns.get('DKIMRecordValue', '(not available)')}")
|
|
55
|
+
click.echo(f" SPF: TXT {domain}")
|
|
56
|
+
click.echo(f" {dns.get('SPFRecordValue', 'v=spf1 include:spf.mailjet.com ~all')}")
|
|
57
|
+
if dns.get("OwnerShipToken"):
|
|
58
|
+
click.echo(f" Owner: TXT {dns.get('OwnerShipTokenRecordName', domain)}")
|
|
59
|
+
click.echo(f" {dns.get('OwnerShipToken')}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@mailjet.command("check-contact")
|
|
63
|
+
@click.argument("email_address")
|
|
64
|
+
def mailjet_check_contact(email_address: str) -> None:
|
|
65
|
+
"""Check a contact's status (blocked, active, bounce history)."""
|
|
66
|
+
from granny.email.mailjet_contacts import MailjetContactClient
|
|
67
|
+
|
|
68
|
+
c = MailjetContactClient()
|
|
69
|
+
contact = c.get_contact(email_address)
|
|
70
|
+
if not contact:
|
|
71
|
+
click.echo(f"Contact not found: {email_address}")
|
|
72
|
+
raise SystemExit(1)
|
|
73
|
+
|
|
74
|
+
excluded = contact.get("IsExcludedFromCampaigns", False)
|
|
75
|
+
status = "BLOCKED" if excluded else "ACTIVE"
|
|
76
|
+
click.echo(f"Email: {contact.get('Email')}")
|
|
77
|
+
click.echo(f"Status: {status}")
|
|
78
|
+
click.echo(f"ID: {contact.get('ID')}")
|
|
79
|
+
click.echo(f"Created: {contact.get('CreatedAt', 'N/A')}")
|
|
80
|
+
click.echo(f"Activity: {contact.get('LastActivityAt', 'N/A')}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@mailjet.command("unblock")
|
|
84
|
+
@click.argument("email_address")
|
|
85
|
+
@click.option("--yes", is_flag=True, help="Skip confirmation.")
|
|
86
|
+
def mailjet_unblock(email_address: str, yes: bool) -> None:
|
|
87
|
+
"""Unblock a contact excluded from campaigns."""
|
|
88
|
+
from granny.email.mailjet_contacts import MailjetContactClient
|
|
89
|
+
|
|
90
|
+
c = MailjetContactClient()
|
|
91
|
+
contact = c.get_contact(email_address)
|
|
92
|
+
if not contact:
|
|
93
|
+
click.echo(f"Contact not found: {email_address}")
|
|
94
|
+
raise SystemExit(1)
|
|
95
|
+
if not contact.get("IsExcludedFromCampaigns"):
|
|
96
|
+
click.echo(f"{email_address} is already active.")
|
|
97
|
+
return
|
|
98
|
+
if not yes:
|
|
99
|
+
click.confirm(f"Unblock {email_address}?", abort=True)
|
|
100
|
+
c.unblock_contact(email_address)
|
|
101
|
+
click.echo(f"Unblocked: {email_address}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@mailjet.command("list-blocked")
|
|
105
|
+
@click.option("--domain", default=None, help="Filter by domain.")
|
|
106
|
+
def mailjet_list_blocked(domain: str | None) -> None:
|
|
107
|
+
"""List contacts excluded from campaigns."""
|
|
108
|
+
from granny.email.mailjet_contacts import MailjetContactClient
|
|
109
|
+
|
|
110
|
+
c = MailjetContactClient()
|
|
111
|
+
contacts = c.list_all_excluded(domain=domain)
|
|
112
|
+
if not contacts:
|
|
113
|
+
click.echo("No blocked contacts found.")
|
|
114
|
+
return
|
|
115
|
+
click.echo(f"Blocked contacts ({len(contacts)}):")
|
|
116
|
+
for ct in contacts:
|
|
117
|
+
click.echo(f" {ct.get('Email', '?'):<40} ID={ct.get('ID')}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# -- WorkMail subgroup --------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@email.group("workmail")
|
|
124
|
+
def workmail() -> None:
|
|
125
|
+
"""AWS WorkMail user management."""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@workmail.command("list-orgs")
|
|
129
|
+
@click.option("--region", default=None, help="AWS region (default: us-east-1).")
|
|
130
|
+
def workmail_list_orgs(region: str | None) -> None:
|
|
131
|
+
"""List WorkMail organizations."""
|
|
132
|
+
from granny.email.workmail import WorkMailManager
|
|
133
|
+
|
|
134
|
+
wm = WorkMailManager(region=region)
|
|
135
|
+
orgs = wm.list_organizations()
|
|
136
|
+
if not orgs:
|
|
137
|
+
click.echo("No organizations found.")
|
|
138
|
+
return
|
|
139
|
+
for org in orgs:
|
|
140
|
+
click.echo(
|
|
141
|
+
f" {org.get('OrganizationId', '?'):<40} "
|
|
142
|
+
f"{org.get('DefaultMailDomain', org.get('Alias', '?')):<30} "
|
|
143
|
+
f"{org.get('State', '?')}"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@workmail.command("list-users")
|
|
148
|
+
@click.argument("domain")
|
|
149
|
+
@click.option("--region", default=None)
|
|
150
|
+
def workmail_list_users(domain: str, region: str | None) -> None:
|
|
151
|
+
"""List users in a WorkMail organization."""
|
|
152
|
+
from granny.email.workmail import WorkMailManager
|
|
153
|
+
|
|
154
|
+
wm = WorkMailManager(region=region)
|
|
155
|
+
org_id = wm.get_org_id(domain)
|
|
156
|
+
users = wm.list_users(org_id)
|
|
157
|
+
for u in users:
|
|
158
|
+
if u.get("UserRole") == "SYSTEM_USER":
|
|
159
|
+
continue
|
|
160
|
+
click.echo(
|
|
161
|
+
f" {u.get('Email', '?'):<40} "
|
|
162
|
+
f"{u.get('DisplayName', ''):<20} "
|
|
163
|
+
f"{u.get('State', '?')}"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@workmail.command("create-user")
|
|
168
|
+
@click.argument("domain")
|
|
169
|
+
@click.option("--email", "user_email", required=True)
|
|
170
|
+
@click.option("--display-name", required=True)
|
|
171
|
+
@click.option("--first-name", default=None)
|
|
172
|
+
@click.option("--last-name", default=None)
|
|
173
|
+
@click.option("--region", default=None)
|
|
174
|
+
def workmail_create_user(
|
|
175
|
+
domain: str,
|
|
176
|
+
user_email: str,
|
|
177
|
+
display_name: str,
|
|
178
|
+
first_name: str | None,
|
|
179
|
+
last_name: str | None,
|
|
180
|
+
region: str | None,
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Create a WorkMail user (password prompted interactively)."""
|
|
183
|
+
from granny.email.workmail import WorkMailManager
|
|
184
|
+
|
|
185
|
+
password = getpass.getpass("Password: ")
|
|
186
|
+
password2 = getpass.getpass("Confirm: ")
|
|
187
|
+
if password != password2:
|
|
188
|
+
raise click.ClickException("Passwords do not match.")
|
|
189
|
+
|
|
190
|
+
wm = WorkMailManager(region=region)
|
|
191
|
+
org_id = wm.get_org_id(domain)
|
|
192
|
+
user_id = wm.create_and_register_user(
|
|
193
|
+
org_id,
|
|
194
|
+
user_email,
|
|
195
|
+
display_name,
|
|
196
|
+
password,
|
|
197
|
+
first_name=first_name,
|
|
198
|
+
last_name=last_name,
|
|
199
|
+
)
|
|
200
|
+
click.echo(f"User created: {user_email} (ID={user_id})")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@workmail.command("reset-password")
|
|
204
|
+
@click.argument("domain")
|
|
205
|
+
@click.option("--email", "user_email", required=True)
|
|
206
|
+
@click.option("--region", default=None)
|
|
207
|
+
def workmail_reset_password(
|
|
208
|
+
domain: str, user_email: str, region: str | None
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Reset a WorkMail user's password."""
|
|
211
|
+
from granny.email.workmail import WorkMailManager
|
|
212
|
+
|
|
213
|
+
password = getpass.getpass("New password: ")
|
|
214
|
+
password2 = getpass.getpass("Confirm: ")
|
|
215
|
+
if password != password2:
|
|
216
|
+
raise click.ClickException("Passwords do not match.")
|
|
217
|
+
|
|
218
|
+
wm = WorkMailManager(region=region)
|
|
219
|
+
org_id = wm.get_org_id(domain)
|
|
220
|
+
user = wm.find_user_by_email(org_id, user_email)
|
|
221
|
+
if not user:
|
|
222
|
+
raise click.ClickException(f"User not found: {user_email}")
|
|
223
|
+
wm.reset_password(org_id, user["Id"], password)
|
|
224
|
+
click.echo(f"Password reset for {user_email}.")
|
granny/cli/main.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Granny CLI entrypoint.
|
|
2
|
+
|
|
3
|
+
Thin wrapper around library functions -- no business logic here.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from dotenv import load_dotenv
|
|
10
|
+
|
|
11
|
+
from granny import __version__
|
|
12
|
+
|
|
13
|
+
# Auto-load a project-local dotenv from CWD so explicit values override the
|
|
14
|
+
# vault. `.deploy.env` is preferred (matches scripts/deploy.sh), `.env` is a
|
|
15
|
+
# fallback. override=True means these entries beat anything already exported.
|
|
16
|
+
for _candidate in (".deploy.env", ".env"):
|
|
17
|
+
_path = Path.cwd() / _candidate
|
|
18
|
+
if _path.is_file():
|
|
19
|
+
load_dotenv(_path, override=True)
|
|
20
|
+
break
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@click.group()
|
|
24
|
+
@click.version_option(__version__, prog_name="granny")
|
|
25
|
+
@click.option("--verbose", is_flag=True, help="Show debug output")
|
|
26
|
+
@click.option("--quiet", is_flag=True, help="Suppress warnings")
|
|
27
|
+
def cli(verbose: bool, quiet: bool) -> None:
|
|
28
|
+
"""Granny -- Cloud tools collection."""
|
|
29
|
+
import logging
|
|
30
|
+
|
|
31
|
+
if verbose:
|
|
32
|
+
logging.basicConfig(level=logging.DEBUG, format="%(name)s: %(message)s")
|
|
33
|
+
elif quiet:
|
|
34
|
+
logging.basicConfig(level=logging.ERROR)
|
|
35
|
+
else:
|
|
36
|
+
logging.basicConfig(level=logging.WARNING)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Register command groups -------------------------------------------------
|
|
40
|
+
|
|
41
|
+
def _register_commands() -> None:
|
|
42
|
+
"""Register available command groups."""
|
|
43
|
+
try:
|
|
44
|
+
from granny.cli.credentials import credentials
|
|
45
|
+
cli.add_command(credentials)
|
|
46
|
+
except ImportError:
|
|
47
|
+
pass
|
|
48
|
+
try:
|
|
49
|
+
from granny.cli.analyze import analyze
|
|
50
|
+
cli.add_command(analyze)
|
|
51
|
+
except ImportError:
|
|
52
|
+
pass
|
|
53
|
+
try:
|
|
54
|
+
from granny.cli.cdn import cdn
|
|
55
|
+
cli.add_command(cdn)
|
|
56
|
+
except ImportError:
|
|
57
|
+
pass
|
|
58
|
+
try:
|
|
59
|
+
from granny.cli.create import create
|
|
60
|
+
cli.add_command(create)
|
|
61
|
+
except ImportError:
|
|
62
|
+
pass
|
|
63
|
+
try:
|
|
64
|
+
from granny.cli.docker import docker
|
|
65
|
+
cli.add_command(docker)
|
|
66
|
+
except ImportError:
|
|
67
|
+
pass
|
|
68
|
+
try:
|
|
69
|
+
from granny.cli.dns import dns
|
|
70
|
+
cli.add_command(dns)
|
|
71
|
+
except ImportError:
|
|
72
|
+
pass
|
|
73
|
+
try:
|
|
74
|
+
from granny.cli.storage import storage
|
|
75
|
+
cli.add_command(storage)
|
|
76
|
+
except ImportError:
|
|
77
|
+
pass
|
|
78
|
+
try:
|
|
79
|
+
from granny.cli.edge import edge
|
|
80
|
+
cli.add_command(edge)
|
|
81
|
+
except ImportError:
|
|
82
|
+
pass
|
|
83
|
+
try:
|
|
84
|
+
from granny.cli.serverless import serverless
|
|
85
|
+
cli.add_command(serverless)
|
|
86
|
+
except ImportError:
|
|
87
|
+
pass
|
|
88
|
+
try:
|
|
89
|
+
from granny.cli.email import email
|
|
90
|
+
cli.add_command(email)
|
|
91
|
+
except ImportError:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
_register_commands()
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
cli()
|
granny/cli/serverless.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""``granny serverless`` — Scaleway Functions (FaaS) management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import zipfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _client(region: str):
|
|
17
|
+
from granny.serverless.scaleway import ScalewayFunctionsClient
|
|
18
|
+
|
|
19
|
+
return ScalewayFunctionsClient(region=region)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
region_option = click.option(
|
|
23
|
+
"--region",
|
|
24
|
+
default="fr-par",
|
|
25
|
+
show_default=True,
|
|
26
|
+
type=click.Choice(["fr-par", "nl-ams", "pl-waw"]),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@click.group()
|
|
31
|
+
def serverless() -> None:
|
|
32
|
+
"""Scaleway Functions (FaaS) management."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@serverless.command("list-namespaces")
|
|
36
|
+
@region_option
|
|
37
|
+
def list_namespaces(region: str) -> None:
|
|
38
|
+
"""List all function namespaces."""
|
|
39
|
+
c = _client(region)
|
|
40
|
+
for ns in c.list_namespaces():
|
|
41
|
+
click.echo(f" {ns.get('id', '?')[:12]} {ns.get('name', '?'):<30} {ns.get('status', '?')}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@serverless.command("create-namespace")
|
|
45
|
+
@click.argument("name")
|
|
46
|
+
@click.option("--description", default="")
|
|
47
|
+
@region_option
|
|
48
|
+
def create_namespace(name: str, description: str, region: str) -> None:
|
|
49
|
+
"""Create a function namespace."""
|
|
50
|
+
c = _client(region)
|
|
51
|
+
result = c.create_namespace(name, description=description)
|
|
52
|
+
click.echo(f"Namespace created: {result.get('id')} ({result.get('name')})")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@serverless.command("list-functions")
|
|
56
|
+
@click.argument("namespace_id")
|
|
57
|
+
@region_option
|
|
58
|
+
def list_functions(namespace_id: str, region: str) -> None:
|
|
59
|
+
"""List functions in a namespace."""
|
|
60
|
+
c = _client(region)
|
|
61
|
+
for fn in c.list_functions(namespace_id):
|
|
62
|
+
click.echo(
|
|
63
|
+
f" {fn.get('id', '?')[:12]} {fn.get('name', '?'):<20} "
|
|
64
|
+
f"runtime={fn.get('runtime', '?')} status={fn.get('status', '?')}"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@serverless.command("create-function")
|
|
69
|
+
@click.argument("namespace_id")
|
|
70
|
+
@click.argument("name")
|
|
71
|
+
@click.option(
|
|
72
|
+
"--runtime",
|
|
73
|
+
default="node20",
|
|
74
|
+
show_default=True,
|
|
75
|
+
type=click.Choice(
|
|
76
|
+
["node20", "node22", "python311", "python312", "go121", "rust165"]
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
@click.option("--handler", default="handler.handler", show_default=True)
|
|
80
|
+
@click.option("--memory", default=256, show_default=True, type=int)
|
|
81
|
+
@click.option("--timeout", default="30s", show_default=True)
|
|
82
|
+
@click.option("--min-scale", default=0, show_default=True, type=int)
|
|
83
|
+
@click.option("--max-scale", default=5, show_default=True, type=int)
|
|
84
|
+
@region_option
|
|
85
|
+
def create_function(
|
|
86
|
+
namespace_id: str,
|
|
87
|
+
name: str,
|
|
88
|
+
runtime: str,
|
|
89
|
+
handler: str,
|
|
90
|
+
memory: int,
|
|
91
|
+
timeout: str,
|
|
92
|
+
min_scale: int,
|
|
93
|
+
max_scale: int,
|
|
94
|
+
region: str,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Create a function in a namespace."""
|
|
97
|
+
c = _client(region)
|
|
98
|
+
result = c.create_function(
|
|
99
|
+
namespace_id,
|
|
100
|
+
name,
|
|
101
|
+
runtime=runtime,
|
|
102
|
+
handler=handler,
|
|
103
|
+
memory_limit=memory,
|
|
104
|
+
timeout=timeout,
|
|
105
|
+
min_scale=min_scale,
|
|
106
|
+
max_scale=max_scale,
|
|
107
|
+
)
|
|
108
|
+
click.echo(f"Function created: {result.get('id')} ({result.get('name')})")
|
|
109
|
+
if result.get("domain_name"):
|
|
110
|
+
click.echo(f"Endpoint: https://{result['domain_name']}")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# Patterns that bloat a function package without contributing to runtime.
|
|
114
|
+
# Matches both whole path segments and trailing-name globs.
|
|
115
|
+
_DEFAULT_EXCLUDES = (
|
|
116
|
+
"*.map", "*.d.ts", "*.d.ts.map",
|
|
117
|
+
"node_modules/.cache/*",
|
|
118
|
+
"node_modules/@types/*",
|
|
119
|
+
"node_modules/typescript/*",
|
|
120
|
+
"node_modules/jest*/*",
|
|
121
|
+
"node_modules/ts-jest/*",
|
|
122
|
+
"node_modules/eslint*/*",
|
|
123
|
+
"node_modules/prettier/*",
|
|
124
|
+
"node_modules/@typescript-eslint/*",
|
|
125
|
+
"node_modules/supertest/*",
|
|
126
|
+
"node_modules/jest-junit/*",
|
|
127
|
+
"node_modules/tsx/*",
|
|
128
|
+
"node_modules/.yarn-state.yml",
|
|
129
|
+
"node_modules/*/README.md",
|
|
130
|
+
"node_modules/*/CHANGELOG.md",
|
|
131
|
+
"node_modules/*/LICENSE*",
|
|
132
|
+
"node_modules/*/.github/*",
|
|
133
|
+
"node_modules/*/test/*",
|
|
134
|
+
"node_modules/*/tests/*",
|
|
135
|
+
"node_modules/*/__tests__/*",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _zip_package(source_dir: Path, out_path: Path) -> None:
|
|
140
|
+
"""Zip the contents of source_dir, applying default excludes.
|
|
141
|
+
|
|
142
|
+
Prefers the `zip` CLI if available (preserves symlinks / perms correctly
|
|
143
|
+
on Linux CI); falls back to zipfile.
|
|
144
|
+
"""
|
|
145
|
+
if shutil.which("zip"):
|
|
146
|
+
cmd = ["zip", "-qr", str(out_path), "."]
|
|
147
|
+
for pat in _DEFAULT_EXCLUDES:
|
|
148
|
+
cmd += ["-x", pat]
|
|
149
|
+
subprocess.run(cmd, cwd=source_dir, check=True)
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
# Python fallback — doesn't apply excludes (best-effort), just zips everything
|
|
153
|
+
with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
154
|
+
for path in source_dir.rglob("*"):
|
|
155
|
+
if path.is_file():
|
|
156
|
+
zf.write(path, path.relative_to(source_dir))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _parse_env_pairs(pairs: tuple[str, ...]) -> dict[str, str]:
|
|
160
|
+
env = {}
|
|
161
|
+
for p in pairs:
|
|
162
|
+
if "=" not in p:
|
|
163
|
+
raise click.BadParameter(f"Expected KEY=VALUE, got: {p}")
|
|
164
|
+
k, v = p.split("=", 1)
|
|
165
|
+
env[k.strip()] = v
|
|
166
|
+
return env
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _parse_secret_pairs(pairs: tuple[str, ...]) -> list[dict]:
|
|
170
|
+
secrets = []
|
|
171
|
+
for p in pairs:
|
|
172
|
+
if "=" not in p:
|
|
173
|
+
raise click.BadParameter(f"Expected KEY=VALUE, got: {p}")
|
|
174
|
+
k, v = p.split("=", 1)
|
|
175
|
+
secrets.append({"key": k.strip(), "value": v})
|
|
176
|
+
return secrets
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@serverless.command("deploy")
|
|
180
|
+
@click.argument("function_name")
|
|
181
|
+
@click.option(
|
|
182
|
+
"--source-dir", "source_dir",
|
|
183
|
+
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
|
184
|
+
required=True,
|
|
185
|
+
help="Directory to package (zipped and uploaded verbatim).",
|
|
186
|
+
)
|
|
187
|
+
@click.option(
|
|
188
|
+
"--namespace", "namespace_name",
|
|
189
|
+
help="Namespace name. If omitted, the function must be uniquely named across all namespaces.",
|
|
190
|
+
)
|
|
191
|
+
@click.option(
|
|
192
|
+
"--env", "env_pairs", multiple=True, metavar="KEY=VALUE",
|
|
193
|
+
help="Non-secret environment variable (repeatable).",
|
|
194
|
+
)
|
|
195
|
+
@click.option(
|
|
196
|
+
"--secret", "secret_pairs", multiple=True, metavar="KEY=VALUE",
|
|
197
|
+
help="Secret environment variable (repeatable).",
|
|
198
|
+
)
|
|
199
|
+
@click.option(
|
|
200
|
+
"--env-json",
|
|
201
|
+
help="Path to JSON file containing {env: {...}, secrets: [{key,value}]}.",
|
|
202
|
+
)
|
|
203
|
+
@region_option
|
|
204
|
+
def deploy(
|
|
205
|
+
function_name: str,
|
|
206
|
+
source_dir: Path,
|
|
207
|
+
namespace_name: str | None,
|
|
208
|
+
env_pairs: tuple[str, ...],
|
|
209
|
+
secret_pairs: tuple[str, ...],
|
|
210
|
+
env_json: str | None,
|
|
211
|
+
region: str,
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Zip ``--source-dir``, upload, sync env vars, and deploy.
|
|
214
|
+
|
|
215
|
+
Example:
|
|
216
|
+
granny serverless deploy bessa-wissa-api \\
|
|
217
|
+
--source-dir backend/dist-bundle \\
|
|
218
|
+
--namespace bessa-wissa \\
|
|
219
|
+
--env NODE_ENV=production \\
|
|
220
|
+
--secret STOZ3N_API_TOKEN=$STOZ3N_API_TOKEN
|
|
221
|
+
"""
|
|
222
|
+
c = _client(region)
|
|
223
|
+
|
|
224
|
+
# Resolve function id
|
|
225
|
+
function = None
|
|
226
|
+
if namespace_name:
|
|
227
|
+
ns = c.find_namespace(namespace_name)
|
|
228
|
+
if not ns:
|
|
229
|
+
raise click.ClickException(f"Namespace '{namespace_name}' not found")
|
|
230
|
+
function = c.find_function(ns["id"], function_name)
|
|
231
|
+
else:
|
|
232
|
+
for ns in c.list_namespaces():
|
|
233
|
+
fn = c.find_function(ns["id"], function_name)
|
|
234
|
+
if fn:
|
|
235
|
+
if function:
|
|
236
|
+
raise click.ClickException(
|
|
237
|
+
f"Function '{function_name}' exists in multiple namespaces; use --namespace"
|
|
238
|
+
)
|
|
239
|
+
function = fn
|
|
240
|
+
|
|
241
|
+
if not function:
|
|
242
|
+
raise click.ClickException(
|
|
243
|
+
f"Function '{function_name}' not found. Create it first with `granny serverless create-function`."
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
click.echo(f"Target: {function['name']} (id={function['id']})")
|
|
247
|
+
|
|
248
|
+
# Merge env/secret inputs
|
|
249
|
+
env_vars = _parse_env_pairs(env_pairs)
|
|
250
|
+
secrets = _parse_secret_pairs(secret_pairs)
|
|
251
|
+
if env_json:
|
|
252
|
+
payload = json.loads(Path(env_json).read_text())
|
|
253
|
+
env_vars.update(payload.get("env", {}))
|
|
254
|
+
secrets.extend(payload.get("secrets", []))
|
|
255
|
+
|
|
256
|
+
# Package + deploy
|
|
257
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
258
|
+
zip_path = Path(tmp) / f"{function_name}.zip"
|
|
259
|
+
click.echo(f"Packaging {source_dir} -> {zip_path.name}")
|
|
260
|
+
_zip_package(source_dir, zip_path)
|
|
261
|
+
size_mb = zip_path.stat().st_size / 1024 / 1024
|
|
262
|
+
click.echo(f"Package size: {size_mb:.1f} MB")
|
|
263
|
+
if size_mb > 80:
|
|
264
|
+
click.echo("WARN: package > 80 MB (Scaleway limit is 100 MB)", err=True)
|
|
265
|
+
|
|
266
|
+
result = c.deploy_package(
|
|
267
|
+
function["id"],
|
|
268
|
+
str(zip_path),
|
|
269
|
+
env=env_vars or None,
|
|
270
|
+
secrets=secrets or None,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
status = result.get("status", "unknown")
|
|
274
|
+
click.echo(f"Deploy initiated: status={status}")
|
|
275
|
+
if result.get("domain_name"):
|
|
276
|
+
click.echo(f"Endpoint: https://{result['domain_name']}")
|
|
277
|
+
if status not in ("pending", "ready", "creating"):
|
|
278
|
+
sys.exit(1)
|