Skip to content

DimKal-Org/fabric-mpe-function-proxy

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

23 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Fabric → Function App → On-prem-style API: End-to-End Integration Guide

This guide reproduces the architecture below using two Linux VMs in an Azure VNet to stand in for on-prem API servers. Once the pattern works with the VMs, the same Function App can target real on-prem endpoints (reachable over ExpressRoute / VPN) with no code changes — only the BACKEND_*_BASE_URL app settings change.

Fabric Notebook ──(Managed Private Endpoint)──▶ Function App ──(VNet integration)──▶ VM1 (FastAPI, Basic auth)
                                                                                |
                                                                                └──▶ VM2 (FastAPI, Basic auth)

Components:

  • Backend VMs: two Ubuntu VMs in vnet-func-demo / snet-backend, each running FastAPI on port 8000 with HTTP Basic auth.
  • Function App: Python Flex Consumption plan, VNet-integrated to snet-function-integration. Acts as a generic reverse proxy that dispatches by /<backend>/... URL prefix and rewrites response URLs so callers never see private IPs.
  • Fabric workspace: a notebook calls the Function App through a Managed Private Endpoint (MPE).

Alt text

0. Prerequisites

  • Azure subscription (Owner / Contributor on the target RG).
  • Local tooling (we used WSL Ubuntu):
    • az CLI (Azure CLI) ≥ 2.60
    • func (Azure Functions Core Tools v4)
    • Python 3.12
    • curl, jq
  • A Microsoft Fabric workspace assigned to a capacity that supports managed private endpoints (F64+ / Trial works).
  • Conventions used below (PowerShell). Set these once per shell session; every later block reuses them:
    $RG             = "rg-fabric-mpe-demo"
    $LOC            = "francecentral"
    $VNET           = "vnet-func-demo"
    $SUBNET_BACKEND = "snet-backend"               # 10.0.1.0/24
    $SUBNET_FUNC    = "snet-function-integration"  # 10.0.2.0/24
    $VM1_NAME       = "vm-fabric-function-demo-1"
    $VM2_NAME       = "vm-fabric-function-demo-2"
    $VM_NSG         = "nsg-vm-backend"
    $UNIQUE         = "<unique>"                   # e.g. your initials + 4 digits
    $FUNC           = "func-fabric-mpe-demo"
    $STG            = "stfabricmpedemo$UNIQUE"
    $BACKEND_USER   = "demo_user"
    $BACKEND_PASS   = '<Your strong password>'                # single quotes prevent $-expansion
    $SUB            = "<your-subscription-id-or-name>"
    $VM1_PUBLIC_IP  = "<vm1-public-ip>"             # fill in after §2.1
    $VM2_PUBLIC_IP  = "<vm2-public-ip>"             # fill in after §2.1
    $VM1_PRIVATE_IP = "10.0.1.4"                   # fill in after §2.1
    $VM2_PRIVATE_IP = "10.0.1.5"                   # fill in after §2.1

    In PowerShell, line continuation is a backtick ` at end of line (not a backslash). Variables are referenced with $Name; use "$Name/32" or $($Name) when concatenating with text.


1. Network foundation

1.1 Resource group + VNet

az group create -n $RG -l $LOC

az network vnet create -g $RG -n $VNET `
  --address-prefixes 10.0.0.0/16

1.2 Backend subnet (hosts the VM)

az network vnet subnet create -g $RG --vnet-name $VNET `
  -n $SUBNET_BACKEND --address-prefixes 10.0.1.0/24

1.3 Function integration subnet

Must be delegated to Microsoft.App/environments (Flex Consumption plan).

az network vnet subnet create -g $RG --vnet-name $VNET `
  -n $SUBNET_FUNC --address-prefixes 10.0.2.0/24

