From cd1089500b84d5f679eab2fd086d7f05f7f8bbd2 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Fri, 7 Aug 2026 02:38:46 -0400 Subject: [PATCH] feat: translate docker-options --sysctl on the k3s scheduler The `docker-local` scheduler supports `--sysctl` for free because docker options are passed verbatim to `docker run`, but the k3s scheduler silently dropped it. Namespaced sysctls now render into the pod's `securityContext.sysctls` for deployments, cron jobs, and one-off runs. A sysctl the kernel does not namespace fails the deploy instead of being dropped, since it cannot take effect within a pod regardless of what was requested. --- docs/advanced-usage/docker-options.md | 10 + docs/deployment/schedulers/k3s.md | 39 ++++ plugins/scheduler-k3s/functions.go | 57 +++++ plugins/scheduler-k3s/functions_test.go | 102 +++++++++ plugins/scheduler-k3s/template.go | 27 +++ plugins/scheduler-k3s/template_test.go | 199 ++++++++++++++++++ .../templates/chart/cron-job.yaml | 8 + .../templates/chart/deployment.yaml | 8 + tests/unit/scheduler-k3s-5.bats | 80 +++++++ 9 files changed, 530 insertions(+) create mode 100644 plugins/scheduler-k3s/template_test.go create mode 100644 tests/unit/scheduler-k3s-5.bats diff --git a/docs/advanced-usage/docker-options.md b/docs/advanced-usage/docker-options.md index 054904e34..d7885167e 100644 --- a/docs/advanced-usage/docker-options.md +++ b/docs/advanced-usage/docker-options.md @@ -38,6 +38,16 @@ More information on supported Docker options can be found [here](https://docs.do Container options configured via the `docker-options` plugin are not used to modify the process a container runs. Container options are the `[OPTIONS]` portion of the following, where `[CONTAINER_COMMAND]` and `[ARG]` are the process and the arguments passed to it that are launched in the created container: `docker run [OPTIONS] [CONTAINER_COMMAND] [ARG...]`. Please see the documentation for [customizing the run command](/docs/deployment/builders/dockerfiles.md#customizing-the-run-command) or use a [Procfile](/docs/deployment/builders/dockerfiles.md#procfiles-and-multiple-processes) to modify the command used by a Dockerfile-based container. +#### Scheduler support + +Docker options are written in Docker's own vocabulary and are passed verbatim to `docker run` by the `docker-local` scheduler. Other schedulers translate only the subset that has an equivalent in their own runtime, and ignore the rest. + +The `k3s` scheduler translates `--cap-add`, `--cap-drop`, `--privileged`, and `--sysctl` into their Kubernetes equivalents. See the [k3s scheduler documentation](/docs/deployment/schedulers/k3s.md) for details, including the restriction that only namespaced sysctls can be set on a pod. + +```shell +dokku docker-options:add node-js-app deploy "--sysctl net.ipv4.ip_unprivileged_port_start=1024" +``` + #### Mounting volumes and host directories Docker supports volume and host directory mounting via the `-v` or `--volume` flags. In order to simplify usage, Dokku provides a `storage` plugin as an abstraction to interact with persistent storage. In most cases, the Dokku project recommends using the persistent storage plugin over directly manipulating docker options at different phases. See the [persistent storage documentation](/docs/advanced-usage/persistent-storage.md) for more information on how to attach persistent storage to your app. diff --git a/docs/deployment/schedulers/k3s.md b/docs/deployment/schedulers/k3s.md index 8572ca8d5..b0c9c59f2 100644 --- a/docs/deployment/schedulers/k3s.md +++ b/docs/deployment/schedulers/k3s.md @@ -740,6 +740,44 @@ A single configured metadata key can also be queried with a flag of the form `-- dokku scheduler-k3s:autoscaling-auth:report node-js-app --scheduler-k3s-autoscaling-auth.datadog.apiKey ``` +### Setting kernel sysctls + +Kernel sysctls fall into two categories, and which one a sysctl belongs to determines how it must be set. + +The kernel maintains a per-namespace copy of `net.*` (network namespace) as well as `kernel.shm*`, `kernel.msg*`, `kernel.sem`, and `fs.mqueue.*` (IPC namespace). These can be set on a single app's pods. Every other sysctl - including all of `vm.*`, and therefore `vm.max_map_count` - holds a single value shared by the entire machine, so it cannot be scoped to a pod and must be applied to the node itself. + +#### Namespaced sysctls + +Namespaced sysctls are set with the `docker-options` plugin, and are translated into the pod's `securityContext.sysctls`. A `ps:restart` is required to apply them. + +```shell +dokku docker-options:add node-js-app deploy "--sysctl net.ipv4.ip_unprivileged_port_start=1024" +``` + +Passing a non-namespaced sysctl this way fails the deploy rather than silently dropping the value, since it provably cannot take effect within a pod. Note this differs from the `docker-local` scheduler, where such an option is passed straight through to `docker run`. + +Kubernetes further splits namespaced sysctls into a *safe* list that any pod may set, and everything else. A sysctl outside the safe list - `net.core.somaxconn`, for example - is rejected at pod admission unless the node's kubelet was started with a matching `allowed-unsafe-sysctls` value, which can be supplied at cluster initialization or when joining a node. + +```shell +dokku scheduler-k3s:initialize --kubelet-args allowed-unsafe-sysctls=net.core.somaxconn +``` + +Dokku does not enforce the safe list itself, as its membership changes between Kubernetes releases. Only the namespaced/non-namespaced distinction, which is a property of the kernel, is validated. + +#### Non-namespaced sysctls + +Non-namespaced sysctls are a property of the node, not of any app. Set them directly on each node in the cluster. + +```shell +sudo sysctl -w vm.max_map_count=262144 +``` + +```shell +echo "vm.max_map_count = 262144" | sudo tee /etc/sysctl.d/99-max-map-count.conf +``` + +The second command is what makes the change survive a reboot; `sysctl -w` alone does not. Repeat both on every node, including any added later via `scheduler-k3s:cluster:add`. + ### Integrating Kustomize Dokku supports integration with [Kustomize](https://kustomize.io/) to further customize the generated helm charts for app deployments. For example, a `config/kustomize/kustomization.yaml` file with the following contents will override the scale for each process deployed to `3`: @@ -894,6 +932,7 @@ This plugin implements various functionality through `plugn` triggers to integra - `--cap-add` - `--cap-drop` - `--privileged` + - `--sysctl` (namespaced sysctls only, see [Setting kernel sysctls](#setting-kernel-sysctls)) - `cron` - `enter` - `deploy` diff --git a/plugins/scheduler-k3s/functions.go b/plugins/scheduler-k3s/functions.go index a53f16771..842a6021b 100644 --- a/plugins/scheduler-k3s/functions.go +++ b/plugins/scheduler-k3s/functions.go @@ -1754,12 +1754,62 @@ func getStartCommand(input StartCommandInput) (StartCommandOutput, error) { }, nil } +// namespacedSysctlPrefixes are the sysctl prefixes the kernel maintains per-namespace +var namespacedSysctlPrefixes = []string{ + "net.", + "kernel.shm", + "kernel.msg", + "fs.mqueue.", +} + +// isNamespacedSysctl reports whether a sysctl is maintained per-namespace by the +// kernel and can therefore be set on a pod spec. Sysctls outside these subtrees +// hold a single value shared by the entire machine, and kubelet rejects them. +func isNamespacedSysctl(name string) bool { + if name == "kernel.sem" { + return true + } + + for _, prefix := range namespacedSysctlPrefixes { + if strings.HasPrefix(name, prefix) { + return true + } + } + + return false +} + +// parseSysctls converts docker-option key=value pairs into sysctls sorted by name, +// rejecting any sysctl that cannot take effect within a pod's namespaces. +func parseSysctls(values []string) ([]Sysctl, error) { + sysctls := []Sysctl{} + for _, value := range values { + name, sysctlValue, found := strings.Cut(value, "=") + if !found || name == "" { + return nil, fmt.Errorf("Invalid --sysctl value, expected name=value: %s", value) + } + + if !isNamespacedSysctl(name) { + return nil, fmt.Errorf("Sysctl %s is not namespaced and cannot be set on a pod, it must be applied at the node level instead", name) + } + + sysctls = append(sysctls, Sysctl{Name: name, Value: sysctlValue}) + } + + sort.Slice(sysctls, func(i int, j int) bool { + return sysctls[i].Name < sysctls[j].Name + }) + + return sysctls, nil +} + func getSecurityContext(appName string, phase string) (SecurityContext, error) { securityContext := SecurityContext{} deployOptions, err := dockeroptions.GetSpecifiedDockerOptionsForPhase(appName, phase, []string{ "--cap-add", "--cap-drop", "--privileged", + "--sysctl", }) if err != nil { return SecurityContext{}, fmt.Errorf("Error getting deploy options: %w", err) @@ -1782,6 +1832,13 @@ func getSecurityContext(appName string, phase string) (SecurityContext, error) { } securityContext.Capabilities.Drop = capabilities } + if sysctlOptions, ok := deployOptions["--sysctl"]; ok { + sysctls, err := parseSysctls(sysctlOptions) + if err != nil { + return SecurityContext{}, err + } + securityContext.Sysctls = sysctls + } return securityContext, nil } diff --git a/plugins/scheduler-k3s/functions_test.go b/plugins/scheduler-k3s/functions_test.go index b2df0995b..6edcfbf6c 100644 --- a/plugins/scheduler-k3s/functions_test.go +++ b/plugins/scheduler-k3s/functions_test.go @@ -255,3 +255,105 @@ func TestNodeLabelsDoesNotMutatePackageLabels(t *testing.T) { t.Errorf("nodeLabels() mutated WorkerLabels: len = %d, want %d", len(WorkerLabels), workerBefore) } } + +func TestIsNamespacedSysctl(t *testing.T) { + cases := []struct { + name string + want bool + }{ + {name: "net.core.somaxconn", want: true}, + {name: "net.ipv4.tcp_rmem", want: true}, + {name: "kernel.shm_rmid_forced", want: true}, + {name: "kernel.shmmax", want: true}, + {name: "kernel.msgmax", want: true}, + {name: "kernel.sem", want: true}, + {name: "fs.mqueue.msg_max", want: true}, + {name: "vm.max_map_count", want: false}, + {name: "vm.swappiness", want: false}, + {name: "kernel.pid_max", want: false}, + {name: "kernel.semaphore", want: false}, + {name: "fs.file-max", want: false}, + {name: "", want: false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isNamespacedSysctl(tc.name); got != tc.want { + t.Errorf("isNamespacedSysctl(%q) = %v, want %v", tc.name, got, tc.want) + } + }) + } +} + +func TestParseSysctls(t *testing.T) { + cases := []struct { + name string + values []string + want []Sysctl + wantErr bool + }{ + { + name: "empty input", + values: []string{}, + want: []Sysctl{}, + }, + { + name: "single namespaced sysctl", + values: []string{"net.core.somaxconn=1024"}, + want: []Sysctl{{Name: "net.core.somaxconn", Value: "1024"}}, + }, + { + name: "sorted by name regardless of input order", + values: []string{"net.core.somaxconn=1024", "kernel.sem=250", "fs.mqueue.msg_max=20"}, + want: []Sysctl{ + {Name: "fs.mqueue.msg_max", Value: "20"}, + {Name: "kernel.sem", Value: "250"}, + {Name: "net.core.somaxconn", Value: "1024"}, + }, + }, + { + name: "value containing an equals sign is preserved", + values: []string{"net.ipv4.tcp_rmem=4096 87380 6291456"}, + want: []Sysctl{{Name: "net.ipv4.tcp_rmem", Value: "4096 87380 6291456"}}, + }, + { + name: "non-namespaced sysctl is rejected", + values: []string{"vm.max_map_count=262144"}, + wantErr: true, + }, + { + name: "missing equals sign is rejected", + values: []string{"net.core.somaxconn"}, + wantErr: true, + }, + { + name: "empty name is rejected", + values: []string{"=1024"}, + wantErr: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := parseSysctls(tc.values) + if tc.wantErr { + if err == nil { + t.Fatalf("parseSysctls(%v) expected an error, got %v", tc.values, got) + } + return + } + if err != nil { + t.Fatalf("parseSysctls(%v) unexpected error: %v", tc.values, err) + } + + if len(got) != len(tc.want) { + t.Fatalf("parseSysctls(%v) = %v, want %v", tc.values, got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("parseSysctls(%v)[%d] = %v, want %v", tc.values, i, got[i], tc.want[i]) + } + } + }) + } +} diff --git a/plugins/scheduler-k3s/template.go b/plugins/scheduler-k3s/template.go index c5fbc8194..b191f9272 100644 --- a/plugins/scheduler-k3s/template.go +++ b/plugins/scheduler-k3s/template.go @@ -394,6 +394,32 @@ type SecurityContext struct { Capabilities SecurityContextCapabilities `yaml:"capabilities,omitempty"` // Privileged contains the privileged flag for a process Privileged bool `yaml:"privileged,omitempty"` + // Sysctls contains the namespaced kernel sysctls for a process + Sysctls []Sysctl `yaml:"sysctls,omitempty"` +} + +// Sysctl contains a single kernel sysctl key/value pair +type Sysctl struct { + // Name is the name of the sysctl + Name string `yaml:"name"` + // Value is the value the sysctl is set to + Value string `yaml:"value"` +} + +// ToCoreV1PodSecurityContext converts the sysctls to a corev1.PodSecurityContext, +// returning nil when no sysctls are configured so an empty security context does +// not churn the pod template hash and trigger a spurious rollout. +func (s SecurityContext) ToCoreV1PodSecurityContext() *corev1.PodSecurityContext { + if len(s.Sysctls) == 0 { + return nil + } + + sysctls := make([]corev1.Sysctl, len(s.Sysctls)) + for i, sysctl := range s.Sysctls { + sysctls[i] = corev1.Sysctl{Name: sysctl.Name, Value: sysctl.Value} + } + + return &corev1.PodSecurityContext{Sysctls: sysctls} } // ToCoreV1SecurityContext converts the security context to a corev1.SecurityContext @@ -533,6 +559,7 @@ func templateKubernetesJob(input Job) (batchv1.Job, error) { }, }, RestartPolicy: corev1.RestartPolicyNever, + SecurityContext: input.SecurityContext.ToCoreV1PodSecurityContext(), ServiceAccountName: input.AppName, }, }, diff --git a/plugins/scheduler-k3s/template_test.go b/plugins/scheduler-k3s/template_test.go new file mode 100644 index 000000000..4fa94b047 --- /dev/null +++ b/plugins/scheduler-k3s/template_test.go @@ -0,0 +1,199 @@ +package scheduler_k3s + +import ( + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/engine" +) + +func TestToCoreV1PodSecurityContext(t *testing.T) { + t.Run("nil when no sysctls are configured", func(t *testing.T) { + securityContext := SecurityContext{Privileged: true} + if got := securityContext.ToCoreV1PodSecurityContext(); got != nil { + t.Errorf("ToCoreV1PodSecurityContext() = %v, want nil", got) + } + }) + + t.Run("nil for an empty sysctl slice", func(t *testing.T) { + securityContext := SecurityContext{Sysctls: []Sysctl{}} + if got := securityContext.ToCoreV1PodSecurityContext(); got != nil { + t.Errorf("ToCoreV1PodSecurityContext() = %v, want nil", got) + } + }) + + t.Run("preserves order and values", func(t *testing.T) { + securityContext := SecurityContext{ + Sysctls: []Sysctl{ + {Name: "kernel.sem", Value: "250"}, + {Name: "net.core.somaxconn", Value: "1024"}, + }, + } + + got := securityContext.ToCoreV1PodSecurityContext() + if got == nil { + t.Fatal("ToCoreV1PodSecurityContext() = nil, want a security context") + } + if len(got.Sysctls) != 2 { + t.Fatalf("ToCoreV1PodSecurityContext() has %d sysctls, want 2", len(got.Sysctls)) + } + if got.Sysctls[0].Name != "kernel.sem" || got.Sysctls[0].Value != "250" { + t.Errorf("ToCoreV1PodSecurityContext() sysctls[0] = %v, want kernel.sem=250", got.Sysctls[0]) + } + if got.Sysctls[1].Name != "net.core.somaxconn" || got.Sysctls[1].Value != "1024" { + t.Errorf("ToCoreV1PodSecurityContext() sysctls[1] = %v, want net.core.somaxconn=1024", got.Sysctls[1]) + } + }) +} + +func renderDeploymentTemplate(t *testing.T, globalValues map[string]interface{}) string { + t.Helper() + + chartDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(chartDir, "templates"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + chartYAML := []byte("apiVersion: v2\nname: test\nversion: 0.0.1\n") + if err := os.WriteFile(filepath.Join(chartDir, "Chart.yaml"), chartYAML, 0o644); err != nil { + t.Fatalf("write Chart.yaml: %v", err) + } + + for _, name := range []string{"deployment.yaml", "_helpers.tpl"} { + contents, err := templates.ReadFile("templates/chart/" + name) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + if err := os.WriteFile(filepath.Join(chartDir, "templates", name), contents, 0o644); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + + loaded, err := loader.Load(chartDir) + if err != nil { + t.Fatalf("load chart: %v", err) + } + + global := map[string]interface{}{ + "app_name": "myapp", + "deployment_id": "1", + "namespace": "myapp", + "image": map[string]interface{}{ + "name": "myapp:latest", + "type": "dockerfile", + }, + } + for key, value := range globalValues { + global[key] = value + } + + values := map[string]interface{}{ + "global": global, + "processes": map[string]interface{}{ + "worker": map[string]interface{}{ + "args": []interface{}{"echo", "hello"}, + "replicas": 1, + }, + }, + } + + renderValues, err := chartutil.ToRenderValues(loaded, values, chartutil.ReleaseOptions{Name: "test", Namespace: "default"}, nil) + if err != nil { + t.Fatalf("ToRenderValues: %v", err) + } + + rendered, err := engine.Render(loaded, renderValues) + if err != nil { + t.Fatalf("render: %v", err) + } + + for name, content := range rendered { + if filepath.Base(name) == "deployment.yaml" { + return content + } + } + t.Fatalf("deployment.yaml not rendered; got: %v", rendered) + return "" +} + +// TestDeploymentSysctlsRendering asserts the pod-level securityContext.sysctls +// block only appears when sysctls are configured, and that numeric values are +// quoted. Kubernetes types sysctls[].value as a string, so an unquoted 1024 +// renders as a YAML integer and the API server rejects the manifest. +func TestDeploymentSysctlsRendering(t *testing.T) { + t.Run("absent when no security context is set", func(t *testing.T) { + manifest := renderDeploymentTemplate(t, map[string]interface{}{}) + if strings.Contains(manifest, "sysctls:") { + t.Errorf("rendered deployment unexpectedly contains sysctls:\n%s", manifest) + } + }) + + t.Run("absent when the security context has no sysctls", func(t *testing.T) { + manifest := renderDeploymentTemplate(t, map[string]interface{}{ + "security_context": map[string]interface{}{"privileged": true}, + }) + if strings.Contains(manifest, "sysctls:") { + t.Errorf("rendered deployment unexpectedly contains sysctls:\n%s", manifest) + } + }) + + t.Run("numeric values are quoted", func(t *testing.T) { + manifest := renderDeploymentTemplate(t, map[string]interface{}{ + "security_context": map[string]interface{}{ + "sysctls": []interface{}{ + map[string]interface{}{"name": "net.core.somaxconn", "value": "1024"}, + }, + }, + }) + + if !strings.Contains(manifest, "- name: net.core.somaxconn") { + t.Errorf("rendered deployment missing sysctl name:\n%s", manifest) + } + if !strings.Contains(manifest, `value: "1024"`) { + t.Errorf("rendered deployment did not quote the sysctl value:\n%s", manifest) + } + }) + + t.Run("sysctls land on the pod spec, not the container", func(t *testing.T) { + manifest := renderDeploymentTemplate(t, map[string]interface{}{ + "security_context": map[string]interface{}{ + "sysctls": []interface{}{ + map[string]interface{}{"name": "net.core.somaxconn", "value": "1024"}, + }, + }, + }) + + var doc map[string]interface{} + decoder := yaml.NewDecoder(strings.NewReader(manifest)) + for { + if err := decoder.Decode(&doc); err != nil { + if errors.Is(err, io.EOF) { + break + } + t.Fatalf("decode manifest: %v", err) + } + if doc == nil { + continue + } + + spec := doc["spec"].(map[string]interface{}) + template := spec["template"].(map[string]interface{}) + podSpec := template["spec"].(map[string]interface{}) + + securityContext, ok := podSpec["securityContext"].(map[string]interface{}) + if !ok { + t.Fatalf("pod spec has no securityContext:\n%s", manifest) + } + if _, ok := securityContext["sysctls"]; !ok { + t.Errorf("pod securityContext has no sysctls:\n%s", manifest) + } + } + }) +} diff --git a/plugins/scheduler-k3s/templates/chart/cron-job.yaml b/plugins/scheduler-k3s/templates/chart/cron-job.yaml index 371ddb34c..289f84f4a 100644 --- a/plugins/scheduler-k3s/templates/chart/cron-job.yaml +++ b/plugins/scheduler-k3s/templates/chart/cron-job.yaml @@ -73,6 +73,14 @@ spec: {{ include "print.labels" (dict "config" $.Values.global "key" "pod") | indent 12 }} {{ include "print.labels" (dict "config" $config "key" "pod") | indent 12 }} spec: + {{- if and (hasKey $.Values.global "security_context") $.Values.global.security_context.sysctls }} + securityContext: + sysctls: + {{- range $.Values.global.security_context.sysctls }} + - name: {{ .name }} + value: {{ .value | quote }} + {{- end }} + {{- end }} containers: - args: {{- range $config.args }} diff --git a/plugins/scheduler-k3s/templates/chart/deployment.yaml b/plugins/scheduler-k3s/templates/chart/deployment.yaml index 34312777e..c2fbb497d 100644 --- a/plugins/scheduler-k3s/templates/chart/deployment.yaml +++ b/plugins/scheduler-k3s/templates/chart/deployment.yaml @@ -58,6 +58,14 @@ spec: {{ include "print.labels" (dict "config" $.Values.global "key" "pod") | indent 8 }} {{ include "print.labels" (dict "config" $config "key" "pod") | indent 8 }} spec: + {{- if and (hasKey $.Values.global "security_context") $.Values.global.security_context.sysctls }} + securityContext: + sysctls: + {{- range $.Values.global.security_context.sysctls }} + - name: {{ .name }} + value: {{ .value | quote }} + {{- end }} + {{- end }} containers: - args: {{- range $config.args }} diff --git a/tests/unit/scheduler-k3s-5.bats b/tests/unit/scheduler-k3s-5.bats new file mode 100644 index 000000000..3f074e373 --- /dev/null +++ b/tests/unit/scheduler-k3s-5.bats @@ -0,0 +1,80 @@ +#!/usr/bin/env bats + +load test_helper + +TEST_APP="rdmtestapp" + +setup() { + uninstall_k3s || true + global_setup + dokku nginx:stop + export KUBECONFIG="/etc/rancher/k3s/k3s.yaml" +} + +teardown() { + global_teardown + dokku nginx:start + uninstall_k3s || true +} + +@test "(scheduler-k3s) docker-options sysctl" { + if [[ -z "$DOCKERHUB_USERNAME" ]] || [[ -z "$DOCKERHUB_TOKEN" ]]; then + skip "skipping due to missing docker.io credentials DOCKERHUB_USERNAME:DOCKERHUB_TOKEN" + fi + + INGRESS_CLASS=nginx install_k3s + + run /bin/bash -c "dokku apps:create $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run deploy_app python dokku@$DOKKU_DOMAIN:$TEST_APP + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "kubectl get deployment $TEST_APP-web -o json | jq -r '.spec.template.spec.securityContext.sysctls'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "null" + + run /bin/bash -c "dokku docker-options:add $TEST_APP deploy '--sysctl net.ipv4.ip_unprivileged_port_start=1024'" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku ps:restart $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "kubectl get deployment $TEST_APP-web -o json | jq -r '.spec.template.spec.securityContext.sysctls[0].name'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "net.ipv4.ip_unprivileged_port_start" + + run /bin/bash -c "kubectl get deployment $TEST_APP-web -o json | jq -r '.spec.template.spec.securityContext.sysctls[0].value'" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "1024" + + run /bin/bash -c "dokku docker-options:remove $TEST_APP deploy '--sysctl net.ipv4.ip_unprivileged_port_start=1024'" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku docker-options:add $TEST_APP deploy '--sysctl vm.max_map_count=262144'" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku ps:restart $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_failure + assert_output_contains "is not namespaced" -1 +}