Repository: radius-project/radius

Issue:
Pull-based "GitOps" deployments of Radius using Flux
## Scenario

I want to be able to treat my git repository as my "source of truth", where I check in my app templates, and then my cluster "picks up" the new app template to get it deployed.

This model is similar to flux: https://fluxcd.io/

Note that there the deployment occurs when the cluster looks for the app definition and uses that, instead of a service reaching out to the cluster to deploy the app.

## Proposal

We should see if we can either leverage flux for Radius applications or see if not, create a feature in UCP/DE that allows for pull-based deployments

## End result

I am an application developer using Radius. I push an update of my app's definition (in Bicep) to a git repository. After some amount of time my Radius environment picks up the new application definition and applies it.

[AB#10285](https://dev.azure.com/azure-octo/e61041b4-555f-47ae-95b2-4f8ab480ea57/_workitems/edit/10285)

Discussion:
@radius-triage-bot: :wave: @AaronCrawfis Thanks for filing this feature request. 

A project maintainer will review this feature request and get back to you soon.

We also welcome community contributions! If you would like to pick this item up sooner and submit a pull request, please visit our [contribution guidelines](https://github.com/radius-project/radius/blob/main/CONTRIBUTING.md) and assign this to yourself by commenting "/assign" on this issue.

_For more information on our triage process please visit our [triage overview](https://github.com/radius-project/radius/blob/main/docs/contributing/triage/triage-process.md)_@radius-triage-bot: :+1: We've reviewed this issue and have agreed to add it to our backlog. Please subscribe to this issue for notifications, we'll provide updates when we pick it up.

We also welcome community contributions! If you would like to pick this item up sooner and submit a pull request, please visit our [contribution guidelines](https://github.com/radius-project/radius/blob/main/CONTRIBUTING.md) and assign this to yourself by commenting "/assign" on this issue.

_For more information on our triage process please visit our [triage overview](https://github.com/radius-project/radius/blob/main/docs/contributing/triage/triage-process.md)_@nicolejms: radius integration with flux for continuous deployment on app definition changes@dtzar: You might alternatively want to consider ArgoCD agent for Bicep reconciliation loop functionality@cveld: This could be a two step architecture:
* argocd takes care of pulling manifest files and creates (crd) resources
* the radius operator responds to these resources and kicks off recipes.

The radius cli could just convert bicep specs into manifest files for argocd to consume.@cveld: Would it be possible to run a small api server in container apps? Not sure if this would the most efficient architecture. Maybe radius should just requiring this mininal footprint. At least in MVP.

Solution patch:
diff --git a/.github/actions/create-kind-cluster/action.yaml b/.github/actions/create-kind-cluster/action.yaml
index c8e7d3d90..7b944366e 100644
--- a/.github/actions/create-kind-cluster/action.yaml
+++ b/.github/actions/create-kind-cluster/action.yaml
@@ -44,18 +44,34 @@ runs:
       shell: bash
       run: |
         # https://kind.sigs.k8s.io/docs/user/local-registry/
+        # Create the cluster with necessary configurations
+        # to expose 30080 port for Gitea
         cat <<EOF | kind create cluster --config=-
         kind: Cluster
         apiVersion: kind.x-k8s.io/v1alpha4
+        nodes:
+        - role: control-plane
+          extraPortMappings:
+            - containerPort: 30080
+              hostPort: 30080
+              protocol: TCP
         EOF
 
     - name: Create a KinD cluster with an insecure local registry
       if: ${{ inputs.with-local-registry == 'true' && inputs.secure == 'false' }}
       shell: bash
       run: |
+        # Create the cluster with necessary configurations
+        # to expose 30080 port for Gitea
         cat <<EOF | kind create cluster --config=-
         kind: Cluster
         apiVersion: kind.x-k8s.io/v1alpha4
+        nodes:
+        - role: control-plane
+          extraPortMappings:
+            - containerPort: 30080
+              hostPort: 30080
+              protocol: TCP
         containerdConfigPatches:
         - |-
           [plugins."io.containerd.grpc.v1.cri".registry]
@@ -93,6 +109,7 @@ runs:
       shell: bash
       run: |
         # Create the cluster with necessary configurations for the secure local registry
+        # and to expose 30080 port for Gitea
         cat <<EOF | kind create cluster --config=-
         kind: Cluster
         apiVersion: kind.x-k8s.io/v1alpha4
@@ -101,6 +118,10 @@ runs:
           extraMounts:
             - containerPath: "/etc/containerd/certs.d/${{ inputs.registry-name }}"
               hostPath: "${{ inputs.temp-cert-dir }}/certs/${{ inputs.registry-server }}"
+          extraPortMappings:
+            - containerPort: 30080
+              hostPort: 30080
+              protocol: TCP
         containerdConfigPatches:
         - |-
           [plugins."io.containerd.grpc.v1.cri".registry]
diff --git a/.github/actions/install-gitea/action.yaml b/.github/actions/install-gitea/action.yaml
new file mode 100644
index 000000000..439b945b5
--- /dev/null
+++ b/.github/actions/install-gitea/action.yaml
@@ -0,0 +1,26 @@
+name: "Install Gitea and Export Token"
+description: "Installs Gitea, creates an admin user, generates an access token, and exports the token to the GitHub output."
+inputs:
+  gitea-username:
+    description: "Gitea admin username"
+    required: true
+  gitea-email:
+    description: "Gitea admin email"
+    required: true
+  gitea-access-token-name:
+    description: "Gitea access token name"
+    required: true
+outputs:
+  gitea-access-token:
+    description: "Gitea access token"
+    value: ${{ steps.export-token.outputs.gitea-access-token }}
+runs:
+  using: "composite"
+  steps:
+    - name: Install Gitea
+      id: export-token
+      shell: bash
+      env:
+        GITEA_PASSWORD: ${{ env.GITEA_PASSWORD }}
+      run: |
+        .github/actions/install-gitea/install-gitea.sh ${{ inputs.gitea-username }} ${{ inputs.gitea-email }} ${{ inputs.gitea-access-token-name }}
diff --git a/.github/actions/install-gitea/gitea-config.yaml b/.github/actions/install-gitea/gitea-config.yaml
new file mode 100644
index 000000000..a524f2d92
--- /dev/null
+++ b/.github/actions/install-gitea/gitea-config.yaml
@@ -0,0 +1,15 @@
+redis-cluster:
+  enabled: false
+postgresql-ha:
+  enabled: false
+redis:
+  enabled: true
+postgresql:
+  enabled: true
+service:
+  http:
+    type: NodePort
+    nodePort: 30080
+  ssh:
+    type: ClusterIP
+    port: 22
diff --git a/.github/actions/install-gitea/install-gitea.sh b/.github/actions/install-gitea/install-gitea.sh
new file mode 100755
index 000000000..5b2048df4
--- /dev/null
+++ b/.github/actions/install-gitea/install-gitea.sh
@@ -0,0 +1,60 @@
+#!/bin/bash
+
+# ------------------------------------------------------------
+# Copyright 2023 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.
+# ------------------------------------------------------------
+
+set -e
+
+GITEA_USERNAME=$1
+GITEA_EMAIL=$2
+GITEA_ACCESS_TOKEN_NAME=$3
+# GITEA_PASSWORD should be set by environment variable
+
+if [ -z "$GITEA_USERNAME" ]; then
+  echo "GITEA_USERNAME is not set. Exiting..."
+  exit 1
+fi
+
+if [ -z "$GITEA_EMAIL" ]; then
+  echo "GITEA_EMAIL is not set. Exiting..."
+  exit 1
+fi
+
+if [ -z "$GITEA_ACCESS_TOKEN_NAME" ]; then
+  echo "GITEA_ACCESS_TOKEN_NAME is not set. Exiting..."
+  exit 1
+fi
+
+# Add Gitea Helm chart repository
+helm repo add gitea-charts https://dl.gitea.io/charts/
+helm repo update
+
+# Install Gitea from Helm chart
+helm install gitea gitea-charts/gitea --version v11.0.0 --namespace gitea --create-namespace -f .github/actions/install-gitea/gitea-config.yaml
+kubectl wait --for=condition=available deployment/gitea -n gitea --timeout=120s
+
+# Get the Gitea pod name
+gitea_pod=$(kubectl get pods -n gitea -l app=gitea -o jsonpath='{.items[0].metadata.name}')
+
+# Create a Gitea admin user
+output=$(kubectl exec -it "$gitea_pod" -n gitea -- gitea admin user create --admin --username "$GITEA_USERNAME" --email "$GITEA_EMAIL" --password "$GITEA_PASSWORD" --must-change-password=false)
+echo "$output"
+
+# Generate an access token for the Gitea admin user
+output=$(kubectl exec -it "$gitea_pod" -n gitea -- gitea admin user generate-access-token --username "$GITEA_USERNAME" --token-name "$GITEA_ACCESS_TOKEN_NAME"  --scopes "write:repository,write:user" --raw)
+echo "$output"
+
+echo "gitea-access-token=$output" >> "$GITHUB_OUTPUT"
diff --git a/.github/workflows/functional-test-noncloud.yaml b/.github/workflows/functional-test-noncloud.yaml
index 4c5cc4ef7..fecde421e 100644
--- a/.github/workflows/functional-test-noncloud.yaml
+++ b/.github/workflows/functional-test-noncloud.yaml
@@ -72,6 +72,14 @@ env:
   LOCAL_REGISTRY_PORT: "5000"
   # bicep-types ACR url for uploading Radius Bicep types
   BICEP_TYPES_REGISTRY: 'biceptypes.azurecr.io'
+  # Gitea server URL
+  GITEA_SERVER_URL: "http://localhost:30080"
+  # Gitea username
+  GITEA_USERNAME: "testuser"
+  # Gitea email
+  GITEA_EMAIL: "testuser@radapp.io"
+  # Gitea access token name
+  GITEA_ACCESS_TOKEN_NAME: "radius-functional-test"
 
 jobs:
   build:
@@ -205,6 +213,29 @@ jobs:
           registry-server: ${{ env.LOCAL_REGISTRY_SERVER }}
           registry-port: ${{ env.LOCAL_REGISTRY_PORT }}
 
+      - name: Publish bicep types 
+        run: | 
+          bicep publish-extension ./hack/bicep-types-radius/generated/index.json --target br:${{ env.LOCAL_REGISTRY_SERVER }}:${{ env.LOCAL_REGISTRY_PORT }}/radius:${{ env.REL_VERSION == 'edge' && 'latest' || env.REL_VERSION }} --force     
+
+      - name: Generate test bicepconfig.json
+        run: |
+          if [[ "${{ env.REL_VERSION }}" == "edge" ]]; then
+            RADIUS_VERSION="latest"
+          else
+            RADIUS_VERSION="${{ env.REL_VERSION }}"
+          fi
+          cat <<EOF > ./test/bicepconfig.json
+          {
+            "experimentalFeaturesEnabled": {
+              "extensibility": true
+            },
+            "extensions": {
+              "radius": "br:${{ env.LOCAL_REGISTRY_SERVER }}:${{ env.LOCAL_REGISTRY_PORT }}/radius:$RADIUS_VERSION",
+              "aws": "br:${{ env.BICEP_TYPES_REGISTRY }}/aws:latest"
+            }
+          }
+          EOF
+
       - name: Build and Push container images
         run: |
           make build && make docker-build && make docker-push
@@ -329,6 +360,24 @@ jobs:
         env:
           USE_CERT_FILE: "true"
           TEMP_CERT_DIR: ${{ steps.create-local-registry.outputs.temp-cert-dir }}
+      
+      - name: Install Flux Source Controller
+        if: matrix.name == 'kubernetes-noncloud'
+        run: |
+          curl -s https://fluxcd.io/install.sh | FLUX_VERSION=2.5.1 sudo bash
+          flux install --namespace=flux-system --version=v2.5.1 --components=source-controller --network-policy=false
+          kubectl wait --for=condition=available deployment -l app.kubernetes.io/component=source-controller -n flux-system --timeout=120s
+
+      - name: Install Gitea
+        if: matrix.name == 'kubernetes-noncloud'
+        id: install-gitea
+        uses: ./.github/actions/install-gitea
+        env:
+          GITEA_PASSWORD: ${{ secrets.GITEA_PASSWORD }}
+        with:
+          gitea-username: ${{ env.GITEA_USERNAME }}
+          gitea-email: ${{ env.GITEA_EMAIL }}
+          gitea-access-token-name: ${{ env.GITEA_ACCESS_TOKEN_NAME }}
 
       - name: Publish Terraform test recipes
         run: |
@@ -341,29 +390,6 @@ jobs:
           sudo mv ./bicep /usr/local/bin/bicep
           bicep --version
 
-      - name: Publish bicep types 
-        run: | 
-          bicep publish-extension ./hack/bicep-types-radius/generated/index.json --target br:${{ env.LOCAL_REGISTRY_SERVER }}:${{ env.LOCAL_REGISTRY_PORT }}/radius:${{ env.REL_VERSION == 'edge' && 'latest' || env.REL_VERSION }} --force     
-
-      - name: Generate test bicepconfig.json
-        run: |
-          if [[ "${{ env.REL_VERSION }}" == "edge" ]]; then
-            RADIUS_VERSION="latest"
-          else
-            RADIUS_VERSION="${{ env.REL_VERSION }}"
-          fi
-          cat <<EOF > ./test/bicepconfig.json
-          {
-            "experimentalFeaturesEnabled": {
-              "extensibility": true
-            },
-            "extensions": {
-              "radius": "br:${{ env.LOCAL_REGISTRY_SERVER }}:${{ env.LOCAL_REGISTRY_PORT }}/radius:$RADIUS_VERSION",
-              "aws": "br:${{ env.BICEP_TYPES_REGISTRY }}/aws:latest"
-            }
-          }
-          EOF
-
       - name: Publish Bicep Test Recipes
         run: |
           export PATH=$GITHUB_WORKSPACE/bin:$PATH
@@ -395,6 +421,7 @@ jobs:
           BICEP_RECIPE_TAG_VERSION: ${{ env.BICEP_RECIPE_TAG_VERSION }}
           GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           GOTESTSUM_OPTS: "--junitfile ./dist/functional_test/results.xml"
+          GITEA_ACCESS_TOKEN: ${{ steps.install-gitea.outputs.gitea-access-token }}
 
       - name: Process Functional Test Results
         uses: ./.github/actions/process-test-results
diff --git a/.github/workflows/long-running-azure.yaml b/.github/workflows/long-running-azure.yaml
index 57cb2eef2..81ad9eda2 100644
--- a/.github/workflows/long-running-azure.yaml
+++ b/.github/workflows/long-running-azure.yaml
@@ -434,7 +434,7 @@ jobs:
           echo "*** Installing Radius to Kubernetes ***"
           rad install kubernetes --reinstall \
             --chart ${{ env.RADIUS_CHART_LOCATION }} \
-            --set rp.image=${{ env.CONTAINER_REGISTRY }}/applications-rp,rp.tag=${{ env.REL_VERSION }},dynamicrp.image=${{ env.CONTAINER_REGISTRY }}/dynamic-rp,dynamicrp.tag=${{ env.REL_VERSION }},controller.image=${{ env.CONTAINER_REGISTRY }}/controller,controller.tag=${{ env.REL_VERSION }},ucp.image=${{ env.CONTAINER_REGISTRY }}/ucpd,ucp.tag=${{ env.REL_VERSION }},,bicep.image=${{ env.CONTAINER_REGISTRY }}/bicep,bicep.tag=${{ env.REL_VERSION }}
+            --set rp.image=${{ env.CONTAINER_REGISTRY }}/applications-rp,rp.tag=${{ env.REL_VERSION }},dynamicrp.image=${{ env.CONTAINER_REGISTRY }}/dynamic-rp,dynamicrp.tag=${{ env.REL_VERSION }},controller.image=${{ env.CONTAINER_REGISTRY }}/controller,controller.tag=${{ env.REL_VERSION }},ucp.image=${{ env.CONTAINER_REGISTRY }}/ucpd,ucp.tag=${{ env.REL_VERSION }},bicep.image=${{ env.CONTAINER_REGISTRY }}/bicep,bicep.tag=${{ env.REL_VERSION }}
       - name: Verify manifests are registered
         if: env.SKIP_BUILD != 'true'
         run: |
diff --git a/cmd/rad/cmd/root.go b/cmd/rad/cmd/root.go
index f7357bab1..b96a2dcfc 100644
--- a/cmd/rad/cmd/root.go
+++ b/cmd/rad/cmd/root.go
@@ -78,6 +78,7 @@ import (
 	"github.com/radius-project/radius/pkg/cli/config"
 	"github.com/radius-project/radius/pkg/cli/connections"
 	"github.com/radius-project/radius/pkg/cli/deploy"
+	"github.com/radius-project/radius/pkg/cli/filesystem"
 	"github.com/radius-project/radius/pkg/cli/framework"
 	"github.com/radius-project/radius/pkg/cli/helm"
 	"github.com/radius-project/radius/pkg/cli/kubernetes"
@@ -218,7 +219,10 @@ func init() {
 
 func initSubCommands() {
 	framework := &framework.Impl{
-		Bicep:             &bicep.Impl{},
+		Bicep: &bicep.Impl{
+			FileSystem: filesystem.OSFileSystem{},
+			Output:     &output.OutputWriter{Writer: RootCmd.OutOrStdout()},
+		},
 		ConnectionFactory: connections.DefaultFactory,
 		ConfigHolder:      ConfigHolder,
 		Deploy:            &deploy.Impl{},
diff --git a/deploy/Chart/crds/radius/radapp.io_deploymenttemplates.yaml b/deploy/Chart/crds/radius/radapp.io_deploymenttemplates.yaml
index e520a0a67..1e6df1ad7 100644
--- a/deploy/Chart/crds/radius/radapp.io_deploymenttemplates.yaml
+++ b/deploy/Chart/crds/radius/radapp.io_deploymenttemplates.yaml
@@ -57,6 +57,10 @@ spec:
               providerConfig:
                 description: ProviderConfig specifies the scopes for resources.
                 type: string
+              repository:
+                description: Repository is the git repository that the Bicep manifests
+                  are stored in.
+                type: string
               template:
                 description: Template is the ARM JSON manifest that defines the resources
                   to deploy.
diff --git a/deploy/Chart/templates/controller/deployment.yaml b/deploy/Chart/templates/controller/deployment.yaml
index 285f4ea44..aa8e96902 100644
--- a/deploy/Chart/templates/controller/deployment.yaml
+++ b/deploy/Chart/templates/controller/deployment.yaml
@@ -27,6 +27,15 @@ spec:
       {{- end }}
     spec:
       serviceAccountName: controller
+      initContainers:
+      - name: bicep
+        image: "{{ .Values.bicep.image }}:{{ .Values.bicep.tag | default $appversion }}"
+        command: ['sh', '-c', 'mv /bicepconfig.json /bicepconfig/bicepconfig.json && mv /bicep /usr/local/bin/bicep']
+        volumeMounts:
+        - name: bicep
+          mountPath: /usr/local/bin
+        - name: bicepconfig
+          mountPath: /bicepconfig
       containers:
       - name: controller
         image: "{{ .Values.controller.image }}:{{ .Values.controller.tag | default $appversion }}"
@@ -39,6 +48,8 @@ spec:
           value: '/var/tls/cert'
         - name: PORT
           value: '9443'
+        - name: RAD_BICEP
+          value: '/usr/local/bin/bicep'
         {{- if .Values.global.rootCA.cert }}
         # This is the environment variable to set the path to the intermediate root CA for outgoing connections.
         - name: {{ .Values.global.rootCA.sslCertDirEnvVar }}
@@ -62,6 +73,11 @@ spec:
         resources:{{ toYaml .Values.controller.resources | nindent 10 }}
         {{- end }}
         volumeMounts:
+        - name: bicep
+          mountPath: /usr/local/bin
+        - name: bicepconfig
+          mountPath: /bicepconfig.json
+          subPath: bicepconfig.json
         - name: config-volume
           mountPath: /etc/config
         - name: cert
@@ -73,6 +89,10 @@ spec:
           readOnly: true
         {{- end }}
       volumes:
+        - name: bicep
+          emptyDir: {}
+        - name: bicepconfig
+          emptyDir: {}
         - name: config-volume
           configMap:
             name: controller-config
diff --git a/deploy/Chart/templates/controller/rbac.yaml b/deploy/Chart/templates/controller/rbac.yaml
index db554aa8d..0a6870ad1 100644
--- a/deploy/Chart/templates/controller/rbac.yaml
+++ b/deploy/Chart/templates/controller/rbac.yaml
@@ -56,6 +56,20 @@ rules:
   - '*'
   verbs:
   - '*'
+- apiGroups:
+  - source.toolkit.fluxcd.io
+  resources:
+  - gitrepositories
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - source.toolkit.fluxcd.io
+  resources:
+  - gitrepositories/status
+  verbs:
+  - get
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
diff --git a/go.mod b/go.mod
index 3426f0549..08da5d146 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,11 @@
 module github.com/radius-project/radius
 
 go 1.24.1
+
+// Replace digest lib to master to gather access to BLAKE3.
+// xref: https://github.com/opencontainers/go-digest/pull/66
+replace github.com/opencontainers/go-digest => github.com/opencontainers/go-digest v1.0.1-0.20220411205349-bde1400a84be
+
 require (
 	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1
 	github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2
@@ -31,6 +36,10 @@ require (
 	github.com/charmbracelet/x/exp/teatest v0.0.0-20240408110044-525ba71bb562
 	github.com/dimchansky/utfbom v1.1.1
 	github.com/fatih/color v1.18.0
+	github.com/fluxcd/pkg/apis/meta v1.6.1
+	github.com/fluxcd/pkg/http/fetch v0.12.1
+	github.com/fluxcd/pkg/tar v0.8.1
+	github.com/fluxcd/source-controller/api v1.4.1
 	github.com/go-chi/chi/v5 v5.2.1
 	github.com/go-git/go-git/v5 v5.14.0
 	github.com/go-logr/logr v1.4.2
@@ -106,6 +115,7 @@ require (
 	cloud.google.com/go/monitoring v1.21.2 // indirect
 	cloud.google.com/go/storage v1.49.0 // indirect
 	dario.cat/mergo v1.0.1 // indirect
+	github.com/42wim/httpsig v1.2.1 // indirect
 	github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 // indirect
 	github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.48.1 // indirect
 	github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.48.1 // indirect
@@ -125,12 +135,15 @@ require (
 	github.com/containerd/errdefs v0.3.0 // indirect
 	github.com/containerd/log v0.1.0 // indirect
 	github.com/containerd/platforms v0.2.1 // indirect
+	github.com/davidmz/go-pageant v1.0.2 // indirect
 	github.com/distribution/reference v0.6.0 // indirect
 	github.com/emirpasic/gods v1.18.1 // indirect
 	github.com/envoyproxy/go-control-plane v0.13.1 // indirect
 	github.com/envoyproxy/protoc-gen-validate v1.1.0 // indirect
 	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
+	github.com/fluxcd/pkg/apis/acl v0.3.0 // indirect
 	github.com/fxamacker/cbor/v2 v2.7.0 // indirect
+	github.com/go-fed/httpsig v1.1.0 // indirect
 	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
 	github.com/go-git/go-billy/v5 v5.6.2 // indirect
 	github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
@@ -145,9 +158,11 @@ require (
 	github.com/jackc/puddle/v2 v2.2.2 // indirect
 	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
 	github.com/kevinburke/ssh_config v1.2.0 // indirect
+	github.com/klauspost/cpuid/v2 v2.2.8 // indirect
 	github.com/mitchellh/go-homedir v1.1.0 // indirect
 	github.com/mitchellh/go-testing-interface v1.14.1 // indirect
 	github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
+	github.com/opencontainers/go-digest/blake3 v0.0.0-20231025023718-d50d2fec9c98 // indirect
 	github.com/pjbgf/sha1cd v0.3.2 // indirect
 	github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
 	github.com/sagikazarmark/locafero v0.7.0 // indirect
@@ -162,6 +177,7 @@ require (
 	github.com/x448/float16 v0.8.4 // indirect
 	github.com/xanzy/ssh-agent v0.3.3 // indirect
 	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+	github.com/zeebo/blake3 v0.2.3 // indirect
 	go.mongodb.org/mongo-driver v1.15.1 // indirect
 	go.opencensus.io v0.24.0 // indirect
 	go.opentelemetry.io/auto/sdk v1.1.0 // indirect
@@ -174,6 +190,7 @@ require (
 )
 
 require (
+	code.gitea.io/sdk/gitea v0.20.0
 	github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
 	github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
diff --git a/go.sum b/go.sum
index 3664c6640..f103ff5cf 100644
--- a/go.sum
+++ b/go.sum
@@ -614,6 +614,8 @@ cloud.google.com/go/workflows v1.7.0/go.mod h1:JhSrZuVZWuiDfKEFxU0/F1PQjmpnpcoIS
 cloud.google.com/go/workflows v1.8.0/go.mod h1:ysGhmEajwZxGn1OhGOGKsTXc5PyxOc0vfKf5Af+to4M=
 cloud.google.com/go/workflows v1.9.0/go.mod h1:ZGkj1aFIOd9c8Gerkjjq7OW7I5+l6cSvT3ujaO/WwSA=
 cloud.google.com/go/workflows v1.10.0/go.mod h1:fZ8LmRmZQWacon9UCX1r/g/DfAXx5VcPALq2CxzdePw=
+code.gitea.io/sdk/gitea v0.20.0 h1:Zm/QDwwZK1awoM4AxdjeAQbxolzx2rIP8dDfmKu+KoU=
+code.gitea.io/sdk/gitea v0.20.0/go.mod h1:faouBHC/zyx5wLgjmRKR62ydyvMzwWf3QnU0bH7Cw6U=
 dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
 dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
@@ -621,6 +623,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
 filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
 gioui.org v0.0.0-20210308172011-57750fc8a0a6/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8=
 git.sr.ht/~sbinet/gg v0.3.1/go.mod h1:KGYtlADtqsqANL9ueOFkWymvzUvLMQllU5Ixo+8v3pc=
+github.com/42wim/httpsig v1.2.1 h1:oLBxptMe9U4ZmSGtkosT8Dlfg31P3VQnAGq6psXv82Y=
+github.com/42wim/httpsig v1.2.1/go.mod h1:P/UYo7ytNBFwc+dg35IubuAUIs8zj5zzFIgUCEl55WY=
 github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
 github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1 h1:DSDNVxqkoXJiko6x8a90zidoYqnYYa6c1MTzDKzKkTo=
@@ -861,6 +865,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
+github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
 github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
@@ -927,6 +933,18 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
 github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/fluxcd/pkg/apis/acl v0.3.0 h1:UOrKkBTOJK+OlZX7n8rWt2rdBmDCoTK+f5TY2LcZi8A=
+github.com/fluxcd/pkg/apis/acl v0.3.0/go.mod h1:WVF9XjSMVBZuU+HTTiSebGAWMgM7IYexFLyVWbK9bNY=
+github.com/fluxcd/pkg/apis/meta v1.6.1 h1:maLhcRJ3P/70ArLCY/LF/YovkxXbX+6sTWZwZQBeNq0=
+github.com/fluxcd/pkg/apis/meta v1.6.1/go.mod h1:YndB/gxgGZmKfqpAfFxyCDNFJFP0ikpeJzs66jwq280=
+github.com/fluxcd/pkg/http/fetch v0.12.1 h1:Iap/cdKols3fW39/MyTGqNXHglaA1FJsWtFgYG2hbCQ=
+github.com/fluxcd/pkg/http/fetch v0.12.1/go.mod h1:t3JL+uqJ46Wm0CwVRn6Pf/3kOqh45tMoR0pMxLhextQ=
+github.com/fluxcd/pkg/tar v0.8.1 h1:K9RWV+E/+Qbz6Mzcg+S9DkVvZrWwJq4957Kqms183RQ=
+github.com/fluxcd/pkg/tar v0.8.1/go.mod h1:vuGrnXQPcdi3M4DoVtwvAyvLnSeFgXRJckTGYuZOy2Q=
+github.com/fluxcd/pkg/testserver v0.7.0 h1:kNVAn+3bAF2rfR9cT6SxzgEz2o84i+o7zKY3XRKTXmk=
+github.com/fluxcd/pkg/testserver v0.7.0/go.mod h1:Ih5IK3Y5G3+a6c77BTqFkdPDCY1Yj1A1W5cXQqkCs9s=
+github.com/fluxcd/source-controller/api v1.4.1 h1:zV01D7xzHOXWbYXr36lXHWWYS7POARsjLt61Nbh3kVY=
+github.com/fluxcd/source-controller/api v1.4.1/go.mod h1:gSjg57T+IG66SsBR0aquv+DFrm4YyBNpKIJVDnu3Ya8=
 github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
 github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
 github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI=
@@ -946,6 +964,8 @@ github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
 github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
 github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
 github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
+github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
+github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
 github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0gppep4g=
 github.com/go-fonts/latin-modern v0.2.0/go.mod h1:rQVLdDMK+mK1xscDwsqM5J8U2jrRa3T0ecnM9pNujks=
 github.com/go-fonts/liberation v0.1.1/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY=
@@ -1251,6 +1271,9 @@ github.com/klauspost/compress v1.15.11/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrD
 github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
 github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
 github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
+github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
+github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
 github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
@@ -1353,8 +1376,10 @@ github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg
 github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
 github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw=
 github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
-github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
-github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
+github.com/opencontainers/go-digest v1.0.1-0.20220411205349-bde1400a84be h1:f2PlhC9pm5sqpBZFvnAoKj+KzXRzbjFMA+TqXfJdgho=
+github.com/opencontainers/go-digest v1.0.1-0.20220411205349-bde1400a84be/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
+github.com/opencontainers/go-digest/blake3 v0.0.0-20231025023718-d50d2fec9c98 h1:LTxrNWOPwquJy9Cu3oz6QHJIO5M5gNyOZtSybXdyLA4=
+github.com/opencontainers/go-digest/blake3 v0.0.0-20231025023718-d50d2fec9c98/go.mod h1:kqQaIc6bZstKgnGpL7GD5dWoLKbA6mH1Y9ULjGImBnM=
 github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
 github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
 github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg=
@@ -1529,7 +1554,13 @@ github.com/zclconf/go-cty v1.16.1 h1:a5TZEPzBFFR53udlIKApXzj8JIF4ZNQ6abH79z5R1S0
 github.com/zclconf/go-cty v1.16.1/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
 github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo=
 github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM=
+github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
+github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
 github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
+github.com/zeebo/blake3 v0.2.3 h1:TFoLXsjeXqRNFxSbk35Dk4YtszE/MQQGK10BH4ptoTg=
+github.com/zeebo/blake3 v0.2.3/go.mod h1:mjJjZpnsyIVtVgTOSpJ9vmRE4wgDeyt2HU3qXvvKCaQ=
+github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
+github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
 github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
 go.mongodb.org/mongo-driver v1.15.1 h1:l+RvoUOoMXFmADTLfYDm7On9dRm7p4T80/lEQM+r7HU=
 go.mongodb.org/mongo-driver v1.15.1/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c=
@@ -1589,6 +1620,7 @@ golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8U
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
+golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
diff --git a/pkg/cli/bicep/build.go b/pkg/cli/bicep/build.go
index ce59413da..62fb68990 100644
--- a/pkg/cli/bicep/build.go
+++ b/pkg/cli/bicep/build.go
@@ -18,7 +18,6 @@ package bicep
 
 import (
 	"bytes"
-	"encoding/json"
 	"fmt"
 	"io"
 	"os"
@@ -79,37 +78,6 @@ func runBicepRaw(args ...string) ([]byte, error) {
 	return bytes, nil
 }
 
-func runBicepJson(args ...string) (map[string]any, error) {
-	bytes, err := runBicepRaw(args...)
-	if err != nil {
-		return nil, err
-	}
-
-	template := map[string]any{}
-	err = json.Unmarshal(bytes, &template)
-	if err != nil {
-		return nil, err
-	}
-
-	return template, err
-}
-
-// Build the provided `.bicep` file and returns the deployment template.
-//
-
-// Build() reads a Bicep file at the given file path and returns a map of the compiled output and an error if the
-// compilation fails.
-func Build(filePath string) (map[string]any, error) {
-	// rad-bicep is being told to output the template to stdout and we will capture it
-	// rad-bicep will output compilation errors to stderr which will go to the user's console
-	return runBicepJson("build", "--stdout", filePath)
-}
-
-// Return a Bicep version.
-//
-// In case we can't determine a version, output "unknown (<failure reason>)".
-//
-
 // Version() attempts to retrieve the version of Bicep by running the command "--version" and returns the version as a
 // string, or an error message if an error occurs.
 func Version() string {
diff --git a/pkg/cli/bicep/files.go b/pkg/cli/bicep/files.go
index faf4f051f..80498eb8c 100644
--- a/pkg/cli/bicep/files.go
+++ b/pkg/cli/bicep/files.go
@@ -22,9 +22,8 @@ import (
 	"os"
 )
 
-// ReadARMJSON reads a JSON file from the given file path and returns a map of strings to any type, or an error if the file
-//
-//	could not be read or unmarshalled.
+// ReadARMJSON reads a JSON file from the given file path and returns a map of strings to any type,
+// or an error if the file could not be read or unmarshalled.
 func ReadARMJSON(filePath string) (map[string]any, error) {
 	bytes, err := os.ReadFile(filePath)
 	if err != nil {
diff --git a/pkg/cli/bicep/mock_bicep.go b/pkg/cli/bicep/mock_bicep.go
index ed13e3db2..167982c2a 100644
--- a/pkg/cli/bicep/mock_bicep.go
+++ b/pkg/cli/bicep/mock_bicep.go
@@ -38,6 +38,49 @@ func (m *MockInterface) EXPECT() *MockInterfaceMockRecorder {
 	return m.recorder
 }
 
+// Call mocks base method.
+func (m *MockInterface) Call(arg0 ...string) ([]byte, error) {
+	m.ctrl.T.Helper()
+	varargs := []any{}
+	for _, a := range arg0 {
+		varargs = append(varargs, a)
+	}
+	ret := m.ctrl.Call(m, "Call", varargs...)
+	ret0, _ := ret[0].([]byte)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Call indicates an expected call of Call.
+func (mr *MockInterfaceMockRecorder) Call(arg0 ...any) *MockInterfaceCallCall {
+	mr.mock.ctrl.T.Helper()
+	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Call", reflect.TypeOf((*MockInterface)(nil).Call), arg0...)
+	return &MockInterfaceCallCall{Call: call}
+}
+
+// MockInterfaceCallCall wrap *gomock.Call
+type MockInterfaceCallCall struct {
+	*gomock.Call
+}
+
+// Return rewrite *gomock.Call.Return
+func (c *MockInterfaceCallCall) Return(arg0 []byte, arg1 error) *MockInterfaceCallCall {
+	c.Call = c.Call.Return(arg0, arg1)
+	return c
+}
+
+// Do rewrite *gomock.Call.Do
+func (c *MockInterfaceCallCall) Do(f func(...string) ([]byte, error)) *MockInterfaceCallCall {
+	c.Call = c.Call.Do(f)
+	return c
+}
+
+// DoAndReturn rewrite *gomock.Call.DoAndReturn
+func (c *MockInterfaceCallCall) DoAndReturn(f func(...string) ([]byte, error)) *MockInterfaceCallCall {
+	c.Call = c.Call.DoAndReturn(f)
+	return c
+}
+
 // PrepareTemplate mocks base method.
 func (m *MockInterface) PrepareTemplate(arg0 string) (map[string]any, error) {
 	m.ctrl.T.Helper()
diff --git a/pkg/cli/bicep/tools/download_tools.go b/pkg/cli/bicep/tools/download_tools.go
index 4b2c2db34..211c546fd 100644
--- a/pkg/cli/bicep/tools/download_tools.go
+++ b/pkg/cli/bicep/tools/download_tools.go
@@ -77,18 +77,12 @@ func getOverridePath(overrideEnvVarName string, binaryName string) (string, erro
 		return "", nil
 	}
 
-	// Since is a development-only setting, we're cool with being noisy about it.
-	fmt.Println("")
-
 	file, err := os.Stat(override)
 	if err != nil {
 		return "", fmt.Errorf("cannot locate %s on overridden path %s: %v", binaryName, override, err)
 	}
 
 	if !file.IsDir() {
-		// Since is a development-only setting, we're cool with being noisy about it.
-		fmt.Printf("%s overridden to %s", binaryName, override)
-		fmt.Println()
 		return override, nil
 	}
 
@@ -102,9 +96,6 @@ func getOverridePath(overrideEnvVarName string, binaryName string) (string, erro
 		return "override", fmt.Errorf("cannot locate %s on overridden path %s: %v", binaryName, override, err)
 	}
 
-	// Since is a development-only setting, we're cool with being noisy about it.
-	fmt.Printf("%s overridden to %s", binaryName, override)
-	fmt.Println()
 	return override, nil
 }
 
diff --git a/pkg/cli/bicep/types.go b/pkg/cli/bicep/types.go
index 6ccd0548e..22bc81221 100644
--- a/pkg/cli/bicep/types.go
+++ b/pkg/cli/bicep/types.go
@@ -17,19 +17,20 @@ limitations under the License.
 package bicep
 
 import (
+	"encoding/json"
 	"fmt"
-	"os"
 	"path"
 	"strings"
 
+	"github.com/radius-project/radius/pkg/cli/filesystem"
 	"github.com/radius-project/radius/pkg/cli/output"
 	"github.com/radius-project/radius/pkg/version"
 )
 
-// Interface is the interface for preparing Bicep or ARM-JSON templates for deployment. This interface
-// is designed to be called from the CLI and will print output to the console.
+// Interface is the interface for interacting with Bicep.
 type Interface interface {
 	PrepareTemplate(filePath string) (map[string]any, error)
+	Call(args ...string) ([]byte, error)
 }
 
 var _ Interface = (*Impl)(nil)
@@ -38,12 +39,13 @@ var _ Interface = (*Impl)(nil)
 
 // Impl is the implementation of Interface.
 type Impl struct {
+	FileSystem filesystem.FileSystem
+	Output     output.Interface
 }
 
 // PrepareTemplate checks if the file is a .json or .bicep file, downloads Bicep if it is not installed, checks if the file
-//
-//	exists, and builds the template if it does. It returns a map of strings to any and an error if one occurs.
-func (*Impl) PrepareTemplate(filePath string) (map[string]any, error) {
+// exists, and builds the template if it does. It returns a map of strings to any and an error if one occurs.
+func (i *Impl) PrepareTemplate(filePath string) (map[string]any, error) {
 	if strings.EqualFold(path.Ext(filePath), ".json") {
 		return ReadARMJSON(filePath)
 	} else if !strings.EqualFold(path.Ext(filePath), ".bicep") {
@@ -56,7 +58,7 @@ func (*Impl) PrepareTemplate(filePath string) (map[string]any, error) {
 	}
 
 	if !ok {
-		output.LogInfo("Downloading Bicep for channel %s...", version.Channel())
+		i.Output.LogInfo("Downloading Bicep for channel %s...", version.Channel())
 		err = DownloadBicep()
 		if err != nil {
 			return nil, fmt.Errorf("failed to download rad-bicep: %w", err)
@@ -64,24 +66,35 @@ func (*Impl) PrepareTemplate(filePath string) (map[string]any, error) {
 	}
 
 	// Check the file manually so we can control the error message.
-	_, err = os.Stat(filePath)
+	_, err = i.FileSystem.Stat(filePath)
 	if err != nil {
 		return nil, fmt.Errorf("could not find file: %w", err)
 	}
 
-	step := output.BeginStep("Building %s...", filePath)
-	template, err := Build(filePath)
+	step := i.Output.BeginStep("Building %s...", filePath)
+	bytes, err := i.Call("build", "--stdout", filePath)
+	if err != nil {
+		i.Output.CompleteStep(step)
+		return nil, fmt.Errorf("failed to build template: %w", err)
+	}
+
+	template := map[string]any{}
+	err = json.Unmarshal(bytes, &template)
 	if err != nil {
 		return nil, err
 	}
 
-	output.CompleteStep(step)
+	i.Output.CompleteStep(step)
 	return template, nil
 }
 
+// Call runs `rad-bicep` with the given arguments.
+func (i *Impl) Call(args ...string) ([]byte, error) {
+	return runBicepRaw(args...)
+}
+
 // ConvertToMapStringInterface takes in a map of strings to maps of strings to any type and returns a map of strings to any
-//
-//	type, with the values of the inner maps being the values of the returned map. No errors are returned.
+// type, with the values of the inner maps being the values of the returned map. No errors are returned.
 func ConvertToMapStringInterface(in map[string]map[string]any) map[string]any {
 	result := make(map[string]any)
 	for k, v := range in {
diff --git a/pkg/cli/filesystem/filesystem.go b/pkg/cli/filesystem/filesystem.go
index 1b40ecd88..f594eaefc 100644
--- a/pkg/cli/filesystem/filesystem.go
+++ b/pkg/cli/filesystem/filesystem.go
@@ -28,4 +28,7 @@ type FileSystem interface {
 	ReadFile(name string) ([]byte, error)
 	Stat(name string) (fs.FileInfo, error)
 	WriteFile(name string, data []byte, perm fs.FileMode) error
+	MkdirTemp(dir string, pattern string) (string, error)
+	MkdirAll(path string, perm fs.FileMode) error
+	RemoveAll(path string) error
 }
diff --git a/pkg/cli/filesystem/memmapfs.go b/pkg/cli/filesystem/memmapfs.go
index 32572eb1a..3cdb1c4b3 100644
--- a/pkg/cli/filesystem/memmapfs.go
+++ b/pkg/cli/filesystem/memmapfs.go
@@ -17,52 +17,156 @@ limitations under the License.
 package filesystem
 
 import (
+	"fmt"
+	"io"
 	"io/fs"
-	"testing/fstest"
+	"time"
 )
 
-// MemMapFileSystem is an implementation of the FileSystem interface that uses an in-memory map to store files.
-// It uses the methods from the fstest package to interact with the in-memory map.
+// MemMapFileSystem is an implementation of the FileSystem interface that uses an in-memory map to store file data.
 type MemMapFileSystem struct {
-	InternalFileSystem fstest.MapFS
+	InternalFileSystem map[string]MemFile
 }
 
 var _ FileSystem = (*MemMapFileSystem)(nil)
 
 func NewMemMapFileSystem() *MemMapFileSystem {
 	return &MemMapFileSystem{
-		InternalFileSystem: fstest.MapFS{},
+		InternalFileSystem: make(map[string]MemFile),
 	}
 }
 
-func (mmfs MemMapFileSystem) Create(name string) (fs.File, error) {
-	mmfs.InternalFileSystem[name] = &fstest.MapFile{}
+func (m *MemMapFileSystem) Create(name string) (fs.File, error) {
+	file := MemFile{
+		Data: []byte{},
+		Mode: fs.ModePerm,
+	}
 
-	return mmfs.InternalFileSystem.Open(name)
+	m.InternalFileSystem[name] = file
+	return &file, nil
 }
 
-func (mmfs MemMapFileSystem) Exists(name string) bool {
-	_, ok := mmfs.InternalFileSystem[name]
-	return ok
-}
+func (m *MemMapFileSystem) Open(name string) (fs.File, error) {
+	file, exists := m.InternalFileSystem[name]
+	if !exists {
+		return nil, fs.ErrNotExist
+	}
 
-func (mmfs MemMapFileSystem) Open(name string) (fs.File, error) {
-	return mmfs.InternalFileSystem.Open(name)
+	return &file, nil
 }
 
-func (mmfs MemMapFileSystem) ReadFile(name string) ([]byte, error) {
-	return mmfs.InternalFileSystem.ReadFile(name)
-}
+func (m *MemMapFileSystem) Remove(name string) error {
+	_, exists := m.InternalFileSystem[name]
+	if !exists {
+		return fs.ErrNotExist
+	}
 
-func (mmfs MemMapFileSystem) Stat(name string) (fs.FileInfo, error) {
-	return mmfs.InternalFileSystem.Stat(name)
+	delete(m.InternalFileSystem, name)
+	return nil
 }
 
-func (mmfs MemMapFileSystem) WriteFile(name string, data []byte, perm fs.FileMode) error {
-	mmfs.InternalFileSystem[name] = &fstest.MapFile{
+func (m *MemMapFileSystem) WriteFile(name string, data []byte, perm fs.FileMode) error {
+	m.InternalFileSystem[name] = MemFile{
 		Data: data,
 		Mode: perm,
 	}
 
 	return nil
 }
+
+func (m *MemMapFileSystem) ReadFile(name string) ([]byte, error) {
+	file, exists := m.InternalFileSystem[name]
+	if !exists {
+		return nil, fs.ErrNotExist
+	}
+
+	return file.Data, nil
+}
+
+func (m *MemMapFileSystem) Stat(name string) (fs.FileInfo, error) {
+	file, exists := m.InternalFileSystem[name]
+	if !exists {
+		return nil, fs.ErrNotExist
+	}
+
+	return &MemFileInfo{name: name, size: int64(len(file.Data)), mode: file.Mode}, nil
+}
+
+func (m *MemMapFileSystem) Exists(name string) bool {
+	_, exists := m.InternalFileSystem[name]
+	return exists
+}
+
+func (m *MemMapFileSystem) MkdirTemp(dir string, pattern string) (string, error) {
+	tempDir := fmt.Sprintf("%s/%s", dir, pattern)
+	if _, exists := m.InternalFileSystem[tempDir]; exists {
+		return "", fmt.Errorf("directory %s already exists", tempDir)
+	}
+
+	m.InternalFileSystem[tempDir] = MemFile{
+		Data: nil,
+		Mode: fs.ModeDir | fs.ModePerm,
+	}
+
+	return tempDir, nil
+}
+
+func (m *MemMapFileSystem) MkdirAll(path string, perm fs.FileMode) error {
+	if _, exists := m.InternalFileSystem[path]; !exists {
+		m.InternalFileSystem[path] = MemFile{
+			Data: nil,
+			Mode: fs.ModeDir | perm,
+		}
+	}
+
+	return nil
+}
+
+func (m *MemMapFileSystem) RemoveAll(path string) error {
+	if _, exists := m.InternalFileSystem[path]; !exists {
+		return fs.ErrNotExist
+	}
+
+	delete(m.InternalFileSystem, path)
+	return nil
+}
+
+type MemFile struct {
+	Name string
+	Data []byte
+	Mode fs.FileMode
+}
+
+func (f *MemFile) Close() error {
+	return nil
+}
+
+func (f *MemFile) Read(p []byte) (n int, err error) {
+	if len(f.Data) == 0 {
+		return 0, io.EOF
+	}
+	n = copy(p, f.Data)
+	f.Data = f.Data[n:]
+	return n, nil
+}
+
+func (f *MemFile) Stat() (fs.FileInfo, error) {
+	return &MemFileInfo{
+		name: f.Name,
+		size: int64(len(f.Data)),
+		mode: f.Mode,
+	}, nil
+}
+
+type MemFileInfo struct {
+	name string
+	size int64
+	mode fs.FileMode
+}
+
+func (f *MemFileInfo) Name() string       { return f.name }
+func (f *MemFileInfo) Size() int64        { return f.size }
+func (f *MemFileInfo) Mode() fs.FileMode  { return f.mode }
+func (f *MemFileInfo) ModTime() time.Time { return time.Now() }
+func (f *MemFileInfo) IsDir() bool        { return f.mode.IsDir() }
+func (f *MemFileInfo) Sys() any           { return nil }
diff --git a/pkg/cli/filesystem/osfs.go b/pkg/cli/filesystem/osfs.go
index 771c9fba0..8820951cd 100644
--- a/pkg/cli/filesystem/osfs.go
+++ b/pkg/cli/filesystem/osfs.go
@@ -55,3 +55,15 @@ func (osfs OSFileSystem) Stat(name string) (fs.FileInfo, error) {
 func (osfs OSFileSystem) WriteFile(name string, data []byte, perm fs.FileMode) error {
 	return os.WriteFile(name, data, perm)
 }
+
+func (osfs OSFileSystem) MkdirTemp(dir, pattern string) (string, error) {
+	return os.MkdirTemp(dir, pattern)
+}
+
+func (osfs OSFileSystem) MkdirAll(path string, perm fs.FileMode) error {
+	return os.MkdirAll(path, perm)
+}
+
+func (osfs OSFileSystem) RemoveAll(path string) error {
+	return os.RemoveAll(path)
+}
diff --git a/pkg/cli/kubernetes/kubernetes.go b/pkg/cli/kubernetes/kubernetes.go
index 60083a811..bbd236ec4 100644
--- a/pkg/cli/kubernetes/kubernetes.go
+++ b/pkg/cli/kubernetes/kubernetes.go
@@ -20,10 +20,12 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"time"
+
 	"github.com/radius-project/radius/pkg/cli/helm"
 	"k8s.io/apimachinery/pkg/util/wait"
-	"time"
 
+	sourcev1 "github.com/fluxcd/source-controller/api/v1"
 	contourv1 "github.com/projectcontour/contour/apis/projectcontour/v1"
 	apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -58,6 +60,7 @@ func init() {
 	_ = clientgoscheme.AddToScheme(Scheme)
 	_ = contourv1.AddToScheme(Scheme)
 	_ = radappiov1alpha3.AddToScheme(Scheme)
+	_ = sourcev1.AddToScheme(Scheme)
 }
 
 // NewDynamicClient creates a new dynamic client by context name, otherwise returns an error.
diff --git a/pkg/cli/output/logger.go b/pkg/cli/output/logger.go
index 23040b526..c3aa4ae7d 100644
--- a/pkg/cli/output/logger.go
+++ b/pkg/cli/output/logger.go
@@ -18,10 +18,6 @@ package output
 
 import "fmt"
 
-// Step represents a logical step that's being executed.
-type Step struct {
-}
-
 // LogInfo logs a message that is displayed to the user by default.
 func LogInfo(format string, v ...any) {
 	fmt.Printf(format, v...)
diff --git a/pkg/cli/output/output_mock.go b/pkg/cli/output/output_mock.go
index 51fe78bee..d1a2abdc0 100644
--- a/pkg/cli/output/output_mock.go
+++ b/pkg/cli/output/output_mock.go
@@ -44,3 +44,11 @@ func (o *MockOutput) WriteFormatted(format string, obj any, options FormatterOpt
 	o.Writes = append(o.Writes, FormattedOutput{Format: format, Obj: obj, Options: options})
 	return nil
 }
+
+func (o *MockOutput) BeginStep(format string, v ...any) Step {
+	o.Writes = append(o.Writes, LogOutput{Format: format, Params: v})
+	return Step{}
+}
+
+func (o *MockOutput) CompleteStep(step Step) {
+}
diff --git a/pkg/cli/output/writer.go b/pkg/cli/output/writer.go
index eef09a3be..d11973e76 100644
--- a/pkg/cli/output/writer.go
+++ b/pkg/cli/output/writer.go
@@ -26,8 +26,19 @@ type Interface interface {
 	// LogInfo logs a message that is displayed to the user by default.
 	LogInfo(format string, v ...any)
 
-	// Write
+	// WriteFormatted takes in a format string, an object of any type, and a FormatterOptions object, and calls
+	// the Write function with the given parameters, writing the output to the Writer field of the OutputWriter object.
 	WriteFormatted(format string, obj any, options FormatterOptions) error
+
+	// BeginStep starts execution of a step
+	BeginStep(format string, v ...any) Step
+
+	// CompleteStep ends execution of a step
+	CompleteStep(step Step)
+}
+
+// Step represents a logical step that's being executed.
+type Step struct {
 }
 
 type OutputWriter struct {
@@ -46,6 +57,16 @@ func (o *OutputWriter) WriteFormatted(format string, obj any, options FormatterO
 	return Write(format, obj, o.Writer, options)
 }
 
+// BeginStep starts execution of a step
+func (o *OutputWriter) BeginStep(format string, v ...any) Step {
+	o.LogInfo(format, v...)
+	return Step{}
+}
+
+// CompleteStep ends execution of a step
+func (o *OutputWriter) CompleteStep(step Step) {
+}
+
 // Write takes in a format string, an object, a writer and formatter options and attempts to format the object according
 // to the format string and write it to the writer. If an error occurs during formatting or writing, an error is returned.
 func Write(format string, obj any, writer io.Writer, options FormatterOptions) error {
diff --git a/pkg/controller/api/radapp.io/v1alpha3/deploymenttemplate_types.go b/pkg/controller/api/radapp.io/v1alpha3/deploymenttemplate_types.go
index 8e97c9d18..df99020a5 100644
--- a/pkg/controller/api/radapp.io/v1alpha3/deploymenttemplate_types.go
+++ b/pkg/controller/api/radapp.io/v1alpha3/deploymenttemplate_types.go
@@ -30,6 +30,9 @@ type DeploymentTemplateSpec struct {
 
 	// ProviderConfig specifies the scopes for resources.
 	ProviderConfig string `json:"providerConfig,omitempty"`
+
+	// Repository is the git repository that the Bicep manifests are stored in.
+	Repository string `json:"repository,omitempty"`
 }
 
 // DeploymentTemplateStatus defines the observed state of a DeploymentTemplate resource.
diff --git a/pkg/controller/reconciler/archivefetcher.go b/pkg/controller/reconciler/archivefetcher.go
new file mode 100644
index 000000000..1f35eb202
--- /dev/null
+++ b/pkg/controller/reconciler/archivefetcher.go
@@ -0,0 +1,33 @@
+package reconciler
+
+import (
+	"github.com/fluxcd/pkg/http/fetch"
+	"github.com/fluxcd/pkg/tar"
+)
+
+// NewArchiveFetcher creates a new ArchiveFetcher
+// with the default options.
+func NewArchiveFetcher() ArchiveFetcher {
+	archiveFetcher := fetch.New(
+		fetch.WithRetries(GitRepositoryHttpRetryCount),
+		fetch.WithMaxDownloadSize(tar.UnlimitedUntarSize),
+		fetch.WithUntar(tar.WithMaxUntarSize(tar.UnlimitedUntarSize)),
+		fetch.WithLogger(nil),
+	)
+
+	return &ArchiveFetcherImpl{
+		inner: archiveFetcher,
+	}
+}
+
+type ArchiveFetcher interface {
+	Fetch(archiveURL string, digest string, dir string) error
+}
+
+type ArchiveFetcherImpl struct {
+	inner *fetch.ArchiveFetcher
+}
+
+func (a *ArchiveFetcherImpl) Fetch(archiveURL string, digest string, dir string) error {
+	return a.inner.Fetch(archiveURL, digest, dir)
+}
diff --git a/pkg/controller/reconciler/const.go b/pkg/controller/reconciler/const.go
index 77a39df10..eb5104cff 100644
--- a/pkg/controller/reconciler/const.go
+++ b/pkg/controller/reconciler/const.go
@@ -53,4 +53,7 @@ const (
 
 	// DeploymentResourceFinalizer is the name of the finalizer added to DeploymentResources.
 	DeploymentResourceFinalizer = "radapp.io/deployment-resource-finalizer"
+
+	// GitRepositoryHttpRetryCount is the number of times to retry GitRepository HTTP requests.
+	GitRepositoryHttpRetryCount = 9
 )
diff --git a/pkg/controller/reconciler/flux_controller.go b/pkg/controller/reconciler/flux_controller.go
new file mode 100644
index 000000000..ed55cebbf
--- /dev/null
+++ b/pkg/controller/reconciler/flux_controller.go
@@ -0,0 +1,432 @@
+package reconciler
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io/fs"
+	"path"
+	"path/filepath"
+	"strings"
+
+	"github.com/go-logr/logr"
+	"github.com/radius-project/radius/pkg/cli/bicep"
+	"github.com/radius-project/radius/pkg/cli/filesystem"
+	sdkclients "github.com/radius-project/radius/pkg/sdk/clients"
+	"github.com/radius-project/radius/pkg/ucp/ucplog"
+	"gopkg.in/yaml.v3"
+	corev1 "k8s.io/api/core/v1"
+	k8serrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/builder"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	sourcev1 "github.com/fluxcd/source-controller/api/v1"
+
+	radappiov1alpha3 "github.com/radius-project/radius/pkg/controller/api/radapp.io/v1alpha3"
+)
+
+const (
+	deploymentTemplateRepositoryField = "spec.repository"
+	radiusConfigFileName              = "radius-gitops-config.yaml"
+	armJSONParametersKeyName          = "parameters"
+)
+
+// FluxController watches GitRepository objects for revision changes
+// and processes the artifacts fetched from the Source Controller.
+// It reads the git repository configuration, builds the bicep files.
+// specified in the configuration, and creates DeploymentTemplate objects
+// on the cluster.
+type FluxController struct {
+	client.Client
+	Bicep          bicep.Interface
+	FileSystem     filesystem.FileSystem
+	ArchiveFetcher ArchiveFetcher
+}
+
+// RadiusGitOpsConfig is the configuration for Radius in a Git repository.
+type RadiusGitOpsConfig struct {
+	Config []ConfigEntry `yaml:"config"`
+}
+
+// ConfigEntry is the build configuration for a Bicep file in a Git repository.
+type ConfigEntry struct {
+	// Name is the name of the Bicep (.bicep) file.
+	Name string `yaml:"name"`
+	// Params is the name of the Bicep parameters (.bicepparam) file.
+	Params string `yaml:"params,omitempty"`
+	// Namespace is the Kubernetes namespace that the generated DeploymentTemplate should be created in.
+	Namespace string `yaml:"namespace,omitempty"`
+	// ResourceGroup is the Radius resource group that the Bicep file should be deployed to.
+	ResourceGroup string `yaml:"resourceGroup,omitempty"`
+}
+
+// deploymentTemplateRepositoryIndexer indexes DeploymentTemplate objects by their repository field.
+func deploymentTemplateRepositoryIndexer(o client.Object) []string {
+	deploymentTemplate, ok := o.(*radappiov1alpha3.DeploymentTemplate)
+	if !ok {
+		return nil
+	}
+	return []string{deploymentTemplate.Spec.Repository}
+}
+
+func (r *FluxController) SetupWithManager(mgr ctrl.Manager) error {
+	if err := mgr.GetFieldIndexer().IndexField(context.Background(), &radappiov1alpha3.DeploymentTemplate{}, deploymentTemplateRepositoryField, deploymentTemplateRepositoryIndexer); err != nil {
+		return err
+	}
+
+	return ctrl.NewControllerManagedBy(mgr).
+		For(&sourcev1.GitRepository{}, builder.WithPredicates(&GitRepositoryRevisionChangePredicate{})).
+		Complete(r)
+}
+
+// +kubebuilder:rbac:groups=source.toolkit.fluxcd.io,resources=gitrepositories,verbs=get;list;watch
+// +kubebuilder:rbac:groups=source.toolkit.fluxcd.io,resources=gitrepositories/status,verbs=get
+
+func (r *FluxController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+	logger := ucplog.FromContextOrDiscard(ctx).WithValues("kind", "FluxController", "name", req.Name, "namespace", req.Namespace)
+	ctx = logr.NewContext(ctx, logger)
+
+	// Get the GitRepository object from the cluster
+	var repository sourcev1.GitRepository
+	if err := r.Get(ctx, req.NamespacedName, &repository); err != nil {
+		return ctrl.Result{}, client.IgnoreNotFound(err)
+	}
+
+	// Check if the Artifact field is set
+	artifact := repository.Status.Artifact
+	if artifact == nil {
+		logger.Info("No artifact found for GitRepository", "name", repository.Name)
+		return ctrl.Result{}, nil
+	}
+
+	logger.Info("New revision detected", "revision", artifact.Revision)
+
+	// Create temp dir to store the fetched artifact
+	tmpDir, err := r.FileSystem.MkdirTemp("", repository.Name)
+	if err != nil {
+		return ctrl.Result{}, fmt.Errorf("failed to create temp dir, error: %w", err)
+	}
+
+	defer func(path string) {
+		err := r.FileSystem.RemoveAll(path)
+		if err != nil {
+			logger.Error(err, "unable to remove temp dir")
+		}
+	}(tmpDir)
+
+	// Fetch the artifact from the Source Controller
+	logger.Info("Fetching artifact", "url", artifact.URL)
+	err = r.ArchiveFetcher.Fetch(artifact.URL, artifact.Digest, tmpDir)
+	if err != nil {
+		logger.Error(err, "Failed to fetch artifact", "url", artifact.URL)
+		return ctrl.Result{}, fmt.Errorf("failed to fetch artifact, error: %w", err)
+	}
+
+	logger.Info("Successfully fetched artifact", "url", artifact.URL)
+
+	// Check if the radius-gitops-config.yaml file exists
+	_, err = r.FileSystem.Stat(filepath.Join(tmpDir, radiusConfigFileName))
+	if err != nil {
+		if errors.Is(err, fs.ErrNotExist) {
+			// No radius-gitops-config.yaml found in the repository, safe to ignore
+			logger.Info(fmt.Sprintf("No radius-gitops-config.yaml found in the repository: %s", repository.Name))
+			return ctrl.Result{}, nil
+		} else {
+			logger.Error(err, "failed to check if radius-gitops-config.yaml exists")
+			return ctrl.Result{}, fmt.Errorf("failed to check if radius-gitops-config.yaml exists, error: %w", err)
+		}
+	}
+
+	// Parse the radius-gitops-config.yaml file
+	radiusConfig, err := r.parseAndValidateRadiusGitOpsConfigFromFile(tmpDir, radiusConfigFileName)
+	if err != nil {
+		logger.Error(err, "failed to parse radius-gitops-config.yaml")
+		return ctrl.Result{}, err
+	}
+
+	// Run bicep build on all bicep files specified in radius-gitops-config.yaml
+	for _, bicepFile := range radiusConfig.Config {
+		fileName := bicepFile.Name
+		paramFileName := bicepFile.Params
+		namespace := bicepFile.Namespace
+		nameBase := strings.TrimSuffix(fileName, path.Ext(fileName))
+
+		if namespace == "" {
+			// If the namespace is not set, use the name of the bicep file
+			// (without extension) as the namespace. e.g. "example.bicep" -> "example"
+			namespace = nameBase
+		}
+		resourceGroup := bicepFile.ResourceGroup
+		if resourceGroup == "" {
+			// If the resource group is not set, use the name of the bicep file
+			// (without extension) as the resource group. e.g. "example.bicep" -> "example"
+			resourceGroup = nameBase
+		}
+
+		// Run bicep build on the bicep file
+		logger.Info("Running bicep build", "name", fileName)
+		template, err := r.runBicepBuild(ctx, tmpDir, fileName)
+		if err != nil {
+			logger.Error(err, "failed to run bicep build")
+			return ctrl.Result{}, err
+		}
+
+		// If the bicepparams file is specified, run bicep build-params on it
+		var armJSONParameters map[string]any
+		if paramFileName != "" {
+			logger.Info("Running bicep build-params", "name", paramFileName)
+			armJSONParameters, err = r.runBicepBuildParams(ctx, tmpDir, paramFileName)
+			if err != nil {
+				logger.Error(err, "failed to run bicep build-params")
+				return ctrl.Result{}, err
+			}
+		}
+
+		// Generate the provider config from the radius-gitops-config.yaml file
+		providerConfig := sdkclients.GenerateProviderConfig(resourceGroup, "", "")
+		marshalledProviderConfig, err := json.MarshalIndent(providerConfig, "", "  ")
+		if err != nil {
+			return ctrl.Result{}, err
+		}
+
+		// Create the namespace if it doesn't exist
+		namespaceObj := &corev1.Namespace{
+			ObjectMeta: metav1.ObjectMeta{
+				Name: namespace,
+			},
+		}
+
+		if err := r.Client.Create(ctx, namespaceObj); err != nil {
+			if !k8serrors.IsAlreadyExists(err) {
+				logger.Error(err, "unable to create namespace")
+				return ctrl.Result{}, err
+			}
+		}
+
+		// Now we should create (or update) each DeploymentTemplate for the bicep files
+		// specified in the git repository.
+		logger.Info("Creating or updating DeploymentTemplate", "name", bicepFile.Name)
+		parameters := convertFromARMJSONParameters(armJSONParameters)
+		err = r.createOrUpdateDeploymentTemplate(ctx, bicepFile.Name, namespace, template, string(marshalledProviderConfig), parameters, repository.Name)
+		if err != nil {
+			logger.Error(err, "failed to create or update deployment template")
+			return ctrl.Result{}, err
+		}
+
+		logger.Info("Successfully created or updated DeploymentTemplate", "name", bicepFile.Name)
+	}
+
+	// List all DeploymentTemplates on the cluster that are from the same git repository
+	deploymentTemplates := &radappiov1alpha3.DeploymentTemplateList{}
+	err = r.Client.List(ctx, deploymentTemplates, client.MatchingFields{deploymentTemplateRepositoryField: repository.Name}, client.InNamespace(""))
+	if err != nil {
+		logger.Error(err, "unable to list deployment templates")
+		return ctrl.Result{}, err
+	}
+
+	logger.Info("Found DeploymentTemplates", "count", len(deploymentTemplates.Items))
+
+	// For all of the DeploymentTemplates on the cluster, check if the bicep file
+	// that it was created from is still present in config. If not, delete the DeploymentTemplate.
+	for _, deploymentTemplate := range deploymentTemplates.Items {
+		if !isSpecifiedInConfig(deploymentTemplate.Name, radiusConfig.Config) {
+			// The DeploymentTemplate is not specified in the config, so we should delete it
+			logger.Info("Deleting DeploymentTemplate", "name", deploymentTemplate.Name)
+			if err := r.Client.Delete(ctx, &deploymentTemplate); err != nil {
+				logger.Error(err, "unable to delete deployment template")
+				return ctrl.Result{}, err
+			}
+
+			logger.Info("Deleted DeploymentTemplate", "name", deploymentTemplate.Name)
+		}
+	}
+
+	return ctrl.Result{}, nil
+}
+
+func (r *FluxController) runBicepBuild(ctx context.Context, filepath, filename string) (armJSON string, err error) {
+	logger := ucplog.FromContextOrDiscard(ctx)
+
+	bicepFile := path.Join(filepath, filename)
+	outFile := path.Join(filepath, strings.ReplaceAll(filename, ".bicep", ".json"))
+
+	// Run bicep build on the bicep file
+	logger.Info(fmt.Sprintf("Running command: bicep build %s --outfile %s", bicepFile, outFile))
+	_, err = r.Bicep.Call("build", bicepFile, "--outfile", outFile)
+	if err != nil {
+		logger.Error(err, "failed to run bicep build")
+		return "", err
+	}
+
+	// Read the contents of the generated .json file
+	contents, err := r.FileSystem.ReadFile(outFile)
+	if err != nil {
+		logger.Error(err, "failed to read bicep build output")
+		return "", err
+	}
+
+	return string(contents), nil
+}
+
+func (r *FluxController) runBicepBuildParams(ctx context.Context, filepath, filename string) (map[string]any, error) {
+	logger := ucplog.FromContextOrDiscard(ctx)
+
+	bicepParamsFile := path.Join(filepath, filename)
+	outfile := path.Join(filepath, strings.ReplaceAll(filename, ".bicepparam", ".parameters.json"))
+
+	// Run bicep build-params on the bicep file
+	logger.Info("Running bicep build-params on " + bicepParamsFile)
+	_, err := r.Bicep.Call("build-params", bicepParamsFile, "--outfile", outfile)
+	if err != nil {
+		logger.Error(err, "failed to run bicep build-params")
+		return map[string]any{}, err
+	}
+
+	// Read the contents of the generated .parameters.json file
+	contents, err := r.FileSystem.ReadFile(outfile)
+	if err != nil {
+		logger.Error(err, "failed to read bicep build-params output")
+		return nil, err
+	}
+
+	params := make(map[string]any)
+	err = json.Unmarshal(contents, &params)
+	if err != nil {
+		logger.Error(err, "failed to unmarshal bicep build-params output")
+		return nil, err
+	}
+
+	if params[armJSONParametersKeyName] == nil {
+		return nil, fmt.Errorf("parameters field not found in bicep build-params output")
+	}
+
+	if _, ok := params[armJSONParametersKeyName].(map[string]any); !ok {
+		typeGot := fmt.Sprintf("%T", params[armJSONParametersKeyName])
+		return nil, fmt.Errorf("unexpected format for parameters field in bicep build-params output, got %s", typeGot)
+	}
+
+	parameters := params[armJSONParametersKeyName].(map[string]any)
+
+	return parameters, nil
+}
+
+// createOrUpdateDeploymentTemplate creates or updates a DeploymentTemplate object in the cluster
+// with the given spec.
+func (r *FluxController) createOrUpdateDeploymentTemplate(ctx context.Context, fileName, namespace, template, providerConfig string, parameters map[string]string, repository string) error {
+	logger := ucplog.FromContextOrDiscard(ctx)
+
+	// Try to get the DeploymentTemplate object from the cluster
+	deploymentTemplate := radappiov1alpha3.DeploymentTemplate{}
+	err := r.Client.Get(ctx, client.ObjectKey{Namespace: namespace, Name: fileName}, &deploymentTemplate)
+	if err != nil {
+		if client.IgnoreNotFound(err) != nil {
+			// Error getting the DeploymentTemplate object that is not a NotFound error
+			logger.Error(err, "unable to get deployment template")
+			return err
+		}
+
+		// If the DeploymentTemplate doesn't exist, create it
+		deploymentTemplate := &radappiov1alpha3.DeploymentTemplate{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      fileName,
+				Namespace: namespace,
+			},
+			Spec: radappiov1alpha3.DeploymentTemplateSpec{
+				Template:       template,
+				Parameters:     parameters,
+				ProviderConfig: providerConfig,
+				Repository:     repository,
+			},
+		}
+		if err := r.Client.Create(ctx, deploymentTemplate); err != nil {
+			logger.Error(err, "unable to create deployment template")
+			return err
+		}
+
+		logger.Info("Created Deployment Template", "name", deploymentTemplate.Name)
+		return nil
+	}
+
+	// If the DeploymentTemplate already exists, update it
+	deploymentTemplate.Spec = radappiov1alpha3.DeploymentTemplateSpec{
+		Template:       template,
+		Parameters:     parameters,
+		ProviderConfig: providerConfig,
+		Repository:     repository,
+	}
+	if err := r.Client.Update(ctx, &deploymentTemplate); err != nil {
+		logger.Error(err, "unable to update deployment template")
+		return err
+	}
+
+	logger.Info("Updated Deployment Template", "name", deploymentTemplate.Name)
+	return nil
+}
+
+// parseAndValidateRadiusGitOpsConfigFromFile reads the radius-gitops-config.yaml file from the given directory
+// and parses it into a RadiusGitOpsConfig struct. It then validates the Radius configuration in the
+// radius-gitops-config.yaml file.
+func (r *FluxController) parseAndValidateRadiusGitOpsConfigFromFile(dir, configFileName string) (*RadiusGitOpsConfig, error) {
+	radiusConfig := RadiusGitOpsConfig{}
+
+	// Read the file contents
+	b, err := r.FileSystem.ReadFile(path.Join(dir, configFileName))
+	if err != nil {
+		return nil, fmt.Errorf("failed to read radius-gitops-config.yaml, error: %w", err)
+	}
+
+	// Unmarshal the file contents into the RadiusGitOpsConfig struct
+	err = yaml.Unmarshal(b, &radiusConfig)
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse radius-gitops-config.yaml, error: %w", err)
+	}
+
+	// Validate the Radius configuration in radius-gitops-config.yaml
+	for _, bicepFile := range radiusConfig.Config {
+		// Validate if the Name field is set
+		if bicepFile.Name == "" {
+			return nil, fmt.Errorf("name field is required in bicepBuild")
+		}
+
+		// Validate that the file extension is .bicep
+		if path.Ext(bicepFile.Name) != ".bicep" {
+			return nil, fmt.Errorf("bicep file must have a .bicep extension")
+		}
+
+		// Validate that the file exists
+		_, err := r.FileSystem.Stat(path.Join(dir, bicepFile.Name))
+		if err != nil {
+			if errors.Is(err, fs.ErrNotExist) {
+				return nil, fmt.Errorf("failed to find bicep file %s, error: %w", bicepFile.Name, err)
+			} else {
+				return nil, fmt.Errorf("failed to check if bicep file exists, error: %w", err)
+			}
+		}
+
+		// If the bicepFile.Params field is set, validate that the file exists
+		if bicepFile.Params != "" {
+			_, err := r.FileSystem.Stat(path.Join(dir, bicepFile.Params))
+			if err != nil {
+				if errors.Is(err, fs.ErrNotExist) {
+					return nil, fmt.Errorf("failed to find bicepparams file %s, error: %w", bicepFile.Params, err)
+				} else {
+					return nil, fmt.Errorf("failed to check if bicepparams file exists, error: %w", err)
+				}
+			}
+		}
+	}
+	return &radiusConfig, nil
+}
+
+func isSpecifiedInConfig(fileName string, config []ConfigEntry) bool {
+	for _, bicepFile := range config {
+		if bicepFile.Name == fileName {
+			return true
+		}
+	}
+
+	return false
+}
diff --git a/pkg/controller/reconciler/flux_gitrepository_predicate.go b/pkg/controller/reconciler/flux_gitrepository_predicate.go
new file mode 100644
index 000000000..a0cee2f26
--- /dev/null
+++ b/pkg/controller/reconciler/flux_gitrepository_predicate.go
@@ -0,0 +1,69 @@
+/*
+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
+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 reconciler
+
+import (
+	"sigs.k8s.io/controller-runtime/pkg/event"
+	"sigs.k8s.io/controller-runtime/pkg/predicate"
+
+	sourcev1 "github.com/fluxcd/source-controller/api/v1"
+)
+
+// Based on: https://github.com/fluxcd/source-watcher/blob/main/controllers/gitrepository_predicate.go
+
+// GitRepositoryRevisionChangePredicate triggers an update event
+// when a GitRepository revision changes.
+type GitRepositoryRevisionChangePredicate struct {
+	predicate.Funcs
+}
+
+func (*GitRepositoryRevisionChangePredicate) Create(e event.CreateEvent) bool {
+	if e.Object == nil {
+		return false
+	}
+
+	src, ok := e.Object.(sourcev1.Source)
+	if !ok || src == nil || src.GetArtifact() == nil {
+		return false
+	}
+
+	return true
+}
+
+func (*GitRepositoryRevisionChangePredicate) Update(e event.UpdateEvent) bool {
+	if e.ObjectOld == nil || e.ObjectNew == nil {
+		return false
+	}
+
+	oldSource, ok := e.ObjectOld.(sourcev1.Source)
+	if !ok {
+		return false
+	}
+
+	newSource, ok := e.ObjectNew.(sourcev1.Source)
+	if !ok {
+		return false
+	}
+
+	if oldSource.GetArtifact() == nil && newSource.GetArtifact() != nil {
+		return true
+	}
+
+	if oldSource.GetArtifact() != nil && newSource.GetArtifact() != nil &&
+		oldSource.GetArtifact().Revision != newSource.GetArtifact().Revision {
+		return true
+	}
+
+	return false
+}
diff --git a/pkg/controller/reconciler/mock_archivefetcher.go b/pkg/controller/reconciler/mock_archivefetcher.go
new file mode 100644
index 000000000..8e796fa77
--- /dev/null
+++ b/pkg/controller/reconciler/mock_archivefetcher.go
@@ -0,0 +1,77 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: github.com/radius-project/radius/pkg/controller/reconciler (interfaces: ArchiveFetcher)
+//
+// Generated by this command:
+//
+//	mockgen -typed -destination=./mock_archivefetcher.go -package=reconciler -self_package github.com/radius-project/radius/pkg/controller/reconciler github.com/radius-project/radius/pkg/controller/reconciler ArchiveFetcher
+//
+
+// Package reconciler is a generated GoMock package.
+package reconciler
+
+import (
+	reflect "reflect"
+
+	gomock "go.uber.org/mock/gomock"
+)
+
+// MockArchiveFetcher is a mock of ArchiveFetcher interface.
+type MockArchiveFetcher struct {
+	ctrl     *gomock.Controller
+	recorder *MockArchiveFetcherMockRecorder
+}
+
+// MockArchiveFetcherMockRecorder is the mock recorder for MockArchiveFetcher.
+type MockArchiveFetcherMockRecorder struct {
+	mock *MockArchiveFetcher
+}
+
+// NewMockArchiveFetcher creates a new mock instance.
+func NewMockArchiveFetcher(ctrl *gomock.Controller) *MockArchiveFetcher {
+	mock := &MockArchiveFetcher{ctrl: ctrl}
+	mock.recorder = &MockArchiveFetcherMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockArchiveFetcher) EXPECT() *MockArchiveFetcherMockRecorder {
+	return m.recorder
+}
+
+// Fetch mocks base method.
+func (m *MockArchiveFetcher) Fetch(arg0, arg1, arg2 string) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Fetch", arg0, arg1, arg2)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// Fetch indicates an expected call of Fetch.
+func (mr *MockArchiveFetcherMockRecorder) Fetch(arg0, arg1, arg2 any) *MockArchiveFetcherFetchCall {
+	mr.mock.ctrl.T.Helper()
+	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Fetch", reflect.TypeOf((*MockArchiveFetcher)(nil).Fetch), arg0, arg1, arg2)
+	return &MockArchiveFetcherFetchCall{Call: call}
+}
+
+// MockArchiveFetcherFetchCall wrap *gomock.Call
+type MockArchiveFetcherFetchCall struct {
+	*gomock.Call
+}
+
+// Return rewrite *gomock.Call.Return
+func (c *MockArchiveFetcherFetchCall) Return(arg0 error) *MockArchiveFetcherFetchCall {
+	c.Call = c.Call.Return(arg0)
+	return c
+}
+
+// Do rewrite *gomock.Call.Do
+func (c *MockArchiveFetcherFetchCall) Do(f func(string, string, string) error) *MockArchiveFetcherFetchCall {
+	c.Call = c.Call.Do(f)
+	return c
+}
+
+// DoAndReturn rewrite *gomock.Call.DoAndReturn
+func (c *MockArchiveFetcherFetchCall) DoAndReturn(f func(string, string, string) error) *MockArchiveFetcherFetchCall {
+	c.Call = c.Call.DoAndReturn(f)
+	return c
+}
diff --git a/pkg/controller/reconciler/util.go b/pkg/controller/reconciler/util.go
index f85254f77..276ba2c5d 100644
--- a/pkg/controller/reconciler/util.go
+++ b/pkg/controller/reconciler/util.go
@@ -19,6 +19,7 @@ package reconciler
 import (
 	"context"
 	"fmt"
+	"os"
 	"strings"
 
 	v1 "github.com/radius-project/radius/pkg/armrpc/api/v1"
@@ -30,6 +31,7 @@ import (
 	ucpv20231001preview "github.com/radius-project/radius/pkg/ucp/api/v20231001preview"
 	"github.com/radius-project/radius/pkg/ucp/resources"
 	"github.com/radius-project/radius/pkg/ucp/ucplog"
+	"gopkg.in/yaml.v3"
 )
 
 func resolveDependencies(ctx context.Context, radius RadiusClient, scope string, environmentName string, applicationName string) (resourceGroupID string, environmentID string, applicationID string, err error) {
@@ -300,3 +302,38 @@ func convertToARMJSONParameters(parameters map[string]string) map[string]map[str
 	}
 	return armJSONParameters
 }
+
+func convertFromARMJSONParameters(armJSONParameters map[string]any) map[string]string {
+	parameters := make(map[string]string, len(armJSONParameters))
+	for key, value := range armJSONParameters {
+		if value == nil {
+			continue
+		}
+
+		if valueMap, ok := value.(map[string]any); ok {
+			if value, ok := valueMap["value"]; ok {
+				if valueStr, ok := value.(string); ok {
+					parameters[key] = valueStr
+				}
+			}
+		}
+	}
+	return parameters
+}
+
+// ParseRadiusGitOpsConfig parses the Radius GitOps configuration file at the given path
+// on the local filesystem.
+func ParseRadiusGitOpsConfig(configFilePath string) (*RadiusGitOpsConfig, error) {
+	radiusConfig := RadiusGitOpsConfig{}
+	b, err := os.ReadFile(configFilePath)
+	if err != nil {
+		return nil, err
+	}
+
+	err = yaml.Unmarshal(b, &radiusConfig)
+	if err != nil {
+		return nil, err
+	}
+
+	return &radiusConfig, nil
+}
diff --git a/pkg/controller/service.go b/pkg/controller/service.go
index f6d5f5093..28e59992e 100644
--- a/pkg/controller/service.go
+++ b/pkg/controller/service.go
@@ -20,15 +20,17 @@ import (
 	"context"
 	"fmt"
 
+	sourcev1 "github.com/fluxcd/source-controller/api/v1"
 	"github.com/radius-project/radius/pkg/armrpc/hostoptions"
 	aztoken "github.com/radius-project/radius/pkg/azure/tokencredentials"
+	"github.com/radius-project/radius/pkg/cli/bicep"
+	"github.com/radius-project/radius/pkg/cli/filesystem"
 	"github.com/radius-project/radius/pkg/components/hosting"
 	radappiov1alpha3 "github.com/radius-project/radius/pkg/controller/api/radapp.io/v1alpha3"
 	"github.com/radius-project/radius/pkg/controller/reconciler"
 	"github.com/radius-project/radius/pkg/sdk"
 	sdkclients "github.com/radius-project/radius/pkg/sdk/clients"
 	"github.com/radius-project/radius/pkg/ucp/ucplog"
-
 	"k8s.io/apimachinery/pkg/runtime"
 	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
 	clientgoscheme "k8s.io/client-go/kubernetes/scheme"
@@ -45,6 +47,7 @@ var (
 func init() {
 	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
 	utilruntime.Must(radappiov1alpha3.AddToScheme(scheme))
+	utilruntime.Must(sourcev1.AddToScheme(scheme))
 }
 
 var _ hosting.Service = (*Service)(nil)
@@ -140,6 +143,18 @@ func (s *Service) Run(ctx context.Context) error {
 		return fmt.Errorf("failed to setup %s controller: %w", "DeploymentResource", err)
 	}
 
+	err = (&reconciler.FluxController{
+		Client:         mgr.GetClient(),
+		ArchiveFetcher: reconciler.NewArchiveFetcher(),
+		FileSystem:     filesystem.NewOSFS(),
+		Bicep: &bicep.Impl{
+			FileSystem: filesystem.NewOSFS(),
+		},
+	}).SetupWithManager(mgr)
+	if err != nil {
+		return fmt.Errorf("failed to setup %s controller: %w", "FluxController", err)
+	}
+
 	if s.TLSCertDir == "" {
 		logger.Info("Webhooks will be skipped. TLS certificates not present.")
 	} else {



Test patch:
diff --git a/pkg/cli/bicep/deployment_parameters_test.go b/pkg/cli/bicep/deployment_parameters_test.go
index 25770b2dc..bb12ab9ee 100644
--- a/pkg/cli/bicep/deployment_parameters_test.go
+++ b/pkg/cli/bicep/deployment_parameters_test.go
@@ -21,7 +21,6 @@ import (
 	"os"
 	"path/filepath"
 	"testing"
-	"testing/fstest"
 
 	"github.com/radius-project/radius/pkg/cli/clients"
 	"github.com/radius-project/radius/pkg/cli/filesystem"
@@ -59,18 +58,18 @@ func Test_ParseParameters_Overwrite(t *testing.T) {
 
 	// Initialize the ParameterParser with the in-memory filesystem
 	parser := ParameterParser{
-		FileSystem: filesystem.MemMapFileSystem{
-			InternalFileSystem: fstest.MapFS{
-				"many.json": {
-					Data: []byte(`{ "parameters": { "key1": { "value": { "someValue": true } }, "key2": { "value": "overridden-value" } } }`),
-				},
-				"single.json": {
-					Data: []byte(`{ "someValue": "another-value" }`),
-				},
-			},
-		},
+		FileSystem: filesystem.NewMemMapFileSystem(),
 	}
 
+	_, err := parser.FileSystem.Create("many.json")
+	require.NoError(t, err)
+	err = parser.FileSystem.WriteFile("many.json", []byte(`{ "parameters": { "key1": { "value": { "someValue": true } }, "key2": { "value": "overridden-value" } } }`), 0644)
+	require.NoError(t, err)
+	_, err = parser.FileSystem.Create("single.json")
+	require.NoError(t, err)
+	err = parser.FileSystem.WriteFile("single.json", []byte(`{ "someValue": "another-value" }`), 0644)
+	require.NoError(t, err)
+
 	parameters, err := parser.Parse(inputs...)
 	require.NoError(t, err)
 
diff --git a/pkg/cli/filesystem/memmapfs_test.go b/pkg/cli/filesystem/memmapfs_test.go
index 87adc9cb3..66ccd984a 100644
--- a/pkg/cli/filesystem/memmapfs_test.go
+++ b/pkg/cli/filesystem/memmapfs_test.go
@@ -1,99 +1,251 @@
-/*
-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
-
-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 filesystem
 
 import (
-	"os"
+	"io"
+	"io/fs"
 	"testing"
-
-	"github.com/stretchr/testify/require"
+	"time"
 )
 
 func TestNewMemMapFileSystem(t *testing.T) {
-	fs := NewMemMapFileSystem()
-	require.NotNil(t, fs)
+	mmfs := NewMemMapFileSystem()
+	if mmfs == nil {
+		t.Fatal("expected non-nil MemMapFileSystem")
+	}
 }
 
 func TestMemMapFileSystem_Create(t *testing.T) {
-	fs := NewMemMapFileSystem()
-	fileName := "testfile"
-
-	file, err := fs.Create(fileName)
-	require.NoError(t, err)
-	require.NotNil(t, file)
-	require.True(t, fs.Exists(fileName))
+	mmfs := NewMemMapFileSystem()
+	file, err := mmfs.Create("testfile")
+	if err != nil {
+		t.Fatalf("expected no error, got %v", err)
+	}
+	if file == nil {
+		t.Fatal("expected non-nil file")
+	}
 }
 
-func TestMemMapFileSystem_Exists(t *testing.T) {
-	fs := NewMemMapFileSystem()
-	fileName := "testfile"
-
-	require.False(t, fs.Exists(fileName))
+func TestMemMapFileSystem_Open(t *testing.T) {
+	mmfs := NewMemMapFileSystem()
+	_, err := mmfs.Create("testfile")
+	if err != nil {
+		t.Fatalf("expected no error, got %v", err)
+	}
+	file, err := mmfs.Open("testfile")
+	if err != nil {
+		t.Fatalf("expected no error, got %v", err)
+	}
+	if file == nil {
+		t.Fatal("expected non-nil file")
+	}
+	file, err = mmfs.Open("nonexistent")
+	if err == nil {
+		t.Fatal("expected error for nonexistent file")
+	}
+	if file != nil {
+		t.Fatal("expected nil file for nonexistent file")
+	}
+}
 
-	_, _ = fs.Create(fileName)
+func TestMemMapFileSystem_Remove(t *testing.T) {
+	mmfs := NewMemMapFileSystem()
+	_, err := mmfs.Create("testfile")
+	if err != nil {
+		t.Fatalf("expected no error, got %v", err)
+	}
+	err = mmfs.Remove("testfile")
+	if err != nil {
+		t.Fatalf("expected no error, got %v", err)
+	}
+	err = mmfs.Remove("nonexistent")
+	if err == nil {
+		t.Fatal("expected error for nonexistent file")
+	}
+}
 
-	require.True(t, fs.Exists(fileName))
+func TestMemMapFileSystem_WriteFile(t *testing.T) {
+	mmfs := NewMemMapFileSystem()
+	err := mmfs.WriteFile("testfile", []byte("data"), fs.ModePerm)
+	if err != nil {
+		t.Fatalf("expected no error, got %v", err)
+	}
 }
 
-func TestMemMapFileSystem_Open(t *testing.T) {
-	fs := NewMemMapFileSystem()
-	fileName := "testfile"
+func TestMemMapFileSystem_ReadFile(t *testing.T) {
+	mmfs := NewMemMapFileSystem()
+	err := mmfs.WriteFile("testfile", []byte("data"), fs.ModePerm)
+	if err != nil {
+		t.Fatalf("expected no error, got %v", err)
+	}
+	data, err := mmfs.ReadFile("testfile")
+	if err != nil {
+		t.Fatalf("expected no error, got %v", err)
+	}
+	if string(data) != "data" {
+		t.Fatalf("expected 'data', got %s", string(data))
+	}
+	_, err = mmfs.ReadFile("nonexistent")
+	if err == nil {
+		t.Fatal("expected error for nonexistent file")
+	}
+}
 
-	_, _ = fs.Create(fileName)
+func TestMemMapFileSystem_Stat(t *testing.T) {
+	mmfs := NewMemMapFileSystem()
+	err := mmfs.WriteFile("testfile", []byte("data"), fs.ModePerm)
+	if err != nil {
+		t.Fatalf("expected no error, got %v", err)
+	}
+	info, err := mmfs.Stat("testfile")
+	if err != nil {
+		t.Fatalf("expected no error, got %v", err)
+	}
+	if info.Name() != "testfile" {
+		t.Fatalf("expected 'testfile', got %s", info.Name())
+	}
+	info, err = mmfs.Stat("nonexistent")
+	if err == nil {
+		t.Fatal("expected error for nonexistent file")
+	}
+	if info != nil {
+		t.Fatal("expected nil info for nonexistent file")
+	}
+}
 
-	file, err := fs.Open(fileName)
-	require.NoError(t, err)
-	require.NotNil(t, file)
+func TestMemMapFileSystem_Exists(t *testing.T) {
+	mmfs := NewMemMapFileSystem()
+	err := mmfs.WriteFile("testfile", []byte("data"), fs.ModePerm)
+	if err != nil {
+		t.Fatalf("expected no error, got %v", err)
+	}
+	if !mmfs.Exists("testfile") {
+		t.Fatal("expected file to exist")
+	}
+	if mmfs.Exists("nonexistent") {
+		t.Fatal("expected file to not exist")
+	}
 }
 
-func TestMemMapFileSystem_ReadFile(t *testing.T) {
-	fs := NewMemMapFileSystem()
-	fileName := "testfile"
-	data := []byte("hello world")
+func TestMemMapFileSystem_MkdirTemp(t *testing.T) {
+	mmfs := NewMemMapFileSystem()
+	dir, err := mmfs.MkdirTemp("/tmp", "testdir")
+	if err != nil {
+		t.Fatalf("expected no error, got %v", err)
+	}
+	if dir != "/tmp/testdir" {
+		t.Fatalf("expected '/tmp/testdir', got %s", dir)
+	}
+	_, err = mmfs.MkdirTemp("/tmp", "testdir")
+	if err == nil {
+		t.Fatal("expected error for existing directory")
+	}
+}
 
-	err := fs.WriteFile(fileName, data, os.ModePerm)
-	require.NoError(t, err)
+func TestMemMapFileSystem_MkdirAll(t *testing.T) {
+	mmfs := NewMemMapFileSystem()
+	err := mmfs.MkdirAll("/tmp/testdir", fs.ModePerm)
+	if err != nil {
+		t.Fatalf("expected no error, got %v", err)
+	}
+	if !mmfs.Exists("/tmp/testdir") {
+		t.Fatal("expected directory to exist")
+	}
+}
 
-	readData, err := fs.ReadFile(fileName)
-	require.NoError(t, err)
-	require.Equal(t, data, readData)
+func TestMemMapFileSystem_RemoveAll(t *testing.T) {
+	mmfs := NewMemMapFileSystem()
+	err := mmfs.MkdirAll("/tmp/testdir", fs.ModePerm)
+	if err != nil {
+		t.Fatalf("expected no error, got %v", err)
+	}
+	err = mmfs.RemoveAll("/tmp/testdir")
+	if err != nil {
+		t.Fatalf("expected no error, got %v", err)
+	}
+	if mmfs.Exists("/tmp/testdir") {
+		t.Fatal("expected directory to not exist")
+	}
+	err = mmfs.RemoveAll("nonexistent")
+	if err == nil {
+		t.Fatal("expected error for nonexistent directory")
+	}
 }
 
-func TestMemMapFileSystem_Stat(t *testing.T) {
-	fs := NewMemMapFileSystem()
-	fileName := "testfile"
+func TestMemFile_Read(t *testing.T) {
+	file := &MemFile{
+		Data: []byte("data"),
+	}
+	buf := make([]byte, 4)
+	n, err := file.Read(buf)
+	if err != nil {
+		t.Fatalf("expected no error, got %v", err)
+	}
+	if n != 4 {
+		t.Fatalf("expected 4 bytes read, got %d", n)
+	}
+	if string(buf) != "data" {
+		t.Fatalf("expected 'data', got %s", string(buf))
+	}
+	n, err = file.Read(buf)
+	if err != io.EOF {
+		t.Fatalf("expected io.EOF, got %v", err)
+	}
+	if n != 0 {
+		t.Fatalf("expected 0 bytes read, got %d", n)
+	}
+}
 
-	_, _ = fs.Create(fileName)
+func TestMemFile_Close(t *testing.T) {
+	file := &MemFile{}
+	err := file.Close()
+	if err != nil {
+		t.Fatalf("expected no error, got %v", err)
+	}
+}
 
-	info, err := fs.Stat(fileName)
-	require.NoError(t, err)
-	require.NotNil(t, info)
-	require.Equal(t, fileName, info.Name())
+func TestMemFile_Stat(t *testing.T) {
+	file := &MemFile{
+		Name: "testfile",
+		Data: []byte("data"),
+		Mode: fs.ModePerm,
+	}
+	info, err := file.Stat()
+	if err != nil {
+		t.Fatalf("expected no error, got %v", err)
+	}
+	if info.Name() != "testfile" {
+		t.Fatalf("expected 'testfile', got %s", info.Name())
+	}
+	if info.Size() != 4 {
+		t.Fatalf("expected size 4, got %d", info.Size())
+	}
+	if info.Mode() != fs.ModePerm {
+		t.Fatalf("expected mode %v, got %v", fs.ModePerm, info.Mode())
+	}
 }
 
-func TestMemMapFileSystem_WriteFile(t *testing.T) {
-	fs := NewMemMapFileSystem()
-	fileName := "testfile"
-	data := []byte("hello world")
+func TestMemFileInfo_ModTime(t *testing.T) {
+	info := &MemFileInfo{}
+	modTime := info.ModTime()
+	if modTime.After(time.Now()) {
+		t.Fatalf("expected modTime to be before or equal to current time, got %v", modTime)
+	}
+}
 
-	err := fs.WriteFile(fileName, data, os.ModePerm)
-	require.NoError(t, err)
+func TestMemFileInfo_IsDir(t *testing.T) {
+	info := &MemFileInfo{mode: fs.ModeDir}
+	if !info.IsDir() {
+		t.Fatal("expected IsDir to return true for directory mode")
+	}
+	info = &MemFileInfo{mode: fs.ModePerm}
+	if info.IsDir() {
+		t.Fatal("expected IsDir to return false for non-directory mode")
+	}
+}
 
-	readData, err := fs.ReadFile(fileName)
-	require.NoError(t, err)
-	require.Equal(t, data, readData)
+func TestMemFileInfo_Sys(t *testing.T) {
+	info := &MemFileInfo{}
+	if info.Sys() != nil {
+		t.Fatal("expected Sys to return nil")
+	}
 }
diff --git a/pkg/controller/reconciler/flux_controller_test.go b/pkg/controller/reconciler/flux_controller_test.go
new file mode 100644
index 000000000..57313f264
--- /dev/null
+++ b/pkg/controller/reconciler/flux_controller_test.go
@@ -0,0 +1,483 @@
+/*
+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
+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 reconciler
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"path"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	sourcev1 "github.com/fluxcd/source-controller/api/v1"
+	"github.com/radius-project/radius/pkg/cli/bicep"
+	"github.com/radius-project/radius/pkg/cli/filesystem"
+	radappiov1alpha3 "github.com/radius-project/radius/pkg/controller/api/radapp.io/v1alpha3"
+	"github.com/radius-project/radius/pkg/to"
+	"github.com/radius-project/radius/test/testcontext"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/mock/gomock"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/wait"
+	ctrl "sigs.k8s.io/controller-runtime"
+	k8sclient "sigs.k8s.io/controller-runtime/pkg/client"
+	crconfig "sigs.k8s.io/controller-runtime/pkg/config"
+	"sigs.k8s.io/controller-runtime/pkg/metrics/server"
+)
+
+func Test_FluxController_Basic(t *testing.T) {
+	testGitRepoName := "flux-basic-repo"
+	testGitRepoURL := fmt.Sprintf("https://github.com/radius-project/%s.git", testGitRepoName)
+	testGitRepoSHA := "sha256:1234"
+
+	mctrl := gomock.NewController(t)
+	defer mctrl.Finish()
+
+	setupOpts := setupFluxControllerTestOptions{
+		testGitRepoName,
+		testGitRepoURL,
+		testGitRepoSHA,
+		mctrl,
+	}
+
+	steps := []Step{
+		{
+			Path: "testdata/flux-basic",
+		},
+	}
+
+	runOpts := setupFluxControllerTest(t, setupOpts, steps)
+
+	runFluxControllerTest(t, runOpts, steps)
+}
+
+func Test_FluxController_Update(t *testing.T) {
+	testGitRepoName := "flux-update-repo"
+	testGitRepoURL := fmt.Sprintf("https://github.com/radius-project/%s.git", testGitRepoName)
+	testGitRepoSHA := "sha256:1234"
+
+	mctrl := gomock.NewController(t)
+	defer mctrl.Finish()
+
+	setupOpts := setupFluxControllerTestOptions{
+		testGitRepoName,
+		testGitRepoURL,
+		testGitRepoSHA,
+		mctrl,
+	}
+
+	steps := []Step{
+		{
+			Path: "testdata/flux-update/step-1",
+		},
+		{
+			Path: "testdata/flux-update/step-2",
+		},
+	}
+
+	runOpts := setupFluxControllerTest(t, setupOpts, steps)
+
+	runFluxControllerTest(t, runOpts, steps)
+}
+
+type Step struct {
+	Path            string
+	BicepFiles      []string
+	BicepParamFiles []string
+}
+
+type setupFluxControllerTestOptions struct {
+	testGitRepoName string
+	testGitRepoURL  string
+	testGitRepoSHA  string
+
+	mctrl *gomock.Controller
+}
+
+type runFluxControllerTestOptions struct {
+	testGitRepoName string
+	testGitRepoURL  string
+	testGitRepoSHA  string
+
+	archiveFetcher *MockArchiveFetcher
+	filesystem     *filesystem.MemMapFileSystem
+	bicep          *bicep.MockInterface
+	client         k8sclient.Client
+}
+
+func setupFluxControllerTest(t *testing.T, opts setupFluxControllerTestOptions, steps []Step) runFluxControllerTestOptions {
+	SkipWithoutEnvironment(t)
+
+	// For debugging, you can set uncomment this to see logs from the controller. This will cause tests to fail
+	// because the logging will continue after the test completes.
+	//
+	// Add runtimelog "sigs.k8s.io/controller-runtime/pkg/log" to imports.
+	//
+	// runtimelog.SetLogger(ucplog.FromContextOrDiscard(testcontext.New(t)))
+
+	// Shut down the manager when the test exits.
+	ctx, cancel := testcontext.NewWithCancel(t)
+	t.Cleanup(cancel)
+
+	fs := filesystem.NewMemMapFileSystem()
+	archiveFetcher := NewMockArchiveFetcher(opts.mctrl)
+	bicep := bicep.NewMockInterface(opts.mctrl)
+
+	mgr, err := ctrl.NewManager(config, ctrl.Options{
+		Scheme: scheme,
+		Controller: crconfig.Controller{
+			SkipNameValidation: to.Ptr(true),
+		},
+
+		// Suppress metrics in tests to avoid conflicts.
+		Metrics: server.Options{
+			BindAddress: "0",
+		},
+	})
+	require.NoError(t, err)
+
+	fluxController := &FluxController{
+		Client:         mgr.GetClient(),
+		FileSystem:     fs,
+		Bicep:          bicep,
+		ArchiveFetcher: archiveFetcher,
+	}
+	err = (fluxController).SetupWithManager(mgr)
+	require.NoError(t, err)
+
+	go func() {
+		err := mgr.Start(ctx)
+		require.NoError(t, err)
+	}()
+
+	var archiveFetcherCalls []any
+	var bicepCalls []any
+	for _, step := range steps {
+		s := step // capture loop variable
+		call := archiveFetcher.EXPECT().
+			Fetch(opts.testGitRepoURL, opts.testGitRepoSHA, gomock.Any()).
+			Return(nil).
+			Times(1).
+			Do(func(archiveURL, digest, dir string) {
+				err := filepath.WalkDir(s.Path, func(srcPath string, info os.DirEntry, err error) error {
+					if err != nil {
+						return err
+					}
+					relPath, err := filepath.Rel(s.Path, srcPath)
+					if err != nil {
+						return err
+					}
+					dstPath := filepath.Join(dir, relPath)
+					if info.IsDir() {
+						return fs.MkdirAll(dstPath, 0755)
+					}
+					// Read contents of srcFile and write to destination.
+					data, err := os.ReadFile(srcPath)
+					if err != nil {
+						return err
+					}
+					return fs.WriteFile(dstPath, data, 0644)
+				})
+				require.NoError(t, err)
+			})
+
+		archiveFetcherCalls = append(archiveFetcherCalls, call)
+
+		radiusConfig, err := ParseRadiusGitOpsConfig(path.Join(s.Path, "radius-gitops-config.yaml"))
+		require.NoError(t, err)
+		require.NotNil(t, radiusConfig)
+
+		for _, configEntry := range radiusConfig.Config {
+			ce := configEntry // capture loop variable
+			name := ce.Name
+			nameBase := strings.TrimSuffix(name, path.Ext(name))
+			outFile := fmt.Sprintf("%s.json", nameBase)
+
+			bicepBuildCall := bicep.EXPECT().
+				Call("build", gomock.Cond(func(s string) bool { return strings.HasSuffix(s, ce.Name) }), "--outfile", gomock.Cond(func(s string) bool { return strings.HasSuffix(s, outFile) })).
+				Return(nil, nil).
+				Times(1).
+				Do(func(args ...string) {
+					filePath := args[1]
+					outFilePath := args[3]
+					outFileName := filepath.Base(outFilePath)
+					localFilePath := s.Path
+					fileContent, err := os.ReadFile(path.Join(localFilePath, outFileName))
+					require.NoError(t, err)
+					err = fs.WriteFile(filePath, fileContent, 0644)
+					require.NoError(t, err)
+				})
+			bicepCalls = append(bicepCalls, bicepBuildCall)
+
+			if ce.Params != "" {
+				bicepBuildParamsCall := bicep.EXPECT().
+					Call("build-params", gomock.Any(), "--outfile", gomock.Any()).
+					Return(nil, nil).
+					Times(1).
+					Do(func(args ...string) {
+						filePath := args[1]
+						outFilePath := args[3]
+						outFileName := filepath.Base(outFilePath)
+						localFilePath := s.Path
+						fileContent, err := os.ReadFile(path.Join(localFilePath, outFileName))
+						require.NoError(t, err)
+						err = fs.WriteFile(filePath, fileContent, 0644)
+						require.NoError(t, err)
+					})
+				bicepCalls = append(bicepCalls, bicepBuildParamsCall)
+			}
+		}
+	}
+	gomock.InOrder(archiveFetcherCalls...)
+	gomock.InOrder(bicepCalls...)
+
+	return runFluxControllerTestOptions{
+		testGitRepoName: opts.testGitRepoName,
+		testGitRepoURL:  opts.testGitRepoURL,
+		testGitRepoSHA:  opts.testGitRepoSHA,
+
+		archiveFetcher: archiveFetcher,
+		filesystem:     fs,
+		bicep:          bicep,
+		client:         mgr.GetClient(),
+	}
+}
+
+func runFluxControllerTest(t *testing.T, opts runFluxControllerTestOptions, steps []Step) {
+	ctx := testcontext.New(t)
+
+	for stepIndex, step := range steps {
+		stepNumber := stepIndex + 1
+		radiusConfig, err := ParseRadiusGitOpsConfig(path.Join(step.Path, "radius-gitops-config.yaml"))
+		require.NoError(t, err)
+		require.NotNil(t, radiusConfig)
+
+		for _, configEntry := range radiusConfig.Config {
+			name := configEntry.Name
+			nameBase := strings.TrimSuffix(name, path.Ext(name))
+			namespaceName := configEntry.Namespace
+			if namespaceName == "" {
+				namespaceName = nameBase
+			}
+
+			// Create a namespace if it does not exist
+			if err := opts.client.Get(ctx, types.NamespacedName{Name: namespaceName}, &corev1.Namespace{}); err != nil {
+				if k8sclient.IgnoreNotFound(err) != nil {
+					require.NoError(t, err)
+				}
+				namespace := &corev1.Namespace{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: namespaceName,
+					},
+				}
+				err := opts.client.Create(ctx, namespace)
+				require.NoError(t, err)
+			}
+
+			gitRepo := sourcev1.GitRepository{}
+			gitRepoNamespacedName := types.NamespacedName{Name: opts.testGitRepoName, Namespace: namespaceName}
+			if stepNumber == 1 {
+				// Create a GitRepository resource on the cluster
+				gitRepo = makeGitRepository(gitRepoNamespacedName, opts.testGitRepoURL)
+				err = opts.client.Create(ctx, &gitRepo)
+				require.NoError(t, err)
+				defer func() {
+					// Clean up the GitRepository resource after the test
+					err := opts.client.Delete(ctx, &gitRepo)
+					if err != nil {
+						if k8sclient.IgnoreNotFound(err) != nil {
+							require.NoError(t, err)
+						}
+					}
+				}()
+
+				// Wait for the GitRepository to be created
+				err = waitForGitRepositoryToExistWithGeneration(ctx, opts.client, gitRepoNamespacedName, &gitRepo, int64(stepNumber))
+				require.NoError(t, err, "GitRepository was not created successfully")
+			}
+
+			// Fetch the latest GitRepository object
+			err = opts.client.Get(ctx, gitRepoNamespacedName, &gitRepo)
+			require.NoError(t, err)
+
+			// Update the Status subresource
+			updatedStatus := sourcev1.GitRepositoryStatus{
+				ObservedGeneration: int64(stepNumber),
+				Artifact: &sourcev1.Artifact{
+					URL:      opts.testGitRepoURL,
+					Digest:   opts.testGitRepoSHA,
+					Revision: fmt.Sprintf("v%d", stepNumber),
+					LastUpdateTime: metav1.Time{
+						Time: time.Now(),
+					},
+				},
+				Conditions: []metav1.Condition{
+					{
+						Type:    "Ready",
+						Status:  metav1.ConditionTrue,
+						Reason:  "Succeeded",
+						Message: "Repository is ready",
+						LastTransitionTime: metav1.Time{
+							Time: time.Now(),
+						},
+					},
+				},
+			}
+			gitRepo.Status = updatedStatus
+			err = opts.client.Status().Update(ctx, &gitRepo)
+			require.NoError(t, err)
+
+			// Now, the FluxController should reconcile the GitRepository and create the DeploymentTemplate resource.
+			deploymentTemplateName := name
+			deploymentTemplateNamespacedName := types.NamespacedName{Name: deploymentTemplateName, Namespace: namespaceName}
+			deploymentTemplate := radappiov1alpha3.DeploymentTemplate{}
+			err = waitForDeploymentTemplateToExistWithGeneration(ctx, opts.client, deploymentTemplateNamespacedName, &deploymentTemplate, int64(stepNumber))
+			require.NoError(t, err)
+
+			// Fetch the latest DeploymentTemplate object
+			err = opts.client.Get(ctx, deploymentTemplateNamespacedName, &deploymentTemplate)
+			require.NoError(t, err)
+		}
+	}
+}
+
+func makeGitRepository(namespacedName types.NamespacedName, url string) sourcev1.GitRepository {
+	return sourcev1.GitRepository{
+		TypeMeta: metav1.TypeMeta{
+			Kind:       "GitRepository",
+			APIVersion: "source.toolkit.fluxcd.io/v1",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      namespacedName.Name,
+			Namespace: namespacedName.Namespace,
+		},
+		Spec: sourcev1.GitRepositorySpec{
+			URL: url,
+		},
+	}
+}
+
+func waitForGitRepositoryToExistWithGeneration(ctx context.Context, k8sClient k8sclient.Client, key k8sclient.ObjectKey, obj k8sclient.Object, generation int64) error {
+	timeout := 10 * time.Second
+	interval := 1 * time.Second
+	deadlineCtx, deadlineCancel := context.WithTimeout(ctx, timeout)
+	defer deadlineCancel()
+
+	err := wait.PollUntilContextTimeout(deadlineCtx, interval, timeout, true, func(_ context.Context) (bool, error) {
+		err := k8sClient.Get(ctx, key, obj)
+		if err != nil {
+			return false, nil // Continue polling
+		}
+
+		gitRepo, ok := obj.(*sourcev1.GitRepository)
+		if !ok {
+			return false, nil // Continue polling
+		}
+
+		if gitRepo.Generation != generation {
+			return false, nil // Continue polling
+		}
+
+		return true, nil // Found
+	})
+
+	if err != nil {
+		return fmt.Errorf("GitRepository %s/%s was not created successfully", key.Namespace, key.Name)
+	}
+
+	return nil
+}
+
+func waitForDeploymentTemplateToExistWithGeneration(ctx context.Context, k8sClient k8sclient.Client, key types.NamespacedName, obj k8sclient.Object, generation int64) error {
+	timeout := 10 * time.Second
+	interval := 1 * time.Second
+	deadlineCtx, deadlineCancel := context.WithTimeout(ctx, timeout)
+	defer deadlineCancel()
+
+	err := wait.PollUntilContextTimeout(deadlineCtx, interval, timeout, true, func(_ context.Context) (bool, error) {
+		err := k8sClient.Get(ctx, key, obj)
+		if err != nil {
+			return false, nil // Continue polling
+		}
+
+		deploymentTemplate, ok := obj.(*radappiov1alpha3.DeploymentTemplate)
+		if !ok {
+			return false, nil // Continue polling
+		}
+
+		if deploymentTemplate.Generation != generation {
+			return false, nil // Continue polling
+		}
+
+		return true, nil // Found
+	})
+
+	if err != nil {
+		return fmt.Errorf("DeploymentTemplate %s/%s was not created successfully", key.Namespace, key.Name)
+	}
+
+	return nil
+}
+
+func Test_isSpecifiedInConfig(t *testing.T) {
+	tests := []struct {
+		name     string
+		fileName string
+		config   []ConfigEntry
+		expected bool
+	}{
+		{
+			name:     "File exists in config",
+			fileName: "example.bicep",
+			config: []ConfigEntry{
+				{Name: "example.bicep"},
+				{Name: "another.bicep"},
+			},
+			expected: true,
+		},
+		{
+			name:     "File does not exist in config",
+			fileName: "missing.bicep",
+			config: []ConfigEntry{
+				{Name: "example.bicep"},
+				{Name: "another.bicep"},
+			},
+			expected: false,
+		},
+		{
+			name:     "Empty config",
+			fileName: "example.bicep",
+			config:   []ConfigEntry{},
+			expected: false,
+		},
+		{
+			name:     "Nil config",
+			fileName: "example.bicep",
+			config:   nil,
+			expected: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := isSpecifiedInConfig(tt.fileName, tt.config)
+			require.Equal(t, tt.expected, result)
+		})
+	}
+}
diff --git a/pkg/controller/reconciler/flux_gitrepository_predicate_test.go b/pkg/controller/reconciler/flux_gitrepository_predicate_test.go
new file mode 100644
index 000000000..9e600fede
--- /dev/null
+++ b/pkg/controller/reconciler/flux_gitrepository_predicate_test.go
@@ -0,0 +1,209 @@
+/*
+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
+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 reconciler
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"sigs.k8s.io/controller-runtime/pkg/event"
+
+	sourcev1 "github.com/fluxcd/source-controller/api/v1"
+)
+
+func TestGitRepositoryRevisionChangePredicate_Create(t *testing.T) {
+	predicate := GitRepositoryRevisionChangePredicate{}
+
+	tests := []struct {
+		name     string
+		event    event.CreateEvent
+		expected bool
+	}{
+		{
+			name: "Source is not a sourcev1.Source",
+			event: event.CreateEvent{
+				Object: &corev1.Pod{},
+			},
+			expected: false,
+		},
+		{
+			name: "Source has no artifact",
+			event: event.CreateEvent{
+				Object: &sourcev1.GitRepository{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "test-repo",
+					},
+				},
+			},
+			expected: false,
+		},
+		{
+			name: "Source has an artifact",
+			event: event.CreateEvent{
+				Object: &sourcev1.GitRepository{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "test-repo",
+					},
+					Status: sourcev1.GitRepositoryStatus{
+						Artifact: &sourcev1.Artifact{
+							Revision: "test-revision",
+						},
+					},
+				},
+			},
+			expected: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := predicate.Create(tt.event)
+			assert.Equal(t, tt.expected, result)
+		})
+	}
+}
+
+func TestGitRepositoryRevisionChangePredicate_Update(t *testing.T) {
+	predicate := GitRepositoryRevisionChangePredicate{}
+
+	tests := []struct {
+		name     string
+		event    event.UpdateEvent
+		expected bool
+	}{
+		{
+			name: "Source ObjectOld is nil",
+			event: event.UpdateEvent{
+				ObjectNew: &sourcev1.GitRepository{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "test-repo",
+					},
+				},
+			},
+			expected: false,
+		},
+		{
+			name: "Source ObjectNew is nil",
+			event: event.UpdateEvent{
+				ObjectOld: &sourcev1.GitRepository{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "test-repo",
+					},
+				},
+			},
+			expected: false,
+		},
+		{
+			name: "Source ObjectOld is not a sourcev1.Source",
+			event: event.UpdateEvent{
+				ObjectOld: &corev1.Pod{},
+				ObjectNew: &sourcev1.GitRepository{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "test-repo",
+					},
+				},
+			},
+			expected: false,
+		},
+		{
+			name: "Source ObjectNew is not a sourcev1.Source",
+			event: event.UpdateEvent{
+				ObjectOld: &sourcev1.GitRepository{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "test-repo",
+					},
+				},
+				ObjectNew: &corev1.Pod{},
+			},
+			expected: false,
+		},
+		{
+			name: "Sources ObjectOld and ObjectNew have no artifact",
+			event: event.UpdateEvent{
+				ObjectOld: &sourcev1.GitRepository{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "test-repo",
+					},
+				},
+				ObjectNew: &sourcev1.GitRepository{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "test-repo",
+					},
+				},
+			},
+			expected: false,
+		},
+		{
+			name: "Source ObjectNew and ObjectOld are the same",
+			event: event.UpdateEvent{
+				ObjectOld: &sourcev1.GitRepository{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "test-repo",
+					},
+					Status: sourcev1.GitRepositoryStatus{
+						Artifact: &sourcev1.Artifact{
+							Revision: "test-revision",
+						},
+					},
+				},
+				ObjectNew: &sourcev1.GitRepository{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "test-repo",
+					},
+					Status: sourcev1.GitRepositoryStatus{
+						Artifact: &sourcev1.Artifact{
+							Revision: "test-revision",
+						},
+					},
+				},
+			},
+			expected: false,
+		},
+		{
+			name: "Source ObjectNew and ObjectOld have different revisions",
+			event: event.UpdateEvent{
+				ObjectOld: &sourcev1.GitRepository{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "test-repo",
+					},
+					Status: sourcev1.GitRepositoryStatus{
+						Artifact: &sourcev1.Artifact{
+							Revision: "test-revision",
+						},
+					},
+				},
+				ObjectNew: &sourcev1.GitRepository{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "test-repo",
+					},
+					Status: sourcev1.GitRepositoryStatus{
+						Artifact: &sourcev1.Artifact{
+							Revision: "test-revision-different",
+						},
+					},
+				},
+			},
+			expected: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := predicate.Update(tt.event)
+			assert.Equal(t, tt.expected, result)
+		})
+	}
+}
diff --git a/pkg/controller/reconciler/main_test.go b/pkg/controller/reconciler/main_test.go
index 9e39680b7..aa0003730 100644
--- a/pkg/controller/reconciler/main_test.go
+++ b/pkg/controller/reconciler/main_test.go
@@ -21,6 +21,7 @@ import (
 	"path/filepath"
 	"testing"
 
+	sourcev1 "github.com/fluxcd/source-controller/api/v1"
 	radappiov1alpha3 "github.com/radius-project/radius/pkg/controller/api/radapp.io/v1alpha3"
 	admissionv1 "k8s.io/api/admissionregistration/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -56,19 +57,23 @@ func TestMain(m *testing.M) {
 	}
 
 	env := &envtest.Environment{
-		CRDDirectoryPaths:     []string{filepath.Join("..", "..", "..", "deploy", "Chart", "crds", "radius")},
+		CRDDirectoryPaths: []string{
+			filepath.Join("..", "..", "..", "deploy", "Chart", "crds", "radius"),
+			filepath.Join("..", "..", "..", "test", "crd", "flux"),
+		},
 		ErrorIfCRDPathMissing: true,
 	}
 
 	initializeWebhookInEnvironment(env)
 	cfg, err := env.Start()
 	if err != nil {
-		panic("failed to start envtest" + err.Error())
+		panic("failed to start envtest: " + err.Error())
 	}
 
 	s := runtime.NewScheme()
 	_ = clientgoscheme.AddToScheme(s)
 	_ = radappiov1alpha3.AddToScheme(s)
+	_ = sourcev1.AddToScheme(s)
 
 	config = cfg
 	scheme = s
diff --git a/pkg/controller/reconciler/testdata/flux-basic/flux-basic.bicep b/pkg/controller/reconciler/testdata/flux-basic/flux-basic.bicep
new file mode 100644
index 000000000..a78323d75
--- /dev/null
+++ b/pkg/controller/reconciler/testdata/flux-basic/flux-basic.bicep
@@ -0,0 +1,12 @@
+extension radius
+
+resource fluxBasicEnv 'Applications.Core/environments@2023-10-01-preview' = {
+  name: 'flux-basic-env'
+  properties: {
+    compute: {
+      kind: 'kubernetes'
+      resourceId: 'self'
+      namespace: 'flux-basic'
+    }
+  }
+}
diff --git a/pkg/controller/reconciler/testdata/flux-basic/flux-basic.json b/pkg/controller/reconciler/testdata/flux-basic/flux-basic.json
new file mode 100644
index 000000000..08e1ee907
--- /dev/null
+++ b/pkg/controller/reconciler/testdata/flux-basic/flux-basic.json
@@ -0,0 +1,36 @@
+{
+  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
+  "languageVersion": "2.1-experimental",
+  "contentVersion": "1.0.0.0",
+  "metadata": {
+    "_EXPERIMENTAL_WARNING": "This template uses ARM features that are experimental. Experimental features should be enabled for testing purposes only, as there are no guarantees about the quality or stability of these features. Do not enable these settings for any production usage, or your production environment may be subject to breaking.",
+    "_EXPERIMENTAL_FEATURES_ENABLED": ["Extensibility"],
+    "_generator": {
+      "name": "bicep",
+      "version": "0.33.93.31351",
+      "templateHash": "15307326309379706687"
+    }
+  },
+  "imports": {
+    "Radius": {
+      "provider": "Radius",
+      "version": "latest"
+    }
+  },
+  "resources": {
+    "fluxBasicEnv": {
+      "import": "Radius",
+      "type": "Applications.Core/environments@2023-10-01-preview",
+      "properties": {
+        "name": "flux-basic-env",
+        "properties": {
+          "compute": {
+            "kind": "kubernetes",
+            "resourceId": "self",
+            "namespace": "flux-basic"
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/pkg/controller/reconciler/testdata/flux-basic/radius-gitops-config.yaml b/pkg/controller/reconciler/testdata/flux-basic/radius-gitops-config.yaml
new file mode 100644
index 000000000..a1a1958fc
--- /dev/null
+++ b/pkg/controller/reconciler/testdata/flux-basic/radius-gitops-config.yaml
@@ -0,0 +1,2 @@
+config:
+  - name: flux-basic.bicep
diff --git a/pkg/controller/reconciler/testdata/flux-update/step-1/flux-update.bicep b/pkg/controller/reconciler/testdata/flux-update/step-1/flux-update.bicep
new file mode 100644
index 000000000..0d22152f8
--- /dev/null
+++ b/pkg/controller/reconciler/testdata/flux-update/step-1/flux-update.bicep
@@ -0,0 +1,37 @@
+extension radius
+
+param replicas string
+
+resource fluxUpdateEnv 'Applications.Core/environments@2023-10-01-preview' = {
+  name: 'flux-update-env'
+  properties: {
+    compute: {
+      kind: 'kubernetes'
+      resourceId: 'self'
+      namespace: 'flux-update'
+    }
+  }
+}
+
+resource fluxUpdateApp 'Applications.Core/applications@2023-10-01-preview' = {
+  name: 'flux-update-app'
+  properties: {
+    environment: fluxUpdateEnv.id
+  }
+}
+
+resource fluxUpdateContainer 'Applications.Core/containers@2023-10-01-preview' = {
+  name: 'flux-update-container'
+  properties: {
+    application: fluxUpdateApp.id
+    container: {
+      image: 'nginx'
+    }
+    extensions: [
+      {
+        kind: 'manualScaling'
+        replicas: int(replicas)
+      }
+    ]
+  }
+}
diff --git a/pkg/controller/reconciler/testdata/flux-update/step-1/flux-update.bicepparam b/pkg/controller/reconciler/testdata/flux-update/step-1/flux-update.bicepparam
new file mode 100644
index 000000000..b21357140
--- /dev/null
+++ b/pkg/controller/reconciler/testdata/flux-update/step-1/flux-update.bicepparam
@@ -0,0 +1,3 @@
+using 'flux-update.bicep'
+
+param replicas = '1'
diff --git a/pkg/controller/reconciler/testdata/flux-update/step-1/flux-update.json b/pkg/controller/reconciler/testdata/flux-update/step-1/flux-update.json
new file mode 100644
index 000000000..305fe2894
--- /dev/null
+++ b/pkg/controller/reconciler/testdata/flux-update/step-1/flux-update.json
@@ -0,0 +1,72 @@
+{
+  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
+  "languageVersion": "2.1-experimental",
+  "contentVersion": "1.0.0.0",
+  "metadata": {
+    "_EXPERIMENTAL_WARNING": "This template uses ARM features that are experimental. Experimental features should be enabled for testing purposes only, as there are no guarantees about the quality or stability of these features. Do not enable these settings for any production usage, or your production environment may be subject to breaking.",
+    "_EXPERIMENTAL_FEATURES_ENABLED": ["Extensibility"],
+    "_generator": {
+      "name": "bicep",
+      "version": "0.33.93.31351",
+      "templateHash": "1204218090440915723"
+    }
+  },
+  "parameters": {
+    "replicas": {
+      "type": "string"
+    }
+  },
+  "imports": {
+    "Radius": {
+      "provider": "Radius",
+      "version": "latest"
+    }
+  },
+  "resources": {
+    "fluxUpdateEnv": {
+      "import": "Radius",
+      "type": "Applications.Core/environments@2023-10-01-preview",
+      "properties": {
+        "name": "flux-update-env",
+        "properties": {
+          "compute": {
+            "kind": "kubernetes",
+            "resourceId": "self",
+            "namespace": "flux-update"
+          }
+        }
+      }
+    },
+    "fluxUpdateApp": {
+      "import": "Radius",
+      "type": "Applications.Core/applications@2023-10-01-preview",
+      "properties": {
+        "name": "flux-update-app",
+        "properties": {
+          "environment": "[reference('fluxUpdateEnv').id]"
+        }
+      },
+      "dependsOn": ["fluxUpdateEnv"]
+    },
+    "fluxUpdateContainer": {
+      "import": "Radius",
+      "type": "Applications.Core/containers@2023-10-01-preview",
+      "properties": {
+        "name": "flux-update-container",
+        "properties": {
+          "application": "[reference('fluxUpdateApp').id]",
+          "container": {
+            "image": "nginx"
+          },
+          "extensions": [
+            {
+              "kind": "manualScaling",
+              "replicas": "[int(parameters('replicas'))]"
+            }
+          ]
+        }
+      },
+      "dependsOn": ["fluxUpdateApp"]
+    }
+  }
+}
diff --git a/pkg/controller/reconciler/testdata/flux-update/step-1/flux-update.parameters.json b/pkg/controller/reconciler/testdata/flux-update/step-1/flux-update.parameters.json
new file mode 100644
index 000000000..e02e41fa6
--- /dev/null
+++ b/pkg/controller/reconciler/testdata/flux-update/step-1/flux-update.parameters.json
@@ -0,0 +1,9 @@
+{
+  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
+  "contentVersion": "1.0.0.0",
+  "parameters": {
+    "replicas": {
+      "value": "1"
+    }
+  }
+}
diff --git a/pkg/controller/reconciler/testdata/flux-update/step-1/radius-gitops-config.yaml b/pkg/controller/reconciler/testdata/flux-update/step-1/radius-gitops-config.yaml
new file mode 100644
index 000000000..0abefbb2b
--- /dev/null
+++ b/pkg/controller/reconciler/testdata/flux-update/step-1/radius-gitops-config.yaml
@@ -0,0 +1,3 @@
+config:
+  - name: flux-update.bicep
+    params: flux-update.bicepparam
diff --git a/pkg/controller/reconciler/testdata/flux-update/step-2/flux-update.bicep b/pkg/controller/reconciler/testdata/flux-update/step-2/flux-update.bicep
new file mode 100644
index 000000000..0d22152f8
--- /dev/null
+++ b/pkg/controller/reconciler/testdata/flux-update/step-2/flux-update.bicep
@@ -0,0 +1,37 @@
+extension radius
+
+param replicas string
+
+resource fluxUpdateEnv 'Applications.Core/environments@2023-10-01-preview' = {
+  name: 'flux-update-env'
+  properties: {
+    compute: {
+      kind: 'kubernetes'
+      resourceId: 'self'
+      namespace: 'flux-update'
+    }
+  }
+}
+
+resource fluxUpdateApp 'Applications.Core/applications@2023-10-01-preview' = {
+  name: 'flux-update-app'
+  properties: {
+    environment: fluxUpdateEnv.id
+  }
+}
+
+resource fluxUpdateContainer 'Applications.Core/containers@2023-10-01-preview' = {
+  name: 'flux-update-container'
+  properties: {
+    application: fluxUpdateApp.id
+    container: {
+      image: 'nginx'
+    }
+    extensions: [
+      {
+        kind: 'manualScaling'
+        replicas: int(replicas)
+      }
+    ]
+  }
+}
diff --git a/pkg/controller/reconciler/testdata/flux-update/step-2/flux-update.bicepparam b/pkg/controller/reconciler/testdata/flux-update/step-2/flux-update.bicepparam
new file mode 100644
index 000000000..68fa33cdf
--- /dev/null
+++ b/pkg/controller/reconciler/testdata/flux-update/step-2/flux-update.bicepparam
@@ -0,0 +1,3 @@
+using 'flux-update.bicep'
+
+param replicas = '2'
diff --git a/pkg/controller/reconciler/testdata/flux-update/step-2/flux-update.json b/pkg/controller/reconciler/testdata/flux-update/step-2/flux-update.json
new file mode 100644
index 000000000..305fe2894
--- /dev/null
+++ b/pkg/controller/reconciler/testdata/flux-update/step-2/flux-update.json
@@ -0,0 +1,72 @@
+{
+  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
+  "languageVersion": "2.1-experimental",
+  "contentVersion": "1.0.0.0",
+  "metadata": {
+    "_EXPERIMENTAL_WARNING": "This template uses ARM features that are experimental. Experimental features should be enabled for testing purposes only, as there are no guarantees about the quality or stability of these features. Do not enable these settings for any production usage, or your production environment may be subject to breaking.",
+    "_EXPERIMENTAL_FEATURES_ENABLED": ["Extensibility"],
+    "_generator": {
+      "name": "bicep",
+      "version": "0.33.93.31351",
+      "templateHash": "1204218090440915723"
+    }
+  },
+  "parameters": {
+    "replicas": {
+      "type": "string"
+    }
+  },
+  "imports": {
+    "Radius": {
+      "provider": "Radius",
+      "version": "latest"
+    }
+  },
+  "resources": {
+    "fluxUpdateEnv": {
+      "import": "Radius",
+      "type": "Applications.Core/environments@2023-10-01-preview",
+      "properties": {
+        "name": "flux-update-env",
+        "properties": {
+          "compute": {
+            "kind": "kubernetes",
+            "resourceId": "self",
+            "namespace": "flux-update"
+          }
+        }
+      }
+    },
+    "fluxUpdateApp": {
+      "import": "Radius",
+      "type": "Applications.Core/applications@2023-10-01-preview",
+      "properties": {
+        "name": "flux-update-app",
+        "properties": {
+          "environment": "[reference('fluxUpdateEnv').id]"
+        }
+      },
+      "dependsOn": ["fluxUpdateEnv"]
+    },
+    "fluxUpdateContainer": {
+      "import": "Radius",
+      "type": "Applications.Core/containers@2023-10-01-preview",
+      "properties": {
+        "name": "flux-update-container",
+        "properties": {
+          "application": "[reference('fluxUpdateApp').id]",
+          "container": {
+            "image": "nginx"
+          },
+          "extensions": [
+            {
+              "kind": "manualScaling",
+              "replicas": "[int(parameters('replicas'))]"
+            }
+          ]
+        }
+      },
+      "dependsOn": ["fluxUpdateApp"]
+    }
+  }
+}
diff --git a/pkg/controller/reconciler/testdata/flux-update/step-2/flux-update.parameters.json b/pkg/controller/reconciler/testdata/flux-update/step-2/flux-update.parameters.json
new file mode 100644
index 000000000..2944b3a64
--- /dev/null
+++ b/pkg/controller/reconciler/testdata/flux-update/step-2/flux-update.parameters.json
@@ -0,0 +1,9 @@
+{
+  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
+  "contentVersion": "1.0.0.0",
+  "parameters": {
+    "replicas": {
+      "value": "2"
+    }
+  }
+}
diff --git a/pkg/controller/reconciler/testdata/flux-update/step-2/radius-gitops-config.yaml b/pkg/controller/reconciler/testdata/flux-update/step-2/radius-gitops-config.yaml
new file mode 100644
index 000000000..0abefbb2b
--- /dev/null
+++ b/pkg/controller/reconciler/testdata/flux-update/step-2/radius-gitops-config.yaml
@@ -0,0 +1,3 @@
+config:
+  - name: flux-update.bicep
+    params: flux-update.bicepparam
diff --git a/test/crd/README.md b/test/crd/README.md
new file mode 100644
index 000000000..d11d9bf42
--- /dev/null
+++ b/test/crd/README.md
@@ -0,0 +1,21 @@
+# Test CRD Generator
+
+## Overview
+
+This directory contains a script that is used to download CRDs that we need for unit testing. 
+
+The pattern is based on the following Kubebuilder documentation: https://book-v3.book.kubebuilder.io/reference/using_an_external_type#prepare-for-testing
+
+## Usage
+
+To download the CRDs, run the following command:
+
+```bash
+go run ./test/crd/generator.go
+```
+
+This will download the CRDs to the `test/crd` directory, under the appropriate subdirectory.
+
+## Updating CRDs
+
+To update the CRDs that get generated, update the `crds` variable in the `generator.go` script.
diff --git a/test/crd/flux/source.toolkit.fluxcd.io_gitrepositories.yaml b/test/crd/flux/source.toolkit.fluxcd.io_gitrepositories.yaml
new file mode 100644
index 000000000..9d01fbd54
--- /dev/null
+++ b/test/crd/flux/source.toolkit.fluxcd.io_gitrepositories.yaml
@@ -0,0 +1,1257 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.16.1
+  name: gitrepositories.source.toolkit.fluxcd.io
+spec:
+  group: source.toolkit.fluxcd.io
+  names:
+    kind: GitRepository
+    listKind: GitRepositoryList
+    plural: gitrepositories
+    shortNames:
+    - gitrepo
+    singular: gitrepository
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .spec.url
+      name: URL
+      type: string
+    - jsonPath: .metadata.creationTimestamp
+      name: Age
+      type: date
+    - jsonPath: .status.conditions[?(@.type=="Ready")].status
+      name: Ready
+      type: string
+    - jsonPath: .status.conditions[?(@.type=="Ready")].message
+      name: Status
+      type: string
+    name: v1
+    schema:
+      openAPIV3Schema:
+        description: GitRepository is the Schema for the gitrepositories API.
+        properties:
+          apiVersion:
+            description: |-
+              APIVersion defines the versioned schema of this representation of an object.
+              Servers should convert recognized schemas to the latest internal value, and
+              may reject unrecognized values.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+            type: string
+          kind:
+            description: |-
+              Kind is a string value representing the REST resource this object represents.
+              Servers may infer this from the endpoint the client submits requests to.
+              Cannot be updated.
+              In CamelCase.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: |-
+              GitRepositorySpec specifies the required configuration to produce an
+              Artifact for a Git repository.
+            properties:
+              ignore:
+                description: |-
+                  Ignore overrides the set of excluded patterns in the .sourceignore format
+                  (which is the same as .gitignore). If not provided, a default will be used,
+                  consult the documentation for your version to find out what those are.
+                type: string
+              include:
+                description: |-
+                  Include specifies a list of GitRepository resources which Artifacts
+                  should be included in the Artifact produced for this GitRepository.
+                items:
+                  description: |-
+                    GitRepositoryInclude specifies a local reference to a GitRepository which
+                    Artifact (sub-)contents must be included, and where they should be placed.
+                  properties:
+                    fromPath:
+                      description: |-
+                        FromPath specifies the path to copy contents from, defaults to the root
+                        of the Artifact.
+                      type: string
+                    repository:
+                      description: |-
+                        GitRepositoryRef specifies the GitRepository which Artifact contents
+                        must be included.
+                      properties:
+                        name:
+                          description: Name of the referent.
+                          type: string
+                      required:
+                      - name
+                      type: object
+                    toPath:
+                      description: |-
+                        ToPath specifies the path to copy contents to, defaults to the name of
+                        the GitRepositoryRef.
+                      type: string
+                  required:
+                  - repository
+                  type: object
+                type: array
+              interval:
+                description: |-
+                  Interval at which the GitRepository URL is checked for updates.
+                  This interval is approximate and may be subject to jitter to ensure
+                  efficient use of resources.
+                pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$
+                type: string
+              provider:
+                description: |-
+                  Provider used for authentication, can be 'azure', 'generic'.
+                  When not specified, defaults to 'generic'.
+                enum:
+                - generic
+                - azure
+                type: string
+              proxySecretRef:
+                description: |-
+                  ProxySecretRef specifies the Secret containing the proxy configuration
+                  to use while communicating with the Git server.
+                properties:
+                  name:
+                    description: Name of the referent.
+                    type: string
+                required:
+                - name
+                type: object
+              recurseSubmodules:
+                description: |-
+                  RecurseSubmodules enables the initialization of all submodules within
+                  the GitRepository as cloned from the URL, using their default settings.
+                type: boolean
+              ref:
+                description: |-
+                  Reference specifies the Git reference to resolve and monitor for
+                  changes, defaults to the 'master' branch.
+                properties:
+                  branch:
+                    description: Branch to check out, defaults to 'master' if no other
+                      field is defined.
+                    type: string
+                  commit:
+                    description: |-
+                      Commit SHA to check out, takes precedence over all reference fields.
+
+                      This can be combined with Branch to shallow clone the branch, in which
+                      the commit is expected to exist.
+                    type: string
+                  name:
+                    description: |-
+                      Name of the reference to check out; takes precedence over Branch, Tag and SemVer.
+
+                      It must be a valid Git reference: https://git-scm.com/docs/git-check-ref-format#_description
+                      Examples: "refs/heads/main", "refs/tags/v0.1.0", "refs/pull/420/head", "refs/merge-requests/1/head"
+                    type: string
+                  semver:
+                    description: SemVer tag expression to check out, takes precedence
+                      over Tag.
+                    type: string
+                  tag:
+                    description: Tag to check out, takes precedence over Branch.
+                    type: string
+                type: object
+              secretRef:
+                description: |-
+                  SecretRef specifies the Secret containing authentication credentials for
+                  the GitRepository.
+                  For HTTPS repositories the Secret must contain 'username' and 'password'
+                  fields for basic auth or 'bearerToken' field for token auth.
+                  For SSH repositories the Secret must contain 'identity'
+                  and 'known_hosts' fields.
+                properties:
+                  name:
+                    description: Name of the referent.
+                    type: string
+                required:
+                - name
+                type: object
+              suspend:
+                description: |-
+                  Suspend tells the controller to suspend the reconciliation of this
+                  GitRepository.
+                type: boolean
+              timeout:
+                default: 60s
+                description: Timeout for Git operations like cloning, defaults to
+                  60s.
+                pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m))+$
+                type: string
+              url:
+                description: URL specifies the Git repository URL, it can be an HTTP/S
+                  or SSH address.
+                pattern: ^(http|https|ssh)://.*$
+                type: string
+              verify:
+                description: |-
+                  Verification specifies the configuration to verify the Git commit
+                  signature(s).
+                properties:
+                  mode:
+                    default: HEAD
+                    description: |-
+                      Mode specifies which Git object(s) should be verified.
+
+                      The variants "head" and "HEAD" both imply the same thing, i.e. verify
+                      the commit that the HEAD of the Git repository points to. The variant
+                      "head" solely exists to ensure backwards compatibility.
+                    enum:
+                    - head
+                    - HEAD
+                    - Tag
+                    - TagAndHEAD
+                    type: string
+                  secretRef:
+                    description: |-
+                      SecretRef specifies the Secret containing the public keys of trusted Git
+                      authors.
+                    properties:
+                      name:
+                        description: Name of the referent.
+                        type: string
+                    required:
+                    - name
+                    type: object
+                required:
+                - secretRef
+                type: object
+            required:
+            - interval
+            - url
+            type: object
+          status:
+            default:
+              observedGeneration: -1
+            description: GitRepositoryStatus records the observed state of a Git repository.
+            properties:
+              artifact:
+                description: Artifact represents the last successful GitRepository
+                  reconciliation.
+                properties:
+                  digest:
+                    description: Digest is the digest of the file in the form of '<algorithm>:<checksum>'.
+                    pattern: ^[a-z0-9]+(?:[.+_-][a-z0-9]+)*:[a-zA-Z0-9=_-]+$
+                    type: string
+                  lastUpdateTime:
+                    description: |-
+                      LastUpdateTime is the timestamp corresponding to the last update of the
+                      Artifact.
+                    format: date-time
+                    type: string
+                  metadata:
+                    additionalProperties:
+                      type: string
+                    description: Metadata holds upstream information such as OCI annotations.
+                    type: object
+                  path:
+                    description: |-
+                      Path is the relative file path of the Artifact. It can be used to locate
+                      the file in the root of the Artifact storage on the local file system of
+                      the controller managing the Source.
+                    type: string
+                  revision:
+                    description: |-
+                      Revision is a human-readable identifier traceable in the origin source
+                      system. It can be a Git commit SHA, Git tag, a Helm chart version, etc.
+                    type: string
+                  size:
+                    description: Size is the number of bytes in the file.
+                    format: int64
+                    type: integer
+                  url:
+                    description: |-
+                      URL is the HTTP address of the Artifact as exposed by the controller
+                      managing the Source. It can be used to retrieve the Artifact for
+                      consumption, e.g. by another controller applying the Artifact contents.
+                    type: string
+                required:
+                - lastUpdateTime
+                - path
+                - revision
+                - url
+                type: object
+              conditions:
+                description: Conditions holds the conditions for the GitRepository.
+                items:
+                  description: Condition contains details for one aspect of the current
+                    state of this API Resource.
+                  properties:
+                    lastTransitionTime:
+                      description: |-
+                        lastTransitionTime is the last time the condition transitioned from one status to another.
+                        This should be when the underlying condition changed.  If that is not known, then using the time when the API field changed is acceptable.
+                      format: date-time
+                      type: string
+                    message:
+                      description: |-
+                        message is a human readable message indicating details about the transition.
+                        This may be an empty string.
+                      maxLength: 32768
+                      type: string
+                    observedGeneration:
+                      description: |-
+                        observedGeneration represents the .metadata.generation that the condition was set based upon.
+                        For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+                        with respect to the current state of the instance.
+                      format: int64
+                      minimum: 0
+                      type: integer
+                    reason:
+                      description: |-
+                        reason contains a programmatic identifier indicating the reason for the condition's last transition.
+                        Producers of specific condition types may define expected values and meanings for this field,
+                        and whether the values are considered a guaranteed API.
+                        The value should be a CamelCase string.
+                        This field may not be empty.
+                      maxLength: 1024
+                      minLength: 1
+                      pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+                      type: string
+                    status:
+                      description: status of the condition, one of True, False, Unknown.
+                      enum:
+                      - "True"
+                      - "False"
+                      - Unknown
+                      type: string
+                    type:
+                      description: type of condition in CamelCase or in foo.example.com/CamelCase.
+                      maxLength: 316
+                      pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+                      type: string
+                  required:
+                  - lastTransitionTime
+                  - message
+                  - reason
+                  - status
+                  - type
+                  type: object
+                type: array
+              includedArtifacts:
+                description: |-
+                  IncludedArtifacts contains a list of the last successfully included
+                  Artifacts as instructed by GitRepositorySpec.Include.
+                items:
+                  description: Artifact represents the output of a Source reconciliation.
+                  properties:
+                    digest:
+                      description: Digest is the digest of the file in the form of
+                        '<algorithm>:<checksum>'.
+                      pattern: ^[a-z0-9]+(?:[.+_-][a-z0-9]+)*:[a-zA-Z0-9=_-]+$
+                      type: string
+                    lastUpdateTime:
+                      description: |-
+                        LastUpdateTime is the timestamp corresponding to the last update of the
+                        Artifact.
+                      format: date-time
+                      type: string
+                    metadata:
+                      additionalProperties:
+                        type: string
+                      description: Metadata holds upstream information such as OCI
+                        annotations.
+                      type: object
+                    path:
+                      description: |-
+                        Path is the relative file path of the Artifact. It can be used to locate
+                        the file in the root of the Artifact storage on the local file system of
+                        the controller managing the Source.
+                      type: string
+                    revision:
+                      description: |-
+                        Revision is a human-readable identifier traceable in the origin source
+                        system. It can be a Git commit SHA, Git tag, a Helm chart version, etc.
+                      type: string
+                    size:
+                      description: Size is the number of bytes in the file.
+                      format: int64
+                      type: integer
+                    url:
+                      description: |-
+                        URL is the HTTP address of the Artifact as exposed by the controller
+                        managing the Source. It can be used to retrieve the Artifact for
+                        consumption, e.g. by another controller applying the Artifact contents.
+                      type: string
+                  required:
+                  - lastUpdateTime
+                  - path
+                  - revision
+                  - url
+                  type: object
+                type: array
+              lastHandledReconcileAt:
+                description: |-
+                  LastHandledReconcileAt holds the value of the most recent
+                  reconcile request value, so a change of the annotation value
+                  can be detected.
+                type: string
+              observedGeneration:
+                description: |-
+                  ObservedGeneration is the last observed generation of the GitRepository
+                  object.
+                format: int64
+                type: integer
+              observedIgnore:
+                description: |-
+                  ObservedIgnore is the observed exclusion patterns used for constructing
+                  the source artifact.
+                type: string
+              observedInclude:
+                description: |-
+                  ObservedInclude is the observed list of GitRepository resources used to
+                  produce the current Artifact.
+                items:
+                  description: |-
+                    GitRepositoryInclude specifies a local reference to a GitRepository which
+                    Artifact (sub-)contents must be included, and where they should be placed.
+                  properties:
+                    fromPath:
+                      description: |-
+                        FromPath specifies the path to copy contents from, defaults to the root
+                        of the Artifact.
+                      type: string
+                    repository:
+                      description: |-
+                        GitRepositoryRef specifies the GitRepository which Artifact contents
+                        must be included.
+                      properties:
+                        name:
+                          description: Name of the referent.
+                          type: string
+                      required:
+                      - name
+                      type: object
+                    toPath:
+                      description: |-
+                        ToPath specifies the path to copy contents to, defaults to the name of
+                        the GitRepositoryRef.
+                      type: string
+                  required:
+                  - repository
+                  type: object
+                type: array
+              observedRecurseSubmodules:
+                description: |-
+                  ObservedRecurseSubmodules is the observed resource submodules
+                  configuration used to produce the current Artifact.
+                type: boolean
+              sourceVerificationMode:
+                description: |-
+                  SourceVerificationMode is the last used verification mode indicating
+                  which Git object(s) have been verified.
+                type: string
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+  - additionalPrinterColumns:
+    - jsonPath: .spec.url
+      name: URL
+      type: string
+    - jsonPath: .status.conditions[?(@.type=="Ready")].status
+      name: Ready
+      type: string
+    - jsonPath: .status.conditions[?(@.type=="Ready")].message
+      name: Status
+      type: string
+    - jsonPath: .metadata.creationTimestamp
+      name: Age
+      type: date
+    deprecated: true
+    deprecationWarning: v1beta1 GitRepository is deprecated, upgrade to v1
+    name: v1beta1
+    schema:
+      openAPIV3Schema:
+        description: GitRepository is the Schema for the gitrepositories API
+        properties:
+          apiVersion:
+            description: |-
+              APIVersion defines the versioned schema of this representation of an object.
+              Servers should convert recognized schemas to the latest internal value, and
+              may reject unrecognized values.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+            type: string
+          kind:
+            description: |-
+              Kind is a string value representing the REST resource this object represents.
+              Servers may infer this from the endpoint the client submits requests to.
+              Cannot be updated.
+              In CamelCase.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: GitRepositorySpec defines the desired state of a Git repository.
+            properties:
+              accessFrom:
+                description: AccessFrom defines an Access Control List for allowing
+                  cross-namespace references to this object.
+                properties:
+                  namespaceSelectors:
+                    description: |-
+                      NamespaceSelectors is the list of namespace selectors to which this ACL applies.
+                      Items in this list are evaluated using a logical OR operation.
+                    items:
+                      description: |-
+                        NamespaceSelector selects the namespaces to which this ACL applies.
+                        An empty map of MatchLabels matches all namespaces in a cluster.
+                      properties:
+                        matchLabels:
+                          additionalProperties:
+                            type: string
+                          description: |-
+                            MatchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+                            map is equivalent to an element of matchExpressions, whose key field is "key", the
+                            operator is "In", and the values array contains only "value". The requirements are ANDed.
+                          type: object
+                      type: object
+                    type: array
+                required:
+                - namespaceSelectors
+                type: object
+              gitImplementation:
+                default: go-git
+                description: |-
+                  Determines which git client library to use.
+                  Defaults to go-git, valid values are ('go-git', 'libgit2').
+                enum:
+                - go-git
+                - libgit2
+                type: string
+              ignore:
+                description: |-
+                  Ignore overrides the set of excluded patterns in the .sourceignore format
+                  (which is the same as .gitignore). If not provided, a default will be used,
+                  consult the documentation for your version to find out what those are.
+                type: string
+              include:
+                description: Extra git repositories to map into the repository
+                items:
+                  description: GitRepositoryInclude defines a source with a from and
+                    to path.
+                  properties:
+                    fromPath:
+                      description: The path to copy contents from, defaults to the
+                        root directory.
+                      type: string
+                    repository:
+                      description: Reference to a GitRepository to include.
+                      properties:
+                        name:
+                          description: Name of the referent.
+                          type: string
+                      required:
+                      - name
+                      type: object
+                    toPath:
+                      description: The path to copy contents to, defaults to the name
+                        of the source ref.
+                      type: string
+                  required:
+                  - repository
+                  type: object
+                type: array
+              interval:
+                description: The interval at which to check for repository updates.
+                type: string
+              recurseSubmodules:
+                description: |-
+                  When enabled, after the clone is created, initializes all submodules within,
+                  using their default settings.
+                  This option is available only when using the 'go-git' GitImplementation.
+                type: boolean
+              ref:
+                description: |-
+                  The Git reference to checkout and monitor for changes, defaults to
+                  master branch.
+                properties:
+                  branch:
+                    description: The Git branch to checkout, defaults to master.
+                    type: string
+                  commit:
+                    description: The Git commit SHA to checkout, if specified Tag
+                      filters will be ignored.
+                    type: string
+                  semver:
+                    description: The Git tag semver expression, takes precedence over
+                      Tag.
+                    type: string
+                  tag:
+                    description: The Git tag to checkout, takes precedence over Branch.
+                    type: string
+                type: object
+              secretRef:
+                description: |-
+                  The secret name containing the Git credentials.
+                  For HTTPS repositories the secret must contain username and password
+                  fields.
+                  For SSH repositories the secret must contain identity and known_hosts
+                  fields.
+                properties:
+                  name:
+                    description: Name of the referent.
+                    type: string
+                required:
+                - name
+                type: object
+              suspend:
+                description: This flag tells the controller to suspend the reconciliation
+                  of this source.
+                type: boolean
+              timeout:
+                default: 60s
+                description: The timeout for remote Git operations like cloning, defaults
+                  to 60s.
+                type: string
+              url:
+                description: The repository URL, can be a HTTP/S or SSH address.
+                pattern: ^(http|https|ssh)://.*$
+                type: string
+              verify:
+                description: Verify OpenPGP signature for the Git commit HEAD points
+                  to.
+                properties:
+                  mode:
+                    description: Mode describes what git object should be verified,
+                      currently ('head').
+                    enum:
+                    - head
+                    type: string
+                  secretRef:
+                    description: The secret name containing the public keys of all
+                      trusted Git authors.
+                    properties:
+                      name:
+                        description: Name of the referent.
+                        type: string
+                    required:
+                    - name
+                    type: object
+                required:
+                - mode
+                type: object
+            required:
+            - interval
+            - url
+            type: object
+          status:
+            default:
+              observedGeneration: -1
+            description: GitRepositoryStatus defines the observed state of a Git repository.
+            properties:
+              artifact:
+                description: Artifact represents the output of the last successful
+                  repository sync.
+                properties:
+                  checksum:
+                    description: Checksum is the SHA256 checksum of the artifact.
+                    type: string
+                  lastUpdateTime:
+                    description: |-
+                      LastUpdateTime is the timestamp corresponding to the last update of this
+                      artifact.
+                    format: date-time
+                    type: string
+                  path:
+                    description: Path is the relative file path of this artifact.
+                    type: string
+                  revision:
+                    description: |-
+                      Revision is a human readable identifier traceable in the origin source
+                      system. It can be a Git commit SHA, Git tag, a Helm index timestamp, a Helm
+                      chart version, etc.
+                    type: string
+                  url:
+                    description: URL is the HTTP address of this artifact.
+                    type: string
+                required:
+                - lastUpdateTime
+                - path
+                - url
+                type: object
+              conditions:
+                description: Conditions holds the conditions for the GitRepository.
+                items:
+                  description: Condition contains details for one aspect of the current
+                    state of this API Resource.
+                  properties:
+                    lastTransitionTime:
+                      description: |-
+                        lastTransitionTime is the last time the condition transitioned from one status to another.
+                        This should be when the underlying condition changed.  If that is not known, then using the time when the API field changed is acceptable.
+                      format: date-time
+                      type: string
+                    message:
+                      description: |-
+                        message is a human readable message indicating details about the transition.
+                        This may be an empty string.
+                      maxLength: 32768
+                      type: string
+                    observedGeneration:
+                      description: |-
+                        observedGeneration represents the .metadata.generation that the condition was set based upon.
+                        For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+                        with respect to the current state of the instance.
+                      format: int64
+                      minimum: 0
+                      type: integer
+                    reason:
+                      description: |-
+                        reason contains a programmatic identifier indicating the reason for the condition's last transition.
+                        Producers of specific condition types may define expected values and meanings for this field,
+                        and whether the values are considered a guaranteed API.
+                        The value should be a CamelCase string.
+                        This field may not be empty.
+                      maxLength: 1024
+                      minLength: 1
+                      pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+                      type: string
+                    status:
+                      description: status of the condition, one of True, False, Unknown.
+                      enum:
+                      - "True"
+                      - "False"
+                      - Unknown
+                      type: string
+                    type:
+                      description: type of condition in CamelCase or in foo.example.com/CamelCase.
+                      maxLength: 316
+                      pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+                      type: string
+                  required:
+                  - lastTransitionTime
+                  - message
+                  - reason
+                  - status
+                  - type
+                  type: object
+                type: array
+              includedArtifacts:
+                description: IncludedArtifacts represents the included artifacts from
+                  the last successful repository sync.
+                items:
+                  description: Artifact represents the output of a source synchronisation.
+                  properties:
+                    checksum:
+                      description: Checksum is the SHA256 checksum of the artifact.
+                      type: string
+                    lastUpdateTime:
+                      description: |-
+                        LastUpdateTime is the timestamp corresponding to the last update of this
+                        artifact.
+                      format: date-time
+                      type: string
+                    path:
+                      description: Path is the relative file path of this artifact.
+                      type: string
+                    revision:
+                      description: |-
+                        Revision is a human readable identifier traceable in the origin source
+                        system. It can be a Git commit SHA, Git tag, a Helm index timestamp, a Helm
+                        chart version, etc.
+                      type: string
+                    url:
+                      description: URL is the HTTP address of this artifact.
+                      type: string
+                  required:
+                  - lastUpdateTime
+                  - path
+                  - url
+                  type: object
+                type: array
+              lastHandledReconcileAt:
+                description: |-
+                  LastHandledReconcileAt holds the value of the most recent
+                  reconcile request value, so a change of the annotation value
+                  can be detected.
+                type: string
+              observedGeneration:
+                description: ObservedGeneration is the last observed generation.
+                format: int64
+                type: integer
+              url:
+                description: |-
+                  URL is the download link for the artifact output of the last repository
+                  sync.
+                type: string
+            type: object
+        type: object
+    served: true
+    storage: false
+    subresources:
+      status: {}
+  - additionalPrinterColumns:
+    - jsonPath: .spec.url
+      name: URL
+      type: string
+    - jsonPath: .metadata.creationTimestamp
+      name: Age
+      type: date
+    - jsonPath: .status.conditions[?(@.type=="Ready")].status
+      name: Ready
+      type: string
+    - jsonPath: .status.conditions[?(@.type=="Ready")].message
+      name: Status
+      type: string
+    deprecated: true
+    deprecationWarning: v1beta2 GitRepository is deprecated, upgrade to v1
+    name: v1beta2
+    schema:
+      openAPIV3Schema:
+        description: GitRepository is the Schema for the gitrepositories API.
+        properties:
+          apiVersion:
+            description: |-
+              APIVersion defines the versioned schema of this representation of an object.
+              Servers should convert recognized schemas to the latest internal value, and
+              may reject unrecognized values.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+            type: string
+          kind:
+            description: |-
+              Kind is a string value representing the REST resource this object represents.
+              Servers may infer this from the endpoint the client submits requests to.
+              Cannot be updated.
+              In CamelCase.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: |-
+              GitRepositorySpec specifies the required configuration to produce an
+              Artifact for a Git repository.
+            properties:
+              accessFrom:
+                description: |-
+                  AccessFrom specifies an Access Control List for allowing cross-namespace
+                  references to this object.
+                  NOTE: Not implemented, provisional as of https://github.com/fluxcd/flux2/pull/2092
+                properties:
+                  namespaceSelectors:
+                    description: |-
+                      NamespaceSelectors is the list of namespace selectors to which this ACL applies.
+                      Items in this list are evaluated using a logical OR operation.
+                    items:
+                      description: |-
+                        NamespaceSelector selects the namespaces to which this ACL applies.
+                        An empty map of MatchLabels matches all namespaces in a cluster.
+                      properties:
+                        matchLabels:
+                          additionalProperties:
+                            type: string
+                          description: |-
+                            MatchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+                            map is equivalent to an element of matchExpressions, whose key field is "key", the
+                            operator is "In", and the values array contains only "value". The requirements are ANDed.
+                          type: object
+                      type: object
+                    type: array
+                required:
+                - namespaceSelectors
+                type: object
+              gitImplementation:
+                default: go-git
+                description: |-
+                  GitImplementation specifies which Git client library implementation to
+                  use. Defaults to 'go-git', valid values are ('go-git', 'libgit2').
+                  Deprecated: gitImplementation is deprecated now that 'go-git' is the
+                  only supported implementation.
+                enum:
+                - go-git
+                - libgit2
+                type: string
+              ignore:
+                description: |-
+                  Ignore overrides the set of excluded patterns in the .sourceignore format
+                  (which is the same as .gitignore). If not provided, a default will be used,
+                  consult the documentation for your version to find out what those are.
+                type: string
+              include:
+                description: |-
+                  Include specifies a list of GitRepository resources which Artifacts
+                  should be included in the Artifact produced for this GitRepository.
+                items:
+                  description: |-
+                    GitRepositoryInclude specifies a local reference to a GitRepository which
+                    Artifact (sub-)contents must be included, and where they should be placed.
+                  properties:
+                    fromPath:
+                      description: |-
+                        FromPath specifies the path to copy contents from, defaults to the root
+                        of the Artifact.
+                      type: string
+                    repository:
+                      description: |-
+                        GitRepositoryRef specifies the GitRepository which Artifact contents
+                        must be included.
+                      properties:
+                        name:
+                          description: Name of the referent.
+                          type: string
+                      required:
+                      - name
+                      type: object
+                    toPath:
+                      description: |-
+                        ToPath specifies the path to copy contents to, defaults to the name of
+                        the GitRepositoryRef.
+                      type: string
+                  required:
+                  - repository
+                  type: object
+                type: array
+              interval:
+                description: Interval at which to check the GitRepository for updates.
+                pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$
+                type: string
+              recurseSubmodules:
+                description: |-
+                  RecurseSubmodules enables the initialization of all submodules within
+                  the GitRepository as cloned from the URL, using their default settings.
+                type: boolean
+              ref:
+                description: |-
+                  Reference specifies the Git reference to resolve and monitor for
+                  changes, defaults to the 'master' branch.
+                properties:
+                  branch:
+                    description: Branch to check out, defaults to 'master' if no other
+                      field is defined.
+                    type: string
+                  commit:
+                    description: |-
+                      Commit SHA to check out, takes precedence over all reference fields.
+
+                      This can be combined with Branch to shallow clone the branch, in which
+                      the commit is expected to exist.
+                    type: string
+                  name:
+                    description: |-
+                      Name of the reference to check out; takes precedence over Branch, Tag and SemVer.
+
+                      It must be a valid Git reference: https://git-scm.com/docs/git-check-ref-format#_description
+                      Examples: "refs/heads/main", "refs/tags/v0.1.0", "refs/pull/420/head", "refs/merge-requests/1/head"
+                    type: string
+                  semver:
+                    description: SemVer tag expression to check out, takes precedence
+                      over Tag.
+                    type: string
+                  tag:
+                    description: Tag to check out, takes precedence over Branch.
+                    type: string
+                type: object
+              secretRef:
+                description: |-
+                  SecretRef specifies the Secret containing authentication credentials for
+                  the GitRepository.
+                  For HTTPS repositories the Secret must contain 'username' and 'password'
+                  fields for basic auth or 'bearerToken' field for token auth.
+                  For SSH repositories the Secret must contain 'identity'
+                  and 'known_hosts' fields.
+                properties:
+                  name:
+                    description: Name of the referent.
+                    type: string
+                required:
+                - name
+                type: object
+              suspend:
+                description: |-
+                  Suspend tells the controller to suspend the reconciliation of this
+                  GitRepository.
+                type: boolean
+              timeout:
+                default: 60s
+                description: Timeout for Git operations like cloning, defaults to
+                  60s.
+                pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m))+$
+                type: string
+              url:
+                description: URL specifies the Git repository URL, it can be an HTTP/S
+                  or SSH address.
+                pattern: ^(http|https|ssh)://.*$
+                type: string
+              verify:
+                description: |-
+                  Verification specifies the configuration to verify the Git commit
+                  signature(s).
+                properties:
+                  mode:
+                    description: Mode specifies what Git object should be verified,
+                      currently ('head').
+                    enum:
+                    - head
+                    type: string
+                  secretRef:
+                    description: |-
+                      SecretRef specifies the Secret containing the public keys of trusted Git
+                      authors.
+                    properties:
+                      name:
+                        description: Name of the referent.
+                        type: string
+                    required:
+                    - name
+                    type: object
+                required:
+                - mode
+                - secretRef
+                type: object
+            required:
+            - interval
+            - url
+            type: object
+          status:
+            default:
+              observedGeneration: -1
+            description: GitRepositoryStatus records the observed state of a Git repository.
+            properties:
+              artifact:
+                description: Artifact represents the last successful GitRepository
+                  reconciliation.
+                properties:
+                  digest:
+                    description: Digest is the digest of the file in the form of '<algorithm>:<checksum>'.
+                    pattern: ^[a-z0-9]+(?:[.+_-][a-z0-9]+)*:[a-zA-Z0-9=_-]+$
+                    type: string
+                  lastUpdateTime:
+                    description: |-
+                      LastUpdateTime is the timestamp corresponding to the last update of the
+                      Artifact.
+                    format: date-time
+                    type: string
+                  metadata:
+                    additionalProperties:
+                      type: string
+                    description: Metadata holds upstream information such as OCI annotations.
+                    type: object
+                  path:
+                    description: |-
+                      Path is the relative file path of the Artifact. It can be used to locate
+                      the file in the root of the Artifact storage on the local file system of
+                      the controller managing the Source.
+                    type: string
+                  revision:
+                    description: |-
+                      Revision is a human-readable identifier traceable in the origin source
+                      system. It can be a Git commit SHA, Git tag, a Helm chart version, etc.
+                    type: string
+                  size:
+                    description: Size is the number of bytes in the file.
+                    format: int64
+                    type: integer
+                  url:
+                    description: |-
+                      URL is the HTTP address of the Artifact as exposed by the controller
+                      managing the Source. It can be used to retrieve the Artifact for
+                      consumption, e.g. by another controller applying the Artifact contents.
+                    type: string
+                required:
+                - lastUpdateTime
+                - path
+                - revision
+                - url
+                type: object
+              conditions:
+                description: Conditions holds the conditions for the GitRepository.
+                items:
+                  description: Condition contains details for one aspect of the current
+                    state of this API Resource.
+                  properties:
+                    lastTransitionTime:
+                      description: |-
+                        lastTransitionTime is the last time the condition transitioned from one status to another.
+                        This should be when the underlying condition changed.  If that is not known, then using the time when the API field changed is acceptable.
+                      format: date-time
+                      type: string
+                    message:
+                      description: |-
+                        message is a human readable message indicating details about the transition.
+                        This may be an empty string.
+                      maxLength: 32768
+                      type: string
+                    observedGeneration:
+                      description: |-
+                        observedGeneration represents the .metadata.generation that the condition was set based upon.
+                        For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+                        with respect to the current state of the instance.
+                      format: int64
+                      minimum: 0
+                      type: integer
+                    reason:
+                      description: |-
+                        reason contains a programmatic identifier indicating the reason for the condition's last transition.
+                        Producers of specific condition types may define expected values and meanings for this field,
+                        and whether the values are considered a guaranteed API.
+                        The value should be a CamelCase string.
+                        This field may not be empty.
+                      maxLength: 1024
+                      minLength: 1
+                      pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+                      type: string
+                    status:
+                      description: status of the condition, one of True, False, Unknown.
+                      enum:
+                      - "True"
+                      - "False"
+                      - Unknown
+                      type: string
+                    type:
+                      description: type of condition in CamelCase or in foo.example.com/CamelCase.
+                      maxLength: 316
+                      pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+                      type: string
+                  required:
+                  - lastTransitionTime
+                  - message
+                  - reason
+                  - status
+                  - type
+                  type: object
+                type: array
+              contentConfigChecksum:
+                description: |-
+                  ContentConfigChecksum is a checksum of all the configurations related to
+                  the content of the source artifact:
+                   - .spec.ignore
+                   - .spec.recurseSubmodules
+                   - .spec.included and the checksum of the included artifacts
+                  observed in .status.observedGeneration version of the object. This can
+                  be used to determine if the content of the included repository has
+                  changed.
+                  It has the format of `<algo>:<checksum>`, for example: `sha256:<checksum>`.
+
+                  Deprecated: Replaced with explicit fields for observed artifact content
+                  config in the status.
+                type: string
+              includedArtifacts:
+                description: |-
+                  IncludedArtifacts contains a list of the last successfully included
+                  Artifacts as instructed by GitRepositorySpec.Include.
+                items:
+                  description: Artifact represents the output of a Source reconciliation.
+                  properties:
+                    digest:
+                      description: Digest is the digest of the file in the form of
+                        '<algorithm>:<checksum>'.
+                      pattern: ^[a-z0-9]+(?:[.+_-][a-z0-9]+)*:[a-zA-Z0-9=_-]+$
+                      type: string
+                    lastUpdateTime:
+                      description: |-
+                        LastUpdateTime is the timestamp corresponding to the last update of the
+                        Artifact.
+                      format: date-time
+                      type: string
+                    metadata:
+                      additionalProperties:
+                        type: string
+                      description: Metadata holds upstream information such as OCI
+                        annotations.
+                      type: object
+                    path:
+                      description: |-
+                        Path is the relative file path of the Artifact. It can be used to locate
+                        the file in the root of the Artifact storage on the local file system of
+                        the controller managing the Source.
+                      type: string
+                    revision:
+                      description: |-
+                        Revision is a human-readable identifier traceable in the origin source
+                        system. It can be a Git commit SHA, Git tag, a Helm chart version, etc.
+                      type: string
+                    size:
+                      description: Size is the number of bytes in the file.
+                      format: int64
+                      type: integer
+                    url:
+                      description: |-
+                        URL is the HTTP address of the Artifact as exposed by the controller
+                        managing the Source. It can be used to retrieve the Artifact for
+                        consumption, e.g. by another controller applying the Artifact contents.
+                      type: string
+                  required:
+                  - lastUpdateTime
+                  - path
+                  - revision
+                  - url
+                  type: object
+                type: array
+              lastHandledReconcileAt:
+                description: |-
+                  LastHandledReconcileAt holds the value of the most recent
+                  reconcile request value, so a change of the annotation value
+                  can be detected.
+                type: string
+              observedGeneration:
+                description: |-
+                  ObservedGeneration is the last observed generation of the GitRepository
+                  object.
+                format: int64
+                type: integer
+              observedIgnore:
+                description: |-
+                  ObservedIgnore is the observed exclusion patterns used for constructing
+                  the source artifact.
+                type: string
+              observedInclude:
+                description: |-
+                  ObservedInclude is the observed list of GitRepository resources used to
+                  to produce the current Artifact.
+                items:
+                  description: |-
+                    GitRepositoryInclude specifies a local reference to a GitRepository which
+                    Artifact (sub-)contents must be included, and where they should be placed.
+                  properties:
+                    fromPath:
+                      description: |-
+                        FromPath specifies the path to copy contents from, defaults to the root
+                        of the Artifact.
+                      type: string
+                    repository:
+                      description: |-
+                        GitRepositoryRef specifies the GitRepository which Artifact contents
+                        must be included.
+                      properties:
+                        name:
+                          description: Name of the referent.
+                          type: string
+                      required:
+                      - name
+                      type: object
+                    toPath:
+                      description: |-
+                        ToPath specifies the path to copy contents to, defaults to the name of
+                        the GitRepositoryRef.
+                      type: string
+                  required:
+                  - repository
+                  type: object
+                type: array
+              observedRecurseSubmodules:
+                description: |-
+                  ObservedRecurseSubmodules is the observed resource submodules
+                  configuration used to produce the current Artifact.
+                type: boolean
+              url:
+                description: |-
+                  URL is the dynamic fetch link for the latest Artifact.
+                  It is provided on a "best effort" basis, and using the precise
+                  GitRepositoryStatus.Artifact data is recommended.
+                type: string
+            type: object
+        type: object
+    served: true
+    storage: false
+    subresources:
+      status: {}
diff --git a/test/crd/generator.go b/test/crd/generator.go
new file mode 100644
index 000000000..fc3137251
--- /dev/null
+++ b/test/crd/generator.go
@@ -0,0 +1,81 @@
+package main
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"path/filepath"
+)
+
+const (
+	fluxSourceBranch = "release/v1.4.x"
+)
+
+// crds specifies the CRDs to download and their URLs.
+// If we need to download more CRDs for testing, we can add them here.
+//
+// Here is the structure of the map:
+//
+//	crds = {
+//		"nameofcrdfile.yaml": {
+//			"pathofcrdfile": "urltofetchcrd"
+//		}
+//	}
+var crds = map[string]map[string]string{
+	"./flux": {
+		"source.toolkit.fluxcd.io_gitrepositories.yaml": fmt.Sprintf("https://raw.githubusercontent.com/fluxcd/source-controller/%s/config/crd/bases/source.toolkit.fluxcd.io_gitrepositories.yaml", fluxSourceBranch),
+	},
+}
+
+// PullCRDs downloads the CRDs from the upstream repository.
+func PullCRDs(pwd string) {
+	for directory, content := range crds {
+		if _, err := os.Stat(filepath.Join(pwd, directory)); os.IsNotExist(err) {
+			fmt.Printf("Directory %s does not exist. Please create it first.\n", directory)
+			os.Exit(1) //nolint:forbidigo // this is OK inside of this tool, this is meant to be run from the command line
+		}
+
+		for fileName, url := range content {
+			destFilePath := filepath.Join(pwd, directory, fileName)
+			if err := downloadFile(url, destFilePath); err != nil {
+				fmt.Printf("Error downloading %s: %v\n", fileName, err)
+				os.Exit(1) //nolint:forbidigo // this is OK inside of this tool, this is meant to be run from the command line
+			}
+			fmt.Printf("Downloaded %s -> %s\n", url, destFilePath)
+		}
+
+		fmt.Println("Flux Source CRDs downloaded successfully.")
+	}
+}
+
+// downloadFile fetches the remote URL and writes its contents to the specified filepath.
+func downloadFile(url, filePath string) error {
+	resp, err := http.Get(url)
+	if err != nil {
+		return fmt.Errorf("http GET failed: %w", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		return fmt.Errorf("unexpected status code %d from %s", resp.StatusCode, url)
+	}
+
+	outFile, err := os.Create(filePath)
+	if err != nil {
+		return fmt.Errorf("failed to create file %q: %w", filePath, err)
+	}
+	defer outFile.Close()
+
+	if _, err := io.Copy(outFile, resp.Body); err != nil {
+		return fmt.Errorf("failed to write to file %q: %w", filePath, err)
+	}
+
+	return nil
+}
+
+func main() {
+	// PullCRDs is meant to be run from
+	// the root of the project.
+	PullCRDs("test/crd")
+}
diff --git a/test/functional-portable/kubernetes/noncloud/deploymenttemplate_test.go b/test/functional-portable/kubernetes/noncloud/deploymenttemplate_test.go
index 9e90d9299..c3bba4f5a 100644
--- a/test/functional-portable/kubernetes/noncloud/deploymenttemplate_test.go
+++ b/test/functional-portable/kubernetes/noncloud/deploymenttemplate_test.go
@@ -26,6 +26,7 @@ import (
 	"time"
 
 	aztoken "github.com/radius-project/radius/pkg/azure/tokencredentials"
+	"github.com/radius-project/radius/pkg/cli/clients"
 	"github.com/radius-project/radius/pkg/cli/clients_new/generated"
 	radappiov1alpha3 "github.com/radius-project/radius/pkg/controller/api/radapp.io/v1alpha3"
 	"github.com/radius-project/radius/pkg/controller/reconciler"
@@ -99,7 +100,8 @@ func Test_DeploymentTemplate_Env(t *testing.T) {
 			{"Applications.Core/environments", fmt.Sprintf("%s-env", name)},
 		}
 
-		assertExpectedResourcesExist(t, ctx, scope, expectedResources, opts.Connection)
+		err = assertExpectedResourcesExist(ctx, scope, expectedResources, opts.Connection)
+		require.NoError(t, err)
 	})
 
 	t.Run("Delete DeploymentTemplate", func(t *testing.T) {
@@ -166,7 +168,8 @@ func Test_DeploymentTemplate_Module(t *testing.T) {
 			{"Applications.Core/applications", fmt.Sprintf("%s-app", name)},
 		}
 
-		assertExpectedResourcesExist(t, ctx, scope, expectedResources, opts.Connection)
+		err = assertExpectedResourcesExist(ctx, scope, expectedResources, opts.Connection)
+		require.NoError(t, err)
 	})
 
 	t.Run("Delete DeploymentTemplate", func(t *testing.T) {
@@ -236,7 +239,8 @@ func Test_DeploymentTemplate_Recipe(t *testing.T) {
 			{"Applications.Datastores/redisCaches", fmt.Sprintf("%s-recipe", name)},
 		}
 
-		assertExpectedResourcesExist(t, ctx, scope, expectedResources, opts.Connection)
+		err = assertExpectedResourcesExist(ctx, scope, expectedResources, opts.Connection)
+		require.NoError(t, err)
 	})
 
 	t.Run("Delete DeploymentTemplate", func(t *testing.T) {
@@ -325,15 +329,46 @@ func createParametersMap(parameters []string) map[string]string {
 
 // assertExpectedResourcesExist asserts that the expected resources exist
 // in Radius for the given scope.
-func assertExpectedResourcesExist(t *testing.T, ctx context.Context, scope string, expectedResources [][]string, connection sdk.Connection) {
+func assertExpectedResourcesExist(ctx context.Context, scope string, expectedResources [][]string, connection sdk.Connection) error {
 	for _, resource := range expectedResources {
 		resourceType := resource[0]
 		resourceName := resource[1]
 
 		client, err := generated.NewGenericResourcesClient(scope, resourceType, &aztoken.AnonymousCredential{}, sdk.NewClientOptions(connection))
-		require.NoError(t, err)
+		if err != nil {
+			return err
+		}
 
 		_, err = client.Get(ctx, resourceName, nil)
-		require.NoError(t, err)
+		if err != nil {
+			return err
+		}
 	}
+
+	return nil
+}
+
+// assertExpectedResourcesToNotExist asserts that the expected resources do not exist
+// in Radius for the given scope. This is useful for testing cleanup after deletion.
+func assertExpectedResourcesToNotExist(ctx context.Context, scope string, expectedResources [][]string, connection sdk.Connection) error {
+	for _, resource := range expectedResources {
+		resourceType := resource[0]
+		resourceName := resource[1]
+
+		client, err := generated.NewGenericResourcesClient(scope, resourceType, &aztoken.AnonymousCredential{}, sdk.NewClientOptions(connection))
+		if err != nil {
+			return err
+		}
+
+		_, err = client.Get(ctx, resourceName, nil)
+		if err == nil {
+			return fmt.Errorf("expected resource %s/%s to be not found, but was found", resourceType, resourceName)
+		}
+
+		if !clients.Is404Error(err) {
+			return fmt.Errorf("Expected resource %s/%s to be not found, but instead got error: %v", resourceType, resourceName, err)
+		}
+	}
+
+	return nil
 }
diff --git a/test/functional-portable/kubernetes/noncloud/flux_test.go b/test/functional-portable/kubernetes/noncloud/flux_test.go
new file mode 100644
index 000000000..bf2ea8855
--- /dev/null
+++ b/test/functional-portable/kubernetes/noncloud/flux_test.go
@@ -0,0 +1,406 @@
+/*
+Copyright 2023 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 kubernetes_test
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"path"
+	"strconv"
+	"strings"
+	"testing"
+	"time"
+
+	radappiov1alpha3 "github.com/radius-project/radius/pkg/controller/api/radapp.io/v1alpha3"
+	"github.com/radius-project/radius/pkg/controller/reconciler"
+	"github.com/radius-project/radius/test/rp"
+	"github.com/radius-project/radius/test/testcontext"
+	corev1 "k8s.io/api/core/v1"
+	controller_runtime "sigs.k8s.io/controller-runtime/pkg/client"
+
+	gitea "code.gitea.io/sdk/gitea"
+	"github.com/fluxcd/pkg/apis/meta"
+	sourcev1 "github.com/fluxcd/source-controller/api/v1"
+	git "github.com/go-git/go-git/v5"
+	"github.com/go-git/go-git/v5/config"
+	gitobject "github.com/go-git/go-git/v5/plumbing/object"
+	githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
+	"github.com/stretchr/testify/require"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/fields"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/watch"
+	"k8s.io/client-go/tools/cache"
+	watchtools "k8s.io/client-go/tools/watch"
+)
+
+const (
+	fluxSystemNamespace                 = "flux-system"
+	testGitServerURLEnvVariableName     = "GITEA_SERVER_URL"
+	testGitUsernameEnvVariableName      = "GITEA_USERNAME"
+	testGitEmailEnvVariableName         = "GITEA_EMAIL"
+	testGiteaAccessTokenEnvVariableName = "GITEA_ACCESS_TOKEN"
+)
+
+func Test_Flux_Basic(t *testing.T) {
+	testName := "flux-basic"
+	steps := []GitOpsTestStep{
+		{
+			path:          "testdata/gitops/basic/step1",
+			resourceGroup: "flux-basic",
+			expectedResources: [][]string{
+				{"Applications.Core/environments", "flux-basic-env"},
+			},
+		},
+		{
+			path:          "testdata/gitops/basic/step2",
+			resourceGroup: "flux-basic",
+			expectedResourcesToNotExist: [][]string{
+				{"Applications.Core/environments", "flux-basic-env"},
+			},
+		},
+	}
+
+	testFluxIntegration(t, testName, steps)
+}
+
+func Test_Flux_Complex(t *testing.T) {
+	testName := "flux-complex"
+	steps := []GitOpsTestStep{
+		{
+			path:          "testdata/gitops/complex/step1",
+			resourceGroup: "flux-complex",
+			expectedResources: [][]string{
+				{"Applications.Core/environments", "flux-complex-env"},
+				{"Applications.Core/applications", "flux-complex-app"},
+				{"Applications.Core/containers", "flux-complex-container"},
+			},
+		},
+		{
+			path:          "testdata/gitops/complex/step2",
+			resourceGroup: "flux-complex",
+			expectedResources: [][]string{
+				{"Applications.Core/environments", "flux-complex-env"},
+				{"Applications.Core/applications", "flux-complex-app"},
+				{"Applications.Core/containers", "flux-complex-container-2"},
+			},
+		},
+		{
+			path:          "testdata/gitops/complex/step3",
+			resourceGroup: "flux-complex",
+			expectedResourcesToNotExist: [][]string{
+				{"Applications.Core/environments", "flux-complex-env"},
+				{"Applications.Core/applications", "flux-complex-app"},
+				{"Applications.Core/containers", "flux-complex-container"},
+				{"Applications.Core/containers", "flux-complex-container-2"},
+			},
+		},
+	}
+
+	testFluxIntegration(t, testName, steps)
+}
+
+// testFluxIntegration is a helper function that runs a test for the integration of Radius and Flux.
+func testFluxIntegration(t *testing.T, testName string, steps []GitOpsTestStep) {
+	ctx := testcontext.New(t)
+	opts := rp.NewRPTestOptions(t)
+
+	gitRepoName := fmt.Sprintf("%s-repo", testName)
+	gitServerURL := os.Getenv(testGitServerURLEnvVariableName)
+	gitUsername := os.Getenv(testGitUsernameEnvVariableName)
+	gitEmail := os.Getenv(testGitEmailEnvVariableName)
+	giteaToken := os.Getenv(testGiteaAccessTokenEnvVariableName)
+
+	// Create the Gitea client.
+	client, err := gitea.NewClient(gitServerURL, gitea.SetToken(giteaToken))
+	require.NoError(t, err)
+
+	// Create a new Git repository, and delete it after the test.
+	_, _, err = client.CreateRepo(gitea.CreateRepoOption{
+		Name: gitRepoName,
+	})
+	defer func() {
+		_, err := client.DeleteRepo(gitUsername, gitRepoName)
+		require.NoError(t, err)
+	}()
+	require.NoError(t, err)
+
+	// Get the repository to ensure it exists.
+	_, _, err = client.GetRepo(gitUsername, gitRepoName)
+	require.NoError(t, err)
+
+	// Create a temp directory for the Git repository.
+	dir, err := os.MkdirTemp("", gitRepoName)
+	require.NoError(t, err)
+	defer os.RemoveAll(dir)
+
+	// Initialize a new Git repository in the temp directory.
+	r, err := git.PlainInit(dir, false)
+	require.NoError(t, err)
+
+	// Get the worktree of the repository.
+	w, err := r.Worktree()
+	require.NoError(t, err)
+
+	// Create the remote for the repository.
+	repoURL := fmt.Sprintf("%s/%s/%s.git", gitServerURL, gitUsername, gitRepoName)
+	_, err = r.CreateRemote(&config.RemoteConfig{
+		Name: "origin",
+		URLs: []string{repoURL},
+	})
+	require.NoError(t, err)
+
+	// Create an initial commit to the repository.
+	commit, err := w.Commit("Initial commit", &git.CommitOptions{
+		AllowEmptyCommits: true,
+		Author: &gitobject.Signature{
+			Name:  gitUsername,
+			Email: gitEmail,
+			When:  time.Now(),
+		},
+	})
+	require.NoError(t, err)
+	t.Log(t, "Commit created:", commit)
+
+	// Push the initial commit back to the repository.
+	err = r.Push(&git.PushOptions{
+		RemoteName: "origin",
+		Auth: &githttp.BasicAuth{
+			Username: gitUsername,
+			Password: giteaToken,
+		},
+	})
+	require.NoError(t, err)
+	t.Log(t, "Pushed changes successfully")
+
+	// Create the secret for the Flux GitRepository.
+	secret := &corev1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      gitRepoName,
+			Namespace: fluxSystemNamespace,
+		},
+		Data: map[string][]byte{
+			"bearerToken": []byte(giteaToken),
+		},
+	}
+	err = opts.Client.Create(ctx, secret)
+	defer func() {
+		err := opts.Client.Delete(ctx, secret)
+		require.NoError(t, err)
+	}()
+	require.NoError(t, err)
+
+	// Create the Flux GitRepository object.
+	fluxGitRepository := &sourcev1.GitRepository{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      gitRepoName,
+			Namespace: fluxSystemNamespace,
+		},
+		Spec: sourcev1.GitRepositorySpec{
+			URL: fmt.Sprintf("http://gitea-http.gitea.svc.cluster.local:3000/%s/%s.git", gitUsername, gitRepoName),
+			SecretRef: &meta.LocalObjectReference{
+				Name: gitRepoName,
+			},
+		},
+	}
+	err = opts.Client.Create(ctx, fluxGitRepository)
+	defer func() {
+		err := opts.Client.Delete(ctx, fluxGitRepository)
+		require.NoError(t, err)
+	}()
+	require.NoError(t, err)
+
+	// Wait for the GitRepository to be ready.
+	_, err = waitForGitRepositoryReady(t, ctx, types.NamespacedName{Name: gitRepoName, Namespace: fluxSystemNamespace}, opts.Client, fluxGitRepository.ResourceVersion)
+	require.NoError(t, err)
+
+	for stepIndex, step := range steps {
+		stepNumber := stepIndex + 1
+
+		// Remove all files from the repository.
+		// Add all files from step.path to the repository.
+		err = addFilesToRepository(w, step.path, dir)
+		require.NoError(t, err)
+
+		// Commit the change.
+		commit, err := w.Commit(fmt.Sprintf("Adding files to %s from step %d", gitRepoName, stepNumber), &git.CommitOptions{
+			Author: &gitobject.Signature{
+				Name:  gitUsername,
+				Email: gitEmail,
+				When:  time.Now(),
+			},
+		})
+		require.NoError(t, err)
+		t.Log(t, "Commit created:", commit)
+
+		// Push the commit back to the repository.
+		err = r.Push(&git.PushOptions{
+			RemoteName: "origin",
+			Auth: &githttp.BasicAuth{
+				Username: gitUsername,
+				Password: giteaToken,
+			},
+		})
+		require.NoError(t, err)
+		t.Log(t, "Pushed changes successfully")
+
+		// Reconcile the GitRepository by updating the reconcile.fluxcd.io/requestedAt annotation.
+		err = opts.Client.Get(ctx, types.NamespacedName{Name: gitRepoName, Namespace: fluxSystemNamespace}, fluxGitRepository)
+		require.NoError(t, err)
+		annotations := fluxGitRepository.GetAnnotations()
+		if annotations == nil {
+			annotations = make(map[string]string)
+		}
+		annotations["reconcile.fluxcd.io/requestedAt"] = strconv.FormatInt(time.Now().Unix(), 10)
+		fluxGitRepository.SetAnnotations(annotations)
+		err = opts.Client.Update(ctx, fluxGitRepository)
+		require.NoError(t, err)
+
+		radiusConfig, err := reconciler.ParseRadiusGitOpsConfig(path.Join(step.path, "radius-gitops-config.yaml"))
+		require.NoError(t, err)
+
+		for _, configEntry := range radiusConfig.Config {
+			name, namespace, _, _ := getValuesFromRadiusGitOpsConfig(configEntry)
+
+			deploymentTemplate, err := waitForDeploymentTemplateToBeReadyWithGeneration(t, ctx, types.NamespacedName{Name: name, Namespace: namespace}, stepNumber, opts.Client)
+			defer func() {
+				err := opts.Client.Delete(ctx, deploymentTemplate)
+				if controller_runtime.IgnoreNotFound(err) != nil {
+					t.Logf("Error deleting deployment template: %v", err)
+				}
+			}()
+			require.NoError(t, err)
+		}
+
+		scope := fmt.Sprintf("/planes/radius/local/resourceGroups/%s", step.resourceGroup)
+
+		retryInterval := 1 * time.Second
+		retryTimeout := 30 * time.Second
+		start := time.Now()
+
+		for time.Since(start) < retryTimeout {
+			err = assertExpectedResourcesExist(ctx, scope, step.expectedResources, opts.Connection)
+			if err == nil {
+				break
+			}
+
+			err = assertExpectedResourcesToNotExist(ctx, scope, step.expectedResourcesToNotExist, opts.Connection)
+			if err == nil {
+				break
+			}
+
+			time.Sleep(retryInterval)
+		}
+
+		if err != nil {
+			t.Fatalf("Error asserting expected resources exist: %v", err)
+		}
+		t.Logf("Successfully asserted expected resources exist in %s", scope)
+	}
+}
+
+func waitForDeploymentTemplateToBeReadyWithGeneration(t *testing.T, ctx context.Context, name types.NamespacedName, generation int, client controller_runtime.WithWatch) (*radappiov1alpha3.DeploymentTemplate, error) {
+	var timeout time.Duration = 60 * time.Second
+	var interval time.Duration = 1 * time.Second
+
+	for start := time.Now(); time.Since(start) < timeout; {
+		deploymentTemplate := &radappiov1alpha3.DeploymentTemplate{}
+		err := client.Get(ctx, name, deploymentTemplate)
+		if err == nil {
+			if deploymentTemplate.Status.Phrase == radappiov1alpha3.DeploymentTemplatePhraseReady {
+				if deploymentTemplate.Status.ObservedGeneration == int64(generation) {
+					t.Logf("DeploymentTemplate %s is ready with generation: %d", name.Name, deploymentTemplate.Status.ObservedGeneration)
+					return deploymentTemplate, nil
+				} else {
+					t.Logf("DeploymentTemplate %s generation: %d, looking for %d", name.Name, deploymentTemplate.Status.ObservedGeneration, generation)
+				}
+			} else {
+				t.Logf("DeploymentTemplate %s phrase: %s, looking for %s", name.Name, deploymentTemplate.Status.Phrase, radappiov1alpha3.DeploymentTemplatePhraseReady)
+			}
+
+			return deploymentTemplate, nil
+		}
+
+		time.Sleep(interval)
+	}
+
+	return nil, fmt.Errorf("deploymentTemplate %s not found after %f seconds", name.Name, timeout.Seconds())
+}
+
+// waitForGitRepositoryReady watches the creation of the GitRepository object
+// and waits for it to be in the "Ready" state.
+func waitForGitRepositoryReady(t *testing.T, ctx context.Context, name types.NamespacedName, client controller_runtime.WithWatch, initialVersion string) (*sourcev1.GitRepository, error) {
+	// Based on https://gist.github.com/PrasadG193/52faed6499d2ec739f9630b9d044ffdc
+	lister := &cache.ListWatch{
+		ListFunc: func(options metav1.ListOptions) (runtime.Object, error) {
+			listOptions := &controller_runtime.ListOptions{Raw: &options, Namespace: name.Namespace, FieldSelector: fields.ParseSelectorOrDie("metadata.name=" + name.Name)}
+			gitRepositories := &sourcev1.GitRepositoryList{}
+			err := client.List(ctx, gitRepositories, listOptions)
+			if err != nil {
+				return nil, err
+			}
+
+			return gitRepositories, nil
+		},
+		WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) {
+			listOptions := &controller_runtime.ListOptions{Raw: &options, Namespace: name.Namespace, FieldSelector: fields.ParseSelectorOrDie("metadata.name=" + name.Name)}
+			gitRepositories := &sourcev1.GitRepositoryList{}
+			return client.Watch(ctx, gitRepositories, listOptions)
+		},
+	}
+	watcher, err := watchtools.NewRetryWatcher(initialVersion, lister)
+	require.NoError(t, err)
+	defer watcher.Stop()
+
+	for {
+		event := <-watcher.ResultChan()
+		r, ok := event.Object.(*sourcev1.GitRepository)
+		if !ok {
+			// Not a GitRepository, likely an event.
+			t.Logf("Received event: %+v", event)
+			continue
+		}
+
+		t.Logf("Received GitRepository. Status: %+v", r.Status)
+		for _, condition := range r.Status.Conditions {
+			if condition.Type == "Ready" && condition.Status == metav1.ConditionTrue {
+				return r, nil
+			}
+		}
+	}
+}
+
+func getValuesFromRadiusGitOpsConfig(configEntry reconciler.ConfigEntry) (name string, namespace string, resourceGroup string, params string) {
+	name = configEntry.Name
+	namespace = configEntry.Namespace
+	resourceGroup = configEntry.ResourceGroup
+	params = configEntry.Params
+
+	nameBase := strings.ReplaceAll(name, path.Ext(name), "")
+
+	if namespace == "" {
+		namespace = nameBase
+	}
+
+	if resourceGroup == "" {
+		resourceGroup = nameBase
+	}
+
+	return name, namespace, resourceGroup, params
+}
diff --git a/test/functional-portable/kubernetes/noncloud/gitops_test.go b/test/functional-portable/kubernetes/noncloud/gitops_test.go
new file mode 100644
index 000000000..266a429ff
--- /dev/null
+++ b/test/functional-portable/kubernetes/noncloud/gitops_test.go
@@ -0,0 +1,72 @@
+/*
+Copyright 2023 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 kubernetes_test
+
+import (
+	"os"
+	"path/filepath"
+
+	git "github.com/go-git/go-git/v5"
+)
+
+// GitOpsTestStep represents a logical step in a GitOps test.
+// This includes:
+//   - The path of the directory that contains the state of the repository
+//     during the step.
+//   - The list of expected Radius resources that should exist after execution
+//     of the step.
+type GitOpsTestStep struct {
+	// The path of the test data directory.
+	path string
+	// The Radius resource group that resources are created in.
+	resourceGroup string
+	// The expected Radius resources that should be created on the cluster.
+	expectedResources [][]string
+	// The expected Radius resources that should not exist on the cluster.
+	// This is used to verify that the resources were deleted.
+	expectedResourcesToNotExist [][]string
+}
+
+// addFilesToRepository adds all files from the given path to the repository.
+func addFilesToRepository(w *git.Worktree, fromPath, toPath string) error {
+	return filepath.WalkDir(fromPath, func(path string, info os.DirEntry, err error) error {
+		if err != nil {
+			return err
+		}
+		relPath, err := filepath.Rel(fromPath, path)
+		if err != nil {
+			return err
+		}
+		dstPath := filepath.Join(toPath, relPath)
+		if info.IsDir() {
+			return os.MkdirAll(dstPath, 0755)
+		}
+		// Read contents of srcFile and write to destination.
+		data, err := os.ReadFile(path)
+		if err != nil {
+			return err
+		}
+
+		err = os.WriteFile(dstPath, data, 0644)
+		if err != nil {
+			return err
+		}
+
+		_, err = w.Add(relPath)
+		return err
+	})
+}
diff --git a/test/functional-portable/kubernetes/noncloud/testdata/gitops/basic/step1/flux-basic.bicep b/test/functional-portable/kubernetes/noncloud/testdata/gitops/basic/step1/flux-basic.bicep
new file mode 100644
index 000000000..a78323d75
--- /dev/null
+++ b/test/functional-portable/kubernetes/noncloud/testdata/gitops/basic/step1/flux-basic.bicep
@@ -0,0 +1,12 @@
+extension radius
+
+resource fluxBasicEnv 'Applications.Core/environments@2023-10-01-preview' = {
+  name: 'flux-basic-env'
+  properties: {
+    compute: {
+      kind: 'kubernetes'
+      resourceId: 'self'
+      namespace: 'flux-basic'
+    }
+  }
+}
diff --git a/test/functional-portable/kubernetes/noncloud/testdata/gitops/basic/step1/radius-gitops-config.yaml b/test/functional-portable/kubernetes/noncloud/testdata/gitops/basic/step1/radius-gitops-config.yaml
new file mode 100644
index 000000000..a1a1958fc
--- /dev/null
+++ b/test/functional-portable/kubernetes/noncloud/testdata/gitops/basic/step1/radius-gitops-config.yaml
@@ -0,0 +1,2 @@
+config:
+  - name: flux-basic.bicep
diff --git a/test/functional-portable/kubernetes/noncloud/testdata/gitops/basic/step2/radius-gitops-config.yaml b/test/functional-portable/kubernetes/noncloud/testdata/gitops/basic/step2/radius-gitops-config.yaml
new file mode 100644
index 000000000..1e2812ba0
--- /dev/null
+++ b/test/functional-portable/kubernetes/noncloud/testdata/gitops/basic/step2/radius-gitops-config.yaml
@@ -0,0 +1 @@
+config:
diff --git a/test/functional-portable/kubernetes/noncloud/testdata/gitops/complex/step1/flux-complex-dependency.bicep b/test/functional-portable/kubernetes/noncloud/testdata/gitops/complex/step1/flux-complex-dependency.bicep
new file mode 100644
index 000000000..32d183ec6
--- /dev/null
+++ b/test/functional-portable/kubernetes/noncloud/testdata/gitops/complex/step1/flux-complex-dependency.bicep
@@ -0,0 +1,21 @@
+extension radius
+
+param appId string
+param containerImage string
+param replicas string
+
+resource fluxComplexCtnr 'Applications.Core/containers@2023-10-01-preview' = {
+  name: 'flux-complex-container'
+  properties: {
+    application: appId
+    container: {
+      image: containerImage
+    }
+    extensions: [
+      {
+        kind: 'manualScaling'
+        replicas: int(replicas)
+      }
+    ]
+  }
+}
diff --git a/test/functional-portable/kubernetes/noncloud/testdata/gitops/complex/step1/flux-complex.bicep b/test/functional-portable/kubernetes/noncloud/testdata/gitops/complex/step1/flux-complex.bicep
new file mode 100644
index 000000000..7deffa00e
--- /dev/null
+++ b/test/functional-portable/kubernetes/noncloud/testdata/gitops/complex/step1/flux-complex.bicep
@@ -0,0 +1,31 @@
+extension radius
+
+param replicas string
+param containerImage string
+
+resource fluxComplexEnv 'Applications.Core/environments@2023-10-01-preview' = {
+  name: 'flux-complex-env'
+  properties: {
+    compute: {
+      kind: 'kubernetes'
+      resourceId: 'self'
+      namespace: 'flux-complex'
+    }
+  }
+}
+
+resource fluxComplexApp 'Applications.Core/applications@2023-10-01-preview' = {
+  name: 'flux-complex-app'
+  properties: {
+    environment: fluxComplexEnv.id
+  }
+}
+
+module fluxComplexDependency 'flux-complex-dependency.bicep' = {
+  name: 'flux-complex-dependency'
+  params: {
+    appId: fluxComplexApp.id
+    replicas: replicas
+    containerImage: containerImage
+  }
+}
diff --git a/test/functional-portable/kubernetes/noncloud/testdata/gitops/complex/step1/flux-complex.bicepparam b/test/functional-portable/kubernetes/noncloud/testdata/gitops/complex/step1/flux-complex.bicepparam
new file mode 100644
index 000000000..34a61722f
--- /dev/null
+++ b/test/functional-portable/kubernetes/noncloud/testdata/gitops/complex/step1/flux-complex.bicepparam
@@ -0,0 +1,4 @@
+using 'flux-complex.bicep'
+
+param replicas = '1'
+param containerImage = 'nginx'
diff --git a/test/functional-portable/kubernetes/noncloud/testdata/gitops/complex/step1/radius-gitops-config.yaml b/test/functional-portable/kubernetes/noncloud/testdata/gitops/complex/step1/radius-gitops-config.yaml
new file mode 100644
index 000000000..189c69552
--- /dev/null
+++ b/test/functional-portable/kubernetes/noncloud/testdata/gitops/complex/step1/radius-gitops-config.yaml
@@ -0,0 +1,4 @@
+config:
+  - name: flux-complex.bicep
+    params: flux-complex.bicepparam
+    namespace: flux-complex
diff --git a/test/functional-portable/kubernetes/noncloud/testdata/gitops/complex/step2/flux-complex-dependency.bicep b/test/functional-portable/kubernetes/noncloud/testdata/gitops/complex/step2/flux-complex-dependency.bicep
new file mode 100644
index 000000000..e669183bc
--- /dev/null
+++ b/test/functional-portable/kubernetes/noncloud/testdata/gitops/complex/step2/flux-complex-dependency.bicep
@@ -0,0 +1,21 @@
+extension radius
+
+param appId string
+param containerImage string
+param replicas string
+
+resource fluxComplexCtnr 'Applications.Core/containers@2023-10-01-preview' = {
+  name: 'flux-complex-container-2'
+  properties: {
+    application: appId
+    container: {
+      image: containerImage
+    }
+    extensions: [
+      {
+        kind: 'manualScaling'
+        replicas: int(replicas)
+      }
+    ]
+  }
+}
diff --git a/test/functional-portable/kubernetes/noncloud/testdata/gitops/complex/step2/flux-complex.bicep b/test/functional-portable/kubernetes/noncloud/testdata/gitops/complex/step2/flux-complex.bicep
new file mode 100644
index 000000000..82980b8d9
--- /dev/null
+++ b/test/functional-portable/kubernetes/noncloud/testdata/gitops/complex/step2/flux-complex.bicep
@@ -0,0 +1,31 @@
+extension radius
+
+param replicas string
+param containerImage string
+
+resource fluxComplexEnv 'Applications.Core/environments@2023-10-01-preview' = {
+  name: 'flux-complex-env'
+  properties: {
+    compute: {
+      kind: 'kubernetes'
+      resourceId: 'self'
+      namespace: 'flux-complex'
+    }
+  }
+}
+
+resource fluxComplexApp 'Applications.Core/applications@2023-10-01-preview' = {
+  name: 'flux-complex-app'
+  properties: {
+    environment: fluxComplexEnv.id
+  }
+}
+
+module fluxComplexDependency 'flux-complex-dependency.bicep' = {
+  name: 'flux-complex-dependency-2'
+  params: {
+    appId: fluxComplexApp.id
+    replicas: replicas
+    containerImage: containerImage
+  }
+}
diff --git a/test/functional-portable/kubernetes/noncloud/testdata/gitops/complex/step2/flux-complex.bicepparam b/test/functional-portable/kubernetes/noncloud/testdata/gitops/complex/step2/flux-complex.bicepparam
new file mode 100644
index 000000000..82f1f27f2
--- /dev/null
+++ b/test/functional-portable/kubernetes/noncloud/testdata/gitops/complex/step2/flux-complex.bicepparam
@@ -0,0 +1,4 @@
+using 'flux-complex.bicep'
+
+param replicas = '2'
+param containerImage = 'nginx'
diff --git a/test/functional-portable/kubernetes/noncloud/testdata/gitops/complex/step2/radius-gitops-config.yaml b/test/functional-portable/kubernetes/noncloud/testdata/gitops/complex/step2/radius-gitops-config.yaml
new file mode 100644
index 000000000..189c69552
--- /dev/null
+++ b/test/functional-portable/kubernetes/noncloud/testdata/gitops/complex/step2/radius-gitops-config.yaml
@@ -0,0 +1,4 @@
+config:
+  - name: flux-complex.bicep
+    params: flux-complex.bicepparam
+    namespace: flux-complex
diff --git a/test/functional-portable/kubernetes/noncloud/testdata/gitops/complex/step3/radius-gitops-config.yaml b/test/functional-portable/kubernetes/noncloud/testdata/gitops/complex/step3/radius-gitops-config.yaml
new file mode 100644
index 000000000..1e2812ba0
--- /dev/null
+++ b/test/functional-portable/kubernetes/noncloud/testdata/gitops/complex/step3/radius-gitops-config.yaml
@@ -0,0 +1 @@
+config: