diff --git a/.github/scripts/publish-recipes.sh b/.github/scripts/publish-recipes.sh index 4b3271c762a..d6874364379 100755 --- a/.github/scripts/publish-recipes.sh +++ b/.github/scripts/publish-recipes.sh @@ -65,5 +65,9 @@ for RECIPE in $(find "$DIRECTORY" -type f -name "*.bicep"); do echo "Publishing $RECIPE to $PUBLISH_REF" echo "- $PUBLISH_REF" >>$GITHUB_STEP_SUMMARY - rad bicep publish --file $RECIPE --target "br:$PUBLISH_REF" + PUBLISH_FLAGS=() + if [[ "${PLAIN_HTTP:-false}" == "true" ]]; then + PUBLISH_FLAGS+=(--plain-http) + fi + rad bicep publish --file "$RECIPE" --target "br:$PUBLISH_REF" "${PUBLISH_FLAGS[@]}" done diff --git a/.gitignore b/.gitignore index 49e3c963e5a..39184a18c3a 100644 --- a/.gitignore +++ b/.gitignore @@ -76,6 +76,11 @@ debug_files/* drad *.lock +# Local-only bicepconfig override written by `make debug-publish-bicep-types`. +# Lives next to the .bicep templates so bicep's nearest-config search picks it up +# over the tracked sibling at .../resources/bicepconfig.json. Removed by debug-stop. +test/functional-portable/dynamicrp/noncloud/resources/testdata/bicepconfig.json + # Demo app demo/* demo diff --git a/build/debug.mk b/build/debug.mk index a3f6f46670e..fa4444ee82c 100644 --- a/build/debug.mk +++ b/build/debug.mk @@ -35,7 +35,67 @@ POSTGRES_ADMIN_CONNECTION ?= postgresql://postgres:radius_pass@localhost:5432/po POSTGRES_FALLBACK_CONNECTION ?= postgresql://$(shell whoami)@localhost:5432/postgres POSTGRES_CONTAINER_NAME ?= radius-postgres -.PHONY: debug-setup debug-start debug-stop debug-status debug-help debug-build-all debug-build-ucpd debug-build-applications-rp debug-build-controller debug-build-dynamic-rp debug-build-rad debug-deployment-engine-pull debug-deployment-engine-start debug-deployment-engine-deploy debug-deployment-engine-port-forward debug-deployment-engine-stop debug-deployment-engine-status debug-deployment-engine-logs debug-register-recipes debug-env-init debug-check-prereqs +# Local OCI registry for test Bicep recipes. applications-rp runs as a host +# process in debug mode, so it pulls recipes from localhost directly. +DEBUG_REGISTRY_NAME ?= radius-debug-registry +DEBUG_REGISTRY_PORT ?= 5000 +DEBUG_REGISTRY_HOST ?= localhost:$(DEBUG_REGISTRY_PORT) + +# In-cluster Git HTTP backend used by the kubernetes-noncloud Flux tests. +# Values mirror .github/workflows/functional-test-noncloud.yaml so that local +# runs of `make test-functional-kubernetes-noncloud` Just Work after +# `make debug-start`. +DEBUG_GIT_HTTP_NAMESPACE ?= git-http-backend +DEBUG_GIT_HTTP_USERNAME ?= testuser +DEBUG_GIT_HTTP_PASSWORD ?= not-a-secret-password +DEBUG_GIT_HTTP_EMAIL ?= testuser@radapp.io +DEBUG_GIT_HTTP_LOCAL_PORT ?= 30080 + +# Flux source-controller is required by the kubernetes-noncloud Flux tests. +# Version must match the locally installed `flux` CLI to avoid the compatibility +# check failing during `flux install`. +DEBUG_FLUX_NAMESPACE ?= flux-system +DEBUG_FLUX_VERSION ?= $(shell flux --version 2>/dev/null | awk '{print $$NF}') + +# Bicep extension types published to the local debug registry. +# Mirrors .github/workflows/functional-test-noncloud.yaml so dynamicrp-noncloud +# tests resolve `radius` / `testresources` extensions from localhost rather than +# the public ACR (which drifts from source `*.yaml`). +# +# Strategy: write a gitignored bicepconfig.json INSIDE the testdata/ dir. Bicep +# walks up from each .bicep template looking for the nearest bicepconfig.json, +# so this override wins over the tracked sibling one folder up — without +# mutating anything that's tracked. +DEBUG_BICEP_VERSION ?= latest +DEBUG_BICEP_TEST_RESOURCES_YAML ?= test/functional-portable/dynamicrp/noncloud/resources/testdata/testresourcetypes.yaml +DEBUG_BICEP_TEST_CONFIG_OVERRIDE ?= test/functional-portable/dynamicrp/noncloud/resources/testdata/bicepconfig.json +# Local file targets for the published bicep extensions. We deliberately avoid +# the local OCI registry here: the bicep CLI's `publish-extension` defaults to +# HTTPS even for `localhost:5000` and has no `--plain-http` flag, so a TLS +# handshake fails. bicepconfig.json's `extensions` block accepts absolute file +# paths to `.tgz` artifacts, which is faster and more deterministic anyway. +DEBUG_BICEP_EXT_DIR ?= $(DEBUG_DEV_ROOT)/bicep-extensions +DEBUG_BICEP_EXT_RADIUS ?= $(DEBUG_BICEP_EXT_DIR)/radius.tgz +DEBUG_BICEP_EXT_TESTRESOURCES ?= $(DEBUG_BICEP_EXT_DIR)/testresources.tgz + +# Contour ingress controller is required by the Gateway functional tests +# (corerp-noncloud) which create `projectcontour.io/v1` HTTPProxy resources. +# Production `rad install kubernetes` installs it; our local-OS-process stack +# bypasses that path, so install it directly via helm. +DEBUG_CONTOUR_NAMESPACE ?= radius-system +DEBUG_CONTOUR_RELEASE ?= contour +DEBUG_CONTOUR_CHART_VERSION ?= 0.1.0 +DEBUG_CONTOUR_HELM_REPO ?= https://projectcontour.github.io/helm-charts + +# In-cluster Terraform module server used by the corerp-noncloud +# TerraformRecipe_* tests. Tests resolve the server via TF_RECIPE_MODULE_SERVER_URL +# (set by test/testutil); CI uses the in-cluster DNS name. For the local stack +# (ARP runs as an OS process), we port-forward the service to localhost:8999. +DEBUG_TF_MODULE_NAMESPACE ?= radius-test-tf-module-server +DEBUG_TF_MODULE_DEPLOYMENT ?= tf-module-server +DEBUG_TF_MODULE_LOCAL_PORT ?= 8999 + +.PHONY: debug-setup debug-start debug-stop debug-status debug-help debug-build-all debug-build-ucpd debug-build-applications-rp debug-build-controller debug-build-dynamic-rp debug-build-rad debug-deployment-engine-pull debug-deployment-engine-start debug-deployment-engine-deploy debug-deployment-engine-port-forward debug-deployment-engine-stop debug-deployment-engine-status debug-deployment-engine-logs debug-register-recipes debug-env-init debug-check-prereqs debug-install-crds debug-start-registry debug-publish-recipes debug-stop-registry debug-install-git-http-backend debug-stop-git-http-backend debug-install-flux debug-install-contour debug-install-tf-module-server debug-stop-tf-module-server debug-publish-bicep-types debug-remove-bicep-types-override debug-help: ## Show debug automation help @echo "Debug Development Automation Commands:" @@ -209,14 +269,34 @@ debug-build-rad: ## Build rad CLI with debug symbols + create drad alias @echo "💡 Use './drad' for debug-configured CLI (preserves 'rad' for your installed version)" debug-start: debug-setup debug-build-all ## Start k3d cluster and all Radius components as OS processes - @echo "Creating k3d cluster..." - @if k3d cluster list | grep -q "radius-debug"; then \ - echo "k3d cluster 'radius-debug' already exists"; \ + @echo "Ensuring k3d cluster 'radius-debug' is running..." + @if k3d cluster list --no-headers 2>/dev/null | awk '{print $$1}' | grep -qx "radius-debug"; then \ + servers=$$(k3d cluster list --no-headers 2>/dev/null | awk '$$1=="radius-debug"{print $$2}'); \ + running=$$(echo "$$servers" | cut -d/ -f1); \ + if [ "$$running" = "0" ]; then \ + echo "k3d cluster 'radius-debug' exists but is stopped — starting it..."; \ + k3d cluster start radius-debug; \ + else \ + echo "k3d cluster 'radius-debug' already running ($$servers servers)"; \ + fi; \ else \ + echo "Creating k3d cluster 'radius-debug'..."; \ k3d cluster create radius-debug --api-port 0.0.0.0:6443 --wait --timeout 60s; \ fi @echo "Switching to k3d context..." + @# If the kubeconfig context is missing (e.g. cluster was created against a + @# different KUBECONFIG, or `kubectl config delete-context` was run), merge + @# the k3d kubeconfig back in before switching. `k3d kubeconfig merge` is + @# idempotent and writes/updates ~/.kube/config. + @if ! kubectl config get-contexts -o name 2>/dev/null | grep -qx "k3d-radius-debug"; then \ + echo " Context 'k3d-radius-debug' missing — merging k3d kubeconfig..."; \ + k3d kubeconfig merge radius-debug --kubeconfig-merge-default --kubeconfig-switch-context >/dev/null; \ + fi @kubectl config use-context k3d-radius-debug + @echo "Ensuring radius-encryption-key secret exists in k3d cluster..." + @chmod +x build/scripts/ensure-encryption-key.sh 2>/dev/null || true + @build/scripts/ensure-encryption-key.sh + @$(MAKE) debug-install-crds @echo "Starting Radius components as OS processes..." @build/scripts/start-radius.sh @echo "Waiting for components to be ready..." @@ -238,10 +318,256 @@ debug-start: debug-setup debug-build-all ## Start k3d cluster and all Radius com fi @echo "Initializing environment resources..." @$(MAKE) debug-env-init + @echo "Publishing test Bicep recipes to the local debug registry..." + @$(MAKE) debug-publish-recipes + @echo "Installing in-cluster Git HTTP backend (for kubernetes-noncloud Flux tests)..." + @$(MAKE) debug-install-git-http-backend + @echo "Installing Flux source-controller (for kubernetes-noncloud Flux tests)..." + @$(MAKE) debug-install-flux + @echo "Installing Contour ingress controller (for Gateway tests)..." + @$(MAKE) debug-install-contour + @echo "Installing Terraform module server (for TerraformRecipe_* tests)..." + @$(MAKE) debug-install-tf-module-server + @echo "Publishing test bicep extensions to the local debug registry..." + @$(MAKE) debug-publish-bicep-types @echo "🚀 All components started and environment initialized!" @echo "📊 Use 'make debug-status' to check component health" @echo "🚢 Use 'make debug-deployment-engine-status' to check deployment engine" +debug-install-crds: ## Apply Radius CRDs (radapp.io and ucp.dev) into the k3d-radius-debug cluster + @echo "Applying Radius CRDs to k3d-radius-debug..." + @kubectl --context k3d-radius-debug apply -f deploy/Chart/crds/radius -f deploy/Chart/crds/ucpd + @echo "✅ Radius CRDs applied" + +debug-start-registry: ## Start a local OCI registry on $(DEBUG_REGISTRY_HOST) for test recipes + @if docker ps --format '{{.Names}}' | grep -qx "$(DEBUG_REGISTRY_NAME)"; then \ + echo "✅ Local registry '$(DEBUG_REGISTRY_NAME)' already running on $(DEBUG_REGISTRY_HOST)"; \ + elif docker ps -a --format '{{.Names}}' | grep -qx "$(DEBUG_REGISTRY_NAME)"; then \ + echo "Starting existing registry container '$(DEBUG_REGISTRY_NAME)'..."; \ + docker start $(DEBUG_REGISTRY_NAME) >/dev/null; \ + echo "✅ Local registry started on $(DEBUG_REGISTRY_HOST)"; \ + else \ + echo "Creating local registry on $(DEBUG_REGISTRY_HOST)..."; \ + docker run -d --restart=unless-stopped --name $(DEBUG_REGISTRY_NAME) \ + -p $(DEBUG_REGISTRY_PORT):5000 registry:2 >/dev/null; \ + echo "✅ Local registry created on $(DEBUG_REGISTRY_HOST)"; \ + fi + +debug-publish-recipes: debug-start-registry ## Publish test Bicep recipes to the local debug registry + @$(MAKE) publish-test-bicep-recipes BICEP_RECIPE_REGISTRY=$(DEBUG_REGISTRY_HOST) BICEP_RECIPE_TAG_VERSION=latest BICEP_RECIPE_PLAIN_HTTP=true + @echo "✅ Recipes published to $(DEBUG_REGISTRY_HOST)" + @echo "💡 Functional tests will auto-detect this registry — no env vars needed." + +debug-stop-registry: ## Stop and remove the local debug OCI registry + @if docker ps -a --format '{{.Names}}' | grep -qx "$(DEBUG_REGISTRY_NAME)"; then \ + docker rm -f $(DEBUG_REGISTRY_NAME) >/dev/null; \ + echo "✅ Local registry '$(DEBUG_REGISTRY_NAME)' removed"; \ + else \ + echo "Local registry '$(DEBUG_REGISTRY_NAME)' not present"; \ + fi + +debug-install-git-http-backend: ## Deploy in-cluster Git HTTP backend and port-forward $(DEBUG_GIT_HTTP_LOCAL_PORT)->3000 (for Flux tests) + @echo "Deploying git-http-backend into namespace '$(DEBUG_GIT_HTTP_NAMESPACE)'..." + @KUBECONFIG_CTX=k3d-radius-debug; \ + kubectl config use-context $$KUBECONFIG_CTX >/dev/null + @.github/actions/install-git-http-backend/install-git-http-backend.sh \ + "$(DEBUG_GIT_HTTP_USERNAME)" "$(DEBUG_GIT_HTTP_PASSWORD)" \ + "$(DEBUG_GIT_HTTP_NAMESPACE)" + @mkdir -p $(DEBUG_DEV_ROOT)/logs + @if [ -f $(DEBUG_DEV_ROOT)/logs/git-http-port-forward.pid ]; then \ + old=$$(cat $(DEBUG_DEV_ROOT)/logs/git-http-port-forward.pid 2>/dev/null); \ + if [ -n "$$old" ] && kill -0 "$$old" 2>/dev/null; then kill "$$old" 2>/dev/null || true; fi; \ + rm -f $(DEBUG_DEV_ROOT)/logs/git-http-port-forward.pid; \ + fi + @pkill -f "port-forward.*git-http-backend.*git-http" 2>/dev/null || true + @echo "Waiting for git-http-backend pod to be Ready..." + @kubectl --context k3d-radius-debug wait --for=condition=Available deployment/git-http-backend \ + -n $(DEBUG_GIT_HTTP_NAMESPACE) --timeout=120s >/dev/null + @echo "Starting port-forward localhost:$(DEBUG_GIT_HTTP_LOCAL_PORT) -> deploy/git-http-backend:3000..." + @# Port-forward to the deployment (auto-selects a Ready pod) rather than the + @# service: a service endpoint round-robin can land on a still-terminating pod + @# from the previous rollout and the forward dies with "network namespace ... + @# is closed". We also retry the whole forward+curl probe so a flaky pod + @# transition can't leave us with a stale, dead listener. + @attempt=0; max=6; ok=0; \ + while [ $$attempt -lt $$max ]; do \ + attempt=$$((attempt+1)); \ + pkill -f "port-forward.*git-http-backend.*git-http" 2>/dev/null || true; \ + sleep 1; \ + nohup kubectl --context k3d-radius-debug port-forward \ + -n $(DEBUG_GIT_HTTP_NAMESPACE) deploy/git-http-backend \ + $(DEBUG_GIT_HTTP_LOCAL_PORT):3000 \ + > $(DEBUG_DEV_ROOT)/logs/git-http-port-forward.log 2>&1 & \ + pf_pid=$$!; \ + echo $$pf_pid > $(DEBUG_DEV_ROOT)/logs/git-http-port-forward.pid; \ + probe=0; \ + while [ $$probe -lt 15 ]; do \ + probe=$$((probe+1)); \ + if kill -0 $$pf_pid 2>/dev/null && \ + curl -s -o /dev/null -m 2 "http://localhost:$(DEBUG_GIT_HTTP_LOCAL_PORT)"; then \ + ok=1; break; \ + fi; \ + sleep 2; \ + done; \ + if [ $$ok -eq 1 ]; then break; fi; \ + echo "git-http-backend port-forward attempt $$attempt/$$max failed, retrying..."; \ + done; \ + if [ $$ok -ne 1 ]; then \ + echo "❌ git-http-backend not reachable after $$max port-forward attempts"; \ + echo "--- port-forward log ---"; \ + tail -20 $(DEBUG_DEV_ROOT)/logs/git-http-port-forward.log 2>/dev/null || true; \ + exit 1; \ + fi + @echo "✅ git-http-backend reachable at http://localhost:$(DEBUG_GIT_HTTP_LOCAL_PORT)" + @echo "💡 Functional tests auto-detect this — no env vars needed." + +debug-stop-git-http-backend: ## Tear down the in-cluster Git HTTP backend and port-forward + @if [ -f $(DEBUG_DEV_ROOT)/logs/git-http-port-forward.pid ]; then \ + pid=$$(cat $(DEBUG_DEV_ROOT)/logs/git-http-port-forward.pid 2>/dev/null); \ + if [ -n "$$pid" ] && kill -0 "$$pid" 2>/dev/null; then kill "$$pid" 2>/dev/null || true; fi; \ + rm -f $(DEBUG_DEV_ROOT)/logs/git-http-port-forward.pid; \ + fi + @pkill -f "port-forward.*git-http-backend.*git-http" 2>/dev/null || true + @kubectl --context k3d-radius-debug delete namespace $(DEBUG_GIT_HTTP_NAMESPACE) --wait=false >/dev/null 2>&1 || true + @echo "✅ git-http-backend stopped" + +debug-install-flux: ## Install Flux source-controller into the k3d-radius-debug cluster (for Flux tests) + @if ! command -v flux >/dev/null 2>&1; then \ + echo "⚠️ flux CLI not found on PATH; skipping Flux source-controller install."; \ + echo " Install via: brew install fluxcd/tap/flux"; \ + echo " Then re-run: make debug-install-flux"; \ + exit 0; \ + fi + @if kubectl --context k3d-radius-debug get deployment -n $(DEBUG_FLUX_NAMESPACE) source-controller >/dev/null 2>&1; then \ + echo "✅ Flux source-controller already installed in namespace '$(DEBUG_FLUX_NAMESPACE)'"; \ + exit 0; \ + fi + @if [ -z "$(DEBUG_FLUX_VERSION)" ]; then \ + echo "❌ Could not determine flux CLI version"; \ + exit 1; \ + fi + @echo "Installing Flux source-controller v$(DEBUG_FLUX_VERSION) (matches local flux CLI)..." + @kubectl config use-context k3d-radius-debug >/dev/null + @for i in 1 2 3; do \ + flux install --namespace=$(DEBUG_FLUX_NAMESPACE) --version=v$(DEBUG_FLUX_VERSION) \ + --components=source-controller --network-policy=false && \ + kubectl wait --for=condition=available deployment \ + -l app.kubernetes.io/component=source-controller \ + -n $(DEBUG_FLUX_NAMESPACE) --timeout=120s && break; \ + echo "Attempt $$i failed, retrying in 10 seconds..."; \ + sleep 10; \ + done + @echo "✅ Flux source-controller ready in namespace '$(DEBUG_FLUX_NAMESPACE)'" + +debug-install-contour: ## Install the Contour ingress controller (required by Gateway tests) + @command -v helm >/dev/null 2>&1 || { echo "❌ helm CLI not found on PATH (install via: brew install helm)"; exit 1; } + @kubectl config use-context k3d-radius-debug >/dev/null + @if helm --kube-context k3d-radius-debug status $(DEBUG_CONTOUR_RELEASE) -n $(DEBUG_CONTOUR_NAMESPACE) -o json 2>/dev/null | grep -q '"status": *"deployed"'; then \ + echo "✅ Contour already installed (release '$(DEBUG_CONTOUR_RELEASE)' in namespace '$(DEBUG_CONTOUR_NAMESPACE)')"; \ + exit 0; \ + fi + @echo "Installing Contour $(DEBUG_CONTOUR_CHART_VERSION) into namespace '$(DEBUG_CONTOUR_NAMESPACE)'..." + @kubectl create namespace $(DEBUG_CONTOUR_NAMESPACE) --dry-run=client -o yaml | \ + kubectl --context k3d-radius-debug apply -f - >/dev/null + @helm --kube-context k3d-radius-debug repo add contour $(DEBUG_CONTOUR_HELM_REPO) >/dev/null 2>&1 || true + @helm --kube-context k3d-radius-debug repo update contour >/dev/null + @# Do NOT pass `--wait`: on k3d, contour-envoy is a LoadBalancer service whose + @# EXTERNAL-IP allocation by klipper-lb can take several minutes, causing the + @# helm release to be marked `failed` even though the controller + envoy pods + @# are Running. We wait explicitly on the things tests actually need below. + @helm --kube-context k3d-radius-debug upgrade --install $(DEBUG_CONTOUR_RELEASE) contour/contour \ + --namespace $(DEBUG_CONTOUR_NAMESPACE) --version $(DEBUG_CONTOUR_CHART_VERSION) --timeout 5m + @echo "Waiting for Contour controller + envoy to become Ready..." + @kubectl --context k3d-radius-debug wait --for=condition=Available \ + deployment/$(DEBUG_CONTOUR_RELEASE)-contour -n $(DEBUG_CONTOUR_NAMESPACE) --timeout=180s + @kubectl --context k3d-radius-debug rollout status \ + daemonset/$(DEBUG_CONTOUR_RELEASE)-envoy -n $(DEBUG_CONTOUR_NAMESPACE) --timeout=180s + @# Sanity check: the Gateway tests reference projectcontour.io/v1 HTTPProxy. + @kubectl --context k3d-radius-debug get crd httpproxies.projectcontour.io >/dev/null \ + || { echo "❌ HTTPProxy CRD missing after Contour install"; exit 1; } + @echo "✅ Contour ingress controller ready (HTTPProxy CRD present)" + +debug-install-tf-module-server: ## Deploy in-cluster Terraform module server and port-forward localhost:$(DEBUG_TF_MODULE_LOCAL_PORT) (for TerraformRecipe_* tests) + @echo "Publishing test Terraform recipes and deploying $(DEBUG_TF_MODULE_DEPLOYMENT) into namespace '$(DEBUG_TF_MODULE_NAMESPACE)'..." + @kubectl config use-context k3d-radius-debug >/dev/null + @$(MAKE) publish-test-terraform-recipes + @echo "Waiting for $(DEBUG_TF_MODULE_DEPLOYMENT) to become Available..." + @kubectl --context k3d-radius-debug wait --for=condition=Available \ + deployment/$(DEBUG_TF_MODULE_DEPLOYMENT) -n $(DEBUG_TF_MODULE_NAMESPACE) --timeout=120s + @# Kill any stale port-forward for this port so we can rebind cleanly. + @pkill -f "kubectl.*port-forward.*$(DEBUG_TF_MODULE_DEPLOYMENT).*$(DEBUG_TF_MODULE_LOCAL_PORT):" >/dev/null 2>&1 || true + @mkdir -p $(DEBUG_DEV_ROOT)/logs + @echo "Starting port-forward localhost:$(DEBUG_TF_MODULE_LOCAL_PORT) -> svc/$(DEBUG_TF_MODULE_DEPLOYMENT):80..." + @nohup kubectl --context k3d-radius-debug port-forward \ + svc/$(DEBUG_TF_MODULE_DEPLOYMENT) $(DEBUG_TF_MODULE_LOCAL_PORT):80 \ + -n $(DEBUG_TF_MODULE_NAMESPACE) \ + > $(DEBUG_DEV_ROOT)/logs/tf-module-server-pf.log 2>&1 & disown + @# Wait for the port-forward to actually accept connections before returning. + @for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do \ + if curl -m 2 -fsS -o /dev/null "http://localhost:$(DEBUG_TF_MODULE_LOCAL_PORT)/" 2>/dev/null \ + || curl -m 2 -sS -o /dev/null -w "%{http_code}" "http://localhost:$(DEBUG_TF_MODULE_LOCAL_PORT)/" 2>/dev/null | grep -qE "^(2|3|4)"; then \ + echo "✅ tf-module-server reachable at http://localhost:$(DEBUG_TF_MODULE_LOCAL_PORT)"; \ + exit 0; \ + fi; \ + sleep 1; \ + done; \ + echo "❌ tf-module-server port-forward not reachable on localhost:$(DEBUG_TF_MODULE_LOCAL_PORT)"; \ + tail -20 $(DEBUG_DEV_ROOT)/logs/tf-module-server-pf.log 2>/dev/null || true; \ + exit 1 + +debug-stop-tf-module-server: ## Tear down the Terraform module server port-forward + @pkill -f "kubectl.*port-forward.*$(DEBUG_TF_MODULE_DEPLOYMENT).*$(DEBUG_TF_MODULE_LOCAL_PORT):" >/dev/null 2>&1 || true + @echo "✅ tf-module-server port-forward stopped" + +debug-publish-bicep-types: ## Build radius+testresources bicep extensions as local .tgz files and drop a closer-wins bicepconfig override next to the test templates + @command -v bicep >/dev/null 2>&1 || { echo "❌ bicep CLI not found on PATH (install via: az bicep install / brew install bicep)"; exit 1; } + @RAD_BIN="$(DEBUG_DEV_ROOT)/bin/rad"; \ + if [ ! -x "$$RAD_BIN" ]; then RAD_BIN="$$(command -v rad)" || true; fi; \ + if [ -z "$$RAD_BIN" ] || [ ! -x "$$RAD_BIN" ]; then \ + echo "❌ rad binary not found at $(DEBUG_DEV_ROOT)/bin/rad or on PATH (run 'make debug-build-rad' first)"; \ + exit 1; \ + fi; \ + mkdir -p $(DEBUG_BICEP_EXT_DIR); \ + echo "Generating bicep types (VERSION=$(DEBUG_BICEP_VERSION))..."; \ + $(MAKE) generate-bicep-types VERSION=$(DEBUG_BICEP_VERSION) || exit $$?; \ + echo "Publishing radius extension -> $(DEBUG_BICEP_EXT_RADIUS)..."; \ + bicep publish-extension ./hack/bicep-types-radius/generated/index.json \ + --target $(DEBUG_BICEP_EXT_RADIUS) --force || exit $$?; \ + echo "Publishing testresources extension -> $(DEBUG_BICEP_EXT_TESTRESOURCES)..."; \ + "$$RAD_BIN" bicep publish-extension \ + -f $(DEBUG_BICEP_TEST_RESOURCES_YAML) \ + --target $(DEBUG_BICEP_EXT_TESTRESOURCES) --force || exit $$? + @# Bicep resolves the nearest bicepconfig.json by walking up from each .bicep + @# file. Writing the override in testdata/ (gitignored) means it wins over the + @# tracked sibling in resources/ without mutating any tracked file. Absolute + @# file paths in `extensions` skip the OCI registry entirely (the bicep CLI's + @# publish-extension forces HTTPS even for localhost, which the local debug + @# registry does not support). + @radius_abs=$$(cd $$(dirname $(DEBUG_BICEP_EXT_RADIUS)) && pwd)/$$(basename $(DEBUG_BICEP_EXT_RADIUS)); \ + tr_abs=$$(cd $$(dirname $(DEBUG_BICEP_EXT_TESTRESOURCES)) && pwd)/$$(basename $(DEBUG_BICEP_EXT_TESTRESOURCES)); \ + { \ + echo '{'; \ + echo ' "experimentalFeaturesEnabled": {'; \ + echo ' "extensibility": true'; \ + echo ' },'; \ + echo ' "extensions": {'; \ + echo " \"radius\": \"$$radius_abs\","; \ + echo ' "aws": "br:biceptypes.azurecr.io/aws:latest",'; \ + echo " \"testresources\": \"$$tr_abs\""; \ + echo ' }'; \ + echo '}'; \ + } > $(DEBUG_BICEP_TEST_CONFIG_OVERRIDE) + @echo "✅ Bicep extensions built to $(DEBUG_BICEP_EXT_DIR); override written to $(DEBUG_BICEP_TEST_CONFIG_OVERRIDE)" + @echo "💡 The tracked resources/bicepconfig.json is untouched; 'make debug-stop' removes the override." + +debug-remove-bicep-types-override: ## Delete the gitignored bicepconfig override next to the dynamicrp test templates + @if [ -f $(DEBUG_BICEP_TEST_CONFIG_OVERRIDE) ]; then \ + rm -f $(DEBUG_BICEP_TEST_CONFIG_OVERRIDE); \ + echo "✅ Removed $(DEBUG_BICEP_TEST_CONFIG_OVERRIDE)"; \ + else \ + echo "ℹ️ No bicepconfig override at $(DEBUG_BICEP_TEST_CONFIG_OVERRIDE); nothing to remove"; \ + fi + debug-stop: ## Stop all running Radius components, destroy k3d cluster, and clean up @echo "Stopping Radius components..." @if [ -f build/scripts/stop-radius.sh ]; then \ @@ -253,6 +579,10 @@ debug-stop: ## Stop all running Radius components, destroy k3d cluster, and clea @echo "Cleaning up debug files and symlinks..." @rm -rf $(DEBUG_DEV_ROOT)/logs @rm -f ./drad + @$(MAKE) debug-remove-bicep-types-override + @$(MAKE) debug-stop-tf-module-server + @$(MAKE) debug-stop-git-http-backend + @$(MAKE) debug-stop-registry @echo "✅ Debug environment completely stopped and cleaned up" debug-status: ## Show status of all components @@ -281,17 +611,30 @@ debug-deployment-engine-pull: ## Pull latest deployment engine image from ghcr.i && echo "✅ Deployment Engine image pulled successfully" \ || echo "❌ Failed to pull Deployment Engine image" -debug-deployment-engine-start: ## Start deployment engine in k3d cluster - @echo "Installing ONLY deployment engine to k3d cluster..." - @if kubectl --context k3d-radius-debug get deployment deployment-engine >/dev/null 2>&1 && \ - kubectl --context k3d-radius-debug get deployment deployment-engine -o jsonpath='{.status.readyReplicas}' 2>/dev/null | grep -q "1" && \ - curl -s "http://localhost:5017/metrics" > /dev/null 2>&1; then \ - echo "✅ Deployment engine already running and healthy"; \ +debug-deployment-engine-start: ## Start deployment engine in k3d cluster (or reuse a local OS process on port 5017) + @echo "Checking for an existing Deployment Engine on localhost:5017..." + @listener_cmd=""; \ + if command -v lsof >/dev/null 2>&1; then \ + listener_cmd=$$(lsof -nP -iTCP:5017 -sTCP:LISTEN 2>/dev/null | awk 'NR==2 {print $$1}'); \ + fi; \ + if [ -n "$$listener_cmd" ] && [ "$$listener_cmd" != "kubectl" ] && curl -s "http://localhost:5017/metrics" > /dev/null 2>&1; then \ + echo "✅ Detected local Deployment Engine process ($$listener_cmd) on port 5017 — reusing it"; \ + echo "💡 Skipping k3d deployment-engine install and port-forward"; \ + mkdir -p $(DEBUG_DEV_ROOT)/logs; \ + echo "external" > $(DEBUG_DEV_ROOT)/logs/de-external.marker; \ else \ - $(MAKE) debug-deployment-engine-deploy; \ - $(MAKE) debug-deployment-engine-port-forward; \ + rm -f $(DEBUG_DEV_ROOT)/logs/de-external.marker 2>/dev/null || true; \ + echo "Installing ONLY deployment engine to k3d cluster..."; \ + if kubectl --context k3d-radius-debug get deployment deployment-engine >/dev/null 2>&1 && \ + kubectl --context k3d-radius-debug get deployment deployment-engine -o jsonpath='{.status.readyReplicas}' 2>/dev/null | grep -q "1" && \ + curl -s "http://localhost:5017/metrics" > /dev/null 2>&1; then \ + echo "✅ Deployment engine already running and healthy in k3d"; \ + else \ + $(MAKE) debug-deployment-engine-deploy; \ + $(MAKE) debug-deployment-engine-port-forward; \ + fi; \ + echo "✅ Deployment engine installed and ready in k3d cluster"; \ fi - @echo "✅ Deployment engine installed and ready in k3d cluster" debug-deployment-engine-deploy: ## Deploy deployment engine to k3d cluster @echo "Applying deployment engine manifest to k3d cluster..." @@ -302,7 +645,12 @@ debug-deployment-engine-deploy: ## Deploy deployment engine to k3d cluster debug-deployment-engine-port-forward: ## Set up port forwarding for deployment engine @build/scripts/setup-deployment-engine-port-forward.sh -debug-deployment-engine-stop: ## Stop deployment engine in k3d cluster +debug-deployment-engine-stop: ## Stop deployment engine in k3d cluster (leaves external local DE process alone) + @if [ -f $(DEBUG_DEV_ROOT)/logs/de-external.marker ]; then \ + echo "ℹ️ External local Deployment Engine was in use — leaving it running"; \ + rm -f $(DEBUG_DEV_ROOT)/logs/de-external.marker; \ + exit 0; \ + fi @echo "Removing deployment engine from k3d cluster..." @if [ -f $(DEBUG_DEV_ROOT)/logs/de-port-forward.pid ]; then \ kill $$(cat $(DEBUG_DEV_ROOT)/logs/de-port-forward.pid) 2>/dev/null || true; \ @@ -315,7 +663,9 @@ debug-deployment-engine-stop: ## Stop deployment engine in k3d cluster debug-deployment-engine-status: ## Check deployment engine status @echo "🚀 Deployment Engine Status:" - @if kubectl --context k3d-radius-debug get deployment deployment-engine >/dev/null 2>&1; then \ + @if [ -f $(DEBUG_DEV_ROOT)/logs/de-external.marker ] && curl -s "http://localhost:5017/metrics" > /dev/null 2>&1; then \ + echo "✅ Deployment Engine (external local process on :5017) - Running"; \ + elif kubectl --context k3d-radius-debug get deployment deployment-engine >/dev/null 2>&1; then \ replicas=$$(kubectl --context k3d-radius-debug get deployment deployment-engine -o jsonpath='{.status.readyReplicas}' 2>/dev/null || echo "0"); \ if [ "$$replicas" = "1" ]; then \ echo "✅ Deployment Engine (k3d) - Running and ready"; \ diff --git a/build/recipes.mk b/build/recipes.mk index 2584cc8b1b8..7134890bcb6 100644 --- a/build/recipes.mk +++ b/build/recipes.mk @@ -27,7 +27,7 @@ publish-test-bicep-recipes: ## Publishes test recipes to @if [ -z "$(BICEP_RECIPE_REGISTRY)" ]; then echo "Error: BICEP_RECIPE_REGISTRY must be set to a valid OCI registry"; exit 1; fi @echo "$(ARROW) Publishing Bicep test recipes from ./test/testrecipes/test-bicep-recipes..." - ./.github/scripts/publish-recipes.sh \ + PLAIN_HTTP=$(BICEP_RECIPE_PLAIN_HTTP) ./.github/scripts/publish-recipes.sh \ ./test/testrecipes/test-bicep-recipes \ ${BICEP_RECIPE_REGISTRY}/test/testrecipes/test-bicep-recipes \ ${BICEP_RECIPE_TAG_VERSION} diff --git a/build/scripts/azure-local-testenv.sh b/build/scripts/azure-local-testenv.sh new file mode 100755 index 00000000000..8f8fd09f624 --- /dev/null +++ b/build/scripts/azure-local-testenv.sh @@ -0,0 +1,460 @@ +#!/usr/bin/env bash +# ------------------------------------------------------------ +# Copyright 2024 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 +# ------------------------------------------------------------ +# +# Orchestrates an ephemeral Azure environment for local functional tests. +# +# Subcommands: +# setup Create an ephemeral resource group, deploy test fixtures, configure +# the current rad environment with the Azure provider scope, and write +# state to debug_files/logs/azure-local.env. +# run Source the state file and run the Test_Azure* subset of the +# corerp-cloud functional tests against the locally-running stack. +# teardown Delete the resource group (no-wait), clear the env-update on the +# current rad environment, and remove the state file. +# +# Auth model: this script assumes the caller is authenticated via `az login`. +# Radius components (RP/UCP) authenticate to Azure via the Azure CLI fallback in +# pkg/azure/armauth/auth.go. The Deployment Engine must also be running locally +# (not in a container) so it can use the same DefaultAzureCredential -> az CLI +# path. See debug_files/logs/de-external.marker for the external-DE indicator. +# +# Environment variables: +# AZURE_LOCATION Azure region (default: westus3). +# AZURE_SUBSCRIPTION_ID Subscription to use (default: az account show). +# AZURE_LOCAL_PREPROVISIONED_RG If set, reuse an existing RG and skip fixture +# deployment / teardown of that RG. The script +# will still write the state file so `run` works. +# RAD_ENV rad environment to configure (default: default). +# +# Note: AWS support is intentionally out of scope for this iteration. +# Note: Test_AzureMSSQL_* tests are not configured here; they auto-skip when the +# AZURE_MSSQL_* env vars are absent. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +STATE_DIR="${REPO_ROOT}/debug_files/logs" +STATE_FILE="${STATE_DIR}/azure-local.env" +BICEP_TEMPLATE="${REPO_ROOT}/test/createAzureTestResources.bicep" +TF_MODULE_SERVER_NS="radius-test-tf-module-server" +TF_MODULE_SERVER_PORT_FORWARD_PID_FILE="${STATE_DIR}/tf-module-server-pf.pid" +TF_MODULE_SERVER_PORT_FORWARD_LOG="${STATE_DIR}/tf-module-server-pf.log" + +AZURE_LOCATION="${AZURE_LOCATION:-westus3}" +RAD_ENV="${RAD_ENV:-default}" + +log() { printf '\033[0;34mℹ\033[0m %s\n' "$*"; } +ok() { printf '\033[0;32m✔\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m⚠\033[0m %s\n' "$*" >&2; } +err() { printf '\033[0;31m✖\033[0m %s\n' "$*" >&2; } + +require_cmd() { + for c in "$@"; do + command -v "$c" >/dev/null 2>&1 || { err "required command not found: $c"; exit 1; } + done +} + +require_az_login() { + if ! az account show >/dev/null 2>&1; then + err "not logged in to Azure. Run 'az login' first." + exit 1 + fi +} + +resolve_subscription() { + if [[ -n "${AZURE_SUBSCRIPTION_ID:-}" ]]; then + echo "${AZURE_SUBSCRIPTION_ID}" + return + fi + az account show --query id -o tsv +} + +ensure_tf_module_server() { + # Terraform-recipe tests fetch zipped modules from http://localhost:8999. + # In CI an in-cluster nginx serves them; locally we deploy the same nginx + # into the debug k3d cluster and port-forward it to localhost:8999. + require_cmd kubectl make + if curl -sf -o /dev/null -m 2 http://localhost:8999/azure-rg.zip; then + log "tf-module-server already reachable at http://localhost:8999" + return 0 + fi + if ! kubectl get ns "${TF_MODULE_SERVER_NS}" >/dev/null 2>&1 \ + || ! kubectl -n "${TF_MODULE_SERVER_NS}" get deploy tf-module-server >/dev/null 2>&1; then + log "Deploying tf-module-server into the debug cluster (publish-test-terraform-recipes)..." + (cd "${REPO_ROOT}" && make publish-test-terraform-recipes >/dev/null) \ + || { err "make publish-test-terraform-recipes failed"; exit 1; } + fi + log "Waiting for tf-module-server rollout..." + kubectl -n "${TF_MODULE_SERVER_NS}" rollout status deploy/tf-module-server --timeout=120s >/dev/null \ + || { err "tf-module-server rollout did not become ready"; exit 1; } + # Stop any stale port-forward before starting a new one. + if [[ -f "${TF_MODULE_SERVER_PORT_FORWARD_PID_FILE}" ]]; then + local old_pid + old_pid="$(cat "${TF_MODULE_SERVER_PORT_FORWARD_PID_FILE}" 2>/dev/null || true)" + if [[ -n "${old_pid}" ]] && kill -0 "${old_pid}" 2>/dev/null; then + kill "${old_pid}" 2>/dev/null || true + fi + rm -f "${TF_MODULE_SERVER_PORT_FORWARD_PID_FILE}" + fi + log "Starting kubectl port-forward svc/tf-module-server 8999:80 -n ${TF_MODULE_SERVER_NS}" + ( kubectl -n "${TF_MODULE_SERVER_NS}" port-forward svc/tf-module-server 8999:80 \ + >"${TF_MODULE_SERVER_PORT_FORWARD_LOG}" 2>&1 ) & + echo $! > "${TF_MODULE_SERVER_PORT_FORWARD_PID_FILE}" + # Wait briefly for the port-forward to come up. + local i + for i in $(seq 1 20); do + if curl -sf -o /dev/null -m 1 http://localhost:8999/azure-rg.zip; then + ok "tf-module-server reachable at http://localhost:8999 (pid $(cat "${TF_MODULE_SERVER_PORT_FORWARD_PID_FILE}"))" + return 0 + fi + sleep 0.5 + done + err "tf-module-server port-forward did not become reachable; see ${TF_MODULE_SERVER_PORT_FORWARD_LOG}" + exit 1 +} + +stop_tf_module_server_port_forward() { + if [[ -f "${TF_MODULE_SERVER_PORT_FORWARD_PID_FILE}" ]]; then + local pid + pid="$(cat "${TF_MODULE_SERVER_PORT_FORWARD_PID_FILE}" 2>/dev/null || true)" + if [[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null; then + log "Stopping tf-module-server port-forward (pid ${pid})" + kill "${pid}" 2>/dev/null || true + fi + rm -f "${TF_MODULE_SERVER_PORT_FORWARD_PID_FILE}" + fi +} + +cmd_setup() { + require_cmd az jq rad + require_az_login + mkdir -p "${STATE_DIR}" + + if [[ -f "${STATE_FILE}" ]]; then + err "state file already exists at ${STATE_FILE}. Run 'teardown' first or remove it manually." + exit 1 + fi + + local sub + sub="$(resolve_subscription)" + local tenant + tenant="$(az account show --query tenantId -o tsv)" + log "Subscription: ${sub}" + + local rg + if [[ -n "${AZURE_LOCAL_PREPROVISIONED_RG:-}" ]]; then + rg="${AZURE_LOCAL_PREPROVISIONED_RG}" + log "Reusing pre-provisioned resource group: ${rg}" + if ! az group show --subscription "${sub}" --name "${rg}" >/dev/null 2>&1; then + err "pre-provisioned resource group ${rg} not found in subscription ${sub}" + exit 1 + fi + else + local user_slug epoch + user_slug="$(echo "${USER:-local}" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9' '-' | sed 's/-\{2,\}/-/g;s/^-//;s/-$//')" + epoch="$(date +%s)" + rg="radlocal-${user_slug}-${epoch}" + log "Creating resource group: ${rg} in ${AZURE_LOCATION}" + az group create \ + --subscription "${sub}" \ + --location "${AZURE_LOCATION}" \ + --name "${rg}" \ + --tags creationTime="${epoch}" creator="${USER:-unknown}" purpose=radius-local-test \ + -o none + while [[ "$(az group exists --subscription "${sub}" --name "${rg}")" != "true" ]]; do + sleep 2 + done + ok "Resource group created: ${rg}" + fi + + local cosmos_id="" + if [[ -z "${AZURE_LOCAL_PREPROVISIONED_RG:-}" ]]; then + # Cosmos DB account names are globally unique. Derive a stable-but-unique + # name from the RG (which already includes user + epoch) and trim to the + # 3-44 char limit. Cosmos requires lowercase alphanumerics + hyphens, and + # disallows leading or trailing hyphens. + local cosmos_name + cosmos_name="$(echo "radlocal-${rg#radlocal-}" \ + | tr '[:upper:]' '[:lower:]' \ + | tr -c 'a-z0-9-' '-' \ + | cut -c1-44 \ + | sed -E 's/^-+//; s/-+$//')" + log "Deploying test fixtures (Cosmos Mongo account ${cosmos_name}) — this typically takes 3-5 minutes..." + local deploy_json + deploy_json="$(az deployment group create \ + --subscription "${sub}" \ + --resource-group "${rg}" \ + --template-file "${BICEP_TEMPLATE}" \ + --parameters cosmosAccountName="${cosmos_name}" \ + -o json)" + cosmos_id="$(echo "${deploy_json}" | jq -r '.properties.outputs.cosmosMongoAccountID.value')" + ok "Cosmos Mongo account deployed: ${cosmos_id}" + else + # Reuse: look up by tag/kind in the pre-provisioned RG. + cosmos_id="$(az resource list \ + --subscription "${sub}" \ + --resource-group "${rg}" \ + --resource-type Microsoft.DocumentDB/databaseAccounts \ + --query '[?kind==`MongoDB`] | [0].id' -o tsv || true)" + if [[ -z "${cosmos_id}" || "${cosmos_id}" == "null" ]]; then + warn "no Cosmos Mongo account found in ${rg}; Test_AzureConnections will fail." + else + ok "Found Cosmos Mongo account: ${cosmos_id}" + fi + fi + + log "Configuring rad environment '${RAD_ENV}' with Azure scope" + rad env update "${RAD_ENV}" \ + --azure-subscription-id "${sub}" \ + --azure-resource-group "${rg}" + + ensure_tf_module_server + + cat > "${STATE_FILE}" <-* RG owned by the current user. + local user_slug + user_slug="$(echo "${USER:-local}" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9' '-' | sed 's/-\{2,\}/-/g;s/^-//;s/-$//')" + local matches + matches="$(az group list --subscription "${sub}" --query "[?starts_with(name, 'radlocal-${user_slug}-')].name" -o tsv)" + local count + count="$(printf '%s\n' "${matches}" | grep -c . || true)" + if [[ "${count}" -eq 0 ]]; then + err "no state file at ${STATE_FILE} and no radlocal-* RG to recover from. Run 'setup' first." + exit 1 + fi + # Pick the newest RG (epoch suffix). RG names are radlocal--. + rg="$(printf '%s\n' ${matches} | sort -t- -k3 -n | tail -1)" + if [[ "${count}" -gt 1 ]]; then + warn "multiple radlocal-${user_slug}-* RGs found; using newest: ${rg}" + warn "clean up the rest with: $0 teardown --all-orphans" + fi + fi + if ! az group show --subscription "${sub}" --name "${rg}" >/dev/null 2>&1; then + err "resource group ${rg} not found in subscription ${sub}" + exit 1 + fi + cosmos_id="$(az resource list \ + --subscription "${sub}" \ + --resource-group "${rg}" \ + --resource-type Microsoft.DocumentDB/databaseAccounts \ + --query '[?kind==`MongoDB`] | [0].id' -o tsv 2>/dev/null || true)" + mkdir -p "${STATE_DIR}" + cat > "${STATE_FILE}" < ${STATE_FILE}" + ensure_tf_module_server +} + +cmd_run() { + if [[ ! -f "${STATE_FILE}" ]]; then + warn "no state file at ${STATE_FILE}; attempting to recover from an existing RG..." + recover_state + fi + # shellcheck disable=SC1090 + source "${STATE_FILE}" + ensure_tf_module_server + # Re-apply Azure scope on the rad env idempotently. `make debug-start` + # recreates the Postgres DB, which wipes the env's Azure provider config + # set during `setup`. Without this, bicep templates that use + # `resourceGroup().id` for `providers.azure.scope` (e.g. + # corerp-resources-terraform-azurerg.bicep) get an empty subscription id + # because the deployment-engine substitutes `resourceGroup().id` from the + # active env's Azure scope. + log "Ensuring rad env '${RAD_ENV}' has Azure scope (sub=${AZURE_SUBSCRIPTION_ID}, rg=${AZURE_LOCAL_TEST_RG})" + rad env update "${RAD_ENV}" \ + --azure-subscription-id "${AZURE_SUBSCRIPTION_ID}" \ + --azure-resource-group "${AZURE_LOCAL_TEST_RG}" >/dev/null + log "Running corerp/cloud functional tests against RG ${AZURE_LOCAL_TEST_RG}" + log "AWS-required tests will skip automatically (RADIUS_TEST_USE_LOCAL_CLOUD_CREDS=azure)" + cd "${REPO_ROOT}" + # Run the full corerp/cloud suite. CheckRequiredFeatures will skip tests that + # need AWS or the CSI driver; only the Azure-required tests will execute. + # + # Stream per-test output: `go test -v` buffers each package's output until + # the package finishes, which hides progress for long Azure deployments. + # Prefer gotestsum (installed via `make test-get-envtools` in CI) for + # per-test live output; fall back to `go test -json` piped through a small + # awk filter that prints each PASS/FAIL/SKIP line as it completes. + if command -v gotestsum >/dev/null 2>&1; then + CGO_ENABLED=1 gotestsum --format testname -- \ + ./test/functional-portable/corerp/cloud/... \ + -timeout "${TEST_TIMEOUT:-1h}" \ + -parallel 5 \ + ${GOTEST_OPTS:-} \ + "$@" + else + log "gotestsum not found; using 'go test -json' for streaming output. Install with: go install gotest.tools/gotestsum@latest" + CGO_ENABLED=1 go test \ + ./test/functional-portable/corerp/cloud/... \ + -json \ + -timeout "${TEST_TIMEOUT:-1h}" \ + -parallel 5 \ + ${GOTEST_OPTS:-} \ + "$@" | \ + awk -F'"' ' + /"Action":"run"/ { for (i=1;i<=NF;i++) if ($i=="Test") { print "RUN " $(i+2); break } } + /"Action":"pass"/ { for (i=1;i<=NF;i++) if ($i=="Test") { print "PASS " $(i+2); break } } + /"Action":"fail"/ { for (i=1;i<=NF;i++) if ($i=="Test") { print "FAIL " $(i+2); break } } + /"Action":"skip"/ { for (i=1;i<=NF;i++) if ($i=="Test") { print "SKIP " $(i+2); break } } + /"Action":"output"/ { for (i=1;i<=NF;i++) if ($i=="Output") { gsub(/\\n/,"",$(i+2)); if ($(i+2) != "") print " " $(i+2); break } } + ' + fi +} + +cmd_teardown() { + require_cmd az + # Optional: --all-orphans deletes every radlocal--* RG in the current + # subscription, regardless of state file. Useful after a debug-stop wiped + # state without tearing down RGs. + if [[ "${1:-}" == "--all-orphans" ]]; then + require_az_login + local sub user_slug matches + sub="$(resolve_subscription)" + user_slug="$(echo "${USER:-local}" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9' '-' | sed 's/-\{2,\}/-/g;s/^-//;s/-$//')" + matches="$(az group list --subscription "${sub}" --query "[?starts_with(name, 'radlocal-${user_slug}-')].name" -o tsv)" + if [[ -z "${matches}" ]]; then + ok "no radlocal-${user_slug}-* RGs to delete." + else + log "Deleting orphan RGs (no-wait):" + printf ' %s\n' ${matches} + for orphan in ${matches}; do + az group delete --subscription "${sub}" --name "${orphan}" --yes --no-wait \ + || warn "failed to start delete for ${orphan}" + done + fi + stop_tf_module_server_port_forward + rm -f "${STATE_FILE}" + return 0 + fi + if [[ ! -f "${STATE_FILE}" ]]; then + warn "no state file at ${STATE_FILE}; nothing to tear down. Use '$0 teardown --all-orphans' to GC stale radlocal-* RGs." + return 0 + fi + # shellcheck disable=SC1090 + source "${STATE_FILE}" + + local sub="${AZURE_SUBSCRIPTION_ID}" + local rg="${AZURE_LOCAL_TEST_RG}" + + if [[ -z "${AZURE_LOCAL_PREPROVISIONED_RG:-}" && "${rg}" == radlocal-* ]]; then + log "Deleting resource group ${rg} (no-wait)" + az group delete \ + --subscription "${sub}" \ + --name "${rg}" \ + --yes --no-wait || warn "az group delete returned non-zero" + else + warn "RG ${rg} was pre-provisioned (or not radlocal-* prefix); leaving it intact." + fi + + log "Clearing Azure scope on rad env '${RAD_ENV}'" + rad env update "${RAD_ENV}" --clear-azure 2>/dev/null || \ + warn "rad env update --clear-azure failed or unsupported; clear manually if needed." + + stop_tf_module_server_port_forward + rm -f "${STATE_FILE}" + ok "Teardown complete." +} + +cmd_all() { + cmd_setup + local rc=0 + if [[ "${AZURE_LOCAL_KEEP_ON_FAILURE:-0}" == "1" || "${AZURE_LOCAL_KEEP_ON_FAILURE:-}" =~ ^[Tt]rue$ ]]; then + log "AZURE_LOCAL_KEEP_ON_FAILURE is set; teardown will be SKIPPED if tests fail (post-mortem mode)." + cmd_run "$@" || rc=$? + if [[ "${rc}" -ne 0 ]]; then + warn "Tests exited with rc=${rc}; preserving RG ${AZURE_LOCAL_TEST_RG:-?} and state file ${STATE_FILE} for inspection." + warn "Run 'make test-functional-azure-local-teardown' when finished." + return "${rc}" + fi + cmd_teardown + return 0 + fi + # Default: ensure teardown runs even if tests fail. + trap cmd_teardown EXIT + cmd_run "$@" || rc=$? + trap - EXIT + cmd_teardown + return "${rc}" +} + +usage() { + cat >&2 < [-- extra go test args] + + setup Create RG, deploy fixtures, write state file. + run Source state file (auto-recover from existing RG if missing) and + run corerp/cloud functional tests. Extra args after the command + are passed through to 'go test' (e.g. -run '^Test_X$' -v). + teardown Delete RG and remove state file. + all setup -> run -> teardown (teardown runs even on failure). + +Examples: + # Re-run only specific failing tests against the existing RG: + $0 run -run '^(Test_TerraformRecipe_AzureResourceGroup|Test_Extender_RecipeAWS_LogGroup)\$' -v + +Environment variables: + AZURE_LOCAL_KEEP_ON_FAILURE=1 When set with 'all', skip teardown on test + failure so the RG can be inspected. The + state file is also preserved; clean up later + with 'teardown'. + AZURE_LOCAL_PREPROVISIONED_RG Reuse an existing RG (skip Cosmos deploy and + skip RG deletion). + AZURE_LOCAL_TEST_RG Force recovery from a specific RG when the + state file is missing. +EOF + exit 2 +} + +cmd="${1:-}" +shift || true +case "${cmd}" in + setup) cmd_setup "$@" ;; + run) cmd_run "$@" ;; + teardown) cmd_teardown "$@" ;; + all) cmd_all "$@" ;; + *) usage ;; +esac diff --git a/build/scripts/ensure-encryption-key.sh b/build/scripts/ensure-encryption-key.sh new file mode 100755 index 00000000000..fd9e658e893 --- /dev/null +++ b/build/scripts/ensure-encryption-key.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Ensure the radius-encryption-key Secret exists in the radius-system namespace +# of the k3d-radius-debug cluster. dynamic-rp (and other Radius components that +# use the encryption key provider) refuse to start without it. +# +# The Helm chart (deploy/Chart/templates/dynamic-rp/secret.yaml) normally +# creates this. The OS-process debug stack skips Helm, so we recreate the same +# secret format here. +# +# This script is idempotent: if the secret already exists, it does nothing. + +set -euo pipefail + +CONTEXT="${KUBE_CONTEXT:-k3d-radius-debug}" +NAMESPACE="${RADIUS_NAMESPACE:-radius-system}" +SECRET_NAME="radius-encryption-key" + +if ! command -v kubectl >/dev/null 2>&1; then + echo "❌ kubectl not found" + exit 1 +fi + +if ! kubectl --context "$CONTEXT" cluster-info >/dev/null 2>&1; then + echo "❌ cluster $CONTEXT not reachable" + exit 1 +fi + +# Ensure namespace exists +kubectl --context "$CONTEXT" get namespace "$NAMESPACE" >/dev/null 2>&1 \ + || kubectl --context "$CONTEXT" create namespace "$NAMESPACE" >/dev/null + +# If the secret already exists, leave it alone. +if kubectl --context "$CONTEXT" -n "$NAMESPACE" get secret "$SECRET_NAME" >/dev/null 2>&1; then + echo "✅ Secret $NAMESPACE/$SECRET_NAME already exists" + exit 0 +fi + +# Generate a 32-byte random key and build the JSON keystore the same way the +# Helm chart does. +now_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +expiry_iso="$(date -u -v+90d +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \ + || date -u -d '+90 days' +%Y-%m-%dT%H:%M:%SZ)" +key_b64="$(head -c 32 /dev/urandom | base64 | tr -d '\n')" + +keystore_json=$(cat </dev/null + +echo "✅ Created secret $NAMESPACE/$SECRET_NAME (random 32-byte key, 90-day expiry)" diff --git a/build/scripts/mirror-test-images.sh b/build/scripts/mirror-test-images.sh new file mode 100755 index 00000000000..b707d22de24 --- /dev/null +++ b/build/scripts/mirror-test-images.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Copy upstream multi-arch container images into the +# ghcr.io/radius-project/mirror/* namespace, preserving the full OCI index +# (i.e. all platforms) so the same tag works on amd64 (CI) and arm64 +# (Apple Silicon dev machines / k3d). Uses `docker buildx imagetools create`, +# which performs a server-side blob mount when source and destination are on +# the same registry, and otherwise a streaming copy. +# +# Requirements: +# - docker (with buildx, included in Docker Desktop) +# - You must be logged in to ghcr.io with a token that has write:packages. +# Easiest: `echo $GH_PACKAGES_TOKEN | docker login ghcr.io -u --password-stdin` +# The token used by `gh auth login` does NOT include write:packages by default; +# create a Classic PAT with the `write:packages` scope. +# +# Usage: +# build/scripts/mirror-test-images.sh # mirror the default list below +# build/scripts/mirror-test-images.sh --dry-run # show what would be copied +# build/scripts/mirror-test-images.sh src=dst # mirror a single mapping +# +# Each mapping is SRC=DST_TAG where: +# SRC = fully-qualified upstream reference (e.g. docker.io/library/rabbitmq:3.12-management-alpine) +# DST_TAG = destination repo+tag under ghcr.io/radius-project/mirror/ +# (e.g. rabbitmq:3.12-management-alpine) + +set -euo pipefail + +DEST_PREFIX="ghcr.io/radius-project/mirror" + +# Default mappings. Every source MUST be multi-arch (at least linux/amd64 + +# linux/arm64) for the destination to be usable from both CI and arm64 dev +# machines. Verify with `docker buildx imagetools inspect ` before adding. +DEFAULT_MAPPINGS=( + # These mirror the exact same tags already referenced by tests / recipes. + # The previous mirror entries were pushed as single-arch (linux/amd64 only) + # blobs, which breaks tests on arm64 dev machines (e.g. rabbitmq 3.10 BEAM + # crashes under QEMU). Re-mirroring with buildx imagetools create copies the + # full upstream OCI index so all platforms work. + "docker.io/library/rabbitmq:3.10=rabbitmq:3.10" + "docker.io/library/redis:6.2=redis:6.2" + "docker.io/library/mongo:4.2=mongo:4.2" + "docker.io/library/postgres:latest=postgres:latest" + "docker.io/library/debian:latest=debian:latest" +) + +dry_run=0 +mappings=() +for arg in "$@"; do + case "$arg" in + --dry-run) dry_run=1 ;; + -h|--help) sed -n '1,30p' "$0"; exit 0 ;; + *=*) mappings+=("$arg") ;; + *) echo "unknown argument: $arg" >&2; exit 2 ;; + esac +done + +if [[ ${#mappings[@]} -eq 0 ]]; then + mappings=("${DEFAULT_MAPPINGS[@]}") +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "docker is required" >&2; exit 1 +fi +if ! docker buildx version >/dev/null 2>&1; then + echo "docker buildx is required (included with Docker Desktop)" >&2; exit 1 +fi + +echo "Mirroring ${#mappings[@]} image(s) to ${DEST_PREFIX}/*" +for m in "${mappings[@]}"; do + src="${m%%=*}" + dst_suffix="${m#*=}" + dst="${DEST_PREFIX}/${dst_suffix}" + echo + echo "==> ${src}" + echo " -> ${dst}" + + # Verify source is multi-arch before copying. The destination will inherit + # whatever platforms the source has, so a single-arch source means we did + # not solve the original problem. + platforms="$(docker buildx imagetools inspect --raw "${src}" 2>/dev/null \ + | python3 -c ' +import json,sys +try: + d=json.load(sys.stdin) +except Exception: + print(""); sys.exit(0) +m=d.get("manifests") +if not m: + print(""); sys.exit(0) +print(",".join(sorted({x["platform"]["os"]+"/"+x["platform"]["architecture"] + for x in m if "platform" in x and x["platform"].get("os")!="unknown"}))) +')" + if [[ -z "${platforms}" ]]; then + echo " !! source is single-arch (no manifest index); refusing to mirror" >&2 + exit 1 + fi + if [[ "${platforms}" != *"linux/amd64"* || "${platforms}" != *"linux/arm64"* ]]; then + echo " !! source must include both linux/amd64 and linux/arm64; got: ${platforms}" >&2 + exit 1 + fi + echo " platforms: ${platforms}" + + if [[ "${dry_run}" -eq 1 ]]; then + continue + fi + docker buildx imagetools create --tag "${dst}" "${src}" +done + +echo +echo "Done." diff --git a/build/scripts/start-radius.sh b/build/scripts/start-radius.sh index 1cf9a3707b0..9adaa0e85d1 100755 --- a/build/scripts/start-radius.sh +++ b/build/scripts/start-radius.sh @@ -130,6 +130,12 @@ fi # Ensure logs directory exists mkdir -p "$DEBUG_ROOT/logs" +# Use a writable Terraform global cache directory for local OS-process runs. +# The default `/terraform` path only exists inside the Radius container image +# and would fail with "read-only file system" on host filesystems. +export TERRAFORM_TEST_GLOBAL_DIR="${TERRAFORM_TEST_GLOBAL_DIR:-$DEBUG_ROOT/terraform-global}" +mkdir -p "$TERRAFORM_TEST_GLOBAL_DIR" + # Check prerequisites check_prerequisites diff --git a/build/test.mk b/build/test.mk index c11f4a3f3fd..ba03e7a3b05 100644 --- a/build/test.mk +++ b/build/test.mk @@ -33,6 +33,40 @@ TEST_TIMEOUT ?=1h RADIUS_CONTAINER_LOG_PATH ?=./dist/container_logs REL_VERSION ?=latest DOCKER_REGISTRY ?=ghcr.io/radius-project/dev + +# Auto-detect the local debug OCI registry started by `make debug-publish-recipes`. +# When it is running and the user has not explicitly set BICEP_RECIPE_REGISTRY, +# point the functional tests at it so the locally-published test recipes are used +# instead of the (private) ghcr.io fallback. +ifeq ($(origin BICEP_RECIPE_REGISTRY), undefined) +ifneq ($(shell docker ps --format '{{.Names}}' 2>/dev/null | grep -x radius-debug-registry),) +export BICEP_RECIPE_REGISTRY := localhost:5000 +export BICEP_RECIPE_TAG_VERSION ?= latest +$(info Using local debug recipe registry: BICEP_RECIPE_REGISTRY=$(BICEP_RECIPE_REGISTRY) BICEP_RECIPE_TAG_VERSION=$(BICEP_RECIPE_TAG_VERSION)) +# When the debug-built rad CLI is available, point the functional tests at it +# (via RAD_PATH, honored by test/radcli/cli.go) so they exercise the HEAD CLI +# matching the locally-running control plane, not any system-installed rad. +ifneq ($(wildcard $(CURDIR)/debug_files/bin/rad),) +export RAD_PATH := $(CURDIR)/debug_files/bin +$(info Using debug-built rad CLI: RAD_PATH=$(RAD_PATH)) +endif +endif +endif + +# Auto-detect the in-cluster Git HTTP backend started by `make debug-install-git-http-backend`. +# When its port-forward PID file exists and the process is alive, export the +# GIT_HTTP_* variables that the kubernetes-noncloud Flux tests expect. +ifeq ($(origin GIT_HTTP_SERVER_URL), undefined) +ifneq ($(wildcard $(CURDIR)/debug_files/logs/git-http-port-forward.pid),) +ifneq ($(shell pid=$$(cat $(CURDIR)/debug_files/logs/git-http-port-forward.pid 2>/dev/null); kill -0 $$pid 2>/dev/null && echo up),) +export GIT_HTTP_SERVER_URL := http://localhost:30080 +export GIT_HTTP_USERNAME ?= testuser +export GIT_HTTP_PASSWORD ?= not-a-secret-password +export GIT_HTTP_EMAIL ?= testuser@radapp.io +$(info Using local git-http-backend: GIT_HTTP_SERVER_URL=$(GIT_HTTP_SERVER_URL)) +endif +endif +endif ENVTEST_ASSETS_DIR=$(shell pwd)/bin K8S_VERSION=1.30.* ENV_SETUP=$(GOBIN)/setup-envtest$(BINARY_EXT) @@ -50,8 +84,13 @@ GOTEST_TOOL ?= go test else # Use these options by default but allow an override via env-var GOTEST_OPTS ?= -# We need the double dash here to separate the 'gotestsum' options from the 'go test' options -GOTEST_TOOL ?= gotestsum $(GOTESTSUM_OPTS) -- +# When set, a per-target JSON timing file is emitted as $(GOTESTSUM_JSONFILE_DIR)/.jsonl. +# This avoids the file being overwritten by each sub-target in test-functional-all-*. +# Example: GOTESTSUM_JSONFILE_DIR=/tmp/timings make test-functional-all-noncloud +GOTESTSUM_JSONFILE_DIR ?= +# Recursive '=' so $@ resolves in each recipe's context. +# We need the double dash here to separate the 'gotestsum' options from the 'go test' options. +GOTEST_TOOL = gotestsum $(GOTESTSUM_OPTS)$(if $(GOTESTSUM_JSONFILE_DIR), --jsonfile=$(GOTESTSUM_JSONFILE_DIR)/$@.jsonl) -- endif .PHONY: test @@ -162,6 +201,44 @@ test-functional-samples: test-functional-samples-noncloud ## Runs all Samples fu test-functional-samples-noncloud: ## Runs Samples functional tests that do not require cloud resources CGO_ENABLED=1 $(GOTEST_TOOL) ./test/functional-portable/samples/noncloud/... -timeout ${TEST_TIMEOUT} -v -parallel 5 $(GOTEST_OPTS) +# ---------------------------------------------------------------------------- +# Local Azure functional tests +# +# These targets orchestrate an ephemeral Azure resource group, deploy the test +# fixtures (Cosmos Mongo for Test_AzureConnections), run the Test_Azure* subset +# of corerp-cloud tests against your locally-running Radius stack (make +# debug-start) using ambient `az login` credentials, and tear everything down. +# +# Prerequisites: +# - `az login` succeeded for the target subscription. +# - `make debug-start` is running (OS-process Radius). +# - Deployment Engine is running locally on :5017 (NOT in a container) so it +# can use the az CLI fallback. See debug_files/logs/de-external.marker. +# +# NOTE: AWS is intentionally out of scope for this iteration. +# ---------------------------------------------------------------------------- +AZURE_LOCAL_TESTENV := ./build/scripts/azure-local-testenv.sh + +.PHONY: test-functional-azure-local-setup +test-functional-azure-local-setup: ## Provision an ephemeral Azure RG and fixtures for local Azure functional tests. + @$(AZURE_LOCAL_TESTENV) setup + +.PHONY: test-functional-azure-local-run +test-functional-azure-local-run: ## Run Test_Azure* against the locally-running Radius stack using the env from setup. + @$(AZURE_LOCAL_TESTENV) run + +.PHONY: test-functional-azure-local-teardown +test-functional-azure-local-teardown: ## Delete the ephemeral Azure RG and clear local Azure test state. + @$(AZURE_LOCAL_TESTENV) teardown + +.PHONY: test-functional-azure-local +test-functional-azure-local: ## Setup -> run Test_Azure* -> teardown (teardown runs even on test failure). + @$(AZURE_LOCAL_TESTENV) all + +.PHONY: test-functional-azure-local-keep +test-functional-azure-local-keep: ## Same as test-functional-azure-local but skips teardown on failure (post-mortem). + @AZURE_LOCAL_KEEP_ON_FAILURE=1 $(AZURE_LOCAL_TESTENV) all + .PHONY: test-validate-bicep test-validate-bicep: ## Validates that all .bicep files compile cleanly BICEP_PATH="${HOME}/.rad/bin/bicep" ./build/validate-bicep.sh diff --git a/docs/contributing/contributing-code/contributing-code-debugging/radius-os-processes-debugging.md b/docs/contributing/contributing-code/contributing-code-debugging/radius-os-processes-debugging.md index 5cc938ddbcb..1652befb5e7 100644 --- a/docs/contributing/contributing-code/contributing-code-debugging/radius-os-processes-debugging.md +++ b/docs/contributing/contributing-code/contributing-code-debugging/radius-os-processes-debugging.md @@ -7,7 +7,8 @@ Run Radius components as OS processes with full debugger support - set breakpoin 1. [Quick Start](#quick-start) 2. [Prerequisites](#prerequisites) 3. [Debugging Workflow](#debugging-workflow) -4. [Troubleshooting](#troubleshooting) +4. [Using a Local Deployment Engine](#using-a-local-deployment-engine) +5. [Troubleshooting](#troubleshooting) ## Overview @@ -235,6 +236,182 @@ make debug-logs # Tail all component logs make debug-help # Show all debug commands ``` +## Using a Local Deployment Engine + +By default `make debug-start` runs the Deployment Engine (DE) inside the k3d +cluster from the published `ghcr.io/radius-project/deployment-engine:latest` +image. If you are working on DE itself you can run it as a local OS process +instead and have the debug stack pick it up automatically. + +### Auto-detection + +`make debug-start` checks whether something is already listening on TCP +**port 5017** (`lsof -nP -iTCP:5017 -sTCP:LISTEN`). If it is, the in-cluster DE +deployment is skipped and a marker file is written to +`debug_files/logs/de-external.marker`. `make debug-stop` honours the marker and +leaves your local DE process alone. + +There is nothing else to configure on the Radius side — UCP/Applications RP +will reach DE at `http://localhost:5017` and DE will reach UCP at +`http://localhost:9000/apis/api.ucp.dev/v1alpha3`. + +### Running DE locally + +From your `deployment-engine` checkout, in a shell where you want DE attached +to a debugger or just running with hot-reload: + +```bash +# UCP endpoint exposed by `make debug-start` +export RADIUSBACKENDURL=http://localhost:9000/apis/api.ucp.dev/v1alpha3 +export ASPNETCORE_URLS=http://+:5017 + +# Provider toggles that the in-cluster DE config sets by default +export AZURE_ENABLED=true +export AWS_ENABLED=false +export KUBERNETES_ENABLED=true + +# IMPORTANT: do NOT set ARM_AUTH_METHOD when you want DE to use ambient +# Azure credentials (az CLI / DefaultAzureCredential). Setting it to +# UCPCredential forces DE to fetch credentials from UCP, which is the +# correct value when DE runs in-cluster but defeats the local path. +unset ARM_AUTH_METHOD SKIP_ARM + +dotnet run --project src/DeploymentEngine +``` + +Then start (or restart) the rest of the stack: + +```bash +make debug-start +# Output will include: +# ℹ Detected Deployment Engine listening on localhost:5017 — using external instance +``` + +### Switching back to the in-cluster DE + +Stop your local DE process so port 5017 is free, then: + +```bash +rm -f debug_files/logs/de-external.marker +make debug-stop +make debug-start +``` + +### Running Azure functional tests against the local stack + +When DE is running locally with ambient `az login` credentials, you can run +the Azure subset of the cloud functional tests without registering any Azure +service principal in UCP. The test helper +`AssertCredentialExists` honours the `RADIUS_TEST_USE_LOCAL_CLOUD_CREDS` +environment variable as a local-dev escape hatch (set to `azure`, `aws`, +`azure,aws`, or `1` for all clouds). The container-DE / CI path is unchanged +and still requires `rad credential register azure …`. + +A make target orchestrates an ephemeral Azure resource group, deploys the +test fixtures (Cosmos Mongo for `Test_AzureConnections`), runs the entire +`corerp/cloud/...` suite (AWS-required tests skip automatically because +`RADIUS_TEST_USE_LOCAL_CLOUD_CREDS=azure` only covers Azure), and tears +everything down even if the tests fail: + +```bash +az login +az account set --subscription + +make debug-start # OS-process Radius, picks up local DE +make test-functional-azure-local # setup → run → teardown +``` + +Sub-targets if you want manual control: + +```bash +make test-functional-azure-local-setup # create RG, deploy fixtures +make test-functional-azure-local-run # run corerp/cloud tests against the stack +make test-functional-azure-local-teardown # delete RG, clear state +``` + +For post-mortem debugging of failing tests, use the `-keep` variant. It runs +setup → run → teardown as normal, but **skips the teardown step if any test +fails** so you can inspect the RG and the running stack: + +```bash +make test-functional-azure-local-keep +# On failure, RG is preserved. When done: +make test-functional-azure-local-teardown +``` + +The setup step creates a resource group named +`radlocal-${USER}-$(date +%s)` (tagged `creator`/`creationTime`/`purpose=radius-local-test`) +and writes state to `debug_files/logs/azure-local.env`. To reuse a long-lived +resource group instead of paying the ~3-5 minute Cosmos provisioning cost on +every run: + +```bash +AZURE_LOCAL_PREPROVISIONED_RG= make test-functional-azure-local-setup +``` + +The teardown step refuses to delete a pre-provisioned RG. + +#### Re-running individual tests + +`run` accepts arbitrary `go test` flags after the sub-command, so you can +quickly re-run one failing test without re-doing setup or teardown: + +```bash +./build/scripts/azure-local-testenv.sh run -run '^Test_TerraformRecipe_AzureResourceGroup$' -v +``` + +If `debug_files/logs/azure-local.env` was wiped (e.g. by `make debug-stop`), +`run` auto-recovers state by listing `radlocal-${USER}-*` resource groups in +the current subscription and picking the newest. It re-applies the Azure +scope on the `default` rad environment too — `make debug-start` resets the +embedded Postgres DB which clears the env's provider config. + +#### Cleaning up orphaned resource groups + +If a previous run left RGs behind (cancelled tests, lost state file, multiple +attempts), garbage-collect everything you own with one command: + +```bash +./build/scripts/azure-local-testenv.sh teardown --all-orphans +``` + +This deletes every `radlocal-${USER}-*` RG in the current subscription +(`--no-wait`), stops the `tf-module-server` port-forward, and removes the +state file. Pre-provisioned RGs (`AZURE_LOCAL_PREPROVISIONED_RG`) are not +touched. + +#### Terraform module server bootstrap + +`Test_TerraformRecipe_AzureResourceGroup` consumes a recipe served from +`http://localhost:8999`. Both `setup` and `run` call `ensure_tf_module_server` +which: + +1. Probes `http://localhost:8999/azure-rg.zip` and short-circuits if reachable. +2. Otherwise runs `make publish-test-terraform-recipes` (deploys the nginx + `tf-module-server` Deployment + Service into the + `radius-test-tf-module-server` namespace). +3. Starts a `kubectl port-forward svc/tf-module-server 8999:80` in the + background (PID stored under `debug_files/logs/tf-module-server-pf.pid`). +4. Waits for `/azure-rg.zip` to return 200. + +Teardown (and `--all-orphans`) stop the port-forward. + +#### Terraform recipes and Azure CLI credentials + +The Azure terraform provider configuration in +[`pkg/recipes/terraform/config/providers/azure.go`](../../../../pkg/recipes/terraform/config/providers/azure.go) +falls back to **`use_cli = true`** when no credential is registered with UCP +(404 from `/planes/azure/azurecloud/providers/System.Azure/credentials/default`). +This makes terraform pick up the same `az login` session the host RP process +already uses. No `rad credential register azure …` is required for local dev. + +In CI a workload-identity credential is registered as before; that path is +unchanged. + +> **Note:** AWS local-credentials and Azure MSSQL fixtures are intentionally +> out of scope for this flow. Tests that require `AZURE_MSSQL_*` env vars +> auto-skip when those vars are absent. + ## Troubleshooting ### Components Won't Start diff --git a/pkg/azure/clientv2/unfold.go b/pkg/azure/clientv2/unfold.go index 65bedea68c4..ef8e8476a63 100644 --- a/pkg/azure/clientv2/unfold.go +++ b/pkg/azure/clientv2/unfold.go @@ -17,6 +17,7 @@ limitations under the License. package clientv2 import ( + "bytes" "encoding/json" "errors" "fmt" @@ -201,12 +202,17 @@ func readResponseBody(resp *http.Response) ([]byte, error) { if resp.Body == nil { return []byte{}, nil } - defer resp.Body.Close() data, err := io.ReadAll(resp.Body) + // Always close the original body; if we successfully read it, replace it + // with an in-memory reader so subsequent reads return the same bytes + // (otherwise unfolding the same *azcore.ResponseError twice would yield + // an empty body and a nil ErrorDetails). + _ = resp.Body.Close() if err != nil { return nil, fmt.Errorf("error reading response body: %w", err) } + resp.Body = io.NopCloser(bytes.NewReader(data)) return data, nil } diff --git a/pkg/corerp/frontend/controller/applications/testbicep_scan_test.go b/pkg/corerp/frontend/controller/applications/testbicep_scan_test.go new file mode 100644 index 00000000000..cbad68550e6 --- /dev/null +++ b/pkg/corerp/frontend/controller/applications/testbicep_scan_test.go @@ -0,0 +1,96 @@ +// ------------------------------------------------------------ +// 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 applications + +import ( + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// Test_NoSharedDefaultEnvironmentInTestBicep enforces the rule that no test +// .bicep file may declare an Applications.Core/environments resource named +// 'default'. Mutating the shared `default` environment from a parallel test +// (e.g. setting its compute.namespace to a long string) leaks into other tests +// that target `default` and silently breaks them — most visibly by pushing the +// joined `-` past Kubernetes' 63-character namespace +// limit. CI clusters are ephemeral so the bug usually hides there, but local +// `make debug-start` persists state in etcd/postgres across runs and exposes +// it. See pkg/corerp/frontend/controller/applications/updatefilter.go. +// +// If you have a legitimate need to test the default env, do it in a dedicated +// test that creates and tears down its own uniquely-named env. +func Test_NoSharedDefaultEnvironmentInTestBicep(t *testing.T) { + repoRoot := findRepoRoot(t) + testDir := filepath.Join(repoRoot, "test") + + // Matches: + // resource 'Applications.Core/environments@...' = { + // name: 'default' + // Allows arbitrary whitespace and any resource-symbol name. + envBlock := regexp.MustCompile( + `(?s)resource\s+\w+\s+'Applications\.Core/environments@[^']+'\s*=\s*\{[^}]*?name:\s*'default'`) + + var offenders []string + err := filepath.Walk(testDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() || !strings.HasSuffix(path, ".bicep") { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if envBlock.Match(data) { + rel, _ := filepath.Rel(repoRoot, path) + offenders = append(offenders, rel) + } + return nil + }) + require.NoError(t, err) + + require.Emptyf(t, offenders, + "the following test bicep files declare an Applications.Core/environments named 'default', "+ + "which mutates the shared default env and breaks other parallel tests "+ + "(see Test_NoSharedDefaultEnvironmentInTestBicep doc comment):\n %s", + strings.Join(offenders, "\n ")) +} + +// findRepoRoot walks up from this test file's directory until it finds a go.mod. +func findRepoRoot(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + require.True(t, ok, "runtime.Caller failed") + dir := filepath.Dir(thisFile) + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatalf("could not locate repo root (no go.mod found above %s)", filepath.Dir(thisFile)) + } + dir = parent + } +} diff --git a/pkg/corerp/frontend/controller/applications/updatefilter.go b/pkg/corerp/frontend/controller/applications/updatefilter.go index 7b050f85909..a467c3c26b7 100644 --- a/pkg/corerp/frontend/controller/applications/updatefilter.go +++ b/pkg/corerp/frontend/controller/applications/updatefilter.go @@ -81,10 +81,17 @@ func CreateAppScopedNamespace(ctx context.Context, newResource, oldResource *dat newResource.Properties.Environment, err.Error())), nil } - namespace := fmt.Sprintf("%s-%s", envNamespace, serviceCtx.ResourceID.Name()) + appName := serviceCtx.ResourceID.Name() + namespace := fmt.Sprintf("%s-%s", envNamespace, appName) if valid, msg := kubernetes.IsValidObjectName(namespace); !valid { - return rest.NewBadRequestResponse(fmt.Sprintf("Application namespace '%s' could not be created: %s", - namespace, msg)), nil + // Include the inputs and their lengths so the failure points at the + // concrete fix (shorten env namespace or app name) instead of forcing + // the reader to count characters in a 60+ char composite string. + return rest.NewBadRequestResponse(fmt.Sprintf( + "Application namespace '%s' could not be created: %s "+ + "(envNamespace=%q len=%d, appName=%q len=%d, joined len=%d, max=63). "+ + "Shorten either the environment's namespace or the application name.", + namespace, msg, envNamespace, len(envNamespace), appName, len(appName), len(namespace))), nil } kubeNamespace = kubernetes.NormalizeResourceName(namespace) diff --git a/pkg/recipes/engine/engine.go b/pkg/recipes/engine/engine.go index e981ed54e82..28948c46ccf 100644 --- a/pkg/recipes/engine/engine.go +++ b/pkg/recipes/engine/engine.go @@ -21,6 +21,7 @@ import ( "fmt" "time" + "github.com/radius-project/radius/pkg/azure/clientv2" "github.com/radius-project/radius/pkg/components/metrics" "github.com/radius-project/radius/pkg/recipes" "github.com/radius-project/radius/pkg/recipes/configloader" @@ -58,8 +59,8 @@ func (e *engine) Execute(ctx context.Context, opts ExecuteOptions) (*recipes.Rec recipeOutput, definition, err := e.executeCore(ctx, opts.Recipe, opts.PreviousState) if err != nil { result = metrics.FailedOperationState - if recipes.GetErrorDetails(err) != nil { - result = recipes.GetErrorDetails(err).Code + if details := recipes.GetErrorDetails(err); details != nil { + result = details.Code } } @@ -120,8 +121,8 @@ func (e *engine) Delete(ctx context.Context, opts DeleteOptions) error { definition, err := e.deleteCore(ctx, opts.Recipe, opts.OutputResources) if err != nil { result = metrics.FailedOperationState - if recipes.GetErrorDetails(err) != nil { - result = recipes.GetErrorDetails(err).Code + if details := recipes.GetErrorDetails(err); details != nil { + result = details.Code } } @@ -138,6 +139,14 @@ func (e *engine) deleteCore(ctx context.Context, recipe recipes.ResourceMetadata logger := ucplog.FromContextOrDiscard(ctx) configuration, err := e.options.ConfigurationLoader.LoadConfiguration(ctx, recipe) if err != nil { + // If the environment has already been deleted (e.g. test cleanup deleted + // it before the child resource's async delete drained), there is nothing + // left to clean up via a recipe. Treat as a successful no-op so the + // resource deletion can proceed. + if clientv2.Is404Error(err) { + logger.Info("environment not found while loading recipe configuration for delete; treating as no-op") + return nil, nil + } return nil, err } diff --git a/pkg/recipes/terraform/config/providers/azure.go b/pkg/recipes/terraform/config/providers/azure.go index 2a97b8f0e31..395106d0743 100644 --- a/pkg/recipes/terraform/config/providers/azure.go +++ b/pkg/recipes/terraform/config/providers/azure.go @@ -21,6 +21,7 @@ import ( "errors" "fmt" + "github.com/radius-project/radius/pkg/azure/clientv2" "github.com/radius-project/radius/pkg/azure/tokencredentials" "github.com/radius-project/radius/pkg/components/secret" "github.com/radius-project/radius/pkg/components/secret/secretprovider" @@ -128,8 +129,8 @@ func fetchAzureCredentials(ctx context.Context, azureCredentialsProvider credent logger := ucplog.FromContextOrDiscard(ctx) credentials, err := azureCredentialsProvider.Fetch(ctx, credentials.AzureCloud, "default") if err != nil { - if errors.Is(err, &secret.ErrNotFound{}) { - logger.Info("Azure credentials are not registered, skipping credentials configuration.") + if errors.Is(err, &secret.ErrNotFound{}) || clientv2.Is404Error(err) { + logger.Info("Azure credentials are not registered, falling back to Azure CLI credentials.") return nil, nil } @@ -168,6 +169,16 @@ func (p *azureProvider) generateProviderConfigMap(configMap map[string]any, cred configMap[azureSubIDParam] = subscriptionID } + // When no Radius-managed credentials are registered (e.g. a developer + // running the RP locally without `rad credential register azure ...`), + // fall back to the Azure CLI credentials available on the host process. + // `use_cli = true` is the azurerm provider default but we set it + // explicitly to make the intent clear in the generated terraform config. + if credentials == nil { + configMap[azureUseCLIParam] = true + return configMap + } + switch credentials.Kind { case ucp_datamodel.AzureServicePrincipalCredentialKind: if credentials.ServicePrincipal != nil && diff --git a/test/createAzureTestResources.bicep b/test/createAzureTestResources.bicep index 2bb507fa2fd..40d542d90ee 100644 --- a/test/createAzureTestResources.bicep +++ b/test/createAzureTestResources.bicep @@ -1,7 +1,10 @@ param location string = resourceGroup().location +@description('Name of the Cosmos DB (MongoDB) account. Cosmos account names are globally unique, so callers running in parallel or recreating the deployment shortly after a delete must override this with a unique value.') +param cosmosAccountName string = 'account-radiustest' + resource account 'Microsoft.DocumentDB/databaseAccounts@2020-04-01' = { - name: 'account-radiustest' + name: cosmosAccountName location: location kind: 'MongoDB' tags: { diff --git a/test/functional-portable/cli/noncloud/cli_test.go b/test/functional-portable/cli/noncloud/cli_test.go index 4d33ff89034..104fd42189f 100644 --- a/test/functional-portable/cli/noncloud/cli_test.go +++ b/test/functional-portable/cli/noncloud/cli_test.go @@ -323,6 +323,20 @@ func Test_Run_Logger(t *testing.T) { // Read the text line-by-line while the command is running, but store it so we can report failures. output := bytes.Buffer{} + sawExpectedLine := false + + // Watchdog: if the expected log line never appears within the window, cancel the + // command so the test fails fast with a useful message instead of hanging until the + // outer test timeout. The bicep template prints "hello from the streaming logs!" in + // a while-true loop with a 10s sleep, so on a healthy cluster the first line should + // arrive well within a few minutes (image pull + pod schedule + first iteration). + const watchdogTimeout = 3 * time.Minute + watchdog := time.AfterFunc(watchdogTimeout, func() { + t.Logf("Test_Run_Logger watchdog: expected log line not observed within %s; cancelling 'rad run'", watchdogTimeout) + cancel() + }) + defer watchdog.Stop() + scanner := bufio.NewScanner(stdout) scanner.Split(bufio.ScanLines) for scanner.Scan() { @@ -330,6 +344,8 @@ func Test_Run_Logger(t *testing.T) { output.WriteString(line) output.WriteString("\n") if strings.Contains(line, "hello from the streaming logs!") { + sawExpectedLine = true + watchdog.Stop() cancel() // Stop the command, but keep reading to drain all output. } } @@ -351,6 +367,9 @@ func Test_Run_Logger(t *testing.T) { // We should have an error, but only because we canceled the context. require.Errorf(t, err, "rad run should have been canceled") require.Equal(t, err, ctx.Err(), "rad run should have been canceled") + require.Truef(t, sawExpectedLine, + "watchdog fired after %s without seeing 'hello from the streaming logs!' — container likely never produced logs. Captured output:\n%s", + watchdogTimeout, output.String()) } func Test_Run_Portforward(t *testing.T) { diff --git a/test/functional-portable/corerp/cloud/resources/extender_test.go b/test/functional-portable/corerp/cloud/resources/extender_test.go index a599bc6243e..2bb0211dcfb 100644 --- a/test/functional-portable/corerp/cloud/resources/extender_test.go +++ b/test/functional-portable/corerp/cloud/resources/extender_test.go @@ -32,10 +32,12 @@ import ( func Test_Extender_RecipeAWS_LogGroup(t *testing.T) { awsAccountID := os.Getenv("AWS_ACCOUNT_ID") awsRegion := os.Getenv("AWS_REGION") - // Error the test if the required environment variables are not set - // for running locally set the environment variables + // Skip the test if the required environment variables are not set + // (in CI these are provided alongside AWS credentials; locally the + // AWS feature gate via RequiredFeatures will also skip the test when + // AWS credentials are not registered with UCP). if awsAccountID == "" || awsRegion == "" { - t.Error("This test needs the env variables AWS_ACCOUNT_ID and AWS_REGION to be set") + t.Skip("This test needs the env variables AWS_ACCOUNT_ID and AWS_REGION to be set") } template := "testdata/corerp-resources-extender-aws-logs-recipe.bicep" diff --git a/test/functional-portable/corerp/cloud/resources/recipe_terraform_test.go b/test/functional-portable/corerp/cloud/resources/recipe_terraform_test.go index 0cb42b3cfe9..479fb4ddecd 100644 --- a/test/functional-portable/corerp/cloud/resources/recipe_terraform_test.go +++ b/test/functional-portable/corerp/cloud/resources/recipe_terraform_test.go @@ -25,6 +25,7 @@ package resource_test import ( "context" + "os" "strings" "testing" @@ -72,7 +73,9 @@ func Test_TerraformRecipe_AzureResourceGroup(t *testing.T) { }, SkipObjectValidation: true, PostStepVerify: func(ctx context.Context, t *testing.T, test rp.RPTest) { - resourceID := "/planes/radius/local/resourcegroups/kind-radius/providers/Applications.Core/extenders/" + name + // Use the active workspace's scope so this works against any + // resource group (CI uses 'kind-radius', local debug uses 'default'). + resourceID := test.Options.Workspace.Scope + "/providers/Applications.Core/extenders/" + name secretSuffix, err := corerp.GetSecretSuffix(resourceID, envName, appName) require.NoError(t, err) @@ -86,7 +89,7 @@ func Test_TerraformRecipe_AzureResourceGroup(t *testing.T) { }) test.PostDeleteVerify = func(ctx context.Context, t *testing.T, test rp.RPTest) { - resourceID := "/planes/radius/local/resourcegroups/kind-radius/providers/Applications.Core/extenders/" + name + resourceID := test.Options.Workspace.Scope + "/providers/Applications.Core/extenders/" + name corerp.TestSecretDeletion(t, ctx, test, appName, envName, resourceID, secretNamespace, secretPrefix) } @@ -103,6 +106,12 @@ func Test_TerraformRecipe_AzureResourceGroup(t *testing.T) { // - Upload the files from test/testrecipes/test-terraform-recipes/kubernetes-redis/modules to a private repository and update the module source in testutil.GetTerraformPrivateModuleSource() // - Create a PAT to access the private repository and update testutil.GetGitPAT() to return the generated PAT. func Test_TerraformPrivateGitModule_KubernetesRedis(t *testing.T) { + // This test pulls a Terraform module from a private GitHub repo using a + // personal access token supplied via the GH_TOKEN env var (set in CI). + // Without that secret the deployment cannot succeed, so skip locally. + if strings.TrimSpace(os.Getenv("GH_TOKEN")) == "" { + t.Skip("Test_TerraformPrivateGitModule_KubernetesRedis requires GH_TOKEN to access a private terraform module repo") + } template := "testdata/corerp-resources-terraform-private-git-repo-redis.bicep" name := "corerp-resources-terraform-private-redis" appName := "corerp-resources-terraform-private-app" diff --git a/test/functional-portable/corerp/noncloud/resources/application_test.go b/test/functional-portable/corerp/noncloud/resources/application_test.go index 211374e61db..5566ba4b97c 100644 --- a/test/functional-portable/corerp/noncloud/resources/application_test.go +++ b/test/functional-portable/corerp/noncloud/resources/application_test.go @@ -18,7 +18,9 @@ package resource_test import ( "context" + "encoding/json" "sort" + "strings" "testing" "github.com/radius-project/radius/test/rp" @@ -29,6 +31,7 @@ import ( aztoken "github.com/radius-project/radius/pkg/azure/tokencredentials" "github.com/radius-project/radius/pkg/cli/clients" "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" + "github.com/radius-project/radius/pkg/ucp/resources" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -110,9 +113,17 @@ func Test_ApplicationGraph(t *testing.T) { res, err := appGraphClient.GetGraph(ctx, "corerp-application-simple1", map[string]any{}, nil) require.NoError(t, err) - // assert that the graph is as expected + // assert that the graph is as expected. The fixture was authored against the + // CI cluster's resource group name `kind-radius`; rewrite it on read so the + // test passes regardless of which resource group the local environment uses. expected := []*v20231001preview.ApplicationGraphResource{} - testutil.MustUnmarshalFromFile("corerp-resources-application-graph-out.json", &expected) + scope, err := resources.ParseScope(appManagementClient.RootScope) + require.NoError(t, err) + rg := scope.FindScope("resourcegroups") + require.NotEmpty(t, rg, "expected a resource group in RootScope %q", appManagementClient.RootScope) + rawFixture := testutil.ReadFixture("corerp-resources-application-graph-out.json") + rawFixture = []byte(strings.ReplaceAll(string(rawFixture), "/resourcegroups/kind-radius/", "/resourcegroups/"+rg+"/")) + require.NoError(t, json.Unmarshal(rawFixture, &expected)) // For easier comparison, we sort the resources by name. sort.Slice(res.Resources, func(i, j int) bool { diff --git a/test/functional-portable/corerp/noncloud/resources/testdata/corerp-resources-simulatedenv.bicep b/test/functional-portable/corerp/noncloud/resources/testdata/corerp-resources-simulatedenv.bicep index d200878ee8c..f5ef0712776 100644 --- a/test/functional-portable/corerp/noncloud/resources/testdata/corerp-resources-simulatedenv.bicep +++ b/test/functional-portable/corerp/noncloud/resources/testdata/corerp-resources-simulatedenv.bicep @@ -9,14 +9,18 @@ param port int = 3000 @description('Specifies the image for the container resource.') param magpieimage string +// NOTE: do NOT name this 'default'. Tests must never mutate the shared `default` +// environment because other tests deploy applications into it; the joined +// `-` would exceed k8s' 63-char namespace limit and +// break unrelated tests (e.g. mechanics/communication-cycle). resource env 'Applications.Core/environments@2023-10-01-preview' = { - name: 'default' + name: 'corerp-simulatedenv' location: 'global' properties: { compute: { kind: 'kubernetes' resourceId: 'self' - namespace: 'corerp-resources-simulatedenv-env' + namespace: 'corerp-simulatedenv' } simulated: true } diff --git a/test/functional-portable/corerp/util.go b/test/functional-portable/corerp/util.go index 60631718720..d699abae469 100644 --- a/test/functional-portable/corerp/util.go +++ b/test/functional-portable/corerp/util.go @@ -18,6 +18,7 @@ package corerp import ( "context" + "strings" "testing" "github.com/stretchr/testify/require" @@ -25,8 +26,11 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/radius-project/radius/pkg/cli" "github.com/radius-project/radius/pkg/recipes" "github.com/radius-project/radius/pkg/recipes/terraform/config/backends" + "github.com/radius-project/radius/pkg/ucp/resources" + resources_radius "github.com/radius-project/radius/pkg/ucp/resources/radius" "github.com/radius-project/radius/test/rp" ) @@ -42,10 +46,37 @@ func TestSecretDeletion(t *testing.T, ctx context.Context, test rp.RPTest, appNa require.Equal(t, secret, &corev1.Secret{}) } -// GetSecretSuffix returns the secret suffix for a given resource +// CurrentWorkspaceResourceGroup loads the rad workspace currently configured for tests and +// returns the resource group from its scope. If no workspace is configured it falls back +// to the historical default "kind-radius" used by the cloud test harness. +func CurrentWorkspaceResourceGroup() string { + config, err := cli.LoadConfig("") + if err != nil { + return "kind-radius" + } + workspace, err := cli.GetWorkspace(config, "") + if err != nil || workspace == nil || workspace.Scope == "" { + return "kind-radius" + } + scope, err := resources.ParseScope(workspace.Scope) + if err != nil { + return "kind-radius" + } + if rg := scope.FindScope(resources_radius.ScopeResourceGroups); rg != "" { + return rg + } + return "kind-radius" +} + +// GetSecretSuffix returns the secret suffix for a given resource. The resource group +// embedded in the environment / application / resource IDs is normalized to the +// current workspace's resource group so the computed suffix matches the secret name +// produced by the Terraform recipe at runtime. func GetSecretSuffix(resourceID, envName, appName string) (string, error) { - envID := "/planes/radius/local/resourcegroups/kind-radius/providers/Applications.Core/environments/" + envName - appID := "/planes/radius/local/resourcegroups/kind-radius/providers/Applications.Core/applications/" + appName + rg := CurrentWorkspaceResourceGroup() + envID := "/planes/radius/local/resourcegroups/" + rg + "/providers/Applications.Core/environments/" + envName + appID := "/planes/radius/local/resourcegroups/" + rg + "/providers/Applications.Core/applications/" + appName + resourceID = rewriteResourceGroup(resourceID, rg) resourceRecipe := recipes.ResourceMetadata{ EnvironmentID: envID, @@ -63,3 +94,19 @@ func GetSecretSuffix(resourceID, envName, appName string) (string, error) { return kubernetes["secret_suffix"].(string), nil } + +// rewriteResourceGroup rewrites the resource group segment of a Radius resource ID to +// the supplied resource group name. Returns the input unchanged if it cannot be parsed. +func rewriteResourceGroup(resourceID, rg string) string { + parsed, err := resources.ParseResource(resourceID) + if err != nil { + return resourceID + } + current := parsed.FindScope(resources_radius.ScopeResourceGroups) + if current == "" || current == rg { + return resourceID + } + // Case-insensitive replacement of the resourcegroups segment value while preserving the + // rest of the ID exactly. + return strings.ReplaceAll(resourceID, "/"+current+"/", "/"+rg+"/") +} diff --git a/test/rp/rptest.go b/test/rp/rptest.go index b60af586f74..31fabb2dfbb 100644 --- a/test/rp/rptest.go +++ b/test/rp/rptest.go @@ -508,7 +508,13 @@ func (ct RPTest) Test(t *testing.T) { continue } - for _, resource := range step.RPResources.Resources { + // Delete resources in reverse declaration order so that the Environment + // (typically declared first) is deleted last. Otherwise the Environment + // can be removed while child resources (Applications/Containers/portable + // resources) are still mid-cascade, causing their recipe-driven cleanup + // to fail loading the (now-gone) environment configuration. + for i := len(step.RPResources.Resources) - 1; i >= 0; i-- { + resource := step.RPResources.Resources[i] t.Logf("deleting %s", resource.Name) if ct.FastCleanup { diff --git a/test/ucp/ucptest.go b/test/ucp/ucptest.go index ff246b30f3d..395227a99b1 100644 --- a/test/ucp/ucptest.go +++ b/test/ucp/ucptest.go @@ -25,6 +25,7 @@ import ( "sync" "testing" + "github.com/radius-project/radius/pkg/cli" "github.com/radius-project/radius/pkg/cli/kubernetes" "github.com/radius-project/radius/pkg/sdk" "github.com/radius-project/radius/test" @@ -100,11 +101,30 @@ func (ucptest UCPTest) Test(t *testing.T) { } }) - config, err := kubernetes.NewCLIClientConfig("") - require.NoError(t, err, "failed to read kubeconfig") - - connection, err := sdk.NewKubernetesConnectionFromConfig(config) - require.NoError(t, err, "failed to create kubernetes connection") + // Prefer the active rad workspace's connection so the tests honor the + // workspace's UCP override (e.g. http://localhost:9000 when Radius runs + // as host OS processes via `make debug-start`). When no override is + // configured (the CI default) `Workspace.Connect()` falls through to + // the standard kubernetes APIService aggregation path and behaves + // identically to `sdk.NewKubernetesConnectionFromConfig`. + var ( + connection sdk.Connection + err error + ) + if config, cfgErr := cli.LoadConfig(""); cfgErr == nil { + if workspace, wsErr := cli.GetWorkspace(config, ""); wsErr == nil && workspace != nil { + t.Logf("Loaded workspace: %s (%s)", workspace.Name, workspace.FmtConnection()) + connection, err = workspace.Connect(ctx) + } + } + if connection == nil { + // Fall back to the legacy direct-from-kubeconfig path when no rad + // workspace is available. + var kcfg, cerr = kubernetes.NewCLIClientConfig("") + require.NoError(t, cerr, "failed to read kubeconfig") + connection, err = sdk.NewKubernetesConnectionFromConfig(kcfg) + } + require.NoError(t, err, "failed to create UCP connection") // Transport will be nil for some default cases as http.Client does not require it to be set. // Since the tests call the transport directly then just pass in the default. diff --git a/test/validation/shared.go b/test/validation/shared.go index a09f81ca954..6c50bdde309 100644 --- a/test/validation/shared.go +++ b/test/validation/shared.go @@ -18,8 +18,8 @@ package validation import ( "context" "encoding/json" - "fmt" "net/http" + "os" "strings" "testing" @@ -129,33 +129,15 @@ func DeleteRPResourceSilent(ctx context.Context, cli *radcli.CLI, client clients func ValidateRPResources(ctx context.Context, t *testing.T, expected *RPResourceSet, client clients.ApplicationsManagementClient) { for _, expectedResource := range expected.Resources { if expectedResource.Type == EnvironmentsResource { - envs, err := client.ListEnvironments(ctx) - require.NoError(t, err) - require.NotEmpty(t, envs) - - found := false - for _, env := range envs { - if *env.Name == expectedResource.Name { - found = true - break - } - } - - require.True(t, found, fmt.Sprintf("environment %s was not found", expectedResource.Name)) + // Use GetEnvironment instead of ListEnvironments to avoid a read-after-write + // race where the resource exists but is not yet visible via the paginated list. + _, err := client.GetEnvironment(ctx, expectedResource.Name) + require.NoErrorf(t, err, "environment %s was not found", expectedResource.Name) } else if expectedResource.Type == ApplicationsResource { - apps, err := client.ListApplications(ctx) - require.NoError(t, err) - require.NotEmpty(t, apps) - - found := false - for _, app := range apps { - if *app.Name == expectedResource.Name { - found = true - break - } - } - - require.True(t, found, fmt.Sprintf("application %s was not found", expectedResource.Name)) + // Use GetApplication instead of ListApplications to avoid a read-after-write + // race where the resource exists but is not yet visible via the paginated list. + _, err := client.GetApplication(ctx, expectedResource.Name) + require.NoErrorf(t, err, "application %s was not found", expectedResource.Name) } else { res, err := client.GetResource(ctx, expectedResource.Type, expectedResource.Name) require.NoError(t, err) @@ -197,7 +179,20 @@ func ValidateRPResources(ctx context.Context, t *testing.T, expected *RPResource } // AssertCredentialExists checks if the credential is registered in the workspace and returns a boolean value. +// +// Local-dev escape hatch: when RADIUS_TEST_USE_LOCAL_CLOUD_CREDS lists the credential +// (comma-separated; supported values: "azure", "aws", or "1"/"true" for all clouds), +// the UCP credential check is bypassed and the test is allowed to run as if the +// credential were registered. This is used by the `test-functional-azure-local` +// make target, which runs Radius components with ambient cloud credentials +// (az CLI / AWS profile) instead of credentials registered in UCP. The container-DE +// path used by CI and most contributors is unaffected. func AssertCredentialExists(t *testing.T, credential string) bool { + if localCloudCredAllowed(credential) { + t.Logf("RADIUS_TEST_USE_LOCAL_CLOUD_CREDS includes %q; bypassing UCP credential check", credential) + return true + } + ctx := testcontext.New(t) config, err := cli.LoadConfig("") @@ -216,3 +211,23 @@ func AssertCredentialExists(t *testing.T, credential string) bool { return cred.CloudProviderStatus.Enabled } + +// localCloudCredAllowed reports whether the given credential ("azure", "aws") is +// covered by the RADIUS_TEST_USE_LOCAL_CLOUD_CREDS escape hatch. Accepted values: +// - "1" / "true": all clouds. +// - comma-separated list of cloud names, e.g. "azure" or "azure,aws". +func localCloudCredAllowed(credential string) bool { + v := strings.TrimSpace(os.Getenv("RADIUS_TEST_USE_LOCAL_CLOUD_CREDS")) + if v == "" { + return false + } + if v == "1" || strings.EqualFold(v, "true") { + return true + } + for _, item := range strings.Split(v, ",") { + if strings.EqualFold(strings.TrimSpace(item), credential) { + return true + } + } + return false +}