From e843db2601e6cec51231b1e815ba1451f36c555b Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Mon, 24 Aug 2026 23:27:22 -0500 Subject: [PATCH] feat(apps): onboard pihole via renderer + --no-image-automation flag (WS-APPS golden path) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First app onboarded through catalog/render.py -> apps/. Added --no-image-automation so apps deploy before the Flux image controllers exist (L1 goldens keep the default, unchanged). KNOWN GAP surfaced: exposed apps get default-deny (ingress: []) with no gateway-ingress allow, so the HTTPRoute path is blocked — renderer/WS-NET follow-up to allow Cilium gateway ingress to services with an exposure/HTTPRoute. --- apps/kustomization.yaml | 9 +++ apps/pihole.yaml | 174 ++++++++++++++++++++++++++++++++++++++++ catalog/render.py | 28 +++++-- 3 files changed, 203 insertions(+), 8 deletions(-) create mode 100644 apps/kustomization.yaml create mode 100644 apps/pihole.yaml diff --git a/apps/kustomization.yaml b/apps/kustomization.yaml new file mode 100644 index 0000000..d370026 --- /dev/null +++ b/apps/kustomization.yaml @@ -0,0 +1,9 @@ +--- +# Onboarded services (WS-APPS). Each app is rendered from its catalog/ ServiceDefinition by +# catalog/render.py and committed here; the `apps` Flux Kustomization (clusters/base/apps.yaml, +# dependsOn platform-config) reconciles this directory. Image-automation CRs are omitted for now +# (--no-image-automation) until the Flux image controllers are installed. +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - pihole.yaml diff --git a/apps/pihole.yaml b/apps/pihole.yaml new file mode 100644 index 0000000..af993bf --- /dev/null +++ b/apps/pihole.yaml @@ -0,0 +1,174 @@ +--- +# Namespace (trust zone; default-deny + policies apply) +apiVersion: v1 +kind: Namespace +metadata: + name: pihole + labels: + homelab.keithjackson.dev/app-namespace: 'true' + gateway-access: shared + annotations: + homelab.keithjackson.dev/backup-policy: daily-offcluster +--- +# Default-deny baseline (R-NET-01): deny all, allow only DNS +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny + namespace: pihole + labels: + homelab.keithjackson.dev/baseline: default-deny +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress + ingress: [] + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 +--- +# PVC pihole-config (R-STATE) +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: pihole-config + namespace: pihole + labels: + app.kubernetes.io/name: pihole +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn + resources: + requests: + storage: 2Gi +--- +# Deployment (R-PLAT-02) +apiVersion: apps/v1 +kind: Deployment +metadata: + name: pihole + namespace: pihole + labels: + app.kubernetes.io/name: pihole +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: pihole + template: + metadata: + labels: + app.kubernetes.io/name: pihole + homelab.keithjackson.dev/egress-class: internet + homelab.keithjackson.dev/auth-mode: forward-auth + annotations: + homelab.keithjackson.dev/auth-mode: forward-auth + homelab.keithjackson.dev/auth-groups: platform-admins + spec: + containers: + - name: pihole + image: pihole/pihole + ports: + - containerPort: 80 + volumeMounts: + - name: config + mountPath: /data/config + volumes: + - name: config + persistentVolumeClaim: + claimName: pihole-config +--- +# Service +apiVersion: v1 +kind: Service +metadata: + name: pihole + namespace: pihole + labels: + app.kubernetes.io/name: pihole +spec: + selector: + app.kubernetes.io/name: pihole + ports: + - name: port-80 + port: 80 + targetPort: 80 +--- +# HTTPRoute pihole-80 (R-TLS/R-DNS) +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: pihole-80 + namespace: pihole + labels: + app.kubernetes.io/name: pihole + annotations: + homelab.keithjackson.dev/auth-mode: forward-auth + homelab.keithjackson.dev/auth-groups: platform-admins +spec: + parentRefs: + - name: shared + namespace: gateway + sectionName: https + hostnames: + - pihole.home.keithjackson.dev + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: pihole + port: 80 +--- +# ServiceMonitor (R-OBS-01) +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: pihole + namespace: pihole + labels: + release: kube-prometheus-stack + app.kubernetes.io/name: pihole +spec: + selector: + matchLabels: + app.kubernetes.io/name: pihole + namespaceSelector: + matchNames: + - pihole + endpoints: + - port: metrics + interval: 30s +--- +# PrometheusRule SLI alerts (R-OBS-01) +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: pihole-slis + namespace: pihole + labels: + release: kube-prometheus-stack + app.kubernetes.io/name: pihole +spec: + groups: + - name: pihole.slis + rules: + - alert: pihole-down + expr: up{job="pihole"} == 0 + for: 5m + labels: + severity: critical + service: pihole + annotations: + summary: pihole is down + description: 'pihole has been unreachable for 5m (SLI: up).' diff --git a/catalog/render.py b/catalog/render.py index fa2feaa..4824f8d 100644 --- a/catalog/render.py +++ b/catalog/render.py @@ -580,8 +580,14 @@ def r_virtualmachine(s: dict) -> dict: # ============================================================================= # Top-level render + emit # ============================================================================= -def render(sd: dict) -> list: - """Return an ordered list of (header_comment, resource_dict) for a ServiceDefinition.""" +def render(sd: dict, image_automation: bool = True) -> list: + """Return an ordered list of (header_comment, resource_dict) for a ServiceDefinition. + + image_automation=False omits the Flux ImageRepository/ImagePolicy CRs — used when + onboarding apps before the Flux image-reflector/-automation controllers are installed + (their CRDs would otherwise be missing and the apps Kustomization would fail). The L1 + render goldens use the default (True), so their expected output is unchanged. + """ s = apply_defaults(sd) if s["kind"] not in ("container", "vm"): raise ValueError(f"unknown kind: {s['kind']!r}") @@ -620,8 +626,9 @@ def render(sd: dict) -> list: if pr: docs.append(("PrometheusRule SLI alerts (R-OBS-01)", pr)) - for res in r_image_automation(s): - docs.append((f"Flux {res['kind']} (R-UPD)", res)) + if image_automation: + for res in r_image_automation(s): + docs.append((f"Flux {res['kind']} (R-UPD)", res)) return docs @@ -645,8 +652,8 @@ def load_sd(path: Path) -> dict: return yaml.safe_load(fh) -def render_file(path: Path) -> str: - return emit(render(load_sd(path))) +def render_file(path: Path, image_automation: bool = True) -> str: + return emit(render(load_sd(path), image_automation=image_automation)) # ============================================================================= @@ -745,16 +752,21 @@ def main(argv=None) -> int: ap.add_argument("--all", metavar="DIR", help="Render every *.yaml ServiceDefinition in DIR") ap.add_argument("-o", "--out-dir", help="With --all: write .yaml files into this dir") ap.add_argument("--selftest", action="store_true", help="Run the built-in self-test") + ap.add_argument("--no-image-automation", action="store_true", + help="Omit Flux ImageRepository/ImagePolicy CRs (for onboarding before the " + "image-reflector/-automation controllers are installed)") args = ap.parse_args(argv) if args.selftest: return _selftest() + image_automation = not args.no_image_automation + if args.all: src = Path(args.all) files = sorted(p for p in src.glob("*.yaml") if p.name != "render.py") for f in files: - text = render_file(f) + text = render_file(f, image_automation=image_automation) if args.out_dir: outdir = Path(args.out_dir) outdir.mkdir(parents=True, exist_ok=True) @@ -766,7 +778,7 @@ def main(argv=None) -> int: if not args.input: ap.error("provide a ServiceDefinition file, --all DIR, or --selftest") - sys.stdout.write(render_file(Path(args.input))) + sys.stdout.write(render_file(Path(args.input), image_automation=image_automation)) return 0