this repo has no description
0
fork

Configure Feed

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

feat(controller): workflow to update app version

Khue Doan cd657d31 1f099975

+1082
+6
README.md
··· 147 147 make 148 148 ``` 149 149 150 + ## TODOs 151 + 152 + - Fix OCI plain HTTP for local development 153 + - Remove hardcoded git username and email 154 + - Credentials for the worker (SSH priv + pub + knowhosts?) 155 + 150 156 ## Acknowledgments and References 151 157 152 158 - [Oracle Terraform Modules](https://github.com/oracle-terraform-modules)
+79
controller/activities/app.go
··· 11 11 "strings" 12 12 13 13 "go.temporal.io/sdk/activity" 14 + "gopkg.in/yaml.v3" 14 15 ) 15 16 16 17 func PushRenderedApp(ctx context.Context, appsPath, namespace, app, cluster, registry string) (*PushResult, error) { ··· 83 84 } 84 85 return matched, nil 85 86 } 87 + 88 + type Image struct { 89 + Repository string 90 + Tag string 91 + } 92 + 93 + func updateImageTags(node *yaml.Node, newImages []Image) error { 94 + var walk func(n *yaml.Node) 95 + walk = func(n *yaml.Node) { 96 + if n.Kind != yaml.MappingNode { 97 + for _, child := range n.Content { 98 + walk(child) 99 + } 100 + return 101 + } 102 + for i := 0; i < len(n.Content)-1; i += 2 { 103 + key := n.Content[i] 104 + val := n.Content[i+1] 105 + if key.Value == "image" && val.Kind == yaml.MappingNode { 106 + var repoNode, tagNode *yaml.Node 107 + for j := 0; j < len(val.Content)-1; j += 2 { 108 + k := val.Content[j] 109 + v := val.Content[j+1] 110 + switch k.Value { 111 + case "repository": 112 + repoNode = v 113 + case "tag": 114 + tagNode = v 115 + } 116 + } 117 + if repoNode != nil && tagNode != nil { 118 + for _, img := range newImages { 119 + if repoNode.Value == img.Repository { 120 + tagNode.Value = img.Tag 121 + } 122 + } 123 + } 124 + } else { 125 + walk(val) 126 + } 127 + } 128 + } 129 + walk(node) 130 + return nil 131 + } 132 + 133 + func UpdateAppVersion(ctx context.Context, appsDir, namespace, app, cluster string, newImages []Image) error { 134 + path := filepath.Join(appsDir, namespace, app, fmt.Sprintf("%s.yaml", cluster)) 135 + 136 + data, err := os.ReadFile(path) 137 + if err != nil { 138 + return fmt.Errorf("failed to read file: %w", err) 139 + } 140 + 141 + var node yaml.Node 142 + if err := yaml.Unmarshal(data, &node); err != nil { 143 + return fmt.Errorf("failed to unmarshal YAML: %w", err) 144 + } 145 + 146 + if err := updateImageTags(&node, newImages); err != nil { 147 + return fmt.Errorf("failed to update image tags: %w", err) 148 + } 149 + 150 + var buf bytes.Buffer 151 + encoder := yaml.NewEncoder(&buf) 152 + encoder.SetIndent(2) 153 + 154 + if err := encoder.Encode(&node); err != nil { 155 + return fmt.Errorf("failed to encode YAML: %w", err) 156 + } 157 + encoder.Close() 158 + 159 + if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil { 160 + return fmt.Errorf("failed to write YAML file: %w", err) 161 + } 162 + 163 + return nil 164 + }
+472
controller/activities/app_test.go
··· 1 + package activities 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os" 7 + "path/filepath" 8 + "testing" 9 + 10 + "github.com/stretchr/testify/assert" 11 + "github.com/stretchr/testify/require" 12 + "gopkg.in/yaml.v3" 13 + ) 14 + 15 + func TestUpdateAppVersion(t *testing.T) { 16 + tests := []struct { 17 + name string 18 + yamlContent string 19 + newImages []Image 20 + expectedUpdate bool 21 + expectError bool 22 + }{ 23 + { 24 + name: "blog app production update", 25 + yamlContent: `defaultPodOptions: 26 + labels: 27 + "istio.io/dataplane-mode": "ambient" 28 + controllers: 29 + main: 30 + replicas: 2 31 + strategy: RollingUpdate 32 + containers: 33 + main: 34 + image: 35 + repository: docker.io/khuedoan/blog 36 + tag: 6fbd90b77a81e0bcb330fddaa230feff744a7010 37 + service: 38 + main: 39 + controller: main 40 + ports: 41 + http: 42 + port: 3000 43 + protocol: HTTP`, 44 + newImages: []Image{ 45 + {Repository: "docker.io/khuedoan/blog", Tag: "abc123def456789"}, 46 + }, 47 + expectedUpdate: true, 48 + }, 49 + { 50 + name: "actualbudget app version update", 51 + yamlContent: `defaultPodOptions: 52 + labels: 53 + "istio.io/dataplane-mode": "ambient" 54 + controllers: 55 + main: 56 + containers: 57 + main: 58 + image: 59 + repository: docker.io/actualbudget/actual-server 60 + tag: 25.6.1-alpine 61 + service: 62 + main: 63 + controller: main 64 + ports: 65 + http: 66 + port: 5006 67 + protocol: HTTP`, 68 + newImages: []Image{ 69 + {Repository: "docker.io/actualbudget/actual-server", Tag: "25.7.0-alpine"}, 70 + }, 71 + expectedUpdate: true, 72 + }, 73 + { 74 + name: "notes app with ghcr registry", 75 + yamlContent: `defaultPodOptions: 76 + labels: 77 + istio.io/dataplane-mode: ambient 78 + controllers: 79 + main: 80 + type: statefulset 81 + containers: 82 + main: 83 + image: 84 + repository: ghcr.io/silverbulletmd/silverbullet 85 + tag: v2 86 + envFrom: 87 + - secret: silverbullet 88 + service: 89 + main: 90 + controller: main 91 + ports: 92 + http: 93 + port: 3000 94 + protocol: HTTP`, 95 + newImages: []Image{ 96 + {Repository: "ghcr.io/silverbulletmd/silverbullet", Tag: "v3"}, 97 + }, 98 + expectedUpdate: true, 99 + }, 100 + { 101 + name: "example service with local registry", 102 + yamlContent: `defaultPodOptions: 103 + labels: 104 + istio.io/dataplane-mode: ambient 105 + controllers: 106 + main: 107 + replicas: 2 108 + strategy: RollingUpdate 109 + containers: 110 + main: 111 + image: 112 + repository: zot.zot.svc.cluster.local/example-service 113 + tag: 828c31f942e8913ab2af53a2841c180586c5b7e1 114 + service: 115 + main: 116 + controller: main 117 + ports: 118 + http: 119 + port: 8080 120 + protocol: HTTP`, 121 + newImages: []Image{ 122 + {Repository: "zot.zot.svc.cluster.local/example-service", Tag: "abc123def456789012345678901234567890abcd"}, 123 + }, 124 + expectedUpdate: true, 125 + }, 126 + { 127 + name: "no matching repository", 128 + yamlContent: `defaultPodOptions: 129 + labels: 130 + istio.io/dataplane-mode: ambient 131 + controllers: 132 + main: 133 + containers: 134 + main: 135 + image: 136 + repository: docker.io/khuedoan/blog 137 + tag: 6fbd90b77a81e0bcb330fddaa230feff744a7010`, 138 + newImages: []Image{ 139 + {Repository: "docker.io/different/app", Tag: "newversion"}, 140 + }, 141 + expectedUpdate: false, 142 + }, 143 + { 144 + name: "multiple images same yaml - partial update", 145 + yamlContent: `defaultPodOptions: 146 + labels: 147 + istio.io/dataplane-mode: ambient 148 + controllers: 149 + frontend: 150 + containers: 151 + main: 152 + image: 153 + repository: docker.io/khuedoan/blog 154 + tag: 6fbd90b77a81e0bcb330fddaa230feff744a7010 155 + backend: 156 + containers: 157 + main: 158 + image: 159 + repository: ghcr.io/silverbulletmd/silverbullet 160 + tag: v2`, 161 + newImages: []Image{ 162 + {Repository: "docker.io/khuedoan/blog", Tag: "newcommithash123"}, 163 + }, 164 + expectedUpdate: true, 165 + }, 166 + { 167 + name: "malformed yaml structure", 168 + yamlContent: `controllers: 169 + main: 170 + containers: 171 + main: 172 + image: 173 + repository: docker.io/test/app 174 + # missing tag field 175 + service: 176 + main: invalid yaml structure`, 177 + newImages: []Image{ 178 + {Repository: "docker.io/test/app", Tag: "v2.0.0"}, 179 + }, 180 + expectError: false, // Should handle gracefully 181 + }, 182 + } 183 + 184 + for _, tt := range tests { 185 + t.Run(tt.name, func(t *testing.T) { 186 + // Create temporary directory structure 187 + tempDir, err := os.MkdirTemp("", "test-update-app-") 188 + require.NoError(t, err) 189 + defer os.RemoveAll(tempDir) 190 + 191 + namespace := "test-ns" 192 + app := "test-app" 193 + cluster := "test-cluster" 194 + 195 + // Create directory structure 196 + appDir := filepath.Join(tempDir, namespace, app) 197 + err = os.MkdirAll(appDir, 0755) 198 + require.NoError(t, err) 199 + 200 + // Write test YAML file 201 + yamlPath := filepath.Join(appDir, fmt.Sprintf("%s.yaml", cluster)) 202 + err = os.WriteFile(yamlPath, []byte(tt.yamlContent), 0644) 203 + require.NoError(t, err) 204 + 205 + // Execute UpdateAppVersion 206 + ctx := context.Background() 207 + err = UpdateAppVersion(ctx, tempDir, namespace, app, cluster, tt.newImages) 208 + 209 + if tt.expectError { 210 + assert.Error(t, err) 211 + return 212 + } 213 + 214 + require.NoError(t, err) 215 + 216 + // Read the updated file 217 + updatedContent, err := os.ReadFile(yamlPath) 218 + require.NoError(t, err) 219 + 220 + // Parse the updated YAML 221 + var updatedData map[string]interface{} 222 + err = yaml.Unmarshal(updatedContent, &updatedData) 223 + require.NoError(t, err) 224 + 225 + // Verify updates were applied correctly 226 + if tt.expectedUpdate { 227 + verifyImageUpdates(t, updatedData, tt.newImages) 228 + } 229 + }) 230 + } 231 + } 232 + 233 + func TestUpdateAppVersion_FileErrors(t *testing.T) { 234 + ctx := context.Background() 235 + tempDir, err := os.MkdirTemp("", "test-update-app-errors-") 236 + require.NoError(t, err) 237 + defer os.RemoveAll(tempDir) 238 + 239 + t.Run("non-existent file", func(t *testing.T) { 240 + err := UpdateAppVersion(ctx, tempDir, "ns", "app", "cluster", []Image{}) 241 + assert.Error(t, err) 242 + assert.Contains(t, err.Error(), "failed to read file") 243 + }) 244 + 245 + t.Run("invalid yaml", func(t *testing.T) { 246 + namespace := "test-ns" 247 + app := "test-app" 248 + cluster := "test-cluster" 249 + 250 + appDir := filepath.Join(tempDir, namespace, app) 251 + err = os.MkdirAll(appDir, 0755) 252 + require.NoError(t, err) 253 + 254 + yamlPath := filepath.Join(appDir, fmt.Sprintf("%s.yaml", cluster)) 255 + err = os.WriteFile(yamlPath, []byte("invalid: yaml: content: ["), 0644) 256 + require.NoError(t, err) 257 + 258 + err = UpdateAppVersion(ctx, tempDir, namespace, app, cluster, []Image{}) 259 + assert.Error(t, err) 260 + assert.Contains(t, err.Error(), "failed to unmarshal YAML") 261 + }) 262 + } 263 + 264 + func TestUpdateImageTags(t *testing.T) { 265 + tests := []struct { 266 + name string 267 + yamlContent string 268 + newImages []Image 269 + expectedTags map[string]string // repository -> expected tag 270 + }{ 271 + { 272 + name: "blog app git hash update", 273 + yamlContent: `controllers: 274 + main: 275 + containers: 276 + main: 277 + image: 278 + repository: docker.io/khuedoan/blog 279 + tag: 6fbd90b77a81e0bcb330fddaa230feff744a7010`, 280 + newImages: []Image{ 281 + {Repository: "docker.io/khuedoan/blog", Tag: "abc123def456789"}, 282 + }, 283 + expectedTags: map[string]string{ 284 + "docker.io/khuedoan/blog": "abc123def456789", 285 + }, 286 + }, 287 + { 288 + name: "actualbudget version update", 289 + yamlContent: `controllers: 290 + main: 291 + containers: 292 + main: 293 + image: 294 + repository: docker.io/actualbudget/actual-server 295 + tag: 25.6.1-alpine`, 296 + newImages: []Image{ 297 + {Repository: "docker.io/actualbudget/actual-server", Tag: "25.7.0-alpine"}, 298 + }, 299 + expectedTags: map[string]string{ 300 + "docker.io/actualbudget/actual-server": "25.7.0-alpine", 301 + }, 302 + }, 303 + { 304 + name: "mixed registries partial update", 305 + yamlContent: `controllers: 306 + main: 307 + containers: 308 + main: 309 + image: 310 + repository: ghcr.io/silverbulletmd/silverbullet 311 + tag: v2 312 + worker: 313 + containers: 314 + worker: 315 + image: 316 + repository: docker.io/actualbudget/actual-server 317 + tag: 25.6.1-alpine`, 318 + newImages: []Image{ 319 + {Repository: "ghcr.io/silverbulletmd/silverbullet", Tag: "v3"}, 320 + }, 321 + expectedTags: map[string]string{ 322 + "ghcr.io/silverbulletmd/silverbullet": "v3", 323 + "docker.io/actualbudget/actual-server": "25.6.1-alpine", // unchanged 324 + }, 325 + }, 326 + { 327 + name: "local registry with full real structure", 328 + yamlContent: `defaultPodOptions: 329 + labels: 330 + istio.io/dataplane-mode: ambient 331 + controllers: 332 + main: 333 + replicas: 2 334 + strategy: RollingUpdate 335 + containers: 336 + main: 337 + image: 338 + repository: zot.zot.svc.cluster.local/example-service 339 + tag: 828c31f942e8913ab2af53a2841c180586c5b7e1 340 + service: 341 + main: 342 + controller: main 343 + ports: 344 + http: 345 + port: 8080 346 + protocol: HTTP`, 347 + newImages: []Image{ 348 + {Repository: "zot.zot.svc.cluster.local/example-service", Tag: "newgithash12345678901234567890"}, 349 + }, 350 + expectedTags: map[string]string{ 351 + "zot.zot.svc.cluster.local/example-service": "newgithash12345678901234567890", 352 + }, 353 + }, 354 + } 355 + 356 + for _, tt := range tests { 357 + t.Run(tt.name, func(t *testing.T) { 358 + var node yaml.Node 359 + err := yaml.Unmarshal([]byte(tt.yamlContent), &node) 360 + require.NoError(t, err) 361 + 362 + err = updateImageTags(&node, tt.newImages) 363 + require.NoError(t, err) 364 + 365 + // Marshall back to verify changes 366 + updatedYAML, err := yaml.Marshal(&node) 367 + require.NoError(t, err) 368 + 369 + var updatedData map[string]interface{} 370 + err = yaml.Unmarshal(updatedYAML, &updatedData) 371 + require.NoError(t, err) 372 + 373 + // Verify the expected tag updates 374 + for expectedRepo, expectedTag := range tt.expectedTags { 375 + found := false 376 + findImageTag(updatedData, expectedRepo, expectedTag, &found) 377 + assert.True(t, found, "Expected to find repository %s with tag %s", expectedRepo, expectedTag) 378 + } 379 + }) 380 + } 381 + } 382 + 383 + // Helper function to verify image updates in parsed YAML data 384 + func verifyImageUpdates(t *testing.T, data map[string]interface{}, expectedImages []Image) { 385 + for _, img := range expectedImages { 386 + found := false 387 + findImageTag(data, img.Repository, img.Tag, &found) 388 + assert.True(t, found, "Expected to find repository %s with tag %s", img.Repository, img.Tag) 389 + } 390 + } 391 + 392 + // Recursive helper to find image tags in nested YAML structure 393 + func findImageTag(data interface{}, targetRepo, expectedTag string, found *bool) { 394 + switch v := data.(type) { 395 + case map[string]interface{}: 396 + if imageMap, ok := v["image"].(map[string]interface{}); ok { 397 + if repo, repoOk := imageMap["repository"].(string); repoOk && repo == targetRepo { 398 + if tag, tagOk := imageMap["tag"].(string); tagOk && tag == expectedTag { 399 + *found = true 400 + return 401 + } 402 + } 403 + } 404 + for _, value := range v { 405 + findImageTag(value, targetRepo, expectedTag, found) 406 + } 407 + case []interface{}: 408 + for _, item := range v { 409 + findImageTag(item, targetRepo, expectedTag, found) 410 + } 411 + } 412 + } 413 + 414 + func TestUpdateAppVersion_YAMLIndentation(t *testing.T) { 415 + // Test that YAML is written with 2-space indentation 416 + tempDir, err := os.MkdirTemp("", "test-yaml-indent-") 417 + require.NoError(t, err) 418 + defer os.RemoveAll(tempDir) 419 + 420 + namespace := "test" 421 + app := "indent-test" 422 + cluster := "local" 423 + 424 + // Create directory structure 425 + appDir := filepath.Join(tempDir, namespace, app) 426 + err = os.MkdirAll(appDir, 0755) 427 + require.NoError(t, err) 428 + 429 + // Create a test YAML file with nested structure 430 + yamlContent := `controllers: 431 + main: 432 + containers: 433 + main: 434 + image: 435 + repository: docker.io/test/app 436 + tag: v1.0.0 437 + service: 438 + main: 439 + controller: main 440 + ports: 441 + http: 442 + port: 8080` 443 + 444 + yamlPath := filepath.Join(appDir, fmt.Sprintf("%s.yaml", cluster)) 445 + err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) 446 + require.NoError(t, err) 447 + 448 + // Update with new image 449 + newImages := []Image{ 450 + {Repository: "docker.io/test/app", Tag: "v2.0.0"}, 451 + } 452 + 453 + ctx := context.Background() 454 + err = UpdateAppVersion(ctx, tempDir, namespace, app, cluster, newImages) 455 + require.NoError(t, err) 456 + 457 + // Read the updated file and check indentation 458 + updatedContent, err := os.ReadFile(yamlPath) 459 + require.NoError(t, err) 460 + 461 + contentStr := string(updatedContent) 462 + 463 + // Check that nested elements use 2-space indentation 464 + assert.Contains(t, contentStr, "controllers:\n main:") 465 + assert.Contains(t, contentStr, " main:\n containers:") 466 + assert.Contains(t, contentStr, " containers:\n main:") 467 + assert.Contains(t, contentStr, " main:\n image:") 468 + assert.Contains(t, contentStr, " image:\n repository:") 469 + 470 + // Verify the tag was actually updated 471 + assert.Contains(t, contentStr, "tag: v2.0.0") 472 + }
+27
controller/activities/git.go
··· 86 86 87 87 return modules, nil 88 88 } 89 + 90 + func GitSync(ctx context.Context, path string) error { 91 + logger := activity.GetLogger(ctx) 92 + 93 + dir := filepath.Dir(path) 94 + relPath := filepath.Base(path) 95 + 96 + cmds := [][]string{ 97 + {"git", "-C", dir, "config", "user.name", "Bot"}, 98 + {"git", "-C", dir, "config", "user.email", "bot@khuedoan.com"}, 99 + {"git", "-C", dir, "add", relPath}, 100 + {"git", "-C", dir, "commit", "-m", "Update app version"}, 101 + {"git", "-C", dir, "push"}, 102 + } 103 + 104 + for _, args := range cmds { 105 + cmd := exec.Command(args[0], args[1:]...) 106 + cmd.Stdout = os.Stdout 107 + cmd.Stderr = os.Stderr 108 + if err := cmd.Run(); err != nil { 109 + logger.Error("command %v failed: %w", args, err) 110 + return err 111 + } 112 + } 113 + 114 + return nil 115 + }
+153
controller/activities/git_test.go
··· 180 180 181 181 return modules 182 182 } 183 + 184 + func TestGitSync_PathParsing(t *testing.T) { 185 + // Test the path parsing logic in GitSync without requiring actual git commands 186 + tests := []struct { 187 + name string 188 + inputPath string 189 + expectedDir string 190 + expectedFile string 191 + }{ 192 + { 193 + name: "simple file", 194 + inputPath: "/tmp/test.yaml", 195 + expectedDir: "/tmp", 196 + expectedFile: "test.yaml", 197 + }, 198 + { 199 + name: "nested file", 200 + inputPath: "/apps/namespace/app/cluster.yaml", 201 + expectedDir: "/apps/namespace/app", 202 + expectedFile: "cluster.yaml", 203 + }, 204 + { 205 + name: "relative path", 206 + inputPath: "relative/file.yaml", 207 + expectedDir: "relative", 208 + expectedFile: "file.yaml", 209 + }, 210 + } 211 + 212 + for _, tt := range tests { 213 + t.Run(tt.name, func(t *testing.T) { 214 + // Test the path manipulation logic that GitSync uses 215 + actualDir := filepath.Dir(tt.inputPath) 216 + actualFile := filepath.Base(tt.inputPath) 217 + 218 + if actualDir != tt.expectedDir { 219 + t.Errorf("Expected directory '%s', got '%s'", tt.expectedDir, actualDir) 220 + } 221 + 222 + if actualFile != tt.expectedFile { 223 + t.Errorf("Expected filename '%s', got '%s'", tt.expectedFile, actualFile) 224 + } 225 + }) 226 + } 227 + } 228 + 229 + func TestGitSync_CommandStructure(t *testing.T) { 230 + // Test that GitSync constructs the expected git commands 231 + // This test validates the command structure without executing them 232 + 233 + testPath := "/tmp/test/app/cluster.yaml" 234 + expectedDir := "/tmp/test/app" 235 + expectedFile := "cluster.yaml" 236 + 237 + // Verify the path parsing logic 238 + actualDir := filepath.Dir(testPath) 239 + actualFile := filepath.Base(testPath) 240 + 241 + if actualDir != expectedDir { 242 + t.Errorf("Expected directory '%s', got '%s'", expectedDir, actualDir) 243 + } 244 + 245 + if actualFile != expectedFile { 246 + t.Errorf("Expected filename '%s', got '%s'", expectedFile, actualFile) 247 + } 248 + 249 + // The GitSync function should construct these commands: 250 + expectedCommands := [][]string{ 251 + {"git", "-C", expectedDir, "add", expectedFile}, 252 + {"git", "-C", expectedDir, "commit", "-m", "Update app version"}, 253 + {"git", "-C", expectedDir, "push"}, 254 + } 255 + 256 + // Verify the command structure is as expected 257 + if len(expectedCommands) != 3 { 258 + t.Errorf("Expected 3 git commands, got %d", len(expectedCommands)) 259 + } 260 + 261 + // Check each command structure 262 + for i, cmd := range expectedCommands { 263 + if len(cmd) < 2 { 264 + t.Errorf("Command %d should have at least 2 parts, got %d", i, len(cmd)) 265 + continue 266 + } 267 + 268 + if cmd[0] != "git" { 269 + t.Errorf("Command %d should start with 'git', got '%s'", i, cmd[0]) 270 + } 271 + 272 + if cmd[1] != "-C" { 273 + t.Errorf("Command %d should have '-C' as second argument, got '%s'", i, cmd[1]) 274 + } 275 + } 276 + } 277 + 278 + func TestGenerateRepoPath(t *testing.T) { 279 + tests := []struct { 280 + name string 281 + url string 282 + revision string 283 + wantPath bool // whether we expect a valid path 284 + }{ 285 + { 286 + name: "simple repo", 287 + url: "https://github.com/user/repo.git", 288 + revision: "main", 289 + wantPath: true, 290 + }, 291 + { 292 + name: "same repo different revision", 293 + url: "https://github.com/user/repo.git", 294 + revision: "develop", 295 + wantPath: true, 296 + }, 297 + { 298 + name: "empty inputs", 299 + url: "", 300 + revision: "", 301 + wantPath: true, // Should still generate a path 302 + }, 303 + } 304 + 305 + for _, tt := range tests { 306 + t.Run(tt.name, func(t *testing.T) { 307 + path := generateRepoPath(tt.url, tt.revision) 308 + 309 + if tt.wantPath { 310 + if path == "" { 311 + t.Error("Expected non-empty path") 312 + } 313 + if !strings.Contains(path, "/tmp/cloudlab-repos/") { 314 + t.Errorf("Expected path to contain '/tmp/cloudlab-repos/', got: %s", path) 315 + } 316 + if len(filepath.Base(path)) != 16 { 317 + t.Errorf("Expected base path to be 16 characters, got: %s", filepath.Base(path)) 318 + } 319 + } 320 + }) 321 + } 322 + 323 + // Test that same inputs generate same path 324 + path1 := generateRepoPath("https://github.com/test/repo.git", "main") 325 + path2 := generateRepoPath("https://github.com/test/repo.git", "main") 326 + if path1 != path2 { 327 + t.Errorf("Same inputs should generate same path: %s != %s", path1, path2) 328 + } 329 + 330 + // Test that different inputs generate different paths 331 + path3 := generateRepoPath("https://github.com/test/repo.git", "develop") 332 + if path1 == path3 { 333 + t.Error("Different revisions should generate different paths") 334 + } 335 + }
+3
controller/worker/main.go
··· 31 31 w.RegisterActivity(activities.PushManifests) 32 32 w.RegisterActivity(activities.PushRenderedApp) 33 33 w.RegisterActivity(activities.DiscoverApps) 34 + w.RegisterActivity(activities.UpdateAppVersion) 35 + w.RegisterActivity(activities.GitSync) 34 36 35 37 w.RegisterWorkflow(workflows.Infra) 36 38 w.RegisterWorkflow(workflows.Platform) 37 39 w.RegisterWorkflow(workflows.Apps) 40 + w.RegisterWorkflow(workflows.AppUpdate) 38 41 39 42 err = w.Run(worker.InterruptCh()) 40 43 if err != nil {
+90
controller/workflows/app_update.go
··· 1 + package workflows 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + "time" 8 + 9 + "cloudlab/controller/activities" 10 + 11 + "go.temporal.io/sdk/workflow" 12 + ) 13 + 14 + type AppUpdateInput struct { 15 + Url string 16 + Revision string 17 + Namespace string 18 + App string 19 + Cluster string 20 + NewImages []activities.Image 21 + } 22 + 23 + // AppUpdate workflow clones a repository, updates app versions, and syncs changes back to git 24 + func AppUpdate(ctx workflow.Context, input AppUpdateInput) error { 25 + logger := workflow.GetLogger(ctx) 26 + logger.Info("AppUpdate workflow started", "input", input) 27 + 28 + var workspace string 29 + if err := workflow.ExecuteActivity( 30 + workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ 31 + StartToCloseTimeout: 2 * time.Minute, 32 + }), 33 + activities.Clone, 34 + input.Url, 35 + input.Revision, 36 + ).Get(ctx, &workspace); err != nil { 37 + logger.Error("Failed to clone repository", "error", err) 38 + return fmt.Errorf("failed to clone repository: %w", err) 39 + } 40 + 41 + logger.Info("Repository cloned successfully", "workspace", workspace) 42 + 43 + defer func() { 44 + if err := os.RemoveAll(workspace); err != nil { 45 + logger.Error("Failed to cleanup workspace", "workspace", workspace, "error", err) 46 + } 47 + }() 48 + 49 + appsDir := filepath.Join(workspace, "apps") 50 + if err := workflow.ExecuteActivity( 51 + workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ 52 + StartToCloseTimeout: 30 * time.Second, 53 + }), 54 + activities.UpdateAppVersion, 55 + appsDir, 56 + input.Namespace, 57 + input.App, 58 + input.Cluster, 59 + input.NewImages, 60 + ).Get(ctx, nil); err != nil { 61 + logger.Error("failed to update app version", "error", err) 62 + return fmt.Errorf("failed to update app version: %w", err) 63 + } 64 + 65 + logger.Info("App version updated successfully", 66 + "namespace", input.Namespace, 67 + "app", input.App, 68 + "cluster", input.Cluster) 69 + 70 + // Step 3: Sync changes back to git 71 + appFilePath := filepath.Join(appsDir, input.Namespace, input.App, fmt.Sprintf("%s.yaml", input.Cluster)) 72 + if err := workflow.ExecuteActivity( 73 + workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ 74 + StartToCloseTimeout: 1 * time.Minute, 75 + }), 76 + activities.GitSync, 77 + appFilePath, 78 + ).Get(ctx, nil); err != nil { 79 + logger.Error("Failed to sync changes to git", "error", err) 80 + return fmt.Errorf("failed to sync changes to git: %w", err) 81 + } 82 + 83 + logger.Info("AppUpdate workflow completed successfully", 84 + "namespace", input.Namespace, 85 + "app", input.App, 86 + "cluster", input.Cluster, 87 + "updated_images", len(input.NewImages)) 88 + 89 + return nil 90 + }
+252
controller/workflows/app_update_test.go
··· 1 + package workflows 2 + 3 + import ( 4 + "errors" 5 + "testing" 6 + "time" 7 + 8 + "cloudlab/controller/activities" 9 + 10 + "github.com/stretchr/testify/mock" 11 + "github.com/stretchr/testify/suite" 12 + "go.temporal.io/sdk/testsuite" 13 + ) 14 + 15 + type AppUpdateWorkflowTestSuite struct { 16 + suite.Suite 17 + testsuite.WorkflowTestSuite 18 + 19 + env *testsuite.TestWorkflowEnvironment 20 + } 21 + 22 + func (s *AppUpdateWorkflowTestSuite) SetupTest() { 23 + s.env = s.NewTestWorkflowEnvironment() 24 + s.env.SetTestTimeout(30 * time.Second) 25 + } 26 + 27 + func (s *AppUpdateWorkflowTestSuite) AfterTest(suiteName, testName string) { 28 + s.env.AssertExpectations(s.T()) 29 + } 30 + 31 + func (s *AppUpdateWorkflowTestSuite) TestAppUpdate_Success() { 32 + input := AppUpdateInput{ 33 + Url: "https://github.com/example/cloudlab.git", 34 + Revision: "main", 35 + Namespace: "khuedoan", 36 + App: "blog", 37 + Cluster: "production", 38 + NewImages: []activities.Image{ 39 + {Repository: "docker.io/khuedoan/blog", Tag: "abc123def456789"}, 40 + }, 41 + } 42 + workspace := "/tmp/cloudlab-repos/abc123" 43 + 44 + s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return(workspace, nil) 45 + s.env.OnActivity(activities.UpdateAppVersion, mock.Anything, 46 + workspace+"/apps", input.Namespace, input.App, input.Cluster, input.NewImages).Return(nil) 47 + s.env.OnActivity(activities.GitSync, mock.Anything, 48 + workspace+"/apps/khuedoan/blog/production.yaml").Return(nil) 49 + 50 + s.env.ExecuteWorkflow(AppUpdate, input) 51 + 52 + s.True(s.env.IsWorkflowCompleted()) 53 + s.NoError(s.env.GetWorkflowError()) 54 + } 55 + 56 + func (s *AppUpdateWorkflowTestSuite) TestAppUpdate_CloneFailure() { 57 + input := AppUpdateInput{ 58 + Url: "https://github.com/example/invalid-repo.git", 59 + Revision: "main", 60 + Namespace: "test", 61 + App: "app", 62 + Cluster: "local", 63 + NewImages: []activities.Image{ 64 + {Repository: "test/app", Tag: "v1.0.0"}, 65 + }, 66 + } 67 + 68 + s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return("", errors.New("repository not found")) 69 + 70 + s.env.ExecuteWorkflow(AppUpdate, input) 71 + 72 + s.True(s.env.IsWorkflowCompleted()) 73 + s.Error(s.env.GetWorkflowError()) 74 + s.Contains(s.env.GetWorkflowError().Error(), "failed to clone repository") 75 + } 76 + 77 + func (s *AppUpdateWorkflowTestSuite) TestAppUpdate_UpdateAppVersionFailure() { 78 + input := AppUpdateInput{ 79 + Url: "https://github.com/example/cloudlab.git", 80 + Revision: "main", 81 + Namespace: "finance", 82 + App: "actualbudget", 83 + Cluster: "local", 84 + NewImages: []activities.Image{ 85 + {Repository: "docker.io/actualbudget/actual-server", Tag: "25.7.0-alpine"}, 86 + }, 87 + } 88 + workspace := "/tmp/cloudlab-repos/def456" 89 + 90 + s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return(workspace, nil) 91 + s.env.OnActivity(activities.UpdateAppVersion, mock.Anything, 92 + workspace+"/apps", input.Namespace, input.App, input.Cluster, input.NewImages).Return( 93 + errors.New("failed to read file: no such file or directory")) 94 + 95 + s.env.ExecuteWorkflow(AppUpdate, input) 96 + 97 + s.True(s.env.IsWorkflowCompleted()) 98 + s.Error(s.env.GetWorkflowError()) 99 + s.Contains(s.env.GetWorkflowError().Error(), "failed to update app version") 100 + } 101 + 102 + func (s *AppUpdateWorkflowTestSuite) TestAppUpdate_GitSyncFailure() { 103 + input := AppUpdateInput{ 104 + Url: "https://github.com/example/cloudlab.git", 105 + Revision: "main", 106 + Namespace: "khuedoan", 107 + App: "notes", 108 + Cluster: "production", 109 + NewImages: []activities.Image{ 110 + {Repository: "ghcr.io/silverbulletmd/silverbullet", Tag: "v3"}, 111 + }, 112 + } 113 + workspace := "/tmp/cloudlab-repos/ghi789" 114 + 115 + s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return(workspace, nil) 116 + s.env.OnActivity(activities.UpdateAppVersion, mock.Anything, 117 + workspace+"/apps", input.Namespace, input.App, input.Cluster, input.NewImages).Return(nil) 118 + s.env.OnActivity(activities.GitSync, mock.Anything, 119 + workspace+"/apps/khuedoan/notes/production.yaml").Return( 120 + errors.New("git push failed: authentication required")) 121 + 122 + s.env.ExecuteWorkflow(AppUpdate, input) 123 + 124 + s.True(s.env.IsWorkflowCompleted()) 125 + s.Error(s.env.GetWorkflowError()) 126 + s.Contains(s.env.GetWorkflowError().Error(), "failed to sync changes to git") 127 + } 128 + 129 + func (s *AppUpdateWorkflowTestSuite) TestAppUpdate_MultipleImages() { 130 + input := AppUpdateInput{ 131 + Url: "https://github.com/example/cloudlab.git", 132 + Revision: "develop", 133 + Namespace: "test", 134 + App: "example", 135 + Cluster: "local", 136 + NewImages: []activities.Image{ 137 + {Repository: "zot.zot.svc.cluster.local/example-service", Tag: "newcommithash123"}, 138 + {Repository: "docker.io/redis", Tag: "7.0-alpine"}, 139 + }, 140 + } 141 + workspace := "/tmp/cloudlab-repos/jkl012" 142 + 143 + s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return(workspace, nil) 144 + s.env.OnActivity(activities.UpdateAppVersion, mock.Anything, 145 + workspace+"/apps", input.Namespace, input.App, input.Cluster, input.NewImages).Return(nil) 146 + s.env.OnActivity(activities.GitSync, mock.Anything, 147 + workspace+"/apps/test/example/local.yaml").Return(nil) 148 + 149 + s.env.ExecuteWorkflow(AppUpdate, input) 150 + 151 + s.True(s.env.IsWorkflowCompleted()) 152 + s.NoError(s.env.GetWorkflowError()) 153 + } 154 + 155 + func (s *AppUpdateWorkflowTestSuite) TestAppUpdate_RealWorldExample() { 156 + // Test with realistic data from the actual apps directory 157 + input := AppUpdateInput{ 158 + Url: "https://github.com/khuedoan/cloudlab.git", 159 + Revision: "main", 160 + Namespace: "khuedoan", 161 + App: "blog", 162 + Cluster: "production", 163 + NewImages: []activities.Image{ 164 + {Repository: "docker.io/khuedoan/blog", Tag: "1234567890abcdef1234567890abcdef12345678"}, 165 + }, 166 + } 167 + workspace := "/tmp/cloudlab-repos/realworld123" 168 + 169 + s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return(workspace, nil) 170 + s.env.OnActivity(activities.UpdateAppVersion, mock.Anything, 171 + workspace+"/apps", input.Namespace, input.App, input.Cluster, input.NewImages).Return(nil) 172 + s.env.OnActivity(activities.GitSync, mock.Anything, 173 + workspace+"/apps/khuedoan/blog/production.yaml").Return(nil) 174 + 175 + s.env.ExecuteWorkflow(AppUpdate, input) 176 + 177 + s.True(s.env.IsWorkflowCompleted()) 178 + s.NoError(s.env.GetWorkflowError()) 179 + } 180 + 181 + func (s *AppUpdateWorkflowTestSuite) TestAppUpdate_ActivityTimeout() { 182 + input := AppUpdateInput{ 183 + Url: "https://github.com/example/cloudlab.git", 184 + Revision: "main", 185 + Namespace: "test", 186 + App: "slow-app", 187 + Cluster: "production", 188 + NewImages: []activities.Image{ 189 + {Repository: "test/slow-app", Tag: "v1.0.0"}, 190 + }, 191 + } 192 + 193 + // Simulate a timeout - we'll just return an error since the test timeout catches this 194 + s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return("", errors.New("timeout")) 195 + 196 + s.env.ExecuteWorkflow(AppUpdate, input) 197 + 198 + s.True(s.env.IsWorkflowCompleted()) 199 + s.Error(s.env.GetWorkflowError()) 200 + } 201 + 202 + func (s *AppUpdateWorkflowTestSuite) TestAppUpdate_EmptyImages() { 203 + input := AppUpdateInput{ 204 + Url: "https://github.com/example/cloudlab.git", 205 + Revision: "main", 206 + Namespace: "test", 207 + App: "app", 208 + Cluster: "local", 209 + NewImages: []activities.Image{}, // Empty images array 210 + } 211 + workspace := "/tmp/cloudlab-repos/empty123" 212 + 213 + s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return(workspace, nil) 214 + s.env.OnActivity(activities.UpdateAppVersion, mock.Anything, 215 + workspace+"/apps", input.Namespace, input.App, input.Cluster, input.NewImages).Return(nil) 216 + s.env.OnActivity(activities.GitSync, mock.Anything, 217 + workspace+"/apps/test/app/local.yaml").Return(nil) 218 + 219 + s.env.ExecuteWorkflow(AppUpdate, input) 220 + 221 + s.True(s.env.IsWorkflowCompleted()) 222 + s.NoError(s.env.GetWorkflowError()) 223 + } 224 + 225 + func (s *AppUpdateWorkflowTestSuite) TestAppUpdate_SpecialCharactersInPath() { 226 + input := AppUpdateInput{ 227 + Url: "https://github.com/example/cloudlab.git", 228 + Revision: "feature/special-branch-name", 229 + Namespace: "test-namespace", 230 + App: "app-with-dashes", 231 + Cluster: "staging-env", 232 + NewImages: []activities.Image{ 233 + {Repository: "registry.example.com/test/app-with-dashes", Tag: "v1.2.3-rc1"}, 234 + }, 235 + } 236 + workspace := "/tmp/cloudlab-repos/special456" 237 + 238 + s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return(workspace, nil) 239 + s.env.OnActivity(activities.UpdateAppVersion, mock.Anything, 240 + workspace+"/apps", input.Namespace, input.App, input.Cluster, input.NewImages).Return(nil) 241 + s.env.OnActivity(activities.GitSync, mock.Anything, 242 + workspace+"/apps/test-namespace/app-with-dashes/staging-env.yaml").Return(nil) 243 + 244 + s.env.ExecuteWorkflow(AppUpdate, input) 245 + 246 + s.True(s.env.IsWorkflowCompleted()) 247 + s.NoError(s.env.GetWorkflowError()) 248 + } 249 + 250 + func TestAppUpdateWorkflowTestSuite(t *testing.T) { 251 + suite.Run(t, new(AppUpdateWorkflowTestSuite)) 252 + }