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.
Files changed (68) hide show
  1. granny/__init__.py +19 -0
  2. granny/analyze/__init__.py +6 -0
  3. granny/analyze/lambdas.py +59 -0
  4. granny/analyze/vpcs.py +57 -0
  5. granny/cdn/__init__.py +9 -0
  6. granny/cdn/bunny.py +231 -0
  7. granny/cli/__init__.py +0 -0
  8. granny/cli/analyze.py +66 -0
  9. granny/cli/cdn.py +210 -0
  10. granny/cli/create.py +94 -0
  11. granny/cli/credentials.py +99 -0
  12. granny/cli/dns.py +290 -0
  13. granny/cli/docker.py +165 -0
  14. granny/cli/edge.py +106 -0
  15. granny/cli/email.py +224 -0
  16. granny/cli/main.py +98 -0
  17. granny/cli/serverless.py +278 -0
  18. granny/cli/storage.py +249 -0
  19. granny/create/__init__.py +4 -0
  20. granny/create/auto_certificate.py +1899 -0
  21. granny/create/cloudfront-security-headers.js +53 -0
  22. granny/create/manage-dns.sh +321 -0
  23. granny/create/manage_mailjet_contacts.py +619 -0
  24. granny/create/registrars.py +363 -0
  25. granny/create/setup_aws_cloudfront.py +2808 -0
  26. granny/create/setup_bunny_edge_script.py +923 -0
  27. granny/create/setup_bunny_storage.py +1719 -0
  28. granny/create/setup_cognito_identity_pool.py +740 -0
  29. granny/create/setup_hetzner_bunny.py +1482 -0
  30. granny/create/setup_mailjet_dns.py +1103 -0
  31. granny/create/setup_private_cdn.py +547 -0
  32. granny/create/setup_s3_website.py +1512 -0
  33. granny/create/setup_scaleway_faas.py +1165 -0
  34. granny/create/setup_workmail.py +1217 -0
  35. granny/create/www-redirect-function.js +17 -0
  36. granny/credentials/__init__.py +15 -0
  37. granny/credentials/secrets.py +403 -0
  38. granny/dns/__init__.py +22 -0
  39. granny/dns/base.py +113 -0
  40. granny/dns/bunny.py +150 -0
  41. granny/dns/cloudflare.py +192 -0
  42. granny/dns/cloudns.py +162 -0
  43. granny/dns/desec.py +152 -0
  44. granny/dns/factory.py +72 -0
  45. granny/dns/hetzner.py +165 -0
  46. granny/dns/manual.py +64 -0
  47. granny/dns/records.py +29 -0
  48. granny/docker/__init__.py +5 -0
  49. granny/docker/build_base.py +204 -0
  50. granny/edge/__init__.py +5 -0
  51. granny/edge/bunny.py +147 -0
  52. granny/email/__init__.py +7 -0
  53. granny/email/mailjet.py +119 -0
  54. granny/email/mailjet_contacts.py +115 -0
  55. granny/email/ses_forwarding.py +281 -0
  56. granny/email/workmail.py +145 -0
  57. granny/report.py +128 -0
  58. granny/serverless/__init__.py +5 -0
  59. granny/serverless/scaleway.py +264 -0
  60. granny/storage/__init__.py +7 -0
  61. granny/storage/aws.py +113 -0
  62. granny/storage/bunny.py +98 -0
  63. granny/storage/hetzner.py +118 -0
  64. granny_devops-0.4.0.dist-info/METADATA +445 -0
  65. granny_devops-0.4.0.dist-info/RECORD +68 -0
  66. granny_devops-0.4.0.dist-info/WHEEL +4 -0
  67. granny_devops-0.4.0.dist-info/entry_points.txt +2 -0
  68. 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()
@@ -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)