Repository: kyverno/policy-reporter
Pull request #928: feat: Add keepalive functionality for webhook target.
Author: login: nikolay-o
Created: 2025-05-08T15:59:25Z | Merged: 2025-05-14T07:02:58Z

Hi,
Our system relies on reports sent from multiple external clusters (owned by other teams or clients), but we currently have no visibility into their runtime logs or metrics. To ensure reliable report delivery especially when Kyverno is unavailable or failing. Fortunately, it's not difficult to implement a keepalive mechanism for the webhook target which is in this PR.

it helps to:
- Proactively detect connectivity issues. We can monitor presence of keepalive messages.
- Maintain reliability without requiring access to external clusters.

I’d appreciate your feedback on the implementation! Let me know if you’d like adjustments.


Diff:
diff --git a/charts/policy-reporter/templates/_helpers.tpl b/charts/policy-reporter/templates/_helpers.tpl
index 95c87a394..56e801c15 100644
--- a/charts/policy-reporter/templates/_helpers.tpl
+++ b/charts/policy-reporter/templates/_helpers.tpl
@@ -203,6 +203,14 @@ config:
   webhook: {{ .webhook | quote }}
   certificate: {{ .certificate | quote }}
   skipTLS: {{ .skipTLS }}
+  {{- if .keepalive }}
+  keepalive:
+    interval: {{ .keepalive.interval | quote }}
+    {{- if .keepalive.params }}
+    params:
+      {{- toYaml .keepalive.params | nindent 6 }}
+    {{- end }}
+  {{- end }}
   {{- with .headers }}
   headers:
   {{- toYaml . | nindent 4 }}
diff --git a/charts/policy-reporter/templates/policyreporter.kyverno.io_targetconfigs.yaml b/charts/policy-reporter/templates/policyreporter.kyverno.io_targetconfigs.yaml
index f035cdd89..6312cbe32 100644
--- a/charts/policy-reporter/templates/policyreporter.kyverno.io_targetconfigs.yaml
+++ b/charts/policy-reporter/templates/policyreporter.kyverno.io_targetconfigs.yaml
@@ -379,6 +379,16 @@ spec:
                     type: boolean
                   webhook:
                     type: string
+                  keepalive:
+                    type: object
+                    properties:
+                      interval:
+                        type: string
+                        pattern: '^\d+(s|m|h)$'
+                      params:
+                        type: object
+                        additionalProperties:
+                          type: string
                 required:
                 - certificate
                 - headers
diff --git a/charts/policy-reporter/values.yaml b/charts/policy-reporter/values.yaml
index 963c478a9..bfef698e1 100644
--- a/charts/policy-reporter/values.yaml
+++ b/charts/policy-reporter/values.yaml
@@ -536,6 +536,12 @@ target:
     skipExistingOnStartup: true
     # -- Added as additional labels
     customFields: {}
+    # -- Keepalive configuration
+    keepalive:
+      # -- Duration string like "30s" for heartbeat interval, '0' - disabled
+      interval: "0"
+      # -- Additional parameters to include in heartbeat payload
+      params: {}
     # -- Filter Results which should send to this target
     # Wildcars for namespaces and policies are supported, you can either define exclude or include values
     # Filters are available for all targets except the UI
diff --git a/pkg/crd/api/targetconfig/v1alpha1/configs.go b/pkg/crd/api/targetconfig/v1alpha1/configs.go
index 870e669e7..6517e8303 100644
--- a/pkg/crd/api/targetconfig/v1alpha1/configs.go
+++ b/pkg/crd/api/targetconfig/v1alpha1/configs.go
@@ -11,6 +11,13 @@ type AWSConfig struct {
 	Endpoint string `mapstructure:"endpoint" json:"endpoint"`
 }
 
