Kubernetes Operator for Tangled Spindles

fixes for upstream changes

evan.jarrett.net da829f9c 5ce3fb63

verified
+77 -120
+2 -9
api/v1alpha1/spindleset_types.go
··· 49 49 // +kubebuilder:validation:Required 50 50 Knot string `json:"knot"` 51 51 52 - // RepoURL is the Git repository URL to clone. 53 - // +kubebuilder:validation:Required 54 - RepoURL string `json:"repoURL"` 55 - 56 - // CommitSHA is the Git commit to checkout. 57 - // +kubebuilder:validation:Required 58 - CommitSHA string `json:"commitSHA"` 59 - 60 52 // CloneCommands are the git commands to run in the clone init container. 61 - // Generated by tangled.org/core/spindle/steps.BuildCloneCommands(). 53 + // Generated by tangled.org/core/spindle/models.BuildCloneStep(). 54 + // These commands are self-contained (include repo URL and commit SHA). 62 55 // +optional 63 56 CloneCommands []string `json:"cloneCommands,omitempty"` 64 57
+2 -9
config/crd/bases/loom.j5t.io_spindlesets.yaml
··· 72 72 cloneCommands: 73 73 description: |- 74 74 CloneCommands are the git commands to run in the clone init container. 75 - Generated by tangled.org/core/spindle/steps.BuildCloneCommands(). 75 + Generated by tangled.org/core/spindle/models.BuildCloneStep(). 76 + These commands are self-contained (include repo URL and commit SHA). 76 77 items: 77 78 type: string 78 79 type: array 79 - commitSHA: 80 - description: CommitSHA is the Git commit to checkout. 81 - type: string 82 80 knot: 83 81 description: Knot is the domain of the knot that triggered this 84 82 pipeline. ··· 86 84 pipelineID: 87 85 description: PipelineID is the unique identifier for this pipeline 88 86 run from the knot. 89 - type: string 90 - repoURL: 91 - description: RepoURL is the Git repository URL to clone. 92 87 type: string 93 88 secrets: 94 89 description: |- ··· 210 205 minItems: 1 211 206 type: array 212 207 required: 213 - - commitSHA 214 208 - knot 215 209 - pipelineID 216 - - repoURL 217 210 - workflows 218 211 type: object 219 212 template:
+2 -2
go.mod
··· 13 13 k8s.io/apimachinery v0.33.0 14 14 k8s.io/client-go v0.33.0 15 15 sigs.k8s.io/controller-runtime v0.21.0 16 - tangled.org/core v1.10.0-alpha 16 + tangled.org/core v1.11.0-alpha 17 17 ) 18 18 19 19 require ( ··· 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.9.1-alpha.0.20251112151818-8694a269186e 200 + replace tangled.org/core => tangled.org/evan.jarrett.net/core v1.11.0-alpha.0.20251122155825-3cdf71e7987f
+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.9.1-alpha.0.20251112151818-8694a269186e h1:xohKBTZPYkoqDSJQtuGl9QYFOsC9zuTRA8UlUBDmB+Y= 691 - tangled.org/evan.jarrett.net/core v1.9.1-alpha.0.20251112151818-8694a269186e/go.mod h1:CUO6beA36K/Cwt0u2yrO5CG+L7+LzAc6zi6WudwO7qs= 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=
-2
internal/controller/spindleset_controller.go
··· 388 388 Architecture: workflowSpec.Architecture, 389 389 Steps: jobSteps, 390 390 WorkflowSpec: workflowSpec, // Pass full workflow spec to runner 391 - RepoURL: pipelineRun.RepoURL, 392 - CommitSHA: pipelineRun.CommitSHA, 393 391 CloneCommands: pipelineRun.CloneCommands, 394 392 SkipClone: pipelineRun.SkipClone, 395 393 SecretName: secretName, // Name of K8s Secret to inject (empty if no secrets)
+6 -2
internal/controller/spindleset_controller_test.go
··· 56 56 PipelineRun: &loomv1alpha1.PipelineRunSpec{ 57 57 PipelineID: "test-pipeline-123", 58 58 Knot: "knot.example.com", 59 - RepoURL: "https://knot.example.com/did:plc:test/repo", 60 - CommitSHA: "abc123", 59 + CloneCommands: []string{ 60 + "git init", 61 + "git remote add origin https://knot.example.com/did:plc:test/repo", 62 + "git fetch --depth=1 origin abc123", 63 + "git checkout FETCH_HEAD", 64 + }, 61 65 Workflows: []loomv1alpha1.WorkflowSpec{ 62 66 { 63 67 Name: "test-workflow",
+62 -77
internal/engine/kubernetes_engine.go
··· 23 23 "tangled.org/core/api/tangled" 24 24 "tangled.org/core/spindle/models" 25 25 "tangled.org/core/spindle/secrets" 26 - "tangled.org/core/spindle/steps" 27 26 28 27 loomv1alpha1 "tangled.org/evan.jarrett.net/loom/api/v1alpha1" 29 28 ) ··· 46 45 // Track created SpindleSets for cleanup 47 46 spindleSets map[string]*loomv1alpha1.SpindleSet 48 47 49 - // Store current knot for Job annotations 50 - currentKnot string 51 - 52 48 // Active log streams per workflow - persist across RunStep calls 53 49 logStreams map[string]*workflowLogStream 54 50 streamMutex sync.RWMutex ··· 67 63 } 68 64 } 69 65 66 + // kubernetesWorkflowData holds pre-computed data for workflow execution. 67 + // Built in InitWorkflow, consumed in SetupWorkflow. 68 + 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 73 + } 74 + 70 75 // SimpleStep implements the models.Step interface. 71 76 type SimpleStep struct { 72 77 StepName string ··· 108 113 } 109 114 110 115 // Convert steps to models.Step interface 111 - steps := make([]models.Step, 0, len(spec.Steps)) 116 + modelSteps := make([]models.Step, 0, len(spec.Steps)) 112 117 for _, stepSpec := range spec.Steps { 113 - steps = append(steps, SimpleStep{ 118 + modelSteps = append(modelSteps, SimpleStep{ 114 119 StepName: stepSpec.Name, 115 120 StepCommand: stepSpec.Command, 116 121 StepKind: models.StepKindUser, 117 122 }) 118 123 } 119 124 120 - // Store the parsed spec in Data field for later use 121 - workflowData := map[string]interface{}{ 122 - "spec": spec, 123 - "triggerMetadata": tpl.TriggerMetadata, 124 - "cloneOpts": twf.Clone, 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) 131 + } 132 + 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, 125 145 } 126 146 127 147 workflow := &models.Workflow{ 128 - Steps: steps, 148 + Steps: modelSteps, 129 149 Name: twf.Name, 130 150 Data: workflowData, 131 151 } ··· 137 157 func (e *KubernetesEngine) SetupWorkflow(ctx context.Context, wid models.WorkflowId, wf *models.Workflow) error { 138 158 logger := log.FromContext(ctx).WithValues("workflow", wid.Name, "pipeline", wid.PipelineId.Rkey) 139 159 140 - // Extract workflow data 141 - workflowData, ok := wf.Data.(map[string]interface{}) 160 + // Extract pre-computed workflow data 161 + data, ok := wf.Data.(*kubernetesWorkflowData) 142 162 if !ok { 143 163 return fmt.Errorf("invalid workflow data type") 144 164 } 145 165 146 - spec, ok := workflowData["spec"].(loomv1alpha1.WorkflowSpec) 147 - if !ok { 148 - return fmt.Errorf("workflow spec not found in data") 149 - } 150 - 151 - triggerMetadata, ok := workflowData["triggerMetadata"].(*tangled.Pipeline_TriggerMetadata) 152 - if !ok { 153 - return fmt.Errorf("trigger metadata not found in data") 154 - } 155 - 156 - cloneOpts, ok := workflowData["cloneOpts"].(*tangled.Pipeline_CloneOpts) 157 - if !ok { 158 - return fmt.Errorf("clone options not found in data") 159 - } 160 - 161 - // Use shared clone command builder to extract commit SHA and build repo URL 162 - cloneConfig := steps.CloneConfig{ 163 - Workflow: tangled.Pipeline_Workflow{ 164 - Clone: cloneOpts, 165 - }, 166 - TriggerMetadata: *triggerMetadata, 167 - DevMode: false, // TODO: Make this configurable 168 - WorkspaceDir: "/tangled/workspace", 169 - } 170 - 171 - cloneCommands, err := steps.BuildCloneCommands(cloneConfig) 172 - if err != nil { 173 - return fmt.Errorf("failed to build clone commands: %w", err) 174 - } 175 - 176 - // Store knot for status reporting 177 - e.currentKnot = triggerMetadata.Repo.Knot 178 - 179 166 // Retrieve secrets for this repository from the vault 180 167 var repoSecrets []loomv1alpha1.SecretData 181 168 if e.vault != nil { 182 - // Build didSlashRepo path: "did:plc:xxx/repo-name" 183 - didSlashRepo, err := securejoin.SecureJoin(triggerMetadata.Repo.Did, triggerMetadata.Repo.Repo) 169 + unlockedSecrets, err := e.vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(data.DidSlashRepo)) 184 170 if err != nil { 185 - logger.Error(err, "Failed to construct repo path for secrets", "did", triggerMetadata.Repo.Did, "repo", triggerMetadata.Repo.Repo) 186 - } else { 187 - // Get unlocked secrets from vault 188 - unlockedSecrets, err := e.vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(didSlashRepo)) 189 - if err != nil { 190 - logger.Error(err, "Failed to retrieve secrets from vault", "repo", didSlashRepo) 191 - } else if len(unlockedSecrets) > 0 { 192 - logger.Info("Retrieved secrets from vault", "repo", didSlashRepo, "count", len(unlockedSecrets)) 193 - // Convert to SecretData for SpindleSet spec 194 - for _, s := range unlockedSecrets { 195 - repoSecrets = append(repoSecrets, loomv1alpha1.SecretData{ 196 - Key: s.Key, 197 - Value: s.Value, 198 - }) 199 - } 171 + logger.Error(err, "Failed to retrieve secrets from vault", "repo", data.DidSlashRepo) 172 + } else if len(unlockedSecrets) > 0 { 173 + logger.Info("Retrieved secrets from vault", "repo", data.DidSlashRepo, "count", len(unlockedSecrets)) 174 + for _, s := range unlockedSecrets { 175 + repoSecrets = append(repoSecrets, loomv1alpha1.SecretData{ 176 + Key: s.Key, 177 + Value: s.Value, 178 + }) 200 179 } 201 180 } 202 181 } 203 182 204 183 // Generate SpindleSet name: spindle-{workflowName}-{pipelineID} 205 184 // Sanitize workflow name (remove .yaml/.yml, replace dots) 206 - sanitizedWorkflowName := strings.TrimSuffix(spec.Name, ".yaml") 185 + sanitizedWorkflowName := strings.TrimSuffix(data.Spec.Name, ".yaml") 207 186 sanitizedWorkflowName = strings.TrimSuffix(sanitizedWorkflowName, ".yml") 208 187 sanitizedWorkflowName = strings.ReplaceAll(sanitizedWorkflowName, ".", "-") 209 188 ··· 213 192 spindleSetName = spindleSetName[:63] 214 193 } 215 194 195 + // Build PipelineRunSpec from pre-computed data 196 + skipClone := len(data.CloneStep.Commands()) == 0 197 + pipelineRun := &loomv1alpha1.PipelineRunSpec{ 198 + PipelineID: wid.PipelineId.Rkey, 199 + Knot: data.Knot, 200 + SkipClone: skipClone, 201 + Secrets: repoSecrets, 202 + Workflows: []loomv1alpha1.WorkflowSpec{data.Spec}, 203 + } 204 + 205 + // Add clone commands if not skipping 206 + if !skipClone { 207 + pipelineRun.CloneCommands = data.CloneStep.Commands() 208 + } 209 + 216 210 // Create SpindleSet for this workflow 217 211 spindleSet := &loomv1alpha1.SpindleSet{ 218 212 ObjectMeta: metav1.ObjectMeta{ ··· 225 219 }, 226 220 }, 227 221 Spec: loomv1alpha1.SpindleSetSpec{ 228 - Template: e.template, 229 - PipelineRun: &loomv1alpha1.PipelineRunSpec{ 230 - PipelineID: wid.PipelineId.Rkey, 231 - Knot: e.currentKnot, 232 - RepoURL: cloneCommands.RepoURL, 233 - CommitSHA: cloneCommands.CommitSHA, 234 - CloneCommands: cloneCommands.All, 235 - SkipClone: cloneCommands.Skip, 236 - Secrets: repoSecrets, // Repository secrets for injection 237 - Workflows: []loomv1alpha1.WorkflowSpec{spec}, // Single workflow per SpindleSet 238 - }, 222 + Template: e.template, 223 + PipelineRun: pipelineRun, 239 224 }, 240 225 } 241 226 242 227 // Create the SpindleSet in Kubernetes 243 - logger.Info("Creating SpindleSet for workflow", "spindleSetName", spindleSetName, "workflow", spec.Name) 228 + logger.Info("Creating SpindleSet for workflow", "spindleSetName", spindleSetName, "workflow", data.Spec.Name) 244 229 if err := e.client.Create(ctx, spindleSet); err != nil { 245 230 return fmt.Errorf("failed to create SpindleSet: %w", err) 246 231 }
+1 -17
internal/jobbuilder/job_template.go
··· 43 43 // Steps are the workflow steps to execute (deprecated - use WorkflowSpec) 44 44 Steps []WorkflowStep 45 45 46 - // RepoURL is the git repository URL to clone 47 - RepoURL string 48 - 49 - // CommitSHA is the git commit to checkout 50 - CommitSHA string 51 - 52 46 // CloneCommands are the git commands to run in the init container 53 - // Generated by steps.BuildCloneCommands() 47 + // Generated by models.BuildCloneStep() - self-contained with repo URL and commit SHA 54 48 CloneCommands []string 55 49 56 50 // SkipClone indicates whether to skip the clone init container entirely ··· 158 152 Namespace: config.Namespace, 159 153 Labels: labels, 160 154 Annotations: map[string]string{ 161 - "loom.j5t.io/repo-url": config.RepoURL, 162 - "loom.j5t.io/commit-sha": config.CommitSHA, 163 155 "loom.j5t.io/architecture": config.Architecture, 164 156 "loom.j5t.io/knot": config.Knot, 165 157 }, ··· 327 319 { 328 320 Name: "TANGLED_PIPELINE_ID", 329 321 Value: config.PipelineID, 330 - }, 331 - { 332 - Name: "TANGLED_REPO_URL", 333 - Value: config.RepoURL, 334 - }, 335 - { 336 - Name: "TANGLED_COMMIT_SHA", 337 - Value: config.CommitSHA, 338 322 }, 339 323 { 340 324 Name: "TANGLED_ARCHITECTURE",