Files
dokku/plugins/scheduler-k3s/template_test.go
Jose Diaz-Gonzalez 1d2c7424aa fix: do not require a local image for k3s deploys
Kubernetes pulls the app image itself, so a k3s host is free to reap its local copy while the workload keeps running, which the `registry` plugin already does on its own. Deploys, restarts, `dokku run`, and in-cluster cron no longer assert that the image is present locally, falling back to the metadata recorded in the app's current Helm release. A `ps:restart` naming a single process type now rolls only that process type's pods rather than silently redeploying every one. Apps with an `app.json` postdeploy task still require the image locally, as that task runs on the Dokku host.
2026-08-12 02:14:56 -04:00

250 lines
7.9 KiB
Go

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()
return renderDeploymentTemplateWithProcesses(t, globalValues, map[string]interface{}{
"worker": map[string]interface{}{
"args": []interface{}{"echo", "hello"},
"replicas": 1,
},
})
}
func renderDeploymentTemplateWithProcesses(t *testing.T, globalValues map[string]interface{}, processes 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": processes,
}
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)
}
}
})
}
// TestDeploymentDeploymentIDRendering asserts each Deployment's pod template
// carries its own process's deployment id. A targeted ps:restart works by
// giving only the named process a fresh id, so an untargeted process whose id
// is unchanged must render an unchanged pod template - otherwise Kubernetes
// rolls its pods too and the targeting is meaningless.
func TestDeploymentDeploymentIDRendering(t *testing.T) {
t.Run("each process renders its own deployment id", func(t *testing.T) {
manifest := renderDeploymentTemplateWithProcesses(t, map[string]interface{}{}, map[string]interface{}{
"web": map[string]interface{}{
"args": []interface{}{"echo", "web"},
"deployment_id": "200",
"replicas": 1,
},
"worker": map[string]interface{}{
"args": []interface{}{"echo", "worker"},
"deployment_id": "100",
"replicas": 1,
},
})
if !strings.Contains(manifest, `app.kubernetes.io/version: "200"`) {
t.Errorf("rendered deployment missing the targeted process id:\n%s", manifest)
}
if !strings.Contains(manifest, `app.kubernetes.io/version: "100"`) {
t.Errorf("rendered deployment missing the untargeted process id:\n%s", manifest)
}
})
t.Run("falls back to the global id when a process has none", func(t *testing.T) {
manifest := renderDeploymentTemplateWithProcesses(t, map[string]interface{}{
"deployment_id": "42",
}, map[string]interface{}{
"worker": map[string]interface{}{
"args": []interface{}{"echo", "worker"},
"replicas": 1,
},
})
if !strings.Contains(manifest, `app.kubernetes.io/version: "42"`) {
t.Errorf("rendered deployment did not fall back to the global id:\n%s", manifest)
}
})
}