az network vnet subnet update -g $RG --vnet-name $VNET -n $SUBNET_FUNC `
  --delegations Microsoft.App/environments

2. Backend Linux VMs

2.1 Create the VMs (Ubuntu 22.04, with public IPs for first-time SSH)

az network nsg create -g $RG -n $VM_NSG

az vm create -g $RG -n $VM1_NAME `
  --image Ubuntu2204 `
  --size Standard_B2s `
  --admin-username azureuser `
  --generate-ssh-keys `
  --vnet-name $VNET --subnet $SUBNET_BACKEND `
  --nsg $VM_NSG `
  --public-ip-sku Standard

az vm create -g $RG -n $VM2_NAME `
  --image Ubuntu2204 `
  --size Standard_B2s `
  --admin-username azureuser `
  --generate-ssh-keys `
  --vnet-name $VNET --subnet $SUBNET_BACKEND `
  --nsg "" `
  --public-ip-sku Standard

Both VMs sit in snet-backend, so the single NSG already covers them. The --nsg "" on VM2 avoids creating a redundant per-NIC NSG.

2.2 NSG rules

Allow SSH from your laptop, and (temporarily) port 8000 so you can test the FastAPI directly from your laptop.

$MYIP = Invoke-RestMethod -Uri "https://api.ipify.org"

az network nsg rule create -g $RG --nsg-name $VM_NSG -n allow-ssh-from-me `
  --priority 200 --access Allow --protocol Tcp --direction Inbound `
  --source-address-prefixes "$MYIP/32" --destination-port-ranges 22

az network nsg rule create -g $RG --nsg-name $VM_NSG -n allow-fastapi-from-me `
  --priority 210 --access Allow --protocol Tcp --direction Inbound `
  --source-address-prefixes "$MYIP/32" --destination-port-ranges 8000

Once the Function App is in place we will add a rule for the function subnet and remove the public-IP rule (see §6).

2.3 Install FastAPI on each VM

Repeat for both VMs. SSH in and set up a virtual environment:

ssh azureuser@<vm-public-ip>

sudo apt-get update && sudo apt-get install -y python3-venv
mkdir -p ~/app && cd ~/app
python3 -m venv venv
source venv/bin/activate
pip install fastapi uvicorn[standard]

2.4 Create main.py

from fastapi import FastAPI, Depends, HTTPException, status, Request
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from datetime import datetime, timezone
import socket
import secrets

app = FastAPI(title="Fabric MPE Demo API")
security = HTTPBasic()

VALID_USERNAME = "demo_user"
VALID_PASSWORD = "<Your strong password>"


def authenticate(credentials: HTTPBasicCredentials = Depends(security)):
    correct_username = secrets.compare_digest(credentials.username, VALID_USERNAME)
    correct_password = secrets.compare_digest(credentials.password, VALID_PASSWORD)
    if not (correct_username and correct_password):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid credentials",
            headers={"WWW-Authenticate": "Basic"},
        )
    return credentials.username


def get_vm_info():
    hostname = socket.gethostname()
    internal_ip = socket.gethostbyname(hostname)
    return hostname, internal_ip


@app.get("/healthcheck")
def healthcheck(username: str = Depends(authenticate)):
    hostname, internal_ip = get_vm_info()
    return {
        "status": "healthy",
        "vm_name": hostname,
        "internal_ip": internal_ip,
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "message": f"Hello from {hostname}! Fabric MPE demo is running.",
    }


@app.get("/data")
def data(username: str = Depends(authenticate)):
    hostname, internal_ip = get_vm_info()
    return {
        "vm_name": hostname,
        "internal_ip": internal_ip,
        "records": [
            {"id": 1, "product": "Historian", "region": "EMEA", "value": 42100},
            {"id": 2, "product": "Wonderware", "region": "APAC", "value": 37850},
            {"id": 3, "product": "InTouch", "region": "AMER", "value": 51200},
            {"id": 4, "product": "System Platform", "region": "EMEA", "value": 29400},
        ],
    }

@app.get("/links")
def links(request: Request, username: str = Depends(authenticate)):
    base = str(request.base_url).rstrip("/")   # e.g. http://10.0.1.4:8000
    return {
        "self": f"{base}/links",
        "healthcheck_url": f"{base}/healthcheck",
        "data_url": f"{base}/data",
    }

2.5 Run as a systemd service

/etc/systemd/system/fastapi-demo.service:

[Unit]
Description=Fabric MPE Demo FastAPI Application
After=network.target

[Service]
User=azureuser
WorkingDirectory=/home/azureuser/app
ExecStart=/home/azureuser/app/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000
Restart=always

[Install]
WantedBy=multi-user.target

Enable + start:

sudo systemctl daemon-reload
sudo systemctl enable --now fastapi-demo
sudo systemctl status fastapi-demo

2.6 Smoke test from your laptop

curl.exe -u "$($BACKEND_USER):$($BACKEND_PASS)" "http://$($VM1_PUBLIC_IP):8000/healthcheck"
curl.exe -u "$($BACKEND_USER):$($BACKEND_PASS)" "http://$($VM2_PUBLIC_IP):8000/healthcheck"

Use curl.exe explicitly — bare curl in PowerShell is an alias for Invoke-WebRequest, which has different flags.


3. Function App project (local)

This repo is organized as:

src/
  function_app.py
  host.json
  local.settings.json        # gitignored
  requirements.txt
notebooks/
  fabric_call_function.ipynb
docs/
  high-level-architecture.png
README.md
Teaser.md
LICENSE

The Azure Functions runtime expects function_app.py, host.json, and requirements.txt at the deployment root. Because they live under src/ here, the deployment commands below run from inside src/, and the Azure Pipelines build (see §3.6.5) is configured with src as its working directory.

3.1 requirements.txt

azure-functions
fastapi
httpx

3.2 host.json

{
  "version": "2.0",
  "extensions": {
    "http": {
      "routePrefix": ""
    }
  }
}

The empty routePrefix means the Function exposes routes at /<path> instead of /api/<path> — easier to reason about as a transparent proxy.

3.3 local.settings.json

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "python",
    "AzureWebJobsFeatureFlags": "EnableWorkerIndexing",
    "BACKEND_VM1_BASE_URL": "http://<vm1-public-ip>:8000",
    "BACKEND_VM2_BASE_URL": "http://<vm2-public-ip>:8000",
    "BACKEND_USERNAME": "demo_user",
    "BACKEND_PASSWORD": "<Your strong password>"
  }
}

Notes:

  • AzureWebJobsStorage="UseDevelopmentStorage=true" points at Azurite for local dev. Without it, the host reports the storage health check unhealthy (HTTP triggers still work, but the noise is annoying).
  • For local testing we use the VMs' public IPs. After deploying to Azure we switch to the private IPs.
  • All backends share the same Basic-auth credentials in this demo. If each VM needs different credentials, use BACKEND_VM1_USERNAME / BACKEND_VM1_PASSWORD etc. and adjust the code accordingly.

3.4 function_app.py — multi-backend reverse proxy

import os
import azure.functions as func
import fastapi
import httpx

# Map of backend tag -> base URL. Add more entries (and matching app settings)
# to expose additional VMs through the same proxy.
BACKENDS: dict[str, str] = {
    "vm1": os.environ["BACKEND_VM1_BASE_URL"].rstrip("/"),
    "vm2": os.environ["BACKEND_VM2_BASE_URL"].rstrip("/"),
}

BACKEND_AUTH = httpx.BasicAuth(
    os.environ["BACKEND_USERNAME"],
    os.environ["BACKEND_PASSWORD"],
)

_HOP_BY_HOP = {
    "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
    "te", "trailers", "transfer-encoding", "upgrade",
    "host", "content-length", "content-encoding",
}

fastapi_app = fastapi.FastAPI()
http_client = httpx.AsyncClient(timeout=30.0, auth=BACKEND_AUTH)


def _public_base_url(request: fastapi.Request) -> str:
    proto = request.headers.get("x-forwarded-proto", request.url.scheme)
    host = request.headers.get("x-forwarded-host") or request.headers.get("host")
    return f"{proto}://{host}".rstrip("/")


def _rewrite_body(body: bytes, backend_base: str, public_base: str) -> bytes:
    if not body:
        return body
    return body.replace(backend_base.encode(), public_base.encode())


@fastapi_app.api_route(
    "/{backend}/{full_path:path}",
    methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
)
async def proxy(backend: str, full_path: str, request: fastapi.Request):
    backend_base = BACKENDS.get(backend)
    if backend_base is None:
        return fastapi.responses.JSONResponse(
            status_code=404,
            content={
                "error": f"unknown backend '{backend}'",
                "valid_backends": sorted(BACKENDS.keys()),
            },
        )

    url = f"{backend_base}/{full_path}"
    fwd_headers = {k: v for k, v in request.headers.items() if k.lower() not in _HOP_BY_HOP}
    body = await request.body()

    try:
        upstream = await http_client.request(
            method=request.method,
            url=url,
            params=request.query_params,
            headers=fwd_headers,
            content=body,
        )
    except httpx.RequestError as e:
        return fastapi.responses.JSONResponse(
            status_code=502,
            content={
                "status": "unreachable",
                "error_type": type(e).__name__,
                "error": str(e) or repr(e),
                "target": url,
            },
        )

    public_base = f"{_public_base_url(request)}/{backend}"
    rewritten = _rewrite_body(upstream.content, backend_base, public_base)
    resp_headers = {k: v for k, v in upstream.headers.items() if k.lower() not in _HOP_BY_HOP}

    return fastapi.responses.Response(
        content=rewritten,
        status_code=upstream.status_code,
        headers=resp_headers,
        media_type=upstream.headers.get("content-type"),
    )


app = func.AsgiFunctionApp(app=fastapi_app, http_auth_level=func.AuthLevel.ANONYMOUS)

Key properties:

  • Backend dispatch: the first URL segment selects the backend. /vm1/healthcheck → VM1, /vm2/data → VM2. Unknown tags get a 404 listing the valid options.
  • Catch-all sub-route /{full_path:path} accepts any method below the backend tag — adding new endpoints to a backend later requires zero changes here.
  • Header forwarding preserves Authorization, custom headers, content-type. Hop-by-hop headers are stripped (RFC 7230 §6.1).
  • URL rewriting: every occurrence of the selected backend's base URL in the response body is replaced with https://<func>/<backend>, so links the VM returns stay self-consistent and routable through this proxy. Callers never see the private IP.

3.5 Run locally

cd <repo>/src
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
func start

Test:

curl http://localhost:7071/vm1/healthcheck
curl http://localhost:7071/vm2/healthcheck
curl http://localhost:7071/vm1/data

3.6 Store the function code in an Azure DevOps repo and deploy via Deployment Center

Instead of running func azure functionapp publish from your laptop, put the project in Azure Repos and let the Function App's Deployment Center pull from it on every commit to main. This is the recommended path for ongoing maintenance.

3.6.1 Prerequisites

  • An Azure DevOps organization + project (e.g. https://dev.azure.com/<org>/FabricIntegration).
  • You signed in to Azure DevOps with the same Entra ID identity that owns the Azure subscription — otherwise Deployment Center won't see your org in the dropdown.
  • The Function App from §4 already created (Deployment Center lives on the Function App resource).

3.6.2 Prepare the local project for source control

Add two ignore files so secrets and local-only junk never leave your machine.

.gitignore:

# Python
__pycache__/
*.py[cod]
venv/
.venv/
.python_packages/

# Azure Functions
bin/
obj/
.azurefunctions/
local.settings.json

# Editors / OS
.vscode/
.idea/
.DS_Store

.funcignore (controls what gets packaged for deployment):

.git/
.gitignore
.venv/
venv/
__pycache__/
local.settings.json
*.md

local.settings.json holds the backend password. Never commit it. The Function App's App Settings (set in §4.3) are the source of truth in Azure.

3.6.3 Create the remote repo in Azure DevOps

In the Azure DevOps portal:

  1. Open your project → Repos.
  2. Use the repo selector at the top → New repository.
  3. Name it fastapi-func, leave Add a README unchecked, click Create.

Or via CLI:

$ORG     = "<your-org>"
$PROJECT = "FabricIntegration"
$REPO    = "fastapi-func"

az extension add --name azure-devops
az devops configure --defaults organization="https://dev.azure.com/$ORG" project=$PROJECT
az repos create --name $REPO --query webUrl -o tsv

3.6.4 Push the code

cd <repo>            # repository root (one level above src/)

git init -b main
git add .
git status                              # verify no local.settings.json, no venv/
git -c user.email="you@example.com" `
    -c user.name="Your Name" `
    commit -m "Initial commit: Fabric MPE function proxy"

git remote add origin "https://dev.azure.com/$ORG/$PROJECT/_git/$REPO"
git push -u origin main

For authentication, the easiest path is Git Credential Manager (a browser window opens on first push for Entra ID sign-in). Alternatively, generate a Personal Access Token (User settings → Personal access tokens → scope Code: Read & write) and paste it as the password.

Refresh the repo in the Azure DevOps portal — src/function_app.py, src/host.json, src/requirements.txt, .gitignore, src/.funcignore are present, and src/local.settings.json is not.

3.6.5 Configure Deployment Center on the Function App

In the Azure portal:

  1. Open the Function App (func-fabric-mpe-demo-<unique>).
  2. Left blade → Deployment → Deployment Center.
  3. Source dropdown → Azure Repos.
  4. Fill in:
    • Organization: your Azure DevOps org
    • Project: FabricIntegration
    • Repository: fastapi-func
    • Branch: main
  5. Build provider: Azure Pipelines (continuous integration).
    • Azure subscription / Resource group / App: prefilled with this Function App.
  6. Click Save at the top.

What this does behind the scenes:

  • Creates a service connection in your Azure DevOps project pointing to the subscription.
  • Commits an azure-pipelines.yml file to the root of the repo on main.
  • Triggers the first pipeline run, which builds the Python project and zip-deploys it to the Function App.

Working directory adjustment for this layout. The auto-generated azure-pipelines.yml assumes the function project is at the repo root. Because we keep it under src/, edit the generated YAML and set workingDirectory: src on the AzureFunctionApp@1 task (and on the pip install / archive steps if they reference paths). A minimal patch looks like:

- task: AzureFunctionApp@1
  inputs:
    azureSubscription: '<service-connection>'
    appType: functionAppLinux
    appName: '$(functionAppName)'
    package: '$(System.DefaultWorkingDirectory)/src'
    runtimeStack: 'PYTHON|3.12'

Commit the change to main and the next pipeline run picks it up.

The portal banner warning ("You are now in the production slot, which is not recommended for setting up CI/CD") is informational. For this demo, deploying directly to production is fine. For real workloads, create a staging deployment slot and point Deployment Center at the slot, then swap.

3.6.6 Verify the first deployment

  • Azure DevOpsPipelines → watch the run finish green.
  • Function AppDeployment Center → Logs tab: shows the deployment with commit SHA + status Success.
  • Smoke-test:
    $HOSTNAME = az functionapp show -g $RG -n $FUNC --query defaultHostName -o tsv
    curl.exe "https://$HOSTNAME/vm1/healthcheck"
    curl.exe "https://$HOSTNAME/vm2/healthcheck"

3.6.7 Day-to-day workflow

$BRANCH = "feature/<short-name>"

git checkout -b $BRANCH
# edit files...
git add -p
git commit -m "Describe the change"
git push -u origin $BRANCH

Open a PR in Azure DevOps → review → merge to main. The merge commit triggers Deployment Center, which redeploys the Function App automatically. No more local func ... publish.

3.6.8 Disconnecting / changing source

Deployment Center → Disconnect removes the integration but leaves the azure-pipelines.yml and service connection in place. Delete those manually in Azure DevOps if you want a fully clean slate.


4. Provision the Function App in Azure (Flex Consumption)

4.1 Storage account

az storage account create -g $RG -n $STG -l $LOC `
  --sku Standard_LRS --kind StorageV2 --min-tls-version TLS1_2

