diff --git a/pkg/cookiebanner/reset_trackers.go b/pkg/cookiebanner/reset_trackers.go index 020e4eccd..e63b65653 100644 --- a/pkg/cookiebanner/reset_trackers.go +++ b/pkg/cookiebanner/reset_trackers.go @@ -33,6 +33,21 @@ type ResetTrackersResult struct { AnalysisRequested bool } +// ResetProgressFunc receives human-readable progress messages emitted as +// a banner reset advances through its phases. It is optional: pass nil to +// run silently. Messages are emitted inside the reset transaction, so a +// later failure that rolls the transaction back may leave already-printed +// progress describing work that did not commit. +type ResetProgressFunc func(message string) + +func (p ResetProgressFunc) report(format string, args ...any) { + if p == nil { + return + } + + p(fmt.Sprintf(format, args...)) +} + // ResetBannerTrackers re-arms the tracker pipeline for a banner's // uncategorised, non-excluded patterns. It is an operator action // (proboctl), tenant-scoped via the provided Scoper. @@ -50,39 +65,57 @@ type ResetTrackersResult struct { // every detection it covers becomes (or rejoins) an exact pattern keyed // by its identifier - and the now-empty glob is deleted. User-categorised // and excluded patterns are never touched. +// +// When keyword is non-nil and non-empty, the reset is scoped to patterns +// whose pattern or display name contains it (case-insensitive): only +// matching globs are decomposed and only matching patterns are re-armed +// for mapping. The banner-wide pattern-analysis re-arm is unaffected. +// +// progress receives human-readable phase updates as the reset runs; pass +// nil to run silently. func ResetBannerTrackers( ctx context.Context, pgClient *pg.Client, scope coredata.Scoper, bannerID gid.GID, mappingOnly bool, + keyword *string, + progress ResetProgressFunc, ) (ResetTrackersResult, error) { var result ResetTrackersResult err := pgClient.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { + progress.report("Loading uncategorised category for banner %s...", bannerID) + var uncategorised coredata.CookieCategory if err := uncategorised.LoadUncategorisedByCookieBannerID(ctx, tx, scope, bannerID); err != nil { return fmt.Errorf("cannot load uncategorised category: %w", err) } if !mappingOnly { - if err := decomposeGlobs(ctx, tx, scope, bannerID, uncategorised.ID, &result); err != nil { + if err := decomposeGlobs(ctx, tx, scope, bannerID, uncategorised.ID, keyword, &result, progress); err != nil { return err } } + progress.report("Resetting links and re-arming mapping on matching patterns...") + var patterns coredata.TrackerPatterns - reset, err := patterns.ResetAndRequestMappingByCookieCategoryID(ctx, tx, scope, uncategorised.ID) + reset, err := patterns.ResetAndRequestMappingByCookieCategoryID(ctx, tx, scope, uncategorised.ID, keyword) if err != nil { return fmt.Errorf("cannot reset and request mapping: %w", err) } result.PatternsReset = reset + progress.report("Reset %d pattern(s) and re-armed mapping.", reset) + if !mappingOnly { + progress.report("Re-arming pattern analysis on banner %s...", bannerID) + banner := coredata.CookieBanner{ID: bannerID} if err := banner.SetPatternAnalysisRequested(ctx, tx); err != nil { return fmt.Errorf("cannot request pattern analysis: %w", err) @@ -110,7 +143,9 @@ func decomposeGlobs( scope coredata.Scoper, bannerID gid.GID, uncategorisedID gid.GID, + keyword *string, result *ResetTrackersResult, + progress ResetProgressFunc, ) error { globMatchType := coredata.TrackerPatternMatchTypeGlob notExcluded := false @@ -121,18 +156,22 @@ func decomposeGlobs( tx, scope, bannerID, - coredata.NewTrackerPatternFilter(&globMatchType, &uncategorisedID, ¬Excluded), + coredata.NewTrackerPatternFilter(&globMatchType, &uncategorisedID, ¬Excluded).WithPatternKeyword(keyword), nil, ); err != nil { return fmt.Errorf("cannot load glob patterns: %w", err) } - for _, glob := range globs { + progress.report("Decomposing %d glob pattern(s) into exact patterns...", len(globs)) + + for i, glob := range globs { var detections coredata.DetectedTrackers if err := detections.LoadAllByTrackerPatternID(ctx, tx, scope, glob.ID); err != nil { return fmt.Errorf("cannot load detections for glob %q: %w", glob.Pattern, err) } + progress.report("[%d/%d] Decomposing glob %q (%d detection(s))...", i+1, len(globs), glob.Pattern, len(detections)) + for _, detection := range detections { exactID, created, err := ensureExactPattern(ctx, tx, scope, glob, uncategorisedID, detection) if err != nil { diff --git a/pkg/cookiebanner/reset_trackers_test.go b/pkg/cookiebanner/reset_trackers_test.go index 316e00f13..3339883bd 100644 --- a/pkg/cookiebanner/reset_trackers_test.go +++ b/pkg/cookiebanner/reset_trackers_test.go @@ -85,7 +85,7 @@ func TestResetBannerTrackers_FullRebuild(t *testing.T) { return nil })) - result, err := ResetBannerTrackers(ctx, client, fx.scope, fx.banner.ID, false) + result, err := ResetBannerTrackers(ctx, client, fx.scope, fx.banner.ID, false, nil, nil) require.NoError(t, err) require.Equal(t, 1, result.GlobsDecomposed) diff --git a/pkg/coredata/tracker_pattern.go b/pkg/coredata/tracker_pattern.go index 1bf103468..04454e8cd 100644 --- a/pkg/coredata/tracker_pattern.go +++ b/pkg/coredata/tracker_pattern.go @@ -1390,14 +1390,19 @@ WHERE // iterating on the mapping agent. Excluded patterns are left untouched - // exclusion is a deliberate suppression. The cookie_category_id key // scopes the reset to the uncategorised category the caller resolves; -// the Scoper keeps it tenant-isolated. Returns the number of patterns -// reset. +// the Scoper keeps it tenant-isolated. When keyword is non-nil and +// non-empty, the reset is further restricted to patterns whose pattern or +// display name contains it (case-insensitive). Returns the number of +// patterns reset. func (tps *TrackerPatterns) ResetAndRequestMappingByCookieCategoryID( ctx context.Context, tx pg.Tx, scope Scoper, cookieCategoryID gid.GID, + keyword *string, ) (int64, error) { + filter := NewTrackerPatternFilter(nil, nil, nil).WithPatternKeyword(keyword) + q := ` UPDATE tracker_patterns SET @@ -1410,12 +1415,14 @@ WHERE %s AND cookie_category_id = @cookie_category_id AND excluded = false + AND %s ` - q = fmt.Sprintf(q, scope.SQLFragment()) + q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment()) args := pgx.StrictNamedArgs{"cookie_category_id": cookieCategoryID} maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, filter.SQLArguments()) result, err := tx.Exec(ctx, q, args) if err != nil { diff --git a/pkg/coredata/tracker_pattern_filter.go b/pkg/coredata/tracker_pattern_filter.go index d816503c3..02c54f4fa 100644 --- a/pkg/coredata/tracker_pattern_filter.go +++ b/pkg/coredata/tracker_pattern_filter.go @@ -24,6 +24,7 @@ type TrackerPatternFilter struct { cookieCategoryID *gid.GID excluded *bool query *string + patternKeyword *string source *CookieSource trackerType *TrackerType thirdPartyID *gid.GID @@ -47,6 +48,16 @@ func (f *TrackerPatternFilter) WithQuery(query *string) *TrackerPatternFilter { return f } +// WithPatternKeyword restricts the result to patterns whose pattern or +// display name contains keyword (case-insensitive). It differs from +// WithQuery, which matches display name or description: this targets the +// raw pattern so operators can select by the matched domain or cookie +// name. A nil or empty keyword disables the filter. +func (f *TrackerPatternFilter) WithPatternKeyword(keyword *string) *TrackerPatternFilter { + f.patternKeyword = keyword + return f +} + func (f *TrackerPatternFilter) WithSource(source *CookieSource) *TrackerPatternFilter { f.source = source return f @@ -102,6 +113,13 @@ func (f *TrackerPatternFilter) SQLFragment() string { ELSE TRUE END AND + CASE + WHEN @filter_pattern_keyword::text IS NOT NULL AND @filter_pattern_keyword::text != '' THEN + (pattern ILIKE '%' || @filter_pattern_keyword || '%' + OR display_name ILIKE '%' || @filter_pattern_keyword || '%') + ELSE TRUE + END + AND CASE WHEN @has_source_filter::boolean = false THEN TRUE WHEN @has_source_filter::boolean = true THEN @@ -148,6 +166,7 @@ func (f *TrackerPatternFilter) SQLArguments() pgx.StrictNamedArgs { "has_excluded_filter": false, "filter_excluded": nil, "filter_query": nil, + "filter_pattern_keyword": nil, "has_source_filter": false, "filter_source": nil, "has_tracker_type_filter": false, @@ -177,6 +196,10 @@ func (f *TrackerPatternFilter) SQLArguments() pgx.StrictNamedArgs { args["filter_query"] = *f.query } + if f.patternKeyword != nil { + args["filter_pattern_keyword"] = *f.patternKeyword + } + if f.source != nil { args["has_source_filter"] = true args["filter_source"] = string(*f.source) diff --git a/pkg/proboctl/cookiebanner/reset_trackers.go b/pkg/proboctl/cookiebanner/reset_trackers.go index 6c3e0dab4..b51d59dc2 100644 --- a/pkg/proboctl/cookiebanner/reset_trackers.go +++ b/pkg/proboctl/cookiebanner/reset_trackers.go @@ -27,6 +27,7 @@ import ( func newCmdResetTrackers(f *cmdutil.Factory) *cobra.Command { var ( flagMappingOnly bool + flagKeyword string flagDryRun bool flagYes bool ) @@ -38,11 +39,14 @@ func newCmdResetTrackers(f *cmdutil.Factory) *cobra.Command { "non-excluded patterns it clears catalog/vendor links, rebuilds the raw exact " + "patterns from detected_trackers (decomposing derived globs), and re-arms the " + "pattern-analysis and mapping workers. User-categorised and excluded patterns are " + - "preserved. With --mapping-only it skips the rebuild and only re-arms mapping.", + "preserved. With --mapping-only it skips the rebuild and only re-arms mapping. " + + "With --keyword the rebuild and mapping reset are scoped to patterns whose pattern " + + "or display name contains the substring.", Args: cobra.ExactArgs(1), } cmd.Flags().BoolVar(&flagMappingOnly, "mapping-only", false, "Only re-arm mapping (skip the detection rebuild and analysis)") + cmd.Flags().StringVar(&flagKeyword, "keyword", "", "Only reset patterns whose pattern or display name contains this substring") cmd.Flags().BoolVar(&flagDryRun, "dry-run", false, "Print the target banner without writing") cmd.Flags().BoolVar(&flagYes, "yes", false, "Skip confirmation") @@ -68,6 +72,12 @@ func newCmdResetTrackers(f *cmdutil.Factory) *cobra.Command { mode = "mapping-only reset" } + var keyword *string + if flagKeyword != "" { + keyword = &flagKeyword + mode = fmt.Sprintf("%s scoped to keyword %q", mode, flagKeyword) + } + if flagDryRun { _, _ = fmt.Fprintf(out, "Would run %s on banner %s.\n", mode, bannerID.String()) return nil @@ -77,7 +87,13 @@ func newCmdResetTrackers(f *cmdutil.Factory) *cobra.Command { return fmt.Errorf("about to run %s on banner %s; pass --yes to proceed or --dry-run to preview", mode, bannerID.String()) } - result, err := cookiebanner.ResetBannerTrackers(ctx, pgClient, scope, bannerID, flagMappingOnly) + _, _ = fmt.Fprintf(out, "Running %s on banner %s.\n", mode, bannerID.String()) + + progress := func(message string) { + _, _ = fmt.Fprintf(out, " %s\n", message) + } + + result, err := cookiebanner.ResetBannerTrackers(ctx, pgClient, scope, bannerID, flagMappingOnly, keyword, progress) if err != nil { return fmt.Errorf("cannot reset banner %s: %w", bannerID, err) }