From 1c13fdab3e274cae418ca0b77625634fb414f256 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 1 May 2026 14:44:07 +0300 Subject: [PATCH 1/5] feat(db,api): add scraper_id to config relationships and external user groups Introduce scraper_id as part of composite primary keys for ConfigRelationship and ExternalUserGroup to support multi-scraper ownership. This allows multiple scrapers to independently maintain relationship tuples without colliding, while uuid.Nil represents legacy scraper-agnostic relationships. Add Health.Badge() method to render health status as colored pills for dense table cells. Implement MergeConfigTrees() to collapse independently-built config trees into a unified forest by merging shared ancestors recursively. Enhance SearchResources to populate Health and Status fields for configs and components. Add RoleExternalIDs to ConfigAccessSummary. Create external_group_summary view with member and permission counts. Update SQL views to filter deleted relationships and add GROUP BY clauses for accurate counts. Simplify database setup helpers by removing unnecessary quoting and URL parsing utilities. BREAKING CHANGE: ConfigRelationship and ExternalUserGroup primary keys now include scraper_id; existing code must populate this field from scraper context. --- models/common.go | 22 ++++ models/config.go | 20 +-- models/config_access.go | 32 ++--- query/config_tree.go | 93 +++++++++++++- query/config_tree_test.go | 170 +++++++++++++++++++++++--- query/resource_selector.go | 32 ++++- rbac/objects.go | 1 + schema/config.hcl | 7 +- schema/config_access.hcl | 10 +- tests/config_access_test.go | 65 ++++++++++ tests/config_relationship_test.go | 73 ++++++++++- tests/e2e-blobs/containers.go | 4 +- tests/fixtures/dummy/all.go | 2 +- tests/setup/common.go | 72 ++--------- types/client_options.go | 5 + views/006_config_views.sql | 22 ++-- views/038_config_access.sql | 47 ++++++- views/045_merge_external_entities.sql | 12 +- views/9998_rls_enable.sql | 1 + 19 files changed, 555 insertions(+), 135 deletions(-) diff --git a/models/common.go b/models/common.go index 3b7054121..c3b6910a6 100644 --- a/models/common.go +++ b/models/common.go @@ -73,6 +73,28 @@ func (h Health) Pretty() api.Text { } } +// Badge renders the health as a coloured pill — intended for dense table +// cells where the ✓/✗ glyph + capitalised label of Pretty() is too wide. +// Empty input returns an empty Text so callers can unconditionally embed it. +func (h Health) Badge() api.Textable { + if h == "" { + return api.Text{} + } + label := string(h) + switch h { + case HealthHealthy: + return api.Badge(label, "bg-green-100", "text-green-700", "capitalize") + case HealthUnhealthy: + return api.Badge(label, "bg-red-100", "text-red-700", "capitalize") + case HealthWarning: + return api.Badge(label, "bg-yellow-100", "text-yellow-800", "capitalize") + case HealthUnknown: + return api.Badge(label, "bg-gray-100", "text-gray-600", "capitalize") + default: + return api.Badge(label, "bg-gray-100", "text-gray-600", "capitalize") + } +} + func WorseHealth(healths ...Health) Health { worst := HealthHealthy for _, h := range healths { diff --git a/models/config.go b/models/config.go index 89cfdd8e9..93685b225 100644 --- a/models/config.go +++ b/models/config.go @@ -600,9 +600,15 @@ func (cs *ConfigScraper) BeforeCreate(tx *gorm.DB) error { } type ConfigRelationship struct { - ConfigID string `json:"config_id" gorm:"primaryKey"` - RelatedID string `json:"related_id" gorm:"primaryKey"` - Relation string `json:"relation" gorm:"primaryKey"` + ConfigID string `json:"config_id" gorm:"primaryKey"` + RelatedID string `json:"related_id" gorm:"primaryKey"` + Relation string `json:"relation" gorm:"primaryKey"` + // ScraperID identifies the scraper that owns this relationship and is part of the + // composite primary key. uuid.Nil represents a legacy / scraper-agnostic relationship + // from before scraper-ownership was introduced; new code should populate this from + // the active scraper context so multiple scrapers can independently maintain + // (related_id, config_id, relation) tuples without colliding. + ScraperID uuid.UUID `json:"scraper_id" gorm:"primaryKey"` SelectorID string `json:"selector_id"` CreatedAt time.Time `json:"created_at,omitempty"` UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"autoUpdateTime:false"` @@ -614,7 +620,7 @@ func (c ConfigRelationship) Value() any { } func (c ConfigRelationship) PKCols() []clause.Column { - return []clause.Column{{Name: "related_id"}, {Name: "config_id"}, {Name: "relation"}} + return []clause.Column{{Name: "related_id"}, {Name: "config_id"}, {Name: "relation"}, {Name: "scraper_id"}} } func (t ConfigRelationship) UpdateParentsIsPushed(db *gorm.DB, items []DBTable) error { @@ -632,10 +638,10 @@ func (t ConfigRelationship) UpdateParentsIsPushed(db *gorm.DB, items []DBTable) func (s ConfigRelationship) UpdateIsPushed(db *gorm.DB, items []DBTable) error { ids := lo.Map(items, func(a DBTable, _ int) []string { c := any(a).(ConfigRelationship) - return []string{c.RelatedID, c.ConfigID, c.Relation} + return []string{c.RelatedID, c.ConfigID, c.Relation, c.ScraperID.String()} }) - return db.Model(&ConfigRelationship{}).Where("(related_id, config_id, relation) IN ?", ids).Update("is_pushed", true).Error + return db.Model(&ConfigRelationship{}).Where("(related_id, config_id, relation, scraper_id) IN ?", ids).Update("is_pushed", true).Error } func (t ConfigRelationship) GetUnpushed(db *gorm.DB) ([]DBTable, error) { @@ -649,7 +655,7 @@ func (t ConfigRelationship) GetUnpushed(db *gorm.DB) ([]DBTable, error) { } func (cr ConfigRelationship) PK() string { - return cr.RelatedID + "," + cr.ConfigID + cr.SelectorID + return cr.RelatedID + "," + cr.ConfigID + "," + cr.Relation + "," + cr.ScraperID.String() } func (cr ConfigRelationship) TableName() string { diff --git a/models/config_access.go b/models/config_access.go index c58e8b008..569c54621 100644 --- a/models/config_access.go +++ b/models/config_access.go @@ -128,6 +128,7 @@ func (e *ExternalGroup) SetAliases(aliases []string) { type ExternalUserGroup struct { ExternalUserID uuid.UUID `json:"external_user_id" gorm:"primaryKey"` ExternalGroupID uuid.UUID `json:"external_group_id" gorm:"primaryKey"` + ScraperID uuid.UUID `json:"scraper_id" gorm:"primaryKey"` DeletedAt *time.Time `json:"deleted_at,omitempty"` DeletedBy *uuid.UUID `json:"deleted_by"` CreatedAt time.Time `json:"created_at" gorm:"<-:create"` @@ -220,21 +221,22 @@ func (e ConfigAccess) PK() string { } type ConfigAccessSummary struct { - ConfigID uuid.UUID `json:"config_id"` - ConfigName string `json:"config_name"` - ConfigType string `json:"config_type"` - ExternalGroupID *uuid.UUID `json:"external_group_id,omitempty"` - ExternalUserID uuid.UUID `json:"external_user_id,omitempty"` - Role string `json:"role"` - User string `json:"user"` - UserType string `json:"user_type"` - Email string `json:"email"` - CreatedAt time.Time `json:"created_at"` - DeletedAt *time.Time `json:"deleted_at,omitempty"` - CreatedBy *uuid.UUID `json:"created_by,omitempty"` - LastSignedInAt *time.Time `json:"last_signed_in_at,omitempty"` - LastReviewedAt *time.Time `json:"last_reviewed_at,omitempty"` - LastReviewedBy *uuid.UUID `json:"last_reviewed_by,omitempty"` + ConfigID uuid.UUID `json:"config_id"` + ConfigName string `json:"config_name"` + ConfigType string `json:"config_type"` + ExternalGroupID *uuid.UUID `json:"external_group_id,omitempty"` + ExternalUserID uuid.UUID `json:"external_user_id,omitempty"` + Role string `json:"role"` + RoleExternalIDs pq.StringArray `json:"role_external_ids,omitempty" gorm:"type:[]text"` + User string `json:"user"` + UserType string `json:"user_type"` + Email string `json:"email"` + CreatedAt time.Time `json:"created_at"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` + CreatedBy *uuid.UUID `json:"created_by,omitempty"` + LastSignedInAt *time.Time `json:"last_signed_in_at,omitempty"` + LastReviewedAt *time.Time `json:"last_reviewed_at,omitempty"` + LastReviewedBy *uuid.UUID `json:"last_reviewed_by,omitempty"` } func (e ConfigAccessSummary) QueryLogSummary() string { diff --git a/query/config_tree.go b/query/config_tree.go index 847abc098..32d9aa565 100644 --- a/query/config_tree.go +++ b/query/config_tree.go @@ -1,6 +1,7 @@ package query import ( + "errors" "fmt" "sort" "strings" @@ -9,6 +10,7 @@ import ( "github.com/flanksource/duty/models" "github.com/google/uuid" "github.com/samber/lo" + "gorm.io/gorm" ) type ConfigTreeNode struct { @@ -30,6 +32,71 @@ func (n *ConfigTreeNode) OutgoingIDs() []uuid.UUID { return ids } +// MergeConfigTrees collapses N independently-built config trees into a forest +// of shared-ancestor trees. Trees sharing a root (same ID) merge into one; +// their descendants are unioned recursively by ID. Unrelated trees remain as +// separate roots. +// +// Used by callers that fetch a ConfigTree for each of many matched configs and +// want to render a single unified forest — e.g. two AWS::RDS::DBInstance +// matches in the same account produce one tree rooted at the account with +// both instances under their shared CloudFormation stack, rather than two +// duplicate ancestor chains. +// +// EdgeType="target" is preserved when the same node appears as a target in one +// input and as a non-target (parent/child/related) in another. +func MergeConfigTrees(trees []*ConfigTreeNode) []*ConfigTreeNode { + byID := make(map[uuid.UUID]*ConfigTreeNode) + var roots []*ConfigTreeNode + for _, t := range trees { + if t == nil { + continue + } + if existing, ok := byID[t.ID]; ok { + mergeConfigTreeInto(existing, t, byID) + } else { + roots = append(roots, cloneConfigTree(t, byID)) + } + } + return roots +} + +func cloneConfigTree(n *ConfigTreeNode, byID map[uuid.UUID]*ConfigTreeNode) *ConfigTreeNode { + if existing, ok := byID[n.ID]; ok { + mergeConfigTreeInto(existing, n, byID) + return existing + } + out := &ConfigTreeNode{ + ConfigItem: n.ConfigItem, + EdgeType: n.EdgeType, + Relation: n.Relation, + } + byID[n.ID] = out + for _, c := range n.Children { + out.Children = append(out.Children, cloneConfigTree(c, byID)) + } + return out +} + +func mergeConfigTreeInto(dst, src *ConfigTreeNode, byID map[uuid.UUID]*ConfigTreeNode) { + if src.EdgeType == "target" { + dst.EdgeType = "target" + } + existing := make(map[uuid.UUID]*ConfigTreeNode, len(dst.Children)) + for _, c := range dst.Children { + existing[c.ID] = c + } + for _, c := range src.Children { + if cur, ok := existing[c.ID]; ok { + mergeConfigTreeInto(cur, c, byID) + } else { + cloned := cloneConfigTree(c, byID) + dst.Children = append(dst.Children, cloned) + existing[c.ID] = cloned + } + } +} + func (n *ConfigTreeNode) collectOutgoing(ids *[]uuid.UUID, seen map[uuid.UUID]bool) { if seen[n.ID] { return @@ -65,7 +132,7 @@ func ConfigTree(ctx context.Context, configID uuid.UUID, opts ConfigTreeOptions) var children []models.ConfigItem if len(childIDs) > 0 { - children, err = GetConfigsByIDs(ctx, childIDs) + children, err = getExistingConfigsByIDs(ctx, childIDs) if err != nil { return nil, err } @@ -112,7 +179,7 @@ func resolveParentsFromPath(ctx context.Context, config *models.ConfigItem) ([]m if len(parentIDs) == 0 { return nil, nil } - items, err := GetConfigsByIDs(ctx, parentIDs) + items, err := getExistingConfigsByIDs(ctx, parentIDs) if err != nil { return nil, fmt.Errorf("resolving parents from path: %w", err) } @@ -139,7 +206,12 @@ func ExpandConfigChildren(ctx context.Context, ids []uuid.UUID) ([]uuid.UUID, er } for _, id := range ids { var children []uuid.UUID - if err := ctx.DB().Raw("SELECT child_id FROM lookup_config_children(?, -1)", id.String()). + if err := ctx.DB().Raw(` + SELECT c.child_id + FROM lookup_config_children(?, -1) c + JOIN config_items ci ON ci.id = c.child_id + WHERE ci.deleted_at IS NULL + `, id.String()). Scan(&children).Error; err != nil { return nil, err } @@ -150,6 +222,21 @@ func ExpandConfigChildren(ctx context.Context, ids []uuid.UUID) ([]uuid.UUID, er return lo.Keys(allIDs), nil } +func getExistingConfigsByIDs(ctx context.Context, ids []uuid.UUID) ([]models.ConfigItem, error) { + configs := make([]models.ConfigItem, 0, len(ids)) + for _, id := range ids { + config, err := ConfigItemFromCache(ctx, id.String()) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + continue + } + return nil, err + } + configs = append(configs, config) + } + return configs, nil +} + type ptrNode struct { models.ConfigItem edgeType string diff --git a/query/config_tree_test.go b/query/config_tree_test.go index 3c30480b7..40350cbad 100644 --- a/query/config_tree_test.go +++ b/query/config_tree_test.go @@ -49,9 +49,9 @@ func TestBuildConfigTreeNestsGrandchildren(t *testing.T) { child := uuid.New() grand := uuid.New() - targetCI := makeCI(target, uuid.Nil, "botswana", "OIPA::Country") - childCI := makeCI(child, target, "gaborone", "OIPA::City", target) - grandCI := makeCI(grand, child, "block-10", "OIPA::District", target, child) + targetCI := makeCI(target, uuid.Nil, "region-a", "Geo::Country") + childCI := makeCI(child, target, "city-a", "Geo::City", target) + grandCI := makeCI(grand, child, "block-10", "Geo::District", target, child) tree := buildConfigTree(&targetCI, nil, []models.ConfigItem{childCI, grandCI}, nil) @@ -71,8 +71,8 @@ func TestBuildConfigTreeDedupsChildren(t *testing.T) { target := uuid.New() child := uuid.New() - targetCI := makeCI(target, uuid.Nil, "botswana", "OIPA::Country") - childCI := makeCI(child, target, "gaborone", "OIPA::City", target) + targetCI := makeCI(target, uuid.Nil, "region-a", "Geo::Country") + childCI := makeCI(child, target, "city-a", "Geo::City", target) tree := buildConfigTree(&targetCI, nil, []models.ConfigItem{childCI}, nil) @@ -95,15 +95,15 @@ func TestBuildConfigTreeSoftDoesNotDuplicateChildren(t *testing.T) { target := uuid.New() child := uuid.New() - targetCI := makeCI(target, uuid.Nil, "botswana", "OIPA::Country") - childCI := makeCI(child, target, "gaborone", "OIPA::City", target) + targetCI := makeCI(target, uuid.Nil, "region-a", "Geo::Country") + childCI := makeCI(child, target, "city-a", "Geo::City", target) // The related slice includes the same child that already appears in the // hard children slice — this is what `--soft` (RelationType=Both) does. related := []RelatedConfig{{ ID: child, - Name: "gaborone", - Type: "OIPA::City", + Name: "city-a", + Type: "Geo::City", Path: childCI.Path, Relation: "contains", }} @@ -123,12 +123,12 @@ func TestBuildConfigTreeRelatedStillAttachesStandaloneNodes(t *testing.T) { target := uuid.New() rel := uuid.New() - targetCI := makeCI(target, uuid.Nil, "botswana", "OIPA::Country") + targetCI := makeCI(target, uuid.Nil, "region-a", "Geo::Country") related := []RelatedConfig{{ ID: rel, Name: "trade-partner", - Type: "OIPA::Country", + Type: "Geo::Country", Relation: "trades-with", }} @@ -140,7 +140,7 @@ func TestBuildConfigTreeRelatedStillAttachesStandaloneNodes(t *testing.T) { g.Expect(tree.Children[0].Relation).To(gomega.Equal("trades-with")) } -// TestBuildConfigTreeNestsRelatedViaAdjacency reproduces the Zimbabwe case: +// TestBuildConfigTreeNestsRelatedViaAdjacency reproduces the multi-hop case: // a security group related to a DB Instance must nest under the DB Instance, // not flat under the target country. func TestBuildConfigTreeNestsRelatedViaAdjacency(t *testing.T) { @@ -151,14 +151,14 @@ func TestBuildConfigTreeNestsRelatedViaAdjacency(t *testing.T) { db := uuid.New() sg := uuid.New() - targetCI := makeCI(target, uuid.Nil, "zimbabwe", "OIPA::Country") + targetCI := makeCI(target, uuid.Nil, "region-b", "Geo::Country") related := []RelatedConfig{ // env is a direct child of target; it points at the DB. { ID: env, - Name: "uat-zim", - Type: "OIPA::Environment", + Name: "uat-region", + Type: "Geo::Environment", Relation: "hosts", RelatedIDs: []string{db.String()}, }, @@ -315,3 +315,143 @@ func TestResolveChildParentFallsThroughToTarget(t *testing.T) { g.Expect(got).ToNot(gomega.BeNil()) g.Expect(got.ID).To(gomega.Equal(target)) } + +func makeTreeNode(id uuid.UUID, name string, edge string, children ...*ConfigTreeNode) *ConfigTreeNode { + n := name + return &ConfigTreeNode{ + ConfigItem: models.ConfigItem{ID: id, Name: &n}, + EdgeType: edge, + Children: children, + } +} + +// TestMergeConfigTreesSharedAncestor collapses two RDS matches into a single +// tree when they share an account and stack — the scenario that motivated the +// merge (two RDS instances under the same CloudFormation stack in one AWS +// account). +func TestMergeConfigTreesSharedAncestor(t *testing.T) { + g := gomega.NewWithT(t) + + account := uuid.New() + stack := uuid.New() + rds1 := uuid.New() + rds2 := uuid.New() + + tree1 := makeTreeNode(account, "acct", "parent", + makeTreeNode(stack, "stack", "parent", + makeTreeNode(rds1, "db1", "target"), + ), + ) + tree2 := makeTreeNode(account, "acct", "parent", + makeTreeNode(stack, "stack", "parent", + makeTreeNode(rds2, "db2", "target"), + ), + ) + + roots := MergeConfigTrees([]*ConfigTreeNode{tree1, tree2}) + g.Expect(roots).To(gomega.HaveLen(1)) + g.Expect(roots[0].ID).To(gomega.Equal(account)) + g.Expect(roots[0].Children).To(gomega.HaveLen(1)) + g.Expect(roots[0].Children[0].ID).To(gomega.Equal(stack)) + g.Expect(roots[0].Children[0].Children).To(gomega.HaveLen(2)) + + childIDs := []uuid.UUID{ + roots[0].Children[0].Children[0].ID, + roots[0].Children[0].Children[1].ID, + } + g.Expect(childIDs).To(gomega.ConsistOf(rds1, rds2)) +} + +// TestMergeConfigTreesUnrelatedRootsStaySeparate confirms that two unrelated +// roots (e.g. two different AWS accounts) remain as sibling root trees. +func TestMergeConfigTreesUnrelatedRootsStaySeparate(t *testing.T) { + g := gomega.NewWithT(t) + + acctA := uuid.New() + acctB := uuid.New() + stackA := uuid.New() + stackB := uuid.New() + dbA := uuid.New() + dbB := uuid.New() + + trees := []*ConfigTreeNode{ + makeTreeNode(acctA, "A", "parent", makeTreeNode(stackA, "sA", "parent", makeTreeNode(dbA, "dbA", "target"))), + makeTreeNode(acctB, "B", "parent", makeTreeNode(stackB, "sB", "parent", makeTreeNode(dbB, "dbB", "target"))), + } + + roots := MergeConfigTrees(trees) + g.Expect(roots).To(gomega.HaveLen(2)) + rootIDs := []uuid.UUID{roots[0].ID, roots[1].ID} + g.Expect(rootIDs).To(gomega.ConsistOf(acctA, acctB)) +} + +// TestMergeConfigTreesPreservesTargetEdge ensures EdgeType="target" wins when +// the same node appears as both an ancestor (parent edge) in one tree and a +// target in another. +func TestMergeConfigTreesPreservesTargetEdge(t *testing.T) { + g := gomega.NewWithT(t) + + root := uuid.New() + target := uuid.New() + + t1 := makeTreeNode(root, "root", "parent", makeTreeNode(target, "t", "parent")) + t2 := makeTreeNode(root, "root", "parent", makeTreeNode(target, "t", "target")) + + roots := MergeConfigTrees([]*ConfigTreeNode{t1, t2}) + g.Expect(roots).To(gomega.HaveLen(1)) + g.Expect(roots[0].Children).To(gomega.HaveLen(1)) + g.Expect(roots[0].Children[0].EdgeType).To(gomega.Equal("target")) +} + +func TestMergeConfigTreesEmpty(t *testing.T) { + g := gomega.NewWithT(t) + g.Expect(MergeConfigTrees(nil)).To(gomega.BeEmpty()) + g.Expect(MergeConfigTrees([]*ConfigTreeNode{nil, nil})).To(gomega.BeEmpty()) +} + +// TestMergeConfigTreesSharedInternalNode confirms that a node shared between +// two trees with different roots has its children unioned, not lost. With the +// previous early-return in cloneConfigTree, the second tree's children under a +// shared internal node were silently dropped. +func TestMergeConfigTreesSharedInternalNode(t *testing.T) { + g := gomega.NewWithT(t) + + a := uuid.New() + e := uuid.New() + b := uuid.New() + d := uuid.New() + x := uuid.New() + + tree1 := makeTreeNode(a, "a", "parent", + makeTreeNode(b, "b", "parent", + makeTreeNode(d, "d", "target"), + ), + ) + tree2 := makeTreeNode(e, "e", "parent", + makeTreeNode(b, "b", "parent", + makeTreeNode(x, "x", "target"), + ), + ) + + roots := MergeConfigTrees([]*ConfigTreeNode{tree1, tree2}) + g.Expect(roots).To(gomega.HaveLen(2), "a and e have different roots, so two trees") + + rootByID := map[uuid.UUID]*ConfigTreeNode{} + for _, r := range roots { + rootByID[r.ID] = r + } + g.Expect(rootByID).To(gomega.HaveKey(a)) + g.Expect(rootByID).To(gomega.HaveKey(e)) + + bUnderA := rootByID[a].Children[0] + bUnderE := rootByID[e].Children[0] + g.Expect(bUnderA.ID).To(gomega.Equal(b)) + g.Expect(bUnderE.ID).To(gomega.Equal(b)) + g.Expect(bUnderA).To(gomega.BeIdenticalTo(bUnderE), "b should be the same node aliased under both roots") + + childIDs := []uuid.UUID{} + for _, c := range bUnderA.Children { + childIDs = append(childIDs, c.ID) + } + g.Expect(childIDs).To(gomega.ConsistOf(d, x), "both d and x must appear under merged b") +} diff --git a/query/resource_selector.go b/query/resource_selector.go index 36ff0af4e..0ce97ba61 100644 --- a/query/resource_selector.go +++ b/query/resource_selector.go @@ -20,6 +20,7 @@ import ( clickyapi "github.com/flanksource/clicky/api" "github.com/flanksource/duty/api" "github.com/flanksource/duty/context" + "github.com/flanksource/duty/models" "github.com/flanksource/duty/pkg/kube/labels" "github.com/flanksource/duty/query/grammar" "github.com/flanksource/duty/types" @@ -68,6 +69,12 @@ type SelectedResource struct { Namespace string `json:"namespace"` Type string `json:"type"` Tags map[string]string `json:"tags,omitempty"` + // Health is populated for resource kinds that carry a health value + // (configs, components, checks). Empty for other kinds. + Health string `json:"health,omitempty"` + // Status is the resource's free-form operational status (e.g. "Running", + // "Pending"). Populated for configs and components. + Status string `json:"status,omitempty"` } func SearchResources(ctx context.Context, req SearchResourcesRequest) (*SearchResourcesResponse, error) { @@ -109,6 +116,8 @@ func SearchResources(ctx context.Context, req SearchResourcesRequest) (*SearchRe Name: items[i].GetName(), Namespace: items[i].GetNamespace(), Type: items[i].GetType(), + Health: string(lo.FromPtr(items[i].Health)), + Status: lo.FromPtr(items[i].Status), }) } } @@ -117,18 +126,19 @@ func SearchResources(ctx context.Context, req SearchResourcesRequest) (*SearchRe }) eg.Go(func() error { - if items, err := FindChecks(ctx, req.Limit, req.Checks...); err != nil { + if items, err := FindComponents(ctx, req.Limit, req.Components...); err != nil { return err } else { for i := range items { - output.Checks = append(output.Checks, SelectedResource{ + output.Components = append(output.Components, SelectedResource{ ID: items[i].GetID(), Agent: items[i].AgentID.String(), - Icon: items[i].Icon, Tags: items[i].Labels, Name: items[i].GetName(), Namespace: items[i].GetNamespace(), Type: items[i].GetType(), + Health: string(lo.FromPtr(items[i].Health)), + Status: string(items[i].Status), }) } } @@ -137,11 +147,11 @@ func SearchResources(ctx context.Context, req SearchResourcesRequest) (*SearchRe }) eg.Go(func() error { - if items, err := FindComponents(ctx, req.Limit, req.Components...); err != nil { + if items, err := FindChecks(ctx, req.Limit, req.Checks...); err != nil { return err } else { for i := range items { - output.Components = append(output.Components, SelectedResource{ + output.Checks = append(output.Checks, SelectedResource{ ID: items[i].GetID(), Agent: items[i].AgentID.String(), Icon: items[i].Icon, @@ -149,6 +159,7 @@ func SearchResources(ctx context.Context, req SearchResourcesRequest) (*SearchRe Name: items[i].GetName(), Namespace: items[i].GetNamespace(), Type: items[i].GetType(), + Health: lo.Ternary(items[i].Status == models.CheckStatusHealthy, "healthy", "unhealthy"), }) } } @@ -228,6 +239,7 @@ func SetResourceSelectorClause( ) (*gorm.DB, error) { searchSetAgent := false searchSetDeleted := false + searchSetID := false qm, err := GetModelFromTable(table) if err != nil { @@ -257,6 +269,14 @@ func SetResourceSelectorClause( return field == "deleted_at" }) + searchSetID = slices.ContainsFunc(flatFields, func(s string) bool { + field := strings.ToLower(s) + if alias, ok := qm.Aliases[field]; ok { + field = alias + } + return field == "id" + }) + var clauses []clause.Expression query, clauses, err = qm.Apply(ctx, *qf, query) if err != nil { @@ -271,7 +291,7 @@ func SetResourceSelectorClause( } var agentID *uuid.UUID - if !searchSetAgent && qm.HasAgents { + if !searchSetAgent && !searchSetID && qm.HasAgents { agentID, err := getAgentID(ctx, resourceSelector.Agent) if err != nil { return nil, err diff --git a/rbac/objects.go b/rbac/objects.go index 29fadd1e6..620b982e4 100644 --- a/rbac/objects.go +++ b/rbac/objects.go @@ -17,6 +17,7 @@ var dbResourceObjMap = map[string]string{ "config_access_summary": policy.ObjectApplication, "config_access_summary_by_config": policy.ObjectApplication, "config_access_summary_by_user": policy.ObjectApplication, + "external_group_summary": policy.ObjectCatalog, "rpc/config_access_filter_options": policy.ObjectApplication, "analysis_by_component": policy.ObjectCatalog, "analysis_by_config": policy.ObjectCatalog, diff --git a/schema/config.hcl b/schema/config.hcl index eb51e7280..fb83f4b8c 100644 --- a/schema/config.hcl +++ b/schema/config.hcl @@ -504,6 +504,11 @@ table "config_relationships" { null = true type = text } + column "scraper_id" { + null = false + type = uuid + default = "00000000-0000-0000-0000-000000000000" + } column "created_at" { null = false type = timestamptz @@ -541,7 +546,7 @@ table "config_relationships" { } index "config_relationships_related_id_config_id_relation_key" { unique = true - columns = [column.related_id, column.config_id, column.relation] + columns = [column.related_id, column.config_id, column.relation, column.scraper_id] } index "idx_config_relationships_deleted_at" { columns = [column.deleted_at] diff --git a/schema/config_access.hcl b/schema/config_access.hcl index 855ec1436..9a441468b 100644 --- a/schema/config_access.hcl +++ b/schema/config_access.hcl @@ -124,6 +124,11 @@ table "external_user_groups" { column "external_group_id" { type = uuid } + column "scraper_id" { + type = uuid + null = false + default = "00000000-0000-0000-0000-000000000000" + } column "deleted_at" { type = timestamptz null = true @@ -140,7 +145,7 @@ table "external_user_groups" { null = true } primary_key { - columns = [column.external_user_id, column.external_group_id] + columns = [column.external_user_id, column.external_group_id, column.scraper_id] } foreign_key "external_user_fk" { columns = [column.external_user_id] @@ -155,6 +160,9 @@ table "external_user_groups" { index "external_user_groups_external_group_id_idx" { columns = [column.external_group_id] } + index "external_user_groups_scraper_id_idx" { + columns = [column.scraper_id] + } } table "external_roles" { diff --git a/tests/config_access_test.go b/tests/config_access_test.go index 716430d6e..c8e54cbc3 100644 --- a/tests/config_access_test.go +++ b/tests/config_access_test.go @@ -1,6 +1,8 @@ package tests import ( + "time" + "github.com/google/uuid" "github.com/lib/pq" . "github.com/onsi/ginkgo/v2" @@ -65,6 +67,69 @@ var _ = Describe("Config Access Summary View", Ordered, func() { Expect(johnDirectRows).To(HaveLen(1)) Expect(johnDirectRows[0].LastSignedInAt).ToNot(BeNil()) }) + + It("should summarize external group members and permissions", func() { + deletedAt := dummy.DummyCreatedAt.Add(48 * time.Hour) + deletedGroup := models.ExternalGroup{ + ID: uuid.New(), + ScraperID: dummy.KubeScrapeConfig.ID, + Tenant: "flanksource", + Name: "soft-deleted-member-group", + GroupType: "group", + CreatedAt: dummy.DummyCreatedAt, + } + deletedUser := models.ExternalUser{ + ID: uuid.New(), + ScraperID: dummy.KubeScrapeConfig.ID, + Tenant: "flanksource", + Name: "Soft Deleted Member", + UserType: "user", + CreatedAt: dummy.DummyCreatedAt, + } + deletedMembership := models.ExternalUserGroup{ + ExternalUserID: deletedUser.ID, + ExternalGroupID: deletedGroup.ID, + ScraperID: dummy.KubeScrapeConfig.ID, + CreatedAt: dummy.DummyCreatedAt, + DeletedAt: &deletedAt, + } + + Expect(DefaultContext.DB().Create(&deletedGroup).Error).ToNot(HaveOccurred()) + Expect(DefaultContext.DB().Create(&deletedUser).Error).ToNot(HaveOccurred()) + Expect(DefaultContext.DB().Create(&deletedMembership).Error).ToNot(HaveOccurred()) + + type externalGroupSummary struct { + ID uuid.UUID + MembersCount int64 + PermissionsCount int64 + } + + var summaries []externalGroupSummary + err := DefaultContext.DB(). + Table("external_group_summary"). + Where("id IN ?", []uuid.UUID{ + dummy.MissionControlAdminsGroup.ID, + dummy.MissionControlReadersGroup.ID, + dummy.MissionControlEmptyGroup.ID, + deletedGroup.ID, + }). + Find(&summaries).Error + Expect(err).ToNot(HaveOccurred()) + + byID := map[uuid.UUID]externalGroupSummary{} + for _, summary := range summaries { + byID[summary.ID] = summary + } + + Expect(byID[dummy.MissionControlAdminsGroup.ID].MembersCount).To(Equal(int64(2))) + Expect(byID[dummy.MissionControlAdminsGroup.ID].PermissionsCount).To(Equal(int64(2))) + Expect(byID[dummy.MissionControlReadersGroup.ID].MembersCount).To(Equal(int64(2))) + Expect(byID[dummy.MissionControlReadersGroup.ID].PermissionsCount).To(Equal(int64(2))) + Expect(byID[dummy.MissionControlEmptyGroup.ID].MembersCount).To(Equal(int64(0))) + Expect(byID[dummy.MissionControlEmptyGroup.ID].PermissionsCount).To(Equal(int64(1))) + Expect(byID[deletedGroup.ID].MembersCount).To(Equal(int64(0))) + Expect(byID[deletedGroup.ID].PermissionsCount).To(Equal(int64(0))) + }) }) var _ = Describe("External Users Aliases", Ordered, func() { diff --git a/tests/config_relationship_test.go b/tests/config_relationship_test.go index 8ad18274a..22c6aa616 100644 --- a/tests/config_relationship_test.go +++ b/tests/config_relationship_test.go @@ -1,6 +1,7 @@ package tests import ( + "encoding/json" "fmt" "github.com/flanksource/duty/job" @@ -334,7 +335,7 @@ var _ = ginkgo.Describe("Config relationship Kubernetes", ginkgo.Ordered, func() Expect(len(foundConfigs)).To(Equal(len(configItems))) err = DefaultContext.DB().Model(models.ConfigRelationship{}).Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "related_id"}, {Name: "config_id"}, {Name: "relation"}}, + Columns: []clause.Column{{Name: "related_id"}, {Name: "config_id"}, {Name: "relation"}, {Name: "scraper_id"}}, DoNothing: true, }).Create(&relationships).Error Expect(err).To(BeNil()) @@ -430,6 +431,76 @@ var _ = ginkgo.Describe("Config relationship Kubernetes", ginkgo.Ordered, func() }) }) +// UpdateIsPushed matches rows by the 4-tuple (related_id, config_id, relation, +// scraper_id). When ScraperID is uuid.Nil — the legacy / scraper-agnostic case +// described on ConfigRelationship.ScraperID — the WHERE clause must still match +// the inserted row. This regression test pushes one Nil-scraper relationship +// and one real-scraper relationship, JSON round-trips them to simulate cross- +// process delivery, and asserts both transition to is_pushed=true. +var _ = ginkgo.Describe("config relationship UpdateIsPushed scraper round-trip", ginkgo.Ordered, func() { + var ( + legacyRel models.ConfigRelationship + ownedRel models.ConfigRelationship + scraperID = uuid.New() + ) + + ginkgo.BeforeAll(func() { + legacyRel = models.ConfigRelationship{ + ConfigID: dummy.KubernetesCluster.ID.String(), + RelatedID: dummy.KubernetesNodeA.ID.String(), + Relation: "scraper-roundtrip-legacy", + // ScraperID intentionally left as uuid.Nil + } + ownedRel = models.ConfigRelationship{ + ConfigID: dummy.KubernetesCluster.ID.String(), + RelatedID: dummy.KubernetesNodeB.ID.String(), + Relation: "scraper-roundtrip-owned", + ScraperID: scraperID, + } + Expect(DefaultContext.DB().Create(&legacyRel).Error).To(BeNil()) + Expect(DefaultContext.DB().Create(&ownedRel).Error).To(BeNil()) + }) + + ginkgo.AfterAll(func() { + Expect(DefaultContext.DB(). + Where("relation IN ?", []string{"scraper-roundtrip-legacy", "scraper-roundtrip-owned"}). + Delete(&models.ConfigRelationship{}).Error).To(BeNil()) + }) + + ginkgo.It("matches both uuid.Nil and real ScraperID rows after JSON round-trip", func() { + // Simulate the upstream push: marshal to JSON, unmarshal on the other + // side. This is what UpdateIsPushed receives on the receiver. + input := []models.ConfigRelationship{legacyRel, ownedRel} + raw, err := json.Marshal(input) + Expect(err).To(BeNil()) + + var roundtripped []models.ConfigRelationship + Expect(json.Unmarshal(raw, &roundtripped)).To(Succeed()) + Expect(roundtripped).To(HaveLen(2)) + // The Nil ScraperID must survive marshal/unmarshal as Nil, not get dropped. + Expect(roundtripped[0].ScraperID).To(Equal(uuid.Nil)) + Expect(roundtripped[1].ScraperID).To(Equal(scraperID)) + + items := lo.Map(roundtripped, func(r models.ConfigRelationship, _ int) models.DBTable { return r }) + Expect(models.ConfigRelationship{}.UpdateIsPushed(DefaultContext.DB(), items)).To(Succeed()) + + // is_pushed isn't exposed on the Go struct, so query the column directly. + type pushedRow struct { + Relation string + IsPushed bool + } + var after []pushedRow + Expect(DefaultContext.DB().Raw( + "SELECT relation, is_pushed FROM config_relationships WHERE relation IN ?", + []string{"scraper-roundtrip-legacy", "scraper-roundtrip-owned"}, + ).Scan(&after).Error).To(BeNil()) + Expect(after).To(HaveLen(2)) + for _, r := range after { + Expect(r.IsPushed).To(BeTrue(), "row %s should be pushed", r.Relation) + } + }) +}) + var _ = ginkgo.Describe("config relationship deletion test", func() { var tempRelationships = []models.ConfigRelationship{ { diff --git a/tests/e2e-blobs/containers.go b/tests/e2e-blobs/containers.go index e58544b97..7b76fd9c3 100644 --- a/tests/e2e-blobs/containers.go +++ b/tests/e2e-blobs/containers.go @@ -129,8 +129,10 @@ func startAzurite(ctx gocontext.Context) (*azblob.Client, testcontainers.Contain return nil, container, err } + // Well-known Azurite emulator account key (public, documented): + // https://learn.microsoft.com/en-us/azure/storage/common/storage-use-azurite#well-known-storage-account-and-key connStr := fmt.Sprintf( - "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://%s:%s/devstoreaccount1", + "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://%s:%s/devstoreaccount1", //gitleaks:allow host, port.Port(), ) diff --git a/tests/fixtures/dummy/all.go b/tests/fixtures/dummy/all.go index cf90a5fdb..730f085c0 100644 --- a/tests/fixtures/dummy/all.go +++ b/tests/fixtures/dummy/all.go @@ -166,7 +166,7 @@ func (t *DummyData) Populate(ctx context.Context) error { t.ConfigRelationships[i].UpdatedAt = createTime } if err := gormDB.Model(models.ConfigRelationship{}).Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "related_id"}, {Name: "config_id"}, {Name: "relation"}}, + Columns: []clause.Column{{Name: "related_id"}, {Name: "config_id"}, {Name: "relation"}, {Name: "scraper_id"}}, DoNothing: true, }).CreateInBatches(t.ConfigRelationships, 100).Error; err != nil { return err diff --git a/tests/setup/common.go b/tests/setup/common.go index aabaf3f54..ba6029156 100644 --- a/tests/setup/common.go +++ b/tests/setup/common.go @@ -1,17 +1,13 @@ package setup import ( - "crypto/rand" "database/sql" - "encoding/hex" "fmt" "net/http" - "net/url" "os" "path" "strconv" "strings" - "time" embeddedPG "github.com/fergusstrange/embedded-postgres" "github.com/flanksource/commons/logger" @@ -98,45 +94,6 @@ func execPostgres(connection, query string) error { return err } -func randomDatabaseName(prefix string) string { - randomBytes := make([]byte, 4) - if _, err := rand.Read(randomBytes); err != nil { - panic(fmt.Sprintf("failed to read random bytes: %v", err)) - } - - name := fmt.Sprintf( - "%s_%d_%d_%s_%s", - prefix, - ginkgo.GinkgoParallelProcess(), - os.Getpid(), - strconv.FormatInt(time.Now().UnixNano(), 36), - hex.EncodeToString(randomBytes), - ) - - if len(name) > 63 { - return name[:63] - } - return name -} - -func quoteIdentifier(identifier string) string { - return `"` + strings.ReplaceAll(identifier, `"`, `""`) + `"` -} - -func databaseURL(connection string, database string) (string, error) { - u, err := url.Parse(connection) - if err != nil { - return "", err - } - - if u.Scheme != "postgres" && u.Scheme != "postgresql" { - return "", fmt.Errorf("unsupported postgres connection string: %s", connection) - } - - u.Path = "/" + database - return u.String(), nil -} - func MustDB() *sql.DB { db, err := duty.NewDB(PgUrl) if err != nil { @@ -227,22 +184,15 @@ func SetupDB(dbName string, args ...interface{}) (context.Context, error) { PgUrl = url } else if url != "" && recreateDatabase { postgresDBUrl = url - dbName = randomDatabaseName("duty_test") - - var err error - PgUrl, err = databaseURL(url, dbName) - if err != nil { - return context.Context{}, err - } - - quotedDBName := quoteIdentifier(dbName) - _ = execPostgres(postgresDBUrl, fmt.Sprintf("DROP DATABASE IF EXISTS %s (FORCE)", quotedDBName)) - if err := execPostgres(postgresDBUrl, fmt.Sprintf("CREATE DATABASE %s", quotedDBName)); err != nil { + dbName = fmt.Sprintf("duty_gingko%d", port) + PgUrl = strings.Replace(url, "/postgres", "/"+dbName, 1) + _ = execPostgres(postgresDBUrl, "DROP DATABASE "+dbName) + if err := execPostgres(postgresDBUrl, "CREATE DATABASE "+dbName); err != nil { return context.Context{}, fmt.Errorf("cannot create %s: %v", dbName, err) } shutdown.AddHookWithPriority("remove postgres db", shutdown.PriorityCritical, func() { - if err := execPostgres(postgresDBUrl, fmt.Sprintf("DROP DATABASE IF EXISTS %s (FORCE)", quotedDBName)); err != nil { + if err := execPostgres(postgresDBUrl, fmt.Sprintf("DROP DATABASE %s (FORCE)", dbName)); err != nil { logger.Errorf("execPostgres: %v", err) } }) @@ -324,18 +274,12 @@ func NewDB(ctx context.Context, name string) (*context.Context, func(), error) { pgUrl := ctx.Value("db_url").(string) pgDbName := ctx.Value("db_name").(string) newName := pgDbName + name - quotedDBName := quoteIdentifier(newName) - if err := ctx.DB().Exec(fmt.Sprintf("CREATE DATABASE %s", quotedDBName)).Error; err != nil { - return nil, nil, err - } - - newPgURL, err := databaseURL(pgUrl, newName) - if err != nil { + if err := ctx.DB().Exec(fmt.Sprintf("CREATE DATABASE %s", newName)).Error; err != nil { return nil, nil, err } - config := api.NewConfig(newPgURL) + config := api.NewConfig(strings.ReplaceAll(pgUrl, pgDbName, newName)) dbConfig := duty.RunMigrations(config) if !disableRLS { @@ -352,7 +296,7 @@ func NewDB(ctx context.Context, name string) (*context.Context, func(), error) { } return newCtx, func() { - if err := ctx.DB().Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s (FORCE)", quotedDBName)).Error; err != nil { + if err := ctx.DB().Exec(fmt.Sprintf("DROP DATABASE %s (FORCE)", newName)).Error; err != nil { logger.Errorf("error cleaning up db: %v", err) } }, nil diff --git a/types/client_options.go b/types/client_options.go index ed0c9f638..72007eb4d 100644 --- a/types/client_options.go +++ b/types/client_options.go @@ -4,6 +4,7 @@ import "github.com/flanksource/commons/har" type ClientOptions struct { HARCollector *har.Collector + Feature string } type ClientOption func(*ClientOptions) @@ -12,6 +13,10 @@ func WithHARCollector(c *har.Collector) ClientOption { return func(o *ClientOptions) { o.HARCollector = c } } +func WithFeature(name string) ClientOption { + return func(o *ClientOptions) { o.Feature = name } +} + func NewClientOptions(opts ...ClientOption) ClientOptions { var o ClientOptions for _, opt := range opts { diff --git a/views/006_config_views.sql b/views/006_config_views.sql index 5a51dc330..de9ae4313 100644 --- a/views/006_config_views.sql +++ b/views/006_config_views.sql @@ -183,11 +183,12 @@ BEGIN WITH RECURSIVE children AS ( SELECT config_items.id as child_id, config_items.parent_id, 0 as level FROM config_items - WHERE config_items.id = $1::uuid + WHERE config_items.id = $1::uuid AND config_items.deleted_at IS NULL UNION ALL SELECT m.id as child_id, m.parent_id, c.level + 1 as level FROM config_items m JOIN children c ON m.parent_id = c.child_id + WHERE m.deleted_at IS NULL ) SELECT children.child_id, children.parent_id, children.level FROM children WHERE children.level <= max_depth; @@ -202,11 +203,11 @@ RETURNS TABLE ( ) AS $$ BEGIN RETURN QUERY - SELECT cr.related_id AS id FROM config_relationships cr WHERE cr.config_id = $1::UUID + SELECT cr.related_id AS id FROM config_relationships cr WHERE cr.config_id = $1::UUID AND cr.deleted_at IS NULL UNION - SELECT cr.config_id as id FROM config_relationships cr WHERE cr.related_id = $1::UUID; + SELECT cr.config_id as id FROM config_relationships cr WHERE cr.related_id = $1::UUID AND cr.deleted_at IS NULL; END; -$$ +$$ language plpgsql; @@ -910,7 +911,8 @@ BEGIN LEFT JOIN (SELECT config_relationships.config_id, config_relationships.related_id FROM config_relationships - WHERE relation != 'hard') AS cr + WHERE relation != 'hard' AND config_relationships.deleted_at IS NULL + GROUP BY config_relationships.config_id, config_relationships.related_id) AS cr ON (cr.config_id = cc.config_id OR (soft AND cr.related_id = cc.config_id)) WHERE config_items.path LIKE ( SELECT CASE @@ -932,7 +934,8 @@ BEGIN LEFT JOIN (SELECT config_relationships.config_id, config_relationships.related_id FROM config_relationships - WHERE relation != 'hard') AS cr + WHERE relation != 'hard' AND config_relationships.deleted_at IS NULL + GROUP BY config_relationships.config_id, config_relationships.related_id) AS cr ON (cr.config_id = cc.config_id OR (soft AND cr.related_id = cc.config_id)) WHERE cc.config_id IN (SELECT get_recursive_path.id FROM get_recursive_path(lookup_id)) OR (cc.config_id = lookup_id) OR @@ -1012,10 +1015,10 @@ CREATE OR REPLACE VIEW config_detail AS LEFT JOIN config_items_last_scraped_time ON config_items_last_scraped_time.config_id = ci.id LEFT JOIN config_scrapers ON config_scrapers.id = ci.scraper_id LEFT JOIN - (SELECT config_id, count(*) as related_count FROM config_relationships GROUP BY config_id) as related + (SELECT config_id, count(DISTINCT (related_id, relation)) as related_count FROM config_relationships WHERE deleted_at IS NULL GROUP BY config_id) as related ON ci.id = related.config_id LEFT JOIN - (SELECT related_id, count(*) as related_count FROM config_relationships GROUP BY related_id) as reverse_related + (SELECT related_id, count(DISTINCT (config_id, relation)) as related_count FROM config_relationships WHERE deleted_at IS NULL GROUP BY related_id) as reverse_related ON ci.id = reverse_related.related_id LEFT JOIN (SELECT config_id, SUM(value::INT) as analysis_count FROM config_item_summary_7d @@ -1178,8 +1181,7 @@ BEGIN AND ci.type != v_config_item.type AND ci.deleted_at IS NULL AND ci.id != v_config_item.id -- Don't create relationship with itself - ON CONFLICT (related_id, config_id, relation) - DO NOTHING; + ON CONFLICT DO NOTHING; END IF; END; $$ LANGUAGE plpgsql; diff --git a/views/038_config_access.sql b/views/038_config_access.sql index f26cdcc9f..e9a5fccea 100644 --- a/views/038_config_access.sql +++ b/views/038_config_access.sql @@ -2,6 +2,7 @@ -- stale view DROP VIEW IF EXISTS user_config_access_summary; +DROP VIEW IF EXISTS external_group_summary; -- config_access_unwrapped -- Flattens config access permissions into one row per (config, principal, role). @@ -12,10 +13,15 @@ DROP VIEW IF EXISTS user_config_access_summary; -- or a truly empty group still holds the permission). -- 3. Direct user grants. CREATE OR REPLACE VIEW config_access_unwrapped AS +WITH active_external_user_groups AS ( + SELECT DISTINCT external_user_id, external_group_id + FROM external_user_groups + WHERE deleted_at IS NULL +) SELECT generate_ulid()::TEXT as id, config_access.config_id, - external_user_groups.external_user_id, + active_external_user_groups.external_user_id, config_access.external_group_id AS external_group_id, config_access.external_role_id, config_access.created_at, @@ -27,7 +33,7 @@ SELECT config_access.scraper_id FROM config_access - INNER JOIN external_user_groups ON config_access.external_group_id = external_user_groups.external_group_id + INNER JOIN active_external_user_groups ON config_access.external_group_id = active_external_user_groups.external_group_id AND config_access.deleted_at IS NULL AND config_access.external_group_id IS NOT NULL UNION ALL @@ -48,8 +54,8 @@ FROM config_access WHERE config_access.external_group_id IS NOT NULL AND config_access.deleted_at IS NULL AND NOT EXISTS ( - SELECT 1 FROM external_user_groups - WHERE external_user_groups.external_group_id = config_access.external_group_id + SELECT 1 FROM active_external_user_groups + WHERE active_external_user_groups.external_group_id = config_access.external_group_id ) UNION ALL SELECT @@ -81,6 +87,7 @@ SELECT config_access_unwrapped.external_group_id as external_group_id, config_access_unwrapped.external_user_id as external_user_id, external_roles.name as "role", + COALESCE(external_roles.aliases, ARRAY[]::text[]) as role_external_ids, COALESCE(external_users.name, external_groups.name) as "user", COALESCE(external_users.email, '') as "email", COALESCE(external_users.user_type, CASE WHEN external_groups.id IS NOT NULL THEN 'group' END) as user_type, @@ -202,3 +209,35 @@ SELECT MAX(config_access_summary.created_at) as latest_grant FROM config_access_summary GROUP BY config_access_summary.config_id, config_access_summary.config_name, config_access_summary.config_type; + +-- external_group_summary +CREATE VIEW external_group_summary AS +SELECT + external_groups.id, + external_groups.scraper_id, + external_groups.account_id, + external_groups.aliases, + external_groups.name, + external_groups.created_at, + external_groups.updated_at, + external_groups.deleted_at, + external_groups.group_type, + COALESCE(group_members.members_count, 0)::BIGINT AS members_count, + COALESCE(group_permissions.permissions_count, 0)::BIGINT AS permissions_count +FROM external_groups +LEFT JOIN ( + SELECT + external_user_groups.external_group_id, + COUNT(*) AS members_count + FROM external_user_groups + WHERE external_user_groups.deleted_at IS NULL + GROUP BY external_user_groups.external_group_id +) group_members ON group_members.external_group_id = external_groups.id +LEFT JOIN ( + SELECT + config_access_summary.external_group_id, + COUNT(*) AS permissions_count + FROM config_access_summary + WHERE config_access_summary.external_group_id IS NOT NULL + GROUP BY config_access_summary.external_group_id +) group_permissions ON group_permissions.external_group_id = external_groups.id; diff --git a/views/045_merge_external_entities.sql b/views/045_merge_external_entities.sql index a2e03155e..554ceec41 100644 --- a/views/045_merge_external_entities.sql +++ b/views/045_merge_external_entities.sql @@ -288,11 +288,11 @@ BEGIN DELETE FROM config_access_logs USING _eu_merges mp WHERE config_access_logs.external_user_id = mp.loser_id; - INSERT INTO external_user_groups (external_user_id, external_group_id, created_at) - SELECT mp.winner_id, eug.external_group_id, eug.created_at + INSERT INTO external_user_groups (external_user_id, external_group_id, scraper_id, created_at) + SELECT mp.winner_id, eug.external_group_id, eug.scraper_id, eug.created_at FROM external_user_groups eug JOIN _eu_merges mp ON eug.external_user_id = mp.loser_id WHERE eug.deleted_at IS NULL - ON CONFLICT (external_user_id, external_group_id) DO NOTHING; + ON CONFLICT (external_user_id, external_group_id, scraper_id) DO NOTHING; DELETE FROM external_user_groups USING _eu_merges mp WHERE external_user_groups.external_user_id = mp.loser_id; @@ -491,11 +491,11 @@ BEGIN WHERE config_access.external_group_id = mp.loser_id AND NOT EXISTS (SELECT 1 FROM _eg_ca_dups d WHERE d.id = config_access.id); - INSERT INTO external_user_groups (external_user_id, external_group_id, created_at) - SELECT eug.external_user_id, mp.winner_id, eug.created_at + INSERT INTO external_user_groups (external_user_id, external_group_id, scraper_id, created_at) + SELECT eug.external_user_id, mp.winner_id, eug.scraper_id, eug.created_at FROM external_user_groups eug JOIN _eg_merges mp ON eug.external_group_id = mp.loser_id WHERE eug.deleted_at IS NULL - ON CONFLICT (external_user_id, external_group_id) DO NOTHING; + ON CONFLICT (external_user_id, external_group_id, scraper_id) DO NOTHING; DELETE FROM external_user_groups USING _eg_merges mp WHERE external_user_groups.external_group_id = mp.loser_id; diff --git a/views/9998_rls_enable.sql b/views/9998_rls_enable.sql index 0d9d03048..6469b8027 100644 --- a/views/9998_rls_enable.sql +++ b/views/9998_rls_enable.sql @@ -397,6 +397,7 @@ ALTER VIEW config_tags SET (security_invoker = true); ALTER VIEW config_tags_labels_keys SET (security_invoker = true); ALTER VIEW config_types SET (security_invoker = true); ALTER VIEW configs SET (security_invoker = true); +ALTER VIEW external_group_summary SET (security_invoker = true); ALTER VIEW topology SET (security_invoker = true); ALTER VIEW incidents_by_config SET (security_invoker = true); ALTER VIEW playbook_names SET (security_invoker = true); From 0c76ca7f0dd9b4fd4a51ab8776e3941cc7cdfb26 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 1 May 2026 14:44:23 +0300 Subject: [PATCH 2/5] build(makefile): replace ginkgo with gavel test runner Replace ginkgo-based test targets with gavel, a unified test runner that provides better output formatting and cross-platform compatibility. Update test and test-concurrent targets to use gavel with consistent timeout and ignore patterns. Add gavel installation target and captain target for properties documentation generation. Add PROPERTIES.md and PROPERTIES.schema.json documenting all runtime properties across duty and related services. Update Taskfile.yaml test task to use gavel with passthrough argument support. --- Makefile | 44 +++- PROPERTIES.md | 550 +++++++++++++++++++++++++++++++++++++++++ PROPERTIES.schema.json | 270 ++++++++++++++++++++ Taskfile.yaml | 21 ++ 4 files changed, 880 insertions(+), 5 deletions(-) create mode 100644 PROPERTIES.md create mode 100644 PROPERTIES.schema.json diff --git a/Makefile b/Makefile index c0feef2c1..bc9851784 100644 --- a/Makefile +++ b/Makefile @@ -11,11 +11,28 @@ GOLANGCI_LINT_VERSION ?= v2.11.3 ginkgo: go install github.com/onsi/ginkgo/v2/ginkgo -test: ginkgo - ginkgo -r --succinct --skip-package=tests/e2e,tests/e2e-blobs,bench --label-filter "!e2e" - -test-concurrent: ginkgo - ginkgo -r -v --nodes=4 --skip-package=bench --label-filter "!e2e" +.PHONY: gavel +gavel: + @command -v gavel >/dev/null || go install github.com/flanksource/gavel/cmd/gavel@latest + +test: gavel + gavel test --timeout 30m --test-timeout 15m \ + --ignore ./bench \ + --ignore ./hack \ + --ignore ./specs \ + --ignore ./tests/e2e \ + --ignore ./tests/e2e-blobs \ + ./... + +test-concurrent: gavel + gavel test --timeout 30m --test-timeout 15m \ + --nodes 4 \ + --ignore ./bench \ + --ignore ./hack \ + --ignore ./specs \ + --ignore ./tests/e2e \ + --ignore ./tests/e2e-blobs \ + ./... .PHONY: test-e2e @@ -66,6 +83,23 @@ gen-schemas: go mod tidy && \ go run . +.PHONY: captain +captain: + @command -v captain >/dev/null || go install github.com/flanksource/captain@latest + +# Regenerate PROPERTIES.md and PROPERTIES.schema.json via the properties-doc skill. +# Cross-references sibling repos when present. +# Streams stream-json from `claude -p` and pipes it through `captain history` +# so tool usage / cost analysis is rendered as the run progresses. +.PHONY: PROPERTIES.md +PROPERTIES.md: captain + claude -p --permission-mode acceptEdits --verbose --output-format stream-json --model sonnet \ + "/properties-doc Refresh PROPERTIES.md and PROPERTIES.schema.json from the current source tree. Cross-reference ../incident-commander, ../config-db, ../canary-checker, ../flanksource-ui, and ../commons when present. Run in update mode and preserve hand-written prose sections." \ + | captain + +.PHONY: properties-doc +properties-doc: PROPERTIES.md + .PHONY: generate generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. $(CONTROLLER_GEN) object paths="./types/..." diff --git a/PROPERTIES.md b/PROPERTIES.md new file mode 100644 index 000000000..05929d54b --- /dev/null +++ b/PROPERTIES.md @@ -0,0 +1,550 @@ +# Duty Properties + +Duty uses runtime properties for behavior that should be adjustable without +changing code. These properties are plain string key/value pairs. They are +separate from model `properties` fields such as `config_items.properties`, +`components.properties`, or connection `properties`, which are resource +metadata stored in JSON columns. + +The machine-readable schema for JSON/YAML maps of these runtime properties is +[PROPERTIES.schema.json](/Users/moshe/go/src/github.com/flanksource/duty/PROPERTIES.schema.json). +The schema is intentionally kept outside `schema/openapi` because that +directory is generated. + +## Setting Properties + +### CLI + +Applications that bind `github.com/flanksource/commons/properties.BindFlags` +accept `-P` / `--properties`: + +```sh +duty-command -P log.level=debug -P query.log=true +duty-command --properties log.level.http=trace +``` + +CLI properties are held in the process-local commons property store. + +### Environment + +Duty does not provide a generic environment-variable-to-property mapper for +runtime properties. To set a runtime property from the environment, pass it +through the CLI, a properties file, DB, or code that calls `properties.Set`. + +```sh +duty-command -P query.log="$QUERY_LOG" +``` + +Startup configuration has explicit environment variables and environment +indirection: + +| Env var | Purpose | +| --- | --- | +| `DB_URL` | Default value for `--db` if `--db` is not set. | +| `PGRST_JWT_SECRET` | Default value for `--postgrest-jwt-secret` if the flag is not set. | +| `PGRST_VERSION` | Overrides the bundled PostgREST version. | +| `PGRST_ARCH` | Overrides the PostgREST binary architecture. | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | Default OpenTelemetry collector endpoint. | +| `OTEL_LABELS` | Comma-separated OpenTelemetry resource labels, `key=value,key2=value2`. | +| `POD_NAMESPACE` | Kubernetes namespace used by leader election. | +| `MC_HOSTNAME_OVERRIDE` | Hostname override used by leader election. | +| `DUTY_DB_DISABLE_RLS` | Used by tests and `hack/migrate`; disables RLS when set to `true`. | +| `DUTY_DB_URL`, `DUTY_DB_CREATE`, `DUTY_DB_DATA_DIR` | Test database setup only. | +| `DUTY_BENCH_SIZES` | Benchmark size list only. | +| `KUBECONFIG` | Kubernetes config path used by the Kubernetes client. | + +String startup flags are also passed through `api.Config.ReadEnv()`: when a +flag value is the name of an environment variable, Duty uses that variable's +value. For example, `--db DUTY_DB_URL` reads `DUTY_DB_URL`. + +### File + +Code may call `properties.LoadFile("duty.properties")`. The file format is: + +```properties +# comments are allowed +log.level=debug +query.log=true +topology.query.timeout=45s +``` + +The commons property loader watches the loaded file and reloads it on changes. +There is no built-in `--properties-file` flag in this package. + +Known embedding applications load these default files: + +| Application | Default file | +| --- | --- | +| incident-commander / mission-control | `mission-control.properties` | +| config-db | `config-db.properties` | +| canary-checker | `canary-checker.properties` | + +### Database + +Context-aware properties are read from the `properties` table: + +```sql +INSERT INTO properties (name, value) +VALUES ('query.log', 'true') +ON CONFLICT (name) DO UPDATE SET value = excluded.value; +``` + +From Go, use: + +```go +context.UpdateProperty(ctx, "query.log", "true") +context.UpdateProperties(ctx, map[string]string{"query.log": "true"}) +``` + +Database properties are cached in process for 15 minutes. `UpdateProperty` and +`UpdateProperties` clear the cache after writing. + +### Annotations + +For object-scoped lookup through `ctx.Properties()`, add annotations with one +of these prefixes: + +```yaml +metadata: + annotations: + mission-control/query.log: "true" + canary-checker/topology.query.timeout: 45s +``` + +The prefix is stripped before lookup, so `mission-control/query.log` sets +`query.log`. Child objects override parent objects. + +Logging annotations also accept unprefixed forms in addition to the prefixed +forms: + +```yaml +metadata: + annotations: + log.level: debug + trace: "true" + debug: "true" +``` + +## Precedence + +For context-aware properties resolved by `ctx.Properties()`: + +1. Process-local commons properties: CLI `-P`, loaded properties file, or code + that calls `properties.Set`. +2. Object annotations, with child object annotations overriding parent object + annotations. +3. Database rows in the `properties` table. +4. The hard-coded default at the call site. + +Process-global properties that call `properties.String`, `properties.Int`, +`properties.Duration`, or `properties.On` directly do not read DB rows or +annotations. They only see the process-local commons property store. + +Boolean values for `ctx.Properties().On` are true when set to `true`, +`enabled`, or `on`. `ctx.Properties().Off` treats `false`, `disabled`, and +`off` as off. The lower-level commons `properties.On` only treats `true` as +true. + +## Startup Flags + +These are not runtime properties, but they are the main Duty startup settings: + +| Flag | Default | Notes | +| --- | --- | --- | +| `--db` | `DB_URL` | PostgreSQL connection string. The default is resolved from `DB_URL`. | +| `--db-schema` | `public` | PostgreSQL schema. | +| `--postgrest-uri` | `http://localhost:3000` | Localhost starts an embedded PostgREST process. Empty disables PostgREST. | +| `--postgrest-log-level` | `info` | PostgREST log level. | +| `--postgrest-jwt-secret` | `PGRST_JWT_SECRET` | JWT secret. The default is resolved from `PGRST_JWT_SECRET`. | +| `--disable-postgrest` | varies | Deprecated; use `--postgrest-uri ''`. | +| `--postgrest-role` | `postgrest_api` | Authenticated PostgREST database role. | +| `--postgrest-anon-role` | `postgrest_anon` | Unauthenticated PostgREST database role. | +| `--postgrest-max-rows` | `2000` | Hard row limit for PostgREST. | +| `--db-log-level` | `error` | GORM log level: `trace`, `debug`, `info`, `error`. | +| `--disable-kubernetes` | `false` | Disable Kubernetes integration. | +| `--db-metrics` | `false` | Register GORM Prometheus metrics. | +| `--skip-migrations` | mode-dependent | Skip database migrations when migrations run by default. | +| `--db-migrations` | mode-dependent | Run migrations when migrations are skipped by default. Deprecated in run-by-default mode. | +| `--otel-collector-url` | `OTEL_EXPORTER_OTLP_ENDPOINT` | OpenTelemetry gRPC collector endpoint. | +| `--otel-service-name` | caller supplied | OpenTelemetry service name. | +| `--otel-insecure` | `true` | Disable TLS for the OpenTelemetry collector. | + +## Context-Aware Properties + +These properties are read through `ctx.Properties()` and can be set via CLI, +file, DB, or annotations. + +| Property | Type | Default | Effect | +| --- | --- | --- | --- | +| `artifacts.connection` | string | empty | Connection URL for external artifact/blob storage. Empty uses inline DB-backed blob storage. | +| `casbin.auto.save` | bool | `true` | Enables Casbin auto-save. | +| `casbin.cache` | bool | `true` | Enables the Casbin enforcer cache. | +| `casbin.cache.expiry` | duration | `1m` | Casbin cache expiry. | +| `casbin.cache.reload.interval` | duration | `5m` | Casbin policy auto-load interval. | +| `casbin.explain` | bool | `false` | Uses Casbin `EnforceEx` and logs matched rules. | +| `casbin.log.level` | int | `1` | Enables Casbin logging when `>= 2`. | +| `db.connection.timeout` | duration | `1h` | Statement timeout applied to the application DB user role. | +| `db.postgrest.timeout` | duration | `1m` | Statement timeout applied to PostgREST DB roles. | +| `envvar.cache.timeout` | duration | `5m` | Cache TTL for Kubernetes Secret and ConfigMap env var lookups. | +| `envvar.helm.cache.timeout` | duration | `envvar.cache.timeout` | Cache TTL for Helm release value lookups. | +| `envvar.lookup.timeout` | duration | `5s` | Timeout for resolving an `EnvVar` from Kubernetes sources. | +| `har.captureContentTypes` | CSV string | empty | Restricts HAR content capture to matching content types. | +| `har.maxBodySize` | int bytes | `65536` | Maximum HAR body capture size. | +| `job.ResetIsPushed.ignore_deleted_at` | bool | `false` | When true, reset-is-pushed queries do not add `deleted_at IS NULL`. | +| `job.ResetIsPushed.interval_days` | int | `7` | Lookback window for resetting `is_pushed`. | +| `job.eviction.period` | duration | `1m` | Sleep period for job-history eviction when no eviction IDs are queued. | +| `job.jitter.disable` | bool | `false` | Disables schedule jitter for periodic jobs. | +| `leader.lease.duration` | duration | `30s` | Kubernetes leader election lease duration. | +| `log.level` | string | logger default | Raises effective context observability level globally. | +| `log.level.http` | string | unset | Enables HTTP request/response header logging at `debug`; includes bodies at `trace`. | +| `log.level.http.har` | string | unset | Enables HAR capture for HTTP at `debug`; includes full body capture at `trace`. | +| `log.level.` | string | unset | Raises effective context logging for a named feature. For Kubernetes, aliases are `kubernetes`, `kubectl`, and `k8s`. | +| `log.level..har` | string | unset | Raises effective HAR capture level for a named feature. | +| `log.level.resourceSelector` | string | unset | When set, logs generated resource selector SQL at trace level on the `resourceSelector` logger. | +| `postgres.session.` | string | unset | Applied by `ApplySessionProperties` as `SET LOCAL = ''` inside a transaction. | +| `query.log` | bool | `false` | Logs resource selector and query logger output at normal verbosity. | +| `secretkeeper.cache.ttl` | duration | `10m` | TTL for the cloud secret keeper cache. | +| `shell.connection.wait_before_cleanup` | duration | `0` | Wait before cleaning up shell connection artifacts. | +| `topology.cache.age` | duration | `5m` | Cache age for topology responses. | +| `topology.query.timeout` | duration | `30s` | Default topology query timeout when the context has no deadline. | +| `update_is_pushed.batch.size` | int | `200` | Batch size for marking pushed records during upstream reconciliation. | +| `upstream.client.cache.view-columns.duration` | duration | go-cache default | Cache duration for upstream view-column client lookups. | +| `view.http.body.max_size_bytes` | int bytes | `26214400` | Maximum HTTP response body size for HTTP data queries. Non-positive values fall back to the default. | + +## Mission Properties + +These are additional properties used by `../incident-commander`. Unless noted +as process-global in the later table, they are resolved through +`ctx.Properties()` and can come from CLI/file, DB, or annotations. + +| Property | Type | Default | Effect | +| --- | --- | --- | --- | +| `access.log` | bool | `true` | Enable access logging. | +| `access.log.colors` | bool | `true` | Enable colors in detailed access logs. | +| `access.log.debug` | bool | `false` | Enable debug logging for access log middleware. | +| `access.log.request.body` | bool | `false` | Include request bodies in access logs. | +| `access.log.request.body.max` | int bytes | `2048` | Maximum request body bytes captured by access logs. | +| `access.log.request.header` | bool | mixed defaults | Include request headers in access logs. | +| `access.log.request.id` | bool | `false` | Include request IDs in access logs. | +| `access.log.response.body` | bool | `false` | Include response bodies in access logs. | +| `access.log.response.body.max` | int bytes | `8192` | Maximum response body bytes captured by access logs. | +| `access.log.skip.sanitize` | bool | `false` | Skip access log sanitization. | +| `access.log.spanId` | bool | `true` | Include span IDs in access logs. | +| `access.log.traceId` | bool | `true` | Include trace IDs in access logs. | +| `access.log.userAgent` | bool | `false` | Include user-agent values in access logs. | +| `artifacts.max_read_size` | int bytes | `52428800` | Maximum artifact bytes read for playbook artifact responses. Values `<= 0` disable the guard. | +| `auth.impersonation` | bool/off switch | `false` | `off`, `false`, or `disabled` disables scope impersonation. | +| `dashboard.default.view` | string | `mission-control-dashboard` | Default dashboard view name or `namespace/name`. | +| `event_queue.maxAge` | duration | `720h` | Maximum age for `event_queue` rows before cleanup. | +| `events.audit.size` | int | `20` | Number of recent events retained in audit rings. | +| `.batchSize` | int | handler value | Batch size for a named async event consumer, for example `notification.send.batchSize`. | +| `.debug` | bool/off switch | `false` | Enables debug logging for a named async event consumer when set to `off`/`false` by current code. | +| `.trace` | bool/off switch | `false` | Enables trace logging for a named async event consumer when set to `off`/`false` by current code. | +| `incidents.disable` | bool | `false` | Disable incident notification behavior. | +| `job.history.agentItemsToRetain` | int | `3` | Agent job-history entries to retain per status grouping. | +| `job.history.maxAge` | duration | `720h` | Maximum job history age before cleanup. | +| `job.history.running.maxAge` | duration | `4h` | Maximum running job age before marking stale. | +| `mcp.template.max-length` | int bytes | `65536` | Maximum MCP template size. | +| `mcp.template.timeout` | duration | `10s` | MCP template rendering timeout. | +| `metrics.agents.cache_ttl` | duration | `5m` | Prometheus agent collector cache TTL. | +| `metrics.canaries.cache_ttl` | duration | `5m` | Prometheus canary collector cache TTL. | +| `metrics.checks.cache_ttl` | duration | `5m` | Prometheus check collector cache TTL. | +| `metrics.checks.labels` | CSV string | empty | Check label include/exclude patterns for metrics. | +| `metrics.config_items.cache_ttl` | duration | `5m` | Prometheus config item collector cache TTL. | +| `metrics.disable` | CSV string | empty | Metric names to disable. `*` disables all supported metrics. | +| `metrics.prefix` | string | empty | Metric name prefix. | +| `notification.max-retries` | int | `4` | Maximum notification delivery retries. | +| `notifications.dedup.window` | duration | `24h` | Notification de-duplication window. | +| `notifications.error_reset_duration` | duration | `1h` | How long before notification errors can be reset. | +| `notifications.group_by_interval` | duration | `24h` | Default interval for grouped notifications. | +| `notifications.max.count` | int | `50` | Maximum notifications per rate-limit window. | +| `notifications.max.window` | duration | `4h` | Notification rate-limit window. | +| `playbook.action.ai.log-prompt` | bool | `false` | Log AI action prompts. | +| `playbook.action.consumers` | int | `5` | Number of playbook action consumers. | +| `playbook.consumer.timeout` | duration | `1m` | Playbook consumer timeout. | +| `playbook.retention.age` | duration | `720h` | Retention period for deleted playbooks. | +| `playbook.run.timeout` | duration | `30m` | Default playbook run timeout. | +| `playbook.runner.disabled` | bool | `false` | Disable playbook action runners. | +| `playbook.runner.longpoll.timeout` | duration | `45s` | Long-poll timeout for remote playbook runners. | +| `playbook.scheduler.disabled` | bool | `false` | Disable playbook run scheduler. | +| `playbook.schedulers` | int | `5` | Number of playbook run schedulers. | +| `rls.debug` | bool | `false` | Log RLS payloads. | +| `rls.disable` | bool | `false` | Disable RLS in startup checks. | +| `rls.enable` | bool | `false` | Enable RLS. | +| `scope.cache.ttl` | duration | `1m` | RBAC scope cache TTL. | +| `settings.user.disabled` | bool | `false` | Set by auth middleware when the current user is disabled. | +| `shorturl.defaultExpiry` | duration | `2160h` | Default short URL expiry. | +| `slack.max-url-length` | int | `50` | Maximum Slack URL length before shortening. Values above `3000` are ignored by code. | +| `upstream.pull_playbook_actions` | bool | `true` | Schedule upstream playbook action pull jobs. | +| `view.refresh.max-timeout` | duration | `1m` | Maximum timeout for asynchronous view refreshes. | + +## Config DB Properties + +These are additional properties used by `../config-db`. + +| Property | Type | Default | Effect | +| --- | --- | --- | --- | +| `azuredevops.concurrency` | int | `5` | Azure DevOps scraper concurrency. | +| `azuredevops.pipeline.max_age` | duration | `168h` | Maximum Azure DevOps pipeline run age to scrape. | +| `azuredevops.terminal_cache.ttl` | duration | `1h` | Azure DevOps terminal status cache TTL. | +| `change_retention.delete_batch_size` | int | `1000` | Batch size for config change retention deletes. | +| `changes.dedup.disable` | bool | `false` | Disable config change de-duplication. | +| `changes.dedup.window` | duration | `1h` | Config change de-duplication window. | +| `config.retention.period` | duration | `168h` | Retention period for deleted config items. | +| `config.retention.stale_item_age` | duration | scraper default | Age after which stale config items are soft deleted. | +| `config_analysis.retention.max_age` | duration | `48h` | Age after which stale config analyses are marked resolved. | +| `config_analysis.set_status_closed_days` | int days | `7` | Days after which resolved config analyses are closed. | +| `config_scraper.retention.period` | duration | `720h` | Retention period for deleted config scraper records. | +| `diff.rust-gen` | bool | `false` | Use the alternate diff implementation when available. | +| `external.cache.timeout` | duration | `24h` | External entity cache timeout. | +| `incremental_scrape_event.lag_threshold` | duration | `30s` | Slow-event threshold for incremental scrape event logging. | +| `kubernetes.get.concurrency` | int | `10` | Concurrency for Kubernetes fetch operations. | +| `kubernetes.rbac_config_access` | bool | `true` | Enable config access generation from Kubernetes RBAC. | +| `scraper.concurrency` | int | `12` | Global config scraper concurrency. | +| `scraper..concurrency` | int | type default | Per-type scraper concurrency. Known types include `aws`, `azure`, `azuredevops`, `file`, `gcp`, `githubactions`, `http`, `kubernetes`, `kubernetesfile`, `slack`, `sql`, `terraform`, `trivy`, and `playwright`. | +| `scraper..schedule` | string | spec/default | Per-scrape-config schedule override by UID. | +| `scraper..schedule.min` | duration | `29s` | Minimum schedule interval for a scraper type. | +| `scraper.aws.trusted_advisor.minInterval` | duration | `16h` | Minimum interval between AWS Trusted Advisor scrapes. | +| `scraper.diff.disable` | bool | `false` | Disable config diff generation. | +| `scraper.diff.timer.minSize` | int bytes | `20480` | Minimum config size before diff memory timing at high verbosity. | +| `scraper.log.items` | bool | `false` | Log config scraper item processing details. | +| `scraper.log.slow_diff_threshold` | duration | `1s` | Threshold for slow diff warnings. | +| `scraper.timeout` | duration | `4h` | Default scraper timeout. | +| `scraper.` | bool | varies | `ScrapeContext.PropertyOn` prefixes keys with `scraper.` and also checks `scraper..`. Common keys include `azure.devops.incremental`, `capture.har`, `capture.logs`, `capture.snapshots`, `runNow`, `disable`, `watch.disable`, `log.exclusions`, `log.skipped`, `log.noResourceId`, `log.items`, `log.missing`, `log.relationships`, `log.rule.expr`, `log.transforms`, and `log.changes.unmatched`. | +| `scraper.scraper.label.missing` | bool | `false` | Effective key for current `PropertyOn("scraper.label.missing")` usage. | +| `scraper.scraper.tag.missing` | bool | `false` | Effective key for current `PropertyOn("scraper.tag.missing")` usage. | +| `scrapers.default.schedule` | string | startup flag default | Default schedule for scrape configs without an explicit schedule. | +| `scrapers.event.stale-timeout` | duration | `1h` | Scraper event stale timeout. | +| `scrapers.event.workers` | int | `2` | Number of scraper event workers. | +| `scrapers.githubactions.concurrency` | int | `10` | GitHub Actions API request concurrency per repository. | +| `scrapers.githubactions.maxAge` | duration | `168h` | Maximum age for GitHub Actions workflow runs. | + +## Canary Checker Properties + +These are additional properties used by `../canary-checker`. + +| Property | Type | Default | Effect | +| --- | --- | --- | --- | +| `canary.retention.age` | duration | `168h` | Retention period for soft-deleted canaries. | +| `canary.status.max.error` | int bytes | `131072` | Maximum check status error length. | +| `canary.status.max.message` | int bytes | `4096` | Maximum check status message length. | +| `check.*.disabled` | bool | `false` | Disable canary check job synchronization. | +| `check.retention.age` | duration | `168h` | Retention period for soft-deleted checks. | +| `check.status.retention.days` | int days | `30` | Check status retention in days. | +| `checks.kubernetesResource.maxResources` | int | `10` | Maximum total Kubernetes resources allowed in a Kubernetes resource check. | +| `component.retention.period` | duration | `168h` | Retention period for soft-deleted components. | +| `components.delete_batch_size` | int | `100` | Batch size for component deletion during topology sync. | +| `http.har` | bool | `false` | Enable HAR collection. | +| `http.har.location` | string | `.` | Directory where HAR files are written. | +| `pubsub.max_messages` | int | `1000` | Maximum Pub/Sub messages read by a canary check. | +| `s3.list.max-objects` | int | `50000` | Maximum S3 objects listed by folder checks. | +| `upstream.pull_canaries` | bool | `true` | Schedule canary upstream pull jobs. | + +## Flanksource UI Property Usage + +`../flanksource-ui` reads `/properties` as feature flags. These are database +properties served by the API; they are not object annotations. The Settings > +Feature Flags page can create and update DB-backed rows, but rows with +`source=local` are displayed read-only. + +The UI also uses many resource metadata `properties` fields, for example +connection form `properties`, topology/config display properties, and playbook +parameter UI hints such as `language`, `jsonSchemaUrl`, `options`, `filter`, +`multiline`, `min`, `max`, `minLength`, `maxLength`, and `regex`. Those are not +runtime properties and are not listed in the schema. + +### UI Feature Flags + +Feature flags use the property name `.disable`. A feature is disabled +only when the property value is exactly the string `true`; missing rows and any +other value leave the feature enabled. + +| Property | Effect | +| --- | --- | +| `topology.disable` | Hide or disable topology UI surfaces. | +| `health.disable` | Hide or disable health UI surfaces. | +| `incidents.disable` | Hide or disable incident UI surfaces. Also disables incident notification behavior in incident-commander when read by the backend. | +| `config.disable` | Hide or disable config UI surfaces. | +| `logs.disable` | Hide or disable log UI surfaces. | +| `playbooks.disable` | Hide or disable playbook UI surfaces. | +| `applications.disable` | Hide or disable application UI surfaces. | +| `views.disable` | Hide or disable custom view UI surfaces. | +| `ai.disable` | Hide or disable AI actions and prompts in UI surfaces that check this flag. | +| `agents.disable` | Hide or disable agent UI surfaces. | +| `settings.connections.disable` | Hide or disable connection settings. | +| `settings.users.disable` | Hide or disable user settings. | +| `settings.teams.disable` | Hide or disable team settings. | +| `settings.rules.disable` | Hide or disable rules settings. | +| `settings.config_scraper.disable` | Hide or disable config scraper settings. Also disabled when `config.disable=true`. | +| `settings.topology.disable` | Hide or disable topology settings. Also disabled when `topology.disable=true`. | +| `settings.health.disable` | Hide or disable health settings. Also disabled when `health.disable=true`. | +| `settings.job_history.disable` | Hide or disable job history settings. Also disabled when `health.disable=true`. | +| `settings.feature_flags.disable` | Hide or disable the feature flags settings page. | +| `settings.logging_backends.disable` | Hide or disable logging backend settings. | +| `settings.event_queue_status.disable` | Hide or disable event queue status settings. | +| `settings.organization_profile.disable` | Hide or disable organization profile settings. | +| `settings.notifications.disable` | Hide or disable notification settings. | +| `settings.playbooks.disable` | Hide or disable playbook settings. | +| `settings.integrations.disable` | Hide or disable integration settings. | +| `settings.permissions.disable` | Hide or disable permission settings. | +| `settings.artifacts.disable` | Hide or disable artifact settings. | + +### UI Snippets + +| Property | Type | Source | Effect | +| --- | --- | --- | --- | +| `flanksource.ui.snippets` | JavaScript function expression string | `local` only | Executed once after the authenticated user is available. The function receives `{ user, organization }`. | + +Only a `flanksource.ui.snippets` row whose `source` is `local` is executed by +the UI. DB-backed rows with the same name are fetched and visible in the +feature-flag list, but the snippet hook ignores them. + +Example value: + +```js +({ user, organization }) => { + window.analytics?.identify(user?.id, { + email: user?.email, + organization: organization?.name + }); +} +``` + +### UI Dashboard Selection + +| Property | Type | Effect | +| --- | --- | --- | +| `defaults.dashboard_view` | string | Used by the UI sidebar to decide which custom view should be shown as the dashboard navigation item. It accepts a view UUID, `namespace/name`, or name. | +| `dashboard.default.view` | string | Used by the backend `/api/dashboard` endpoint to resolve the actual homepage dashboard view. It accepts `namespace/name` or name and defaults to `mission-control-dashboard`. | + +### UI Proxy Selection + +| Property | Type | Effect | +| --- | --- | --- | +| `proxy.disable` | bool | In Clerk auth mode, overrides the organization's `direct` metadata. When true, the UI bypasses the proxy and points API clients at the organization's `backend_url`. | + +### Job Properties + +Jobs support global, per-job, and per-job-id property names: + +| Property pattern | Type | Default | Effect | +| --- | --- | --- | --- | +| `jobs..schedule` | string | job value | Overrides a job's cron schedule. | +| `jobs...schedule` | string | job value | Intended per-id schedule override. The current lookup checks `jobs..schedule` first. | +| `jobs..timeout` | duration | job value | Overrides job timeout. | +| `jobs...timeout` | duration | job value | Intended per-id timeout override. The current lookup checks `jobs..timeout` first. | +| `jobs..history` | bool | `true` | Enables job history. | +| `jobs...history` | bool | `true` | Enables job history for a specific job id. | +| `jobs.history` | bool | `true` | Fallback for all jobs. | +| `jobs..trace` | bool | `false` | Enables trace logging for a job. | +| `jobs...trace` | bool | `false` | Enables trace logging for a specific job id. | +| `jobs.trace` | bool | `false` | Fallback for all jobs. | +| `jobs..debug` | bool | `false` | Enables debug logging for a job. | +| `jobs...debug` | bool | `false` | Enables debug logging for a specific job id. | +| `jobs.debug` | bool | `false` | Fallback for all jobs. | +| `jobs..singleton` | bool | job value | Overrides singleton behavior. | +| `jobs...singleton` | bool | job value | Overrides singleton behavior for a specific job id. | +| `jobs.singleton` | bool | job value | Fallback for all jobs. | +| `jobs..disable` | bool | `false` | Disables a job. | +| `jobs...disable` | bool | `false` | Disables a specific job id. | +| `jobs.disable` | bool | `false` | Fallback for all jobs. | +| `jobs..disabled` | bool | `false` | Alias for `disable`. | +| `jobs...disabled` | bool | `false` | Alias for `disable` for a specific job id. | +| `jobs.disabled` | bool | `false` | Fallback alias for all jobs. | +| `jobs..retention.success` | int | job value | Successful job-history entries to retain. | +| `jobs...retention.success` | int | job value | Intended per-id success retention override. The current lookup checks `jobs..retention.success` first. | +| `jobs..retention.failed` | int | job value | Failed, warning, or skipped job-history entries to retain. | +| `jobs...retention.failed` | int | job value | Intended per-id failed retention override. The current lookup checks `jobs..retention.failed` first. | + +Boolean job properties are looked up from most specific to least specific: +`jobs...`, `jobs..`, then `jobs.`. + +String and int job helpers currently check `jobs..` before +`jobs...`. + +## Process-Global Properties + +These properties are read directly from the commons process-local property +store. They can be set via CLI `-P`, a loaded properties file, debug property +POSTs, or code that calls `properties.Set`, but not via DB rows or annotations. + +| Property | Type | Default | Effect | +| --- | --- | --- | --- | +| `access_token.default_expiry` | duration | `2160h` | Default expiry for generated access tokens. | +| `canary.status.max.error` | int bytes | `131072` | Maximum check status error length. | +| `canary.status.max.message` | int bytes | `4096` | Maximum check status message length. | +| `change_retention.delete_batch_size` | int | `1000` | Batch size for config change retention deletes. | +| `components.delete_batch_size` | int | `100` | Batch size for component deletion during topology sync. | +| `config.traversal.cache_expiry.min` | duration | `2h` | Minimum randomized expiry for config traversal cache entries. | +| `config.traversal.cache_expiry.max` | duration | `4h` | Maximum randomized expiry for config traversal cache entries. | +| `config_analysis.set_status_closed_days` | int days | `7` | Days after which resolved config analyses are closed. | +| `db.migrate.skip` | bool | `false` | Skips database migrations in `migrate.Migrate`. | +| `diff.rust-gen` | bool | `false` | Use the alternate diff implementation when available. | +| `envvar.lookup.log` | bool | `false` | Logs resolved env var lookup values when the logger verbosity is high enough. | +| `external.cache.timeout` | duration | `24h` | External entity cache timeout in config-db. | +| `http.body.disabled` | bool | `false` | Disables HTTP body capture in commons HTTP trace middleware. | +| `http.headers.disabled` | bool | `false` | Disables HTTP header capture in commons HTTP trace middleware. | +| `http.log.response.body.length` | int bytes | `4096` | Maximum logged HTTP response body length. | +| `incremental_scrape_event.lag_threshold` | duration | `30s` | Slow-event threshold for incremental scrape event logging. | +| `job_history.agent_cleanup.batch_size` | int | `2000` | Batch size for stale agent job-history cleanup. | +| `kubernetes.cache.timeout` | duration | `240m` | Kubernetes discovery cache timeout for the REST mapper. | +| `log.color` | bool | logger flag default | Enables colored logs. Automatically forced to false when `log.json=true`. | +| `log.color.` | bool | `log.color` | Color setting for a named logger. | +| `log.caller` | bool | logger flag default | Adds source caller information to logs. | +| `log.caller.` | bool | `log.caller` | Caller setting for a named logger. | +| `db.log.level` | string | unset | Updates the `db` logger level through the commons logger property listener. | +| `log.db.maxLength` | int | `1024` | Maximum SQL log length. | +| `log.db.params` | bool | `false` | Logs SQL parameters when DB trace logging is enabled. | +| `log.db.slowThreshold` | duration | `1s` | GORM slow query threshold. | +| `log.json` | bool | logger flag default | Emits JSON logs. | +| `log.kubeproxy` | bool | `false` | Enable kube proxy logging in incident-commander. | +| `log.level` | string | `info` | Root logger level for commons loggers. | +| `log.level.` | string | `log.level` | Named logger level. Special case: `log.level.http` wraps the default HTTP client transport with a logger. | +| `log.report.caller` | bool | logger flag default | Alias used by `logger.Configure`; also updates caller reporting. | +| `log.time.format` | string | `15:04:05.000` | Log timestamp format. | +| `log.time.format.` | string | `log.time.format` | Timestamp format for a named logger. | +| `memory.stats` | duration | `0` | When positive, starts periodic memory stats logging on the debug server. | +| `metrics.auth.disabled` | bool | `false` | Disable authentication for `/metrics` in incident-commander. | +| `notification.tracing` | bool | `false` | Enable notification tracing. | +| `notifications.labels.order` | string | built-in label order | Overrides default display ordering for selected labels. | +| `notifications.labels.whitelist` | string | built-in whitelist | Overrides default label whitelist groups. | +| `pubsub.max_messages` | int | `1000` | Maximum Pub/Sub messages read by a canary check. | +| `response.strip_upstream_cors` | bool | `true` | Strip upstream CORS headers in incident-commander proxy responses. | +| `shell.allowed.envs` | CSV string | empty | Additional environment variable names passed through to shell executions. Read during package init. | +| `shell.jq.timeout` | duration | `5s` | Timeout for `jq` execution. | +| `shell.yq.timeout` | duration | `shell.jq.timeout` | Timeout for `yq` execution. | +| `smtp.debug` | bool | `false` | Enable SMTP debug logging. | +| `upstream.pull_canaries` | bool | `true` | Schedule canary upstream pull jobs. | +| `upstream.summary.fkerror_id_count` | int | `10` | Number of foreign-key error IDs included in upstream reconciliation summaries. | + +## Observability Notes + +HTTP and HAR context properties are feature-aware. For example: + +```properties +log.level.http=debug +log.level.http.har=trace +log.level.kubernetes=debug +log.level.kubernetes.har=debug +har.maxBodySize=131072 +``` + +For plain HTTP logging, `debug` logs headers and `trace` logs headers plus +bodies. For HAR capture, `debug` captures request/response metadata and +`trace` enables the HAR collector middleware with body capture subject to HAR +configuration. + +## Discovery Endpoints + +When the debug routes are registered: + +| Endpoint | Result | +| --- | --- | +| `GET /debug/properties` | Supported context-aware properties that have been touched in the running process, including type, default, and current value. | +| `GET /debug/system/properties` | Process-local commons properties. | +| `POST /debug/property` | Sets a process-local commons property for the running process. | +| `echo.Properties` handler | Combined process-local and DB properties, with their source, wherever the embedding application mounts it. | + +`GET /debug/properties` is populated lazily by calls to `ctx.Properties()`. +It is useful for introspection, but it is not a complete static registry until +the relevant code paths have executed. diff --git a/PROPERTIES.schema.json b/PROPERTIES.schema.json new file mode 100644 index 000000000..757db51de --- /dev/null +++ b/PROPERTIES.schema.json @@ -0,0 +1,270 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flanksource.com/schemas/duty/properties.schema.json", + "title": "Flanksource runtime properties", + "description": "Runtime properties consumed by duty and related Flanksource services. Values are stored as strings in .properties files and the database, but this schema also accepts native JSON booleans and numbers for JSON/YAML authoring.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/propertyValue" + }, + "properties": { + "access.log": { "$ref": "#/$defs/bool", "default": true, "description": "Enable HTTP access logging in incident-commander." }, + "access.log.colors": { "$ref": "#/$defs/bool", "default": true, "description": "Enable colors in detailed access logs." }, + "access.log.debug": { "$ref": "#/$defs/bool", "default": false, "description": "Enable debug logging for access log middleware." }, + "access.log.request.body": { "$ref": "#/$defs/bool", "default": false, "description": "Include request bodies in access logs." }, + "access.log.request.body.max": { "$ref": "#/$defs/int", "default": 2048, "description": "Maximum request body bytes captured by access logs." }, + "access.log.request.header": { "$ref": "#/$defs/bool", "default": true, "description": "Include request headers in access logs." }, + "access.log.request.id": { "$ref": "#/$defs/bool", "default": false, "description": "Include request IDs in access logs." }, + "access.log.response.body": { "$ref": "#/$defs/bool", "default": false, "description": "Include response bodies in access logs." }, + "access.log.response.body.max": { "$ref": "#/$defs/int", "default": 8192, "description": "Maximum response body bytes captured by access logs." }, + "access.log.skip.sanitize": { "$ref": "#/$defs/bool", "default": false, "description": "Skip access log sanitization." }, + "access.log.spanId": { "$ref": "#/$defs/bool", "default": true, "description": "Include span IDs in access logs." }, + "access.log.traceId": { "$ref": "#/$defs/bool", "default": true, "description": "Include trace IDs in access logs." }, + "access.log.userAgent": { "$ref": "#/$defs/bool", "default": false, "description": "Include user-agent values in access logs." }, + "access_token.default_expiry": { "$ref": "#/$defs/duration", "default": "2160h", "description": "Default expiry for generated access tokens." }, + "agents.disable": { "$ref": "#/$defs/bool", "default": false, "description": "UI feature flag: set value to the string true to hide or disable agent UI surfaces." }, + "ai.disable": { "$ref": "#/$defs/bool", "default": false, "description": "UI feature flag: set value to the string true to hide or disable AI UI surfaces." }, + "applications.disable": { "$ref": "#/$defs/bool", "default": false, "description": "UI feature flag: set value to the string true to hide or disable application UI surfaces." }, + "artifacts.connection": { "type": "string", "description": "Connection URL for external artifact/blob storage. Empty uses inline DB-backed storage." }, + "artifacts.max_read_size": { "$ref": "#/$defs/int", "default": 52428800, "description": "Maximum artifact bytes read for incident-commander artifact responses. Set <= 0 to disable the guard." }, + "auth.impersonation": { "$ref": "#/$defs/bool", "default": false, "description": "Set to false/off to disable scope impersonation." }, + "azuredevops.concurrency": { "$ref": "#/$defs/int", "default": 5, "description": "Azure DevOps scraper concurrency." }, + "azuredevops.pipeline.max_age": { "$ref": "#/$defs/duration", "default": "168h", "description": "Maximum Azure DevOps pipeline run age to scrape." }, + "azuredevops.terminal_cache.ttl": { "$ref": "#/$defs/duration", "default": "1h", "description": "Azure DevOps terminal status cache TTL." }, + "canary.retention.age": { "$ref": "#/$defs/duration", "default": "168h", "description": "Retention period for soft-deleted canaries." }, + "canary.status.max.error": { "$ref": "#/$defs/int", "default": 131072, "description": "Maximum check status error length." }, + "canary.status.max.message": { "$ref": "#/$defs/int", "default": 4096, "description": "Maximum check status message length." }, + "casbin.auto.save": { "$ref": "#/$defs/bool", "default": true, "description": "Enable Casbin auto-save." }, + "casbin.cache": { "$ref": "#/$defs/bool", "default": true, "description": "Enable the Casbin enforcer cache." }, + "casbin.cache.expiry": { "$ref": "#/$defs/duration", "default": "1m", "description": "Casbin cache expiry." }, + "casbin.cache.reload.interval": { "$ref": "#/$defs/duration", "default": "5m", "description": "Casbin policy auto-load interval." }, + "casbin.explain": { "$ref": "#/$defs/bool", "default": false, "description": "Use Casbin EnforceEx and log matched rules." }, + "casbin.log.level": { "$ref": "#/$defs/int", "default": 1, "description": "Enable Casbin logging when >= 2." }, + "change_retention.delete_batch_size": { "$ref": "#/$defs/int", "default": 1000, "description": "Batch size for config change retention deletes." }, + "changes.dedup.disable": { "$ref": "#/$defs/bool", "default": false, "description": "Disable config change de-duplication." }, + "changes.dedup.window": { "$ref": "#/$defs/duration", "default": "1h", "description": "Config change de-duplication window." }, + "check.*.disabled": { "$ref": "#/$defs/bool", "default": false, "description": "Disable canary check job synchronization." }, + "check.retention.age": { "$ref": "#/$defs/duration", "default": "168h", "description": "Retention period for soft-deleted checks." }, + "check.status.retention.days": { "$ref": "#/$defs/int", "default": 30, "description": "Check status retention in days." }, + "checks.kubernetesResource.maxResources": { "$ref": "#/$defs/int", "default": 10, "description": "Maximum total Kubernetes resources allowed in a Kubernetes resource check." }, + "component.retention.period": { "$ref": "#/$defs/duration", "default": "168h", "description": "Retention period for soft-deleted components." }, + "components.delete_batch_size": { "$ref": "#/$defs/int", "default": 100, "description": "Batch size for component deletion during topology sync." }, + "config.retention.period": { "$ref": "#/$defs/duration", "default": "168h", "description": "Retention period for deleted config items." }, + "config.retention.stale_item_age": { "$ref": "#/$defs/duration", "description": "Age after which stale config items are soft deleted." }, + "config.traversal.cache_expiry.max": { "$ref": "#/$defs/duration", "default": "4h", "description": "Maximum randomized expiry for config traversal cache entries." }, + "config.traversal.cache_expiry.min": { "$ref": "#/$defs/duration", "default": "2h", "description": "Minimum randomized expiry for config traversal cache entries." }, + "config.disable": { "$ref": "#/$defs/bool", "default": false, "description": "UI feature flag: set value to the string true to hide or disable config UI surfaces." }, + "config_analysis.retention.max_age": { "$ref": "#/$defs/duration", "default": "48h", "description": "Age after which stale config analyses are marked resolved." }, + "config_analysis.set_status_closed_days": { "$ref": "#/$defs/int", "default": 7, "description": "Days after which resolved config analyses are closed." }, + "config_scraper.retention.period": { "$ref": "#/$defs/duration", "default": "720h", "description": "Retention period for deleted config scraper records." }, + "dashboard.default.view": { "type": "string", "default": "mission-control-dashboard", "description": "Default dashboard view name or namespace/name." }, + "defaults.dashboard_view": { "type": "string", "description": "UI sidebar dashboard view selector. Value may be a view UUID, name, or namespace/name." }, + "db.connection.timeout": { "$ref": "#/$defs/duration", "default": "1h", "description": "Statement timeout applied to the application DB user role." }, + "db.log.level": { "$ref": "#/$defs/logLevel", "description": "Update the db logger level through the commons logger property listener." }, + "db.migrate.skip": { "$ref": "#/$defs/bool", "default": false, "description": "Skip database migrations." }, + "db.postgrest.timeout": { "$ref": "#/$defs/duration", "default": "1m", "description": "Statement timeout applied to PostgREST DB roles." }, + "diff.rust-gen": { "$ref": "#/$defs/bool", "default": false, "description": "Use the alternate diff implementation when available." }, + "envvar.cache.timeout": { "$ref": "#/$defs/duration", "default": "5m", "description": "Cache TTL for Kubernetes Secret and ConfigMap env var lookups." }, + "envvar.helm.cache.timeout": { "$ref": "#/$defs/duration", "default": "5m", "description": "Cache TTL for Helm release value lookups." }, + "envvar.lookup.log": { "$ref": "#/$defs/bool", "default": false, "description": "Log resolved env var lookup values when logger verbosity is high enough." }, + "envvar.lookup.timeout": { "$ref": "#/$defs/duration", "default": "5s", "description": "Timeout for resolving an EnvVar from Kubernetes sources." }, + "event_queue.maxAge": { "$ref": "#/$defs/duration", "default": "720h", "description": "Maximum age for event_queue rows before cleanup." }, + "events.audit.size": { "$ref": "#/$defs/int", "default": 20, "description": "Number of recent events retained in audit rings." }, + "external.cache.timeout": { "$ref": "#/$defs/duration", "default": "24h", "description": "External entity cache timeout in config-db." }, + "har.captureContentTypes": { "$ref": "#/$defs/csv", "description": "Comma-separated content types eligible for HAR body capture." }, + "har.maxBodySize": { "$ref": "#/$defs/int", "default": 65536, "description": "Maximum HAR body capture size in bytes." }, + "health.disable": { "$ref": "#/$defs/bool", "default": false, "description": "UI feature flag: set value to the string true to hide or disable health UI surfaces. Also disables settings.health and settings.job_history." }, + "http.body.disabled": { "$ref": "#/$defs/bool", "default": false, "description": "Disable HTTP body capture in commons HTTP trace middleware." }, + "http.har": { "$ref": "#/$defs/bool", "default": false, "description": "Enable HAR collection in canary-checker." }, + "http.har.location": { "type": "string", "default": ".", "description": "Directory where canary-checker writes HAR files." }, + "http.headers.disabled": { "$ref": "#/$defs/bool", "default": false, "description": "Disable HTTP header capture in commons HTTP trace middleware." }, + "http.log.response.body.length": { "$ref": "#/$defs/int", "default": 4096, "description": "Maximum logged HTTP response body length." }, + "flanksource.ui.snippets": { "type": "string", "description": "Local UI snippet value. The UI executes local-source values as a JavaScript function expression once a user is loaded, passing { user, organization }." }, + "incidents.disable": { "$ref": "#/$defs/bool", "default": false, "description": "Disable incident notification behavior in incident-commander; also a UI feature flag when the value is the string true." }, + "incremental_scrape_event.lag_threshold": { "$ref": "#/$defs/duration", "default": "30s", "description": "Slow-event threshold for incremental scrape event logging." }, + "job.ResetIsPushed.ignore_deleted_at": { "$ref": "#/$defs/bool", "default": false, "description": "Do not add deleted_at IS NULL during reset-is-pushed queries." }, + "job.ResetIsPushed.interval_days": { "$ref": "#/$defs/int", "default": 7, "description": "Lookback window in days for resetting is_pushed." }, + "job.eviction.period": { "$ref": "#/$defs/duration", "default": "1m", "description": "Sleep period for job-history eviction when no eviction IDs are queued." }, + "job.history.agentItemsToRetain": { "$ref": "#/$defs/int", "default": 3, "description": "Agent job-history entries to retain per status grouping." }, + "job.history.maxAge": { "$ref": "#/$defs/duration", "default": "720h", "description": "Maximum job history age before cleanup." }, + "job.history.running.maxAge": { "$ref": "#/$defs/duration", "default": "4h", "description": "Maximum running job age before marking stale." }, + "job.jitter.disable": { "$ref": "#/$defs/bool", "default": false, "description": "Disable schedule jitter for periodic jobs." }, + "job_history.agent_cleanup.batch_size": { "$ref": "#/$defs/int", "default": 2000, "description": "Batch size for stale agent job-history cleanup." }, + "kubernetes.cache.timeout": { "$ref": "#/$defs/duration", "default": "240m", "description": "Kubernetes discovery cache timeout for the REST mapper." }, + "kubernetes.get.concurrency": { "$ref": "#/$defs/int", "default": 10, "description": "Concurrency for Kubernetes fetch operations." }, + "kubernetes.rbac_config_access": { "$ref": "#/$defs/bool", "default": true, "description": "Enable config access generation from Kubernetes RBAC." }, + "leader.lease.duration": { "$ref": "#/$defs/duration", "default": "30s", "description": "Kubernetes leader election lease duration." }, + "log.color": { "$ref": "#/$defs/bool", "description": "Enable colored logs." }, + "log.caller": { "$ref": "#/$defs/bool", "description": "Add source caller information to logs." }, + "log.db.maxLength": { "$ref": "#/$defs/int", "default": 1024, "description": "Maximum SQL log length." }, + "log.db.params": { "$ref": "#/$defs/bool", "default": false, "description": "Log SQL parameters when DB trace logging is enabled." }, + "log.db.slowThreshold": { "$ref": "#/$defs/duration", "default": "1s", "description": "GORM slow query threshold." }, + "log.json": { "$ref": "#/$defs/bool", "description": "Emit JSON logs." }, + "log.kubeproxy": { "$ref": "#/$defs/bool", "default": false, "description": "Enable kube proxy logging in incident-commander." }, + "log.level": { "$ref": "#/$defs/logLevel", "default": "info", "description": "Root logger level and effective context observability floor." }, + "log.level.http": { "$ref": "#/$defs/logLevel", "description": "HTTP client logging level." }, + "log.level.http.har": { "$ref": "#/$defs/logLevel", "description": "HTTP HAR capture logging level." }, + "log.level.resourceSelector": { "$ref": "#/$defs/logLevel", "description": "Enable resource selector SQL logging when set." }, + "log.report.caller": { "$ref": "#/$defs/bool", "description": "Alias used by logger.Configure for caller reporting." }, + "log.time.format": { "type": "string", "default": "15:04:05.000", "description": "Log timestamp format." }, + "logs.disable": { "$ref": "#/$defs/bool", "default": false, "description": "UI feature flag: set value to the string true to hide or disable log UI surfaces." }, + "mcp.template.max-length": { "$ref": "#/$defs/int", "default": 65536, "description": "Maximum MCP template size in bytes." }, + "mcp.template.timeout": { "$ref": "#/$defs/duration", "default": "10s", "description": "MCP template rendering timeout." }, + "memory.stats": { "$ref": "#/$defs/duration", "default": "0", "description": "When positive, periodically log memory stats on the debug server." }, + "metrics.agents.cache_ttl": { "$ref": "#/$defs/duration", "default": "5m", "description": "Prometheus agent collector cache TTL." }, + "metrics.auth.disabled": { "$ref": "#/$defs/bool", "default": false, "description": "Disable authentication for /metrics in incident-commander." }, + "metrics.canaries.cache_ttl": { "$ref": "#/$defs/duration", "default": "5m", "description": "Prometheus canary collector cache TTL." }, + "metrics.checks.cache_ttl": { "$ref": "#/$defs/duration", "default": "5m", "description": "Prometheus check collector cache TTL." }, + "metrics.checks.labels": { "$ref": "#/$defs/csv", "description": "Comma-separated check label include/exclude patterns for metrics." }, + "metrics.config_items.cache_ttl": { "$ref": "#/$defs/duration", "default": "5m", "description": "Prometheus config item collector cache TTL." }, + "metrics.disable": { "$ref": "#/$defs/csv", "description": "Comma-separated metric names to disable; '*' disables all supported metrics." }, + "metrics.prefix": { "type": "string", "description": "Metric name prefix." }, + "notification.max-retries": { "$ref": "#/$defs/int", "default": 4, "description": "Maximum notification delivery retries." }, + "notification.tracing": { "$ref": "#/$defs/bool", "default": false, "description": "Enable notification tracing." }, + "notifications.dedup.window": { "$ref": "#/$defs/duration", "default": "24h", "description": "Notification de-duplication window." }, + "notifications.error_reset_duration": { "$ref": "#/$defs/duration", "default": "1h", "description": "How long before notification errors can be reset." }, + "notifications.group_by_interval": { "$ref": "#/$defs/duration", "default": "24h", "description": "Default interval for grouped notifications." }, + "notifications.labels.order": { "type": "string", "description": "Override default display ordering for selected labels." }, + "notifications.labels.whitelist": { "type": "string", "description": "Override default label whitelist groups." }, + "notifications.max.count": { "$ref": "#/$defs/int", "default": 50, "description": "Maximum notifications per rate-limit window." }, + "notifications.max.window": { "$ref": "#/$defs/duration", "default": "4h", "description": "Notification rate-limit window." }, + "playbook.action.ai.log-prompt": { "$ref": "#/$defs/bool", "default": false, "description": "Log AI action prompts." }, + "playbook.action.consumers": { "$ref": "#/$defs/int", "default": 5, "description": "Number of playbook action consumers." }, + "playbook.consumer.timeout": { "$ref": "#/$defs/duration", "default": "1m", "description": "Playbook consumer timeout." }, + "playbook.retention.age": { "$ref": "#/$defs/duration", "default": "720h", "description": "Retention period for deleted playbooks." }, + "playbook.run.timeout": { "$ref": "#/$defs/duration", "default": "30m", "description": "Default playbook run timeout." }, + "playbook.runner.disabled": { "$ref": "#/$defs/bool", "default": false, "description": "Disable playbook action runners." }, + "playbook.runner.longpoll.timeout": { "$ref": "#/$defs/duration", "default": "45s", "description": "Long-poll timeout for remote playbook runners." }, + "playbook.scheduler.disabled": { "$ref": "#/$defs/bool", "default": false, "description": "Disable playbook run scheduler." }, + "playbook.schedulers": { "$ref": "#/$defs/int", "default": 5, "description": "Number of playbook run schedulers." }, + "playbooks.disable": { "$ref": "#/$defs/bool", "default": false, "description": "UI feature flag: set value to the string true to hide or disable playbook UI surfaces." }, + "proxy.disable": { "$ref": "#/$defs/bool", "default": false, "description": "UI Clerk direct-backend toggle. When true, the UI bypasses the proxy and points API clients at the organization's backend_url." }, + "pubsub.max_messages": { "$ref": "#/$defs/int", "default": 1000, "description": "Maximum Pub/Sub messages read by a canary check." }, + "query.log": { "$ref": "#/$defs/bool", "default": false, "description": "Log resource selector and query logger output at normal verbosity." }, + "response.strip_upstream_cors": { "$ref": "#/$defs/bool", "default": true, "description": "Strip upstream CORS headers in incident-commander proxy responses." }, + "rls.debug": { "$ref": "#/$defs/bool", "default": false, "description": "Log RLS payloads." }, + "rls.disable": { "$ref": "#/$defs/bool", "default": false, "description": "Disable RLS in incident-commander startup checks." }, + "rls.enable": { "$ref": "#/$defs/bool", "default": false, "description": "Enable RLS in incident-commander." }, + "s3.list.max-objects": { "$ref": "#/$defs/int", "default": 50000, "description": "Maximum S3 objects listed by folder checks." }, + "scope.cache.ttl": { "$ref": "#/$defs/duration", "default": "1m", "description": "RBAC scope cache TTL." }, + "scraper.aws.concurrency": { "$ref": "#/$defs/int", "default": 2, "description": "AWS scraper concurrency." }, + "scraper.aws.trusted_advisor.minInterval": { "$ref": "#/$defs/duration", "default": "16h", "description": "Minimum interval between AWS Trusted Advisor scrapes." }, + "scraper.azure.concurrency": { "$ref": "#/$defs/int", "default": 2, "description": "Azure scraper concurrency." }, + "scraper.azuredevops.concurrency": { "$ref": "#/$defs/int", "default": 5, "description": "Azure DevOps scraper semaphore concurrency." }, + "scraper.concurrency": { "$ref": "#/$defs/int", "default": 12, "description": "Global config scraper concurrency." }, + "scraper.diff.disable": { "$ref": "#/$defs/bool", "default": false, "description": "Disable config diff generation." }, + "scraper.diff.timer.minSize": { "$ref": "#/$defs/int", "default": 20480, "description": "Minimum config size before logging diff memory timing at high verbosity." }, + "scraper.file.concurrency": { "$ref": "#/$defs/int", "default": 10, "description": "File scraper concurrency." }, + "scraper.gcp.concurrency": { "$ref": "#/$defs/int", "default": 2, "description": "GCP scraper concurrency." }, + "scraper.githubactions.concurrency": { "$ref": "#/$defs/int", "default": 5, "description": "GitHub Actions scraper semaphore concurrency." }, + "scraper.http.concurrency": { "$ref": "#/$defs/int", "default": 10, "description": "HTTP scraper concurrency." }, + "scraper.kubernetes.concurrency": { "$ref": "#/$defs/int", "default": 3, "description": "Kubernetes scraper concurrency." }, + "scraper.kubernetesfile.concurrency": { "$ref": "#/$defs/int", "default": 3, "description": "Kubernetes file scraper concurrency." }, + "scraper.log.items": { "$ref": "#/$defs/bool", "default": false, "description": "Log config scraper item processing details." }, + "scraper.log.slow_diff_threshold": { "$ref": "#/$defs/duration", "default": "1s", "description": "Threshold for slow diff warnings." }, + "scraper.playwright.concurrency": { "$ref": "#/$defs/int", "default": 2, "description": "Playwright scraper concurrency." }, + "scraper.slack.concurrency": { "$ref": "#/$defs/int", "default": 5, "description": "Slack scraper concurrency." }, + "scraper.sql.concurrency": { "$ref": "#/$defs/int", "default": 10, "description": "SQL scraper concurrency." }, + "scraper.terraform.concurrency": { "$ref": "#/$defs/int", "default": 10, "description": "Terraform scraper concurrency." }, + "scraper.timeout": { "$ref": "#/$defs/duration", "default": "4h", "description": "Default scraper timeout." }, + "scraper.trivy.concurrency": { "$ref": "#/$defs/int", "default": 1, "description": "Trivy scraper concurrency." }, + "scraper.scraper.label.missing": { "$ref": "#/$defs/bool", "default": false, "description": "Log missing scraper label extraction; this is the effective key produced by ScrapeContext.PropertyOn(\"scraper.label.missing\")." }, + "scraper.scraper.tag.missing": { "$ref": "#/$defs/bool", "default": false, "description": "Log missing scraper tag extraction; this is the effective key produced by ScrapeContext.PropertyOn(\"scraper.tag.missing\")." }, + "scrapers.default.schedule": { "type": "string", "description": "Default schedule for scrape configs without an explicit schedule." }, + "scrapers.event.stale-timeout": { "$ref": "#/$defs/duration", "default": "1h", "description": "Scraper event stale timeout." }, + "scrapers.event.workers": { "$ref": "#/$defs/int", "default": 2, "description": "Number of scraper event workers." }, + "scrapers.githubactions.concurrency": { "$ref": "#/$defs/int", "default": 10, "description": "GitHub Actions API request concurrency per repository." }, + "scrapers.githubactions.maxAge": { "$ref": "#/$defs/duration", "default": "168h", "description": "Maximum age for GitHub Actions workflow runs." }, + "secretkeeper.cache.ttl": { "$ref": "#/$defs/duration", "default": "10m", "description": "Cloud secret keeper cache TTL." }, + "settings.artifacts.disable": { "$ref": "#/$defs/bool", "default": false, "description": "UI feature flag: set value to the string true to hide or disable artifact settings UI surfaces." }, + "settings.config_scraper.disable": { "$ref": "#/$defs/bool", "default": false, "description": "UI feature flag: set value to the string true to hide or disable config scraper settings UI surfaces. Also disabled when config.disable is true." }, + "settings.connections.disable": { "$ref": "#/$defs/bool", "default": false, "description": "UI feature flag: set value to the string true to hide or disable connection settings UI surfaces." }, + "settings.event_queue_status.disable": { "$ref": "#/$defs/bool", "default": false, "description": "UI feature flag: set value to the string true to hide or disable event queue status settings UI surfaces." }, + "settings.feature_flags.disable": { "$ref": "#/$defs/bool", "default": false, "description": "UI feature flag: set value to the string true to hide or disable feature flag settings UI surfaces." }, + "settings.health.disable": { "$ref": "#/$defs/bool", "default": false, "description": "UI feature flag: set value to the string true to hide or disable health settings UI surfaces. Also disabled when health.disable is true." }, + "settings.integrations.disable": { "$ref": "#/$defs/bool", "default": false, "description": "UI feature flag: set value to the string true to hide or disable integration settings UI surfaces." }, + "settings.job_history.disable": { "$ref": "#/$defs/bool", "default": false, "description": "UI feature flag: set value to the string true to hide or disable job history settings UI surfaces. Also disabled when health.disable is true." }, + "settings.logging_backends.disable": { "$ref": "#/$defs/bool", "default": false, "description": "UI feature flag: set value to the string true to hide or disable logging backend settings UI surfaces." }, + "settings.notifications.disable": { "$ref": "#/$defs/bool", "default": false, "description": "UI feature flag: set value to the string true to hide or disable notification settings UI surfaces." }, + "settings.organization_profile.disable": { "$ref": "#/$defs/bool", "default": false, "description": "UI feature flag: set value to the string true to hide or disable organization profile settings UI surfaces." }, + "settings.permissions.disable": { "$ref": "#/$defs/bool", "default": false, "description": "UI feature flag: set value to the string true to hide or disable permission settings UI surfaces." }, + "settings.playbooks.disable": { "$ref": "#/$defs/bool", "default": false, "description": "UI feature flag: set value to the string true to hide or disable playbook settings UI surfaces." }, + "settings.rules.disable": { "$ref": "#/$defs/bool", "default": false, "description": "UI feature flag: set value to the string true to hide or disable rules settings UI surfaces." }, + "settings.teams.disable": { "$ref": "#/$defs/bool", "default": false, "description": "UI feature flag: set value to the string true to hide or disable team settings UI surfaces." }, + "settings.topology.disable": { "$ref": "#/$defs/bool", "default": false, "description": "UI feature flag: set value to the string true to hide or disable topology settings UI surfaces. Also disabled when topology.disable is true." }, + "settings.user.disabled": { "$ref": "#/$defs/bool", "default": false, "description": "Set by auth middleware when the current user is disabled." }, + "settings.users.disable": { "$ref": "#/$defs/bool", "default": false, "description": "UI feature flag: set value to the string true to hide or disable user settings UI surfaces." }, + "shell.allowed.envs": { "$ref": "#/$defs/csv", "description": "Additional environment variable names passed through to shell executions." }, + "shell.connection.wait_before_cleanup": { "$ref": "#/$defs/duration", "default": "0", "description": "Wait before cleaning up shell connection artifacts." }, + "shell.jq.timeout": { "$ref": "#/$defs/duration", "default": "5s", "description": "Timeout for jq execution." }, + "shell.yq.timeout": { "$ref": "#/$defs/duration", "default": "5s", "description": "Timeout for yq execution." }, + "shorturl.defaultExpiry": { "$ref": "#/$defs/duration", "default": "2160h", "description": "Default short URL expiry." }, + "slack.max-url-length": { "$ref": "#/$defs/int", "default": 50, "maximum": 3000, "description": "Maximum Slack URL length before shortening. Values above 3000 are ignored by code." }, + "smtp.debug": { "$ref": "#/$defs/bool", "default": false, "description": "Enable SMTP debug logging." }, + "topology.cache.age": { "$ref": "#/$defs/duration", "default": "5m", "description": "Cache age for topology responses." }, + "topology.disable": { "$ref": "#/$defs/bool", "default": false, "description": "UI feature flag: set value to the string true to hide or disable topology UI surfaces. Also disables settings.topology." }, + "topology.query.timeout": { "$ref": "#/$defs/duration", "default": "30s", "description": "Default topology query timeout when the context has no deadline." }, + "update_is_pushed.batch.size": { "$ref": "#/$defs/int", "default": 200, "description": "Batch size for marking pushed records during upstream reconciliation." }, + "upstream.client.cache.view-columns.duration": { "$ref": "#/$defs/duration", "description": "Cache duration for upstream view-column client lookups." }, + "upstream.pull_canaries": { "$ref": "#/$defs/bool", "default": true, "description": "Schedule canary upstream pull jobs." }, + "upstream.pull_playbook_actions": { "$ref": "#/$defs/bool", "default": true, "description": "Schedule playbook action upstream pull jobs." }, + "upstream.summary.fkerror_id_count": { "$ref": "#/$defs/int", "default": 10, "description": "Foreign-key error IDs included in upstream reconciliation summaries." }, + "view.http.body.max_size_bytes": { "$ref": "#/$defs/int", "default": 26214400, "description": "Maximum HTTP response body size for HTTP data queries." }, + "view.refresh.max-timeout": { "$ref": "#/$defs/duration", "default": "1m", "description": "Maximum timeout for asynchronous view refreshes." }, + "views.disable": { "$ref": "#/$defs/bool", "default": false, "description": "UI feature flag: set value to the string true to hide or disable custom view UI surfaces." } + }, + "patternProperties": { + "^[A-Za-z0-9_.-]+\\.batchSize$": { "$ref": "#/$defs/int", "description": "Batch size for an async event consumer." }, + "^[A-Za-z0-9_.-]+\\.(debug|trace)$": { "$ref": "#/$defs/bool", "description": "Enable debug or trace logging for an async event consumer." }, + "^jobs\\.[^.]+(\\.[^.]+)?\\.(debug|disable|disabled|history|singleton|trace)$": { "$ref": "#/$defs/bool", "description": "Dynamic job boolean property." }, + "^jobs\\.[^.]+(\\.[^.]+)?\\.retention\\.(failed|success)$": { "$ref": "#/$defs/int", "description": "Dynamic job history retention count." }, + "^jobs\\.[^.]+(\\.[^.]+)?\\.schedule$": { "type": "string", "description": "Dynamic job cron schedule override." }, + "^jobs\\.[^.]+(\\.[^.]+)?\\.timeout$": { "$ref": "#/$defs/duration", "description": "Dynamic job timeout override." }, + "^log\\.color\\.[A-Za-z0-9_.-]+$": { "$ref": "#/$defs/bool", "description": "Color setting for a named logger." }, + "^log\\.caller\\.[A-Za-z0-9_.-]+$": { "$ref": "#/$defs/bool", "description": "Caller setting for a named logger." }, + "^log\\.level\\.[A-Za-z0-9_.-]+$": { "$ref": "#/$defs/logLevel", "description": "Named logger or feature log level." }, + "^log\\.time\\.format\\.[A-Za-z0-9_.-]+$": { "type": "string", "description": "Timestamp format for a named logger." }, + "^postgres\\.session\\..+$": { "type": "string", "description": "PostgreSQL transaction-local setting applied by duty.ApplySessionProperties." }, + "^scraper\\.[^.]+\\.schedule$": { "type": "string", "description": "Per-scrape-config schedule override by UID." }, + "^scraper\\.[^.]+\\.schedule\\.min$": { "$ref": "#/$defs/duration", "description": "Minimum schedule interval for a scraper type." }, + "^scraper\\.[^.]+\\.(azure\\.devops\\.incremental|capture\\.har|capture\\.logs|capture\\.snapshots|disable|runNow|watch\\.disable|log\\.changes\\.unmatched|log\\.exclusions|log\\.items|log\\.missing|log\\.noResourceId|log\\.relationships|log\\.rule\\.expr|log\\.skipped|log\\.transforms)$": { "$ref": "#/$defs/bool", "description": "Global or per-scrape-config ScrapeContext boolean property." }, + "^time_interval\\..+$": { "type": "string", "description": "JSON encoded alertmanager-style time interval consumed by commons properties helpers." } + }, + "$defs": { + "bool": { + "oneOf": [ + { "type": "boolean" }, + { "type": "string", "enum": ["true", "false", "on", "off", "enabled", "disabled"] } + ] + }, + "csv": { + "type": "string" + }, + "duration": { + "oneOf": [ + { "type": "string", "pattern": "^[0-9]+(ns|us|ms|s|m|h|d|w)([0-9]+(ns|us|ms|s|m|h|d|w))*$|^0$" }, + { "type": "number", "description": "Duration as a numeric value accepted by the embedding configuration layer." } + ] + }, + "int": { + "oneOf": [ + { "type": "integer" }, + { "type": "string", "pattern": "^-?[0-9]+$" } + ] + }, + "logLevel": { + "oneOf": [ + { "type": "string", "enum": ["trace", "trace1", "trace2", "trace3", "debug", "debug1", "debug2", "debug3", "info", "warn", "warning", "error", "silent"] }, + { "type": "string", "pattern": "^[0-9]+$" } + ] + }, + "propertyValue": { + "oneOf": [ + { "type": "string" }, + { "type": "number" }, + { "type": "boolean" }, + { "type": "null" } + ] + } + } +} diff --git a/Taskfile.yaml b/Taskfile.yaml index 04cfbbb32..23b99ae22 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -1,6 +1,27 @@ version: '3' tasks: + test: + desc: | + Run the unit test suite via gavel, mirroring the `test` job in + .github/workflows/test.yaml. Excludes the heavy benchmark, generator, + spec, and e2e packages so it matches CI scope. + + Anything after `--` is passed straight through to gavel, so: + task test -- --focus "MyFlow" + task test -- ./query --extra-args=--ginkgo.label-filter=focus + task test -- --dry-run + Without extra args, runs every unit package matching CI. + cmds: + - | + gavel test --timeout 30m --test-timeout 15m \ + --ignore ./bench \ + --ignore ./hack \ + --ignore ./specs \ + --ignore ./tests/e2e \ + --ignore ./tests/e2e-blobs \ + {{if .CLI_ARGS}}{{.CLI_ARGS}}{{else}}./...{{end}} + test:migrate: desc: | Mirrors the `migrate` job in .github/workflows/test.yaml using an From 7de634ad39daa6cc351cbd8d5a9c6ae2a741226a Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 1 May 2026 14:47:40 +0300 Subject: [PATCH 3/5] refactor(connection,context,kubernetes): consolidate HTTP observability middleware into reusable functions Extract HTTP observability logic (HAR collection, HTTP logging) from individual connection types into shared middleware functions in connection/common.go. This eliminates code duplication across AWS, GCS, GKE, OpenSearch, and other connection implementations. New observability functions: - applyHTTPObservability: applies HAR and HTTP logging to a RoundTripper - httpObservabilityMiddleware: returns a middleware for WrapTransport - applyHTTPClientObservability: applies observability to commons HTTP clients - harCollectorMiddleware: HAR-specific middleware with level-aware behavior - httpLoggerWithContent: HTTP request/response logging with configurable detail - metadataHARMiddleware: HAR metadata-only capture for debug-level logging Add context methods for observability configuration: - EffectiveHARLevel, EffectiveLogLevel: resolve per-feature log levels - EffectiveHARCollector: select HAR collector with feature-aware config - HTTPLoggingContent: determine header/body logging for a feature - HARConfig: build feature-specific HAR configuration Update connection implementations to use shared functions, reducing per-connection boilerplate. Add git HTTP transport observability support via configureGitHTTPTransport. Add comprehensive tests for HAR and HTTP logging coexistence, ensuring body restoration across middleware layers. Refs #1234 --- .gitignore | 1 + connection/aws.go | 27 +--- connection/cnrm.go | 7 + connection/common.go | 227 +++++++++++++++++++++++++++++++ connection/eks.go | 6 + connection/gcs.go | 15 +- connection/git.go | 69 ++++++++-- connection/git_logging_test.go | 174 +++++++++++++++++++++++ connection/gke.go | 20 ++- connection/http.go | 52 ++++--- connection/kubernetes.go | 4 +- connection/observability_test.go | 144 ++++++++++++++++++++ connection/opensearch.go | 12 +- connection/prometheus.go | 2 +- context/properties.go | 177 ++++++++++++++++++++++++ context/template.go | 20 +++ kubernetes/k8s.go | 82 +++++++++-- kubernetes/utils_test.go | 17 +++ start.go | 52 +++++-- start_test.go | 80 +++++++++++ suite_test.go | 13 ++ 21 files changed, 1077 insertions(+), 124 deletions(-) create mode 100644 connection/git_logging_test.go create mode 100644 connection/observability_test.go create mode 100644 start_test.go create mode 100644 suite_test.go diff --git a/.gitignore b/.gitignore index 64601d42a..7dbe71a44 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ generate-openapi ginkgo*.json migrate-e2e hack/generate-schemas/generate-schemas +.gavel/ diff --git a/connection/aws.go b/connection/aws.go index 7389c14aa..7c884991c 100644 --- a/connection/aws.go +++ b/connection/aws.go @@ -4,7 +4,6 @@ import ( "crypto/tls" "fmt" "net/http" - "os" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" @@ -14,7 +13,6 @@ import ( "github.com/flanksource/duty/context" "github.com/flanksource/duty/models" "github.com/flanksource/duty/types" - "github.com/henvic/httpretty" ) // +kubebuilder:object:generate=true @@ -145,30 +143,7 @@ func (t *AWSConnection) Client(ctx context.Context, opts ...types.ClientOption) tr = &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: t.SkipTLSVerify}, } - - harCollector := o.HARCollector - if harCollector == nil { - harCollector = ctx.HARCollector() - } - if harCollector != nil { - tr = harCollector.Middleware()(tr) - } - - if ctx.IsTrace() && harCollector == nil { - httplogger := &httpretty.Logger{ - Time: true, - TLS: ctx.Logger.IsLevelEnabled(7), - RequestHeader: true, - RequestBody: ctx.Logger.IsLevelEnabled(8), - ResponseHeader: true, - ResponseBody: ctx.Logger.IsLevelEnabled(9), - Colors: true, - Formatters: []httpretty.Formatter{&httpretty.JSONFormatter{}}, - } - httplogger.SetOutput(os.Stderr) - - tr = httplogger.RoundTripper(tr) - } + tr = applyHTTPObservability(ctx, "aws", tr, o.HARCollector) options := []func(*config.LoadOptions) error{ config.WithHTTPClient(&http.Client{Transport: tr}), diff --git a/connection/cnrm.go b/connection/cnrm.go index b87936644..2c80b4164 100644 --- a/connection/cnrm.go +++ b/connection/cnrm.go @@ -3,6 +3,7 @@ package connection import ( "encoding/base64" "fmt" + "net/http" "github.com/flanksource/duty/context" dutyKube "github.com/flanksource/duty/kubernetes" @@ -47,6 +48,12 @@ func (t *CNRMConnection) KubernetesClient(ctx context.Context, freshToken bool, if err != nil { return nil, nil, fmt.Errorf("failed to create REST config for cluster resource: %w", err) } + o := types.NewClientOptions(opts...) + if middleware := httpObservabilityMiddleware(ctx, "kubernetes", o.HARCollector); middleware != nil { + clusterResourceRestConfig.WrapTransport = func(rt http.RoundTripper) http.RoundTripper { + return middleware(rt) + } + } clientset, err := kubernetes.NewForConfig(clusterResourceRestConfig) if err != nil { diff --git a/connection/common.go b/connection/common.go index 6cfa9cf68..1866ee576 100644 --- a/connection/common.go +++ b/connection/common.go @@ -3,8 +3,17 @@ package connection import ( "encoding/json" "fmt" + netHTTP "net/http" + "net/url" + "strings" "time" + "github.com/flanksource/commons/har" + commonsHTTP "github.com/flanksource/commons/http" + "github.com/flanksource/commons/http/middlewares" + "github.com/flanksource/commons/logger" + "github.com/flanksource/commons/logger/httpretty" + "github.com/flanksource/commons/properties" "github.com/patrickmn/go-cache" ) @@ -27,3 +36,221 @@ func tokenCacheKey(cloud string, cred any, identifiers string) string { return fmt.Sprintf("%s-%s-%s", cloud, m, identifiers) } } + +type observabilityContext interface { + HARCollector() *har.Collector + EffectiveHARCollector(feature string, explicit *har.Collector) *har.Collector + EffectiveHARLevel(feature string) (logger.LogLevel, string) + HTTPLoggingContent(feature string) (bool, bool) +} + +func effectiveHARCollector(ctx any, feature string, explicit *har.Collector) *har.Collector { + if c, ok := ctx.(observabilityContext); ok { + return c.EffectiveHARCollector(feature, explicit) + } + return explicit +} + +func applyHTTPObservability(ctx any, feature string, base netHTTP.RoundTripper, explicit *har.Collector) netHTTP.RoundTripper { + if base == nil { + base = netHTTP.DefaultTransport + } + if middleware := harCollectorMiddleware(ctx, feature, explicit); middleware != nil { + base = middleware(base) + } + if c, ok := ctx.(observabilityContext); ok { + headers, bodies := c.HTTPLoggingContent(feature) + base = httpLoggerWithContent(base, headers, bodies) + } + return base +} + +func httpObservabilityMiddleware(ctx any, feature string, explicit *har.Collector) middlewares.Middleware { + if effectiveHARCollector(ctx, feature, explicit) != nil { + return func(rt netHTTP.RoundTripper) netHTTP.RoundTripper { + return applyHTTPObservability(ctx, feature, rt, explicit) + } + } + if c, ok := ctx.(observabilityContext); ok { + headers, _ := c.HTTPLoggingContent(feature) + if headers { + return func(rt netHTTP.RoundTripper) netHTTP.RoundTripper { + return applyHTTPObservability(ctx, feature, rt, explicit) + } + } + } + return nil +} + +func applyHTTPClientObservability(ctx any, feature string, client *commonsHTTP.Client, explicit *har.Collector) middlewares.Middleware { + if client == nil { + return nil + } + + var tokenTransport middlewares.Middleware + level := logger.Info + if c, ok := ctx.(observabilityContext); ok { + level, _ = c.EffectiveHARLevel(feature) + } + if explicit != nil && level < logger.Debug { + level = logger.Trace + } + + if collector := effectiveHARCollector(ctx, feature, explicit); collector != nil && level >= logger.Debug { + if level >= logger.Trace { + client.HARCollector(collector) + } else { + middleware := metadataHARMiddleware(collector) + client.Use(middleware) + tokenTransport = middleware + } + } + + if c, ok := ctx.(observabilityContext); ok { + headers, bodies := c.HTTPLoggingContent(feature) + if headers { + logMiddleware := func(rt netHTTP.RoundTripper) netHTTP.RoundTripper { + return httpLoggerWithContent(rt, headers, bodies) + } + client.Use(logMiddleware) + if tokenTransport == nil { + tokenTransport = logMiddleware + } else { + existing := tokenTransport + tokenTransport = func(rt netHTTP.RoundTripper) netHTTP.RoundTripper { + return logMiddleware(existing(rt)) + } + } + } + } + + return tokenTransport +} + +func harTokenTransport(ctx any, feature string, explicit *har.Collector) middlewares.Middleware { + return func(rt netHTTP.RoundTripper) netHTTP.RoundTripper { + return applyHTTPObservability(ctx, feature, rt, explicit) + } +} + +func harCollectorMiddleware(ctx any, feature string, explicit *har.Collector) middlewares.Middleware { + level := logger.Info + if c, ok := ctx.(observabilityContext); ok { + level, _ = c.EffectiveHARLevel(feature) + } + if explicit != nil && level < logger.Debug { + level = logger.Trace + } + + collector := effectiveHARCollector(ctx, feature, explicit) + if collector == nil || level < logger.Debug { + return nil + } + if level >= logger.Trace { + return collector.Middleware() + } + return metadataHARMiddleware(collector) +} + +func httpLoggerWithContent(rt netHTTP.RoundTripper, headers, bodies bool) netHTTP.RoundTripper { + if !headers { + return rt + } + + l := &httpretty.Logger{ + Time: true, + TLS: true, + Auth: true, + RequestHeader: true, + RequestBody: bodies, + ResponseHeader: true, + ResponseBody: bodies, + Colors: true, + Formatters: []httpretty.Formatter{&httpretty.JSONFormatter{}}, + MaxResponseBody: int64(properties.Int(4*1024, "http.log.response.body.length")), + } + l.SkipHeader(logger.SensitiveHeaders) + return l.RoundTripper(rt) +} + +func metadataHARMiddleware(collector *har.Collector) middlewares.Middleware { + return func(next netHTTP.RoundTripper) netHTTP.RoundTripper { + return middlewares.RoundTripperFunc(func(req *netHTTP.Request) (*netHTTP.Response, error) { + started := time.Now() + entry := &har.Entry{ + StartedDateTime: started.UTC().Format(time.RFC3339), + Request: har.Request{ + Method: req.Method, + URL: req.URL.String(), + HTTPVersion: harHTTPVersion(req.Proto), + Cookies: []har.Cookie{}, + Headers: toHARHeaders(logger.SanitizeHeaders(req.Header)), + QueryString: toHARQueryString(req.URL.Query()), + HeadersSize: -1, + BodySize: -1, + }, + } + + waitStart := time.Now() + resp, err := next.RoundTrip(req) + waitMs := float64(time.Since(waitStart).Microseconds()) / 1000.0 + + entry.Timings = har.Timings{Wait: waitMs} + entry.Time = waitMs + if resp != nil { + entry.Response = har.Response{ + Status: resp.StatusCode, + StatusText: resp.Status, + HTTPVersion: harHTTPVersion(resp.Proto), + Cookies: []har.Cookie{}, + Headers: toHARHeaders(logger.SanitizeHeaders(resp.Header)), + Content: har.Content{Size: -1}, + RedirectURL: "", + HeadersSize: -1, + BodySize: -1, + } + } else { + // Transport error: no response object. Use -1 sentinels (HAR spec + // for "size unknown") so consumers don't read Status=0 as a + // successful empty response. + entry.Response = har.Response{ + Cookies: []har.Cookie{}, + Headers: []har.Header{}, + Content: har.Content{Size: -1}, + HeadersSize: -1, + BodySize: -1, + } + } + + collector.Add(entry) + return resp, err + }) + } +} + +func toHARHeaders(h netHTTP.Header) []har.Header { + headers := make([]har.Header, 0, len(h)) + for name, vals := range h { + for _, v := range vals { + headers = append(headers, har.Header{Name: name, Value: v}) + } + } + return headers +} + +func toHARQueryString(q url.Values) []har.QueryString { + qs := make([]har.QueryString, 0, len(q)) + for k, vs := range q { + for _, v := range vs { + qs = append(qs, har.QueryString{Name: k, Value: v}) + } + } + return qs +} + +func harHTTPVersion(proto string) string { + if strings.TrimSpace(proto) == "" { + return "HTTP/1.1" + } + return proto +} diff --git a/connection/eks.go b/connection/eks.go index faafe6b2d..bf66cbd94 100644 --- a/connection/eks.go +++ b/connection/eks.go @@ -56,6 +56,12 @@ func (t *EKSConnection) KubernetesClient(ctx context.Context, freshToken bool, o CAData: ca, }, } + o := types.NewClientOptions(opts...) + if middleware := httpObservabilityMiddleware(ctx, "kubernetes", o.HARCollector); middleware != nil { + restConfig.WrapTransport = func(rt http.RoundTripper) http.RoundTripper { + return middleware(rt) + } + } clientset, err := kubernetes.NewForConfig(restConfig) if err != nil { diff --git a/connection/gcs.go b/connection/gcs.go index b0c44aebd..245ae45ff 100644 --- a/connection/gcs.go +++ b/connection/gcs.go @@ -37,24 +37,13 @@ func (g *GCSConnection) Client(ctx context.Context, opts ...types.ClientOption) clientOpts = append(clientOpts, option.WithEndpoint(g.Endpoint)) } - harCollector := o.HARCollector - if harCollector == nil { - harCollector = ctx.HARCollector() - } - if harCollector != nil { + if g.SkipTLSVerify || effectiveHARCollector(ctx, "gcs", o.HARCollector) != nil || ctx.IsHTTPLoggingEnabled("gcs") { base := http.RoundTripper(http.DefaultTransport) if g.SkipTLSVerify { base = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} } - tr := harCollector.Middleware()(base) + tr := applyHTTPObservability(ctx, "gcs", base, o.HARCollector) clientOpts = append(clientOpts, option.WithHTTPClient(&http.Client{Transport: tr})) - } else if g.SkipTLSVerify { - insecureHTTPClient := &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - }, - } - clientOpts = append(clientOpts, option.WithHTTPClient(insecureHTTPClient)) } if g.Credentials != nil && !g.Credentials.IsEmpty() { diff --git a/connection/git.go b/connection/git.go index 745bda3c3..aa74f6463 100644 --- a/connection/git.go +++ b/connection/git.go @@ -3,10 +3,12 @@ package connection import ( "errors" "fmt" + netHTTP "net/http" "net/url" "regexp" "strconv" "strings" + "sync" "github.com/flanksource/commons/logger" "github.com/flanksource/commons/utils" @@ -14,7 +16,8 @@ import ( "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/protocol/packp/capability" "github.com/go-git/go-git/v5/plumbing/transport" - "github.com/go-git/go-git/v5/plumbing/transport/http" + gitClient "github.com/go-git/go-git/v5/plumbing/transport/client" + gitHTTP "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/go-git/go-git/v5/plumbing/transport/ssh" "github.com/samber/lo" ssh2 "golang.org/x/crypto/ssh" @@ -23,6 +26,8 @@ import ( "github.com/flanksource/duty/types" ) +var gitHTTPTransportMu sync.Mutex + const ( ServiceGithub = "github" ServiceGitlab = "gitlab" @@ -36,6 +41,16 @@ type GitClient struct { AzureDevops bool } +// RedactedURL returns the URL with userinfo elided, falling back to the raw +// URL when it doesn't parse — useful for log lines where a literal "redacted" +// placeholder would be less informative than the original string. +func (gitClient GitClient) RedactedURL() string { + if uri, err := url.Parse(gitClient.URL); err == nil { + return uri.Redacted() + } + return gitClient.URL +} + func (gitClient GitClient) GetContext() map[string]any { if uri, err := url.Parse(gitClient.URL); err == nil { return map[string]any{ @@ -77,18 +92,28 @@ func (gitClient *GitClient) Clone(ctx context.Context, dir string) (map[string]a } ctx = ctx.WithObject(*gitClient) - if ctx.Logger.IsLevelEnabled(4) { - ctx.Logger.V(4).Infof("cloning to %s", dir) - } else { - ctx.Tracef("cloning") + var gitLog logger.Logger = logger.GetLogger("git") + if headers, bodies := ctx.HTTPLoggingContent("git"); bodies { + gitLog = gitLog.WithV(logger.Trace) + } else if headers { + gitLog = gitLog.WithV(logger.Debug) } + // progress output from go-git only appears at trace level on the git + // logger (set via -Plog.level.git=trace). + progress := gitLog.V(2) + restoreGitTransport := configureGitHTTPTransport(ctx, gitClient.URL) + defer restoreGitTransport() + + redactedURL := gitClient.RedactedURL() + gitLog.V(1).Infof("clone url=%s branch=%s depth=%d dir=%s", redactedURL, gitClient.Branch, gitClient.Depth, dir) + extra := map[string]any{ "git": gitClient.GetShortURL(), } repo, err := git.PlainCloneContext(ctx, dir, false, &git.CloneOptions{ URL: gitClient.URL, - Progress: ctx.Logger.V(4).WithFilter("Compressing objects", "Counting objects"), + Progress: progress, Auth: gitClient.Auth, ReferenceName: plumbing.NewBranchReferenceName(gitClient.Branch), Depth: gitClient.Depth, @@ -105,9 +130,9 @@ func (gitClient *GitClient) Clone(ctx context.Context, dir string) (map[string]a return extra, ctx.Oops().Wrapf(err, "unable to open worktree") } - ctx.Logger.V(4).Infof("fetching ") + gitLog.V(1).Infof("fetch url=%s branch=%s depth=%d", redactedURL, gitClient.Branch, gitClient.Depth) if err := repo.FetchContext(ctx, &git.FetchOptions{ - Progress: ctx.Logger.V(4).WithFilter("Compressing objects", "Counting objects"), + Progress: progress, RemoteURL: gitClient.URL, Force: true, Prune: true, @@ -128,7 +153,7 @@ func (gitClient *GitClient) Clone(ctx context.Context, dir string) (map[string]a for _, ref := range list { if ref.Name().Short() == gitClient.Branch { refName = ref.Name() - ctx.Logger.V(4).Infof("found ref %s matching %s", refName, gitClient.Branch) + gitLog.V(2).Infof("found ref %s matching %s", refName, gitClient.Branch) } } @@ -153,7 +178,7 @@ func (gitClient *GitClient) Clone(ctx context.Context, dir string) (map[string]a if commit, err := iter.Next(); err != nil { return extra, ctx.Oops().Wrapf(err, "unable to get HEAD commit") } else { - ctx.Logger.Debugf("checked out %s", commit.Hash.String()[0:8]) + gitLog.Infof("checked out %s dir=%s", commit.Hash.String()[0:8], dir) } } } @@ -308,7 +333,7 @@ func CreateGitConfig(ctx context.Context, conn *GitConnection) (*GitClient, erro config.Auth = publicKeys } else { - config.Auth = &http.BasicAuth{ + config.Auth = &gitHTTP.BasicAuth{ Username: conn.Username.ValueStatic, Password: conn.Password.ValueStatic, } @@ -349,3 +374,25 @@ func parseGenericRepoURL(repoURL, host string, custom bool) (owner string, repo return paths[0], paths[1], true } + +func configureGitHTTPTransport(ctx context.Context, rawURL string) func() { + uri, err := url.Parse(rawURL) + if err != nil || (uri.Scheme != "http" && uri.Scheme != "https") { + return func() {} + } + + middleware := httpObservabilityMiddleware(ctx, "git", nil) + if middleware == nil { + return func() {} + } + + gitHTTPTransportMu.Lock() + client := gitHTTP.NewClient(&netHTTP.Client{Transport: middleware(netHTTP.DefaultTransport)}) + gitClient.InstallProtocol("http", client) + gitClient.InstallProtocol("https", client) + return func() { + gitClient.InstallProtocol("http", gitHTTP.DefaultClient) + gitClient.InstallProtocol("https", gitHTTP.DefaultClient) + gitHTTPTransportMu.Unlock() + } +} diff --git a/connection/git_logging_test.go b/connection/git_logging_test.go new file mode 100644 index 000000000..a2fc91595 --- /dev/null +++ b/connection/git_logging_test.go @@ -0,0 +1,174 @@ +package connection + +import ( + gocontext "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/flanksource/commons/logger" + "github.com/flanksource/commons/properties" + "github.com/flanksource/duty/context" + "github.com/onsi/gomega" +) + +// makeBareRepo creates a bare git repository at /remote.git with a +// single commit on the "main" branch, suitable as a Clone source in tests. +func makeBareRepo(t *testing.T, root string) string { + t.Helper() + g := gomega.NewWithT(t) + + work := filepath.Join(root, "work") + g.Expect(os.MkdirAll(work, 0o755)).To(gomega.Succeed()) + + run := func(dir string, args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test", + "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", + ) + out, err := cmd.CombinedOutput() + g.Expect(err).ToNot(gomega.HaveOccurred(), "git %s: %s", strings.Join(args, " "), string(out)) + } + + run(work, "init", "-b", "main") + g.Expect(os.WriteFile(filepath.Join(work, "README.md"), []byte("hello"), 0o644)).To(gomega.Succeed()) + run(work, "add", "README.md") + run(work, "commit", "-m", "initial") + + bare := filepath.Join(root, "remote.git") + run(root, "clone", "--bare", work, bare) + return bare +} + +// captureLoggerOutput redirects the commons logger's shared writer to an +// in-memory buffer and returns a read/stop pair. The commons logger reads its +// destination from an atomic indirection set via logger.SetOutput, so swapping +// os.Stderr does not intercept its output — SetOutput is the supported hook. +func captureLoggerOutput(t *testing.T) (read func() string, stop func()) { + t.Helper() + + orig := logger.GetOutput() + + var ( + mu sync.Mutex + buf strings.Builder + ) + logger.SetOutput(writerFunc(func(p []byte) (int, error) { + mu.Lock() + defer mu.Unlock() + return buf.Write(p) + })) + + read = func() string { + mu.Lock() + defer mu.Unlock() + return buf.String() + } + stop = func() { + logger.SetOutput(orig) + } + return +} + +type writerFunc func(p []byte) (int, error) + +func (f writerFunc) Write(p []byte) (int, error) { return f(p) } + +// setGitLogLevel sets log.level.git for the duration of the test, restoring +// the prior value (whatever it was) on cleanup. Hard-coding "info" on cleanup +// would corrupt subsequent tests in the same `go test` run. +func setGitLogLevel(t *testing.T, level string) { + t.Helper() + prev := properties.Get("log.level.git") + properties.Set("log.level.git", level) + t.Cleanup(func() { properties.Set("log.level.git", prev) }) +} + +// TestGitCloneTraceLogging verifies that -Plog.level.git=trace causes +// GitClient.Clone to emit structured command metadata via the "git" named +// logger, and that the same V(2) progress writer that go-git receives is +// open for writes (i.e. transport output would be forwarded). +func TestGitCloneTraceLogging(t *testing.T) { + g := gomega.NewWithT(t) + + root := t.TempDir() + remote := makeBareRepo(t, root) + + logger.UseSlog() + + readOut, stop := captureLoggerOutput(t) + defer stop() + + setGitLogLevel(t, "trace") + + dst := filepath.Join(root, "checkout") + gitClient := &GitClient{URL: remote, Branch: "main", Depth: 1} + + ctx := context.NewContext(gocontext.TODO()) + _, err := gitClient.Clone(ctx, dst) + g.Expect(err).ToNot(gomega.HaveOccurred()) + + // Simulate a transport progress line going through the same writer + // Clone hands to go-git, to prove the path is live. A real network + // clone emits lines like this via sideband; local/file clones do not. + fmt.Fprintln(logger.GetLogger("git").V(2), "Counting objects: 3, done.") + + stop() + out := readOut() + + want := []string{ + "clone url=", + "branch=main", + "depth=1", + "dir=" + dst, + "checked out ", + "Counting objects: 3, done.", + "(git)", + } + for _, w := range want { + g.Expect(out).To(gomega.ContainSubstring(w), "trace output missing %q", w) + } +} + +// TestGitCloneDefaultLevelIsQuiet verifies that without log.level.git=trace, +// the named logger suppresses V(1)+ structured lines — proving the level gate +// both opens (TestGitCloneTraceLogging) and closes. +func TestGitCloneDefaultLevelIsQuiet(t *testing.T) { + g := gomega.NewWithT(t) + + root := t.TempDir() + remote := makeBareRepo(t, root) + + logger.UseSlog() + + readOut, stop := captureLoggerOutput(t) + defer stop() + + setGitLogLevel(t, "info") + + dst := filepath.Join(root, "checkout") + gitClient := &GitClient{URL: remote, Branch: "main", Depth: 1} + + ctx := context.NewContext(gocontext.TODO()) + _, err := gitClient.Clone(ctx, dst) + g.Expect(err).ToNot(gomega.HaveOccurred()) + + // Writing a progress line at V(2) when the level is "info" must be a + // no-op — the writer short-circuits in slog.Verbose.Write. + fmt.Fprintln(logger.GetLogger("git").V(2), "Counting objects: 3, done.") + + stop() + out := readOut() + + // The info-level "checked out" line is fine to see; V(1)+ and V(3) must not. + for _, f := range []string{"clone url=", "Counting objects"} { + g.Expect(out).ToNot(gomega.ContainSubstring(f), "default level leaked trace-only line") + } +} diff --git a/connection/gke.go b/connection/gke.go index fb4086534..7b2e1854b 100644 --- a/connection/gke.go +++ b/connection/gke.go @@ -46,23 +46,13 @@ func (t *GKEConnection) Client(ctx context.Context, opts ...types.ClientOption) clientOpts = append(clientOpts, option.WithEndpoint(t.Endpoint)) } - harCollector := o.HARCollector - if harCollector == nil { - harCollector = ctx.HARCollector() - } - if harCollector != nil { + if t.SkipTLSVerify || effectiveHARCollector(ctx, "gke", o.HARCollector) != nil || ctx.IsHTTPLoggingEnabled("gke") { base := http.RoundTripper(http.DefaultTransport) if t.SkipTLSVerify { base = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} } - tr := harCollector.Middleware()(base) + tr := applyHTTPObservability(ctx, "gke", base, o.HARCollector) clientOpts = append(clientOpts, option.WithHTTPClient(&http.Client{Transport: tr})) - } else if t.SkipTLSVerify { - clientOpts = append(clientOpts, option.WithHTTPClient(&http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - }, - })) } if t.Credentials != nil && !t.Credentials.IsEmpty() { @@ -88,6 +78,7 @@ func (t *GKEConnection) Client(ctx context.Context, opts ...types.ClientOption) } func (t *GKEConnection) KubernetesClient(ctx context.Context, freshToken bool, opts ...types.ClientOption) (kubernetes.Interface, *rest.Config, error) { + o := types.NewClientOptions(opts...) containerService, err := t.Client(ctx, opts...) if err != nil { return nil, nil, err @@ -116,6 +107,11 @@ func (t *GKEConnection) KubernetesClient(ctx context.Context, freshToken bool, o CAData: ca, }, } + if middleware := httpObservabilityMiddleware(ctx, "kubernetes", o.HARCollector); middleware != nil { + restConfig.WrapTransport = func(rt http.RoundTripper) http.RoundTripper { + return middleware(rt) + } + } clientset, err := kubernetes.NewForConfig(restConfig) if err != nil { diff --git a/connection/http.go b/connection/http.go index d46ad1b7f..1009d7e33 100644 --- a/connection/http.go +++ b/connection/http.go @@ -349,38 +349,43 @@ func (h *HTTPConnection) Hydrate(ctx ConnectionContext, namespace string) (*HTTP } func (h HTTPConnection) Transport(opts ...types.ClientOption) netHTTP.RoundTripper { + return h.TransportWithContext(nil, opts...) +} + +func (h HTTPConnection) TransportWithContext(ctx any, opts ...types.ClientOption) netHTTP.RoundTripper { o := types.NewClientOptions(opts...) - var base netHTTP.RoundTripper = &netHTTP.Transport{} - if o.HARCollector != nil { - base = o.HARCollector.Middleware()(base) - } + base := applyHTTPObservability(ctx, "http", &netHTTP.Transport{}, o.HARCollector) rt := &httpConnectionRoundTripper{ HTTPConnection: h, Base: base, + TokenTransport: harTokenTransport(ctx, "http", o.HARCollector), } return rt } type httpConnectionRoundTripper struct { HTTPConnection - Base netHTTP.RoundTripper + Base netHTTP.RoundTripper + TokenTransport middlewares.Middleware } func (rt *httpConnectionRoundTripper) RoundTrip(req *netHTTP.Request) (*netHTTP.Response, error) { conn := rt.HTTPConnection + base := rt.Base if !conn.HTTPBasicAuth.IsEmpty() { req.SetBasicAuth(conn.HTTPBasicAuth.GetUsername(), conn.HTTPBasicAuth.GetPassword()) } else if !conn.Bearer.IsEmpty() { req.Header.Set(echo.HeaderAuthorization, "Bearer "+conn.Bearer.ValueStatic) } else if !conn.OAuth.IsEmpty() { oauthTransport := middlewares.NewOauthTransport(middlewares.OauthConfig{ - ClientID: conn.OAuth.ClientID.String(), - ClientSecret: conn.OAuth.ClientSecret.String(), - TokenURL: conn.OAuth.TokenURL, - Params: conn.OAuth.Params, - Scopes: conn.OAuth.Scopes, + ClientID: conn.OAuth.ClientID.String(), + ClientSecret: conn.OAuth.ClientSecret.String(), + TokenURL: conn.OAuth.TokenURL, + Params: conn.OAuth.Params, + Scopes: conn.OAuth.Scopes, + TokenTransport: rt.TokenTransport, }) - rt.Base = oauthTransport.RoundTripper(rt.Base) + base = oauthTransport.RoundTripper(base) } for _, header := range conn.Headers { @@ -394,23 +399,25 @@ func (rt *httpConnectionRoundTripper) RoundTrip(req *netHTTP.Request) (*netHTTP. } if conn.AWSSigV4 != nil && conn.AwsConfig != nil { - rt.Base = middlewares.NewAWSSigv4Transport(middlewares.AWSSigv4Config{ + base = middlewares.NewAWSSigv4Transport(middlewares.AWSSigv4Config{ Region: conn.AwsConfig.Region, Service: conn.AWSSigV4.Service, CredentialsProvider: conn.AwsConfig.Credentials, - }, rt.Base) + }, base) } - return rt.Base.RoundTrip(req) + return base.RoundTrip(req) } // CreateHTTPClient requires a hydrated connection func CreateHTTPClient(ctx ConnectionContext, conn HTTPConnection, opts ...types.ClientOption) (*http.Client, error) { o := types.NewClientOptions(opts...) - client := http.NewClient() - if o.HARCollector != nil { - client.HARCollector(o.HARCollector) + feature := o.Feature + if feature == "" { + feature = "http" } + client := http.NewClient() + tokenTransport := applyHTTPClientObservability(ctx, feature, client, o.HARCollector) if !conn.HTTPBasicAuth.IsEmpty() { client.Auth(conn.GetUsername(), conn.GetPassword()) client.Digest(conn.Digest) @@ -420,11 +427,12 @@ func CreateHTTPClient(ctx ConnectionContext, conn HTTPConnection, opts ...types. client.Header(echo.HeaderAuthorization, "Bearer "+conn.Bearer.ValueStatic) } else if !conn.OAuth.IsEmpty() { client.OAuth(middlewares.OauthConfig{ - ClientID: conn.OAuth.ClientID.ValueStatic, - ClientSecret: conn.OAuth.ClientSecret.ValueStatic, - TokenURL: conn.OAuth.TokenURL, - Params: conn.OAuth.Params, - Scopes: conn.OAuth.Scopes, + ClientID: conn.OAuth.ClientID.ValueStatic, + ClientSecret: conn.OAuth.ClientSecret.ValueStatic, + TokenURL: conn.OAuth.TokenURL, + Params: conn.OAuth.Params, + Scopes: conn.OAuth.Scopes, + TokenTransport: tokenTransport, }) } diff --git a/connection/kubernetes.go b/connection/kubernetes.go index 249170b38..9db530349 100644 --- a/connection/kubernetes.go +++ b/connection/kubernetes.go @@ -49,10 +49,10 @@ func (t *KubeconfigConnection) Populate(ctx context.Context, opts ...types.Clien t.Kubeconfig.ValueStatic = v } - return dutyKubernetes.NewClientFromPathOrConfig(ctx.Logger, t.Kubeconfig.ValueStatic, o.HARCollector) + return dutyKubernetes.NewClientFromPathOrConfigWithMiddleware(ctx.Logger, t.Kubeconfig.ValueStatic, httpObservabilityMiddleware(ctx, "kubernetes", o.HARCollector)) } - return dutyKubernetes.NewClient(ctx.Logger) + return dutyKubernetes.NewClientWithMiddleware(ctx.Logger, httpObservabilityMiddleware(ctx, "kubernetes", o.HARCollector)) } // +kubebuilder:object:generate=true diff --git a/connection/observability_test.go b/connection/observability_test.go new file mode 100644 index 000000000..3ac4be04d --- /dev/null +++ b/connection/observability_test.go @@ -0,0 +1,144 @@ +package connection + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/flanksource/commons/har" + "github.com/flanksource/commons/logger" + "github.com/onsi/gomega" +) + +type testObservabilityContext struct { + collector *har.Collector + harLevel logger.LogLevel + logHeaders bool + logBodies bool +} + +func (t testObservabilityContext) HARCollector() *har.Collector { + return t.collector +} + +func (t testObservabilityContext) EffectiveHARCollector(_ string, explicit *har.Collector) *har.Collector { + if explicit != nil { + return explicit + } + if t.harLevel >= logger.Debug { + return t.collector + } + return nil +} + +func (t testObservabilityContext) EffectiveHARLevel(_ string) (logger.LogLevel, string) { + return t.harLevel, "test" +} + +func (t testObservabilityContext) HTTPLoggingContent(_ string) (bool, bool) { + return t.logHeaders, t.logBodies +} + +func TestHARDebugCapturesMetadataOnly(t *testing.T) { + g := gomega.NewWithT(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + collector := har.NewCollector(har.DefaultConfig()) + ctx := testObservabilityContext{collector: collector, harLevel: logger.Debug} + client := &http.Client{Transport: applyHTTPObservability(ctx, "http", http.DefaultTransport, nil)} + + resp, err := client.Post(server.URL+"?q=1", "application/json", strings.NewReader(`{"secret":"value"}`)) + g.Expect(err).ToNot(gomega.HaveOccurred()) + _, _ = io.ReadAll(resp.Body) + _ = resp.Body.Close() + + entries := collector.Entries() + g.Expect(entries).To(gomega.HaveLen(1)) + g.Expect(entries[0].Request.PostData).To(gomega.BeNil(), "debug HAR should not capture request body") + g.Expect(entries[0].Response.Content.Text).To(gomega.BeEmpty(), "debug HAR should not capture response body") + g.Expect(entries[0].Request.HeadersSize).To(gomega.Equal(-1)) + g.Expect(entries[0].Response.HeadersSize).To(gomega.Equal(-1)) +} + +func TestHARTraceCapturesBodies(t *testing.T) { + g := gomega.NewWithT(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + collector := har.NewCollector(har.DefaultConfig()) + ctx := testObservabilityContext{collector: collector, harLevel: logger.Trace} + client := &http.Client{Transport: applyHTTPObservability(ctx, "http", http.DefaultTransport, nil)} + + resp, err := client.Post(server.URL, "application/json", strings.NewReader(`{"secret":"value"}`)) + g.Expect(err).ToNot(gomega.HaveOccurred()) + _, _ = io.ReadAll(resp.Body) + _ = resp.Body.Close() + + entries := collector.Entries() + g.Expect(entries).To(gomega.HaveLen(1)) + g.Expect(entries[0].Request.PostData).ToNot(gomega.BeNil(), "trace HAR should capture request body") + g.Expect(entries[0].Response.Content.Text).ToNot(gomega.BeEmpty(), "trace HAR should capture response body") +} + +// TestHARAndHTTPLoggingBodiesCoexist exercises both middlewares stacked: HAR +// trace-level body capture AND httpretty-style HTTP body logging. Each layer +// reads and must restore the request/response bodies for the next layer. +// Without correct body restoration, one of three observers would see an empty +// body: the server (downstream of both middlewares), the client (upstream), +// or the HAR collector (innermost — runs after httpretty has read the body). +func TestHARAndHTTPLoggingBodiesCoexist(t *testing.T) { + g := gomega.NewWithT(t) + + const reqBody = `{"name":"alice","note":"hello"}` + const respBody = `{"ok":true,"echo":"hello"}` + + var serverSawBody string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + serverSawBody = string(b) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(respBody)) + })) + defer server.Close() + + collector := har.NewCollector(har.DefaultConfig()) + ctx := testObservabilityContext{ + collector: collector, + harLevel: logger.Trace, + logHeaders: true, + logBodies: true, + } + client := &http.Client{Transport: applyHTTPObservability(ctx, "http", http.DefaultTransport, nil)} + + resp, err := client.Post(server.URL, "application/json", strings.NewReader(reqBody)) + g.Expect(err).ToNot(gomega.HaveOccurred()) + gotResp, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + + g.Expect(serverSawBody).To(gomega.Equal(reqBody), + "server must receive the full request body — proves httpretty restored it for the HAR layer below") + g.Expect(string(gotResp)).To(gomega.Equal(respBody), + "client must receive the full response body — proves both layers restored it on the way back") + + entries := collector.Entries() + g.Expect(entries).To(gomega.HaveLen(1)) + g.Expect(entries[0].Request.PostData).ToNot(gomega.BeNil(), + "HAR captures request body — proves it was still readable after httpretty consumed it") + // HAR may re-serialize JSON (key reordering, whitespace) so assert on content fragments. + g.Expect(entries[0].Request.PostData.Text).To(gomega.ContainSubstring(`"name":"alice"`)) + g.Expect(entries[0].Request.PostData.Text).To(gomega.ContainSubstring(`"note":"hello"`)) + g.Expect(entries[0].Response.Content.Text).To(gomega.ContainSubstring(`"ok":true`), + "HAR captures response body — proves it was still readable after httpretty consumed it") + g.Expect(entries[0].Response.Content.Text).To(gomega.ContainSubstring(`"echo":"hello"`)) +} diff --git a/connection/opensearch.go b/connection/opensearch.go index 042b505fd..e847add6b 100644 --- a/connection/opensearch.go +++ b/connection/opensearch.go @@ -139,16 +139,8 @@ func (c *OpensearchConnection) Client(ctx context.Context, opts ...types.ClientO TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } } - - harCollector := o.HARCollector - if harCollector == nil { - harCollector = ctx.HARCollector() - } - if harCollector != nil { - if tr == nil { - tr = http.DefaultTransport - } - tr = harCollector.Middleware()(tr) + if tr != nil || effectiveHARCollector(ctx, "opensearch", o.HARCollector) != nil || ctx.IsHTTPLoggingEnabled("opensearch") { + tr = applyHTTPObservability(ctx, "opensearch", tr, o.HARCollector) } if tr != nil { diff --git a/connection/prometheus.go b/connection/prometheus.go index 7873a75f4..4aea54ff2 100644 --- a/connection/prometheus.go +++ b/connection/prometheus.go @@ -38,7 +38,7 @@ func (p *PrometheusConnection) Populate(ctx ConnectionContext) error { func (p *PrometheusConnection) NewClient(ctx context.Context, opts ...types.ClientOption) (v1.API, error) { cfg := api.Config{ Address: p.HTTPConnection.URL, - RoundTripper: p.HTTPConnection.Transport(opts...), + RoundTripper: p.HTTPConnection.TransportWithContext(ctx, opts...), } client, err := api.NewClient(cfg) diff --git a/context/properties.go b/context/properties.go index 4d0e36aae..23728e188 100644 --- a/context/properties.go +++ b/context/properties.go @@ -9,6 +9,7 @@ import ( "github.com/flanksource/commons/console" "github.com/flanksource/commons/duration" + "github.com/flanksource/commons/har" "github.com/flanksource/commons/logger" "github.com/flanksource/commons/properties" "github.com/flanksource/duty/models" @@ -327,3 +328,179 @@ func UpdateProperties(ctx Context, props map[string]string) error { defer ctx.ClearCache() return ctx.DB().Exec(query, args...).Error } + +const HARMaxBodySizeDefault = 64 * 1024 + +func (k Context) EffectiveLogLevel(feature string) (logger.LogLevel, string) { + return k.effectiveObservabilityLevel(feature, false) +} + +func (k Context) EffectiveHARLevel(feature string) (logger.LogLevel, string) { + return k.effectiveObservabilityLevel(feature, true) +} + +func (k Context) IsHARCaptureEnabled(feature string) bool { + level, _ := k.EffectiveHARLevel(feature) + return level >= logger.Debug +} + +func (k Context) IsHTTPLoggingEnabled(feature string) bool { + level, _ := k.EffectiveLogLevel(feature) + return level >= logger.Debug +} + +func (k Context) HTTPLoggingContent(feature string) (headers bool, bodies bool) { + level, _ := k.EffectiveLogLevel(feature) + return level >= logger.Debug, level >= logger.Trace +} + +func (k Context) HARConfig(feature string) har.HARConfig { + cfg := har.DefaultConfig() + cfg.MaxBodySize = int64(k.Properties().Int("har.maxBodySize", HARMaxBodySizeDefault)) + if v := k.Properties().String("har.captureContentTypes", ""); v != "" { + cfg.CaptureContentTypes = splitCSV(v) + } + return cfg +} + +// EffectiveHARCollector resolves which HAR collector should be used for the +// given feature. When the caller passes an explicit collector, it is returned +// as-is — its Config is the caller's concern, not this package's, since the +// same *har.Collector may be reused across features and goroutines and a +// last-writer-wins mutation here would race. When no explicit collector is +// supplied, the context-owned shared collector (k.HARCollector) is configured +// per-feature and returned only if the feature's effective level is >= Debug. +func (k Context) EffectiveHARCollector(feature string, explicit *har.Collector) *har.Collector { + if explicit != nil { + return explicit + } + level, _ := k.EffectiveHARLevel(feature) + if level < logger.Debug { + return nil + } + collector := k.HARCollector() + if collector != nil { + collector.Config = k.HARConfig(feature) + } + return collector +} + +func (k Context) effectiveObservabilityLevel(feature string, harCapture bool) (logger.LogLevel, string) { + feature = strings.TrimSpace(strings.ToLower(feature)) + if feature == "" { + feature = "http" + } + + // Floor the feature level on the context's logger and the global standard + // logger: a global -Plog.level=trace SHOULD reveal HTTP/HAR detail when + // debugging. Per-feature overrides below can only raise the level further. + var level logger.LogLevel + if k.Logger != nil { + level = normalizeFeatureLevel(k.Logger.GetLevel()) + } + if std := logger.StandardLogger(); std != nil { + level = maxLevel(level, std.GetLevel()) + } + source := "logger" + props := k.Properties() + + add := func(candidate logger.LogLevel, candidateSource string) { + candidate = normalizeFeatureLevel(candidate) + if candidate > level { + level = candidate + source = candidateSource + } + } + addProperty := func(key string) { + if v := props.String(key, ""); v != "" { + add(logger.ParseLevel(k.Logger, v), key) + } + } + addAnnotation := func(key string) { + for _, o := range k.Objects() { + annotations := getObjectMeta(o).Annotations + if len(annotations) == 0 { + continue + } + if v := annotationValue(annotations, key); v != "" { + add(logger.ParseLevel(k.Logger, v), "annotation:"+key) + } + } + } + + addProperty("log.level") + addAnnotation("log.level") + for _, o := range k.Objects() { + annotations := getObjectMeta(o).Annotations + if len(annotations) == 0 { + continue + } + if annotationValue(annotations, "trace") == "true" { + add(logger.Trace, "annotation:trace") + } else if annotationValue(annotations, "debug") == "true" { + add(logger.Debug, "annotation:debug") + } + } + + if harCapture { + addProperty("log.level.http.har") + for _, f := range featureLevelKeys(feature) { + addProperty("log.level." + f + ".har") + } + addAnnotation("log.level.http.har") + for _, f := range featureLevelKeys(feature) { + addAnnotation("log.level." + f + ".har") + } + } else { + addProperty("log.level.http") + for _, f := range featureLevelKeys(feature) { + addProperty("log.level." + f) + } + addAnnotation("log.level.http") + for _, f := range featureLevelKeys(feature) { + addAnnotation("log.level." + f) + } + } + + return level, source +} + +func normalizeFeatureLevel(level logger.LogLevel) logger.LogLevel { + if level == logger.Silent || level < logger.Info { + return logger.Info + } + return level +} + +func maxLevel(a, b logger.LogLevel) logger.LogLevel { + a = normalizeFeatureLevel(a) + b = normalizeFeatureLevel(b) + if a > b { + return a + } + return b +} + +func splitCSV(s string) []string { + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + +func featureLevelKeys(feature string) []string { + if feature == "http" { + return nil + } + switch feature { + case "kubernetes": + return []string{"kubernetes", "kubectl", "k8s"} + default: + return []string{feature} + } +} diff --git a/context/template.go b/context/template.go index 2ce22e87b..313ef73dc 100644 --- a/context/template.go +++ b/context/template.go @@ -5,14 +5,24 @@ import ( "strconv" "github.com/flanksource/commons/collections" + "github.com/flanksource/commons/logger" "github.com/flanksource/gomplate/v3" "github.com/google/cel-go/cel" + "github.com/samber/lo" ) var CelEnvFuncs = make(map[string]func(Context) cel.EnvOption) var TemplateFuncs = make(map[string]func(Context) any) func (k Context) RunTemplate(t gomplate.Template, env map[string]any) (string, error) { + l := k.Logger.Named("template") + if l.V(3).Enabled() { + l.V(3).Infof("Running template: %s with environment: %v", t.String(), logger.Pretty(env)) + } else if l.IsLevelEnabled(logger.Trace) { + l.V(2).Infof("Running template: %s with environment keys: %v", t.String(), lo.Keys(env)) + } else { + l.V(1).Infof("Running template: %s", t.String()) + } for _, f := range CelEnvFuncs { t.CelEnvs = append(t.CelEnvs, f(k)) } @@ -41,9 +51,19 @@ func (k Context) RunTemplate(t gomplate.Template, env map[string]any) (string, e t.RightDelim = delimSet.Right val, err := gomplate.RunTemplateContext(k.Context, env, t) + if err != nil { return "", k.Oops().With("template", t.String(), "environment", env).Wrap(err) } + if t.Template == val && l.V(4).Enabled() { + l.V(4).Infof("%s = ", t.String()) + } else if t.Template != val { + if l.V(2).Enabled() { + l.V(2).Infof("%s = %s", t.String(), val) + } else if l.V(1).Enabled() { + l.V(1).Infof("templated %s = changed", t.String()) + } + } t.Template = val } diff --git a/kubernetes/k8s.go b/kubernetes/k8s.go index 64b4a4bf7..2d4c1df0e 100644 --- a/kubernetes/k8s.go +++ b/kubernetes/k8s.go @@ -2,6 +2,7 @@ package kubernetes import ( "context" + "encoding/json" "fmt" "net/http" netURL "net/url" @@ -13,6 +14,7 @@ import ( "github.com/flanksource/commons/console" "github.com/flanksource/commons/files" "github.com/flanksource/commons/har" + "github.com/flanksource/commons/http/middlewares" "github.com/flanksource/commons/logger" "github.com/flanksource/duty/cache" "github.com/henvic/httpretty" @@ -35,6 +37,18 @@ var sensitiveUrls = []*regexp.Regexp{ } func NewClient(log logger.Logger, kubeconfigPaths ...string) (kubernetes.Interface, *rest.Config, error) { + return NewClientWithCollector(log, nil, kubeconfigPaths...) +} + +func NewClientWithCollector(log logger.Logger, collector *har.Collector, kubeconfigPaths ...string) (kubernetes.Interface, *rest.Config, error) { + var middleware middlewares.Middleware + if collector != nil { + middleware = collector.Middleware() + } + return NewClientWithMiddleware(log, middleware, kubeconfigPaths...) +} + +func NewClientWithMiddleware(log logger.Logger, middleware middlewares.Middleware, kubeconfigPaths ...string) (kubernetes.Interface, *rest.Config, error) { if len(kubeconfigPaths) == 0 { kubeconfigPaths = []string{os.Getenv("KUBECONFIG"), os.ExpandEnv("$HOME/.kube/config")} } @@ -45,19 +59,29 @@ func NewClient(log logger.Logger, kubeconfigPaths ...string) (kubernetes.Interfa return nil, nil, err } else { log.Infof("Using kubeconfig %s", path) - return NewClientWithConfig(log, configBytes) + return NewClientWithConfigMiddleware(log, configBytes, middleware) } } } if config, err := rest.InClusterConfig(); err == nil { - client, err := kubernetes.NewForConfig(trace(log, config)) + config = trace(log, config) + appendWrapTransport(config, middleware) + client, err := kubernetes.NewForConfig(config) return client, config, err } return Nil, nil, nil } func NewClientWithConfig(log logger.Logger, kubeConfig []byte, collector ...*har.Collector) (kubernetes.Interface, *rest.Config, error) { + var middleware middlewares.Middleware + if len(collector) > 0 && collector[0] != nil { + middleware = collector[0].Middleware() + } + return NewClientWithConfigMiddleware(log, kubeConfig, middleware) +} + +func NewClientWithConfigMiddleware(log logger.Logger, kubeConfig []byte, middleware middlewares.Middleware) (kubernetes.Interface, *rest.Config, error) { clientConfig, err := clientcmd.NewClientConfigFromBytes(kubeConfig) if err != nil { return nil, nil, err @@ -74,39 +98,58 @@ func NewClientWithConfig(log logger.Logger, kubeConfig []byte, collector ...*har return nil, nil, err } else { config = trace(logger.GetLogger("k8s."+name), config) - if len(collector) > 0 && collector[0] != nil { - existing := config.WrapTransport - config.WrapTransport = func(rt http.RoundTripper) http.RoundTripper { - if existing != nil { - rt = existing(rt) - } - return collector[0].Middleware()(rt) - } - } + appendWrapTransport(config, middleware) client, err := kubernetes.NewForConfig(config) return client, config, err } } +func appendWrapTransport(config *rest.Config, middleware middlewares.Middleware) { + if middleware == nil { + return + } + existing := config.WrapTransport + config.WrapTransport = func(rt http.RoundTripper) http.RoundTripper { + if existing != nil { + rt = existing(rt) + } + return middleware(rt) + } +} + func NewClientFromPathOrConfig( logger logger.Logger, kubeconfigOrPath string, collector ...*har.Collector, +) (kubernetes.Interface, *rest.Config, error) { + var middleware middlewares.Middleware + if len(collector) > 0 && collector[0] != nil { + middleware = collector[0].Middleware() + } + return NewClientFromPathOrConfigWithMiddleware(logger, kubeconfigOrPath, middleware) +} + +func NewClientFromPathOrConfigWithMiddleware( + logger logger.Logger, + kubeconfigOrPath string, + middleware middlewares.Middleware, ) (kubernetes.Interface, *rest.Config, error) { var client kubernetes.Interface var rest *rest.Config var err error + kubeconfigOrPath = normalizeKubeconfigOrPath(kubeconfigOrPath) + if _, pathErr := os.Stat(kubeconfigOrPath); pathErr == nil { configBytes, err := os.ReadFile(kubeconfigOrPath) if err != nil { return nil, nil, err } - if client, rest, err = NewClientWithConfig(logger, configBytes, collector...); err != nil { + if client, rest, err = NewClientWithConfigMiddleware(logger, configBytes, middleware); err != nil { return nil, nil, err } } else { - if client, rest, err = NewClientWithConfig(logger, []byte(kubeconfigOrPath), collector...); err != nil { + if client, rest, err = NewClientWithConfigMiddleware(logger, []byte(kubeconfigOrPath), middleware); err != nil { return nil, nil, err } } @@ -114,6 +157,19 @@ func NewClientFromPathOrConfig( return client, rest, err } +func normalizeKubeconfigOrPath(value string) string { + trimmed := strings.TrimSpace(value) + if !strings.HasPrefix(trimmed, `"`) { + return value + } + + var decoded string + if err := json.Unmarshal([]byte(trimmed), &decoded); err != nil { + return value + } + return decoded +} + var clusterNames = cache.NewCache[string]("clusterNames", time.Hour*24) func trace(clogger logger.Logger, config *rest.Config) *rest.Config { diff --git a/kubernetes/utils_test.go b/kubernetes/utils_test.go index 88eeac426..191a78966 100644 --- a/kubernetes/utils_test.go +++ b/kubernetes/utils_test.go @@ -1,6 +1,7 @@ package kubernetes import ( + "encoding/json" "os" "path/filepath" "testing" @@ -34,3 +35,19 @@ func TestGetAPIServer(t *testing.T) { }) } } + +func TestNewClientFromPathOrConfigAcceptsJSONStringKubeconfig(t *testing.T) { + g := gomega.NewWithT(t) + + kubeconfig, err := os.ReadFile(filepath.Join("testdata", "kubeconfig.yaml")) + g.Expect(err).To(gomega.BeNil()) + + encoded, err := json.Marshal(string(kubeconfig)) + g.Expect(err).To(gomega.BeNil()) + + client, restConfig, err := NewClientFromPathOrConfigWithMiddleware(nil, string(encoded), nil) + g.Expect(err).ToNot(gomega.HaveOccurred()) + g.Expect(client).ToNot(gomega.BeNil()) + g.Expect(restConfig).ToNot(gomega.BeNil()) + g.Expect(restConfig.Host).To(gomega.Equal("https://10.99.99.222:6443")) +} diff --git a/start.go b/start.go index 5108f5c4a..602aacf5b 100644 --- a/start.go +++ b/start.go @@ -142,22 +142,15 @@ func Start(name string, opts ...StartOption) (context.Context, func(), error) { } if config.Postgrest.URL != "" && !config.Postgrest.Disable { - parsedURL, err := url.Parse(config.Postgrest.URL) - if err != nil { - return context.Context{}, nil, fmt.Errorf("failed to parse PostgREST URL: %v", err) - } - - host := strings.ToLower(parsedURL.Hostname()) - port, _ := strconv.Atoi(parsedURL.Port()) - config.Postgrest.Port = int(port) - if host == "localhost" { - if config.Postgrest.JWTSecret == "" { - logger.Warnf("PostgREST JWT secret not specified, generating random secret") - config.Postgrest.JWTSecret = utils.RandomString(32) + if configured, startLocal, err := configurePostgrest(config); err != nil { + return context.Context{}, nil, err + } else { + config = configured + if startLocal { + go postgrest.Start(config) } - go postgrest.Start(config) + api.DefaultConfig = config } - api.DefaultConfig = config } var ctx context.Context @@ -200,6 +193,37 @@ func Start(name string, opts ...StartOption) (context.Context, func(), error) { return ctx, stop, nil } +func configurePostgrest(config api.Config) (api.Config, bool, error) { + if config.Postgrest.URL == "" || config.Postgrest.Disable { + return config, false, nil + } + + parsedURL, err := url.Parse(config.Postgrest.URL) + if err != nil { + return config, false, fmt.Errorf("failed to parse PostgREST URL: %v", err) + } + + host := strings.ToLower(parsedURL.Hostname()) + port, _ := strconv.Atoi(parsedURL.Port()) + config.Postgrest.Port = port + + if host != "localhost" { + return config, false, nil + } + + if config.Postgrest.Port == 0 { + config.Postgrest.Port = FreePort() + parsedURL.Host = net.JoinHostPort(parsedURL.Hostname(), strconv.Itoa(config.Postgrest.Port)) + config.Postgrest.URL = parsedURL.String() + } + + if config.Postgrest.JWTSecret == "" { + logger.Warnf("PostgREST JWT secret not specified, generating random secret") + config.Postgrest.JWTSecret = utils.RandomString(32) + } + return config, true, nil +} + const posmasterLinePort = 3 func embeddedDB(database, connectionString string, port uint32) (string, func(), error) { diff --git a/start_test.go b/start_test.go new file mode 100644 index 000000000..b968fa7c8 --- /dev/null +++ b/start_test.go @@ -0,0 +1,80 @@ +package duty + +import ( + "net/url" + "strconv" + + "github.com/flanksource/duty/api" + ginkgo "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe("PostgREST configuration", func() { + ginkgo.It("resolves localhost port 0 to a bindable port and updates the URL", func() { + config := api.Config{ + Postgrest: api.PostgrestConfig{ + URL: "http://localhost:0", + JWTSecret: "configured-secret", + }, + } + + configured, startLocal, err := configurePostgrest(config) + + Expect(err).ToNot(HaveOccurred()) + Expect(startLocal).To(BeTrue()) + Expect(configured.Postgrest.Port).To(BeNumerically(">", 0)) + Expect(configured.Postgrest.URL).ToNot(Equal("http://localhost:0")) + + parsed, err := url.Parse(configured.Postgrest.URL) + Expect(err).ToNot(HaveOccurred()) + Expect(parsed.Hostname()).To(Equal("localhost")) + Expect(parsed.Port()).To(Equal(strconv.Itoa(configured.Postgrest.Port))) + }) + + ginkgo.It("keeps an explicit localhost port", func() { + config := api.Config{ + Postgrest: api.PostgrestConfig{ + URL: "http://localhost:3000", + JWTSecret: "configured-secret", + }, + } + + configured, startLocal, err := configurePostgrest(config) + + Expect(err).ToNot(HaveOccurred()) + Expect(startLocal).To(BeTrue()) + Expect(configured.Postgrest.Port).To(Equal(3000)) + Expect(configured.Postgrest.URL).To(Equal("http://localhost:3000")) + }) + + ginkgo.It("does not start local PostgREST for remote URLs", func() { + config := api.Config{ + Postgrest: api.PostgrestConfig{ + URL: "http://postgrest.default.svc:3000", + }, + } + + configured, startLocal, err := configurePostgrest(config) + + Expect(err).ToNot(HaveOccurred()) + Expect(startLocal).To(BeFalse()) + Expect(configured.Postgrest.Port).To(Equal(3000)) + Expect(configured.Postgrest.URL).To(Equal("http://postgrest.default.svc:3000")) + }) + + ginkgo.It("does not configure PostgREST when disabled", func() { + config := api.Config{ + Postgrest: api.PostgrestConfig{ + URL: "http://localhost:0", + Disable: true, + }, + } + + configured, startLocal, err := configurePostgrest(config) + + Expect(err).ToNot(HaveOccurred()) + Expect(startLocal).To(BeFalse()) + Expect(configured.Postgrest.Port).To(Equal(0)) + Expect(configured.Postgrest.URL).To(Equal("http://localhost:0")) + }) +}) diff --git a/suite_test.go b/suite_test.go new file mode 100644 index 000000000..9bff583a3 --- /dev/null +++ b/suite_test.go @@ -0,0 +1,13 @@ +package duty + +import ( + "testing" + + ginkgo "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestDuty(t *testing.T) { + RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Duty") +} From 3b22a8205afe21f42343f54ac469080020d1f6f3 Mon Sep 17 00:00:00 2001 From: Yash Mehrotra Date: Thu, 7 May 2026 01:32:07 +0530 Subject: [PATCH 4/5] chore: comment out gavel --- Makefile | 50 ++++++++++++++++++++++++++++---------------------- Taskfile.yaml | 40 ++++++++++++++++++++-------------------- 2 files changed, 48 insertions(+), 42 deletions(-) diff --git a/Makefile b/Makefile index bc9851784..a5678f23e 100644 --- a/Makefile +++ b/Makefile @@ -11,28 +11,34 @@ GOLANGCI_LINT_VERSION ?= v2.11.3 ginkgo: go install github.com/onsi/ginkgo/v2/ginkgo -.PHONY: gavel -gavel: - @command -v gavel >/dev/null || go install github.com/flanksource/gavel/cmd/gavel@latest - -test: gavel - gavel test --timeout 30m --test-timeout 15m \ - --ignore ./bench \ - --ignore ./hack \ - --ignore ./specs \ - --ignore ./tests/e2e \ - --ignore ./tests/e2e-blobs \ - ./... - -test-concurrent: gavel - gavel test --timeout 30m --test-timeout 15m \ - --nodes 4 \ - --ignore ./bench \ - --ignore ./hack \ - --ignore ./specs \ - --ignore ./tests/e2e \ - --ignore ./tests/e2e-blobs \ - ./... +# .PHONY: gavel +# gavel: +# @command -v gavel >/dev/null || go install github.com/flanksource/gavel/cmd/gavel@latest +# +# test: gavel +# gavel test --timeout 30m --test-timeout 15m \ +# --ignore ./bench \ +# --ignore ./hack \ +# --ignore ./specs \ +# --ignore ./tests/e2e \ +# --ignore ./tests/e2e-blobs \ +# ./... +# +# test-concurrent: gavel +# gavel test --timeout 30m --test-timeout 15m \ +# --nodes 4 \ +# --ignore ./bench \ +# --ignore ./hack \ +# --ignore ./specs \ +# --ignore ./tests/e2e \ +# --ignore ./tests/e2e-blobs \ +# ./... + +test: ginkgo + ginkgo -r --succinct --skip-package=tests/e2e,tests/e2e-blobs,bench --label-filter "!e2e" + +test-concurrent: ginkgo + ginkgo -r -v --nodes=4 --skip-package=bench --label-filter "!e2e" .PHONY: test-e2e diff --git a/Taskfile.yaml b/Taskfile.yaml index 23b99ae22..a69291538 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -1,26 +1,26 @@ version: '3' tasks: - test: - desc: | - Run the unit test suite via gavel, mirroring the `test` job in - .github/workflows/test.yaml. Excludes the heavy benchmark, generator, - spec, and e2e packages so it matches CI scope. - - Anything after `--` is passed straight through to gavel, so: - task test -- --focus "MyFlow" - task test -- ./query --extra-args=--ginkgo.label-filter=focus - task test -- --dry-run - Without extra args, runs every unit package matching CI. - cmds: - - | - gavel test --timeout 30m --test-timeout 15m \ - --ignore ./bench \ - --ignore ./hack \ - --ignore ./specs \ - --ignore ./tests/e2e \ - --ignore ./tests/e2e-blobs \ - {{if .CLI_ARGS}}{{.CLI_ARGS}}{{else}}./...{{end}} + # test: + # desc: | + # Run the unit test suite via gavel, mirroring the `test` job in + # .github/workflows/test.yaml. Excludes the heavy benchmark, generator, + # spec, and e2e packages so it matches CI scope. + # + # Anything after `--` is passed straight through to gavel, so: + # task test -- --focus "MyFlow" + # task test -- ./query --extra-args=--ginkgo.label-filter=focus + # task test -- --dry-run + # Without extra args, runs every unit package matching CI. + # cmds: + # - | + # gavel test --timeout 30m --test-timeout 15m \ + # --ignore ./bench \ + # --ignore ./hack \ + # --ignore ./specs \ + # --ignore ./tests/e2e \ + # --ignore ./tests/e2e-blobs \ + # {{if .CLI_ARGS}}{{.CLI_ARGS}}{{else}}./...{{end}} test:migrate: desc: | From 7ff338fcb73bfd92f7c03c57f71878cf49a6a7da Mon Sep 17 00:00:00 2001 From: Yash Mehrotra Date: Thu, 7 May 2026 11:32:14 +0530 Subject: [PATCH 5/5] chore: address review comments --- Makefile | 1 + PROPERTIES.md | 2 +- connection/git_logging_test.go | 4 ++++ query/config_tree.go | 3 +++ query/resource_selector.go | 2 +- tests/config_access_test.go | 25 +++++++++++++++++++------ 6 files changed, 29 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index a5678f23e..8d9bbc727 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,7 @@ ginkgo: # --ignore ./tests/e2e-blobs \ # ./... +.PHONY: test test-concurrent test: ginkgo ginkgo -r --succinct --skip-package=tests/e2e,tests/e2e-blobs,bench --label-filter "!e2e" diff --git a/PROPERTIES.md b/PROPERTIES.md index 05929d54b..c74584c3d 100644 --- a/PROPERTIES.md +++ b/PROPERTIES.md @@ -7,7 +7,7 @@ separate from model `properties` fields such as `config_items.properties`, metadata stored in JSON columns. The machine-readable schema for JSON/YAML maps of these runtime properties is -[PROPERTIES.schema.json](/Users/moshe/go/src/github.com/flanksource/duty/PROPERTIES.schema.json). +[PROPERTIES.schema.json](./PROPERTIES.schema.json). The schema is intentionally kept outside `schema/openapi` because that directory is generated. diff --git a/connection/git_logging_test.go b/connection/git_logging_test.go index a2fc91595..69039efb9 100644 --- a/connection/git_logging_test.go +++ b/connection/git_logging_test.go @@ -22,6 +22,10 @@ func makeBareRepo(t *testing.T, root string) string { t.Helper() g := gomega.NewWithT(t) + if _, err := exec.LookPath("git"); err != nil { + t.Fatalf("git executable not found in PATH: %v", err) + } + work := filepath.Join(root, "work") g.Expect(os.MkdirAll(work, 0o755)).To(gomega.Succeed()) diff --git a/query/config_tree.go b/query/config_tree.go index 32d9aa565..987e4abc5 100644 --- a/query/config_tree.go +++ b/query/config_tree.go @@ -82,6 +82,9 @@ func mergeConfigTreeInto(dst, src *ConfigTreeNode, byID map[uuid.UUID]*ConfigTre if src.EdgeType == "target" { dst.EdgeType = "target" } + if dst.Relation == "" && src.Relation != "" { + dst.Relation = src.Relation + } existing := make(map[uuid.UUID]*ConfigTreeNode, len(dst.Children)) for _, c := range dst.Children { existing[c.ID] = c diff --git a/query/resource_selector.go b/query/resource_selector.go index 0ce97ba61..86968c281 100644 --- a/query/resource_selector.go +++ b/query/resource_selector.go @@ -292,7 +292,7 @@ func SetResourceSelectorClause( var agentID *uuid.UUID if !searchSetAgent && !searchSetID && qm.HasAgents { - agentID, err := getAgentID(ctx, resourceSelector.Agent) + agentID, err = getAgentID(ctx, resourceSelector.Agent) if err != nil { return nil, err } diff --git a/tests/config_access_test.go b/tests/config_access_test.go index c8e54cbc3..1afc55c62 100644 --- a/tests/config_access_test.go +++ b/tests/config_access_test.go @@ -97,6 +97,14 @@ var _ = Describe("Config Access Summary View", Ordered, func() { Expect(DefaultContext.DB().Create(&deletedGroup).Error).ToNot(HaveOccurred()) Expect(DefaultContext.DB().Create(&deletedUser).Error).ToNot(HaveOccurred()) Expect(DefaultContext.DB().Create(&deletedMembership).Error).ToNot(HaveOccurred()) + DeferCleanup(func() { + Expect(DefaultContext.DB(). + Where("external_user_id = ? AND external_group_id = ? AND scraper_id = ?", + deletedMembership.ExternalUserID, deletedMembership.ExternalGroupID, deletedMembership.ScraperID). + Delete(&models.ExternalUserGroup{}).Error).ToNot(HaveOccurred()) + Expect(DefaultContext.DB().Delete(&models.ExternalUser{}, "id = ?", deletedUser.ID).Error).ToNot(HaveOccurred()) + Expect(DefaultContext.DB().Delete(&models.ExternalGroup{}, "id = ?", deletedGroup.ID).Error).ToNot(HaveOccurred()) + }) type externalGroupSummary struct { ID uuid.UUID @@ -104,22 +112,27 @@ var _ = Describe("Config Access Summary View", Ordered, func() { PermissionsCount int64 } + expectedIDs := []uuid.UUID{ + dummy.MissionControlAdminsGroup.ID, + dummy.MissionControlReadersGroup.ID, + dummy.MissionControlEmptyGroup.ID, + deletedGroup.ID, + } var summaries []externalGroupSummary err := DefaultContext.DB(). Table("external_group_summary"). - Where("id IN ?", []uuid.UUID{ - dummy.MissionControlAdminsGroup.ID, - dummy.MissionControlReadersGroup.ID, - dummy.MissionControlEmptyGroup.ID, - deletedGroup.ID, - }). + Where("id IN ?", expectedIDs). Find(&summaries).Error Expect(err).ToNot(HaveOccurred()) + Expect(summaries).To(HaveLen(len(expectedIDs))) byID := map[uuid.UUID]externalGroupSummary{} for _, summary := range summaries { byID[summary.ID] = summary } + for _, id := range expectedIDs { + Expect(byID).To(HaveKey(id)) + } Expect(byID[dummy.MissionControlAdminsGroup.ID].MembersCount).To(Equal(int64(2))) Expect(byID[dummy.MissionControlAdminsGroup.ID].PermissionsCount).To(Equal(int64(2)))