Critical — storage networking on Flex Consumption. The Function App's worker reads the deployment package and runtime state from this storage account continuously (cold start, scale-out, host bookkeeping), not just at deploy time. If the storage account's public access is fully disabled and there is no private path from the function's integration subnet, the worker can't load and the app returns 503 with an empty function list (az functionapp function list returns []). See §4.1.1 below.

4.1.1 Allow the function integration subnet to reach the storage account

Add the Microsoft.Storage service endpoint to snet-function-integration, then allow that subnet on the storage account. This keeps the storage account off the public internet while letting the function worker reach it through the Azure backbone.

# 1. Service endpoint on the function subnet
az network vnet subnet update -g $RG --vnet-name $VNET -n $SUBNET_FUNC `
  --service-endpoints Microsoft.Storage

# 2. Allow that subnet on the storage account, deny everything else,
#    and let trusted Azure services (Functions control plane) bypass.
$SUBNET_FUNC_ID = az network vnet subnet show -g $RG --vnet-name $VNET -n $SUBNET_FUNC --query id -o tsv
az storage account network-rule add -g $RG --account-name $STG --subnet $SUBNET_FUNC_ID
az storage account update -g $RG -n $STG `
  --bypass AzureServices `
  --default-action Deny `
  --public-network-access Enabled

