Introduce flux trigger receiver

Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
This commit is contained in:
Matheus Pimenta 2026-05-21 17:17:59 +01:00
parent 9d9e56208c
commit 4bfdb6d459
No known key found for this signature in database
GPG key ID: 4639F038AE28FBFF
3 changed files with 752 additions and 0 deletions

31
cmd/flux/trigger.go Normal file
View file

@ -0,0 +1,31 @@
/*
Copyright 2026 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"github.com/spf13/cobra"
)
var triggerCmd = &cobra.Command{
Use: "trigger",
Short: "Trigger Flux resources from outside the cluster",
Long: `The trigger sub-commands invoke Flux resources from outside the cluster, such as a Receiver's incoming webhook.`,
}
func init() {
rootCmd.AddCommand(triggerCmd)
}

View file

@ -0,0 +1,368 @@
/*
Copyright 2026 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/spf13/cobra"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/fluxcd/pkg/auth/actionsoidc"
notificationv1 "github.com/fluxcd/notification-controller/api/v1"
)
const (
// genericOIDCReceiver mirrors notificationv1.GenericOIDCReceiver from the
// upcoming notification-controller release.
// TODO: Replace it with the constant from the api module once the dependency
// is bumped.
genericOIDCReceiver = "generic-oidc"
// defaultOIDCAudience mirrors notificationv1.DefaultOIDCAudience.
// TODO: Replace it with the constant from the api module once the dependency
// is bumped.
defaultOIDCAudience = "notification-controller"
// defaultOIDCTokenEnvVar is the environment variable the OIDC token is read
// from when neither --oidc-provider nor --oidc-token is set.
defaultOIDCTokenEnvVar = "FLUX_TRIGGER_RECEIVER_OIDC_TOKEN"
)
const (
oidcProviderGitHub = "github"
oidcProviderForgejo = "forgejo"
)
var triggerReceiverCmd = &cobra.Command{
Use: "receiver [name]",
Short: "Trigger the webhook of a Receiver",
Long: `The trigger receiver command sends a request to the incoming webhook of a Receiver.
The command computes the webhook path from the Receiver name, namespace and token,
appends it to the base URL and sends an HTTP POST request with the given payload.
It does not require access to the Kubernetes cluster.`,
Example: ` # Trigger a generic Receiver
flux trigger receiver my-receiver \
--token=my-token \
--url=https://flux-webhook.example.com
# Trigger a generic Receiver with a custom JSON payload
flux trigger receiver my-receiver \
--token=my-token \
--url=https://flux-webhook.example.com \
--payload='{"image":"ghcr.io/org/app:v1.0.0"}'
# Trigger a generic-hmac Receiver
flux trigger receiver my-receiver \
--type=generic-hmac \
--token=my-token \
--url=https://flux-webhook.example.com \
--payload='{"image":"ghcr.io/org/app:v1.0.0"}'
# Trigger a generic-oidc Receiver from a GitHub Actions workflow.
# The job needs 'permissions: id-token: write'. The OIDC token is fetched
# automatically and the receiver token is not used by this type.
flux trigger receiver my-receiver \
--type=generic-oidc \
--oidc-provider=github \
--url=https://flux-webhook.example.com
# Trigger a generic-oidc Receiver from a GitHub Actions workflow with a custom OIDC audience
flux trigger receiver my-receiver \
--type=generic-oidc \
--oidc-provider=github \
--oidc-audience=my-flux-instance \
--url=https://flux-webhook.example.com
# Trigger a generic-oidc Receiver from a Forgejo Actions workflow
flux trigger receiver my-receiver \
--type=generic-oidc \
--oidc-provider=forgejo \
--url=https://flux-webhook.example.com
# Trigger a generic-oidc Receiver from a GitLab CI/CD job, reading the OIDC
# token from an id_token environment variable defined in the job spec.
flux trigger receiver my-receiver \
--type=generic-oidc \
--oidc-token="${MY_ID_TOKEN}" \
--url=https://flux-webhook.example.com
# Trigger a generic-oidc Receiver from a GitLab CI/CD job, reading the OIDC
# token from the default FLUX_TRIGGER_RECEIVER_OIDC_TOKEN environment variable,
# e.g. defined as:
# job:
# id_tokens:
# FLUX_TRIGGER_RECEIVER_OIDC_TOKEN:
# aud: notification-controller
flux trigger receiver my-receiver \
--type=generic-oidc \
--url=https://flux-webhook.example.com
# Trigger a Receiver in a specific namespace
flux trigger receiver my-receiver -n apps \
--token=my-token \
--url=https://flux-webhook.example.com
# Trigger a Receiver in the namespace of the current kubeconfig context
flux trigger receiver my-receiver \
--ns-follows-kube-context \
--token=my-token \
--url=https://flux-webhook.example.com`,
Args: cobra.ExactArgs(1),
RunE: triggerReceiverCmdRun,
}
type triggerReceiverFlags struct {
token string
url string
receiverType string
oidcProvider string
oidcToken string
oidcAudience string
payload string
retries int
retryDelay time.Duration
}
var triggerReceiverArgs triggerReceiverFlags
func init() {
triggerReceiverCmd.Flags().StringVar(&triggerReceiverArgs.token, "token", "",
"the Receiver token, required for all types except generic-oidc where it must not be set")
triggerReceiverCmd.Flags().StringVar(&triggerReceiverArgs.url, "url", "",
"the base URL of the notification-controller webhook receiver, may contain a base path")
triggerReceiverCmd.Flags().StringVar(&triggerReceiverArgs.receiverType, "type", notificationv1.GenericReceiver,
fmt.Sprintf("the Receiver type, one of: %s, %s, %s",
notificationv1.GenericReceiver, notificationv1.GenericHMACReceiver, genericOIDCReceiver))
triggerReceiverCmd.Flags().StringVar(&triggerReceiverArgs.oidcProvider, "oidc-provider", "",
fmt.Sprintf("the OIDC provider to fetch the token from, one of: %s, %s (generic-oidc only, mutually exclusive with --oidc-token)",
oidcProviderGitHub, oidcProviderForgejo))
triggerReceiverCmd.Flags().StringVar(&triggerReceiverArgs.oidcToken, "oidc-token", "",
fmt.Sprintf("the OIDC token to authenticate the request (generic-oidc only, mutually exclusive with --oidc-provider); defaults to the %s environment variable", defaultOIDCTokenEnvVar))
triggerReceiverCmd.Flags().StringVar(&triggerReceiverArgs.oidcAudience, "oidc-audience", "",
fmt.Sprintf("the audience of the OIDC token to fetch (requires --oidc-provider); defaults to %q", defaultOIDCAudience))
triggerReceiverCmd.Flags().StringVar(&triggerReceiverArgs.payload, "payload", "{}",
"the JSON payload to send in the request body")
triggerReceiverCmd.Flags().IntVar(&triggerReceiverArgs.retries, "retries", 10,
"the number of times to retry on connection errors or retryable HTTP status codes (404, 408, 429, 5xx); set to 0 to disable")
triggerReceiverCmd.Flags().DurationVar(&triggerReceiverArgs.retryDelay, "retry-delay", 10*time.Second,
"the delay between retries")
triggerCmd.AddCommand(triggerReceiverCmd)
}
func triggerReceiverCmdRun(cmd *cobra.Command, args []string) error {
name := args[0]
if triggerReceiverArgs.url == "" {
return fmt.Errorf("--url is required")
}
if err := validateTriggerReceiverArgs(); err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), rootArgs.timeout)
defer cancel()
// For generic-oidc the Receiver has no secretRef, so the webhook path is
// salted with an empty token. For all other types the token is required.
pathToken := triggerReceiverArgs.token
if triggerReceiverArgs.receiverType == genericOIDCReceiver {
pathToken = ""
}
receiver := &notificationv1.Receiver{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: *kubeconfigArgs.Namespace,
},
}
webhookURL := strings.TrimRight(triggerReceiverArgs.url, "/") + receiver.GetWebhookPath(pathToken)
payload := []byte(triggerReceiverArgs.payload)
// Compute the request headers once; the auth material does not change between
// attempts, so they are applied to a fresh request on each retry.
headers := map[string]string{
"Content-Type": "application/json",
"User-Agent": fmt.Sprintf("flux/v%s", VERSION),
}
switch triggerReceiverArgs.receiverType {
case notificationv1.GenericReceiver:
// No authentication, the payload is sent as-is.
case notificationv1.GenericHMACReceiver:
mac := hmac.New(sha256.New, []byte(triggerReceiverArgs.token))
mac.Write(payload)
headers["X-Signature"] = "sha256=" + hex.EncodeToString(mac.Sum(nil))
case genericOIDCReceiver:
oidcToken, err := resolveOIDCToken(ctx)
if err != nil {
return err
}
headers["Authorization"] = "Bearer " + oidcToken
}
// send performs a single attempt. It reports retryable=true for transient
// failures (connection errors and retryable HTTP status codes) so the caller
// can retry; permanent failures (e.g. authentication or validation errors)
// report retryable=false and fail immediately.
send := func() (retryable bool, err error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(payload))
if err != nil {
return false, fmt.Errorf("unable to create request: %w", err)
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return true, fmt.Errorf("request to %s failed: %w", webhookURL, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
statusErr := fmt.Errorf("request to %s failed with status %s", webhookURL, resp.Status)
if msg := strings.TrimSpace(string(body)); msg != "" {
statusErr = fmt.Errorf("request to %s failed with status %s: %s", webhookURL, resp.Status, msg)
}
return isRetryableStatus(resp.StatusCode), statusErr
}
return false, nil
}
logger.Actionf("triggering Receiver %s/%s", *kubeconfigArgs.Namespace, name)
for attempt := 0; ; attempt++ {
retryable, err := send()
if err == nil {
logger.Successf("Receiver %s/%s triggered", *kubeconfigArgs.Namespace, name)
return nil
}
if !retryable || attempt >= triggerReceiverArgs.retries {
return err
}
logger.Waitingf("%s; retrying in %s (%d/%d)",
err, triggerReceiverArgs.retryDelay, attempt+1, triggerReceiverArgs.retries)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(triggerReceiverArgs.retryDelay):
}
}
}
// isRetryableStatus reports whether an HTTP status returned by the webhook
// receiver is worth retrying. 404 is included because the Receiver's webhook
// path may not be registered yet right after the notification-controller starts
// or while the Receiver reconciles.
func isRetryableStatus(code int) bool {
switch code {
case http.StatusNotFound, // 404
http.StatusRequestTimeout, // 408
http.StatusTooManyRequests, // 429
http.StatusInternalServerError, // 500
http.StatusBadGateway, // 502
http.StatusServiceUnavailable, // 503
http.StatusGatewayTimeout: // 504
return true
default:
return false
}
}
// validateTriggerReceiverArgs validates the receiver type and the combination of
// token and OIDC flags.
func validateTriggerReceiverArgs() error {
isOIDC := triggerReceiverArgs.receiverType == genericOIDCReceiver
switch triggerReceiverArgs.receiverType {
case notificationv1.GenericReceiver, notificationv1.GenericHMACReceiver, genericOIDCReceiver:
default:
return fmt.Errorf("invalid --type %q, must be one of: %s, %s, %s",
triggerReceiverArgs.receiverType,
notificationv1.GenericReceiver, notificationv1.GenericHMACReceiver, genericOIDCReceiver)
}
if !isOIDC {
if triggerReceiverArgs.token == "" {
return fmt.Errorf("--token is required for --type=%s", triggerReceiverArgs.receiverType)
}
if triggerReceiverArgs.oidcProvider != "" || triggerReceiverArgs.oidcToken != "" || triggerReceiverArgs.oidcAudience != "" {
return fmt.Errorf("--oidc-provider, --oidc-token and --oidc-audience can only be set for --type=%s", genericOIDCReceiver)
}
return nil
}
// generic-oidc.
if triggerReceiverArgs.token != "" {
return fmt.Errorf("--token must not be set for --type=%s, the Receiver of this type has no secret", genericOIDCReceiver)
}
if triggerReceiverArgs.oidcProvider != "" && triggerReceiverArgs.oidcToken != "" {
return fmt.Errorf("--oidc-provider and --oidc-token are mutually exclusive")
}
if triggerReceiverArgs.oidcProvider != "" {
switch triggerReceiverArgs.oidcProvider {
case oidcProviderGitHub, oidcProviderForgejo:
default:
return fmt.Errorf("invalid --oidc-provider %q, must be one of: %s, %s",
triggerReceiverArgs.oidcProvider, oidcProviderGitHub, oidcProviderForgejo)
}
}
if triggerReceiverArgs.oidcAudience != "" && triggerReceiverArgs.oidcProvider == "" {
return fmt.Errorf("--oidc-audience can only be set together with --oidc-provider")
}
return nil
}
// resolveOIDCToken returns the OIDC token used to authenticate the request,
// either by fetching it from the configured provider or by reading it from the
// --oidc-token flag or the default environment variable.
func resolveOIDCToken(ctx context.Context) (string, error) {
switch {
case triggerReceiverArgs.oidcProvider != "":
audience := triggerReceiverArgs.oidcAudience
if audience == "" {
audience = defaultOIDCAudience
}
// GitHub and Forgejo Actions expose the same token request endpoint.
token, _, err := actionsoidc.FetchToken(ctx, audience)
return token, err
case triggerReceiverArgs.oidcToken != "":
return triggerReceiverArgs.oidcToken, nil
default:
token := os.Getenv(defaultOIDCTokenEnvVar)
if token == "" {
return "", fmt.Errorf("no OIDC token provided: set --oidc-provider, --oidc-token or the %s environment variable", defaultOIDCTokenEnvVar)
}
return token, nil
}
}

View file

@ -0,0 +1,353 @@
//go:build unit
// +build unit
/*
Copyright 2026 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
notificationv1 "github.com/fluxcd/notification-controller/api/v1"
)
// resetTriggerReceiverArgs restores the package-global flags to their defaults
// so tests do not leak state into each other.
func resetTriggerReceiverArgs(t *testing.T) {
t.Helper()
prev := triggerReceiverArgs
prevNS := kubeconfigArgs.Namespace
prevTimeout := rootArgs.timeout
triggerReceiverArgs = triggerReceiverFlags{
receiverType: notificationv1.GenericReceiver,
payload: "{}",
}
ns := "default"
kubeconfigArgs.Namespace = &ns
rootArgs.timeout = time.Minute
t.Cleanup(func() {
triggerReceiverArgs = prev
kubeconfigArgs.Namespace = prevNS
rootArgs.timeout = prevTimeout
})
}
func TestValidateTriggerReceiverArgs(t *testing.T) {
tests := []struct {
name string
args triggerReceiverFlags
wantErr string
}{
{
name: "generic requires token",
args: triggerReceiverFlags{receiverType: notificationv1.GenericReceiver},
wantErr: "--token is required",
},
{
name: "generic with token is valid",
args: triggerReceiverFlags{receiverType: notificationv1.GenericReceiver, token: "t"},
},
{
name: "generic rejects oidc flags",
args: triggerReceiverFlags{receiverType: notificationv1.GenericReceiver, token: "t", oidcProvider: "github"},
wantErr: "can only be set for --type=generic-oidc",
},
{
name: "hmac with token is valid",
args: triggerReceiverFlags{receiverType: notificationv1.GenericHMACReceiver, token: "t"},
},
{
name: "unknown type",
args: triggerReceiverFlags{receiverType: "bogus", token: "t"},
wantErr: "invalid --type",
},
{
name: "oidc rejects token",
args: triggerReceiverFlags{receiverType: genericOIDCReceiver, token: "t"},
wantErr: "--token must not be set",
},
{
name: "oidc provider and token mutually exclusive",
args: triggerReceiverFlags{receiverType: genericOIDCReceiver, oidcProvider: "github", oidcToken: "x"},
wantErr: "mutually exclusive",
},
{
name: "oidc invalid provider",
args: triggerReceiverFlags{receiverType: genericOIDCReceiver, oidcProvider: "gitlab"},
wantErr: "invalid --oidc-provider",
},
{
name: "oidc audience requires provider",
args: triggerReceiverFlags{receiverType: genericOIDCReceiver, oidcToken: "x", oidcAudience: "aud"},
wantErr: "--oidc-audience can only be set together with --oidc-provider",
},
{
name: "oidc with provider is valid",
args: triggerReceiverFlags{receiverType: genericOIDCReceiver, oidcProvider: "forgejo", oidcAudience: "aud"},
},
{
name: "oidc with token is valid",
args: triggerReceiverFlags{receiverType: genericOIDCReceiver, oidcToken: "x"},
},
{
name: "oidc without provider or token is valid (env fallback)",
args: triggerReceiverFlags{receiverType: genericOIDCReceiver},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resetTriggerReceiverArgs(t)
triggerReceiverArgs = tt.args
err := validateTriggerReceiverArgs()
if tt.wantErr == "" {
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
return
}
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got: %v", tt.wantErr, err)
}
})
}
}
func TestTriggerReceiverRun(t *testing.T) {
const name = "my-receiver"
const ns = "default"
const token = "my-token"
expectedPath := (&notificationv1.Receiver{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns},
}).GetWebhookPath(token)
expectedOIDCPath := (&notificationv1.Receiver{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns},
}).GetWebhookPath("")
t.Run("generic sends payload with default headers", func(t *testing.T) {
resetTriggerReceiverArgs(t)
var got *http.Request
var gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r
b, _ := io.ReadAll(r.Body)
gotBody = string(b)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
triggerReceiverArgs.url = srv.URL
triggerReceiverArgs.token = token
triggerReceiverArgs.payload = `{"hello":"world"}`
if err := triggerReceiverCmdRun(nil, []string{name}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.URL.Path != expectedPath {
t.Errorf("path = %q, want %q", got.URL.Path, expectedPath)
}
if got.Method != http.MethodPost {
t.Errorf("method = %q, want POST", got.Method)
}
if ct := got.Header.Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
if ua := got.Header.Get("User-Agent"); !strings.HasPrefix(ua, "flux/v") {
t.Errorf("User-Agent = %q, want prefix flux/v", ua)
}
if gotBody != `{"hello":"world"}` {
t.Errorf("body = %q", gotBody)
}
})
t.Run("generic-hmac sets X-Signature", func(t *testing.T) {
resetTriggerReceiverArgs(t)
var sig string
payload := `{"a":1}`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sig = r.Header.Get("X-Signature")
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
triggerReceiverArgs.url = srv.URL
triggerReceiverArgs.token = token
triggerReceiverArgs.receiverType = notificationv1.GenericHMACReceiver
triggerReceiverArgs.payload = payload
if err := triggerReceiverCmdRun(nil, []string{name}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
mac := hmac.New(sha256.New, []byte(token))
mac.Write([]byte(payload))
want := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if sig != want {
t.Errorf("X-Signature = %q, want %q", sig, want)
}
})
t.Run("generic-oidc with --oidc-token sets bearer and empty-token path", func(t *testing.T) {
resetTriggerReceiverArgs(t)
var auth, path string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth = r.Header.Get("Authorization")
path = r.URL.Path
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
triggerReceiverArgs.url = srv.URL
triggerReceiverArgs.receiverType = genericOIDCReceiver
triggerReceiverArgs.oidcToken = "the-oidc-token"
if err := triggerReceiverCmdRun(nil, []string{name}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if auth != "Bearer the-oidc-token" {
t.Errorf("Authorization = %q, want Bearer the-oidc-token", auth)
}
if path != expectedOIDCPath {
t.Errorf("path = %q, want %q (empty token salt)", path, expectedOIDCPath)
}
})
t.Run("generic-oidc reads default env var", func(t *testing.T) {
resetTriggerReceiverArgs(t)
t.Setenv(defaultOIDCTokenEnvVar, "env-oidc-token")
var auth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth = r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
triggerReceiverArgs.url = srv.URL
triggerReceiverArgs.receiverType = genericOIDCReceiver
if err := triggerReceiverCmdRun(nil, []string{name}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if auth != "Bearer env-oidc-token" {
t.Errorf("Authorization = %q, want Bearer env-oidc-token", auth)
}
})
t.Run("non-2xx response is an error", func(t *testing.T) {
resetTriggerReceiverArgs(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("nope"))
}))
defer srv.Close()
triggerReceiverArgs.url = srv.URL
triggerReceiverArgs.token = token
err := triggerReceiverCmdRun(nil, []string{name})
if err == nil || !strings.Contains(err.Error(), "nope") {
t.Fatalf("expected error containing response body, got: %v", err)
}
})
t.Run("retries on retryable status then succeeds", func(t *testing.T) {
resetTriggerReceiverArgs(t)
var attempts int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if atomic.AddInt32(&attempts, 1) < 3 {
w.WriteHeader(http.StatusNotFound) // transient: path not registered yet
return
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
triggerReceiverArgs.url = srv.URL
triggerReceiverArgs.token = token
triggerReceiverArgs.retries = 5
triggerReceiverArgs.retryDelay = time.Millisecond
if err := triggerReceiverCmdRun(nil, []string{name}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := atomic.LoadInt32(&attempts); got != 3 {
t.Errorf("attempts = %d, want 3", got)
}
})
t.Run("does not retry non-retryable status", func(t *testing.T) {
resetTriggerReceiverArgs(t)
var attempts int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&attempts, 1)
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
triggerReceiverArgs.url = srv.URL
triggerReceiverArgs.token = token
triggerReceiverArgs.retries = 5
triggerReceiverArgs.retryDelay = time.Millisecond
if err := triggerReceiverCmdRun(nil, []string{name}); err == nil {
t.Fatal("expected error")
}
if got := atomic.LoadInt32(&attempts); got != 1 {
t.Errorf("attempts = %d, want 1 (no retry on 403)", got)
}
})
t.Run("returns error after exhausting retries", func(t *testing.T) {
resetTriggerReceiverArgs(t)
var attempts int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&attempts, 1)
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer srv.Close()
triggerReceiverArgs.url = srv.URL
triggerReceiverArgs.token = token
triggerReceiverArgs.retries = 2
triggerReceiverArgs.retryDelay = time.Millisecond
if err := triggerReceiverCmdRun(nil, []string{name}); err == nil {
t.Fatal("expected error after exhausting retries")
}
if got := atomic.LoadInt32(&attempts); got != 3 {
t.Errorf("attempts = %d, want 3 (1 initial + 2 retries)", got)
}
})
}