mirror of
https://github.com/dokku/dokku.git
synced 2026-08-29 10:08:53 +02:00
Sysctls the kernel does not namespace, such as `vm.max_map_count`, cannot be set from a pod spec and previously had no answer beyond editing `/etc/sysctl.d` on each host by hand. `scheduler-k3s:node-sysctls:set` now applies them through a privileged daemonset, which reaches nodes joined later and reapplies after a reboot. Sysctls may be scoped to a node profile, with a profile scope inheriting the global values and overriding them on conflict so that every node is covered by exactly one daemonset. Clearing a sysctl stops dokku managing it but does not restore the previous value, which persists until the node reboots.
306 lines
9.8 KiB
Go
306 lines
9.8 KiB
Go
package scheduler_k3s
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"helm.sh/helm/v3/pkg/chart/loader"
|
|
"helm.sh/helm/v3/pkg/chartutil"
|
|
"helm.sh/helm/v3/pkg/engine"
|
|
)
|
|
|
|
func TestGetNodeSysctlsProperty(t *testing.T) {
|
|
if got := getNodeSysctlsProperty(""); got != "node-sysctls.global" {
|
|
t.Errorf("getNodeSysctlsProperty(\"\") = %q, want node-sysctls.global", got)
|
|
}
|
|
if got := getNodeSysctlsProperty("edge-workers"); got != "node-sysctls.profile.edge-workers" {
|
|
t.Errorf("getNodeSysctlsProperty(\"edge-workers\") = %q, want node-sysctls.profile.edge-workers", got)
|
|
}
|
|
}
|
|
|
|
// TestGetNodeSysctlsPropertyIsReserved asserts the property prefix is excluded from
|
|
// the annotations scan. Without this a property like node-sysctls.profile.pod would
|
|
// be mistaken for a pod annotation.
|
|
func TestGetNodeSysctlsPropertyIsReserved(t *testing.T) {
|
|
reserved := false
|
|
for _, prefix := range reservedAnnotationPrefixes {
|
|
if prefix == "node-sysctls." {
|
|
reserved = true
|
|
}
|
|
}
|
|
|
|
if !reserved {
|
|
t.Error("node-sysctls. is not in reservedAnnotationPrefixes")
|
|
}
|
|
}
|
|
|
|
func TestGetNodeSysctlsReleaseName(t *testing.T) {
|
|
if got := getNodeSysctlsReleaseName(""); got != "dokku-node-sysctls-global" {
|
|
t.Errorf("getNodeSysctlsReleaseName(\"\") = %q, want dokku-node-sysctls-global", got)
|
|
}
|
|
if got := getNodeSysctlsReleaseName("edge-workers"); got != "dokku-node-sysctls-profile-edge-workers" {
|
|
t.Errorf("getNodeSysctlsReleaseName(\"edge-workers\") = %q, want dokku-node-sysctls-profile-edge-workers", got)
|
|
}
|
|
}
|
|
|
|
func TestSortedSysctls(t *testing.T) {
|
|
got := sortedSysctls(map[string]string{
|
|
"vm.swappiness": "10",
|
|
"vm.max_map_count": "262144",
|
|
"fs.file-max": "100000",
|
|
})
|
|
|
|
want := []Sysctl{
|
|
{Name: "fs.file-max", Value: "100000"},
|
|
{Name: "vm.max_map_count", Value: "262144"},
|
|
{Name: "vm.swappiness", Value: "10"},
|
|
}
|
|
|
|
if len(got) != len(want) {
|
|
t.Fatalf("sortedSysctls() = %v, want %v", got, want)
|
|
}
|
|
for i := range got {
|
|
if got[i] != want[i] {
|
|
t.Errorf("sortedSysctls()[%d] = %v, want %v", i, got[i], want[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSortedSysctlsEmpty(t *testing.T) {
|
|
if got := sortedSysctls(map[string]string{}); len(got) != 0 {
|
|
t.Errorf("sortedSysctls(empty) = %v, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestNodeSysctlsScopeLabel(t *testing.T) {
|
|
if got := nodeSysctlsScopeLabel(""); got != "unprofiled nodes" {
|
|
t.Errorf("nodeSysctlsScopeLabel(\"\") = %q, want 'unprofiled nodes'", got)
|
|
}
|
|
if got := nodeSysctlsScopeLabel("edge"); got != "node profile edge" {
|
|
t.Errorf("nodeSysctlsScopeLabel(\"edge\") = %q, want 'node profile edge'", got)
|
|
}
|
|
}
|
|
|
|
func renderNodeSysctlsTemplate(t *testing.T, values NodeSysctlsGlobalValues) 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)
|
|
}
|
|
|
|
tpl, err := templates.ReadFile("templates/node-sysctls-chart/templates/daemonset.yaml")
|
|
if err != nil {
|
|
t.Fatalf("read daemonset template: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(chartDir, "templates", "daemonset.yaml"), tpl, 0o644); err != nil {
|
|
t.Fatalf("write daemonset template: %v", err)
|
|
}
|
|
|
|
loaded, err := loader.Load(chartDir)
|
|
if err != nil {
|
|
t.Fatalf("load chart: %v", err)
|
|
}
|
|
|
|
sysctls := []interface{}{}
|
|
for _, sysctl := range values.Sysctls {
|
|
sysctls = append(sysctls, map[string]interface{}{"name": sysctl.Name, "value": sysctl.Value})
|
|
}
|
|
|
|
renderValues, err := chartutil.ToRenderValues(loaded, map[string]interface{}{
|
|
"global": map[string]interface{}{
|
|
"image": values.Image,
|
|
"pause_image": values.PauseImage,
|
|
"profile_name": values.ProfileName,
|
|
"release_name": values.ReleaseName,
|
|
"sysctls": sysctls,
|
|
},
|
|
}, chartutil.ReleaseOptions{Name: "test", Namespace: "kube-system"}, 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) == "daemonset.yaml" {
|
|
return content
|
|
}
|
|
}
|
|
t.Fatalf("daemonset.yaml not rendered; got: %v", rendered)
|
|
return ""
|
|
}
|
|
|
|
// TestNodeSysctlsDaemonSetRendering asserts each scope targets a disjoint set of
|
|
// nodes. If both the global and a profile DaemonSet landed on the same node they
|
|
// would race writing the same /proc/sys file, making the result depend on pod
|
|
// scheduling order.
|
|
func TestNodeSysctlsDaemonSetRendering(t *testing.T) {
|
|
base := NodeSysctlsGlobalValues{
|
|
Image: DefaultNodeSysctlsImage,
|
|
PauseImage: DefaultNodeSysctlsPauseImage,
|
|
Sysctls: []Sysctl{{Name: "vm.max_map_count", Value: "262144"}},
|
|
}
|
|
|
|
t.Run("global scope excludes profiled nodes", func(t *testing.T) {
|
|
values := base
|
|
values.ReleaseName = "dokku-node-sysctls-global"
|
|
manifest := renderNodeSysctlsTemplate(t, values)
|
|
|
|
if !strings.Contains(manifest, "operator: DoesNotExist") {
|
|
t.Errorf("global daemonset missing DoesNotExist affinity:\n%s", manifest)
|
|
}
|
|
if !strings.Contains(manifest, "key: dokku.com/node-profile") {
|
|
t.Errorf("global daemonset missing node profile affinity key:\n%s", manifest)
|
|
}
|
|
if strings.Contains(manifest, "nodeSelector:") {
|
|
t.Errorf("global daemonset should not use a nodeSelector:\n%s", manifest)
|
|
}
|
|
})
|
|
|
|
t.Run("profile scope targets only its own nodes", func(t *testing.T) {
|
|
values := base
|
|
values.ProfileName = "edge-workers"
|
|
values.ReleaseName = "dokku-node-sysctls-profile-edge-workers"
|
|
manifest := renderNodeSysctlsTemplate(t, values)
|
|
|
|
if !strings.Contains(manifest, "dokku.com/node-profile: \"edge-workers\"") {
|
|
t.Errorf("profile daemonset missing nodeSelector:\n%s", manifest)
|
|
}
|
|
if strings.Contains(manifest, "DoesNotExist") {
|
|
t.Errorf("profile daemonset should not carry the global affinity:\n%s", manifest)
|
|
}
|
|
})
|
|
|
|
t.Run("tolerates every taint", func(t *testing.T) {
|
|
values := base
|
|
values.ReleaseName = "dokku-node-sysctls-global"
|
|
manifest := renderNodeSysctlsTemplate(t, values)
|
|
|
|
if !strings.Contains(manifest, "- operator: Exists") {
|
|
t.Errorf("daemonset does not tolerate all taints:\n%s", manifest)
|
|
}
|
|
})
|
|
|
|
t.Run("applies each sysctl privileged", func(t *testing.T) {
|
|
values := base
|
|
values.ReleaseName = "dokku-node-sysctls-global"
|
|
values.Sysctls = []Sysctl{
|
|
{Name: "vm.max_map_count", Value: "262144"},
|
|
{Name: "vm.swappiness", Value: "10"},
|
|
}
|
|
manifest := renderNodeSysctlsTemplate(t, values)
|
|
|
|
if !strings.Contains(manifest, "privileged: true") {
|
|
t.Errorf("daemonset init container is not privileged:\n%s", manifest)
|
|
}
|
|
if !strings.Contains(manifest, `sysctl -w "vm.max_map_count=262144"`) {
|
|
t.Errorf("daemonset missing max_map_count write:\n%s", manifest)
|
|
}
|
|
if !strings.Contains(manifest, `sysctl -w "vm.swappiness=10"`) {
|
|
t.Errorf("daemonset missing swappiness write:\n%s", manifest)
|
|
}
|
|
})
|
|
|
|
t.Run("image overrides reach both containers", func(t *testing.T) {
|
|
values := base
|
|
values.ReleaseName = "dokku-node-sysctls-global"
|
|
values.Image = "registry.internal/busybox:1.36"
|
|
values.PauseImage = "registry.internal/pause:3.9"
|
|
manifest := renderNodeSysctlsTemplate(t, values)
|
|
|
|
if !strings.Contains(manifest, `image: "registry.internal/busybox:1.36"`) {
|
|
t.Errorf("daemonset did not use the sysctl image override:\n%s", manifest)
|
|
}
|
|
if !strings.Contains(manifest, `image: "registry.internal/pause:3.9"`) {
|
|
t.Errorf("daemonset did not use the pause image override:\n%s", manifest)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestMergeNodeSysctls asserts a profile scope inherits the global sysctls and
|
|
// overrides them on conflict. Profiled nodes are excluded from the global
|
|
// DaemonSet, so a global value omitted here would never reach them.
|
|
func TestMergeNodeSysctls(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
global map[string]string
|
|
profile map[string]string
|
|
want []Sysctl
|
|
}{
|
|
{
|
|
name: "no sysctls at all",
|
|
global: map[string]string{},
|
|
profile: map[string]string{},
|
|
want: []Sysctl{},
|
|
},
|
|
{
|
|
name: "profile inherits global values",
|
|
global: map[string]string{"vm.max_map_count": "262144"},
|
|
profile: map[string]string{},
|
|
want: []Sysctl{{Name: "vm.max_map_count", Value: "262144"}},
|
|
},
|
|
{
|
|
name: "profile wins on conflict",
|
|
global: map[string]string{"vm.max_map_count": "262144"},
|
|
profile: map[string]string{"vm.max_map_count": "524288"},
|
|
want: []Sysctl{{Name: "vm.max_map_count", Value: "524288"}},
|
|
},
|
|
{
|
|
name: "profile adds to global",
|
|
global: map[string]string{"vm.max_map_count": "262144"},
|
|
profile: map[string]string{"vm.swappiness": "10"},
|
|
want: []Sysctl{
|
|
{Name: "vm.max_map_count", Value: "262144"},
|
|
{Name: "vm.swappiness", Value: "10"},
|
|
},
|
|
},
|
|
{
|
|
name: "profile only",
|
|
global: map[string]string{},
|
|
profile: map[string]string{"vm.swappiness": "10"},
|
|
want: []Sysctl{{Name: "vm.swappiness", Value: "10"}},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := mergeNodeSysctls(tc.global, tc.profile)
|
|
if len(got) != len(tc.want) {
|
|
t.Fatalf("mergeNodeSysctls() = %v, want %v", got, tc.want)
|
|
}
|
|
for i := range got {
|
|
if got[i] != tc.want[i] {
|
|
t.Errorf("mergeNodeSysctls()[%d] = %v, want %v", i, got[i], tc.want[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestMergeNodeSysctlsDoesNotMutateInputs asserts the merge leaves the caller's maps
|
|
// alone, since resolveNodeSysctlScopes reuses the global map across every profile.
|
|
func TestMergeNodeSysctlsDoesNotMutateInputs(t *testing.T) {
|
|
global := map[string]string{"vm.max_map_count": "262144"}
|
|
profile := map[string]string{"vm.max_map_count": "524288", "vm.swappiness": "10"}
|
|
|
|
mergeNodeSysctls(global, profile)
|
|
|
|
if len(global) != 1 || global["vm.max_map_count"] != "262144" {
|
|
t.Errorf("mergeNodeSysctls() mutated the global map: %v", global)
|
|
}
|
|
if len(profile) != 2 {
|
|
t.Errorf("mergeNodeSysctls() mutated the profile map: %v", profile)
|
|
}
|
|
}
|