Result, visible under Storage account → Networking → Public network access:

  • Public network access: Enable
  • Public network access scope: Enable from selected networks
  • Virtual Networks: vnet-func-demo / snet-function-integration with Endpoint Status: Enabled

Do not flip "Public network access" to Disable unless you have first added a private endpoint for the storage account's blob sub-resource (and file if used), with the privatelink.blob.core.windows.net Private DNS zone linked to the VNet. With publicNetworkAccess=Disabled, service endpoints stop working too — only private endpoints are honored. A running worker may continue to serve from a warm instance for a while, but on the next restart / cold start / scale event the app will fail to start.

If you do want the fully private path (no public endpoint at all), add a private endpoint:

az network private-endpoint create -g $RG -n "pe-$STG-blob" `
  --vnet-name $VNET --subnet $SUBNET_BACKEND `
  --private-connection-resource-id (az storage account show -g $RG -n $STG --query id -o tsv) `
  --group-id blob --connection-name "pe-$STG-blob-conn"

# Private DNS zone for blob privatelink + link to the VNet
az network private-dns zone create -g $RG -n "privatelink.blob.core.windows.net"
az network private-dns link vnet create -g $RG -n "link-blob" `
  --zone-name "privatelink.blob.core.windows.net" --virtual-network $VNET --registration-enabled false

