diff --git a/pkg/virtualkubelet/execute.go b/pkg/virtualkubelet/execute.go index 98737d37..7f527817 100644 --- a/pkg/virtualkubelet/execute.go +++ b/pkg/virtualkubelet/execute.go @@ -14,6 +14,7 @@ import ( "net/url" "os" "regexp" + "sort" "strconv" "strings" "time" @@ -829,33 +830,74 @@ func remoteExecutionHandleProjectedSource( // https://kubernetes.io/docs/concepts/workloads/pods/downward-api/ // See URL doc above, that describe what type of DownwardAPI to expect from volume. For now, only FieldRef is supported. // The rest are ignored. - for _, item := range source.DownwardAPI.Items { - switch { - - case item.FieldRef != nil: - switch item.FieldRef.FieldPath { - case "metadata.name": - projectedVolume.Data[item.Path] = pod.Name - - case "metadata.namespace": - projectedVolume.Data[item.Path] = pod.Namespace - - case "metadata.uid": - projectedVolume.Data[item.Path] = string(pod.UID) - - // TODO implement DownwardAPI annotation and label if needed. + err := populateProjectedVolumeFromDownwardAPI(ctx, pod, source.DownwardAPI.Items, projectedVolume) + if err != nil { + return err + } + } + return nil +} - default: - log.G(ctx).Warningf("in pod %s unsupported DownwardAPI FieldPath %s in InterLink, ignoring this source...", pod.Name, item.FieldRef.FieldPath) - } +func formatDownwardAPIMetadataMap(data map[string]string) string { + if len(data) == 0 { + return "" + } + keys := make([]string, len(data)) + i := 0 + for k := range data { + keys[i] = k + i++ + } + sort.Strings(keys) + var b strings.Builder + for _, k := range keys { + b.WriteString(k) + b.WriteString("=") + b.WriteString(strconv.Quote(data[k])) + b.WriteString("\n") + } + return b.String() +} - case item.ResourceFieldRef != nil: - // TODO implement DownwardAPI resourceFieldRef if needed. - log.G(ctx).Warningf("in pod %s unsupported DownwardAPI resourceFieldRef in InterLink, ignoring this source...", pod.Name) +func resolveDownwardAPIFieldPath(pod *v1.Pod, fieldPath string) (string, bool) { + switch fieldPath { + case "metadata.name": + return pod.Name, true + case "metadata.namespace": + return pod.Namespace, true + case "metadata.uid": + return string(pod.UID), true + case "metadata.labels": + return formatDownwardAPIMetadataMap(pod.Labels), true + case "metadata.annotations": + return formatDownwardAPIMetadataMap(pod.Annotations), true + case "spec.nodeName": + return pod.Spec.NodeName, true + case "spec.serviceAccountName": + return pod.Spec.ServiceAccountName, true + case "status.podIP": + return pod.Status.PodIP, true + case "status.hostIP": + return pod.Status.HostIP, true + default: + return "", false + } +} - default: - log.G(ctx).Warningf("in pod %s unsupported unknown DownwardAPI in InterLink, ignoring this source...", pod.Name) +func populateProjectedVolumeFromDownwardAPI(ctx context.Context, pod *v1.Pod, items []v1.DownwardAPIVolumeFile, projectedVolume *v1.ConfigMap) error { + for _, item := range items { + switch { + case item.FieldRef != nil: + if value, ok := resolveDownwardAPIFieldPath(pod, item.FieldRef.FieldPath); ok { + projectedVolume.Data[item.Path] = value + } else { + log.G(ctx).Warningf("in pod %s unsupported DownwardAPI FieldPath %s in InterLink, ignoring this source...", pod.Name, item.FieldRef.FieldPath) } + case item.ResourceFieldRef != nil: + // TODO implement DownwardAPI resourceFieldRef if needed. + log.G(ctx).Warningf("in pod %s unsupported DownwardAPI resourceFieldRef in InterLink, ignoring this source...", pod.Name) + default: + log.G(ctx).Warningf("in pod %s unsupported unknown DownwardAPI in InterLink, ignoring this source...", pod.Name) } } return nil @@ -916,6 +958,46 @@ func remoteExecutionHandleVolumes(ctx context.Context, p *Provider, pod *v1.Pod, log.G(ctx).Debug("ProjectedVolumeMaps len: ", len(req.ProjectedVolumeMaps)) } + case volume.DownwardAPI != nil: + if p.config.DisableProjectedVolumes { + log.G(ctx).Warning("Flag DisableProjectedVolumes set to true, so not handling DownwardAPI Volume: ", volume) + break + } + + projectedVolume := v1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: volume.Name}, + Data: make(map[string]string), + } + log.G(ctx).Debug("Adding to PodCreateRequests the downwardAPI volume ", volume.Name) + + err := populateProjectedVolumeFromDownwardAPI(ctx, pod, volume.DownwardAPI.Items, &projectedVolume) + if err != nil { + return err + } + + req.ProjectedVolumeMaps = append(req.ProjectedVolumeMaps, projectedVolume) + + for i := range pod.Spec.Volumes { + if pod.Spec.Volumes[i].Name != volume.Name { + continue + } + + pod.Spec.Volumes[i].VolumeSource = v1.VolumeSource{ + Projected: &v1.ProjectedVolumeSource{ + DefaultMode: volume.DownwardAPI.DefaultMode, + Sources: []v1.VolumeProjection{ + { + DownwardAPI: &v1.DownwardAPIProjection{ + Items: volume.DownwardAPI.Items, + }, + }, + }, + }, + } + + break + } + case volume.Secret != nil: scrt, err := p.clientSet.CoreV1().Secrets(pod.Namespace).Get(ctx, volume.Secret.SecretName, metav1.GetOptions{}) if err != nil { @@ -1082,6 +1164,75 @@ func resolveEnvRefs( } } +func resolveEnvFromRefs( + ctx context.Context, + p *Provider, + pod *v1.Pod, + container *v1.Container, +) { + if len(container.EnvFrom) == 0 { + return + } + + explicitEnv := make(map[string]struct{}, len(container.Env)) + envIndex := make(map[string]int, len(container.Env)) + for i := range container.Env { + explicitEnv[container.Env[i].Name] = struct{}{} + envIndex[container.Env[i].Name] = i + } + + upsert := func(name, value string) { + if _, isExplicit := explicitEnv[name]; isExplicit { + return + } + if idx, ok := envIndex[name]; ok { + container.Env[idx].Value = value + container.Env[idx].ValueFrom = nil + return + } + container.Env = append(container.Env, v1.EnvVar{Name: name, Value: value}) + envIndex[name] = len(container.Env) - 1 + } + + for _, envFrom := range container.EnvFrom { + prefix := envFrom.Prefix + + if cmRef := envFrom.ConfigMapRef; cmRef != nil { + cm, err := p.clientSet.CoreV1(). + ConfigMaps(pod.Namespace). + Get(ctx, cmRef.Name, metav1.GetOptions{}) + if err != nil { + if cmRef.Optional != nil && *cmRef.Optional { + continue + } + log.G(ctx).Errorf("resolving envFrom ConfigMap %s/%s: %v", pod.Namespace, cmRef.Name, err) + continue + } + for key, value := range cm.Data { + upsert(prefix+key, value) + } + } + + if secretRef := envFrom.SecretRef; secretRef != nil { + secret, err := p.clientSet.CoreV1(). + Secrets(pod.Namespace). + Get(ctx, secretRef.Name, metav1.GetOptions{}) + if err != nil { + if secretRef.Optional != nil && *secretRef.Optional { + continue + } + log.G(ctx).Errorf("resolving envFrom Secret %s/%s: %v", pod.Namespace, secretRef.Name, err) + continue + } + for key, value := range secret.Data { + upsert(prefix+key, string(value)) + } + } + } + + container.EnvFrom = nil +} + // RemoteExecution is called by the VK everytime a Pod is being registered or deleted to/from the VK. // Depending on the mode (CREATE/DELETE), it performs different actions, making different REST calls. // Note: for the CREATE mode, the function gets stuck up to 5 minutes waiting for every missing ConfigMap/Secret. @@ -1130,6 +1281,13 @@ func RemoteExecution(ctx context.Context, config Config, p *Provider, pod *v1.Po addKubernetesServicesEnvVars(ctx, config, podToOffload) + for i := range podToOffload.Spec.InitContainers { + resolveEnvFromRefs(ctx, p, podToOffload, &podToOffload.Spec.InitContainers[i]) + } + for i := range podToOffload.Spec.Containers { + resolveEnvFromRefs(ctx, p, podToOffload, &podToOffload.Spec.Containers[i]) + } + if config.SkipDownwardAPIResolution { log.G(ctx).Info("SkipDownwardAPIResolution is set to true") for i := range podToOffload.Spec.InitContainers { diff --git a/pkg/virtualkubelet/execute_test.go b/pkg/virtualkubelet/execute_test.go index 5d76b9a0..f6e64c0d 100644 --- a/pkg/virtualkubelet/execute_test.go +++ b/pkg/virtualkubelet/execute_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + types "github.com/interlink-hq/interlink/pkg/interlink" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" v1 "k8s.io/api/core/v1" @@ -16,6 +17,8 @@ import ( "k8s.io/client-go/kubernetes/fake" ) +const testNamespace = "test-ns" + // unixSocketRoundTripper rewrites http+unix URLs to http://unix so the underlying // transport can dial the configured unix socket. type unixSocketRoundTripper struct { @@ -274,7 +277,7 @@ func TestGetSessionContextMessage(t *testing.T) { func TestRemoteExecutionHandleProjectedSourceConfigMap(t *testing.T) { ctx := context.Background() - namespace := "test-ns" + namespace := testNamespace pod := &v1.Pod{ ObjectMeta: metav1.ObjectMeta{ @@ -419,3 +422,243 @@ func TestRemoteExecutionHandleProjectedSourceConfigMap(t *testing.T) { }) } } + +func TestRemoteExecutionHandleProjectedSourceDownwardAPIFieldRef(t *testing.T) { + ctx := context.Background() + pod := &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: testNamespace, + UID: "uid-1234", + Labels: map[string]string{"app": "demo", "tier": "backend"}, + Annotations: map[string]string{"my.annotation/key": "value"}, + }, + Spec: v1.PodSpec{ + NodeName: "node-a", + ServiceAccountName: "svc-account", + }, + Status: v1.PodStatus{ + PodIP: "10.42.0.15", + HostIP: "172.18.0.20", + }, + } + source := v1.VolumeProjection{ + DownwardAPI: &v1.DownwardAPIProjection{ + Items: []v1.DownwardAPIVolumeFile{ + {Path: "pod-name", FieldRef: &v1.ObjectFieldSelector{FieldPath: "metadata.name"}}, + {Path: "namespace", FieldRef: &v1.ObjectFieldSelector{FieldPath: "metadata.namespace"}}, + {Path: "uid", FieldRef: &v1.ObjectFieldSelector{FieldPath: "metadata.uid"}}, + {Path: "labels", FieldRef: &v1.ObjectFieldSelector{FieldPath: "metadata.labels"}}, + {Path: "annotations", FieldRef: &v1.ObjectFieldSelector{FieldPath: "metadata.annotations"}}, + {Path: "node-name", FieldRef: &v1.ObjectFieldSelector{FieldPath: "spec.nodeName"}}, + {Path: "sa-name", FieldRef: &v1.ObjectFieldSelector{FieldPath: "spec.serviceAccountName"}}, + {Path: "pod-ip", FieldRef: &v1.ObjectFieldSelector{FieldPath: "status.podIP"}}, + {Path: "host-ip", FieldRef: &v1.ObjectFieldSelector{FieldPath: "status.hostIP"}}, + }, + }, + } + projectedVolume := &v1.ConfigMap{Data: map[string]string{}} + + err := remoteExecutionHandleProjectedSource(ctx, &Provider{}, pod, source, projectedVolume) + require.NoError(t, err) + + assert.Equal(t, "test-pod", projectedVolume.Data["pod-name"]) + assert.Equal(t, testNamespace, projectedVolume.Data["namespace"]) + assert.Equal(t, "uid-1234", projectedVolume.Data["uid"]) + assert.Equal(t, "app=\"demo\"\ntier=\"backend\"\n", projectedVolume.Data["labels"]) + assert.Equal(t, "my.annotation/key=\"value\"\n", projectedVolume.Data["annotations"]) + assert.Equal(t, "node-a", projectedVolume.Data["node-name"]) + assert.Equal(t, "svc-account", projectedVolume.Data["sa-name"]) + assert.Equal(t, "10.42.0.15", projectedVolume.Data["pod-ip"]) + assert.Equal(t, "172.18.0.20", projectedVolume.Data["host-ip"]) +} + +func TestRemoteExecutionHandleVolumesDownwardAPI(t *testing.T) { + ctx := context.Background() + namespace := testNamespace + pod := &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: namespace, + UID: "uid-1234", + Labels: map[string]string{"app": "demo"}, + Annotations: map[string]string{"a": "b"}, + }, + Spec: v1.PodSpec{ + Volumes: []v1.Volume{ + { + Name: "podinfo", + VolumeSource: v1.VolumeSource{ + DownwardAPI: &v1.DownwardAPIVolumeSource{ + Items: []v1.DownwardAPIVolumeFile{ + {Path: "pod-name", FieldRef: &v1.ObjectFieldSelector{FieldPath: "metadata.name"}}, + {Path: "labels", FieldRef: &v1.ObjectFieldSelector{FieldPath: "metadata.labels"}}, + {Path: "annotations", FieldRef: &v1.ObjectFieldSelector{FieldPath: "metadata.annotations"}}, + }, + }, + }, + }, + }, + }, + } + + fakeClient := fake.NewSimpleClientset(pod.DeepCopy()) + p := &Provider{ + clientSet: fakeClient, + notifier: func(*v1.Pod) {}, + } + req := &types.PodCreateRequests{} + + err := remoteExecutionHandleVolumes(ctx, p, pod, req) + require.NoError(t, err) + require.Len(t, req.ProjectedVolumeMaps, 1) + assert.Equal(t, "podinfo", req.ProjectedVolumeMaps[0].Name) + assert.Equal(t, "test-pod", req.ProjectedVolumeMaps[0].Data["pod-name"]) + assert.Equal(t, "app=\"demo\"\n", req.ProjectedVolumeMaps[0].Data["labels"]) + assert.Equal(t, "a=\"b\"\n", req.ProjectedVolumeMaps[0].Data["annotations"]) + require.Len(t, pod.Spec.Volumes, 1) + require.NotNil(t, pod.Spec.Volumes[0].Projected) + assert.Nil(t, pod.Spec.Volumes[0].DownwardAPI) + require.Len(t, pod.Spec.Volumes[0].Projected.Sources, 1) + require.NotNil(t, pod.Spec.Volumes[0].Projected.Sources[0].DownwardAPI) + assert.Equal(t, pod.Spec.Volumes[0].Projected.Sources[0].DownwardAPI.Items, []v1.DownwardAPIVolumeFile{ + {Path: "pod-name", FieldRef: &v1.ObjectFieldSelector{FieldPath: "metadata.name"}}, + {Path: "labels", FieldRef: &v1.ObjectFieldSelector{FieldPath: "metadata.labels"}}, + {Path: "annotations", FieldRef: &v1.ObjectFieldSelector{FieldPath: "metadata.annotations"}}, + }) +} + +func TestRemoteExecutionHandleVolumesDownwardAPIDisabledProjectedVolumes(t *testing.T) { + ctx := context.Background() + namespace := testNamespace + pod := &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: namespace, + }, + Spec: v1.PodSpec{ + Volumes: []v1.Volume{ + { + Name: "podinfo", + VolumeSource: v1.VolumeSource{ + DownwardAPI: &v1.DownwardAPIVolumeSource{ + Items: []v1.DownwardAPIVolumeFile{ + {Path: "pod-name", FieldRef: &v1.ObjectFieldSelector{FieldPath: "metadata.name"}}, + }, + }, + }, + }, + }, + }, + } + + fakeClient := fake.NewSimpleClientset(pod.DeepCopy()) + p := &Provider{ + clientSet: fakeClient, + notifier: func(*v1.Pod) {}, + config: Config{ + DisableProjectedVolumes: true, + }, + } + req := &types.PodCreateRequests{} + + err := remoteExecutionHandleVolumes(ctx, p, pod, req) + require.NoError(t, err) + assert.Empty(t, req.ProjectedVolumeMaps) + require.Len(t, pod.Spec.Volumes, 1) + require.NotNil(t, pod.Spec.Volumes[0].DownwardAPI) + assert.Nil(t, pod.Spec.Volumes[0].Projected) +} + +func TestResolveEnvFromRefs(t *testing.T) { + ctx := context.Background() + namespace := testNamespace + + pod := &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: namespace, + }, + } + + container := &v1.Container{ + Name: "main", + Env: []v1.EnvVar{ + {Name: "LOG_LEVEL", Value: "info"}, + }, + EnvFrom: []v1.EnvFromSource{ + { + ConfigMapRef: &v1.ConfigMapEnvSource{ + LocalObjectReference: v1.LocalObjectReference{Name: "app-config"}, + }, + }, + { + SecretRef: &v1.SecretEnvSource{ + LocalObjectReference: v1.LocalObjectReference{Name: "aws-credentials"}, + }, + Prefix: "AWS_", + }, + }, + } + + fakeClient := fake.NewSimpleClientset( + &v1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "app-config", Namespace: namespace}, + Data: map[string]string{ + "LOG_LEVEL": "debug", + "DATABASE": "postgresql", + }, + }, + &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "aws-credentials", Namespace: namespace}, + Data: map[string][]byte{ + "ACCESS_KEY_ID": []byte("AKIA"), + }, + }, + ) + + p := &Provider{ + clientSet: fakeClient, + } + + resolveEnvFromRefs(ctx, p, pod, container) + + assert.Empty(t, container.EnvFrom) + assert.Contains(t, container.Env, v1.EnvVar{Name: "LOG_LEVEL", Value: "info"}) + assert.Contains(t, container.Env, v1.EnvVar{Name: "DATABASE", Value: "postgresql"}) + assert.Contains(t, container.Env, v1.EnvVar{Name: "AWS_ACCESS_KEY_ID", Value: "AKIA"}) +} + +func TestResolveEnvFromRefsOptionalMissingSecret(t *testing.T) { + ctx := context.Background() + namespace := testNamespace + + pod := &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: namespace, + }, + } + + optional := true + container := &v1.Container{ + Name: "main", + EnvFrom: []v1.EnvFromSource{ + { + SecretRef: &v1.SecretEnvSource{ + LocalObjectReference: v1.LocalObjectReference{Name: "missing-secret"}, + Optional: &optional, + }, + }, + }, + } + + p := &Provider{ + clientSet: fake.NewSimpleClientset(), + } + + resolveEnvFromRefs(ctx, p, pod, container) + + assert.Empty(t, container.Env) + assert.Empty(t, container.EnvFrom) +} diff --git a/scripts/k3s-test-run.sh b/scripts/k3s-test-run.sh index e5122171..6f8893d8 100755 --- a/scripts/k3s-test-run.sh +++ b/scripts/k3s-test-run.sh @@ -89,6 +89,18 @@ fi echo "Using vk-test-set from submodule..." cd "${PROJECT_ROOT}/test/vk-test-set" +# Apply repository-maintained e2e template overrides when present. +OVERRIDES_DIR="${PROJECT_ROOT}/test/e2e-overrides" +if [ -d "${OVERRIDES_DIR}" ]; then + echo "Applying e2e template overrides from ${OVERRIDES_DIR}..." + shopt -s nullglob + override_files=("${OVERRIDES_DIR}"/*.yaml) + shopt -u nullglob + if [ ${#override_files[@]} -gt 0 ]; then + cp -f "${override_files[@]}" "${PROJECT_ROOT}/test/vk-test-set/vktestset/templates/" + fi +fi + # Create test configuration echo "Creating test configuration..." cat >vktest_config.yaml < 8 else '***' + print(f"✓ {var}: {masked}") + else: + print(f"✓ {var}: {value}") + else: + print(f"✗ {var}: NOT_SET") + missing_app.append(var) + + print("\n--- AWS Credentials Secret ---") + missing_aws = [] + for var in aws_vars: + value = os.environ.get(var) + if value: + if any(x in var for x in ['KEY', 'SECRET']): + masked = value[:4] + '***' + value[-4:] if len(value) > 8 else '***' + print(f"✓ {var}: {masked}") + else: + print(f"✓ {var}: {value}") + else: + print(f"✗ {var}: NOT_SET") + missing_aws.append(var) + + # Check for unexpected prefix (some implementations add prefixes) + print("\n--- Checking for prefixed variables ---") + prefixed_found = False + for key in os.environ: + if any(key.endswith(v) and key != v for v in app_vars + aws_vars): + print(f"! Found prefixed variable: {key}") + prefixed_found = True + if not prefixed_found: + print("✓ No prefixed variables found (correct behavior)") + + print("\n=== Test Results ===") + if missing_app or missing_aws: + print(f"FAILED: Missing {len(missing_app) + len(missing_aws)} variables") + sys.exit(1) + else: + print("SUCCESS: All environment variables from secretRef are present") + print("envFrom secretRef test completed successfully") + + envFrom: + # Import all keys from app-credentials secret + - secretRef: + name: app-credentials-{{ uuid }} + + # Import all keys from aws-credentials secret + - secretRef: + name: aws-credentials-{{ uuid }} + + imagePullPolicy: Always + + dnsPolicy: ClusterFirst + tolerations: {{ tolerations | tojson }} + +################################################################################ +# VALIDATION +timeout_seconds: 20 +check_pods: + - name: secret-envfrom-test-{{ uuid }} + namespace: {{ namespace }} + +check_logs: + # Verify app credentials secret variables without depending on the rendered log prefix. + - name: secret-envfrom-test-{{ uuid }} + namespace: {{ namespace }} + regex: "DATABASE_URL: postgresql" + operator: Exists + + - name: secret-envfrom-test-{{ uuid }} + namespace: {{ namespace }} + regex: "API_KEY: fake" + operator: Exists + + - name: secret-envfrom-test-{{ uuid }} + namespace: {{ namespace }} + regex: "SERVICE_TOKEN: fake" + operator: Exists + + - name: secret-envfrom-test-{{ uuid }} + namespace: {{ namespace }} + regex: "CACHE_HOST: redis.example.com:6379" + operator: Exists + + - name: secret-envfrom-test-{{ uuid }} + namespace: {{ namespace }} + regex: "LOG_LEVEL: debug" + operator: Exists + + # Verify AWS credentials secret variables + - name: secret-envfrom-test-{{ uuid }} + namespace: {{ namespace }} + regex: "AWS_ACCESS_KEY_ID: fake" + operator: Exists + + - name: secret-envfrom-test-{{ uuid }} + namespace: {{ namespace }} + regex: "AWS_SECRET_ACCESS_KEY: fake" + operator: Exists + + - name: secret-envfrom-test-{{ uuid }} + namespace: {{ namespace }} + regex: "AWS_DEFAULT_REGION: us-west-2" + operator: Exists + + # Verify test completion + - name: secret-envfrom-test-{{ uuid }} + namespace: {{ namespace }} + regex: "envFrom secretRef test completed successfully" + operator: Exists + +clean_configs: + - type: pod + name: secret-envfrom-test-{{ uuid }} + namespace: {{ namespace }} + condition: onSuccess + + - type: secret + name: app-credentials-{{ uuid }} + namespace: {{ namespace }} + condition: always + + - type: secret + name: aws-credentials-{{ uuid }} + namespace: {{ namespace }} + condition: always diff --git a/test/e2e-overrides/095-default-entrypoint.yaml b/test/e2e-overrides/095-default-entrypoint.yaml new file mode 100644 index 00000000..8a06973e --- /dev/null +++ b/test/e2e-overrides/095-default-entrypoint.yaml @@ -0,0 +1,178 @@ +## Default Container Entrypoint Test +## +## Tests containers with no command or args specified in the pod spec, +## relying entirely on the container image's default ENTRYPOINT and CMD. +## +## This validates that the virtual kubelet properly: +## - Respects image-defined ENTRYPOINT and CMD +## - Does not require explicit command/args in pod spec +## - Correctly executes the container's default behavior +## +## This is important because many production images have pre-configured +## entry points (nginx, redis, postgres, etc.) and should run without +## command overrides. + +apiVersion: v1 +kind: Pod +metadata: + name: default-entrypoint-{{ uuid }} + namespace: {{ namespace }} + annotations: {{ annotations | tojson }} + labels: + app: default-entrypoint-test + test-id: "{{ uuid }}" + +spec: + restartPolicy: Never + nodeSelector: + kubernetes.io/hostname: {{ target_node }} + + containers: + # Container 1: Explicit minimal command - validates container execution works. + # Uses an explicit "sh" command (which is busybox's default CMD) to force + # singularity exec instead of singularity run, improving CI reliability. + - name: busybox-default + image: busybox:1.36 + command: ["sh", "-c", "exit 0"] + imagePullPolicy: Always + + # Container 2: Only args specified - appends to image ENTRYPOINT + - name: echo-with-args + image: busybox:1.36 + # Override CMD but keep ENTRYPOINT + args: ["sh", "-c", "echo 'Args only test passed' && sleep 5"] + imagePullPolicy: Always + + # Container 3: Image with built-in executable (no overrides needed) + - name: custom-app + image: alpine:3.19 + # Alpine's default command is "/bin/sh" which exits + # We rely on this behavior to test default execution + imagePullPolicy: Always + + dnsPolicy: ClusterFirst + tolerations: {{ tolerations | tojson }} + +--- + +# Second pod: Test with an image that has a working default command +apiVersion: v1 +kind: Pod +metadata: + name: default-cmd-workload-{{ uuid }} + namespace: {{ namespace }} + annotations: {{ annotations | tojson }} + labels: + app: default-cmd-test + test-id: "{{ uuid }}" + +spec: + restartPolicy: Never + nodeSelector: + kubernetes.io/hostname: {{ target_node }} + + containers: + # Use a simple image with a productive default command + - name: python-version + image: python:3.11-alpine + # Python image default CMD is "python3" which starts REPL and waits + # We override with args to run a one-liner that completes + args: ["python3", "-c", "import sys; print(f'Python {sys.version}'); print('Default command test completed')"] + imagePullPolicy: Always + + dnsPolicy: ClusterFirst + tolerations: {{ tolerations | tojson }} + +--- + +# Third pod: Test container that runs with image ENTRYPOINT only +apiVersion: v1 +kind: Pod +metadata: + name: entrypoint-only-{{ uuid }} + namespace: {{ namespace }} + annotations: {{ annotations | tojson }} + labels: + app: entrypoint-only-test + test-id: "{{ uuid }}" + +spec: + restartPolicy: Never + nodeSelector: + kubernetes.io/hostname: {{ target_node }} + + containers: + - name: nginx-version + image: nginx:alpine + # NGINX has ENTRYPOINT and CMD defined in image + # Override CMD to just show version and exit + command: ["nginx"] + args: ["-v"] + imagePullPolicy: Always + + dnsPolicy: ClusterFirst + tolerations: {{ tolerations | tojson }} + +################################################################################ +# VALIDATION +timeout_seconds: 30 + +check_pods: + # First pod should complete (all containers exit) + - name: default-entrypoint-{{ uuid }} + namespace: {{ namespace }} + status: Succeeded + + # Second pod should complete successfully + - name: default-cmd-workload-{{ uuid }} + namespace: {{ namespace }} + status: Succeeded + + # Third pod should complete successfully + - name: entrypoint-only-{{ uuid }} + namespace: {{ namespace }} + status: Succeeded + +check_logs: + # Verify second pod output + - name: default-cmd-workload-{{ uuid }} + namespace: {{ namespace }} + container: python-version + regex: "Python 3.11" + operator: Exists + + - name: default-cmd-workload-{{ uuid }} + namespace: {{ namespace }} + container: python-version + regex: "Default command test completed" + operator: Exists + + # Verify third pod (nginx version) + - name: entrypoint-only-{{ uuid }} + namespace: {{ namespace }} + container: nginx-version + regex: "nginx version" + operator: Exists + + # Verify args-only container in first pod + - name: default-entrypoint-{{ uuid }} + namespace: {{ namespace }} + container: echo-with-args + regex: "Args only test passed" + operator: Exists + +clean_configs: + - type: pod + name: default-entrypoint-{{ uuid }} + namespace: {{ namespace }} + condition: always + + - type: pod + name: default-cmd-workload-{{ uuid }} + namespace: {{ namespace }} + condition: always + + - type: pod + name: entrypoint-only-{{ uuid }} + namespace: {{ namespace }} + condition: always