From afbf2efe399cca0991249db35e0259dd0f04a47e Mon Sep 17 00:00:00 2001 From: Jason Morrow Date: Mon, 24 Aug 2026 16:10:54 -0400 Subject: [PATCH] feat: satisfy job pod admission requirements for kata jobs The agent sandbox platform runs job pods as Kata microVMs. --- src/cmd/root.go | 8 ++ src/pkg/k8s.go | 25 ++-- src/pkg/k8s_config.go | 116 +++++++++++++++- src/pkg/k8s_config_test.go | 274 +++++++++++++++++++++++++++++++++++++ src/pkg/k8s_test.go | 19 ++- 5 files changed, 426 insertions(+), 16 deletions(-) create mode 100644 src/pkg/k8s_config_test.go diff --git a/src/cmd/root.go b/src/cmd/root.go index b333480..19f8d85 100644 --- a/src/cmd/root.go +++ b/src/cmd/root.go @@ -54,6 +54,10 @@ func init() { rootCmd.PersistentFlags().Int("job-pod-log-max-size", 1000000, "The max amount in bytes to buffer before pod logs are shipped to OpsLevel. Works in tandem with 'job-pod-log-max-interval'") rootCmd.PersistentFlags().Bool("job-agent-mode", false, "Enable agent mode with privileged security context for Container-in-Container support. WARNING: This grants elevated privileges and should only be enabled for trusted workloads.") rootCmd.PersistentFlags().String("job-pod-helper-image", "", "Override the helper init container image. Defaults to the published ECR image matching the runner version. Useful for local development with kind.") + rootCmd.PersistentFlags().String("job-pod-image", "", "Override the image the job container runs. Defaults to the image from the job definition.") + rootCmd.PersistentFlags().String("job-pod-runtime-class-name", "", "The RuntimeClass to run job pods under, ie 'kata-qemu' for VM isolation. Empty runs under the cluster default runtime with no VM boundary.") + rootCmd.PersistentFlags().Int64("job-pod-active-deadline", 0, "The hard deadline in seconds after which the job pod is terminated by kubernetes. 0 leaves it unset.") + rootCmd.PersistentFlags().String("job-pod-automount-service-account-token", "", "Whether to project a kubernetes API token into job pods (options [\"true\", \"false\"]). Empty leaves the namespace default in place.") rootCmd.PersistentFlags().String("queue", "", "The queue this runner should process jobs from. Empty means the default queue.") rootCmd.PersistentFlags().Int("k8s-api-qps", 50, "The maximum sustained queries per second to the Kubernetes API server.") @@ -81,6 +85,10 @@ func init() { viper.BindEnv("job-pod-log-max-size", "OPSLEVEL_JOB_POD_LOG_MAX_SIZE") viper.BindEnv("job-agent-mode", "OPSLEVEL_JOB_AGENT_MODE") viper.BindEnv("job-pod-helper-image", "OPSLEVEL_JOB_POD_HELPER_IMAGE") + viper.BindEnv("job-pod-image", "OPSLEVEL_JOB_POD_IMAGE") + viper.BindEnv("job-pod-runtime-class-name", "OPSLEVEL_JOB_POD_RUNTIME_CLASS_NAME") + viper.BindEnv("job-pod-active-deadline", "OPSLEVEL_JOB_POD_ACTIVE_DEADLINE") + viper.BindEnv("job-pod-automount-service-account-token", "OPSLEVEL_JOB_POD_AUTOMOUNT_SERVICE_ACCOUNT_TOKEN") viper.BindEnv("queue", "OPSLEVEL_QUEUE") viper.BindEnv("k8s-api-qps", "OPSLEVEL_K8S_API_QPS") diff --git a/src/pkg/k8s.go b/src/pkg/k8s.go index a398ca8..32caad0 100644 --- a/src/pkg/k8s.go +++ b/src/pkg/k8s.go @@ -179,7 +179,6 @@ func executable() *int32 { } func (s *JobRunner) getPodObject(identifier string, labels map[string]string, job opslevel.RunnerJob) *corev1.Pod { - // TODO: Allow configuration of Labels // TODO: Allow configuration of Pod Command podSecurityContext := s.podConfig.SecurityContext @@ -193,13 +192,14 @@ func (s *JobRunner) getPodObject(identifier string, labels map[string]string, jo } } - var containerSecurityContext *corev1.SecurityContext + containerSecurityContext := s.podConfig.ContainerSecurityContext.DeepCopy() if s.podConfig.AgentMode { // Agent mode jobs need privileged mode for creating containers within container privileged := true - containerSecurityContext = &corev1.SecurityContext{ - Privileged: &privileged, + if containerSecurityContext == nil { + containerSecurityContext = &corev1.SecurityContext{} } + containerSecurityContext.Privileged = &privileged } initContainers := []corev1.Container{ @@ -207,6 +207,8 @@ func (s *JobRunner) getPodObject(identifier string, labels map[string]string, jo Name: ContainerNameHelper, Image: s.podConfig.helperImage(), ImagePullPolicy: s.podConfig.PullPolicy, + Resources: s.podConfig.Resources, + SecurityContext: containerSecurityContext.DeepCopy(), Command: []string{ "cp", "/opslevel-runner", @@ -223,14 +225,14 @@ func (s *JobRunner) getPodObject(identifier string, labels map[string]string, jo } if len(job.InitCommands) > 0 { - initContainers = append(initContainers, s.getInitContainer(job, containerSecurityContext)) + initContainers = append(initContainers, s.getInitContainer(job, containerSecurityContext.DeepCopy())) } return &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: identifier, Namespace: s.podConfig.Namespace, - Labels: labels, + Labels: s.podConfig.podLabels(labels), Annotations: s.podConfig.Annotations, }, Spec: corev1.PodSpec{ @@ -239,12 +241,17 @@ func (s *JobRunner) getPodObject(identifier string, labels map[string]string, jo SecurityContext: &podSecurityContext, ServiceAccountName: s.podConfig.ServiceAccountName, NodeSelector: s.podConfig.NodeSelector, + DNSPolicy: s.podConfig.DNSPolicy, + RuntimeClassName: s.podConfig.runtimeClassName(), + Tolerations: s.podConfig.Tolerations, + ActiveDeadlineSeconds: s.podConfig.activeDeadlineSeconds(), + AutomountServiceAccountToken: s.podConfig.AutomountServiceAccountToken, InitContainers: initContainers, Containers: []corev1.Container{ { Name: ContainerNameJob, - Image: job.Image, - ImagePullPolicy: corev1.PullIfNotPresent, + Image: s.podConfig.jobImage(job.Image), + ImagePullPolicy: s.podConfig.jobPullPolicy(), Command: []string{ "/bin/sh", "-c", @@ -252,7 +259,7 @@ func (s *JobRunner) getPodObject(identifier string, labels map[string]string, jo }, Resources: s.podConfig.Resources, Env: s.getPodEnv(job.Variables, opslevel.RunnerJobVariableScopeMain), - SecurityContext: containerSecurityContext, + SecurityContext: containerSecurityContext.DeepCopy(), VolumeMounts: []corev1.VolumeMount{ { Name: "scripts", diff --git a/src/pkg/k8s_config.go b/src/pkg/k8s_config.go index cd83ad5..67eb44c 100644 --- a/src/pkg/k8s_config.go +++ b/src/pkg/k8s_config.go @@ -3,6 +3,7 @@ package pkg import ( "fmt" "os" + "strings" "github.com/spf13/viper" corev1 "k8s.io/api/core/v1" @@ -26,12 +27,44 @@ type K8SPodConfig struct { DNSPolicy corev1.DNSPolicy `yaml:"dnsPolicy"` PullPolicy corev1.PullPolicy `yaml:"pullPolicy"` SecurityContext corev1.PodSecurityContext `yaml:"securityContext"` - NodeSelector map[string]string `yaml:"nodeSelector"` - AgentMode bool `yaml:"agentMode"` - HelperImage string `yaml:"helperImage"` + // ContainerSecurityContext is applied to every init and main container. + ContainerSecurityContext *corev1.SecurityContext `yaml:"containerSecurityContext"` + NodeSelector map[string]string `yaml:"nodeSelector"` + AgentMode bool `yaml:"agentMode"` + HelperImage string `yaml:"helperImage"` + + // Image overrides the image the job container runs. When empty the image + // from the job definition is used. + Image string `yaml:"image"` + + // RuntimeClassName selects the container runtime for the job pod, e.g. + // "kata-qemu" for VM isolation or "gvisor". When empty the pod runs under + // the cluster default runtime (usually runc) with no VM boundary. + RuntimeClassName string `yaml:"runtimeClassName"` + + // Tolerations let job pods schedule onto tainted nodes - sandbox node pools + // are typically tainted so ordinary workloads cannot land on them. + Tolerations []corev1.Toleration `yaml:"tolerations"` + + // ActiveDeadlineSeconds bounds the lifetime of the pod itself, unlike + // Lifetime which only bounds the job container's sleep from the inside. + ActiveDeadlineSeconds int64 `yaml:"activeDeadlineSeconds"` + + // AutomountServiceAccountToken controls whether a Kubernetes API credential + // is projected into the job pod. Nil leaves the namespace default in place. + AutomountServiceAccountToken *bool `yaml:"automountServiceAccountToken"` + + // Labels are merged onto the labels the runner sets on each job pod. Runner + // labels win on conflict since the runner selects pods by them. + Labels map[string]string `yaml:"labels"` } func ReadPodConfig(path string) (*K8SPodConfig, error) { + automountServiceAccountToken, err := automountFromViper() + if err != nil { + return nil, err + } + config := Config{ Kubernetes: K8SPodConfig{ Namespace: viper.GetString("job-pod-namespace"), @@ -53,6 +86,10 @@ func ReadPodConfig(path string) (*K8SPodConfig, error) { TerminationGracePeriodSeconds: 5, AgentMode: viper.GetBool("job-agent-mode"), HelperImage: viper.GetString("job-pod-helper-image"), + Image: viper.GetString("job-pod-image"), + RuntimeClassName: viper.GetString("job-pod-runtime-class-name"), + ActiveDeadlineSeconds: viper.GetInt64("job-pod-active-deadline"), + AutomountServiceAccountToken: automountServiceAccountToken, }, } // Early out with viper defaults if config file doesn't exist @@ -71,6 +108,79 @@ func ReadPodConfig(path string) (*K8SPodConfig, error) { return &config.Kubernetes, nil } +// automountFromViper reads the tri-state flag. It is a string rather than a bool +// because a bound bool flag always carries its default, leaving no way to tell +// "unset" from "explicitly false" - and defaulting to false would silently strip +// the token mount from every existing user. Any other non-empty value is rejected +// rather than failing open to the namespace default. +func automountFromViper() (*bool, error) { + value := strings.TrimSpace(viper.GetString("job-pod-automount-service-account-token")) + switch strings.ToLower(value) { + case "": + return nil, nil + case "true": + value := true + return &value, nil + case "false": + value := false + return &value, nil + default: + return nil, fmt.Errorf("invalid job-pod-automount-service-account-token value %q: expected true, false, or empty", value) + } +} + +// jobPullPolicy defaults to IfNotPresent rather than leaving the field empty so +// that job pods keep their historical behavior when PullPolicy is unset. +func (c *K8SPodConfig) jobPullPolicy() corev1.PullPolicy { + if c.PullPolicy == "" { + return corev1.PullIfNotPresent + } + return c.PullPolicy +} + +// jobImage returns the image the job container should run - the config override +// when set, otherwise the image from the job definition. +func (c *K8SPodConfig) jobImage(jobImage string) string { + if c.Image != "" { + return c.Image + } + return jobImage +} + +// runtimeClassName must be nil rather than a pointer to "" when unset - the API +// server treats an empty runtime class as a request for a class named "". +func (c *K8SPodConfig) runtimeClassName() *string { + if c.RuntimeClassName == "" { + return nil + } + return &c.RuntimeClassName +} + +// activeDeadlineSeconds must be nil rather than a pointer to 0 when unset - a +// zero deadline is rejected, it does not mean "unspecified". +func (c *K8SPodConfig) activeDeadlineSeconds() *int64 { + if c.ActiveDeadlineSeconds <= 0 { + return nil + } + return &c.ActiveDeadlineSeconds +} + +// podLabels merges the configured labels under the labels the runner manages - +// the runner selects and cleans up pods by its own labels, so they must win. +func (c *K8SPodConfig) podLabels(runnerLabels map[string]string) map[string]string { + if len(c.Labels) == 0 { + return runnerLabels + } + merged := make(map[string]string, len(c.Labels)+len(runnerLabels)) + for k, v := range c.Labels { + merged[k] = v + } + for k, v := range runnerLabels { + merged[k] = v + } + return merged +} + func (c *K8SPodConfig) helperImage() string { if c.HelperImage != "" { return c.HelperImage diff --git a/src/pkg/k8s_config_test.go b/src/pkg/k8s_config_test.go new file mode 100644 index 0000000..0304bd8 --- /dev/null +++ b/src/pkg/k8s_config_test.go @@ -0,0 +1,274 @@ +package pkg + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/opslevel/opslevel-go/v2026" + "github.com/rocktavious/autopilot/v2023" + "github.com/rs/zerolog" + "github.com/spf13/viper" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" +) + +func boolPtr(value bool) *bool { + return &value +} + +func TestReadPodConfig_ContainerSecurityContext(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.yaml") + configYAML := []byte(` +kubernetes: + containerSecurityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL +`) + if err := os.WriteFile(configPath, configYAML, 0o600); err != nil { + t.Fatal(err) + } + + config, err := ReadPodConfig(configPath) + if err != nil { + t.Fatal(err) + } + if config.ContainerSecurityContext == nil { + t.Fatal("ContainerSecurityContext should be loaded") + } + if config.ContainerSecurityContext.AllowPrivilegeEscalation == nil { + t.Fatal("AllowPrivilegeEscalation should be loaded") + } + autopilot.Equals(t, false, *config.ContainerSecurityContext.AllowPrivilegeEscalation) + if config.ContainerSecurityContext.Capabilities == nil { + t.Fatal("Capabilities should be loaded") + } + autopilot.Equals(t, []corev1.Capability{"ALL"}, config.ContainerSecurityContext.Capabilities.Drop) +} + +func TestReadPodConfig_InvalidAutomountServiceAccountToken(t *testing.T) { + const key = "job-pod-automount-service-account-token" + originalValue := viper.Get(key) + viper.Set(key, "flase") + t.Cleanup(func() { viper.Set(key, originalValue) }) + + config, err := ReadPodConfig(filepath.Join(t.TempDir(), "missing.yaml")) + + if err == nil { + t.Fatal("ReadPodConfig should reject an invalid automount value") + } + autopilot.Equals(t, (*K8SPodConfig)(nil), config) + autopilot.Equals(t, `invalid job-pod-automount-service-account-token value "flase": expected true, false, or empty`, err.Error()) +} + +// fullyPopulatedPodConfig sets every field of K8SPodConfig to a distinctive +// non-zero value so the wiring assertions below can tell it apart from a default. +func fullyPopulatedPodConfig() *K8SPodConfig { + runAsUser := int64(1234) + allowPrivilegeEscalation := false + return &K8SPodConfig{ + Namespace: "test-namespace", + Lifetime: 4242, + Shell: "/bin/bash", + WorkingDir: "/test-workdir", + Annotations: map[string]string{ + "test.opslevel.com/annotation": "yes", + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewMilliQuantity(250, resource.DecimalSI), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: *resource.NewMilliQuantity(500, resource.DecimalSI), + }, + }, + ServiceAccountName: "test-sa", + TerminationGracePeriodSeconds: 37, + DNSPolicy: corev1.DNSClusterFirstWithHostNet, + PullPolicy: corev1.PullAlways, + SecurityContext: corev1.PodSecurityContext{RunAsUser: &runAsUser}, + ContainerSecurityContext: &corev1.SecurityContext{ + AllowPrivilegeEscalation: &allowPrivilegeEscalation, + Capabilities: &corev1.Capabilities{ + Drop: []corev1.Capability{"ALL"}, + }, + }, + NodeSelector: map[string]string{"katacontainers.io/kata-runtime": "true"}, + AgentMode: false, + HelperImage: "test-helper:1.2.3", + Image: "test-job-image:4.5.6", + RuntimeClassName: "kata-qemu", + Tolerations: []corev1.Toleration{ + {Key: "kata-runtime", Operator: corev1.TolerationOpEqual, Value: "true", Effect: corev1.TaintEffectNoSchedule}, + }, + ActiveDeadlineSeconds: 1200, + AutomountServiceAccountToken: boolPtr(false), + Labels: map[string]string{"platform.opslevel.com/sandbox": "true"}, + } +} + +// TestK8SPodConfig_AllFieldsWired asserts that every field of K8SPodConfig has a +// visible effect on the rendered pod. Reflection over the struct fails the test +// when a field has no assertion here, so a new config field cannot be added +// without either wiring it up or documenting why it is not part of the PodSpec. +// This is what would have caught DNSPolicy sitting unread for as long as it did. +func TestK8SPodConfig_AllFieldsWired(t *testing.T) { + assertions := map[string]func(t *testing.T, pod *corev1.Pod){ + "Namespace": func(t *testing.T, pod *corev1.Pod) { + autopilot.Equals(t, "test-namespace", pod.Namespace) + }, + "Lifetime": func(t *testing.T, pod *corev1.Pod) { + autopilot.Equals(t, "sleep 4242", pod.Spec.Containers[0].Command[2]) + }, + "Annotations": func(t *testing.T, pod *corev1.Pod) { + autopilot.Equals(t, "yes", pod.Annotations["test.opslevel.com/annotation"]) + }, + "Resources": func(t *testing.T, pod *corev1.Pod) { + containers := append([]corev1.Container{}, pod.Spec.InitContainers...) + containers = append(containers, pod.Spec.Containers...) + for _, container := range containers { + t.Run(container.Name, func(t *testing.T) { + autopilot.Equals(t, "250m", container.Resources.Requests.Cpu().String()) + autopilot.Equals(t, "500m", container.Resources.Limits.Cpu().String()) + }) + } + }, + "ServiceAccountName": func(t *testing.T, pod *corev1.Pod) { + autopilot.Equals(t, "test-sa", pod.Spec.ServiceAccountName) + }, + "TerminationGracePeriodSeconds": func(t *testing.T, pod *corev1.Pod) { + autopilot.Equals(t, int64(37), *pod.Spec.TerminationGracePeriodSeconds) + }, + "DNSPolicy": func(t *testing.T, pod *corev1.Pod) { + autopilot.Equals(t, corev1.DNSClusterFirstWithHostNet, pod.Spec.DNSPolicy) + }, + "PullPolicy": func(t *testing.T, pod *corev1.Pod) { + autopilot.Equals(t, corev1.PullAlways, pod.Spec.InitContainers[0].ImagePullPolicy) + autopilot.Equals(t, corev1.PullAlways, pod.Spec.Containers[0].ImagePullPolicy) + }, + "SecurityContext": func(t *testing.T, pod *corev1.Pod) { + autopilot.Equals(t, int64(1234), *pod.Spec.SecurityContext.RunAsUser) + }, + "ContainerSecurityContext": func(t *testing.T, pod *corev1.Pod) { + containers := append([]corev1.Container{}, pod.Spec.InitContainers...) + containers = append(containers, pod.Spec.Containers...) + for _, container := range containers { + t.Run(container.Name, func(t *testing.T) { + securityContext := container.SecurityContext + if securityContext == nil { + t.Fatal("SecurityContext should be set") + } + if securityContext.AllowPrivilegeEscalation == nil { + t.Fatal("AllowPrivilegeEscalation should be set") + } + autopilot.Equals(t, false, *securityContext.AllowPrivilegeEscalation) + if securityContext.Capabilities == nil { + t.Fatal("Capabilities should be set") + } + autopilot.Equals(t, []corev1.Capability{"ALL"}, securityContext.Capabilities.Drop) + }) + } + }, + "NodeSelector": func(t *testing.T, pod *corev1.Pod) { + autopilot.Equals(t, "true", pod.Spec.NodeSelector["katacontainers.io/kata-runtime"]) + }, + "HelperImage": func(t *testing.T, pod *corev1.Pod) { + autopilot.Equals(t, "test-helper:1.2.3", pod.Spec.InitContainers[0].Image) + }, + "Image": func(t *testing.T, pod *corev1.Pod) { + autopilot.Equals(t, "test-job-image:4.5.6", pod.Spec.Containers[0].Image) + }, + "RuntimeClassName": func(t *testing.T, pod *corev1.Pod) { + autopilot.Assert(t, pod.Spec.RuntimeClassName != nil, "RuntimeClassName should be set") + autopilot.Equals(t, "kata-qemu", *pod.Spec.RuntimeClassName) + }, + "Tolerations": func(t *testing.T, pod *corev1.Pod) { + autopilot.Equals(t, 1, len(pod.Spec.Tolerations)) + autopilot.Equals(t, "kata-runtime", pod.Spec.Tolerations[0].Key) + }, + "ActiveDeadlineSeconds": func(t *testing.T, pod *corev1.Pod) { + autopilot.Assert(t, pod.Spec.ActiveDeadlineSeconds != nil, "ActiveDeadlineSeconds should be set") + autopilot.Equals(t, int64(1200), *pod.Spec.ActiveDeadlineSeconds) + }, + "AutomountServiceAccountToken": func(t *testing.T, pod *corev1.Pod) { + autopilot.Assert(t, pod.Spec.AutomountServiceAccountToken != nil, "AutomountServiceAccountToken should be set") + autopilot.Equals(t, false, *pod.Spec.AutomountServiceAccountToken) + }, + "Labels": func(t *testing.T, pod *corev1.Pod) { + autopilot.Equals(t, "true", pod.Labels["platform.opslevel.com/sandbox"]) + }, + // Fields that legitimately do not surface in the pod object itself. + // Shell and WorkingDir shape the exec command, not the PodSpec; AgentMode + // has dedicated coverage in TestGetPodObject_AgentModePrivileged. + "Shell": nil, + "WorkingDir": nil, + "AgentMode": nil, + } + + runner := &JobRunner{logger: zerolog.Nop(), podConfig: fullyPopulatedPodConfig()} + pod := runner.getPodObject("test-pod", map[string]string{"app": "test"}, opslevel.RunnerJob{ + Image: "from-job:latest", + InitCommands: []string{"prepare"}, + }) + + configType := reflect.TypeOf(K8SPodConfig{}) + for i := range configType.NumField() { + name := configType.Field(i).Name + assertion, ok := assertions[name] + if !ok { + t.Errorf("K8SPodConfig field %q has no wiring assertion - either wire it into the pod object or add it to the exempt list in this test with a reason", name) + continue + } + if assertion == nil { + continue + } + t.Run(name, func(t *testing.T) { assertion(t, pod) }) + } +} + +// TestGetPodObject_NewFieldsUnsetIsBackwardsCompatible pins the pointer-type +// caveat: an unset runtime class or deadline must stay nil, since an empty +// runtime class or a zero deadline is rejected by the API server rather than +// treated as "unspecified". +func TestGetPodObject_NewFieldsUnsetIsBackwardsCompatible(t *testing.T) { + runner := &JobRunner{ + logger: zerolog.Nop(), + podConfig: &K8SPodConfig{Namespace: "test"}, + } + + pod := runner.getPodObject("test-pod", map[string]string{"app": "test"}, opslevel.RunnerJob{Image: "alpine:latest"}) + + autopilot.Assert(t, pod.Spec.RuntimeClassName == nil, "RuntimeClassName should stay nil when unset") + autopilot.Assert(t, pod.Spec.ActiveDeadlineSeconds == nil, "ActiveDeadlineSeconds should stay nil when unset") + autopilot.Assert(t, pod.Spec.AutomountServiceAccountToken == nil, "AutomountServiceAccountToken should stay nil when unset") + autopilot.Assert(t, pod.Spec.Tolerations == nil, "Tolerations should stay nil when unset") + containers := append([]corev1.Container{}, pod.Spec.InitContainers...) + containers = append(containers, pod.Spec.Containers...) + for _, container := range containers { + t.Run(container.Name+"SecurityContext", func(t *testing.T) { + autopilot.Assert(t, container.SecurityContext == nil, "SecurityContext should stay nil when unset") + }) + } + autopilot.Equals(t, "alpine:latest", pod.Spec.Containers[0].Image) + autopilot.Equals(t, corev1.PullIfNotPresent, pod.Spec.Containers[0].ImagePullPolicy) +} + +func TestPodLabels_RunnerLabelsWinOnConflict(t *testing.T) { + config := &K8SPodConfig{Labels: map[string]string{ + "app.kubernetes.io/instance": "hijacked", + "platform.opslevel.com/sandbox": "true", + }} + + labels := config.podLabels(map[string]string{"app.kubernetes.io/instance": "opslevel-job-1"}) + + autopilot.Equals(t, "opslevel-job-1", labels["app.kubernetes.io/instance"]) + autopilot.Equals(t, "true", labels["platform.opslevel.com/sandbox"]) +} + +func TestJobImage(t *testing.T) { + autopilot.Equals(t, "from-job:1", (&K8SPodConfig{}).jobImage("from-job:1")) + autopilot.Equals(t, "override:2", (&K8SPodConfig{Image: "override:2"}).jobImage("from-job:1")) +} diff --git a/src/pkg/k8s_test.go b/src/pkg/k8s_test.go index f7322e5..055e3ba 100644 --- a/src/pkg/k8s_test.go +++ b/src/pkg/k8s_test.go @@ -36,7 +36,8 @@ func TestGetPodObject_AgentModePrivileged(t *testing.T) { }, } job := opslevel.RunnerJob{ - Image: "alpine:latest", + Image: "alpine:latest", + InitCommands: []string{"prepare"}, } labels := map[string]string{"app": "test"} @@ -44,9 +45,19 @@ func TestGetPodObject_AgentModePrivileged(t *testing.T) { pod := runner.getPodObject("test-pod", labels, job) // Assert - autopilot.Assert(t, pod.Spec.Containers[0].SecurityContext != nil, "SecurityContext should be set for agent mode") - autopilot.Assert(t, pod.Spec.Containers[0].SecurityContext.Privileged != nil, "Privileged should be set for agent mode") - autopilot.Equals(t, true, *pod.Spec.Containers[0].SecurityContext.Privileged) + containers := append([]corev1.Container{}, pod.Spec.InitContainers...) + containers = append(containers, pod.Spec.Containers...) + for _, container := range containers { + t.Run(container.Name, func(t *testing.T) { + if container.SecurityContext == nil { + t.Fatal("SecurityContext should be set for agent mode") + } + if container.SecurityContext.Privileged == nil { + t.Fatal("Privileged should be set for agent mode") + } + autopilot.Equals(t, true, *container.SecurityContext.Privileged) + }) + } autopilot.Equals(t, int64(0), *pod.Spec.SecurityContext.RunAsUser) autopilot.Equals(t, int64(0), *pod.Spec.SecurityContext.FSGroup) }