# Auto-create the A record from the PE
az network private-endpoint dns-zone-group create -g $RG `
  --endpoint-name "pe-$STG-blob" -n "zg-blob" `
  --private-dns-zone "privatelink.blob.core.windows.net" --zone-name blob

# Now safe to fully close public access
az storage account update -g $RG -n $STG --public-network-access Disabled

4.1.2 Production note: fully private storage via private endpoints

The §4.1.1 setup (service endpoint + Enable from selected networks) keeps the storage account off the public internet for everyone except resources inside snet-function-integration, and is what this demo uses. It is secure enough for most workloads and costs nothing extra.

For a true private-only posture (storage account with publicNetworkAccess=Disabled, reachable only via private IP in your VNet), you must replace the service endpoint with private endpoints on every storage sub-resource the Flex Consumption worker uses: blob, file, queue, and table. Missing any one of them — file is the easiest to overlook — breaks worker cold start.

High-level production checklist:

  1. Create a dedicated snet-private-endpoints subnet (do not put PEs in the delegated snet-function-integration).
  2. Create four Private DNS zones and link them to the VNet:
    • privatelink.blob.core.windows.net
    • privatelink.file.core.windows.net
    • privatelink.queue.core.windows.net
    • privatelink.table.core.windows.net
  3. Create one private endpoint per sub-resource (--group-id blob|file|queue|table) on the storage account, each wired into its matching DNS zone group.
  4. Remove the Microsoft.Storage service endpoint from snet-function-integration and drop the storage network rule.
  5. az storage account update --public-network-access Disabled.
  6. Restart the Function App and verify az functionapp function list is non-empty after the cold start.

