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/create.py ADDED
@@ -0,0 +1,94 @@
1
+ """`granny create ...` -- dispatch over granny/create/*.py.
2
+
3
+ Each subcommand loads the matching script from the in-package ``granny.create``
4
+ module and invokes its argparse ``main()`` with forwarded argv. Scripts live
5
+ inside the package so the CLI is self-contained (no external ``tools/`` dir).
6
+
7
+ Adding a new command: drop the script in ``granny/create/`` and add an entry
8
+ to ``COMMANDS`` below.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import importlib
14
+ import sys
15
+ from pathlib import Path
16
+ from types import ModuleType
17
+
18
+ import click
19
+
20
+ _CREATE_DIR = Path(__file__).resolve().parent.parent / "create"
21
+
22
+ # subcommand name -> script filename (relative to granny/create/)
23
+ COMMANDS: dict[str, str] = {
24
+ "aws-cloudfront": "setup_aws_cloudfront.py",
25
+ "bunny-edge-script": "setup_bunny_edge_script.py",
26
+ "bunny-storage": "setup_bunny_storage.py",
27
+ "cognito-identity-pool": "setup_cognito_identity_pool.py",
28
+ "hetzner-bunny": "setup_hetzner_bunny.py",
29
+ "mailjet-dns": "setup_mailjet_dns.py",
30
+ "private-cdn": "setup_private_cdn.py",
31
+ "s3-website": "setup_s3_website.py",
32
+ "scaleway-faas": "setup_scaleway_faas.py",
33
+ "workmail": "setup_workmail.py",
34
+ "auto-certificate": "auto_certificate.py",
35
+ "mailjet-contacts": "manage_mailjet_contacts.py",
36
+ }
37
+
38
+
39
+ def _load_script(filename: str) -> ModuleType:
40
+ """Import a script from granny.create by filename."""
41
+ path = _CREATE_DIR / filename
42
+ if not path.is_file():
43
+ raise click.ClickException(f"Script not found: {path}")
44
+
45
+ module_name = f"granny.create.{path.stem}"
46
+ try:
47
+ return importlib.import_module(module_name)
48
+ except Exception as exc:
49
+ raise click.ClickException(f"Cannot load {module_name}: {exc}") from exc
50
+
51
+
52
+ def _make_command(name: str, filename: str) -> click.Command:
53
+ """Build a click command that forwards argv to a script's main()."""
54
+
55
+ @click.command(
56
+ name=name,
57
+ context_settings={
58
+ "ignore_unknown_options": True,
59
+ "allow_extra_args": True,
60
+ "help_option_names": [], # let the script's own -h/--help through
61
+ },
62
+ help=(
63
+ f"Run granny/create/{filename}. All arguments are forwarded; "
64
+ f"use `-- --help` to see the script's own flags."
65
+ ),
66
+ )
67
+ @click.argument("script_args", nargs=-1, type=click.UNPROCESSED)
68
+ def _cmd(script_args: tuple[str, ...]) -> None:
69
+ module = _load_script(filename)
70
+ if not hasattr(module, "main"):
71
+ raise click.ClickException(f"{filename} has no main() function")
72
+
73
+ original_argv = sys.argv
74
+ sys.argv = [filename, *script_args]
75
+ try:
76
+ rc = module.main()
77
+ except SystemExit as exc:
78
+ rc = exc.code if isinstance(exc.code, int) else (1 if exc.code else 0)
79
+ finally:
80
+ sys.argv = original_argv
81
+
82
+ if rc:
83
+ sys.exit(rc)
84
+
85
+ return _cmd
86
+
87
+
88
+ @click.group()
89
+ def create() -> None:
90
+ """Provision and configure cloud resources."""
91
+
92
+
93
+ for _name, _filename in COMMANDS.items():
94
+ create.add_command(_make_command(_name, _filename))
@@ -0,0 +1,99 @@
1
+ """CLI commands for credential management."""
2
+
3
+ import sys
4
+
5
+ import click
6
+
7
+
8
+ @click.group()
9
+ def credentials() -> None:
10
+ """Manage credentials and vault secrets."""
11
+
12
+
13
+ @credentials.command()
14
+ @click.argument("name")
15
+ def get(name: str) -> None:
16
+ """Retrieve a secret by env var name (e.g. BUNNY_API_KEY)."""
17
+ from granny.credentials import get_secret
18
+
19
+ value = get_secret(name)
20
+ if value:
21
+ click.echo(value)
22
+ else:
23
+ click.echo(f"Secret {name!r} not found", err=True)
24
+ sys.exit(1)
25
+
26
+
27
+ @credentials.command()
28
+ @click.option(
29
+ "--keys",
30
+ default=None,
31
+ help="Comma-separated list of env var names (default: all registered)",
32
+ )
33
+ def status(keys: str | None) -> None:
34
+ """Show resolution status for registered secrets."""
35
+ from granny.credentials.secrets import SECRET_MAP, get_secret
36
+
37
+ key_list = keys.split(",") if keys else list(SECRET_MAP.keys())
38
+
39
+ for key in sorted(key_list):
40
+ value = get_secret(key)
41
+ indicator = "ok" if value else "MISSING"
42
+ click.echo(f" {key:<30} {indicator}")
43
+
44
+
45
+ @credentials.command("show-key")
46
+ @click.argument("name")
47
+ @click.option("--masked/--no-masked", default=True, help="Mask the middle of the key (default: masked)")
48
+ def show_key(name: str, masked: bool) -> None:
49
+ """Print a credential by name from the OS keystore (Windows Credential Manager / Keychain).
50
+
51
+ Requires the [vault] extra: pip install granny-devops[vault]
52
+ """
53
+ try:
54
+ from locke.keystore import get_credential
55
+ except ImportError:
56
+ click.echo(
57
+ "locke not installed — install with: pip install 'granny-devops[vault]'",
58
+ err=True,
59
+ )
60
+ sys.exit(1)
61
+
62
+ try:
63
+ value = get_credential(name)
64
+ except Exception as e:
65
+ click.echo(f"Keystore access failed: {e}", err=True)
66
+ sys.exit(1)
67
+
68
+ if not value:
69
+ click.echo(f"{name} not found in keystore", err=True)
70
+ sys.exit(1)
71
+
72
+ if masked:
73
+ display = f"{value[:4]}…{value[-4:]}" if len(value) > 8 else "****"
74
+ click.echo(f"{display} (length={len(value)})")
75
+ else:
76
+ click.echo(value)
77
+
78
+
79
+ @credentials.command("test-vault")
80
+ def test_vault() -> None:
81
+ """Test connectivity to Vaultwarden via Locke.
82
+
83
+ Requires the [vault] extra: pip install granny-devops[vault]
84
+ """
85
+ from granny.credentials.secrets import _LOCKE_AVAILABLE, _get_vault_client
86
+
87
+ if not _LOCKE_AVAILABLE:
88
+ click.echo(
89
+ "locke not installed — install with: pip install 'granny-devops[vault]'",
90
+ err=True,
91
+ )
92
+ sys.exit(1)
93
+
94
+ client = _get_vault_client()
95
+ if client:
96
+ click.echo("Vault connection: OK")
97
+ else:
98
+ click.echo("Vault connection: FAILED", err=True)
99
+ sys.exit(1)
granny/cli/dns.py ADDED
@@ -0,0 +1,290 @@
1
+ """``granny dns`` — provider-agnostic DNS record CRUD.
2
+
3
+ Replaces ``tools/create/manage-dns.sh`` with a native Python CLI that works
4
+ against Cloudflare, Bunny, Hetzner, deSEC, ClouDNS, or a manual stub. The
5
+ legacy bash script still works; this command is the preferred entry point.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import os
13
+
14
+ import click
15
+ import requests
16
+
17
+ from granny.dns import DNSRecord, get_provider, provider_choices
18
+ from granny.dns.base import DNSProvider
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ DEFAULT_PROVIDER_ENV = "GRANNY_DNS_PROVIDER"
23
+ DEFAULT_DOMAIN_ENV = "GRANNY_DNS_DOMAIN"
24
+
25
+
26
+ def _split_fqdn(value: str, domain: str | None) -> tuple[str, str]:
27
+ """Split ``value`` into (short_name, zone_name).
28
+
29
+ - ``api.example.com`` + ``None`` → (``api``, ``example.com``)
30
+ - ``example.com`` + ``None`` → (``@``, ``example.com``)
31
+ - ``api`` + ``example.com`` → (``api``, ``example.com``)
32
+ - ``@`` + ``example.com`` → (``@``, ``example.com``)
33
+
34
+ Raises ``click.UsageError`` if ``value`` is a bare subdomain and
35
+ ``domain`` is not supplied.
36
+ """
37
+ value = value.strip().rstrip(".")
38
+ if not value:
39
+ raise click.UsageError("NAME cannot be empty")
40
+
41
+ if domain:
42
+ domain = domain.strip().rstrip(".").lower()
43
+
44
+ if "." in value:
45
+ value_lower = value.lower()
46
+ if domain and value_lower != domain and not value_lower.endswith(f".{domain}"):
47
+ raise click.UsageError(
48
+ f"{value!r} is not under domain {domain!r}"
49
+ )
50
+ if domain is None:
51
+ return ("@", value_lower)
52
+ if value_lower == domain:
53
+ return ("@", domain)
54
+ short = value_lower[: -(len(domain) + 1)]
55
+ return (short, domain)
56
+
57
+ # Bare label — needs a domain.
58
+ if not domain:
59
+ raise click.UsageError(
60
+ "Bare subdomain requires --domain (or set $GRANNY_DNS_DOMAIN)"
61
+ )
62
+ if value == "@":
63
+ return ("@", domain)
64
+ return (value, domain)
65
+
66
+
67
+ def _provider_instance(name: str | None) -> DNSProvider:
68
+ resolved = name or os.environ.get(DEFAULT_PROVIDER_ENV)
69
+ if not resolved:
70
+ raise click.UsageError(
71
+ "--provider is required (or set $GRANNY_DNS_PROVIDER). "
72
+ f"Choices: {', '.join(provider_choices())}"
73
+ )
74
+ try:
75
+ return get_provider(resolved)
76
+ except ValueError as exc:
77
+ raise click.UsageError(str(exc))
78
+ except Exception as exc:
79
+ raise click.ClickException(f"Failed to initialize provider {resolved!r}: {exc}")
80
+
81
+
82
+ def _detect_public_ip() -> str:
83
+ """Best-effort public IP lookup — mirrors manage-dns.sh."""
84
+ for url in (
85
+ "https://api.ipify.org",
86
+ "https://ifconfig.me",
87
+ "https://icanhazip.com",
88
+ ):
89
+ try:
90
+ resp = requests.get(url, timeout=5)
91
+ if resp.status_code == 200 and resp.text.strip():
92
+ return resp.text.strip()
93
+ except requests.RequestException:
94
+ continue
95
+ raise click.ClickException("Could not auto-detect public IP; pass VALUE explicitly.")
96
+
97
+
98
+ def _format_table(records: list[DNSRecord]) -> str:
99
+ if not records:
100
+ return "(no records)"
101
+ rows = [
102
+ (rec.type, rec.fqdn or rec.name or "@", rec.value, str(rec.ttl))
103
+ for rec in records
104
+ ]
105
+ widths = [max(len(r[i]) for r in rows) for i in range(4)]
106
+ return "\n".join(
107
+ " ".join(row[i].ljust(widths[i]) for i in range(4)) for row in rows
108
+ )
109
+
110
+
111
+ def _format_record(record: DNSRecord) -> str:
112
+ payload = {
113
+ "name": record.fqdn or record.name,
114
+ "type": record.type,
115
+ "value": record.value,
116
+ "ttl": record.ttl,
117
+ "id": record.id,
118
+ }
119
+ return json.dumps(payload, indent=2)
120
+
121
+
122
+ provider_option = click.option(
123
+ "--provider",
124
+ "-p",
125
+ default=None,
126
+ help=(
127
+ "DNS provider. Choices: "
128
+ + ", ".join(provider_choices())
129
+ + f". Default from ${DEFAULT_PROVIDER_ENV}."
130
+ ),
131
+ )
132
+ domain_option = click.option(
133
+ "--domain",
134
+ "-d",
135
+ default=lambda: os.environ.get(DEFAULT_DOMAIN_ENV),
136
+ help=f"Zone/domain to operate on. Default from ${DEFAULT_DOMAIN_ENV}.",
137
+ )
138
+
139
+
140
+ @click.group()
141
+ def dns() -> None:
142
+ """Provider-agnostic DNS record management."""
143
+
144
+
145
+ @dns.command("list")
146
+ @provider_option
147
+ @domain_option
148
+ @click.option("--type", "record_type", default=None, help="Filter by record type.")
149
+ def list_cmd(provider: str | None, domain: str | None, record_type: str | None) -> None:
150
+ """List records in a zone."""
151
+ if not domain:
152
+ raise click.UsageError("--domain is required")
153
+ dns_provider = _provider_instance(provider)
154
+ zone_id = dns_provider.get_zone_id(domain)
155
+ records = dns_provider.list_records(zone_id, record_type=record_type)
156
+ click.echo(_format_table(records))
157
+
158
+
159
+ @dns.command("get")
160
+ @click.argument("name")
161
+ @provider_option
162
+ @domain_option
163
+ @click.option("--type", "record_type", default="A", show_default=True)
164
+ def get_cmd(
165
+ name: str, provider: str | None, domain: str | None, record_type: str
166
+ ) -> None:
167
+ """Get a record by short name or FQDN."""
168
+ short, zone_name = _split_fqdn(name, domain)
169
+ dns_provider = _provider_instance(provider)
170
+ zone_id = dns_provider.get_zone_id(zone_name)
171
+ record = dns_provider.get_record(zone_id, short, record_type)
172
+ if not record:
173
+ full = zone_name if short == "@" else f"{short}.{zone_name}"
174
+ click.echo(f"No {record_type} record found for {full}")
175
+ raise SystemExit(1)
176
+ click.echo(_format_record(record))
177
+
178
+
179
+ @dns.command("add")
180
+ @click.argument("name")
181
+ @click.argument("value", required=False)
182
+ @provider_option
183
+ @domain_option
184
+ @click.option("--type", "record_type", default="A", show_default=True)
185
+ @click.option("--ttl", default=300, show_default=True, type=int)
186
+ @click.option("--proxied/--no-proxied", default=False, help="Cloudflare proxy flag.")
187
+ def add_cmd(
188
+ name: str,
189
+ value: str | None,
190
+ provider: str | None,
191
+ domain: str | None,
192
+ record_type: str,
193
+ ttl: int,
194
+ proxied: bool,
195
+ ) -> None:
196
+ """Create or update a record (upsert).
197
+
198
+ If VALUE is omitted and --type is A, auto-detects your public IP (same
199
+ behavior as ``manage-dns.sh add <name>``).
200
+ """
201
+ short, zone_name = _split_fqdn(name, domain)
202
+ if value is None:
203
+ if record_type.upper() != "A":
204
+ raise click.UsageError("VALUE required unless --type A")
205
+ value = _detect_public_ip()
206
+ click.echo(f"Auto-detected public IP: {value}")
207
+ dns_provider = _provider_instance(provider)
208
+ zone_id = dns_provider.get_zone_id(zone_name)
209
+ record = dns_provider.upsert_record(
210
+ zone_id, short, record_type, value, ttl=ttl, proxied=proxied
211
+ )
212
+ full = zone_name if short == "@" else f"{short}.{zone_name}"
213
+ click.echo(f"OK {record_type} {full} -> {value}")
214
+ click.echo(_format_record(record))
215
+
216
+
217
+ @dns.command("upsert")
218
+ @click.argument("name")
219
+ @click.argument("value")
220
+ @provider_option
221
+ @domain_option
222
+ @click.option("--type", "record_type", default="A", show_default=True)
223
+ @click.option("--ttl", default=300, show_default=True, type=int)
224
+ @click.option("--proxied/--no-proxied", default=False)
225
+ def upsert_cmd(
226
+ name: str,
227
+ value: str,
228
+ provider: str | None,
229
+ domain: str | None,
230
+ record_type: str,
231
+ ttl: int,
232
+ proxied: bool,
233
+ ) -> None:
234
+ """Explicit upsert (same as ``add`` but requires VALUE)."""
235
+ short, zone_name = _split_fqdn(name, domain)
236
+ dns_provider = _provider_instance(provider)
237
+ zone_id = dns_provider.get_zone_id(zone_name)
238
+ record = dns_provider.upsert_record(
239
+ zone_id, short, record_type, value, ttl=ttl, proxied=proxied
240
+ )
241
+ click.echo(_format_record(record))
242
+
243
+
244
+ @dns.command("delete")
245
+ @click.argument("name")
246
+ @provider_option
247
+ @domain_option
248
+ @click.option("--type", "record_type", default="A", show_default=True)
249
+ @click.option("--yes", is_flag=True, help="Skip confirmation prompt.")
250
+ def delete_cmd(
251
+ name: str,
252
+ provider: str | None,
253
+ domain: str | None,
254
+ record_type: str,
255
+ yes: bool,
256
+ ) -> None:
257
+ """Delete a record."""
258
+ short, zone_name = _split_fqdn(name, domain)
259
+ dns_provider = _provider_instance(provider)
260
+ zone_id = dns_provider.get_zone_id(zone_name)
261
+ record = dns_provider.get_record(zone_id, short, record_type)
262
+ full = zone_name if short == "@" else f"{short}.{zone_name}"
263
+ if not record:
264
+ click.echo(f"No {record_type} record found for {full}")
265
+ raise SystemExit(1)
266
+ if not yes:
267
+ click.confirm(f"Delete {record_type} {full} -> {record.value}?", abort=True)
268
+ dns_provider.delete_record(zone_id, record.id)
269
+ click.echo(f"Deleted {record_type} {full}")
270
+
271
+
272
+ @dns.command("nameservers")
273
+ @click.argument("domain")
274
+ @provider_option
275
+ def nameservers_cmd(domain: str, provider: str | None) -> None:
276
+ """Print the authoritative nameservers the provider has for DOMAIN.
277
+
278
+ Useful before flipping registrar delegation with ``granny create
279
+ bunny-storage --registrar easyname``.
280
+ """
281
+ dns_provider = _provider_instance(provider)
282
+ try:
283
+ ns_list = dns_provider.get_nameservers(domain)
284
+ except NotImplementedError as exc:
285
+ raise click.ClickException(str(exc))
286
+ if not ns_list:
287
+ click.echo(f"No nameservers found for {domain}")
288
+ raise SystemExit(1)
289
+ for ns in ns_list:
290
+ click.echo(ns)
granny/cli/docker.py ADDED
@@ -0,0 +1,165 @@
1
+ """CLI commands for Docker multi-arch image builds."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+
10
+ @click.group()
11
+ def docker() -> None:
12
+ """Docker multi-arch build helpers."""
13
+
14
+
15
+ @docker.command("build-base")
16
+ @click.option(
17
+ "--context",
18
+ "context_dir",
19
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
20
+ default=Path.cwd(),
21
+ show_default="current directory",
22
+ help="Build context directory (where the Dockerfile lives).",
23
+ )
24
+ @click.option(
25
+ "--dockerfile",
26
+ type=click.Path(path_type=Path),
27
+ default=Path("Dockerfile.base"),
28
+ show_default=True,
29
+ help="Dockerfile path (relative to --context or absolute).",
30
+ )
31
+ @click.option(
32
+ "--image",
33
+ required=True,
34
+ help="Image reference without tag, e.g. budelius/icaap-climaterisk-base.",
35
+ )
36
+ @click.option(
37
+ "--hash-file",
38
+ "hash_files",
39
+ type=click.Path(path_type=Path),
40
+ multiple=True,
41
+ required=True,
42
+ help="File whose content contributes to DEPS_HASH. Pass multiple times. Order matters.",
43
+ )
44
+ @click.option(
45
+ "--platforms",
46
+ default="linux/amd64,linux/arm64",
47
+ show_default=True,
48
+ help="Comma-separated buildx platforms.",
49
+ )
50
+ @click.option(
51
+ "--builder",
52
+ default="granny-multiarch",
53
+ show_default=True,
54
+ help="buildx builder name. Created on demand.",
55
+ )
56
+ @click.option(
57
+ "--secret",
58
+ "secrets",
59
+ multiple=True,
60
+ help=(
61
+ "BuildKit secret, format: id=ENV_VAR. The value is read from the env "
62
+ "var (populated via granny.credentials) and mounted as "
63
+ "/run/secrets/<id> during build. Pass multiple times."
64
+ ),
65
+ )
66
+ @click.option(
67
+ "--login-docker-hub/--no-login-docker-hub",
68
+ default=True,
69
+ show_default=True,
70
+ help="docker login to Docker Hub using DOCKER_HUB_USER/DOCKER_HUB_TOKEN from granny's vault.",
71
+ )
72
+ @click.option(
73
+ "--force/--no-force",
74
+ default=False,
75
+ show_default=True,
76
+ help="Rebuild even if the DEPS_HASH tag already exists in the registry.",
77
+ )
78
+ def build_base(
79
+ context_dir: Path,
80
+ dockerfile: Path,
81
+ image: str,
82
+ hash_files: tuple[Path, ...],
83
+ platforms: str,
84
+ builder: str,
85
+ secrets: tuple[str, ...],
86
+ login_docker_hub: bool,
87
+ force: bool,
88
+ ) -> None:
89
+ """Build and push a multi-arch Docker base image with deterministic tagging.
90
+
91
+ Computes a short sha256 over --hash-file contents, tags the image as
92
+ `<image>:<hash>` and `<image>:latest`, and pushes. Skips the build if the
93
+ hash tag already exists in the registry (unless --force).
94
+
95
+ Credentials are fetched from granny's Vaultwarden vault via granny.credentials.
96
+ Secrets are mounted into BuildKit via --mount=type=secret so they never
97
+ enter image history.
98
+ """
99
+ import os
100
+ import subprocess
101
+
102
+ from granny.credentials import load_secrets_into_env
103
+ from granny.docker.build_base import BuildBaseConfig, build_base_image
104
+
105
+ # Parse --secret flags: each is "id=ENV_VAR"
106
+ secret_map: dict[str, str] = {}
107
+ for s in secrets:
108
+ if "=" not in s:
109
+ click.echo(f"Invalid --secret value {s!r}, expected id=ENV_VAR", err=True)
110
+ sys.exit(2)
111
+ sid, env = s.split("=", 1)
112
+ secret_map[sid.strip()] = env.strip()
113
+
114
+ # Load required secrets from vault into os.environ (env var wins if set)
115
+ required_env_vars = set(secret_map.values())
116
+ if login_docker_hub:
117
+ required_env_vars.update({"DOCKER_HUB_USER", "DOCKER_HUB_TOKEN"})
118
+ if required_env_vars:
119
+ results = load_secrets_into_env(list(required_env_vars))
120
+ missing = [k for k, ok in results.items() if not ok]
121
+ if missing:
122
+ click.echo(
123
+ f"Missing secrets (not in env): {', '.join(sorted(missing))}\n"
124
+ f"Set them in .env / .deploy.env or export them in your shell.",
125
+ err=True,
126
+ )
127
+ sys.exit(1)
128
+
129
+ # Login to Docker Hub before the build so buildx can push.
130
+ if login_docker_hub:
131
+ user = os.environ["DOCKER_HUB_USER"]
132
+ token = os.environ["DOCKER_HUB_TOKEN"]
133
+ click.echo(f"Logging into Docker Hub as {user}...")
134
+ result = subprocess.run(
135
+ ["docker", "login", "--username", user, "--password-stdin"],
136
+ input=token,
137
+ text=True,
138
+ capture_output=True,
139
+ check=False,
140
+ )
141
+ if result.returncode != 0:
142
+ click.echo(f"docker login failed: {result.stderr.strip()}", err=True)
143
+ sys.exit(result.returncode)
144
+
145
+ cfg = BuildBaseConfig(
146
+ context=context_dir,
147
+ dockerfile=dockerfile,
148
+ image=image,
149
+ hash_files=list(hash_files),
150
+ platforms=platforms,
151
+ builder=builder,
152
+ secrets=secret_map,
153
+ force=force,
154
+ )
155
+ try:
156
+ deps_hash, was_built = build_base_image(cfg)
157
+ except Exception as e:
158
+ click.echo(f"build-base failed: {e}", err=True)
159
+ sys.exit(1)
160
+
161
+ if was_built:
162
+ click.echo(f"Pushed: {image}:{deps_hash}")
163
+ click.echo(f"Pushed: {image}:latest")
164
+ else:
165
+ click.echo(f"Skipped: {image}:{deps_hash} already exists in registry")