granny-devops 0.17.3__tar.gz → 0.17.4__tar.gz

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 (116) hide show
  1. {granny_devops-0.17.3 → granny_devops-0.17.4}/PKG-INFO +14 -1
  2. {granny_devops-0.17.3 → granny_devops-0.17.4}/README.md +13 -0
  3. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/__init__.py +1 -1
  4. granny_devops-0.17.4/granny/airtable/__init__.py +5 -0
  5. granny_devops-0.17.4/granny/airtable/client.py +113 -0
  6. granny_devops-0.17.4/granny/cli/airtable.py +107 -0
  7. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/main.py +5 -0
  8. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/credentials/secrets.py +1 -0
  9. {granny_devops-0.17.3 → granny_devops-0.17.4}/pyproject.toml +1 -1
  10. {granny_devops-0.17.3 → granny_devops-0.17.4}/.gitignore +0 -0
  11. {granny_devops-0.17.3 → granny_devops-0.17.4}/LICENSE +0 -0
  12. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/analyze/__init__.py +0 -0
  13. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/analyze/costs.py +0 -0
  14. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/analyze/credits.py +0 -0
  15. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/analyze/gpu_pricing.py +0 -0
  16. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/analyze/gpus.py +0 -0
  17. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/analyze/kubernetes.py +0 -0
  18. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/analyze/lambdas.py +0 -0
  19. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/analyze/vpcs.py +0 -0
  20. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/authentik/__init__.py +0 -0
  21. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/authentik/client.py +0 -0
  22. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/authentik/provision.py +0 -0
  23. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/aws/__init__.py +0 -0
  24. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/aws/bedrock.py +0 -0
  25. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/aws/quota.py +0 -0
  26. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/azure/__init__.py +0 -0
  27. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/azure/_client.py +0 -0
  28. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/azure/account.py +0 -0
  29. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/azure/deployment.py +0 -0
  30. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/azure/openai.py +0 -0
  31. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/azure/quota.py +0 -0
  32. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/azure/resourcegroup.py +0 -0
  33. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/azure/role.py +0 -0
  34. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/azure/sku.py +0 -0
  35. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/azure/vm.py +0 -0
  36. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/azure/webapp.py +0 -0
  37. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cdn/__init__.py +0 -0
  38. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cdn/bunny.py +0 -0
  39. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/__init__.py +0 -0
  40. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/analyze.py +0 -0
  41. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/authentik.py +0 -0
  42. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/aws.py +0 -0
  43. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/azure.py +0 -0
  44. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/cdn.py +0 -0
  45. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/cloudflare.py +0 -0
  46. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/create.py +0 -0
  47. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/credentials.py +0 -0
  48. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/dns.py +0 -0
  49. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/docker.py +0 -0
  50. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/edge.py +0 -0
  51. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/elk.py +0 -0
  52. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/email.py +0 -0
  53. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/indexing.py +0 -0
  54. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/oauth.py +0 -0
  55. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/serverless.py +0 -0
  56. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/storage.py +0 -0
  57. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cli/zitadel.py +0 -0
  58. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cloudflare/__init__.py +0 -0
  59. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cloudflare/d1.py +0 -0
  60. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cloudflare/r2.py +0 -0
  61. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/cloudflare/workers.py +0 -0
  62. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/__init__.py +0 -0
  63. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/auto_certificate.py +0 -0
  64. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/cloudfront-security-headers.js +0 -0
  65. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/letsencrypt_cert.py +0 -0
  66. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/manage-dns.sh +0 -0
  67. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/manage_mailjet_contacts.py +0 -0
  68. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/registrars.py +0 -0
  69. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/setup_aws_cloudfront.py +0 -0
  70. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/setup_bunny_edge_script.py +0 -0
  71. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/setup_bunny_storage.py +0 -0
  72. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/setup_cognito_identity_pool.py +0 -0
  73. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/setup_hetzner_bunny.py +0 -0
  74. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/setup_mailjet_dns.py +0 -0
  75. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/setup_private_cdn.py +0 -0
  76. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/setup_s3_website.py +0 -0
  77. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/setup_scaleway_container.py +0 -0
  78. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/setup_scaleway_faas.py +0 -0
  79. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/setup_workmail.py +0 -0
  80. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/create/www-redirect-function.js +0 -0
  81. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/credentials/__init__.py +0 -0
  82. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/dns/__init__.py +0 -0
  83. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/dns/base.py +0 -0
  84. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/dns/bunny.py +0 -0
  85. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/dns/cloudflare.py +0 -0
  86. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/dns/cloudns.py +0 -0
  87. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/dns/desec.py +0 -0
  88. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/dns/factory.py +0 -0
  89. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/dns/hetzner.py +0 -0
  90. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/dns/inwx.py +0 -0
  91. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/dns/manual.py +0 -0
  92. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/dns/records.py +0 -0
  93. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/docker/__init__.py +0 -0
  94. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/docker/build_base.py +0 -0
  95. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/edge/__init__.py +0 -0
  96. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/edge/bunny.py +0 -0
  97. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/elk/__init__.py +0 -0
  98. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/elk/client.py +0 -0
  99. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/email/__init__.py +0 -0
  100. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/email/mailjet.py +0 -0
  101. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/email/mailjet_contacts.py +0 -0
  102. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/email/ses_forwarding.py +0 -0
  103. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/email/stalwart.py +0 -0
  104. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/email/workmail.py +0 -0
  105. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/indexing/__init__.py +0 -0
  106. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/indexing/google.py +0 -0
  107. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/indexing/indexnow.py +0 -0
  108. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/report.py +0 -0
  109. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/serverless/__init__.py +0 -0
  110. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/serverless/scaleway.py +0 -0
  111. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/storage/__init__.py +0 -0
  112. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/storage/aws.py +0 -0
  113. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/storage/bunny.py +0 -0
  114. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/storage/hetzner.py +0 -0
  115. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/zitadel/__init__.py +0 -0
  116. {granny_devops-0.17.3 → granny_devops-0.17.4}/granny/zitadel/client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: granny-devops
