From bf029d0033975e661b4c2a8963d09091dc5fa7ef Mon Sep 17 00:00:00 2001 From: Sebastian Preisner Date: Wed, 29 Jul 2026 11:40:16 +0200 Subject: [PATCH] helm: optional NetworkPolicy per component In a namespace with a default-deny policy the chart cannot be installed: the components never reach each other, and the post-install bucket hook waits on the master and filer until it gives up. networkPolicy.enabled renders one policy per component, selecting its pods by the standard app.kubernetes.io labels and admitting the other pods of the release on the ports that component listens on. The port lists come from the same values as the containerPorts, and CI asserts the two agree. Restricting egress is a second opt-in with extraEgress for the filer store and notification sinks, which the chart cannot know about. Closes #10421 Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/helm_ci.yml | 279 ++++++++++++++++++ k8s/charts/seaweedfs/README.md | 30 ++ .../templates/shared/networkpolicy.yaml | 231 +++++++++++++++ .../shared/post-install-bucket-hook.yaml | 2 + k8s/charts/seaweedfs/values.yaml | 108 +++++++ 5 files changed, 650 insertions(+) create mode 100644 k8s/charts/seaweedfs/templates/shared/networkpolicy.yaml diff --git a/.github/workflows/helm_ci.yml b/.github/workflows/helm_ci.yml index 7f9679a92..262d0928a 100644 --- a/.github/workflows/helm_ci.yml +++ b/.github/workflows/helm_ci.yml @@ -822,6 +822,184 @@ jobs: PYEOF echo "Hook Job label tests passed" + echo "" + echo "=== Testing NetworkPolicy rendering ===" + # The policies are only exercised for real by the networkpolicy-install + # job below. These assertions cover what template rendering can see: + # that the flag stays off by default, that every deployed component has + # exactly one policy, that each policy allows every port its workload + # declares, and that egress stays a separate opt-in. + python3 - "$CHART_DIR" <<'PYEOF' + import subprocess, sys, yaml + + chart = sys.argv[1] + + def render(values): + args = ["helm", "template", "test", chart] + for k, v in values.items(): + args += ["--set", f"{k}={v}"] + return subprocess.check_output(args, text=True, stderr=subprocess.STDOUT) + + def docs(manifest): + return [d for d in yaml.safe_load_all(manifest) if d] + + def component(labels): + return labels.get("app.kubernetes.io/component") + + def policies(manifest): + out = {} + for d in docs(manifest): + if d.get("kind") != "NetworkPolicy": + continue + out[component(d["spec"]["podSelector"]["matchLabels"])] = d + return out + + def workloads(manifest, kinds=("Deployment", "StatefulSet")): + out = {} + for d in docs(manifest): + if d.get("kind") in kinds: + out[component(d["spec"]["template"]["metadata"]["labels"])] = d + return out + + def allowed_ports(policy): + ports = set() + for rule in policy["spec"].get("ingress") or []: + for p in rule.get("ports") or []: + ports.add(p["port"]) + return ports + + def container_ports(workload): + ports = set() + for c in workload["spec"]["template"]["spec"].get("containers", []): + for p in c.get("ports") or []: + ports.add(p["containerPort"]) + return ports + + EVERYTHING = { + "s3.enabled": "true", + "sftp.enabled": "true", + "admin.enabled": "true", + "worker.enabled": "true", + "cosi.enabled": "true", + "s3.createBuckets[0].name": "b", + "volumes.ssd.port": "8081", + "global.seaweedfs.monitoring.enabled": "true", + } + + failed = [] + + # Off by default: the chart has never shipped a NetworkPolicy and must not + # start now, or every existing release in a default-deny namespace changes + # behaviour on the next upgrade. + if policies(render(EVERYTHING)): + failed.append("networkPolicy.enabled unset: policies rendered anyway") + else: + print("networkPolicy off by default: no policies rendered") + + on = dict(EVERYTHING, **{"networkPolicy.enabled": "true"}) + out = render(on) + pols = policies(out) + wls = workloads(out) + + # Every workload gets exactly one policy, and every policy has a workload. + # A component with no policy is wide open under default-deny; a policy with + # no component is dead weight that hides a renamed label. + for comp in wls: + if comp not in pols: + failed.append(f"{comp}: workload has no NetworkPolicy") + for comp in pols: + # The hook Jobs are covered too, and the resize hook Job only renders + # when a cluster lookup says a PVC needs growing, so it is never in the + # rendered set here. + if comp not in wls and comp not in ("bucket-hook", "volume-resize-hook"): + failed.append(f"{comp}: NetworkPolicy selects a component that is not deployed") + + # The ports a component listens on come from the same values as its + # containerPorts, so the two must agree. This is what catches a port added to + # a workload and forgotten in the policy - the failure mode that only shows up + # once someone turns the flag on. + for comp, wl in wls.items(): + if comp not in pols: + continue + declared = container_ports(wl) + allowed = allowed_ports(pols[comp]) + missing = sorted(declared - allowed) + if missing: + failed.append(f"{comp}: listens on {missing} but its policy does not allow it") + if not failed: + print("every workload has a policy covering all of its containerPorts") + + # Egress is its own opt-in: with it off the policies must not constrain + # outbound traffic at all, or enabling networkPolicy alone would cut the filer + # off from its store. + for comp, p in pols.items(): + if "Egress" in p["spec"]["policyTypes"]: + failed.append(f"{comp}: Egress in policyTypes while networkPolicy.egress.enabled is false") + if not any("Egress" in p["spec"]["policyTypes"] for p in pols.values()): + print("egress off by default: policies are ingress-only") + + # Components that need the API server must not silently lose it: rendering + # fails with a pointer to the value instead. + try: + render(dict(on, **{"networkPolicy.egress.enabled": "true"})) + failed.append("egress on with empty kubeApiServer.cidrs: render should have failed") + except subprocess.CalledProcessError as e: + if "kubeApiServer.cidrs is empty" not in (e.output or ""): + failed.append(f"egress on with empty cidrs: unexpected error: {(e.output or '')[:200]}") + else: + print("empty kubeApiServer.cidrs fails the render with a pointer to the value") + + egress_on = dict(on, **{ + "networkPolicy.egress.enabled": "true", + "networkPolicy.egress.kubeApiServer.cidrs[0]": "10.96.0.1/32", + }) + out = render(egress_on) + pols = policies(out) + + # DNS for everyone: every component addresses its peers by service name. + for comp, p in pols.items(): + dns = [r for r in p["spec"]["egress"] + if {x["port"] for x in r.get("ports") or []} == {53}] + if not dns: + failed.append(f"{comp}: egress on but no DNS rule") + if not failed: + print("every policy allows DNS when egress is on") + + # The API server rule goes only to the components that talk to it. + apiserver = {c for c, p in pols.items() + if any("ipBlock" in t for r in p["spec"]["egress"] for t in r.get("to") or [])} + expected = {"admin", "objectstorage-provisioner", "volume-resize-hook"} + if apiserver != expected: + failed.append(f"API server egress granted to {sorted(apiserver)}, expected {sorted(expected)}") + else: + print(f"API server egress limited to {sorted(expected)}") + + # The resize hook runs as a pre-install hook at weight 0, before the release + # manifest is applied, so its policy has to be a hook itself and has to sort + # ahead of the Job. + rh = pols["volume-resize-hook"]["metadata"].get("annotations", {}) + if rh.get("helm.sh/hook") != "pre-install,pre-upgrade": + failed.append(f"volume-resize-hook policy is not a pre-install hook: {rh}") + elif int(rh.get("helm.sh/hook-weight", 0)) >= 0: + failed.append(f"volume-resize-hook policy weight {rh.get('helm.sh/hook-weight')} does not sort before the Job at 0") + else: + print("volume-resize-hook policy is a pre-install hook ahead of the Job") + + # The bucket hook is post-install, so the release manifest is already applied; + # its policy must be a plain resource that uninstall cleans up. + if "helm.sh/hook" in (pols["bucket-hook"]["metadata"].get("annotations") or {}): + failed.append("bucket-hook policy is a hook resource; post-install runs after the manifest is applied") + else: + print("bucket-hook policy is a plain release resource") + + if failed: + print("\nFAIL:", file=sys.stderr) + for f in failed: + print(f" - {f}", file=sys.stderr) + sys.exit(1) + PYEOF + echo "NetworkPolicy rendering tests passed" + echo "All template rendering tests passed!" - name: Create kind cluster @@ -874,3 +1052,104 @@ jobs: kubectl delete namespace "$NS" echo "SFTP host key lifecycle tests passed" + + - name: Verify install into a default-deny namespace + run: | + set -e + CHART_DIR="k8s/charts/seaweedfs" + NS="netpol" + + # kind enforces NetworkPolicy out of the box since v0.24 (kindnetd + # runs sigs.k8s.io/kube-network-policies), so the cluster created for + # chart-testing above is enough and no extra CNI is needed. The two + # probes at the end fail loudly if that ever stops being true, rather + # than letting this pass vacuously. + kubectl create namespace "$NS" + kubectl apply -n "$NS" -f - <<'EOF' + apiVersion: networking.k8s.io/v1 + kind: NetworkPolicy + metadata: + name: default-deny + spec: + podSelector: {} + policyTypes: [Ingress, Egress] + EOF + + # The backend address, not the kubernetes service ClusterIP: kube-proxy + # rewrites the destination, so an ipBlock has to name the real endpoint. + APISERVER=$(kubectl get endpointslice kubernetes \ + -o jsonpath='{.endpoints[0].addresses[0]}' 2>/dev/null || true) + if [ -z "$APISERVER" ]; then + APISERVER=$(kubectl get endpoints kubernetes -o jsonpath='{.subsets[0].addresses[0].ip}') + fi + echo "kube-apiserver at $APISERVER" + + echo "=== install with the policies on ===" + # Without them this hangs: the components cannot resolve or reach each + # other, and the post-install bucket hook waits on master and filer + # until it gives up. --wait covers the components, and helm fails the + # release if the hook Job does not finish, so a clean install is the + # assertion. + helm install np $CHART_DIR -n "$NS" --wait --timeout 8m \ + --set s3.enabled=true \ + --set s3.createBuckets[0].name=testbucket \ + --set networkPolicy.enabled=true \ + --set networkPolicy.egress.enabled=true \ + --set networkPolicy.egress.kubeApiServer.cidrs[0]="$APISERVER/32" + echo "release came up and the bucket hook finished under default-deny" + + FILER_IP=$(kubectl get pod -n "$NS" -l app.kubernetes.io/component=filer \ + -o jsonpath='{.items[0].status.podIP}') + echo "filer pod at $FILER_IP" + + # Both probes get their own all-egress policy, so the namespace-wide + # default-deny is not what decides the outcome: the only thing left in + # the way is the filer's own policy, which admits release pods only. + # Probing the pod IP keeps DNS out of it. + kubectl apply -n "$NS" -f - <<'EOF' + apiVersion: networking.k8s.io/v1 + kind: NetworkPolicy + metadata: + name: probe-egress + spec: + podSelector: + matchExpressions: + - key: probe + operator: Exists + policyTypes: [Egress] + egress: + - {} + EOF + + probe() { + local name=$1 + local labels=$2 + kubectl run "$name" -n "$NS" --image=busybox:1.36 --restart=Never \ + --labels="probe=$name,$labels" --command -- \ + sh -c "wget -T 5 -q -O /dev/null http://$FILER_IP:8888/; echo exit=\$?" + kubectl wait -n "$NS" --for=jsonpath='{.status.phase}'=Succeeded \ + "pod/$name" --timeout=120s >/dev/null + kubectl logs -n "$NS" "$name" + } + + # A pod carrying the release labels is what the filer's policy allows. + # This has to succeed, otherwise the policies are blocking traffic they + # are supposed to permit - or nothing is enforced and the next probe + # would be meaningless. + ALLOWED=$(probe probe-allowed "app.kubernetes.io/name=seaweedfs,app.kubernetes.io/instance=np") + echo "labelled probe: $ALLOWED" + case "$ALLOWED" in + *exit=0*) echo "a pod with the release labels reaches the filer";; + *) echo "FAIL: the filer policy rejects a pod carrying the release labels"; exit 1;; + esac + + # The same probe without those labels must not get through. + DENIED=$(probe probe-denied "role=outsider") + echo "unlabelled probe: $DENIED" + case "$DENIED" in + *exit=0*) echo "FAIL: a pod outside the release reached the filer; the policy over-allows"; exit 1;; + *) echo "the filer policy refuses a pod outside the release";; + esac + + kubectl delete namespace "$NS" --wait=false + echo "default-deny namespace tests passed" diff --git a/k8s/charts/seaweedfs/README.md b/k8s/charts/seaweedfs/README.md index a7d666d55..1785e714e 100644 --- a/k8s/charts/seaweedfs/README.md +++ b/k8s/charts/seaweedfs/README.md @@ -365,6 +365,36 @@ helm install seaweedfs-worker-vacuum seaweedfs/seaweedfs -f values-worker-vacuum helm install seaweedfs-worker-balance seaweedfs/seaweedfs -f values-worker-balance.yaml ``` +## Network Policies + +In a namespace with a default-deny policy the install hangs: the components cannot resolve each other, and the post-install bucket hook waits on the master and filer until it gives up. `networkPolicy.enabled` renders one `NetworkPolicy` per component, selecting its pods by the standard `app.kubernetes.io/{name,instance,component}` labels and admitting traffic from the other pods of the release on the ports that component listens on. + +```bash +helm install seaweedfs seaweedfs/seaweedfs --set networkPolicy.enabled=true +``` + +That alone leaves outbound traffic untouched. Restricting egress is a second opt-in, because the chart knows where its own components live but not where your filer store, notification sink or remote tier does: + +```yaml +networkPolicy: + enabled: true + egress: + enabled: true + kubeApiServer: + # the endpoint behind the kubernetes service, not its ClusterIP + cidrs: ["172.18.0.2/32"] + extraEgress: + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: postgresql + ports: + - protocol: TCP + port: 5432 +``` + +Anything reaching the release from outside - an ingress controller, a Prometheus in another namespace - goes into `networkPolicy.extraIngress`, or into `networkPolicy.components..extraIngress` for a single component. See the `networkPolicy` block in `values.yaml` for the full set. + ## OpenShift Support SeaweedFS can be deployed on OpenShift or any cluster enforcing the Kubernetes "restricted" Pod Security Standard. By default, OpenShift blocks containers that run as root or use `hostPath` volumes. diff --git a/k8s/charts/seaweedfs/templates/shared/networkpolicy.yaml b/k8s/charts/seaweedfs/templates/shared/networkpolicy.yaml new file mode 100644 index 000000000..b6a18e75d --- /dev/null +++ b/k8s/charts/seaweedfs/templates/shared/networkpolicy.yaml @@ -0,0 +1,231 @@ +{{- include "seaweedfs.compat" . -}} +{{- if .Values.networkPolicy.enabled }} +{{- $np := .Values.networkPolicy }} +{{- $egressCfg := $np.egress | default dict }} +{{- $overrides := $np.components | default dict }} + +{{- /* One entry per rendered workload, keyed by its app.kubernetes.io/component + label. "ports" is everything the workload listens on, taken from the same + values the containerPorts are taken from, so a retuned port cannot drift + out of the policy. "apiserver" marks the workloads that talk to the + Kubernetes API. */}} +{{- $targets := list }} + +{{- /* allInOne.enabled does not turn the individual components off - their + templates only look at their own enabled flag - so the all-in-one pod is + an addition here, not an alternative. */}} +{{- if .Values.allInOne.enabled }} +{{- $ports := list .Values.master.port .Values.master.grpcPort .Values.volume.port .Values.volume.grpcPort .Values.filer.port .Values.filer.grpcPort }} +{{- if .Values.allInOne.metricsPort }} +{{- $ports = append $ports .Values.allInOne.metricsPort }} +{{- end }} +{{- if .Values.allInOne.s3.enabled }} +{{- $ports = append $ports (.Values.allInOne.s3.port | default .Values.s3.port) }} +{{- $https := .Values.allInOne.s3.httpsPort | default .Values.s3.httpsPort }} +{{- if and $https (gt (int $https) 0) }} +{{- $ports = append $ports $https }} +{{- end }} +{{- end }} +{{- if .Values.allInOne.sftp.enabled }} +{{- $ports = append $ports (.Values.allInOne.sftp.port | default .Values.sftp.port) }} +{{- end }} +{{- $targets = append $targets (dict "component" "seaweedfs-all-in-one" "ports" $ports) }} +{{- end }} +{{- if .Values.master.enabled }} +{{- $ports := list .Values.master.port .Values.master.grpcPort }} +{{- if .Values.master.metricsPort }} +{{- $ports = append $ports .Values.master.metricsPort }} +{{- end }} +{{- $targets = append $targets (dict "component" "master" "ports" $ports) }} +{{- end }} +{{- /* Every volume group carries its own component label and its own ports. */}} +{{- $anyVolume := false }} +{{- $volumes := deepCopy .Values.volumes | mergeOverwrite (dict "" .Values.volume) }} +{{- range $vname, $volume := $volumes }} +{{- $volumeName := trimSuffix "-" (printf "volume-%s" $vname) }} +{{- $volume := mergeOverwrite (deepCopy $.Values.volume) (dict "enabled" true) $volume }} +{{- if $volume.enabled }} +{{- $ports := list $volume.port $volume.grpcPort }} +{{- if $volume.metricsPort }} +{{- $ports = append $ports $volume.metricsPort }} +{{- end }} +{{- $targets = append $targets (dict "component" $volumeName "ports" $ports) }} +{{- $anyVolume = true }} +{{- end }} +{{- end }} +{{- if .Values.filer.enabled }} +{{- $ports := list .Values.filer.port .Values.filer.grpcPort }} +{{- if .Values.filer.metricsPort }} +{{- $ports = append $ports .Values.filer.metricsPort }} +{{- end }} +{{- if .Values.filer.s3.enabled }} +{{- $ports = append $ports .Values.filer.s3.port }} +{{- if and .Values.filer.s3.httpsPort (gt (int .Values.filer.s3.httpsPort) 0) }} +{{- $ports = append $ports .Values.filer.s3.httpsPort }} +{{- end }} +{{- end }} +{{- $targets = append $targets (dict "component" "filer" "ports" $ports) }} +{{- end }} +{{- if .Values.s3.enabled }} +{{- $ports := list .Values.s3.port }} +{{- if and .Values.s3.httpsPort (gt (int .Values.s3.httpsPort) 0) }} +{{- $ports = append $ports .Values.s3.httpsPort }} +{{- end }} +{{- if .Values.s3.icebergPort }} +{{- $ports = append $ports .Values.s3.icebergPort }} +{{- end }} +{{- if .Values.s3.metricsPort }} +{{- $ports = append $ports .Values.s3.metricsPort }} +{{- end }} +{{- $targets = append $targets (dict "component" "s3" "ports" $ports) }} +{{- end }} +{{- if .Values.sftp.enabled }} +{{- $ports := list .Values.sftp.port }} +{{- if .Values.sftp.metricsPort }} +{{- $ports = append $ports .Values.sftp.metricsPort }} +{{- end }} +{{- $targets = append $targets (dict "component" "sftp" "ports" $ports) }} +{{- end }} +{{- if .Values.admin.enabled }} +{{- $targets = append $targets (dict "component" "admin" "ports" (list .Values.admin.port .Values.admin.grpcPort) "apiserver" true) }} +{{- end }} +{{- if .Values.worker.enabled }} +{{- $ports := list }} +{{- if .Values.worker.metricsPort }} +{{- $ports = append $ports .Values.worker.metricsPort }} +{{- end }} +{{- $targets = append $targets (dict "component" "worker" "ports" $ports) }} +{{- end }} + +{{- if .Values.cosi.enabled }} +{{- /* The provisioner sidecar watches the COSI CRs, so it needs the API + server; nothing needs to reach the driver itself. */}} +{{- $targets = append $targets (dict "component" "objectstorage-provisioner" "ports" (list) "apiserver" true) }} +{{- end }} + +{{- /* The hook Jobs. Nothing connects to either, but under a default-deny + namespace they need egress or they hang: the bucket hook waits on the + master and filer, the resize hook shells out to kubectl. + + The bucket hook is post-install, and Helm applies the release manifest + before post-install hooks run, so a plain policy is already in place by + then. The resize hook is pre-install weight 0, which runs before the + manifest is applied at all, so its policy has to be a pre-install hook + itself, at a weight below the Job's. */}} +{{- /* Same condition as the Job in post-install-bucket-hook.yaml: an S3 + endpoint plus buckets to create. Keep the two in step. */}} +{{- $bucketHook := false }} +{{- if .Values.allInOne.enabled }} +{{- $bucketHook = and .Values.allInOne.s3.enabled .Values.allInOne.s3.createBuckets }} +{{- else if .Values.master.enabled }} +{{- if or .Values.filer.s3.enabled .Values.s3.enabled }} +{{- $bucketHook = or .Values.s3.createBuckets .Values.filer.s3.createBuckets }} +{{- end }} +{{- end }} +{{- if $bucketHook }} +{{- $targets = append $targets (dict "component" "bucket-hook" "ports" (list)) }} +{{- end }} +{{- /* Whether the resize hook Job materialises depends on a cluster lookup, so + there is nothing to key on at render time. Its policy is created whenever + the hook is enabled and selects no pods on the upgrades that need no + resize. */}} +{{- if and .Values.volume.resizeHook.enabled $anyVolume }} +{{- $targets = append $targets (dict "component" "volume-resize-hook" "ports" (list) "apiserver" true "hook" "pre-install,pre-upgrade" "hookWeight" "-10") }} +{{- end }} + +{{- range $target := $targets }} +{{- $component := $target.component }} +{{- $override := get $overrides $component | default dict }} +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "seaweedfs.componentName" (list $ $component) }} + namespace: {{ $.Release.Namespace }} + labels: + app.kubernetes.io/name: {{ template "seaweedfs.name" $ }} + helm.sh/chart: {{ $.Chart.Name }}-{{ $.Chart.Version | replace "+" "_" }} + app.kubernetes.io/managed-by: {{ $.Release.Service }} + app.kubernetes.io/instance: {{ $.Release.Name }} + app.kubernetes.io/component: {{ $component }} +{{- if $target.hook }} + annotations: + {{- /* Only before-hook-creation: the policy has to outlive the Job it + covers, so it must not be deleted when the Job succeeds. That does + leave it behind on uninstall, the usual trade-off for Helm hook + resources. */}} + "helm.sh/hook": {{ $target.hook }} + "helm.sh/hook-weight": {{ $target.hookWeight | quote }} + "helm.sh/hook-delete-policy": before-hook-creation +{{- end }} +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: {{ template "seaweedfs.name" $ }} + app.kubernetes.io/instance: {{ $.Release.Name }} + app.kubernetes.io/component: {{ $component }} + policyTypes: + - Ingress +{{- if $egressCfg.enabled }} + - Egress +{{- end }} + ingress: +{{- $extraIngress := concat ($np.extraIngress | default list) ($override.extraIngress | default list) }} +{{- if and (not $target.ports) (not $extraIngress) }} + [] +{{- end }} +{{- if $target.ports }} + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: {{ template "seaweedfs.name" $ }} + app.kubernetes.io/instance: {{ $.Release.Name }} + ports: +{{- range $port := $target.ports }} + - protocol: TCP + port: {{ $port }} +{{- end }} +{{- end }} +{{- with $extraIngress }} +{{- toYaml . | nindent 4 }} +{{- end }} +{{- if $egressCfg.enabled }} + egress: + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: {{ template "seaweedfs.name" $ }} + app.kubernetes.io/instance: {{ $.Release.Name }} +{{- if $egressCfg.allowDNS }} + - to: + - namespaceSelector: + {{- toYaml $egressCfg.dnsNamespaceSelector | nindent 12 }} + podSelector: + {{- toYaml $egressCfg.dnsPodSelector | nindent 12 }} + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 +{{- end }} +{{- if and $target.apiserver ($egressCfg.kubeApiServer).enabled }} +{{- if not $egressCfg.kubeApiServer.cidrs }} +{{- fail (printf "networkPolicy: %s needs the Kubernetes API server, but networkPolicy.egress.kubeApiServer.cidrs is empty. Set it to your API server address(es), or disable networkPolicy.egress.kubeApiServer and allow it through networkPolicy.egress.extraEgress." $component) }} +{{- end }} +{{- range $cidr := $egressCfg.kubeApiServer.cidrs }} + - to: + - ipBlock: + cidr: {{ $cidr }} + ports: +{{- range $port := $egressCfg.kubeApiServer.ports }} + - protocol: TCP + port: {{ $port }} +{{- end }} +{{- end }} +{{- end }} +{{- with concat ($egressCfg.extraEgress | default list) ($override.extraEgress | default list) }} +{{- toYaml . | nindent 4 }} +{{- end }} +{{- end }} +{{- end }} +{{- end }} diff --git a/k8s/charts/seaweedfs/templates/shared/post-install-bucket-hook.yaml b/k8s/charts/seaweedfs/templates/shared/post-install-bucket-hook.yaml index 8f6cd287f..83fab94b3 100644 --- a/k8s/charts/seaweedfs/templates/shared/post-install-bucket-hook.yaml +++ b/k8s/charts/seaweedfs/templates/shared/post-install-bucket-hook.yaml @@ -40,6 +40,8 @@ {{- end }} {{- end }} +{{- /* networkpolicy.yaml decides whether to render this Job's policy off the + same condition. Keep the two in step. */}} {{- if and $s3Enabled $createBuckets }} --- apiVersion: batch/v1 diff --git a/k8s/charts/seaweedfs/values.yaml b/k8s/charts/seaweedfs/values.yaml index f20726fea..ef97bca9c 100644 --- a/k8s/charts/seaweedfs/values.yaml +++ b/k8s/charts/seaweedfs/values.yaml @@ -1810,3 +1810,111 @@ certificates: podLabels: {} # Annotations to be added to all the created pods podAnnotations: {} + +networkPolicy: + # Opt-in. While false the chart renders no NetworkPolicy objects at all and + # the cluster default (usually allow-all) applies. Turn this on for a + # namespace with a default-deny policy, where the components otherwise + # cannot reach each other and the install hangs. + enabled: false + + # One policy is rendered per component, selecting its pods by the standard + # app.kubernetes.io/{name,instance,component} labels. Each policy admits + # traffic from any pod of this release, on the ports that component listens + # on - the same values the containerPorts are taken from, so retuning a port + # does not leave the policy behind. Everything else is denied. + # + # Traffic from outside the release has to be added here. Rules are plain + # NetworkPolicyIngressRule entries, appended to every component's policy: + # + # extraIngress: + # # an ingress controller reaching filer/s3/admin + # - from: + # - namespaceSelector: + # matchLabels: + # kubernetes.io/metadata.name: ingress-nginx + # ports: + # - protocol: TCP + # port: 8888 + # - protocol: TCP + # port: 8333 + # # Prometheus scraping the metrics ports + # - from: + # - namespaceSelector: + # matchLabels: + # kubernetes.io/metadata.name: monitoring + # ports: + # - protocol: TCP + # port: 9327 + # + # An ingress controller running on the host network is not a pod as far as + # the policy is concerned; select it with an ipBlock of the node addresses + # instead of a namespaceSelector. + # + # Kubelet probes are not covered by these rules. Node-to-pod traffic is + # outside NetworkPolicy in the CNIs that come up without host endpoints + # (Calico, Cilium); if yours polices it, allow the node addresses here. + extraIngress: [] + + egress: + # Egress is a separate opt-in: the chart knows the addresses of its own + # components, but not where your filer store (MySQL, Postgres, Redis, + # ...), notification sink or remote tier lives. Enabling this without + # listing those under extraEgress will cut the filer off from its store. + enabled: false + + # Every component resolves its peers by DNS name, so this is required + # for the release to function at all. + allowDNS: true + dnsNamespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + dnsPodSelector: + matchLabels: + k8s-app: kube-dns + + # The admin component manages pods through the Kubernetes API, the COSI + # driver watches the COSI custom resources, and the volume resize hook + # shells out to kubectl. Only those three get this rule. + kubeApiServer: + enabled: true + # The API server cannot be selected by label, so its address has to be + # given here - there is no sane default, and 0.0.0.0/0 would defeat the + # point. Rendering fails with a pointer to this value if a component + # needs the API server and this is empty. + # + # Use the endpoint behind the kubernetes service, not its ClusterIP: + # kube-proxy rewrites the destination before the packet is matched. + # kubectl get endpointslice kubernetes -o jsonpath='{.endpoints[*].addresses[*]}' + cidrs: [] + ports: [443, 6443] + + # Plain NetworkPolicyEgressRule entries, appended to every component's + # policy. This is where the filer store and the notification sinks go: + # + # extraEgress: + # - to: + # - podSelector: + # matchLabels: + # app.kubernetes.io/name: mysql + # ports: + # - protocol: TCP + # port: 3306 + extraEgress: [] + + # Per-component additions, keyed by the component's app.kubernetes.io/component + # label value, for rules that should not apply to the whole release. The keys + # are master, filer, s3, sftp, admin, worker, bucket-hook, + # volume-resize-hook, objectstorage-provisioner, seaweedfs-all-in-one, and + # volume (plus volume- for every entry under `volumes`): + # + # components: + # filer: + # extraEgress: + # - to: + # - ipBlock: + # cidr: 10.0.0.5/32 + # ports: + # - protocol: TCP + # port: 5432 + components: {}