+type KeepaliveConfig struct {
+	// +optional
+	Interval string `mapstructure:"interval" json:"interval"` // Duration string like "5m"
+	// +optional
+	Params map[string]string `mapstructure:"params" json:"params"`
+}
+
 type WebhookOptions struct {
 	Webhook string `mapstructure:"webhook" json:"webhook"`
 	// +optional
@@ -19,6 +26,8 @@ type WebhookOptions struct {
 	Certificate string `mapstructure:"certificate" json:"certificate"`
 	// +optional
 	Headers map[string]string `mapstructure:"headers" json:"headers"`
+	// +optional
+	Keepalive *KeepaliveConfig `mapstructure:"keepalive" json:"keepalive"`
 }
 
 type JiraOptions struct {
diff --git a/pkg/listener/send_result_test.go b/pkg/listener/send_result_test.go
index 8fe4ddb18..a02f3fd79 100644
--- a/pkg/listener/send_result_test.go
+++ b/pkg/listener/send_result_test.go
@@ -49,6 +49,8 @@ func (c *client) Reset(_ context.Context) error {
 	return nil
 }
 
+func (c *client) SendHeartbeat() {}
+
 func (c *client) CleanUp(_ context.Context, _ v1alpha2.ReportInterface) {
 	c.cleanupCalled = true
 }
diff --git a/pkg/target/client.go b/pkg/target/client.go
index 25d849cea..897dff951 100644
--- a/pkg/target/client.go
+++ b/pkg/target/client.go
@@ -43,6 +43,8 @@ type Client interface {
 	CleanUp(context.Context, v1alpha2.ReportInterface)
 	// Reset the current state in the related target
 	Reset(context.Context) error
+	// SendHeartbeat sends a periodic keepalive message
+	SendHeartbeat()
 }
 
 type ResultFilterFactory struct {
@@ -246,6 +248,8 @@ func (c *BaseClient) CleanUp(_ context.Context, _ v1alpha2.ReportInterface) {}
 
 func (c *BaseClient) BatchSend(_ v1alpha2.ReportInterface, _ []v1alpha2.PolicyReportResult) {}
 
+func (c *BaseClient) SendHeartbeat() {} // Default no-op implementation
+
 func NewBaseClient(options ClientOptions) BaseClient {
 	return BaseClient{options.Name, options.SkipExistingOnStartup, options.ResultFilter, options.ReportFilter}
 }
diff --git a/pkg/target/collection.go b/pkg/target/collection.go
index 41c92e669..e644d0429 100644
--- a/pkg/target/collection.go
+++ b/pkg/target/collection.go
@@ -3,6 +3,8 @@ package target
 import (
 	"context"
 	"sync"
+	"sync/atomic"
+	"time"
 
 	"go.uber.org/zap"
 
@@ -49,11 +51,14 @@ type TargetConfig interface {
 }
 
 type Target struct {
-	ID           string
-	Type         TargetType
-	Client       Client
-	ParentConfig TargetConfig
-	Config       TargetConfig
+	ID               string
+	Type             TargetType
+	Client           Client
+	ParentConfig     TargetConfig
+	Config           TargetConfig
+	Keepalive        time.Duration
+	keepaliveRunning uint32 // Simple atomic flag
+	cancelKeepalive  context.CancelFunc
 }
 
 func (t *Target) Secret() string {
@@ -64,6 +69,43 @@ func (t *Target) Secret() string {
 	return t.ParentConfig.Secret()
 }
 
+func (t *Target) StartKeepalive() {
+	// Simple atomic check to prevent duplicates
+	if !atomic.CompareAndSwapUint32(&t.keepaliveRunning, 0, 1) {
+		zap.L().Info("keepalive already started for target: ", zap.String("target", t.Type))
+		return
+	}
+	zap.L().Info("starting keepalive for target: ", zap.String("target", t.Type))
+
+	ctx, cancel := context.WithCancel(context.Background())
+	t.cancelKeepalive = cancel
+
+	go func() {
+		defer atomic.StoreUint32(&t.keepaliveRunning, 0)
+
+		ticker := time.NewTicker(t.Keepalive)
+		defer ticker.Stop()
+
+		for {
+			select {
+			case <-ticker.C:
+				t.Client.SendHeartbeat()
+			case <-ctx.Done():
+				return
+			}
+		}
+	}()
+}
+
+func (t *Target) StopKeepalive() {
+	if t.cancelKeepalive != nil {
+		zap.L().Info("stopping keepalive for target: ", zap.String("target", t.Type))
+		t.cancelKeepalive()
+		t.cancelKeepalive = nil
+		atomic.StoreUint32(&t.keepaliveRunning, 0)
+	}
+}
+
 type Collection struct {
 	mx      *sync.Mutex
 	clients []Client
@@ -78,7 +120,10 @@ func (c *Collection) AddTarget(key string, t *Target) {
 
 func (c *Collection) RemoveTarget(key string) {
 	c.mx.Lock()
-	delete(c.targets, key)
+	if target, exists := c.targets[key]; exists {
+		target.StopKeepalive()
+		delete(c.targets, key)
+	}
 	c.mx.Unlock()
 }
 
@@ -155,6 +200,7 @@ func (c *Collection) Length() int {
 	return len(c.targets)
 }
 
+// NewCollection creates a new target Collection.
 func NewCollection(targets ...*Target) *Collection {
 	collection := &Collection{
 		clients: make([]Client, 0),
diff --git a/pkg/target/factory/factory.go b/pkg/target/factory/factory.go
index a1390561c..a30d4b358 100644
--- a/pkg/target/factory/factory.go
+++ b/pkg/target/factory/factory.go
@@ -98,34 +98,52 @@ func (f *TargetFactory) CreateClients(config *target.Targets) *target.Collection
 	targets = append(targets, createClients("SecurityHub", config.SecurityHub, f.CreateSecurityHubTarget)...)
 	targets = append(targets, createClients("GoogleCloudStorage", config.GCS, f.CreateGCSTarget)...)
 
-	return target.NewCollection(targets...)
+	collection := target.NewCollection(targets...)
+
+	for _, t := range targets {
+		if t != nil && t.Keepalive > 0 {
+			go t.StartKeepalive()
+		}
+	}
+
+	return collection
 }
 
 func (f *TargetFactory) CreateSingleClient(tc *v1alpha1.TargetConfig) (*target.Target, error) {
-	if tc.Spec.S3 != nil {
-		return helper.First(createClients(tc.Name, createConfig(tc, tc.Spec.S3), f.CreateS3Target)), nil
-	} else if tc.Spec.Webhook != nil {
-		return helper.First(createClients(tc.Name, createConfig(tc, tc.Spec.Webhook), f.CreateWebhookTarget)), nil
-	} else if tc.Spec.GCS != nil {
-		return helper.First(createClients(tc.Name, createConfig(tc, tc.Spec.GCS), f.CreateGCSTarget)), nil
-	} else if tc.Spec.ElasticSearch != nil {
-		return helper.First(createClients(tc.Name, createConfig(tc, tc.Spec.ElasticSearch), f.CreateElasticsearchTarget)), nil
-	} else if tc.Spec.Telegram != nil {
-		return helper.First(createClients(tc.Name, createConfig(tc, tc.Spec.Telegram), f.CreateTelegramTarget)), nil
-	} else if tc.Spec.Kinesis != nil {
-		return helper.First(createClients(tc.Name, createConfig(tc, tc.Spec.Kinesis), f.CreateKinesisTarget)), nil
-	} else if tc.Spec.SecurityHub != nil {
-		return helper.First(createClients(tc.Name, createConfig(tc, tc.Spec.SecurityHub), f.CreateSecurityHubTarget)), nil
-	} else if tc.Spec.Loki != nil {
-		return helper.First(createClients(tc.Name, createConfig(tc, tc.Spec.Loki), f.CreateLokiTarget)), nil
-	} else if tc.Spec.Slack != nil {
-		return helper.First(createClients(tc.Name, createConfig(tc, tc.Spec.Slack), f.CreateSlackTarget)), nil
-	} else if tc.Spec.Teams != nil {
-		return helper.First(createClients(tc.Name, createConfig(tc, tc.Spec.Teams), f.CreateTeamsTarget)), nil
-	} else if tc.Spec.Jira != nil {
-		return helper.First(createClients(tc.Name, createConfig(tc, tc.Spec.Jira), f.CreateJiraTarget)), nil
-	}
-	return nil, fmt.Errorf("invalid target type passed")
+	var target *target.Target
+
+	switch {
+	case tc.Spec.S3 != nil:
+		target = helper.First(createClients(tc.Name, createConfig(tc, tc.Spec.S3), f.CreateS3Target))
+	case tc.Spec.Webhook != nil:
+		target = helper.First(createClients(tc.Name, createConfig(tc, tc.Spec.Webhook), f.CreateWebhookTarget))
+	case tc.Spec.GCS != nil:
+		target = helper.First(createClients(tc.Name, createConfig(tc, tc.Spec.GCS), f.CreateGCSTarget))
+	case tc.Spec.ElasticSearch != nil:
+		target = helper.First(createClients(tc.Name, createConfig(tc, tc.Spec.ElasticSearch), f.CreateElasticsearchTarget))
+	case tc.Spec.Telegram != nil:
+		target = helper.First(createClients(tc.Name, createConfig(tc, tc.Spec.Telegram), f.CreateTelegramTarget))
+	case tc.Spec.Kinesis != nil:
+		target = helper.First(createClients(tc.Name, createConfig(tc, tc.Spec.Kinesis), f.CreateKinesisTarget))
+	case tc.Spec.SecurityHub != nil:
+		target = helper.First(createClients(tc.Name, createConfig(tc, tc.Spec.SecurityHub), f.CreateSecurityHubTarget))
+	case tc.Spec.Loki != nil:
+		target = helper.First(createClients(tc.Name, createConfig(tc, tc.Spec.Loki), f.CreateLokiTarget))
+	case tc.Spec.Slack != nil:
+		target = helper.First(createClients(tc.Name, createConfig(tc, tc.Spec.Slack), f.CreateSlackTarget))
+	case tc.Spec.Teams != nil:
+		target = helper.First(createClients(tc.Name, createConfig(tc, tc.Spec.Teams), f.CreateTeamsTarget))
+	case tc.Spec.Jira != nil:
+		target = helper.First(createClients(tc.Name, createConfig(tc, tc.Spec.Jira), f.CreateJiraTarget))
+	default:
+		return nil, fmt.Errorf("invalid target type passed")
+	}
+
+	if target != nil && target.Keepalive > 0 {
+		go target.StartKeepalive()
+	}
+
+	return target, nil
 }
 
 func (f *TargetFactory) CreateSlackTarget(config, parent *v1alpha1.Config[v1alpha1.SlackOptions]) *target.Target {
@@ -384,6 +402,18 @@ func (f *TargetFactory) CreateWebhookTarget(config, parent *v1alpha1.Config[v1al
 		return nil
 	}
 
+	var keepalive time.Duration
+	if config.Config.Keepalive != nil && config.Config.Keepalive.Interval != "" {
+		var err error
+		keepalive, err = time.ParseDuration(config.Config.Keepalive.Interval)
+		if err != nil {
+			zap.L().Error("failed to parse keepalive duration",
+				zap.String("target", config.Name),
+				zap.String("keepalive", config.Config.Keepalive.Interval),
+				zap.Error(err))
+		}
+	}
+
 	zap.S().Infof("%s configured", config.Name)
 
 	return &target.Target{
@@ -391,6 +421,7 @@ func (f *TargetFactory) CreateWebhookTarget(config, parent *v1alpha1.Config[v1al
 		Type:         target.Webhook,
 		Config:       config,
 		ParentConfig: parent,
+		Keepalive:    keepalive,
 		Client: webhook.NewClient(webhook.Options{
 			ClientOptions: target.ClientOptions{
 				Name:                  config.Name,
@@ -402,6 +433,7 @@ func (f *TargetFactory) CreateWebhookTarget(config, parent *v1alpha1.Config[v1al
 			Headers:      config.Config.Headers,
 			CustomFields: config.CustomFields,
 			HTTPClient:   http.NewClient(config.Config.Certificate, config.Config.SkipTLS),
+			Keepalive:    config.Config.Keepalive,
 		}),
 	}
 }
diff --git a/pkg/target/webhook/webhook.go b/pkg/target/webhook/webhook.go
index 032b73781..6d95be175 100644
--- a/pkg/target/webhook/webhook.go
+++ b/pkg/target/webhook/webhook.go
@@ -1,7 +1,12 @@
 package webhook
 
 import (
+	"time"
+
+	"go.uber.org/zap"
+
 	"github.com/kyverno/policy-reporter/pkg/crd/api/policyreport/v1alpha2"
+	"github.com/kyverno/policy-reporter/pkg/crd/api/targetconfig/v1alpha1"
 	"github.com/kyverno/policy-reporter/pkg/target"
 	"github.com/kyverno/policy-reporter/pkg/target/http"
 )
@@ -13,6 +18,7 @@ type Options struct {
 	Headers      map[string]string
 	CustomFields map[string]string
 	HTTPClient   http.Client
+	Keepalive    *v1alpha1.KeepaliveConfig
 }
 
 type client struct {
@@ -21,6 +27,7 @@ type client struct {
 	headers      map[string]string
 	customFields map[string]string
 	client       http.Client
+	keepalive    *v1alpha1.KeepaliveConfig
 }
 
 func (e *client) Send(result v1alpha2.PolicyReportResult) {
@@ -51,17 +58,50 @@ func (e *client) Send(result v1alpha2.PolicyReportResult) {
 	http.ProcessHTTPResponse(e.Name(), resp, err)
 }
 
+func (e *client) SendHeartbeat() {
+	payload := map[string]interface{}{
+		"event": "heartbeat",
+		"time":  time.Now().Format(time.RFC3339),
+	}
+
+	// Add keepalive params if configured
+	if e.keepalive != nil {
+		if len(e.keepalive.Params) > 0 {
+			for k, v := range e.keepalive.Params {
+				payload[k] = v
+			}
+		}
+	}
+
+	zap.L().Debug("sending heartbeat payload",
+		zap.String("target", e.Name()),
+		zap.Any("payload", payload))
+
+	req, err := http.CreateJSONRequest("POST", e.host, payload)
+	if err != nil {
+		return
+	}
+
+	for header, value := range e.headers {
+		req.Header.Set(header, value)
+	}
+
+	resp, err := e.client.Do(req)
+	http.ProcessHTTPResponse(e.Name()+"-heartbeat", resp, err)
+}
+
 func (e *client) Type() target.ClientType {
 	return target.SingleSend
 }
 
-// NewClient creates a new loki.client to send Results to Elasticsearch
+// NewClient creates a new webhook client
 func NewClient(options Options) target.Client {
 	return &client{
-		target.NewBaseClient(options.ClientOptions),
-		options.Host,
-		options.Headers,
-		options.CustomFields,
-		options.HTTPClient,
+		BaseClient:   target.NewBaseClient(options.ClientOptions),
+		host:         options.Host,
+		headers:      options.Headers,
+		customFields: options.CustomFields,
+		client:       options.HTTPClient,
+		keepalive:    options.Keepalive,
 	}
 }


Reviews:
user: login: aerosouund
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-10T19:09:03Z
user: login: aerosouund
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-10T19:21:17Z
user: login: nikolay-o
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-11T12:13:01Z
user: login: nikolay-o
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-11T12:24:36Z
user: login: aerosouund
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-11T15:00:37Z
user: login: fjogeleit
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-12T05:51:53Z
user: login: fjogeleit
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-12T05:54:38Z
user: login: nikolay-o
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T15:29:12Z

Review comments:
diff_hunk: @@ -64,6 +66,25 @@ func (t *Target) Secret() string {
 	return t.ParentConfig.Secret()
 }
 
+func (t *Target) startKeepalive() {
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	ticker := time.NewTicker(t.Keepalive)
+	defer ticker.Stop()
+
+	for {
+		select {
+		case <-ticker.C:
+			if sendClient, ok := t.Client.(interface{ SendHeartbeat() }); ok {
path: pkg/target/collection.go
user: login: aerosouund
body: Whats the need for this check ? if the base client implements the method then it will always return true. Or am i missing something ?
created_at: 2025-05-10T19:09:03Z
author_association: MEMBER
diff_hunk: @@ -164,6 +186,12 @@ func NewCollection(targets ...*Target) *Collection {
 
 	for _, t := range targets {
 		if t != nil {
+			if t.Keepalive > 0 {
+				zap.L().Info("starting keepalive for target",
+					zap.String("type", t.Type),
+					zap.String("name", t.Client.Name()))
+				go t.startKeepalive()
path: pkg/target/collection.go
user: login: aerosouund
body: Do you intend for this work for targets created with the `TargetConfig` resource ?

If yes, then this won't work since `NewCollection` gets called only at the the initialization of the reporter.
I would suggest that the method becomes an exported method, and at the end of `CreateClients`, iterate on the targets and call the `startKeepAlive` method.

Likewise if you want this to work for targets created using `TargetConfig` i would recommend that the method gets called at the end of `CreateSingleClient`
created_at: 2025-05-10T19:21:17Z
author_association: MEMBER
diff_hunk: @@ -64,6 +66,25 @@ func (t *Target) Secret() string {
 	return t.ParentConfig.Secret()
 }
 
+func (t *Target) startKeepalive() {
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	ticker := time.NewTicker(t.Keepalive)
+	defer ticker.Stop()
+
+	for {
+		select {
+		case <-ticker.C:
+			if sendClient, ok := t.Client.(interface{ SendHeartbeat() }); ok {
path: pkg/target/collection.go
user: login: nikolay-o
body: you right, it's not needed there, left from previous versions. removed.
created_at: 2025-05-11T12:13:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -164,6 +186,12 @@ func NewCollection(targets ...*Target) *Collection {
 
 	for _, t := range targets {
 		if t != nil {
+			if t.Keepalive > 0 {
+				zap.L().Info("starting keepalive for target",
+					zap.String("type", t.Type),
+					zap.String("name", t.Client.Name()))
+				go t.startKeepalive()
path: pkg/target/collection.go
user: login: nikolay-o
body: Thanks for review!
I moved `startKeepalive` to `CreateClients` and `CreateSingleClient`. However it looks like code and logic  duplication, wdyt, may be it will be better to move it to [createClients](https://github.com/kyverno/policy-reporter/blob/main/pkg/target/factory/factory.go#L47C4-L47C19)
created_at: 2025-05-11T12:24:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -164,6 +186,12 @@ func NewCollection(targets ...*Target) *Collection {
 
 	for _, t := range targets {
 		if t != nil {
+			if t.Keepalive > 0 {
+				zap.L().Info("starting keepalive for target",
+					zap.String("type", t.Type),
+					zap.String("name", t.Client.Name()))
+				go t.startKeepalive()
path: pkg/target/collection.go
user: login: aerosouund
body: In my opinion i am fine with this type of code duplication. I think its intuitive for anyone reading the code to see that after target creation, a keep alive with the target is initiated.

Part of my to why it was in `NewCollection` is that it feels like an unintuitive place to start a keep alive thread and it would take a while for anyone debugging to understand whats happening.

So personally i am happy with the code as it is
created_at: 2025-05-11T15:00:36Z
author_association: MEMBER
diff_hunk: @@ -64,6 +68,32 @@ func (t *Target) Secret() string {
 	return t.ParentConfig.Secret()
 }
 
+func (t *Target) StartKeepalive() {
+	// Simple atomic check to prevent duplicates
+	if !atomic.CompareAndSwapUint32(&t.keepaliveRunning, 0, 1) {
+		zap.L().Info("keepalive already started for target: ", zap.String("target", t.Type))
+		return
+	}
+	zap.L().Info("starting keepalive for target: ", zap.String("target", t.Type))
path: pkg/target/collection.go
user: login: fjogeleit
body: Only Webhooks will really support this right? So it might be misleading when its logging this for all kind of targets.
created_at: 2025-05-12T05:51:53Z
author_association: MEMBER
diff_hunk: @@ -64,6 +68,32 @@ func (t *Target) Secret() string {
 	return t.ParentConfig.Secret()
 }
 
+func (t *Target) StartKeepalive() {
+	// Simple atomic check to prevent duplicates
+	if !atomic.CompareAndSwapUint32(&t.keepaliveRunning, 0, 1) {
+		zap.L().Info("keepalive already started for target: ", zap.String("target", t.Type))
+		return
+	}
+	zap.L().Info("starting keepalive for target: ", zap.String("target", t.Type))
path: pkg/target/collection.go
user: login: fjogeleit
body: Thinking if it makes more sense to do this in the Webhook Constructor function, to keep the functionality close to the only target which will support it.
created_at: 2025-05-12T05:54:38Z
author_association: MEMBER
diff_hunk: @@ -64,6 +68,32 @@ func (t *Target) Secret() string {
 	return t.ParentConfig.Secret()
 }
 
+func (t *Target) StartKeepalive() {
+	// Simple atomic check to prevent duplicates
+	if !atomic.CompareAndSwapUint32(&t.keepaliveRunning, 0, 1) {
+		zap.L().Info("keepalive already started for target: ", zap.String("target", t.Type))
+		return
+	}
+	zap.L().Info("starting keepalive for target: ", zap.String("target", t.Type))
path: pkg/target/collection.go
user: login: nikolay-o
body: by placing this log here I wanted to make unified logging for all targets that will be added later.
I will emit log record only for targets with `t.Keepalive`. However if you prefer, I can move it.
created_at: 2025-05-12T15:29:12Z
author_association: CONTRIBUTOR

PR comments:
user: login: coveralls
created_at: 2025-05-08T16:14:36Z
body: ## Pull Request Test Coverage Report for [Build 14977928961](https://coveralls.io/builds/73627857)


### Details

* **15** of **116**   **(12.93%)**  changed or added relevant lines in **4** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage decreased (**-0.9%**) to **82.416%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [pkg/target/client.go](https://coveralls.io/builds/73627857/source?filename=pkg%2Ftarget%2Fclient.go#L251) | 0 | 1 | 0.0%
| [pkg/target/webhook/webhook.go](https://coveralls.io/builds/73627857/source?filename=pkg%2Ftarget%2Fwebhook%2Fwebhook.go#L61) | 6 | 31 | 19.35%
| [pkg/target/collection.go](https://coveralls.io/builds/73627857/source?filename=pkg%2Ftarget%2Fcollection.go#L72) | 0 | 34 | 0.0%
| [pkg/target/factory/factory.go](https://coveralls.io/builds/73627857/source?filename=pkg%2Ftarget%2Ffactory%2Ffactory.go#L105) | 9 | 50 | 18.0%
<!-- | **Total:** | **15** | **116** | **12.93%** | -->


|  Totals | [![Coverage Status](https://coveralls.io/builds/73627857/badge)](https://coveralls.io/builds/73627857) |
| :-- | --: |
| Change from base [Build 14916249275](https://coveralls.io/builds/73583991): |  -0.9% |
| Covered Lines: | 5601 |
| Relevant Lines: | 6796 |

---
##### 💛  - [Coveralls](https://coveralls.io)

author_association: NONE
user: login: aerosouund
created_at: 2025-05-10T19:23:13Z
body: @nikolay-o 
Thanks a lot for this.
I have added a few questions/comments
author_association: MEMBER
user: login: nikolay-o
created_at: 2025-05-11T13:28:31Z
body: > @nikolay-o Thanks a lot for this. I have added a few questions/comments

thanks for review, I finished with fixes. Could you please have a look once more?
author_association: CONTRIBUTOR
user: login: fjogeleit
created_at: 2025-05-12T05:55:58Z
body: thanks for the contribution, one thing which is missing is to stop the keepalive calls when the webhook target is removed during runtime.
author_association: MEMBER
user: login: nikolay-o
created_at: 2025-05-12T16:57:04Z
body: > thanks for the contribution, one thing which is missing is to stop the keepalive calls when the webhook target is removed during runtime.

Thanks for review, there was no support for keepalive in `targetconfig` CRD, I've added as well as `StopKeepalive()`.

could you please look one more time?
author_association: CONTRIBUTOR