Kubernetes Operator for Tangled Spindles
15
fork

Configure Feed

Select the types of activity you want to include in your feed.

move workflow spec to configmap for jobs

+328 -57
+4 -2
.tangled/workflows/release-helm.yaml
··· 12 12 steps: 13 13 - name: Login to registry 14 14 command: | 15 + REPO_DID_ENCODED=$(printf '%s' "${TANGLED_REPO_DID}" | sed 's/:/-/g') 15 16 echo "${APP_PASSWORD}" | helm registry login \ 16 - -u "${TANGLED_REPO_DID}" \ 17 + -u "${REPO_DID_ENCODED}" \ 17 18 --password-stdin \ 18 19 ${IMAGE_REGISTRY} 19 20 20 21 - name: Package and push Helm chart 21 22 command: | 23 + REPO_DID_ENCODED=$(printf '%s' "${TANGLED_REPO_DID}" | sed 's/:/-/g') 22 24 helm package helm/loom --version ${TANGLED_REF_NAME#v} --app-version ${TANGLED_REF_NAME#v} 23 - helm push loom-${TANGLED_REF_NAME#v}.tgz oci://${IMAGE_REGISTRY}/${TANGLED_REPO_DID}/charts 25 + helm push loom-${TANGLED_REF_NAME#v}.tgz oci://${IMAGE_REGISTRY}/${REPO_DID_ENCODED}/charts
+7 -5
.tangled/workflows/release.yaml
··· 15 15 steps: 16 16 - name: Login to registry 17 17 command: | 18 + REPO_DID_ENCODED=$(printf '%s' "${TANGLED_REPO_DID}" | sed 's/:/-/g') 18 19 echo "${APP_PASSWORD}" | buildah login \ 19 - -u "${TANGLED_REPO_DID}" \ 20 + -u "${REPO_DID_ENCODED}" \ 20 21 --password-stdin \ 21 22 ${IMAGE_REGISTRY} 22 23 23 24 - name: Build and push Loom image 24 25 command: | 26 + REPO_DID_ENCODED=$(printf '%s' "${TANGLED_REPO_DID}" | sed 's/:/-/g') 25 27 buildah bud \ 26 - --tag ${IMAGE_REGISTRY}/${TANGLED_REPO_DID}/${TANGLED_REPO_NAME}:${TANGLED_REF_NAME} \ 27 - --tag ${IMAGE_REGISTRY}/${TANGLED_REPO_DID}/${TANGLED_REPO_NAME}:latest \ 28 + --tag ${IMAGE_REGISTRY}/${REPO_DID_ENCODED}/${TANGLED_REPO_NAME}:${TANGLED_REF_NAME} \ 29 + --tag ${IMAGE_REGISTRY}/${REPO_DID_ENCODED}/${TANGLED_REPO_NAME}:latest \ 28 30 --file ./Dockerfile \ 29 31 . 30 32 31 - buildah push ${IMAGE_REGISTRY}/${TANGLED_REPO_DID}/${TANGLED_REPO_NAME}:latest 32 - buildah push ${IMAGE_REGISTRY}/${TANGLED_REPO_DID}/${TANGLED_REPO_NAME}:${TANGLED_REF_NAME} 33 + buildah push ${IMAGE_REGISTRY}/${REPO_DID_ENCODED}/${TANGLED_REPO_NAME}:latest 34 + buildah push ${IMAGE_REGISTRY}/${REPO_DID_ENCODED}/${TANGLED_REPO_NAME}:${TANGLED_REF_NAME} 33 35
+8 -12
Dockerfile
··· 13 13 14 14 WORKDIR /workspace 15 15 16 - # Copy core dependency (from replace directive in go.mod) 17 - COPY core/ core/ 18 - 19 - # Copy loom go mod files and download deps 20 - COPY loom/go.mod loom/go.sum loom/ 21 - WORKDIR /workspace/loom 16 + # Copy go mod files and download deps 17 + COPY go.mod go.sum ./ 22 18 RUN go mod download 23 19 24 - # Copy loom source code 25 - COPY loom/api/ api/ 26 - COPY loom/cmd/ cmd/ 27 - COPY loom/internal/ internal/ 20 + # Copy source code 21 + COPY api/ api/ 22 + COPY cmd/ cmd/ 23 + COPY internal/ internal/ 28 24 29 25 # Build runner (static, no CGO) 30 26 # Use -s -w to strip debug symbols and reduce binary size ··· 41 37 42 38 # Unified image with both binaries 43 39 FROM gcr.io/distroless/base-debian13:nonroot 44 - COPY --from=builder /workspace/loom/manager /manager 45 - COPY --from=builder /workspace/loom/loom-runner /loom-runner 40 + COPY --from=builder /workspace/manager /manager 41 + COPY --from=builder /workspace/loom-runner /loom-runner 46 42 47 43 LABEL org.opencontainers.image.title="Loom" \ 48 44 org.opencontainers.image.description="Kubernetes Operator for Tangled Spindles " \
+3 -3
Makefile
··· 171 171 # More info: https://docs.docker.com/develop/develop-images/build_enhancements/ 172 172 .PHONY: docker-build 173 173 docker-build: setup-buildx ## Build and push multi-arch docker image. 174 - cd .. && $(CONTAINER_TOOL) buildx build \ 174 + $(CONTAINER_TOOL) buildx build \ 175 175 --builder loom-builder \ 176 176 --platform=linux/amd64,linux/arm64 \ 177 177 --push \ 178 178 --tag ${IMG} \ 179 - -f loom/Dockerfile . 179 + -f Dockerfile . 180 180 181 181 .PHONY: docker-build-local 182 182 docker-build-local: ## Build docker image for local arch only (no push). 183 - cd .. && $(CONTAINER_TOOL) build -f loom/Dockerfile -t ${IMG} . 183 + $(CONTAINER_TOOL) build -t ${IMG} . 184 184 185 185 .PHONY: setup-buildx 186 186 setup-buildx: ## Set up buildx builder with credential access for multi-arch builds
+6
api/v1alpha1/spindleset_types.go
··· 214 214 // +optional 215 215 Affinity *corev1.Affinity `json:"affinity,omitempty"` 216 216 217 + // ImagePullSecrets is a list of secret names for pulling container images. 218 + // Specified directly on the pod spec to avoid kubelet races when resolving 219 + // secrets from the service account. 220 + // +optional 221 + ImagePullSecrets []string `json:"imagePullSecrets,omitempty"` 222 + 217 223 // RegistryCredentialsSecret is the name of a kubernetes.io/dockerconfigjson secret 218 224 // containing registry credentials for buildah to use when pushing images. 219 225 // If specified, the secret is mounted at /home/user/.docker/config.json.
+5
api/v1alpha1/zz_generated.deepcopy.go
··· 264 264 *out = new(v1.Affinity) 265 265 (*in).DeepCopyInto(*out) 266 266 } 267 + if in.ImagePullSecrets != nil { 268 + in, out := &in.ImagePullSecrets, &out.ImagePullSecrets 269 + *out = make([]string, len(*in)) 270 + copy(*out, *in) 271 + } 267 272 } 268 273 269 274 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SpindleTemplate.
+2
cmd/controller/main.go
··· 64 64 65 65 // LoomTemplateConfig holds job template configuration 66 66 type LoomTemplateConfig struct { 67 + ImagePullSecrets []string `yaml:"imagePullSecrets"` 67 68 ResourceProfiles []ResourceProfileConfig `yaml:"resourceProfiles"` 68 69 } 69 70 ··· 197 198 198 199 // Create template from loom config 199 200 template := loomv1alpha1.SpindleTemplate{ 201 + ImagePullSecrets: loomCfg.Template.ImagePullSecrets, 200 202 ResourceProfiles: profiles, 201 203 } 202 204
+13 -3
cmd/runner/main.go
··· 110 110 } 111 111 112 112 func run() error { 113 - // Read workflow spec from environment 114 - workflowJSON := os.Getenv("LOOM_WORKFLOW_SPEC") 113 + // Read workflow spec from file (preferred) or environment variable (fallback). 114 + // File-based reading keeps the Job object small in etcd. 115 + var workflowJSON string 116 + if specPath := os.Getenv("LOOM_WORKFLOW_SPEC_PATH"); specPath != "" { 117 + data, err := os.ReadFile(specPath) 118 + if err != nil { 119 + return fmt.Errorf("failed to read workflow spec from %s: %w", specPath, err) 120 + } 121 + workflowJSON = string(data) 122 + } else { 123 + workflowJSON = os.Getenv("LOOM_WORKFLOW_SPEC") 124 + } 115 125 if workflowJSON == "" { 116 - return fmt.Errorf("LOOM_WORKFLOW_SPEC environment variable not set") 126 + return fmt.Errorf("workflow spec not provided: set LOOM_WORKFLOW_SPEC_PATH or LOOM_WORKFLOW_SPEC") 117 127 } 118 128 119 129 var workflow loomv1alpha1.WorkflowSpec
+8
config/crd/bases/loom.j5t.io_spindlesets.yaml
··· 1192 1192 x-kubernetes-list-type: atomic 1193 1193 type: object 1194 1194 type: object 1195 + imagePullSecrets: 1196 + description: |- 1197 + ImagePullSecrets is a list of secret names for pulling container images. 1198 + Specified directly on the pod spec to avoid kubelet races when resolving 1199 + secrets from the service account. 1200 + items: 1201 + type: string 1202 + type: array 1195 1203 registryCredentialsSecret: 1196 1204 description: |- 1197 1205 RegistryCredentialsSecret is the name of a kubernetes.io/dockerconfigjson secret
+14 -13
config/rbac/role.yaml
··· 7 7 - apiGroups: 8 8 - "" 9 9 resources: 10 + - configmaps 11 + - secrets 12 + - services 13 + verbs: 14 + - create 15 + - delete 16 + - get 17 + - list 18 + - patch 19 + - update 20 + - watch 21 + - apiGroups: 22 + - "" 23 + resources: 10 24 - nodes 11 25 verbs: 12 26 - list ··· 25 39 - pods/log 26 40 verbs: 27 41 - get 28 - - apiGroups: 29 - - "" 30 - resources: 31 - - secrets 32 - - services 33 - verbs: 34 - - create 35 - - delete 36 - - get 37 - - list 38 - - patch 39 - - update 40 - - watch 41 42 - apiGroups: 42 43 - batch 43 44 resources:
+6
helm/loom/templates/configmap.yaml
··· 12 12 13 13 # Template for spindle job pods 14 14 template: 15 + # imagePullSecrets specified directly on job pod specs to avoid 16 + # kubelet races when resolving secrets from the service account 17 + imagePullSecrets: 18 + {{- range .Values.imagePullSecrets }} 19 + - {{ .name }} 20 + {{- end }} 15 21 # Resource profiles are matched against workflow architecture and node labels. 16 22 # The first profile matching the workflow's architecture is selected. 17 23 # Profile's nodeSelector and resources are applied to the job pod.
+51
internal/controller/spindleset_controller.go
··· 18 18 19 19 import ( 20 20 "context" 21 + "encoding/json" 21 22 "fmt" 22 23 "strings" 23 24 "sync" ··· 75 76 // +kubebuilder:rbac:groups="",resources=nodes,verbs=list;watch 76 77 // +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch 77 78 // +kubebuilder:rbac:groups="",resources=pods/log,verbs=get 79 + // +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;patch;delete 78 80 // +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete 79 81 // +kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch;delete 80 82 ··· 530 532 return fmt.Errorf("failed to check for existing job: %w", err) 531 533 } 532 534 535 + // Create a ConfigMap with the workflow spec JSON to keep the Job object small in etcd. 536 + // The runner reads the spec from a mounted file instead of an env var. 537 + workflowSpecJSON, err := json.Marshal(workflowSpec) 538 + if err != nil { 539 + return fmt.Errorf("failed to marshal workflow spec for %s: %w", workflowSpec.Name, err) 540 + } 541 + 542 + configMapName := jobName + "-spec" 543 + if len(configMapName) > 63 { 544 + configMapName = configMapName[:63] 545 + } 546 + 547 + existingCM := &corev1.ConfigMap{} 548 + err = r.Get(ctx, client.ObjectKey{ 549 + Name: configMapName, 550 + Namespace: spindleSet.Namespace, 551 + }, existingCM) 552 + 553 + if err != nil { 554 + if apierrors.IsNotFound(err) { 555 + cm := &corev1.ConfigMap{ 556 + ObjectMeta: metav1.ObjectMeta{ 557 + Name: configMapName, 558 + Namespace: spindleSet.Namespace, 559 + Labels: map[string]string{ 560 + "loom.j5t.io/spindleset": spindleSet.Name, 561 + "loom.j5t.io/pipeline-id": pipelineRun.PipelineID, 562 + "loom.j5t.io/workflow": workflowSpec.Name, 563 + }, 564 + }, 565 + Data: map[string]string{ 566 + "workflow-spec.json": string(workflowSpecJSON), 567 + }, 568 + } 569 + if err := controllerutil.SetControllerReference(spindleSet, cm, r.Scheme); err != nil { 570 + return fmt.Errorf("failed to set controller reference on configmap: %w", err) 571 + } 572 + logger.Info("Creating ConfigMap for workflow spec", "configmap", configMapName) 573 + if err := r.retryCreate(ctx, cm); err != nil { 574 + if !apierrors.IsAlreadyExists(err) { 575 + return fmt.Errorf("failed to create configmap for workflow %s: %w", workflowSpec.Name, err) 576 + } 577 + } 578 + } else { 579 + return fmt.Errorf("failed to check for existing configmap: %w", err) 580 + } 581 + } 582 + 533 583 // Convert workflow steps to jobbuilder format 534 584 jobSteps := make([]jobbuilder.WorkflowStep, 0, len(workflowSpec.Steps)) 535 585 for _, step := range workflowSpec.Steps { ··· 554 604 SkipClone: pipelineRun.SkipClone, 555 605 SecretName: secretName, 556 606 SecretKeys: secretKeys, 607 + ConfigMapName: configMapName, 557 608 Template: spindleSet.Spec.Template, 558 609 Namespace: spindleSet.Namespace, 559 610 OperatorAddr: r.OperatorAddr,
+83 -19
internal/jobbuilder/job_template.go
··· 58 58 // If empty, no secrets are injected 59 59 SecretName string 60 60 61 + // ConfigMapName is the name of the ConfigMap containing the workflow spec JSON. 62 + // When set, the spec is mounted as a file instead of passed as an env var, 63 + // keeping the Job object small in etcd. 64 + ConfigMapName string 65 + 61 66 // SecretKeys is the list of environment variable names that contain secrets. 62 67 // These are passed to the runner for log masking. 63 68 SecretKeys []string ··· 143 148 return nil, fmt.Errorf("spindleset name is required") 144 149 } 145 150 146 - // Marshal workflow spec to JSON for the runner binary 147 - workflowSpecJSON, err := json.Marshal(config.WorkflowSpec) 148 - if err != nil { 149 - return nil, fmt.Errorf("failed to marshal workflow spec: %w", err) 151 + // Marshal workflow spec to JSON for the runner binary (only needed when not using ConfigMap) 152 + var workflowSpecJSON []byte 153 + if config.ConfigMapName == "" { 154 + var err error 155 + workflowSpecJSON, err = json.Marshal(config.WorkflowSpec) 156 + if err != nil { 157 + return nil, fmt.Errorf("failed to marshal workflow spec: %w", err) 158 + } 150 159 } 151 160 152 161 // Select resource profile based on workflow architecture and available nodes ··· 251 260 252 261 VolumeMounts: buildRunnerVolumeMounts(config), 253 262 254 - Env: append(buildEnvironmentVariables(config), 255 - corev1.EnvVar{ 256 - Name: "LOOM_WORKFLOW_SPEC", 257 - Value: string(workflowSpecJSON), 258 - }, 259 - corev1.EnvVar{ 260 - Name: "LOOM_SECRET_KEYS", 261 - Value: strings.Join(config.SecretKeys, ","), 262 - }, 263 - corev1.EnvVar{ 264 - Name: "LOOM_OPERATOR_ADDR", 265 - Value: config.OperatorAddr, 266 - }, 267 - ), 263 + Env: buildContainerEnv(config, workflowSpecJSON), 268 264 269 265 // Inject repository secrets via envFrom if available 270 266 EnvFrom: buildEnvFromSources(config), ··· 280 276 Affinity: finalAffinity, 281 277 282 278 // Use dedicated service account with minimal permissions 283 - // Note: imagePullSecrets should be attached to this SA, not the controller SA 284 279 ServiceAccountName: "loom-spindle-job-runner", 280 + 281 + // Specify imagePullSecrets directly on the pod spec to avoid 282 + // a kubelet race where SA-attached secrets aren't resolved 283 + // in time for the first image pull attempt 284 + ImagePullSecrets: buildImagePullSecrets(config), 285 285 }, 286 286 }, 287 287 }, ··· 290 290 return job, nil 291 291 } 292 292 293 + // buildImagePullSecrets converts template secret names to LocalObjectReference list. 294 + func buildImagePullSecrets(config WorkflowConfig) []corev1.LocalObjectReference { 295 + var refs []corev1.LocalObjectReference 296 + for _, name := range config.Template.ImagePullSecrets { 297 + refs = append(refs, corev1.LocalObjectReference{Name: name}) 298 + } 299 + return refs 300 + } 301 + 293 302 // buildEnvironmentVariables creates the environment variables for the runner container. 294 303 // All environment variables come from WorkflowSpec.Environment, which includes: 295 304 // - Engine-specific vars (PATH, TANGLED_ARCHITECTURE, HOME) set in InitWorkflow ··· 305 314 return env 306 315 } 307 316 317 + // buildContainerEnv builds the environment variables for the runner container. 318 + // When a ConfigMap is used, the workflow spec is referenced via LOOM_WORKFLOW_SPEC_PATH 319 + // instead of embedding the full JSON in LOOM_WORKFLOW_SPEC. 320 + func buildContainerEnv(config WorkflowConfig, workflowSpecJSON []byte) []corev1.EnvVar { 321 + env := buildEnvironmentVariables(config) 322 + 323 + if config.ConfigMapName != "" { 324 + env = append(env, corev1.EnvVar{ 325 + Name: "LOOM_WORKFLOW_SPEC_PATH", 326 + Value: "/runner-config/workflow-spec.json", 327 + }) 328 + } else { 329 + env = append(env, corev1.EnvVar{ 330 + Name: "LOOM_WORKFLOW_SPEC", 331 + Value: string(workflowSpecJSON), 332 + }) 333 + } 334 + 335 + env = append(env, 336 + corev1.EnvVar{ 337 + Name: "LOOM_SECRET_KEYS", 338 + Value: strings.Join(config.SecretKeys, ","), 339 + }, 340 + corev1.EnvVar{ 341 + Name: "LOOM_OPERATOR_ADDR", 342 + Value: config.OperatorAddr, 343 + }, 344 + ) 345 + 346 + return env 347 + } 348 + 308 349 // buildEnvFromSources creates EnvFromSource entries for secrets injection. 309 350 func buildEnvFromSources(config WorkflowConfig) []corev1.EnvFromSource { 310 351 var envFrom []corev1.EnvFromSource ··· 524 565 }, 525 566 } 526 567 568 + // Mount workflow spec ConfigMap if specified 569 + if config.ConfigMapName != "" { 570 + mounts = append(mounts, corev1.VolumeMount{ 571 + Name: "workflow-spec", 572 + MountPath: "/runner-config", 573 + ReadOnly: true, 574 + }) 575 + } 576 + 527 577 // Mount registry credentials if specified 528 578 if config.Template.RegistryCredentialsSecret != "" { 529 579 mounts = append(mounts, corev1.VolumeMount{ ··· 581 631 EmptyDir: &corev1.EmptyDirVolumeSource{}, 582 632 }, 583 633 }, 634 + } 635 + 636 + // Add workflow spec ConfigMap volume if specified 637 + if config.ConfigMapName != "" { 638 + volumes = append(volumes, corev1.Volume{ 639 + Name: "workflow-spec", 640 + VolumeSource: corev1.VolumeSource{ 641 + ConfigMap: &corev1.ConfigMapVolumeSource{ 642 + LocalObjectReference: corev1.LocalObjectReference{ 643 + Name: config.ConfigMapName, 644 + }, 645 + }, 646 + }, 647 + }) 584 648 } 585 649 586 650 // Add registry credentials volume if specified
+118
internal/jobbuilder/job_template_test.go
··· 326 326 } 327 327 } 328 328 329 + func TestBuildJobConfigMapVolume(t *testing.T) { 330 + config := WorkflowConfig{ 331 + WorkflowName: "test-workflow", 332 + PipelineID: "test-pipeline", 333 + SpindleSetName: "test-spindleset", 334 + Image: "test:latest", 335 + Architecture: "amd64", 336 + WorkflowSpec: loomv1alpha1.WorkflowSpec{Name: "test"}, 337 + Namespace: "default", 338 + ConfigMapName: "spindle-test-pipeline-test-workflow-spec", 339 + } 340 + nodes := makeNodeList(map[string]string{"kubernetes.io/arch": "amd64"}) 341 + 342 + job, err := BuildJob(config, nodes) 343 + if err != nil { 344 + t.Fatalf("BuildJob() error = %v", err) 345 + } 346 + 347 + container := job.Spec.Template.Spec.Containers[0] 348 + 349 + // Should have LOOM_WORKFLOW_SPEC_PATH, not LOOM_WORKFLOW_SPEC 350 + var hasSpecPath, hasSpecInline bool 351 + for _, env := range container.Env { 352 + if env.Name == "LOOM_WORKFLOW_SPEC_PATH" { 353 + hasSpecPath = true 354 + if env.Value != "/runner-config/workflow-spec.json" { 355 + t.Errorf("LOOM_WORKFLOW_SPEC_PATH = %q, want /runner-config/workflow-spec.json", env.Value) 356 + } 357 + } 358 + if env.Name == "LOOM_WORKFLOW_SPEC" { 359 + hasSpecInline = true 360 + } 361 + } 362 + if !hasSpecPath { 363 + t.Error("expected LOOM_WORKFLOW_SPEC_PATH env var when ConfigMapName is set") 364 + } 365 + if hasSpecInline { 366 + t.Error("LOOM_WORKFLOW_SPEC env var should not be set when ConfigMapName is set") 367 + } 368 + 369 + // Should have workflow-spec volume 370 + var hasVolume bool 371 + for _, v := range job.Spec.Template.Spec.Volumes { 372 + if v.Name == "workflow-spec" { 373 + hasVolume = true 374 + if v.ConfigMap == nil { 375 + t.Error("workflow-spec volume should be a ConfigMap volume") 376 + } else if v.ConfigMap.Name != config.ConfigMapName { 377 + t.Errorf("ConfigMap name = %q, want %q", v.ConfigMap.Name, config.ConfigMapName) 378 + } 379 + } 380 + } 381 + if !hasVolume { 382 + t.Error("expected workflow-spec volume when ConfigMapName is set") 383 + } 384 + 385 + // Should have volume mount at /runner-config 386 + var hasMount bool 387 + for _, m := range container.VolumeMounts { 388 + if m.Name == "workflow-spec" { 389 + hasMount = true 390 + if m.MountPath != "/runner-config" { 391 + t.Errorf("mount path = %q, want /runner-config", m.MountPath) 392 + } 393 + if !m.ReadOnly { 394 + t.Error("workflow-spec mount should be read-only") 395 + } 396 + } 397 + } 398 + if !hasMount { 399 + t.Error("expected workflow-spec volume mount when ConfigMapName is set") 400 + } 401 + } 402 + 403 + func TestBuildJobWithoutConfigMap(t *testing.T) { 404 + config := WorkflowConfig{ 405 + WorkflowName: "test-workflow", 406 + PipelineID: "test-pipeline", 407 + SpindleSetName: "test-spindleset", 408 + Image: "test:latest", 409 + Architecture: "amd64", 410 + WorkflowSpec: loomv1alpha1.WorkflowSpec{Name: "test"}, 411 + Namespace: "default", 412 + } 413 + nodes := makeNodeList(map[string]string{"kubernetes.io/arch": "amd64"}) 414 + 415 + job, err := BuildJob(config, nodes) 416 + if err != nil { 417 + t.Fatalf("BuildJob() error = %v", err) 418 + } 419 + 420 + container := job.Spec.Template.Spec.Containers[0] 421 + 422 + // Should have LOOM_WORKFLOW_SPEC, not LOOM_WORKFLOW_SPEC_PATH 423 + var hasSpecInline, hasSpecPath bool 424 + for _, env := range container.Env { 425 + if env.Name == "LOOM_WORKFLOW_SPEC" { 426 + hasSpecInline = true 427 + } 428 + if env.Name == "LOOM_WORKFLOW_SPEC_PATH" { 429 + hasSpecPath = true 430 + } 431 + } 432 + if !hasSpecInline { 433 + t.Error("expected LOOM_WORKFLOW_SPEC env var when ConfigMapName is empty") 434 + } 435 + if hasSpecPath { 436 + t.Error("LOOM_WORKFLOW_SPEC_PATH should not be set when ConfigMapName is empty") 437 + } 438 + 439 + // Should NOT have workflow-spec volume 440 + for _, v := range job.Spec.Template.Spec.Volumes { 441 + if v.Name == "workflow-spec" { 442 + t.Error("workflow-spec volume should not exist when ConfigMapName is empty") 443 + } 444 + } 445 + } 446 + 329 447 func TestBuildJob(t *testing.T) { 330 448 tests := []struct { 331 449 name string