Trade-offs vs. the demo:

  • Cost: four private endpoints (~$7–8/month each in most regions) and the DNS zones, versus a service endpoint that is free.
  • Operational complexity: DNS misconfiguration is the #1 cause of "the function worked yesterday and not today" in this topology — every new VNet that needs to talk to storage must have the four private DNS zones linked.
  • Blast radius: matches a regulated-workload baseline (e.g. PCI/HIPAA/internal data-exfiltration controls) where any path to a public Azure endpoint is forbidden.

For this demo we deliberately stay on the simpler service-endpoint design so the walkthrough stays focused on the Fabric → MPE → Function → VM pattern. When you take this to production, swap in the private-endpoint variant above before exposing real data.

4.2 Function App (Flex Consumption + VNet integration)

az functionapp create `
  -g $RG -n $FUNC `
  --storage-account $STG `
  --flexconsumption-location $LOC `
  --runtime python --runtime-version 3.12 `
  --vnet $VNET --subnet $SUBNET_FUNC

4.3 App settings

$VM1_PRIVATE_IP = "10.0.1.4"   # whatever VM1 actually got
$VM2_PRIVATE_IP = "10.0.1.5"   # whatever VM2 actually got

az functionapp config appsettings set -g $RG -n $FUNC --settings `
  BACKEND_VM1_BASE_URL="http://$($VM1_PRIVATE_IP):8000" `
  BACKEND_VM2_BASE_URL="http://$($VM2_PRIVATE_IP):8000" `
  BACKEND_USERNAME="$BACKEND_USER" `
  BACKEND_PASSWORD=$BACKEND_PASS

Each BACKEND_*_BASE_URL must include the scheme and the port, and no trailing slash. The proxy uses each one both for outbound calls and as the literal string for response-body rewriting, so they must exactly match what each backend produces with request.base_url.

4.4 Deploy the code

cd <repo>/src
az login                                  # if not already
az account set --subscription $SUB
func azure functionapp publish $FUNC --python

4.5 Post-deploy connectivity rule

The Function App now egresses from snet-function-integration (10.0.2.0/24). Add an inbound rule on the VM NSG:

az network nsg rule create -g $RG --nsg-name $VM_NSG -n allow-func-to-fastapi `
  --priority 220 --access Allow --protocol Tcp --direction Inbound `
  --source-address-prefixes 10.0.2.0/24 --destination-port-ranges 8000

(You can keep the laptop rule for debugging, or remove it now.)

4.6 Verify

$HOSTNAME = az functionapp show -g $RG -n $FUNC --query defaultHostName -o tsv
curl.exe "https://$HOSTNAME/vm1/healthcheck"
curl.exe "https://$HOSTNAME/vm2/healthcheck"

You should see each VM's JSON, with the links rewritten to point to https://$HOSTNAME/vm1/... and https://$HOSTNAME/vm2/... respectively.


5. Fabric — Managed Private Endpoint (MPE)

Goal: have Fabric reach the Function App privately instead of over the public internet.

5.1 Disable public network access to the Function App

# Optional: lock down the public endpoint so only the MPE can reach the function.
az functionapp update -g $RG -n $FUNC --set publicNetworkAccess=Disabled

5.2 Create the MPE in Fabric

  • Get the resource ID of the Function App.
az functionapp show --name $FUNC --resource-group $RG --query id --output tsv
  1. Open the Fabric workspace.
  2. Workspace settings → Outbound networking → Create.
  3. Fill in:
    • Managed private endpoint name: mpe-func-fabric-mpe-demo
    • Resource identifier: <The result from the powershell command you ran at step 1>
    • Target sub-resource: <It should automatically point to Azure Function>
  4. Click Create. State becomes Pending.

5.3 Approve the connection on the Function side

$FUNC_ID = az functionapp show -g $RG -n $FUNC --query id -o tsv
az network private-endpoint-connection list --id $FUNC_ID -o table

$PEC_ID = "<id-from-above>"
az network private-endpoint-connection approve --id $PEC_ID `
  --description "Approved for Fabric MPE"

