diff --git a/models/config.go b/models/config.go index c3ff9791..01c9ca2b 100644 --- a/models/config.go +++ b/models/config.go @@ -265,3 +265,16 @@ func (e ExternalID) CacheKey() string { func (e ExternalID) WhereClause(db *gorm.DB) *gorm.DB { return db.Where("type = ? AND external_id @> ?", e.ConfigType, pq.StringArray(e.ExternalID)) } + +type RelatedConfigType string + +const ( + RelatedConfigTypeIncoming RelatedConfigType = "incoming" + RelatedConfigTypeOutgoing RelatedConfigType = "outgoing" +) + +type RelatedConfig struct { + Relation string `json:"relation"` + Type RelatedConfigType `json:"relation_type" gorm:"column:relation_type"` + Config types.JSONMap `json:"config"` +} diff --git a/tests/config_relationship_test.go b/tests/config_relationship_test.go new file mode 100644 index 00000000..a148669a --- /dev/null +++ b/tests/config_relationship_test.go @@ -0,0 +1,34 @@ +package tests + +import ( + "github.com/flanksource/duty/models" + "github.com/flanksource/duty/tests/fixtures/dummy" + ginkgo "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe("Config relationship", ginkgo.Ordered, func() { + ginkgo.It("should return OUTGOING relationships", func() { + var relatedConfigs []models.RelatedConfig + err := DefaultContext.DB().Raw("SELECT * FROM related_configs(?, false)", dummy.KubernetesCluster.ID).Find(&relatedConfigs).Error + Expect(err).To(BeNil()) + + Expect(len(relatedConfigs)).To(Equal(2)) + for _, rc := range relatedConfigs { + Expect(rc.Relation).To(Equal("ClusterNode")) + Expect(rc.Type).To(Equal(models.RelatedConfigTypeOutgoing)) + Expect(rc.Config["id"]).To(BeElementOf([]string{dummy.KubernetesNodeA.ID.String(), dummy.KubernetesNodeB.ID.String()})) + } + }) + + ginkgo.It("should return INCOOMING relationships", func() { + var relatedConfigs []models.RelatedConfig + err := DefaultContext.DB().Raw("SELECT * FROM related_configs(?, false)", dummy.KubernetesNodeA.ID).Find(&relatedConfigs).Error + Expect(err).To(BeNil()) + + Expect(len(relatedConfigs)).To(Equal(1)) + Expect(relatedConfigs[0].Relation).To(Equal("ClusterNode")) + Expect(relatedConfigs[0].Type).To(Equal(models.RelatedConfigTypeIncoming)) + Expect(relatedConfigs[0].Config["id"]).To(Equal(dummy.KubernetesCluster.ID.String())) + }) +}) diff --git a/tests/fixtures/dummy/all.go b/tests/fixtures/dummy/all.go index 0b7d3cc7..d2ddb6d3 100644 --- a/tests/fixtures/dummy/all.go +++ b/tests/fixtures/dummy/all.go @@ -24,6 +24,7 @@ type DummyData struct { ComponentRelationships []models.ComponentRelationship Configs []models.ConfigItem + ConfigRelationships []models.ConfigRelationship ConfigScrapers []models.ConfigScraper ConfigChanges []models.ConfigChange ConfigAnalyses []models.ConfigAnalysis @@ -94,6 +95,13 @@ func (t *DummyData) Populate(gormDB *gorm.DB) error { return err } } + for _, c := range t.ConfigRelationships { + c.CreatedAt = createTime + err = gormDB.Create(&c).Error + if err != nil { + return err + } + } for _, c := range t.ConfigChanges { err = gormDB.Create(&c).Error if err != nil { @@ -320,6 +328,7 @@ func GetStaticDummyData(db *gorm.DB) DummyData { ComponentRelationships: append([]models.ComponentRelationship{}, AllDummyComponentRelationships...), Configs: append([]models.ConfigItem{}, AllDummyConfigs...), ConfigChanges: append([]models.ConfigChange{}, AllDummyConfigChanges...), + ConfigRelationships: append([]models.ConfigRelationship{}, AllConfigRelationships...), ConfigAnalyses: append([]models.ConfigAnalysis{}, AllDummyConfigAnalysis()...), ConfigComponentRelationships: append([]models.ConfigComponentRelationship{}, AllDummyConfigComponentRelationships...), Teams: append([]models.Team{}, AllDummyTeams...), @@ -832,6 +841,22 @@ func GenerateDynamicDummyData(db *gorm.DB) DummyData { LogisticsDBRDS, } + var ClusterNodeARelationship = models.ConfigRelationship{ + ConfigID: KubernetesCluster.ID.String(), + RelatedID: KubernetesNodeA.ID.String(), + Relation: "ClusterNode", + CreatedAt: DummyCreatedAt, + } + + var ClusterNodeBRelationship = models.ConfigRelationship{ + ConfigID: KubernetesCluster.ID.String(), + RelatedID: KubernetesNodeB.ID.String(), + Relation: "ClusterNode", + CreatedAt: DummyCreatedAt, + } + + var configRelationships = []models.ConfigRelationship{ClusterNodeARelationship, ClusterNodeBRelationship} + var LogisticsDBRDSAnalysis = models.ConfigAnalysis{ ID: uuid.New(), ConfigID: LogisticsDBRDS.ID, @@ -1106,6 +1131,7 @@ func GenerateDynamicDummyData(db *gorm.DB) DummyData { Components: components, ComponentRelationships: componentRelationships, + ConfigRelationships: configRelationships, ConfigScrapers: configScrapers, Configs: configs, ConfigChanges: configChanges, diff --git a/tests/fixtures/dummy/config.go b/tests/fixtures/dummy/config.go index f6942e7a..1c3aad59 100644 --- a/tests/fixtures/dummy/config.go +++ b/tests/fixtures/dummy/config.go @@ -140,3 +140,17 @@ var AzureConfigScraper = models.ConfigScraper{ } var AllConfigScrapers = []models.ConfigScraper{AzureConfigScraper} + +var ClusterNodeARelationship = models.ConfigRelationship{ + ConfigID: KubernetesCluster.ID.String(), + RelatedID: KubernetesNodeA.ID.String(), + Relation: "ClusterNode", +} + +var ClusterNodeBRelationship = models.ConfigRelationship{ + ConfigID: KubernetesCluster.ID.String(), + RelatedID: KubernetesNodeB.ID.String(), + Relation: "ClusterNode", +} + +var AllConfigRelationships = []models.ConfigRelationship{ClusterNodeARelationship, ClusterNodeBRelationship} diff --git a/views/006_config_views.sql b/views/006_config_views.sql index 0267b21a..80ca14b7 100644 --- a/views/006_config_views.sql +++ b/views/006_config_views.sql @@ -423,4 +423,65 @@ CREATE OR REPLACE VIEW config_detail AS ON ci.id = config_changes.config_id LEFT JOIN (SELECT config_id, count(*) as playbook_runs_count FROM playbook_runs GROUP BY config_id) as playbook_runs - ON ci.id = playbook_runs.config_id; \ No newline at end of file + ON ci.id = playbook_runs.config_id; + +DROP FUNCTION IF EXISTS related_configs(UUID, BOOLEAN); + +CREATE FUNCTION related_configs ( + config_id UUID, + include_deleted_configs BOOLEAN +) +RETURNS TABLE ( + relation TEXT, + relation_type TEXT, + config JSONB +) AS $$ +BEGIN + RETURN query + SELECT + config_relationships.relation, + 'outgoing' AS relation_type, + jsonb_build_object( + 'id', c.id, + 'name', c.name, + 'type', c.type, + 'tags', c.tags, + 'changes', c.changes, + 'analysis', c.analysis, + 'cost_per_minute', c.cost_per_minute, + 'cost_total_1d', c.cost_total_1d, + 'cost_total_7d', c.cost_total_7d, + 'cost_total_30d', c.cost_total_30d, + 'created_at', c.created_at, + 'updated_at', c.updated_at + ) AS config + FROM config_relationships + INNER JOIN configs AS c ON config_relationships.related_id = c.id AND ($2 OR c.deleted_at IS NULL) + WHERE + config_relationships.deleted_at IS NULL + AND config_relationships.config_id = $1 + UNION + SELECT + config_relationships.relation, + 'incoming' AS relation_type, + jsonb_build_object( + 'id', c.id, + 'name', c.name, + 'type', c.type, + 'tags', c.tags, + 'changes', c.changes, + 'analysis', c.analysis, + 'cost_per_minute', c.cost_per_minute, + 'cost_total_1d', c.cost_total_1d, + 'cost_total_7d', c.cost_total_7d, + 'cost_total_30d', c.cost_total_30d, + 'created_at', c.created_at, + 'updated_at', c.updated_at + ) AS config + FROM config_relationships + INNER JOIN configs AS c ON config_relationships.config_id = c.id AND ($2 OR c.deleted_at IS NULL) + WHERE + config_relationships.deleted_at IS NULL + AND config_relationships.related_id = $1; +END; +$$ LANGUAGE plpgsql; \ No newline at end of file