diff --git a/pkg/kapp/cmd/app/deploy.go b/pkg/kapp/cmd/app/deploy.go index 2cd46a824..8ed35d955 100644 --- a/pkg/kapp/cmd/app/deploy.go +++ b/pkg/kapp/cmd/app/deploy.go @@ -183,6 +183,13 @@ func (o *DeployOptions) Run() error { return err } + if o.DeployFlags.ChangeGraphFile != "" { + err = clusterChangesGraph.WriteToFile(o.DeployFlags.ChangeGraphFile) + if err != nil { + return err + } + } + if o.DiffFlags.UI { return o.presentDiffUI(clusterChangesGraph) } diff --git a/pkg/kapp/cmd/app/deploy_flags.go b/pkg/kapp/cmd/app/deploy_flags.go index 09dd2e150..d9cca1b09 100644 --- a/pkg/kapp/cmd/app/deploy_flags.go +++ b/pkg/kapp/cmd/app/deploy_flags.go @@ -29,6 +29,8 @@ type DeployFlags struct { AppMetadataFile string DisableGKScoping bool + + ChangeGraphFile string } func (s *DeployFlags) Set(cmd *cobra.Command) { @@ -63,4 +65,6 @@ func (s *DeployFlags) Set(cmd *cobra.Command) { cmd.Flags().BoolVar(&s.DisableGKScoping, "dangerous-disable-gk-scoping", false, "Disable scoping of resource searching to used GroupKinds") + + cmd.Flags().StringVar(&s.ChangeGraphFile, "change-graph-file-output", "", "Render the deployment graph to a file") } diff --git a/pkg/kapp/diffgraph/change_graph.go b/pkg/kapp/diffgraph/change_graph.go index 375437ca1..7fd7148c9 100644 --- a/pkg/kapp/diffgraph/change_graph.go +++ b/pkg/kapp/diffgraph/change_graph.go @@ -4,12 +4,15 @@ package diffgraph import ( + "encoding/json" "fmt" + "os" "sort" "strings" ctlconf "carvel.dev/kapp/pkg/kapp/config" "carvel.dev/kapp/pkg/kapp/logger" + ctlres "carvel.dev/kapp/pkg/kapp/resources" ) type ChangeGraph struct { @@ -314,3 +317,50 @@ func (g *ChangeGraph) checkCyclesVisit(nodeN *Change, markedTemp, markedPerm map markedPerm[nodeN] = struct{}{} return nil } + +func (g *ChangeGraph) WriteToFile(outputFile string) error { + sb := &strings.Builder{} + encoder := json.NewEncoder(sb) + nodes := []RenderedNode{} + edges := []RenderedEdge{} + + for _, change := range g.All() { + change.Change.Resource().UnstructuredObject() + changeGroups, _ := change.Groups() + + groups := []string{} + for _, changeGroup := range changeGroups { + groups = append(groups, changeGroup.Name) + } + changeID := ctlres.NewAssociationLabel(change.Change.Resource()).Value() + var namespaceRef *string + namespace := change.Change.Resource().Namespace() + if namespace != "" { + namespaceRef = &namespace + } + node := RenderedNode{ + ID: changeID, + Data: RenderedNodeData{ + Name: change.Change.Resource().Name(), + Namespace: namespaceRef, + ChangeGroups: groups, + GroupKind: RenderedGroupKind(change.Change.Resource().GroupKind()), + Op: change.Change.Op(), + }, + } + nodes = append(nodes, node) + changeDependencies := change.WaitingFor + for _, dependency := range changeDependencies { + depID := ctlres.NewAssociationLabel(dependency.Change.Resource()).Value() + edges = append(edges, RenderedEdge{ + Source: changeID, + Target: depID, + }) + } + } + encoder.Encode(RenderedGraph{ + Nodes: nodes, + Edges: edges, + }) + return os.WriteFile(outputFile, []byte(sb.String()), os.ModePerm) +} diff --git a/pkg/kapp/diffgraph/change_graph_test.go b/pkg/kapp/diffgraph/change_graph_test.go index c22b62cba..47162faac 100644 --- a/pkg/kapp/diffgraph/change_graph_test.go +++ b/pkg/kapp/diffgraph/change_graph_test.go @@ -4,6 +4,7 @@ package diffgraph_test import ( + "os" "strings" "testing" @@ -840,6 +841,48 @@ func buildChangeGraph(resourcesBs string, op ctldgraph.ActualChangeOp, t *testin return buildChangeGraphWithOpts(buildGraphOpts{resourcesBs: resourcesBs, op: op}, t) } +func TestRenderGraph(t *testing.T) { + configYAML := ` +kind: Job +metadata: + name: import-etcd-into-db + annotations: + kapp.k14s.io/change-group: "apps.big.co/import-etcd-into-db" +--- +kind: Job +metadata: + name: after-migrations + annotations: + kapp.k14s.io/change-group.group1: "apps.big.co/after-migrations-1" + kapp.k14s.io/change-group.group2: "apps.big.co/after-migrations-2" +--- +kind: Job +metadata: + name: migrations + annotations: + kapp.k14s.io/change-rule.rule1: "upsert after upserting apps.big.co/import-etcd-into-db" + kapp.k14s.io/change-rule.rule2: "upsert before upserting apps.big.co/after-migrations-1" + kapp.k14s.io/change-rule.rule3: "upsert before upserting apps.big.co/after-migrations-2" +` + + graph, err := buildChangeGraph(configYAML, ctldgraph.ActualChangeOpUpsert, t) + require.NoErrorf(t, err, "Expected graph to build") + tmpFile, err := os.CreateTemp("", "*") + defer func() { + os.Remove(tmpFile.Name()) + }() + require.NoErrorf(t, err, "Failed to create temp file") + err = graph.WriteToFile(tmpFile.Name()) + require.NoErrorf(t, err, "Failed to write to file") + contents, err := os.ReadFile(tmpFile.Name()) + require.NoErrorf(t, err, "Failed to read file") + + expectedOutput := strings.TrimSpace(` +{"nodes":[{"id":"v1.88b09231fb1239b5798a9fc230ef23f3","data":{"name":"import-etcd-into-db","namespace":null,"changeGroups":["apps.big.co/import-etcd-into-db"],"groupKind":{"group":"","kind":"Job"},"op":"upsert"}},{"id":"v1.3258dfc11ef4e6bfbfe728d7f6ed8193","data":{"name":"after-migrations","namespace":null,"changeGroups":["apps.big.co/after-migrations-1","apps.big.co/after-migrations-2"],"groupKind":{"group":"","kind":"Job"},"op":"upsert"}},{"id":"v1.cb954b4a1b9d4fbaaf4ff3ce5a9df1ba","data":{"name":"migrations","namespace":null,"changeGroups":[],"groupKind":{"group":"","kind":"Job"},"op":"upsert"}}],"edges":[{"source":"v1.3258dfc11ef4e6bfbfe728d7f6ed8193","target":"v1.cb954b4a1b9d4fbaaf4ff3ce5a9df1ba"},{"source":"v1.cb954b4a1b9d4fbaaf4ff3ce5a9df1ba","target":"v1.88b09231fb1239b5798a9fc230ef23f3"}]} + `) + require.Equal(t, expectedOutput, strings.TrimSpace(string(contents))) +} + type buildGraphOpts struct { resources []ctlres.Resource resourcesBs string diff --git a/pkg/kapp/diffgraph/graph_meta.go b/pkg/kapp/diffgraph/graph_meta.go new file mode 100644 index 000000000..991d9965b --- /dev/null +++ b/pkg/kapp/diffgraph/graph_meta.go @@ -0,0 +1,32 @@ +// Copyright 2024 The Carvel Authors. +// SPDX-License-Identifier: Apache-2.0 + +package diffgraph + +type RenderedNodeData struct { + Name string `json:"name"` + Namespace *string `json:"namespace"` + ChangeGroups []string `json:"changeGroups"` + GroupKind RenderedGroupKind `json:"groupKind"` + Op ActualChangeOp `json:"op"` +} + +type RenderedNode struct { + ID string `json:"id"` + Data RenderedNodeData `json:"data"` +} + +type RenderedGroupKind struct { + Group string `json:"group"` + Kind string `json:"kind"` +} + +type RenderedEdge struct { + Source string `json:"source"` + Target string `json:"target"` +} + +type RenderedGraph struct { + Nodes []RenderedNode `json:"nodes"` + Edges []RenderedEdge `json:"edges"` +}