Refresh the Fabric MPE page → status becomes Succeeded.

5.4 Verify the public lockdown

curl.exe -i "https://$HOSTNAME/vm1/healthcheck"
# Expect 403 / "Web App is stopped" — public path is closed.

6. Fabric notebook

The notebook notebooks/fabric_call_function.ipynb (in this repo) demonstrates calling the Function App from Fabric.

Key cell — generic helper that accepts either a path or a full URL (so you can chain calls based on the links returned by the backend):

import requests

FUNCTION_BASE_URL = "https://<funcname>.azurewebsites.net"
FUNCTION_KEY = None  # only if AuthLevel.FUNCTION

def call_function(target: str, *, params: dict | None = None) -> dict:
    if target.startswith(("http://", "https://")):
        url = target
    else:
        url = f"{FUNCTION_BASE_URL.rstrip('/')}/{target.lstrip('/')}"
    headers = {"x-functions-key": FUNCTION_KEY} if FUNCTION_KEY else {}
    r = requests.get(url, headers=headers, params=params, timeout=15)
    r.raise_for_status()
    return r.json()

Typical chained usage:

hc_vm1 = call_function("/vm1/healthcheck")
data   = call_function(hc_vm1["links"]["data_url"])   # already rewritten to /vm1/data
hc_vm2 = call_function("/vm2/healthcheck")

