this repo has no description
0
fork

Configure Feed

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

chore: clean up unused controller and app-engine

Migrating them to Netamos.

Khue Doan 01855a9e dd0707de

-3854
-3
Makefile
··· 25 25 @temporal workflow result --workflow-id apps-manual 26 26 27 27 test: 28 - cd controller && go test ./... 29 28 cd test && go test 30 29 31 30 fmt: ··· 36 35 . 37 36 terragrunt hcl format 38 37 cd infra/_modules && tofu fmt -recursive 39 - cd controller && go fmt ./... 40 38 cd infra/_modules/tfstate && go fmt ./... 41 - cd infra/staging && go fmt ./... 42 39 cd test && go fmt ./... 43 40 44 41 tidy: fmt
-26
controller/Dockerfile
··· 1 - FROM docker.io/golang:1.24.3-alpine AS builder 2 - 3 - WORKDIR /src 4 - 5 - COPY go.mod go.sum ./ 6 - 7 - RUN go mod download 8 - 9 - COPY . . 10 - 11 - RUN go build -o /bin/worker ./worker 12 - 13 - FROM docker.io/nixos/nix 14 - 15 - RUN echo "experimental-features = flakes nix-command" >> /etc/nix/nix.conf 16 - 17 - # TODO use native nix develop, currently it's a bit slow 18 - RUN nix-env --install --quiet --attr \ 19 - nixpkgs.kubernetes-helm \ 20 - nixpkgs.opentofu \ 21 - nixpkgs.oras \ 22 - nixpkgs.terragrunt 23 - 24 - COPY --from=builder /bin/worker /bin/worker 25 - 26 - CMD ["/bin/worker"]
-264
controller/activities/activity_test.go
··· 1 - package activities 2 - 3 - import ( 4 - "context" 5 - "strings" 6 - "testing" 7 - "time" 8 - 9 - "github.com/stretchr/testify/assert" 10 - "github.com/stretchr/testify/suite" 11 - "go.temporal.io/sdk/testsuite" 12 - ) 13 - 14 - type ActivityTestSuite struct { 15 - suite.Suite 16 - testsuite.WorkflowTestSuite 17 - 18 - env *testsuite.TestActivityEnvironment 19 - } 20 - 21 - func (s *ActivityTestSuite) SetupTest() { 22 - s.env = s.NewTestActivityEnvironment() 23 - s.env.SetTestTimeout(30 * time.Second) 24 - } 25 - 26 - // Test graph pruning logic with direct calls 27 - func (s *ActivityTestSuite) TestPruneGraph_Success() { 28 - ctx := context.Background() 29 - originalGraph := &Graph{ 30 - Nodes: map[string]bool{ 31 - "vpc": true, 32 - "database": true, 33 - "app": true, 34 - }, 35 - Edges: map[string][]string{ 36 - "database": {"vpc"}, 37 - "app": {"database"}, 38 - }, 39 - } 40 - changedFiles := []string{"database"} 41 - 42 - prunedGraph, err := PruneGraph(ctx, originalGraph, changedFiles) 43 - 44 - s.NoError(err) 45 - s.True(prunedGraph.Nodes["database"]) // changed module should be included 46 - s.True(prunedGraph.Nodes["app"]) // dependent should be included 47 - s.False(prunedGraph.Nodes["vpc"]) // non-dependent should be pruned 48 - } 49 - 50 - func (s *ActivityTestSuite) TestPruneGraph_EmptyChanges() { 51 - ctx := context.Background() 52 - originalGraph := &Graph{ 53 - Nodes: map[string]bool{ 54 - "vpc": true, 55 - "database": true, 56 - }, 57 - Edges: map[string][]string{ 58 - "database": {"vpc"}, 59 - }, 60 - } 61 - changedFiles := []string{} // No changes 62 - 63 - prunedGraph, err := PruneGraph(ctx, originalGraph, changedFiles) 64 - 65 - s.NoError(err) 66 - s.Empty(prunedGraph.Nodes) // no changes means empty graph 67 - } 68 - 69 - func (s *ActivityTestSuite) TestPruneGraph_ComplexDependencies() { 70 - ctx := context.Background() 71 - // Complex graph: monitoring -> app -> [database, cache] -> vpc 72 - originalGraph := &Graph{ 73 - Nodes: map[string]bool{ 74 - "vpc": true, 75 - "database": true, 76 - "cache": true, 77 - "app": true, 78 - "monitoring": true, 79 - }, 80 - Edges: map[string][]string{ 81 - "database": {"vpc"}, 82 - "cache": {"vpc"}, 83 - "app": {"database", "cache"}, 84 - "monitoring": {"app"}, 85 - }, 86 - } 87 - changedFiles := []string{"database"} // Only database changed 88 - 89 - prunedGraph, err := PruneGraph(ctx, originalGraph, changedFiles) 90 - 91 - s.NoError(err) 92 - s.True(prunedGraph.Nodes["database"]) // changed module 93 - s.True(prunedGraph.Nodes["app"]) // direct dependent 94 - s.True(prunedGraph.Nodes["monitoring"]) // transitive dependent 95 - s.False(prunedGraph.Nodes["vpc"]) // not a dependent 96 - s.False(prunedGraph.Nodes["cache"]) // not a dependent 97 - } 98 - 99 - // Test using TestActivityEnvironment for activities that need proper context 100 - func (s *ActivityTestSuite) TestTerragruntPrune_WithActivityEnvironment() { 101 - // Test data 102 - graph := &Graph{ 103 - Nodes: map[string]bool{ 104 - "vpc": true, 105 - "database": true, 106 - "app": true, 107 - "monitoring": true, 108 - }, 109 - Edges: map[string][]string{ 110 - "app": {"database", "vpc"}, 111 - "database": {"vpc"}, 112 - "monitoring": {"app"}, 113 - }, 114 - } 115 - 116 - changedModules := []string{"database"} 117 - 118 - s.env.RegisterActivity(PruneGraph) 119 - 120 - val, err := s.env.ExecuteActivity(PruneGraph, graph, changedModules) 121 - s.NoError(err) 122 - 123 - var result *Graph 124 - s.NoError(val.Get(&result)) 125 - 126 - // Only database (changed) and its dependents (app, monitoring) should be included 127 - // vpc is not included because nothing depends on it 128 - expectedNodes := []string{"database", "app", "monitoring"} 129 - actualNodes := result.GetNodes() 130 - s.ElementsMatch(expectedNodes, actualNodes) 131 - 132 - s.Contains(result.Nodes, "database") 133 - s.Contains(result.Nodes, "app") 134 - s.Contains(result.Nodes, "monitoring") 135 - s.NotContains(result.Nodes, "vpc") 136 - } 137 - 138 - func TestActivityTestSuite(t *testing.T) { 139 - suite.Run(t, new(ActivityTestSuite)) 140 - } 141 - 142 - // Additional comprehensive unit tests for graph functions 143 - func TestNewGraphFromDot_EmptyGraph(t *testing.T) { 144 - dotString := `digraph { 145 - }` 146 - 147 - graph, err := NewGraphFromDot(dotString) 148 - 149 - assert.NoError(t, err) 150 - assert.Empty(t, graph.Nodes) 151 - } 152 - 153 - func TestNewGraphFromDot_InvalidFormat(t *testing.T) { 154 - dotString := `not a valid dot format` 155 - 156 - graph, err := NewGraphFromDot(dotString) 157 - 158 - assert.NoError(t, err) // Should not error, just ignore invalid lines 159 - assert.Empty(t, graph.Nodes) 160 - } 161 - 162 - func TestGraph_TopologicalSort_CyclicGraph(t *testing.T) { 163 - // Create a graph with a cycle: A -> B -> C -> A 164 - graph := &Graph{ 165 - Nodes: map[string]bool{ 166 - "a": true, 167 - "b": true, 168 - "c": true, 169 - }, 170 - Edges: map[string][]string{ 171 - "a": {"b"}, 172 - "b": {"c"}, 173 - "c": {"a"}, // Creates cycle 174 - }, 175 - } 176 - 177 - levels := graph.TopologicalSort() 178 - 179 - // Should handle cycles gracefully by putting remaining nodes in final level 180 - assert.Greater(t, len(levels), 0) 181 - 182 - // All nodes should be present somewhere in the levels 183 - allNodes := make(map[string]bool) 184 - for _, level := range levels { 185 - for _, node := range level { 186 - allNodes[node] = true 187 - } 188 - } 189 - assert.True(t, allNodes["a"]) 190 - assert.True(t, allNodes["b"]) 191 - assert.True(t, allNodes["c"]) 192 - } 193 - 194 - func TestExtractQuoted(t *testing.T) { 195 - // Test the extractQuoted function indirectly through NewGraphFromDot 196 - dotString := `digraph { 197 - "hello" -> "world"; 198 - "test"; 199 - }` 200 - 201 - graph, err := NewGraphFromDot(dotString) 202 - 203 - assert.NoError(t, err) 204 - assert.True(t, graph.Nodes["hello"]) 205 - assert.True(t, graph.Nodes["world"]) 206 - assert.True(t, graph.Nodes["test"]) 207 - } 208 - 209 - func TestGraph_AddEdge_CreatesNodes(t *testing.T) { 210 - graph := NewGraph() 211 - 212 - graph.AddEdge("a", "b") 213 - 214 - assert.True(t, graph.Nodes["a"]) 215 - assert.True(t, graph.Nodes["b"]) 216 - assert.Contains(t, graph.Edges["a"], "b") 217 - } 218 - 219 - func TestGraph_GetNodes(t *testing.T) { 220 - graph := &Graph{ 221 - Nodes: map[string]bool{ 222 - "vpc": true, 223 - "database": true, 224 - "app": true, 225 - }, 226 - Edges: map[string][]string{}, 227 - } 228 - 229 - nodes := graph.GetNodes() 230 - 231 - assert.Len(t, nodes, 3) 232 - assert.Contains(t, nodes, "vpc") 233 - assert.Contains(t, nodes, "database") 234 - assert.Contains(t, nodes, "app") 235 - } 236 - 237 - func TestClone_PathGeneration(t *testing.T) { 238 - // Test that generateRepoPath creates deterministic paths 239 - url1 := "https://github.com/example/repo.git" 240 - revision1 := "main" 241 - 242 - path1 := generateRepoPath(url1, revision1) 243 - path2 := generateRepoPath(url1, revision1) 244 - 245 - // Same inputs should generate same path 246 - assert.Equal(t, path1, path2) 247 - 248 - // Different inputs should generate different paths 249 - path3 := generateRepoPath(url1, "develop") 250 - assert.NotEqual(t, path1, path3) 251 - 252 - path4 := generateRepoPath("https://github.com/other/repo.git", revision1) 253 - assert.NotEqual(t, path1, path4) 254 - 255 - // Paths should be under /tmp/cloudlab-repos/ 256 - assert.True(t, strings.HasPrefix(path1, "/tmp/cloudlab-repos/")) 257 - } 258 - 259 - func TestClone_CheckRepoStatus(t *testing.T) { 260 - // Test hasCorrectRevision with non-existent directory 261 - nonExistentPath := "/tmp/non-existent-repo-12345" 262 - hasCorrect := hasCorrectRevision(context.Background(), nonExistentPath, "main") 263 - assert.False(t, hasCorrect) 264 - }
-170
controller/activities/app.go
··· 1 - package activities 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "fmt" 7 - "os" 8 - "os/exec" 9 - "path" 10 - "path/filepath" 11 - "strings" 12 - 13 - "go.temporal.io/sdk/activity" 14 - "gopkg.in/yaml.v3" 15 - ) 16 - 17 - func PushRenderedApp(ctx context.Context, appsPath, namespace, app, cluster, registry string) (*PushResult, error) { 18 - logger := activity.GetLogger(ctx) 19 - 20 - tmpDir, err := os.MkdirTemp("", fmt.Sprintf("%s-%s-", app, cluster)) 21 - if err != nil { 22 - logger.Error("failed to create temp dir", "error", err) 23 - return nil, err 24 - } 25 - defer os.RemoveAll(tmpDir) 26 - 27 - cmd := exec.CommandContext( 28 - ctx, 29 - "helm", "template", 30 - "--namespace", namespace, 31 - app, 32 - "oci://ghcr.io/bjw-s-labs/helm/app-template:4.1.1", 33 - "--values", path.Join(namespace, app, cluster+".yaml"), 34 - ) 35 - cmd.Dir = appsPath 36 - 37 - var stdout, stderr bytes.Buffer 38 - cmd.Stdout = &stdout 39 - cmd.Stderr = &stderr 40 - 41 - logger.Info("running helm template", "cmd", cmd.String()) 42 - 43 - if err := cmd.Run(); err != nil { 44 - logger.Error("helm template failed", "error", err, "stderr", stderr.String()) 45 - return nil, err 46 - } 47 - 48 - if err := os.WriteFile(filepath.Join(tmpDir, "rendered.yaml"), stdout.Bytes(), 0644); err != nil { 49 - logger.Error("failed to write rendered output to file", "error", err) 50 - return nil, err 51 - } 52 - 53 - outputPath, err := filepath.Abs(tmpDir) 54 - if err != nil { 55 - logger.Error("failed to get absolute path to rendered manifests", "error", err) 56 - return nil, err 57 - } 58 - 59 - imageRef := fmt.Sprintf("%s/%s/%s:%s", registry, namespace, app, cluster) 60 - result, err := PushManifests(ctx, outputPath, imageRef) 61 - if err != nil { 62 - logger.Error("failed to push manifests", "error", err) 63 - return nil, err 64 - } 65 - 66 - return result, nil 67 - } 68 - 69 - func DiscoverApps(ctx context.Context, appsDir string, cluster string) ([]string, error) { 70 - // TODO logs 71 - _ = activity.GetLogger(ctx) 72 - var matched []string 73 - err := filepath.Walk(appsDir, func(path string, info os.FileInfo, err error) error { 74 - if err != nil || info.IsDir() { 75 - return nil 76 - } 77 - if strings.HasSuffix(info.Name(), cluster+".yaml") { 78 - matched = append(matched, path) 79 - } 80 - return nil 81 - }) 82 - if err != nil { 83 - return nil, err 84 - } 85 - return matched, nil 86 - } 87 - 88 - type Image struct { 89 - Repository string 90 - Tag string 91 - } 92 - 93 - func updateImageTags(node *yaml.Node, newImages []Image) (bool, error) { 94 - changed := false 95 - var walk func(n *yaml.Node) 96 - walk = func(n *yaml.Node) { 97 - if n.Kind != yaml.MappingNode { 98 - for _, child := range n.Content { 99 - walk(child) 100 - } 101 - return 102 - } 103 - for i := 0; i < len(n.Content)-1; i += 2 { 104 - key := n.Content[i] 105 - val := n.Content[i+1] 106 - if key.Value == "image" && val.Kind == yaml.MappingNode { 107 - var repoNode, tagNode *yaml.Node 108 - for j := 0; j < len(val.Content)-1; j += 2 { 109 - k := val.Content[j] 110 - v := val.Content[j+1] 111 - switch k.Value { 112 - case "repository": 113 - repoNode = v 114 - case "tag": 115 - tagNode = v 116 - } 117 - } 118 - if repoNode != nil && tagNode != nil { 119 - for _, img := range newImages { 120 - if repoNode.Value == img.Repository && tagNode.Value != img.Tag { 121 - tagNode.Value = img.Tag 122 - changed = true 123 - } 124 - } 125 - } 126 - } else { 127 - walk(val) 128 - } 129 - } 130 - } 131 - walk(node) 132 - return changed, nil 133 - } 134 - 135 - func UpdateAppVersion(ctx context.Context, appsDir, namespace, app, cluster string, newImages []Image) (bool, error) { 136 - path := filepath.Join(appsDir, namespace, app, fmt.Sprintf("%s.yaml", cluster)) 137 - 138 - data, err := os.ReadFile(path) 139 - if err != nil { 140 - return false, fmt.Errorf("failed to read file: %w", err) 141 - } 142 - 143 - var node yaml.Node 144 - if err := yaml.Unmarshal(data, &node); err != nil { 145 - return false, fmt.Errorf("failed to unmarshal YAML: %w", err) 146 - } 147 - 148 - changed, err := updateImageTags(&node, newImages) 149 - if err != nil { 150 - return false, fmt.Errorf("failed to update image tags: %w", err) 151 - } 152 - 153 - if changed { 154 - var buf bytes.Buffer 155 - encoder := yaml.NewEncoder(&buf) 156 - encoder.SetIndent(2) 157 - 158 - if err := encoder.Encode(&node); err != nil { 159 - return false, fmt.Errorf("failed to encode YAML: %w", err) 160 - } 161 - encoder.Close() 162 - 163 - newData := buf.Bytes() 164 - if err := os.WriteFile(path, newData, 0644); err != nil { 165 - return false, fmt.Errorf("failed to write YAML file: %w", err) 166 - } 167 - } 168 - 169 - return changed, nil 170 - }
-473
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: registry.registry.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: "registry.registry.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 - changed, 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 - assert.Equal(t, tt.expectedUpdate, changed, "Expected change result doesn't match") 216 - 217 - // Read the updated file 218 - updatedContent, err := os.ReadFile(yamlPath) 219 - require.NoError(t, err) 220 - 221 - // Parse the updated YAML 222 - var updatedData map[string]interface{} 223 - err = yaml.Unmarshal(updatedContent, &updatedData) 224 - require.NoError(t, err) 225 - 226 - // Verify updates were applied correctly 227 - if tt.expectedUpdate { 228 - verifyImageUpdates(t, updatedData, tt.newImages) 229 - } 230 - }) 231 - } 232 - } 233 - 234 - func TestUpdateAppVersion_FileErrors(t *testing.T) { 235 - ctx := context.Background() 236 - tempDir, err := os.MkdirTemp("", "test-update-app-errors-") 237 - require.NoError(t, err) 238 - defer os.RemoveAll(tempDir) 239 - 240 - t.Run("non-existent file", func(t *testing.T) { 241 - _, err := UpdateAppVersion(ctx, tempDir, "ns", "app", "cluster", []Image{}) 242 - assert.Error(t, err) 243 - assert.Contains(t, err.Error(), "failed to read file") 244 - }) 245 - 246 - t.Run("invalid yaml", func(t *testing.T) { 247 - namespace := "test-ns" 248 - app := "test-app" 249 - cluster := "test-cluster" 250 - 251 - appDir := filepath.Join(tempDir, namespace, app) 252 - err = os.MkdirAll(appDir, 0755) 253 - require.NoError(t, err) 254 - 255 - yamlPath := filepath.Join(appDir, fmt.Sprintf("%s.yaml", cluster)) 256 - err = os.WriteFile(yamlPath, []byte("invalid: yaml: content: ["), 0644) 257 - require.NoError(t, err) 258 - 259 - _, err = UpdateAppVersion(ctx, tempDir, namespace, app, cluster, []Image{}) 260 - assert.Error(t, err) 261 - assert.Contains(t, err.Error(), "failed to unmarshal YAML") 262 - }) 263 - } 264 - 265 - func TestUpdateImageTags(t *testing.T) { 266 - tests := []struct { 267 - name string 268 - yamlContent string 269 - newImages []Image 270 - expectedTags map[string]string // repository -> expected tag 271 - }{ 272 - { 273 - name: "blog app git hash update", 274 - yamlContent: `controllers: 275 - main: 276 - containers: 277 - main: 278 - image: 279 - repository: docker.io/khuedoan/blog 280 - tag: 6fbd90b77a81e0bcb330fddaa230feff744a7010`, 281 - newImages: []Image{ 282 - {Repository: "docker.io/khuedoan/blog", Tag: "abc123def456789"}, 283 - }, 284 - expectedTags: map[string]string{ 285 - "docker.io/khuedoan/blog": "abc123def456789", 286 - }, 287 - }, 288 - { 289 - name: "actualbudget version update", 290 - yamlContent: `controllers: 291 - main: 292 - containers: 293 - main: 294 - image: 295 - repository: docker.io/actualbudget/actual-server 296 - tag: 25.6.1-alpine`, 297 - newImages: []Image{ 298 - {Repository: "docker.io/actualbudget/actual-server", Tag: "25.7.0-alpine"}, 299 - }, 300 - expectedTags: map[string]string{ 301 - "docker.io/actualbudget/actual-server": "25.7.0-alpine", 302 - }, 303 - }, 304 - { 305 - name: "mixed registries partial update", 306 - yamlContent: `controllers: 307 - main: 308 - containers: 309 - main: 310 - image: 311 - repository: ghcr.io/silverbulletmd/silverbullet 312 - tag: v2 313 - worker: 314 - containers: 315 - worker: 316 - image: 317 - repository: docker.io/actualbudget/actual-server 318 - tag: 25.6.1-alpine`, 319 - newImages: []Image{ 320 - {Repository: "ghcr.io/silverbulletmd/silverbullet", Tag: "v3"}, 321 - }, 322 - expectedTags: map[string]string{ 323 - "ghcr.io/silverbulletmd/silverbullet": "v3", 324 - "docker.io/actualbudget/actual-server": "25.6.1-alpine", // unchanged 325 - }, 326 - }, 327 - { 328 - name: "local registry with full real structure", 329 - yamlContent: `defaultPodOptions: 330 - labels: 331 - istio.io/dataplane-mode: ambient 332 - controllers: 333 - main: 334 - replicas: 2 335 - strategy: RollingUpdate 336 - containers: 337 - main: 338 - image: 339 - repository: registry.registry.svc.cluster.local/example-service 340 - tag: 828c31f942e8913ab2af53a2841c180586c5b7e1 341 - service: 342 - main: 343 - controller: main 344 - ports: 345 - http: 346 - port: 8080 347 - protocol: HTTP`, 348 - newImages: []Image{ 349 - {Repository: "registry.registry.svc.cluster.local/example-service", Tag: "newgithash12345678901234567890"}, 350 - }, 351 - expectedTags: map[string]string{ 352 - "registry.registry.svc.cluster.local/example-service": "newgithash12345678901234567890", 353 - }, 354 - }, 355 - } 356 - 357 - for _, tt := range tests { 358 - t.Run(tt.name, func(t *testing.T) { 359 - var node yaml.Node 360 - err := yaml.Unmarshal([]byte(tt.yamlContent), &node) 361 - require.NoError(t, err) 362 - 363 - _, err = updateImageTags(&node, tt.newImages) 364 - require.NoError(t, err) 365 - 366 - // Marshall back to verify changes 367 - updatedYAML, err := yaml.Marshal(&node) 368 - require.NoError(t, err) 369 - 370 - var updatedData map[string]interface{} 371 - err = yaml.Unmarshal(updatedYAML, &updatedData) 372 - require.NoError(t, err) 373 - 374 - // Verify the expected tag updates 375 - for expectedRepo, expectedTag := range tt.expectedTags { 376 - found := false 377 - findImageTag(updatedData, expectedRepo, expectedTag, &found) 378 - assert.True(t, found, "Expected to find repository %s with tag %s", expectedRepo, expectedTag) 379 - } 380 - }) 381 - } 382 - } 383 - 384 - // Helper function to verify image updates in parsed YAML data 385 - func verifyImageUpdates(t *testing.T, data map[string]interface{}, expectedImages []Image) { 386 - for _, img := range expectedImages { 387 - found := false 388 - findImageTag(data, img.Repository, img.Tag, &found) 389 - assert.True(t, found, "Expected to find repository %s with tag %s", img.Repository, img.Tag) 390 - } 391 - } 392 - 393 - // Recursive helper to find image tags in nested YAML structure 394 - func findImageTag(data interface{}, targetRepo, expectedTag string, found *bool) { 395 - switch v := data.(type) { 396 - case map[string]interface{}: 397 - if imageMap, ok := v["image"].(map[string]interface{}); ok { 398 - if repo, repoOk := imageMap["repository"].(string); repoOk && repo == targetRepo { 399 - if tag, tagOk := imageMap["tag"].(string); tagOk && tag == expectedTag { 400 - *found = true 401 - return 402 - } 403 - } 404 - } 405 - for _, value := range v { 406 - findImageTag(value, targetRepo, expectedTag, found) 407 - } 408 - case []interface{}: 409 - for _, item := range v { 410 - findImageTag(item, targetRepo, expectedTag, found) 411 - } 412 - } 413 - } 414 - 415 - func TestUpdateAppVersion_YAMLIndentation(t *testing.T) { 416 - // Test that YAML is written with 2-space indentation 417 - tempDir, err := os.MkdirTemp("", "test-yaml-indent-") 418 - require.NoError(t, err) 419 - defer os.RemoveAll(tempDir) 420 - 421 - namespace := "test" 422 - app := "indent-test" 423 - cluster := "local" 424 - 425 - // Create directory structure 426 - appDir := filepath.Join(tempDir, namespace, app) 427 - err = os.MkdirAll(appDir, 0755) 428 - require.NoError(t, err) 429 - 430 - // Create a test YAML file with nested structure 431 - yamlContent := `controllers: 432 - main: 433 - containers: 434 - main: 435 - image: 436 - repository: docker.io/test/app 437 - tag: v1.0.0 438 - service: 439 - main: 440 - controller: main 441 - ports: 442 - http: 443 - port: 8080` 444 - 445 - yamlPath := filepath.Join(appDir, fmt.Sprintf("%s.yaml", cluster)) 446 - err = os.WriteFile(yamlPath, []byte(yamlContent), 0644) 447 - require.NoError(t, err) 448 - 449 - // Update with new image 450 - newImages := []Image{ 451 - {Repository: "docker.io/test/app", Tag: "v2.0.0"}, 452 - } 453 - 454 - ctx := context.Background() 455 - _, err = UpdateAppVersion(ctx, tempDir, namespace, app, cluster, newImages) 456 - require.NoError(t, err) 457 - 458 - // Read the updated file and check indentation 459 - updatedContent, err := os.ReadFile(yamlPath) 460 - require.NoError(t, err) 461 - 462 - contentStr := string(updatedContent) 463 - 464 - // Check that nested elements use 2-space indentation 465 - assert.Contains(t, contentStr, "controllers:\n main:") 466 - assert.Contains(t, contentStr, " main:\n containers:") 467 - assert.Contains(t, contentStr, " containers:\n main:") 468 - assert.Contains(t, contentStr, " main:\n image:") 469 - assert.Contains(t, contentStr, " image:\n repository:") 470 - 471 - // Verify the tag was actually updated 472 - assert.Contains(t, contentStr, "tag: v2.0.0") 473 - }
-143
controller/activities/git.go
··· 1 - package activities 2 - 3 - import ( 4 - "context" 5 - "crypto/sha256" 6 - "fmt" 7 - "os" 8 - "os/exec" 9 - "path/filepath" 10 - "strings" 11 - 12 - "go.temporal.io/sdk/activity" 13 - ) 14 - 15 - func generateRepoPath(url string, revision string) string { 16 - hash := sha256.Sum256([]byte(url + ":" + revision)) 17 - return filepath.Join("/tmp", "cloudlab-repos", fmt.Sprintf("%x", hash)[:16]) 18 - } 19 - 20 - func hasCorrectRevision(ctx context.Context, path, revision string) bool { 21 - if _, err := os.Stat(filepath.Join(path, ".git")); os.IsNotExist(err) { 22 - return false 23 - } 24 - 25 - cmd := exec.CommandContext(ctx, "git", "rev-parse", revision) 26 - cmd.Dir = path 27 - return cmd.Run() == nil 28 - } 29 - 30 - func Clone(ctx context.Context, url string, revision string) (string, error) { 31 - logger := activity.GetLogger(ctx) 32 - path := generateRepoPath(url, revision) 33 - 34 - if hasCorrectRevision(ctx, path, revision) { 35 - logger.Info("Repository already available", "path", path) 36 - return path, nil 37 - } 38 - 39 - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { 40 - return "", fmt.Errorf("failed to create parent directory: %w", err) 41 - } 42 - os.RemoveAll(path) 43 - 44 - logger.Info("Cloning repository", "url", url, "revision", revision) 45 - 46 - cmd := exec.CommandContext(ctx, "git", "clone", "--depth", "1", "--branch", revision, url, path) 47 - if err := cmd.Run(); err != nil { 48 - os.RemoveAll(path) 49 - return "", fmt.Errorf("failed to clone repository: %w", err) 50 - } 51 - 52 - return path, nil 53 - } 54 - 55 - func ChangedModules(ctx context.Context, repoPath string, oldRevision string) ([]string, error) { 56 - logger := activity.GetLogger(ctx) 57 - 58 - // Since we now clone with depth 1, we need to fetch the oldRevision before we can diff against it 59 - logger.Info("Fetching old revision for comparison", "oldRevision", oldRevision) 60 - fetchCmd := exec.CommandContext(ctx, "git", "fetch", "origin", oldRevision) 61 - fetchCmd.Dir = repoPath 62 - if err := fetchCmd.Run(); err != nil { 63 - return nil, fmt.Errorf("failed to fetch old revision %s: %w", oldRevision, err) 64 - } 65 - 66 - cmd := exec.CommandContext(ctx, "git", "diff", "--name-only", oldRevision, "HEAD") 67 - cmd.Dir = repoPath 68 - output, err := cmd.Output() 69 - if err != nil { 70 - return nil, fmt.Errorf("failed to run git diff: %w", err) 71 - } 72 - 73 - seen := make(map[string]struct{}) 74 - var modules []string 75 - 76 - for _, file := range strings.Fields(string(output)) { 77 - if file == "" { 78 - continue 79 - } 80 - 81 - for dir := filepath.Dir(file); dir != "." && dir != "/"; dir = filepath.Dir(dir) { 82 - if _, err := os.Stat(filepath.Join(repoPath, dir, "terragrunt.hcl")); err == nil { 83 - // Remove infra/stack prefix to get module path 84 - if parts := strings.Split(filepath.ToSlash(dir), "/"); len(parts) >= 3 && parts[0] == "infra" { 85 - if module := strings.Join(parts[2:], "/"); module != "" { 86 - if _, exists := seen[module]; !exists { 87 - modules = append(modules, module) 88 - seen[module] = struct{}{} 89 - } 90 - } 91 - } 92 - break 93 - } 94 - } 95 - } 96 - 97 - return modules, nil 98 - } 99 - 100 - func GitAdd(ctx context.Context, path string) error { 101 - logger := activity.GetLogger(ctx) 102 - 103 - dir := filepath.Dir(path) 104 - relPath := filepath.Base(path) 105 - 106 - cmd := exec.Command("git", "-C", dir, "add", relPath) 107 - cmd.Stdout = os.Stdout 108 - cmd.Stderr = os.Stderr 109 - if err := cmd.Run(); err != nil { 110 - logger.Error("git add failed", "error", err) 111 - return fmt.Errorf("git add failed: %w", err) 112 - } 113 - 114 - return nil 115 - } 116 - 117 - func GitCommit(ctx context.Context, dir string, message string) error { 118 - logger := activity.GetLogger(ctx) 119 - 120 - cmd := exec.Command("git", "-C", dir, "commit", "-m", message) 121 - cmd.Stdout = os.Stdout 122 - cmd.Stderr = os.Stderr 123 - if err := cmd.Run(); err != nil { 124 - logger.Error("git commit failed", "error", err) 125 - return fmt.Errorf("git commit failed: %w", err) 126 - } 127 - 128 - return nil 129 - } 130 - 131 - func GitPush(ctx context.Context, dir string) error { 132 - logger := activity.GetLogger(ctx) 133 - 134 - cmd := exec.Command("git", "-C", dir, "push") 135 - cmd.Stdout = os.Stdout 136 - cmd.Stderr = os.Stderr 137 - if err := cmd.Run(); err != nil { 138 - logger.Error("git push failed", "error", err) 139 - return fmt.Errorf("git push failed: %w", err) 140 - } 141 - 142 - return nil 143 - }
-422
controller/activities/git_test.go
··· 1 - package activities 2 - 3 - import ( 4 - "os" 5 - "path/filepath" 6 - "reflect" 7 - "sort" 8 - "strings" 9 - "testing" 10 - ) 11 - 12 - func TestChangedModules(t *testing.T) { 13 - // Create a temporary directory structure for testing 14 - tempDir, err := os.MkdirTemp("", "test-git-") 15 - if err != nil { 16 - t.Fatalf("Failed to create temp dir: %v", err) 17 - } 18 - defer os.RemoveAll(tempDir) 19 - 20 - // Create test directory structure 21 - testDirs := []string{ 22 - "infra/dev/core", 23 - "infra/dev/networking", 24 - "infra/dev/databases/postgres", 25 - "infra/prod/core", 26 - "infra/prod/monitoring", 27 - "shared/modules/vpc", 28 - "shared/modules/security", 29 - "docs", 30 - } 31 - 32 - for _, dir := range testDirs { 33 - err := os.MkdirAll(filepath.Join(tempDir, dir), 0755) 34 - if err != nil { 35 - t.Fatalf("Failed to create dir %s: %v", dir, err) 36 - } 37 - } 38 - 39 - // Create terragrunt.hcl files in specific directories 40 - terragruntDirs := []string{ 41 - "infra/dev/core", 42 - "infra/dev/networking", 43 - "infra/dev/databases/postgres", 44 - "infra/prod/core", 45 - "infra/prod/monitoring", 46 - "shared/modules/vpc", 47 - } 48 - 49 - for _, dir := range terragruntDirs { 50 - terragruntPath := filepath.Join(tempDir, dir, "terragrunt.hcl") 51 - err := os.WriteFile(terragruntPath, []byte("# terragrunt config"), 0644) 52 - if err != nil { 53 - t.Fatalf("Failed to create terragrunt.hcl in %s: %v", dir, err) 54 - } 55 - } 56 - 57 - // Test cases with mock changed files 58 - testCases := []struct { 59 - name string 60 - changedFiles []string 61 - expected []string 62 - }{ 63 - { 64 - name: "Single module change", 65 - changedFiles: []string{ 66 - "infra/dev/core/main.tf", 67 - "infra/dev/core/variables.tf", 68 - }, 69 - expected: []string{"core"}, 70 - }, 71 - { 72 - name: "Multiple modules in same environment", 73 - changedFiles: []string{ 74 - "infra/dev/core/main.tf", 75 - "infra/dev/networking/vpc.tf", 76 - "infra/dev/databases/postgres/db.tf", 77 - }, 78 - expected: []string{"core", "networking", "databases/postgres"}, 79 - }, 80 - { 81 - name: "Modules across different environments", 82 - changedFiles: []string{ 83 - "infra/dev/core/main.tf", 84 - "infra/prod/core/main.tf", 85 - "infra/prod/monitoring/alerts.tf", 86 - }, 87 - expected: []string{"core", "monitoring"}, 88 - }, 89 - { 90 - name: "Shared modules without infra prefix", 91 - changedFiles: []string{ 92 - "shared/modules/vpc/main.tf", 93 - "shared/modules/vpc/outputs.tf", 94 - }, 95 - expected: []string{"shared/modules/vpc"}, 96 - }, 97 - { 98 - name: "Files without terragrunt.hcl", 99 - changedFiles: []string{ 100 - "docs/README.md", 101 - "shared/modules/security/policy.tf", // no terragrunt.hcl in security dir 102 - }, 103 - expected: []string{}, 104 - }, 105 - { 106 - name: "Mixed files with and without terragrunt.hcl", 107 - changedFiles: []string{ 108 - "infra/dev/core/main.tf", 109 - "docs/README.md", 110 - "shared/modules/vpc/vpc.tf", 111 - }, 112 - expected: []string{"core", "shared/modules/vpc"}, 113 - }, 114 - } 115 - 116 - for _, tc := range testCases { 117 - t.Run(tc.name, func(t *testing.T) { 118 - // Create a mock ChangedModules function that uses our test data 119 - // We'll create a custom function that simulates the file system checks 120 - modules := getChangedModulesFromFiles(tempDir, tc.changedFiles) 121 - 122 - sort.Strings(modules) 123 - sort.Strings(tc.expected) 124 - 125 - if !reflect.DeepEqual(modules, tc.expected) { 126 - t.Errorf("Expected modules %v, but got %v", tc.expected, modules) 127 - } 128 - }) 129 - } 130 - } 131 - 132 - // Helper function to simulate ChangedModules logic without Git 133 - func getChangedModulesFromFiles(repoPath string, changedFiles []string) []string { 134 - seen := make(map[string]struct{}) 135 - modules := make([]string, 0) // Initialize as empty slice instead of nil 136 - 137 - for _, file := range changedFiles { 138 - // Get the directory of the changed file 139 - dir := filepath.Dir(file) 140 - 141 - // Walk up the directory tree to find the closest directory containing terragrunt.hcl 142 - currentDir := dir 143 - for { 144 - terragruntPath := filepath.Join(repoPath, currentDir, "terragrunt.hcl") 145 - if _, err := os.Stat(terragruntPath); err == nil { 146 - // Found terragrunt.hcl, this is a module directory 147 - modulePath := currentDir 148 - 149 - // Remove infra/<env>/ prefix if present 150 - if len(modulePath) > 0 && filepath.HasPrefix(modulePath, "infra/") { 151 - parts := strings.Split(filepath.ToSlash(modulePath), "/") 152 - if len(parts) >= 3 && parts[0] == "infra" { 153 - // Remove "infra" and environment (e.g., "dev", "prod") 154 - modulePath = strings.Join(parts[2:], "/") 155 - } 156 - } 157 - 158 - // Skip empty paths 159 - if modulePath != "" && modulePath != "." { 160 - // Normalize path separators to forward slashes 161 - modulePath = filepath.ToSlash(modulePath) 162 - 163 - if _, exists := seen[modulePath]; !exists { 164 - modules = append(modules, modulePath) 165 - seen[modulePath] = struct{}{} 166 - } 167 - } 168 - break 169 - } 170 - 171 - // Move up one directory level 172 - parent := filepath.Dir(currentDir) 173 - if parent == currentDir || parent == "." { 174 - // Reached the root, no terragrunt.hcl found 175 - break 176 - } 177 - currentDir = parent 178 - } 179 - } 180 - 181 - return modules 182 - } 183 - 184 - func TestGitAdd_PathParsing(t *testing.T) { 185 - // Test the path parsing logic in GitAdd 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 GitAdd 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 TestGitCommit_PathParsing(t *testing.T) { 230 - // Test the path parsing logic in GitCommit 231 - tests := []struct { 232 - name string 233 - inputPath string 234 - expectedDir string 235 - message string 236 - }{ 237 - { 238 - name: "simple file with default message", 239 - inputPath: "/tmp/test.yaml", 240 - expectedDir: "/tmp", 241 - message: "chore(test/app): update local version", 242 - }, 243 - { 244 - name: "nested file with custom message", 245 - inputPath: "/apps/namespace/app/cluster.yaml", 246 - expectedDir: "/apps/namespace/app", 247 - message: "feat: update application configuration", 248 - }, 249 - } 250 - 251 - for _, tt := range tests { 252 - t.Run(tt.name, func(t *testing.T) { 253 - // Test the path manipulation logic that GitCommit uses 254 - actualDir := filepath.Dir(tt.inputPath) 255 - 256 - if actualDir != tt.expectedDir { 257 - t.Errorf("Expected directory '%s', got '%s'", tt.expectedDir, actualDir) 258 - } 259 - 260 - // Verify message is not empty 261 - if tt.message == "" { 262 - t.Error("Commit message should not be empty") 263 - } 264 - }) 265 - } 266 - } 267 - 268 - func TestGitPush_PathParsing(t *testing.T) { 269 - // Test the path parsing logic in GitPush 270 - tests := []struct { 271 - name string 272 - inputPath string 273 - expectedDir string 274 - }{ 275 - { 276 - name: "simple file", 277 - inputPath: "/tmp/test.yaml", 278 - expectedDir: "/tmp", 279 - }, 280 - { 281 - name: "nested file", 282 - inputPath: "/apps/namespace/app/cluster.yaml", 283 - expectedDir: "/apps/namespace/app", 284 - }, 285 - } 286 - 287 - for _, tt := range tests { 288 - t.Run(tt.name, func(t *testing.T) { 289 - // Test the path manipulation logic that GitPush uses 290 - actualDir := filepath.Dir(tt.inputPath) 291 - 292 - if actualDir != tt.expectedDir { 293 - t.Errorf("Expected directory '%s', got '%s'", tt.expectedDir, actualDir) 294 - } 295 - }) 296 - } 297 - } 298 - 299 - func TestGitActivities_CommandStructure(t *testing.T) { 300 - // Test that the separate git activities construct the expected commands 301 - testPath := "/tmp/test/app/cluster.yaml" 302 - expectedDir := "/tmp/test/app" 303 - expectedFile := "cluster.yaml" 304 - commitMessage := "chore(khuedoan/blog): update production version" 305 - 306 - // Verify the path parsing logic 307 - actualDir := filepath.Dir(testPath) 308 - actualFile := filepath.Base(testPath) 309 - 310 - if actualDir != expectedDir { 311 - t.Errorf("Expected directory '%s', got '%s'", expectedDir, actualDir) 312 - } 313 - 314 - if actualFile != expectedFile { 315 - t.Errorf("Expected filename '%s', got '%s'", expectedFile, actualFile) 316 - } 317 - 318 - // Verify the expected command structures for each activity 319 - tests := []struct { 320 - name string 321 - expectedCommand []string 322 - description string 323 - }{ 324 - { 325 - name: "GitAdd command", 326 - expectedCommand: []string{"git", "-C", expectedDir, "add", expectedFile}, 327 - description: "GitAdd should construct git add command", 328 - }, 329 - { 330 - name: "GitCommit command", 331 - expectedCommand: []string{"git", "-C", expectedDir, "commit", "-m", commitMessage}, 332 - description: "GitCommit should construct git commit command with message", 333 - }, 334 - { 335 - name: "GitPush command", 336 - expectedCommand: []string{"git", "-C", expectedDir, "push"}, 337 - description: "GitPush should construct git push command", 338 - }, 339 - } 340 - 341 - for _, tt := range tests { 342 - t.Run(tt.name, func(t *testing.T) { 343 - cmd := tt.expectedCommand 344 - 345 - if len(cmd) < 3 { 346 - t.Errorf("%s should have at least 3 parts, got %d", tt.description, len(cmd)) 347 - return 348 - } 349 - 350 - if cmd[0] != "git" { 351 - t.Errorf("%s should start with 'git', got '%s'", tt.description, cmd[0]) 352 - } 353 - 354 - if cmd[1] != "-C" { 355 - t.Errorf("%s should have '-C' as second argument, got '%s'", tt.description, cmd[1]) 356 - } 357 - 358 - if cmd[2] != expectedDir { 359 - t.Errorf("%s should use directory '%s', got '%s'", tt.description, expectedDir, cmd[2]) 360 - } 361 - }) 362 - } 363 - } 364 - 365 - func TestGenerateRepoPath(t *testing.T) { 366 - tests := []struct { 367 - name string 368 - url string 369 - revision string 370 - wantPath bool // whether we expect a valid path 371 - }{ 372 - { 373 - name: "simple repo", 374 - url: "https://github.com/user/repo.git", 375 - revision: "main", 376 - wantPath: true, 377 - }, 378 - { 379 - name: "same repo different revision", 380 - url: "https://github.com/user/repo.git", 381 - revision: "develop", 382 - wantPath: true, 383 - }, 384 - { 385 - name: "empty inputs", 386 - url: "", 387 - revision: "", 388 - wantPath: true, // Should still generate a path 389 - }, 390 - } 391 - 392 - for _, tt := range tests { 393 - t.Run(tt.name, func(t *testing.T) { 394 - path := generateRepoPath(tt.url, tt.revision) 395 - 396 - if tt.wantPath { 397 - if path == "" { 398 - t.Error("Expected non-empty path") 399 - } 400 - if !strings.Contains(path, "/tmp/cloudlab-repos/") { 401 - t.Errorf("Expected path to contain '/tmp/cloudlab-repos/', got: %s", path) 402 - } 403 - if len(filepath.Base(path)) != 16 { 404 - t.Errorf("Expected base path to be 16 characters, got: %s", filepath.Base(path)) 405 - } 406 - } 407 - }) 408 - } 409 - 410 - // Test that same inputs generate same path 411 - path1 := generateRepoPath("https://github.com/test/repo.git", "main") 412 - path2 := generateRepoPath("https://github.com/test/repo.git", "main") 413 - if path1 != path2 { 414 - t.Errorf("Same inputs should generate same path: %s != %s", path1, path2) 415 - } 416 - 417 - // Test that different inputs generate different paths 418 - path3 := generateRepoPath("https://github.com/test/repo.git", "develop") 419 - if path1 == path3 { 420 - t.Error("Different revisions should generate different paths") 421 - } 422 - }
-192
controller/activities/graph.go
··· 1 - package activities 2 - 3 - import ( 4 - "context" 5 - "strings" 6 - ) 7 - 8 - type Graph struct { 9 - Nodes map[string]bool `json:"nodes"` 10 - Edges map[string][]string `json:"edges"` 11 - } 12 - 13 - func NewGraph() *Graph { 14 - return &Graph{ 15 - Nodes: make(map[string]bool), 16 - Edges: make(map[string][]string), 17 - } 18 - } 19 - 20 - func (g *Graph) AddNode(name string) { 21 - g.Nodes[name] = true 22 - } 23 - 24 - func (g *Graph) AddEdge(src, dest string) { 25 - g.AddNode(src) 26 - g.AddNode(dest) 27 - g.Edges[src] = append(g.Edges[src], dest) 28 - } 29 - 30 - func (g *Graph) GetNodes() []string { 31 - nodes := make([]string, 0, len(g.Nodes)) 32 - for name := range g.Nodes { 33 - nodes = append(nodes, name) 34 - } 35 - return nodes 36 - } 37 - 38 - func PruneGraph(ctx context.Context, graph *Graph, changed []string) (*Graph, error) { 39 - dependents := make(map[string][]string) 40 - for src, dests := range graph.Edges { 41 - for _, dest := range dests { 42 - dependents[dest] = append(dependents[dest], src) 43 - } 44 - } 45 - 46 - keep := make(map[string]bool) 47 - var visit func(string) 48 - visit = func(node string) { 49 - if keep[node] { 50 - return 51 - } 52 - keep[node] = true 53 - for _, dep := range dependents[node] { 54 - visit(dep) 55 - } 56 - } 57 - 58 - for _, nodeName := range changed { 59 - if graph.Nodes[nodeName] { 60 - visit(nodeName) 61 - } 62 - } 63 - 64 - prunedGraph := NewGraph() 65 - for node := range keep { 66 - prunedGraph.AddNode(node) 67 - } 68 - for src, dests := range graph.Edges { 69 - if keep[src] { 70 - for _, dest := range dests { 71 - if keep[dest] { 72 - prunedGraph.AddEdge(src, dest) 73 - } 74 - } 75 - } 76 - } 77 - 78 - return prunedGraph, nil 79 - } 80 - 81 - func NewGraphFromDot(dot string) (*Graph, error) { 82 - graph := NewGraph() 83 - 84 - lines := strings.SplitSeq(dot, "\n") 85 - for line := range lines { 86 - line = strings.TrimSpace(line) 87 - if line == "" || strings.HasPrefix(line, "//") || line == "digraph {" || line == "}" { 88 - continue 89 - } 90 - 91 - line = strings.TrimSuffix(line, ";") 92 - line = strings.TrimSpace(line) 93 - 94 - if strings.Contains(line, "->") { 95 - parts := strings.Split(line, "->") 96 - if len(parts) == 2 { 97 - src := extractQuotedString(strings.TrimSpace(parts[0])) 98 - dest := extractQuotedString(strings.TrimSpace(parts[1])) 99 - if src != "" && dest != "" { 100 - graph.AddEdge(src, dest) 101 - } 102 - } 103 - } else { 104 - // Parse standalone nodes: "C" 105 - nodeName := extractQuotedString(line) 106 - if nodeName != "" { 107 - graph.AddNode(nodeName) 108 - } 109 - } 110 - } 111 - 112 - return graph, nil 113 - } 114 - 115 - // extractQuotedString extracts the content between quotes from a string like "hello" 116 - func extractQuotedString(s string) string { 117 - s = strings.TrimSpace(s) 118 - if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { 119 - return s[1 : len(s)-1] 120 - } 121 - return "" 122 - } 123 - 124 - // TopologicalSort returns modules grouped by dependency levels for parallel execution. 125 - // Edge from A to B means A depends on B, so B must run before A. 126 - func (g *Graph) TopologicalSort() [][]string { 127 - // Build adjacency list and in-degree count 128 - adjList := make(map[string][]string) 129 - inDegree := make(map[string]int) 130 - 131 - // Initialize all nodes with in-degree 0 132 - for node := range g.Nodes { 133 - inDegree[node] = 0 134 - adjList[node] = []string{} 135 - } 136 - 137 - // Build the graph and calculate in-degrees 138 - // Edge from Src to Dest means Src depends on Dest 139 - // So Dest should run before Src 140 - for src, dests := range g.Edges { 141 - for _, dest := range dests { 142 - adjList[dest] = append(adjList[dest], src) 143 - inDegree[src]++ 144 - } 145 - } 146 - 147 - var levels [][]string 148 - remaining := make(map[string]bool) 149 - for node := range g.Nodes { 150 - remaining[node] = true 151 - } 152 - 153 - // Process nodes level by level 154 - for len(remaining) > 0 { 155 - var currentLevel []string 156 - 157 - // Find all nodes with in-degree 0 (no dependencies) 158 - for nodeName := range remaining { 159 - if inDegree[nodeName] == 0 { 160 - currentLevel = append(currentLevel, nodeName) 161 - } 162 - } 163 - 164 - // If no nodes found with in-degree 0, there's a cycle 165 - if len(currentLevel) == 0 { 166 - // Return remaining nodes as the final level to handle cycles gracefully 167 - var cycleNodes []string 168 - for nodeName := range remaining { 169 - cycleNodes = append(cycleNodes, nodeName) 170 - } 171 - if len(cycleNodes) > 0 { 172 - levels = append(levels, cycleNodes) 173 - } 174 - break 175 - } 176 - 177 - // Add current level 178 - levels = append(levels, currentLevel) 179 - 180 - // Remove processed nodes and update in-degrees 181 - for _, nodeName := range currentLevel { 182 - delete(remaining, nodeName) 183 - for _, dependent := range adjList[nodeName] { 184 - if remaining[dependent] { 185 - inDegree[dependent]-- 186 - } 187 - } 188 - } 189 - } 190 - 191 - return levels 192 - }
-404
controller/activities/graph_test.go
··· 1 - package activities 2 - 3 - import ( 4 - "context" 5 - "reflect" 6 - "sort" 7 - "testing" 8 - ) 9 - 10 - func TestPruneGraphSimple(t *testing.T) { 11 - dot := ` 12 - digraph { 13 - "A" -> "B"; 14 - "B" -> "C"; 15 - "D" -> "B"; 16 - "E" -> "F"; 17 - "C"; 18 - "F"; 19 - } 20 - ` 21 - graph, err := NewGraphFromDot(dot) 22 - if err != nil { 23 - t.Fatalf("Failed to create graph from dot: %v", err) 24 - } 25 - 26 - testCases := []struct { 27 - name string 28 - changed []string 29 - expectedNodes []string 30 - expectedEdges map[string][]string 31 - }{ 32 - { 33 - name: "Prune to C and its dependencies", 34 - changed: []string{"C"}, 35 - expectedNodes: []string{"A", "B", "C", "D"}, 36 - expectedEdges: map[string][]string{ 37 - "A": {"B"}, 38 - "B": {"C"}, 39 - "D": {"B"}, 40 - }, 41 - }, 42 - { 43 - name: "Prune to F and its dependencies", 44 - changed: []string{"F"}, 45 - expectedNodes: []string{"E", "F"}, 46 - expectedEdges: map[string][]string{ 47 - "E": {"F"}, 48 - }, 49 - }, 50 - { 51 - name: "Prune to B and its dependencies", 52 - changed: []string{"B"}, 53 - expectedNodes: []string{"A", "B", "D"}, 54 - expectedEdges: map[string][]string{ 55 - "A": {"B"}, 56 - "D": {"B"}, 57 - }, 58 - }, 59 - { 60 - name: "No nodes changed", 61 - changed: []string{}, 62 - expectedNodes: []string{}, 63 - expectedEdges: map[string][]string{}, 64 - }, 65 - { 66 - name: "Changed node not in graph", 67 - changed: []string{"Z"}, 68 - expectedNodes: []string{}, 69 - expectedEdges: map[string][]string{}, 70 - }, 71 - { 72 - name: "Multiple changed nodes", 73 - changed: []string{"C", "F"}, 74 - expectedNodes: []string{"A", "B", "C", "D", "E", "F"}, 75 - expectedEdges: map[string][]string{ 76 - "A": {"B"}, 77 - "B": {"C"}, 78 - "D": {"B"}, 79 - "E": {"F"}, 80 - }, 81 - }, 82 - } 83 - 84 - for _, tc := range testCases { 85 - t.Run(tc.name, func(t *testing.T) { 86 - prunedGraph, err := PruneGraph(context.Background(), graph, tc.changed) 87 - if err != nil { 88 - t.Fatalf("PruneGraph failed: %v", err) 89 - } 90 - 91 - prunedNodes := prunedGraph.GetNodes() 92 - sort.Strings(prunedNodes) 93 - sort.Strings(tc.expectedNodes) 94 - 95 - if !reflect.DeepEqual(prunedNodes, tc.expectedNodes) { 96 - t.Errorf("Expected nodes %v, but got %v", tc.expectedNodes, prunedNodes) 97 - } 98 - 99 - // Compare edges 100 - if !reflect.DeepEqual(prunedGraph.Edges, tc.expectedEdges) { 101 - t.Errorf("Expected edges %v, but got %v", tc.expectedEdges, prunedGraph.Edges) 102 - } 103 - }) 104 - } 105 - } 106 - 107 - func TestPruneGraphRealWorld(t *testing.T) { 108 - realWorldDot := `digraph { 109 - "aks-windows-node-exporter" ; 110 - "azuresql" ; 111 - "azuresql" -> "core"; 112 - "azuresqlusers" ; 113 - "azuresqlusers" -> "azuresql"; 114 - "bootstrap-va" ; 115 - "bootstrap-va" -> "cluster-va"; 116 - "bootstrap2-va" ; 117 - "bootstrap2-va" -> "cluster2-va"; 118 - "cluster-va" ; 119 - "cluster-va" -> "core"; 120 - "cluster2-va" ; 121 - "core" ; 122 - "db/auror-integration" ; 123 - "db/auror-integration" -> "core"; 124 - "db/auror-integration" -> "azuresql"; 125 - "db/doc-chat" ; 126 - "db/doc-chat" -> "core"; 127 - "db/doc-chat" -> "azuresql"; 128 - "dems-cluster-identity" ; 129 - "dems-cluster-identity" -> "cluster-va"; 130 - "dems-search-grpc/cosmosdb-cassandra" ; 131 - "dems-search-grpc/cosmosdb-cassandra" -> "core"; 132 - "doc-chat/openai" ; 133 - "doc-chat/openai" -> "core"; 134 - "doc-chat/openai-fallback" ; 135 - "doc-chat/openai-fallback" -> "core"; 136 - "doc-chat/search-service-va" ; 137 - "doc-chat/search-service-va" -> "core"; 138 - "ecom/arkham-hsm-als-endpoint" ; 139 - "ecom/arkham-hsm-legacy-endpoint" ; 140 - "ecom/redis" ; 141 - "ecom/redis" -> "core"; 142 - "ecom/redis-case" ; 143 - "ecom/redis-case" -> "core"; 144 - "ecom/redis-legacy-endpoint" ; 145 - "ecom/redis-legacy-endpoint" -> "core"; 146 - "ecom/redis-legacy-endpoint" -> "ecom/redis"; 147 - "ecom/redis-sharon" ; 148 - "ecom/redis-sharon" -> "core"; 149 - "ecom/redis-webhooks-premium" ; 150 - "ecom/redis-webhooks-premium" -> "core"; 151 - "endpoints/azuresql-legacy-endpoint-tx" ; 152 - "endpoints/azuresql-legacy-endpoint-tx" -> "core"; 153 - "endpoints/azuresql-legacy-endpoint-tx" -> "azuresql"; 154 - "endpoints/azuresql-legacy-endpoint-va" ; 155 - "endpoints/azuresql-legacy-endpoint-va" -> "core"; 156 - "endpoints/azuresql-legacy-endpoint-va" -> "azuresql"; 157 - "endpoints/storage-accounts" ; 158 - "endpoints/storage-accounts" -> "storage-accounts/ingestion"; 159 - "enterprise/app-identity/auror" ; 160 - "enterprise/app-identity/auror" -> "core"; 161 - "enterprise/keyvault/auror" ; 162 - "enterprise/keyvault/auror" -> "core"; 163 - "enterprise/keyvault/auror" -> "enterprise/app-identity/auror"; 164 - "enterprise/redis/auror" ; 165 - "enterprise/redis/auror" -> "core"; 166 - "espio/az-openai" ; 167 - "espio/az-openai" -> "core"; 168 - "espio/espio-redis" ; 169 - "espio/espio-redis" -> "core"; 170 - "espio/openai" ; 171 - "espio/openai-b" ; 172 - "espio/openai-b" -> "core"; 173 - "eventgrid-subscription" ; 174 - "eventgrid-subscription" -> "core"; 175 - "evp/hyperscale" ; 176 - "evp/hyperscale" -> "core"; 177 - "evp/hyperscaleusers" ; 178 - "evp/hyperscaleusers" -> "evp/hyperscale"; 179 - "performance/lakehouse" ; 180 - "performance/lakehouse" -> "core"; 181 - "performance/redis-jarvis" ; 182 - "performance/redis-jarvis" -> "core"; 183 - "performance/redis-pipeline" ; 184 - "performance/redis-pipeline" -> "core"; 185 - "performance/redis-starhopper" ; 186 - "performance/redis-starhopper" -> "core"; 187 - "pes/keyvault" ; 188 - "pes/keyvault" -> "core"; 189 - "pes/keyvault" -> "dems-cluster-identity"; 190 - "ratelimit/redis" ; 191 - "ratelimit/redis" -> "core"; 192 - "sage/datafactory" ; 193 - "sage/datafactory" -> "core"; 194 - "sage/datafactory/alerts" ; 195 - "sage/datafactory/alerts" -> "sage/datafactory"; 196 - "sage/datafactory/alerts" -> "core"; 197 - "sage/datafactory/evidence-domain-migration-internal-pipeline" ; 198 - "sage/datafactory/evidence-domain-migration-internal-pipeline" -> "sage/datafactory"; 199 - "sage/datafactory/evidence-domain-migration-main-pipeline" ; 200 - "sage/datafactory/evidence-domain-migration-main-pipeline" -> "sage/datafactory"; 201 - "sage/endpoints/hyperscale-legacy-endpoint-tx" ; 202 - "sage/endpoints/hyperscale-legacy-endpoint-tx" -> "core"; 203 - "sage/endpoints/hyperscale-legacy-endpoint-tx" -> "sage/hyperscale"; 204 - "sage/endpoints/hyperscale-legacy-endpoint-va" ; 205 - "sage/endpoints/hyperscale-legacy-endpoint-va" -> "core"; 206 - "sage/endpoints/hyperscale-legacy-endpoint-va" -> "sage/hyperscale"; 207 - "sage/hyperscale" ; 208 - "sage/hyperscale" -> "core"; 209 - "sage/hyperscale/named-replica" ; 210 - "sage/hyperscale/named-replica" -> "sage/hyperscale"; 211 - "sage/hyperscale/named-replica" -> "core"; 212 - "sage/hyperscaleusers" ; 213 - "sage/hyperscaleusers" -> "sage/hyperscale"; 214 - "sage/redis" ; 215 - "sage/redis" -> "core"; 216 - "servicebus-premium" ; 217 - "servicebus-premium" -> "core"; 218 - "sonic/rev-storage" ; 219 - "sonic/rev-storage" -> "core"; 220 - "sonic/sonic" ; 221 - "sonic/sonic" -> "core"; 222 - "sonic/sonic-redis" ; 223 - "sonic/sonic-redis" -> "core"; 224 - "sonic/translation" ; 225 - "sonic/translation" -> "core"; 226 - "storage-accounts/case-share" ; 227 - "storage-accounts/ingestion" ; 228 - "storage-accounts/rtiworker" ; 229 - "storage-accounts/sage" ; 230 - "system-status/cosmosdb-cassandra" ; 231 - "system-status/cosmosdb-cassandra" -> "core"; 232 - "user-settings/cosmosdb-cassandra" ; 233 - "user-settings/cosmosdb-cassandra" -> "core"; 234 - "visionsearchpoc/vision" ; 235 - "visionsearchpoc/vision" -> "core"; 236 - "visualization/cosmosdb-cassandra" ; 237 - "visualization/cosmosdb-cassandra" -> "core"; 238 - "visualization/redis-cluster" ; 239 - "visualization/redis-cluster" -> "core"; 240 - "visualization/redis-cluster-rtm" ; 241 - "visualization/redis-cluster-rtm" -> "core"; 242 - "vm-apps/lsln-500" ; 243 - "vm-apps/lsln-500" -> "core"; 244 - "vm-apps/lsln-500" -> "vm-apps/solr8-j11-lb"; 245 - "vm-apps/solr8-j11-lb" ; 246 - "vm-apps/solr8-j11-lb" -> "core"; 247 - "webhooks/cosmosdb-cassandra-dispatch" ; 248 - "webhooks/cosmosdb-cassandra-dispatch" -> "core"; 249 - "xshare/azuresql" ; 250 - "xshare/azuresql" -> "core"; 251 - "xshare/azuresqlusers" ; 252 - "xshare/azuresqlusers" -> "xshare/azuresql"; 253 - } 254 - ` 255 - graph, err := NewGraphFromDot(realWorldDot) 256 - if err != nil { 257 - t.Fatalf("Failed to create graph from real-world DOT: %v", err) 258 - } 259 - 260 - // Test case: cluster-va changed 261 - prunedGraph, err := PruneGraph(context.Background(), graph, []string{"cluster-va"}) 262 - if err != nil { 263 - t.Fatalf("PruneGraph failed: %v", err) 264 - } 265 - 266 - // Expected result: 267 - // digraph { 268 - // "bootstrap-va" -> "cluster-va"; 269 - // "dems-cluster-identity" -> "cluster-va"; 270 - // "pes/keyvault" -> "dems-cluster-identity"; 271 - // } 272 - expectedNodes := []string{"bootstrap-va", "cluster-va", "dems-cluster-identity", "pes/keyvault"} 273 - expectedEdges := map[string][]string{ 274 - "bootstrap-va": {"cluster-va"}, 275 - "dems-cluster-identity": {"cluster-va"}, 276 - "pes/keyvault": {"dems-cluster-identity"}, 277 - } 278 - 279 - prunedNodes := prunedGraph.GetNodes() 280 - sort.Strings(prunedNodes) 281 - sort.Strings(expectedNodes) 282 - 283 - if !reflect.DeepEqual(prunedNodes, expectedNodes) { 284 - t.Errorf("Expected nodes %v, but got %v", expectedNodes, prunedNodes) 285 - } 286 - 287 - if !reflect.DeepEqual(prunedGraph.Edges, expectedEdges) { 288 - t.Errorf("Expected edges %v, but got %v", expectedEdges, prunedGraph.Edges) 289 - } 290 - } 291 - 292 - func TestTopologicalSort(t *testing.T) { 293 - testCases := []struct { 294 - name string 295 - nodes []string 296 - edges map[string][]string 297 - expectedLevels [][]string 298 - }{ 299 - { 300 - name: "Simple linear dependency", 301 - nodes: []string{"A", "B", "C"}, 302 - edges: map[string][]string{ 303 - "B": {"A"}, 304 - "C": {"B"}, 305 - }, 306 - expectedLevels: [][]string{ 307 - {"A"}, 308 - {"B"}, 309 - {"C"}, 310 - }, 311 - }, 312 - { 313 - name: "Parallel dependencies", 314 - nodes: []string{"A", "B", "C", "D"}, 315 - edges: map[string][]string{ 316 - "C": {"A", "B"}, 317 - "D": {"C"}, 318 - }, 319 - expectedLevels: [][]string{ 320 - {"A", "B"}, 321 - {"C"}, 322 - {"D"}, 323 - }, 324 - }, 325 - { 326 - name: "Complex dependency graph", 327 - nodes: []string{"A", "B", "C", "D", "E", "F"}, 328 - edges: map[string][]string{ 329 - "C": {"A", "B"}, 330 - "D": {"C"}, 331 - "E": {"C"}, 332 - "F": {"D", "E"}, 333 - }, 334 - expectedLevels: [][]string{ 335 - {"A", "B"}, 336 - {"C"}, 337 - {"D", "E"}, 338 - {"F"}, 339 - }, 340 - }, 341 - { 342 - name: "No dependencies", 343 - nodes: []string{"A", "B", "C"}, 344 - edges: map[string][]string{}, 345 - expectedLevels: [][]string{{"A", "B", "C"}}, 346 - }, 347 - { 348 - name: "Single node", 349 - nodes: []string{"A"}, 350 - edges: map[string][]string{}, 351 - expectedLevels: [][]string{{"A"}}, 352 - }, 353 - { 354 - name: "Real world example: bootstrap depends on cluster", 355 - nodes: []string{"bootstrap", "cluster"}, 356 - edges: map[string][]string{ 357 - "bootstrap": {"cluster"}, 358 - }, 359 - expectedLevels: [][]string{ 360 - {"cluster"}, 361 - {"bootstrap"}, 362 - }, 363 - }, 364 - } 365 - 366 - for _, tc := range testCases { 367 - t.Run(tc.name, func(t *testing.T) { 368 - // Create graph 369 - graph := NewGraph() 370 - 371 - for _, nodeName := range tc.nodes { 372 - graph.AddNode(nodeName) 373 - } 374 - 375 - for src, dests := range tc.edges { 376 - for _, dest := range dests { 377 - graph.AddEdge(src, dest) 378 - } 379 - } 380 - 381 - // Get topological sort 382 - levels := graph.TopologicalSort() 383 - 384 - // Verify number of levels 385 - if len(levels) != len(tc.expectedLevels) { 386 - t.Errorf("Expected %d levels, but got %d", len(tc.expectedLevels), len(levels)) 387 - return 388 - } 389 - 390 - // Verify each level 391 - for levelIndex, expectedLevel := range tc.expectedLevels { 392 - actualLevel := levels[levelIndex] 393 - 394 - // Sort both slices for comparison 395 - sort.Strings(expectedLevel) 396 - sort.Strings(actualLevel) 397 - 398 - if !reflect.DeepEqual(actualLevel, expectedLevel) { 399 - t.Errorf("Level %d: expected %v, but got %v", levelIndex, expectedLevel, actualLevel) 400 - } 401 - } 402 - }) 403 - } 404 - }
-43
controller/activities/oci.go
··· 1 - package activities 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "encoding/json" 7 - "os/exec" 8 - 9 - "go.temporal.io/sdk/activity" 10 - ) 11 - 12 - type PushResult struct { 13 - Reference string `json:"reference"` 14 - MediaType string `json:"mediaType"` 15 - Digest string `json:"digest"` 16 - Size int `json:"size"` 17 - Annotations map[string]string `json:"annotations"` 18 - ArtifactType string `json:"artifactType"` 19 - ReferenceAsTags []string `json:"referenceAsTags"` 20 - } 21 - 22 - func PushManifests(ctx context.Context, path string, image string) (*PushResult, error) { 23 - logger := activity.GetLogger(ctx) 24 - cmd := exec.CommandContext(ctx, "oras", "push", "--format=json", "--plain-http", image, ".") 25 - cmd.Dir = path 26 - 27 - var stdout, stderr bytes.Buffer 28 - cmd.Stdout = &stdout 29 - cmd.Stderr = &stderr 30 - 31 - if err := cmd.Run(); err != nil { 32 - logger.Error("oras push failed", "error", err, "stderr", stderr.String()) 33 - return nil, err 34 - } 35 - 36 - var result PushResult 37 - if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { 38 - logger.Error("failed to parse oras output", "error", err, "output", stdout.String()) 39 - return nil, err 40 - } 41 - 42 - return &result, nil 43 - }
-105
controller/activities/terragrunt.go
··· 1 - package activities 2 - 3 - import ( 4 - "bufio" 5 - "context" 6 - "fmt" 7 - "os/exec" 8 - "path/filepath" 9 - "strings" 10 - "time" 11 - 12 - "go.temporal.io/sdk/activity" 13 - ) 14 - 15 - func TerragruntGraph(ctx context.Context, path string) (*Graph, error) { 16 - cmd := exec.CommandContext(ctx, "terragrunt", "dag", "graph") 17 - cmd.Dir = path 18 - output, err := cmd.Output() 19 - if err != nil { 20 - return nil, fmt.Errorf("failed to run terragrunt dag graph: %w", err) 21 - } 22 - 23 - return NewGraphFromDot(string(output)) 24 - } 25 - 26 - func TerragruntPrune(ctx context.Context, graph *Graph, changedFiles []string) (*Graph, error) { 27 - return PruneGraph(ctx, graph, changedFiles) 28 - } 29 - 30 - func TerragruntApply(ctx context.Context, repoUrl string, revision string, modulePath string, stack string) error { 31 - logger := activity.GetLogger(ctx) 32 - logger.Info("Running terragrunt apply", "module", modulePath, "stack", stack) 33 - 34 - repoPath, err := Clone(ctx, repoUrl, revision) 35 - if err != nil { 36 - return fmt.Errorf("failed to ensure repository is available: %w", err) 37 - } 38 - 39 - fullPath := filepath.Join(repoPath, "infra", stack, modulePath) 40 - 41 - cmd := exec.CommandContext(ctx, "terragrunt", "apply", "--backend-bootstrap", "--auto-approve") 42 - cmd.Dir = fullPath 43 - 44 - // Create pipes to capture output and send heartbeats 45 - stdout, err := cmd.StdoutPipe() 46 - if err != nil { 47 - return fmt.Errorf("failed to create stdout pipe: %w", err) 48 - } 49 - stderr, err := cmd.StderrPipe() 50 - if err != nil { 51 - return fmt.Errorf("failed to create stderr pipe: %w", err) 52 - } 53 - 54 - if err := cmd.Start(); err != nil { 55 - return fmt.Errorf("failed to start terragrunt apply: %w", err) 56 - } 57 - 58 - // Monitor output and send heartbeats 59 - done := make(chan error, 1) 60 - go func() { 61 - done <- cmd.Wait() 62 - }() 63 - 64 - // Send heartbeats while monitoring output 65 - heartbeatTicker := time.NewTicker(25 * time.Second) // Send heartbeat every 25s (before 30s timeout) 66 - defer heartbeatTicker.Stop() 67 - 68 - var lastOutput string 69 - outputScanner := bufio.NewScanner(stdout) 70 - errorScanner := bufio.NewScanner(stderr) 71 - 72 - for { 73 - select { 74 - case err := <-done: 75 - if err != nil { 76 - return fmt.Errorf("terragrunt apply failed for module %s: %w", modulePath, err) 77 - } 78 - safeHeartbeat(ctx, fmt.Sprintf("Terragrunt apply completed for %s", modulePath)) 79 - return nil 80 - 81 - case <-heartbeatTicker.C: 82 - safeHeartbeat(ctx, fmt.Sprintf("Terragrunt apply in progress for %s - %s", modulePath, lastOutput)) 83 - 84 - default: 85 - // Check for new output 86 - if outputScanner.Scan() { 87 - line := strings.TrimSpace(outputScanner.Text()) 88 - if line != "" { 89 - lastOutput = line 90 - logger.Info("Terragrunt output", "module", modulePath, "output", line) 91 - } 92 - } 93 - if errorScanner.Scan() { 94 - line := strings.TrimSpace(errorScanner.Text()) 95 - if line != "" { 96 - lastOutput = line 97 - logger.Info("Terragrunt error output", "module", modulePath, "error", line) 98 - } 99 - } 100 - 101 - // Small sleep to prevent busy waiting 102 - time.Sleep(100 * time.Millisecond) 103 - } 104 - } 105 - }
-17
controller/activities/utils.go
··· 1 - package activities 2 - 3 - import ( 4 - "context" 5 - 6 - "go.temporal.io/sdk/activity" 7 - ) 8 - 9 - // safeHeartbeat sends a heartbeat only if we're in an activity context 10 - func safeHeartbeat(ctx context.Context, details string) { 11 - defer func() { 12 - if r := recover(); r != nil { 13 - // Ignore panic - we're not in an activity context 14 - } 15 - }() 16 - activity.RecordHeartbeat(ctx, details) 17 - }
-35
controller/go.mod
··· 1 - module cloudlab/controller 2 - 3 - go 1.24.3 4 - 5 - require ( 6 - github.com/stretchr/testify v1.10.0 7 - go.temporal.io/sdk v1.34.0 8 - ) 9 - 10 - require ( 11 - github.com/davecgh/go-spew v1.1.1 // indirect 12 - github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect 13 - github.com/gogo/protobuf v1.3.2 // indirect 14 - github.com/golang/mock v1.6.0 // indirect 15 - github.com/google/go-cmp v0.7.0 // indirect 16 - github.com/google/uuid v1.6.0 // indirect 17 - github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect 18 - github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect 19 - github.com/nexus-rpc/sdk-go v0.3.0 // indirect 20 - github.com/pmezard/go-difflib v1.0.0 // indirect 21 - github.com/robfig/cron v1.2.0 // indirect 22 - github.com/rogpeppe/go-internal v1.13.1 // indirect 23 - github.com/stretchr/objx v0.5.2 // indirect 24 - go.temporal.io/api v1.46.0 // indirect 25 - golang.org/x/net v0.39.0 // indirect 26 - golang.org/x/sync v0.13.0 // indirect 27 - golang.org/x/sys v0.32.0 // indirect 28 - golang.org/x/text v0.24.0 // indirect 29 - golang.org/x/time v0.9.0 // indirect 30 - google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed // indirect 31 - google.golang.org/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed // indirect 32 - google.golang.org/grpc v1.66.0 // indirect 33 - google.golang.org/protobuf v1.36.5 // indirect 34 - gopkg.in/yaml.v3 v3.0.1 // indirect 35 - )
-174
controller/go.sum
··· 1 - cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 - github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 - github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 4 - github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 5 - github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 6 - github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= 7 - github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 - github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 - github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 - github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 11 - github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 12 - github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= 13 - github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 14 - github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a h1:yDWHCSQ40h88yih2JAcL6Ls/kVkSE8GFACTGVnMPruw= 15 - github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a/go.mod h1:7Ga40egUymuWXxAe151lTNnCv97MddSOVsjpPPkityA= 16 - github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= 17 - github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= 18 - github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 19 - github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 20 - github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 21 - github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 22 - github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 23 - github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= 24 - github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= 25 - github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 26 - github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 27 - github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= 28 - github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4= 29 - github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 30 - github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 31 - github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 32 - github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 33 - github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 34 - github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 35 - github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= 36 - github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= 37 - github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= 38 - github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= 39 - github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 40 - github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 41 - github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 42 - github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 43 - github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 44 - github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 45 - github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 46 - github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 47 - github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 48 - github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 49 - github.com/nexus-rpc/sdk-go v0.3.0 h1:Y3B0kLYbMhd4C2u00kcYajvmOrfozEtTV/nHSnV57jA= 50 - github.com/nexus-rpc/sdk-go v0.3.0/go.mod h1:TpfkM2Cw0Rlk9drGkoiSMpFqflKTiQLWUNyKJjF8mKQ= 51 - github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= 52 - github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 53 - github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 54 - github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 55 - github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 56 - github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= 57 - github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= 58 - github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 59 - github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 60 - github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= 61 - github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 62 - github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 63 - github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 64 - github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 65 - github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 66 - github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 67 - github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 68 - github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 69 - github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 70 - github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 71 - github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 72 - github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 73 - github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 74 - go.temporal.io/api v1.46.0 h1:O1efPDB6O2B8uIeCDIa+3VZC7tZMvYsMZYQapSbHvCg= 75 - go.temporal.io/api v1.46.0/go.mod h1:iaxoP/9OXMJcQkETTECfwYq4cw/bj4nwov8b3ZLVnXM= 76 - go.temporal.io/sdk v1.34.0 h1:VLg/h6ny7GvLFVoQPqz2NcC93V9yXboQwblkRvZ1cZE= 77 - go.temporal.io/sdk v1.34.0/go.mod h1:iE4U5vFrH3asOhqpBBphpj9zNtw8btp8+MSaf5A0D3w= 78 - go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 79 - go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= 80 - go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= 81 - go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= 82 - golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 83 - golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 84 - golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 85 - golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 86 - golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 87 - golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 88 - golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 89 - golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 90 - golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 91 - golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 92 - golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 93 - golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 94 - golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 95 - golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 96 - golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 97 - golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 98 - golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 99 - golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 100 - golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 101 - golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 102 - golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 103 - golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 104 - golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 105 - golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 106 - golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 107 - golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 108 - golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 109 - golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 110 - golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 111 - golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 112 - golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 113 - golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 114 - golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 115 - golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 116 - golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 117 - golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 118 - golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 119 - golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 120 - golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 121 - golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 122 - golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 123 - golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 124 - golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 125 - golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 126 - golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 127 - golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 128 - golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 129 - golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= 130 - golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 131 - golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 132 - golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 133 - golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 134 - golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 135 - golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 136 - golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 137 - golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 138 - golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 139 - golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 140 - golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 141 - golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 142 - golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 143 - golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 144 - golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 145 - google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 146 - google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 147 - google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 148 - google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 149 - google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= 150 - google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed h1:3RgNmBoI9MZhsj3QxC+AP/qQhNwpCLOvYDYYsFrhFt0= 151 - google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed/go.mod h1:OCdP9MfskevB/rbYvHTsXTtKC+3bHWajPdoKgjcYkfo= 152 - google.golang.org/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed h1:J6izYgfBXAI3xTKLgxzTmUltdYaLsuBxFCgDHWJ/eXg= 153 - google.golang.org/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= 154 - google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 155 - google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 156 - google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= 157 - google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 158 - google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= 159 - google.golang.org/grpc v1.66.0 h1:DibZuoBznOxbDQxRINckZcUvnCEvrW9pcWIE2yF9r1c= 160 - google.golang.org/grpc v1.66.0/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y= 161 - google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= 162 - google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 163 - gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 164 - gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 165 - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 166 - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 167 - gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 168 - gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 169 - gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 170 - gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 171 - gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 172 - gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 173 - honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 174 - honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-48
controller/worker/main.go
··· 1 - package main 2 - 3 - import ( 4 - "log" 5 - "os" 6 - 7 - "cloudlab/controller/activities" 8 - "cloudlab/controller/workflows" 9 - 10 - "go.temporal.io/sdk/client" 11 - "go.temporal.io/sdk/worker" 12 - ) 13 - 14 - func main() { 15 - // The client and worker are heavyweight objects that should be created once per process. 16 - temporalClient, err := client.Dial(client.Options{ 17 - HostPort: os.Getenv("TEMPORAL_HOST"), 18 - }) 19 - if err != nil { 20 - log.Fatalln("Unable to create client", err) 21 - } 22 - defer temporalClient.Close() 23 - 24 - w := worker.New(temporalClient, "cloudlab", worker.Options{}) 25 - 26 - w.RegisterActivity(activities.Clone) 27 - w.RegisterActivity(activities.ChangedModules) 28 - w.RegisterActivity(activities.TerragruntGraph) 29 - w.RegisterActivity(activities.PruneGraph) 30 - w.RegisterActivity(activities.TerragruntApply) 31 - w.RegisterActivity(activities.PushManifests) 32 - w.RegisterActivity(activities.PushRenderedApp) 33 - w.RegisterActivity(activities.DiscoverApps) 34 - w.RegisterActivity(activities.UpdateAppVersion) 35 - w.RegisterActivity(activities.GitAdd) 36 - w.RegisterActivity(activities.GitCommit) 37 - w.RegisterActivity(activities.GitPush) 38 - 39 - w.RegisterWorkflow(workflows.Infra) 40 - w.RegisterWorkflow(workflows.Platform) 41 - w.RegisterWorkflow(workflows.Apps) 42 - w.RegisterWorkflow(workflows.AppUpdate) 43 - 44 - err = w.Run(worker.InterruptCh()) 45 - if err != nil { 46 - log.Fatalln("Unable to start Worker", err) 47 - } 48 - }
-148
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 - Registry string 21 - NewImages []activities.Image 22 - } 23 - 24 - // AppUpdate workflow clones a repository, updates app versions, and syncs changes back to git 25 - func AppUpdate(ctx workflow.Context, input AppUpdateInput) error { 26 - logger := workflow.GetLogger(ctx) 27 - logger.Info("AppUpdate workflow started", "input", input) 28 - 29 - var workspace string 30 - if err := workflow.ExecuteActivity( 31 - workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ 32 - StartToCloseTimeout: 2 * time.Minute, 33 - }), 34 - activities.Clone, 35 - input.Url, 36 - input.Revision, 37 - ).Get(ctx, &workspace); err != nil { 38 - logger.Error("Failed to clone repository", "error", err) 39 - return fmt.Errorf("failed to clone repository: %w", err) 40 - } 41 - 42 - logger.Info("Repository cloned successfully", "workspace", workspace) 43 - 44 - defer func() { 45 - if err := os.RemoveAll(workspace); err != nil { 46 - logger.Error("Failed to cleanup workspace", "workspace", workspace, "error", err) 47 - } 48 - }() 49 - 50 - appsDir := filepath.Join(workspace, "apps") 51 - var changed bool 52 - if err := workflow.ExecuteActivity( 53 - workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ 54 - StartToCloseTimeout: 30 * time.Second, 55 - }), 56 - activities.UpdateAppVersion, 57 - appsDir, 58 - input.Namespace, 59 - input.App, 60 - input.Cluster, 61 - input.NewImages, 62 - ).Get(ctx, &changed); err != nil { 63 - logger.Error("failed to update app version", "error", err) 64 - return fmt.Errorf("failed to update app version: %w", err) 65 - } 66 - 67 - logger.Info("App version updated successfully", 68 - "namespace", input.Namespace, 69 - "app", input.App, 70 - "cluster", input.Cluster, 71 - "changed", changed) 72 - 73 - // Skip remaining steps if no changes were made 74 - if !changed { 75 - logger.Info("No changes detected, skipping remaining steps") 76 - return nil 77 - } 78 - 79 - // Step 3: Git add changes 80 - appFilePath := filepath.Join(appsDir, input.Namespace, input.App, fmt.Sprintf("%s.yaml", input.Cluster)) 81 - if err := workflow.ExecuteActivity( 82 - workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ 83 - StartToCloseTimeout: 30 * time.Second, 84 - }), 85 - activities.GitAdd, 86 - appFilePath, 87 - ).Get(ctx, nil); err != nil { 88 - logger.Error("Failed to add changes to git", "error", err) 89 - return fmt.Errorf("failed to add changes to git: %w", err) 90 - } 91 - 92 - // Step 4: Git commit changes 93 - commitMessage := fmt.Sprintf("chore(%s/%s): update %s version", input.Namespace, input.App, input.Cluster) 94 - if err := workflow.ExecuteActivity( 95 - workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ 96 - StartToCloseTimeout: 30 * time.Second, 97 - }), 98 - activities.GitCommit, 99 - workspace, 100 - commitMessage, 101 - ).Get(ctx, nil); err != nil { 102 - logger.Error("Failed to commit changes to git", "error", err) 103 - return fmt.Errorf("failed to commit changes to git: %w", err) 104 - } 105 - 106 - // Step 5 & 6: Execute GitPush and PushRenderedApp concurrently 107 - gitPushFuture := workflow.ExecuteActivity( 108 - workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ 109 - StartToCloseTimeout: 1 * time.Minute, 110 - }), 111 - activities.GitPush, 112 - workspace, 113 - ) 114 - 115 - pushRenderedAppFuture := workflow.ExecuteActivity( 116 - workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ 117 - StartToCloseTimeout: 2 * time.Minute, 118 - }), 119 - activities.PushRenderedApp, 120 - appsDir, 121 - input.Namespace, 122 - input.App, 123 - input.Cluster, 124 - input.Registry, 125 - ) 126 - 127 - // Wait for GitPush to complete 128 - if err := gitPushFuture.Get(ctx, nil); err != nil { 129 - logger.Error("Failed to push changes to git", "error", err) 130 - return fmt.Errorf("failed to push changes to git: %w", err) 131 - } 132 - 133 - // Wait for PushRenderedApp to complete 134 - var pushResult *activities.PushResult 135 - if err := pushRenderedAppFuture.Get(ctx, &pushResult); err != nil { 136 - logger.Error("Failed to push rendered app to registry", "error", err) 137 - return fmt.Errorf("failed to push rendered app to registry: %w", err) 138 - } 139 - 140 - logger.Info("AppUpdate workflow completed successfully", 141 - "namespace", input.Namespace, 142 - "app", input.App, 143 - "cluster", input.Cluster, 144 - "updated_images", len(input.NewImages), 145 - "rendered_app_digest", pushResult.Digest) 146 - 147 - return nil 148 - }
-417
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 - Registry: "registry.example.com", 39 - NewImages: []activities.Image{ 40 - {Repository: "docker.io/khuedoan/blog", Tag: "abc123def456789"}, 41 - }, 42 - } 43 - workspace := "/tmp/cloudlab-repos/abc123" 44 - appFilePath := workspace + "/apps/khuedoan/blog/production.yaml" 45 - mockPushResult := &activities.PushResult{ 46 - Reference: "registry.example.com/khuedoan/blog:production", 47 - Digest: "sha256:abc123def456", 48 - } 49 - 50 - s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return(workspace, nil) 51 - s.env.OnActivity(activities.UpdateAppVersion, mock.Anything, 52 - workspace+"/apps", input.Namespace, input.App, input.Cluster, input.NewImages).Return(true, nil) 53 - s.env.OnActivity(activities.GitAdd, mock.Anything, appFilePath).Return(nil) 54 - s.env.OnActivity(activities.GitCommit, mock.Anything, workspace, "chore(khuedoan/blog): update production version").Return(nil) 55 - s.env.OnActivity(activities.GitPush, mock.Anything, workspace).Return(nil) 56 - s.env.OnActivity(activities.PushRenderedApp, mock.Anything, 57 - workspace+"/apps", input.Namespace, input.App, input.Cluster, input.Registry).Return(mockPushResult, nil) 58 - 59 - s.env.ExecuteWorkflow(AppUpdate, input) 60 - 61 - s.True(s.env.IsWorkflowCompleted()) 62 - s.NoError(s.env.GetWorkflowError()) 63 - } 64 - 65 - func (s *AppUpdateWorkflowTestSuite) TestAppUpdate_CloneFailure() { 66 - input := AppUpdateInput{ 67 - Url: "https://github.com/example/invalid-repo.git", 68 - Revision: "main", 69 - Namespace: "test", 70 - App: "app", 71 - Cluster: "local", 72 - Registry: "registry.127.0.0.1.sslip.io", 73 - NewImages: []activities.Image{ 74 - {Repository: "test/app", Tag: "v1.0.0"}, 75 - }, 76 - } 77 - 78 - s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return("", errors.New("repository not found")) 79 - 80 - s.env.ExecuteWorkflow(AppUpdate, input) 81 - 82 - s.True(s.env.IsWorkflowCompleted()) 83 - s.Error(s.env.GetWorkflowError()) 84 - s.Contains(s.env.GetWorkflowError().Error(), "failed to clone repository") 85 - } 86 - 87 - func (s *AppUpdateWorkflowTestSuite) TestAppUpdate_UpdateAppVersionFailure() { 88 - input := AppUpdateInput{ 89 - Url: "https://github.com/example/cloudlab.git", 90 - Revision: "main", 91 - Namespace: "finance", 92 - App: "actualbudget", 93 - Cluster: "local", 94 - Registry: "registry.127.0.0.1.sslip.io", 95 - NewImages: []activities.Image{ 96 - {Repository: "docker.io/actualbudget/actual-server", Tag: "25.7.0-alpine"}, 97 - }, 98 - } 99 - workspace := "/tmp/cloudlab-repos/def456" 100 - 101 - s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return(workspace, nil) 102 - s.env.OnActivity(activities.UpdateAppVersion, mock.Anything, 103 - workspace+"/apps", input.Namespace, input.App, input.Cluster, input.NewImages).Return(false, 104 - errors.New("failed to read file: no such file or directory")) 105 - 106 - s.env.ExecuteWorkflow(AppUpdate, input) 107 - 108 - s.True(s.env.IsWorkflowCompleted()) 109 - s.Error(s.env.GetWorkflowError()) 110 - s.Contains(s.env.GetWorkflowError().Error(), "failed to update app version") 111 - } 112 - 113 - func (s *AppUpdateWorkflowTestSuite) TestAppUpdate_GitAddFailure() { 114 - input := AppUpdateInput{ 115 - Url: "https://github.com/example/cloudlab.git", 116 - Revision: "main", 117 - Namespace: "khuedoan", 118 - App: "notes", 119 - Cluster: "production", 120 - Registry: "registry.example.com", 121 - NewImages: []activities.Image{ 122 - {Repository: "ghcr.io/silverbulletmd/silverbullet", Tag: "v3"}, 123 - }, 124 - } 125 - workspace := "/tmp/cloudlab-repos/ghi789" 126 - appFilePath := workspace + "/apps/khuedoan/notes/production.yaml" 127 - 128 - s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return(workspace, nil) 129 - s.env.OnActivity(activities.UpdateAppVersion, mock.Anything, 130 - workspace+"/apps", input.Namespace, input.App, input.Cluster, input.NewImages).Return(true, nil) 131 - s.env.OnActivity(activities.GitAdd, mock.Anything, appFilePath).Return( 132 - errors.New("git add failed: file not found")) 133 - 134 - s.env.ExecuteWorkflow(AppUpdate, input) 135 - 136 - s.True(s.env.IsWorkflowCompleted()) 137 - s.Error(s.env.GetWorkflowError()) 138 - s.Contains(s.env.GetWorkflowError().Error(), "failed to add changes to git") 139 - } 140 - 141 - func (s *AppUpdateWorkflowTestSuite) TestAppUpdate_GitCommitFailure() { 142 - input := AppUpdateInput{ 143 - Url: "https://github.com/example/cloudlab.git", 144 - Revision: "main", 145 - Namespace: "khuedoan", 146 - App: "notes", 147 - Cluster: "production", 148 - Registry: "registry.example.com", 149 - NewImages: []activities.Image{ 150 - {Repository: "ghcr.io/silverbulletmd/silverbullet", Tag: "v3"}, 151 - }, 152 - } 153 - workspace := "/tmp/cloudlab-repos/ghi789" 154 - appFilePath := workspace + "/apps/khuedoan/notes/production.yaml" 155 - 156 - s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return(workspace, nil) 157 - s.env.OnActivity(activities.UpdateAppVersion, mock.Anything, 158 - workspace+"/apps", input.Namespace, input.App, input.Cluster, input.NewImages).Return(true, nil) 159 - s.env.OnActivity(activities.GitAdd, mock.Anything, appFilePath).Return(nil) 160 - s.env.OnActivity(activities.GitCommit, mock.Anything, workspace, "chore(khuedoan/notes): update production version").Return( 161 - errors.New("git commit failed: nothing to commit")) 162 - 163 - s.env.ExecuteWorkflow(AppUpdate, input) 164 - 165 - s.True(s.env.IsWorkflowCompleted()) 166 - s.Error(s.env.GetWorkflowError()) 167 - s.Contains(s.env.GetWorkflowError().Error(), "failed to commit changes to git") 168 - } 169 - 170 - func (s *AppUpdateWorkflowTestSuite) TestAppUpdate_GitPushFailure() { 171 - input := AppUpdateInput{ 172 - Url: "https://github.com/example/cloudlab.git", 173 - Revision: "main", 174 - Namespace: "khuedoan", 175 - App: "notes", 176 - Cluster: "production", 177 - Registry: "registry.example.com", 178 - NewImages: []activities.Image{ 179 - {Repository: "ghcr.io/silverbulletmd/silverbullet", Tag: "v3"}, 180 - }, 181 - } 182 - workspace := "/tmp/cloudlab-repos/ghi789" 183 - appFilePath := workspace + "/apps/khuedoan/notes/production.yaml" 184 - 185 - s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return(workspace, nil) 186 - s.env.OnActivity(activities.UpdateAppVersion, mock.Anything, 187 - workspace+"/apps", input.Namespace, input.App, input.Cluster, input.NewImages).Return(true, nil) 188 - s.env.OnActivity(activities.GitAdd, mock.Anything, appFilePath).Return(nil) 189 - s.env.OnActivity(activities.GitCommit, mock.Anything, workspace, "chore(khuedoan/notes): update production version").Return(nil) 190 - s.env.OnActivity(activities.GitPush, mock.Anything, workspace).Return( 191 - errors.New("git push failed: authentication required")) 192 - 193 - s.env.ExecuteWorkflow(AppUpdate, input) 194 - 195 - s.True(s.env.IsWorkflowCompleted()) 196 - s.Error(s.env.GetWorkflowError()) 197 - s.Contains(s.env.GetWorkflowError().Error(), "failed to push changes to git") 198 - } 199 - 200 - func (s *AppUpdateWorkflowTestSuite) TestAppUpdate_PushRenderedAppFailure() { 201 - input := AppUpdateInput{ 202 - Url: "https://github.com/example/cloudlab.git", 203 - Revision: "main", 204 - Namespace: "khuedoan", 205 - App: "notes", 206 - Cluster: "production", 207 - Registry: "registry.example.com", 208 - NewImages: []activities.Image{ 209 - {Repository: "ghcr.io/silverbulletmd/silverbullet", Tag: "v3"}, 210 - }, 211 - } 212 - workspace := "/tmp/cloudlab-repos/ghi789" 213 - appFilePath := workspace + "/apps/khuedoan/notes/production.yaml" 214 - 215 - s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return(workspace, nil) 216 - s.env.OnActivity(activities.UpdateAppVersion, mock.Anything, 217 - workspace+"/apps", input.Namespace, input.App, input.Cluster, input.NewImages).Return(true, nil) 218 - s.env.OnActivity(activities.GitAdd, mock.Anything, appFilePath).Return(nil) 219 - s.env.OnActivity(activities.GitCommit, mock.Anything, workspace, "chore(khuedoan/notes): update production version").Return(nil) 220 - s.env.OnActivity(activities.GitPush, mock.Anything, workspace).Return(nil) 221 - s.env.OnActivity(activities.PushRenderedApp, mock.Anything, 222 - workspace+"/apps", input.Namespace, input.App, input.Cluster, input.Registry).Return( 223 - nil, errors.New("helm template failed: chart not found")) 224 - 225 - s.env.ExecuteWorkflow(AppUpdate, input) 226 - 227 - s.True(s.env.IsWorkflowCompleted()) 228 - s.Error(s.env.GetWorkflowError()) 229 - s.Contains(s.env.GetWorkflowError().Error(), "failed to push rendered app to registry") 230 - } 231 - 232 - func (s *AppUpdateWorkflowTestSuite) TestAppUpdate_MultipleImages() { 233 - input := AppUpdateInput{ 234 - Url: "https://github.com/example/cloudlab.git", 235 - Revision: "develop", 236 - Namespace: "test", 237 - App: "example", 238 - Cluster: "local", 239 - Registry: "registry.registry.svc.cluster.local", 240 - NewImages: []activities.Image{ 241 - {Repository: "registry.registry.svc.cluster.local/example-service", Tag: "newcommithash123"}, 242 - {Repository: "docker.io/redis", Tag: "7.0-alpine"}, 243 - }, 244 - } 245 - workspace := "/tmp/cloudlab-repos/jkl012" 246 - appFilePath := workspace + "/apps/test/example/local.yaml" 247 - mockPushResult := &activities.PushResult{ 248 - Reference: "registry.registry.svc.cluster.local/test/example:local", 249 - Digest: "sha256:def789abc123", 250 - } 251 - 252 - s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return(workspace, nil) 253 - s.env.OnActivity(activities.UpdateAppVersion, mock.Anything, 254 - workspace+"/apps", input.Namespace, input.App, input.Cluster, input.NewImages).Return(true, nil) 255 - s.env.OnActivity(activities.GitAdd, mock.Anything, appFilePath).Return(nil) 256 - s.env.OnActivity(activities.GitCommit, mock.Anything, workspace, "chore(test/example): update local version").Return(nil) 257 - s.env.OnActivity(activities.GitPush, mock.Anything, workspace).Return(nil) 258 - s.env.OnActivity(activities.PushRenderedApp, mock.Anything, 259 - workspace+"/apps", input.Namespace, input.App, input.Cluster, input.Registry).Return(mockPushResult, nil) 260 - 261 - s.env.ExecuteWorkflow(AppUpdate, input) 262 - 263 - s.True(s.env.IsWorkflowCompleted()) 264 - s.NoError(s.env.GetWorkflowError()) 265 - } 266 - 267 - func (s *AppUpdateWorkflowTestSuite) TestAppUpdate_RealWorldExample() { 268 - // Test with realistic data from the actual apps directory 269 - input := AppUpdateInput{ 270 - Url: "https://github.com/khuedoan/cloudlab.git", 271 - Revision: "main", 272 - Namespace: "khuedoan", 273 - App: "blog", 274 - Cluster: "production", 275 - Registry: "registry.cloudlab.khuedoan.com", 276 - NewImages: []activities.Image{ 277 - {Repository: "docker.io/khuedoan/blog", Tag: "1234567890abcdef1234567890abcdef12345678"}, 278 - }, 279 - } 280 - workspace := "/tmp/cloudlab-repos/realworld123" 281 - appFilePath := workspace + "/apps/khuedoan/blog/production.yaml" 282 - mockPushResult := &activities.PushResult{ 283 - Reference: "registry.cloudlab.khuedoan.com/khuedoan/blog:production", 284 - Digest: "sha256:realworld789", 285 - } 286 - 287 - s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return(workspace, nil) 288 - s.env.OnActivity(activities.UpdateAppVersion, mock.Anything, 289 - workspace+"/apps", input.Namespace, input.App, input.Cluster, input.NewImages).Return(true, nil) 290 - s.env.OnActivity(activities.GitAdd, mock.Anything, appFilePath).Return(nil) 291 - s.env.OnActivity(activities.GitCommit, mock.Anything, workspace, "chore(khuedoan/blog): update production version").Return(nil) 292 - s.env.OnActivity(activities.GitPush, mock.Anything, workspace).Return(nil) 293 - s.env.OnActivity(activities.PushRenderedApp, mock.Anything, 294 - workspace+"/apps", input.Namespace, input.App, input.Cluster, input.Registry).Return(mockPushResult, nil) 295 - 296 - s.env.ExecuteWorkflow(AppUpdate, input) 297 - 298 - s.True(s.env.IsWorkflowCompleted()) 299 - s.NoError(s.env.GetWorkflowError()) 300 - } 301 - 302 - func (s *AppUpdateWorkflowTestSuite) TestAppUpdate_ActivityTimeout() { 303 - input := AppUpdateInput{ 304 - Url: "https://github.com/example/cloudlab.git", 305 - Revision: "main", 306 - Namespace: "test", 307 - App: "slow-app", 308 - Cluster: "production", 309 - Registry: "registry.example.com", 310 - NewImages: []activities.Image{ 311 - {Repository: "test/slow-app", Tag: "v1.0.0"}, 312 - }, 313 - } 314 - 315 - // Simulate a timeout - we'll just return an error since the test timeout catches this 316 - s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return("", errors.New("timeout")) 317 - 318 - s.env.ExecuteWorkflow(AppUpdate, input) 319 - 320 - s.True(s.env.IsWorkflowCompleted()) 321 - s.Error(s.env.GetWorkflowError()) 322 - } 323 - 324 - func (s *AppUpdateWorkflowTestSuite) TestAppUpdate_EmptyImages() { 325 - input := AppUpdateInput{ 326 - Url: "https://github.com/example/cloudlab.git", 327 - Revision: "main", 328 - Namespace: "test", 329 - App: "app", 330 - Cluster: "local", 331 - Registry: "registry.127.0.0.1.sslip.io", 332 - NewImages: []activities.Image{}, // Empty images array 333 - } 334 - workspace := "/tmp/cloudlab-repos/empty123" 335 - appFilePath := workspace + "/apps/test/app/local.yaml" 336 - mockPushResult := &activities.PushResult{ 337 - Reference: "registry.127.0.0.1.sslip.io/test/app:local", 338 - Digest: "sha256:empty456", 339 - } 340 - 341 - s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return(workspace, nil) 342 - s.env.OnActivity(activities.UpdateAppVersion, mock.Anything, 343 - workspace+"/apps", input.Namespace, input.App, input.Cluster, input.NewImages).Return(true, nil) 344 - s.env.OnActivity(activities.GitAdd, mock.Anything, appFilePath).Return(nil) 345 - s.env.OnActivity(activities.GitCommit, mock.Anything, workspace, "chore(test/app): update local version").Return(nil) 346 - s.env.OnActivity(activities.GitPush, mock.Anything, workspace).Return(nil) 347 - s.env.OnActivity(activities.PushRenderedApp, mock.Anything, 348 - workspace+"/apps", input.Namespace, input.App, input.Cluster, input.Registry).Return(mockPushResult, nil) 349 - 350 - s.env.ExecuteWorkflow(AppUpdate, input) 351 - 352 - s.True(s.env.IsWorkflowCompleted()) 353 - s.NoError(s.env.GetWorkflowError()) 354 - } 355 - 356 - func (s *AppUpdateWorkflowTestSuite) TestAppUpdate_NoChanges() { 357 - input := AppUpdateInput{ 358 - Url: "https://github.com/example/cloudlab.git", 359 - Revision: "main", 360 - Namespace: "test", 361 - App: "app", 362 - Cluster: "local", 363 - Registry: "registry.example.com", 364 - NewImages: []activities.Image{ 365 - {Repository: "docker.io/test/app", Tag: "existing-tag"}, // Same tag as already in file 366 - }, 367 - } 368 - workspace := "/tmp/cloudlab-repos/nochange123" 369 - 370 - s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return(workspace, nil) 371 - s.env.OnActivity(activities.UpdateAppVersion, mock.Anything, 372 - workspace+"/apps", input.Namespace, input.App, input.Cluster, input.NewImages).Return(false, nil) 373 - // Note: No other activities should be called when there are no changes 374 - 375 - s.env.ExecuteWorkflow(AppUpdate, input) 376 - 377 - s.True(s.env.IsWorkflowCompleted()) 378 - s.NoError(s.env.GetWorkflowError()) 379 - } 380 - 381 - func (s *AppUpdateWorkflowTestSuite) TestAppUpdate_SpecialCharactersInPath() { 382 - input := AppUpdateInput{ 383 - Url: "https://github.com/example/cloudlab.git", 384 - Revision: "feature/special-branch-name", 385 - Namespace: "test-namespace", 386 - App: "app-with-dashes", 387 - Cluster: "staging-env", 388 - Registry: "registry.example.com", 389 - NewImages: []activities.Image{ 390 - {Repository: "registry.example.com/test/app-with-dashes", Tag: "v1.2.3-rc1"}, 391 - }, 392 - } 393 - workspace := "/tmp/cloudlab-repos/special456" 394 - appFilePath := workspace + "/apps/test-namespace/app-with-dashes/staging-env.yaml" 395 - mockPushResult := &activities.PushResult{ 396 - Reference: "registry.example.com/test-namespace/app-with-dashes:staging-env", 397 - Digest: "sha256:special123", 398 - } 399 - 400 - s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return(workspace, nil) 401 - s.env.OnActivity(activities.UpdateAppVersion, mock.Anything, 402 - workspace+"/apps", input.Namespace, input.App, input.Cluster, input.NewImages).Return(true, nil) 403 - s.env.OnActivity(activities.GitAdd, mock.Anything, appFilePath).Return(nil) 404 - s.env.OnActivity(activities.GitCommit, mock.Anything, workspace, "chore(test-namespace/app-with-dashes): update staging-env version").Return(nil) 405 - s.env.OnActivity(activities.GitPush, mock.Anything, workspace).Return(nil) 406 - s.env.OnActivity(activities.PushRenderedApp, mock.Anything, 407 - workspace+"/apps", input.Namespace, input.App, input.Cluster, input.Registry).Return(mockPushResult, nil) 408 - 409 - s.env.ExecuteWorkflow(AppUpdate, input) 410 - 411 - s.True(s.env.IsWorkflowCompleted()) 412 - s.NoError(s.env.GetWorkflowError()) 413 - } 414 - 415 - func TestAppUpdateWorkflowTestSuite(t *testing.T) { 416 - suite.Run(t, new(AppUpdateWorkflowTestSuite)) 417 - }
-94
controller/workflows/apps.go
··· 1 - package workflows 2 - 3 - import ( 4 - "os" 5 - "path/filepath" 6 - "strings" 7 - "time" 8 - 9 - "cloudlab/controller/activities" 10 - 11 - "go.temporal.io/sdk/workflow" 12 - ) 13 - 14 - type AppsInput struct { 15 - Url string 16 - Revision string 17 - Registry string 18 - Cluster string 19 - } 20 - 21 - func Apps(ctx workflow.Context, input PlatformInput) error { 22 - logger := workflow.GetLogger(ctx) 23 - logger.Info("Platform workflow started", "platform", input) 24 - 25 - var workspace string 26 - if err := workflow.ExecuteActivity( 27 - workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ 28 - StartToCloseTimeout: 1 * time.Minute, 29 - }), 30 - activities.Clone, 31 - input.Url, 32 - input.Revision, 33 - ).Get(ctx, &workspace); err != nil { 34 - return err 35 - } 36 - 37 - defer os.RemoveAll(workspace) 38 - 39 - appsDir := workspace + "/apps" 40 - 41 - // TODO this should be a separate activity 42 - var matchedPaths []string 43 - if err := workflow.ExecuteActivity( 44 - workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ 45 - StartToCloseTimeout: 10 * time.Second, 46 - }), 47 - activities.DiscoverApps, 48 - appsDir, 49 - input.Cluster, 50 - ).Get(ctx, &matchedPaths); err != nil { 51 - return err 52 - } 53 - ctx = workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ 54 - StartToCloseTimeout: 1 * time.Minute, 55 - }) 56 - 57 - var futures []workflow.Future 58 - var results []activities.PushResult 59 - 60 - for _, yamlPath := range matchedPaths { 61 - parts := strings.Split(filepath.ToSlash(yamlPath), "/") 62 - if len(parts) < 4 { 63 - logger.Warn("Skipping invalid path", "path", yamlPath) 64 - continue 65 - } 66 - 67 - namespace := parts[len(parts)-3] 68 - app := parts[len(parts)-2] 69 - 70 - logger.Info("Dispatching PushRenderedHelm", "path", yamlPath, "namespace", namespace, "app", app) 71 - 72 - fut := workflow.ExecuteActivity( 73 - ctx, 74 - activities.PushRenderedApp, 75 - appsDir, 76 - namespace, 77 - app, 78 - input.Cluster, 79 - input.Registry, 80 - ) 81 - futures = append(futures, fut) 82 - } 83 - 84 - for _, fut := range futures { 85 - var result activities.PushResult 86 - if err := fut.Get(ctx, &result); err != nil { 87 - return err 88 - } 89 - results = append(results, result) 90 - } 91 - 92 - logger.Info("Finished pushing all matching apps", "count", len(results)) 93 - return nil 94 - }
-101
controller/workflows/infra.go
··· 1 - package workflows 2 - 3 - import ( 4 - "fmt" 5 - "os" 6 - "time" 7 - 8 - "cloudlab/controller/activities" 9 - 10 - "go.temporal.io/sdk/temporal" 11 - "go.temporal.io/sdk/workflow" 12 - ) 13 - 14 - type InfraInputs struct { 15 - Url string 16 - Revision string 17 - OldRevision string 18 - Stack string 19 - } 20 - 21 - func Infra(ctx workflow.Context, input InfraInputs) (*activities.Graph, error) { 22 - logger := workflow.GetLogger(ctx) 23 - logger.Info("Infra workflow started", "infra", input) 24 - 25 - cloneCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ 26 - StartToCloseTimeout: 1 * time.Minute, 27 - }) 28 - 29 - var workspace string 30 - if err := workflow.ExecuteActivity(cloneCtx, activities.Clone, input.Url, input.Revision).Get(ctx, &workspace); err != nil { 31 - return nil, err 32 - } 33 - 34 - defer os.RemoveAll(workspace) 35 - 36 - // Graph and analysis activities: moderate timeout 37 - analysisCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ 38 - StartToCloseTimeout: 5 * time.Second, 39 - RetryPolicy: &temporal.RetryPolicy{ 40 - MaximumAttempts: 1, 41 - }, 42 - }) 43 - 44 - var graph *activities.Graph 45 - var prunedGraph *activities.Graph 46 - 47 - // Get the terragrunt graph 48 - if err := workflow.ExecuteActivity(analysisCtx, activities.TerragruntGraph, workspace+"/infra/"+input.Stack).Get(ctx, &graph); err != nil { 49 - return nil, err 50 - } 51 - 52 - // If oldRevision is not provided, use the full graph (no pruning) 53 - if input.OldRevision == "" { 54 - logger.Info("No oldRevision provided, using full graph", "nodes", len(graph.Nodes)) 55 - prunedGraph = graph 56 - } else { 57 - // Determine changed modules and prune graph 58 - var changedModules []string 59 - if err := workflow.ExecuteActivity(analysisCtx, activities.ChangedModules, workspace, input.OldRevision).Get(ctx, &changedModules); err != nil { 60 - return nil, err 61 - } 62 - 63 - if err := workflow.ExecuteActivity(analysisCtx, activities.PruneGraph, graph, changedModules).Get(ctx, &prunedGraph); err != nil { 64 - return nil, err 65 - } 66 - 67 - logger.Info("Graph pruning completed", "nodes", len(prunedGraph.Nodes)) 68 - } 69 - 70 - for levelIndex, level := range prunedGraph.TopologicalSort() { 71 - logger.Info("Starting terragrunt apply", "level", levelIndex, "modules", level) 72 - 73 - var futures []workflow.Future 74 - for _, module := range level { 75 - moduleCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ 76 - StartToCloseTimeout: 30 * time.Minute, 77 - HeartbeatTimeout: 2 * time.Minute, 78 - Summary: fmt.Sprintf("%s/%s", input.Stack, module), 79 - RetryPolicy: &temporal.RetryPolicy{ 80 - MaximumAttempts: 2, 81 - NonRetryableErrorTypes: []string{ 82 - "TerraformValidationError", 83 - "TerraformPlanError", 84 - }, 85 - }, 86 - }) 87 - futures = append(futures, workflow.ExecuteActivity(moduleCtx, activities.TerragruntApply, input.Url, input.Revision, module, input.Stack)) 88 - } 89 - 90 - for i, future := range futures { 91 - if err := future.Get(ctx, nil); err != nil { 92 - logger.Error("TerragruntApply failed", "module", level[i], "level", levelIndex, "error", err) 93 - return nil, err 94 - } 95 - logger.Info("Module apply completed", "module", level[i], "level", levelIndex) 96 - } 97 - } 98 - 99 - logger.Info("Infra workflow completed", "levels", len(prunedGraph.TopologicalSort()), "modules", len(prunedGraph.Nodes)) 100 - return prunedGraph, nil 101 - }
-465
controller/workflows/infra_test.go
··· 1 - package workflows 2 - 3 - import ( 4 - "context" 5 - "errors" 6 - "testing" 7 - "time" 8 - 9 - "cloudlab/controller/activities" 10 - 11 - "github.com/stretchr/testify/mock" 12 - "github.com/stretchr/testify/suite" 13 - "go.temporal.io/sdk/testsuite" 14 - ) 15 - 16 - type InfraWorkflowTestSuite struct { 17 - suite.Suite 18 - testsuite.WorkflowTestSuite 19 - 20 - env *testsuite.TestWorkflowEnvironment 21 - } 22 - 23 - func (s *InfraWorkflowTestSuite) SetupTest() { 24 - s.env = s.NewTestWorkflowEnvironment() 25 - // Set a reasonable timeout for tests 26 - s.env.SetTestTimeout(30 * time.Second) 27 - } 28 - 29 - func (s *InfraWorkflowTestSuite) AfterTest(suiteName, testName string) { 30 - s.env.AssertExpectations(s.T()) 31 - } 32 - 33 - func (s *InfraWorkflowTestSuite) TestInfraWorkflow_Success() { 34 - input := InfraInputs{ 35 - Url: "https://github.com/example/repo.git", 36 - Revision: "main", 37 - OldRevision: "HEAD~1", 38 - Stack: "dev", 39 - } 40 - repoPath := "/tmp/infra-12345" 41 - changedModules := []string{"module1", "module2"} 42 - 43 - graph := &activities.Graph{ 44 - Nodes: map[string]bool{ 45 - "module1": true, 46 - "module2": true, 47 - }, 48 - Edges: map[string][]string{ 49 - "module1": {"module2"}, // module1 depends on module2 50 - }, 51 - } 52 - 53 - prunedGraph := graph // Both modules changed 54 - 55 - s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return(repoPath, nil) 56 - s.env.OnActivity(activities.TerragruntGraph, mock.Anything, repoPath+"/infra/"+input.Stack).Return(graph, nil) 57 - s.env.OnActivity(activities.ChangedModules, mock.Anything, repoPath, input.OldRevision).Return(changedModules, nil) 58 - s.env.OnActivity(activities.PruneGraph, mock.Anything, graph, changedModules).Return(prunedGraph, nil) 59 - s.env.OnActivity(activities.TerragruntApply, mock.Anything, input.Url, input.Revision, "module2", input.Stack).Return(nil) 60 - s.env.OnActivity(activities.TerragruntApply, mock.Anything, input.Url, input.Revision, "module1", input.Stack).Return(nil) 61 - 62 - s.env.ExecuteWorkflow(Infra, input) 63 - 64 - s.True(s.env.IsWorkflowCompleted()) 65 - s.NoError(s.env.GetWorkflowError()) 66 - } 67 - 68 - func (s *InfraWorkflowTestSuite) TestInfraWorkflow_CloneFailure() { 69 - input := InfraInputs{ 70 - Url: "https://github.com/example/invalid-repo.git", 71 - Revision: "main", 72 - OldRevision: "HEAD~1", 73 - Stack: "dev", 74 - } 75 - 76 - // Mock Clone to return error 77 - s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return("", errors.New("repository not found")) 78 - 79 - s.env.ExecuteWorkflow(Infra, input) 80 - 81 - s.True(s.env.IsWorkflowCompleted()) 82 - s.Error(s.env.GetWorkflowError()) 83 - s.Contains(s.env.GetWorkflowError().Error(), "repository not found") 84 - } 85 - 86 - func (s *InfraWorkflowTestSuite) TestInfraWorkflow_TerragruntGraphFailure() { 87 - input := InfraInputs{ 88 - Url: "https://github.com/example/repo.git", 89 - Revision: "main", 90 - OldRevision: "HEAD~1", 91 - Stack: "dev", 92 - } 93 - repoPath := "/tmp/infra-12345" 94 - 95 - s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return(repoPath, nil) 96 - s.env.OnActivity(activities.TerragruntGraph, mock.Anything, repoPath+"/infra/"+input.Stack).Return( 97 - (*activities.Graph)(nil), errors.New("terragrunt dag graph failed")) 98 - 99 - s.env.ExecuteWorkflow(Infra, input) 100 - 101 - s.True(s.env.IsWorkflowCompleted()) 102 - s.Error(s.env.GetWorkflowError()) 103 - s.Contains(s.env.GetWorkflowError().Error(), "terragrunt dag graph failed") 104 - } 105 - 106 - func (s *InfraWorkflowTestSuite) TestInfraWorkflow_ChangedModulesFailure() { 107 - input := InfraInputs{ 108 - Url: "https://github.com/example/repo.git", 109 - Revision: "main", 110 - OldRevision: "HEAD~1", 111 - Stack: "dev", 112 - } 113 - repoPath := "/tmp/infra-12345" 114 - graph := &activities.Graph{ 115 - Nodes: map[string]bool{"module1": true}, 116 - Edges: map[string][]string{}, 117 - } 118 - 119 - s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return(repoPath, nil) 120 - s.env.OnActivity(activities.TerragruntGraph, mock.Anything, repoPath+"/infra/"+input.Stack).Return(graph, nil) 121 - s.env.OnActivity(activities.ChangedModules, mock.Anything, repoPath, input.OldRevision).Return( 122 - []string{}, errors.New("git diff failed")) 123 - 124 - s.env.ExecuteWorkflow(Infra, input) 125 - 126 - s.True(s.env.IsWorkflowCompleted()) 127 - s.Error(s.env.GetWorkflowError()) 128 - s.Contains(s.env.GetWorkflowError().Error(), "git diff failed") 129 - } 130 - 131 - func (s *InfraWorkflowTestSuite) TestInfraWorkflow_TerragruntApplyFailure() { 132 - input := InfraInputs{ 133 - Url: "https://github.com/example/repo.git", 134 - Revision: "main", 135 - OldRevision: "HEAD~1", 136 - Stack: "dev", 137 - } 138 - repoPath := "/tmp/infra-12345" 139 - changedModules := []string{"module1"} 140 - 141 - graph := &activities.Graph{ 142 - Nodes: map[string]bool{"module1": true}, 143 - Edges: map[string][]string{}, 144 - } 145 - 146 - prunedGraph := &activities.Graph{ 147 - Nodes: map[string]bool{"module1": true}, 148 - Edges: map[string][]string{}, 149 - } 150 - 151 - s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return(repoPath, nil) 152 - s.env.OnActivity(activities.TerragruntGraph, mock.Anything, repoPath+"/infra/"+input.Stack).Return(graph, nil) 153 - s.env.OnActivity(activities.ChangedModules, mock.Anything, repoPath, input.OldRevision).Return(changedModules, nil) 154 - s.env.OnActivity(activities.PruneGraph, mock.Anything, graph, changedModules).Return(prunedGraph, nil) 155 - s.env.OnActivity(activities.TerragruntApply, mock.Anything, input.Url, input.Revision, "module1", input.Stack).Return( 156 - errors.New("terragrunt apply failed: resource conflict")) 157 - 158 - s.env.ExecuteWorkflow(Infra, input) 159 - 160 - s.True(s.env.IsWorkflowCompleted()) 161 - s.Error(s.env.GetWorkflowError()) 162 - s.Contains(s.env.GetWorkflowError().Error(), "terragrunt apply failed") 163 - } 164 - 165 - func (s *InfraWorkflowTestSuite) TestInfraWorkflow_ComplexDependencyGraph() { 166 - input := InfraInputs{ 167 - Url: "https://github.com/example/repo.git", 168 - Revision: "main", 169 - OldRevision: "HEAD~1", 170 - Stack: "prod", 171 - } 172 - repoPath := "/tmp/infra-67890" 173 - changedModules := []string{"vpc", "database"} 174 - 175 - // Complex dependency graph: 176 - // app -> [database, loadbalancer] 177 - // database -> vpc 178 - // loadbalancer -> vpc 179 - // monitoring -> app 180 - graph := &activities.Graph{ 181 - Nodes: map[string]bool{ 182 - "vpc": true, 183 - "database": true, 184 - "loadbalancer": true, 185 - "app": true, 186 - "monitoring": true, 187 - }, 188 - Edges: map[string][]string{ 189 - "app": {"database", "loadbalancer"}, 190 - "database": {"vpc"}, 191 - "loadbalancer": {"vpc"}, 192 - "monitoring": {"app"}, 193 - }, 194 - } 195 - 196 - // Pruned graph should contain changed modules and their dependents 197 - prunedGraph := &activities.Graph{ 198 - Nodes: map[string]bool{ 199 - "vpc": true, 200 - "database": true, 201 - "app": true, 202 - "monitoring": true, 203 - }, 204 - Edges: map[string][]string{ 205 - "app": {"database"}, 206 - "database": {"vpc"}, 207 - "monitoring": {"app"}, 208 - }, 209 - } 210 - 211 - s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return(repoPath, nil) 212 - s.env.OnActivity(activities.TerragruntGraph, mock.Anything, repoPath+"/infra/"+input.Stack).Return(graph, nil) 213 - s.env.OnActivity(activities.ChangedModules, mock.Anything, repoPath, input.OldRevision).Return(changedModules, nil) 214 - s.env.OnActivity(activities.PruneGraph, mock.Anything, graph, changedModules).Return(prunedGraph, nil) 215 - 216 - // Mock TerragruntApply calls in dependency order 217 - // Level 0: vpc 218 - s.env.OnActivity(activities.TerragruntApply, mock.Anything, input.Url, input.Revision, "vpc", input.Stack).Return(nil) 219 - // Level 1: database 220 - s.env.OnActivity(activities.TerragruntApply, mock.Anything, input.Url, input.Revision, "database", input.Stack).Return(nil) 221 - // Level 2: app 222 - s.env.OnActivity(activities.TerragruntApply, mock.Anything, input.Url, input.Revision, "app", input.Stack).Return(nil) 223 - // Level 3: monitoring 224 - s.env.OnActivity(activities.TerragruntApply, mock.Anything, input.Url, input.Revision, "monitoring", input.Stack).Return(nil) 225 - 226 - s.env.ExecuteWorkflow(Infra, input) 227 - 228 - s.True(s.env.IsWorkflowCompleted()) 229 - s.NoError(s.env.GetWorkflowError()) 230 - 231 - var result *activities.Graph 232 - s.NoError(s.env.GetWorkflowResult(&result)) 233 - s.True(result.Nodes["vpc"]) 234 - s.True(result.Nodes["database"]) 235 - s.True(result.Nodes["app"]) 236 - s.True(result.Nodes["monitoring"]) 237 - } 238 - 239 - func (s *InfraWorkflowTestSuite) TestInfraWorkflow_NoChangedModules() { 240 - input := InfraInputs{ 241 - Url: "https://github.com/example/repo.git", 242 - Revision: "main", 243 - OldRevision: "HEAD~1", 244 - Stack: "dev", 245 - } 246 - repoPath := "/tmp/infra-12345" 247 - changedModules := []string{} // No changes 248 - 249 - graph := &activities.Graph{ 250 - Nodes: map[string]bool{ 251 - "module1": true, 252 - "module2": true, 253 - }, 254 - Edges: map[string][]string{ 255 - "module1": {"module2"}, 256 - }, 257 - } 258 - 259 - // Pruned graph should be empty 260 - prunedGraph := &activities.Graph{ 261 - Nodes: map[string]bool{}, 262 - Edges: map[string][]string{}, 263 - } 264 - 265 - s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return(repoPath, nil) 266 - s.env.OnActivity(activities.TerragruntGraph, mock.Anything, repoPath+"/infra/"+input.Stack).Return(graph, nil) 267 - s.env.OnActivity(activities.ChangedModules, mock.Anything, repoPath, input.OldRevision).Return(changedModules, nil) 268 - s.env.OnActivity(activities.PruneGraph, mock.Anything, graph, changedModules).Return(prunedGraph, nil) 269 - 270 - // No TerragruntApply calls should be made since no modules to deploy 271 - 272 - s.env.ExecuteWorkflow(Infra, input) 273 - 274 - s.True(s.env.IsWorkflowCompleted()) 275 - s.NoError(s.env.GetWorkflowError()) 276 - 277 - var result *activities.Graph 278 - s.NoError(s.env.GetWorkflowResult(&result)) 279 - s.Empty(result.Nodes) 280 - } 281 - 282 - func (s *InfraWorkflowTestSuite) TestInfraWorkflow_ActivityTimeout() { 283 - input := InfraInputs{ 284 - Url: "https://github.com/example/repo.git", 285 - Revision: "main", 286 - OldRevision: "HEAD~1", 287 - Stack: "dev", 288 - } 289 - 290 - // Mock Clone to simulate a timeout scenario 291 - s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return( 292 - "", errors.New("activity timeout")) 293 - 294 - s.env.ExecuteWorkflow(Infra, input) 295 - 296 - s.True(s.env.IsWorkflowCompleted()) 297 - s.Error(s.env.GetWorkflowError()) 298 - } 299 - 300 - func (s *InfraWorkflowTestSuite) TestInfraWorkflow_ParallelExecution() { 301 - // Test that modules at the same dependency level are executed in parallel 302 - input := InfraInputs{ 303 - Url: "https://github.com/example/repo.git", 304 - Revision: "main", 305 - OldRevision: "HEAD~1", 306 - Stack: "dev", 307 - } 308 - repoPath := "/tmp/infra-12345" 309 - changedModules := []string{"module-a", "module-b", "module-c"} 310 - 311 - // Graph with parallel modules: 312 - // module-a and module-b can run in parallel (both depend on module-c) 313 - graph := &activities.Graph{ 314 - Nodes: map[string]bool{ 315 - "module-a": true, 316 - "module-b": true, 317 - "module-c": true, 318 - }, 319 - Edges: map[string][]string{ 320 - "module-a": {"module-c"}, 321 - "module-b": {"module-c"}, 322 - }, 323 - } 324 - 325 - prunedGraph := graph // All modules changed 326 - 327 - s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return(repoPath, nil) 328 - s.env.OnActivity(activities.TerragruntGraph, mock.Anything, repoPath+"/infra/"+input.Stack).Return(graph, nil) 329 - s.env.OnActivity(activities.ChangedModules, mock.Anything, repoPath, input.OldRevision).Return(changedModules, nil) 330 - s.env.OnActivity(activities.PruneGraph, mock.Anything, graph, changedModules).Return(prunedGraph, nil) 331 - 332 - // Level 0: module-c 333 - s.env.OnActivity(activities.TerragruntApply, mock.Anything, input.Url, input.Revision, "module-c", input.Stack).Return(nil) 334 - // Level 1: module-a and module-b (should execute in parallel) 335 - s.env.OnActivity(activities.TerragruntApply, mock.Anything, input.Url, input.Revision, "module-a", input.Stack).Return(nil) 336 - s.env.OnActivity(activities.TerragruntApply, mock.Anything, input.Url, input.Revision, "module-b", input.Stack).Return(nil) 337 - 338 - s.env.ExecuteWorkflow(Infra, input) 339 - 340 - s.True(s.env.IsWorkflowCompleted()) 341 - s.NoError(s.env.GetWorkflowError()) 342 - } 343 - 344 - func (s *InfraWorkflowTestSuite) TestInfraWorkflow_WorkerFailureRetry() { 345 - // Test that TerragruntApply can handle worker failure and retry on a different worker 346 - // by ensuring the activity is self-contained (clones repo internally) 347 - input := InfraInputs{ 348 - Url: "https://github.com/example/repo.git", 349 - Revision: "main", 350 - OldRevision: "HEAD~1", 351 - Stack: "dev", 352 - } 353 - repoPath := "/tmp/infra-12345" 354 - newWorkerRepoPath := "/tmp/infra-67890" 355 - changedModules := []string{"module1"} 356 - 357 - graph := &activities.Graph{ 358 - Nodes: map[string]bool{ 359 - "module1": true, 360 - }, 361 - Edges: map[string][]string{}, 362 - } 363 - 364 - prunedGraph := graph // Only module1 changed 365 - 366 - // Initial workflow activities (successful) 367 - s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return(repoPath, nil) 368 - s.env.OnActivity(activities.TerragruntGraph, mock.Anything, repoPath+"/infra/"+input.Stack).Return(graph, nil) 369 - s.env.OnActivity(activities.ChangedModules, mock.Anything, repoPath, input.OldRevision).Return(changedModules, nil) 370 - s.env.OnActivity(activities.PruneGraph, mock.Anything, graph, changedModules).Return(prunedGraph, nil) 371 - 372 - // Simulate worker failure and retry on different worker 373 - applyCallCount := 0 374 - s.env.OnActivity(activities.TerragruntApply, mock.Anything, input.Url, input.Revision, "module1", input.Stack).Return( 375 - func(ctx context.Context, repoUrl, revision, modulePath, stack string) error { 376 - applyCallCount++ 377 - if applyCallCount == 1 { 378 - // First attempt fails (simulating worker failure) 379 - return errors.New("worker failed: connection lost") 380 - } 381 - // Second attempt succeeds (activity is self-contained and clones repo again) 382 - return nil 383 - }) 384 - 385 - // Mock additional Clone calls for TerragruntApply retries 386 - // The activity will call Clone internally to ensure repo availability 387 - s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return(newWorkerRepoPath, nil).Maybe() 388 - 389 - s.env.ExecuteWorkflow(Infra, input) 390 - 391 - s.True(s.env.IsWorkflowCompleted()) 392 - s.NoError(s.env.GetWorkflowError()) 393 - 394 - var result *activities.Graph 395 - s.NoError(s.env.GetWorkflowResult(&result)) 396 - s.True(result.Nodes["module1"]) 397 - } 398 - 399 - func (s *InfraWorkflowTestSuite) TestInfraWorkflow_NoOldRevisionProvided() { 400 - // Test that when oldRevision is not provided, all modules are deployed 401 - input := InfraInputs{ 402 - Url: "https://github.com/example/repo.git", 403 - Revision: "main", 404 - OldRevision: "", // Empty string - no old revision provided 405 - Stack: "dev", 406 - } 407 - repoPath := "/tmp/infra-12345" 408 - 409 - // Full graph with all modules 410 - graph := &activities.Graph{ 411 - Nodes: map[string]bool{ 412 - "vpc": true, 413 - "database": true, 414 - "loadbalancer": true, 415 - "app": true, 416 - "monitoring": true, 417 - }, 418 - Edges: map[string][]string{ 419 - "app": {"database", "loadbalancer"}, 420 - "database": {"vpc"}, 421 - "loadbalancer": {"vpc"}, 422 - "monitoring": {"app"}, 423 - }, 424 - } 425 - 426 - // When oldRevision is empty, the workflow should use the full graph 427 - // without calling ChangedModules or PruneGraph activities 428 - 429 - s.env.OnActivity(activities.Clone, mock.Anything, input.Url, input.Revision).Return(repoPath, nil) 430 - s.env.OnActivity(activities.TerragruntGraph, mock.Anything, repoPath+"/infra/"+input.Stack).Return(graph, nil) 431 - 432 - // Mock TerragruntApply calls in dependency order for all modules 433 - // Level 0: vpc 434 - s.env.OnActivity(activities.TerragruntApply, mock.Anything, input.Url, input.Revision, "vpc", input.Stack).Return(nil) 435 - // Level 1: database, loadbalancer 436 - s.env.OnActivity(activities.TerragruntApply, mock.Anything, input.Url, input.Revision, "database", input.Stack).Return(nil) 437 - s.env.OnActivity(activities.TerragruntApply, mock.Anything, input.Url, input.Revision, "loadbalancer", input.Stack).Return(nil) 438 - // Level 2: app 439 - s.env.OnActivity(activities.TerragruntApply, mock.Anything, input.Url, input.Revision, "app", input.Stack).Return(nil) 440 - // Level 3: monitoring 441 - s.env.OnActivity(activities.TerragruntApply, mock.Anything, input.Url, input.Revision, "monitoring", input.Stack).Return(nil) 442 - 443 - s.env.ExecuteWorkflow(Infra, input) 444 - 445 - s.True(s.env.IsWorkflowCompleted()) 446 - s.NoError(s.env.GetWorkflowError()) 447 - 448 - var result *activities.Graph 449 - s.NoError(s.env.GetWorkflowResult(&result)) 450 - 451 - // Verify that all modules are in the result graph 452 - s.True(result.Nodes["vpc"]) 453 - s.True(result.Nodes["database"]) 454 - s.True(result.Nodes["loadbalancer"]) 455 - s.True(result.Nodes["app"]) 456 - s.True(result.Nodes["monitoring"]) 457 - 458 - // Verify that the result graph has the same structure as the original 459 - s.Equal(len(graph.Nodes), len(result.Nodes)) 460 - s.Equal(len(graph.Edges), len(result.Edges)) 461 - } 462 - 463 - func TestInfraWorkflowTestSuite(t *testing.T) { 464 - suite.Run(t, new(InfraWorkflowTestSuite)) 465 - }
-50
controller/workflows/platform.go
··· 1 - package workflows 2 - 3 - import ( 4 - "os" 5 - "time" 6 - 7 - "cloudlab/controller/activities" 8 - 9 - "go.temporal.io/sdk/workflow" 10 - ) 11 - 12 - type PlatformInput struct { 13 - Url string 14 - Revision string 15 - Registry string 16 - Cluster string 17 - } 18 - 19 - func Platform(ctx workflow.Context, input PlatformInput) error { 20 - logger := workflow.GetLogger(ctx) 21 - logger.Info("Platform workflow started", "platform", input) 22 - 23 - var workspace string 24 - if err := workflow.ExecuteActivity( 25 - workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ 26 - StartToCloseTimeout: 1 * time.Minute, 27 - }), 28 - activities.Clone, 29 - input.Url, 30 - input.Revision, 31 - ).Get(ctx, &workspace); err != nil { 32 - return err 33 - } 34 - 35 - defer os.RemoveAll(workspace) 36 - 37 - var pushResult *activities.PushResult 38 - if err := workflow.ExecuteActivity( 39 - workflow.WithActivityOptions(ctx, workflow.ActivityOptions{ 40 - StartToCloseTimeout: 1 * time.Minute, 41 - }), 42 - activities.PushManifests, 43 - workspace+"/platform/"+input.Cluster, 44 - input.Registry+"/platform:"+input.Cluster, 45 - ).Get(ctx, &pushResult); err != nil { 46 - return err 47 - } 48 - 49 - return nil 50 - }
-60
platform/local/app-engine.yaml
··· 1 - apiVersion: argoproj.io/v1alpha1 2 - kind: Application 3 - metadata: 4 - finalizers: 5 - - resources-finalizer.argocd.argoproj.io 6 - name: app-engine 7 - spec: 8 - destination: 9 - name: in-cluster 10 - namespace: app-engine 11 - project: default 12 - syncPolicy: 13 - automated: 14 - prune: true 15 - selfHeal: true 16 - syncOptions: 17 - - CreateNamespace=true 18 - - ApplyOutOfSyncOnly=true 19 - - ServerSideApply=true 20 - source: 21 - repoURL: https://bjw-s-labs.github.io/helm-charts 22 - chart: app-template 23 - targetRevision: 3.7.3 24 - helm: 25 - valuesObject: 26 - defaultPodOptions: 27 - restartPolicy: Always 28 - labels: 29 - istio.io/dataplane-mode: ambient 30 - hostNetwork: true 31 - controllers: 32 - worker: 33 - strategy: RollingUpdate 34 - containers: 35 - app: 36 - image: 37 - # TODO bootstrap and build itself 38 - # repository: registry.registry.svc.cluster.local/khuedoan/app-engine 39 - repository: docker.io/khuedoan/app-engine 40 - tag: 4118f906ab07a17f3dac608f1a690b2215e4d2a5 41 - pullPolicy: Always 42 - env: 43 - TEMPORAL_URL: http://temporal-frontend.temporal:7233 44 - REGISTRY: registry.registry.svc.cluster.local 45 - docker: 46 - image: 47 - repository: docker.io/library/docker 48 - tag: 27-dind 49 - command: 50 - - dockerd 51 - - --host=unix:///var/run/docker.sock 52 - - --insecure-registry=registry.registry.svc.cluster.local 53 - securityContext: 54 - privileged: true 55 - persistence: 56 - socket: 57 - type: emptyDir 58 - globalMounts: 59 - - path: /var/run 60 - subPath: docker.sock