diff --git a/.github/workflows/functional-test-noncloud.yaml b/.github/workflows/functional-test-noncloud.yaml index 77a741fabd..0e688cea5d 100644 --- a/.github/workflows/functional-test-noncloud.yaml +++ b/.github/workflows/functional-test-noncloud.yaml @@ -246,6 +246,7 @@ jobs: statestore-noncloud, samples-noncloud, ucp-noncloud, + authz-noncloud, datastoresrp-noncloud, dynamicrp-noncloud, upgrade-noncloud, @@ -462,6 +463,12 @@ jobs: if: matrix.name == 'database-noncloud' run: echo "DATABASE_INSTALL_ARGS=--set database.enabled=true" >> "${GITHUB_ENV}" + - name: Enable the authorization dry run + # Installs the authz leg with a non-default authorization mode so its smoke test verifies + # that every component reads the mode from the Helm values. + if: matrix.name == 'authz-noncloud' + run: echo "AUTHZ_INSTALL_ARGS=--set global.rbac.dryRun=true" >> "${GITHUB_ENV}" + - name: Wait for Deployment Engine image to be available # When this run was triggered by deployment-engine's publish dispatch, DE_TAG # names an image that repo just pushed to GHCR. The dispatch that starts this @@ -537,6 +544,9 @@ jobs: # default Kubernetes API server store. Empty for all other legs. RAD_COMMAND="$RAD_COMMAND ${DATABASE_INSTALL_ARGS:-}" + # For the authz leg, install with the authorization dry run enabled. Empty for all other legs. + RAD_COMMAND="$RAD_COMMAND ${AUTHZ_INSTALL_ARGS:-}" + echo "*** Installing Radius to Kubernetes ***" eval $RAD_COMMAND diff --git a/build/test.mk b/build/test.mk index b40e200307..2c34afbfc2 100644 --- a/build/test.mk +++ b/build/test.mk @@ -156,7 +156,7 @@ test-functional-all: test-functional-ucp test-functional-kubernetes test-functio .PHONY: test-functional-all-noncloud # Run all functional tests that do not require cloud resources -test-functional-all-noncloud: test-functional-ucp-noncloud test-functional-kubernetes-noncloud test-functional-corerp-noncloud test-functional-cli-noncloud test-functional-msgrp-noncloud test-functional-daprrp-noncloud test-functional-datastoresrp-noncloud test-functional-samples-noncloud test-functional-dynamicrp-noncloud ## Runs all functional tests that do not require cloud resources +test-functional-all-noncloud: test-functional-ucp-noncloud test-functional-kubernetes-noncloud test-functional-corerp-noncloud test-functional-cli-noncloud test-functional-msgrp-noncloud test-functional-daprrp-noncloud test-functional-datastoresrp-noncloud test-functional-samples-noncloud test-functional-dynamicrp-noncloud test-functional-authz-noncloud ## Runs all functional tests that do not require cloud resources .PHONY: test-functional-all-cloud # Run all functional tests that require cloud resources @@ -169,6 +169,10 @@ test-functional-ucp: test-functional-ucp-noncloud test-functional-ucp-cloud ## R test-functional-ucp-noncloud: ## Runs UCP functional tests that do not require cloud resources CGO_ENABLED=1 $(GOTEST_TOOL) ./test/functional-portable/ucp/noncloud/... -timeout ${TEST_TIMEOUT} -v -parallel 5 $(GOTEST_OPTS) +.PHONY: test-functional-authz-noncloud +test-functional-authz-noncloud: ## Runs internal component authorization functional tests that do not require cloud resources + CGO_ENABLED=1 $(GOTEST_TOOL) ./test/functional-portable/authz/noncloud/... -timeout ${TEST_TIMEOUT} -v -parallel 5 $(GOTEST_OPTS) + .PHONY: test-functional-ucp-cloud test-functional-ucp-cloud: ## Runs UCP functional tests that require cloud resources CGO_ENABLED=1 $(GOTEST_TOOL) ./test/functional-portable/ucp/cloud/... -timeout ${TEST_TIMEOUT} -v -parallel 5 $(GOTEST_OPTS) diff --git a/docs/contributing/contributing-code/contributing-code-tests/running-functional-tests.md b/docs/contributing/contributing-code/contributing-code-tests/running-functional-tests.md index 78ca287724..c77877e4f6 100644 --- a/docs/contributing/contributing-code/contributing-code-tests/running-functional-tests.md +++ b/docs/contributing/contributing-code/contributing-code-tests/running-functional-tests.md @@ -61,7 +61,9 @@ make test-functional-daprrp make test-functional-datastoresrp ``` -To run a single group directly, call its `make` target — for example `make test-functional-corerp-noncloud` for the non-cloud Core RP tests, or `make test-functional-all-noncloud` for the standard non-cloud groups. The groups (`ucp`, `kubernetes`, `corerp`, `cli`, `msgrp`, `daprrp`, `datastoresrp`, `dynamicrp`, `samples`, `upgrade`, `multicluster`, `database`, and `statestore`) and the variants each group supports are defined in [`build/test.mk`](../../../../build/test.mk). +To run a single group directly, call its `make` target — for example `make test-functional-corerp-noncloud` for the non-cloud Core RP tests, or `make test-functional-all-noncloud` for the standard non-cloud groups. The groups (`ucp`, `authz`, `kubernetes`, `corerp`, `cli`, `msgrp`, `daprrp`, `datastoresrp`, `dynamicrp`, `samples`, `upgrade`, `multicluster`, `database`, and `statestore`) and the variants each group supports are defined in [`build/test.mk`](../../../../build/test.mk). + +The `authz` group (`make test-functional-authz-noncloud`) covers internal component authorization. Its smoke test reads the installed Helm values and checks that every Radius component logs the matching `authz mode=`, so it passes on any install. The `authz-noncloud` CI leg installs Radius with `--set global.rbac.dryRun=true` so the test checks a non-default mode; to reproduce that locally, run `rad install kubernetes --set global.rbac.dryRun=true` before the tests. You can also run or debug individual tests from VS Code. diff --git a/test/functional-portable/authz/noncloud/boundaries_test.go b/test/functional-portable/authz/noncloud/boundaries_test.go new file mode 100644 index 0000000000..9df2b0b4d1 --- /dev/null +++ b/test/functional-portable/authz/noncloud/boundaries_test.go @@ -0,0 +1,73 @@ +/* +Copyright 2026 The Radius 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 authz + +import "testing" + +// The tests below are placeholders for the trust boundaries described in the internal +// component authorization design (eng/design-notes/security/2026-09-internal-component-authorization.md). +// Each is enabled by the pull request that implements the boundary. Expected results use the +// design's error codes; see pkg/authz for their HTTP status codes. + +// Expected: a pod without a Radius component certificate calling applications-rp directly +// fails the mTLS handshake (logged as PeerCertificateInvalid) or is rejected with +// InvalidAuthenticationInfo (401). +func Test_Boundary_UnauthenticatedCallToApplicationsRP(t *testing.T) { + t.Skip("enabled by A4") +} + +// Expected: a request to UCP carrying a forged x-remote-user header, without a verified +// Kubernetes proxy certificate, is rejected with InvalidAuthenticationInfo (401). +func Test_Boundary_ForgedRemoteUserHeader(t *testing.T) { + t.Skip("enabled by A6") +} + +// Expected: a deployment engine request for a resource outside the execution record's +// actions or targets is rejected by UCP with GrantScopeExceeded (403). +func Test_Boundary_RecordScopeExceeded(t *testing.T) { + t.Skip("enabled by B3") +} + +// Expected: a resource provider or worker receiving inputs that do not hash to the +// operation's inputHash rejects the work with OperationInputMismatch (409). +func Test_Boundary_OperationInputMismatch(t *testing.T) { + t.Skip("enabled by B5") +} + +// Expected: a controller object in a namespace that is not mapped to the target resource +// group or environment is rejected with AuthorizationFailed (403) before any change starts. +func Test_Boundary_ControllerNamespaceMapping(t *testing.T) { + t.Skip("enabled by C3") +} + +// Expected: a provider writing to the data-access service for an operation it is not +// assigned to is rejected with OperationNotAssigned (403). +func Test_Boundary_UnassignedDataAccessWrite(t *testing.T) { + t.Skip("enabled by D2") +} + +// Expected: an application template that creates a ClusterRole is denied by the +// namespace-limited template identity with AuthorizationFailed (403). +func Test_Boundary_TemplateClusterRoleDenied(t *testing.T) { + t.Skip("enabled by D7") +} + +// Expected: an application workload that references a control-plane Secret is rejected +// by admission with AdmissionPolicyDenied (403). +func Test_Boundary_ControlPlaneSecretReferenceDenied(t *testing.T) { + t.Skip("enabled by E2") +} diff --git a/test/functional-portable/authz/noncloud/mode_test.go b/test/functional-portable/authz/noncloud/mode_test.go new file mode 100644 index 0000000000..34629d8d65 --- /dev/null +++ b/test/functional-portable/authz/noncloud/mode_test.go @@ -0,0 +1,205 @@ +/* +Copyright 2026 The Radius 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 authz + +import ( + "context" + "io" + "regexp" + "testing" + "time" + + "github.com/stretchr/testify/require" + helm "helm.sh/helm/v4/pkg/action" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/cli-runtime/pkg/genericclioptions" + k8s "k8s.io/client-go/kubernetes" + + "github.com/radius-project/radius/pkg/authz" + "github.com/radius-project/radius/pkg/cli/kubernetes" + "github.com/radius-project/radius/test" +) + +const ( + radiusNamespace = "radius-system" + radiusReleaseName = "radius" + readyTimeout = 2 * time.Minute +) + +// component is a Radius service that reads the authorization mode at startup. +type component struct { + deployment string + container string +} + +var components = []component{ + {deployment: "ucp", container: "ucp"}, + {deployment: "applications-rp", container: "applications-rp"}, + {deployment: "dynamic-rp", container: "dynamic-rp"}, + {deployment: "controller", container: "controller"}, +} + +var modeLogPattern = regexp.MustCompile(`authz mode=(\w+)`) + +// Test_AuthzMode_LoggedByEveryComponent verifies the control plane is up and that every +// Radius component logs the authorization mode derived from the installed Helm values. +func Test_AuthzMode_LoggedByEveryComponent(t *testing.T) { + options := test.NewTestOptions(t) + want := installedMode(t) + t.Logf("Helm values select authz mode=%s", want) + + for _, c := range components { + t.Run(c.deployment, func(t *testing.T) { + t.Parallel() + ctx := t.Context() + + requireDeploymentReady(ctx, t, options.K8sClient, c.deployment) + + pods, err := options.K8sClient.CoreV1().Pods(radiusNamespace).List(ctx, metav1.ListOptions{ + LabelSelector: "app.kubernetes.io/name=" + c.deployment, + }) + require.NoError(t, err) + require.NotEmpty(t, pods.Items, "no pods found for %s", c.deployment) + + for _, pod := range pods.Items { + if pod.Status.Phase != corev1.PodRunning { + continue + } + logs := podLogs(ctx, t, options.K8sClient, pod.Name, c.container) + got, ok := loggedMode(logs) + require.Truef(t, ok, "pod %s did not log %q", pod.Name, "authz mode=") + require.Equalf(t, string(want), got, "pod %s logged the wrong authz mode", pod.Name) + } + }) + } +} + +func Test_ExpectedMode(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + values map[string]any + want authz.Mode + }{ + {name: "no global values", values: map[string]any{}, want: authz.ModeOff}, + {name: "no rbac values", values: rbacValues(nil), want: authz.ModeOff}, + {name: "both false", values: rbacValues(map[string]any{"enabled": false, "dryRun": false}), want: authz.ModeOff}, + {name: "dry run", values: rbacValues(map[string]any{"dryRun": true}), want: authz.ModeDryRun}, + {name: "enforce", values: rbacValues(map[string]any{"enabled": true}), want: authz.ModeEnforce}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, expectedMode(tt.values)) + }) + } +} + +func Test_LoggedMode(t *testing.T) { + t.Parallel() + + got, ok := loggedMode(`{"level":"info","msg":"authz mode=dryRun","authz.mode":"dryRun"}`) + require.True(t, ok) + require.Equal(t, "dryRun", got) + + _, ok = loggedMode(`{"level":"info","msg":"Loaded options"}`) + require.False(t, ok) +} + +func rbacValues(rbac map[string]any) map[string]any { + global := map[string]any{} + if rbac != nil { + global["rbac"] = rbac + } + return map[string]any{"global": global} +} + +// installedMode reads the installed Radius Helm release and returns the authorization mode +// its values select. +func installedMode(t *testing.T) authz.Mode { + t.Helper() + + contextName, err := kubernetes.GetContextFromConfigFileIfExists("", "") + require.NoError(t, err) + + flags := genericclioptions.NewConfigFlags(false) + namespace := radiusNamespace + flags.Namespace = &namespace + if contextName != "" { + flags.Context = &contextName + } + + cfg := &helm.Configuration{} + require.NoError(t, cfg.Init(flags, radiusNamespace, "secret")) + + get := helm.NewGetValues(cfg) + get.AllValues = true + values, err := get.Run(radiusReleaseName) + require.NoError(t, err, "failed to read Helm values for release %q", radiusReleaseName) + + return expectedMode(values) +} + +// expectedMode mirrors the chart's radius.authz.mode helper. +func expectedMode(values map[string]any) authz.Mode { + global, _ := values["global"].(map[string]any) + rbac, _ := global["rbac"].(map[string]any) + if dryRun, _ := rbac["dryRun"].(bool); dryRun { + return authz.ModeDryRun + } + if enabled, _ := rbac["enabled"].(bool); enabled { + return authz.ModeEnforce + } + return authz.ModeOff +} + +func loggedMode(logs string) (string, bool) { + match := modeLogPattern.FindStringSubmatch(logs) + if match == nil { + return "", false + } + return match[1], true +} + +func requireDeploymentReady(ctx context.Context, t *testing.T, client k8s.Interface, name string) { + t.Helper() + + err := wait.PollUntilContextTimeout(ctx, 2*time.Second, readyTimeout, true, func(ctx context.Context) (bool, error) { + deployment, err := client.AppsV1().Deployments(radiusNamespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, err + } + return deployment.Status.ReadyReplicas > 0, nil + }) + require.NoError(t, err, "deployment %s/%s is not ready", radiusNamespace, name) +} + +func podLogs(ctx context.Context, t *testing.T, client k8s.Interface, pod, container string) string { + t.Helper() + + stream, err := client.CoreV1().Pods(radiusNamespace).GetLogs(pod, &corev1.PodLogOptions{Container: container}).Stream(ctx) + require.NoError(t, err, "failed to read logs for %s/%s", pod, container) + defer stream.Close() + + data, err := io.ReadAll(stream) + require.NoError(t, err) + return string(data) +} diff --git a/test/rogue/rogue.go b/test/rogue/rogue.go new file mode 100644 index 0000000000..2b2126be92 --- /dev/null +++ b/test/rogue/rogue.go @@ -0,0 +1,313 @@ +/* +Copyright 2026 The Radius 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 rogue simulates a compromised workload or component inside the cluster for +// authorization functional tests. It runs a pod with curl and calls internal Radius +// endpoints from it, optionally presenting a TLS certificate from a mounted Secret. +package rogue + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "path" + "slices" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/remotecommand" + utilexec "k8s.io/client-go/util/exec" + + "github.com/radius-project/radius/pkg/to" +) + +const ( + // DefaultImage is the image used for the rogue pod. It must provide curl and sleep. + // It is the project's GHCR mirror of docker.io/curlimages/curl:8.17.0; see + // docs/contributing/contributing-code/contributing-code-tests/tests-images-pushtoghcr.md. + DefaultImage = "ghcr.io/radius-project/mirror/curlimages/curl:8.17.0" + + // TLSMountPath is where the Secret named by Options.TLSSecretName is mounted. + // Requests read tls.crt, tls.key, and ca.crt from this directory. + TLSMountPath = "/etc/radius-rogue/tls" + + containerName = "rogue" + podNamePrefix = "radius-rogue-" + labelKey = "app.kubernetes.io/name" + labelValue = "radius-rogue-client" + tlsVolumeName = "tls" + statusMarker = "\nradius-rogue-status:" + curlTimeout = "30" + readyTimeout = 2 * time.Minute + + // runAsUser is the curl_user UID in DefaultImage. The image names its user rather than + // its UID, so it must be set explicitly for runAsNonRoot to pass. + runAsUser = int64(100) +) + +// Options configures the rogue pod. +type Options struct { + // Namespace is the existing namespace to create the pod in. + Namespace string + + // Image overrides DefaultImage. + Image string + + // TLSSecretName optionally names a kubernetes.io/tls Secret in Namespace to mount at + // TLSMountPath, so requests can present its certificate to act as a component. + TLSSecretName string +} + +// Request is an HTTP request sent from the rogue pod. +type Request struct { + // Method defaults to GET. + Method string + URL string + Headers map[string]string + Body string + + // ClientCert presents tls.crt and tls.key from the mounted TLS Secret. + ClientCert bool + + // CACert verifies the server with ca.crt from the mounted TLS Secret. + CACert bool + + // Insecure skips server certificate verification. + Insecure bool +} + +// Response is the result of a Request. +type Response struct { + // StatusCode is the HTTP status, or 0 when no HTTP response was received + // (for example, a refused connection or failed TLS handshake). + StatusCode int + Body string + + // ExitCode is curl's exit code. Non-zero means the request did not complete. + ExitCode int + Stderr string +} + +// Client sends requests from a rogue pod. +type Client struct { + k8s kubernetes.Interface + config *rest.Config + pod *corev1.Pod + hasTLS bool +} + +// New creates the rogue pod, waits for it to run, and deletes it when the test finishes. +func New(t *testing.T, k8s kubernetes.Interface, config *rest.Config, options Options) *Client { + t.Helper() + require.NotEmpty(t, options.Namespace, "rogue: Namespace is required") + + ctx := t.Context() + pods := k8s.CoreV1().Pods(options.Namespace) + pod, err := pods.Create(ctx, newPod(options), metav1.CreateOptions{}) + require.NoError(t, err, "rogue: failed to create pod") + t.Logf("Created rogue pod %s/%s", pod.Namespace, pod.Name) + + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + err := pods.Delete(ctx, pod.Name, metav1.DeleteOptions{GracePeriodSeconds: to.Ptr(int64(0))}) + if err != nil { + t.Logf("rogue: failed to delete pod %s/%s: %v", pod.Namespace, pod.Name, err) + } + }) + + err = wait.PollUntilContextTimeout(ctx, time.Second, readyTimeout, true, func(ctx context.Context) (bool, error) { + current, err := pods.Get(ctx, pod.Name, metav1.GetOptions{}) + if err != nil { + return false, err + } + pod = current + if pod.Status.Phase == corev1.PodFailed || pod.Status.Phase == corev1.PodSucceeded { + return false, fmt.Errorf("pod exited with phase %s", pod.Status.Phase) + } + return pod.Status.Phase == corev1.PodRunning, nil + }) + require.NoError(t, err, "rogue: pod %s/%s did not start", pod.Namespace, pod.Name) + + return &Client{k8s: k8s, config: config, pod: pod, hasTLS: options.TLSSecretName != ""} +} + +// Pod returns the rogue pod. +func (c *Client) Pod() *corev1.Pod { + return c.pod +} + +// Do sends req from the rogue pod. It returns an error only when the request could not be +// run; HTTP errors and curl failures are reported in the Response. +func (c *Client) Do(ctx context.Context, req Request) (Response, error) { + args, err := curlArgs(req, c.hasTLS) + if err != nil { + return Response{}, err + } + + execReq := c.k8s.CoreV1().RESTClient().Post(). + Resource("pods"). + Namespace(c.pod.Namespace). + Name(c.pod.Name). + SubResource("exec"). + VersionedParams(&corev1.PodExecOptions{ + Container: containerName, + Command: args, + Stdout: true, + Stderr: true, + }, scheme.ParameterCodec) + + executor, err := remotecommand.NewSPDYExecutor(c.config, http.MethodPost, execReq.URL()) + if err != nil { + return Response{}, fmt.Errorf("rogue: creating exec stream: %w", err) + } + + var stdout, stderr bytes.Buffer + resp := Response{} + err = executor.StreamWithContext(ctx, remotecommand.StreamOptions{Stdout: &stdout, Stderr: &stderr}) + resp.Stderr = stderr.String() + + var exitErr utilexec.ExitError + switch { + case errors.As(err, &exitErr): + resp.ExitCode = exitErr.ExitStatus() + case err != nil: + return resp, fmt.Errorf("rogue: exec failed: %w: %s", err, strings.TrimSpace(resp.Stderr)) + } + + resp.StatusCode, resp.Body, err = parseCurlOutput(stdout.String()) + if err != nil && resp.ExitCode != 0 { + // curl can exit before writing the status, for example on invalid arguments. + return resp, nil + } + return resp, err +} + +// newPod builds the rogue pod spec. +func newPod(options Options) *corev1.Pod { + image := options.Image + if image == "" { + image = DefaultImage + } + + container := corev1.Container{ + Name: containerName, + Image: image, + Command: []string{"sleep", "infinity"}, + SecurityContext: &corev1.SecurityContext{ + RunAsNonRoot: to.Ptr(true), + RunAsUser: to.Ptr(runAsUser), + AllowPrivilegeEscalation: to.Ptr(false), + Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}}, + SeccompProfile: &corev1.SeccompProfile{Type: corev1.SeccompProfileTypeRuntimeDefault}, + }, + } + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: podNamePrefix, + Namespace: options.Namespace, + Labels: map[string]string{labelKey: labelValue}, + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + AutomountServiceAccountToken: to.Ptr(false), + TerminationGracePeriodSeconds: to.Ptr(int64(0)), + }, + } + + if options.TLSSecretName != "" { + pod.Spec.Volumes = []corev1.Volume{{ + Name: tlsVolumeName, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{SecretName: options.TLSSecretName}, + }, + }} + container.VolumeMounts = []corev1.VolumeMount{{Name: tlsVolumeName, MountPath: TLSMountPath, ReadOnly: true}} + } + + pod.Spec.Containers = []corev1.Container{container} + return pod +} + +// curlArgs builds the curl command for req. The response body is written to stdout, +// followed by statusMarker and the HTTP status code. +func curlArgs(req Request, hasTLS bool) ([]string, error) { + if req.URL == "" { + return nil, errors.New("rogue: URL is required") + } + if (req.ClientCert || req.CACert) && !hasTLS { + return nil, errors.New("rogue: ClientCert and CACert require Options.TLSSecretName") + } + + method := req.Method + if method == "" { + method = http.MethodGet + } + + args := []string{"curl", "-sS", "--max-time", curlTimeout, "-w", statusMarker + "%{http_code}", "-X", method} + + keys := make([]string, 0, len(req.Headers)) + for k := range req.Headers { + keys = append(keys, k) + } + slices.Sort(keys) + for _, k := range keys { + args = append(args, "-H", k+": "+req.Headers[k]) + } + + if req.Body != "" { + args = append(args, "--data-raw", req.Body) + } + if req.ClientCert { + args = append(args, "--cert", path.Join(TLSMountPath, "tls.crt"), "--key", path.Join(TLSMountPath, "tls.key")) + } + if req.CACert { + args = append(args, "--cacert", path.Join(TLSMountPath, "ca.crt")) + } + if req.Insecure { + args = append(args, "-k") + } + + return append(args, req.URL), nil +} + +// parseCurlOutput splits curl's stdout into the response body and HTTP status code. +// A status of 000 (no response) is returned as 0. +func parseCurlOutput(stdout string) (int, string, error) { + i := strings.LastIndex(stdout, statusMarker) + if i < 0 { + return 0, stdout, fmt.Errorf("rogue: status marker not found in curl output %q", stdout) + } + + status, err := strconv.Atoi(strings.TrimSpace(stdout[i+len(statusMarker):])) + if err != nil { + return 0, stdout, fmt.Errorf("rogue: invalid status in curl output: %w", err) + } + + return status, stdout[:i], nil +} diff --git a/test/rogue/rogue_test.go b/test/rogue/rogue_test.go new file mode 100644 index 0000000000..ce09354ac6 --- /dev/null +++ b/test/rogue/rogue_test.go @@ -0,0 +1,218 @@ +/* +Copyright 2026 The Radius 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 rogue + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" +) + +func Test_CurlArgs(t *testing.T) { + t.Parallel() + + base := []string{"curl", "-sS", "--max-time", "30", "-w", statusMarker + "%{http_code}"} + with := func(args ...string) []string { + return append(append([]string{}, base...), args...) + } + + tests := []struct { + name string + req Request + hasTLS bool + want []string + wantErr string + }{ + { + name: "defaults to GET", + req: Request{URL: "http://applications-rp.radius-system:5443/apis"}, + want: with("-X", "GET", "http://applications-rp.radius-system:5443/apis"), + }, + { + name: "headers are sorted and body is sent raw", + req: Request{ + Method: "PUT", + URL: "http://x/y", + Headers: map[string]string{"x-remote-user": "admin", "Content-Type": "application/json"}, + Body: `{"a":"$b"}`, + }, + want: with( + "-X", "PUT", + "-H", "Content-Type: application/json", + "-H", "x-remote-user: admin", + "--data-raw", `{"a":"$b"}`, + "http://x/y"), + }, + { + name: "client certificate and CA from the mounted secret", + req: Request{URL: "https://ucp.radius-system/apis", ClientCert: true, CACert: true}, + hasTLS: true, + want: with( + "-X", "GET", + "--cert", TLSMountPath+"/tls.crt", + "--key", TLSMountPath+"/tls.key", + "--cacert", TLSMountPath+"/ca.crt", + "https://ucp.radius-system/apis"), + }, + { + name: "insecure skips server verification", + req: Request{URL: "https://x", Insecure: true}, + want: with("-X", "GET", "-k", "https://x"), + }, + { + name: "URL is required", + req: Request{}, + wantErr: "URL is required", + }, + { + name: "client certificate requires a TLS secret", + req: Request{URL: "https://x", ClientCert: true}, + wantErr: "TLSSecretName", + }, + { + name: "CA certificate requires a TLS secret", + req: Request{URL: "https://x", CACert: true}, + wantErr: "TLSSecretName", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := curlArgs(tt.req, tt.hasTLS) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func Test_ParseCurlOutput(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + stdout string + wantStatus int + wantBody string + wantErr bool + }{ + { + name: "status and body", + stdout: `{"value":[]}` + statusMarker + "200", + wantStatus: 200, + wantBody: `{"value":[]}`, + }, + { + name: "empty body", + stdout: statusMarker + "401", + wantStatus: 401, + }, + { + name: "body containing newlines", + stdout: "line1\nline2\n" + statusMarker + "403\n", + wantStatus: 403, + wantBody: "line1\nline2\n", + }, + { + name: "no HTTP response", + stdout: statusMarker + "000", + wantStatus: 0, + }, + { + name: "missing marker", + stdout: "partial output", + wantErr: true, + }, + { + name: "non-numeric status", + stdout: statusMarker + "abc", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + status, body, err := parseCurlOutput(tt.stdout) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.wantStatus, status) + require.Equal(t, tt.wantBody, body) + }) + } +} + +func Test_DefaultImage_UsesMirror(t *testing.T) { + t.Parallel() + + // Test images are pulled from the project's GHCR mirror rather than Docker Hub. + require.True(t, strings.HasPrefix(DefaultImage, "ghcr.io/radius-project/mirror/"), DefaultImage) +} + +func Test_NewPod(t *testing.T) { + t.Parallel() + + t.Run("defaults", func(t *testing.T) { + t.Parallel() + + pod := newPod(Options{Namespace: "rogue-ns"}) + + require.Equal(t, "rogue-ns", pod.Namespace) + require.Equal(t, podNamePrefix, pod.GenerateName) + require.Equal(t, labelValue, pod.Labels[labelKey]) + require.Equal(t, corev1.RestartPolicyNever, pod.Spec.RestartPolicy) + require.False(t, *pod.Spec.AutomountServiceAccountToken) + require.Len(t, pod.Spec.Containers, 1) + require.Empty(t, pod.Spec.Volumes) + + container := pod.Spec.Containers[0] + require.Equal(t, DefaultImage, container.Image) + require.Equal(t, []string{"sleep", "infinity"}, container.Command) + require.True(t, *container.SecurityContext.RunAsNonRoot) + require.Equal(t, runAsUser, *container.SecurityContext.RunAsUser) + require.False(t, *container.SecurityContext.AllowPrivilegeEscalation) + require.Equal(t, []corev1.Capability{"ALL"}, container.SecurityContext.Capabilities.Drop) + require.Equal(t, corev1.SeccompProfileTypeRuntimeDefault, container.SecurityContext.SeccompProfile.Type) + require.Empty(t, container.VolumeMounts) + }) + + t.Run("custom image and TLS secret", func(t *testing.T) { + t.Parallel() + + pod := newPod(Options{Namespace: "ns", Image: "example.com/curl:1", TLSSecretName: "ucp-tls"}) + + container := pod.Spec.Containers[0] + require.Equal(t, "example.com/curl:1", container.Image) + require.Len(t, pod.Spec.Volumes, 1) + require.Equal(t, "ucp-tls", pod.Spec.Volumes[0].Secret.SecretName) + require.Len(t, container.VolumeMounts, 1) + require.Equal(t, TLSMountPath, container.VolumeMounts[0].MountPath) + require.True(t, container.VolumeMounts[0].ReadOnly) + require.Equal(t, pod.Spec.Volumes[0].Name, container.VolumeMounts[0].Name) + }) +}