Repository: fluxcd/notification-controller
Pull request #62 (2020-10-17 11:26:13)

Add support for http(s) proxy when sending alerts
Changes:
- Add `Proxy` optional field to Provider API
- Add URL prefix validation to CRDs
- Implement http(s) proxy for alerts

Proxy example:
```yaml
apiVersion: notification.toolkit.fluxcd.io/v1beta1
kind: Provider
metadata:
  name: slack
  namespace: default
spec:
  type: slack
  channel: general
  address: https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK
  proxy: https://proxy.corp:8080
```

Fix: #61 


Patch:
diff --git a/api/v1beta1/provider_types.go b/api/v1beta1/provider_types.go
index ed574dd..0d89ab6 100644
--- a/api/v1beta1/provider_types.go
+++ b/api/v1beta1/provider_types.go
@@ -38,10 +38,18 @@ type ProviderSpec struct {
 	// +optional
 	Username string `json:"username,omitempty"`
 
-	// HTTP(S) webhook address of this provider
+	// HTTP/S webhook address of this provider
+	// +kubebuilder:validation:Pattern="^(http|https)://"
+	// +kubebuilder:validation:Optional
 	// +optional
 	Address string `json:"address,omitempty"`
 
+	// HTTP/S address of the proxy
+	// +kubebuilder:validation:Pattern="^(http|https)://"
+	// +kubebuilder:validation:Optional
+	// +optional
+	Proxy string `json:"proxy,omitempty"`
+
 	// Secret reference containing the provider webhook URL
 	// +optional
 	SecretRef *corev1.LocalObjectReference `json:"secretRef,omitempty"`
diff --git a/api/v1beta1/reference_types.go b/api/v1beta1/reference_types.go
index 5e35a95..81a7dd9 100644
--- a/api/v1beta1/reference_types.go
+++ b/api/v1beta1/reference_types.go
@@ -29,10 +29,15 @@ type CrossNamespaceObjectReference struct {
 	Kind string `json:"kind,omitempty"`
 
 	// Name of the referent
+	// +kubebuilder:validation:MinLength=1
+	// +kubebuilder:validation:MaxLength=53
 	// +required
 	Name string `json:"name"`
 
 	// Namespace of the referent
+	// +kubebuilder:validation:MinLength=1
+	// +kubebuilder:validation:MaxLength=53
+	// +kubebuilder:validation:Optional
 	// +optional
 	Namespace string `json:"namespace,omitempty"`
 }
diff --git a/config/crd/bases/notification.toolkit.fluxcd.io_alerts.yaml b/config/crd/bases/notification.toolkit.fluxcd.io_alerts.yaml
index 91b3861..06918cc 100644
--- a/config/crd/bases/notification.toolkit.fluxcd.io_alerts.yaml
+++ b/config/crd/bases/notification.toolkit.fluxcd.io_alerts.yaml
@@ -76,9 +76,13 @@ spec:
                       type: string
                     name:
                       description: Name of the referent
+                      maxLength: 53
+                      minLength: 1
                       type: string
                     namespace:
                       description: Namespace of the referent
+                      maxLength: 53
+                      minLength: 1
                       type: string
                   required:
                   - name
diff --git a/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml b/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml
index 554ae06..f33ba5a 100644
--- a/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml
+++ b/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml
@@ -47,11 +47,16 @@ spec:
             description: ProviderSpec defines the desired state of Provider
             properties:
               address:
-                description: HTTP(S) webhook address of this provider
+                description: HTTP/S webhook address of this provider
+                pattern: ^(http|https)://
                 type: string
               channel:
                 description: Alert channel for this provider
                 type: string
+              proxy:
+                description: HTTP/S address of the proxy
+                pattern: ^(http|https)://
+                type: string
               secretRef:
                 description: Secret reference containing the provider webhook URL
                 properties:
diff --git a/config/crd/bases/notification.toolkit.fluxcd.io_receivers.yaml b/config/crd/bases/notification.toolkit.fluxcd.io_receivers.yaml
index 2a69b72..c943348 100644
--- a/config/crd/bases/notification.toolkit.fluxcd.io_receivers.yaml
+++ b/config/crd/bases/notification.toolkit.fluxcd.io_receivers.yaml
@@ -73,9 +73,13 @@ spec:
                       type: string
                     name:
                       description: Name of the referent
+                      maxLength: 53
+                      minLength: 1
                       type: string
                     namespace:
                       description: Namespace of the referent
+                      maxLength: 53
+                      minLength: 1
                       type: string
                   required:
                   - name
diff --git a/docs/api/notification.md b/docs/api/notification.md
index 37a8c6c..e3cc623 100644
--- a/docs/api/notification.md
+++ b/docs/api/notification.md
@@ -244,7 +244,19 @@ string
 </td>
 <td>
 <em>(Optional)</em>
-<p>HTTP(S) webhook address of this provider</p>
+<p>HTTP/S webhook address of this provider</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>proxy</code><br>
+<em>
+string
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>HTTP/S address of the proxy</p>
 </td>
 </tr>
 <tr>
@@ -658,7 +670,19 @@ string
 </td>
 <td>
 <em>(Optional)</em>
-<p>HTTP(S) webhook address of this provider</p>
+<p>HTTP/S webhook address of this provider</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>proxy</code><br>
+<em>
+string
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>HTTP/S address of the proxy</p>
 </td>
 </tr>
 <tr>
diff --git a/docs/spec/v1beta1/provider.md b/docs/spec/v1beta1/provider.md
index f0d3979..ef476cb 100644
--- a/docs/spec/v1beta1/provider.md
+++ b/docs/spec/v1beta1/provider.md
@@ -21,10 +21,16 @@ type ProviderSpec struct {
 	// +optional
 	Username string `json:"username,omitempty"`
 
-	// HTTP(S) webhook address of this provider
+	// HTTP/S webhook address of this provider
+	// +kubebuilder:validation:Pattern="^(http|https)://"
 	// +optional
 	Address string `json:"address,omitempty"`
 
+	// HTTP/S address of the proxy
+	// +kubebuilder:validation:Pattern="^(http|https)://"
+	// +optional
+	Proxy string `json:"proxy,omitempty"`
+
 	// Secret reference containing the provider webhook URL
 	// +optional
 	SecretRef *corev1.LocalObjectReference `json:"secretRef,omitempty"`
@@ -79,6 +85,8 @@ spec:
   channel: general
   # webhook address (ignored if secretRef is specified)
   address: https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK
+  # HTTP(S) proxy (optional)
+  proxy: https://proxy.corp:8080
   # secret containing the webhook address (optional)
   secretRef:
     name: webhook-url
diff --git a/internal/notifier/client.go b/internal/notifier/client.go
index 5cc2779..c23404e 100644
--- a/internal/notifier/client.go
+++ b/internal/notifier/client.go
@@ -19,13 +19,37 @@ package notifier
 import (
 	"encoding/json"
 	"fmt"
+	"net"
+	"net/http"
+	"net/url"
+	"runtime"
 	"time"
 
 	"github.com/hashicorp/go-retryablehttp"
 )
 
-func postMessage(address string, payload interface{}) error {
+func postMessage(address string, proxy string, payload interface{}) error {
 	httpClient := retryablehttp.NewClient()
+
+	if proxy != "" {
+		proxyURL, err := url.Parse(proxy)
+		if err != nil {
+			return fmt.Errorf("unable to parse proxy URL '%s', error: %w", proxy, err)
+		}
+		httpClient.HTTPClient.Transport = &http.Transport{
+			Proxy: http.ProxyURL(proxyURL),
+			DialContext: (&net.Dialer{
+				Timeout:   15 * time.Second,
+				KeepAlive: 30 * time.Second,
+			}).DialContext,
+			MaxIdleConns:          100,
+			IdleConnTimeout:       90 * time.Second,
+			TLSHandshakeTimeout:   10 * time.Second,
+			ExpectContinueTimeout: 1 * time.Second,
+			MaxIdleConnsPerHost:   runtime.GOMAXPROCS(0) + 1,
+		}
+	}
+
 	httpClient.HTTPClient.Timeout = 15 * time.Second
 	httpClient.RetryWaitMin = 2 * time.Second
 	httpClient.RetryWaitMax = 30 * time.Second
diff --git a/internal/notifier/discord.go b/internal/notifier/discord.go
index 53b9677..25c7d33 100644
--- a/internal/notifier/discord.go
+++ b/internal/notifier/discord.go
@@ -29,12 +29,13 @@ import (
 // Discord holds the hook URL
 type Discord struct {
 	URL      string
+	ProxyURL string
 	Username string
 	Channel  string
 }
 
 // NewDiscord validates the URL and returns a Discord object
-func NewDiscord(hookURL string, username string, channel string) (*Discord, error) {
+func NewDiscord(hookURL string, proxyURL string, username string, channel string) (*Discord, error) {
 	webhook, err := url.ParseRequestURI(hookURL)
 	if err != nil {
 		return nil, fmt.Errorf("invalid Discord hook URL %s", hookURL)
@@ -58,6 +59,7 @@ func NewDiscord(hookURL string, username string, channel string) (*Discord, erro
 	return &Discord{
 		Channel:  channel,
 		URL:      hookURL,
+		ProxyURL: proxyURL,
 		Username: username,
 	}, nil
 }
@@ -97,7 +99,7 @@ func (s *Discord) Post(event recorder.Event) error {
 
 	payload.Attachments = []SlackAttachment{a}
 
-	err := postMessage(s.URL, payload)
+	err := postMessage(s.URL, s.ProxyURL, payload)
 	if err != nil {
 		return fmt.Errorf("postMessage failed: %w", err)
 	}
diff --git a/internal/notifier/factory.go b/internal/notifier/factory.go
index 9a3a2fd..20feeb8 100644
--- a/internal/notifier/factory.go
+++ b/internal/notifier/factory.go
@@ -24,14 +24,16 @@ import (
 
 type Factory struct {
 	URL      string
+	ProxyURL string
 	Username string
 	Channel  string
 	Token    string
 }
 
-func NewFactory(url string, username string, channel string, token string) *Factory {
+func NewFactory(url string, proxy string, username string, channel string, token string) *Factory {
 	return &Factory{
 		URL:      url,
+		ProxyURL: proxy,
 		Channel:  channel,
 		Username: username,
 		Token:    token,
@@ -47,15 +49,15 @@ func (f Factory) Notifier(provider string) (Interface, error) {
 	var err error
 	switch provider {
 	case v1beta1.GenericProvider:
-		n, err = NewForwarder(f.URL)
+		n, err = NewForwarder(f.URL, f.ProxyURL)
 	case v1beta1.SlackProvider:
-		n, err = NewSlack(f.URL, f.Username, f.Channel)
+		n, err = NewSlack(f.URL, f.ProxyURL, f.Username, f.Channel)
 	case v1beta1.DiscordProvider:
-		n, err = NewDiscord(f.URL, f.Username, f.Channel)
+		n, err = NewDiscord(f.URL, f.ProxyURL, f.Username, f.Channel)
 	case v1beta1.RocketProvider:
-		n, err = NewRocket(f.URL, f.Username, f.Channel)
+		n, err = NewRocket(f.URL, f.ProxyURL, f.Username, f.Channel)
 	case v1beta1.MSTeamsProvider:
-		n, err = NewMSTeams(f.URL)
+		n, err = NewMSTeams(f.URL, f.ProxyURL)
 	case v1beta1.GitHubProvider:
 		n, err = NewGitHub(f.URL, f.Token)
 	case v1beta1.GitLabProvider:
diff --git a/internal/notifier/forwarder.go b/internal/notifier/forwarder.go
index b8d172f..337f303 100644
--- a/internal/notifier/forwarder.go
+++ b/internal/notifier/forwarder.go
@@ -8,19 +8,23 @@ import (
 )
 
 type Forwarder struct {
-	URL string
+	URL      string
+	ProxyURL string
 }
 
-func NewForwarder(hookURL string) (*Forwarder, error) {
+func NewForwarder(hookURL string, proxyURL string) (*Forwarder, error) {
 	if _, err := url.ParseRequestURI(hookURL); err != nil {
 		return nil, fmt.Errorf("invalid Discord hook URL %s", hookURL)
 	}
 
-	return &Forwarder{URL: hookURL}, nil
+	return &Forwarder{
+		URL:      hookURL,
+		ProxyURL: proxyURL,
+	}, nil
 }
 
 func (f *Forwarder) Post(event recorder.Event) error {
-	err := postMessage(f.URL, event)
+	err := postMessage(f.URL, f.ProxyURL, event)
 	if err != nil {
 		return fmt.Errorf("postMessage failed: %w", err)
 	}
diff --git a/internal/notifier/rocket.go b/internal/notifier/rocket.go
index cc1cc88..3a42e2a 100644
--- a/internal/notifier/rocket.go
+++ b/internal/notifier/rocket.go
@@ -27,12 +27,13 @@ import (
 // Rocket holds the hook URL
 type Rocket struct {
 	URL      string
+	ProxyURL string
 	Username string
 	Channel  string
 }
 
 // NewRocket validates the Rocket URL and returns a Rocket object
-func NewRocket(hookURL string, username string, channel string) (*Rocket, error) {
+func NewRocket(hookURL string, proxyURL string, username string, channel string) (*Rocket, error) {
 	_, err := url.ParseRequestURI(hookURL)
 	if err != nil {
 		return nil, fmt.Errorf("invalid Rocket hook URL %s", hookURL)
@@ -49,6 +50,7 @@ func NewRocket(hookURL string, username string, channel string) (*Rocket, error)
 	return &Rocket{
 		Channel:  channel,
 		URL:      hookURL,
+		ProxyURL: proxyURL,
 		Username: username,
 	}, nil
 }
@@ -85,7 +87,7 @@ func (s *Rocket) Post(event recorder.Event) error {
 
 	payload.Attachments = []SlackAttachment{a}
 
-	err := postMessage(s.URL, payload)
+	err := postMessage(s.URL, s.ProxyURL, payload)
 	if err != nil {
 		return fmt.Errorf("postMessage failed: %w", err)
 	}
diff --git a/internal/notifier/slack.go b/internal/notifier/slack.go
index e30086b..1210459 100644
--- a/internal/notifier/slack.go
+++ b/internal/notifier/slack.go
@@ -27,6 +27,7 @@ import (
 // Slack holds the hook URL
 type Slack struct {
 	URL      string
+	ProxyURL string
 	Username string
 	Channel  string
 }
@@ -57,7 +58,7 @@ type SlackField struct {
 }
 
 // NewSlack validates the Slack URL and returns a Slack object
-func NewSlack(hookURL string, username string, channel string) (*Slack, error) {
+func NewSlack(hookURL string, proxyURL string, username string, channel string) (*Slack, error) {
 	_, err := url.ParseRequestURI(hookURL)
 	if err != nil {
 		return nil, fmt.Errorf("invalid Slack hook URL %s", hookURL)
@@ -71,6 +72,7 @@ func NewSlack(hookURL string, username string, channel string) (*Slack, error) {
 		Channel:  channel,
 		Username: username,
 		URL:      hookURL,
+		ProxyURL: proxyURL,
 	}, nil
 }
 
@@ -109,7 +111,7 @@ func (s *Slack) Post(event recorder.Event) error {
 
 	payload.Attachments = []SlackAttachment{a}
 
-	err := postMessage(s.URL, payload)
+	err := postMessage(s.URL, s.ProxyURL, payload)
 	if err != nil {
 		return fmt.Errorf("postMessage failed: %w", err)
 	}
diff --git a/internal/notifier/teams.go b/internal/notifier/teams.go
index 511be12..324dcaa 100644
--- a/internal/notifier/teams.go
+++ b/internal/notifier/teams.go
@@ -25,7 +25,8 @@ import (
 
 // MS Teams holds the incoming webhook URL
 type MSTeams struct {
-	URL string
+	URL      string
+	ProxyURL string
 }
 
 // MSTeamsPayload holds the message card data
@@ -50,14 +51,15 @@ type MSTeamsField struct {
 }
 
 // NewMSTeams validates the MS Teams URL and returns a MSTeams object
-func NewMSTeams(hookURL string) (*MSTeams, error) {
+func NewMSTeams(hookURL string, proxyURL string) (*MSTeams, error) {
 	_, err := url.ParseRequestURI(hookURL)
 	if err != nil {
 		return nil, fmt.Errorf("invalid MS Teams webhook URL %s", hookURL)
 	}
 
 	return &MSTeams{
-		URL: hookURL,
+		URL:      hookURL,
+		ProxyURL: proxyURL,
 	}, nil
 }
 
@@ -95,7 +97,7 @@ func (s *MSTeams) Post(event recorder.Event) error {
 		payload.ThemeColor = "FF0000"
 	}
 
-	err := postMessage(s.URL, payload)
+	err := postMessage(s.URL, s.ProxyURL, payload)
 	if err != nil {
 		return fmt.Errorf("postMessage failed: %w", err)
 	}
diff --git a/internal/server/event_handlers.go b/internal/server/event_handlers.go
index 101cf2e..45bd20e 100644
--- a/internal/server/event_handlers.go
+++ b/internal/server/event_handlers.go
@@ -139,7 +139,7 @@ func (s *EventServer) handleEvent() func(w http.ResponseWriter, r *http.Request)
 				return
 			}
 
-			factory := notifier.NewFactory(webhook, provider.Spec.Username, provider.Spec.Channel, token)
+			factory := notifier.NewFactory(webhook, provider.Spec.Proxy, provider.Spec.Username, provider.Spec.Channel, token)
 			sender, err := factory.Notifier(provider.Spec.Type)
 			if err != nil {
 				s.logger.Error(err, "failed to initialise provider",


Test patch:
diff --git a/internal/notifier/client_test.go b/internal/notifier/client_test.go
index 830a95c..dd4139a 100644
--- a/internal/notifier/client_test.go
+++ b/internal/notifier/client_test.go
@@ -43,7 +43,7 @@ func Test_postMessage(t *testing.T) {
 	}))
 	defer ts.Close()
 
-	err := postMessage(ts.URL, map[string]string{"status": "success"})
+	err := postMessage(ts.URL, "", map[string]string{"status": "success"})
 	require.NoError(t, err)
 }
 
diff --git a/internal/notifier/discord_test.go b/internal/notifier/discord_test.go
index 5da26d8..b116c8a 100644
--- a/internal/notifier/discord_test.go
+++ b/internal/notifier/discord_test.go
@@ -41,7 +41,7 @@ func TestDiscord_Post(t *testing.T) {
 	}))
 	defer ts.Close()
 
-	discord, err := NewDiscord(ts.URL, "test", "test")
+	discord, err := NewDiscord(ts.URL, "", "test", "test")
 	require.NoError(t, err)
 	assert.True(t, strings.HasSuffix(discord.URL, "/slack"))
 
diff --git a/internal/notifier/forwarder_test.go b/internal/notifier/forwarder_test.go
index faf165d..8a16414 100644
--- a/internal/notifier/forwarder_test.go
+++ b/internal/notifier/forwarder_test.go
@@ -41,7 +41,7 @@ func TestForwarder_Post(t *testing.T) {
 	}))
 	defer ts.Close()
 
-	forwarder, err := NewForwarder(ts.URL)
+	forwarder, err := NewForwarder(ts.URL, "")
 	require.NoError(t, err)
 
 	err = forwarder.Post(testEvent())
diff --git a/internal/notifier/rocket_test.go b/internal/notifier/rocket_test.go
index 4c896e9..510c05a 100644
--- a/internal/notifier/rocket_test.go
+++ b/internal/notifier/rocket_test.go
@@ -39,7 +39,7 @@ func TestRocket_Post(t *testing.T) {
 	}))
 	defer ts.Close()
 
-	rocket, err := NewRocket(ts.URL, "test", "test")
+	rocket, err := NewRocket(ts.URL, "", "test", "test")
 	require.NoError(t, err)
 
 	err = rocket.Post(testEvent())
diff --git a/internal/notifier/slack_test.go b/internal/notifier/slack_test.go
index bc976eb..9f66212 100644
--- a/internal/notifier/slack_test.go
+++ b/internal/notifier/slack_test.go
@@ -39,7 +39,7 @@ func TestSlack_Post(t *testing.T) {
 	}))
 	defer ts.Close()
 
-	slack, err := NewSlack(ts.URL, "", "test")
+	slack, err := NewSlack(ts.URL, "", "", "test")
 	require.NoError(t, err)
 
 	err = slack.Post(testEvent())
@@ -48,7 +48,7 @@ func TestSlack_Post(t *testing.T) {
 }
 
 func TestSlack_PostUpdate(t *testing.T) {
-	slack, err := NewSlack("http://localhost", "", "test")
+	slack, err := NewSlack("http://localhost", "", "", "test")
 	require.NoError(t, err)
 
 	event := testEvent()
diff --git a/internal/notifier/teams_test.go b/internal/notifier/teams_test.go
index 037659b..2de745c 100644
--- a/internal/notifier/teams_test.go
+++ b/internal/notifier/teams_test.go
@@ -39,7 +39,7 @@ func TestTeams_Post(t *testing.T) {
 	}))
 	defer ts.Close()
 
-	teams, err := NewMSTeams(ts.URL)
+	teams, err := NewMSTeams(ts.URL, "")
 	require.NoError(t, err)
 
 	err = teams.Post(testEvent())