Skip to content

Commit 9c5872b

Browse files
authored
Merge branch 'v1.4.x' into develop
2 parents 76e38d6 + a2d91c4 commit 9c5872b

File tree

21 files changed

+1161
-125
lines changed

21 files changed

+1161
-125
lines changed

.ai/codebase-notes.md

Lines changed: 127 additions & 0 deletions
Large diffs are not rendered by default.

.github/workflows/11-test-acceptance.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ jobs:
3131
path: pipe-fittings
3232
ref: develop
3333

34+
- name: Run CLI Unit Tests
35+
run: |
36+
cd powerpipe
37+
go clean -testcache
38+
go test -timeout 30s ./... -test.v
39+
3440
- name: Set up Docker
3541
uses: docker/setup-buildx-action@v3
3642

@@ -102,6 +108,7 @@ jobs:
102108
- "snapshot"
103109
- "dashboard_parsing_validation"
104110
- "database_precedence"
111+
- "tag_filtering"
105112
- "config_precedence"
106113
runs-on: ${{ matrix.platform }}
107114
steps:

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## v1.4.2 [TBD]
2+
_Bug Fixes_
3+
- Fixed: cell controls not appearing after scroll. ([#956](https://github.com/turbot/powerpipe/issues/956))
4+
15
## v1.4.1 [2025-10-07]
26
_Bug Fixes_
37
- Build: Restored CentOS/RHEL 9 compatibility by pinning the build image to an older libstdc++/GCC baseline. Previous build linked against newer GLIBCXX symbols, causing Powerpipe to fail on CentOS/RHEL 9.

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ require (
104104
github.com/cloudwego/base64x v0.1.4 // indirect
105105
github.com/cloudwego/iasm v0.2.0 // indirect
106106
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
107-
github.com/containerd/containerd v1.7.27 // indirect
107+
github.com/containerd/containerd v1.7.29 // indirect
108108
github.com/containerd/errdefs v0.3.0 // indirect
109109
github.com/containerd/log v0.1.0 // indirect
110110
github.com/containerd/platforms v0.2.1 // indirect
@@ -260,7 +260,7 @@ require (
260260
golang.org/x/oauth2 v0.30.0 // indirect
261261
golang.org/x/sys v0.35.0 // indirect
262262
golang.org/x/term v0.34.0 // indirect
263-
golang.org/x/time v0.11.0 // indirect
263+
golang.org/x/time v0.12.0 // indirect
264264
golang.org/x/tools v0.36.0 // indirect
265265
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
266266
google.golang.org/api v0.230.0 // indirect

go.sum

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -771,8 +771,8 @@ github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv
771771
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
772772
github.com/containerd/cgroups v1.1.0 h1:v8rEWFl6EoqHB+swVNjVoCJE8o3jX7e8nqBGPLaDFBM=
773773
github.com/containerd/cgroups v1.1.0/go.mod h1:6ppBcbh/NOOUU+dMKrykgaBnK9lCIBxHqJDGwsa1mIw=
774-
github.com/containerd/containerd v1.7.27 h1:yFyEyojddO3MIGVER2xJLWoCIn+Up4GaHFquP7hsFII=
775-
github.com/containerd/containerd v1.7.27/go.mod h1:xZmPnl75Vc+BLGt4MIfu6bp+fy03gdHAn9bz+FreFR0=
774+
github.com/containerd/containerd v1.7.29 h1:90fWABQsaN9mJhGkoVnuzEY+o1XDPbg9BTC9QTAHnuE=
775+
github.com/containerd/containerd v1.7.29/go.mod h1:azUkWcOvHrWvaiUjSQH0fjzuHIwSPg1WL5PshGP4Szs=
776776
github.com/containerd/continuity v0.4.4 h1:/fNVfTJ7wIl/YPMHjf+5H32uFhl63JucB34PlCpMKII=
777777
github.com/containerd/continuity v0.4.4/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE=
778778
github.com/containerd/errdefs v0.3.0 h1:FSZgGOeK4yuT/+DnF07/Olde/q4KBoMsaamhXxIMDp4=
@@ -1819,8 +1819,8 @@ golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxb
18191819
golang.org/x/time v0.0.0-20220922220347-f3bd1da661af/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
18201820
golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
18211821
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
1822-
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
1823-
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
1822+
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
1823+
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
18241824
golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
18251825
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
18261826
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

internal/controlexecute/execution_tree.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,9 @@ func (e *ExecutionTree) waitForActiveRunsToComplete(ctx context.Context, paralle
177177

178178
func (e *ExecutionTree) populateControlFilterMap(controlFilter pworkspace.ResourceFilter) error {
179179
// if we derived or were passed a where clause, run the filter
180-
if controlFilter.Empty() {
180+
// Note: WherePredicate can be set even when Tags/Where are empty (new tag filtering logic),
181+
// so we must honor a custom predicate even if the filter appears "empty".
182+
if controlFilter.Empty() && controlFilter.WherePredicate == nil {
181183
return nil
182184
}
183185

internal/controlinit/init_data.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ import (
1010
"github.com/turbot/pipe-fittings/v2/error_helpers"
1111
"github.com/turbot/pipe-fittings/v2/modconfig"
1212
"github.com/turbot/pipe-fittings/v2/statushooks"
13-
"github.com/turbot/pipe-fittings/v2/workspace"
13+
pfworkspace "github.com/turbot/pipe-fittings/v2/workspace"
1414
"github.com/turbot/powerpipe/internal/controldisplay"
1515
"github.com/turbot/powerpipe/internal/initialisation"
1616
"github.com/turbot/powerpipe/internal/resources"
17+
"github.com/turbot/powerpipe/internal/workspace"
1718
)
1819

1920
type CheckTarget interface {
@@ -24,7 +25,7 @@ type CheckTarget interface {
2425
type InitData struct {
2526
initialisation.InitData
2627
OutputFormatter controldisplay.Formatter
27-
ControlFilter workspace.ResourceFilter
28+
ControlFilter pfworkspace.ResourceFilter
2829
}
2930

3031
func (i *InitData) BaseInitData() *initialisation.InitData {
@@ -49,7 +50,7 @@ func NewInitData[T CheckTarget](ctx context.Context, cmd *cobra.Command, args ..
4950

5051
w := i.Workspace
5152
if !w.ModfileExists() {
52-
i.Result.Error = workspace.ErrorNoModDefinition
53+
i.Result.Error = pfworkspace.ErrorNoModDefinition
5354
}
5455

5556
if viper.GetString(constants.ArgOutput) == constants.OutputFormatNone {
@@ -105,13 +106,14 @@ func NewInitData[T CheckTarget](ctx context.Context, cmd *cobra.Command, args ..
105106

106107
func (i *InitData) setControlFilter() {
107108
if viper.IsSet(constants.ArgTag) {
108-
// if '--tag' args were used, derive the whereClause from them
109+
// if '--tag' args were used, build a predicate that supports both equality and inequality
110+
// (key!=value includes resources missing the tag). This replaces the older tag-only map filter.
109111
tags := viper.GetStringSlice(constants.ArgTag)
110-
i.ControlFilter = workspace.ResourceFilterFromTags(tags)
112+
i.ControlFilter = workspace.ResourceFilterFromTagArgs(tags)
111113
} else if viper.IsSet(constants.ArgWhere) {
112114
// if a 'where' arg was used, execute this sql to get a list of control names
113115
// use this list to build a name map used to determine whether to run a particular control
114-
i.ControlFilter = workspace.ResourceFilter{
116+
i.ControlFilter = pfworkspace.ResourceFilter{
115117
Where: viper.GetString(constants.ArgWhere),
116118
}
117119
}

internal/workspace/tag_filter.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package workspace
2+
3+
import (
4+
"strings"
5+
6+
"github.com/turbot/pipe-fittings/v2/modconfig"
7+
"github.com/turbot/pipe-fittings/v2/workspace"
8+
)
9+
10+
// tagFilter captures allowed and disallowed values for a tag key.
11+
type tagFilter struct {
12+
equals []string
13+
notEquals []string
14+
}
15+
16+
// ResourceFilterFromTagArgs builds a workspace.ResourceFilter from CLI --tag args,
17+
// supporting both equality (key=value) and inequality (key!=value) semantics.
18+
// For inequality, resources without the tag must be included (only resources
19+
// with the tag set to the disallowed value are excluded). This aligns CLI
20+
// behavior with user expectations (e.g., env!=staging should include items
21+
// with no env tag).
22+
func ResourceFilterFromTagArgs(tags []string) workspace.ResourceFilter {
23+
tagFilters := map[string]*tagFilter{}
24+
25+
for _, arg := range tags {
26+
var key, value string
27+
var isNotEquals bool
28+
29+
if parts := strings.SplitN(arg, "!=", 2); len(parts) == 2 {
30+
key, value = parts[0], parts[1]
31+
isNotEquals = true
32+
} else if parts := strings.SplitN(arg, "=", 2); len(parts) == 2 {
33+
key, value = parts[0], parts[1]
34+
} else {
35+
// malformed tag; skip and let validation elsewhere handle errors (consistent with prior behavior)
36+
continue
37+
}
38+
39+
tf := tagFilters[key]
40+
if tf == nil {
41+
tf = &tagFilter{}
42+
tagFilters[key] = tf
43+
}
44+
if isNotEquals {
45+
tf.notEquals = append(tf.notEquals, value)
46+
} else {
47+
tf.equals = append(tf.equals, value)
48+
}
49+
}
50+
51+
return workspace.ResourceFilter{
52+
WherePredicate: func(item modconfig.HclResource) bool {
53+
itemTags := item.GetTags()
54+
55+
for key, filter := range tagFilters {
56+
tagValue, present := itemTags[key]
57+
58+
// Equality rules: tag must be present AND match one of the allowed values.
59+
if len(filter.equals) > 0 {
60+
if !present || !contains(filter.equals, tagValue) {
61+
return false
62+
}
63+
}
64+
65+
// Inequality rules: exclude only if tag present AND value is disallowed.
66+
if len(filter.notEquals) > 0 && present && contains(filter.notEquals, tagValue) {
67+
return false
68+
}
69+
}
70+
71+
return true
72+
},
73+
}
74+
}
75+
76+
func contains(values []string, candidate string) bool {
77+
for _, v := range values {
78+
if v == candidate {
79+
return true
80+
}
81+
}
82+
return false
83+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package workspace
2+
3+
import (
4+
"testing"
5+
6+
"github.com/hashicorp/hcl/v2"
7+
"github.com/turbot/pipe-fittings/v2/modconfig"
8+
pfworkspace "github.com/turbot/pipe-fittings/v2/workspace"
9+
"github.com/turbot/powerpipe/internal/resources"
10+
)
11+
12+
func TestResourceFilterFromTagArgs(t *testing.T) {
13+
mod := modconfig.NewMod("tag_filter_mod", ".", hcl.Range{})
14+
controls := map[string]*resources.Control{
15+
"deprecated_true": makeTaggedControl(t, mod, "deprecated_true", map[string]string{"deprecated": "true"}),
16+
"deprecated_false": makeTaggedControl(t, mod, "deprecated_false", map[string]string{"deprecated": "false"}),
17+
"no_tag": makeTaggedControl(t, mod, "no_tag", map[string]string{}),
18+
"other_tag_only": makeTaggedControl(t, mod, "other_tag_only", map[string]string{"env": "qa"}),
19+
}
20+
modResources := resources.NewModResources(mod).(*resources.PowerpipeModResources)
21+
for _, c := range controls {
22+
_ = modResources.AddResource(c)
23+
}
24+
mod.Resources = modResources
25+
26+
w := &PowerpipeWorkspace{
27+
Workspace: pfworkspace.Workspace{
28+
Mod: mod,
29+
},
30+
}
31+
32+
tests := []struct {
33+
name string
34+
tagArgs []string
35+
wantNames map[string]struct{}
36+
}{
37+
{
38+
name: "equals match",
39+
tagArgs: []string{"deprecated=true"},
40+
wantNames: map[string]struct{}{
41+
"tag_filter_mod.control.deprecated_true": {},
42+
},
43+
},
44+
{
45+
name: "not equals includes missing",
46+
tagArgs: []string{"deprecated!=true"},
47+
wantNames: map[string]struct{}{
48+
"tag_filter_mod.control.deprecated_false": {},
49+
"tag_filter_mod.control.no_tag": {},
50+
"tag_filter_mod.control.other_tag_only": {},
51+
},
52+
},
53+
{
54+
name: "not equals multiple values includes missing",
55+
tagArgs: []string{"deprecated!=true", "deprecated!=false"},
56+
wantNames: map[string]struct{}{
57+
"tag_filter_mod.control.no_tag": {},
58+
"tag_filter_mod.control.other_tag_only": {},
59+
},
60+
},
61+
{
62+
name: "mix equals and not equals honors both",
63+
tagArgs: []string{"deprecated!=true", "env=qa"},
64+
wantNames: map[string]struct{}{
65+
"tag_filter_mod.control.other_tag_only": {},
66+
},
67+
},
68+
}
69+
70+
for _, tt := range tests {
71+
t.Run(tt.name, func(t *testing.T) {
72+
filter := ResourceFilterFromTagArgs(tt.tagArgs)
73+
got, err := pfworkspace.FilterWorkspaceResourcesOfType[*resources.Control](&w.Workspace, filter)
74+
if err != nil {
75+
t.Fatalf("FilterWorkspaceResourcesOfType error: %v", err)
76+
}
77+
78+
if len(got) != len(tt.wantNames) {
79+
t.Fatalf("expected %d results, got %d", len(tt.wantNames), len(got))
80+
}
81+
for name := range tt.wantNames {
82+
if _, ok := got[name]; !ok {
83+
t.Fatalf("expected %s but not present", name)
84+
}
85+
}
86+
})
87+
}
88+
}
89+
90+
func makeTaggedControl(t *testing.T, mod *modconfig.Mod, name string, tags map[string]string) *resources.Control {
91+
t.Helper()
92+
93+
title := "title"
94+
description := "desc"
95+
sql := "select 1"
96+
control := resources.NewControl(&hcl.Block{Type: "control"}, mod, name).(*resources.Control)
97+
control.Title = &title
98+
control.Description = &description
99+
control.SQL = &sql
100+
control.Tags = tags
101+
102+
return control
103+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
mod "tag_filtering_mod" {
2+
title = "Tag filtering acceptance mod"
3+
description = "Exercises control/benchmark tag filters for deprecated and env tags."
4+
}

0 commit comments

Comments
 (0)