diff --git a/.tangled/workflows/release-helm.yaml b/.tangled/workflows/release-helm.yaml index 2bd1b66..c35b6f3 100644 --- a/.tangled/workflows/release-helm.yaml +++ b/.tangled/workflows/release-helm.yaml @@ -12,12 +12,14 @@ environment: steps: - name: Login to registry command: | + REPO_DID_ENCODED=$(printf '%s' "${TANGLED_REPO_DID}" | sed 's/:/-/g') echo "${APP_PASSWORD}" | helm registry login \ - -u "${TANGLED_REPO_DID}" \ + -u "${REPO_DID_ENCODED}" \ --password-stdin \ ${IMAGE_REGISTRY} - name: Package and push Helm chart command: | + REPO_DID_ENCODED=$(printf '%s' "${TANGLED_REPO_DID}" | sed 's/:/-/g') helm package helm/loom --version ${TANGLED_REF_NAME#v} --app-version ${TANGLED_REF_NAME#v} - helm push loom-${TANGLED_REF_NAME#v}.tgz oci://${IMAGE_REGISTRY}/${TANGLED_REPO_DID}/charts + helm push loom-${TANGLED_REF_NAME#v}.tgz oci://${IMAGE_REGISTRY}/${REPO_DID_ENCODED}/charts diff --git a/.tangled/workflows/release.yaml b/.tangled/workflows/release.yaml index ed44e66..daf83e8 100644 --- a/.tangled/workflows/release.yaml +++ b/.tangled/workflows/release.yaml @@ -15,19 +15,21 @@ environment: steps: - name: Login to registry command: | + REPO_DID_ENCODED=$(printf '%s' "${TANGLED_REPO_DID}" | sed 's/:/-/g') echo "${APP_PASSWORD}" | buildah login \ - -u "${TANGLED_REPO_DID}" \ + -u "${REPO_DID_ENCODED}" \ --password-stdin \ ${IMAGE_REGISTRY} - name: Build and push Loom image command: | + REPO_DID_ENCODED=$(printf '%s' "${TANGLED_REPO_DID}" | sed 's/:/-/g') buildah bud \ - --tag ${IMAGE_REGISTRY}/${TANGLED_REPO_DID}/${TANGLED_REPO_NAME}:${TANGLED_REF_NAME} \ - --tag ${IMAGE_REGISTRY}/${TANGLED_REPO_DID}/${TANGLED_REPO_NAME}:latest \ + --tag ${IMAGE_REGISTRY}/${REPO_DID_ENCODED}/${TANGLED_REPO_NAME}:${TANGLED_REF_NAME} \ + --tag ${IMAGE_REGISTRY}/${REPO_DID_ENCODED}/${TANGLED_REPO_NAME}:latest \ --file ./Dockerfile \ . - buildah push ${IMAGE_REGISTRY}/${TANGLED_REPO_DID}/${TANGLED_REPO_NAME}:latest - buildah push ${IMAGE_REGISTRY}/${TANGLED_REPO_DID}/${TANGLED_REPO_NAME}:${TANGLED_REF_NAME} + buildah push ${IMAGE_REGISTRY}/${REPO_DID_ENCODED}/${TANGLED_REPO_NAME}:latest + buildah push ${IMAGE_REGISTRY}/${REPO_DID_ENCODED}/${TANGLED_REPO_NAME}:${TANGLED_REF_NAME} diff --git a/Dockerfile b/Dockerfile index da557bc..52671a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,18 +13,14 @@ RUN if [ "$BUILDARCH" = "amd64" ] && [ "$TARGETARCH" = "arm64" ]; then \ WORKDIR /workspace -# Copy core dependency (from replace directive in go.mod) -COPY core/ core/ - -# Copy loom go mod files and download deps -COPY loom/go.mod loom/go.sum loom/ -WORKDIR /workspace/loom +# Copy go mod files and download deps +COPY go.mod go.sum ./ RUN go mod download -# Copy loom source code -COPY loom/api/ api/ -COPY loom/cmd/ cmd/ -COPY loom/internal/ internal/ +# Copy source code +COPY api/ api/ +COPY cmd/ cmd/ +COPY internal/ internal/ # Build runner (static, no CGO) # Use -s -w to strip debug symbols and reduce binary size @@ -41,8 +37,8 @@ RUN CC=$(if [ "$TARGETARCH" = "arm64" ] && [ "$BUILDARCH" = "amd64" ]; then echo # Unified image with both binaries FROM gcr.io/distroless/base-debian13:nonroot -COPY --from=builder /workspace/loom/manager /manager -COPY --from=builder /workspace/loom/loom-runner /loom-runner +COPY --from=builder /workspace/manager /manager +COPY --from=builder /workspace/loom-runner /loom-runner LABEL org.opencontainers.image.title="Loom" \ org.opencontainers.image.description="Kubernetes Operator for Tangled Spindles " \ diff --git a/Makefile b/Makefile index 3b881d2..04511ef 100644 --- a/Makefile +++ b/Makefile @@ -171,16 +171,16 @@ run: manifests generate fmt vet ## Run a controller from your host. # More info: https://docs.docker.com/develop/develop-images/build_enhancements/ .PHONY: docker-build docker-build: setup-buildx ## Build and push multi-arch docker image. - cd .. && $(CONTAINER_TOOL) buildx build \ + $(CONTAINER_TOOL) buildx build \ --builder loom-builder \ --platform=linux/amd64,linux/arm64 \ --push \ --tag ${IMG} \ - -f loom/Dockerfile . + -f Dockerfile . .PHONY: docker-build-local docker-build-local: ## Build docker image for local arch only (no push). - cd .. && $(CONTAINER_TOOL) build -f loom/Dockerfile -t ${IMG} . + $(CONTAINER_TOOL) build -t ${IMG} . .PHONY: setup-buildx setup-buildx: ## Set up buildx builder with credential access for multi-arch builds diff --git a/api/v1alpha1/spindleset_types.go b/api/v1alpha1/spindleset_types.go index 8833653..3bba04a 100644 --- a/api/v1alpha1/spindleset_types.go +++ b/api/v1alpha1/spindleset_types.go @@ -214,6 +214,12 @@ type SpindleTemplate struct { // +optional Affinity *corev1.Affinity `json:"affinity,omitempty"` + // ImagePullSecrets is a list of secret names for pulling container images. + // Specified directly on the pod spec to avoid kubelet races when resolving + // secrets from the service account. + // +optional + ImagePullSecrets []string `json:"imagePullSecrets,omitempty"` + // RegistryCredentialsSecret is the name of a kubernetes.io/dockerconfigjson secret // containing registry credentials for buildah to use when pushing images. // If specified, the secret is mounted at /home/user/.docker/config.json. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 4219111..766ac64 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -264,6 +264,11 @@ func (in *SpindleTemplate) DeepCopyInto(out *SpindleTemplate) { *out = new(v1.Affinity) (*in).DeepCopyInto(*out) } + if in.ImagePullSecrets != nil { + in, out := &in.ImagePullSecrets, &out.ImagePullSecrets + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SpindleTemplate. diff --git a/cmd/controller/main.go b/cmd/controller/main.go index dc2baa5..05a6ade 100644 --- a/cmd/controller/main.go +++ b/cmd/controller/main.go @@ -64,6 +64,7 @@ type LoomConfig struct { // LoomTemplateConfig holds job template configuration type LoomTemplateConfig struct { + ImagePullSecrets []string `yaml:"imagePullSecrets"` ResourceProfiles []ResourceProfileConfig `yaml:"resourceProfiles"` } @@ -197,6 +198,7 @@ func initializeSpindle( // Create template from loom config template := loomv1alpha1.SpindleTemplate{ + ImagePullSecrets: loomCfg.Template.ImagePullSecrets, ResourceProfiles: profiles, } diff --git a/cmd/runner/main.go b/cmd/runner/main.go index fcdb250..ecc5ea3 100644 --- a/cmd/runner/main.go +++ b/cmd/runner/main.go @@ -110,10 +110,20 @@ func installSelf(dst string) error { } func run() error { - // Read workflow spec from environment - workflowJSON := os.Getenv("LOOM_WORKFLOW_SPEC") + // Read workflow spec from file (preferred) or environment variable (fallback). + // File-based reading keeps the Job object small in etcd. + var workflowJSON string + if specPath := os.Getenv("LOOM_WORKFLOW_SPEC_PATH"); specPath != "" { + data, err := os.ReadFile(specPath) + if err != nil { + return fmt.Errorf("failed to read workflow spec from %s: %w", specPath, err) + } + workflowJSON = string(data) + } else { + workflowJSON = os.Getenv("LOOM_WORKFLOW_SPEC") + } if workflowJSON == "" { - return fmt.Errorf("LOOM_WORKFLOW_SPEC environment variable not set") + return fmt.Errorf("workflow spec not provided: set LOOM_WORKFLOW_SPEC_PATH or LOOM_WORKFLOW_SPEC") } var workflow loomv1alpha1.WorkflowSpec diff --git a/config/crd/bases/loom.j5t.io_spindlesets.yaml b/config/crd/bases/loom.j5t.io_spindlesets.yaml index 1d22154..8b2a302 100644 --- a/config/crd/bases/loom.j5t.io_spindlesets.yaml +++ b/config/crd/bases/loom.j5t.io_spindlesets.yaml @@ -1192,6 +1192,14 @@ spec: x-kubernetes-list-type: atomic type: object type: object + imagePullSecrets: + description: |- + ImagePullSecrets is a list of secret names for pulling container images. + Specified directly on the pod spec to avoid kubelet races when resolving + secrets from the service account. + items: + type: string + type: array registryCredentialsSecret: description: |- RegistryCredentialsSecret is the name of a kubernetes.io/dockerconfigjson secret diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index ac8aece..0a7a9e0 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -7,37 +7,38 @@ rules: - apiGroups: - "" resources: - - nodes + - configmaps + - secrets + - services verbs: + - create + - delete + - get - list + - patch + - update - watch - apiGroups: - "" resources: - - pods + - nodes verbs: - - get - list - watch - apiGroups: - "" resources: - - pods/log + - pods verbs: - get + - list + - watch - apiGroups: - "" resources: - - secrets - - services + - pods/log verbs: - - create - - delete - get - - list - - patch - - update - - watch - apiGroups: - batch resources: diff --git a/helm/loom/templates/configmap.yaml b/helm/loom/templates/configmap.yaml index a41e8dc..06e927d 100644 --- a/helm/loom/templates/configmap.yaml +++ b/helm/loom/templates/configmap.yaml @@ -12,6 +12,12 @@ data: # Template for spindle job pods template: + # imagePullSecrets specified directly on job pod specs to avoid + # kubelet races when resolving secrets from the service account + imagePullSecrets: + {{- range .Values.imagePullSecrets }} + - {{ .name }} + {{- end }} # Resource profiles are matched against workflow architecture and node labels. # The first profile matching the workflow's architecture is selected. # Profile's nodeSelector and resources are applied to the job pod. diff --git a/internal/controller/spindleset_controller.go b/internal/controller/spindleset_controller.go index a71c810..d1cd692 100644 --- a/internal/controller/spindleset_controller.go +++ b/internal/controller/spindleset_controller.go @@ -18,6 +18,7 @@ package controller import ( "context" + "encoding/json" "fmt" "strings" "sync" @@ -75,6 +76,7 @@ type SpindleSetReconciler struct { // +kubebuilder:rbac:groups="",resources=nodes,verbs=list;watch // +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=pods/log,verbs=get +// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch;delete @@ -530,6 +532,54 @@ func (r *SpindleSetReconciler) createWorkflowJob(ctx context.Context, spindleSet return fmt.Errorf("failed to check for existing job: %w", err) } + // Create a ConfigMap with the workflow spec JSON to keep the Job object small in etcd. + // The runner reads the spec from a mounted file instead of an env var. + workflowSpecJSON, err := json.Marshal(workflowSpec) + if err != nil { + return fmt.Errorf("failed to marshal workflow spec for %s: %w", workflowSpec.Name, err) + } + + configMapName := jobName + "-spec" + if len(configMapName) > 63 { + configMapName = configMapName[:63] + } + + existingCM := &corev1.ConfigMap{} + err = r.Get(ctx, client.ObjectKey{ + Name: configMapName, + Namespace: spindleSet.Namespace, + }, existingCM) + + if err != nil { + if apierrors.IsNotFound(err) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: configMapName, + Namespace: spindleSet.Namespace, + Labels: map[string]string{ + "loom.j5t.io/spindleset": spindleSet.Name, + "loom.j5t.io/pipeline-id": pipelineRun.PipelineID, + "loom.j5t.io/workflow": workflowSpec.Name, + }, + }, + Data: map[string]string{ + "workflow-spec.json": string(workflowSpecJSON), + }, + } + if err := controllerutil.SetControllerReference(spindleSet, cm, r.Scheme); err != nil { + return fmt.Errorf("failed to set controller reference on configmap: %w", err) + } + logger.Info("Creating ConfigMap for workflow spec", "configmap", configMapName) + if err := r.retryCreate(ctx, cm); err != nil { + if !apierrors.IsAlreadyExists(err) { + return fmt.Errorf("failed to create configmap for workflow %s: %w", workflowSpec.Name, err) + } + } + } else { + return fmt.Errorf("failed to check for existing configmap: %w", err) + } + } + // Convert workflow steps to jobbuilder format jobSteps := make([]jobbuilder.WorkflowStep, 0, len(workflowSpec.Steps)) for _, step := range workflowSpec.Steps { @@ -554,6 +604,7 @@ func (r *SpindleSetReconciler) createWorkflowJob(ctx context.Context, spindleSet SkipClone: pipelineRun.SkipClone, SecretName: secretName, SecretKeys: secretKeys, + ConfigMapName: configMapName, Template: spindleSet.Spec.Template, Namespace: spindleSet.Namespace, OperatorAddr: r.OperatorAddr, diff --git a/internal/jobbuilder/job_template.go b/internal/jobbuilder/job_template.go index 11fe5dc..6e54aba 100644 --- a/internal/jobbuilder/job_template.go +++ b/internal/jobbuilder/job_template.go @@ -58,6 +58,11 @@ type WorkflowConfig struct { // If empty, no secrets are injected SecretName string + // ConfigMapName is the name of the ConfigMap containing the workflow spec JSON. + // When set, the spec is mounted as a file instead of passed as an env var, + // keeping the Job object small in etcd. + ConfigMapName string + // SecretKeys is the list of environment variable names that contain secrets. // These are passed to the runner for log masking. SecretKeys []string @@ -143,10 +148,14 @@ func BuildJob(config WorkflowConfig, nodes *corev1.NodeList) (*batchv1.Job, erro return nil, fmt.Errorf("spindleset name is required") } - // Marshal workflow spec to JSON for the runner binary - workflowSpecJSON, err := json.Marshal(config.WorkflowSpec) - if err != nil { - return nil, fmt.Errorf("failed to marshal workflow spec: %w", err) + // Marshal workflow spec to JSON for the runner binary (only needed when not using ConfigMap) + var workflowSpecJSON []byte + if config.ConfigMapName == "" { + var err error + workflowSpecJSON, err = json.Marshal(config.WorkflowSpec) + if err != nil { + return nil, fmt.Errorf("failed to marshal workflow spec: %w", err) + } } // Select resource profile based on workflow architecture and available nodes @@ -251,20 +260,7 @@ func BuildJob(config WorkflowConfig, nodes *corev1.NodeList) (*batchv1.Job, erro VolumeMounts: buildRunnerVolumeMounts(config), - Env: append(buildEnvironmentVariables(config), - corev1.EnvVar{ - Name: "LOOM_WORKFLOW_SPEC", - Value: string(workflowSpecJSON), - }, - corev1.EnvVar{ - Name: "LOOM_SECRET_KEYS", - Value: strings.Join(config.SecretKeys, ","), - }, - corev1.EnvVar{ - Name: "LOOM_OPERATOR_ADDR", - Value: config.OperatorAddr, - }, - ), + Env: buildContainerEnv(config, workflowSpecJSON), // Inject repository secrets via envFrom if available EnvFrom: buildEnvFromSources(config), @@ -280,8 +276,12 @@ func BuildJob(config WorkflowConfig, nodes *corev1.NodeList) (*batchv1.Job, erro Affinity: finalAffinity, // Use dedicated service account with minimal permissions - // Note: imagePullSecrets should be attached to this SA, not the controller SA ServiceAccountName: "loom-spindle-job-runner", + + // Specify imagePullSecrets directly on the pod spec to avoid + // a kubelet race where SA-attached secrets aren't resolved + // in time for the first image pull attempt + ImagePullSecrets: buildImagePullSecrets(config), }, }, }, @@ -290,6 +290,15 @@ func BuildJob(config WorkflowConfig, nodes *corev1.NodeList) (*batchv1.Job, erro return job, nil } +// buildImagePullSecrets converts template secret names to LocalObjectReference list. +func buildImagePullSecrets(config WorkflowConfig) []corev1.LocalObjectReference { + var refs []corev1.LocalObjectReference + for _, name := range config.Template.ImagePullSecrets { + refs = append(refs, corev1.LocalObjectReference{Name: name}) + } + return refs +} + // buildEnvironmentVariables creates the environment variables for the runner container. // All environment variables come from WorkflowSpec.Environment, which includes: // - Engine-specific vars (PATH, TANGLED_ARCHITECTURE, HOME) set in InitWorkflow @@ -305,6 +314,38 @@ func buildEnvironmentVariables(config WorkflowConfig) []corev1.EnvVar { return env } +// buildContainerEnv builds the environment variables for the runner container. +// When a ConfigMap is used, the workflow spec is referenced via LOOM_WORKFLOW_SPEC_PATH +// instead of embedding the full JSON in LOOM_WORKFLOW_SPEC. +func buildContainerEnv(config WorkflowConfig, workflowSpecJSON []byte) []corev1.EnvVar { + env := buildEnvironmentVariables(config) + + if config.ConfigMapName != "" { + env = append(env, corev1.EnvVar{ + Name: "LOOM_WORKFLOW_SPEC_PATH", + Value: "/runner-config/workflow-spec.json", + }) + } else { + env = append(env, corev1.EnvVar{ + Name: "LOOM_WORKFLOW_SPEC", + Value: string(workflowSpecJSON), + }) + } + + env = append(env, + corev1.EnvVar{ + Name: "LOOM_SECRET_KEYS", + Value: strings.Join(config.SecretKeys, ","), + }, + corev1.EnvVar{ + Name: "LOOM_OPERATOR_ADDR", + Value: config.OperatorAddr, + }, + ) + + return env +} + // buildEnvFromSources creates EnvFromSource entries for secrets injection. func buildEnvFromSources(config WorkflowConfig) []corev1.EnvFromSource { var envFrom []corev1.EnvFromSource @@ -524,6 +565,15 @@ func buildRunnerVolumeMounts(config WorkflowConfig) []corev1.VolumeMount { }, } + // Mount workflow spec ConfigMap if specified + if config.ConfigMapName != "" { + mounts = append(mounts, corev1.VolumeMount{ + Name: "workflow-spec", + MountPath: "/runner-config", + ReadOnly: true, + }) + } + // Mount registry credentials if specified if config.Template.RegistryCredentialsSecret != "" { mounts = append(mounts, corev1.VolumeMount{ @@ -583,6 +633,20 @@ func buildVolumes(config WorkflowConfig) []corev1.Volume { }, } + // Add workflow spec ConfigMap volume if specified + if config.ConfigMapName != "" { + volumes = append(volumes, corev1.Volume{ + Name: "workflow-spec", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: config.ConfigMapName, + }, + }, + }, + }) + } + // Add registry credentials volume if specified if config.Template.RegistryCredentialsSecret != "" { volumes = append(volumes, corev1.Volume{ diff --git a/internal/jobbuilder/job_template_test.go b/internal/jobbuilder/job_template_test.go index 5ef9c65..8e46888 100644 --- a/internal/jobbuilder/job_template_test.go +++ b/internal/jobbuilder/job_template_test.go @@ -326,6 +326,124 @@ func TestBuildInitContainers(t *testing.T) { } } +func TestBuildJobConfigMapVolume(t *testing.T) { + config := WorkflowConfig{ + WorkflowName: "test-workflow", + PipelineID: "test-pipeline", + SpindleSetName: "test-spindleset", + Image: "test:latest", + Architecture: "amd64", + WorkflowSpec: loomv1alpha1.WorkflowSpec{Name: "test"}, + Namespace: "default", + ConfigMapName: "spindle-test-pipeline-test-workflow-spec", + } + nodes := makeNodeList(map[string]string{"kubernetes.io/arch": "amd64"}) + + job, err := BuildJob(config, nodes) + if err != nil { + t.Fatalf("BuildJob() error = %v", err) + } + + container := job.Spec.Template.Spec.Containers[0] + + // Should have LOOM_WORKFLOW_SPEC_PATH, not LOOM_WORKFLOW_SPEC + var hasSpecPath, hasSpecInline bool + for _, env := range container.Env { + if env.Name == "LOOM_WORKFLOW_SPEC_PATH" { + hasSpecPath = true + if env.Value != "/runner-config/workflow-spec.json" { + t.Errorf("LOOM_WORKFLOW_SPEC_PATH = %q, want /runner-config/workflow-spec.json", env.Value) + } + } + if env.Name == "LOOM_WORKFLOW_SPEC" { + hasSpecInline = true + } + } + if !hasSpecPath { + t.Error("expected LOOM_WORKFLOW_SPEC_PATH env var when ConfigMapName is set") + } + if hasSpecInline { + t.Error("LOOM_WORKFLOW_SPEC env var should not be set when ConfigMapName is set") + } + + // Should have workflow-spec volume + var hasVolume bool + for _, v := range job.Spec.Template.Spec.Volumes { + if v.Name == "workflow-spec" { + hasVolume = true + if v.ConfigMap == nil { + t.Error("workflow-spec volume should be a ConfigMap volume") + } else if v.ConfigMap.Name != config.ConfigMapName { + t.Errorf("ConfigMap name = %q, want %q", v.ConfigMap.Name, config.ConfigMapName) + } + } + } + if !hasVolume { + t.Error("expected workflow-spec volume when ConfigMapName is set") + } + + // Should have volume mount at /runner-config + var hasMount bool + for _, m := range container.VolumeMounts { + if m.Name == "workflow-spec" { + hasMount = true + if m.MountPath != "/runner-config" { + t.Errorf("mount path = %q, want /runner-config", m.MountPath) + } + if !m.ReadOnly { + t.Error("workflow-spec mount should be read-only") + } + } + } + if !hasMount { + t.Error("expected workflow-spec volume mount when ConfigMapName is set") + } +} + +func TestBuildJobWithoutConfigMap(t *testing.T) { + config := WorkflowConfig{ + WorkflowName: "test-workflow", + PipelineID: "test-pipeline", + SpindleSetName: "test-spindleset", + Image: "test:latest", + Architecture: "amd64", + WorkflowSpec: loomv1alpha1.WorkflowSpec{Name: "test"}, + Namespace: "default", + } + nodes := makeNodeList(map[string]string{"kubernetes.io/arch": "amd64"}) + + job, err := BuildJob(config, nodes) + if err != nil { + t.Fatalf("BuildJob() error = %v", err) + } + + container := job.Spec.Template.Spec.Containers[0] + + // Should have LOOM_WORKFLOW_SPEC, not LOOM_WORKFLOW_SPEC_PATH + var hasSpecInline, hasSpecPath bool + for _, env := range container.Env { + if env.Name == "LOOM_WORKFLOW_SPEC" { + hasSpecInline = true + } + if env.Name == "LOOM_WORKFLOW_SPEC_PATH" { + hasSpecPath = true + } + } + if !hasSpecInline { + t.Error("expected LOOM_WORKFLOW_SPEC env var when ConfigMapName is empty") + } + if hasSpecPath { + t.Error("LOOM_WORKFLOW_SPEC_PATH should not be set when ConfigMapName is empty") + } + + // Should NOT have workflow-spec volume + for _, v := range job.Spec.Template.Spec.Volumes { + if v.Name == "workflow-spec" { + t.Error("workflow-spec volume should not exist when ConfigMapName is empty") + } + } +} + func TestBuildJob(t *testing.T) { tests := []struct { name string