3
- Version: 0.17.3
3
+ Version: 0.17.4
4
4
  Summary: Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation
5
5
  Author-email: Martin Wieser <martin.wieser@pseekoo.com>
6
6
  License: MIT License
@@ -215,6 +215,7 @@ Common keys:
215
215
  | Provider | Variables |
216
216
  |---|---|
217
217
  | Bunny | `BUNNY_API_KEY` (+ `BUNNY_API_KEY_<CUSTOMER>` for multi-account) |
218
+ | Airtable | `AIRTABLE_API_KEY` |
218
219
  | Cloudflare | `CLOUDFLARE_API_TOKEN` |
219
220
  | Hetzner | `HETZNER_S3_ACCESS_KEY`, `HETZNER_S3_SECRET_KEY`, `HETZNER_DNS_API_TOKEN` |
220
221
  | Scaleway | `SCW_ACCESS_KEY`, `SCW_SECRET_KEY`, `SCW_DEFAULT_PROJECT_ID` |
@@ -286,6 +287,15 @@ granny cloudflare d1 create my-app
286
287
  granny cloudflare r2 create my-app-media
287
288
  granny cloudflare site provision my-app --secret-from-vault MAILJET_API_KEY
288
289
 
290
+ # Airtable endpoint maintenance
291
+ granny airtable bases
292
+ granny airtable tables --base app123
293
+ granny airtable find-text --base app123 --table Config --contains old.example.com
294
+ granny airtable replace-text --base app123 --table Config \
295
+ --field Endpoint --old old.example.com --new new.example.com # dry run
296
+ granny airtable replace-text --base app123 --table Config \
297
+ --field Endpoint --old old.example.com --new new.example.com --apply --confirm
298
+
289
299
  # Multi-arch Docker builds with deterministic tags
290
300
  granny docker build-base --image myapp-base --hash-file requirements.txt
