Kubernetes Operator for Tangled Spindles

upstream envvars

evan.jarrett.net 54c0fe3d da829f9c

verified
+60 -76
-4
api/v1alpha1/spindleset_types.go
··· 45 45 // +kubebuilder:validation:Required 46 46 PipelineID string `json:"pipelineID"` 47 47 48 - // Knot is the domain of the knot that triggered this pipeline. 49 - // +kubebuilder:validation:Required 50 - Knot string `json:"knot"` 51 - 52 48 // CloneCommands are the git commands to run in the clone init container. 53 49 // Generated by tangled.org/core/spindle/models.BuildCloneStep(). 54 50 // These commands are self-contained (include repo URL and commit SHA).
-5
config/crd/bases/loom.j5t.io_spindlesets.yaml
··· 77 77 items: 78 78 type: string 79 79 type: array 80 - knot: 81 - description: Knot is the domain of the knot that triggered this 82 - pipeline. 83 - type: string 84 80 pipelineID: 85 81 description: PipelineID is the unique identifier for this pipeline 86 82 run from the knot. ··· 205 201 minItems: 1 206 202 type: array 207 203 required: 208 - - knot 209 204 - pipelineID 210 205 - workflows 211 206 type: object
+1 -1
go.mod
··· 197 197 ) 198 198 199 199 // Use our custom version of tangled until its upstreamed 200 - replace tangled.org/core => tangled.org/evan.jarrett.net/core v1.11.0-alpha.0.20251122155825-3cdf71e7987f 200 + replace tangled.org/core => tangled.org/evan.jarrett.net/core v1.11.0-alpha.0.20251124173227-196aa76bafc3
+2 -2
go.sum
··· 687 687 sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= 688 688 sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= 689 689 sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= 690 - tangled.org/evan.jarrett.net/core v1.11.0-alpha.0.20251122155825-3cdf71e7987f h1:GY1l29z2WSns8aVryXpRo8LN6/GP2UiivyHUO9OL7Hs= 691 - tangled.org/evan.jarrett.net/core v1.11.0-alpha.0.20251122155825-3cdf71e7987f/go.mod h1:DpfEc3N9VfsIYVcXwP71zDQpGWnTQ3wBLBxqV0oom8g= 690 + tangled.org/evan.jarrett.net/core v1.11.0-alpha.0.20251124173227-196aa76bafc3 h1:uoAq/kDgUByxxG0kxjIRoBbcIzoU119DAI1hsNYIyVY= 691 + tangled.org/evan.jarrett.net/core v1.11.0-alpha.0.20251124173227-196aa76bafc3/go.mod h1:DpfEc3N9VfsIYVcXwP71zDQpGWnTQ3wBLBxqV0oom8g=
-1
internal/controller/spindleset_controller.go
··· 393 393 SecretName: secretName, // Name of K8s Secret to inject (empty if no secrets) 394 394 Template: spindleSet.Spec.Template, 395 395 Namespace: spindleSet.Namespace, 396 - Knot: pipelineRun.Knot, 397 396 } 398 397 399 398 // Create the Job
-1
internal/controller/spindleset_controller_test.go
··· 55 55 Template: loomv1alpha1.SpindleTemplate{}, 56 56 PipelineRun: &loomv1alpha1.PipelineRunSpec{ 57 57 PipelineID: "test-pipeline-123", 58 - Knot: "knot.example.com", 59 58 CloneCommands: []string{ 60 59 "git init", 61 60 "git remote add origin https://knot.example.com/did:plc:test/repo",
+48 -33
internal/engine/kubernetes_engine.go
··· 1 1 package engine 2 2 3 3 import ( 4 + "maps" 4 5 "bufio" 5 6 "context" 6 7 "encoding/json" ··· 66 67 // kubernetesWorkflowData holds pre-computed data for workflow execution. 67 68 // Built in InitWorkflow, consumed in SetupWorkflow. 68 69 type kubernetesWorkflowData struct { 69 - Spec loomv1alpha1.WorkflowSpec 70 - CloneStep models.CloneStep // empty if clone should be skipped 71 - DidSlashRepo string // for secrets lookup 72 - Knot string // for status reporting 70 + Spec loomv1alpha1.WorkflowSpec 71 + CloneStep models.CloneStep // empty if clone should be skipped 73 72 } 74 73 75 74 // SimpleStep implements the models.Step interface. ··· 92 91 } 93 92 94 93 // InitWorkflow parses the workflow YAML and initializes a Workflow model. 95 - func (e *KubernetesEngine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) { 94 + // Pipeline environment variables (TANGLED_*) are injected into workflow.Environment 95 + // by the framework after this method returns. 96 + func (e *KubernetesEngine) InitWorkflow(twf tangled.Pipeline_Workflow, cloneStep models.CloneStep) (*models.Workflow, error) { 96 97 // Parse the Raw YAML into the unified WorkflowSpec type 97 98 var spec loomv1alpha1.WorkflowSpec 98 99 if err := yaml.Unmarshal([]byte(twf.Raw), &spec); err != nil { ··· 122 123 }) 123 124 } 124 125 125 - // Build clone step (uses upstream models.BuildCloneStep which is self-contained) 126 - var cloneStep models.CloneStep 127 - devMode := false // TODO: Make this configurable 128 - 129 - if twf.Clone == nil || !twf.Clone.Skip { 130 - cloneStep = models.BuildCloneStep(twf, *tpl.TriggerMetadata, devMode) 126 + // Store pre-computed workflow data 127 + workflowData := &kubernetesWorkflowData{ 128 + Spec: spec, 129 + CloneStep: cloneStep, 131 130 } 132 131 133 - // Build didSlashRepo path for secrets lookup 134 - didSlashRepo, err := securejoin.SecureJoin(tpl.TriggerMetadata.Repo.Did, tpl.TriggerMetadata.Repo.Repo) 135 - if err != nil { 136 - return nil, fmt.Errorf("failed to construct repo path for secrets: %w", err) 137 - } 138 - 139 - // Store pre-computed workflow data 140 - workflowData := &kubernetesWorkflowData{ 141 - Spec: spec, 142 - CloneStep: cloneStep, 143 - DidSlashRepo: didSlashRepo, 144 - Knot: tpl.TriggerMetadata.Repo.Knot, 132 + // Set engine-specific environment variables on the workflow 133 + // These will be merged with pipeline env vars by the framework 134 + workflowEnv := map[string]string{ 135 + "PATH": "/runner-bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", 136 + "TANGLED_ARCHITECTURE": spec.Architecture, 145 137 } 146 138 147 139 workflow := &models.Workflow{ 148 - Steps: modelSteps, 149 - Name: twf.Name, 150 - Data: workflowData, 140 + Steps: modelSteps, 141 + Name: twf.Name, 142 + Data: workflowData, 143 + Environment: workflowEnv, 151 144 } 152 145 153 146 return workflow, nil ··· 163 156 return fmt.Errorf("invalid workflow data type") 164 157 } 165 158 159 + // Copy framework-injected pipeline environment variables (TANGLED_*) to the spec 160 + // These are injected by the upstream framework after InitWorkflow returns 161 + if wf.Environment != nil { 162 + if data.Spec.Environment == nil { 163 + data.Spec.Environment = make(map[string]string) 164 + } 165 + maps.Copy(data.Spec.Environment, wf.Environment) 166 + } 167 + 168 + // Build didSlashRepo from pipeline environment variables for secrets lookup 169 + repoDid := wf.Environment["TANGLED_REPO_DID"] 170 + repoName := wf.Environment["TANGLED_REPO_NAME"] 171 + var didSlashRepo string 172 + if repoDid != "" && repoName != "" { 173 + var err error 174 + didSlashRepo, err = securejoin.SecureJoin(repoDid, repoName) 175 + if err != nil { 176 + logger.Error(err, "Failed to construct repo path for secrets") 177 + } 178 + } 179 + 166 180 // Retrieve secrets for this repository from the vault 167 181 var repoSecrets []loomv1alpha1.SecretData 168 - if e.vault != nil { 169 - unlockedSecrets, err := e.vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(data.DidSlashRepo)) 182 + if e.vault != nil && didSlashRepo != "" { 183 + unlockedSecrets, err := e.vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(didSlashRepo)) 170 184 if err != nil { 171 - logger.Error(err, "Failed to retrieve secrets from vault", "repo", data.DidSlashRepo) 185 + logger.Error(err, "Failed to retrieve secrets from vault", "repo", didSlashRepo) 172 186 } else if len(unlockedSecrets) > 0 { 173 - logger.Info("Retrieved secrets from vault", "repo", data.DidSlashRepo, "count", len(unlockedSecrets)) 187 + logger.Info("Retrieved secrets from vault", "repo", didSlashRepo, "count", len(unlockedSecrets)) 174 188 for _, s := range unlockedSecrets { 175 189 repoSecrets = append(repoSecrets, loomv1alpha1.SecretData{ 176 190 Key: s.Key, ··· 193 207 } 194 208 195 209 // Build PipelineRunSpec from pre-computed data 210 + // Knot is extracted from the pipeline ID provided by the framework 196 211 skipClone := len(data.CloneStep.Commands()) == 0 197 212 pipelineRun := &loomv1alpha1.PipelineRunSpec{ 198 213 PipelineID: wid.PipelineId.Rkey, 199 - Knot: data.Knot, 200 214 SkipClone: skipClone, 201 215 Secrets: repoSecrets, 202 216 Workflows: []loomv1alpha1.WorkflowSpec{data.Spec}, ··· 305 319 err := e.client.List(ctx, jobList, 306 320 client.InNamespace(e.namespace), 307 321 client.MatchingLabels{ 308 - "loom.j5t.io/spindleset": spindleSet.Name, 309 - "loom.j5t.io/workflow": w.Name, 322 + "loom.j5t.io/spindleset": spindleSet.Name, 323 + "loom.j5t.io/workflow": w.Name, 324 + "loom.j5t.io/pipeline-id": wid.PipelineId.Rkey, 310 325 }) 311 326 if err != nil { 312 327 return fmt.Errorf("failed to list jobs: %w", err)
+9 -29
internal/jobbuilder/job_template.go
··· 54 54 // If empty, no secrets are injected 55 55 SecretName string 56 56 57 - // Knot is the tangled.org knot URL 58 - Knot string 59 - 60 57 // Template is the SpindleSet template to apply 61 58 Template loomv1alpha1.SpindleTemplate 62 59 ··· 151 148 Name: jobName, 152 149 Namespace: config.Namespace, 153 150 Labels: labels, 154 - Annotations: map[string]string{ 155 - "loom.j5t.io/architecture": config.Architecture, 156 - "loom.j5t.io/knot": config.Knot, 157 - }, 158 151 }, 159 152 Spec: batchv1.JobSpec{ 160 153 BackoffLimit: &backoffLimit, ··· 306 299 } 307 300 308 301 // buildEnvironmentVariables creates the environment variables for the runner container. 302 + // All environment variables come from WorkflowSpec.Environment, which includes: 303 + // - Engine-specific vars (PATH, TANGLED_ARCHITECTURE) set in InitWorkflow 304 + // - Pipeline-level vars (TANGLED_REPO_*, TANGLED_REF, CI, etc.) injected by framework 309 305 func buildEnvironmentVariables(config WorkflowConfig) []corev1.EnvVar { 310 - env := []corev1.EnvVar{ 311 - { 312 - Name: "PATH", 313 - Value: "/runner-bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", 314 - }, 315 - { 316 - Name: "TANGLED_WORKFLOW", 317 - Value: config.WorkflowName, 318 - }, 319 - { 320 - Name: "TANGLED_PIPELINE_ID", 321 - Value: config.PipelineID, 322 - }, 323 - { 324 - Name: "TANGLED_ARCHITECTURE", 325 - Value: config.Architecture, 326 - }, 327 - { 328 - Name: "CI", 329 - Value: "true", 330 - }, 306 + var env []corev1.EnvVar 307 + for key, value := range config.WorkflowSpec.Environment { 308 + env = append(env, corev1.EnvVar{ 309 + Name: key, 310 + Value: value, 311 + }) 331 312 } 332 - 333 313 return env 334 314 } 335 315