granny-devops 0.17.1__tar.gz → 0.17.2__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.
- {granny_devops-0.17.1 → granny_devops-0.17.2}/PKG-INFO +9 -1
- {granny_devops-0.17.1 → granny_devops-0.17.2}/README.md +8 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/__init__.py +1 -1
- granny_devops-0.17.2/granny/analyze/kubernetes.py +505 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/authentik/client.py +18 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/cli/analyze.py +81 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/cli/authentik.py +23 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/cli/main.py +5 -0
- granny_devops-0.17.2/granny/cli/oauth.py +96 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/pyproject.toml +1 -1
- {granny_devops-0.17.1 → granny_devops-0.17.2}/.gitignore +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/LICENSE +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/analyze/__init__.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/analyze/costs.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/analyze/credits.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/analyze/gpu_pricing.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/analyze/gpus.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/analyze/lambdas.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/analyze/vpcs.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/authentik/__init__.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/authentik/provision.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/aws/__init__.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/aws/bedrock.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/aws/quota.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/azure/__init__.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/azure/_client.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/azure/account.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/azure/deployment.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/azure/openai.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/azure/quota.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/azure/resourcegroup.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/azure/role.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/azure/sku.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/azure/vm.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/azure/webapp.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/cdn/__init__.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/cdn/bunny.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/cli/__init__.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/cli/aws.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/cli/azure.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/cli/cdn.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/cli/cloudflare.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/cli/create.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/cli/credentials.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/cli/dns.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/cli/docker.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/cli/edge.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/cli/elk.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/cli/email.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/cli/indexing.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/cli/serverless.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/cli/storage.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/cli/zitadel.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/cloudflare/__init__.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/cloudflare/d1.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/cloudflare/r2.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/cloudflare/workers.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/create/__init__.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/create/auto_certificate.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/create/cloudfront-security-headers.js +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/create/letsencrypt_cert.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/create/manage-dns.sh +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/create/manage_mailjet_contacts.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/create/registrars.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/create/setup_aws_cloudfront.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/create/setup_bunny_edge_script.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/create/setup_bunny_storage.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/create/setup_cognito_identity_pool.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/create/setup_hetzner_bunny.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/create/setup_mailjet_dns.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/create/setup_private_cdn.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/create/setup_s3_website.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/create/setup_scaleway_container.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/create/setup_scaleway_faas.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/create/setup_workmail.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/create/www-redirect-function.js +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/credentials/__init__.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/credentials/secrets.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/dns/__init__.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/dns/base.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/dns/bunny.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/dns/cloudflare.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/dns/cloudns.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/dns/desec.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/dns/factory.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/dns/hetzner.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/dns/inwx.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/dns/manual.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/dns/records.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/docker/__init__.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/docker/build_base.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/edge/__init__.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/edge/bunny.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/elk/__init__.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/elk/client.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/email/__init__.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/email/mailjet.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/email/mailjet_contacts.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/email/ses_forwarding.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/email/stalwart.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/email/workmail.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/indexing/__init__.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/indexing/google.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/indexing/indexnow.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/report.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/serverless/__init__.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/serverless/scaleway.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/storage/__init__.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/storage/aws.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/storage/bunny.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/storage/hetzner.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/granny/zitadel/__init__.py +0 -0
- {granny_devops-0.17.1 → granny_devops-0.17.2}/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
|
+
Version: 0.17.2
|
|
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
|
|
|
@@ -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")
|
|
@@ -202,6 +202,29 @@ def rotate_secret_cmd(name: str) -> None:
|
|
|
202
202
|
click.echo(f"Rotated. New client_secret: {new_secret}")
|
|
203
203
|
|
|
204
204
|
|
|
205
|
+
@authentik.command("delete-app")
|
|
206
|
+
@click.argument("slug")
|
|
207
|
+
@click.option(
|
|
208
|
+
"--name",
|
|
209
|
+
"expected_name",
|
|
210
|
+
required=True,
|
|
211
|
+
help="Required display-name confirmation. Refuses to delete on mismatch.",
|
|
212
|
+
)
|
|
213
|
+
@click.confirmation_option(prompt="Really delete this Authentik application?")
|
|
214
|
+
def delete_app_cmd(slug: str, expected_name: str) -> None:
|
|
215
|
+
"""Delete an Authentik application by slug after confirming its name."""
|
|
216
|
+
client = _client()
|
|
217
|
+
try:
|
|
218
|
+
deleted = client.delete_application(slug, expected_name=expected_name)
|
|
219
|
+
except AuthentikError as exc:
|
|
220
|
+
click.echo(f"Authentik error: {exc}", err=True)
|
|
221
|
+
sys.exit(1)
|
|
222
|
+
if deleted:
|
|
223
|
+
click.echo(f"Application {slug} deleted")
|
|
224
|
+
else:
|
|
225
|
+
click.echo(f"Application {slug} already absent")
|
|
226
|
+
|
|
227
|
+
|
|
205
228
|
@authentik.command("ensure-group")
|
|
206
229
|
@click.argument("name", default=ADMIN_GROUP_NAME)
|
|
207
230
|
def ensure_group_cmd(name: str) -> None:
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""OAuth configuration commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.group()
|
|
11
|
+
def oauth() -> None:
|
|
12
|
+
"""Configure OAuth client settings for local apps."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@oauth.command("configure-productivity-local")
|
|
16
|
+
@click.option(
|
|
17
|
+
"--env-file",
|
|
18
|
+
type=click.Path(path_type=Path),
|
|
19
|
+
default=Path(".deploy.env"),
|
|
20
|
+
show_default=True,
|
|
21
|
+
help="Env file to update in the current project.",
|
|
22
|
+
)
|
|
23
|
+
@click.option("--google-client-id", default=None, help="Google OAuth client ID.")
|
|
24
|
+
@click.option("--google-client-secret", default=None, help="Google OAuth client secret (optional; not echoed).")
|
|
25
|
+
@click.option("--azure-client-id", default=None, help="Microsoft Entra application/client ID.")
|
|
26
|
+
@click.option("--azure-client-secret", default=None, help="Microsoft Entra client secret (optional; not echoed).")
|
|
27
|
+
@click.option("--dry-run", is_flag=True, help="Print planned keys without writing the env file.")
|
|
28
|
+
def configure_productivity_local(
|
|
29
|
+
env_file: Path,
|
|
30
|
+
google_client_id: str | None,
|
|
31
|
+
google_client_secret: str | None,
|
|
32
|
+
azure_client_id: str | None,
|
|
33
|
+
azure_client_secret: str | None,
|
|
34
|
+
dry_run: bool,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Write real Google/Microsoft OAuth IDs into a local dashboard env file.
|
|
37
|
+
|
|
38
|
+
Provider application creation is intentionally not automated here: Google
|
|
39
|
+
Cloud OAuth consent screens and Microsoft Entra app registrations require
|
|
40
|
+
tenant-admin policy choices. Use this after real provider apps exist.
|
|
41
|
+
"""
|
|
42
|
+
updates = {
|
|
43
|
+
"GOOGLE_CLIENT_ID": google_client_id,
|
|
44
|
+
"GOOGLE_CLIENT_SECRET": google_client_secret,
|
|
45
|
+
"AZURE_CLIENT_ID": azure_client_id,
|
|
46
|
+
"AZURE_CLIENT_SECRET": azure_client_secret,
|
|
47
|
+
}
|
|
48
|
+
updates = {key: value for key, value in updates.items() if value}
|
|
49
|
+
if not updates:
|
|
50
|
+
raise click.UsageError("Provide at least one client id or secret value.")
|
|
51
|
+
|
|
52
|
+
existing = _read_env(env_file)
|
|
53
|
+
merged = {**existing, **updates}
|
|
54
|
+
|
|
55
|
+
click.echo(f"Env file: {env_file}")
|
|
56
|
+
for key in sorted(updates):
|
|
57
|
+
display = _mask(updates[key]) if key.endswith("SECRET") else updates[key]
|
|
58
|
+
click.echo(f" set {key}={display}")
|
|
59
|
+
|
|
60
|
+
if dry_run:
|
|
61
|
+
click.echo("Dry run; no file written.")
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
_write_env(env_file, merged)
|
|
65
|
+
click.echo("Updated local OAuth configuration.")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _read_env(path: Path) -> dict[str, str]:
|
|
69
|
+
if not path.exists():
|
|
70
|
+
return {}
|
|
71
|
+
values: dict[str, str] = {}
|
|
72
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
73
|
+
stripped = line.strip()
|
|
74
|
+
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
|
75
|
+
continue
|
|
76
|
+
key, value = stripped.split("=", 1)
|
|
77
|
+
values[key.strip()] = value.strip().strip('"').strip("'")
|
|
78
|
+
return values
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _write_env(path: Path, values: dict[str, str]) -> None:
|
|
82
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
lines = [f"{key}={_quote_env(value)}" for key, value in sorted(values.items())]
|
|
84
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _quote_env(value: str) -> str:
|
|
88
|
+
if not value or any(ch.isspace() for ch in value) or any(ch in value for ch in '"\'#$'):
|
|
89
|
+
return f'"{value.replace(chr(34), chr(92) + chr(34))}"'
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _mask(value: str) -> str:
|
|
94
|
+
if len(value) <= 8:
|
|
95
|
+
return "****"
|
|
96
|
+
return f"{value[:4]}…{value[-4:]}"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|