mirror of
https://github.com/fluxcd/flux2.git
synced 2026-05-23 01:45:53 +00:00
Merge 4bfdb6d459 into 9d9e56208c
This commit is contained in:
commit
eb2ee6325c
3 changed files with 752 additions and 0 deletions
31
cmd/flux/trigger.go
Normal file
31
cmd/flux/trigger.go
Normal 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)
|
||||
}
|
||||
368
cmd/flux/trigger_receiver.go
Normal file
368
cmd/flux/trigger_receiver.go
Normal 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 := ¬ificationv1.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
|
||||
}
|
||||
}
|
||||
353
cmd/flux/trigger_receiver_test.go
Normal file
353
cmd/flux/trigger_receiver_test.go
Normal 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 := (¬ificationv1.Receiver{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns},
|
||||
}).GetWebhookPath(token)
|
||||
expectedOIDCPath := (¬ificationv1.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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue