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/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")
|