291
301
 
@@ -383,6 +393,7 @@ granny indexing google https://example.com/job-posting --action URL_UPDATED
383
393
  | Object storage | AWS S3, Bunny Storage, Hetzner S3 |
384
394
  | Serverless functions | Scaleway FaaS, Scaleway Containers |
385
395
  | Workers / KV / D1 / R2 | Cloudflare |
396
+ | Record search and endpoint maintenance | Airtable |
386
397
  | Email send infra | Mailjet, AWS SES, AWS WorkMail |
387
398
  | AWS inventory | VPCs, Lambdas |
388
399
  | Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
@@ -419,6 +430,7 @@ load_secrets_into_env()
419
430
  ```
420
431
  granny/
421
432
  cli/ Click command groups (granny <group> <verb>)
433
+ airtable/ Airtable record search and endpoint maintenance
422
434
  analyze/ Cross-cloud inventory (AWS, GCP, Azure)
423
435
  authentik/ Authentik admin
424
436
  azure/ Azure account, ARM deployments, Cognitive, App Service, VMs, RBAC roles, quota
@@ -438,6 +450,7 @@ granny/
438
450
 
439
451
  ## Where to look next
440
452
 
453
+ - [`CHANGELOG.md`](CHANGELOG.md) — release history and notable changes.
441
454
  - [`Project_Guidelines.md`](Project_Guidelines.md) — contributor toolchain,
442
455
  conventions, extension checklists, release flow.
443
456
  - [`AGENTS.md`](AGENTS.md) — instructions for AI coding agents working
@@ -81,6 +81,7 @@ Common keys:
81
81
  | Provider | Variables |
82
82
  |---|---|
83
83
  | Bunny | `BUNNY_API_KEY` (+ `BUNNY_API_KEY_<CUSTOMER>` for multi-account) |
84
+ | Airtable | `AIRTABLE_API_KEY` |
84
85
  | Cloudflare | `CLOUDFLARE_API_TOKEN` |
85
86
  | Hetzner | `HETZNER_S3_ACCESS_KEY`, `HETZNER_S3_SECRET_KEY`, `HETZNER_DNS_API_TOKEN` |
86
87
  | Scaleway | `SCW_ACCESS_KEY`, `SCW_SECRET_KEY`, `SCW_DEFAULT_PROJECT_ID` |
@@ -152,6 +153,15 @@ granny cloudflare d1 create my-app
152
153
  granny cloudflare r2 create my-app-media
153
154
  granny cloudflare site provision my-app --secret-from-vault MAILJET_API_KEY
154
155
 
156
+ # Airtable endpoint maintenance
157
+ granny airtable bases
158
+ granny airtable tables --base app123
159
+ granny airtable find-text --base app123 --table Config --contains old.example.com
160
+ granny airtable replace-text --base app123 --table Config \
161
+ --field Endpoint --old old.example.com --new new.example.com # dry run
162
+ granny airtable replace-text --base app123 --table Config \
163
+ --field Endpoint --old old.example.com --new new.example.com --apply --confirm
164
+
155
165
  # Multi-arch Docker builds with deterministic tags
156
166
  granny docker build-base --image myapp-base --hash-file requirements.txt
157
167
 
@@ -249,6 +259,7 @@ granny indexing google https://example.com/job-posting --action URL_UPDATED
249
259
  | Object storage | AWS S3, Bunny Storage, Hetzner S3 |
250
260
  | Serverless functions | Scaleway FaaS, Scaleway Containers |
251
261
  | Workers / KV / D1 / R2 | Cloudflare |
262
+ | Record search and endpoint maintenance | Airtable |
252
263
  | Email send infra | Mailjet, AWS SES, AWS WorkMail |
253
264
  | AWS inventory | VPCs, Lambdas |
254
265
  | Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
@@ -285,6 +296,7 @@ load_secrets_into_env()
285
296
  ```
286
297
  granny/
287
298
  cli/ Click command groups (granny <group> <verb>)
299
+ airtable/ Airtable record search and endpoint maintenance
288
300
  analyze/ Cross-cloud inventory (AWS, GCP, Azure)
289
301
  authentik/ Authentik admin
290
302
  azure/ Azure account, ARM deployments, Cognitive, App Service, VMs, RBAC roles, quota
@@ -304,6 +316,7 @@ granny/
304
316
 
305
317
  ## Where to look next
306
318
 
319
+ - [`CHANGELOG.md`](CHANGELOG.md) — release history and notable changes.
307
320
  - [`Project_Guidelines.md`](Project_Guidelines.md) — contributor toolchain,
308
321
  conventions, extension checklists, release flow.
309
322
  - [`AGENTS.md`](AGENTS.md) — instructions for AI coding agents working
@@ -1,6 +1,6 @@
1
1
  """Granny -- Cloud tools collection for AWS infrastructure and DevOps automation."""
2
2
 
3
- __version__ = "0.17.3"
3
+ __version__ = "0.17.4"
4
4
  __all__ = [
5
5
  "get_secret",
6
6
  "load_secrets_into_env",
@@ -0,0 +1,5 @@
1
+ """Airtable API helpers."""
2
+
3
+ from granny.airtable.client import AirtableClient
4
+
5
+ __all__ = ["AirtableClient"]
@@ -0,0 +1,113 @@
1
+ """Small Airtable Web API client used by the granny CLI."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Any, Iterable
6
+ from urllib.parse import quote
7
+
8
+ import requests
9
+
10
+ from granny.credentials import get_secret
11
+
12
+
13
+ @dataclass
14
+ class AirtableClient:
15
+ api_key: str
16
+ api_base: str = "https://api.airtable.com/v0"
17
+ meta_base: str = "https://api.airtable.com/v0/meta"
18
+
19
+ @classmethod
20
+ def from_environment(cls) -> "AirtableClient":
21
+ api_key = get_secret("AIRTABLE_API_KEY")
22
+ if not api_key:
23
+ raise RuntimeError("AIRTABLE_API_KEY is not configured")
24
+ return cls(api_key=api_key)
25
+
26
+ def _request(self, method: str, url: str, **kwargs: Any) -> dict[str, Any]:
27
+ headers = dict(kwargs.pop("headers", {}) or {})
28
+ headers.setdefault("Authorization", f"Bearer {self.api_key}")
29
+ if method.upper() in {"POST", "PATCH", "PUT"}:
30
+ headers.setdefault("Content-Type", "application/json")
31
+ resp = requests.request(method, url, headers=headers, timeout=60, **kwargs)
32
+ if not resp.ok:
33
+ raise RuntimeError(f"Airtable API error {resp.status_code}: {resp.text[:1000]}")
34
+ if not resp.content:
35
+ return {}
36
+ return resp.json()
37
+
38
+ def list_bases(self) -> list[dict[str, Any]]:
39
+ return list(self._request("GET", f"{self.meta_base}/bases").get("bases", []))
40
+
41
+ def list_tables(self, base_id: str) -> list[dict[str, Any]]:
42
+ return list(self._request("GET", f"{self.meta_base}/bases/{base_id}/tables").get("tables", []))
43
+
44
+ def iter_records(
45
+ self,
46
+ base_id: str,
47
+ table: str,
48
+ *,
49
+ fields: Iterable[str] | None = None,
50
+ page_size: int = 100,
51
+ ):
52
+ table_path = quote(table, safe="")
53
+ url = f"{self.api_base}/{base_id}/{table_path}"
54
+ params: list[tuple[str, str | int]] = [("pageSize", page_size)]
55
+ for field in fields or ():
56
+ params.append(("fields[]", field))
57
+ while True:
58
+ data = self._request("GET", url, params=params)
59
+ yield from data.get("records", [])
60
+ offset = data.get("offset")
61
+ if not offset:
62
+ break
63
+ params = [(k, v) for k, v in params if k != "offset"] + [("offset", offset)]
64
+
65
+ def update_record(self, base_id: str, table: str, record_id: str, fields: dict[str, Any]) -> dict[str, Any]:
66
+ table_path = quote(table, safe="")
67
+ return self._request(
68
+ "PATCH",
69
+ f"{self.api_base}/{base_id}/{table_path}/{record_id}",
70
+ json={"fields": fields},
71
+ )
72
+
73
+
74
+ def field_contains(value: Any, needle: str) -> bool:
75
+ return needle in _stringify(value)
76
+
77
+
78
+ def replace_in_value(value: Any, old: str, new: str) -> tuple[Any, bool]:
79
+ """Return value with string occurrences replaced, preserving container shape."""
80
+ if isinstance(value, str):
81
+ replaced = value.replace(old, new)
82
+ return replaced, replaced != value
83
+ if isinstance(value, list):
84
+ changed = False
85
+ out = []
86
+ for item in value:
87
+ replaced, item_changed = replace_in_value(item, old, new)
88
+ out.append(replaced)
89
+ changed = changed or item_changed
90
+ return out, changed
91
+ if isinstance(value, dict):
92
+ changed = False
93
+ out = {}
94
+ for key, item in value.items():
95
+ replaced, item_changed = replace_in_value(item, old, new)
96
+ out[key] = replaced
97
+ changed = changed or item_changed
98
+ return out, changed
99
+ return value, False
100
+
101
+
102
+ def _stringify(value: Any) -> str:
103
+ if value is None:
104
+ return ""
105
+ if isinstance(value, str):
106
+ return value
107
+ if isinstance(value, (int, float, bool)):
108
+ return str(value)
109
+ if isinstance(value, list):
110
+ return "\n".join(_stringify(item) for item in value)
111
+ if isinstance(value, dict):
112
+ return "\n".join(f"{key}: {_stringify(item)}" for key, item in value.items())
113
+ return str(value)
@@ -0,0 +1,107 @@
1
+ """Airtable operational helpers."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+
6
+ import click
7
+
8
+ from granny.airtable.client import AirtableClient, field_contains, replace_in_value
9
+
10
+
11
+ @click.group()
12
+ def airtable() -> None:
13
+ """Inspect and update Airtable records."""
14
+
15
+
16
+ @airtable.command("bases")
17
+ def list_bases() -> None:
18
+ """List accessible Airtable bases."""
19
+ client = AirtableClient.from_environment()
20
+ for base in client.list_bases():
21
+ click.echo(f"{base.get('id')}\t{base.get('name')}")
22
+
23
+
24
+ @airtable.command("tables")
25
+ @click.option("--base", "base_id", required=True, help="Airtable base id (app...).")
26
+ def list_tables(base_id: str) -> None:
27
+ """List tables in a base."""
28
+ client = AirtableClient.from_environment()
29
+ for table in client.list_tables(base_id):
30
+ click.echo(f"{table.get('id')}\t{table.get('name')}")
31
+
32
+
33
+ @airtable.command("find-text")
34
+ @click.option("--base", "base_id", required=True, help="Airtable base id (app...).")
35
+ @click.option("--table", required=True, help="Table name or table id.")
36
+ @click.option("--contains", "needle", required=True, help="Text/URL fragment to search for.")
37
+ @click.option("--field", "fields", multiple=True, help="Limit search to field(s). Repeatable.")
38
+ @click.option("--json", "as_json", is_flag=True, help="Emit JSON lines.")
39
+ def find_text(base_id: str, table: str, needle: str, fields: tuple[str, ...], as_json: bool) -> None:
40
+ """Find records whose field values contain text.
41
+
42
+ This can find old webhook/API endpoints in record fields, button URL fields,
43
+ or config tables. Airtable automations/scripts themselves are not exposed by
44
+ the public Web API; export/edit those manually if they are not represented in
45
+ records.
46
+ """
47
+ client = AirtableClient.from_environment()
48
+ matches = 0
49
+ for record in client.iter_records(base_id, table, fields=fields or None):
50
+ record_fields = record.get("fields", {})
51
+ haystack = fields or tuple(record_fields.keys())
52
+ hit_fields = [name for name in haystack if field_contains(record_fields.get(name), needle)]
53
+ if not hit_fields:
54
+ continue
55
+ matches += 1
56
+ payload = {"id": record.get("id"), "fields": hit_fields}
57
+ if as_json:
58
+ click.echo(json.dumps(payload, ensure_ascii=False))
59
+ else:
60
+ click.echo(f"{record.get('id')}\t{','.join(hit_fields)}")
61
+ if matches == 0:
62
+ click.echo("no matches", err=True)
63
+
64
+
65
+ @airtable.command("replace-text")
66
+ @click.option("--base", "base_id", required=True, help="Airtable base id (app...).")
67
+ @click.option("--table", required=True, help="Table name or table id.")
68
+ @click.option("--old", required=True, help="Old text/URL fragment.")
69
+ @click.option("--new", required=True, help="Replacement text/URL fragment.")
70
+ @click.option("--field", "fields", multiple=True, required=True, help="Field(s) to update. Repeatable.")
71
+ @click.option("--dry-run/--apply", default=True, help="Preview changes unless --apply is used.")
72
+ @click.option("--confirm", is_flag=True, help="Required with --apply.")
73
+ def replace_text(
74
+ base_id: str,
75
+ table: str,
76
+ old: str,
77
+ new: str,
78
+ fields: tuple[str, ...],
79
+ dry_run: bool,
80
+ confirm: bool,
81
+ ) -> None:
82
+ """Replace old endpoint text in selected Airtable record fields."""
83
+ if not dry_run and not confirm:
84
+ raise click.UsageError("--apply requires --confirm")
85
+
86
+ client = AirtableClient.from_environment()
87
+ scanned = changed = updated = 0
88
+ for record in client.iter_records(base_id, table, fields=fields):
89
+ scanned += 1
90
+ record_fields = record.get("fields", {})
91
+ update: dict[str, object] = {}
92
+ for field in fields:
93
+ if field not in record_fields:
94
+ continue
95
+ value, did_change = replace_in_value(record_fields[field], old, new)
96
+ if did_change:
97
+ update[field] = value
98
+ if not update:
99
+ continue
100
+ changed += 1
101
+ click.echo(f"{record.get('id')}\t{','.join(update.keys())}")
102
+ if not dry_run:
103
+ client.update_record(base_id, table, record["id"], update)
104
+ updated += 1
105
+
106
+ mode = "dry-run" if dry_run else "applied"
107
+ click.echo(f"{mode}: scanned={scanned} changed={changed} updated={updated}")
@@ -100,6 +100,11 @@ def _register_commands() -> None:
100
100
  cli.add_command(authentik)
101
101
  except ImportError:
102
102
  pass
103
+ try:
104
+ from granny.cli.airtable import airtable
105
+ cli.add_command(airtable)
106
+ except ImportError:
107
+ pass
103
108
  try:
104
109
  from granny.cli.elk import elk
105
110
  cli.add_command(elk)
@@ -148,6 +148,7 @@ SECRET_MAP: dict[str, str] = {
148
148
  "HETZNER_S3_SECRET_KEY": "hetzner-s3-secret-key",
149
149
  "HETZNER_DNS_API_TOKEN": "hetzner-dns-api-token",
150
150
  "BUNNY_API_KEY": "bunny-api-key",
151
+ "AIRTABLE_API_KEY": "airtable-api-key",
151
152
  "SCW_ACCESS_KEY": "scw-access-key",
152
153
  "SCW_SECRET_KEY": "scw-secret-key",
153
154
  "SCW_DEFAULT_PROJECT_ID": "scw-default-project-id",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "granny-devops"
3
- version = "0.17.3"
3
+ version = "0.17.4"
4
4
  description = "Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
File without changes