diff --git a/.gitignore b/.gitignore
index 66f21bb..faf38e2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,4 @@ __pycache__
# Local-only Bicep parameter overrides
infra/*.local.bicepparam
infra/*.local.bicepparam.json
+copy.main.bicepparam
diff --git a/.gitmodules b/.gitmodules
index cfdec76..6a06097 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,3 @@
[submodule "submodules/ai-landing-zone"]
path = submodules/ai-landing-zone
- url = https://github.com/Azure/AI-Landing-Zones.git
+ url = https://github.com/Azure/bicep-ptn-aiml-landing-zone
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9c818e0..2a08de2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,13 +2,51 @@
All notable changes to this project will be documented in this file.
+## [2026-03-20]
+### Added
+- Read-only PostgreSQL mirroring preflight script for validating runner prerequisites before mirror setup
+- PostgreSQL mirroring follow-up wrapper to run preflight, preparation, and mirror creation as a deliberate post-deployment flow
+- Shared AI Search helper module for OneLake indexing scripts to centralize public network access toggles and tokenized REST calls
+
+### Changed
+- Repository documentation now uses Microsoft Foundry naming more consistently, including the README, deployment verification guide, and related runbooks
+- PostgreSQL mirroring guidance now treats mirroring as a follow-up step after `azd up`, with clearer public-access versus private-network paths
+- Postprovision now restores only PostgreSQL mirroring readiness preparation instead of attempting full mirror creation during the main deployment run
+- PostgreSQL infrastructure outputs now expose the intended Fabric connection identity and default authentication settings needed for mirroring setup
+- Fabric connection and workspace automation now resolve more values from deployment outputs, azd environment values, and deployed resources when transient hook context is incomplete
+- PostgreSQL mirroring scripts now support explicit connection-mode outputs, stronger credential handling, clearer network-path failures, and gateway-aware Fabric connection creation
+- Purview collection and Fabric datasource registration scripts now derive default names and deployment context more reliably from outputs and environment values
+- Fabric workspace and capacity automation now tolerate more incomplete hook context, recover more reliably from existing resources, and improve capacity/workspace lookup behavior
+- Preprovision retries the landing-zone deployment when Foundry account provisioning is still settling instead of failing immediately on transient provisioning-state errors
+- Secure REST helpers now sanitize captured response bodies before surfacing API errors in automation logs
+- Post-deployment and mirroring documentation consolidated the mirror workflow into a single primary runbook and clarified when mirroring should be deferred
+
+### Removed
+- Temporary PostgreSQL mirroring prep wrapper that toggled public access as a separate script
+- Fabric connection probe debug script and the redundant PostgreSQL mirroring opt-in guide
+
+## [2026-03-18]
+### Added
+- Parameter to override Log Analytics workspace resource ID and output mapping for automation scripts
+- Optional `SKIP_PURVIEW_INTEGRATION` guard for Purview automation scripts (used by hooks when Purview is disabled)
+- Retry/timeout handling for AI Search public network access toggles in OneLake indexing scripts
+
+### Changed
+- Preprovision error output simplified with concise failure reason and optional verbose diagnostics
+- Main parameter file reordered into required/optional/defaulted sections with clearer comments
+- OneLake indexing scripts prefer outputs, include AAD-only auth, and handle transient 409 run conflicts
+- Post-deployment steps now include Fabric mirroring checklist items and Key Vault networking guidance for retrieving the `fabric_user` password
+
+### Removed
+- Log Analytics linkage script `scripts/automationScripts/FabricPurviewAutomation/connect_log_analytics.ps1`
+
## [1.3] - 2025-12-09
### Added
- Microsoft Fabric integration with automatic capacity creation and management
- Microsoft Purview integration for governance and data cataloging
- OneLake indexing pipeline connecting Fabric lakehouses to AI Search
- Comprehensive post-provision automation (22 hooks for Fabric/Purview/Search setup)
-- New documentation: `deploy_app_from_foundry.md` for publishing apps from AI Foundry
+- New documentation: `deploy_app_from_foundry.md` for publishing apps from Microsoft Foundry
- New documentation: `TRANSPARENCY_FAQ.md` for responsible AI transparency
- New documentation: `NewUserGuide.md` for first-time users
- Header icons matching GSA standard format
diff --git a/README.md b/README.md
index 97c5cb6..5066bdb 100644
--- a/README.md
+++ b/README.md
@@ -1,83 +1,68 @@
# Deploy Your AI Application In Production
-Stand up a complete, production-ready AI application environment in Azure with a single command. This solution accelerator provisions Azure AI Foundry, Microsoft Fabric, Azure AI Search, and connects to your tenant level Microsoft Purview (when resourceId is provided) —all pre-wired with private networking, managed identities, and governance controls—so you can move from proof-of-concept to production in hours instead of weeks.
+Stand up a complete, production-ready AI application environment in Azure with a single command. This solution accelerator provisions Microsoft Foundry, Microsoft Fabric, Azure AI Search, and connects to your tenant level Microsoft Purview (when resourceId is provided) —all pre-wired with private networking, managed identities, and governance controls—so you can move from proof-of-concept to production in hours instead of weeks.
-[**SOLUTION OVERVIEW**](#solution-overview) \| [**QUICK DEPLOY**](#quick-deploy) \| [**BUSINESS SCENARIO**](#business-scenario) \| [**SUPPORTING DOCUMENTATION**](#supporting-documentation)
+[**START HERE**](#start-here) \| [**SOLUTION OVERVIEW**](#solution-overview) \| [**BUSINESS SCENARIO**](#business-scenario) \| [**SUPPORTING DOCUMENTATION**](#supporting-documentation)
-
-
-
-
-Solution Overview
+
+
+
+
+Start Here
-This accelerator extends the [AI Landing Zone](https://github.com/Azure/ai-landing-zone) reference architecture to deliver an enterprise-scale, production-ready foundation for deploying secure AI applications and agents in Azure. It packages Microsoft's Well-Architected Framework principles around networking, identity, and operations from day zero.
+### Use This Accelerator When
-### Solution Architecture
+This accelerator is a good fit if you want an end to end AI and Data platform built from the AI Landing Zone, in one deployment:
-|  |
-|---|
+1. Microsoft Foundry
+2. Azure AI Search and OneLake index
+3. Microsoft Fabric workspace and lakehouses
+4. Optional Microsoft Purview integration
+5. Private networking and production-style Azure controls
-### Key Components
+If you only want a small Foundry demo or a basic RAG sample, this repo is heavier than you need.
-| Component | Purpose |
-|-----------|---------|
-| **Azure AI Foundry** | Unified platform for AI development, testing, and deployment with playground, prompt flow, and publishing |
-| **Microsoft Fabric** | Data foundation with lakehouses (bronze/silver/gold) for document storage and OneLake indexing |
-| **Azure AI Search** | Retrieval backbone enabling RAG (Retrieval-Augmented Generation) chat experiences |
-| **Microsoft Purview** | Governance layer for cataloging, scans, and Data Security Posture Management |
-| **Private Networking** | All traffic secured via private endpoints—no public internet exposure |
+### Required Deployment Steps
-
+1. Start from an environment with `azd`, `az`, and `pwsh` available
+2. Authenticate with Azure and select the target subscription and region
+3. Initialize git submodules if you are not using Codespaces or Dev Containers
+4. Review `infra/main.bicepparam` and decide whether Fabric and Purview are enabled for the first run
+5. Check Azure OpenAI quota in the target region
+6. Run `azd up`
+7. Validate the deployment in [docs/post_deployment_steps.md](./docs/post_deployment_steps.md)
-### Additional Resources
+For the first attempt, the lowest-risk path is to keep Fabric and Purview disabled unless you already have their prerequisites in place.
-- [AI Landing Zone Documentation](https://github.com/Azure/ai-landing-zone)
-- [Azure AI Foundry Documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/)
-- [Microsoft Fabric Documentation](https://learn.microsoft.com/en-us/fabric/)
+### Dependency Map
-
+| Area | Required to enable it | If missing |
+|------|------------------------|------------|
+| Base deployment | Azure subscription permissions, `az`, `azd`, `pwsh`, Azure sign-in, initialized submodules, Azure OpenAI quota | `azd up` fails before or during provisioning |
+| Fabric automation | Fabric Administrator permissions or an existing Fabric setup, plus valid Fabric parameter values | Postprovision Fabric steps fail |
+| Fabric capacity creation | At least one valid `fabricCapacityAdmins` entry when `fabricCapacityPreset='create'` | Capacity creation fails |
+| Purview integration | Existing Purview account resource ID in the target tenant and subscription | Purview steps fail |
+| PostgreSQL mirroring | PostgreSQL enabled in the deployment with `postgreSqlNetworkIsolation = false`, then follow the post-deploy mirror steps | Database deploys, but mirroring is not completed |
+| Private networking | `networkIsolation = true` and enough deployment time for private endpoint provisioning | Deployment takes longer and is harder to troubleshoot if other prerequisites are not already stable |
-
-
-
-### Key features
-
- Click to learn more about the key features this solution enables
+### Choose Your Starting Path
- - **Single-command deployment**
- Run `azd up` to provision 30+ Azure resources in ~45 minutes with pre-wired security controls.
-
- - **Production-grade security from day zero**
- Private endpoints, managed identities, and RBAC enabled by default—no public internet exposure.
-
- - **Integrated data-to-AI pipeline**
- Connect Fabric lakehouses → OneLake indexer → AI Search → Foundry playground for grounded chat experiences.
+| Goal | Recommended path |
+|------|------------------|
+| Fastest realistic validation | Local `azd up` workflow |
+| Clean environment with fewer local setup issues | GitHub Codespaces |
+| Deep customization before deploy | Read [docs/PARAMETER_GUIDE.md](./docs/PARAMETER_GUIDE.md) first |
+| Lowest-risk first run | Disable Fabric and Purview, then re-enable later |
- - **Governance built-in**
- Microsoft Purview integration for cataloging, scoped scans, and Data Security Posture Management (DSPM).
-
- - **Extensible AVM-driven platform**
- Toggle additional Azure services through AI Landing Zone parameters for broader intelligent app scenarios.
-
-
-
-
-
-
-
-
-Quick deploy
-
-
-### How to install or deploy
+### How to Install or Deploy
Follow the deployment guide to deploy this solution to your own Azure subscription.
@@ -92,7 +77,7 @@ Follow the deployment guide to deploy this solution to your own Azure subscripti
-> **Important: This repository uses git submodules**
+> **Important: This repository uses git submodules**
>
Clone with submodules included:
> ```bash
> git clone --recurse-submodules https://github.com/microsoft/Deploy-Your-AI-Application-In-Production.git
@@ -103,15 +88,21 @@ Follow the deployment guide to deploy this solution to your own Azure subscripti
> ```
> **GitHub Codespaces and Dev Containers handle this automatically.**
-> **Windows shell note**
->
Preprovision uses `shell: sh`. Run `azd` from Git Bash/WSL so `bash` is available, or switch the `preprovision` hook in `azure.yaml` to the provided PowerShell script if you want to stay in PowerShell.
-
-
+> **Shell requirement**
+>
The repo uses `azd` as the main deployment interface. The preprovision and postprovision hooks run with PowerShell (`pwsh`), so your environment must be able to invoke `pwsh`.
-> **Important: Check Azure OpenAI Quota Availability**
+> **Important: Check Azure OpenAI quota availability**
>
To ensure sufficient quota is available in your subscription, please follow the [quota check instructions guide](./docs/quota_check.md) before deploying.
-
+### First Deployment Checklist
+
+1. Run `azd auth login` and confirm the target subscription with `az account show`
+2. Create a new environment and set `AZURE_SUBSCRIPTION_ID` and `AZURE_LOCATION`
+3. Review `infra/main.bicepparam`, especially `principalId`, `aiSearchAdditionalAccessObjectIds`, `fabricCapacityPreset`, `fabricWorkspacePreset`, `fabricCapacityAdmins`, `purviewAccountResourceId`, `networkIsolation`, and `postgreSqlNetworkIsolation`
+4. Run `azd up`
+5. Follow [docs/post_deployment_steps.md](./docs/post_deployment_steps.md) to verify the deployment
+
+> **Note:** Mirroring automation in the current branch is set for PostgreSQL deployments where `postgreSqlNetworkIsolation = false`. If you want PostgreSQL fully isolated, keep the private networking path and plan on the Fabric VNet gateway route for end-to-end mirroring.
### Prerequisites & Costs
@@ -140,13 +131,13 @@ Follow the deployment guide to deploy this solution to your own Azure subscripti
| Service | SKU | Estimated Monthly Cost |
|---------|-----|------------------------|
- | Azure AI Foundry | Standard | [Pricing](https://azure.microsoft.com/pricing/details/machine-learning/) |
+ | Microsoft Foundry | Standard | [Pricing](https://azure.microsoft.com/pricing/details/machine-learning/) |
| Azure OpenAI | Pay-per-token | [Pricing](https://azure.microsoft.com/pricing/details/cognitive-services/openai-service/) |
| Azure AI Search | Standard | [Pricing](https://azure.microsoft.com/pricing/details/search/) |
| Microsoft Fabric | F8 Capacity (if enabled) | [Pricing](https://azure.microsoft.com/pricing/details/microsoft-fabric/) |
| Virtual Network + Bastion | Standard | [Pricing](https://azure.microsoft.com/pricing/details/azure-bastion/) |
- > **Cost Optimization:** Fabric capacity can be paused when not in use. Use `az fabric capacity suspend` to stop billing.
+ > **Cost Optimization:** Fabric capacity can be paused when not in use. Use `az fabric capacity suspend` to stop billing.
Use the [Azure Pricing Calculator](https://azure.microsoft.com/pricing/calculator/) for detailed estimates.
@@ -155,50 +146,87 @@ Follow the deployment guide to deploy this solution to your own Azure subscripti
-
+
-
-Business Scenario
+
+Solution Overview
-### What You Get
+This accelerator extends the [AI Landing Zone](https://github.com/Azure/ai-landing-zone) reference architecture to deliver an enterprise-scale, production-ready foundation for deploying secure AI applications and agents in Azure. It packages Microsoft's Well-Architected Framework principles around networking, identity, and operations from day zero.
-After deployment, you'll have a complete, enterprise-ready platform that unifies AI development, data management, and governance:
+### Solution Architecture
-| Layer | What's Deployed | Why It Matters |
-|-------|-----------------|----------------|
-| **AI Platform** | Azure AI Foundry with OpenAI models, playground, and prompt flow | Build, test, and publish AI chat applications without managing infrastructure |
-| **Data Foundation** | Microsoft Fabric with bronze/silver/gold lakehouses and OneLake indexing | Store documents at scale and automatically feed them into your AI workflows |
-| **Search & Retrieval** | Azure AI Search with vector and semantic search | Enable RAG (Retrieval-Augmented Generation) for grounded, accurate AI responses |
-| **Governance** | Microsoft Purview with cataloging, scans, and DSPM | Track data lineage, enforce policies, and maintain compliance visibility |
-| **Security** | Private endpoints, managed identities, RBAC, network isolation | Zero public internet exposure—all traffic stays on the Microsoft backbone |
+|  |
+|---|
+
+### Key Components
+
+| Component | Purpose |
+|-----------|---------|
+| **Microsoft Foundry** | Unified platform for AI development, testing, and deployment with playground, prompt flow, and publishing |
+| **Microsoft Fabric** | Data foundation with lakehouses (bronze/silver/gold) for document storage and OneLake indexing |
+| **Azure Database for PostgreSQL** | Optional operational data source that can be prepared for Microsoft Fabric mirroring, including automated Fabric connection creation or reuse after deployment |
+| **Azure AI Search** | Retrieval backbone enabling RAG (Retrieval-Augmented Generation) chat experiences |
+| **Microsoft Purview** | Governance layer for cataloging, scans, and Data Security Posture Management |
+| **Private Networking** | All traffic secured via private endpoints—no public internet exposure |
+### Additional Resources
+
+- [AI Landing Zone Documentation](https://github.com/Azure/bicep-ptn-aiml-landing-zone)
+- [Microsoft Foundry documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/)
+- [Microsoft Fabric Documentation](https://learn.microsoft.com/en-us/fabric/)
+
### Key Features
- Click to learn more about key features
+ Click to learn more about the key features this solution enables
- - **Production-grade AI Foundry deployments**
-
Stand up Azure AI Foundry projects in a locked-down virtual network with private endpoints, managed identities, and telemetry aligned to the Well-Architected Framework.
+ - **Single-command deployment**
+ Run `azd up` to provision 30+ Azure resources in ~45 minutes with pre-wired security controls.
- - **Fabric-powered retrieval workflows**
-
Land documents in a Fabric lakehouse, index them with OneLake + Azure AI Search, and wire the index into the Foundry playground for grounded chat experiences.
+ - **Production-grade security from day zero**
+ Private endpoints, managed identities, and RBAC enabled by default—no public internet exposure.
- - **Governed data and agent operations**
-
Integrate Microsoft Purview for cataloging, scoped scans, and Data Security Posture Management (DSPM) so compliance teams can monitor the same assets the app consumes.
+ - **Integrated data-to-AI pipeline**
+ Connect Fabric lakehouses → OneLake indexer → AI Search → Foundry playground for grounded chat experiences.
- - **Extensible AVM-driven platform**
-
Toggle additional Azure services (API Management, Cosmos DB, SQL, and more) through AI Landing Zone parameters to tailor the environment for broader intelligent app scenarios.
+ - **PostgreSQL-to-Fabric mirroring path**
+ Provision Azure Database for PostgreSQL, prepare it for Fabric mirroring, automatically create or reuse the Fabric connection, and mirror operational data into OneLake for downstream analytics and AI scenarios.
- - **Launch-ready demos and pilots**
-
Publish experiences from Azure AI Foundry directly to a browser-based application, giving stakeholders an end-to-end view from infrastructure to user-facing app.
+ - **Governance built-in**
+ Microsoft Purview integration for cataloging, scoped scans, and Data Security Posture Management (DSPM).
+
+ - **Extensible AVM-driven platform**
+ Toggle additional Azure services through AI Landing Zone parameters for broader intelligent app scenarios.
+
+
+
+
+Business Scenario
+
+
+### What You Get
+
+After deployment, you'll have a complete, enterprise-ready platform that unifies AI development, data management, and governance:
+
+| Layer | What's Deployed | Why It Matters |
+|-------|-----------------|----------------|
+| **AI Platform** | Microsoft Foundry with OpenAI models, playground, and prompt flow | Build, test, and publish AI chat applications without managing infrastructure |
+| **Data Foundation** | Microsoft Fabric with bronze/silver/gold lakehouses and OneLake indexing | Store documents at scale and automatically feed them into your AI workflows |
+| **Operational Data Mirroring** | Azure Database for PostgreSQL prepared for Fabric mirroring | Bring PostgreSQL operational data into Fabric with an automated connection-and-mirror flow plus documented fallback steps |
+| **Search & Retrieval** | Azure AI Search with vector and semantic search | Enable RAG (Retrieval-Augmented Generation) for grounded, accurate AI responses |
+| **Governance** | Microsoft Purview with cataloging, scans, and DSPM | Track data lineage, enforce policies, and maintain compliance visibility |
+| **Security** | Private endpoints, managed identities, RBAC, network isolation | Zero public internet exposure—all traffic stays on the Microsoft backbone |
+
+
+
### Sample Workflow
1. **Deploy infrastructure** → Run `azd up` to provision all resources (~45 minutes)
@@ -208,6 +236,15 @@ After deployment, you'll have a complete, enterprise-ready platform that unifies
5. **Publish application** → Deploy the chat experience to end users
6. **Monitor governance** → Review data lineage and security posture in Purview
+### PostgreSQL Post-Provision Steps
+
+If you deploy Azure Database for PostgreSQL, use these docs after deployment:
+
+1. [docs/postgresql_mirroring.md](./docs/postgresql_mirroring.md)
+2. [docs/post_deployment_steps.md](./docs/post_deployment_steps.md)
+
+If the post-provision mirroring automation cannot complete, start with the **Minimal Manual Fallback** section in [docs/postgresql_mirroring.md](./docs/postgresql_mirroring.md). It calls out the shortest path for both public-access and private-network deployments.
+
@@ -223,6 +260,7 @@ Supporting documentation
|----------|-------------|
| [Deployment Guide](./docs/DeploymentGuide.md) | Complete deployment instructions |
| [Post Deployment Steps](./docs/post_deployment_steps.md) | Verify your deployment |
+| [PostgreSQL Mirroring](./docs/postgresql_mirroring.md) | Automate or troubleshoot the Fabric connection and PostgreSQL mirror flow |
| [Parameter Guide](./docs/PARAMETER_GUIDE.md) | Configure deployment parameters |
| [Quota Check Guide](./docs/quota_check.md) | Check Azure OpenAI quota availability |
@@ -245,7 +283,7 @@ Supporting documentation
**Recommendations:**
- Enable [GitHub secret scanning](https://docs.github.com/code-security/secret-scanning/about-secret-scanning) on your repository
- Consider enabling [Microsoft Defender for Cloud](https://learn.microsoft.com/azure/defender-for-cloud/)
- - Review the [AI Foundry security documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/)
+ - Review the [Microsoft Foundry security documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/)
> ⚠️ **Important:** This template is built to showcase Azure services. Implement additional security measures before production use.
diff --git a/azure.yaml b/azure.yaml
index fb0ad0c..b76fdd8 100644
--- a/azure.yaml
+++ b/azure.yaml
@@ -16,14 +16,8 @@ metadata:
hooks:
preprovision:
# Integrated preprovision:
- # - Runs AI Landing Zone preprovision to generate deploy/ files and Template Specs
- # - Ensures our wrapper points to deploy/main.bicep (Template Spec-based) to avoid ARM 4MB template limit
- # On Windows, `shell: sh` may not be available; the PowerShell script is a fallback.
- - shell: sh
- run: ./scripts/preprovision-integrated.sh
- interactive: false
- continueOnError: true
-
+ # - Deploys the AI Landing Zone submodule separately to avoid ARM 4MB template limit
+ # PowerShell is the supported entrypoint in this repo.
- shell: pwsh
run: ./scripts/preprovision-integrated.ps1
interactive: false
@@ -77,6 +71,12 @@ hooks:
interactive: false
shell: pwsh
continueOnError: false
+
+ # Stage 7.5: Prepare PostgreSQL server for Fabric mirroring readiness
+ - run: ./scripts/automationScripts/FabricWorkspace/mirror/prepare_postgresql_for_mirroring.ps1
+ interactive: false
+ shell: pwsh
+ continueOnError: false
# Stage 8: Setup Fabric Workspace Private Link (for VNet integration)
- run: ./scripts/automationScripts/FabricWorkspace/SecureWorkspace/setup_fabric_private_link.ps1
@@ -138,14 +138,4 @@ hooks:
shell: pwsh
continueOnError: false
- # Stage 18: Connect Log Analytics (placeholder)
- - run: ./scripts/automationScripts/FabricPurviewAutomation/connect_log_analytics.ps1
- interactive: false
- shell: pwsh
- continueOnError: false
- # Stage 19: Clean up AI Landing Zone template specs
- - run: ./submodules/ai-landing-zone/bicep/scripts/postprovision.ps1
- interactive: false
- shell: pwsh
- continueOnError: false
diff --git a/docs/ACCESSING_PRIVATE_RESOURCES.md b/docs/ACCESSING_PRIVATE_RESOURCES.md
index 0f7e4f0..f1b50c4 100644
--- a/docs/ACCESSING_PRIVATE_RESOURCES.md
+++ b/docs/ACCESSING_PRIVATE_RESOURCES.md
@@ -32,7 +32,7 @@ Once connected to the Jump VM, you can:
- **Azure AI Search**: Manage indexes via Azure Portal
- **Storage Account**: Browse blobs via Azure Portal or Storage Explorer
- **Container Registry**: Push/pull images using Docker CLI
-- **AI Foundry**: Manage projects and deployments
+- **Microsoft Foundry**: Manage projects and deployments
### 3. Install Tools on Jump VM (Optional)
diff --git a/docs/DeploymentGuide.md b/docs/DeploymentGuide.md
index 7de2f86..e68d01a 100644
--- a/docs/DeploymentGuide.md
+++ b/docs/DeploymentGuide.md
@@ -28,7 +28,7 @@ To deploy this solution accelerator, ensure you have access to an [Azure subscri
| Git | Latest | [Install Git](https://git-scm.com/downloads) |
| PowerShell | 7.0+ | [Install PowerShell](https://learn.microsoft.com/powershell/scripting/install/installing-powershell) |
-> **Windows-specific shell requirement:** Preprovision hooks run with `shell: sh`. Install Git for Windows (includes Git Bash) **or** run `azd` from WSL/Ubuntu so `bash/sh` is on PATH. If you prefer pure PowerShell, update `azure.yaml` to point `preprovision` to the provided `preprovision.ps1`.
+> **Windows shell requirement:** Preprovision runs with PowerShell (`pwsh`). Use PowerShell 7+ so `pwsh` is on PATH.
### External Resources
@@ -106,7 +106,7 @@ If you're not using Codespaces or Dev Containers:
4. Continue with [Deployment Steps](#deployment-steps) below
-> **Note (Windows):** Run `azd up` from Git Bash or WSL so the `preprovision` hook can execute. If you want to stay in PowerShell, edit `azure.yaml` to use `preprovision.ps1` instead of the `.sh` script.
+> **Note (Windows):** Run `azd up` from PowerShell 7+ so the `pwsh` preprovision hook can execute.
@@ -152,22 +152,23 @@ Edit `infra/main.bicepparam` or set environment variables:
| Parameter | Description | Example |
|-----------|-------------|---------|
| `purviewAccountResourceId` | Resource ID of existing Purview account | `/subscriptions/.../Microsoft.Purview/accounts/...` |
-| `aiSearchAdditionalAccessObjectIds` | Array of Entra object IDs to grant Search roles | `["00000000-0000-0000-0000-000000000000"]` |
-| `fabricCapacityMode` | Fabric capacity mode: `create`, `byo`, or `none` | `create` |
-| `fabricWorkspaceMode` | Fabric workspace mode: `create`, `byo`, or `none` | `create` |
-| `fabricCapacitySku` | Fabric capacity SKU (only used when `fabricCapacityMode=create`) | `F8` (default) |
-| `fabricCapacityAdmins` | Fabric capacity admin principals (UPN emails or Entra object IDs) (required when `fabricCapacityMode=create`) | `["user@contoso.com"]` |
-| `fabricCapacityResourceId` | Existing Fabric capacity ARM resource ID (required when `fabricCapacityMode=byo`) | `/subscriptions/.../providers/Microsoft.Fabric/capacities/...` |
-| `fabricWorkspaceId` | Existing Fabric workspace ID (GUID) (required when `fabricWorkspaceMode=byo`) | `00000000-0000-0000-0000-000000000000` |
-| `fabricWorkspaceName` | Existing Fabric workspace name (used when `fabricWorkspaceMode=byo`) | `my-existing-workspace` |
+| `fabricCapacityPreset` | Fabric capacity preset: `create`, `byo`, or `none` | `create` |
+| `fabricWorkspacePreset` | Fabric workspace preset: `create`, `byo`, or `none` | `create` |
+| `fabricCapacitySku` | Fabric capacity SKU (only used when `fabricCapacityPreset=create`) | `F8` (default) |
+| `fabricCapacityAdmins` | Fabric capacity admin principals (UPN emails or Entra object IDs) (required when `fabricCapacityPreset=create`) | `["user@contoso.com"]` |
+| `fabricCapacityResourceId` | Existing Fabric capacity ARM resource ID (required when `fabricCapacityPreset=byo`) | `/subscriptions/.../providers/Microsoft.Fabric/capacities/...` |
+| `fabricWorkspaceId` | Existing Fabric workspace ID (GUID) (required when `fabricWorkspacePreset=byo`) | `00000000-0000-0000-0000-000000000000` |
+| `fabricWorkspaceName` | Existing Fabric workspace name (used when `fabricWorkspacePreset=byo`) | `my-existing-workspace` |
```bash
# Example: Set Purview account
-azd env set purviewAccountResourceId "/subscriptions//resourceGroups//providers/Microsoft.Purview/accounts/"
+# (Edit infra/main.bicepparam)
+# param purviewAccountResourceId = "/subscriptions//resourceGroups//providers/Microsoft.Purview/accounts/"
# Example: Disable all Fabric automation
-azd env set fabricCapacityMode none
-azd env set fabricWorkspaceMode none
+# (Edit infra/main.bicepparam)
+# var fabricCapacityPreset = 'none'
+# var fabricWorkspacePreset = 'none'
```
@@ -177,9 +178,11 @@ azd env set fabricWorkspaceMode none
| Parameter | Description | Default |
|-----------|-------------|---------|
-| `aiSearchAdditionalAccessObjectIds` | Entra ID object IDs for additional Search access | `[]` |
-| `networkIsolationMode` | Network isolation level | `AllowInternetOutbound` |
-| `vmAdminUsername` | Jump box VM admin username | `azureuser` |
+| `networkIsolation` | Enable network isolation | `false` |
+| `postgreSqlNetworkIsolation` | PostgreSQL private networking toggle (defaults to `networkIsolation`) | `networkIsolation` |
+| `useExistingVNet` | Reuse an existing VNet | `false` |
+| `existingVnetResourceId` | Existing VNet resource ID (when `useExistingVNet=true`) | `` |
+| `vmUserName` | Jump box VM admin username | `` |
| `vmAdminPassword` | Jump box VM admin password | (prompted) |
@@ -214,8 +217,8 @@ azd up
```
This command will:
-1. Run pre-provision hooks (validate environment)
-2. Deploy all Azure infrastructure (~30-40 minutes)
+1. Run pre-provision hooks (deploy AI Landing Zone submodule)
+2. Deploy Fabric capacity and supporting infrastructure (~30-40 minutes)
3. Run post-provision hooks (configure Fabric, Purview, Search RBAC)
> **Note:** The entire deployment typically takes 45-60 minutes.
@@ -233,7 +236,7 @@ Running postprovision hooks
✓ Lakehouse creation (bronze, silver, gold)
✓ Purview registration
✓ OneLake indexing setup
- ✓ AI Foundry RBAC configuration
+ ✓ Microsoft Foundry RBAC configuration
```
### Step 5: Verify Deployment
@@ -265,7 +268,7 @@ Then follow the [Post Deployment Steps](./post_deployment_steps.md) to validate:
### Connect Foundry to Search Index
1. Navigate to [ai.azure.com](https://ai.azure.com)
-2. Open your AI Foundry project
+2. Open your Microsoft Foundry project
3. Go to **Playgrounds** → **Chat**
4. Click **Add your data** → Select your Search index
5. Test with a sample query
diff --git a/docs/PARAMETER_GUIDE.md b/docs/PARAMETER_GUIDE.md
index a02131f..65d3641 100644
--- a/docs/PARAMETER_GUIDE.md
+++ b/docs/PARAMETER_GUIDE.md
@@ -5,18 +5,20 @@ This guide focuses on configuration concepts for the **AI Landing Zone**.
> **Important**: This repository deploys using Bicep parameter files, not `infra/main.parameters.json`.
>
> - Primary parameters file: `infra/main.bicepparam`
-> - AI Landing Zone submodule parameters file (if you deploy it directly): `submodules/ai-landing-zone/bicep/infra/main.bicepparam`
+> - AI Landing Zone submodule parameters file (if you deploy it directly): `submodules/ai-landing-zone/main.parameters.json`
>
> **Fabric options in this repo** are configured in `infra/main.bicepparam` via:
> - `fabricCapacityPreset` (`create` | `byo` | `none`)
> - `fabricWorkspacePreset` (`create` | `byo` | `none`)
> - BYO inputs: `fabricCapacityResourceId`, `fabricWorkspaceId`, `fabricWorkspaceName`
+> **Deployment flow**: This repo deploys the AI Landing Zone submodule from `submodules/ai-landing-zone/main.bicep` during the preprovision hook. The single source of truth for parameters is `infra/main.bicepparam`.
+
## Table of Contents
1. [Basic Parameters](#basic-parameters)
2. [Deployment Toggles](#deployment-toggles)
3. [Network Configuration](#network-configuration)
-4. [AI Foundry Configuration](#ai-foundry-configuration)
+4. [Microsoft Foundry Configuration](#microsoft-foundry-configuration)
5. [Individual Service Configuration](#individual-service-configuration)
6. [Common Customization Examples](#common-customization-examples)
@@ -151,6 +153,14 @@ Each toggle controls whether a service is created. Set to `true` to deploy, `fal
- `buildVm: true` - For CI/CD build agents
- `jumpVm: true` - For Windows-based management
+### Log Analytics (Optional)
+
+If you are using an existing Log Analytics workspace, set the resource ID in `infra/main.bicepparam`:
+
+```bicep-params
+param logAnalyticsWorkspaceResourceId = '/subscriptions//resourceGroups//providers/Microsoft.OperationalInsights/workspaces/'
+```
+
### Network Security Groups
```json
@@ -283,11 +293,11 @@ Each toggle controls whether a service is created. Set to `true` to deploy, `fal
---
-## AI Foundry Configuration
+## Microsoft Foundry Configuration
### aiFoundryDefinition
-Controls AI Foundry hub/project and model deployments.
+Controls Microsoft Foundry account/project and model deployments.
```json
"aiFoundryDefinition": {
@@ -304,7 +314,7 @@ Controls AI Foundry hub/project and model deployments.
### includeAssociatedResources
**Type**: `boolean`
**Default**: `true`
-**Description**: Create dedicated AI Search, Cosmos DB, Key Vault, and Storage for AI Foundry.
+**Description**: Create dedicated AI Search, Cosmos DB, Key Vault, and Storage for Microsoft Foundry.
Set to `false` if you want to use shared resources.
@@ -427,6 +437,29 @@ az cognitiveservices account list-usage \
## Individual Service Configuration
+### PostgreSQL Flexible Server (Repo Wrapper)
+
+Use these in `infra/main.bicepparam` when deploying via this repo. `postgreSqlNetworkIsolation` defaults to `networkIsolation`.
+
+```bicep-params
+param deployPostgreSql = true
+param postgreSqlNetworkIsolation = networkIsolation
+param postgreSqlMirrorConnectionMode = 'fabricUser'
+param postgreSqlAuthConfig = {
+ activeDirectoryAuth: 'Enabled'
+ passwordAuth: 'Enabled'
+}
+```
+
+When `postgreSqlNetworkIsolation` is `false`, PostgreSQL uses public access and does not create private endpoints or private DNS resources.
+
+`postgreSqlAuthConfig` should remain set to both authentication modes enabled if you plan to configure Fabric mirroring after deployment. This ensures the server is created with password authentication available for the `fabric_user` connection instead of relying on a later hook to change the auth mode.
+
+`postgreSqlMirrorConnectionMode` controls which credential the manual Fabric PostgreSQL connection should use after deployment:
+
+- `fabricUser` uses the dedicated least-privilege mirroring user and `postgres-fabric-user-password`. This is the production-oriented default.
+- `admin` uses the PostgreSQL admin login and `postgres-admin-password`. This is intended for demo automation scenarios where you want to avoid creating a separate mirroring user.
+
### Storage Account
```json
diff --git a/docs/Required_roles_scopes_resources.md b/docs/Required_roles_scopes_resources.md
index caa4f31..873896c 100644
--- a/docs/Required_roles_scopes_resources.md
+++ b/docs/Required_roles_scopes_resources.md
@@ -1,4 +1,4 @@
-# Required Roles and Scopes for AI Foundry isolated network template deployment
+# Required Roles and Scopes for Microsoft Foundry isolated network template deployment
To deploy this code, assign roles with minimal privileges to create and manage necessary Azure resources. Ensure roles are assigned at the appropriate subscription or resource group levels.
## Role Assignments:
@@ -13,14 +13,14 @@ Be sure these resource providers are registered in your Azure subscription. To r
| **Resource Type** | **Azure Resource Provider** | **Type** | **Description** |
|-------------------|----------------------------|----------|-----------------|
-| Application Insights | Microsoft.Insights | /components | An Azure Application Insights instance associated with the Azure AI Foundry Hub |
+| Application Insights | Microsoft.Insights | /components | An Azure Application Insights instance associated with the Microsoft Foundry account |
|Azure Log Analytics|Microsoft.OperationalInsights|/workspaces|An Azure Log Analytics workspace used to collect diagnostics|
-|Azure Key Vault|Microsoft.KeyVault|/vaults|An Azure Key Vault instance associated with the Azure AI Foundry Hub|
-|Azure Storage Account|Microsoft.Storage|/storageAccounts|An Azure Storage instance associated with the Azure AI Foundry Hub|
-|Azure Container Registry|Microsoft.ContainerRegistry|/registries|An Azure Container Registry instance associated with the Azure AI Foundry Account|
+|Azure Key Vault|Microsoft.KeyVault|/vaults|An Azure Key Vault instance associated with the Microsoft Foundry account|
+|Azure Storage Account|Microsoft.Storage|/storageAccounts|An Azure Storage instance associated with the Microsoft Foundry account|
+|Azure Container Registry|Microsoft.ContainerRegistry|/registries|An Azure Container Registry instance associated with the Microsoft Foundry account|
|Azure AI Services|Microsoft.CognitiveServices|/accounts|An Azure AI Services as the model-as-a-service endpoint provider including GPT-4o and ADA Text Embeddings model deployments|
-|Azure Virtual Network|Microsoft.Network|/virtualNetworks|A bring-your-own (BYO) virtual network hosting a virtual machine to connect to Azure AI Foundry which will be behind a private endpoint when in network isolation mode. |
+|Azure Virtual Network|Microsoft.Network|/virtualNetworks|A bring-your-own (BYO) virtual network hosting a virtual machine to connect to Microsoft Foundry which will be behind a private endpoint when in network isolation mode. |
|Bastion Host|Microsoft.Network||A Bastion Host defined in the BYO virtual network that provides RDP connectivity to the jumpbox virtual machine|
|Azure NAT Gateway|Microsoft.Network|/natGateways|An Azure NAT Gateway that provides outbound connectivity to the jumpbox virtual machine|
-|Azure Private Endpoints|Microsoft.Network|/privateEndpoints|Azure Private Endpoints defined in the BYO virtual network for Azure Container Registry, Azure Key Vault, Azure Storage Account, and Azure AI Foundry Hub/Project|
+|Azure Private Endpoints|Microsoft.Network|/privateEndpoints|Azure Private Endpoints defined in the BYO virtual network for Azure Container Registry, Azure Key Vault, Azure Storage Account, and Microsoft Foundry account/project|
|Azure Private DNS Zones|Microsoft.Network|/privateDnsZones|Azure Private DNS Zones are used for the DNS resolution of the Azure Private Endpoints|
diff --git a/docs/TRANSPARENCY_FAQ.md b/docs/TRANSPARENCY_FAQ.md
index 72d796f..5944ac0 100644
--- a/docs/TRANSPARENCY_FAQ.md
+++ b/docs/TRANSPARENCY_FAQ.md
@@ -4,7 +4,7 @@
### What is the Deploy Your AI Application In Production solution accelerator?
-This solution accelerator automates the deployment of a complete, production-ready AI application environment in Azure. It provisions Azure AI Foundry, Microsoft Fabric, Azure AI Search, and Microsoft Purview—all pre-wired with private networking, managed identities, and governance controls.
+This solution accelerator automates the deployment of a complete, production-ready AI application environment in Azure. It provisions Microsoft Foundry, Microsoft Fabric, Azure AI Search, and Microsoft Purview—all pre-wired with private networking, managed identities, and governance controls.
### What is the intended use of this solution accelerator?
diff --git a/docs/automation-outputs-mapping.md b/docs/automation-outputs-mapping.md
index 9fe8a81..e81607d 100644
--- a/docs/automation-outputs-mapping.md
+++ b/docs/automation-outputs-mapping.md
@@ -20,8 +20,8 @@ The postprovision automation scripts consume deployment outputs via the `AZURE_O
| Bicep Output | Script Variable | Used By | Purpose |
|-------------|-----------------|---------|---------|
-| `fabricCapacityModeOut` | `fabricCapacityMode` | Multiple Fabric scripts | Whether capacity is `create`, `byo`, or `none` |
-| `fabricWorkspaceModeOut` | `fabricWorkspaceMode` | Multiple Fabric scripts | Whether workspace is `create`, `byo`, or `none` |
+| `fabricCapacityModeOut` | `fabricCapacityMode` | Multiple Fabric scripts | Resolved mode from `fabricCapacityPreset` (`create`, `byo`, `none`) |
+| `fabricWorkspaceModeOut` | `fabricWorkspaceMode` | Multiple Fabric scripts | Resolved mode from `fabricWorkspacePreset` (`create`, `byo`, `none`) |
| `fabricCapacityId` | `FABRIC_CAPACITY_ID` | `ensure_active_capacity.ps1` | ARM resource ID of Fabric capacity |
| `fabricCapacityResourceIdOut` | `fabricCapacityId` | `create_fabric_workspace.ps1` | Resource ID for capacity assignment |
| `fabricWorkspaceIdOut` | `FABRIC_WORKSPACE_ID` | Multiple Fabric scripts | Existing or created Fabric workspace ID |
@@ -39,11 +39,11 @@ The postprovision automation scripts consume deployment outputs via the `AZURE_O
| `aiSearchSubscriptionId` | `aiSearchSubscriptionId` | OneLake indexing scripts | Subscription for AI Search |
| `aiSearchAdditionalAccessObjectIds` | `aiSearchAdditionalAccessObjectIds` | RBAC scripts | Optional Entra principals granted Search roles |
-### AI Foundry
+### Microsoft Foundry
| Bicep Output | Script Variable | Used By | Purpose |
|-------------|-----------------|---------|---------|
-| `aiFoundryProjectName` | `aiFoundryName` | `06_setup_ai_foundry_search_rbac.ps1` | AI Foundry project name |
+| `aiFoundryProjectName` | `aiFoundryName` | `06_setup_ai_foundry_search_rbac.ps1` | Microsoft Foundry project name |
| `aiFoundryServicesName` | `aiServicesName` | RBAC scripts | Cognitive Services account name |
### Purview Integration
diff --git a/docs/deploy_app_from_foundry.md b/docs/deploy_app_from_foundry.md
index 5058b77..b7f99e6 100644
--- a/docs/deploy_app_from_foundry.md
+++ b/docs/deploy_app_from_foundry.md
@@ -1,33 +1,33 @@
-# Deploy an Application from Azure AI Foundry
+# Deploy an Application from Microsoft Foundry
-This guide explains how to deploy a chat application directly from the Azure AI Foundry playground to Azure App Service.
+This guide explains how to deploy a chat application directly from the Microsoft Foundry playground to Azure App Service.
## Overview
-Azure AI Foundry provides a built-in capability to publish playground experiences as web applications. This accelerator deploys the required infrastructure (App Service, managed identity, networking) so you can publish directly from the Foundry playground.
+Microsoft Foundry provides a built-in capability to publish playground experiences as web applications. This accelerator deploys the required infrastructure (App Service, managed identity, networking) so you can publish directly from the Foundry playground.
## Prerequisites
- Completed deployment of this accelerator (`azd up`)
-- Access to the AI Foundry project via the Jump VM
+- Access to the Microsoft Foundry project via the Jump VM
- An AI Search index with your data (created via OneLake indexer or manually)
## Steps to Deploy an App from Foundry Playground
-### 1. Access AI Foundry via Jump VM
+### 1. Access Microsoft Foundry via Jump VM
-Since all resources are deployed with private endpoints, you must access AI Foundry through the Jump VM:
+Since all resources are deployed with private endpoints, you must access Microsoft Foundry through the Jump VM:
1. Go to the [Azure Portal](https://portal.azure.com)
2. Navigate to your resource group
3. Select the **Jump VM** (Windows Virtual Machine)
4. Click **Connect** → **Bastion**
5. Enter the VM credentials (set during deployment)
-6. Once connected, open a browser and navigate to [AI Foundry](https://ai.azure.com)
+6. Once connected, open a browser and navigate to [Microsoft Foundry](https://ai.azure.com)
### 2. Configure Your Playground
-1. In AI Foundry, select your **Project**
+1. In Microsoft Foundry, select your **Project**
2. Navigate to **Playgrounds** → **Chat playground**
3. Configure your deployment:
- Select your **GPT model deployment** (e.g., gpt-4o)
@@ -61,7 +61,7 @@ After deployment completes:
## Additional Resources
- [Deploy a web app for chat on your data](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-web-app)
-- [Azure AI Foundry documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/)
+- [Microsoft Foundry documentation](https://learn.microsoft.com/en-us/azure/ai-foundry/)
- [Customize the web app](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-web-app#customize-the-web-app)
## Troubleshooting
diff --git a/docs/faq.md b/docs/faq.md
index ef69848..365c3a9 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -1,13 +1,13 @@
# Frequently Asked Questions
-## How do Azure AI Foundry account and project identities interact with Azure AI Search RBAC?
+## How do Microsoft Foundry account and project identities interact with Azure AI Search RBAC?
-Fabric/Azure AI Foundry creates **separate managed identities** for the Foundry account and for each project. Azure RBAC permissions do **not** cascade from the account to its projects, so a role assignment that targets the account identity does not automatically grant the same access to the project identity.
+Microsoft Foundry creates **separate managed identities** for the Foundry account and for each project. Azure RBAC permissions do **not** cascade from the account to its projects, so a role assignment that targets the account identity does not automatically grant the same access to the project identity.
The post-provision script `scripts/automationScripts/OneLakeIndex/06_setup_ai_foundry_search_rbac.ps1` therefore resolves **both** identities:
-- `aiFoundryIdentity` → the AI Foundry **account** managed identity
-- `projectPrincipalId` → the AI Foundry **project** managed identity
+- `aiFoundryIdentity` → the Microsoft Foundry **account** managed identity
+- `projectPrincipalId` → the Microsoft Foundry **project** managed identity
It then assigns the required Azure AI Search roles to every principal it finds. If the script cannot resolve the project identity, it logs a warning and only the account identity receives the roles. In that case, re-run the script once the project identity exists or assign the roles manually.
@@ -46,9 +46,9 @@ az role assignment create \
Because the knowledge source uses the **project** identity when it ingests data, those roles must be granted to the project principal even if the account identity already has them.
-## How do I integrate an existing Azure AI Foundry project into the AI Landing Zone?
+## How do I integrate an existing Microsoft Foundry project into the AI Landing Zone?
-Integrating the new Azure AI Foundry project model (Cognitive Services account plus project announced at Ignite) into an AI Landing Zone is a matter of extending the landing zone controls so the project runs entirely inside the isolated estate. Work through these considerations:
+Integrating the Microsoft Foundry project model (Cognitive Services account plus project) into an AI Landing Zone is a matter of extending the landing zone controls so the project runs entirely inside the isolated estate. Work through these considerations:
1. **Locate the project**: Record the account and project resource IDs, region, and tenant. Confirm the region aligns with the landing zone virtual network and private DNS footprint so private endpoints can be created without cross-region limitations.
2. **Carve out network space**: Add a dedicated subnet (or set of subnets) in the landing zone virtual network for the Foundry managed network. Apply the landing zone NSG, UDR, and firewall baselines. If the project already uses managed network isolation, update it to target the new subnet; otherwise plan for a fresh isolated project and migrate assets with export/import tooling.
diff --git a/docs/post_deployment_steps.md b/docs/post_deployment_steps.md
index 647af6a..b1cd628 100644
--- a/docs/post_deployment_steps.md
+++ b/docs/post_deployment_steps.md
@@ -10,7 +10,7 @@ After running `azd up` or `azd provision` followed by `azd hooks run postprovisi
|-----------|---------------|----------------|
| Fabric Capacity | Azure Portal → Microsoft Fabric capacities | **Active** (not Paused) |
| Fabric Workspace | [app.fabric.microsoft.com](https://app.fabric.microsoft.com) | Workspace visible with 3 lakehouses |
-| AI Foundry Project | [ai.azure.com](https://ai.azure.com) | Project accessible, models deployed |
+| Microsoft Foundry project | [ai.azure.com](https://ai.azure.com) | Project accessible, models deployed |
| AI Search Index | Azure Portal → AI Search → Indexes | `onelake-index` exists with documents |
| Purview Scan | Purview Portal → Data Map → Sources | Fabric data source registered |
@@ -45,13 +45,40 @@ az fabric capacity resume --capacity-name --resource-group --resource-group `)
4. Check **Scans** to see if the initial scan completed
+If `purviewCollectionName` is left empty in [infra/main.bicepparam](../infra/main.bicepparam), the automation now uses `collection-`.
+
+If you need to rerun the Purview steps after provisioning:
+
+```powershell
+pwsh ./scripts/automationScripts/FabricPurviewAutomation/create_purview_collection.ps1
+pwsh ./scripts/automationScripts/FabricWorkspace/CreateWorkspace/register_fabric_datasource.ps1
+pwsh ./scripts/automationScripts/FabricPurviewAutomation/trigger_purview_scan_for_fabric_workspace.ps1
+```
+
### Data Lineage
1. In Purview, go to **Data Catalog** → **Browse**
@@ -106,17 +143,17 @@ If no documents appear, check:
## 6. Verify Network Isolation (if enabled)
-When `networkIsolationMode` is set to isolate resources:
+When `networkIsolation` is set to `true`:
-### Check AI Foundry Network Settings
+### Check Microsoft Foundry Network Settings
-1. Go to **Azure Portal** → **Azure AI Foundry** → your account
+1. Go to **Azure Portal** → **Microsoft Foundry** → your account
2. Click **Settings** → **Networking**
3. Verify:
- **Public network access**: Disabled (if fully isolated)
- **Private endpoints**: Active connections listed
- 
+ 
4. Open the **Workspace managed outbound access** tab to see private endpoints
@@ -124,7 +161,7 @@ When `networkIsolationMode` is set to isolate resources:
### Test Isolation
-When accessing AI Foundry from outside the virtual network, you should see an access denied message:
+When accessing Microsoft Foundry from outside the virtual network, you should see an access denied message:

@@ -153,7 +190,7 @@ For network-isolated deployments, use Azure Bastion to access resources:

5. Once connected, open **Edge browser** and navigate to:
- - [ai.azure.com](https://ai.azure.com) — AI Foundry
+ - [ai.azure.com](https://ai.azure.com) — Microsoft Foundry
- [app.fabric.microsoft.com](https://app.fabric.microsoft.com) — Fabric
6. Complete MFA if prompted
@@ -178,9 +215,9 @@ az resource show --ids /subscriptions//resourceGroups//providers/Micros
az fabric capacity resume --capacity-name --resource-group
```
-### AI Search Connection Fails in AI Foundry Playground
+### AI Search Connection Fails in Microsoft Foundry Playground
-Verify RBAC roles are assigned to the AI Foundry identities:
+Verify RBAC roles are assigned to the Microsoft Foundry identities:
```bash
# Get the AI Search resource ID
@@ -191,7 +228,7 @@ az role assignment list --scope $SEARCH_ID --output table
```
Required roles on the AI Search service:
-- **Search Service Contributor** — For the AI Foundry account and project managed identities
+- **Search Service Contributor** — For the Microsoft Foundry account and project managed identities
- **Search Index Data Contributor** — For read/write access to index data
- **Search Index Data Reader** — For read access to index data
@@ -249,7 +286,7 @@ pwsh ./scripts/automationScripts/.ps1
Once verification is complete:
1. **Upload documents** to the bronze lakehouse for indexing
-2. **Test the AI Foundry playground** with your indexed content
+2. **Test the Microsoft Foundry playground** with your indexed content
3. **Configure additional models** if needed
-4. **[Deploy your app](./deploy_app_from_foundry.md)** from the AI Foundry playground
+4. **[Deploy your app](./deploy_app_from_foundry.md)** from the Microsoft Foundry playground
5. **Review governance** in Microsoft Purview
diff --git a/docs/postgresql_mirroring.md b/docs/postgresql_mirroring.md
new file mode 100644
index 0000000..b69f71f
--- /dev/null
+++ b/docs/postgresql_mirroring.md
@@ -0,0 +1,327 @@
+# PostgreSQL Mirroring to Fabric
+
+This guide explains how to complete PostgreSQL mirroring in Microsoft Fabric after deployment.
+
+Mirroring automation in the current branch is set for PostgreSQL deployments where `postgreSqlNetworkIsolation = false`.
+
+If you want full PostgreSQL isolation, the database deployment can still succeed, but end-to-end Fabric mirroring moves to the Fabric VNet gateway path.
+
+If you are not changing the network approach right now, there are only two valid post-deployment outcomes:
+
+1. Use a public-network path that lets Fabric reach PostgreSQL, then complete the mirror.
+2. Keep PostgreSQL private and treat mirroring as deferred.
+
+Do not expect a private-endpoint PostgreSQL deployment to produce a working Fabric mirror during the main deployment workflow alone.
+
+## Minimal Manual Fallback
+
+Use the shortest follow-up path below after deployment.
+
+Choose one path up front:
+
+- If you do not want to expose PostgreSQL publicly for Fabric, stop after the rest of post-provision validation and leave mirroring for a later run.
+- If you want the mirror now, use the public-access path below.
+
+### Public Access Enabled
+
+Use this path when the PostgreSQL server has `publicNetworkAccess=Enabled`. In this repo, that corresponds to `postgreSqlNetworkIsolation = false`.
+
+1. In Azure Portal, open the PostgreSQL Flexible Server.
+2. Open **Fabric Mirroring** on the server and let the portal prepare the server-side prerequisites.
+ - Microsoft documentation explicitly calls out this page as the path that automates the server-side mirroring prerequisites.
+ - This overlaps with what `prepare_postgresql_for_mirroring.ps1` is trying to automate.
+ - It does **not** create the Fabric connection object or the mirrored database item in the Fabric workspace.
+3. In **Networking**, make sure Fabric can reach the server.
+ - Shortest path: add the `0.0.0.0` firewall rule to allow Azure services.
+ - If you only need to read the password secret yourself, temporarily add only your client IP to Key Vault, retrieve the secret, then remove the IP again.
+4. In Fabric, create a new **Mirrored Azure Database for PostgreSQL** item.
+5. Use these deployment values instead of hardcoding names:
+
+```powershell
+azd env get-value postgreSqlServerFqdn
+azd env get-value postgreSqlMirrorConnectionModeOut
+azd env get-value postgreSqlMirrorConnectionUserNameOut
+azd env get-value postgreSqlMirrorConnectionSecretNameOut
+```
+
+6. Read the password from Key Vault:
+
+```powershell
+az keyvault secret show --vault-name --name --query value -o tsv
+```
+
+7. If the admin login fails in Fabric, switch to the dedicated Fabric PostgreSQL role instead of continuing to retry `pgadmin`.
+8. After the connection is created, persist the connection ID for future reruns:
+
+```powershell
+azd env set-value fabricPostgresConnectionId ""
+```
+
+### Private Network or Private Endpoint
+
+Use this path when the PostgreSQL server is private-only or Fabric cannot reach it over public networking.
+
+1. Treat mirroring as deferred for this provisioning cycle.
+2. Use the PostgreSQL server's **Fabric Mirroring** page in Azure Portal only if you want to confirm the source-server prerequisite experience.
+3. Continue validating the rest of the deployment: Fabric workspace, lakehouses, PostgreSQL server, AI Search, and Purview.
+4. For end-to-end mirroring with PostgreSQL kept private, use the Fabric VNet gateway route.
+
+### What to Do First
+
+If you just need the mirror working with the fewest manual steps:
+
+1. Prefer **Public Access Enabled** plus **Allow Azure services** when your deployment intentionally permits public connectivity.
+2. Prefer the PostgreSQL server's **Fabric Mirroring** page in Azure Portal over running local SQL.
+3. Use the dedicated Fabric role if the admin login is rejected by Fabric.
+
+If you are intentionally staying private for now, the correct action is to skip mirror creation for this provisioning test and continue validating the rest of the deployment.
+
+## Recommended Repo Flow
+
+In this repo, mirroring should be treated as a deliberate follow-up step after the main deployment completes.
+
+That means:
+
+1. `azd up` deploys the infrastructure and core postprovision automation.
+2. PostgreSQL mirroring is not a required same-run success criterion.
+3. If you want mirroring, run it afterward from a runner that can actually reach PostgreSQL, Key Vault, and Fabric.
+
+The cleanest sequence is:
+
+1. Run `azd up`.
+2. Validate the deployment with [post_deployment_steps.md](./post_deployment_steps.md).
+3. Connect to the deployed VM or another runner with PostgreSQL network reachability.
+4. Run the mirroring follow-up flow.
+5. Verify the Fabric connection and mirrored database.
+
+Running from the deployed VM is usually the least fragile option because it avoids local DNS, firewall, VPN, and endpoint-security issues.
+
+### Follow-Up Wrapper
+
+If you want the repo-managed sequence, run:
+
+```powershell
+pwsh -NoProfile -File .\scripts\automationScripts\FabricWorkspace\Mirror\run_postgresql_mirroring_followup.ps1
+```
+
+That wrapper runs, in order:
+
+1. `test_postgresql_mirroring_prereqs.ps1`
+2. `prepare_postgresql_for_mirroring.ps1`
+3. `create_postgresql_mirror.ps1`
+
+### Preflight First
+
+Before attempting mirroring from a VM or any other runner, use the read-only preflight:
+
+```powershell
+pwsh -NoProfile -File .\scripts\automationScripts\FabricWorkspace\Mirror\test_postgresql_mirroring_prereqs.ps1
+```
+
+It checks the things that usually break the flow:
+
+1. `az` and `azd` availability
+2. Azure sign-in
+3. required `azd` environment values
+4. DNS resolution for PostgreSQL
+5. TCP connectivity to PostgreSQL on `5432`
+6. Fabric token acquisition
+
+If preflight fails, fix the runner first instead of continuing into SQL prep or Fabric connection creation.
+
+## Automation status
+
+What is automated today:
+
+- PostgreSQL server deployment during `azd up`.
+- PostgreSQL mirroring prep during `azd up` postprovision (server parameters, auth mode, mirroring role/grants, and seed table).
+- Manual or follow-up Fabric connection creation for PostgreSQL mirroring.
+- Manual or follow-up mirror creation after the Fabric connection is resolved.
+
+## Why a Fabric Connection Is Required
+
+The Fabric mirroring API requires a Fabric "connection" object that stores the PostgreSQL endpoint and credentials. The mirror call only accepts a `connectionId` and database name, so a valid Fabric connection must exist before mirroring can be created.
+
+## Prerequisites
+
+- Deployment finished, and PostgreSQL Flexible Server exists.
+- You can sign in to Fabric (app.fabric.microsoft.com) with access to the workspace.
+- PostgreSQL authentication mode is **PostgreSQL and Microsoft Entra authentication** (password auth enabled).
+- You have access to the Key Vault that stores the PostgreSQL secrets.
+- Decide which connection mode you are using: `fabricUser` (default) or `admin` via `postgreSqlMirrorConnectionMode`.
+
+## Step 1: Confirm PostgreSQL Details
+
+Get the PostgreSQL server FQDN and database name:
+
+- FQDN: from `azd env get-value postgreSqlServerFqdn`
+- Database name: `postgres` (default) or your custom DB
+- Connection mode: from `azd env get-value postgreSqlMirrorConnectionModeOut`
+- Fabric login: from `azd env get-value postgreSqlMirrorConnectionUserNameOut`
+- Fabric password secret name: from `azd env get-value postgreSqlMirrorConnectionSecretNameOut`
+
+## Step 2: Prepare the Database (Run Automatically During Postprovision)
+
+The mirroring prep script configures the server and creates a seed table so Fabric always finds at least one table to replicate.
+
+During `azd up`, postprovision now runs:
+
+```powershell
+pwsh ./scripts/automationScripts/FabricWorkspace/Mirror/prepare_postgresql_for_mirroring.ps1
+```
+
+Re-run it manually only if you need to repair or reapply the PostgreSQL mirroring readiness settings.
+
+### Manual rerun
+
+Run:
+
+```powershell
+pwsh ./scripts/automationScripts/FabricWorkspace/Mirror/prepare_postgresql_for_mirroring.ps1
+```
+
+If you are running from a non-VNet host and the Key Vault blocks public access, set:
+
+```powershell
+$env:POSTGRES_TEMP_ENABLE_KV_PUBLIC_ACCESS = 'true'
+```
+
+What it does:
+
+- Invokes Azure-side Fabric mirroring enablement for the selected database when available.
+- Creates or validates the `fabric_user` role when mode is `fabricUser`.
+- Ensures PostgreSQL auth modes are enabled (password + Entra).
+- Grants `azure_cdc_admin` and database permissions.
+- Creates a seed table: `public.fabric_mirror_seed` (owned by the mirroring identity, either `fabric_user` or `pgadmin`).
+- Uses `psql` fallback when `rdbms-connect` cannot install.
+
+### Manual (only if automation fails)
+
+If your deployment allows public access, the shortest supported fallback is usually the server's **Fabric Mirroring** page in Azure Portal instead of running these SQL statements manually.
+
+Use that portal page only for the server-side prerequisite work. You still need either the automation or a manual Fabric connection and mirrored database creation step afterward.
+
+Connect as `pgadmin` in the `postgres` database and run:
+
+```sql
+CREATE ROLE "fabric_user" CREATEDB CREATEROLE LOGIN REPLICATION PASSWORD '';
+GRANT azure_cdc_admin TO "fabric_user";
+GRANT CREATE ON DATABASE "postgres" TO "fabric_user";
+GRANT USAGE ON SCHEMA public TO "fabric_user";
+ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO "fabric_user";
+
+CREATE TABLE IF NOT EXISTS public.fabric_mirror_seed (
+ id bigserial PRIMARY KEY,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+INSERT INTO public.fabric_mirror_seed (created_at)
+SELECT now()
+WHERE NOT EXISTS (SELECT 1 FROM public.fabric_mirror_seed);
+
+ALTER TABLE public.fabric_mirror_seed OWNER TO "fabric_user";
+```
+
+Update the Key Vault secret after you set the password (automation already does this unless it failed):
+
+```powershell
+az keyvault secret set --vault-name --name postgres-fabric-user-password --value ""
+```
+
+> Ownership note: Fabric requires the mirror user to own tables. If you create tables as `pgadmin`, change ownership to `fabric_user`.
+
+## Step 3: Create or Reuse the Fabric Connection (Automated by Default)
+
+Run:
+
+```powershell
+pwsh ./scripts/automationScripts/FabricWorkspace/Mirror/create_postgresql_mirror.ps1
+```
+
+What the script does now:
+
+- Reuses `fabricPostgresConnectionId` when it is already stored in `azd`.
+- Otherwise resolves the connection login from `postgreSqlMirrorConnectionUserNameOut`.
+- Resolves the connection password secret name from `postgreSqlMirrorConnectionSecretNameOut`.
+- Reads the chosen secret from Key Vault, creates or reuses the Fabric PostgreSQL connection, and stores the resulting `fabricPostgresConnectionId` back into `azd`.
+- Creates the mirrored database after the connection is available.
+
+If your PostgreSQL server is reachable only through a Fabric VNet data gateway, set the gateway ID before rerunning the script:
+
+```powershell
+azd env set-value fabricPostgresGatewayId ""
+```
+
+Without `fabricPostgresGatewayId`, the script creates a standard cloud connection.
+
+### Manual fallback
+
+If your deployment has public access enabled, try the **Minimal Manual Fallback** section first. It is shorter than manually creating the Fabric connection from scratch.
+
+If you need to create the Fabric connection manually, do not hardcode `fabric_user`, `pgadmin`, or the secret name. Read the values from the deployment outputs first:
+
+```powershell
+azd env get-value postgreSqlMirrorConnectionModeOut
+azd env get-value postgreSqlMirrorConnectionUserNameOut
+azd env get-value postgreSqlMirrorConnectionSecretNameOut
+```
+
+Then in Fabric:
+
+1. Open the Fabric workspace.
+2. Go to **Settings** -> **Manage connections and gateways**.
+3. Select **New connection** -> **PostgreSQL**.
+4. Enter:
+ - Server: PostgreSQL FQDN (example: `pg-.postgres.database.azure.com`)
+ - Database: `postgres` (or your custom DB)
+ - User: the value from `postgreSqlMirrorConnectionUserNameOut`
+ - Password: the Key Vault secret value stored under `postgreSqlMirrorConnectionSecretNameOut`
+5. Save and copy the **Connection ID**.
+
+## Step 4: Persist the Connection ID in azd (only if you created it manually)
+
+```powershell
+azd env set-value fabricPostgresConnectionId ""
+azd env set-value POSTGRES_DATABASE_NAME "postgres"
+```
+
+## Step 5: Create the Mirror
+
+If the previous script already created the connection automatically, re-running it is safe and idempotent. If you created the connection manually, run it once now:
+
+```powershell
+./scripts/automationScripts/FabricWorkspace/Mirror/create_postgresql_mirror.ps1
+```
+
+## Verify
+
+- In Fabric, a mirrored database named `pg-mirror-` should appear.
+- Re-running the script is safe; it will skip if the mirror already exists.
+
+## Notes
+
+- The deployment now attempts to create or reuse the Fabric PostgreSQL connection automatically before creating the mirror.
+- If automatic connection creation cannot reach Key Vault or the source database, the script leaves a manual fallback path.
+- Without public reachability or `fabricPostgresGatewayId`, a private PostgreSQL server is not expected to mirror successfully.
+- If you rotate passwords, update the Fabric connection in the workspace.
+
+## Troubleshooting
+
+### Invalid credentials
+
+- Ensure PostgreSQL auth is **PostgreSQL and Microsoft Entra authentication** (password auth enabled).
+- Use the login from `postgreSqlMirrorConnectionUserNameOut` in the Fabric connection.
+- Verify the Key Vault secret named by `postgreSqlMirrorConnectionSecretNameOut` matches the chosen connection credential.
+
+### Private networking or gateway-required sources
+
+- If the PostgreSQL server is private-only, set `fabricPostgresGatewayId` in `azd` before rerunning the script so the connection is created under the Fabric VNet gateway.
+- If the gateway ID is not set, the automation uses a shareable cloud connection.
+- If automation still cannot complete SQL prep from your machine, use the PostgreSQL server's **Fabric Mirroring** page first, then fall back to a Bastion or other VNet-connected host only if needed.
+
+### Must be owner of table
+
+If Fabric reports `must be owner of table `:
+
+```sql
+ALTER TABLE public.fabric_mirror_seed OWNER TO "fabric_user";
+```
diff --git a/docs/quota_check.md b/docs/quota_check.md
index 46ee0e6..d809a32 100644
--- a/docs/quota_check.md
+++ b/docs/quota_check.md
@@ -78,8 +78,8 @@ The final table lists regions with available quota. You can select any of these
## **If using VS Code or Codespaces**
1. Open the terminal in VS Code or Codespaces.
-2. If you're using VS Code, click the dropdown on the right side of the terminal window, and select `Git Bash`.
- 
+2. Use a terminal that can run bash. This is only for the quota check script; deployment uses PowerShell.
+ 
3. Navigate to the `scripts` folder where the script files are located and make the script as executable:
```sh
cd scripts
diff --git a/img/Architecture/Depoly-AI-App-in-Prod-Architecture-final.png b/img/Architecture/Depoly-AI-App-in-Prod-Architecture-final.png
new file mode 100644
index 0000000..59f8a33
Binary files /dev/null and b/img/Architecture/Depoly-AI-App-in-Prod-Architecture-final.png differ
diff --git a/infra/main.bicep b/infra/main.bicep
index c7433e4..f4543c8 100644
--- a/infra/main.bicep
+++ b/infra/main.bicep
@@ -8,65 +8,157 @@
targetScope = 'resourceGroup'
metadata description = 'Deploys AI Landing Zone with Fabric capacity extension'
-import * as types from '../submodules/ai-landing-zone/bicep/infra/common/types.bicep'
+import * as const from '../submodules/ai-landing-zone/constants/constants.bicep'
// ========================================
-// PARAMETERS - AI LANDING ZONE (Required)
+// PARAMETERS - AI LANDING ZONE (Pass-through)
// ========================================
-@description('Per-service deployment toggles for the AI Landing Zone submodule.')
-param deployToggles object = {}
+@description('Name of the Azure Developer CLI environment.')
+param environmentName string
-@description('Optional. Enable platform landing zone integration.')
-param flagPlatformLandingZone bool = false
-
-@description('Optional. Existing resource IDs to reuse.')
-param resourceIds types.resourceIdsType = {}
-
-@description('Optional. Azure region for resources.')
+@description('Azure region for resources.')
param location string = resourceGroup().location
-@description('Optional. Environment name for resource naming.')
-param environmentName string = ''
-
-@description('Optional. Resource naming token.')
-param resourceToken string = toLower(uniqueString(subscription().id, resourceGroup().name, location))
-
-@description('Optional. Base name for resources.')
-param baseName string = substring(resourceToken, 0, 12)
+@description('Azure region for Cosmos DB.')
+param cosmosLocation string = resourceGroup().location
-@description('Optional. AI Search settings.')
-param aiSearchDefinition types.kSAISearchDefinitionType?
+@description('Principal ID for role assignments.')
+param principalId string
-@description('Optional. Additional Entra object IDs (users or groups) granted AI Search contributor roles.')
+@description('Principal type for role assignments.')
+@allowed([
+ 'User'
+ 'ServicePrincipal'
+ 'Group'
+])
+param principalType string = 'User'
+
+@description('Tags for all resources.')
+param deploymentTags object = {}
+
+@description('App Configuration label.')
+param appConfigLabel string = 'ai-lz'
+
+@description('Enable network isolation.')
+param networkIsolation bool = false
+
+@description('Use an existing VNet.')
+param useExistingVNet bool = false
+
+@description('Existing VNet resource ID.')
+param existingVnetResourceId string = ''
+
+@description('Subnet names.')
+param agentSubnetName string = 'agent-subnet'
+param peSubnetName string = 'pe-subnet'
+param gatewaySubnetName string = 'gateway-subnet'
+param azureBastionSubnetName string = 'AzureBastionSubnet'
+param azureFirewallSubnetName string = 'AzureFirewallSubnet'
+param azureAppGatewaySubnetName string = 'AppGatewaySubnet'
+param jumpboxSubnetName string = 'jumpbox-subnet'
+param apiManagementSubnetName string = 'api-management-subnet'
+param acaEnvironmentSubnetName string = 'aca-environment-subnet'
+param devopsBuildAgentsSubnetName string = 'devops-build-agents-subnet'
+
+@description('VNet address prefixes.')
+param vnetAddressPrefixes array = [
+ '192.168.0.0/21'
+]
+
+@description('Subnet address prefixes.')
+param agentSubnetPrefix string = '192.168.0.0/24'
+param acaEnvironmentSubnetPrefix string = '192.168.1.0/24'
+param peSubnetPrefix string = '192.168.2.0/26'
+param azureBastionSubnetPrefix string = '192.168.2.64/26'
+param azureFirewallSubnetPrefix string = '192.168.2.128/26'
+param gatewaySubnetPrefix string = '192.168.2.192/26'
+param azureAppGatewaySubnetPrefix string = '192.168.3.0/27'
+param apimSubnetPrefix string = '192.168.3.32/27'
+param jumpboxSubnetPrefix string = '192.168.3.64/27'
+param devopsBuildAgentsSubnetPrefix string = '192.168.3.96/27'
+
+@description('Feature flags.')
+param deployGroundingWithBing bool = true
+param deployAiFoundry bool = true
+param deployAiFoundrySubnet bool = true
+param deployAppConfig bool = true
+param deployKeyVault bool = true
+param deployVmKeyVault bool = true
+param deployLogAnalytics bool = false
+param deployAppInsights bool = true
+param deploySearchService bool = true
+param deployStorageAccount bool = true
+param deployCosmosDb bool = true
+param deployContainerApps bool = true
+param deployContainerRegistry bool = true
+param deployContainerEnv bool = true
+param deployVM bool = true
+param deploySubnets bool = true
+param deployNsgs bool = true
+param sideBySideDeploy bool = true
+param deploySoftware bool = true
+param deployApim bool = false
+param deployAfProject bool = true
+param deployAAfAgentSvc bool = true
+param enableAgenticRetrieval bool = false
+
+@description('Existing resource IDs to reuse.')
+param aiSearchResourceId string = ''
+@description('Optional additional Entra object IDs to grant Search roles.')
param aiSearchAdditionalAccessObjectIds array = []
+param aiFoundryStorageAccountResourceId string = ''
+param aiFoundryCosmosDBAccountResourceId string = ''
+param keyVaultResourceId string = ''
-@description('Optional. Enable telemetry.')
-param enableTelemetry bool = true
-
-@description('Optional. Tags for all resources.')
-param tags object = {}
-
-// All other optional parameters from AI Landing Zone - pass as needed
-@description('Optional. Private DNS Zone configuration.')
-param privateDnsZonesDefinition types.privateDnsZonesDefinitionType = {}
+@description('Identity options.')
+param useUAI bool = false
+param useCAppAPIKey bool = false
+param useZoneRedundancy bool = false
-@description('Optional. Enable Defender for AI.')
-param enableDefenderForAI bool = true
+@description('Resource naming token.')
+param resourceToken string = toLower(uniqueString(subscription().id, environmentName, location))
-@description('Optional. NSG definitions per subnet.')
-param nsgDefinitions types.nsgPerSubnetDefinitionsType?
-
-@description('Optional. Virtual Network configuration.')
-param vNetDefinition types.vNetDefinitionType?
-
-@description('Optional. AI Foundry configuration.')
-param aiFoundryDefinition types.aiFoundryDefinitionType = {}
-
-@description('Optional. API Management configuration.')
-param apimDefinition types.apimDefinitionType?
+@description('Short base name for resource naming.')
+param baseName string = substring(resourceToken, 0, 12)
-// Add more parameters as needed from AI Landing Zone...
+@description('Resource names.')
+param aiFoundryAccountName string = '${const.abbrs.ai.aiFoundry}${resourceToken}'
+param aiFoundryProjectName string = '${const.abbrs.ai.aiFoundryProject}${resourceToken}'
+param aiFoundryStorageAccountName string = replace('${const.abbrs.storage.storageAccount}${const.abbrs.ai.aiFoundry}${resourceToken}', '-', '')
+param aiFoundrySearchServiceName string = '${const.abbrs.ai.aiSearch}${const.abbrs.ai.aiFoundry}${resourceToken}'
+param aiFoundryCosmosDbName string = '${const.abbrs.databases.cosmosDBDatabase}${const.abbrs.ai.aiFoundry}${resourceToken}'
+param bingSearchName string = '${const.abbrs.ai.bing}${resourceToken}'
+param appConfigName string = '${const.abbrs.configuration.appConfiguration}${resourceToken}'
+param appInsightsName string = '${const.abbrs.managementGovernance.applicationInsights}${resourceToken}'
+param containerEnvName string = '${const.abbrs.containers.containerAppsEnvironment}${resourceToken}'
+param containerRegistryName string = '${const.abbrs.containers.containerRegistry}${resourceToken}'
+param dbAccountName string = '${const.abbrs.databases.cosmosDBDatabase}${resourceToken}'
+param dbDatabaseName string = '${const.abbrs.databases.cosmosDBDatabase}db${resourceToken}'
+param keyVaultName string = '${const.abbrs.security.keyVault}${resourceToken}'
+param logAnalyticsWorkspaceName string = '${const.abbrs.managementGovernance.logAnalyticsWorkspace}${resourceToken}'
+param searchServiceName string = '${const.abbrs.ai.aiSearch}${resourceToken}'
+param storageAccountName string = '${const.abbrs.storage.storageAccount}${resourceToken}'
+param vnetName string = '${const.abbrs.networking.virtualNetwork}${resourceToken}'
+
+@description('Model deployments and container app configuration.')
+param modelDeploymentList array
+param containerAppsList array
+param workloadProfiles array = []
+
+@description('Miscellaneous settings.')
+param acrDnsSuffix string = (environment().name == 'AzureUSGovernment' ? 'azurecr.us' : environment().name == 'AzureChinaCloud' ? 'azurecr.cn' : 'azurecr.io')
+param databaseContainersList array
+param vmName string = ''
+param vmUserName string = ''
+@secure()
+param vmAdminPassword string
+param vmSize string = 'Standard_D8s_v5'
+param vmImageSku string = 'win11-25h2-ent'
+param vmImagePublisher string = 'MicrosoftWindowsDesktop'
+param vmImageOffer string = 'windows-11'
+param vmImageVersion string = 'latest'
+param storageAccountContainersList array
// ========================================
// PARAMETERS - FABRIC EXTENSION
@@ -114,37 +206,119 @@ param purviewAccountResourceId string = ''
param purviewCollectionName string = ''
// ========================================
-// AI LANDING ZONE DEPLOYMENT
+// PARAMETERS - POSTGRESQL FLEXIBLE SERVER
// ========================================
-module aiLandingZone '../submodules/ai-landing-zone/bicep/deploy/main.bicep' = {
- name: 'ai-landing-zone'
- params: {
- deployToggles: deployToggles
- flagPlatformLandingZone: flagPlatformLandingZone
- resourceIds: resourceIds
- location: location
- resourceToken: resourceToken
- baseName: baseName
- enableTelemetry: enableTelemetry
- tags: tags
- privateDnsZonesDefinition: privateDnsZonesDefinition
- enableDefenderForAI: enableDefenderForAI
- nsgDefinitions: nsgDefinitions
- vNetDefinition: vNetDefinition
- aiFoundryDefinition: aiFoundryDefinition
- apimDefinition: apimDefinition
- aiSearchDefinition: aiSearchDefinition
- // Add more parameters as needed...
- }
+@description('Deploy PostgreSQL Flexible Server.')
+param deployPostgreSql bool = false
+
+@description('PostgreSQL Flexible Server name.')
+param postgreSqlServerName string = 'pg${resourceToken}'
+
+@description('Enable network isolation for PostgreSQL (private DNS + private endpoint).')
+param postgreSqlNetworkIsolation bool = networkIsolation
+
+@description('Create and link the PostgreSQL private DNS zone to the VNet.')
+param deployPostgreSqlPrivateDnsLink bool = true
+
+@description('Optional override for the PostgreSQL private DNS VNet link name.')
+param postgreSqlPrivateDnsLinkNameOverride string = ''
+
+@description('PostgreSQL admin username.')
+param postgreSqlAdminLogin string = 'pgadmin'
+
+@description('PostgreSQL admin password.')
+@secure()
+param postgreSqlAdminPassword string
+
+@description('Store PostgreSQL admin password in Key Vault.')
+param enablePostgreSqlKeyVaultSecret bool = true
+
+@description('Key Vault secret name for PostgreSQL admin password.')
+param postgreSqlAdminSecretName string = 'postgres-admin-password'
+
+@description('PostgreSQL role name for Fabric mirroring.')
+param postgreSqlFabricUserName string = 'fabric_user'
+
+@description('Key Vault secret name for the Fabric mirroring PostgreSQL role password.')
+param postgreSqlFabricUserSecretName string = 'postgres-fabric-user-password'
+
+@description('Credential mode used for the Fabric PostgreSQL connection. Use fabricUser for the production-oriented least-privilege path or admin for a simplified demo automation path.')
+@allowed([
+ 'fabricUser'
+ 'admin'
+])
+param postgreSqlMirrorConnectionMode string = 'fabricUser'
+
+@description('Authentication configuration for PostgreSQL Flexible Server. Defaults to both Microsoft Entra and password authentication enabled so Fabric mirroring can be configured immediately after deployment.')
+param postgreSqlAuthConfig resourceInput<'Microsoft.DBforPostgreSQL/flexibleServers@2025-06-01-preview'>.properties.authConfig = {
+ activeDirectoryAuth: 'Enabled'
+ passwordAuth: 'Enabled'
}
+@description('PostgreSQL SKU name (tier + family + cores).')
+param postgreSqlSkuName string = 'Standard_D2s_v3'
+
+@description('PostgreSQL tier aligned with SKU.')
+@allowed([
+ 'Burstable'
+ 'GeneralPurpose'
+ 'MemoryOptimized'
+])
+param postgreSqlTier string = 'GeneralPurpose'
+
+@description('PostgreSQL availability zone. -1 means no zone preference.')
+@allowed([
+ -1
+ 1
+ 2
+ 3
+])
+param postgreSqlAvailabilityZone int = -1
+
+@description('PostgreSQL high availability mode.')
+@allowed([
+ 'Disabled'
+ 'SameZone'
+ 'ZoneRedundant'
+])
+param postgreSqlHighAvailability string = 'Disabled'
+
+@description('PostgreSQL high availability standby zone. -1 means no zone preference.')
+@allowed([
+ -1
+ 1
+ 2
+ 3
+])
+param postgreSqlHighAvailabilityZone int = -1
+
+@description('PostgreSQL version.')
+@allowed([
+ '11'
+ '12'
+ '13'
+ '14'
+ '15'
+ '16'
+ '17'
+ '18'
+])
+param postgreSqlVersion string = '16'
+
+@description('PostgreSQL storage size in GB.')
+param postgreSqlStorageSizeGB int = 32
+@description('Generated value used when postgreSqlAdminPassword is left as the placeholder token.')
+@secure()
+param generatedPostgreSqlAdminPassword string = newGuid()
+
// ========================================
// FABRIC CAPACITY DEPLOYMENT
// ========================================
var effectiveFabricCapacityMode = fabricCapacityMode
var effectiveFabricWorkspaceMode = fabricWorkspaceMode
+var effectiveLocation = !empty(location) ? location : resourceGroup().location
var envSlugSanitized = replace(replace(replace(replace(replace(replace(replace(replace(toLower(environmentName), ' ', ''), '-', ''), '_', ''), '.', ''), '/', ''), '\\', ''), ':', ''), ',', '')
@@ -152,37 +326,129 @@ var envSlugTrimmed = substring(envSlugSanitized, 0, min(40, length(envSlugSaniti
var capacityNameBase = !empty(envSlugTrimmed) ? 'fabric${envSlugTrimmed}' : 'fabric${baseName}'
var capacityName = substring(capacityNameBase, 0, min(50, length(capacityNameBase)))
+var effectiveVnetResourceId = useExistingVNet && !empty(existingVnetResourceId)
+ ? existingVnetResourceId
+ : resourceId('Microsoft.Network/virtualNetworks', vnetName)
+
+var postgreSqlPrivateDnsZoneName = 'privatelink.postgres.database.azure.com'
+var postgreSqlPrivateDnsLinkNameRaw = '${postgreSqlServerName}-vnetlink'
+var postgreSqlPrivateEndpointNameRaw = '${postgreSqlServerName}-pe'
+var postgreSqlPrivateDnsLinkName = substring(postgreSqlPrivateDnsLinkNameRaw, 0, min(80, length(postgreSqlPrivateDnsLinkNameRaw)))
+var effectivePostgreSqlPrivateDnsLinkName = !empty(postgreSqlPrivateDnsLinkNameOverride)
+ ? postgreSqlPrivateDnsLinkNameOverride
+ : postgreSqlPrivateDnsLinkName
+var postgreSqlPrivateEndpointName = substring(postgreSqlPrivateEndpointNameRaw, 0, min(80, length(postgreSqlPrivateEndpointNameRaw)))
+
+var effectiveKeyVaultResourceId = !empty(keyVaultResourceId)
+ ? keyVaultResourceId
+ : resourceId('Microsoft.KeyVault/vaults', keyVaultName)
+
+var effectivePostgreSqlAdminPassword = postgreSqlAdminPassword == '$(secretOrRandomPassword)'
+ ? '${uniqueString(subscription().id, resourceGroup().id, postgreSqlServerName)}!${replace(generatedPostgreSqlAdminPassword, '-', '')}'
+ : postgreSqlAdminPassword
+
+resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = {
+ name: last(split(effectiveKeyVaultResourceId, '/'))
+}
+
+resource postgreSqlPrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (deployPostgreSql && postgreSqlNetworkIsolation) {
+ name: postgreSqlPrivateDnsZoneName
+ location: 'global'
+ tags: deploymentTags
+}
+
+resource postgreSqlPrivateDnsZoneVnetLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = if (deployPostgreSql && postgreSqlNetworkIsolation && deployPostgreSqlPrivateDnsLink) {
+ name: effectivePostgreSqlPrivateDnsLinkName
+ parent: postgreSqlPrivateDnsZone
+ location: 'global'
+ properties: {
+ virtualNetwork: {
+ id: effectiveVnetResourceId
+ }
+ registrationEnabled: false
+ }
+}
+
+var postgreSqlPrivateEndpoints = postgreSqlNetworkIsolation ? [
+ {
+ name: postgreSqlPrivateEndpointName
+ subnetResourceId: '${effectiveVnetResourceId}/subnets/${peSubnetName}'
+ privateDnsZoneGroup: {
+ privateDnsZoneGroupConfigs: [
+ {
+ privateDnsZoneResourceId: postgreSqlPrivateDnsZone.id
+ }
+ ]
+ }
+ }
+] : []
+
+module postgreSqlFlexibleServer 'br/public:avm/res/db-for-postgre-sql/flexible-server:0.15.2' = if (deployPostgreSql) {
+ name: 'postgresql-flexible'
+ params: {
+ availabilityZone: postgreSqlAvailabilityZone
+ highAvailability: postgreSqlHighAvailability
+ highAvailabilityZone: postgreSqlHighAvailabilityZone
+ name: postgreSqlServerName
+ skuName: postgreSqlSkuName
+ tier: postgreSqlTier
+ administratorLogin: postgreSqlAdminLogin
+ administratorLoginPassword: effectivePostgreSqlAdminPassword
+ authConfig: postgreSqlAuthConfig
+ managedIdentities: {
+ systemAssigned: true
+ }
+ publicNetworkAccess: postgreSqlNetworkIsolation ? 'Disabled' : 'Enabled'
+ version: postgreSqlVersion
+ storageSizeGB: postgreSqlStorageSizeGB
+ privateEndpoints: postgreSqlPrivateEndpoints
+ tags: deploymentTags
+ }
+}
+
+resource postgreSqlAdminSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (deployPostgreSql && enablePostgreSqlKeyVaultSecret) {
+ name: postgreSqlAdminSecretName
+ parent: keyVault
+ properties: {
+ value: effectivePostgreSqlAdminPassword
+ }
+}
+
module fabricCapacity 'modules/fabric-capacity.bicep' = if (effectiveFabricCapacityMode == 'create') {
name: 'fabric-capacity'
params: {
capacityName: capacityName
- location: location
+ location: effectiveLocation
sku: fabricCapacitySku
- adminMembers: fabricCapacityAdmins
- tags: tags
+ adminMembers: union(deployer().?userPrincipalName == null
+ ? [deployer().objectId]
+ : [deployer().userPrincipalName], fabricCapacityAdmins)
+ tags: deploymentTags
}
- dependsOn: [
- aiLandingZone
- ]
}
// ========================================
// OUTPUTS - Pass through from AI Landing Zone
// ========================================
-output virtualNetworkResourceId string = aiLandingZone.outputs.virtualNetworkResourceId
-output keyVaultResourceId string = aiLandingZone.outputs.keyVaultResourceId
-output storageAccountResourceId string = aiLandingZone.outputs.storageAccountResourceId
-output aiFoundryProjectName string = aiLandingZone.outputs.aiFoundryProjectName
-output logAnalyticsWorkspaceResourceId string = aiLandingZone.outputs.logAnalyticsWorkspaceResourceId
-output aiSearchResourceId string = aiLandingZone.outputs.aiSearchResourceId
-output aiSearchName string = aiLandingZone.outputs.aiSearchName
+var effectiveAiSearchResourceId = !empty(aiSearchResourceId)
+ ? aiSearchResourceId
+ : resourceId('Microsoft.Search/searchServices', searchServiceName)
+
+var effectiveStorageAccountResourceId = resourceId('Microsoft.Storage/storageAccounts', storageAccountName)
+
+output virtualNetworkResourceId string = effectiveVnetResourceId
+output keyVaultResourceId string = effectiveKeyVaultResourceId
+output storageAccountResourceId string = effectiveStorageAccountResourceId
+output aiFoundryProjectName string = aiFoundryProjectName
+output aiSearchResourceId string = effectiveAiSearchResourceId
+output aiSearchName string = searchServiceName
output aiSearchAdditionalAccessObjectIds array = aiSearchAdditionalAccessObjectIds
-// Subnet IDs (constructed from VNet ID using AI Landing Zone naming convention)
-output peSubnetResourceId string = '${aiLandingZone.outputs.virtualNetworkResourceId}/subnets/pe-subnet'
-output jumpboxSubnetResourceId string = '${aiLandingZone.outputs.virtualNetworkResourceId}/subnets/jumpbox-subnet'
-output agentSubnetResourceId string = '${aiLandingZone.outputs.virtualNetworkResourceId}/subnets/agent-subnet'
+// Subnet IDs (constructed from VNet ID and subnet names)
+output peSubnetResourceId string = '${effectiveVnetResourceId}/subnets/${peSubnetName}'
+output jumpboxSubnetResourceId string = '${effectiveVnetResourceId}/subnets/${jumpboxSubnetName}'
+output agentSubnetResourceId string = '${effectiveVnetResourceId}/subnets/${agentSubnetName}'
// Fabric outputs
output fabricCapacityModeOut string = effectiveFabricCapacityMode
@@ -200,6 +466,19 @@ output fabricCapacityResourceIdOut string = effectiveFabricCapacityResourceId
output fabricCapacityName string = effectiveFabricCapacityName
output fabricCapacityId string = effectiveFabricCapacityResourceId
+// PostgreSQL outputs
+output postgreSqlServerNameOut string = deployPostgreSql ? postgreSqlFlexibleServer.outputs.name : ''
+output postgreSqlServerResourceId string = deployPostgreSql ? postgreSqlFlexibleServer.outputs.resourceId : ''
+output postgreSqlServerFqdn string = deployPostgreSql ? postgreSqlFlexibleServer.outputs.fqdn : ''
+output postgreSqlSystemAssignedPrincipalId string = deployPostgreSql ? postgreSqlFlexibleServer.outputs.systemAssignedMIPrincipalId : ''
+output postgreSqlAdminSecretName string = deployPostgreSql && enablePostgreSqlKeyVaultSecret ? postgreSqlAdminSecretName : ''
+output postgreSqlAdminLoginOut string = deployPostgreSql ? postgreSqlAdminLogin : ''
+output postgreSqlFabricUserNameOut string = deployPostgreSql ? postgreSqlFabricUserName : ''
+output postgreSqlFabricUserSecretNameOut string = deployPostgreSql && enablePostgreSqlKeyVaultSecret ? postgreSqlFabricUserSecretName : ''
+output postgreSqlMirrorConnectionModeOut string = deployPostgreSql ? postgreSqlMirrorConnectionMode : ''
+output postgreSqlMirrorConnectionUserNameOut string = deployPostgreSql ? (postgreSqlMirrorConnectionMode == 'admin' ? postgreSqlAdminLogin : postgreSqlFabricUserName) : ''
+output postgreSqlMirrorConnectionSecretNameOut string = deployPostgreSql && enablePostgreSqlKeyVaultSecret ? (postgreSqlMirrorConnectionMode == 'admin' ? postgreSqlAdminSecretName : postgreSqlFabricUserSecretName) : ''
+
var effectiveFabricWorkspaceName = effectiveFabricWorkspaceMode == 'byo'
? (!empty(fabricWorkspaceName) ? fabricWorkspaceName : (!empty(environmentName) ? 'workspace-${environmentName}' : 'workspace-${baseName}'))
: (!empty(environmentName) ? 'workspace-${environmentName}' : 'workspace-${baseName}')
diff --git a/infra/main.bicepparam b/infra/main.bicepparam
index 9d68690..635b4a4 100644
--- a/infra/main.bicepparam
+++ b/infra/main.bicepparam
@@ -1,83 +1,208 @@
using './main.bicep'
// ========================================
-// AI LANDING ZONE PARAMETERS
-// ========================================
-
-// Per-service deployment toggles.
-param deployToggles = {
- acaEnvironmentNsg: true
- agentNsg: true
- apiManagement: false
- apiManagementNsg: false
- appConfig: true
- appInsights: true
- applicationGateway: true
- applicationGatewayNsg: true
- applicationGatewayPublicIp: true
- bastionHost: true
- bastionNsg: true
- buildVm: true
- containerApps: true
- containerEnv: true
- containerRegistry: true
- cosmosDb: true
- devopsBuildAgentsNsg: true
- firewall: false
- groundingWithBingSearch: true
- jumpVm: true
- jumpboxNsg: true
- keyVault: true
- logAnalytics: true
- peNsg: true
- searchService: true
- storageAccount: true
- virtualNetwork: true
- wafPolicy: true
+// REQUIRED INPUTS
+// ========================================
+
+param environmentName = readEnvironmentVariable('AZURE_ENV_NAME', '')
+param location = readEnvironmentVariable('AZURE_LOCATION', '')
+param cosmosLocation = readEnvironmentVariable('AZURE_COSMOS_LOCATION', '')
+// Entra object ID of the identity to grant RBAC (user, group, service principal, or UAI). Set this if Graph lookup is blocked.
+param principalId = ''
+param principalType = 'User'
+
+// ========================================
+// OPTIONAL INPUTS (Existing Resources)
+// ========================================
+// Use these to reuse existing resources instead of creating new ones.
+
+param aiSearchResourceId = ''
+param aiFoundryStorageAccountResourceId = ''
+param aiFoundryCosmosDBAccountResourceId = ''
+param keyVaultResourceId = ''
+param useExistingVNet = false
+param existingVnetResourceId = readEnvironmentVariable('EXISTING_VNET_RESOURCE_ID', '')
+
+// Optional additional Entra object IDs to grant Search roles.
+param aiSearchAdditionalAccessObjectIds = ['']
+
+// ========================================
+// OPTIONAL INPUTS (Configuration)
+// ========================================
+
+param deploymentTags = {}
+param appConfigLabel = 'ai-lz'
+param networkIsolation = true
+
+// Coordinate PostgreSQL networking with the overall isolation flag by default.
+param postgreSqlNetworkIsolation = networkIsolation
+// Skip this if a PostgreSQL private DNS zone is already linked to the VNet.
+param deployPostgreSqlPrivateDnsLink = true
+// Optional: use an existing VNet link name to avoid conflicts.
+param postgreSqlPrivateDnsLinkNameOverride = ''
+
+// ========================================
+// POSTGRESQL FLEXIBLE SERVER (Optional)
+// ========================================
+
+var postgreSqlEnvNameLower = toLower(environmentName)
+var postgreSqlEnvNameSanitized = replace(replace(replace(replace(replace(replace(replace(postgreSqlEnvNameLower, ' ', '-'), '_', '-'), '.', ''), '/', ''), '\\', ''), ':', ''), ',', '')
+var postgreSqlEnvNameTrimmed = substring(postgreSqlEnvNameSanitized, 0, min(50, length(postgreSqlEnvNameSanitized)))
+var postgreSqlServerNameBase = !empty(postgreSqlEnvNameTrimmed)
+ ? 'pg-${postgreSqlEnvNameTrimmed}'
+ : 'pg${uniqueString(readEnvironmentVariable('AZURE_SUBSCRIPTION_ID', ''), environmentName, location)}'
+
+param deployPostgreSql = true
+param postgreSqlServerName = substring(postgreSqlServerNameBase, 0, min(63, length(postgreSqlServerNameBase)))
+param postgreSqlAdminLogin = 'pgadmin'
+param postgreSqlAdminPassword = readEnvironmentVariable('POSTGRES_ADMIN_PASSWORD', '$(secretOrRandomPassword)')
+param enablePostgreSqlKeyVaultSecret = true
+param postgreSqlAdminSecretName = 'postgres-admin-password'
+param postgreSqlFabricUserName = 'fabric_user'
+param postgreSqlFabricUserSecretName = 'postgres-fabric-user-password'
+param postgreSqlMirrorConnectionMode = 'fabricUser'
+param postgreSqlAuthConfig = {
+ activeDirectoryAuth: 'Enabled'
+ passwordAuth: 'Enabled'
}
+param postgreSqlSkuName = 'Standard_D2s_v3'
+param postgreSqlTier = 'GeneralPurpose'
+param postgreSqlAvailabilityZone = 1
+param postgreSqlHighAvailability = 'Disabled'
+param postgreSqlHighAvailabilityZone = -1
+param postgreSqlVersion = '16'
+param postgreSqlStorageSizeGB = 32
-// Existing resource IDs (empty means create new) Add any resource ID separated by a comma to utilize existing items like Keyvault, Storage, etc..
-param resourceIds = {}
+// ========================================
+// FEATURE TOGGLES
+// ========================================
-// Enable platform landing zone integration. When true, private DNS zones and private endpoints are managed by the platform landing zone.
-param flagPlatformLandingZone = false
+param deployGroundingWithBing = false
+param deployAiFoundry = true
+param deployAiFoundrySubnet = false
+param deployAppConfig = true
+param deployKeyVault = true
+param deployVmKeyVault = readEnvironmentVariable('DEPLOY_VM_KEY_VAULT', 'true') == 'false'
+param deployLogAnalytics = false
+param deployAppInsights = true
+param deploySearchService = true
+param deployStorageAccount = true
+param deployCosmosDb = false
+param deployContainerApps = true
+param deployContainerRegistry = true
+param deployContainerEnv = true
+param deployVM = true
+param deploySubnets = readEnvironmentVariable('DEPLOY_SUBNETS', 'true') == 'true'
+param deployNsgs = true
+param sideBySideDeploy = readEnvironmentVariable('SIDE_BY_SIDE', 'true') == 'true'
+param deploySoftware = false
+param deployApim = false
+param deployAfProject = true
+param deployAAfAgentSvc = false
+param enableAgenticRetrieval = readEnvironmentVariable('ENABLE_AGENTIC_RETRIEVAL', 'false') == 'true'
-// Environment name for resource naming (uses AZURE_ENV_NAME from azd).
-param environmentName = readEnvironmentVariable('AZURE_ENV_NAME', '')
+// ========================================
+// ADVANCED SETTINGS (Defaults)
+// ========================================
-// Collapse the environment name into an Azure-safe token.
-var foundryEnvName = empty(environmentName)
- ? 'default'
- : toLower(replace(replace(replace(environmentName, ' ', '-'), '_', '-'), '.', '-'))
-
-param aiFoundryDefinition = {
- aiFoundryConfiguration: {
- accountName: 'ai-${foundryEnvName}'
- allowProjectManagement: true
- createCapabilityHosts: false
- disableLocalAuth: false
- project: {
- name: 'project-${foundryEnvName}'
- displayName: 'AI Foundry project (${environmentName})'
- description: 'Environment-scoped project created by the AI Landing Zone deployment.'
+param useUAI = readEnvironmentVariable('USE_UAI', 'false') == 'true'
+param useCAppAPIKey = readEnvironmentVariable('USE_CAPP_API_KEY', 'false') == 'true'
+param useZoneRedundancy = false
+
+param modelDeploymentList = [
+ {
+ name: 'chat'
+ model: {
+ format: 'OpenAI'
+ name: 'gpt-4.1-mini'
+ version: '2025-04-14'
+ }
+ sku: {
+ name: 'GlobalStandard'
+ capacity: 40
}
+ canonical_name: 'CHAT_DEPLOYMENT_NAME'
+ apiVersion: '2025-01-01-preview'
}
-}
+ {
+ name: 'text-embedding'
+ model: {
+ format: 'OpenAI'
+ name: 'text-embedding-3-large'
+ version: '1'
+ }
+ sku: {
+ name: 'Standard'
+ capacity: 40
+ }
+ canonical_name: 'EMBEDDING_DEPLOYMENT_NAME'
+ apiVersion: '2025-01-01-preview'
+ }
+]
+param workloadProfiles = [
+ {
+ name: 'Consumption'
+ workloadProfileType: 'Consumption'
+ }
+ {
+ workloadProfileType: 'D4'
+ name: 'main'
+ minimumCount: 0
+ maximumCount: 1
+ }
+]
+param storageAccountContainersList = [
+ {
+ name: 'documents-images'
+ canonical_name: 'DOCUMENTS_IMAGES_STORAGE_CONTAINER'
+ }
+]
-// AI Search settings for the default deployment.
-param aiSearchDefinition = {
- name: toLower('search-${empty(environmentName) ? 'default' : replace(replace(environmentName, '_', '-'), ' ', '-')}')
- sku: 'standard'
- semanticSearch: 'free'
- managedIdentities: {
- systemAssigned: true
+param databaseContainersList = [
+ {
+ name: 'conversations'
+ canonical_name: 'CONVERSATIONS_DATABASE_CONTAINER'
}
- disableLocalAuth: true
-}
+ {
+ name: 'datasources'
+ canonical_name: 'DATASOURCES_DATABASE_CONTAINER'
+ }
+ {
+ name: 'prompts'
+ canonical_name: 'PROMPTS_CONTAINER'
+ }
+ {
+ name: 'mcp'
+ canonical_name: 'MCP_CONTAINER'
+ }
+]
+
+param containerAppsList = [
+ {
+ name: null
+ external: true
+ service_name: 'orchestrator'
+ profile_name: 'main'
+ min_replicas: 1
+ max_replicas: 1
+ canonical_name: 'ORCHESTRATOR_APP'
+ roles: [
+ 'AppConfigurationDataReader'
+ 'CognitiveServicesUser'
+ 'CognitiveServicesOpenAIUser'
+ 'AcrPull'
+ 'CosmosDBBuiltInDataContributor'
+ 'SearchIndexDataReader'
+ 'StorageBlobDataReader'
+ 'KeyVaultSecretsUser'
+ ]
+ }
+]
-param aiSearchAdditionalAccessObjectIds = []
+param vmAdminPassword = readEnvironmentVariable('VM_ADMIN_PASSWORD', '$(secretOrRandomPassword)')
+param vmSize = 'Standard_D8s_v5'
// ========================================
// FABRIC CAPACITY PARAMETERS
@@ -114,11 +239,10 @@ param fabricWorkspaceId = '' // required when fabricWorkspacePreset='byo'
param fabricWorkspaceName = '' // optional (helpful for naming/UX)
// Fabric capacity SKU.
-
param fabricCapacitySku = 'F8'
// Fabric capacity admin members (email addresses or object IDs).
-param fabricCapacityAdmins = []
+param fabricCapacityAdmins = ['']
// ========================================
// PURVIEW PARAMETERS (Optional)
diff --git a/scripts/automationScripts/FabricPurviewAutomation/connect_log_analytics.ps1 b/scripts/automationScripts/FabricPurviewAutomation/connect_log_analytics.ps1
deleted file mode 100644
index d6ed33e..0000000
--- a/scripts/automationScripts/FabricPurviewAutomation/connect_log_analytics.ps1
+++ /dev/null
@@ -1,90 +0,0 @@
-<#
-.SYNOPSIS
- Placeholder: Connect a Fabric workspace to an Azure Log Analytics workspace (if API exists).
-.DESCRIPTION
- This PowerShell script replicates the placeholder behavior of the original shell script.
-#>
-
-[CmdletBinding()]
-param(
- [string]$FabricWorkspaceName = $env:FABRIC_WORKSPACE_NAME,
- [string]$LogAnalyticsWorkspaceId = $env:LOG_ANALYTICS_WORKSPACE_ID
-)
-
-Set-StrictMode -Version Latest
-
-# Import security module
-$SecurityModulePath = Join-Path $PSScriptRoot "../SecurityModule.ps1"
-. $SecurityModulePath
-$ErrorActionPreference = 'Stop'
-
-function Log([string]$m){ Write-Host "[fabric-loganalytics] $m" }
-function Warn([string]$m){ Write-Warning "[fabric-loganalytics] $m" }
-
-# Skip when Fabric workspace automation is disabled
-$fabricWorkspaceMode = $env:fabricWorkspaceMode
-if (-not $fabricWorkspaceMode -and $env:AZURE_OUTPUTS_JSON) {
- try {
- $out0 = $env:AZURE_OUTPUTS_JSON | ConvertFrom-Json -ErrorAction Stop
- if ($out0.fabricWorkspaceModeOut -and $out0.fabricWorkspaceModeOut.value) { $fabricWorkspaceMode = $out0.fabricWorkspaceModeOut.value }
- elseif ($out0.fabricWorkspaceMode -and $out0.fabricWorkspaceMode.value) { $fabricWorkspaceMode = $out0.fabricWorkspaceMode.value }
- } catch { }
-}
-if (-not $fabricWorkspaceMode) {
- try {
- $azdMode = & azd env get-value fabricWorkspaceModeOut 2>$null
- if ($LASTEXITCODE -eq 0 -and $azdMode) { $fabricWorkspaceMode = $azdMode.Trim() }
- if (-not $fabricWorkspaceMode) {
- $azdMode = & azd env get-value fabricWorkspaceMode 2>$null
- if ($LASTEXITCODE -eq 0 -and $azdMode) { $fabricWorkspaceMode = $azdMode.Trim() }
- }
- } catch { }
-}
-if ($fabricWorkspaceMode -and $fabricWorkspaceMode.ToString().Trim().ToLowerInvariant() -eq 'none') {
- Warn "Fabric workspace mode is 'none'; skipping Log Analytics linkage."
- exit 0
-}
-
-if (-not $FabricWorkspaceName) {
- # try .azure env
- $envDir = $env:AZURE_ENV_NAME
- if (-not $envDir -and (Test-Path '.azure')) { $envDir = (Get-ChildItem -Path .azure -Name -ErrorAction SilentlyContinue | Select-Object -First 1) }
- if ($envDir) {
- $envPath = Join-Path -Path '.azure' -ChildPath "$envDir/.env"
- if (Test-Path $envPath) {
- Get-Content $envPath | ForEach-Object {
- if ($_ -match '^desiredFabricWorkspaceName=(?:"|")?(.+?)(?:"|")?$') { $FabricWorkspaceName = $Matches[1] }
- }
- }
- }
-}
-
-if (-not $FabricWorkspaceName) { Warn 'No FABRIC_WORKSPACE_NAME determined; skipping Log Analytics linkage.'; exit 0 }
-
-# Acquire token
-try { $accessToken = Get-SecureApiToken -Resource $SecureApiResources.PowerBI -Description "Power BI" } catch { $accessToken = $null }
-if (-not $accessToken) { Warn 'Cannot acquire token; skip LA linkage.'; exit 0 }
-
-$apiRoot = 'https://api.powerbi.com/v1.0/myorg'
-$workspaceId = $env:WORKSPACE_ID
-if (-not $workspaceId) {
- try {
- $groups = Invoke-SecureRestMethod -Uri "$apiRoot/groups?%24top=5000" -Headers $powerBIHeaders -Method Get -ErrorAction Stop
- $g = $groups.value | Where-Object { $_.name -eq $FabricWorkspaceName }
- if ($g) { $workspaceId = $g.id }
- } catch {
- Warn "Unable to resolve workspace ID for '$FabricWorkspaceName'; skipping."; # Clean up sensitive variables
-Clear-SensitiveVariables -VariableNames @("accessToken", "fabricToken", "purviewToken", "powerBIToken", "storageToken")
-exit 0
- }
-}
-
-if (-not $workspaceId) { Warn "Unable to resolve workspace ID for '$FabricWorkspaceName'; skipping."; exit 0 }
-
-if (-not $LogAnalyticsWorkspaceId) { Warn "LOG_ANALYTICS_WORKSPACE_ID not provided; skipping."; exit 0 }
-
-Log "(PLACEHOLDER) Would link Fabric workspace $FabricWorkspaceName ($workspaceId) to Log Analytics workspace $LogAnalyticsWorkspaceId"
-Log "No public API yet; skipping."
-# Clean up sensitive variables
-Clear-SensitiveVariables -VariableNames @("accessToken", "fabricToken", "purviewToken", "powerBIToken", "storageToken")
-exit 0
diff --git a/scripts/automationScripts/FabricPurviewAutomation/create_purview_collection.ps1 b/scripts/automationScripts/FabricPurviewAutomation/create_purview_collection.ps1
index 00b7526..2657df3 100644
--- a/scripts/automationScripts/FabricPurviewAutomation/create_purview_collection.ps1
+++ b/scripts/automationScripts/FabricPurviewAutomation/create_purview_collection.ps1
@@ -17,6 +17,12 @@ function Log([string]$m){ Write-Host "[purview-collection] $m" }
function Warn([string]$m){ Write-Warning "[purview-collection] $m" }
function Fail([string]$m){ Write-Error "[script] $m"; Clear-SensitiveVariables -VariableNames @("accessToken", "fabricToken", "purviewToken", "powerBIToken", "storageToken"); exit 1 }
+if ($env:SKIP_PURVIEW_INTEGRATION -and $env:SKIP_PURVIEW_INTEGRATION.ToLowerInvariant() -eq 'true') {
+ Warn "SKIP_PURVIEW_INTEGRATION=true; skipping Purview collection setup."
+ Clear-SensitiveVariables -VariableNames @("accessToken", "fabricToken", "purviewToken", "powerBIToken", "storageToken")
+ exit 0
+}
+
# Skip when Fabric workspace automation is disabled
$fabricWorkspaceMode = $env:fabricWorkspaceMode
if (-not $fabricWorkspaceMode) { $fabricWorkspaceMode = $env:fabricWorkspaceModeOut }
@@ -47,6 +53,56 @@ function Get-AzdEnvValue([string]$key){
return $value.Trim()
}
+function Get-LatestDeploymentOutputs([string]$resourceGroup, [string]$subscriptionId, [string]$environmentName) {
+ if ([string]::IsNullOrWhiteSpace($resourceGroup)) { return $null }
+
+ try {
+ $listArgs = @('deployment', 'group', 'list', '--resource-group', $resourceGroup, '-o', 'json')
+ if ($subscriptionId) { $listArgs += @('--subscription', $subscriptionId) }
+ $deploymentsJson = & az @listArgs 2>$null
+ if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($deploymentsJson)) { return $null }
+
+ $deployments = @($deploymentsJson | ConvertFrom-Json -ErrorAction Stop)
+ if (-not $deployments) { return $null }
+
+ $preferred = $null
+ if (-not [string]::IsNullOrWhiteSpace($environmentName)) {
+ $preferred = $deployments |
+ Where-Object { $_.name -like "$environmentName-*" } |
+ Sort-Object { $_.properties.timestamp } -Descending |
+ Select-Object -First 1
+ }
+ if (-not $preferred) {
+ $preferred = $deployments |
+ Where-Object { $_.name -notlike 'PolicyDeployment_*' } |
+ Sort-Object { $_.properties.timestamp } -Descending |
+ Select-Object -First 1
+ }
+ if (-not $preferred) { return $null }
+
+ $showArgs = @('deployment', 'group', 'show', '--resource-group', $resourceGroup, '--name', $preferred.name, '--query', 'properties.outputs', '-o', 'json')
+ if ($subscriptionId) { $showArgs += @('--subscription', $subscriptionId) }
+ $outputsJson = & az @showArgs 2>$null
+ if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($outputsJson)) { return $null }
+
+ return $outputsJson | ConvertFrom-Json -ErrorAction Stop
+ } catch {
+ return $null
+ }
+}
+
+function Get-OutputValue([object]$outputsObject, [string]$propertyName) {
+ if (-not $outputsObject) { return $null }
+
+ $property = $outputsObject.PSObject.Properties[$propertyName]
+ if (-not $property -or -not $property.Value) { return $null }
+
+ $valueProperty = $property.Value.PSObject.Properties['value']
+ if ($valueProperty) { return $valueProperty.Value }
+
+ return $null
+}
+
function Resolve-PurviewFromResourceId([string]$resourceId) {
if ([string]::IsNullOrWhiteSpace($resourceId)) { return $null }
$parts = $resourceId.Split('/', [System.StringSplitOptions]::RemoveEmptyEntries)
@@ -58,18 +114,52 @@ function Resolve-PurviewFromResourceId([string]$resourceId) {
}
}
+function Get-DefaultPurviewCollectionName() {
+ $environmentName = $env:AZURE_ENV_NAME
+ if (-not $environmentName) { $environmentName = Get-AzdEnvValue -key 'AZURE_ENV_NAME' }
+ if ([string]::IsNullOrWhiteSpace($environmentName)) { return $null }
+
+ return "collection-$($environmentName.Trim())"
+}
+
# Use azd env if available
+$outputs = $null
+if ($env:AZURE_OUTPUTS_JSON) {
+ try { $outputs = $env:AZURE_OUTPUTS_JSON | ConvertFrom-Json -ErrorAction Stop } catch { $outputs = $null }
+}
+if (-not $outputs) {
+ $deploymentResourceGroup = $env:AZURE_RESOURCE_GROUP
+ if (-not $deploymentResourceGroup) { $deploymentResourceGroup = Get-AzdEnvValue -key 'AZURE_RESOURCE_GROUP' }
+ $deploymentSubscriptionId = $env:AZURE_SUBSCRIPTION_ID
+ if (-not $deploymentSubscriptionId) { $deploymentSubscriptionId = Get-AzdEnvValue -key 'AZURE_SUBSCRIPTION_ID' }
+ $deploymentEnvironmentName = $env:AZURE_ENV_NAME
+ if (-not $deploymentEnvironmentName) { $deploymentEnvironmentName = Get-AzdEnvValue -key 'AZURE_ENV_NAME' }
+ $outputs = Get-LatestDeploymentOutputs -resourceGroup $deploymentResourceGroup -subscriptionId $deploymentSubscriptionId -environmentName $deploymentEnvironmentName
+}
+
$purviewAccountName = $null
$purviewSubscriptionId = $null
$purviewResourceGroup = $null
$collectionName = $null
-$purviewAccountName = Get-AzdEnvValue -key 'purviewAccountName'
-$purviewSubscriptionId = Get-AzdEnvValue -key 'purviewSubscriptionId'
-$purviewResourceGroup = Get-AzdEnvValue -key 'purviewResourceGroup'
+$purviewAccountResourceId = $null
+
+if ($outputs) {
+ $purviewAccountName = Get-OutputValue -outputsObject $outputs -propertyName 'purviewAccountName'
+ $purviewSubscriptionId = Get-OutputValue -outputsObject $outputs -propertyName 'purviewSubscriptionId'
+ $purviewResourceGroup = Get-OutputValue -outputsObject $outputs -propertyName 'purviewResourceGroup'
+ $collectionName = Get-OutputValue -outputsObject $outputs -propertyName 'purviewCollectionName'
+ if (-not $collectionName) { $collectionName = Get-OutputValue -outputsObject $outputs -propertyName 'desiredFabricDomainName' }
+ $purviewAccountResourceId = Get-OutputValue -outputsObject $outputs -propertyName 'purviewAccountResourceId'
+}
+
+if (-not $purviewAccountName) { $purviewAccountName = Get-AzdEnvValue -key 'purviewAccountName' }
+if (-not $purviewSubscriptionId) { $purviewSubscriptionId = Get-AzdEnvValue -key 'purviewSubscriptionId' }
+if (-not $purviewResourceGroup) { $purviewResourceGroup = Get-AzdEnvValue -key 'purviewResourceGroup' }
# First try purviewCollectionName, then fall back to desiredFabricDomainName for backwards compatibility
-$collectionName = Get-AzdEnvValue -key 'purviewCollectionName'
+if (-not $collectionName) { $collectionName = Get-AzdEnvValue -key 'purviewCollectionName' }
if (-not $collectionName) { $collectionName = Get-AzdEnvValue -key 'desiredFabricDomainName' }
-$purviewAccountResourceId = Get-AzdEnvValue -key 'purviewAccountResourceId'
+if (-not $collectionName) { $collectionName = Get-DefaultPurviewCollectionName }
+if (-not $purviewAccountResourceId) { $purviewAccountResourceId = Get-AzdEnvValue -key 'purviewAccountResourceId' }
if (-not $purviewAccountResourceId) { $purviewAccountResourceId = $env:PURVIEW_ACCOUNT_RESOURCE_ID }
diff --git a/scripts/automationScripts/FabricPurviewAutomation/trigger_purview_scan_for_fabric_workspace.ps1 b/scripts/automationScripts/FabricPurviewAutomation/trigger_purview_scan_for_fabric_workspace.ps1
index 989f1c4..2979857 100644
--- a/scripts/automationScripts/FabricPurviewAutomation/trigger_purview_scan_for_fabric_workspace.ps1
+++ b/scripts/automationScripts/FabricPurviewAutomation/trigger_purview_scan_for_fabric_workspace.ps1
@@ -26,6 +26,12 @@ function Log([string]$m){ Write-Host "[purview-scan] $m" }
function Warn([string]$m){ Write-Warning "[purview-scan] $m" }
function Fail([string]$m){ Write-Error "[script] $m"; Clear-SensitiveVariables -VariableNames @("accessToken", "fabricToken", "purviewToken", "powerBIToken", "storageToken"); exit 1 }
+if ($env:SKIP_PURVIEW_INTEGRATION -and $env:SKIP_PURVIEW_INTEGRATION.ToLowerInvariant() -eq 'true') {
+ Warn "SKIP_PURVIEW_INTEGRATION=true; skipping Purview scan trigger."
+ Clear-SensitiveVariables -VariableNames @("accessToken", "fabricToken", "purviewToken", "powerBIToken", "storageToken")
+ exit 0
+}
+
# Skip when Fabric workspace automation is disabled
$fabricWorkspaceMode = $env:fabricWorkspaceMode
if (-not $fabricWorkspaceMode) { $fabricWorkspaceMode = $env:fabricWorkspaceModeOut }
@@ -231,34 +237,160 @@ if ($collectionId) {
$bodyJson = $payload | ConvertTo-Json -Depth 10
-# Create or update scan
-$createUrl = "$endpoint/scan/datasources/$datasourceName/scans/${scanName}?api-version=2022-07-01-preview"
-try {
- $resp = Invoke-SecureWebRequest -Uri $createUrl -Method Put -Headers (New-SecureHeaders -Token $purviewToken -AdditionalHeaders @{'Content-Type' = 'application/json'}) -Body $bodyJson -ErrorAction Stop
- $code = $resp.StatusCode
- $respBody = $resp.Content
-} catch [System.Net.WebException] {
- $resp = $_.Exception.Response
- if ($resp) {
- $reader = New-Object System.IO.StreamReader($resp.GetResponseStream())
- $respBody = $reader.ReadToEnd()
+function Invoke-PurviewWebRequest {
+ param(
+ [string]$Uri,
+ [string]$Method,
+ [hashtable]$Headers,
+ [string]$Body
+ )
+
+ try {
+ $resp = Invoke-WebRequest -Uri $Uri -Method $Method -Headers $Headers -Body $Body -ErrorAction Stop
+ return [PSCustomObject]@{
+ StatusCode = $resp.StatusCode
+ Content = $resp.Content
+ }
+ } catch {
+ $resp = $null
+ try { $resp = $_.Exception.Response } catch { $resp = $null }
+ if (-not $resp -and $_.Exception.InnerException) {
+ try { $resp = $_.Exception.InnerException.Response } catch { $resp = $null }
+ }
+ if ($resp) {
+ $content = $null
+ try {
+ $reader = New-Object System.IO.StreamReader($resp.GetResponseStream())
+ $content = $reader.ReadToEnd()
+ } catch { $content = $null }
+ $status = $null
+ try { $status = $resp.StatusCode } catch { $status = $null }
+ return [PSCustomObject]@{
+ StatusCode = $status
+ Content = $content
+ }
+ }
+
+ throw
+ }
+}
+
+# Create or update scan with retries
+$createUrl = "$endpoint/scan/datasources/${datasourceName}/scans/${scanName}?api-version=2022-07-01-preview"
+$maxCreateAttempts = 10
+if ($env:PURVIEW_SCAN_CREATE_MAX_RETRIES) {
+ [int]::TryParse($env:PURVIEW_SCAN_CREATE_MAX_RETRIES, [ref]$maxCreateAttempts) | Out-Null
+}
+$createDelaySeconds = 20
+if ($env:PURVIEW_SCAN_CREATE_DELAY_SECONDS) {
+ [int]::TryParse($env:PURVIEW_SCAN_CREATE_DELAY_SECONDS, [ref]$createDelaySeconds) | Out-Null
+}
+
+$scanExists = $false
+$createSucceeded = $false
+$lastCreateStatus = $null
+$lastCreateBody = $null
+for ($attempt = 1; $attempt -le $maxCreateAttempts; $attempt++) {
+ $code = $null
+ $respBody = $null
+ try {
+ $headers = New-SecureHeaders -Token $purviewToken -AdditionalHeaders @{'Content-Type' = 'application/json'}
+ $resp = Invoke-PurviewWebRequest -Uri $createUrl -Method Put -Headers $headers -Body $bodyJson
$code = $resp.StatusCode
- } else {
- Fail "Scan create/update failed: $_"
+ $respBody = $resp.Content
+ $lastCreateStatus = $code
+ $lastCreateBody = $respBody
+ } catch {
+ Warn "Scan create/update failed (attempt $attempt of $maxCreateAttempts): $($_.Exception.Message)"
+ }
+
+ if ($code -ge 200 -and $code -lt 300) {
+ Log "Scan definition created/updated (HTTP $code)"
+ $createSucceeded = $true
+ break
+ }
+
+ Warn "Scan create/update failed (HTTP $code): $respBody"
+ if ($collectionId) {
+ try {
+ Warn "Retrying scan create/update without collection assignment..."
+ $payloadNoCollection = $payload.PSObject.Copy()
+ if ($payloadNoCollection.properties -and $payloadNoCollection.properties.PSObject.Properties.Name -contains 'collection') {
+ $payloadNoCollection.properties.PSObject.Properties.Remove('collection')
+ }
+ $bodyJsonNoCollection = $payloadNoCollection | ConvertTo-Json -Depth 10
+ $headers = New-SecureHeaders -Token $purviewToken -AdditionalHeaders @{'Content-Type' = 'application/json'}
+ $retryResp = Invoke-PurviewWebRequest -Uri $createUrl -Method Put -Headers $headers -Body $bodyJsonNoCollection
+ $lastCreateStatus = $retryResp.StatusCode
+ $lastCreateBody = $retryResp.Content
+ if ($retryResp.StatusCode -ge 200 -and $retryResp.StatusCode -lt 300) {
+ $createSucceeded = $true
+ Log "Scan definition created/updated without collection (HTTP $($retryResp.StatusCode))"
+ break
+ }
+ } catch {
+ Warn "Retry without collection failed: $($_.Exception.Message)"
+ }
+ }
+
+ try {
+ $getUrl = "$endpoint/scan/datasources/${datasourceName}/scans/${scanName}?api-version=2022-07-01-preview"
+ $getHeaders = New-SecureHeaders -Token $purviewToken -AdditionalHeaders @{'Content-Type' = 'application/json'}
+ $getResp = Invoke-PurviewWebRequest -Uri $getUrl -Method Get -Headers $getHeaders -Body $null
+ if ($getResp.StatusCode -ge 200 -and $getResp.StatusCode -lt 300) {
+ $scanExists = $true
+ Log "Existing scan definition found. Continuing with scan run."
+ break
+ }
+ } catch {
+ Warn "Unable to retrieve existing scan definition: $($_.Exception.Message)"
+ }
+
+ if ($attempt -lt $maxCreateAttempts) {
+ Write-Warning "Scan definition not ready. Waiting ${createDelaySeconds}s before retry..."
+ Start-Sleep -Seconds $createDelaySeconds
}
}
-if ($code -ge 200 -and $code -lt 300) { Log "Scan definition created/updated (HTTP $code)" } else { Warn "Scan create/update failed (HTTP $code): $respBody"; Fail "Could not create/update scan" }
+if (-not $createSucceeded -and -not $scanExists) {
+ if ($lastCreateStatus -or $lastCreateBody) {
+ Warn "Final scan create/update response (HTTP $lastCreateStatus): $lastCreateBody"
+ }
+ Fail "Could not create or retrieve scan definition after $maxCreateAttempts attempts."
+}
-# Trigger a run
-$runUrl = "$endpoint/scan/datasources/$datasourceName/scans/$scanName/run?api-version=2022-07-01-preview"
-try {
- $runResp = Invoke-SecureWebRequest -Uri $runUrl -Method Post -Headers (New-SecureHeaders -Token $purviewToken -AdditionalHeaders @{'Content-Type' = 'application/json'}) -Body '{}' -ErrorAction Stop
- $runBody = $runResp.Content
- $runCode = $runResp.StatusCode
-} catch [System.Net.WebException] {
- $resp = $_.Exception.Response
- if ($resp) { $reader = New-Object System.IO.StreamReader($resp.GetResponseStream()); $runBody = $reader.ReadToEnd(); $runCode = $resp.StatusCode } else { Fail "Scan run request failed: $_" }
+# Trigger a run with retries
+$runUrl = "$endpoint/scan/datasources/${datasourceName}/scans/${scanName}/run?api-version=2022-07-01-preview"
+$maxRunAttempts = 3
+if ($env:PURVIEW_SCAN_RUN_MAX_RETRIES) {
+ [int]::TryParse($env:PURVIEW_SCAN_RUN_MAX_RETRIES, [ref]$maxRunAttempts) | Out-Null
+}
+$runDelaySeconds = 15
+if ($env:PURVIEW_SCAN_RUN_DELAY_SECONDS) {
+ [int]::TryParse($env:PURVIEW_SCAN_RUN_DELAY_SECONDS, [ref]$runDelaySeconds) | Out-Null
+}
+
+$runCode = $null
+$runBody = $null
+for ($attempt = 1; $attempt -le $maxRunAttempts; $attempt++) {
+ try {
+ $runHeaders = New-SecureHeaders -Token $purviewToken -AdditionalHeaders @{'Content-Type' = 'application/json'}
+ $runResp = Invoke-PurviewWebRequest -Uri $runUrl -Method Post -Headers $runHeaders -Body '{}'
+ $runBody = $runResp.Content
+ $runCode = $runResp.StatusCode
+ } catch {
+ Warn "Scan run request failed (attempt $attempt of $maxRunAttempts): $($_.Exception.Message)"
+ }
+
+ if ($runCode -eq 200 -or $runCode -eq 202) { break }
+ if ($attempt -lt $maxRunAttempts) {
+ Write-Warning "Scan run not accepted yet (HTTP $runCode). Waiting ${runDelaySeconds}s before retry..."
+ Start-Sleep -Seconds $runDelaySeconds
+ }
+}
+
+if (-not ($runCode -eq 200 -or $runCode -eq 202)) {
+ Fail "Scan run request failed after $maxRunAttempts attempts (HTTP $runCode)"
}
if ($runCode -ne 200 -and $runCode -ne 202) {
diff --git a/scripts/automationScripts/FabricWorkspace/CreateWorkspace/assign_workspace_to_domain.ps1 b/scripts/automationScripts/FabricWorkspace/CreateWorkspace/assign_workspace_to_domain.ps1
index e90d2fb..e608162 100644
--- a/scripts/automationScripts/FabricWorkspace/CreateWorkspace/assign_workspace_to_domain.ps1
+++ b/scripts/automationScripts/FabricWorkspace/CreateWorkspace/assign_workspace_to_domain.ps1
@@ -19,14 +19,106 @@ function Log([string]$m){ Write-Host "[assign-domain] $m" }
function Warn([string]$m){ Write-Warning "[assign-domain] $m" }
function Fail([string]$m){ Write-Error "[assign-domain] $m"; Clear-SensitiveVariables -VariableNames @('accessToken', 'fabricToken'); exit 1 }
+function Get-NormalizedString {
+ param(
+ [Parameter(ValueFromPipeline = $true)]
+ $Value
+ )
+
+ if ($null -eq $Value) { return $null }
+
+ if ($Value -is [string]) {
+ $trimmed = $Value.Trim()
+ if ([string]::IsNullOrWhiteSpace($trimmed)) { return $null }
+ if ($trimmed -in @('System.Object[]', 'System.Object')) { return $null }
+ return $trimmed
+ }
+
+ if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) {
+ foreach ($item in $Value) {
+ $candidate = Get-NormalizedString -Value $item
+ if ($candidate) { return $candidate }
+ }
+ return $null
+ }
+
+ if ($Value.PSObject) {
+ foreach ($propertyName in @('value', 'id', 'resourceId', 'name', 'displayName')) {
+ if ($Value.PSObject.Properties[$propertyName]) {
+ $candidate = Get-NormalizedString -Value $Value.$propertyName
+ if ($candidate) { return $candidate }
+ }
+ }
+ }
+
+ $stringValue = $Value.ToString().Trim()
+ if ([string]::IsNullOrWhiteSpace($stringValue)) { return $null }
+ if ($stringValue -in @('System.Object[]', 'System.Object')) { return $null }
+ return $stringValue
+}
+
+function Get-CapacityLookupName {
+ param(
+ [string]$ResolvedCapacityId,
+ [string]$ResolvedCapacityName
+ )
+
+ if ($ResolvedCapacityId) {
+ if ($ResolvedCapacityId -match '^[0-9a-fA-F-]{36}$') { return $ResolvedCapacityId }
+ if ($ResolvedCapacityId -like '*/providers/Microsoft.Fabric/capacities/*') {
+ return ($ResolvedCapacityId -split '/')[ -1 ]
+ }
+ return $ResolvedCapacityId
+ }
+
+ return $ResolvedCapacityName
+}
+
+function Get-AzdEnvValue {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Key
+ )
+
+ try {
+ $value = & azd env get-value $Key 2>$null
+ if ($LASTEXITCODE -ne 0) { return $null }
+ return Get-NormalizedString -Value $value
+ } catch {
+ return $null
+ }
+}
+
+function Get-EnvironmentName {
+ if ($env:AZURE_ENV_NAME) { return $env:AZURE_ENV_NAME.Trim() }
+ return Get-AzdEnvValue -Key 'AZURE_ENV_NAME'
+}
+
+function Resolve-DeployedFabricCapacity {
+ param(
+ [string]$SubscriptionId,
+ [string]$ResourceGroup
+ )
+
+ if (-not $ResourceGroup) { return $null }
+
+ try {
+ $args = @('resource', 'list', '--resource-group', $ResourceGroup, '--resource-type', 'Microsoft.Fabric/capacities', '--query', '[0].{id:id,name:name}', '-o', 'json')
+ if ($SubscriptionId) { $args += @('--subscription', $SubscriptionId) }
+ $json = & az @args 2>$null
+ if ($LASTEXITCODE -ne 0 -or -not $json) { return $null }
+ return $json | ConvertFrom-Json -ErrorAction Stop
+ } catch {
+ return $null
+ }
+}
+
# Skip when Fabric workspace automation is disabled or BYO
$fabricWorkspaceMode = $env:fabricWorkspaceMode
if (-not $fabricWorkspaceMode) { $fabricWorkspaceMode = $env:fabricWorkspaceModeOut }
if (-not $fabricWorkspaceMode) {
- try {
- $azdMode = & azd env get-value fabricWorkspaceModeOut 2>$null
- if ($azdMode) { $fabricWorkspaceMode = $azdMode.ToString().Trim() }
- } catch {}
+ $azdMode = Get-AzdEnvValue -Key 'fabricWorkspaceModeOut'
+ if ($azdMode) { $fabricWorkspaceMode = $azdMode }
}
if (-not $fabricWorkspaceMode -and $env:AZURE_OUTPUTS_JSON) {
try {
@@ -68,13 +160,18 @@ $FABRIC_CAPACITY_NAME = $env:FABRIC_CAPACITY_NAME
if ($env:AZURE_OUTPUTS_JSON) {
try {
$out = $env:AZURE_OUTPUTS_JSON | ConvertFrom-Json -ErrorAction Stop
- if (-not $FABRIC_CAPACITY_ID -and $out.fabricCapacityId -and $out.fabricCapacityId.value) { $FABRIC_CAPACITY_ID = $out.fabricCapacityId.value }
+ if (-not $FABRIC_CAPACITY_ID -and $out.fabricCapacityId -and $out.fabricCapacityId.value) { $FABRIC_CAPACITY_ID = Get-NormalizedString -Value $out.fabricCapacityId.value }
if (-not $FABRIC_WORKSPACE_NAME -and $out.desiredFabricWorkspaceName -and $out.desiredFabricWorkspaceName.value) { $FABRIC_WORKSPACE_NAME = $out.desiredFabricWorkspaceName.value }
if (-not $FABRIC_DOMAIN_NAME -and $out.desiredFabricDomainName -and $out.desiredFabricDomainName.value) { $FABRIC_DOMAIN_NAME = $out.desiredFabricDomainName.value }
- if (-not $FABRIC_CAPACITY_NAME -and $out.fabricCapacityName -and $out.fabricCapacityName.value) { $FABRIC_CAPACITY_NAME = $out.fabricCapacityName.value }
+ if (-not $FABRIC_CAPACITY_NAME -and $out.fabricCapacityName -and $out.fabricCapacityName.value) { $FABRIC_CAPACITY_NAME = Get-NormalizedString -Value $out.fabricCapacityName.value }
} catch { }
}
+if (-not $FABRIC_WORKSPACE_NAME) { $FABRIC_WORKSPACE_NAME = Get-AzdEnvValue -Key 'desiredFabricWorkspaceName' }
+if (-not $FABRIC_DOMAIN_NAME) { $FABRIC_DOMAIN_NAME = Get-AzdEnvValue -Key 'desiredFabricDomainName' }
+if (-not $FABRIC_CAPACITY_ID) { $FABRIC_CAPACITY_ID = Get-AzdEnvValue -Key 'fabricCapacityId' }
+if (-not $FABRIC_CAPACITY_NAME) { $FABRIC_CAPACITY_NAME = Get-AzdEnvValue -Key 'fabricCapacityName' }
+
# Try .azure env file
if ((-not $FABRIC_WORKSPACE_NAME) -or (-not $FABRIC_DOMAIN_NAME) -or (-not $FABRIC_CAPACITY_ID)) {
$envDir = $env:AZURE_ENV_NAME
@@ -83,15 +180,34 @@ if ((-not $FABRIC_WORKSPACE_NAME) -or (-not $FABRIC_DOMAIN_NAME) -or (-not $FABR
$envPath = Join-Path -Path '.azure' -ChildPath "$envDir/.env"
if (Test-Path $envPath) {
Get-Content $envPath | ForEach-Object {
- if ($_ -match '^fabricCapacityId=(?:"|")?(.+?)(?:"|")?$') { if (-not $FABRIC_CAPACITY_ID) { $FABRIC_CAPACITY_ID = $Matches[1] } }
+ if ($_ -match '^fabricCapacityId=(?:"|")?(.+?)(?:"|")?$') { if (-not $FABRIC_CAPACITY_ID) { $FABRIC_CAPACITY_ID = Get-NormalizedString -Value $Matches[1] } }
if ($_ -match '^desiredFabricWorkspaceName=(?:"|")?(.+?)(?:"|")?$') { if (-not $FABRIC_WORKSPACE_NAME) { $FABRIC_WORKSPACE_NAME = $Matches[1] } }
if ($_ -match '^desiredFabricDomainName=(?:"|")?(.+?)(?:"|")?$') { if (-not $FABRIC_DOMAIN_NAME) { $FABRIC_DOMAIN_NAME = $Matches[1] } }
- if ($_ -match '^fabricCapacityName=(?:"|")?(.+?)(?:"|")?$') { if (-not $FABRIC_CAPACITY_NAME) { $FABRIC_CAPACITY_NAME = $Matches[1] } }
+ if ($_ -match '^fabricCapacityName=(?:"|")?(.+?)(?:"|")?$') { if (-not $FABRIC_CAPACITY_NAME) { $FABRIC_CAPACITY_NAME = Get-NormalizedString -Value $Matches[1] } }
}
}
}
}
+$FABRIC_CAPACITY_ID = Get-NormalizedString -Value $FABRIC_CAPACITY_ID
+$FABRIC_CAPACITY_NAME = Get-NormalizedString -Value $FABRIC_CAPACITY_NAME
+
+$environmentName = Get-EnvironmentName
+if (-not $FABRIC_WORKSPACE_NAME -and $environmentName) { $FABRIC_WORKSPACE_NAME = "workspace-$environmentName" }
+if (-not $FABRIC_DOMAIN_NAME -and $environmentName) { $FABRIC_DOMAIN_NAME = "domain-$environmentName" }
+
+if (-not $FABRIC_CAPACITY_ID -or -not $FABRIC_CAPACITY_NAME) {
+ $subscriptionId = $env:AZURE_SUBSCRIPTION_ID
+ if (-not $subscriptionId) { $subscriptionId = Get-AzdEnvValue -Key 'AZURE_SUBSCRIPTION_ID' }
+ $resourceGroup = $env:AZURE_RESOURCE_GROUP
+ if (-not $resourceGroup) { $resourceGroup = Get-AzdEnvValue -Key 'AZURE_RESOURCE_GROUP' }
+ $resolvedCapacity = Resolve-DeployedFabricCapacity -SubscriptionId $subscriptionId -ResourceGroup $resourceGroup
+ if ($resolvedCapacity) {
+ if (-not $FABRIC_CAPACITY_ID -and $resolvedCapacity.id) { $FABRIC_CAPACITY_ID = Get-NormalizedString -Value $resolvedCapacity.id }
+ if (-not $FABRIC_CAPACITY_NAME -and $resolvedCapacity.name) { $FABRIC_CAPACITY_NAME = Get-NormalizedString -Value $resolvedCapacity.name }
+ }
+}
+
if (-not $FABRIC_WORKSPACE_NAME) { Fail 'FABRIC_WORKSPACE_NAME unresolved (no outputs/env/bicep).' }
if (-not $FABRIC_DOMAIN_NAME) { Fail 'FABRIC_DOMAIN_NAME unresolved (no outputs/env/bicep).' }
if (-not $FABRIC_CAPACITY_ID -and -not $FABRIC_CAPACITY_NAME) { Fail 'FABRIC_CAPACITY_ID or FABRIC_CAPACITY_NAME unresolved (no outputs/env/bicep).' }
@@ -130,7 +246,7 @@ if (-not $domainId) { Fail "Domain '$FABRIC_DOMAIN_NAME' not found. Create it fi
# 2. Resolve capacity GUID - Direct approach with immediate success when APIs work
$capacityGuid = $null
-$capName = if ($FABRIC_CAPACITY_ID) { ($FABRIC_CAPACITY_ID -split '/')[-1] } else { $FABRIC_CAPACITY_NAME }
+$capName = Get-CapacityLookupName -ResolvedCapacityId $FABRIC_CAPACITY_ID -ResolvedCapacityName $FABRIC_CAPACITY_NAME
Log "Deriving Fabric capacity GUID for name: $capName"
# Try Fabric API first - this should work immediately for deployed capacities
@@ -138,12 +254,21 @@ try {
Log "Calling Fabric API: $apiFabricRoot/capacities"
$caps = Invoke-SecureRestMethod -Uri "$apiFabricRoot/capacities" -Headers $fabricHeaders -Method Get -ErrorAction Stop
if ($caps.value) {
- $match = $caps.value | Where-Object { $_.displayName -eq $capName } | Select-Object -First 1
+ $match = $caps.value | Where-Object {
+ $displayName = if ($_.PSObject.Properties['displayName']) { $_.displayName } else { '' }
+ $name = if ($_.PSObject.Properties['name']) { $_.name } else { '' }
+ $id = if ($_.PSObject.Properties['id']) { $_.id } else { '' }
+ $displayName -eq $capName -or $name -eq $capName -or $id -eq $capName
+ } | Select-Object -First 1
if ($match) {
$capacityGuid = $match.id
Log "SUCCESS: Found capacity via Fabric API: $capacityGuid"
} else {
- $available = ($caps.value | ForEach-Object { $_.displayName }) -join ', '
+ $available = ($caps.value | ForEach-Object {
+ if ($_.PSObject.Properties['displayName']) { $_.displayName }
+ elseif ($_.PSObject.Properties['name']) { $_.name }
+ elseif ($_.PSObject.Properties['id']) { $_.id }
+ }) -join ', '
Log "Capacity '$capName' not found. Available: $available"
}
}
diff --git a/scripts/automationScripts/FabricWorkspace/CreateWorkspace/create_fabric_domain.ps1 b/scripts/automationScripts/FabricWorkspace/CreateWorkspace/create_fabric_domain.ps1
index c120196..c97e7ff 100644
--- a/scripts/automationScripts/FabricWorkspace/CreateWorkspace/create_fabric_domain.ps1
+++ b/scripts/automationScripts/FabricWorkspace/CreateWorkspace/create_fabric_domain.ps1
@@ -17,14 +17,33 @@ function Log([string]$m){ Write-Host "[fabric-domain] $m" }
function Warn([string]$m){ Write-Warning "[fabric-domain] $m" }
function Fail([string]$m){ Write-Error "[fabric-domain] $m"; Clear-SensitiveVariables -VariableNames @('accessToken', 'fabricToken'); exit 1 }
+function Get-AzdEnvValue {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Key
+ )
+
+ try {
+ $value = & azd env get-value $Key 2>$null
+ if ($LASTEXITCODE -ne 0) { return $null }
+ if (-not $value) { return $null }
+ return $value.ToString().Trim()
+ } catch {
+ return $null
+ }
+}
+
+function Get-EnvironmentName {
+ if ($env:AZURE_ENV_NAME) { return $env:AZURE_ENV_NAME.Trim() }
+ return Get-AzdEnvValue -Key 'AZURE_ENV_NAME'
+}
+
# Skip when Fabric workspace automation is disabled or BYO
$fabricWorkspaceMode = $env:fabricWorkspaceMode
if (-not $fabricWorkspaceMode) { $fabricWorkspaceMode = $env:fabricWorkspaceModeOut }
if (-not $fabricWorkspaceMode) {
- try {
- $azdMode = & azd env get-value fabricWorkspaceModeOut 2>$null
- if ($azdMode) { $fabricWorkspaceMode = $azdMode.ToString().Trim() }
- } catch {}
+ $azdMode = Get-AzdEnvValue -Key 'fabricWorkspaceModeOut'
+ if ($azdMode) { $fabricWorkspaceMode = $azdMode }
}
if (-not $fabricWorkspaceMode -and $env:AZURE_OUTPUTS_JSON) {
try {
@@ -44,12 +63,19 @@ $domainName = $env:desiredFabricDomainName
$workspaceName = $env:desiredFabricWorkspaceName
if (-not $domainName -and $env:AZURE_OUTPUTS_JSON) { try { $domainName = ($env:AZURE_OUTPUTS_JSON | ConvertFrom-Json).desiredFabricDomainName.value } catch {} }
if (-not $workspaceName -and $env:AZURE_OUTPUTS_JSON) { try { $workspaceName = ($env:AZURE_OUTPUTS_JSON | ConvertFrom-Json).desiredFabricWorkspaceName.value } catch {} }
+if (-not $domainName) { $domainName = Get-AzdEnvValue -Key 'desiredFabricDomainName' }
+if (-not $workspaceName) { $workspaceName = Get-AzdEnvValue -Key 'desiredFabricWorkspaceName' }
+
+$environmentName = Get-EnvironmentName
+if (-not $domainName -and $environmentName) { $domainName = "domain-$environmentName" }
+if (-not $workspaceName -and $environmentName) { $workspaceName = "workspace-$environmentName" }
# Fallback: try reading from parameter file
if (-not $domainName -and (Test-Path 'infra/main.bicepparam')) {
try {
$bicepparam = Get-Content 'infra/main.bicepparam' -Raw
- $m = [regex]::Match($bicepparam, "param\s+domainName\s*=\s*'(?[^']+)'")
+ $m = [regex]::Match($bicepparam, "param\s+desiredFabricDomainName\s*=\s*'(?[^']+)'")
+ if (-not $m.Success) { $m = [regex]::Match($bicepparam, "param\s+domainName\s*=\s*'(?[^']+)'") }
if ($m.Success) { $domainName = $m.Groups['val'].Value }
} catch {}
}
diff --git a/scripts/automationScripts/FabricWorkspace/CreateWorkspace/create_fabric_workspace.ps1 b/scripts/automationScripts/FabricWorkspace/CreateWorkspace/create_fabric_workspace.ps1
index bc867e6..338d573 100644
--- a/scripts/automationScripts/FabricWorkspace/CreateWorkspace/create_fabric_workspace.ps1
+++ b/scripts/automationScripts/FabricWorkspace/CreateWorkspace/create_fabric_workspace.ps1
@@ -21,14 +21,106 @@ function Log([string]$m){ Write-Host "[fabric-workspace] $m" }
function Warn([string]$m){ Write-Warning "[fabric-workspace] $m" }
function Fail([string]$m){ Write-Error "[fabric-workspace] $m"; Clear-SensitiveVariables -VariableNames @('accessToken'); exit 1 }
+function Get-NormalizedString {
+ param(
+ [Parameter(ValueFromPipeline = $true)]
+ $Value
+ )
+
+ if ($null -eq $Value) { return $null }
+
+ if ($Value -is [string]) {
+ $trimmed = $Value.Trim()
+ if ([string]::IsNullOrWhiteSpace($trimmed)) { return $null }
+ if ($trimmed -in @('System.Object[]', 'System.Object')) { return $null }
+ return $trimmed
+ }
+
+ if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) {
+ foreach ($item in $Value) {
+ $candidate = Get-NormalizedString -Value $item
+ if ($candidate) { return $candidate }
+ }
+ return $null
+ }
+
+ if ($Value.PSObject) {
+ foreach ($propertyName in @('value', 'id', 'resourceId', 'name', 'displayName')) {
+ if ($Value.PSObject.Properties[$propertyName]) {
+ $candidate = Get-NormalizedString -Value $Value.$propertyName
+ if ($candidate) { return $candidate }
+ }
+ }
+ }
+
+ $stringValue = $Value.ToString().Trim()
+ if ([string]::IsNullOrWhiteSpace($stringValue)) { return $null }
+ if ($stringValue -in @('System.Object[]', 'System.Object')) { return $null }
+ return $stringValue
+}
+
+function Get-CapacityLookupName {
+ param(
+ [string]$ResolvedCapacityId,
+ [string]$ResolvedCapacityName
+ )
+
+ if ($ResolvedCapacityId) {
+ if ($ResolvedCapacityId -match '^[0-9a-fA-F-]{36}$') { return $ResolvedCapacityId }
+ if ($ResolvedCapacityId -like '*/providers/Microsoft.Fabric/capacities/*') {
+ return ($ResolvedCapacityId -split '/')[ -1 ]
+ }
+ return $ResolvedCapacityId
+ }
+
+ return $ResolvedCapacityName
+}
+
+function Get-AzdEnvValue {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Key
+ )
+
+ try {
+ $value = & azd env get-value $Key 2>$null
+ if ($LASTEXITCODE -ne 0) { return $null }
+ return Get-NormalizedString -Value $value
+ } catch {
+ return $null
+ }
+}
+
+function Get-EnvironmentName {
+ if ($env:AZURE_ENV_NAME) { return $env:AZURE_ENV_NAME.Trim() }
+ return Get-AzdEnvValue -Key 'AZURE_ENV_NAME'
+}
+
+function Resolve-DeployedFabricCapacity {
+ param(
+ [string]$SubscriptionId,
+ [string]$ResourceGroup
+ )
+
+ if (-not $ResourceGroup) { return $null }
+
+ try {
+ $args = @('resource', 'list', '--resource-group', $ResourceGroup, '--resource-type', 'Microsoft.Fabric/capacities', '--query', '[0].{id:id,name:name}', '-o', 'json')
+ if ($SubscriptionId) { $args += @('--subscription', $SubscriptionId) }
+ $json = & az @args 2>$null
+ if ($LASTEXITCODE -ne 0 -or -not $json) { return $null }
+ return $json | ConvertFrom-Json -ErrorAction Stop
+ } catch {
+ return $null
+ }
+}
+
# Skip or BYO handling based on deployment outputs
$fabricWorkspaceMode = $env:fabricWorkspaceMode
if (-not $fabricWorkspaceMode) { $fabricWorkspaceMode = $env:fabricWorkspaceModeOut }
if (-not $fabricWorkspaceMode) {
- try {
- $azdMode = & azd env get-value fabricWorkspaceModeOut 2>$null
- if ($azdMode) { $fabricWorkspaceMode = $azdMode.ToString().Trim() }
- } catch {}
+ $azdMode = Get-AzdEnvValue -Key 'fabricWorkspaceModeOut'
+ if ($azdMode) { $fabricWorkspaceMode = $azdMode }
}
if (-not $fabricWorkspaceMode -and $env:AZURE_OUTPUTS_JSON) {
try {
@@ -81,21 +173,28 @@ if (-not $WorkspaceName -and $env:desiredFabricWorkspaceName) { $WorkspaceName =
if (-not $WorkspaceName -and $env:fabricWorkspaceNameOut) { $WorkspaceName = $env:fabricWorkspaceNameOut }
if (-not $CapacityId -and $env:fabricCapacityId) { $CapacityId = $env:fabricCapacityId }
if (-not $CapacityId -and $env:fabricCapacityResourceIdOut) { $CapacityId = $env:fabricCapacityResourceIdOut }
+$CapacityName = $null
+if ($env:FABRIC_CAPACITY_NAME) { $CapacityName = $env:FABRIC_CAPACITY_NAME }
+if (-not $CapacityName -and $env:fabricCapacityName) { $CapacityName = $env:fabricCapacityName }
# Fallback: try azd env get-value (common in azd hook execution where AZURE_OUTPUTS_JSON is not present)
if (-not $WorkspaceName) {
- try {
- $azdWorkspaceName = & azd env get-value desiredFabricWorkspaceName 2>$null
- if (-not $azdWorkspaceName) { $azdWorkspaceName = & azd env get-value fabricWorkspaceNameOut 2>$null }
- if ($azdWorkspaceName) { $WorkspaceName = $azdWorkspaceName.ToString().Trim() }
- } catch {}
+ $azdWorkspaceName = Get-AzdEnvValue -Key 'desiredFabricWorkspaceName'
+ if (-not $azdWorkspaceName) { $azdWorkspaceName = Get-AzdEnvValue -Key 'fabricWorkspaceNameOut' }
+ if ($azdWorkspaceName) { $WorkspaceName = $azdWorkspaceName }
}
if (-not $CapacityId) {
- try {
- $azdCapacityId = & azd env get-value fabricCapacityResourceIdOut 2>$null
- if (-not $azdCapacityId) { $azdCapacityId = & azd env get-value fabricCapacityId 2>$null }
- if ($azdCapacityId) { $CapacityId = $azdCapacityId.ToString().Trim() }
- } catch {}
+ $azdCapacityId = Get-AzdEnvValue -Key 'fabricCapacityResourceIdOut'
+ if (-not $azdCapacityId) { $azdCapacityId = Get-AzdEnvValue -Key 'fabricCapacityId' }
+ $CapacityId = Get-NormalizedString -Value $azdCapacityId
+}
+if (-not $CapacityName) {
+ $CapacityName = Get-AzdEnvValue -Key 'fabricCapacityName'
+}
+
+if (-not $WorkspaceName) {
+ $environmentName = Get-EnvironmentName
+ if ($environmentName) { $WorkspaceName = "workspace-$environmentName" }
}
# Resolve from AZURE_OUTPUTS_JSON if present
@@ -103,7 +202,10 @@ if (-not $WorkspaceName -and $env:AZURE_OUTPUTS_JSON) {
try { $out = $env:AZURE_OUTPUTS_JSON | ConvertFrom-Json; $WorkspaceName = $out.desiredFabricWorkspaceName.value } catch {}
}
if (-not $CapacityId -and $env:AZURE_OUTPUTS_JSON) {
- try { $out = $env:AZURE_OUTPUTS_JSON | ConvertFrom-Json; $CapacityId = $out.fabricCapacityId.value } catch {}
+ try { $out = $env:AZURE_OUTPUTS_JSON | ConvertFrom-Json; $CapacityId = Get-NormalizedString -Value $out.fabricCapacityId.value } catch {}
+}
+if (-not $CapacityName -and $env:AZURE_OUTPUTS_JSON) {
+ try { $out = $env:AZURE_OUTPUTS_JSON | ConvertFrom-Json; $CapacityName = Get-NormalizedString -Value $out.fabricCapacityName.value } catch {}
}
# Fallbacks: try .azure//.env and infra/main.bicep before failing
@@ -119,7 +221,8 @@ if (-not $WorkspaceName) {
if (Test-Path $envFile) {
Get-Content $envFile | ForEach-Object {
if ($_ -match '^FABRIC_WORKSPACE_NAME=(.+)$') { $WorkspaceName = $Matches[1].Trim("'", '"') }
- if ($_ -match '^fabricCapacityId=(.+)$') { $CapacityId = $Matches[1].Trim("'", '"') }
+ if ($_ -match '^fabricCapacityId=(.+)$') { $CapacityId = Get-NormalizedString -Value $Matches[1].Trim("'", '"') }
+ if ($_ -match '^fabricCapacityName=(.+)$') { $CapacityName = Get-NormalizedString -Value $Matches[1].Trim("'", '"') }
}
}
}
@@ -149,36 +252,98 @@ if (-not $WorkspaceName -and (Test-Path 'infra/main-orchestrator.bicep')) {
if (-not $WorkspaceName) { Fail 'FABRIC_WORKSPACE_NAME unresolved (no outputs/env/bicep).' }
+$WorkspaceName = Get-NormalizedString -Value $WorkspaceName
+$CapacityId = Get-NormalizedString -Value $CapacityId
+$CapacityName = Get-NormalizedString -Value $CapacityName
+
+if (-not $CapacityId -or -not $CapacityName) {
+ $subscriptionId = $env:AZURE_SUBSCRIPTION_ID
+ if (-not $subscriptionId) { $subscriptionId = Get-AzdEnvValue -Key 'AZURE_SUBSCRIPTION_ID' }
+ $resourceGroup = $env:AZURE_RESOURCE_GROUP
+ if (-not $resourceGroup) { $resourceGroup = Get-AzdEnvValue -Key 'AZURE_RESOURCE_GROUP' }
+ $resolvedCapacity = Resolve-DeployedFabricCapacity -SubscriptionId $subscriptionId -ResourceGroup $resourceGroup
+ if ($resolvedCapacity) {
+ if (-not $CapacityId -and $resolvedCapacity.id) { $CapacityId = Get-NormalizedString -Value $resolvedCapacity.id }
+ if (-not $CapacityName -and $resolvedCapacity.name) { $CapacityName = Get-NormalizedString -Value $resolvedCapacity.name }
+ }
+}
+
# If we are in create mode, fail fast when Fabric capacity wasn't provided.
# This avoids creating an orphaned workspace and then failing later when we try to assign a capacity.
if ((-not $fabricWorkspaceMode) -or ($fabricWorkspaceMode.ToString().Trim().ToLowerInvariant() -eq 'create')) {
- if (-not $CapacityId) {
+ if (-not $CapacityId -and -not $CapacityName) {
Fail "FABRIC_CAPACITY_ID unresolved. Either set Fabric to 'none' (fabricWorkspaceModeOut=none) or provide/provision a capacity (fabricCapacityModeOut=create/byo and fabricCapacityResourceIdOut)."
}
}
# Acquire tokens securely
try {
- Log "Acquiring Power BI API token..."
- $accessToken = Get-SecureApiToken -Resource $SecureApiResources.PowerBI -Description "Power BI"
+ Log "Acquiring Fabric API token..."
+ $accessToken = Get-SecureApiToken -Resource $SecureApiResources.Fabric -Description "Fabric"
} catch {
Fail "Authentication failed: $($_.Exception.Message)"
}
-$apiRoot = 'https://api.powerbi.com/v1.0/myorg'
+$apiRoot = 'https://api.fabric.microsoft.com/v1'
# Create secure headers
-$powerBIHeaders = New-SecureHeaders -Token $accessToken
+$apiHeaders = New-SecureHeaders -Token $accessToken
+
+function Resolve-WorkspaceIdByName {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Name
+ )
+
+ $foundId = $null
+ $nameLower = $Name.ToLower()
+
+ # Prefer workspaces list (when available)
+ try {
+ $workspaces = Invoke-SecureRestMethod -Uri "$apiRoot/workspaces?%24top=5000" -Headers $apiHeaders -Method Get -ErrorAction Stop
+ if ($workspaces.value) {
+ $match = $workspaces.value | Where-Object {
+ $displayName = if ($_.PSObject.Properties['displayName']) { $_.displayName } else { $null }
+ $wsName = if ($_.PSObject.Properties['name']) { $_.name } else { $null }
+ ($displayName -and $displayName.ToLower() -eq $nameLower) -or
+ ($wsName -and $wsName.ToLower() -eq $nameLower)
+ }
+ if ($match) { $foundId = $match.id }
+ }
+ } catch {
+ Warn "Workspace list (/workspaces) failed: $($_.Exception.Message)"
+ }
+
+ if (-not $foundId) {
+ try {
+ $groups = Invoke-SecureRestMethod -Uri "$apiRoot/groups?%24top=5000" -Headers $apiHeaders -Method Get -ErrorAction Stop
+ $g = $groups.value | Where-Object {
+ $groupName = if ($_.PSObject.Properties['name']) { $_.name } else { $null }
+ $groupDisplayName = if ($_.PSObject.Properties['displayName']) { $_.displayName } else { $null }
+ ($groupName -and $groupName.ToLower() -eq $nameLower) -or
+ ($groupDisplayName -and $groupDisplayName.ToLower() -eq $nameLower)
+ }
+ if ($g) { $foundId = $g.id }
+ } catch {
+ Warn "Workspace list (/groups) failed: $($_.Exception.Message)"
+ }
+ }
+
+ return $foundId
+}
# Resolve capacity GUID if capacity ARM id given
$capacityGuid = $null
Log "CapacityId parameter: '$CapacityId'"
-if ($CapacityId) {
- $capName = ($CapacityId -split '/')[ -1 ]
+if ($CapacityName) {
+ Log "CapacityName parameter: '$CapacityName'"
+}
+$capName = Get-CapacityLookupName -ResolvedCapacityId $CapacityId -ResolvedCapacityName $CapacityName
+if ($capName) {
Log "Deriving Fabric capacity GUID for name: $capName"
try {
- $caps = Invoke-SecureRestMethod -Uri "$apiRoot/admin/capacities" -Headers $powerBIHeaders -Method Get
+ $caps = Invoke-SecureRestMethod -Uri "$apiRoot/capacities" -Headers $apiHeaders -Method Get
if ($caps.value) {
Log "Searching through $($caps.value.Count) capacities for: '$capName'"
@@ -186,19 +351,20 @@ if ($CapacityId) {
foreach ($cap in $caps.value) {
$capDisplayName = if ($cap.PSObject.Properties['displayName']) { $cap.displayName } else { '' }
$capName2 = if ($cap.PSObject.Properties['name']) { $cap.name } else { '' }
+ $capId = if ($cap.PSObject.Properties['id']) { $cap.id } else { '' }
- Log " Checking capacity: displayName='$capDisplayName' name='$capName2' id='$($cap.id)'"
+ Log " Checking capacity: displayName='$capDisplayName' name='$capName2' id='$capId'"
# Direct string comparison
- if ($capDisplayName -eq $capName -or $capName2 -eq $capName) {
- $capacityGuid = $cap.id
+ if ($capDisplayName -eq $capName -or $capName2 -eq $capName -or $capId -eq $capName) {
+ $capacityGuid = $capId
Log "EXACT MATCH FOUND: Using capacity '$capDisplayName' with GUID: $capacityGuid"
break
}
# Case-insensitive fallback
- if ($capDisplayName.ToLower() -eq $capName.ToLower() -or $capName2.ToLower() -eq $capName.ToLower()) {
- $capacityGuid = $cap.id
+ if (([string]$capDisplayName).ToLowerInvariant() -eq $capName.ToLowerInvariant() -or ([string]$capName2).ToLowerInvariant() -eq $capName.ToLowerInvariant() -or ([string]$capId).ToLowerInvariant() -eq $capName.ToLowerInvariant()) {
+ $capacityGuid = $capId
Log "CASE-INSENSITIVE MATCH FOUND: Using capacity '$capDisplayName' with GUID: $capacityGuid"
break
}
@@ -207,7 +373,10 @@ if ($CapacityId) {
if (-not $capacityGuid) {
Log "NO MATCH FOUND. Available capacities:"
foreach ($cap in $caps.value) {
- Log " - displayName='$($cap.displayName)' name='$($cap.name)' id='$($cap.id)'"
+ $availableDisplayName = if ($cap.PSObject.Properties['displayName']) { $cap.displayName } else { '' }
+ $availableName = if ($cap.PSObject.Properties['name']) { $cap.name } else { '' }
+ $availableId = if ($cap.PSObject.Properties['id']) { $cap.id } else { '' }
+ Log " - displayName='$availableDisplayName' name='$availableName' id='$availableId'"
}
Fail "Could not find capacity named '$capName'"
}
@@ -227,11 +396,7 @@ if ($CapacityId) {
# Check if workspace exists
$workspaceId = $null
-try {
- $groups = Invoke-SecureRestMethod -Uri "$apiRoot/groups?%24top=5000" -Headers $powerBIHeaders -Method Get -ErrorAction Stop
- $g = $groups.value | Where-Object { $_.name -eq $WorkspaceName }
- if ($g) { $workspaceId = $g.id }
-} catch { }
+$workspaceId = Resolve-WorkspaceIdByName -Name $WorkspaceName
if ($workspaceId) {
Log "Workspace '$WorkspaceName' already exists (id=$workspaceId). Ensuring capacity assignment & admins."
@@ -240,7 +405,7 @@ if ($workspaceId) {
$currentCapacity = $null
$policyBlocked = $false
try {
- $workspace = Invoke-SecureRestMethod -Uri "$apiRoot/groups/$workspaceId" -Headers $powerBIHeaders -Method Get -ErrorAction Stop
+ $workspace = Invoke-SecureRestMethod -Uri "$apiRoot/workspaces/$workspaceId" -Headers $apiHeaders -Method Get -ErrorAction Stop
if ($workspace.capacityId) { $currentCapacity = $workspace.capacityId }
} catch {
$errMsg = $_.Exception.Message
@@ -259,12 +424,12 @@ if ($workspaceId) {
} else {
Log "Assigning workspace to capacity GUID $capacityGuid"
try {
- $assignResp = Invoke-SecureWebRequest -Uri "$apiRoot/groups/$workspaceId/AssignToCapacity" -Method Post -Headers ($powerBIHeaders) -Body (@{ capacityId = $capacityGuid } | ConvertTo-Json) -ErrorAction Stop
+ $assignResp = Invoke-SecureWebRequest -Uri "$apiRoot/workspaces/$workspaceId/assignToCapacity" -Method Post -Headers ($apiHeaders) -Body (@{ capacityId = $capacityGuid } | ConvertTo-Json) -ErrorAction Stop
Log "Capacity assignment response: $($assignResp.StatusCode)"
# Verify assignment worked
Start-Sleep -Seconds 3
- $workspace = Invoke-SecureRestMethod -Uri "$apiRoot/groups/$workspaceId" -Headers $powerBIHeaders -Method Get -ErrorAction Stop
+ $workspace = Invoke-SecureRestMethod -Uri "$apiRoot/workspaces/$workspaceId" -Headers $apiHeaders -Method Get -ErrorAction Stop
if ($workspace.capacityId) {
Log "Workspace successfully assigned to capacity: $($workspace.capacityId)"
} else {
@@ -283,15 +448,38 @@ if ($workspaceId) {
# assign admins
if ($AdminUPNs) {
$admins = $AdminUPNs -split ',' | ForEach-Object { $_.Trim() }
- try { $currentUsers = Invoke-SecureRestMethod -Uri "$apiRoot/groups/$workspaceId/users" -Headers $powerBIHeaders -Method Get -ErrorAction Stop } catch { $currentUsers = $null }
+ try { $currentRoleAssignments = Invoke-SecureRestMethod -Uri "$apiRoot/workspaces/$workspaceId/roleAssignments" -Headers $apiHeaders -Method Get -ErrorAction Stop } catch { $currentRoleAssignments = $null }
foreach ($admin in $admins) {
if ([string]::IsNullOrWhiteSpace($admin)) { continue }
$hasAdmin = $false
- if ($currentUsers -and $currentUsers.value) { $hasAdmin = ($currentUsers.value | Where-Object { $_.identifier -eq $admin -and $_.groupUserAccessRight -eq 'Admin' }) }
+ if ($currentRoleAssignments -and $currentRoleAssignments.value) {
+ $hasAdmin = ($currentRoleAssignments.value | Where-Object {
+ (($_.principal.id -eq $admin) -or ($_.principal.userDetails.userPrincipalName -eq $admin)) -and $_.role -eq 'Admin'
+ })
+ }
if (-not $hasAdmin) {
Log "Adding admin: $admin"
try {
- Invoke-SecureWebRequest -Uri "$apiRoot/groups/$workspaceId/users" -Method Post -Headers ($powerBIHeaders) -Body (@{ identifier = $admin; groupUserAccessRight = 'Admin'; principalType = 'User' } | ConvertTo-Json) -ErrorAction Stop
+ $principalId = $admin
+ if ($admin -like '*@*') {
+ try {
+ $userJson = az ad user show --id $admin --output json 2>$null
+ if ($LASTEXITCODE -eq 0 -and $userJson) {
+ $userObj = $userJson | ConvertFrom-Json -ErrorAction Stop
+ if ($userObj.id) {
+ $principalId = $userObj.id
+ } else {
+ Warn "No Entra user id returned for '$admin'."
+ }
+ } else {
+ Warn "Unable to resolve Entra user for '$admin' via az ad user show."
+ }
+ } catch {
+ Warn "Failed to resolve principal id for '$admin': $($_)"
+ }
+ }
+
+ Invoke-SecureWebRequest -Uri "$apiRoot/workspaces/$workspaceId/roleAssignments" -Method Post -Headers ($apiHeaders) -Body (@{ principal = @{ id = $principalId; type = 'User' }; role = 'Admin' } | ConvertTo-Json) -ErrorAction Stop
} catch { Warn "Failed to add $($admin): $($_)" }
} else { Log "Admin already present: $admin" }
}
@@ -309,24 +497,37 @@ if ($workspaceId) {
# Create workspace
Log "Creating Fabric workspace '$WorkspaceName'..."
-$createPayload = @{ name = $WorkspaceName; type = 'Workspace' } | ConvertTo-Json -Depth 4
+$createPayload = @{ displayName = $WorkspaceName } | ConvertTo-Json -Depth 4
try {
- $resp = Invoke-SecureWebRequest -Uri "$apiRoot/groups?workspaceV2=true" -Method Post -Headers $powerBIHeaders -Body $createPayload -ErrorAction Stop
+ $resp = Invoke-SecureWebRequest -Uri "$apiRoot/workspaces" -Method Post -Headers $apiHeaders -Body $createPayload -ErrorAction Stop
$body = $resp.Content | ConvertFrom-Json -ErrorAction SilentlyContinue
$workspaceId = $body.id
Log "Created workspace id: $workspaceId"
-} catch { Fail "Workspace creation failed: $_" }
+} catch {
+ $errMsg = $_.Exception.Message
+ if ($errMsg -match '409' -or $errMsg -match 'Conflict') {
+ Warn "Workspace create returned 409 (Conflict). Attempting to resolve existing workspace by name."
+ $workspaceId = Resolve-WorkspaceIdByName -Name $WorkspaceName
+ if ($workspaceId) { Log "Using existing workspace id: $workspaceId" }
+
+ if (-not $workspaceId) {
+ Fail "Workspace creation failed with 409, but existing workspace could not be resolved. $_"
+ }
+ } else {
+ Fail "Workspace creation failed: $_"
+ }
+}
# Assign to capacity
if ($capacityGuid) {
try {
Log "Assigning workspace to capacity GUID: $capacityGuid"
- $assignResp = Invoke-SecureWebRequest -Uri "$apiRoot/groups/$workspaceId/AssignToCapacity" -Method Post -Headers ($powerBIHeaders) -Body (@{ capacityId = $capacityGuid } | ConvertTo-Json) -ErrorAction Stop
+ $assignResp = Invoke-SecureWebRequest -Uri "$apiRoot/workspaces/$workspaceId/assignToCapacity" -Method Post -Headers ($apiHeaders) -Body (@{ capacityId = $capacityGuid } | ConvertTo-Json) -ErrorAction Stop
Log "Capacity assignment response: $($assignResp.StatusCode)"
# Verify assignment worked
Start-Sleep -Seconds 3
- $workspace = Invoke-SecureRestMethod -Uri "$apiRoot/groups/$workspaceId" -Headers $powerBIHeaders -Method Get -ErrorAction Stop
+ $workspace = Invoke-SecureRestMethod -Uri "$apiRoot/workspaces/$workspaceId" -Headers $apiHeaders -Method Get -ErrorAction Stop
if ($workspace.capacityId) {
Log "Workspace successfully assigned to capacity: $($workspace.capacityId)"
} else {
@@ -340,9 +541,28 @@ if ($AdminUPNs) {
$admins = $AdminUPNs -split ',' | ForEach-Object { $_.Trim() }
foreach ($admin in $admins) {
if ([string]::IsNullOrWhiteSpace($admin)) { continue }
+ Log "Adding admin: $admin"
try {
- Invoke-SecureWebRequest -Uri "$apiRoot/groups/$workspaceId/users" -Method Post -Headers ($powerBIHeaders) -Body (@{ identifier = $admin; groupUserAccessRight = 'Admin'; principalType = 'User' } | ConvertTo-Json) -ErrorAction Stop
- Log "Added admin: $admin"
+ $principalId = $admin
+ if ($admin -like '*@*') {
+ try {
+ $userJson = az ad user show --id $admin --output json 2>$null
+ if ($LASTEXITCODE -eq 0 -and $userJson) {
+ $userObj = $userJson | ConvertFrom-Json -ErrorAction Stop
+ if ($userObj.id) {
+ $principalId = $userObj.id
+ } else {
+ Warn "No Entra user id returned for '$admin'."
+ }
+ } else {
+ Warn "Unable to resolve Entra user for '$admin' via az ad user show."
+ }
+ } catch {
+ Warn "Failed to resolve principal id for '$admin': $($_)"
+ }
+ }
+
+ Invoke-SecureWebRequest -Uri "$apiRoot/workspaces/$workspaceId/roleAssignments" -Method Post -Headers ($apiHeaders) -Body (@{ principal = @{ id = $principalId; type = 'User' }; role = 'Admin' } | ConvertTo-Json) -ErrorAction Stop
} catch { Warn "Failed to add $($admin): $($_)" }
}
}
diff --git a/scripts/automationScripts/FabricWorkspace/CreateWorkspace/ensure_active_capacity.ps1 b/scripts/automationScripts/FabricWorkspace/CreateWorkspace/ensure_active_capacity.ps1
index 42b27e0..0fac27b 100644
--- a/scripts/automationScripts/FabricWorkspace/CreateWorkspace/ensure_active_capacity.ps1
+++ b/scripts/automationScripts/FabricWorkspace/CreateWorkspace/ensure_active_capacity.ps1
@@ -22,6 +22,41 @@ function Log([string]$m){ Write-Host "[fabric-capacity] $m" }
function Warn([string]$m){ Write-Warning "[fabric-capacity] $m" }
function Fail([string]$m){ Write-Error "[script] $m"; Clear-SensitiveVariables -VariableNames @("accessToken", "fabricToken", "purviewToken", "powerBIToken", "storageToken"); exit 1 }
+function Get-AzdEnvValue {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Key
+ )
+
+ try {
+ $value = & azd env get-value $Key 2>$null
+ if ($LASTEXITCODE -ne 0) { return $null }
+ if (-not $value) { return $null }
+ return $value.ToString().Trim()
+ } catch {
+ return $null
+ }
+}
+
+function Resolve-DeployedFabricCapacity {
+ param(
+ [string]$SubscriptionId,
+ [string]$ResourceGroup
+ )
+
+ if (-not $ResourceGroup) { return $null }
+
+ try {
+ $args = @('resource', 'list', '--resource-group', $ResourceGroup, '--resource-type', 'Microsoft.Fabric/capacities', '--query', '[0].{id:id,name:name}', '-o', 'json')
+ if ($SubscriptionId) { $args += @('--subscription', $SubscriptionId) }
+ $json = & az @args 2>$null
+ if ($LASTEXITCODE -ne 0 -or -not $json) { return $null }
+ return $json | ConvertFrom-Json -ErrorAction Stop
+ } catch {
+ return $null
+ }
+}
+
# Helper: parse AZURE_OUTPUTS_JSON if provided
function Get-OutputValue($jsonString, $path) {
if (-not $jsonString) { return $null }
@@ -49,10 +84,8 @@ $fabricCapacityMode = $null
if ($env:fabricCapacityMode) { $fabricCapacityMode = $env:fabricCapacityMode }
if (-not $fabricCapacityMode) { $fabricCapacityMode = $env:fabricCapacityModeOut }
if (-not $fabricCapacityMode) {
- try {
- $azdMode = & azd env get-value fabricCapacityModeOut 2>$null
- if ($azdMode) { $fabricCapacityMode = $azdMode.ToString().Trim() }
- } catch { }
+ $azdMode = Get-AzdEnvValue -Key 'fabricCapacityModeOut'
+ if ($azdMode) { $fabricCapacityMode = $azdMode }
}
if (-not $fabricCapacityMode -and $azureOutputsJson) {
$val = Get-OutputValue -jsonString $azureOutputsJson -path 'fabricCapacityModeOut.value'
@@ -122,6 +155,18 @@ if (-not $FABRIC_CAPACITY_ID -and $FABRIC_CAPACITY_NAME) {
}
}
+if (-not $FABRIC_CAPACITY_ID -or -not $FABRIC_CAPACITY_NAME) {
+ $subscriptionId = $env:AZURE_SUBSCRIPTION_ID
+ if (-not $subscriptionId) { $subscriptionId = Get-AzdEnvValue -Key 'AZURE_SUBSCRIPTION_ID' }
+ $resourceGroup = $env:AZURE_RESOURCE_GROUP
+ if (-not $resourceGroup) { $resourceGroup = Get-AzdEnvValue -Key 'AZURE_RESOURCE_GROUP' }
+ $resolvedCapacity = Resolve-DeployedFabricCapacity -SubscriptionId $subscriptionId -ResourceGroup $resourceGroup
+ if ($resolvedCapacity) {
+ if (-not $FABRIC_CAPACITY_ID -and $resolvedCapacity.id) { $FABRIC_CAPACITY_ID = $resolvedCapacity.id }
+ if (-not $FABRIC_CAPACITY_NAME -and $resolvedCapacity.name) { $FABRIC_CAPACITY_NAME = $resolvedCapacity.name }
+ }
+}
+
if (-not $FABRIC_CAPACITY_ID) {
Warn "FABRIC_CAPACITY_ID unresolved; skipping capacity activation checks."
Clear-SensitiveVariables -VariableNames @("accessToken", "fabricToken", "purviewToken", "powerBIToken", "storageToken")
diff --git a/scripts/automationScripts/FabricWorkspace/CreateWorkspace/materialize_document_folders.ps1 b/scripts/automationScripts/FabricWorkspace/CreateWorkspace/materialize_document_folders.ps1
index e4bc979..f47d157 100644
--- a/scripts/automationScripts/FabricWorkspace/CreateWorkspace/materialize_document_folders.ps1
+++ b/scripts/automationScripts/FabricWorkspace/CreateWorkspace/materialize_document_folders.ps1
@@ -75,14 +75,14 @@ if (-not $WorkspaceId) {
}
if (-not $WorkspaceId) {
- Write-Warning "[materialize] WorkspaceId not provided and could not be resolved from environment; skipping."
+ Write-Host "[materialize] WorkspaceId not provided and could not be resolved; skipping."
exit 0
}
# Get access token for OneLake (uses Storage scope)
$storageToken = Get-SecureApiToken -Resource $SecureApiResources.Storage -Description "Storage"
if (!$storageToken) {
- Write-Warning "[materialize] Failed to get storage access token; skipping."
+ Write-Host "[materialize] Failed to get storage access token; skipping."
exit 0
}
@@ -94,23 +94,27 @@ Write-Host "[materialize] Getting lakehouse ID for '$LakehouseName'..."
# Create secure headers
$fabricHeaders = New-SecureHeaders -Token $fabricToken -AdditionalHeaders @{ 'Content-Type' = 'application/json' }
-try {
- $lakehousesResponse = Invoke-SecureRestMethod -Uri "https://api.fabric.microsoft.com/v1/workspaces/$WorkspaceId/lakehouses" -Headers $fabricHeaders -Method Get
- $lakehouse = $lakehousesResponse.value | Where-Object { $_.displayName -eq $LakehouseName }
-
- if (!$lakehouse) {
- Write-Warning "[materialize] Lakehouse '$LakehouseName' not found in workspace; skipping."
- exit 0
- }
-
- $lakehouseId = $lakehouse.id
- Write-Host "[materialize] Found lakehouse '$LakehouseName' with ID: $lakehouseId"
-
-} catch {
- # Import security module
- $SecurityModulePath = Join-Path $PSScriptRoot "../../SecurityModule.ps1"
+$lakehouseId = $null
+for ($attempt = 1; $attempt -le 6; $attempt++) {
+ try {
+ $lakehousesResponse = Invoke-SecureRestMethod -Uri "https://api.fabric.microsoft.com/v1/workspaces/$WorkspaceId/lakehouses" -Headers $fabricHeaders -Method Get
+ $lakehouse = $lakehousesResponse.value | Where-Object { $_.displayName -eq $LakehouseName } | Select-Object -First 1
+ if ($lakehouse) {
+ $lakehouseId = $lakehouse.id
+ break
+ }
+ } catch { }
+
+ Start-Sleep -Seconds (5 * $attempt)
}
+if (-not $lakehouseId) {
+ Write-Host "[materialize] Lakehouse '$LakehouseName' not found yet; skipping."
+ exit 0
+}
+
+Write-Host "[materialize] Found lakehouse '$LakehouseName' with ID: $lakehouseId"
+
# Create secure headers for storage access
$storageHeaders = New-SecureHeaders -Token $storageToken
@@ -122,6 +126,16 @@ $onelakeHeaders = $storageHeaders + @{
# Base URI for OneLake access
$baseUri = "https://onelake.dfs.fabric.microsoft.com/$WorkspaceId/$lakehouseId"
+# Skip if structure already exists
+try {
+ $checkUri = "$baseUri/Files/documents" + "?resource=filesystem&recursive=true"
+ $checkResponse = Invoke-SecureRestMethod -Uri $checkUri -Headers $onelakeHeaders -Method GET
+ if ($checkResponse.paths) {
+ Write-Host "[materialize] Folder structure already present; skipping."
+ exit 0
+ }
+} catch { }
+
# Define folder structure to create
$foldersToCreate = @(
"Files/documents",
diff --git a/scripts/automationScripts/FabricWorkspace/CreateWorkspace/register_fabric_datasource.ps1 b/scripts/automationScripts/FabricWorkspace/CreateWorkspace/register_fabric_datasource.ps1
index 7bb1c4a..c54a7bb 100644
--- a/scripts/automationScripts/FabricWorkspace/CreateWorkspace/register_fabric_datasource.ps1
+++ b/scripts/automationScripts/FabricWorkspace/CreateWorkspace/register_fabric_datasource.ps1
@@ -17,6 +17,12 @@ function Log([string]$m){ Write-Host "[register-datasource] $m" }
function Warn([string]$m){ Write-Warning "[register-datasource] $m" }
function Fail([string]$m){ Write-Error "[register-datasource] $m"; Clear-SensitiveVariables -VariableNames @('purviewToken'); exit 1 }
+if ($env:SKIP_PURVIEW_INTEGRATION -and $env:SKIP_PURVIEW_INTEGRATION.ToLowerInvariant() -eq 'true') {
+ Warn "SKIP_PURVIEW_INTEGRATION=true; skipping Purview datasource registration."
+ Clear-SensitiveVariables -VariableNames @('purviewToken')
+ exit 0
+}
+
# Skip when Fabric workspace automation is disabled
$fabricWorkspaceMode = $env:fabricWorkspaceMode
if (-not $fabricWorkspaceMode) { $fabricWorkspaceMode = $env:fabricWorkspaceModeOut }
@@ -51,7 +57,17 @@ function Test-FabricCapacityActive {
try {
$resJson = & az resource show --ids $capacityId -o json 2>$null | ConvertFrom-Json -ErrorAction Stop
- $state = $resJson.properties.state
+ $state = $null
+ if ($resJson.PSObject.Properties['properties'] -and $resJson.properties -and $resJson.properties.PSObject.Properties['state']) {
+ $state = $resJson.properties.state
+ }
+ if (-not $state -and $resJson.PSObject.Properties['state']) {
+ $state = $resJson.state
+ }
+ if (-not $state -and $resJson.PSObject.Properties['provisioningState']) {
+ $state = $resJson.provisioningState
+ }
+ if (-not $state) { return $true }
if ($state -eq 'Active') { return $true }
Log "Fabric capacity state: $state"
return $false
@@ -70,12 +86,65 @@ if (-not (Test-FabricCapacityActive)) {
function Get-AzdEnvValue([string]$key){
$value = $null
- try { $value = & azd env get-value $key 2>$null } catch { $value = $null }
+ try {
+ $value = & azd env get-value $key 2>$null
+ if ($LASTEXITCODE -ne 0) { $value = $null }
+ } catch { $value = $null }
if ([string]::IsNullOrWhiteSpace($value)) { return $null }
if ($value -match '^\s*ERROR:') { return $null }
return $value.Trim()
}
+function Get-LatestDeploymentOutputs([string]$resourceGroup, [string]$subscriptionId, [string]$environmentName) {
+ if ([string]::IsNullOrWhiteSpace($resourceGroup)) { return $null }
+
+ try {
+ $listArgs = @('deployment', 'group', 'list', '--resource-group', $resourceGroup, '-o', 'json')
+ if ($subscriptionId) { $listArgs += @('--subscription', $subscriptionId) }
+ $deploymentsJson = & az @listArgs 2>$null
+ if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($deploymentsJson)) { return $null }
+
+ $deployments = @($deploymentsJson | ConvertFrom-Json -ErrorAction Stop)
+ if (-not $deployments) { return $null }
+
+ $preferred = $null
+ if (-not [string]::IsNullOrWhiteSpace($environmentName)) {
+ $preferred = $deployments |
+ Where-Object { $_.name -like "$environmentName-*" } |
+ Sort-Object { $_.properties.timestamp } -Descending |
+ Select-Object -First 1
+ }
+ if (-not $preferred) {
+ $preferred = $deployments |
+ Where-Object { $_.name -notlike 'PolicyDeployment_*' } |
+ Sort-Object { $_.properties.timestamp } -Descending |
+ Select-Object -First 1
+ }
+ if (-not $preferred) { return $null }
+
+ $showArgs = @('deployment', 'group', 'show', '--resource-group', $resourceGroup, '--name', $preferred.name, '--query', 'properties.outputs', '-o', 'json')
+ if ($subscriptionId) { $showArgs += @('--subscription', $subscriptionId) }
+ $outputsJson = & az @showArgs 2>$null
+ if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($outputsJson)) { return $null }
+
+ return $outputsJson | ConvertFrom-Json -ErrorAction Stop
+ } catch {
+ return $null
+ }
+}
+
+function Get-OutputValue([object]$outputsObject, [string]$propertyName) {
+ if (-not $outputsObject) { return $null }
+
+ $property = $outputsObject.PSObject.Properties[$propertyName]
+ if (-not $property -or -not $property.Value) { return $null }
+
+ $valueProperty = $property.Value.PSObject.Properties['value']
+ if ($valueProperty) { return $valueProperty.Value }
+
+ return $null
+}
+
function Resolve-PurviewFromResourceId([string]$resourceId) {
if ([string]::IsNullOrWhiteSpace($resourceId)) { return $null }
$parts = $resourceId.Split('/', [System.StringSplitOptions]::RemoveEmptyEntries)
@@ -87,14 +156,52 @@ function Resolve-PurviewFromResourceId([string]$resourceId) {
}
}
-# Resolve Purview account and collection name from azd (if present)
-$purviewAccountName = Get-AzdEnvValue -key 'purviewAccountName'
+function Get-DefaultPurviewCollectionName() {
+ $environmentName = $env:AZURE_ENV_NAME
+ if (-not $environmentName) { $environmentName = Get-AzdEnvValue -key 'AZURE_ENV_NAME' }
+ if ([string]::IsNullOrWhiteSpace($environmentName)) { return $null }
+
+ return "collection-$($environmentName.Trim())"
+}
+
+# Resolve Purview account and collection name from outputs/env/azd
+$outputs = $null
+if ($env:AZURE_OUTPUTS_JSON) {
+ try { $outputs = $env:AZURE_OUTPUTS_JSON | ConvertFrom-Json -ErrorAction Stop } catch { $outputs = $null }
+}
+if (-not $outputs) {
+ $deploymentResourceGroup = $env:AZURE_RESOURCE_GROUP
+ if (-not $deploymentResourceGroup) { $deploymentResourceGroup = Get-AzdEnvValue -key 'AZURE_RESOURCE_GROUP' }
+ $deploymentSubscriptionId = $env:AZURE_SUBSCRIPTION_ID
+ if (-not $deploymentSubscriptionId) { $deploymentSubscriptionId = Get-AzdEnvValue -key 'AZURE_SUBSCRIPTION_ID' }
+ $deploymentEnvironmentName = $env:AZURE_ENV_NAME
+ if (-not $deploymentEnvironmentName) { $deploymentEnvironmentName = Get-AzdEnvValue -key 'AZURE_ENV_NAME' }
+ $outputs = Get-LatestDeploymentOutputs -resourceGroup $deploymentResourceGroup -subscriptionId $deploymentSubscriptionId -environmentName $deploymentEnvironmentName
+}
+
+$purviewAccountName = $null
+$collectionName = $null
+$purviewAccountResourceId = $null
+$purviewSubscriptionId = $null
+$purviewResourceGroup = $null
+
+if ($outputs) {
+ $purviewAccountName = Get-OutputValue -outputsObject $outputs -propertyName 'purviewAccountName'
+ $collectionName = Get-OutputValue -outputsObject $outputs -propertyName 'purviewCollectionName'
+ if (-not $collectionName) { $collectionName = Get-OutputValue -outputsObject $outputs -propertyName 'desiredFabricDomainName' }
+ $purviewAccountResourceId = Get-OutputValue -outputsObject $outputs -propertyName 'purviewAccountResourceId'
+ $purviewSubscriptionId = Get-OutputValue -outputsObject $outputs -propertyName 'purviewSubscriptionId'
+ $purviewResourceGroup = Get-OutputValue -outputsObject $outputs -propertyName 'purviewResourceGroup'
+}
+
+if (-not $purviewAccountName) { $purviewAccountName = Get-AzdEnvValue -key 'purviewAccountName' }
# First try purviewCollectionName, then fall back to desiredFabricDomainName for backwards compatibility
-$collectionName = Get-AzdEnvValue -key 'purviewCollectionName'
+if (-not $collectionName) { $collectionName = Get-AzdEnvValue -key 'purviewCollectionName' }
if (-not $collectionName) { $collectionName = Get-AzdEnvValue -key 'desiredFabricDomainName' }
-$purviewAccountResourceId = Get-AzdEnvValue -key 'purviewAccountResourceId'
-$purviewSubscriptionId = Get-AzdEnvValue -key 'purviewSubscriptionId'
-$purviewResourceGroup = Get-AzdEnvValue -key 'purviewResourceGroup'
+if (-not $collectionName) { $collectionName = Get-DefaultPurviewCollectionName }
+if (-not $purviewAccountResourceId) { $purviewAccountResourceId = Get-AzdEnvValue -key 'purviewAccountResourceId' }
+if (-not $purviewSubscriptionId) { $purviewSubscriptionId = Get-AzdEnvValue -key 'purviewSubscriptionId' }
+if (-not $purviewResourceGroup) { $purviewResourceGroup = Get-AzdEnvValue -key 'purviewResourceGroup' }
if (-not $purviewAccountResourceId) { $purviewAccountResourceId = $env:PURVIEW_ACCOUNT_RESOURCE_ID }
diff --git a/scripts/automationScripts/FabricWorkspace/mirror/create_postgresql_mirror.ps1 b/scripts/automationScripts/FabricWorkspace/mirror/create_postgresql_mirror.ps1
new file mode 100644
index 0000000..f455afe
--- /dev/null
+++ b/scripts/automationScripts/FabricWorkspace/mirror/create_postgresql_mirror.ps1
@@ -0,0 +1,924 @@
+<#
+.SYNOPSIS
+ Create a Fabric mirrored database for the provisioned PostgreSQL server.
+#>
+
+[CmdletBinding()]
+param(
+ [string]$MirrorName = $env:FABRIC_POSTGRES_MIRROR_NAME,
+ [string]$DatabaseName = $env:POSTGRES_DATABASE_NAME,
+ [string]$ConnectionId = $env:FABRIC_POSTGRES_CONNECTION_ID,
+ [string]$WorkspaceId = $env:FABRIC_WORKSPACE_ID,
+ [string]$ConnectionDisplayName = $env:FABRIC_POSTGRES_CONNECTION_NAME,
+ [string]$GatewayId = $env:FABRIC_POSTGRES_GATEWAY_ID,
+ [string]$MirrorConnectionMode = $env:POSTGRES_MIRROR_CONNECTION_MODE,
+ [string]$MirrorConnectionUserName = $env:POSTGRES_MIRROR_CONNECTION_USER_NAME,
+ [string]$MirrorConnectionSecretName = $env:POSTGRES_MIRROR_CONNECTION_SECRET_NAME,
+ [string]$MirrorConnectionPassword = $env:POSTGRES_MIRROR_CONNECTION_PASSWORD,
+ [string]$TempEnableKeyVaultPublicAccess = $env:POSTGRES_TEMP_ENABLE_KV_PUBLIC_ACCESS
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+# Import security module
+$SecurityModulePath = Join-Path $PSScriptRoot "../../SecurityModule.ps1"
+. $SecurityModulePath
+
+function Log([string]$m){ Write-Host "[fabric-pg-mirror] $m" }
+function Warn([string]$m){ Write-Warning "[fabric-pg-mirror] $m" }
+function Fail([string]$m){ Write-Error "[fabric-pg-mirror] $m"; exit 1 }
+function IsTrue([string]$v){ return ($v -and $v.ToString().Trim().ToLowerInvariant() -in @('1','true','yes')) }
+
+function Get-AzdEnvValue([string]$key) {
+ try {
+ $val = & azd env get-value $key 2>$null
+ if ($val -and -not ($val -match '^\s*ERROR:')) { return $val.ToString().Trim() }
+ } catch {}
+
+ return $null
+}
+
+function Get-LatestDeploymentOutputs([string]$resourceGroup, [string]$subscriptionId, [string]$environmentName) {
+ if ([string]::IsNullOrWhiteSpace($resourceGroup)) { return $null }
+
+ try {
+ $listArgs = @('deployment', 'group', 'list', '--resource-group', $resourceGroup, '-o', 'json')
+ if ($subscriptionId) { $listArgs += @('--subscription', $subscriptionId) }
+ $deploymentsJson = & az @listArgs 2>$null
+ if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($deploymentsJson)) { return $null }
+
+ $deployments = @($deploymentsJson | ConvertFrom-Json -ErrorAction Stop)
+ if (-not $deployments) { return $null }
+
+ $preferred = $null
+ if (-not [string]::IsNullOrWhiteSpace($environmentName)) {
+ $preferred = $deployments |
+ Where-Object { $_.name -like "$environmentName-*" } |
+ Sort-Object { $_.properties.timestamp } -Descending |
+ Select-Object -First 1
+ }
+ if (-not $preferred) {
+ $preferred = $deployments |
+ Where-Object { $_.name -notlike 'PolicyDeployment_*' } |
+ Sort-Object { $_.properties.timestamp } -Descending |
+ Select-Object -First 1
+ }
+ if (-not $preferred) { return $null }
+
+ $showArgs = @('deployment', 'group', 'show', '--resource-group', $resourceGroup, '--name', $preferred.name, '--query', 'properties.outputs', '-o', 'json')
+ if ($subscriptionId) { $showArgs += @('--subscription', $subscriptionId) }
+ $outputsJson = & az @showArgs 2>$null
+ if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($outputsJson)) { return $null }
+
+ return $outputsJson | ConvertFrom-Json -ErrorAction Stop
+ } catch {
+ return $null
+ }
+}
+
+function Set-AzdEnvValue([string]$key, [string]$value) {
+ if ([string]::IsNullOrWhiteSpace($value)) { return }
+
+ try {
+ & azd env set-value $key $value 1>$null
+ } catch {
+ Warn "Failed to persist '$key' to azd env: $($_.Exception.Message)"
+ }
+}
+
+function Get-ResourceNameFromId([string]$resourceId) {
+ if ([string]::IsNullOrWhiteSpace($resourceId)) { return $null }
+
+ $segments = $resourceId.Split('/', [System.StringSplitOptions]::RemoveEmptyEntries)
+ if ($segments.Length -lt 2) { return $null }
+
+ return $segments[$segments.Length - 1]
+}
+
+function Get-ResourceGroupFromId([string]$resourceId) {
+ if ([string]::IsNullOrWhiteSpace($resourceId)) { return $null }
+
+ $segments = $resourceId.Split('/', [System.StringSplitOptions]::RemoveEmptyEntries)
+ $rgIndex = [Array]::IndexOf($segments, 'resourceGroups')
+ if ($rgIndex -lt 0 -or $rgIndex + 1 -ge $segments.Length) { return $null }
+
+ return $segments[$rgIndex + 1]
+}
+
+function Invoke-AzCliCapture([string[]]$Args) {
+ $output = & az @Args
+ if ($LASTEXITCODE -ne 0) {
+ throw "Azure CLI command failed with exit code ${LASTEXITCODE}: az $($Args -join ' ')"
+ }
+
+ return $output
+}
+
+function Test-KeyVaultAccess([string]$vaultName) {
+ try {
+ $null = Invoke-AzCliCapture @('keyvault','secret','list','--vault-name', $vaultName,'--maxresults','1','--query','[0].id','-o','tsv')
+ return $true
+ } catch {
+ return $false
+ }
+}
+
+function Set-KeyVaultPublicAccess([string]$vaultName, [string]$state) {
+ if ([string]::IsNullOrWhiteSpace($vaultName)) { return }
+
+ & az keyvault update -n $vaultName --public-network-access $state 1>$null
+ if ($LASTEXITCODE -ne 0) {
+ throw "Failed to set Key Vault public network access to '$state' for '$vaultName'."
+ }
+}
+
+function Get-PostgreSqlPublicAccess([string]$resourceGroup, [string]$serverName, [string]$subscriptionId) {
+ if ([string]::IsNullOrWhiteSpace($resourceGroup) -or [string]::IsNullOrWhiteSpace($serverName)) { return $null }
+
+ try {
+ $args = @('postgres', 'flexible-server', 'show', '--resource-group', $resourceGroup, '--name', $serverName, '--query', 'network.publicNetworkAccess', '-o', 'tsv')
+ if ($subscriptionId) { $args += @('--subscription', $subscriptionId) }
+
+ $value = & az @args 2>$null
+ if ($LASTEXITCODE -ne 0 -or -not $value) { return $null }
+
+ return $value.ToString().Trim()
+ } catch {
+ return $null
+ }
+}
+
+function Set-PostgreSqlPublicAccess([string]$resourceGroup, [string]$serverName, [string]$state, [string]$subscriptionId) {
+ if ([string]::IsNullOrWhiteSpace($resourceGroup) -or [string]::IsNullOrWhiteSpace($serverName) -or [string]::IsNullOrWhiteSpace($state)) { return }
+
+ $args = @('postgres', 'flexible-server', 'update', '--resource-group', $resourceGroup, '--name', $serverName, '--public-access', $state)
+ if ($subscriptionId) { $args += @('--subscription', $subscriptionId) }
+
+ & az @args 1>$null
+ if ($LASTEXITCODE -ne 0) {
+ throw "Failed to set PostgreSQL public access to '$state' for '$serverName'."
+ }
+}
+
+function Add-PostgreSqlFirewallRule([string]$resourceGroup, [string]$serverName, [string]$ruleName, [string]$startIpAddress, [string]$endIpAddress, [string]$subscriptionId) {
+ if ([string]::IsNullOrWhiteSpace($resourceGroup) -or [string]::IsNullOrWhiteSpace($serverName) -or [string]::IsNullOrWhiteSpace($ruleName)) { return }
+
+ $args = @('postgres', 'flexible-server', 'firewall-rule', 'create', '--resource-group', $resourceGroup, '--name', $serverName, '--rule-name', $ruleName, '--start-ip-address', $startIpAddress, '--end-ip-address', $endIpAddress)
+ if ($subscriptionId) { $args += @('--subscription', $subscriptionId) }
+
+ & az @args 1>$null
+ if ($LASTEXITCODE -ne 0) {
+ throw "Failed to create PostgreSQL firewall rule '$ruleName' for '$serverName'."
+ }
+}
+
+function Remove-PostgreSqlFirewallRule([string]$resourceGroup, [string]$serverName, [string]$ruleName, [string]$subscriptionId) {
+ if ([string]::IsNullOrWhiteSpace($resourceGroup) -or [string]::IsNullOrWhiteSpace($serverName) -or [string]::IsNullOrWhiteSpace($ruleName)) { return }
+
+ $args = @('postgres', 'flexible-server', 'firewall-rule', 'delete', '--resource-group', $resourceGroup, '--name', $serverName, '--rule-name', $ruleName, '--yes')
+ if ($subscriptionId) { $args += @('--subscription', $subscriptionId) }
+
+ & az @args 1>$null 2>$null
+}
+
+function Invoke-FabricPagedGet([string]$InitialUri, [hashtable]$Headers, [string]$Description) {
+ $results = @()
+ $nextUri = $InitialUri
+
+ while ($nextUri) {
+ $page = Invoke-SecureRestMethod -Uri $nextUri -Headers $Headers -Method Get -Description $Description
+
+ if ($page -is [System.Array]) {
+ $results += @($page)
+ $nextUri = $null
+ continue
+ }
+
+ $valueProperty = $page.PSObject.Properties['value']
+ if ($valueProperty -and $valueProperty.Value) {
+ $results += @($page.value)
+ } elseif ($page) {
+ $results += @($page)
+ }
+
+ $continuationProperty = $page.PSObject.Properties['continuationUri']
+ if ($continuationProperty -and $continuationProperty.Value) {
+ $nextUri = $continuationProperty.Value
+ } else {
+ $nextUri = $null
+ }
+ }
+
+ return $results
+}
+
+function Get-ConnectionParameterValue([object]$parameterDefinition, [string]$ServerFqdn, [string]$TargetDatabase, [string]$UserName) {
+ $parameterName = $parameterDefinition.name.ToString().Trim().ToLowerInvariant()
+ $allowedValues = @($parameterDefinition.allowedValues)
+
+ switch ($parameterName) {
+ 'server' { return $ServerFqdn }
+ 'host' { return $ServerFqdn }
+ 'database' { return $TargetDatabase }
+ 'databasename' { return $TargetDatabase }
+ 'port' { return 5432 }
+ 'username' { return $UserName }
+ 'user' { return $UserName }
+ default {
+ if ($allowedValues.Count -eq 1) {
+ return $allowedValues[0]
+ }
+ }
+ }
+
+ return $null
+}
+
+function New-ConnectionDetailsParameter([object]$parameterDefinition, $value) {
+ $parameter = @{
+ dataType = $parameterDefinition.dataType
+ name = $parameterDefinition.name
+ }
+
+ switch ($parameterDefinition.dataType) {
+ 'Number' { $parameter.value = [int]$value }
+ 'Boolean' { $parameter.value = [bool]$value }
+ default { $parameter.value = [string]$value }
+ }
+
+ return $parameter
+}
+
+function Select-PostgreSqlConnectionMetadata([object[]]$SupportedTypes) {
+ $candidates = @($SupportedTypes | Where-Object {
+ $creationMethods = @($_.creationMethods)
+ $_.type -match 'postgres' -or (@($creationMethods | Where-Object { $_.name -match 'postgres' })).Count -gt 0
+ })
+
+ if (-not $candidates) {
+ throw 'Fabric did not report a supported PostgreSQL connection type.'
+ }
+
+ $orderedCandidates = $candidates | Sort-Object @(
+ @{ Expression = {
+ if ($_.type -match '^Azure.*PostgreSQL$') { 0 }
+ elseif ($_.type -match '^PostgreSQL$') { 1 }
+ else { 2 }
+ }
+ },
+ @{ Expression = { $_.type } }
+ )
+
+ foreach ($candidate in $orderedCandidates) {
+ $selectedMethod = @($candidate.creationMethods | Sort-Object @(
+ @{ Expression = { if ($_.name -match 'postgres') { 0 } else { 1 } } },
+ @{ Expression = { $_.name } }
+ )) | Select-Object -First 1
+
+ if ($selectedMethod) {
+ return @{
+ Type = $candidate.type
+ CreationMethod = $selectedMethod
+ Metadata = $candidate
+ }
+ }
+ }
+
+ throw 'Fabric reported PostgreSQL connection metadata, but no creation method was available.'
+}
+
+function New-FabricPostgreSqlConnectionBody(
+ [string]$DisplayName,
+ [string]$ConnectivityType,
+ [string]$ConnectionType,
+ [string]$CreationMethod,
+ [object[]]$Parameters,
+ [string]$PrivacyLevel,
+ [string]$ConnectionEncryption,
+ [string]$UserName,
+ [string]$Password,
+ [string]$GatewayId
+) {
+ $body = @{
+ connectivityType = $ConnectivityType
+ displayName = $DisplayName
+ connectionDetails = @{
+ type = $ConnectionType
+ creationMethod = $CreationMethod
+ parameters = $Parameters
+ }
+ privacyLevel = $PrivacyLevel
+ credentialDetails = @{
+ singleSignOnType = 'None'
+ connectionEncryption = $ConnectionEncryption
+ skipTestConnection = $false
+ credentials = @{
+ credentialType = 'Basic'
+ username = [string]$UserName
+ password = [string]$Password
+ }
+ }
+ }
+
+ if ($GatewayId) {
+ $body.gatewayId = $GatewayId
+ }
+
+ return $body
+}
+
+function Test-IsFabricIncorrectCredentialFailure([string]$ResponseBody) {
+ if ([string]::IsNullOrWhiteSpace($ResponseBody)) { return $false }
+
+ return ($ResponseBody -match 'IncorrectCredentials' -or $ResponseBody -match 'AccessUnauthorized')
+}
+
+function Test-IsFabricConnectivityTimeoutFailure([string]$ResponseBody) {
+ if ([string]::IsNullOrWhiteSpace($ResponseBody)) { return $false }
+
+ return ($ResponseBody -match 'did not properly respond' -or $ResponseBody -match 'failed to respond' -or $ResponseBody -match 'No such host is known' -or $ResponseBody -match 'Gateway_MashupDataAccessError')
+}
+
+# Skip when Fabric workspace is disabled
+$fabricWorkspaceMode = $env:fabricWorkspaceMode
+if (-not $fabricWorkspaceMode) { $fabricWorkspaceMode = $env:fabricWorkspaceModeOut }
+if (-not $fabricWorkspaceMode) {
+ try {
+ $azdMode = & azd env get-value fabricWorkspaceModeOut 2>$null
+ if ($azdMode) { $fabricWorkspaceMode = $azdMode.ToString().Trim() }
+ } catch {}
+}
+if (-not $fabricWorkspaceMode -and $env:AZURE_OUTPUTS_JSON) {
+ try {
+ $out0 = $env:AZURE_OUTPUTS_JSON | ConvertFrom-Json -ErrorAction Stop
+ if ($out0.fabricWorkspaceModeOut -and $out0.fabricWorkspaceModeOut.value) { $fabricWorkspaceMode = $out0.fabricWorkspaceModeOut.value }
+ elseif ($out0.fabricWorkspaceMode -and $out0.fabricWorkspaceMode.value) { $fabricWorkspaceMode = $out0.fabricWorkspaceMode.value }
+ } catch {}
+}
+if ($fabricWorkspaceMode -and $fabricWorkspaceMode.ToString().Trim().ToLowerInvariant() -eq 'none') {
+ Warn "Fabric workspace mode is 'none'; skipping PostgreSQL mirror."
+ exit 0
+}
+
+# Resolve PostgreSQL outputs
+$postgreSqlServerResourceId = $null
+$postgreSqlServerName = $null
+$postgreSqlServerFqdn = $null
+$serverDetails = $null
+$postgreSqlSystemAssignedPrincipalId = $null
+$postgreSqlAdminLogin = $null
+$postgreSqlFabricUserName = $null
+$postgreSqlFabricUserSecretName = $null
+$keyVaultResourceId = $null
+
+if ($env:AZURE_OUTPUTS_JSON) {
+ try {
+ $out = $env:AZURE_OUTPUTS_JSON | ConvertFrom-Json -ErrorAction Stop
+ if ($out.postgreSqlServerResourceId -and $out.postgreSqlServerResourceId.value) { $postgreSqlServerResourceId = $out.postgreSqlServerResourceId.value }
+ if ($out.postgreSqlServerNameOut -and $out.postgreSqlServerNameOut.value) { $postgreSqlServerName = $out.postgreSqlServerNameOut.value }
+ if ($out.postgreSqlServerFqdn -and $out.postgreSqlServerFqdn.value) { $postgreSqlServerFqdn = $out.postgreSqlServerFqdn.value }
+ if ($out.postgreSqlSystemAssignedPrincipalId -and $out.postgreSqlSystemAssignedPrincipalId.value) { $postgreSqlSystemAssignedPrincipalId = $out.postgreSqlSystemAssignedPrincipalId.value }
+ if ($out.postgreSqlAdminLoginOut -and $out.postgreSqlAdminLoginOut.value) { $postgreSqlAdminLogin = $out.postgreSqlAdminLoginOut.value }
+ if ($out.postgreSqlFabricUserNameOut -and $out.postgreSqlFabricUserNameOut.value) { $postgreSqlFabricUserName = $out.postgreSqlFabricUserNameOut.value }
+ if ($out.postgreSqlFabricUserSecretNameOut -and $out.postgreSqlFabricUserSecretNameOut.value) { $postgreSqlFabricUserSecretName = $out.postgreSqlFabricUserSecretNameOut.value }
+ if ($out.keyVaultResourceId -and $out.keyVaultResourceId.value) { $keyVaultResourceId = $out.keyVaultResourceId.value }
+ if ($out.postgreSqlMirrorConnectionModeOut -and $out.postgreSqlMirrorConnectionModeOut.value -and (-not $MirrorConnectionMode)) { $MirrorConnectionMode = $out.postgreSqlMirrorConnectionModeOut.value }
+ if ($out.postgreSqlMirrorConnectionUserNameOut -and $out.postgreSqlMirrorConnectionUserNameOut.value -and (-not $MirrorConnectionUserName)) { $MirrorConnectionUserName = $out.postgreSqlMirrorConnectionUserNameOut.value }
+ if ($out.postgreSqlMirrorConnectionSecretNameOut -and $out.postgreSqlMirrorConnectionSecretNameOut.value -and (-not $MirrorConnectionSecretName)) { $MirrorConnectionSecretName = $out.postgreSqlMirrorConnectionSecretNameOut.value }
+ if ($out.postgreSqlFabricUserNameOut -and $out.postgreSqlFabricUserNameOut.value -and (-not $MirrorConnectionUserName)) { $MirrorConnectionUserName = $out.postgreSqlFabricUserNameOut.value }
+ if ($out.postgreSqlFabricUserSecretNameOut -and $out.postgreSqlFabricUserSecretNameOut.value -and (-not $MirrorConnectionSecretName)) { $MirrorConnectionSecretName = $out.postgreSqlFabricUserSecretNameOut.value }
+ if ($out.postgreSqlAdminSecretName -and $out.postgreSqlAdminSecretName.value -and (-not $MirrorConnectionSecretName)) { $MirrorConnectionSecretName = $out.postgreSqlAdminSecretName.value }
+ } catch {}
+}
+
+if (-not $postgreSqlServerResourceId) { $postgreSqlServerResourceId = Get-AzdEnvValue 'postgreSqlServerResourceId' }
+if (-not $postgreSqlServerName) { $postgreSqlServerName = Get-AzdEnvValue 'postgreSqlServerNameOut' }
+if (-not $postgreSqlServerFqdn) { $postgreSqlServerFqdn = Get-AzdEnvValue 'postgreSqlServerFqdn' }
+if (-not $postgreSqlSystemAssignedPrincipalId) { $postgreSqlSystemAssignedPrincipalId = Get-AzdEnvValue 'postgreSqlSystemAssignedPrincipalId' }
+if (-not $postgreSqlAdminLogin) { $postgreSqlAdminLogin = Get-AzdEnvValue 'postgreSqlAdminLoginOut' }
+if (-not $postgreSqlFabricUserName) { $postgreSqlFabricUserName = Get-AzdEnvValue 'postgreSqlFabricUserNameOut' }
+if (-not $postgreSqlFabricUserSecretName) { $postgreSqlFabricUserSecretName = Get-AzdEnvValue 'postgreSqlFabricUserSecretNameOut' }
+if (-not $keyVaultResourceId) { $keyVaultResourceId = Get-AzdEnvValue 'keyVaultResourceId' }
+if (-not $MirrorConnectionMode) { $MirrorConnectionMode = Get-AzdEnvValue 'postgreSqlMirrorConnectionModeOut' }
+if (-not $MirrorConnectionUserName) { $MirrorConnectionUserName = Get-AzdEnvValue 'postgreSqlMirrorConnectionUserNameOut' }
+if (-not $MirrorConnectionSecretName) { $MirrorConnectionSecretName = Get-AzdEnvValue 'postgreSqlMirrorConnectionSecretNameOut' }
+if (-not $MirrorConnectionUserName) { $MirrorConnectionUserName = Get-AzdEnvValue 'postgreSqlFabricUserNameOut' }
+if (-not $MirrorConnectionSecretName) { $MirrorConnectionSecretName = Get-AzdEnvValue 'postgreSqlFabricUserSecretNameOut' }
+if (-not $MirrorConnectionSecretName) { $MirrorConnectionSecretName = Get-AzdEnvValue 'postgreSqlAdminSecretName' }
+if (-not $GatewayId) { $GatewayId = Get-AzdEnvValue 'fabricPostgresGatewayId' }
+
+$subscriptionIdFromEnv = $env:AZURE_SUBSCRIPTION_ID
+if (-not $subscriptionIdFromEnv) { $subscriptionIdFromEnv = Get-AzdEnvValue 'AZURE_SUBSCRIPTION_ID' }
+$resourceGroupFromEnv = $env:AZURE_RESOURCE_GROUP
+if (-not $resourceGroupFromEnv) { $resourceGroupFromEnv = Get-AzdEnvValue 'AZURE_RESOURCE_GROUP' }
+
+$deploymentOutputs = $null
+if (-not $env:AZURE_OUTPUTS_JSON) {
+ $deploymentEnvironmentName = $env:AZURE_ENV_NAME
+ if (-not $deploymentEnvironmentName) { $deploymentEnvironmentName = Get-AzdEnvValue 'AZURE_ENV_NAME' }
+ $deploymentOutputs = Get-LatestDeploymentOutputs -resourceGroup $resourceGroupFromEnv -subscriptionId $subscriptionIdFromEnv -environmentName $deploymentEnvironmentName
+}
+if ($deploymentOutputs) {
+ if (-not $postgreSqlServerResourceId -and $deploymentOutputs.postgreSqlServerResourceId -and $deploymentOutputs.postgreSqlServerResourceId.value) { $postgreSqlServerResourceId = $deploymentOutputs.postgreSqlServerResourceId.value }
+ if (-not $postgreSqlServerName -and $deploymentOutputs.postgreSqlServerNameOut -and $deploymentOutputs.postgreSqlServerNameOut.value) { $postgreSqlServerName = $deploymentOutputs.postgreSqlServerNameOut.value }
+ if (-not $postgreSqlServerFqdn -and $deploymentOutputs.postgreSqlServerFqdn -and $deploymentOutputs.postgreSqlServerFqdn.value) { $postgreSqlServerFqdn = $deploymentOutputs.postgreSqlServerFqdn.value }
+ if (-not $postgreSqlSystemAssignedPrincipalId -and $deploymentOutputs.postgreSqlSystemAssignedPrincipalId -and $deploymentOutputs.postgreSqlSystemAssignedPrincipalId.value) { $postgreSqlSystemAssignedPrincipalId = $deploymentOutputs.postgreSqlSystemAssignedPrincipalId.value }
+ if (-not $postgreSqlAdminLogin -and $deploymentOutputs.postgreSqlAdminLoginOut -and $deploymentOutputs.postgreSqlAdminLoginOut.value) { $postgreSqlAdminLogin = $deploymentOutputs.postgreSqlAdminLoginOut.value }
+ if (-not $postgreSqlFabricUserName -and $deploymentOutputs.postgreSqlFabricUserNameOut -and $deploymentOutputs.postgreSqlFabricUserNameOut.value) { $postgreSqlFabricUserName = $deploymentOutputs.postgreSqlFabricUserNameOut.value }
+ if (-not $postgreSqlFabricUserSecretName -and $deploymentOutputs.postgreSqlFabricUserSecretNameOut -and $deploymentOutputs.postgreSqlFabricUserSecretNameOut.value) { $postgreSqlFabricUserSecretName = $deploymentOutputs.postgreSqlFabricUserSecretNameOut.value }
+ if (-not $keyVaultResourceId -and $deploymentOutputs.keyVaultResourceId -and $deploymentOutputs.keyVaultResourceId.value) { $keyVaultResourceId = $deploymentOutputs.keyVaultResourceId.value }
+ if (-not $MirrorConnectionMode -and $deploymentOutputs.postgreSqlMirrorConnectionModeOut -and $deploymentOutputs.postgreSqlMirrorConnectionModeOut.value) { $MirrorConnectionMode = $deploymentOutputs.postgreSqlMirrorConnectionModeOut.value }
+ if (-not $MirrorConnectionUserName -and $deploymentOutputs.postgreSqlMirrorConnectionUserNameOut -and $deploymentOutputs.postgreSqlMirrorConnectionUserNameOut.value) { $MirrorConnectionUserName = $deploymentOutputs.postgreSqlMirrorConnectionUserNameOut.value }
+ if (-not $MirrorConnectionSecretName -and $deploymentOutputs.postgreSqlMirrorConnectionSecretNameOut -and $deploymentOutputs.postgreSqlMirrorConnectionSecretNameOut.value) { $MirrorConnectionSecretName = $deploymentOutputs.postgreSqlMirrorConnectionSecretNameOut.value }
+}
+
+function Resolve-PrimaryResource {
+ param(
+ [string]$ResourceType,
+ [string]$ResourceGroup,
+ [string]$SubscriptionId
+ )
+
+ if ([string]::IsNullOrWhiteSpace($ResourceGroup)) { return $null }
+
+ try {
+ $args = @('resource', 'list', '--resource-group', $ResourceGroup, '--query', "[?type=='$ResourceType'].{id:id,name:name}", '-o', 'json')
+ if ($SubscriptionId) { $args += @('--subscription', $SubscriptionId) }
+ $json = & az @args 2>$null
+ if ($LASTEXITCODE -ne 0 -or -not $json) { return $null }
+
+ $resources = @($json | ConvertFrom-Json -ErrorAction Stop)
+ if (-not $resources) { return $null }
+
+ if ($ResourceType -eq 'Microsoft.KeyVault/vaults') {
+ $preferred = $resources | Where-Object { $_.name -notlike 'kv-ai-*' } | Select-Object -First 1
+ if ($preferred) { return $preferred }
+ }
+
+ return $resources | Select-Object -First 1
+ } catch {
+ return $null
+ }
+}
+
+if (-not $postgreSqlServerResourceId) {
+ $pgResource = Resolve-PrimaryResource -ResourceType 'Microsoft.DBforPostgreSQL/flexibleServers' -ResourceGroup $resourceGroupFromEnv -SubscriptionId $subscriptionIdFromEnv
+ if ($pgResource) {
+ $postgreSqlServerResourceId = $pgResource.id
+ if (-not $postgreSqlServerName) { $postgreSqlServerName = $pgResource.name }
+ }
+}
+
+if (-not $keyVaultResourceId) {
+ $kvResource = Resolve-PrimaryResource -ResourceType 'Microsoft.KeyVault/vaults' -ResourceGroup $resourceGroupFromEnv -SubscriptionId $subscriptionIdFromEnv
+ if ($kvResource) { $keyVaultResourceId = $kvResource.id }
+}
+
+function Resolve-PostgreSqlServerDetails {
+ param(
+ [string]$ServerName,
+ [string]$ResourceGroup,
+ [string]$SubscriptionId
+ )
+
+ if ([string]::IsNullOrWhiteSpace($ServerName) -or [string]::IsNullOrWhiteSpace($ResourceGroup)) {
+ return $null
+ }
+
+ try {
+ $args = @('postgres', 'flexible-server', 'show', '--resource-group', $ResourceGroup, '--name', $ServerName, '-o', 'json')
+ if ($SubscriptionId) { $args += @('--subscription', $SubscriptionId) }
+
+ $json = & az @args 2>$null
+ if ($LASTEXITCODE -ne 0 -or -not $json) { return $null }
+
+ return $json | ConvertFrom-Json -ErrorAction Stop
+ } catch {
+ return $null
+ }
+}
+
+if (-not $postgreSqlServerName -and $postgreSqlServerResourceId) {
+ $postgreSqlServerName = Get-ResourceNameFromId $postgreSqlServerResourceId
+}
+
+if (-not $postgreSqlServerFqdn -and $postgreSqlServerName) {
+ $serverDetails = Resolve-PostgreSqlServerDetails -ServerName $postgreSqlServerName -ResourceGroup $resourceGroupFromEnv -SubscriptionId $subscriptionIdFromEnv
+ if ($serverDetails) {
+ if (-not $postgreSqlServerFqdn -and $serverDetails.fullyQualifiedDomainName) {
+ $postgreSqlServerFqdn = $serverDetails.fullyQualifiedDomainName
+ Set-AzdEnvValue -key 'postgreSqlServerFqdn' -value $postgreSqlServerFqdn
+ }
+
+ if (-not $postgreSqlSystemAssignedPrincipalId -and $serverDetails.identity -and $serverDetails.identity.principalId) {
+ $postgreSqlSystemAssignedPrincipalId = $serverDetails.identity.principalId
+ Set-AzdEnvValue -key 'postgreSqlSystemAssignedPrincipalId' -value $postgreSqlSystemAssignedPrincipalId
+ }
+
+ if (-not $postgreSqlAdminLogin -and $serverDetails.administratorLogin) {
+ $postgreSqlAdminLogin = $serverDetails.administratorLogin
+ Set-AzdEnvValue -key 'postgreSqlAdminLoginOut' -value $postgreSqlAdminLogin
+ }
+ }
+}
+
+if (-not $postgreSqlServerResourceId -or [string]::IsNullOrWhiteSpace($postgreSqlServerResourceId)) {
+ Warn "PostgreSQL server outputs not found; skipping mirror."
+ exit 0
+}
+
+if (-not $postgreSqlServerFqdn) {
+ Warn "PostgreSQL server FQDN not resolved; skipping mirror."
+ exit 0
+}
+
+# Resolve workspace id if needed
+if (-not $WorkspaceId) {
+ $workspaceEnvPath = Join-Path ([IO.Path]::GetTempPath()) 'fabric_workspace.env'
+ if (Test-Path $workspaceEnvPath) {
+ Get-Content $workspaceEnvPath | ForEach-Object {
+ if ($_ -match '^FABRIC_WORKSPACE_ID=(.+)$') { $WorkspaceId = $Matches[1].Trim() }
+ }
+ }
+}
+if (-not $WorkspaceId -and $env:AZURE_OUTPUTS_JSON) {
+ try {
+ $out = $env:AZURE_OUTPUTS_JSON | ConvertFrom-Json -ErrorAction Stop
+ if ($out.fabricWorkspaceIdOut -and $out.fabricWorkspaceIdOut.value) { $WorkspaceId = $out.fabricWorkspaceIdOut.value }
+ elseif ($out.fabricWorkspaceId -and $out.fabricWorkspaceId.value) { $WorkspaceId = $out.fabricWorkspaceId.value }
+ } catch {}
+}
+if (-not $WorkspaceId) {
+ try {
+ $val = & azd env get-value fabricWorkspaceIdOut 2>$null
+ if (-not $val) { $val = & azd env get-value fabricWorkspaceId 2>$null }
+ if ($val) { $WorkspaceId = $val.ToString().Trim() }
+ } catch {}
+}
+
+if (-not $WorkspaceId) { Warn "WorkspaceId not resolved; skipping mirror."; exit 0 }
+
+if (-not $ConnectionId) {
+ $ConnectionId = Get-AzdEnvValue 'fabricPostgresConnectionId'
+}
+
+if (-not $DatabaseName) { $DatabaseName = 'postgres' }
+if (-not $MirrorConnectionMode) { $MirrorConnectionMode = 'fabricUser' }
+if (-not $MirrorConnectionUserName -and $MirrorConnectionMode -eq 'admin') { $MirrorConnectionUserName = $postgreSqlAdminLogin }
+if (-not $MirrorConnectionUserName -and $MirrorConnectionMode -eq 'fabricUser') { $MirrorConnectionUserName = 'fabric_user' }
+if (-not $MirrorConnectionSecretName) {
+ $MirrorConnectionSecretName = if ($MirrorConnectionMode -eq 'admin') { 'postgres-admin-password' } else { 'postgres-fabric-user-password' }
+}
+if (-not $postgreSqlFabricUserName) { $postgreSqlFabricUserName = 'fabric_user' }
+if (-not $postgreSqlFabricUserSecretName) { $postgreSqlFabricUserSecretName = 'postgres-fabric-user-password' }
+if (-not $MirrorName) {
+ $envName = $env:AZURE_ENV_NAME
+ if ([string]::IsNullOrWhiteSpace($envName)) { $envName = 'env' }
+ $MirrorName = "pg-mirror-$envName"
+}
+if (-not $ConnectionDisplayName) {
+ $displayUserLabel = $MirrorConnectionUserName
+ if ([string]::IsNullOrWhiteSpace($displayUserLabel)) {
+ $displayUserLabel = if ($MirrorConnectionMode -eq 'admin') { 'admin' } else { 'connection' }
+ }
+ $ConnectionDisplayName = "$postgreSqlServerFqdn;$DatabaseName $displayUserLabel"
+}
+
+$keyVaultName = Get-ResourceNameFromId $keyVaultResourceId
+$tempEnableKvPublicAccess = IsTrue $TempEnableKeyVaultPublicAccess
+$postgreSqlResourceGroup = $resourceGroupFromEnv
+if (-not $postgreSqlResourceGroup) { $postgreSqlResourceGroup = Get-ResourceGroupFromId $postgreSqlServerResourceId }
+$restorePostgreSqlPublicAccess = $null
+$temporarilyEnabledPostgreSqlPublicAccess = $false
+$temporaryFirewallRuleName = 'AllowAzureServicesFabricMirrorTemp'
+$temporarilyAddedAzureServicesFirewallRule = $false
+
+try {
+ if (-not $GatewayId -and $postgreSqlResourceGroup -and $postgreSqlServerName) {
+ $restorePostgreSqlPublicAccess = Get-PostgreSqlPublicAccess -resourceGroup $postgreSqlResourceGroup -serverName $postgreSqlServerName -subscriptionId $subscriptionIdFromEnv
+ if ($restorePostgreSqlPublicAccess -and $restorePostgreSqlPublicAccess.ToLowerInvariant() -ne 'enabled') {
+ Log "Temporarily enabling PostgreSQL public access so Fabric can create the connection..."
+ Set-PostgreSqlPublicAccess -resourceGroup $postgreSqlResourceGroup -serverName $postgreSqlServerName -state 'Enabled' -subscriptionId $subscriptionIdFromEnv
+ $temporarilyEnabledPostgreSqlPublicAccess = $true
+ }
+
+ Log "Temporarily allowing Azure services to reach PostgreSQL for Fabric connection validation..."
+ Add-PostgreSqlFirewallRule -resourceGroup $postgreSqlResourceGroup -serverName $postgreSqlServerName -ruleName $temporaryFirewallRuleName -startIpAddress '0.0.0.0' -endIpAddress '0.0.0.0' -subscriptionId $subscriptionIdFromEnv
+ $temporarilyAddedAzureServicesFirewallRule = $true
+ }
+
+ # Acquire Fabric token
+ try { $fabricToken = Get-SecureApiToken -Resource $SecureApiResources.Fabric -Description "Fabric" } catch { $fabricToken = $null }
+ if (-not $fabricToken) { Warn "Cannot acquire Fabric API token; ensure az login."; exit 0 }
+
+ $fabricHeaders = New-SecureHeaders -Token $fabricToken
+ $apiRoot = 'https://api.fabric.microsoft.com/v1'
+
+ $connections = $null
+ try {
+ $connections = Invoke-FabricPagedGet -InitialUri "$apiRoot/connections" -Headers $fabricHeaders -Description 'Fabric connections'
+ } catch {
+ Warn "Unable to list existing Fabric connections. Automatic connection reuse will be limited."
+ }
+
+ if ($ConnectionId) {
+ try {
+ $match = @($connections | Where-Object { $_.id -eq $ConnectionId }) | Select-Object -First 1
+ if (-not $match) {
+ Warn "Stored Fabric PostgreSQL connection ID '$ConnectionId' was not found. Attempting to resolve or recreate the connection."
+ $ConnectionId = $null
+ } else {
+ Log "Using existing Fabric connection ID: $ConnectionId"
+ }
+ } catch {
+ Warn "Unable to validate Fabric connection ID '$ConnectionId'; attempting to continue."
+ }
+ }
+
+ if (-not $ConnectionId -and $connections) {
+ $expectedPath = "$postgreSqlServerFqdn;$DatabaseName"
+ $existingConnection = @($connections | Where-Object {
+ $_.displayName -eq $ConnectionDisplayName -or $_.connectionDetails.path -eq $expectedPath
+ }) | Select-Object -First 1
+
+ if ($existingConnection) {
+ $ConnectionId = $existingConnection.id
+ Log "Reusing existing Fabric PostgreSQL connection '$($existingConnection.displayName)' ($ConnectionId)."
+ Set-AzdEnvValue -key 'fabricPostgresConnectionId' -value $ConnectionId
+ }
+ }
+
+ if (-not $ConnectionId) {
+ if ([string]::IsNullOrWhiteSpace($MirrorConnectionUserName)) {
+ Warn "Mirror connection username was not resolved. Check postgreSqlMirrorConnectionUserNameOut and retry."
+ exit 0
+ }
+
+ if ([string]::IsNullOrWhiteSpace($MirrorConnectionSecretName) -and [string]::IsNullOrWhiteSpace($MirrorConnectionPassword)) {
+ Warn "Mirror connection secret name was not resolved. Check postgreSqlMirrorConnectionSecretNameOut and retry."
+ exit 0
+ }
+
+ try {
+ if (-not $MirrorConnectionPassword) {
+ if ($tempEnableKvPublicAccess -and $keyVaultName) {
+ Log "Temporarily enabling Key Vault public access for Fabric connection secret retrieval..."
+ Set-KeyVaultPublicAccess -vaultName $keyVaultName -state 'Enabled'
+ }
+
+ if ($keyVaultName -and $MirrorConnectionSecretName) {
+ if (-not (Test-KeyVaultAccess $keyVaultName)) {
+ Warn "Key Vault '$keyVaultName' is not reachable. Automatic Fabric connection creation requires access to the mirror credential secret."
+ exit 0
+ }
+
+ $MirrorConnectionPassword = Invoke-AzCliCapture @('keyvault','secret','show','--vault-name', $keyVaultName,'--name', $MirrorConnectionSecretName,'--query','value','-o','tsv')
+ }
+ }
+
+ if (-not $MirrorConnectionPassword) {
+ Warn "Mirror connection password was not resolved from Key Vault or environment. Automatic Fabric connection creation skipped."
+ exit 0
+ }
+
+ $supportedTypesUri = if ($GatewayId) {
+ "$apiRoot/connections/supportedConnectionTypes?gatewayId=$([System.Uri]::EscapeDataString($GatewayId))&showAllCreationMethods=true"
+ } else {
+ "$apiRoot/connections/supportedConnectionTypes?showAllCreationMethods=true"
+ }
+
+ $supportedTypes = Invoke-FabricPagedGet -InitialUri $supportedTypesUri -Headers $fabricHeaders -Description 'Supported Fabric connection types'
+ $selectedMetadata = Select-PostgreSqlConnectionMetadata -SupportedTypes $supportedTypes
+
+ if ($selectedMetadata.Metadata.supportedCredentialTypes -notcontains 'Basic') {
+ Warn "Fabric does not report Basic auth support for connection type '$($selectedMetadata.Type)'. Automatic connection creation skipped."
+ exit 0
+ }
+
+ $parameterList = @()
+ foreach ($parameterDefinition in @($selectedMetadata.CreationMethod.parameters)) {
+ $parameterValue = Get-ConnectionParameterValue -parameterDefinition $parameterDefinition -ServerFqdn $postgreSqlServerFqdn -TargetDatabase $DatabaseName -UserName $MirrorConnectionUserName
+ if ($null -eq $parameterValue) {
+ if ($parameterDefinition.required) {
+ throw "Unsupported required PostgreSQL Fabric connection parameter '$($parameterDefinition.name)' for creation method '$($selectedMetadata.CreationMethod.name)'."
+ }
+
+ continue
+ }
+
+ $parameterList += New-ConnectionDetailsParameter -parameterDefinition $parameterDefinition -value $parameterValue
+ }
+
+ $connectionEncryption = @('Encrypted', 'Any', 'NotEncrypted') | Where-Object {
+ @($selectedMetadata.Metadata.supportedConnectionEncryptionTypes) -contains $_
+ } | Select-Object -First 1
+ if (-not $connectionEncryption) { $connectionEncryption = 'Encrypted' }
+
+ $primaryAttempt = @{
+ UserName = $MirrorConnectionUserName
+ SecretName = $MirrorConnectionSecretName
+ Password = $MirrorConnectionPassword
+ DisplayName = $ConnectionDisplayName
+ }
+ $connectionAttempts = @($primaryAttempt)
+ $canFallbackToFabricUser = (
+ $MirrorConnectionMode -eq 'admin' -and
+ -not [string]::IsNullOrWhiteSpace($postgreSqlFabricUserName) -and
+ -not [string]::IsNullOrWhiteSpace($postgreSqlFabricUserSecretName) -and
+ $postgreSqlFabricUserName -ne $MirrorConnectionUserName
+ )
+
+ if ($canFallbackToFabricUser) {
+ $connectionAttempts += @{
+ UserName = $postgreSqlFabricUserName
+ SecretName = $postgreSqlFabricUserSecretName
+ Password = $null
+ DisplayName = "$postgreSqlServerFqdn;$DatabaseName $postgreSqlFabricUserName"
+ }
+ }
+
+ $lastConnectionFailure = $null
+ foreach ($connectionAttempt in $connectionAttempts) {
+ if (-not $connectionAttempt.Password -and $keyVaultName -and $connectionAttempt.SecretName) {
+ $connectionAttempt.Password = Invoke-AzCliCapture @('keyvault','secret','show','--vault-name', $keyVaultName,'--name', $connectionAttempt.SecretName,'--query','value','-o','tsv')
+ }
+
+ if (-not $connectionAttempt.Password) {
+ throw "Mirror connection password was not resolved for user '$($connectionAttempt.UserName)'."
+ }
+
+ $createConnectionBody = New-FabricPostgreSqlConnectionBody -DisplayName $connectionAttempt.DisplayName -ConnectivityType $(if ($GatewayId) { 'VirtualNetworkGateway' } else { 'ShareableCloud' }) -ConnectionType $selectedMetadata.Type -CreationMethod $selectedMetadata.CreationMethod.name -Parameters $parameterList -PrivacyLevel 'None' -ConnectionEncryption $connectionEncryption -UserName $connectionAttempt.UserName -Password $connectionAttempt.Password -GatewayId $GatewayId
+
+ try {
+ Log "Creating Fabric PostgreSQL connection '$($connectionAttempt.DisplayName)' for $postgreSqlServerFqdn/$DatabaseName"
+ $connectionResponse = Invoke-SecureRestMethod -Uri "$apiRoot/connections" -Headers $fabricHeaders -Method Post -Body $createConnectionBody -Description 'Create Fabric PostgreSQL connection'
+ $ConnectionId = $connectionResponse.id
+ $ConnectionDisplayName = $connectionAttempt.DisplayName
+ Set-AzdEnvValue -key 'fabricPostgresConnectionId' -value $ConnectionId
+ Log "Created Fabric PostgreSQL connection: $ConnectionId"
+ $lastConnectionFailure = $null
+ break
+ } catch {
+ $responseBody = $null
+ try {
+ if ($_.Exception.Response) {
+ $responseBody = Read-SecureResponseBody -Response $_.Exception.Response
+ if ($responseBody) { $responseBody = Sanitize-SecureResponseBody -ResponseBody $responseBody }
+ }
+ } catch {}
+
+ $lastConnectionFailure = @{
+ Message = $_.Exception.Message
+ ResponseBody = $responseBody
+ }
+
+ if ($connectionAttempt.UserName -eq $MirrorConnectionUserName -and $canFallbackToFabricUser -and (Test-IsFabricIncorrectCredentialFailure -ResponseBody $responseBody)) {
+ Warn "Fabric rejected the admin credential for PostgreSQL mirroring. Retrying with dedicated Fabric user '$postgreSqlFabricUserName'."
+ continue
+ }
+
+ throw
+ }
+ }
+
+ if (-not $ConnectionId -and $lastConnectionFailure) {
+ throw $lastConnectionFailure.Message
+ }
+ } catch {
+ $responseBody = $null
+ try {
+ if ($_.Exception.Response) {
+ $responseBody = Read-SecureResponseBody -Response $_.Exception.Response
+ if ($responseBody) { $responseBody = Sanitize-SecureResponseBody -ResponseBody $responseBody }
+ }
+ } catch {}
+
+ Warn "Automatic Fabric PostgreSQL connection creation failed: $($_.Exception.Message)"
+ if ($responseBody) { Warn "Fabric API response body: $responseBody" }
+ if (-not $GatewayId -and $serverDetails -and @($serverDetails.privateEndpointConnections).Count -gt 0 -and (Test-IsFabricConnectivityTimeoutFailure -ResponseBody $responseBody)) {
+ Fail "Fabric cannot reach PostgreSQL server '$postgreSqlServerFqdn' over the default shared-cloud connection path. This server has a private endpoint, and no Fabric VNet data gateway is configured. Create or identify a Fabric VNet data gateway with network reachability to the PostgreSQL private endpoint, set azd env value 'fabricPostgresGatewayId' to that gateway ID, and rerun mirror creation. See docs/postgresql_mirroring.md for the gateway-backed path."
+ }
+
+ Fail "Fabric PostgreSQL connection creation failed. See the warnings above for the service response. If your PostgreSQL source is only reachable through private networking, set 'fabricPostgresGatewayId' before rerunning this script."
+ } finally {
+ if ($tempEnableKvPublicAccess -and $keyVaultName) {
+ Log "Restoring Key Vault public access to Disabled after Fabric connection secret retrieval..."
+ Set-KeyVaultPublicAccess -vaultName $keyVaultName -state 'Disabled'
+ }
+ }
+ }
+
+ if (-not $ConnectionId) {
+ Warn "No Fabric PostgreSQL connection ID is available; skipping mirrored database creation."
+ exit 0
+ }
+
+ try {
+ $validatedConnection = @($connections | Where-Object { $_.id -eq $ConnectionId }) | Select-Object -First 1
+ if (-not $validatedConnection) {
+ $validatedConnection = Invoke-SecureRestMethod -Uri "$apiRoot/connections/$ConnectionId" -Headers $fabricHeaders -Method Get -Description 'Fabric connection details'
+ }
+ if ($validatedConnection -and $validatedConnection.id) {
+ Log "Validated Fabric PostgreSQL connection '$($validatedConnection.displayName)' ($ConnectionId)."
+ }
+ } catch {
+ Warn "Unable to validate Fabric connection ID '$ConnectionId'; continuing with mirror attempt."
+ }
+
+ if ($postgreSqlSystemAssignedPrincipalId) {
+ $roleAssignmentBody = @{
+ principal = @{
+ id = $postgreSqlSystemAssignedPrincipalId
+ type = 'ServicePrincipal'
+ }
+ role = 'Contributor'
+ } | ConvertTo-Json -Depth 4
+
+ try {
+ Invoke-SecureRestMethod -Uri "$apiRoot/workspaces/$WorkspaceId/roleAssignments" -Headers $fabricHeaders -Method Post -Body $roleAssignmentBody | Out-Null
+ Log "Granted Fabric workspace access to PostgreSQL managed identity: $postgreSqlSystemAssignedPrincipalId"
+ } catch {
+ $msg = $_.Exception.Message
+ if ($msg -like '*409*' -or $msg -like '*already*') {
+ Log "PostgreSQL managed identity already has Fabric workspace access."
+ } else {
+ Warn "Failed to grant workspace access to PostgreSQL managed identity: $msg"
+ }
+ }
+ } else {
+ Warn "PostgreSQL managed identity principalId not found; skipping Fabric RBAC assignment."
+ }
+
+ # Skip if mirror already exists
+ try {
+ $existing = Invoke-SecureRestMethod -Uri "$apiRoot/workspaces/$WorkspaceId/mirroredDatabases" -Headers $fabricHeaders -Method Get -ErrorAction Stop
+ if ($existing.value) {
+ $match = $existing.value | Where-Object { $_.displayName -eq $MirrorName }
+ if ($match) { Log "Mirror already exists: $MirrorName ($($match.id))"; exit 0 }
+ }
+ } catch {}
+
+ $mirroringJson = @{
+ properties = @{
+ source = @{
+ type = 'AzurePostgreSql'
+ typeProperties = @{
+ connection = $ConnectionId
+ database = $DatabaseName
+ }
+ }
+ target = @{
+ type = 'MountedRelationalDatabase'
+ typeProperties = @{
+ defaultSchema = 'public'
+ format = 'Delta'
+ }
+ }
+ }
+ }
+
+ $mirroringJsonText = $mirroringJson | ConvertTo-Json -Depth 10
+ $mirroringPayload = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($mirroringJsonText))
+
+ $body = @{
+ displayName = $MirrorName
+ description = "Mirrored PostgreSQL database from $postgreSqlServerName"
+ definition = @{
+ parts = @(
+ @{
+ path = 'mirroring.json'
+ payload = $mirroringPayload
+ payloadType = 'InlineBase64'
+ }
+ )
+ }
+ }
+
+ Log "Creating mirrored database '$MirrorName' in workspace $WorkspaceId"
+ try {
+ $resp = Invoke-SecureRestMethod -Uri "$apiRoot/workspaces/$WorkspaceId/mirroredDatabases" -Headers $fabricHeaders -Method Post -Body $body -ErrorAction Stop
+ Log "Created mirror: $($resp.id)"
+ } catch {
+ $rawBody = $null
+ try {
+ if ($_.Exception.Response) {
+ $rawBody = Read-SecureResponseBody -Response $_.Exception.Response
+ if ($rawBody) { $rawBody = Sanitize-SecureResponseBody -ResponseBody $rawBody }
+ }
+ } catch { $rawBody = $null }
+ Warn "Failed to create mirror: $($_.Exception.Message)"
+ if ($rawBody) { Warn "Fabric API response body: $rawBody" }
+ throw
+ }
+} finally {
+ if ($temporarilyAddedAzureServicesFirewallRule -and $postgreSqlResourceGroup -and $postgreSqlServerName) {
+ Log "Removing temporary PostgreSQL firewall rule '$temporaryFirewallRuleName'..."
+ Remove-PostgreSqlFirewallRule -resourceGroup $postgreSqlResourceGroup -serverName $postgreSqlServerName -ruleName $temporaryFirewallRuleName -subscriptionId $subscriptionIdFromEnv
+ }
+
+ if ($temporarilyEnabledPostgreSqlPublicAccess -and $postgreSqlResourceGroup -and $postgreSqlServerName -and $restorePostgreSqlPublicAccess) {
+ Log "Restoring PostgreSQL public access to '$restorePostgreSqlPublicAccess' after Fabric mirror setup..."
+ Set-PostgreSqlPublicAccess -resourceGroup $postgreSqlResourceGroup -serverName $postgreSqlServerName -state $restorePostgreSqlPublicAccess -subscriptionId $subscriptionIdFromEnv
+ }
+}
diff --git a/scripts/automationScripts/FabricWorkspace/mirror/prepare_postgresql_for_mirroring.ps1 b/scripts/automationScripts/FabricWorkspace/mirror/prepare_postgresql_for_mirroring.ps1
new file mode 100644
index 0000000..8ab2563
--- /dev/null
+++ b/scripts/automationScripts/FabricWorkspace/mirror/prepare_postgresql_for_mirroring.ps1
@@ -0,0 +1,919 @@
+<#
+.SYNOPSIS
+ Prepare PostgreSQL flexible server for Fabric mirroring.
+#>
+
+[CmdletBinding()]
+param(
+ [string]$DatabaseName = $env:POSTGRES_DATABASE_NAME,
+ [string]$FabricUserName = $env:POSTGRES_FABRIC_USER_NAME,
+ [string]$MirrorConnectionMode = $env:POSTGRES_MIRROR_CONNECTION_MODE,
+ [string]$EntraRoleName = $env:POSTGRES_FABRIC_ENTRA_ROLE_NAME,
+ [string]$EntraObjectId = $env:POSTGRES_FABRIC_ENTRA_OBJECT_ID,
+ [string]$EntraObjectType = $env:POSTGRES_FABRIC_ENTRA_OBJECT_TYPE,
+ [string]$EntraRequireMfa = $env:POSTGRES_FABRIC_ENTRA_REQUIRE_MFA,
+ [string]$EnableFabricMirroring = $env:POSTGRES_ENABLE_FABRIC_MIRRORING,
+ [string]$TempEnableKeyVaultPublicAccess = $env:POSTGRES_TEMP_ENABLE_KV_PUBLIC_ACCESS,
+ [string]$CreateMirrorSeedTable = $env:POSTGRES_CREATE_MIRRORING_SEED_TABLE,
+ [string]$MirrorSeedTableName = $env:POSTGRES_MIRRORING_SEED_TABLE_NAME,
+ [int]$MirrorCount = 1
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+# Import security module
+
+$SecurityModulePath = Join-Path $PSScriptRoot "../../SecurityModule.ps1"
+. $SecurityModulePath
+
+function Log([string]$m){ Write-Host "[pg-mirroring-prep] $m" }
+function Warn([string]$m){ Write-Warning "[pg-mirroring-prep] $m" }
+function Fail([string]$m){ Write-Error "[pg-mirroring-prep] $m"; exit 1 }
+function IsTrue([string]$v){ return ($v -and $v.ToString().Trim().ToLowerInvariant() -in @('1','true','yes')) }
+function Convert-SqlToAzQueryText([string]$sqlText){ return (($sqlText -replace "[`r`n]+", ' ').Trim()) }
+
+function Ensure-AzExtension([string]$name) {
+ $null = & az extension show --name $name 2>$null
+ if ($LASTEXITCODE -eq 0) {
+ return $true
+ }
+
+ Warn "Azure CLI extension '$name' is required but not installed."
+ Warn "Install: az extension add --name $name"
+ Warn "If install fails: & \"C:\Program Files\Microsoft SDKs\Azure\CLI2\python.exe\" -m pip install --upgrade pip setuptools wheel"
+ return $false
+}
+
+# Resolve PostgreSQL outputs
+$postgreSqlServerResourceId = $null
+$postgreSqlServerName = $null
+$postgreSqlAdminLogin = $null
+$postgreSqlAdminSecretName = $null
+$postgreSqlFabricUserSecretName = $null
+$keyVaultResourceId = $null
+$script:PsqlPath = $null
+$script:NpgsqlReady = $false
+$script:NpgsqlPackageVersion = '8.0.3'
+$script:LastPostgresCredentialError = $null
+
+function Invoke-AzCli([string[]]$AzArguments) {
+ $script:LASTEXITCODE = 0
+ $azCmd = 'az'
+ try {
+ $resolved = Get-Command az -ErrorAction Stop
+ if ($resolved -and $resolved.Source) { $azCmd = $resolved.Source }
+ } catch {}
+
+ $output = & $azCmd @AzArguments 2>&1
+ $outputText = (($output | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine)
+ if ($LASTEXITCODE -ne 0) {
+ if ([string]::IsNullOrWhiteSpace($outputText)) {
+ throw "Azure CLI command failed with exit code ${LASTEXITCODE}: az $($AzArguments -join ' ')"
+ }
+
+ throw "Azure CLI command failed with exit code ${LASTEXITCODE}: az $($AzArguments -join ' ')`n$outputText"
+ }
+
+ if ($outputText -match 'Welcome to the cool new Azure CLI!|^Group\s+az\b|^Commands:\s*$') {
+ throw "Azure CLI returned help text instead of executing the command: az $($AzArguments -join ' ')"
+ }
+}
+
+function Invoke-AzCliCapture([string[]]$AzArguments) {
+ $script:LASTEXITCODE = 0
+ $azCmd = 'az'
+ try {
+ $resolved = Get-Command az -ErrorAction Stop
+ if ($resolved -and $resolved.Source) { $azCmd = $resolved.Source }
+ } catch {}
+
+ $output = & $azCmd @AzArguments 2>&1
+ $outputText = (($output | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine)
+ if ($LASTEXITCODE -ne 0) {
+ if ([string]::IsNullOrWhiteSpace($outputText)) {
+ throw "Azure CLI command failed with exit code ${LASTEXITCODE}: az $($AzArguments -join ' ')"
+ }
+
+ throw "Azure CLI command failed with exit code ${LASTEXITCODE}: az $($AzArguments -join ' ')`n$outputText"
+ }
+
+ if ($outputText -match 'Welcome to the cool new Azure CLI!|^Group\s+az\b|^Commands:\s*$') {
+ throw "Azure CLI returned help text instead of executing the command: az $($AzArguments -join ' ')"
+ }
+
+ return $output
+}
+
+function Invoke-AzCliWithServerBusyRetry([string[]]$AzArguments, [int]$MaxRetries = 8, [int]$DelaySeconds = 15) {
+ for ($attempt = 1; $attempt -le $MaxRetries; $attempt++) {
+ try {
+ return Invoke-AzCliCapture $AzArguments
+ } catch {
+ $message = $_.Exception.Message
+ if ($attempt -lt $MaxRetries -and $message -match 'ServerIsBusy|server .* is busy processing another operation') {
+ Warn "Azure reported the PostgreSQL server is busy. Retrying in $DelaySeconds seconds (attempt $attempt of $MaxRetries)..."
+ Start-Sleep -Seconds $DelaySeconds
+ continue
+ }
+
+ throw
+ }
+ }
+}
+
+function Resolve-PsqlPath() {
+ if ($script:PsqlPath -and (Test-Path $script:PsqlPath)) {
+ return $script:PsqlPath
+ }
+
+ $cmd = Get-Command psql -ErrorAction SilentlyContinue
+ if ($cmd) {
+ $script:PsqlPath = $cmd.Source
+ return $script:PsqlPath
+ }
+
+ $candidatePaths = @()
+ foreach ($root in @($env:ProgramFiles, ${env:ProgramFiles(x86)})) {
+ if (-not $root) { continue }
+ $postgresRoot = Join-Path $root 'PostgreSQL'
+ if (-not (Test-Path $postgresRoot)) { continue }
+
+ $candidatePaths += Get-ChildItem -Path $postgresRoot -Directory -ErrorAction SilentlyContinue |
+ Sort-Object Name -Descending |
+ ForEach-Object { Join-Path $_.FullName 'bin\psql.exe' }
+ }
+
+ $resolved = $candidatePaths | Where-Object { Test-Path $_ } | Select-Object -First 1
+ if ($resolved) {
+ $script:PsqlPath = $resolved
+ }
+
+ return $script:PsqlPath
+}
+
+function Ensure-Psql([bool]$allowInstall) {
+ if (Resolve-PsqlPath) {
+ return $true
+ }
+
+ if (-not $allowInstall) {
+ Warn "psql not found. Set POSTGRES_ALLOW_PSQL_INSTALL=true to install it automatically, or allow the portable Npgsql fallback to run."
+ return $false
+ }
+
+ Warn "psql not found. Attempting to install PostgreSQL client tools via winget..."
+ try {
+ & winget install --id PostgreSQL.PostgreSQL.16 -e --source winget --accept-package-agreements --accept-source-agreements 1>$null
+ } catch {
+ Warn "winget failed to install PostgreSQL client tools."
+ return $false
+ }
+
+ return [bool](Resolve-PsqlPath)
+}
+
+function Ensure-Npgsql() {
+ if ($script:NpgsqlReady) {
+ return $true
+ }
+
+ if ($PSVersionTable.PSVersion.Major -lt 7) {
+ Warn "Portable Npgsql fallback requires pwsh 7 or later."
+ return $false
+ }
+
+ try {
+ $cacheRoot = Join-Path $env:LOCALAPPDATA "DeployYourAIApplicationInProduction\tools\npgsql\$($script:NpgsqlPackageVersion)"
+ $nugetExe = Join-Path $cacheRoot 'nuget.exe'
+ $restoreMarker = Join-Path $cacheRoot '.restored'
+
+ if (-not (Test-Path $restoreMarker)) {
+ New-Item -ItemType Directory -Path $cacheRoot -Force | Out-Null
+
+ if (-not (Test-Path $nugetExe)) {
+ Invoke-WebRequest -Uri 'https://dist.nuget.org/win-x86-commandline/latest/nuget.exe' -OutFile $nugetExe
+ }
+
+ & $nugetExe install Npgsql -Version $script:NpgsqlPackageVersion -OutputDirectory $cacheRoot -Framework net8.0 -DirectDownload -NonInteractive 1>$null
+ if ($LASTEXITCODE -ne 0) {
+ throw "nuget.exe failed to restore Npgsql dependencies."
+ }
+
+ New-Item -ItemType File -Path $restoreMarker -Force | Out-Null
+ }
+
+ $runtimeDlls = Get-ChildItem -Path $cacheRoot -Recurse -Filter *.dll | Where-Object {
+ $_.FullName -match '[\\/]lib[\\/]net8\.0[\\/]'
+ }
+
+ if (-not $runtimeDlls) {
+ throw 'Portable Npgsql restore did not produce any net8.0 assemblies.'
+ }
+
+ foreach ($dll in ($runtimeDlls | Where-Object Name -ne 'Npgsql.dll' | Sort-Object FullName)) {
+ try {
+ Add-Type -Path $dll.FullName -ErrorAction Stop
+ } catch {
+ if ($_.Exception.Message -notmatch 'already loaded|Duplicate type name') {
+ throw
+ }
+ }
+ }
+
+ $npgsqlDll = $runtimeDlls | Where-Object Name -eq 'Npgsql.dll' | Select-Object -First 1
+ if (-not $npgsqlDll) {
+ throw 'Portable Npgsql restore did not produce Npgsql.dll.'
+ }
+
+ try {
+ Add-Type -Path $npgsqlDll.FullName -ErrorAction Stop
+ } catch {
+ if ($_.Exception.Message -notmatch 'already loaded|Duplicate type name') {
+ throw
+ }
+ }
+
+ $script:NpgsqlReady = $true
+ return $true
+ } catch {
+ Warn "Portable Npgsql fallback failed: $($_.Exception.Message)"
+ return $false
+ }
+}
+if ($env:AZURE_OUTPUTS_JSON) {
+ try {
+ $out = $env:AZURE_OUTPUTS_JSON | ConvertFrom-Json -ErrorAction Stop
+ if ($out.postgreSqlServerResourceId -and $out.postgreSqlServerResourceId.value) { $postgreSqlServerResourceId = $out.postgreSqlServerResourceId.value }
+ if ($out.postgreSqlServerNameOut -and $out.postgreSqlServerNameOut.value) { $postgreSqlServerName = $out.postgreSqlServerNameOut.value }
+ if ($out.postgreSqlAdminLoginOut -and $out.postgreSqlAdminLoginOut.value) { $postgreSqlAdminLogin = $out.postgreSqlAdminLoginOut.value }
+ if ($out.postgreSqlAdminSecretName -and $out.postgreSqlAdminSecretName.value) { $postgreSqlAdminSecretName = $out.postgreSqlAdminSecretName.value }
+ if ($out.postgreSqlFabricUserSecretNameOut -and $out.postgreSqlFabricUserSecretNameOut.value) { $postgreSqlFabricUserSecretName = $out.postgreSqlFabricUserSecretNameOut.value }
+ if ($out.keyVaultResourceId -and $out.keyVaultResourceId.value) { $keyVaultResourceId = $out.keyVaultResourceId.value }
+ if ($out.postgreSqlFabricUserNameOut -and $out.postgreSqlFabricUserNameOut.value -and (-not $FabricUserName)) { $FabricUserName = $out.postgreSqlFabricUserNameOut.value }
+ if ($out.postgreSqlMirrorConnectionModeOut -and $out.postgreSqlMirrorConnectionModeOut.value -and (-not $MirrorConnectionMode)) { $MirrorConnectionMode = $out.postgreSqlMirrorConnectionModeOut.value }
+ } catch {}
+}
+
+function Get-AzdEnvValue([string]$key){
+ try {
+ $val = & azd env get-value $key 2>$null
+ if ($LASTEXITCODE -eq 0 -and $val -and -not ($val -match '^\s*ERROR:')) { return $val.ToString().Trim() }
+ } catch {}
+ return $null
+}
+
+function Resolve-PrimaryResource {
+ param(
+ [string]$ResourceType,
+ [string]$ResourceGroup,
+ [string]$SubscriptionId
+ )
+
+ if ([string]::IsNullOrWhiteSpace($ResourceGroup)) { return $null }
+
+ try {
+ $args = @('resource', 'list', '--resource-group', $ResourceGroup, '--query', "[?type=='$ResourceType'].{id:id,name:name}", '-o', 'json')
+ if ($SubscriptionId) { $args += @('--subscription', $SubscriptionId) }
+ $json = & az @args 2>$null
+ if ($LASTEXITCODE -ne 0 -or -not $json) { return $null }
+
+ $resources = @($json | ConvertFrom-Json -ErrorAction Stop)
+ if (-not $resources) { return $null }
+
+ if ($ResourceType -eq 'Microsoft.KeyVault/vaults') {
+ $preferred = $resources | Where-Object { $_.name -notlike 'kv-ai-*' } | Select-Object -First 1
+ if ($preferred) { return $preferred }
+ }
+
+ return $resources | Select-Object -First 1
+ } catch {
+ return $null
+ }
+}
+
+if (-not $postgreSqlServerResourceId) { $postgreSqlServerResourceId = Get-AzdEnvValue 'postgreSqlServerResourceId' }
+if (-not $postgreSqlServerName) { $postgreSqlServerName = Get-AzdEnvValue 'postgreSqlServerNameOut' }
+if (-not $postgreSqlAdminLogin) { $postgreSqlAdminLogin = Get-AzdEnvValue 'postgreSqlAdminLoginOut' }
+if (-not $postgreSqlAdminSecretName) { $postgreSqlAdminSecretName = Get-AzdEnvValue 'postgreSqlAdminSecretName' }
+if (-not $postgreSqlFabricUserSecretName) { $postgreSqlFabricUserSecretName = Get-AzdEnvValue 'postgreSqlFabricUserSecretNameOut' }
+if (-not $keyVaultResourceId) { $keyVaultResourceId = Get-AzdEnvValue 'keyVaultResourceId' }
+if (-not $FabricUserName) { $FabricUserName = Get-AzdEnvValue 'postgreSqlFabricUserNameOut' }
+if (-not $MirrorConnectionMode) { $MirrorConnectionMode = Get-AzdEnvValue 'postgreSqlMirrorConnectionModeOut' }
+
+$subscriptionId = $env:AZURE_SUBSCRIPTION_ID
+if (-not $subscriptionId) { $subscriptionId = Get-AzdEnvValue 'AZURE_SUBSCRIPTION_ID' }
+$resourceGroupFromEnv = $env:AZURE_RESOURCE_GROUP
+if (-not $resourceGroupFromEnv) { $resourceGroupFromEnv = Get-AzdEnvValue 'AZURE_RESOURCE_GROUP' }
+
+if (-not $postgreSqlServerResourceId) {
+ $pgResource = Resolve-PrimaryResource -ResourceType 'Microsoft.DBforPostgreSQL/flexibleServers' -ResourceGroup $resourceGroupFromEnv -SubscriptionId $subscriptionId
+ if ($pgResource) {
+ $postgreSqlServerResourceId = $pgResource.id
+ if (-not $postgreSqlServerName) { $postgreSqlServerName = $pgResource.name }
+ }
+}
+
+if (-not $keyVaultResourceId) {
+ $kvResource = Resolve-PrimaryResource -ResourceType 'Microsoft.KeyVault/vaults' -ResourceGroup $resourceGroupFromEnv -SubscriptionId $subscriptionId
+ if ($kvResource) { $keyVaultResourceId = $kvResource.id }
+}
+
+if (-not $postgreSqlServerResourceId) {
+ Warn "PostgreSQL server outputs not found; skipping mirroring prep."
+ exit 0
+}
+
+if (-not $DatabaseName) { $DatabaseName = 'postgres' }
+if ([string]::IsNullOrWhiteSpace($postgreSqlAdminLogin)) { $postgreSqlAdminLogin = 'pgadmin' }
+if ([string]::IsNullOrWhiteSpace($postgreSqlAdminSecretName)) { $postgreSqlAdminSecretName = 'postgres-admin-password' }
+if (-not $FabricUserName) { $FabricUserName = 'fabric_user' }
+if ($EntraRoleName) { $FabricUserName = $EntraRoleName }
+if (-not $MirrorConnectionMode) { $MirrorConnectionMode = 'fabricUser' }
+$MirrorConnectionMode = $MirrorConnectionMode.Trim()
+if ($MirrorConnectionMode -notin @('fabricUser', 'admin')) {
+ Warn "Unsupported PostgreSQL mirror connection mode '$MirrorConnectionMode'. Use 'fabricUser' or 'admin'."
+ exit 1
+}
+$useAdminForMirrorConnection = $MirrorConnectionMode -eq 'admin'
+$useEntra = (-not [string]::IsNullOrWhiteSpace($EntraRoleName)) -or (-not [string]::IsNullOrWhiteSpace($EntraObjectId))
+if ([string]::IsNullOrWhiteSpace($FabricUserName) -or ($FabricUserName -notmatch '^[a-zA-Z0-9_]+$')) {
+ if (-not $EntraRoleName -and -not $useAdminForMirrorConnection) {
+ Warn "Invalid Fabric user name '$FabricUserName'. Use only letters, numbers, and underscore."
+ exit 1
+ }
+}
+
+$enableFabricMirroring = if ($EnableFabricMirroring) { IsTrue $EnableFabricMirroring } else { $true }
+if (-not $MirrorSeedTableName) { $MirrorSeedTableName = 'fabric_mirror_seed' }
+if (-not $CreateMirrorSeedTable) { $CreateMirrorSeedTable = 'true' }
+$createMirrorSeedTable = IsTrue $CreateMirrorSeedTable
+
+# Parse resource ID
+$parts = $postgreSqlServerResourceId.Split('/', [System.StringSplitOptions]::RemoveEmptyEntries)
+if ($parts.Length -lt 8) { Warn "Invalid PostgreSQL resource ID."; exit 1 }
+$subscriptionId = $parts[1]
+$resourceGroup = $parts[3]
+if (-not $postgreSqlServerName) { $postgreSqlServerName = $parts[7] }
+
+$serverState = $null
+try {
+ $serverStateJson = Invoke-AzCliCapture @('postgres','flexible-server','show','-g', $resourceGroup,'-n', $postgreSqlServerName,'--subscription', $subscriptionId,'-o','json')
+ if ($serverStateJson) {
+ $serverState = $serverStateJson | ConvertFrom-Json -ErrorAction Stop
+ }
+} catch {
+ Warn "Unable to read PostgreSQL server state before mirroring prep."
+}
+
+if ($serverState -and [string]::IsNullOrWhiteSpace($serverState.administratorLogin)) {
+ Fail "PostgreSQL server '$postgreSqlServerName' was created without an administrator login. Password authentication cannot be enabled in-place on this server. Redeploy the server with postgreSqlAuthConfig.passwordAuth='Enabled' and a non-empty postgreSqlAdminLogin so Fabric mirroring can be configured automatically."
+}
+
+Log "Ensuring PostgreSQL auth modes (password + Entra) are enabled..."
+try {
+ Invoke-AzCli @('postgres','flexible-server','update','-g', $resourceGroup,'-n', $postgreSqlServerName,'--subscription', $subscriptionId,'--microsoft-entra-auth','Enabled','--password-auth','Enabled')
+} catch {
+ Warn "Failed to enable PostgreSQL password/Entra auth modes. Configure the server Authentication settings in the portal and retry."
+ throw
+}
+
+# Resolve Key Vault name
+$keyVaultName = $null
+if ($keyVaultResourceId) {
+ $kvParts = $keyVaultResourceId.Split('/', [System.StringSplitOptions]::RemoveEmptyEntries)
+ if ($kvParts.Length -ge 8) { $keyVaultName = $kvParts[7] }
+}
+
+function Test-KeyVaultAccess([string]$vaultName) {
+ try {
+ $null = Invoke-AzCliCapture @('keyvault','secret','list','--vault-name', $vaultName,'--maxresults','1','--query','[0].id','-o','tsv')
+ return $true
+ } catch {
+ return $false
+ }
+}
+
+$tempEnableKvPublicAccess = IsTrue $TempEnableKeyVaultPublicAccess
+
+function Set-KeyVaultPublicAccess([string]$vaultName, [string]$state) {
+ if (-not $vaultName) { return }
+ try {
+ Invoke-AzCli @('keyvault','update','-n', $vaultName,'--public-network-access', $state)
+ } catch {
+ Warn "Failed to set Key Vault public network access to '$state' for $vaultName."
+ throw
+ }
+}
+
+function Get-PublicClientIp() {
+ $candidates = @(
+ 'https://api.ipify.org',
+ 'https://ifconfig.me/ip',
+ 'https://icanhazip.com'
+ )
+
+ foreach ($candidate in $candidates) {
+ try {
+ $value = Invoke-RestMethod -Uri $candidate -TimeoutSec 10
+ $ip = $value.ToString().Trim()
+ if ($ip -match '^\d{1,3}(\.\d{1,3}){3}$') {
+ return $ip
+ }
+ } catch {}
+ }
+
+ return $null
+}
+
+function Add-PostgreSqlFirewallRule([string]$resourceGroupName, [string]$serverName, [string]$ruleName, [string]$ipAddress, [string]$subscription) {
+ if ([string]::IsNullOrWhiteSpace($resourceGroupName) -or [string]::IsNullOrWhiteSpace($serverName) -or [string]::IsNullOrWhiteSpace($ruleName) -or [string]::IsNullOrWhiteSpace($ipAddress)) {
+ return
+ }
+
+ Invoke-AzCli @('postgres','flexible-server','firewall-rule','create','--resource-group', $resourceGroupName,'--name', $serverName,'--rule-name', $ruleName,'--start-ip-address', $ipAddress,'--end-ip-address', $ipAddress,'--subscription', $subscription)
+}
+
+function Remove-PostgreSqlFirewallRule([string]$resourceGroupName, [string]$serverName, [string]$ruleName, [string]$subscription) {
+ if ([string]::IsNullOrWhiteSpace($resourceGroupName) -or [string]::IsNullOrWhiteSpace($serverName) -or [string]::IsNullOrWhiteSpace($ruleName)) {
+ return
+ }
+
+ & az postgres flexible-server firewall-rule delete --resource-group $resourceGroupName --name $serverName --rule-name $ruleName --subscription $subscription --yes 1>$null 2>$null
+}
+
+function Invoke-PostgresSql([string]$sqlText) {
+ if ($script:sqlExecutionMode -eq 'az') {
+ $queryText = Convert-SqlToAzQueryText $sqlText
+ Invoke-AzCli @('postgres','flexible-server','execute','--name', $postgreSqlServerName,'--admin-user', $postgreSqlAdminLogin,'--admin-password', $adminPassword,'--database-name', $DatabaseName,'--querytext', $queryText,'--subscription', $subscriptionId) 1>$null
+ return
+ }
+
+ if ($script:sqlExecutionMode -eq 'npgsql') {
+ $fqdn = "$postgreSqlServerName.postgres.database.azure.com"
+ $connString = "Host=$fqdn;Port=5432;Database=$DatabaseName;Username=$postgreSqlAdminLogin;Password=$adminPassword;SSL Mode=Require;Trust Server Certificate=true"
+ $conn = [Npgsql.NpgsqlConnection]::new($connString)
+ try {
+ $conn.Open()
+ $cmd = $conn.CreateCommand()
+ $cmd.CommandText = $sqlText
+ [void]$cmd.ExecuteNonQuery()
+ return
+ } finally {
+ $conn.Dispose()
+ }
+ }
+
+ $fqdn = "$postgreSqlServerName.postgres.database.azure.com"
+ $pgUser = $postgreSqlAdminLogin
+ $env:PGPASSWORD = $adminPassword
+ $pgConn = "host=$fqdn port=5432 dbname=$DatabaseName sslmode=require"
+ & $script:PsqlPath -d $pgConn -U $pgUser -v ON_ERROR_STOP=1 -c $sqlText 1>$null
+ if ($LASTEXITCODE -ne 0) {
+ throw "psql command failed with exit code $LASTEXITCODE for admin user '$postgreSqlAdminLogin'."
+ }
+}
+
+function Invoke-PostgresSqlAsUser([string]$userName, [string]$userPassword, [string]$sqlText) {
+ if ([string]::IsNullOrWhiteSpace($userName) -or [string]::IsNullOrWhiteSpace($userPassword)) {
+ throw "User credentials are required to execute SQL as user."
+ }
+
+ if ($script:sqlExecutionMode -eq 'az') {
+ $queryText = Convert-SqlToAzQueryText $sqlText
+ Invoke-AzCli @('postgres','flexible-server','execute','--name', $postgreSqlServerName,'--admin-user', $userName,'--admin-password', $userPassword,'--database-name', $DatabaseName,'--querytext', $queryText,'--subscription', $subscriptionId) 1>$null
+ return
+ }
+
+ if ($script:sqlExecutionMode -eq 'npgsql') {
+ $fqdn = "$postgreSqlServerName.postgres.database.azure.com"
+ $connString = "Host=$fqdn;Port=5432;Database=$DatabaseName;Username=$userName;Password=$userPassword;SSL Mode=Require;Trust Server Certificate=true"
+ $conn = [Npgsql.NpgsqlConnection]::new($connString)
+ try {
+ $conn.Open()
+ $cmd = $conn.CreateCommand()
+ $cmd.CommandText = $sqlText
+ [void]$cmd.ExecuteNonQuery()
+ return
+ } finally {
+ $conn.Dispose()
+ }
+ }
+
+ $fqdn = "$postgreSqlServerName.postgres.database.azure.com"
+ $pgUser = $userName
+ $env:PGPASSWORD = $userPassword
+ $pgConn = "host=$fqdn port=5432 dbname=$DatabaseName sslmode=require"
+ & $script:PsqlPath -d $pgConn -U $pgUser -v ON_ERROR_STOP=1 -c $sqlText 1>$null
+ if ($LASTEXITCODE -ne 0) {
+ throw "psql command failed with exit code $LASTEXITCODE for user '$userName'."
+ }
+}
+
+function Test-PostgresCredential([string]$userName, [string]$userPassword) {
+ $script:LastPostgresCredentialError = $null
+ if ([string]::IsNullOrWhiteSpace($userName) -or [string]::IsNullOrWhiteSpace($userPassword)) {
+ $script:LastPostgresCredentialError = 'Missing PostgreSQL username or password.'
+ return $false
+ }
+
+ $fqdn = "$postgreSqlServerName.postgres.database.azure.com"
+
+ if ($script:sqlExecutionMode -eq 'az') {
+ try {
+ Invoke-AzCli @('postgres','flexible-server','execute','--name', $postgreSqlServerName,'--admin-user', $userName,'--admin-password', $userPassword,'--database-name', $DatabaseName,'--querytext', 'select 1;','--subscription', $subscriptionId) 1>$null
+ return $true
+ } catch {
+ $script:LastPostgresCredentialError = $_.Exception.Message
+ return $false
+ }
+ }
+
+ if ($script:sqlExecutionMode -eq 'npgsql') {
+ $connString = "Host=$fqdn;Port=5432;Database=$DatabaseName;Username=$userName;Password=$userPassword;SSL Mode=Require;Trust Server Certificate=true"
+ $conn = [Npgsql.NpgsqlConnection]::new($connString)
+ try {
+ $conn.Open()
+ return $true
+ } catch {
+ $script:LastPostgresCredentialError = $_.Exception.Message
+ return $false
+ } finally {
+ $conn.Dispose()
+ }
+ }
+
+ if ($script:sqlExecutionMode -eq 'psql') {
+ $env:PGPASSWORD = $userPassword
+ $pgConn = "host=$fqdn port=5432 dbname=$DatabaseName sslmode=require"
+ & $script:PsqlPath -d $pgConn -U $userName -v ON_ERROR_STOP=1 -c 'select 1;' 1>$null 2>$null
+ if ($LASTEXITCODE -eq 0) {
+ return $true
+ }
+
+ $script:LastPostgresCredentialError = "psql exited with code $LASTEXITCODE while testing PostgreSQL connectivity."
+ return $false
+ }
+
+ $script:LastPostgresCredentialError = 'No supported PostgreSQL credential validation backend is available.'
+ return $false
+}
+
+$allowPsqlInstall = IsTrue ($env:POSTGRES_ALLOW_PSQL_INSTALL)
+if (Ensure-AzExtension 'rdbms-connect') {
+ $script:sqlExecutionMode = 'az'
+} elseif (Ensure-Npgsql) {
+ $script:sqlExecutionMode = 'npgsql'
+} elseif (Ensure-Psql $allowPsqlInstall) {
+ $script:sqlExecutionMode = 'psql'
+} else {
+ Fail 'No supported PostgreSQL SQL execution backend is available. Install the Azure CLI rdbms-connect extension, make psql available, or allow the portable Npgsql fallback to restore dependencies.'
+}
+
+$temporaryClientFirewallRuleName = 'AllowCurrentClientFabricMirrorPrep'
+$temporaryClientFirewallIp = $null
+$temporaryClientFirewallRuleAdded = $false
+if ($serverState -and $serverState.network -and $serverState.network.publicNetworkAccess -eq 'Enabled') {
+ $temporaryClientFirewallIp = Get-PublicClientIp
+ if ($temporaryClientFirewallIp) {
+ Log "Temporarily allowing current client IP $temporaryClientFirewallIp to reach PostgreSQL for mirroring preparation..."
+ Add-PostgreSqlFirewallRule -resourceGroupName $resourceGroup -serverName $postgreSqlServerName -ruleName $temporaryClientFirewallRuleName -ipAddress $temporaryClientFirewallIp -subscription $subscriptionId
+ $temporaryClientFirewallRuleAdded = $true
+ } else {
+ Warn 'Unable to determine the current public client IP. Mirroring preparation may fail if the server firewall does not already allow this host.'
+ }
+}
+
+# Fetch admin password from Key Vault or environment
+$adminPassword = $null
+try {
+ if ($tempEnableKvPublicAccess -and $keyVaultName) {
+ Log "Temporarily enabling Key Vault public access for secret operations..."
+ Set-KeyVaultPublicAccess -vaultName $keyVaultName -state 'Enabled'
+ }
+
+ if ($keyVaultName -and $postgreSqlAdminSecretName) {
+ try {
+ $adminPassword = Invoke-AzCliCapture @('keyvault','secret','show','--vault-name', $keyVaultName,'--name', $postgreSqlAdminSecretName,'--query','value','-o','tsv')
+ } catch {}
+ }
+ if (-not $adminPassword) { $adminPassword = $env:POSTGRES_ADMIN_PASSWORD }
+ if (-not $adminPassword) {
+ if ($keyVaultName -and (-not (Test-KeyVaultAccess $keyVaultName))) {
+ Warn "Key Vault '$keyVaultName' is not reachable. Run from a VNet-connected host or enable trusted access, then retry."
+ exit 1
+ }
+
+ Fail "PostgreSQL admin password was not found in Key Vault secret '$postgreSqlAdminSecretName' or POSTGRES_ADMIN_PASSWORD. Provisioning is expected to create this credential; mirroring prep will not generate or rotate it."
+ }
+
+ if (-not (Test-PostgresCredential -userName $postgreSqlAdminLogin -userPassword $adminPassword)) {
+ if ($script:LastPostgresCredentialError -match 'Connection timed out|timed out|timeout|failed to respond|No such host is known|Unable to connect|connection attempt failed') {
+ Fail "Unable to validate the PostgreSQL admin credential because this machine cannot reach '$postgreSqlServerName.postgres.database.azure.com' on port 5432 using the current DNS/network path. Run mirroring prep from a VNet-connected host or a client that can reach the server directly, then rerun the script. The stored secret '$postgreSqlAdminSecretName' was not rotated."
+ }
+
+ Fail "The stored PostgreSQL admin credential for '$postgreSqlAdminLogin' is out of sync with the server. Mirroring prep will not rotate it automatically because provisioning already owns that credential. Sync the existing secret '$postgreSqlAdminSecretName' with the live server password, then rerun mirroring prep."
+ }
+
+ $fabricUserPassword = $null
+ if (-not $useEntra) {
+ # Always provision the dedicated Fabric user so create_postgresql_mirror.ps1 can
+ # fall back to MD5-based auth when the admin account is unsuitable for Fabric.
+ if (-not $postgreSqlFabricUserSecretName) { $postgreSqlFabricUserSecretName = 'postgres-fabric-user-password' }
+ if ($keyVaultName) {
+ try {
+ $fabricUserPassword = Invoke-AzCliCapture @('keyvault','secret','show','--vault-name', $keyVaultName,'--name', $postgreSqlFabricUserSecretName,'--query','value','-o','tsv')
+ if ($fabricUserPassword) {
+ Log "Using Fabric user password from Key Vault secret: $postgreSqlFabricUserSecretName"
+ }
+ } catch {}
+ }
+ if (-not $fabricUserPassword) {
+ $bytes = New-Object byte[] 32
+ [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
+ $fabricUserPassword = ([Convert]::ToBase64String($bytes).TrimEnd('=')) + 'a!'
+ if ($keyVaultName) {
+ try {
+ Invoke-AzCli @('keyvault','secret','set','--vault-name', $keyVaultName,'--name', $postgreSqlFabricUserSecretName,'--value', $fabricUserPassword)
+ Log "Stored Fabric user password in Key Vault: $postgreSqlFabricUserSecretName"
+ } catch {
+ Fail "Failed to store Fabric user password in Key Vault. Refusing to continue because the server password must not change without Key Vault remaining in sync."
+ }
+ }
+ }
+ }
+ if ($useAdminForMirrorConnection) {
+ Log "PostgreSQL mirror connection mode is 'admin'. Demo mode will prefer the admin credential, but a dedicated MD5-auth Fabric user will also be maintained as a fallback."
+ }
+} finally {
+ if ($tempEnableKvPublicAccess -and $keyVaultName) {
+ Log "Restoring Key Vault public access to Disabled..."
+ Set-KeyVaultPublicAccess -vaultName $keyVaultName -state 'Disabled'
+ }
+}
+
+# Set server parameters for mirroring
+$changed = $false
+$needsRestart = $false
+
+function Get-ParamValue([string]$paramName) {
+ try {
+ $val = Invoke-AzCliCapture @('postgres','flexible-server','parameter','show','-g', $resourceGroup,'-s', $postgreSqlServerName,'-n', $paramName,'--query','value','-o','tsv','--subscription', $subscriptionId)
+ return $val
+ } catch { return $null }
+}
+
+function Get-ParamAllowedValues([string]$paramName) {
+ try {
+ $val = Invoke-AzCliCapture @('postgres','flexible-server','parameter','show','-g', $resourceGroup,'-s', $postgreSqlServerName,'-n', $paramName,'--query','allowedValues','-o','tsv','--subscription', $subscriptionId)
+ if ($val) { return ($val -split ',') | ForEach-Object { $_.Trim() } | Where-Object { $_ } }
+ } catch { }
+ return @()
+}
+
+function Get-ParamDefaultValue([string]$paramName) {
+ try {
+ $val = Invoke-AzCliCapture @('postgres','flexible-server','parameter','show','-g', $resourceGroup,'-s', $postgreSqlServerName,'-n', $paramName,'--query','defaultValue','-o','tsv','--subscription', $subscriptionId)
+ if ($val) { return $val.ToString().Trim() }
+ } catch { }
+ return $null
+}
+
+function Set-ParamValue([string]$paramName, [string]$value, [bool]$requiresRestart) {
+ $current = Get-ParamValue $paramName
+ if ($current -ne $value) {
+ Log "Setting $paramName to '$value' (was '$current')"
+ Invoke-AzCli @('postgres','flexible-server','parameter','set','-g', $resourceGroup,'-s', $postgreSqlServerName,'-n', $paramName,'--value', $value,'--subscription', $subscriptionId)
+ $script:changed = $true
+ if ($requiresRestart) { $script:needsRestart = $true }
+ }
+}
+
+Set-ParamValue -paramName 'wal_level' -value 'logical' -requiresRestart $true
+
+if ($enableFabricMirroring) {
+ # Match portal enablement: configure Fabric mirroring flags for the server.
+ Set-ParamValue -paramName 'azure.fabric_mirror_enabled' -value 'on' -requiresRestart $true
+ Set-ParamValue -paramName 'azure.mirror_databases' -value $DatabaseName -requiresRestart $true
+}
+
+
+# Increase max_worker_processes by 3 per mirrored database
+$maxWorkers = Get-ParamValue 'max_worker_processes'
+if ($maxWorkers -and $maxWorkers -as [int]) {
+ $currentWorkers = [int]$maxWorkers
+ $defaultWorkersValue = Get-ParamDefaultValue 'max_worker_processes'
+ $minimumWorkers = $currentWorkers
+ if ($defaultWorkersValue -and ($defaultWorkersValue -as [int])) {
+ $minimumWorkers = [int]$defaultWorkersValue + (3 * $MirrorCount)
+ }
+ $targetWorkers = [Math]::Max($currentWorkers, $minimumWorkers)
+ Set-ParamValue -paramName 'max_worker_processes' -value $targetWorkers.ToString() -requiresRestart $true
+}
+
+if ($changed -and $needsRestart) {
+ Log "Restarting PostgreSQL server to apply mirroring settings..."
+ Invoke-AzCli @('postgres','flexible-server','restart','-g', $resourceGroup,'-n', $postgreSqlServerName,'--subscription', $subscriptionId)
+}
+
+if ($enableFabricMirroring) {
+ try {
+ Log "Invoking Azure-side Fabric mirroring enablement for database '$DatabaseName'..."
+ [void](Invoke-AzCliWithServerBusyRetry @('postgres','flexible-server','fabric-mirroring','start','-g', $resourceGroup,'-s', $postgreSqlServerName,'--database-names', $DatabaseName,'--subscription', $subscriptionId,'-y'))
+ } catch {
+ Warn "Azure-side Fabric mirroring enablement did not complete automatically. Continuing with local role and grant preparation."
+ }
+}
+
+# Configure role and grants
+$mfaFlag = if ($EntraRequireMfa -and $EntraRequireMfa.ToLowerInvariant() -eq 'true') { 'true' } else { 'false' }
+
+if ($useEntra) {
+ if (-not $EntraRoleName) {
+ Warn "Entra role name is required when using Entra mapping. Set POSTGRES_FABRIC_ENTRA_ROLE_NAME."
+ exit 1
+ }
+ if (-not $EntraObjectType) { $EntraObjectType = 'user' }
+
+ $createPrincipalSql = if ($EntraObjectId) {
+ "select * from pg_catalog.pgaadauth_create_principal_with_oid('$EntraRoleName', '$EntraObjectId', '$EntraObjectType', false, $mfaFlag);"
+ } else {
+ "select * from pg_catalog.pgaadauth_create_principal('$EntraRoleName', false, $mfaFlag);"
+ }
+ $grantParts = @(
+ ('GRANT azure_cdc_admin TO "{0}";' -f $EntraRoleName),
+ ('GRANT CREATE ON DATABASE "{0}" TO "{1}";' -f $DatabaseName, $EntraRoleName),
+ ('GRANT USAGE ON SCHEMA public TO "{0}";' -f $EntraRoleName),
+ ('GRANT CREATE ON SCHEMA public TO "{0}";' -f $EntraRoleName),
+ ('ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO "{0}";' -f $EntraRoleName)
+ ) | Where-Object { $_ }
+ $grantSql = if ($grantParts) { [string]::Join(' ', $grantParts) } else { '' }
+} else {
+ $ensureRoleSql = @'
+DO $do$
+BEGIN
+ PERFORM set_config($q$password_encryption$q$, $q$md5$q$, true);
+ IF EXISTS (
+ SELECT 1 FROM pg_roles WHERE rolname = $q${0}$q$
+ ) THEN
+ ALTER ROLE "{0}" WITH CREATEDB CREATEROLE LOGIN REPLICATION PASSWORD $pwd${1}$pwd$;
+ ELSE
+ CREATE ROLE "{0}" CREATEDB CREATEROLE LOGIN REPLICATION PASSWORD $pwd${1}$pwd$;
+ END IF;
+END $do$;
+'@ -f $FabricUserName, $fabricUserPassword
+ $grantParts = @(
+ ('GRANT azure_cdc_admin TO "{0}";' -f $FabricUserName),
+ ('GRANT CREATE ON DATABASE "{0}" TO "{1}";' -f $DatabaseName, $FabricUserName),
+ ('GRANT USAGE ON SCHEMA public TO "{0}";' -f $FabricUserName),
+ ('GRANT CREATE ON SCHEMA public TO "{0}";' -f $FabricUserName),
+ ('ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO "{0}";' -f $FabricUserName)
+ ) | Where-Object { $_ }
+ $grantSql = if ($grantParts) { [string]::Join(' ', $grantParts) } else { '' }
+}
+
+Log "Creating/validating Fabric mirroring role in database '$DatabaseName'..."
+try {
+ if ($useEntra) {
+ Invoke-PostgresSql $createPrincipalSql
+ } else {
+ Invoke-PostgresSql $ensureRoleSql
+ }
+ if ($grantSql) {
+ Invoke-PostgresSql $grantSql
+ }
+ if ($useAdminForMirrorConnection) {
+ Log "Using PostgreSQL admin login '$postgreSqlAdminLogin' as the preferred Fabric mirror connection identity."
+ }
+ $verifyRoleSql = if ($useAdminForMirrorConnection) {
+@'
+DO $do$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_roles WHERE rolname = $q${0}$q$ AND rolcanlogin
+ ) THEN
+ RAISE EXCEPTION $msg$PostgreSQL admin role missing or cannot login: {0}$msg$;
+ END IF;
+END $do$;
+'@ -f $postgreSqlAdminLogin
+ } elseif ($useEntra) {
+@'
+DO $do$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_roles WHERE rolname = $q${0}$q$ AND rolcanlogin
+ ) THEN
+ RAISE EXCEPTION $msg$Fabric mirroring role missing or cannot login: {0}$msg$;
+ END IF;
+ IF NOT pg_has_role($q${0}$q$, $q$azure_cdc_admin$q$, $q$member$q$) THEN
+ RAISE EXCEPTION $msg$Fabric mirroring role missing azure_cdc_admin membership: {0}$msg$;
+ END IF;
+ IF NOT has_database_privilege($q${0}$q$, $q${1}$q$, $q$CREATE$q$) THEN
+ RAISE EXCEPTION $msg$Fabric mirroring role missing CREATE privilege on database {1}: {0}$msg$;
+ END IF;
+ IF NOT has_schema_privilege($q${0}$q$, $q$public$q$, $q$CREATE$q$) THEN
+ RAISE EXCEPTION $msg$Fabric mirroring role missing CREATE privilege on schema public: {0}$msg$;
+ END IF;
+END $do$;
+'@ -f $EntraRoleName, $DatabaseName
+ } else {
+@'
+DO $do$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_roles WHERE rolname = $q${0}$q$ AND rolcanlogin
+ ) THEN
+ RAISE EXCEPTION $msg$Fabric mirroring role missing or cannot login: {0}$msg$;
+ END IF;
+ IF NOT pg_has_role($q${0}$q$, $q$azure_cdc_admin$q$, $q$member$q$) THEN
+ RAISE EXCEPTION $msg$Fabric mirroring role missing azure_cdc_admin membership: {0}$msg$;
+ END IF;
+ IF NOT has_database_privilege($q${0}$q$, $q${1}$q$, $q$CREATE$q$) THEN
+ RAISE EXCEPTION $msg$Fabric mirroring role missing CREATE privilege on database {1}: {0}$msg$;
+ END IF;
+ IF NOT has_schema_privilege($q${0}$q$, $q$public$q$, $q$CREATE$q$) THEN
+ RAISE EXCEPTION $msg$Fabric mirroring role missing CREATE privilege on schema public: {0}$msg$;
+ END IF;
+ IF EXISTS (SELECT 1 FROM pg_proc WHERE proname = $q$azure_roles_authtype$q$) AND NOT EXISTS (
+ SELECT 1 FROM azure_roles_authtype()
+ WHERE rolename = $q${0}$q$ AND authtype = $q$MD5$q$
+ ) THEN
+ RAISE EXCEPTION $msg$Fabric mirroring role is not using MD5 password auth: {0}$msg$;
+ END IF;
+END $do$;
+'@ -f $FabricUserName, $DatabaseName
+ }
+ Invoke-PostgresSql $verifyRoleSql
+ if ($enableFabricMirroring -and $createMirrorSeedTable) {
+ $seedTableSql = @"
+CREATE TABLE IF NOT EXISTS public.\"$MirrorSeedTableName\" (
+ id bigserial PRIMARY KEY,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+INSERT INTO public.\"$MirrorSeedTableName\" (created_at)
+SELECT now()
+WHERE NOT EXISTS (SELECT 1 FROM public.\"$MirrorSeedTableName\");
+"@
+ $ownerName = if ($useAdminForMirrorConnection) { $postgreSqlAdminLogin } elseif ($useEntra) { $EntraRoleName } else { $FabricUserName }
+ $createdAsFabricUser = $false
+ if (-not $useAdminForMirrorConnection -and -not $useEntra -and $FabricUserName -and $fabricUserPassword) {
+ try {
+ Invoke-PostgresSqlAsUser -userName $FabricUserName -userPassword $fabricUserPassword -sqlText $seedTableSql
+ $createdAsFabricUser = $true
+ } catch {
+ Warn "Failed to create seed table as '$FabricUserName'; falling back to admin."
+ }
+ }
+
+ if (-not $createdAsFabricUser) {
+ Invoke-PostgresSql $seedTableSql
+ }
+ if ($ownerName -and -not ($createdAsFabricUser -and $ownerName -eq $FabricUserName)) {
+ $ownerSql = ('ALTER TABLE public."{0}" OWNER TO "{1}";' -f $MirrorSeedTableName, $ownerName)
+ Invoke-PostgresSql $ownerSql
+ }
+ $verifyTableSql = @'
+DO $do$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.tables
+ WHERE table_schema = $q$public$q$ AND table_name = $q${0}$q$
+ ) THEN
+ RAISE EXCEPTION $msg$Mirror seed table not found: public.{0}$msg$;
+ END IF;
+END $do$;
+'@ -f $MirrorSeedTableName
+ Invoke-PostgresSql $verifyTableSql
+ if ($ownerName) {
+ $verifyOwnerSql = @'
+DO $do$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_tables
+ WHERE schemaname = $q$public$q$
+ AND tablename = $q${0}$q$
+ AND tableowner = $q${1}$q$
+ ) THEN
+ RAISE EXCEPTION $msg$Mirror seed table owner mismatch: public.{0} owner is not {1}$msg$;
+ END IF;
+END $do$;
+'@ -f $MirrorSeedTableName, $ownerName
+ Invoke-PostgresSql $verifyOwnerSql
+ }
+ Log "Ensured mirror seed table exists: public.$MirrorSeedTableName"
+ }
+ if ($useAdminForMirrorConnection) {
+ Log "PostgreSQL admin demo mode configured for Fabric mirroring."
+ } else {
+ Log "Fabric mirroring role configured."
+ }
+} catch {
+ Warn "Failed to apply SQL grants. Ensure your machine can reach the server or use a VNet gateway."
+ Warn "For the shortest manual fallback, see docs/postgresql_mirroring.md and start with the 'Minimal Manual Fallback' section."
+ throw
+} finally {
+ if ($temporaryClientFirewallRuleAdded) {
+ Log "Removing temporary PostgreSQL firewall rule '$temporaryClientFirewallRuleName' for client IP $temporaryClientFirewallIp..."
+ Remove-PostgreSqlFirewallRule -resourceGroupName $resourceGroup -serverName $postgreSqlServerName -ruleName $temporaryClientFirewallRuleName -subscription $subscriptionId
+ }
+}
diff --git a/scripts/automationScripts/FabricWorkspace/mirror/run_postgresql_mirroring_followup.ps1 b/scripts/automationScripts/FabricWorkspace/mirror/run_postgresql_mirroring_followup.ps1
new file mode 100644
index 0000000..f6b9930
--- /dev/null
+++ b/scripts/automationScripts/FabricWorkspace/mirror/run_postgresql_mirroring_followup.ps1
@@ -0,0 +1,59 @@
+<#
+.SYNOPSIS
+ Runs the full PostgreSQL-to-Fabric mirroring follow-up flow from a chosen runner such as the deployed VM.
+
+.DESCRIPTION
+ Executes:
+ 1. Read-only preflight
+ 2. PostgreSQL mirroring preparation
+ 3. Fabric connection creation and mirror creation
+#>
+
+[CmdletBinding()]
+param(
+ [switch]$SkipPreflight,
+ [switch]$SkipPrep,
+ [switch]$SkipMirrorCreation
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+function Log([string]$m){ Write-Host "[pg-mirroring-followup] $m" }
+function Fail([string]$m){ Write-Error "[pg-mirroring-followup] $m"; exit 1 }
+
+function Invoke-Step([string]$label, [string]$scriptPath) {
+ Log "Starting: $label"
+ & pwsh -NoProfile -File $scriptPath
+ if ($LASTEXITCODE -ne 0) {
+ Fail "$label failed with exit code $LASTEXITCODE."
+ }
+ Log "Completed: $label"
+}
+
+$preflightScript = Join-Path $PSScriptRoot 'test_postgresql_mirroring_prereqs.ps1'
+$prepScript = Join-Path $PSScriptRoot 'prepare_postgresql_for_mirroring.ps1'
+$mirrorScript = Join-Path $PSScriptRoot 'create_postgresql_mirror.ps1'
+
+Log 'PostgreSQL mirroring follow-up started.'
+
+if (-not $SkipPreflight) {
+ Invoke-Step -label 'Mirroring preflight' -scriptPath $preflightScript
+} else {
+ Log 'Skipping preflight by request.'
+}
+
+if (-not $SkipPrep) {
+ Invoke-Step -label 'PostgreSQL mirroring preparation' -scriptPath $prepScript
+} else {
+ Log 'Skipping mirroring preparation by request.'
+}
+
+if (-not $SkipMirrorCreation) {
+ Invoke-Step -label 'Fabric connection and mirror creation' -scriptPath $mirrorScript
+} else {
+ Log 'Skipping mirror creation by request.'
+}
+
+Log 'PostgreSQL mirroring follow-up completed successfully.'
+exit 0
\ No newline at end of file
diff --git a/scripts/automationScripts/FabricWorkspace/mirror/test_postgresql_mirroring_prereqs.ps1 b/scripts/automationScripts/FabricWorkspace/mirror/test_postgresql_mirroring_prereqs.ps1
new file mode 100644
index 0000000..443f533
--- /dev/null
+++ b/scripts/automationScripts/FabricWorkspace/mirror/test_postgresql_mirroring_prereqs.ps1
@@ -0,0 +1,200 @@
+<#
+.SYNOPSIS
+ Read-only preflight for PostgreSQL mirroring from the current runner.
+
+.DESCRIPTION
+ Checks whether the current execution environment is likely to succeed when running
+ PostgreSQL mirroring preparation and Fabric mirror creation.
+#>
+
+[CmdletBinding()]
+param(
+ [int]$TcpTimeoutMs = 5000
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+function Info([string]$m){ Write-Host "[pg-mirror-preflight] $m" }
+function Pass([string]$m){ Write-Host "[PASS] $m" -ForegroundColor Green }
+function Warn([string]$m){ Write-Warning "[pg-mirror-preflight] $m" }
+function Fail([string]$m){ Write-Host "[FAIL] $m" -ForegroundColor Red }
+
+$script:CriticalFailures = 0
+$script:Warnings = 0
+
+function Add-CriticalFailure([string]$message) {
+ $script:CriticalFailures++
+ Fail $message
+}
+
+function Add-Warning([string]$message) {
+ $script:Warnings++
+ Warn $message
+}
+
+function Test-CommandAvailable([string]$name) {
+ return [bool](Get-Command $name -ErrorAction SilentlyContinue)
+}
+
+function Get-AzdEnvValue([string]$key) {
+ try {
+ $value = & azd env get-value $key 2>$null
+ if ($LASTEXITCODE -eq 0 -and $value -and -not ($value -match '^\s*ERROR:')) {
+ return $value.ToString().Trim()
+ }
+ } catch {}
+
+ return $null
+}
+
+function Test-TcpPort([string]$hostName, [int]$port, [int]$timeoutMs) {
+ $client = New-Object System.Net.Sockets.TcpClient
+ try {
+ $async = $client.BeginConnect($hostName, $port, $null, $null)
+ if (-not $async.AsyncWaitHandle.WaitOne($timeoutMs, $false)) {
+ return $false
+ }
+
+ $client.EndConnect($async)
+ return $true
+ } catch {
+ return $false
+ } finally {
+ $client.Dispose()
+ }
+}
+
+function Test-ResolveDns([string]$hostName) {
+ try {
+ [void][System.Net.Dns]::GetHostAddresses($hostName)
+ return $true
+ } catch {
+ return $false
+ }
+}
+
+function Invoke-AzCliText([string[]]$args) {
+ $output = & az @args 2>&1
+ $text = (($output | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine)
+ return @{
+ ExitCode = $LASTEXITCODE
+ Text = $text
+ }
+}
+
+Info 'Checking local prerequisites...'
+
+if (Test-CommandAvailable 'az') {
+ Pass 'Azure CLI is available.'
+} else {
+ Add-CriticalFailure 'Azure CLI is not available.'
+}
+
+if (Test-CommandAvailable 'azd') {
+ Pass 'Azure Developer CLI is available.'
+} else {
+ Add-CriticalFailure 'Azure Developer CLI is not available.'
+}
+
+if ($script:CriticalFailures -gt 0) {
+ Info "Preflight failed with $script:CriticalFailures critical issue(s)."
+ exit 1
+}
+
+$accountCheck = Invoke-AzCliText @('account','show','--query','id','-o','tsv')
+if ($accountCheck.ExitCode -eq 0 -and -not [string]::IsNullOrWhiteSpace($accountCheck.Text)) {
+ Pass "Azure CLI is authenticated. Subscription: $($accountCheck.Text.Trim())"
+} else {
+ Add-CriticalFailure 'Azure CLI is not authenticated for this runner.'
+}
+
+$environmentName = Get-AzdEnvValue 'AZURE_ENV_NAME'
+if ($environmentName) {
+ Pass "azd environment is selected: $environmentName"
+} else {
+ Add-CriticalFailure 'No azd environment is currently selected.'
+}
+
+$requiredValues = @(
+ 'postgreSqlServerFqdn',
+ 'postgreSqlMirrorConnectionModeOut',
+ 'postgreSqlMirrorConnectionUserNameOut',
+ 'postgreSqlMirrorConnectionSecretNameOut',
+ 'keyVaultResourceId',
+ 'fabricWorkspaceIdOut'
+)
+
+$resolved = @{}
+foreach ($key in $requiredValues) {
+ $resolved[$key] = Get-AzdEnvValue $key
+ if ($resolved[$key]) {
+ Pass "Resolved azd value: $key"
+ } else {
+ Add-CriticalFailure "Required azd value is missing: $key"
+ }
+}
+
+if ($script:CriticalFailures -gt 0) {
+ Info "Preflight failed with $script:CriticalFailures critical issue(s)."
+ exit 1
+}
+
+$postgresFqdn = $resolved['postgreSqlServerFqdn']
+$secretName = $resolved['postgreSqlMirrorConnectionSecretNameOut']
+$keyVaultResourceId = $resolved['keyVaultResourceId']
+
+$resourceIdSegments = $keyVaultResourceId.Split('/', [System.StringSplitOptions]::RemoveEmptyEntries)
+$keyVaultName = if ($resourceIdSegments.Length -ge 8) { $resourceIdSegments[$resourceIdSegments.Length - 1] } else { $null }
+
+Info 'Checking runner connectivity...'
+
+if (Test-ResolveDns $postgresFqdn) {
+ Pass "DNS resolves for PostgreSQL host: $postgresFqdn"
+} else {
+ Add-CriticalFailure "DNS resolution failed for PostgreSQL host: $postgresFqdn"
+}
+
+if (Test-TcpPort -hostName $postgresFqdn -port 5432 -timeoutMs $TcpTimeoutMs) {
+ Pass "Runner can open TCP 5432 to $postgresFqdn"
+} else {
+ Add-CriticalFailure "Runner cannot open TCP 5432 to $postgresFqdn"
+}
+
+Info 'Checking secret and Fabric prerequisites...'
+
+if ($keyVaultName) {
+ $kvShow = Invoke-AzCliText @('keyvault','show','--name', $keyVaultName, '--query', 'name', '-o', 'tsv')
+ if ($kvShow.ExitCode -eq 0) {
+ Pass "Key Vault metadata is reachable: $keyVaultName"
+ } else {
+ Add-CriticalFailure "Key Vault metadata is not reachable for $keyVaultName"
+ }
+
+ $secretCheck = Invoke-AzCliText @('keyvault','secret','show','--vault-name', $keyVaultName, '--name', $secretName, '--query', 'id', '-o', 'tsv')
+ if ($secretCheck.ExitCode -eq 0 -and -not [string]::IsNullOrWhiteSpace($secretCheck.Text)) {
+ Pass "Mirroring secret is readable from Key Vault: $secretName"
+ } else {
+ Add-Warning "The mirroring secret is not readable from Key Vault right now. The wrapper may still succeed if it temporarily opens Key Vault public access, but this runner is not currently able to read the secret directly."
+ }
+} else {
+ Add-CriticalFailure 'Unable to resolve Key Vault name from keyVaultResourceId.'
+}
+
+$fabricTokenCheck = Invoke-AzCliText @('account','get-access-token','--resource','https://api.fabric.microsoft.com','--query','accessToken','-o','tsv')
+if ($fabricTokenCheck.ExitCode -eq 0 -and -not [string]::IsNullOrWhiteSpace($fabricTokenCheck.Text)) {
+ Pass 'Fabric API token acquisition succeeded.'
+} else {
+ Add-CriticalFailure 'Fabric API token acquisition failed for this runner.'
+}
+
+Info 'Preflight summary:'
+Info "Critical failures: $script:CriticalFailures"
+Info "Warnings: $script:Warnings"
+
+if ($script:CriticalFailures -gt 0) {
+ exit 1
+}
+
+Pass 'This runner passed the critical mirroring preflight checks.'
+exit 0
\ No newline at end of file
diff --git a/scripts/automationScripts/OneLakeIndex/01_setup_rbac.ps1 b/scripts/automationScripts/OneLakeIndex/01_setup_rbac.ps1
index 3887ce2..1507992 100644
--- a/scripts/automationScripts/OneLakeIndex/01_setup_rbac.ps1
+++ b/scripts/automationScripts/OneLakeIndex/01_setup_rbac.ps1
@@ -8,14 +8,27 @@ param(
Set-StrictMode -Version Latest
+function Get-AzdEnvValue {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Key
+ )
+
+ try {
+ $value = & azd env get-value $Key 2>$null
+ if ($LASTEXITCODE -ne 0 -or -not $value) { return $null }
+ return $value.ToString().Trim()
+ } catch {
+ return $null
+ }
+}
+
# Skip when Fabric is disabled for this environment
$fabricWorkspaceMode = $env:fabricWorkspaceMode
if (-not $fabricWorkspaceMode) { $fabricWorkspaceMode = $env:fabricWorkspaceModeOut }
if (-not $fabricWorkspaceMode) {
- try {
- $azdMode = & azd env get-value fabricWorkspaceModeOut 2>$null
- if ($azdMode) { $fabricWorkspaceMode = $azdMode.ToString().Trim() }
- } catch { }
+ $azdMode = Get-AzdEnvValue -Key 'fabricWorkspaceModeOut'
+ if ($azdMode) { $fabricWorkspaceMode = $azdMode }
}
if (-not $fabricWorkspaceMode -and $env:AZURE_OUTPUTS_JSON) {
try {
@@ -44,6 +57,19 @@ Log "=================================================================="
try {
Log "Checking for AI Search deployment outputs..."
+ $aiSearchName = ''
+ $aiSearchResourceGroup = ''
+ $aiSearchSubscriptionId = ''
+ $aiFoundryName = ''
+ $aiFoundryResourceGroup = ''
+ $fabricWorkspaceName = ''
+ $aiSearchResourceId = ''
+
+ $outputs = $null
+ if ($env:AZURE_OUTPUTS_JSON) {
+ try { $outputs = $env:AZURE_OUTPUTS_JSON | ConvertFrom-Json -ErrorAction Stop } catch { $outputs = $null }
+ }
+
# Get azd environment values
$azdEnvValues = azd env get-values 2>$null
if (-not $azdEnvValues) {
@@ -61,13 +87,30 @@ try {
}
# Extract required values
- $aiSearchName = $env_vars['aiSearchName']
+ if (-not $aiSearchName -and $outputs -and $outputs.aiSearchName -and $outputs.aiSearchName.value) { $aiSearchName = $outputs.aiSearchName.value }
+ if (-not $aiSearchName) { $aiSearchName = $env_vars['aiSearchName'] }
if (-not $aiSearchName) { $aiSearchName = $env_vars['AZURE_AI_SEARCH_NAME'] }
- $aiSearchResourceGroup = $env_vars['aiSearchResourceGroup']
- $aiSearchSubscriptionId = $env_vars['aiSearchSubscriptionId']
- $aiFoundryName = $env_vars['aiFoundryName']
- $fabricWorkspaceName = $env_vars['desiredFabricWorkspaceName']
- $aiSearchResourceId = $env_vars['aiSearchResourceId']
+ if (-not $aiSearchResourceGroup -and $outputs -and $outputs.aiSearchResourceGroup -and $outputs.aiSearchResourceGroup.value) { $aiSearchResourceGroup = $outputs.aiSearchResourceGroup.value }
+ if (-not $aiSearchResourceGroup) { $aiSearchResourceGroup = $env_vars['aiSearchResourceGroup'] }
+ if (-not $aiSearchSubscriptionId -and $outputs -and $outputs.aiSearchSubscriptionId -and $outputs.aiSearchSubscriptionId.value) { $aiSearchSubscriptionId = $outputs.aiSearchSubscriptionId.value }
+ if (-not $aiSearchSubscriptionId) { $aiSearchSubscriptionId = $env_vars['aiSearchSubscriptionId'] }
+ if (-not $aiFoundryName -and $outputs -and $outputs.aiFoundryName -and $outputs.aiFoundryName.value) { $aiFoundryName = $outputs.aiFoundryName.value }
+ if (-not $aiFoundryName) { $aiFoundryName = $env_vars['aiFoundryName'] }
+ if (-not $fabricWorkspaceName -and $outputs -and $outputs.desiredFabricWorkspaceName -and $outputs.desiredFabricWorkspaceName.value) { $fabricWorkspaceName = $outputs.desiredFabricWorkspaceName.value }
+ if (-not $fabricWorkspaceName) { $fabricWorkspaceName = $env_vars['desiredFabricWorkspaceName'] }
+ if (-not $fabricWorkspaceName) { $fabricWorkspaceName = $env_vars['FABRIC_WORKSPACE_NAME'] }
+ if (-not $fabricWorkspaceName) { $fabricWorkspaceName = $env:FABRIC_WORKSPACE_NAME }
+ if (-not $fabricWorkspaceName) { $fabricWorkspaceName = Get-AzdEnvValue -Key 'FABRIC_WORKSPACE_NAME' }
+ if (-not $fabricWorkspaceName) { $fabricWorkspaceName = Get-AzdEnvValue -Key 'fabricWorkspaceNameOut' }
+ if (-not $fabricWorkspaceName) { $fabricWorkspaceName = Get-AzdEnvValue -Key 'desiredFabricWorkspaceName' }
+ if (-not $fabricWorkspaceName -and (Test-Path (Join-Path ([IO.Path]::GetTempPath()) 'fabric_workspace.env'))) {
+ Get-Content (Join-Path ([IO.Path]::GetTempPath()) 'fabric_workspace.env') | ForEach-Object {
+ if ($_ -match '^FABRIC_WORKSPACE_NAME=(.+)$' -and -not $fabricWorkspaceName) { $fabricWorkspaceName = $Matches[1].Trim() }
+ }
+ }
+ if (-not $fabricWorkspaceName -and $env:AZURE_ENV_NAME) { $fabricWorkspaceName = "workspace-$($env:AZURE_ENV_NAME.Trim())" }
+ if (-not $aiSearchResourceId -and $outputs -and $outputs.aiSearchResourceId -and $outputs.aiSearchResourceId.value) { $aiSearchResourceId = $outputs.aiSearchResourceId.value }
+ if (-not $aiSearchResourceId) { $aiSearchResourceId = $env_vars['aiSearchResourceId'] }
if (-not $aiSearchResourceGroup -and $aiSearchResourceId -and $aiSearchResourceId -match '/resourceGroups/([^/]+)/') {
$aiSearchResourceGroup = $matches[1]
diff --git a/scripts/automationScripts/OneLakeIndex/02_create_onelake_skillsets.ps1 b/scripts/automationScripts/OneLakeIndex/02_create_onelake_skillsets.ps1
index bb9a43b..3b683e1 100644
--- a/scripts/automationScripts/OneLakeIndex/02_create_onelake_skillsets.ps1
+++ b/scripts/automationScripts/OneLakeIndex/02_create_onelake_skillsets.ps1
@@ -28,12 +28,20 @@ if ($fabricWorkspaceMode -and $fabricWorkspaceMode.ToString().Trim().ToLowerInva
exit 0
}
+$outputs = $null
+if ($env:AZURE_OUTPUTS_JSON) {
+ try { $outputs = $env:AZURE_OUTPUTS_JSON | ConvertFrom-Json -ErrorAction Stop } catch { $outputs = $null }
+}
+
# Resolve parameters from environment
+if (-not $aiSearchName -and $outputs -and $outputs.aiSearchName -and $outputs.aiSearchName.value) { $aiSearchName = $outputs.aiSearchName.value }
if (-not $aiSearchName) { $aiSearchName = $env:aiSearchName }
if (-not $aiSearchName) { $aiSearchName = $env:AZURE_AI_SEARCH_NAME }
+if (-not $resourceGroup -and $outputs -and $outputs.aiSearchResourceGroup -and $outputs.aiSearchResourceGroup.value) { $resourceGroup = $outputs.aiSearchResourceGroup.value }
if (-not $resourceGroup) { $resourceGroup = $env:aiSearchResourceGroup }
if (-not $resourceGroup) { $resourceGroup = $env:AZURE_RESOURCE_GROUP_NAME }
if (-not $resourceGroup) { $resourceGroup = $env:AZURE_RESOURCE_GROUP }
+if (-not $subscription -and $outputs -and $outputs.aiSearchSubscriptionId -and $outputs.aiSearchSubscriptionId.value) { $subscription = $outputs.aiSearchSubscriptionId.value }
if (-not $subscription) { $subscription = $env:aiSearchSubscriptionId }
if (-not $subscription) { $subscription = $env:AZURE_SUBSCRIPTION_ID }
@@ -45,25 +53,12 @@ if (-not $aiSearchName -or -not $resourceGroup -or -not $subscription) {
exit 1
}
-# Acquire Entra ID access token for Azure AI Search data plane
-try {
- $accessToken = az account get-access-token --resource https://search.azure.com --subscription $subscription --query accessToken -o tsv
-} catch {
- $accessToken = $null
-}
-
-if (-not $accessToken) {
- Write-Error "Failed to acquire Azure AI Search access token via Microsoft Entra ID"
- exit 1
-}
-
-$headers = @{
- 'Authorization' = "Bearer $accessToken"
- 'Content-Type' = 'application/json'
-}
+. "$PSScriptRoot/SearchHelpers.ps1"
-# Use preview API version required for OneLake
-$apiVersion = '2024-05-01-preview'
+$originalPublicAccess = Ensure-SearchPublicAccess
+try {
+ # Use preview API version required for OneLake
+ $apiVersion = '2024-05-01-preview'
# Create text-only skillset for OneLake documents
Write-Host "Creating onelake-textonly-skillset..."
@@ -101,7 +96,7 @@ $skillsetBody = @{
# Delete existing skillset if present
try {
$deleteUrl = "https://$aiSearchName.search.windows.net/skillsets/onelake-textonly-skillset?api-version=$apiVersion"
- Invoke-RestMethod -Uri $deleteUrl -Headers $headers -Method DELETE
+ Invoke-SearchRequest -Method 'DELETE' -Uri $deleteUrl
Write-Host "Deleted existing skillset"
} catch {
Write-Host "No existing skillset to delete"
@@ -111,12 +106,15 @@ try {
$createUrl = "https://$aiSearchName.search.windows.net/skillsets?api-version=$apiVersion"
try {
- $response = Invoke-RestMethod -Uri $createUrl -Headers $headers -Method POST -Body $skillsetBody
+ $response = Invoke-SearchRequest -Method 'POST' -Uri $createUrl -Body $skillsetBody
Write-Host "✅ Successfully created skillset: $($response.name)"
} catch {
Write-Error "Failed to create skillset: $($_.Exception.Message)"
exit 1
}
-Write-Host ""
-Write-Host "OneLake skillsets created successfully!"
+ Write-Host ""
+ Write-Host "OneLake skillsets created successfully!"
+} finally {
+ Restore-SearchPublicAccess -OriginalAccess $originalPublicAccess
+}
diff --git a/scripts/automationScripts/OneLakeIndex/03_create_onelake_index.ps1 b/scripts/automationScripts/OneLakeIndex/03_create_onelake_index.ps1
index ee5038d..3a7ee43 100644
--- a/scripts/automationScripts/OneLakeIndex/03_create_onelake_index.ps1
+++ b/scripts/automationScripts/OneLakeIndex/03_create_onelake_index.ps1
@@ -31,6 +31,11 @@ if ($fabricWorkspaceMode -and $fabricWorkspaceMode.ToString().Trim().ToLowerInva
exit 0
}
+$outputs = $null
+if ($env:AZURE_OUTPUTS_JSON) {
+ try { $outputs = $env:AZURE_OUTPUTS_JSON | ConvertFrom-Json -ErrorAction Stop } catch { $outputs = $null }
+}
+
# Import security module
. "$PSScriptRoot/../SecurityModule.ps1"
@@ -66,11 +71,14 @@ if ($indexName -eq 'onelake-documents-index') {
}
# Resolve parameters from environment
+if (-not $aiSearchName -and $outputs -and $outputs.aiSearchName -and $outputs.aiSearchName.value) { $aiSearchName = $outputs.aiSearchName.value }
if (-not $aiSearchName) { $aiSearchName = $env:aiSearchName }
if (-not $aiSearchName) { $aiSearchName = $env:AZURE_AI_SEARCH_NAME }
+if (-not $resourceGroup -and $outputs -and $outputs.aiSearchResourceGroup -and $outputs.aiSearchResourceGroup.value) { $resourceGroup = $outputs.aiSearchResourceGroup.value }
if (-not $resourceGroup) { $resourceGroup = $env:aiSearchResourceGroup }
if (-not $resourceGroup) { $resourceGroup = $env:AZURE_RESOURCE_GROUP_NAME }
if (-not $resourceGroup) { $resourceGroup = $env:AZURE_RESOURCE_GROUP }
+if (-not $subscription -and $outputs -and $outputs.aiSearchSubscriptionId -and $outputs.aiSearchSubscriptionId.value) { $subscription = $outputs.aiSearchSubscriptionId.value }
if (-not $subscription) { $subscription = $env:aiSearchSubscriptionId }
if (-not $subscription) { $subscription = $env:AZURE_SUBSCRIPTION_ID }
@@ -82,30 +90,17 @@ if (-not $aiSearchName -or -not $resourceGroup -or -not $subscription) {
exit 1
}
+. "$PSScriptRoot/SearchHelpers.ps1"
+
Write-Host "Index Name: $indexName"
if ($workspaceName) { Write-Host "Derived Fabric Workspace Name: $workspaceName" }
if ($domainName) { Write-Host "Derived Fabric Domain Name: $domainName" }
Write-Host ""
-# Acquire Entra ID access token for Azure AI Search data plane
+$originalPublicAccess = Ensure-SearchPublicAccess
try {
- $accessToken = az account get-access-token --resource https://search.azure.com --subscription $subscription --query accessToken -o tsv
-} catch {
- $accessToken = $null
-}
-
-if (-not $accessToken) {
- Write-Error "Failed to acquire Azure AI Search access token via Microsoft Entra ID"
- exit 1
-}
-
-$headers = @{
- 'Authorization' = "Bearer $accessToken"
- 'Content-Type' = 'application/json'
-}
-
-# Use preview API version required for OneLake
-$apiVersion = '2024-05-01-preview'
+ # Use preview API version required for OneLake
+ $apiVersion = '2024-05-01-preview'
# Create index with exact schema from working test
Write-Host "Creating OneLake index: $indexName"
@@ -214,10 +209,10 @@ $indexBody = @{
# First, check if index exists and delete it if it does
$existingIndexUri = "https://$aiSearchName.search.windows.net/indexes/$indexName" + "?api-version=$apiVersion"
try {
- $existingIndex = Invoke-SecureRestMethod -Uri $existingIndexUri -Headers $headers -Method GET -ErrorAction SilentlyContinue
+ $existingIndex = Invoke-SearchRequest -Method 'GET' -Uri $existingIndexUri
if ($existingIndex) {
Write-Host "Deleting existing index to recreate with correct schema..."
- Invoke-SecureRestMethod -Uri $existingIndexUri -Headers $headers -Method DELETE
+ Invoke-SearchRequest -Method 'DELETE' -Uri $existingIndexUri
Write-Host "Existing index deleted."
}
} catch {
@@ -228,7 +223,7 @@ try {
# Create the index
$createIndexUri = "https://$aiSearchName.search.windows.net/indexes" + "?api-version=$apiVersion"
try {
- $response = Invoke-SecureRestMethod -Uri $createIndexUri -Headers $headers -Body $indexBody -Method POST
+ $response = Invoke-SearchRequest -Method 'POST' -Uri $createIndexUri -Body $indexBody
Write-Host ""
Write-Host "OneLake index created successfully!"
Write-Host "Index Name: $($response.name)"
@@ -244,3 +239,6 @@ try {
Write-Host ""
Write-Host "✅ OneLake index setup complete!"
+} finally {
+ Restore-SearchPublicAccess -OriginalAccess $originalPublicAccess
+}
diff --git a/scripts/automationScripts/OneLakeIndex/04_create_onelake_datasource.ps1 b/scripts/automationScripts/OneLakeIndex/04_create_onelake_datasource.ps1
index eec7160..b3cfdd3 100644
--- a/scripts/automationScripts/OneLakeIndex/04_create_onelake_datasource.ps1
+++ b/scripts/automationScripts/OneLakeIndex/04_create_onelake_datasource.ps1
@@ -36,6 +36,11 @@ if ($fabricWorkspaceMode -and $fabricWorkspaceMode.ToString().Trim().ToLowerInva
exit 0
}
+$outputs = $null
+if ($env:AZURE_OUTPUTS_JSON) {
+ try { $outputs = $env:AZURE_OUTPUTS_JSON | ConvertFrom-Json -ErrorAction Stop } catch { $outputs = $null }
+}
+
# Import security module
. "$PSScriptRoot/../SecurityModule.ps1"
@@ -66,11 +71,14 @@ if ($dataSourceName -eq 'onelake-reports-datasource' -and $workspaceName) {
}
# Resolve parameters from environment
+if (-not $aiSearchName -and $outputs -and $outputs.aiSearchName -and $outputs.aiSearchName.value) { $aiSearchName = $outputs.aiSearchName.value }
if (-not $aiSearchName) { $aiSearchName = $env:aiSearchName }
if (-not $aiSearchName) { $aiSearchName = $env:AZURE_AI_SEARCH_NAME }
+if (-not $resourceGroup -and $outputs -and $outputs.aiSearchResourceGroup -and $outputs.aiSearchResourceGroup.value) { $resourceGroup = $outputs.aiSearchResourceGroup.value }
if (-not $resourceGroup) { $resourceGroup = $env:aiSearchResourceGroup }
if (-not $resourceGroup) { $resourceGroup = $env:AZURE_RESOURCE_GROUP_NAME }
if (-not $resourceGroup) { $resourceGroup = $env:AZURE_RESOURCE_GROUP }
+if (-not $subscription -and $outputs -and $outputs.aiSearchSubscriptionId -and $outputs.aiSearchSubscriptionId.value) { $subscription = $outputs.aiSearchSubscriptionId.value }
if (-not $subscription) { $subscription = $env:aiSearchSubscriptionId }
if (-not $subscription) { $subscription = $env:AZURE_SUBSCRIPTION_ID }
@@ -109,30 +117,17 @@ if (-not $workspaceId -or -not $lakehouseId) {
exit 1
}
+. "$PSScriptRoot/SearchHelpers.ps1"
+
Write-Host "Workspace ID: $workspaceId"
Write-Host "Lakehouse ID: $lakehouseId"
Write-Host "Query Path: $queryPath"
Write-Host ""
-# Acquire Entra ID access token for Azure AI Search data plane
+$originalPublicAccess = Ensure-SearchPublicAccess
try {
- $accessToken = az account get-access-token --resource https://search.azure.com --subscription $subscription --query accessToken -o tsv
-} catch {
- $accessToken = $null
-}
-
-if (-not $accessToken) {
- Write-Error "Failed to acquire Azure AI Search access token via Microsoft Entra ID"
- exit 1
-}
-
-$headers = @{
- 'Authorization' = "Bearer $accessToken"
- 'Content-Type' = 'application/json'
-}
-
-# Use preview API version required for OneLake
-$apiVersion = '2024-05-01-preview'
+ # Use preview API version required for OneLake
+ $apiVersion = '2024-05-01-preview'
# Create OneLake data source with System-Assigned Managed Identity
Write-Host "Creating OneLake data source: $dataSourceName"
@@ -180,13 +175,13 @@ $dataSourceBody = @{
# First, check if datasource exists and delete it if it does
$existingDataSourceUri = "https://$aiSearchName.search.windows.net/datasources/$dataSourceName" + "?api-version=$apiVersion"
try {
- $existingDataSource = Invoke-SecureRestMethod -Uri $existingDataSourceUri -Headers $headers -Method GET -ErrorAction SilentlyContinue
+ $existingDataSource = Invoke-SearchRequest -Method 'GET' -Uri $existingDataSourceUri
if ($existingDataSource) {
Write-Host "Found existing datasource. Checking for dependent indexers..."
# Get all indexers to see if any reference this datasource
$indexersUri = "https://$aiSearchName.search.windows.net/indexers?api-version=$apiVersion"
- $indexers = Invoke-SecureRestMethod -Uri $indexersUri -Headers $headers -Method GET
+ $indexers = Invoke-SearchRequest -Method 'GET' -Uri $indexersUri
$dependentIndexers = $indexers.value | Where-Object { $_.dataSourceName -eq $dataSourceName }
@@ -195,7 +190,7 @@ try {
foreach ($indexer in $dependentIndexers) {
$deleteIndexerUri = "https://$aiSearchName.search.windows.net/indexers/$($indexer.name)?api-version=$apiVersion"
try {
- Invoke-SecureRestMethod -Uri $deleteIndexerUri -Headers $headers -Method DELETE
+ Invoke-SearchRequest -Method 'DELETE' -Uri $deleteIndexerUri
Write-Host "Deleted indexer: $($indexer.name)"
} catch {
Write-Host "Warning: Could not delete indexer $($indexer.name): $($_.Exception.Message)"
@@ -204,7 +199,7 @@ try {
}
Write-Host "Deleting existing datasource to recreate with current values..."
- Invoke-SecureRestMethod -Uri $existingDataSourceUri -Headers $headers -Method DELETE
+ Invoke-SearchRequest -Method 'DELETE' -Uri $existingDataSourceUri
Write-Host "Existing datasource deleted."
}
} catch {
@@ -215,7 +210,7 @@ try {
# Create the datasource
$createDataSourceUri = "https://$aiSearchName.search.windows.net/datasources" + "?api-version=$apiVersion"
try {
- $response = Invoke-SecureRestMethod -Uri $createDataSourceUri -Headers $headers -Body $dataSourceBody -Method POST
+ $response = Invoke-SearchRequest -Method 'POST' -Uri $createDataSourceUri -Body $dataSourceBody
Write-Host ""
Write-Host "OneLake data source created successfully!"
Write-Host "Datasource Name: $($response.name)"
@@ -256,3 +251,6 @@ Write-Host ""
Write-Host "⚠️ IMPORTANT: Ensure the AI Search System-Assigned Managed Identity has:"
Write-Host " 1. OneLake data access role in the Fabric workspace"
Write-Host " 2. Storage Blob Data Reader role in Azure"
+} finally {
+ Restore-SearchPublicAccess -OriginalAccess $originalPublicAccess
+}
diff --git a/scripts/automationScripts/OneLakeIndex/05_create_onelake_indexer.ps1 b/scripts/automationScripts/OneLakeIndex/05_create_onelake_indexer.ps1
index 8331c39..8cc7881 100644
--- a/scripts/automationScripts/OneLakeIndex/05_create_onelake_indexer.ps1
+++ b/scripts/automationScripts/OneLakeIndex/05_create_onelake_indexer.ps1
@@ -35,6 +35,11 @@ if ($fabricWorkspaceMode -and $fabricWorkspaceMode.ToString().Trim().ToLowerInva
exit 0
}
+$outputs = $null
+if ($env:AZURE_OUTPUTS_JSON) {
+ try { $outputs = $env:AZURE_OUTPUTS_JSON | ConvertFrom-Json -ErrorAction Stop } catch { $outputs = $null }
+}
+
function Get-SafeName([string]$name) {
if (-not $name) { return $null }
$safe = $name.ToLower() -replace "[^a-z0-9-]", "-" -replace "-+", "-"
@@ -76,11 +81,14 @@ if ($indexerName -eq 'onelake-reports-indexer') {
}
# Resolve parameters from environment
+ if (-not $aiSearchName -and $outputs -and $outputs.aiSearchName -and $outputs.aiSearchName.value) { $aiSearchName = $outputs.aiSearchName.value }
if (-not $aiSearchName) { $aiSearchName = $env:aiSearchName }
if (-not $aiSearchName) { $aiSearchName = $env:AZURE_AI_SEARCH_NAME }
+ if (-not $resourceGroup -and $outputs -and $outputs.aiSearchResourceGroup -and $outputs.aiSearchResourceGroup.value) { $resourceGroup = $outputs.aiSearchResourceGroup.value }
if (-not $resourceGroup) { $resourceGroup = $env:aiSearchResourceGroup }
if (-not $resourceGroup) { $resourceGroup = $env:AZURE_RESOURCE_GROUP_NAME }
if (-not $resourceGroup) { $resourceGroup = $env:AZURE_RESOURCE_GROUP }
+ if (-not $subscription -and $outputs -and $outputs.aiSearchSubscriptionId -and $outputs.aiSearchSubscriptionId.value) { $subscription = $outputs.aiSearchSubscriptionId.value }
if (-not $subscription) { $subscription = $env:aiSearchSubscriptionId }
if (-not $subscription) { $subscription = $env:AZURE_SUBSCRIPTION_ID }
@@ -92,6 +100,8 @@ if (-not $aiSearchName -or -not $resourceGroup -or -not $subscription) {
exit 1
}
+. "$PSScriptRoot/SearchHelpers.ps1"
+
Write-Host "Index Name: $indexName"
Write-Host "Data Source: $dataSourceName"
Write-Host "Skillset: $skillsetName"
@@ -100,25 +110,10 @@ if ($workspaceName) { Write-Host "Derived Fabric Workspace Name: $workspaceName"
if ($folderPath) { Write-Host "Folder Path: $folderPath" }
Write-Host ""
-# Acquire Entra ID access token for Azure AI Search data plane
+$originalPublicAccess = Ensure-SearchPublicAccess
try {
- $accessToken = az account get-access-token --resource https://search.azure.com --subscription $subscription --query accessToken -o tsv
-} catch {
- $accessToken = $null
-}
-
-if (-not $accessToken) {
- Write-Error "Failed to acquire Azure AI Search access token via Microsoft Entra ID"
- exit 1
-}
-
-$headers = @{
- 'Authorization' = "Bearer $accessToken"
- 'Content-Type' = 'application/json'
-}
-
-# Use preview API version required for OneLake
-$apiVersion = '2024-05-01-preview'
+ # Use preview API version required for OneLake
+ $apiVersion = '2024-05-01-preview'
# Create OneLake indexer
Write-Host "Creating OneLake indexer: $indexerName"
@@ -179,7 +174,7 @@ $indexerBody = @{
# Delete existing indexer if present
try {
$deleteUrl = "https://$aiSearchName.search.windows.net/indexers/$indexerName?api-version=$apiVersion"
- Invoke-RestMethod -Uri $deleteUrl -Headers $headers -Method DELETE
+ Invoke-SearchRequest -Method 'DELETE' -Uri $deleteUrl
Write-Host "Deleted existing indexer"
} catch {
Write-Host "No existing indexer to delete"
@@ -189,15 +184,27 @@ try {
$createUrl = "https://$aiSearchName.search.windows.net/indexers?api-version=$apiVersion"
try {
- $response = Invoke-RestMethod -Uri $createUrl -Headers $headers -Method POST -Body $indexerBody
+ $response = Invoke-SearchRequest -Method 'POST' -Uri $createUrl -Body $indexerBody
Write-Host "✅ Successfully created OneLake indexer: $($response.name)"
# Run the indexer immediately
Write-Host ""
Write-Host "Running indexer..."
$runUrl = "https://$aiSearchName.search.windows.net/indexers/$indexerName/run?api-version=$apiVersion"
- Invoke-RestMethod -Uri $runUrl -Headers $headers -Method POST
- Write-Host "✅ Indexer execution started"
+ try {
+ Invoke-SearchRequest -Method 'POST' -Uri $runUrl
+ Write-Host "✅ Indexer execution started"
+ } catch {
+ $runStatusCode = $null
+ $runErrorBody = $null
+ try { $runStatusCode = $_.Exception.Response.StatusCode.value__ } catch { }
+ try { $runErrorBody = $_.ErrorDetails.Message } catch { }
+ if ($runStatusCode -eq 409 -and $runErrorBody -match 'invocation.*in progress') {
+ Write-Warning "Indexer is already running; continuing without starting a new run."
+ } else {
+ throw
+ }
+ }
# Wait a moment and check status
Write-Host ""
@@ -205,7 +212,7 @@ try {
Start-Sleep -Seconds 30
$statusUrl = "https://$aiSearchName.search.windows.net/indexers/$indexerName/status?api-version=$apiVersion"
- $status = Invoke-RestMethod -Uri $statusUrl -Headers $headers -Method GET
+ $status = Invoke-SearchRequest -Method 'GET' -Uri $statusUrl
Write-Host ""
Write-Host "🎯 INDEXER EXECUTION RESULTS:"
@@ -232,7 +239,7 @@ try {
# Check the search index for documents
$searchUrl = "https://$aiSearchName.search.windows.net/indexes/$indexName/docs?api-version=$apiVersion&search=*&`$count=true&`$top=3"
try {
- $searchResults = Invoke-RestMethod -Uri $searchUrl -Headers $headers -Method GET
+ $searchResults = Invoke-SearchRequest -Method 'GET' -Uri $searchUrl
Write-Host "Total documents in search index: $($searchResults.'@odata.count')"
if ($searchResults.value.Count -gt 0) {
@@ -247,10 +254,10 @@ try {
}
} else {
Write-Host ""
- Write-Host "⚠️ No documents were processed. This may indicate:"
- Write-Host " 1. Permission issues with AI Search accessing OneLake"
- Write-Host " 2. No documents found in the specified path"
- Write-Host " 3. Authentication problems with the managed identity"
+ Write-Host "ℹ️ No documents were processed. This is expected if the lakehouse is empty."
+ Write-Host " If you expected documents, check:"
+ Write-Host " 1. Documents exist in the configured path"
+ Write-Host " 2. AI Search has access to OneLake"
}
} catch {
@@ -264,18 +271,24 @@ try {
Write-Host "HTTP Reason: $($_.Exception.Response.ReasonPhrase)"
}
- # Try using curl to get a better error message
- Write-Host ""
- Write-Host "Attempting to get detailed error using curl..."
- $curlResult = & curl -s -w "%{http_code}" -X POST $createUrl -H "api-key: $apiKey" -H "Content-Type: application/json" -d $indexerBody
- Write-Host "Curl result: $curlResult"
+ # Try using curl with bearer token to get a better error message when possible
+ try {
+ $accessToken = Get-SearchAccessToken
+ } catch { $accessToken = $null }
+ if ($accessToken) {
+ Write-Host ""
+ Write-Host "Attempting to get detailed error using curl..."
+ $curlResult = & curl -s -D - -X POST "$createUrl" -H "Authorization: Bearer $accessToken" -H "Content-Type: application/json" -d $indexerBody
+ Write-Host "Curl result:"
+ Write-Host $curlResult
+ }
# Check if prerequisite resources exist
Write-Host ""
Write-Host "Checking prerequisite resources..."
try {
$indexUrl = "https://$aiSearchName.search.windows.net/indexes/$indexName?api-version=$apiVersion"
- $indexExists = Invoke-RestMethod -Uri $indexUrl -Headers $headers -Method GET -ErrorAction SilentlyContinue
+ $indexExists = Invoke-SearchRequest -Method 'GET' -Uri $indexUrl
Write-Host "✅ Index '$indexName' exists"
} catch {
Write-Host "❌ Index '$indexName' does not exist or is inaccessible"
@@ -283,7 +296,7 @@ try {
try {
$datasourceUrl = "https://$aiSearchName.search.windows.net/datasources/$dataSourceName?api-version=$apiVersion"
- $datasourceExists = Invoke-RestMethod -Uri $datasourceUrl -Headers $headers -Method GET -ErrorAction SilentlyContinue
+ $datasourceExists = Invoke-SearchRequest -Method 'GET' -Uri $datasourceUrl
Write-Host "✅ Datasource '$dataSourceName' exists"
} catch {
Write-Host "❌ Datasource '$dataSourceName' does not exist or is inaccessible"
@@ -292,5 +305,8 @@ try {
exit 1
}
-Write-Host ""
-Write-Host "OneLake indexer setup completed!"
+ Write-Host ""
+ Write-Host "OneLake indexer setup completed!"
+} finally {
+ Restore-SearchPublicAccess -OriginalAccess $originalPublicAccess
+}
diff --git a/scripts/automationScripts/OneLakeIndex/06_setup_ai_foundry_search_rbac.ps1 b/scripts/automationScripts/OneLakeIndex/06_setup_ai_foundry_search_rbac.ps1
index e31614d..53eabfa 100644
--- a/scripts/automationScripts/OneLakeIndex/06_setup_ai_foundry_search_rbac.ps1
+++ b/scripts/automationScripts/OneLakeIndex/06_setup_ai_foundry_search_rbac.ps1
@@ -40,6 +40,8 @@ if ($fabricWorkspaceMode -and $fabricWorkspaceMode.ToString().Trim().ToLowerInva
Set-StrictMode -Version Latest
+$script:aiFoundryProjectName = $null
+
# Import security module
$skipRoleAssignment = $false
if ($env:SKIP_FOUNDATION_RBAC -and $env:SKIP_FOUNDATION_RBAC.ToLowerInvariant() -eq 'true') {
@@ -87,6 +89,19 @@ Log "=================================================================="
Log "Setting up AI Foundry to AI Search RBAC integration"
Log "=================================================================="
+$outputs = $null
+if ($env:AZURE_OUTPUTS_JSON) {
+ try { $outputs = $env:AZURE_OUTPUTS_JSON | ConvertFrom-Json -ErrorAction Stop } catch { $outputs = $null }
+}
+
+if (-not $AISearchName -and $outputs -and $outputs.aiSearchName -and $outputs.aiSearchName.value) { $AISearchName = $outputs.aiSearchName.value }
+if (-not $AISearchResourceGroup -and $outputs -and $outputs.aiSearchResourceGroup -and $outputs.aiSearchResourceGroup.value) { $AISearchResourceGroup = $outputs.aiSearchResourceGroup.value }
+if (-not $AISearchSubscriptionId -and $outputs -and $outputs.aiSearchSubscriptionId -and $outputs.aiSearchSubscriptionId.value) { $AISearchSubscriptionId = $outputs.aiSearchSubscriptionId.value }
+if (-not $AIFoundryName -and $outputs -and $outputs.aiFoundryName -and $outputs.aiFoundryName.value) { $AIFoundryName = $outputs.aiFoundryName.value }
+if (-not $AIFoundryResourceGroup -and $outputs -and $outputs.aiFoundryResourceGroup -and $outputs.aiFoundryResourceGroup.value) { $AIFoundryResourceGroup = $outputs.aiFoundryResourceGroup.value }
+if (-not $AIFoundrySubscriptionId -and $outputs -and $outputs.aiFoundrySubscriptionId -and $outputs.aiFoundrySubscriptionId.value) { $AIFoundrySubscriptionId = $outputs.aiFoundrySubscriptionId.value }
+if (-not $script:aiFoundryProjectName -and $outputs -and $outputs.aiFoundryProjectName -and $outputs.aiFoundryProjectName.value) { $script:aiFoundryProjectName = $outputs.aiFoundryProjectName.value }
+
# Get values from azd environment if not provided
if (-not $AISearchName -or -not $AIFoundryName) {
Log "Getting configuration from azd environment..."
@@ -147,9 +162,70 @@ if (-not $AISearchName -or -not $AIFoundryName) {
exit 1
}
+function Test-AiFoundryProjectExists {
+ param([string]$ProjectName)
+ if (-not $ProjectName) { return $false }
+
+ # Accept either "project" or "account/project" formats.
+ if ($ProjectName -match '/') {
+ $ProjectName = ($ProjectName -split '/', 2)[1]
+ }
+
+ try {
+ $projectResourceId = "/subscriptions/$AIFoundrySubscriptionId/resourceGroups/$AIFoundryResourceGroup/providers/Microsoft.CognitiveServices/accounts/$AIFoundryName/projects/$ProjectName"
+ $null = az resource show --ids $projectResourceId --query id -o tsv 2>$null
+ return ($LASTEXITCODE -eq 0)
+ } catch {
+ return $false
+ }
+}
+
+if ($script:aiFoundryProjectName) {
+ if ($script:aiFoundryProjectName -eq $AIFoundryName) {
+ Warn "AI Foundry project name matches account name; clearing and attempting discovery."
+ $script:aiFoundryProjectName = $null
+ } elseif (-not (Test-AiFoundryProjectExists -ProjectName $script:aiFoundryProjectName)) {
+ Warn "AI Foundry project '$script:aiFoundryProjectName' not found; clearing and attempting discovery."
+ $script:aiFoundryProjectName = $null
+ }
+}
+
+if (-not $script:aiFoundryProjectName) {
+ try {
+ $projectCandidatesRaw = az resource list `
+ --resource-group $AIFoundryResourceGroup `
+ --subscription $AIFoundrySubscriptionId `
+ --resource-type "Microsoft.CognitiveServices/accounts/projects" `
+ --query "[?contains(id, '/accounts/$AIFoundryName/')].name" -o tsv 2>$null
+
+ if ($projectCandidatesRaw) {
+ [string[]]$projectCandidates = ($projectCandidatesRaw -split "\r?\n") | Where-Object { $_ -and $_.Trim() } | ForEach-Object { $_.Trim() }
+ if ($projectCandidates.Length -eq 1) {
+ $script:aiFoundryProjectName = $projectCandidates[0]
+ Log "Discovered AI Foundry project: $script:aiFoundryProjectName"
+ } elseif ($projectCandidates.Length -gt 1) {
+ $script:aiFoundryProjectName = $projectCandidates[0]
+ Warn "Multiple AI Foundry projects detected; defaulting to '$script:aiFoundryProjectName'. Override via -AIFoundryName/-AIFoundryResourceGroup if needed."
+ Log "Candidates: $($projectCandidates -join ', ')"
+ }
+ }
+ } catch {
+ Warn "Unable to auto-discover AI Foundry project: $($_.Exception.Message)"
+ }
+}
+
$additionalPrincipalIds = @()
try {
- if ($env_vars -and $env_vars.ContainsKey('aiSearchAdditionalAccessObjectIds')) {
+ if ($env:AZURE_OUTPUTS_JSON) {
+ try {
+ $out0 = $env:AZURE_OUTPUTS_JSON | ConvertFrom-Json -ErrorAction Stop
+ if ($out0.aiSearchAdditionalAccessObjectIds -and $out0.aiSearchAdditionalAccessObjectIds.value) {
+ $jsonValue = $out0.aiSearchAdditionalAccessObjectIds.value | ConvertTo-Json -Compress
+ $additionalPrincipalIds = ConvertTo-PrincipalIdArray -RawValue $jsonValue
+ }
+ } catch { }
+ }
+ if ($additionalPrincipalIds.Count -eq 0 -and $env_vars -and $env_vars.ContainsKey('aiSearchAdditionalAccessObjectIds')) {
$additionalPrincipalIds = ConvertTo-PrincipalIdArray -RawValue $env_vars['aiSearchAdditionalAccessObjectIds']
}
if ($additionalPrincipalIds.Count -eq 0) {
diff --git a/scripts/automationScripts/OneLakeIndex/SearchHelpers.ps1 b/scripts/automationScripts/OneLakeIndex/SearchHelpers.ps1
new file mode 100644
index 0000000..7acbf4d
--- /dev/null
+++ b/scripts/automationScripts/OneLakeIndex/SearchHelpers.ps1
@@ -0,0 +1,209 @@
+function Get-SearchPublicNetworkAccess {
+ try {
+ return az search service show --name $aiSearchName --resource-group $resourceGroup --subscription $subscription --query "publicNetworkAccess" -o tsv
+ } catch {
+ return $null
+ }
+}
+
+function Get-SearchResourceId {
+ try {
+ return az search service show --name $aiSearchName --resource-group $resourceGroup --subscription $subscription --query "id" -o tsv
+ } catch {
+ return $null
+ }
+}
+
+function Get-ArmAccessToken {
+ try {
+ return az account get-access-token --resource https://management.azure.com/ --subscription $subscription --query accessToken -o tsv
+ } catch {
+ return $null
+ }
+}
+
+function Invoke-AzCliWithTimeout {
+ param(
+ [string[]]$Args,
+ [int]$TimeoutSeconds = 120
+ )
+
+ $escapedArgs = $Args | ForEach-Object {
+ if ($_ -match '\s|"') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ }
+ }
+
+ $azPath = (Get-Command az -ErrorAction SilentlyContinue).Source
+ if (-not $azPath) {
+ throw "Azure CLI (az) not found on PATH."
+ }
+
+ $psi = New-Object System.Diagnostics.ProcessStartInfo
+ $psi.FileName = $azPath
+ $psi.Arguments = ($escapedArgs -join ' ')
+ $psi.RedirectStandardOutput = $true
+ $psi.RedirectStandardError = $true
+ $psi.UseShellExecute = $false
+ $psi.CreateNoWindow = $true
+
+ $process = [System.Diagnostics.Process]::Start($psi)
+ if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
+ try { $process.Kill() } catch { }
+ throw "Azure CLI command timed out after $TimeoutSeconds seconds: az $($psi.Arguments)"
+ }
+
+ $stdout = $process.StandardOutput.ReadToEnd()
+ $stderr = $process.StandardError.ReadToEnd()
+
+ if ($process.ExitCode -ne 0) {
+ throw "Azure CLI failed with exit code $($process.ExitCode): $stderr"
+ }
+
+ return $stdout
+}
+
+function Set-SearchPublicNetworkAccess {
+ param([string]$Mode)
+
+ $timeoutSeconds = 120
+ if ($env:AI_SEARCH_PUBLIC_ACCESS_TIMEOUT_SECONDS) {
+ [int]::TryParse($env:AI_SEARCH_PUBLIC_ACCESS_TIMEOUT_SECONDS, [ref]$timeoutSeconds) | Out-Null
+ }
+
+ $maxRetries = 3
+ if ($env:AI_SEARCH_PUBLIC_ACCESS_MAX_RETRIES) {
+ [int]::TryParse($env:AI_SEARCH_PUBLIC_ACCESS_MAX_RETRIES, [ref]$maxRetries) | Out-Null
+ }
+
+ $waitSeconds = 120
+ if ($env:AI_SEARCH_PUBLIC_ACCESS_POLL_SECONDS) {
+ [int]::TryParse($env:AI_SEARCH_PUBLIC_ACCESS_POLL_SECONDS, [ref]$waitSeconds) | Out-Null
+ }
+
+ for ($attempt = 1; $attempt -le $maxRetries; $attempt++) {
+ try {
+ $resourceId = Get-SearchResourceId
+ if (-not $resourceId) {
+ throw "Unable to resolve AI Search resource ID."
+ }
+ $armToken = Get-ArmAccessToken
+ if (-not $armToken) {
+ throw "Unable to acquire ARM access token."
+ }
+ $body = @{ properties = @{ publicNetworkAccess = $Mode } } | ConvertTo-Json -Compress
+ $url = "https://management.azure.com${resourceId}?api-version=2023-11-01"
+ $headers = @{ Authorization = "Bearer $armToken"; 'Content-Type' = 'application/json' }
+ Invoke-RestMethod -Method Patch -Uri $url -Headers $headers -Body $body | Out-Null
+ $deadline = (Get-Date).AddSeconds($waitSeconds)
+ do {
+ $current = Get-SearchPublicNetworkAccess
+ if ($current -eq $Mode) { return }
+ Start-Sleep -Seconds 5
+ } while ((Get-Date) -lt $deadline)
+
+ throw "Timed out waiting for AI Search public network access to become '$Mode'."
+ } catch {
+ if ($attempt -lt $maxRetries) {
+ Write-Warning "Failed to set AI Search public network access (attempt $attempt of $maxRetries): $($_.Exception.Message)"
+ Start-Sleep -Seconds 10
+ continue
+ }
+ throw
+ }
+ }
+}
+
+function Ensure-SearchPublicAccess {
+ if ($env:AI_SEARCH_SKIP_PUBLIC_ACCESS_TOGGLE -and $env:AI_SEARCH_SKIP_PUBLIC_ACCESS_TOGGLE.ToLowerInvariant() -eq 'true') {
+ Write-Host "Skipping temporary public network access toggle for AI Search."
+ return $null
+ }
+
+ $current = Get-SearchPublicNetworkAccess
+ if (-not $current) { return $null }
+
+ if ($current -eq 'Disabled') {
+ Write-Warning "AI Search public network access is Disabled. Enabling temporarily for OneLake setup."
+ Set-SearchPublicNetworkAccess -Mode 'Enabled'
+ }
+
+ return $current
+}
+
+function Restore-SearchPublicAccess {
+ param([string]$OriginalAccess)
+
+ if (-not $OriginalAccess) { return }
+ if ($env:AI_SEARCH_SKIP_PUBLIC_ACCESS_TOGGLE -and $env:AI_SEARCH_SKIP_PUBLIC_ACCESS_TOGGLE.ToLowerInvariant() -eq 'true') { return }
+
+ $current = Get-SearchPublicNetworkAccess
+ if ($current -and $current -ne $OriginalAccess) {
+ Write-Host "Restoring AI Search public network access to '$OriginalAccess'."
+ try {
+ Set-SearchPublicNetworkAccess -Mode $OriginalAccess
+ } catch {
+ Write-Warning "Failed to restore AI Search public network access: $($_.Exception.Message)"
+ }
+ }
+}
+
+function Get-SearchAccessToken {
+ try {
+ return az account get-access-token --resource https://search.azure.com --subscription $subscription --query accessToken -o tsv
+ } catch {
+ return $null
+ }
+}
+
+function New-SearchHeaders {
+ param(
+ [string]$AccessToken
+ )
+
+ if ($AccessToken) {
+ return @{
+ 'Authorization' = "Bearer $AccessToken"
+ 'Content-Type' = 'application/json'
+ }
+ }
+
+ return $null
+}
+
+function Invoke-SearchRequest {
+ param(
+ [string]$Method,
+ [string]$Uri,
+ [string]$Body
+ )
+
+ $maxAttempts = 6
+ $delaySeconds = 30
+
+ for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
+ $accessToken = Get-SearchAccessToken
+ $headers = New-SearchHeaders -AccessToken $accessToken
+
+ if (-not $headers) {
+ Write-Error "Failed to acquire Azure AI Search access token via Microsoft Entra ID"
+ exit 1
+ }
+
+ try {
+ if ($Body) {
+ return Invoke-RestMethod -Uri $Uri -Headers $headers -Method $Method -Body $Body
+ }
+ return Invoke-RestMethod -Uri $Uri -Headers $headers -Method $Method
+ } catch {
+ $statusCode = $null
+ try { $statusCode = $_.Exception.Response.StatusCode.value__ } catch { }
+
+ if (($statusCode -eq 401 -or $statusCode -eq 403) -and $attempt -lt $maxAttempts) {
+ Write-Warning "Search request denied (HTTP $statusCode). Waiting ${delaySeconds}s for RBAC propagation (attempt $attempt of $maxAttempts)."
+ Start-Sleep -Seconds $delaySeconds
+ continue
+ }
+
+ throw
+ }
+ }
+}
\ No newline at end of file
diff --git a/scripts/automationScripts/OneLakeIndex/setup_ai_services_rbac.ps1 b/scripts/automationScripts/OneLakeIndex/setup_ai_services_rbac.ps1
index e5082ee..9cac831 100644
--- a/scripts/automationScripts/OneLakeIndex/setup_ai_services_rbac.ps1
+++ b/scripts/automationScripts/OneLakeIndex/setup_ai_services_rbac.ps1
@@ -28,6 +28,22 @@ function Log([string]$m) { Write-Host "[ai-services-rbac] $m" -ForegroundColor C
function Warn([string]$m) { Write-Warning "[ai-services-rbac] $m" }
function Success([string]$m) { Write-Host "[ai-services-rbac] ✅ $m" -ForegroundColor Green }
+function Test-AiFoundryProjectExists {
+ param(
+ [string]$AccountScope,
+ [string]$ProjectName
+ )
+
+ if (-not $ProjectName) { return $false }
+ try {
+ $projectResourceId = "$AccountScope/projects/$ProjectName"
+ $null = az resource show --ids $projectResourceId --query id -o tsv 2>$null
+ return ($LASTEXITCODE -eq 0)
+ } catch {
+ return $false
+ }
+}
+
function ConvertTo-PrincipalIdArray {
param([string]$RawValue)
$ids = @()
@@ -220,6 +236,14 @@ try {
$projectName = $env:aiFoundryProjectName
if (-not $projectName) { $projectName = $env:AI_FOUNDRY_PROJECT_NAME }
if (-not $projectName -and $aiFoundryAccount.defaultProject) { $projectName = $aiFoundryAccount.defaultProject }
+ if ($projectName -eq $AIFoundryName) {
+ Warn "AI Foundry project name matches account name; clearing and attempting discovery."
+ $projectName = $null
+ } elseif ($projectName -and -not (Test-AiFoundryProjectExists -AccountScope $accountScope -ProjectName $projectName)) {
+ Warn "AI Foundry project '$projectName' not found; clearing and attempting discovery."
+ $projectName = $null
+ }
+
if (-not $projectName) {
try {
$projectListArgs = @('--resource-group', $AIFoundryResourceGroup, '--resource-type', 'Microsoft.CognitiveServices/accounts/projects', '--query', "[?starts_with(name, '$AIFoundryName/')].name", '-o', 'tsv')
diff --git a/scripts/automationScripts/SecurityModule.ps1 b/scripts/automationScripts/SecurityModule.ps1
index 2d28990..e1edfe5 100644
--- a/scripts/automationScripts/SecurityModule.ps1
+++ b/scripts/automationScripts/SecurityModule.ps1
@@ -69,6 +69,66 @@ function New-SecureHeaders {
}
}
+function Read-SecureResponseBody {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ $Response
+ )
+
+ $responseStream = $null
+ $reader = $null
+
+ try {
+ $responseStream = $Response.GetResponseStream()
+ if (-not $responseStream) { return $null }
+
+ $reader = New-Object System.IO.StreamReader($responseStream)
+ return $reader.ReadToEnd()
+ }
+ catch {
+ return $null
+ }
+ finally {
+ if ($reader -ne $null) {
+ $reader.Dispose()
+ }
+ elseif ($responseStream -ne $null) {
+ $responseStream.Dispose()
+ }
+ }
+}
+
+function Sanitize-SecureResponseBody {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $false)]
+ [AllowNull()]
+ [string]$ResponseBody,
+
+ [Parameter(Mandatory = $false)]
+ [int]$MaxLength = 1024
+ )
+
+ if ([string]::IsNullOrEmpty($ResponseBody)) {
+ return $null
+ }
+
+ $sanitizedBody = $ResponseBody
+ $sanitizedBody = $sanitizedBody -replace 'Bearer [A-Za-z0-9\-\._~\+\/=]+=*', 'Bearer [REDACTED]'
+ $sanitizedBody = $sanitizedBody -replace '"access_token"\s*:\s*".*?"', '"access_token":"[REDACTED]"'
+ $sanitizedBody = $sanitizedBody -replace '"refresh_token"\s*:\s*".*?"', '"refresh_token":"[REDACTED]"'
+ $sanitizedBody = $sanitizedBody -replace '"client_secret"\s*:\s*".*?"', '"client_secret":"[REDACTED]"'
+ $sanitizedBody = $sanitizedBody -replace '"password"\s*:\s*".*?"', '"password":"[REDACTED]"'
+ $sanitizedBody = $sanitizedBody -replace '([?&](sig|signature|token|code)=)[^&\s"]+', '$1[REDACTED]'
+
+ if ($sanitizedBody.Length -gt $MaxLength) {
+ $sanitizedBody = $sanitizedBody.Substring(0, $MaxLength) + '...[truncated]'
+ }
+
+ return $sanitizedBody
+}
+
# Secure REST method with error sanitization
function Invoke-SecureRestMethod {
[CmdletBinding()]
@@ -115,8 +175,23 @@ function Invoke-SecureRestMethod {
}
catch {
# Sanitize error message to remove sensitive data
- $sanitizedError = $_.Exception.Message -replace 'Bearer [A-Za-z0-9\-\._~\+\/]+=*', 'Bearer [REDACTED]'
- Write-Error "Secure $Description failed: $sanitizedError" -ErrorAction Stop
+ $sanitizedError = $_.Exception.Message -replace 'Bearer [A-Za-z0-9\-\._~\+\/=]+=*', 'Bearer [REDACTED]'
+ $statusCode = $null
+ $responseBody = $null
+ $response = $null
+ try { $response = $_.Exception.Response } catch { $response = $null }
+ if ($response) {
+ try { $statusCode = $response.StatusCode } catch { $statusCode = $null }
+ $responseBody = Read-SecureResponseBody -Response $response
+ }
+ if ($responseBody) {
+ $responseBody = Sanitize-SecureResponseBody -ResponseBody $responseBody
+ }
+
+ Write-Error "Secure $Description failed: $sanitizedError"
+ if ($statusCode) { Write-Error "HTTP Status: $statusCode" }
+ if ($responseBody) { Write-Error "HTTP Body: $responseBody" }
+ throw
}
}
diff --git a/scripts/preprovision-integrated.ps1 b/scripts/preprovision-integrated.ps1
index b4a0c94..d90c7b7 100644
--- a/scripts/preprovision-integrated.ps1
+++ b/scripts/preprovision-integrated.ps1
@@ -17,73 +17,7 @@ Write-Host " AI Landing Zone - Integrated Preprovision" -ForegroundColor Cyan
Write-Host "================================================" -ForegroundColor Cyan
Write-Host ""
-function Get-PreprovisionMarkerPath {
- param(
- [string]$RepoRoot
- )
-
- $envName = $env:AZURE_ENV_NAME
- if ([string]::IsNullOrWhiteSpace($envName)) {
- try { $envName = (& azd env get-value AZURE_ENV_NAME 2>$null).ToString().Trim() } catch { $envName = $null }
- }
- if ([string]::IsNullOrWhiteSpace($envName)) { $envName = 'default' }
-
- $azureDir = Join-Path $RepoRoot '.azure'
- return Join-Path $azureDir ("preprovision-integrated.$envName.ok")
-}
-
-function Test-PreprovisionAlreadyComplete {
- param(
- [string]$RepoRoot
- )
-
- $markerPath = Get-PreprovisionMarkerPath -RepoRoot $RepoRoot
- if (-not (Test-Path $markerPath)) { return $false }
-
- $deployDir = Join-Path $RepoRoot 'submodules' 'ai-landing-zone' 'bicep' 'deploy'
- if (-not (Test-Path $deployDir)) { return $false }
-
- $wrapperPath = Join-Path $RepoRoot 'infra' 'main.bicep'
- if (-not (Test-Path $wrapperPath)) { return $false }
-
- try {
- $wrapperContent = Get-Content $wrapperPath -Raw
- if ($wrapperContent -notmatch '/bicep/deploy/main\.bicep') { return $false }
- } catch {
- return $false
- }
-
- return $true
-}
-
-function Write-PreprovisionMarker {
- param(
- [string]$RepoRoot,
- [string]$Location,
- [string]$ResourceGroup,
- [string]$SubscriptionId
- )
-
- $markerPath = Get-PreprovisionMarkerPath -RepoRoot $RepoRoot
- $markerDir = Split-Path -Parent $markerPath
- if (-not (Test-Path $markerDir)) {
- New-Item -ItemType Directory -Path $markerDir -Force | Out-Null
- }
-
- $stamp = (Get-Date).ToString('s')
- @(
- "timestamp=$stamp",
- "location=$Location",
- "resourceGroup=$ResourceGroup",
- "subscriptionId=$SubscriptionId"
- ) | Set-Content -Path $markerPath -Encoding UTF8
-}
-
-$repoRootResolved = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
-if (Test-PreprovisionAlreadyComplete -RepoRoot $repoRootResolved) {
- Write-Host "[i] Preprovision already completed by prior step; skipping PowerShell fallback." -ForegroundColor Yellow
- exit 0
-}
+ $repoRootResolved = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
function Resolve-AzdEnvironmentValues {
param(
@@ -138,6 +72,90 @@ $Location = $resolved.Location
$ResourceGroup = $resolved.ResourceGroup
$SubscriptionId = $resolved.SubscriptionId
+if ([string]::IsNullOrWhiteSpace($env:AZURE_LOCATION) -and -not [string]::IsNullOrWhiteSpace($Location)) {
+ $env:AZURE_LOCATION = $Location
+}
+
+if ([string]::IsNullOrWhiteSpace($env:AZURE_COSMOS_LOCATION) -and -not [string]::IsNullOrWhiteSpace($Location)) {
+ $env:AZURE_COSMOS_LOCATION = $Location
+}
+
+if ([string]::IsNullOrWhiteSpace($env:AZURE_PRINCIPAL_ID)) {
+ try {
+ $fromAzd = (& azd env get-value AZURE_PRINCIPAL_ID 2>$null).ToString().Trim()
+ if (-not [string]::IsNullOrWhiteSpace($fromAzd)) {
+ $env:AZURE_PRINCIPAL_ID = $fromAzd
+ }
+ } catch {
+ # Ignore and fall back to other methods.
+ }
+}
+
+$isGuid = $false
+if (-not [string]::IsNullOrWhiteSpace($env:AZURE_PRINCIPAL_ID)) {
+ $isGuid = $env:AZURE_PRINCIPAL_ID -match '^[0-9a-fA-F-]{36}$'
+}
+
+if (-not $isGuid) {
+ try {
+ $acctType = (& az account show --query user.type -o tsv 2>$null).Trim()
+ $acctName = (& az account show --query user.name -o tsv 2>$null).Trim()
+
+ if ($acctType -eq 'user') {
+ $principal = (& az ad signed-in-user show --query id -o tsv 2>$null)
+ if ([string]::IsNullOrWhiteSpace($principal) -and -not [string]::IsNullOrWhiteSpace($acctName)) {
+ $principal = (& az ad user show --id $acctName --query id -o tsv 2>$null)
+ }
+ } elseif ($acctType -eq 'servicePrincipal') {
+ if (-not [string]::IsNullOrWhiteSpace($acctName)) {
+ $principal = (& az ad sp show --id $acctName --query id -o tsv 2>$null)
+ }
+ }
+
+ if (-not [string]::IsNullOrWhiteSpace($principal) -and ($principal -match '^[0-9a-fA-F-]{36}$')) {
+ $env:AZURE_PRINCIPAL_ID = $principal.Trim()
+ $isGuid = $true
+ }
+ } catch {
+ # Ignore and fall back to provided values.
+ }
+}
+
+if ([string]::IsNullOrWhiteSpace($env:AZURE_PRINCIPAL_ID)) {
+ try {
+ $acctType = (& az account show --query user.type -o tsv 2>$null).Trim()
+ $acctName = (& az account show --query user.name -o tsv 2>$null).Trim()
+
+ if ($acctType -eq 'user') {
+ $principal = (& az ad signed-in-user show --query id -o tsv 2>$null)
+ if ([string]::IsNullOrWhiteSpace($principal) -and -not [string]::IsNullOrWhiteSpace($acctName)) {
+ $principal = (& az ad user show --id $acctName --query id -o tsv 2>$null)
+ }
+ } elseif ($acctType -eq 'servicePrincipal') {
+ if (-not [string]::IsNullOrWhiteSpace($acctName)) {
+ $principal = (& az ad sp show --id $acctName --query id -o tsv 2>$null)
+ }
+ }
+
+ if (-not [string]::IsNullOrWhiteSpace($principal)) {
+ $env:AZURE_PRINCIPAL_ID = $principal.Trim()
+ }
+ } catch {
+ # Ignore and fall back to provided values.
+ }
+}
+
+if ([string]::IsNullOrWhiteSpace($env:NETWORK_ISOLATION)) {
+ try {
+ $ni = (& azd env get-value NETWORK_ISOLATION 2>$null).ToString().Trim()
+ if (-not [string]::IsNullOrWhiteSpace($ni)) {
+ $env:NETWORK_ISOLATION = $ni
+ }
+ } catch {
+ # Ignore and fall back to defaults.
+ }
+}
+
# In non-interactive hook execution (azure.yaml sets interactive:false), Read-Host prompts are not usable.
# If the resource group is missing, derive a deterministic default from AZURE_ENV_NAME.
if ([string]::IsNullOrWhiteSpace($ResourceGroup)) {
@@ -174,7 +192,7 @@ if ([string]::IsNullOrWhiteSpace($Location) -or [string]::IsNullOrWhiteSpace($Re
}
# Navigate to AI Landing Zone submodule
-$aiLandingZonePath = Join-Path $PSScriptRoot ".." "submodules" "ai-landing-zone" "bicep"
+$aiLandingZonePath = Join-Path $PSScriptRoot ".." "submodules" "ai-landing-zone"
if (-not (Test-Path $aiLandingZonePath)) {
Write-Host "[!] AI Landing Zone submodule not initialized" -ForegroundColor Yellow
@@ -203,61 +221,270 @@ if (-not (Test-Path $aiLandingZonePath)) {
}
}
-Write-Host "[1] Running AI Landing Zone preprovision..." -ForegroundColor Cyan
+Write-Host "[1] Deploying AI Landing Zone submodule..." -ForegroundColor Cyan
Write-Host ""
-# Run the AI Landing Zone preprovision script
-$preprovisionScript = Join-Path $aiLandingZonePath "scripts" "preprovision.ps1"
+$submoduleMain = Join-Path $aiLandingZonePath "main.bicep"
+if (-not (Test-Path $submoduleMain)) {
+ Write-Host "[X] AI Landing Zone main.bicep not found!" -ForegroundColor Red
+ Write-Host " Expected: $submoduleMain" -ForegroundColor Yellow
+ exit 1
+}
+
+$parentParamsFile = Join-Path $PSScriptRoot ".." "infra" "main.bicepparam"
+if (-not (Test-Path $parentParamsFile)) {
+ Write-Host "[X] Parent parameters file not found!" -ForegroundColor Red
+ Write-Host " Expected: $parentParamsFile" -ForegroundColor Yellow
+ exit 1
+}
-if (-not (Test-Path $preprovisionScript)) {
- Write-Host "[X] AI Landing Zone preprovision script not found!" -ForegroundColor Red
- Write-Host " Expected: $preprovisionScript" -ForegroundColor Yellow
+$az = Get-Command az -ErrorAction SilentlyContinue
+if ($null -eq $az) {
+ Write-Host "[X] Azure CLI (az) not found in PATH." -ForegroundColor Red
exit 1
}
-# Call AI Landing Zone preprovision with current directory context
-Push-Location $aiLandingZonePath
+Write-Host " [+] Submodule template: $submoduleMain" -ForegroundColor Green
+Write-Host " [+] Parent params file: $parentParamsFile" -ForegroundColor Green
+
+if (-not [string]::IsNullOrWhiteSpace($SubscriptionId)) {
+ & az account set --subscription $SubscriptionId | Out-Null
+}
+
+$envNameForDeployment = $env:AZURE_ENV_NAME
+if ([string]::IsNullOrWhiteSpace($envNameForDeployment)) { $envNameForDeployment = 'default' }
+$deploymentName = "ai-landing-zone-$envNameForDeployment-$(Get-Date -Format 'yyyyMMddHHmmss')"
+$deploymentRetryCount = 6
+$deploymentRetryDelaySeconds = 30
+
+Write-Host " [+] Deployment name: $deploymentName" -ForegroundColor Green
+
+function Format-AzDeploymentError {
+ param(
+ [string]$Raw
+ )
+
+ $code = $null
+ $message = $null
+ $rawText = $Raw
+
+ if (-not [string]::IsNullOrWhiteSpace($Raw)) {
+ try {
+ $json = $Raw | ConvertFrom-Json -ErrorAction Stop
+ if ($null -ne $json.error) {
+ $code = $json.error.code
+ $message = $json.error.message
+ if ($json.error.details -and $json.error.details.Count -gt 0) {
+ $detail = $json.error.details[0]
+ if ($detail.code) { $code = $detail.code }
+ if ($detail.message) { $message = $detail.message }
+ }
+ }
+ } catch {
+ # Not JSON, fall back to regex matching below.
+ }
+
+ if (-not $code -and $Raw -match 'DeploymentActive') {
+ $code = 'DeploymentActive'
+ }
+ if (-not $code -and $Raw -match 'AccountProvisioningStateInvalid') {
+ $code = 'AccountProvisioningStateInvalid'
+ }
+ if (-not $code -and $Raw -match "management\.azure\.com") {
+ $code = 'NetworkResolutionFailed'
+ }
+
+ if ([string]::IsNullOrWhiteSpace($message)) {
+ $lines = $Raw -split "`r?`n" | Where-Object { $_ -and $_ -notmatch '^WARNING:' }
+ $message = ($lines | Select-Object -First 3) -join ' '
+ }
+ }
+
+ return [pscustomobject]@{
+ Code = $code
+ Message = $message
+ Raw = $rawText
+ }
+}
+
+$compiledParent = Join-Path $env:TEMP ("parent.$deploymentName.parameters.json")
+
+& az bicep build-params --file $parentParamsFile --outfile $compiledParent | Out-Null
+if ($LASTEXITCODE -ne 0 -or -not (Test-Path $compiledParent)) {
+ Write-Host "[X] Failed to compile parent bicepparam to JSON: $compiledParent" -ForegroundColor Red
+ exit 1
+}
+
+$allowedParamNames = Select-String -Path $submoduleMain -Pattern '^param\s+(\w+)' | ForEach-Object {
+ $_.Matches[0].Groups[1].Value
+} | Sort-Object -Unique
+
+$parentJson = Get-Content $compiledParent -Raw | ConvertFrom-Json
+$parentPrincipal = $null
try {
- & $preprovisionScript -Location $Location -ResourceGroup $ResourceGroup -SubscriptionId $SubscriptionId
- if ($LASTEXITCODE -ne 0) {
- Write-Host "[X] AI Landing Zone preprovision failed" -ForegroundColor Red
- exit 1
+ $parentPrincipal = [string]$parentJson.parameters.principalId.value
+} catch {
+ $parentPrincipal = $null
+}
+
+if ([string]::IsNullOrWhiteSpace($parentPrincipal)) {
+ Write-Host "[X] principalId is empty in infra/main.bicepparam. Set it to your Entra Object ID (GUID)." -ForegroundColor Red
+ exit 1
+}
+
+$parentPrincipal = $parentPrincipal.Trim()
+if ($parentPrincipal -notmatch '^[0-9a-fA-F-]{36}$') {
+ Write-Host "[X] principalId must be a GUID. Current value: '$parentPrincipal'" -ForegroundColor Red
+ exit 1
+}
+
+$env:AZURE_PRINCIPAL_ID = $parentPrincipal
+try {
+ & azd env set AZURE_PRINCIPAL_ID $env:AZURE_PRINCIPAL_ID 2>$null | Out-Null
+} catch {
+ # Ignore and proceed.
+}
+$filtered = [ordered]@{
+ '$schema' = $parentJson.'$schema'
+ contentVersion = $parentJson.contentVersion
+ parameters = @{}
+}
+
+foreach ($name in $allowedParamNames) {
+ $value = $parentJson.parameters.$name
+ if ($null -ne $value) {
+ $filtered.parameters[$name] = $value
}
-} finally {
- Pop-Location
}
-Write-Host ""
-Write-Host "[2] Verifying deploy directory..." -ForegroundColor Cyan
+$filteredParams = Join-Path $env:TEMP ("ai-landing-zone.$deploymentName.parameters.json")
+$filtered | ConvertTo-Json -Depth 50 | Set-Content -Path $filteredParams -Encoding UTF8
+
+$deployOutput = $null
+$deployExitCode = 1
+$parsed = $null
+$raw = $null
+
+for ($attempt = 1; $attempt -le $deploymentRetryCount; $attempt++) {
+ $deployOutput = & az deployment group create --name $deploymentName --resource-group $ResourceGroup --template-file $submoduleMain --parameters ("@" + $filteredParams) --only-show-errors 2>&1
+ $deployExitCode = $LASTEXITCODE
+
+ if ($deployExitCode -eq 0) {
+ break
+ }
+
+ $raw = ($deployOutput | Out-String).Trim()
+ $parsed = Format-AzDeploymentError -Raw $raw
+
+ if ($parsed.Code -ne 'AccountProvisioningStateInvalid' -or $attempt -eq $deploymentRetryCount) {
+ break
+ }
+
+ Write-Host " [!] AI Foundry account is still provisioning (attempt $attempt/$deploymentRetryCount). Waiting ${deploymentRetryDelaySeconds}s before retry..." -ForegroundColor Yellow
+ Start-Sleep -Seconds $deploymentRetryDelaySeconds
+}
+
+if ($deployExitCode -ne 0) {
+ Write-Host "[X] AI Landing Zone submodule deployment failed" -ForegroundColor Red
+
+ if (-not $raw) {
+ $raw = ($deployOutput | Out-String).Trim()
+ }
+ if (-not $parsed) {
+ $parsed = Format-AzDeploymentError -Raw $raw
+ }
+
+ if (-not [string]::IsNullOrWhiteSpace($parsed.Code) -or -not [string]::IsNullOrWhiteSpace($parsed.Message)) {
+ $reasonParts = @()
+ if ($parsed.Code) { $reasonParts += $parsed.Code }
+ if ($parsed.Message) { $reasonParts += $parsed.Message }
+ Write-Host (" Failure: {0}" -f ($reasonParts -join " - ")) -ForegroundColor Yellow
+ }
+
+ if ($parsed.Code -eq 'DeploymentActive') {
+ Write-Host " Another deployment is still running in this resource group. Wait for it to complete or cancel it, then re-run." -ForegroundColor Yellow
+ } elseif ($parsed.Code -eq 'AccountProvisioningStateInvalid') {
+ Write-Host " AI Foundry account is still provisioning. Retry after it reaches 'Succeeded'." -ForegroundColor Yellow
+ } elseif ($parsed.Code -eq 'NetworkResolutionFailed') {
+ Write-Host " Network/DNS could not resolve management.azure.com. Check connectivity and retry." -ForegroundColor Yellow
+ }
+
+ if ($env:AZD_VERBOSE_ERRORS -and -not [string]::IsNullOrWhiteSpace($raw)) {
+ $preview = ($raw -split "`r?`n" | Select-Object -First 5) -join "`n"
+ Write-Host " Raw error (first lines):" -ForegroundColor DarkGray
+ Write-Host $preview -ForegroundColor DarkGray
+ }
-$deployDir = Join-Path $aiLandingZonePath "deploy"
-if (-not (Test-Path $deployDir)) {
- Write-Host "[X] Deploy directory not created: $deployDir" -ForegroundColor Red
exit 1
}
-Write-Host " [+] Deploy directory ready: $deployDir" -ForegroundColor Green
+Write-Host " [+] AI Landing Zone deployment complete" -ForegroundColor Green
Write-Host ""
-Write-Host "[3] Updating wrapper to use deploy directory..." -ForegroundColor Cyan
+Write-Host "[2] Publishing submodule outputs to azd env..." -ForegroundColor Cyan
+
+function Set-AzdEnvValue {
+ param(
+ [string]$Name,
+ [string]$Value
+ )
+
+ if ([string]::IsNullOrWhiteSpace($Value)) { return }
+ try {
+ & azd env set $Name $Value 2>$null | Out-Null
+ } catch {
+ # Ignore and continue.
+ }
+}
+
+$aiSearchName = $null
+try { $aiSearchName = [string]$parentJson.parameters.searchServiceName.value } catch { }
+if ([string]::IsNullOrWhiteSpace($aiSearchName)) {
+ try { $aiSearchName = [string]$parentJson.parameters.aiFoundrySearchServiceName.value } catch { }
+}
+if ([string]::IsNullOrWhiteSpace($aiSearchName)) {
+ try { $aiSearchName = (az search service list --resource-group $ResourceGroup --query "[0].name" -o tsv 2>$null).Trim() } catch { }
+}
-# Update our wrapper to reference deploy/ instead of infra/
-$wrapperPath = Join-Path $PSScriptRoot ".." "infra" "main.bicep"
-$wrapperContent = Get-Content $wrapperPath -Raw
+$aiFoundryName = $null
+try { $aiFoundryName = [string]$parentJson.parameters.aiFoundryAccountName.value } catch { }
+if ([string]::IsNullOrWhiteSpace($aiFoundryName)) {
+ try {
+ $aiFoundryName = (az cognitiveservices account list --resource-group $ResourceGroup --query "[?kind=='AIServices']|[0].name" -o tsv 2>$null).Trim()
+ } catch { }
+}
-# Replace infra/main.bicep reference with deploy/main.bicep
-$pattern = '/bicep/infra/main\.bicep'
-$replacement = '/bicep/deploy/main.bicep'
+$aiFoundryProjectName = $null
+try { $aiFoundryProjectName = [string]$parentJson.parameters.aiFoundryProjectName.value } catch { }
+if ([string]::IsNullOrWhiteSpace($aiFoundryProjectName) -and -not [string]::IsNullOrWhiteSpace($aiFoundryName)) {
+ try {
+ $projectCandidatesRaw = az resource list --resource-group $ResourceGroup --resource-type "Microsoft.CognitiveServices/accounts/projects" --query "[?contains(id, '/accounts/$aiFoundryName/')].name" -o tsv 2>$null
+ if ($projectCandidatesRaw) {
+ [string[]]$projectCandidates = ($projectCandidatesRaw -split "\r?\n") | Where-Object { $_ -and $_.Trim() } | ForEach-Object { $_.Trim() }
+ if ($projectCandidates.Length -ge 1) {
+ $aiFoundryProjectName = $projectCandidates[0]
+ }
+ }
+ } catch {
+ # Ignore discovery failures and continue.
+ }
+}
-if ($wrapperContent -match $pattern) {
- $updatedContent = $wrapperContent -replace $pattern, $replacement
- Set-Content -Path $wrapperPath -Value $updatedContent -NoNewline
- Write-Host " [+] Wrapper updated to use Template Spec deployment" -ForegroundColor Green
-} else {
- Write-Host " [!] Warning: Could not update wrapper reference" -ForegroundColor Yellow
- Write-Host " Expected pattern: $pattern" -ForegroundColor Gray
+$aiSearchResourceId = $null
+if (-not [string]::IsNullOrWhiteSpace($aiSearchName)) {
+ try { $aiSearchResourceId = (az resource show --resource-group $ResourceGroup --name $aiSearchName --resource-type Microsoft.Search/searchServices --query id -o tsv 2>$null).Trim() } catch { }
}
+Set-AzdEnvValue -Name 'aiSearchName' -Value $aiSearchName
+Set-AzdEnvValue -Name 'AZURE_AI_SEARCH_NAME' -Value $aiSearchName
+Set-AzdEnvValue -Name 'aiSearchResourceId' -Value $aiSearchResourceId
+Set-AzdEnvValue -Name 'aiSearchResourceGroup' -Value $ResourceGroup
+Set-AzdEnvValue -Name 'aiSearchSubscriptionId' -Value $SubscriptionId
+Set-AzdEnvValue -Name 'aiFoundryName' -Value $aiFoundryName
+Set-AzdEnvValue -Name 'aiFoundryResourceGroup' -Value $ResourceGroup
+Set-AzdEnvValue -Name 'aiFoundryProjectName' -Value $aiFoundryProjectName
+
+
Write-Host ""
Write-Host "[OK] Preprovision complete!" -ForegroundColor Green
diff --git a/submodules/ai-landing-zone b/submodules/ai-landing-zone
index 46fc4c9..37b856b 160000
--- a/submodules/ai-landing-zone
+++ b/submodules/ai-landing-zone
@@ -1 +1 @@
-Subproject commit 46fc4c9516bd4568aa444169faa01a95f2990a5f
+Subproject commit 37b856bc3113814187dc588c3cca5da34e9ca4c7