Import the notebook into Fabric:

  1. Workspace → + New → Import notebook → Upload.
  2. Open it, set FUNCTION_BASE_URL, run all cells.
  3. Because the workspace has the MPE associated, the HTTPS calls go privately to the Function App, which then forwards to the VMs through VNet integration. No public internet involved.

7. Wrap-up checklist

  • VNet + two subnets (backend, function-integration with delegation)
  • Two Linux VMs running FastAPI under systemd, Basic auth, /healthcheck returning links to other endpoints
  • NSG rules: laptop (temp) and function subnet (permanent)
  • Local Function project: multi-backend proxy with response-URL rewriting
  • Flex Consumption Function App deployed, VNet-integrated, app settings set with private backend IPs for both VMs
  • Storage account locked to snet-function-integration via service endpoint (public network access = Enabled from selected networks)
  • Public access disabled on the Function App
  • Managed Private Endpoint created in Fabric and approved
  • Fabric notebook calling /vm1/healthcheck and /vm2/healthcheck end-to-end

8. Going to real on-prem

Switching from the demo VMs to real on-prem servers requires only:

  1. ExpressRoute / Site-to-Site VPN gateway in the VNet, with routes to the on-prem CIDRs.
  2. DNS resolution for the on-prem hostnames (custom DNS server, Private DNS Resolver, or hosts file in the function — least preferred).
  3. Update each BACKEND_*_BASE_URL to the corresponding on-prem URL (HTTPS recommended; for private CAs, mount the CA bundle and set httpx.AsyncClient(verify=...)).
  4. Adjust on-prem firewalls to allow inbound from snet-function-integration (10.0.2.0/24).
  5. To add another backend, add one app setting (BACKEND_VM3_BASE_URL=...) and one line to BACKENDS in function_app.py.

No changes to the proxy logic or the Fabric notebook beyond that.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors