granny-devops 0.17.1__tar.gz → 0.17.3__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 (113) hide show
  1. {granny_devops-0.17.1 → granny_devops-0.17.3}/PKG-INFO +9 -1
  2. {granny_devops-0.17.1 → granny_devops-0.17.3}/README.md +8 -0
  3. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/__init__.py +1 -1
  4. granny_devops-0.17.3/granny/analyze/kubernetes.py +505 -0
  5. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/authentik/client.py +18 -0
  6. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/cli/analyze.py +81 -0
  7. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/cli/authentik.py +23 -0
  8. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/cli/dns.py +21 -0
  9. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/cli/main.py +5 -0
  10. granny_devops-0.17.3/granny/cli/oauth.py +96 -0
  11. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/dns/base.py +12 -0
  12. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/dns/cloudflare.py +60 -0
  13. {granny_devops-0.17.1 → granny_devops-0.17.3}/pyproject.toml +1 -1
  14. {granny_devops-0.17.1 → granny_devops-0.17.3}/.gitignore +0 -0
  15. {granny_devops-0.17.1 → granny_devops-0.17.3}/LICENSE +0 -0
  16. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/analyze/__init__.py +0 -0
  17. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/analyze/costs.py +0 -0
  18. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/analyze/credits.py +0 -0
  19. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/analyze/gpu_pricing.py +0 -0
  20. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/analyze/gpus.py +0 -0
  21. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/analyze/lambdas.py +0 -0
  22. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/analyze/vpcs.py +0 -0
  23. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/authentik/__init__.py +0 -0
  24. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/authentik/provision.py +0 -0
  25. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/aws/__init__.py +0 -0
  26. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/aws/bedrock.py +0 -0
  27. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/aws/quota.py +0 -0
  28. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/azure/__init__.py +0 -0
  29. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/azure/_client.py +0 -0
  30. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/azure/account.py +0 -0
  31. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/azure/deployment.py +0 -0
  32. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/azure/openai.py +0 -0
  33. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/azure/quota.py +0 -0
  34. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/azure/resourcegroup.py +0 -0
  35. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/azure/role.py +0 -0
  36. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/azure/sku.py +0 -0
  37. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/azure/vm.py +0 -0
  38. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/azure/webapp.py +0 -0
  39. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/cdn/__init__.py +0 -0
  40. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/cdn/bunny.py +0 -0
  41. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/cli/__init__.py +0 -0
  42. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/cli/aws.py +0 -0
  43. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/cli/azure.py +0 -0
  44. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/cli/cdn.py +0 -0
  45. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/cli/cloudflare.py +0 -0
  46. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/cli/create.py +0 -0
  47. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/cli/credentials.py +0 -0
  48. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/cli/docker.py +0 -0
  49. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/cli/edge.py +0 -0
  50. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/cli/elk.py +0 -0
  51. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/cli/email.py +0 -0
  52. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/cli/indexing.py +0 -0
  53. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/cli/serverless.py +0 -0
  54. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/cli/storage.py +0 -0
  55. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/cli/zitadel.py +0 -0
  56. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/cloudflare/__init__.py +0 -0
  57. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/cloudflare/d1.py +0 -0
  58. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/cloudflare/r2.py +0 -0
  59. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/cloudflare/workers.py +0 -0
  60. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/create/__init__.py +0 -0
  61. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/create/auto_certificate.py +0 -0
  62. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/create/cloudfront-security-headers.js +0 -0
  63. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/create/letsencrypt_cert.py +0 -0
  64. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/create/manage-dns.sh +0 -0
  65. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/create/manage_mailjet_contacts.py +0 -0
  66. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/create/registrars.py +0 -0
  67. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/create/setup_aws_cloudfront.py +0 -0
  68. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/create/setup_bunny_edge_script.py +0 -0
  69. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/create/setup_bunny_storage.py +0 -0
  70. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/create/setup_cognito_identity_pool.py +0 -0
  71. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/create/setup_hetzner_bunny.py +0 -0
  72. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/create/setup_mailjet_dns.py +0 -0
  73. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/create/setup_private_cdn.py +0 -0
  74. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/create/setup_s3_website.py +0 -0
  75. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/create/setup_scaleway_container.py +0 -0
  76. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/create/setup_scaleway_faas.py +0 -0
  77. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/create/setup_workmail.py +0 -0
  78. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/create/www-redirect-function.js +0 -0
  79. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/credentials/__init__.py +0 -0
  80. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/credentials/secrets.py +0 -0
  81. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/dns/__init__.py +0 -0
  82. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/dns/bunny.py +0 -0
  83. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/dns/cloudns.py +0 -0
  84. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/dns/desec.py +0 -0
  85. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/dns/factory.py +0 -0
  86. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/dns/hetzner.py +0 -0
  87. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/dns/inwx.py +0 -0
  88. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/dns/manual.py +0 -0
  89. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/dns/records.py +0 -0
  90. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/docker/__init__.py +0 -0
  91. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/docker/build_base.py +0 -0
  92. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/edge/__init__.py +0 -0
  93. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/edge/bunny.py +0 -0
  94. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/elk/__init__.py +0 -0
  95. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/elk/client.py +0 -0
  96. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/email/__init__.py +0 -0
  97. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/email/mailjet.py +0 -0
  98. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/email/mailjet_contacts.py +0 -0
  99. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/email/ses_forwarding.py +0 -0
  100. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/email/stalwart.py +0 -0
  101. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/email/workmail.py +0 -0
  102. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/indexing/__init__.py +0 -0
  103. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/indexing/google.py +0 -0
  104. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/indexing/indexnow.py +0 -0
  105. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/report.py +0 -0
  106. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/serverless/__init__.py +0 -0
  107. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/serverless/scaleway.py +0 -0
  108. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/storage/__init__.py +0 -0
  109. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/storage/aws.py +0 -0
  110. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/storage/bunny.py +0 -0
  111. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/storage/hetzner.py +0 -0
  112. {granny_devops-0.17.1 → granny_devops-0.17.3}/granny/zitadel/__init__.py +0 -0
  113. {granny_devops-0.17.1 → granny_devops-0.17.3}/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.1
3
+ Version: 0.17.3
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
@@ -274,6 +274,13 @@ granny analyze costs # MTD + month-end forecast
274
274
  granny analyze capacity-blocks --instance-type p5.48xlarge --hours 24
275
275
  granny analyze capacity-blocks --instance-type p5e.48xlarge --count 2 --hours 168
276
276
 
277
+ # Kubernetes service architecture inventory via kubectl
278
+ # Maps Services to Ingress hosts, EndpointSlices/Endpoints, and owning workloads.
279
+ # Refuses writable Kubernetes identities by default; use a read-only kubeconfig.
280
+ granny analyze k8s-services --context prod-cluster --namespace platform
281
+ granny analyze k8s-services --context prod-cluster --json-output
282
+ granny analyze k8s-services --context prod-cluster --mermaid > architecture.mmd
283
+
277
284
  # Cloudflare account resources (Workers, D1, R2, KV)
278
285
  granny cloudflare d1 create my-app
279
286
  granny cloudflare r2 create my-app-media
@@ -306,6 +313,7 @@ granny authentik provision-oauth-app my-app \
306
313
  --group my-app-admins
307
314
  granny authentik list providers
308
315
  granny authentik rotate-secret my-oauth-provider
316
+ granny authentik delete-app my-app --name "My App" # name-confirmed application delete
309
317
  granny authentik add-user-to-group user@example.com # defaults to dash_admins
310
318
  granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
311
319
 
@@ -140,6 +140,13 @@ granny analyze costs # MTD + month-end forecast
140
140
  granny analyze capacity-blocks --instance-type p5.48xlarge --hours 24
141
141
  granny analyze capacity-blocks --instance-type p5e.48xlarge --count 2 --hours 168
142
142
 
143
+ # Kubernetes service architecture inventory via kubectl
144
+ # Maps Services to Ingress hosts, EndpointSlices/Endpoints, and owning workloads.
145
+ # Refuses writable Kubernetes identities by default; use a read-only kubeconfig.
146
+ granny analyze k8s-services --context prod-cluster --namespace platform
147
+ granny analyze k8s-services --context prod-cluster --json-output
148
+ granny analyze k8s-services --context prod-cluster --mermaid > architecture.mmd
149
+
143
150
  # Cloudflare account resources (Workers, D1, R2, KV)
144
151
  granny cloudflare d1 create my-app
145
152
  granny cloudflare r2 create my-app-media
@@ -172,6 +179,7 @@ granny authentik provision-oauth-app my-app \
172
179
  --group my-app-admins
173
180
  granny authentik list providers
174
181
  granny authentik rotate-secret my-oauth-provider
182
+ granny authentik delete-app my-app --name "My App" # name-confirmed application delete
175
183
  granny authentik add-user-to-group user@example.com # defaults to dash_admins
176
184
  granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
177
185
 
@@ -1,6 +1,6 @@
1
1
  """Granny -- Cloud tools collection for AWS infrastructure and DevOps automation."""
2
2
 
3
- __version__ = "0.17.1"
3
+ __version__ = "0.17.3"
4
4
  __all__ = [
5
5
  "get_secret",
6
6
  "load_secrets_into_env",
@@ -0,0 +1,505 @@
1
+ """Kubernetes service inventory for architecture analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class KubernetesPort:
13
+ name: str | None
14
+ port: int | None
15
+ target_port: str | int | None
16
+ protocol: str
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class KubernetesServiceRef:
21
+ namespace: str
22
+ name: str
23
+ kind: str
24
+ service_type: str
25
+ cluster_ip: str | None
26
+ external_name: str | None
27
+ external_ips: list[str]
28
+ load_balancer_ips: list[str]
29
+ ports: list[KubernetesPort]
30
+ selector: dict[str, str]
31
+ matched_workloads: list[str]
32
+ endpoint_count: int
33
+ ingress_hosts: list[str]
34
+ labels: dict[str, str]
35
+ annotations: dict[str, str]
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class KubernetesWorkloadRef:
40
+ namespace: str
41
+ kind: str
42
+ name: str
43
+ replicas: int | None
44
+ ready_replicas: int | None
45
+ labels: dict[str, str]
46
+ images: list[str]
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class KubernetesIngressRef:
51
+ namespace: str
52
+ name: str
53
+ class_name: str | None
54
+ hosts: list[str]
55
+ services: list[str]
56
+ tls_hosts: list[str]
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class KubernetesServiceScan:
61
+ context: str | None
62
+ namespaces: list[str]
63
+ services: list[KubernetesServiceRef]
64
+ workloads: list[KubernetesWorkloadRef]
65
+ ingresses: list[KubernetesIngressRef]
66
+ findings: list[str] = field(default_factory=list)
67
+
68
+ @property
69
+ def summary(self) -> dict[str, int]:
70
+ exposed = [s for s in self.services if _is_exposed(s)]
71
+ return {
72
+ "namespaces": len(self.namespaces),
73
+ "services": len(self.services),
74
+ "workloads": len(self.workloads),
75
+ "ingresses": len(self.ingresses),
76
+ "externally_exposed_services": len(exposed),
77
+ "services_without_matched_workloads": len(
78
+ [s for s in self.services if s.selector and not s.matched_workloads]
79
+ ),
80
+ "services_without_ready_endpoints": len(
81
+ [s for s in self.services if s.kind != "ExternalName" and s.endpoint_count == 0]
82
+ ),
83
+ "selectorless_services": len([s for s in self.services if not s.selector]),
84
+ }
85
+
86
+
87
+ def scan_kubernetes_services(
88
+ *,
89
+ context: str | None = None,
90
+ kubeconfig: str | None = None,
91
+ namespaces: list[str] | None = None,
92
+ include_annotations: bool = False,
93
+ enforce_readonly: bool = False,
94
+ ) -> KubernetesServiceScan:
95
+ """Scan Kubernetes Services and their ingress/workload relationships via kubectl."""
96
+ scope = _Scope(context=context, kubeconfig=kubeconfig, namespaces=namespaces or [])
97
+ if enforce_readonly:
98
+ assert_readonly_access(scope)
99
+ namespaces_seen = _namespace_names(_kubectl_json(scope, ["get", "namespaces"]))
100
+
101
+ services_payload = _filter_payload(
102
+ scope,
103
+ _kubectl_json(scope, ["get", "services", *_namespace_args(scope), "-o", "json"]),
104
+ )
105
+ workloads_payloads = [
106
+ _filter_payload(
107
+ scope,
108
+ _kubectl_json(
109
+ scope,
110
+ ["get", kind, *_namespace_args(scope), "-o", "json"],
111
+ allow_not_found=True,
112
+ ),
113
+ )
114
+ for kind in ("deployments", "statefulsets", "daemonsets", "jobs", "cronjobs")
115
+ ]
116
+ ingress_payload = _filter_payload(
117
+ scope,
118
+ _kubectl_json(
119
+ scope,
120
+ ["get", "ingress", *_namespace_args(scope), "-o", "json"],
121
+ allow_not_found=True,
122
+ ),
123
+ )
124
+ endpoints_by_service = _endpoint_counts(scope)
125
+
126
+ workloads = [
127
+ workload
128
+ for payload in workloads_payloads
129
+ for workload in _parse_workloads(payload)
130
+ ]
131
+ ingresses = _parse_ingresses(ingress_payload)
132
+ ingress_hosts_by_service = _ingress_hosts_by_service(ingresses)
133
+ services = _parse_services(
134
+ services_payload,
135
+ workloads=workloads,
136
+ endpoints_by_service=endpoints_by_service,
137
+ ingress_hosts_by_service=ingress_hosts_by_service,
138
+ include_annotations=include_annotations,
139
+ )
140
+
141
+ namespaces_filter = set(scope.namespaces)
142
+ active_namespaces = sorted(
143
+ namespaces_filter
144
+ or {item.namespace for item in [*services, *workloads, *ingresses]}
145
+ or set(namespaces_seen)
146
+ )
147
+ findings = _build_findings(services)
148
+ return KubernetesServiceScan(
149
+ context=context,
150
+ namespaces=active_namespaces,
151
+ services=services,
152
+ workloads=workloads,
153
+ ingresses=ingresses,
154
+ findings=findings,
155
+ )
156
+
157
+
158
+ def render_mermaid(scan: KubernetesServiceScan) -> str:
159
+ """Render a compact Mermaid graph of ingress -> service -> workload edges."""
160
+ lines = ["flowchart LR"]
161
+ for ingress in scan.ingresses:
162
+ ingress_id = _node_id("ing", ingress.namespace, ingress.name)
163
+ hosts = ", ".join(ingress.hosts) or ingress.name
164
+ lines.append(f' {ingress_id}["Ingress {ingress.namespace}/{hosts}"]')
165
+ for service in ingress.services:
166
+ namespace, name = service.split("/", 1)
167
+ lines.append(f" {ingress_id} --> {_node_id('svc', namespace, name)}")
168
+ for service in scan.services:
169
+ service_id = _node_id("svc", service.namespace, service.name)
170
+ label = f"Service {service.namespace}/{service.name}\\n{service.service_type}"
171
+ lines.append(f' {service_id}["{label}"]')
172
+ for workload in service.matched_workloads:
173
+ kind_name = workload.split("/", 1)
174
+ if len(kind_name) != 2:
175
+ continue
176
+ workload_id = _node_id("wl", service.namespace, workload.replace("/", "-"))
177
+ lines.append(f' {workload_id}["{workload}"]')
178
+ lines.append(f" {service_id} --> {workload_id}")
179
+ if service.external_name:
180
+ ext_id = _node_id("ext", service.namespace, service.name)
181
+ lines.append(f' {ext_id}(("{service.external_name}"))')
182
+ lines.append(f" {service_id} --> {ext_id}")
183
+ return "\n".join(lines)
184
+
185
+
186
+ @dataclass(frozen=True)
187
+ class _Scope:
188
+ context: str | None
189
+ kubeconfig: str | None
190
+ namespaces: list[str]
191
+
192
+
193
+ _WRITE_VERBS = ("create", "update", "patch", "delete", "deletecollection")
194
+ _WRITE_RESOURCES = (
195
+ "pods",
196
+ "services",
197
+ "endpoints",
198
+ "endpointslices.discovery.k8s.io",
199
+ "deployments.apps",
200
+ "statefulsets.apps",
201
+ "daemonsets.apps",
202
+ "jobs.batch",
203
+ "cronjobs.batch",
204
+ "ingresses.networking.k8s.io",
205
+ "configmaps",
206
+ "secrets",
207
+ )
208
+
209
+
210
+ def assert_readonly_access(scope: _Scope) -> None:
211
+ """Fail when the active Kubernetes identity can mutate scanned resources."""
212
+ writable: list[str] = []
213
+ for verb in _WRITE_VERBS:
214
+ for resource in _WRITE_RESOURCES:
215
+ args = ["auth", "can-i", verb, resource, *_auth_namespace_args(scope)]
216
+ try:
217
+ allowed = _kubectl_text(scope, args).strip().lower()
218
+ except RuntimeError as exc:
219
+ raise RuntimeError(f"read-only preflight failed: {exc}") from exc
220
+ if allowed == "yes":
221
+ writable.append(f"{verb} {resource}")
222
+ if writable:
223
+ sample = ", ".join(writable[:8])
224
+ more = "..." if len(writable) > 8 else ""
225
+ raise RuntimeError(
226
+ "Kubernetes context is not read-only; refusing to scan with writable credentials. "
227
+ f"Allowed mutations: {sample}{more}"
228
+ )
229
+
230
+
231
+ def _kubectl_json(scope: _Scope, args: list[str], *, allow_not_found: bool = False) -> dict[str, Any]:
232
+ result = _run_kubectl(scope, args)
233
+ if result.returncode != 0:
234
+ stderr = result.stderr.strip()
235
+ if allow_not_found and ("the server doesn't have a resource type" in stderr or "NotFound" in stderr):
236
+ return {"items": []}
237
+ raise RuntimeError(f"kubectl failed ({result.returncode}): {stderr[:500]}")
238
+ try:
239
+ return json.loads(result.stdout or "{}")
240
+ except json.JSONDecodeError as exc:
241
+ raise RuntimeError("kubectl returned invalid JSON") from exc
242
+
243
+
244
+ def _kubectl_text(scope: _Scope, args: list[str]) -> str:
245
+ result = _run_kubectl(scope, args)
246
+ if result.returncode != 0:
247
+ raise RuntimeError(f"kubectl failed ({result.returncode}): {result.stderr.strip()[:500]}")
248
+ return result.stdout
249
+
250
+
251
+ def _run_kubectl(scope: _Scope, args: list[str]) -> subprocess.CompletedProcess[str]:
252
+ command = ["kubectl"]
253
+ if scope.context:
254
+ command.extend(["--context", scope.context])
255
+ if scope.kubeconfig:
256
+ command.extend(["--kubeconfig", scope.kubeconfig])
257
+ command.extend(args)
258
+ try:
259
+ return subprocess.run(command, check=False, capture_output=True, text=True, timeout=120)
260
+ except FileNotFoundError as exc:
261
+ raise RuntimeError("kubectl not found in PATH") from exc
262
+ except subprocess.TimeoutExpired as exc:
263
+ raise RuntimeError("kubectl command timed out") from exc
264
+
265
+
266
+ def _namespace_args(scope: _Scope) -> list[str]:
267
+ if len(scope.namespaces) == 1:
268
+ return ["-n", scope.namespaces[0]]
269
+ if len(scope.namespaces) > 1:
270
+ return ["--all-namespaces"]
271
+ return ["--all-namespaces"]
272
+
273
+
274
+ def _auth_namespace_args(scope: _Scope) -> list[str]:
275
+ if len(scope.namespaces) == 1:
276
+ return ["-n", scope.namespaces[0]]
277
+ return ["--all-namespaces"]
278
+
279
+
280
+ def _namespace_names(payload: dict[str, Any]) -> list[str]:
281
+ return sorted(
282
+ item.get("metadata", {}).get("name", "")
283
+ for item in payload.get("items", [])
284
+ if item.get("metadata", {}).get("name")
285
+ )
286
+
287
+
288
+ def _filter_payload(scope: _Scope, payload: dict[str, Any]) -> dict[str, Any]:
289
+ if len(scope.namespaces) <= 1:
290
+ return payload
291
+ wanted = set(scope.namespaces)
292
+ return {
293
+ **payload,
294
+ "items": [
295
+ item
296
+ for item in payload.get("items", [])
297
+ if item.get("metadata", {}).get("namespace") in wanted
298
+ ],
299
+ }
300
+
301
+
302
+ def _parse_services(
303
+ payload: dict[str, Any],
304
+ *,
305
+ workloads: list[KubernetesWorkloadRef],
306
+ endpoints_by_service: dict[str, int],
307
+ ingress_hosts_by_service: dict[str, list[str]],
308
+ include_annotations: bool,
309
+ ) -> list[KubernetesServiceRef]:
310
+ services: list[KubernetesServiceRef] = []
311
+ for item in payload.get("items", []):
312
+ metadata = item.get("metadata", {})
313
+ spec = item.get("spec", {})
314
+ namespace = metadata.get("namespace", "default")
315
+ name = metadata.get("name", "")
316
+ selector = {str(k): str(v) for k, v in spec.get("selector", {}).items()}
317
+ matched = [
318
+ f"{w.kind}/{w.name}"
319
+ for w in workloads
320
+ if w.namespace == namespace and selector and _matches_selector(selector, w.labels)
321
+ ]
322
+ lb_ips = _load_balancer_ips(item)
323
+ key = f"{namespace}/{name}"
324
+ annotations = metadata.get("annotations", {}) if include_annotations else {}
325
+ services.append(
326
+ KubernetesServiceRef(
327
+ namespace=namespace,
328
+ name=name,
329
+ kind=spec.get("type", "ClusterIP"),
330
+ service_type=spec.get("type", "ClusterIP"),
331
+ cluster_ip=spec.get("clusterIP"),
332
+ external_name=spec.get("externalName"),
333
+ external_ips=list(spec.get("externalIPs", [])),
334
+ load_balancer_ips=lb_ips,
335
+ ports=[_parse_port(p) for p in spec.get("ports", [])],
336
+ selector=selector,
337
+ matched_workloads=sorted(matched),
338
+ endpoint_count=endpoints_by_service.get(key, 0),
339
+ ingress_hosts=ingress_hosts_by_service.get(key, []),
340
+ labels={str(k): str(v) for k, v in metadata.get("labels", {}).items()},
341
+ annotations={str(k): str(v) for k, v in annotations.items()},
342
+ )
343
+ )
344
+ return sorted(services, key=lambda s: (s.namespace, s.name))
345
+
346
+
347
+ def _parse_workloads(payload: dict[str, Any]) -> list[KubernetesWorkloadRef]:
348
+ workloads: list[KubernetesWorkloadRef] = []
349
+ for item in payload.get("items", []):
350
+ metadata = item.get("metadata", {})
351
+ spec = item.get("spec", {})
352
+ status = item.get("status", {})
353
+ kind = item.get("kind", "Workload")
354
+ pod_template = _pod_template(item)
355
+ labels = pod_template.get("metadata", {}).get("labels", {}) or metadata.get("labels", {})
356
+ containers = pod_template.get("spec", {}).get("containers", [])
357
+ workloads.append(
358
+ KubernetesWorkloadRef(
359
+ namespace=metadata.get("namespace", "default"),
360
+ kind=kind,
361
+ name=metadata.get("name", ""),
362
+ replicas=spec.get("replicas"),
363
+ ready_replicas=status.get("readyReplicas") or status.get("numberReady"),
364
+ labels={str(k): str(v) for k, v in labels.items()},
365
+ images=sorted({str(c.get("image")) for c in containers if c.get("image")}),
366
+ )
367
+ )
368
+ return sorted(workloads, key=lambda w: (w.namespace, w.kind, w.name))
369
+
370
+
371
+ def _parse_ingresses(payload: dict[str, Any]) -> list[KubernetesIngressRef]:
372
+ ingresses: list[KubernetesIngressRef] = []
373
+ for item in payload.get("items", []):
374
+ metadata = item.get("metadata", {})
375
+ spec = item.get("spec", {})
376
+ namespace = metadata.get("namespace", "default")
377
+ services: set[str] = set()
378
+ hosts: list[str] = []
379
+ for rule in spec.get("rules", []):
380
+ host = rule.get("host")
381
+ if host:
382
+ hosts.append(str(host))
383
+ for path in rule.get("http", {}).get("paths", []):
384
+ name = path.get("backend", {}).get("service", {}).get("name")
385
+ if name:
386
+ services.add(f"{namespace}/{name}")
387
+ default_service = spec.get("defaultBackend", {}).get("service", {}).get("name")
388
+ if default_service:
389
+ services.add(f"{namespace}/{default_service}")
390
+ tls_hosts = [str(h) for tls in spec.get("tls", []) for h in tls.get("hosts", [])]
391
+ ingresses.append(
392
+ KubernetesIngressRef(
393
+ namespace=namespace,
394
+ name=metadata.get("name", ""),
395
+ class_name=spec.get("ingressClassName"),
396
+ hosts=sorted(set(hosts)),
397
+ services=sorted(services),
398
+ tls_hosts=sorted(set(tls_hosts)),
399
+ )
400
+ )
401
+ return sorted(ingresses, key=lambda i: (i.namespace, i.name))
402
+
403
+
404
+ def _endpoint_counts(scope: _Scope) -> dict[str, int]:
405
+ try:
406
+ payload = _filter_payload(
407
+ scope,
408
+ _kubectl_json(
409
+ scope,
410
+ ["get", "endpointslices", *_namespace_args(scope), "-o", "json"],
411
+ allow_not_found=True,
412
+ ),
413
+ )
414
+ counts: dict[str, int] = {}
415
+ for item in payload.get("items", []):
416
+ metadata = item.get("metadata", {})
417
+ namespace = metadata.get("namespace", "default")
418
+ service = metadata.get("labels", {}).get("kubernetes.io/service-name")
419
+ if not service:
420
+ continue
421
+ key = f"{namespace}/{service}"
422
+ counts[key] = counts.get(key, 0) + len(item.get("endpoints", []))
423
+ if counts:
424
+ return counts
425
+ except RuntimeError:
426
+ pass
427
+
428
+ payload = _filter_payload(
429
+ scope,
430
+ _kubectl_json(
431
+ scope,
432
+ ["get", "endpoints", *_namespace_args(scope), "-o", "json"],
433
+ allow_not_found=True,
434
+ ),
435
+ )
436
+ counts = {}
437
+ for item in payload.get("items", []):
438
+ metadata = item.get("metadata", {})
439
+ key = f"{metadata.get('namespace', 'default')}/{metadata.get('name', '')}"
440
+ counts[key] = sum(len(subset.get("addresses", [])) for subset in item.get("subsets", []))
441
+ return counts
442
+
443
+
444
+ def _pod_template(item: dict[str, Any]) -> dict[str, Any]:
445
+ if item.get("kind") == "CronJob":
446
+ return item.get("spec", {}).get("jobTemplate", {}).get("spec", {}).get("template", {})
447
+ return item.get("spec", {}).get("template", {})
448
+
449
+
450
+ def _parse_port(port: dict[str, Any]) -> KubernetesPort:
451
+ return KubernetesPort(
452
+ name=port.get("name"),
453
+ port=port.get("port"),
454
+ target_port=port.get("targetPort"),
455
+ protocol=port.get("protocol", "TCP"),
456
+ )
457
+
458
+
459
+ def _matches_selector(selector: dict[str, str], labels: dict[str, str]) -> bool:
460
+ return all(labels.get(key) == value for key, value in selector.items())
461
+
462
+
463
+ def _load_balancer_ips(item: dict[str, Any]) -> list[str]:
464
+ ingresses = item.get("status", {}).get("loadBalancer", {}).get("ingress", [])
465
+ values = []
466
+ for ingress in ingresses:
467
+ value = ingress.get("ip") or ingress.get("hostname")
468
+ if value:
469
+ values.append(str(value))
470
+ return sorted(values)
471
+
472
+
473
+ def _ingress_hosts_by_service(ingresses: list[KubernetesIngressRef]) -> dict[str, list[str]]:
474
+ mapping: dict[str, set[str]] = {}
475
+ for ingress in ingresses:
476
+ for service in ingress.services:
477
+ mapping.setdefault(service, set()).update(ingress.hosts or [ingress.name])
478
+ return {key: sorted(values) for key, values in mapping.items()}
479
+
480
+
481
+ def _is_exposed(service: KubernetesServiceRef) -> bool:
482
+ return bool(
483
+ service.service_type in {"LoadBalancer", "NodePort"}
484
+ or service.external_ips
485
+ or service.load_balancer_ips
486
+ or service.ingress_hosts
487
+ )
488
+
489
+
490
+ def _build_findings(services: list[KubernetesServiceRef]) -> list[str]:
491
+ findings = []
492
+ for service in services:
493
+ label = f"{service.namespace}/{service.name}"
494
+ if service.selector and not service.matched_workloads:
495
+ findings.append(f"{label}: selector does not match any scanned workload")
496
+ if service.kind != "ExternalName" and service.endpoint_count == 0:
497
+ findings.append(f"{label}: no ready endpoints reported")
498
+ if service.service_type in {"LoadBalancer", "NodePort"} and not service.ingress_hosts:
499
+ findings.append(f"{label}: directly exposed as {service.service_type}")
500
+ return findings
501
+
502
+
503
+ def _node_id(prefix: str, namespace: str, name: str) -> str:
504
+ safe = "_".join(part.replace("-", "_").replace(".", "_") for part in (prefix, namespace, name))
505
+ return safe.replace("/", "_")
@@ -257,6 +257,24 @@ class AuthentikClient:
257
257
  body={"client_secret": ""},
258
258
  )
259
259
 
260
+ def delete_application(self, slug: str, *, expected_name: str | None = None) -> bool:
261
+ """Delete an application by slug.
262
+
263
+ Returns ``True`` when an application was deleted and ``False`` when
264
+ it was already absent. When ``expected_name`` is provided, the
265
+ application display name must match before deletion.
266
+ """
267
+ app = self.find_application_by_slug(slug)
268
+ if not app:
269
+ return False
270
+ if expected_name is not None and app.get("name") != expected_name:
271
+ raise AuthentikError(
272
+ 409,
273
+ f"Application {slug!r} has name {app.get('name')!r}, not {expected_name!r}",
274
+ )
275
+ self.request("DELETE", f"/api/v3/core/applications/{slug}/")
276
+ return True
277
+
260
278
  def create_user(
261
279
  self,
262
280
  username: str,
@@ -39,6 +39,87 @@ def lambdas(region: tuple[str, ...], as_json: bool) -> None:
39
39
  click.echo(f"{fn.name:<40} {fn.runtime:<15} {fn.version:<10}")
40
40
 
41
41
 
42
+ @analyze.command("k8s-services")
43
+ @click.option("--context", "context_name", help="Kubernetes context to scan")
44
+ @click.option("--kubeconfig", help="Path to kubeconfig file")
45
+ @click.option(
46
+ "--namespace",
47
+ "namespaces",
48
+ multiple=True,
49
+ help="Namespace to scan (repeatable; default: all)",
50
+ )
51
+ @click.option(
52
+ "--include-annotations",
53
+ is_flag=True,
54
+ help="Include service annotations in JSON output",
55
+ )
56
+ @click.option("--json-output", "as_json", is_flag=True, help="Output as JSON")
57
+ @click.option("--mermaid", is_flag=True, help="Output a Mermaid architecture graph")
58
+ @click.option(
59
+ "--enforce-readonly/--no-enforce-readonly",
60
+ default=True,
61
+ help="Refuse to scan if the Kubernetes identity has write permissions",
62
+ )
63
+ def k8s_services(
64
+ context_name: str | None,
65
+ kubeconfig: str | None,
66
+ namespaces: tuple[str, ...],
67
+ include_annotations: bool,
68
+ as_json: bool,
69
+ mermaid: bool,
70
+ enforce_readonly: bool,
71
+ ) -> None:
72
+ """Scan Kubernetes Services and map them to workloads and ingresses."""
73
+ from dataclasses import asdict
74
+
75
+ from granny.analyze.kubernetes import render_mermaid, scan_kubernetes_services
76
+
77
+ scan = scan_kubernetes_services(
78
+ context=context_name,
79
+ kubeconfig=kubeconfig,
80
+ namespaces=list(namespaces) or None,
81
+ include_annotations=include_annotations,
82
+ enforce_readonly=enforce_readonly,
83
+ )
84
+
85
+ if as_json:
86
+ payload = asdict(scan)
87
+ payload["summary"] = scan.summary
88
+ click.echo(json.dumps(payload, indent=2))
89
+ return
90
+
91
+ if mermaid:
92
+ click.echo(render_mermaid(scan))
93
+ return
94
+
95
+ summary = scan.summary
96
+ cluster = context_name or "current-context"
97
+ click.echo(f"Kubernetes service architecture scan: {cluster}")
98
+ click.echo(
99
+ f"Namespaces: {summary['namespaces']} | Services: {summary['services']} | "
100
+ f"Workloads: {summary['workloads']} | Ingresses: {summary['ingresses']} | "
101
+ f"Exposed: {summary['externally_exposed_services']}"
102
+ )
103
+ click.echo("")
104
+ click.echo(
105
+ f"{'Namespace':<24} {'Service':<34} {'Type':<14} "
106
+ f"{'Ports':<18} {'Endpoints':<9} Routes / Workloads"
107
+ )
108
+ click.echo("-" * 120)
109
+ for service in scan.services:
110
+ ports = ",".join(str(port.port) for port in service.ports if port.port) or "-"
111
+ routes = ",".join(service.ingress_hosts) or "-"
112
+ workloads = ",".join(service.matched_workloads) or service.external_name or "-"
113
+ click.echo(
114
+ f"{service.namespace:<24} {service.name:<34} {service.service_type:<14} "
115
+ f"{ports:<18} {service.endpoint_count:<9} {routes} -> {workloads}"
116
+ )
117
+ if scan.findings:
118
+ click.echo("\nFindings:")
119
+ for finding in scan.findings:
120
+ click.echo(f"- {finding}")
121
+
122
+
42
123
  @analyze.command()
43
124
  @click.option("--region", multiple=True, help="Specific region(s) to query")
44
125
  @click.option("--json-output", "as_json", is_flag=True, help="Output as JSON")