diff --git a/.github/workflows/helm_ci.yml b/.github/workflows/helm_ci.yml index f990e3166..3e12704c4 100644 --- a/.github/workflows/helm_ci.yml +++ b/.github/workflows/helm_ci.yml @@ -718,6 +718,141 @@ jobs: helm template test $CHART_DIR --set cosi.enabled=true > /tmp/cosi.yaml grep -q "seaweedfs-cosi" /tmp/cosi.yaml echo "COSI driver renders correctly" + + echo "" + echo "=== Testing configurable pod and container security contexts ===" + helm template test $CHART_DIR > /tmp/security-context-defaults.yaml + + security_context_args=() + for component in master volume filer s3 sftp admin worker allInOne cosi; do + security_context_args+=( + --set "$component.podSecurityContext.enabled=true" + --set "$component.podSecurityContext.seccompProfile.type=RuntimeDefault" + --set "$component.containerSecurityContext.enabled=true" + --set "$component.containerSecurityContext.privileged=false" + --set "$component.containerSecurityContext.allowPrivilegeEscalation=false" + --set "$component.containerSecurityContext.capabilities.drop[0]=ALL" + --set "$component.containerSecurityContext.seccompProfile.type=RuntimeDefault" + ) + done + + helm template test $CHART_DIR \ + "${security_context_args[@]}" \ + --set s3.enabled=true \ + --set s3.createBuckets[0].name=test \ + --set sftp.enabled=true \ + --set admin.enabled=true \ + --set admin.secret.adminPassword=ci-admin-password \ + --set worker.enabled=true \ + --set volume.idx.type=hostPath \ + --set volume.idx.hostPathPrefix=/tmp \ + --set cosi.enabled=true > /tmp/security-contexts.yaml + helm template test $CHART_DIR \ + "${security_context_args[@]}" \ + --set allInOne.enabled=true > /tmp/security-contexts-aio.yaml + python3 - /tmp/security-context-defaults.yaml /tmp/security-contexts.yaml /tmp/security-contexts-aio.yaml <<'PYEOF' + import sys + + import yaml + + errors = [] + workloads = 0 + containers = 0 + chart_managed_init_containers = 0 + components = set() + + def validate_container(workload_name, container): + context = container.get("securityContext", {}) + prefix = f"{workload_name}/{container['name']}" + if "enabled" in context: + errors.append(f"{prefix}: internal enabled flag leaked into container securityContext") + if context.get("privileged") is not False: + errors.append(f"{prefix}: privileged is not false") + if context.get("allowPrivilegeEscalation") is not False: + errors.append(f"{prefix}: allowPrivilegeEscalation is not false") + if context.get("capabilities", {}).get("drop") != ["ALL"]: + errors.append(f"{prefix}: capabilities.drop is not exactly [ALL]") + if context.get("seccompProfile", {}).get("type") != "RuntimeDefault": + errors.append(f"{prefix}: seccompProfile is not RuntimeDefault") + + with open(sys.argv[1]) as stream: + default_documents = [document for document in yaml.safe_load_all(stream) if document] + for document in default_documents: + if document.get("kind") not in ("Deployment", "StatefulSet", "Job"): + continue + name = document["metadata"]["name"] + pod = document["spec"]["template"]["spec"] + if "securityContext" in pod: + errors.append(f"{name}: pod securityContext should be absent by default") + for container in pod.get("containers", []): + if "securityContext" in container: + errors.append( + f"{name}/{container['name']}: container securityContext " + f"should be absent by default" + ) + + for path in sys.argv[2:]: + with open(path) as stream: + documents = [document for document in yaml.safe_load_all(stream) if document] + for document in documents: + if document.get("kind") not in ("Deployment", "StatefulSet", "Job"): + continue + workloads += 1 + name = document["metadata"]["name"] + pod = document["spec"]["template"]["spec"] + component = document["spec"]["template"]["metadata"]["labels"].get("app.kubernetes.io/component") + if component: + components.add(component) + else: + errors.append(f"{name}: app.kubernetes.io/component label is missing") + pod_context = pod.get("securityContext", {}) + if "enabled" in pod_context: + errors.append(f"{name}: internal enabled flag leaked into pod securityContext") + if pod_context.get("seccompProfile", {}).get("type") != "RuntimeDefault": + errors.append(f"{name}: pod seccompProfile is not RuntimeDefault") + for container in pod.get("containers", []): + containers += 1 + validate_container(name, container) + for container in pod.get("initContainers", []): + if container["name"] != "seaweedfs-vol-move-idx": + continue + chart_managed_init_containers += 1 + validate_container(name, container) + + expected_components = { + "master", "volume", "filer", "s3", "sftp", "admin", "worker", + "objectstorage-provisioner", "bucket-hook", "seaweedfs-all-in-one", + } + if components != expected_components: + errors.append( + f"security context workload coverage is incomplete: " + f"expected {sorted(expected_components)}, got {sorted(components)}" + ) + if workloads != 10 or containers != 11: + errors.append( + f"expected 10 workloads and 11 containers, got " + f"{workloads} workloads and {containers} containers" + ) + if chart_managed_init_containers != 1: + errors.append( + f"expected one chart-managed seaweedfs-vol-move-idx init container, " + f"got {chart_managed_init_containers}" + ) + + if errors: + print("\n".join(f"FAIL: {error}" for error in errors), file=sys.stderr) + sys.exit(1) + print(f"Validated security contexts on {workloads} workloads and {containers} containers") + PYEOF + + # The resize hook depends on lookup finding a live StatefulSet and + # therefore cannot render during helm template. Keep static coverage + # for both security-context blocks. + grep -Fqx ' securityContext: {{- omit .Values.volume.podSecurityContext "enabled" | toYaml | nindent 8 }}' \ + "$CHART_DIR/templates/volume/volume-resize-hook.yaml" + grep -Fqx ' securityContext: {{- omit .Values.volume.containerSecurityContext "enabled" | toYaml | nindent 12 }}' \ + "$CHART_DIR/templates/volume/volume-resize-hook.yaml" + echo "Volume resize hook security contexts are covered" echo "" echo "=== Testing long release name: service names match DNS references ===" @@ -1605,6 +1740,69 @@ jobs: - name: Create kind cluster uses: helm/kind-action@v1.15.0 + - name: Verify volume resize hook security contexts + run: | + set -e + CHART_DIR="k8s/charts/seaweedfs" + NS="resize-hook-security" + kubectl create namespace "$NS" + kubectl apply -n "$NS" -f - <<'EOF' + apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + name: data1-resize-seaweedfs-volume-0 + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + EOF + + helm template resize "$CHART_DIR" -n "$NS" --dry-run=server \ + --set volume.dataDirs[0].name=data1 \ + --set volume.dataDirs[0].type=persistentVolumeClaim \ + --set volume.dataDirs[0].size=2Gi \ + --set volume.podSecurityContext.enabled=true \ + --set volume.podSecurityContext.seccompProfile.type=RuntimeDefault \ + --set volume.containerSecurityContext.enabled=true \ + --set volume.containerSecurityContext.privileged=false \ + --set volume.containerSecurityContext.allowPrivilegeEscalation=false \ + --set volume.containerSecurityContext.capabilities.drop[0]=ALL \ + --set volume.containerSecurityContext.seccompProfile.type=RuntimeDefault \ + > /tmp/security-context-resize-hook.yaml + + python3 - /tmp/security-context-resize-hook.yaml <<'PYEOF' + import sys + + import yaml + + with open(sys.argv[1]) as stream: + documents = [document for document in yaml.safe_load_all(stream) if document] + + jobs = [ + document for document in documents + if document.get("kind") == "Job" + and document["metadata"]["name"].endswith("-volume-resize-hook") + ] + if len(jobs) != 1: + raise AssertionError(f"expected one volume resize hook Job, got {len(jobs)}") + + pod = jobs[0]["spec"]["template"]["spec"] + assert pod["securityContext"] == { + "seccompProfile": {"type": "RuntimeDefault"}, + } + assert len(pod["containers"]) == 1 + assert pod["containers"][0]["securityContext"] == { + "allowPrivilegeEscalation": False, + "capabilities": {"drop": ["ALL"]}, + "privileged": False, + "seccompProfile": {"type": "RuntimeDefault"}, + } + PYEOF + kubectl delete namespace "$NS" + echo "Volume resize hook security contexts render correctly" + - name: Run chart-testing (install) run: | ct install --target-branch ${{ github.event.repository.default_branch }} --all --chart-dirs k8s/charts \ diff --git a/k8s/charts/seaweedfs/Chart.yaml b/k8s/charts/seaweedfs/Chart.yaml index 1a7fac150..99b2866a1 100644 --- a/k8s/charts/seaweedfs/Chart.yaml +++ b/k8s/charts/seaweedfs/Chart.yaml @@ -3,4 +3,4 @@ description: SeaweedFS name: seaweedfs appVersion: "4.48" # Dev note: Trigger a helm chart release by `git tag -a helm-` -version: 4.48.0 +version: 4.48.1 diff --git a/k8s/charts/seaweedfs/README.md b/k8s/charts/seaweedfs/README.md index bebf3b0bc..53556c38a 100644 --- a/k8s/charts/seaweedfs/README.md +++ b/k8s/charts/seaweedfs/README.md @@ -570,6 +570,14 @@ Two things worth knowing before you turn this on: The DNS selectors default to CoreDNS as kubeadm, kind and the managed offerings from AWS, Google and Azure install it. On OpenShift, override `egress.dnsNamespaceSelector` and `egress.dnsPodSelector` to match `openshift-dns`; see the comment in `values.yaml`. +## Pod and container security contexts + +Pod and container security contexts are configurable independently for every built-in workload and remain empty by default for backwards compatibility. The examples in `values.yaml` show how to enable a `RuntimeDefault` seccomp profile, disable privilege escalation and privileged mode, and drop all Linux capabilities. + +The chart does not enable `runAsNonRoot` by default because its default `hostPath` storage may be owned by root. To enforce the Kubernetes `restricted` Pod Security Standard, use storage that is writable by a non-root user and configure `runAsNonRoot` or use the OpenShift overrides below. + +Security contexts configured for a component also apply to the chart-managed helper containers for that component. User-provided init containers and sidecars must define their own container security context. + ## 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/cosi/cosi-deployment.yaml b/k8s/charts/seaweedfs/templates/cosi/cosi-deployment.yaml index ff15b71de..005df44ea 100644 --- a/k8s/charts/seaweedfs/templates/cosi/cosi-deployment.yaml +++ b/k8s/charts/seaweedfs/templates/cosi/cosi-deployment.yaml @@ -148,6 +148,9 @@ spec: resources: {{- toYaml . | nindent 12 }} {{- end }} + {{- if .Values.cosi.containerSecurityContext.enabled }} + securityContext: {{- omit .Values.cosi.containerSecurityContext "enabled" | toYaml | nindent 12 }} + {{- end }} - name: seaweedfs-cosi-sidecar image: "{{ .Values.cosi.sidecar.image }}" imagePullPolicy: {{ default "IfNotPresent" .Values.global.seaweedfs.imagePullPolicy }} diff --git a/k8s/charts/seaweedfs/templates/volume/volume-resize-hook.yaml b/k8s/charts/seaweedfs/templates/volume/volume-resize-hook.yaml index caa5cd430..161af3050 100644 --- a/k8s/charts/seaweedfs/templates/volume/volume-resize-hook.yaml +++ b/k8s/charts/seaweedfs/templates/volume/volume-resize-hook.yaml @@ -32,6 +32,9 @@ spec: spec: serviceAccountName: {{ $seaweedfsName }}-volume-resize-hook restartPolicy: Never + {{- if .Values.volume.podSecurityContext.enabled }} + securityContext: {{- omit .Values.volume.podSecurityContext "enabled" | toYaml | nindent 8 }} + {{- end }} containers: - name: resize image: {{ .Values.volume.resizeHook.image }} @@ -39,6 +42,9 @@ spec: args: - | {{ $commands | indent 14 }} + {{- if .Values.volume.containerSecurityContext.enabled }} + securityContext: {{- omit .Values.volume.containerSecurityContext "enabled" | toYaml | nindent 12 }} + {{- end }} --- apiVersion: v1 kind: ServiceAccount diff --git a/k8s/charts/seaweedfs/values.yaml b/k8s/charts/seaweedfs/values.yaml index 5b8a8c2e5..47d1fd103 100644 --- a/k8s/charts/seaweedfs/values.yaml +++ b/k8s/charts/seaweedfs/values.yaml @@ -260,6 +260,8 @@ master: # runAsUser: 1000 # runAsGroup: 3000 # fsGroup: 2000 + # seccompProfile: + # type: RuntimeDefault podSecurityContext: {} # Configure security context for Container @@ -268,7 +270,15 @@ master: # containerSecurityContext: # enabled: true # runAsUser: 2000 + # runAsGroup: 3000 + # runAsNonRoot: true + # privileged: false # allowPrivilegeEscalation: false + # capabilities: + # drop: + # - ALL + # seccompProfile: + # type: RuntimeDefault containerSecurityContext: {} ingress: @@ -560,6 +570,8 @@ volume: # runAsUser: 1000 # runAsGroup: 3000 # fsGroup: 2000 + # seccompProfile: + # type: RuntimeDefault podSecurityContext: {} # Configure security context for Container @@ -568,7 +580,15 @@ volume: # containerSecurityContext: # enabled: true # runAsUser: 2000 + # runAsGroup: 3000 + # runAsNonRoot: true + # privileged: false # allowPrivilegeEscalation: false + # capabilities: + # drop: + # - ALL + # seccompProfile: + # type: RuntimeDefault containerSecurityContext: {} # used to configure livenessProbe on volume-server containers @@ -845,6 +865,8 @@ filer: # runAsUser: 1000 # runAsGroup: 3000 # fsGroup: 2000 + # seccompProfile: + # type: RuntimeDefault podSecurityContext: {} # Configure security context for Container @@ -853,7 +875,15 @@ filer: # containerSecurityContext: # enabled: true # runAsUser: 2000 + # runAsGroup: 3000 + # runAsNonRoot: true + # privileged: false # allowPrivilegeEscalation: false + # capabilities: + # drop: + # - ALL + # seccompProfile: + # type: RuntimeDefault containerSecurityContext: {} ingresses: @@ -1127,6 +1157,8 @@ s3: # runAsUser: 1000 # runAsGroup: 3000 # fsGroup: 2000 + # seccompProfile: + # type: RuntimeDefault podSecurityContext: {} # Configure security context for Container @@ -1135,7 +1167,15 @@ s3: # containerSecurityContext: # enabled: true # runAsUser: 2000 + # runAsGroup: 3000 + # runAsNonRoot: true + # privileged: false # allowPrivilegeEscalation: false + # capabilities: + # drop: + # - ALL + # seccompProfile: + # type: RuntimeDefault containerSecurityContext: {} # You can also use emptyDir storage: @@ -1287,7 +1327,32 @@ sftp: priorityClassName: "" schedulerName: "" serviceAccountName: "" + # Configure security context for Pod + # ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/ + # Example: + # podSecurityContext: + # enabled: true + # runAsUser: 1000 + # runAsGroup: 3000 + # fsGroup: 2000 + # seccompProfile: + # type: RuntimeDefault podSecurityContext: {} + # Configure security context for Container + # ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/ + # Example: + # containerSecurityContext: + # enabled: true + # runAsUser: 2000 + # runAsGroup: 3000 + # runAsNonRoot: true + # privileged: false + # allowPrivilegeEscalation: false + # capabilities: + # drop: + # - ALL + # seccompProfile: + # type: RuntimeDefault containerSecurityContext: {} logs: @@ -1434,7 +1499,32 @@ admin: priorityClassName: "" schedulerName: "" serviceAccountName: "" + # Configure security context for Pod + # ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/ + # Example: + # podSecurityContext: + # enabled: true + # runAsUser: 1000 + # runAsGroup: 3000 + # fsGroup: 2000 + # seccompProfile: + # type: RuntimeDefault podSecurityContext: {} + # Configure security context for Container + # ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/ + # Example: + # containerSecurityContext: + # enabled: true + # runAsUser: 2000 + # runAsGroup: 3000 + # runAsNonRoot: true + # privileged: false + # allowPrivilegeEscalation: false + # capabilities: + # drop: + # - ALL + # seccompProfile: + # type: RuntimeDefault containerSecurityContext: {} extraEnvironmentVars: {} @@ -1591,7 +1681,32 @@ worker: priorityClassName: "" schedulerName: "" serviceAccountName: "" + # Configure security context for Pod + # ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/ + # Example: + # podSecurityContext: + # enabled: true + # runAsUser: 1000 + # runAsGroup: 3000 + # fsGroup: 2000 + # seccompProfile: + # type: RuntimeDefault podSecurityContext: {} + # Configure security context for Container + # ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/ + # Example: + # containerSecurityContext: + # enabled: true + # runAsUser: 2000 + # runAsGroup: 3000 + # runAsNonRoot: true + # privileged: false + # allowPrivilegeEscalation: false + # capabilities: + # drop: + # - ALL + # seccompProfile: + # type: RuntimeDefault containerSecurityContext: {} extraEnvironmentVars: {} @@ -1851,6 +1966,8 @@ allInOne: # runAsUser: 1000 # runAsGroup: 3000 # fsGroup: 2000 + # seccompProfile: + # type: RuntimeDefault podSecurityContext: {} # Configure security context for Container @@ -1859,7 +1976,15 @@ allInOne: # containerSecurityContext: # enabled: true # runAsUser: 2000 + # runAsGroup: 3000 + # runAsNonRoot: true + # privileged: false # allowPrivilegeEscalation: false + # capabilities: + # drop: + # - ALL + # seccompProfile: + # type: RuntimeDefault containerSecurityContext: {} # Resource management @@ -1898,7 +2023,32 @@ cosi: # should have a secret key called seaweedfs_s3_config with an inline json configure existingConfigSecret: null + # Configure security context for Pod + # ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/ + # Example: + # podSecurityContext: + # enabled: true + # runAsUser: 1000 + # runAsGroup: 3000 + # fsGroup: 2000 + # seccompProfile: + # type: RuntimeDefault podSecurityContext: {} + # Configure security context for Container + # ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/ + # Example: + # containerSecurityContext: + # enabled: true + # runAsUser: 2000 + # runAsGroup: 3000 + # runAsNonRoot: true + # privileged: false + # allowPrivilegeEscalation: false + # capabilities: + # drop: + # - ALL + # seccompProfile: + # type: RuntimeDefault containerSecurityContext: {} # used to assign a custom scheduler to cosi pods