Scope reset-trackers by keyword and report progress
The reset-trackers operator command reset every uncategorised, non-excluded pattern of a banner and printed only a single summary line once the transaction committed, giving no feedback during long rebuilds. Add a --keyword flag that scopes both the glob decomposition and the mapping reset to patterns whose pattern or display name contains the substring. The match lives in a new TrackerPatternFilter.WithPatternKeyword field so it runs in SQL and is shared by the glob load and the ResetAndRequestMappingByCookieCategoryID update, keeping the two in lockstep. The banner-wide pattern-analysis re-arm is left unscoped. Thread an optional progress callback through ResetBannerTrackers so the command streams per-phase updates (category load, per-glob decomposition, mapping reset, analysis re-arm) as the work runs. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
@@ -33,6 +33,21 @@ type ResetTrackersResult struct {
|
|||||||
AnalysisRequested bool
|
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
|
// ResetBannerTrackers re-arms the tracker pipeline for a banner's
|
||||||
// uncategorised, non-excluded patterns. It is an operator action
|
// uncategorised, non-excluded patterns. It is an operator action
|
||||||
// (proboctl), tenant-scoped via the provided Scoper.
|
// (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
|
// every detection it covers becomes (or rejoins) an exact pattern keyed
|
||||||
// by its identifier - and the now-empty glob is deleted. User-categorised
|
// by its identifier - and the now-empty glob is deleted. User-categorised
|
||||||
// and excluded patterns are never touched.
|
// 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(
|
func ResetBannerTrackers(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
pgClient *pg.Client,
|
pgClient *pg.Client,
|
||||||
scope coredata.Scoper,
|
scope coredata.Scoper,
|
||||||
bannerID gid.GID,
|
bannerID gid.GID,
|
||||||
mappingOnly bool,
|
mappingOnly bool,
|
||||||
|
keyword *string,
|
||||||
|
progress ResetProgressFunc,
|
||||||
) (ResetTrackersResult, error) {
|
) (ResetTrackersResult, error) {
|
||||||
var result ResetTrackersResult
|
var result ResetTrackersResult
|
||||||
|
|
||||||
err := pgClient.WithTx(
|
err := pgClient.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, tx pg.Tx) error {
|
func(ctx context.Context, tx pg.Tx) error {
|
||||||
|
progress.report("Loading uncategorised category for banner %s...", bannerID)
|
||||||
|
|
||||||
var uncategorised coredata.CookieCategory
|
var uncategorised coredata.CookieCategory
|
||||||
if err := uncategorised.LoadUncategorisedByCookieBannerID(ctx, tx, scope, bannerID); err != nil {
|
if err := uncategorised.LoadUncategorisedByCookieBannerID(ctx, tx, scope, bannerID); err != nil {
|
||||||
return fmt.Errorf("cannot load uncategorised category: %w", err)
|
return fmt.Errorf("cannot load uncategorised category: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !mappingOnly {
|
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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
progress.report("Resetting links and re-arming mapping on matching patterns...")
|
||||||
|
|
||||||
var patterns coredata.TrackerPatterns
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot reset and request mapping: %w", err)
|
return fmt.Errorf("cannot reset and request mapping: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.PatternsReset = reset
|
result.PatternsReset = reset
|
||||||
|
|
||||||
|
progress.report("Reset %d pattern(s) and re-armed mapping.", reset)
|
||||||
|
|
||||||
if !mappingOnly {
|
if !mappingOnly {
|
||||||
|
progress.report("Re-arming pattern analysis on banner %s...", bannerID)
|
||||||
|
|
||||||
banner := coredata.CookieBanner{ID: bannerID}
|
banner := coredata.CookieBanner{ID: bannerID}
|
||||||
if err := banner.SetPatternAnalysisRequested(ctx, tx); err != nil {
|
if err := banner.SetPatternAnalysisRequested(ctx, tx); err != nil {
|
||||||
return fmt.Errorf("cannot request pattern analysis: %w", err)
|
return fmt.Errorf("cannot request pattern analysis: %w", err)
|
||||||
@@ -110,7 +143,9 @@ func decomposeGlobs(
|
|||||||
scope coredata.Scoper,
|
scope coredata.Scoper,
|
||||||
bannerID gid.GID,
|
bannerID gid.GID,
|
||||||
uncategorisedID gid.GID,
|
uncategorisedID gid.GID,
|
||||||
|
keyword *string,
|
||||||
result *ResetTrackersResult,
|
result *ResetTrackersResult,
|
||||||
|
progress ResetProgressFunc,
|
||||||
) error {
|
) error {
|
||||||
globMatchType := coredata.TrackerPatternMatchTypeGlob
|
globMatchType := coredata.TrackerPatternMatchTypeGlob
|
||||||
notExcluded := false
|
notExcluded := false
|
||||||
@@ -121,18 +156,22 @@ func decomposeGlobs(
|
|||||||
tx,
|
tx,
|
||||||
scope,
|
scope,
|
||||||
bannerID,
|
bannerID,
|
||||||
coredata.NewTrackerPatternFilter(&globMatchType, &uncategorisedID, ¬Excluded),
|
coredata.NewTrackerPatternFilter(&globMatchType, &uncategorisedID, ¬Excluded).WithPatternKeyword(keyword),
|
||||||
nil,
|
nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("cannot load glob patterns: %w", err)
|
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
|
var detections coredata.DetectedTrackers
|
||||||
if err := detections.LoadAllByTrackerPatternID(ctx, tx, scope, glob.ID); err != nil {
|
if err := detections.LoadAllByTrackerPatternID(ctx, tx, scope, glob.ID); err != nil {
|
||||||
return fmt.Errorf("cannot load detections for glob %q: %w", glob.Pattern, err)
|
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 {
|
for _, detection := range detections {
|
||||||
exactID, created, err := ensureExactPattern(ctx, tx, scope, glob, uncategorisedID, detection)
|
exactID, created, err := ensureExactPattern(ctx, tx, scope, glob, uncategorisedID, detection)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ func TestResetBannerTrackers_FullRebuild(t *testing.T) {
|
|||||||
return nil
|
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.NoError(t, err)
|
||||||
|
|
||||||
require.Equal(t, 1, result.GlobsDecomposed)
|
require.Equal(t, 1, result.GlobsDecomposed)
|
||||||
|
|||||||
@@ -1390,14 +1390,19 @@ WHERE
|
|||||||
// iterating on the mapping agent. Excluded patterns are left untouched -
|
// iterating on the mapping agent. Excluded patterns are left untouched -
|
||||||
// exclusion is a deliberate suppression. The cookie_category_id key
|
// exclusion is a deliberate suppression. The cookie_category_id key
|
||||||
// scopes the reset to the uncategorised category the caller resolves;
|
// scopes the reset to the uncategorised category the caller resolves;
|
||||||
// the Scoper keeps it tenant-isolated. Returns the number of patterns
|
// the Scoper keeps it tenant-isolated. When keyword is non-nil and
|
||||||
// reset.
|
// 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(
|
func (tps *TrackerPatterns) ResetAndRequestMappingByCookieCategoryID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
tx pg.Tx,
|
tx pg.Tx,
|
||||||
scope Scoper,
|
scope Scoper,
|
||||||
cookieCategoryID gid.GID,
|
cookieCategoryID gid.GID,
|
||||||
|
keyword *string,
|
||||||
) (int64, error) {
|
) (int64, error) {
|
||||||
|
filter := NewTrackerPatternFilter(nil, nil, nil).WithPatternKeyword(keyword)
|
||||||
|
|
||||||
q := `
|
q := `
|
||||||
UPDATE tracker_patterns
|
UPDATE tracker_patterns
|
||||||
SET
|
SET
|
||||||
@@ -1410,12 +1415,14 @@ WHERE
|
|||||||
%s
|
%s
|
||||||
AND cookie_category_id = @cookie_category_id
|
AND cookie_category_id = @cookie_category_id
|
||||||
AND excluded = false
|
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}
|
args := pgx.StrictNamedArgs{"cookie_category_id": cookieCategoryID}
|
||||||
maps.Copy(args, scope.SQLArguments())
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
maps.Copy(args, filter.SQLArguments())
|
||||||
|
|
||||||
result, err := tx.Exec(ctx, q, args)
|
result, err := tx.Exec(ctx, q, args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type TrackerPatternFilter struct {
|
|||||||
cookieCategoryID *gid.GID
|
cookieCategoryID *gid.GID
|
||||||
excluded *bool
|
excluded *bool
|
||||||
query *string
|
query *string
|
||||||
|
patternKeyword *string
|
||||||
source *CookieSource
|
source *CookieSource
|
||||||
trackerType *TrackerType
|
trackerType *TrackerType
|
||||||
thirdPartyID *gid.GID
|
thirdPartyID *gid.GID
|
||||||
@@ -47,6 +48,16 @@ func (f *TrackerPatternFilter) WithQuery(query *string) *TrackerPatternFilter {
|
|||||||
return f
|
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 {
|
func (f *TrackerPatternFilter) WithSource(source *CookieSource) *TrackerPatternFilter {
|
||||||
f.source = source
|
f.source = source
|
||||||
return f
|
return f
|
||||||
@@ -102,6 +113,13 @@ func (f *TrackerPatternFilter) SQLFragment() string {
|
|||||||
ELSE TRUE
|
ELSE TRUE
|
||||||
END
|
END
|
||||||
AND
|
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
|
CASE
|
||||||
WHEN @has_source_filter::boolean = false THEN TRUE
|
WHEN @has_source_filter::boolean = false THEN TRUE
|
||||||
WHEN @has_source_filter::boolean = true THEN
|
WHEN @has_source_filter::boolean = true THEN
|
||||||
@@ -148,6 +166,7 @@ func (f *TrackerPatternFilter) SQLArguments() pgx.StrictNamedArgs {
|
|||||||
"has_excluded_filter": false,
|
"has_excluded_filter": false,
|
||||||
"filter_excluded": nil,
|
"filter_excluded": nil,
|
||||||
"filter_query": nil,
|
"filter_query": nil,
|
||||||
|
"filter_pattern_keyword": nil,
|
||||||
"has_source_filter": false,
|
"has_source_filter": false,
|
||||||
"filter_source": nil,
|
"filter_source": nil,
|
||||||
"has_tracker_type_filter": false,
|
"has_tracker_type_filter": false,
|
||||||
@@ -177,6 +196,10 @@ func (f *TrackerPatternFilter) SQLArguments() pgx.StrictNamedArgs {
|
|||||||
args["filter_query"] = *f.query
|
args["filter_query"] = *f.query
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if f.patternKeyword != nil {
|
||||||
|
args["filter_pattern_keyword"] = *f.patternKeyword
|
||||||
|
}
|
||||||
|
|
||||||
if f.source != nil {
|
if f.source != nil {
|
||||||
args["has_source_filter"] = true
|
args["has_source_filter"] = true
|
||||||
args["filter_source"] = string(*f.source)
|
args["filter_source"] = string(*f.source)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import (
|
|||||||
func newCmdResetTrackers(f *cmdutil.Factory) *cobra.Command {
|
func newCmdResetTrackers(f *cmdutil.Factory) *cobra.Command {
|
||||||
var (
|
var (
|
||||||
flagMappingOnly bool
|
flagMappingOnly bool
|
||||||
|
flagKeyword string
|
||||||
flagDryRun bool
|
flagDryRun bool
|
||||||
flagYes 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 " +
|
"non-excluded patterns it clears catalog/vendor links, rebuilds the raw exact " +
|
||||||
"patterns from detected_trackers (decomposing derived globs), and re-arms the " +
|
"patterns from detected_trackers (decomposing derived globs), and re-arms the " +
|
||||||
"pattern-analysis and mapping workers. User-categorised and excluded patterns are " +
|
"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),
|
Args: cobra.ExactArgs(1),
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolVar(&flagMappingOnly, "mapping-only", false, "Only re-arm mapping (skip the detection rebuild and analysis)")
|
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(&flagDryRun, "dry-run", false, "Print the target banner without writing")
|
||||||
cmd.Flags().BoolVar(&flagYes, "yes", false, "Skip confirmation")
|
cmd.Flags().BoolVar(&flagYes, "yes", false, "Skip confirmation")
|
||||||
|
|
||||||
@@ -68,6 +72,12 @@ func newCmdResetTrackers(f *cmdutil.Factory) *cobra.Command {
|
|||||||
mode = "mapping-only reset"
|
mode = "mapping-only reset"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var keyword *string
|
||||||
|
if flagKeyword != "" {
|
||||||
|
keyword = &flagKeyword
|
||||||
|
mode = fmt.Sprintf("%s scoped to keyword %q", mode, flagKeyword)
|
||||||
|
}
|
||||||
|
|
||||||
if flagDryRun {
|
if flagDryRun {
|
||||||
_, _ = fmt.Fprintf(out, "Would run %s on banner %s.\n", mode, bannerID.String())
|
_, _ = fmt.Fprintf(out, "Would run %s on banner %s.\n", mode, bannerID.String())
|
||||||
return nil
|
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())
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot reset banner %s: %w", bannerID, err)
|
return fmt.Errorf("cannot reset banner %s: %w", bannerID, err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user