···309309ENVTEST_VERSION ?= $(shell go list -m -f "{{ .Version }}" sigs.k8s.io/controller-runtime | awk -F'[v.]' '{printf "release-%d.%d", $$2, $$3}')
310310#ENVTEST_K8S_VERSION is the version of Kubernetes to use for setting up ENVTEST binaries (i.e. 1.31)
311311ENVTEST_K8S_VERSION ?= $(shell go list -m -f "{{ .Version }}" k8s.io/api | awk -F'[v.]' '{printf "1.%d", $$3}')
312312-GOLANGCI_LINT_VERSION ?= v2.1.0
312312+GOLANGCI_LINT_VERSION ?= v2.11.4
313313314314.PHONY: kustomize
315315kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary.
+3-1
cmd/controller/main.go
···176176}
177177178178// initializeSpindle creates a spindle server with KubernetesEngine
179179-func initializeSpindle(ctx context.Context, cfg *config.Config, mgr ctrl.Manager, loomCfg *LoomConfig) (*spindle.Spindle, error) {
179179+func initializeSpindle(
180180+ ctx context.Context, cfg *config.Config, mgr ctrl.Manager, loomCfg *LoomConfig,
181181+) (*spindle.Spindle, error) {
180182 // Initialize Kubernetes engine
181183 // Get namespace from environment (injected via Downward API)
182184 namespace := os.Getenv("POD_NAMESPACE")
+2-1
config/crd/bases/loom.j5t.io_spindlesets.yaml
···12401240 operator:
12411241 description: |-
12421242 Operator represents a key's relationship to the value.
12431243- Valid operators are Exists and Equal. Defaults to Equal.
12431243+ Valid operators are Exists, Equal, Lt, and Gt. Defaults to Equal.
12441244 Exists is equivalent to wildcard for value, so that a pod can
12451245 tolerate all taints of a particular category.
12461246+ Lt and Gt perform numeric comparisons (requires feature gate TaintTolerationComparisonOperators).
12461247 type: string
12471248 tolerationSeconds:
12481249 description: |-
+11-6
internal/controller/spindleset_controller.go
···4242 "tangled.org/evan.jarrett.net/loom/internal/jobbuilder"
4343)
44444545+const (
4646+ phaseSucceeded = "Succeeded"
4747+ phaseFailed = "Failed"
4848+)
4949+4550// SpindleSetReconciler reconciles a SpindleSet object
4651type SpindleSetReconciler struct {
4752 client.Client
···129134 }
130135131136 // If SpindleSet is terminal, don't create new jobs — check for TTL cleanup
132132- if spindleSet.Status.Phase == "Succeeded" || spindleSet.Status.Phase == "Failed" {
137137+ if spindleSet.Status.Phase == phaseSucceeded || spindleSet.Status.Phase == phaseFailed {
133138 age := time.Since(spindleSet.CreationTimestamp.Time)
134139 const spindleSetMaxLifetime = 4 * time.Hour
135140 if age > spindleSetMaxLifetime {
···316321317322 // Compute Phase with latch semantics: once terminal, never revert.
318323 // This prevents job recreation after TTL deletes completed Jobs.
319319- if spindleSet.Status.Phase != "Succeeded" && spindleSet.Status.Phase != "Failed" {
324324+ if spindleSet.Status.Phase != phaseSucceeded && spindleSet.Status.Phase != phaseFailed {
320325 expectedWorkflows := int32(len(spindleSet.Spec.PipelineRun.Workflows))
321326 totalTerminal := completed + failed
322327323328 var newPhase string
324329 switch {
325330 case expectedWorkflows > 0 && totalTerminal >= expectedWorkflows && failed > 0:
326326- newPhase = "Failed"
331331+ newPhase = phaseFailed
327332 case expectedWorkflows > 0 && totalTerminal >= expectedWorkflows:
328328- newPhase = "Succeeded"
333333+ newPhase = phaseSucceeded
329334 case running > 0:
330335 newPhase = "Running"
331336 default:
···405410// ensurePipelineJobs ensures Jobs are created for all workflows in a pipeline run
406411func (r *SpindleSetReconciler) ensurePipelineJobs(ctx context.Context, spindleSet *loomv1alpha1.SpindleSet) error {
407412 // Don't recreate jobs for completed pipelines
408408- if spindleSet.Status.Phase == "Succeeded" || spindleSet.Status.Phase == "Failed" {
413413+ if spindleSet.Status.Phase == phaseSucceeded || spindleSet.Status.Phase == phaseFailed {
409414 return nil
410415 }
411416···465470466471 // List nodes for profile selection (to validate nodeSelector labels exist)
467472 var nodeList corev1.NodeList
468468- if err := r.Client.List(ctx, &nodeList); err != nil {
473473+ if err := r.List(ctx, &nodeList); err != nil {
469474 return fmt.Errorf("failed to list nodes: %w", err)
470475 }
471476
+14-14
internal/engine/kubernetes_engine.go
···1111 "sync"
1212 "time"
13131414- "github.com/cyphar/filepath-securejoin"
1414+ securejoin "github.com/cyphar/filepath-securejoin"
1515 "gopkg.in/yaml.v3"
1616 batchv1 "k8s.io/api/batch/v1"
1717 corev1 "k8s.io/api/core/v1"
···5959}
60606161// NewKubernetesEngine creates a new Kubernetes-based spindle engine.
6262-func NewKubernetesEngine(client client.Client, config *rest.Config, namespace string, template loomv1alpha1.SpindleTemplate, vault secrets.Manager) *KubernetesEngine {
6262+func NewKubernetesEngine(k8sClient client.Client, config *rest.Config, namespace string, template loomv1alpha1.SpindleTemplate, vault secrets.Manager) *KubernetesEngine {
6363 return &KubernetesEngine{
6464- client: client,
6464+ client: k8sClient,
6565 config: config,
6666 namespace: namespace,
6767 template: template,
···164164165165// SetupWorkflow creates a SpindleSet CR for the workflow.
166166func (e *KubernetesEngine) SetupWorkflow(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, wfLogger models.WorkflowLogger) error {
167167- logger := log.FromContext(ctx).WithValues("workflow", wid.Name, "pipeline", wid.PipelineId.Rkey)
167167+ logger := log.FromContext(ctx).WithValues("workflow", wid.Name, "pipeline", wid.Rkey)
168168169169 // Extract pre-computed workflow data
170170 data, ok := wf.Data.(*kubernetesWorkflowData)
···216216 sanitizedWorkflowName = strings.TrimSuffix(sanitizedWorkflowName, ".yml")
217217 sanitizedWorkflowName = strings.ReplaceAll(sanitizedWorkflowName, ".", "-")
218218219219- spindleSetName := fmt.Sprintf("spindle-%s-%s", sanitizedWorkflowName, wid.PipelineId.Rkey)
219219+ spindleSetName := fmt.Sprintf("spindle-%s-%s", sanitizedWorkflowName, wid.Rkey)
220220 if len(spindleSetName) > 63 {
221221 // Kubernetes names must be 63 chars or less
222222 spindleSetName = spindleSetName[:63]
···226226 // Knot is extracted from the pipeline ID provided by the framework
227227 skipClone := len(data.CloneStep.Commands()) == 0
228228 pipelineRun := &loomv1alpha1.PipelineRunSpec{
229229- PipelineID: wid.PipelineId.Rkey,
229229+ PipelineID: wid.Rkey,
230230 SkipClone: skipClone,
231231 Secrets: repoSecrets,
232232 Workflows: []loomv1alpha1.WorkflowSpec{data.Spec},
···244244 Namespace: e.namespace,
245245 Labels: map[string]string{
246246 "loom.j5t.io/component": "spindle",
247247- "loom.j5t.io/pipeline-id": wid.PipelineId.Rkey,
247247+ "loom.j5t.io/pipeline-id": wid.Rkey,
248248 "loom.j5t.io/workflow": wf.Name,
249249 },
250250 },
···286286 if err := e.client.List(ctx, spindleSetList,
287287 client.InNamespace(e.namespace),
288288 client.MatchingLabels{
289289- "loom.j5t.io/pipeline-id": wid.PipelineId.Rkey,
289289+ "loom.j5t.io/pipeline-id": wid.Rkey,
290290 "loom.j5t.io/workflow": wid.Name,
291291 }); err != nil {
292292 return nil, fmt.Errorf("failed to query SpindleSet: %w", err)
···304304305305// DestroyWorkflow cleans up the SpindleSet after completion.
306306func (e *KubernetesEngine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error {
307307- logger := log.FromContext(ctx).WithValues("workflow", wid.Name, "pipeline", wid.PipelineId.Rkey)
307307+ logger := log.FromContext(ctx).WithValues("workflow", wid.Name, "pipeline", wid.Rkey)
308308309309 spindleSet, err := e.getSpindleSet(ctx, wid)
310310 if err != nil {
···352352// RunStep streams logs for the specific step and waits for that step to complete.
353353// For Kubernetes engine, all steps run in a single Job, but we stream logs incrementally
354354// as each step executes. Each RunStep call blocks until that step's "end" control event is received.
355355-func (e *KubernetesEngine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger models.WorkflowLogger) error {
356356- logger := log.FromContext(ctx).WithValues("workflow", wid.Name, "pipeline", wid.PipelineId.Rkey, "step", idx)
355355+func (e *KubernetesEngine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, wfSecrets []secrets.UnlockedSecret, wfLogger models.WorkflowLogger) error {
356356+ logger := log.FromContext(ctx).WithValues("workflow", wid.Name, "pipeline", wid.Rkey, "step", idx)
357357358358 // Query for the Job created by SpindleSetReconciler (only on first step)
359359 var job *batchv1.Job
···379379 client.MatchingLabels{
380380 "loom.j5t.io/spindleset": spindleSet.Name,
381381 "loom.j5t.io/workflow": w.Name,
382382- "loom.j5t.io/pipeline-id": wid.PipelineId.Rkey,
382382+ "loom.j5t.io/pipeline-id": wid.Rkey,
383383 })
384384 if err != nil {
385385 return fmt.Errorf("failed to list jobs: %w", err)
···437437 }
438438439439 // Create new stream
440440- logger := log.FromContext(ctx).WithValues("workflow", wid.Name, "pipeline", wid.PipelineId.Rkey)
440440+ logger := log.FromContext(ctx).WithValues("workflow", wid.Name, "pipeline", wid.Rkey)
441441442442 // Create kubernetes clientset for log streaming
443443 clientset, err := kubernetes.NewForConfig(e.config)
···591591 logLine.Stream = "stdout" // Default to stdout
592592 }
593593 dataWriter := wfLogger.DataWriter(logLine.StepId, logLine.Stream)
594594- dataWriter.Write([]byte(logLine.Content + "\n"))
594594+ _, _ = dataWriter.Write([]byte(logLine.Content + "\n"))
595595 }
596596 }
597597
+1-1
internal/jobbuilder/job_template.go
···287287// - Engine-specific vars (PATH, TANGLED_ARCHITECTURE, HOME) set in InitWorkflow
288288// - Pipeline-level vars (TANGLED_REPO_*, TANGLED_REF, CI, etc.) injected by framework
289289func buildEnvironmentVariables(config WorkflowConfig) []corev1.EnvVar {
290290- var env []corev1.EnvVar
290290+ env := make([]corev1.EnvVar, 0, len(config.WorkflowSpec.Environment))
291291 for key, value := range config.WorkflowSpec.Environment {
292292 env = append(env, corev1.EnvVar{
293293 Name: key,