mirror of
https://github.com/dokku/dokku.git
synced 2026-08-29 10:08:53 +02:00
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.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
199
plugins/scheduler-k3s/template_test.go
Normal file
199
plugins/scheduler-k3s/template_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
Reference in New Issue
Block a user