diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 6309f0c..28fb765 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -145,4 +145,5 @@ jobs: tag="epp-packages-${RUN_NUMBER}-${RUN_ATTEMPT}" gh release create "$tag" artifacts/*.zip artifacts/SHA256SUMS.txt \ --target "$COMMIT_SHA" --title "EPP Function ZIPs ${RUN_NUMBER}.${RUN_ATTEMPT}" \ + --latest \ --notes "Packaged and tested from commit ${COMMIT_SHA}. Download one language ZIP and verify it against SHA256SUMS.txt. JavaScript includes production dependencies. .NET contains project and C# source only: extract and build/publish the project before deployment. Python is a source ZIP requiring Azure remote build on Linux. Neither source ZIP is a direct run-from-package artifact. Configure runtime, app settings, Key Vault access, and Easy Auth separately. No cloud deployment or live provider verification is performed." \ No newline at end of file diff --git a/README.md b/README.md index 3ab59de..189bb1e 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,13 @@ Easy Auth. The home tenant remains allowed by Entra. Policy activation remains m ## Download a Function ZIP -Download the preview ZIP for your chosen language: +Download the latest successful CI ZIP for your chosen language: | Language | Download | Contents | |---|---|---| -| JavaScript | [epp-javascript.zip](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-guided-setup-preview-20260915/epp-javascript.zip) | Application and production dependencies | -| .NET | [epp-dotnet-source.zip](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-guided-setup-preview-20260915/epp-dotnet-source.zip) | C# Function source and project file; build/publish before deployment | -| Python | [epp-python-source.zip](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-guided-setup-preview-20260915/epp-python-source.zip) | Source for Azure remote build on Linux | +| JavaScript | [epp-javascript.zip](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/latest/download/epp-javascript.zip) | Application and production dependencies | +| .NET | [epp-dotnet-source.zip](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/latest/download/epp-dotnet-source.zip) | C# Function source and project file; build/publish before deployment | +| Python | [epp-python-source.zip](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/latest/download/epp-python-source.zip) | Source for Azure remote build on Linux | Customers do not need PowerShell or a local build toolchain to download these files. Verify downloads against the corresponding release's `SHA256SUMS.txt`. Configure the target Function App's runtime, app settings, @@ -52,14 +52,14 @@ project; Python requires remote build to install dependencies. Neither source ZI as a run-from-package artifact. GitHub's **Code > Download ZIP** is the whole source repository, not a Function deployment package. -After the packaging workflow is merged, each successful `main` build tests all three implementations, -builds and inspects the ZIPs, and publishes a new versioned release. Get those builds from +Each successful `main` build tests all three implementations, builds and inspects the ZIPs, and +publishes a new versioned release marked as the latest release. +The direct links above and guided setup therefore track the newest successful CI package build. Get builds from [Latest release](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/latest). Older releases remain available; existing assets are not overwritten. Pull requests build downloadable workflow artifacts only and cannot publish releases. GitHub sign-in may be required for workflow artifacts, but public release downloads do not require a local build. Packaging does not deploy or -verify live provider delivery. The current preview is built from the packaging branch, not a merged -release of the separate provider feature branches. +verify live provider delivery. ## Build ZIPs Locally diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index f4476a1..e8418d6 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -62,7 +62,7 @@ Use [CONTRACT.md](CONTRACT.md) for the full request contract and production limi | `EPP_PROVIDER_APP_ID`, `EPP_PROVIDER_SCOPE` | Soprano API application ID and the exact selected scope, including `/.default`. These are not the calling application's ID. Leave blank for API-key providers. | | `EPP_OUTBOUND_CLIENT_ID` | Existing calling application's Application (client) ID used during setup, not its Object ID or Soprano's API ID. | | `EPP_OUTBOUND_MI_CLIENT_ID` | Setup-created outbound user-assigned identity's Client ID, not its principal/Object ID. | - | `EPP_PROVIDER_TIMEOUT_MS`, `EPP_PROVIDER_RETRY_INTERVAL_MS`, `EPP_PROVIDER_TEST_CONFIGURATION` | Selected profile values, as strings. Retry interval and test-configuration metadata do not enable runtime retries or shutter mode. | + | `EPP_PROVIDER_TIMEOUT_MS`, `EPP_PROVIDER_RETRY_INTERVAL_MS` | Selected profile values, as strings. The retry interval does not enable runtime retries or shutter mode. | | `KEY_VAULT_URL` | Setup-created or explicitly selected credential vault URL, not a secret value. | The outbound IDs are used only for Soprano OAuth; leave them blank for local API-key-only diff --git a/docs/local.settings.sample.json b/docs/local.settings.sample.json index 122d57f..4621858 100644 --- a/docs/local.settings.sample.json +++ b/docs/local.settings.sample.json @@ -16,7 +16,6 @@ "EPP_PROVIDER_SCOPE": "", "EPP_PROVIDER_TIMEOUT_MS": "1500", "EPP_PROVIDER_RETRY_INTERVAL_MS": "30000", - "EPP_PROVIDER_TEST_CONFIGURATION": "false", "EPP_OUTBOUND_CLIENT_ID": "", "EPP_OUTBOUND_MI_CLIENT_ID": "", diff --git a/setup/EPP-Setup.psd1 b/setup/EPP-Setup.psd1 index 3e158c4..9229e1a 100644 --- a/setup/EPP-Setup.psd1 +++ b/setup/EPP-Setup.psd1 @@ -1,6 +1,6 @@ @{ PackageName = 'EPP endpoint deployment' - PackageVersion = '0.3.0' + PackageVersion = '0.3.1' EntryPoint = 'Setup-Epp.ps1' MinimumPowerShellVersion = '7.0' Support = @('support/Epp.Setup.psm1', 'support/Epp.Packages.ps1') diff --git a/setup/Setup-Epp.ps1 b/setup/Setup-Epp.ps1 index 3a379a9..578ca92 100644 --- a/setup/Setup-Epp.ps1 +++ b/setup/Setup-Epp.ps1 @@ -9,6 +9,8 @@ No Azure resources are changed until you approve the complete plan. .PARAMETER SourceRepository Public GitHub owner/repository containing the setup files. Use with SourceRef to test a fork. +.PARAMETER PackageReleaseTag + Optional stable epp-packages release tag. By default, setup uses the latest stable CI package release. .PARAMETER InstallPrerequisites Install missing Microsoft Graph modules and the Azure CLI Bicep component after explicit opt-in. .PARAMETER ForceAuthentication @@ -27,13 +29,13 @@ param( [string] $Provider, [string] $Channel, [string] $EndpointRegion, - [string] $ProviderAccountName, [string] $ResourcePrefix, [string] $Language, [string] $OutputDirectory = (Join-Path $PSScriptRoot 'epp-output'), [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$')] [string] $SourceRepository = 'Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample', [string] $SourceRef = 'main', + [string] $PackageReleaseTag, [switch] $NonInteractive, [switch] $InstallPrerequisites, [switch] $ForceAuthentication, diff --git a/setup/docs/README.md b/setup/docs/README.md index b0cb168..6c015da 100644 --- a/setup/docs/README.md +++ b/setup/docs/README.md @@ -8,25 +8,22 @@ The customer does not clone this repository or download Bicep/support scripts se ## Availability -Choose **JavaScript, .NET, or Python**, then **Telesign or Soprano**, **SMS or voice**, and a -**Global or EU endpoint**. The private test branch uses its matching fork preview release so the -package and provider-authentication contract stay in sync. There is no package URL or checksum to -enter. Setup verifies `SHA256SUMS.txt` automatically and performs the required build and publication -for the selected language. - -Provider profiles contain complete channel/region route objects. Unknown values use **explicit dummy -test values**, not a separate placeholder list or empty fields that block setup. They are written -into the Function App's **actual environment settings** after approval. Telesign's supplied route -URLs, tenant, and timings are preserved, while its zero application IDs remain test-labelled. -Soprano contains its provider tenant, production Global/EU routes, API application ID, scope, and -timing values. -The plan and saved summary identify test configuration. Deployment does not make these values -working endpoints or credentials. The provider files contain the complete deployment contract. - -The default download URLs below become usable when this change is published upstream. Before merging, -test from a published public fork using `-SourceRepository ` and +Choose **SMS or voice**, a **Global or EU tenant scope**, **Telesign or Soprano**, and an +Azure Function **platform**: Node.js, .NET, or Python. By default, setup resolves the source repository's latest stable +`epp-packages-*` release produced by CI. A private test branch can use its matching fork release. +There is no package URL or checksum to enter. Setup verifies `SHA256SUMS.txt` automatically and +performs the required build and publication for the selected language. + +Provider profiles contain complete channel/region route objects. Telesign contains its supplied +route URLs, tenant, authentication, and timings. Soprano contains its provider tenant, Global/EU +routes, API application ID, scope, authentication, and timings. The provider files contain the +complete deployment contract. + +To test unpublished upstream changes, publish them to a public fork with a matching stable package +release, then use `-SourceRepository ` and `-SourceRef `. Both options must identify the same source as the downloaded -launcher. Unpublished worktree changes are not downloadable from GitHub. +launcher. Use `-PackageReleaseTag` if the fork contains more than one stable package release. +Unpublished worktree changes are not downloadable from GitHub. ## Step 1 - manually create the application @@ -129,17 +126,20 @@ Force explicit account selection when testing on a shared or multi-account compu The flow is: 1. **Collect missing customer inputs:** tenant, subscription, existing application client ID, Azure - region, and provider account/sender name. Supplied values - are reused without prompts. Credentials are never requested as ordinary string parameters. -2. **Choose one language**. Setup looks up its GitHub release and checksum file in - `packages/catalog.json`; there are no `PackageUrl` or `PackageSha256` inputs. -3. **Choose a provider**, then **SMS or voice**, then **Global or EU endpoint**. Setup downloads the - provider JSON and resolves one complete route containing endpoint, authentication, app-ID/scope + region, and resource prefix. Supplied values are reused without prompts. Credentials are never + requested as ordinary string parameters. +2. **Choose SMS or voice**, then the **Global or EU tenant scope**. +3. **Choose a provider**, then an Azure Function **platform**: Node.js, .NET, or Python. Setup downloads the provider JSON, + resolves one complete route containing endpoint, authentication, app-ID/scope when applicable, timeout, and retry interval. Explicit test values are allowed, shown as test - configuration, and passed to Azure settings. Malformed or disabled profiles still fail before - resource creation. -4. **Enter a resource prefix**, such as `contoso`: 2-8 lowercase letters/digits, starting with a - letter. Every top-level resource name then adds the meaningful `epp` marker, for example + configuration, and passed to Azure settings. It also resolves the latest stable CI package + release and looks up the language asset name in `packages/catalog.json`; there are no + `PackageUrl` or `PackageSha256` inputs. Use + `-PackageReleaseTag epp-packages--` to pin a previous CI release. Malformed or + disabled profiles still fail before resource creation. +4. **Enter a resource prefix**, such as `contoso`. All resources created by the script start with + this prefix. Use 2-8 lowercase letters or digits, starting with a letter. Every top-level + resource name then adds the meaningful `epp` marker, for example `contoso-epp-rg-`. A deterministic suffix derived from the subscription, application ID, and prefix reduces global-name collisions. Reruns use the same names. 5. **Check prerequisites and sign in.** Missing Graph modules or Bicep can be installed after a @@ -197,9 +197,7 @@ explicitly rather than hidden. The public certificate and a timestamped identifier summary are saved to `epp-output` beside the downloaded script, or to `-OutputDirectory`. -Private keys remain in the user's certificate store and Key Vault, not in that summary. With dummy -profiles, `EPP_PROVIDER_TEST_CONFIGURATION=true` is stored alongside the real environment settings. -This is a label, not a replacement for caller authentication or a guarantee of provider connectivity. +Private keys remain in the user's certificate store and Key Vault, not in that summary. For unattended runs, supply every input, authenticate both clients first, and explicitly authorize the whole displayed plan with **both** `-NonInteractive -ApproveDeployment`. `-NonInteractive` @@ -210,9 +208,13 @@ alone never approves changes. There is no `-Stage`, `-Resume`, `-ConfigPath`, or `-SourceRepository` defaults to `Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample`. The small entry point resolves `-SourceRef` (default `main`) to a single commit in that repository. All supporting PowerShell, Bicep, the catalog, and the selected provider profile are downloaded from that commit. -Use a reviewed full commit SHA for repeatable deployments. Provider JSON selects data only; it -cannot redirect execution to another script. Download failures stop setup, and temporary downloads -are removed on completion or failure. Select only a repository whose code you trust: its supporting +The package catalog supplies asset names, while setup resolves the latest stable `epp-packages-*` +release from the same repository and verifies the selected asset against that release's +`SHA256SUMS.txt`. The plan and saved summary record the concrete versioned URL and hashes. +For a fully repeatable deployment, use both a reviewed full commit SHA and +`-PackageReleaseTag epp-packages--`. Provider JSON selects data only; it cannot +redirect execution to another script. Download failures stop setup, and temporary downloads are +removed on completion or failure. Select only a repository whose code you trust: its supporting PowerShell is executed locally. ## Step 3 - manually validate and activate policy @@ -220,25 +222,23 @@ PowerShell is executed locally. 1. Save the Step 2 summary and confirm its tenant, application client ID, endpoint URL, encryption key ID, and certificate with the EPP onboarding owner. **Replace all test provider values** and provision the adapter-named API credentials in Key Vault. Verify the package's channel routing - and retry behavior; the tenant/scope metadata and test label do not enable unsupported behavior. + and retry behavior. 2. Validate the deployed endpoint with synthetic, non-delivering evaluation requests first. Missing/invalid credentials and unauthorized callers must be rejected by Easy Auth. An admitted caller's valid encrypted request must return the matching nonce. Then verify live SMS/voice provider acceptance and handset delivery through the supported test procedure. Never put phone numbers, messages, tokens, private keys, or nonce values in shared logs. 3. An **Authentication Policy Administrator**, using the approved Microsoft Graph tool and delegated - `Policy.ReadWrite.AuthenticationMethod`, must verify that the tenant's currently supported EPP - contract is available. For the preview contract formerly handled by Step 3, inspect - `https://graph.microsoft.com/beta/$metadata` for `authenticationMethodsPolicy.cyot` and its - `endpoint`, `appId`, and `migrated` fields. **If absent or different, stop and obtain the supported - onboarding procedure from Microsoft; do not send a guessed PATCH or enable a different method.** -4. Read `https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy` using that supported - contract, save the existing `cyot` value with tenant ID and timestamp, and independently approve - the migration choice. `migrated` is a routing decision, not a script default. -5. Re-read immediately before a manual change, stop if the policy changed, and use `If-Match` when - an ETag is available. Patch **only** the `cyot` property with the tested endpoint, the same - application client ID, and the deliberately chosen migration Boolean. Read it back and compare - before considering activation complete. + `Policy.ReadWrite.AuthenticationMethod`, must read the selected channel configuration: + `https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy/authenticationMethodConfigurations/Sms` + for SMS or the same path ending in `/Voice` for voice. If the selected configuration or its + `url` and `appId` properties are unavailable, stop and obtain the supported onboarding procedure + from Microsoft rather than sending a guessed update. +4. Save the existing channel configuration with the tenant ID and timestamp. Re-read it immediately + before a manual change, stop if it changed, and use `If-Match` when an ETag is available. +5. Update `url` with the highlighted Function endpoint and `appId` with the highlighted endpoint + application client ID printed by setup. Preserve all other properties, then read the configuration + back and compare those values before considering activation complete. Policy activation, policy backups, and policy rollback are administrator-owned manual operations. No policy API is called by the setup package. For rollback, restore only the reviewed prior EPP diff --git a/setup/docs/Troubleshooting.md b/setup/docs/Troubleshooting.md index a38e9a4..259b503 100644 --- a/setup/docs/Troubleshooting.md +++ b/setup/docs/Troubleshooting.md @@ -68,24 +68,13 @@ the intended `-SourceRepository` and `-SourceRef`. The current version asks for and reads its package URL and published checksum automatically. Remove old package URL/hash arguments from saved commands. -## Provider settings are dummy values - -Telesign remains explicitly labelled with `deployment.testConfiguration: true` because its route -application IDs are still zero GUIDs. The selected values are written into the actual Function App -environment with `EPP_PROVIDER_TEST_CONFIGURATION=true`. Soprano now has provider-supplied tenant, -endpoint, application ID, scope, and timing values and is not labelled as test configuration. - -The script can deploy code with these values, but dummy routes cannot deliver real messages. -Update the provider-owned profile before live use. Telesign requires its API-key secrets in Key -Vault. Soprano uses the selected OAuth tenant/scope/app ID and outbound managed-identity federation; -provider consent and API roles remain external onboarding steps. - ## A checksum or package download fails -Each language entry points to a versioned GitHub ZIP and the same release's `SHA256SUMS.txt`. -The file must contain exactly one valid entry for that asset. Missing, duplicate, malformed, or -mismatched checksums fail closed; there is no manual-hash or skip-verification workaround. -Verify the catalog's links and your access to GitHub/release assets. +Setup resolves the latest stable `epp-packages-*` CI release by default, then downloads the selected +catalog asset and that release's `SHA256SUMS.txt`. Use `-PackageReleaseTag` when reproducing a +specific release. The checksum file must contain exactly one valid entry for that asset. Missing, +duplicate, malformed, or mismatched checksums fail closed; there is no manual-hash or +skip-verification workaround. Verify the release assets and your access to GitHub. Supporting tools, Bicep, catalogs, and provider JSON all come from the commit selected at startup. For a public-fork branch, pass both source options. A full commit SHA avoids branch-resolution diff --git a/setup/packages/catalog.json b/setup/packages/catalog.json index 76ebe5c..b4173eb 100644 --- a/setup/packages/catalog.json +++ b/setup/packages/catalog.json @@ -1,25 +1,23 @@ { - "schemaVersion": 1, + "schemaVersion": 2, + "releaseTagPrefix": "epp-packages-", "packages": [ { "id": "javascript", - "displayName": "JavaScript", - "url": "https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-guided-setup-preview-20260915/epp-javascript.zip", - "checksumsUrl": "https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-guided-setup-preview-20260915/SHA256SUMS.txt", + "displayName": "Node.js", + "assetName": "epp-javascript.zip", "buildStrategy": "ready" }, { "id": "dotnet", "displayName": ".NET", - "url": "https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-guided-setup-preview-20260915/epp-dotnet-source.zip", - "checksumsUrl": "https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-guided-setup-preview-20260915/SHA256SUMS.txt", + "assetName": "epp-dotnet-source.zip", "buildStrategy": "dotnet-publish" }, { "id": "python", "displayName": "Python", - "url": "https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-guided-setup-preview-20260915/epp-python-source.zip", - "checksumsUrl": "https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-guided-setup-preview-20260915/SHA256SUMS.txt", + "assetName": "epp-python-source.zip", "buildStrategy": "remote-build" } ] diff --git a/setup/providers/soprano.json b/setup/providers/soprano.json index 07d94ee..9ad6cf2 100644 --- a/setup/providers/soprano.json +++ b/setup/providers/soprano.json @@ -1,7 +1,6 @@ { "deployment": { "enabled": true, - "testConfiguration": false, "providerName": "Soprano", "tenantId": "801bae25-4443-4a29-9e56-9d1cf22ff819", "authentication": { @@ -10,14 +9,14 @@ "routes": { "sms": { "global": { - "endpoint": "https://na1.smartmessagingsuite.com/cgpapi/messages/sendmsg/", + "endpoint": "https://na1.smartmessagingsuite.com/cgpapi/messages/omnimsg", "appId": "32dfc82a-86dd-4515-a0a2-f20ef2f5c7fe", "scope": "api://32dfc82a-86dd-4515-a0a2-f20ef2f5c7fe/.default", "timeoutMilliseconds": 1500, "retryIntervalSeconds": 30 }, "eu": { - "endpoint": "https://eu.sopranodesign.com/cgpapi/sendmsg", + "endpoint": "https://na1.smartmessagingsuite.com/cgpapi/messages/omnimsg", "appId": "32dfc82a-86dd-4515-a0a2-f20ef2f5c7fe", "scope": "api://32dfc82a-86dd-4515-a0a2-f20ef2f5c7fe/.default", "timeoutMilliseconds": 1500, @@ -26,14 +25,14 @@ }, "voice": { "global": { - "endpoint": "https://na1.smartmessagingsuite.com/cgpapi/messages/sendmsg/", + "endpoint": "https://na1.smartmessagingsuite.com/cgpapi/messages/omnimsg", "appId": "32dfc82a-86dd-4515-a0a2-f20ef2f5c7fe", "scope": "api://32dfc82a-86dd-4515-a0a2-f20ef2f5c7fe/.default", "timeoutMilliseconds": 1500, "retryIntervalSeconds": 30 }, "eu": { - "endpoint": "https://eu.sopranodesign.com/cgpapi/messages/sendmsg", + "endpoint": "https://na1.smartmessagingsuite.com/cgpapi/messages/omnimsg", "appId": "32dfc82a-86dd-4515-a0a2-f20ef2f5c7fe", "scope": "api://32dfc82a-86dd-4515-a0a2-f20ef2f5c7fe/.default", "timeoutMilliseconds": 1500, diff --git a/setup/providers/telesign.json b/setup/providers/telesign.json index 190269e..c7d8b21 100644 --- a/setup/providers/telesign.json +++ b/setup/providers/telesign.json @@ -1,7 +1,6 @@ { "deployment": { "enabled": true, - "testConfiguration": true, "providerName": "Telesign", "tenantId": "d818b557-ea1c-4070-a3f1-928330b7a30c", "authentication": { @@ -13,13 +12,11 @@ "sms": { "global": { "endpoint": "https://verify.telesign.com/integration/msft/cyot", - "appId": "00000000-0000-0000-0000-000000000000", "timeoutMilliseconds": 1500, "retryIntervalSeconds": 30 }, "eu": { "endpoint": "https://verify.telesign.com/integration/msft/cyot", - "appId": "00000000-0000-0000-0000-000000000000", "timeoutMilliseconds": 1500, "retryIntervalSeconds": 30 } @@ -27,18 +24,15 @@ "voice": { "global": { "endpoint": "https://verify.telesign.com/integration/msft/cyot", - "appId": "00000000-0000-0000-0000-000000000000", "timeoutMilliseconds": 1500, "retryIntervalSeconds": 30 }, "eu": { "endpoint": "https://verify.telesign.com/integration/msft/cyot", - "appId": "00000000-0000-0000-0000-000000000000", "timeoutMilliseconds": 1500, "retryIntervalSeconds": 30 } } - }, - "note": "All Telesign channel and region selections use the supplied Microsoft CYOT integration endpoint. Application IDs remain explicit test values until Telesign provides them." + } } } diff --git a/setup/support/Epp.Packages.ps1 b/setup/support/Epp.Packages.ps1 index c4fc307..a320654 100644 --- a/setup/support/Epp.Packages.ps1 +++ b/setup/support/Epp.Packages.ps1 @@ -1,28 +1,84 @@ +function Get-EppPackageRelease { + param([string] $SourceRepository, [string] $Tag, [string] $TagPrefix) + + $tagPattern = '^' + [regex]::Escape($TagPrefix) + '[0-9]+-[0-9]+$' + $headers = @{ + 'User-Agent' = 'EPP-Setup' + Accept = 'application/vnd.github+json' + 'X-GitHub-Api-Version' = '2022-11-28' + } + $uri = if ($Tag) { + "https://api.github.com/repos/$SourceRepository/releases/tags/$([Uri]::EscapeDataString($Tag))" + } + else { + "https://api.github.com/repos/$SourceRepository/releases?per_page=100" + } + try { + $response = Invoke-RestMethod -Uri $uri -Headers $headers -TimeoutSec 60 + } + catch { + $description = if ($Tag) { "package release '$Tag'" } else { 'stable package releases' } + throw "Could not resolve the $description in '$SourceRepository': $($_.Exception.Message)" + } + $release = if ($Tag) { + $response + } + else { + @($response) | Where-Object { + -not $_.draft -and -not $_.prerelease -and [string]$_.tag_name -cmatch $tagPattern + } | Select-Object -First 1 + } + if (-not $release) { + throw "No stable CI package release matching '$TagPrefix-' was found in '$SourceRepository'." + } + $resolvedTag = [string]$release.tag_name + if ($release.draft -or $release.prerelease -or $resolvedTag -cnotmatch $tagPattern) { + throw "GitHub release '$resolvedTag' is not a stable CI package release matching '$TagPrefix-'." + } + return $release +} + function Get-EppLanguage { - param([string] $AssetDirectory, [string] $Language, [string] $SourceRepository, [switch] $NonInteractive) + param( + [string] $AssetDirectory, [string] $Language, [string] $SourceRepository, + [string] $PackageReleaseTag, [switch] $NonInteractive + ) $catalog = Read-EppJson (Join-Path $AssetDirectory 'packages/catalog.json') - if ($catalog['schemaVersion'] -ne 1 -or -not $catalog['packages']) { throw 'Unsupported or empty language package catalog.' } + if ($catalog['schemaVersion'] -ne 2 -or -not $catalog['packages'] -or + [string]$catalog['releaseTagPrefix'] -cnotmatch '^[A-Za-z0-9][A-Za-z0-9.-]*-$') { + throw 'Unsupported or empty language package catalog.' + } + $tagPrefix = [string]$catalog['releaseTagPrefix'] $strategies = @{ javascript = 'ready'; dotnet = 'dotnet-publish'; python = 'remote-build' } $seen = @{} $entries = @($catalog['packages']) foreach ($entry in $entries) { if ($entry -isnot [Collections.IDictionary] -or -not $strategies.ContainsKey([string]$entry['id']) -or $seen.ContainsKey($entry['id']) -or $entry['buildStrategy'] -cne $strategies[$entry['id']] -or - -not $entry['displayName'] -or $entry['displayName'] -match '[\x00-\x1f]') { + -not $entry['displayName'] -or $entry['displayName'] -match '[\x00-\x1f]' -or + [string]$entry['assetName'] -cnotmatch '^[A-Za-z0-9][A-Za-z0-9_.-]*\.zip$') { throw 'Language catalog contains an invalid, unsupported, or duplicate entry.' } $seen[$entry['id']] = $true - $null = Read-EppInput -Name PackageUrl -Value $entry['url'] -Kind PackageUrl -SourceRepository $SourceRepository -NonInteractive - $url = [Uri]$entry['url'] - if ($url.Segments[-1] -cnotmatch '^[A-Za-z0-9][A-Za-z0-9_.-]*\.zip$' -or - $entry['checksumsUrl'] -cne ($entry['url'].Substring(0, $entry['url'].LastIndexOf('/') + 1) + 'SHA256SUMS.txt')) { - throw 'Each package needs an unambiguous ZIP filename and SHA256SUMS.txt in the same GitHub release.' + } + $entry = Select-EppOption -Entries $entries -Name Language -PromptName Platform ` + -Value $Language -NonInteractive:$NonInteractive + $release = Get-EppPackageRelease -SourceRepository $SourceRepository -Tag $PackageReleaseTag -TagPrefix $tagPrefix + $releaseTag = [string]$release.tag_name + $releaseBaseUrl = "https://github.com/$SourceRepository/releases/download/$releaseTag" + $assetName = [string]$entry['assetName'] + $assets = @($release.assets) + foreach ($requiredAsset in @($assetName, 'SHA256SUMS.txt')) { + if (@($assets | Where-Object { $_.name -ceq $requiredAsset }).Count -ne 1) { + throw "Release '$releaseTag' must contain exactly one '$requiredAsset' asset." } } - $entry = Select-EppOption -Entries $entries -Name Language -Value $Language -NonInteractive:$NonInteractive + $url = Read-EppInput -Name PackageUrl -Value "$releaseBaseUrl/$assetName" -Kind PackageUrl ` + -SourceRepository $SourceRepository -NonInteractive return [pscustomobject]@{ - Id = $entry['id']; DisplayName = $entry['displayName']; Url = $entry['url']; ChecksumsUrl = $entry['checksumsUrl'] + Id = $entry['id']; DisplayName = $entry['displayName']; Url = $url + ChecksumsUrl = "$releaseBaseUrl/SHA256SUMS.txt"; ReleaseTag = $releaseTag BuildStrategy = $entry['buildStrategy'] } } diff --git a/setup/support/Epp.Setup.psm1 b/setup/support/Epp.Setup.psm1 index fe10e2c..08ed495 100644 --- a/setup/support/Epp.Setup.psm1 +++ b/setup/support/Epp.Setup.psm1 @@ -18,47 +18,51 @@ function Read-EppJson { } function ConvertTo-EppGuid { - param([string] $Value, [switch] $AllowZero) + param([string] $Value) $guid = [Guid]::Empty - if (-not [Guid]::TryParse($Value, [ref] $guid) -or (-not $AllowZero -and $guid -eq [Guid]::Empty)) { + if (-not [Guid]::TryParse($Value, [ref] $guid) -or $guid -eq [Guid]::Empty) { throw 'Use a nonempty GUID, not an application name or an all-zero placeholder.' } return $guid.ToString('D') } function Assert-EppHttpsUrl { - param([string] $Value, [switch] $AllowTestHost) + param([string] $Value) $uri = $null if (-not [Uri]::TryCreate($Value, [UriKind]::Absolute, [ref] $uri) -or $uri.Scheme -ne 'https' -or $uri.Port -ne 443 -or $uri.IsLoopback -or $uri.HostNameType -ne [UriHostNameType]::Dns -or $uri.UserInfo -or $uri.Query -or $uri.Fragment -or $uri.Host -notmatch '\.' -or - (-not $AllowTestHost -and $uri.Host -match '(?i)((^|\.)example\.(com|net|org)$|\.(invalid|test|example)$)')) { + $uri.Host -match '(?i)((^|\.)example\.(com|net|org)$|\.(invalid|test|example)$)') { throw 'Use a public HTTPS hostname on port 443, without credentials, a query string, or placeholders.' } } function Select-EppOption { - param([object[]] $Entries, [string] $Name, [string] $Value, [switch] $NonInteractive) + param( + [object[]] $Entries, [string] $Name, [string] $Value, + [string] $PromptName, [switch] $NonInteractive + ) $ids = @($Entries | ForEach-Object { $_['id'] }) + $displayName = if ($PromptName) { $PromptName } else { $Name } if ($Value) { $selected = $Entries | Where-Object { $_['id'] -ieq $Value -or $_['displayName'] -ieq $Value } | Select-Object -First 1 if (-not $selected) { throw "Unknown $Name '$Value'. Choose: $($ids -join ', ')." } return $selected } if ($NonInteractive) { throw "-$Name is required. Choose: $($ids -join ', ')." } - Write-Host "`nChoose your $($Name.ToLowerInvariant()):" -ForegroundColor Cyan + Write-Host "`nChoose your $($displayName.ToLowerInvariant()):" -ForegroundColor Cyan for ($index = 0; $index -lt $Entries.Count; $index++) { Write-Host " [$($index + 1)] $($Entries[$index]['displayName'])" } while ($true) { - $answer = ([string](Read-Host "$Name number or name")).Trim() + $answer = ([string](Read-Host "$displayName number or name")).Trim() $number = 0 if ([int]::TryParse($answer, [ref] $number) -and $number -ge 1 -and $number -le $Entries.Count) { return $Entries[$number - 1] } $selected = $Entries | Where-Object { $_['id'] -ieq $answer -or $_['displayName'] -ieq $answer } | Select-Object -First 1 if ($selected) { return $selected } - Write-Warning "Choose one of the listed $($Name.ToLowerInvariant()) options." + Write-Warning "Choose one of the listed $($displayName.ToLowerInvariant()) options." } } @@ -76,7 +80,8 @@ function Read-EppInput { while ($true) { if (-not $supplied) { if ($NonInteractive) { throw "-$Name is required in noninteractive mode." } - $Value = [string](Read-Host "$Name - $Hint") + $prompt = if ($Hint) { "$Name - $Hint" } else { $Name } + $Value = [string](Read-Host $prompt) } $Value = $Value.Trim() try { @@ -157,15 +162,11 @@ function ConvertTo-EppProviderSettings { $issues = [Collections.Generic.List[string]]::new() $deployment = $Profile['deployment'] if ($deployment -isnot [Collections.IDictionary]) { throw "Provider '$DisplayName' has no deployment configuration." } - $testConfiguration = $deployment['testConfiguration'] -eq $true - if ($deployment.Contains('testConfiguration') -and $deployment['testConfiguration'] -isnot [bool]) { - $issues.Add('deployment.testConfiguration must be a JSON Boolean') - } if ($deployment['enabled'] -isnot [bool] -or -not $deployment['enabled']) { $issues.Add('the provider owner has not enabled this profile') } if ($deployment['providerName'] -ine $Id) { $issues.Add('deployment.providerName must match the catalog ID or display name') } - try { $providerTenantId = ConvertTo-EppGuid $deployment['tenantId'] -AllowZero:$testConfiguration } + try { $providerTenantId = ConvertTo-EppGuid $deployment['tenantId'] } catch { $issues.Add('deployment.tenantId must identify the provider tenant') } $authentication = $deployment['authentication'] @@ -194,7 +195,7 @@ function ConvertTo-EppProviderSettings { $issues.Add("deployment.routes.$channelId.$regionId is missing") continue } - try { Assert-EppHttpsUrl $route['endpoint'] -AllowTestHost:$testConfiguration } + try { Assert-EppHttpsUrl $route['endpoint'] } catch { $issues.Add("deployment.routes.$channelId.$regionId.endpoint must be a public HTTPS endpoint") } $timeout = $route['timeoutMilliseconds'] $retry = $route['retryIntervalSeconds'] @@ -205,13 +206,13 @@ function ConvertTo-EppProviderSettings { $issues.Add("deployment.routes.$channelId.$regionId.retryIntervalSeconds must be a nonnegative integer fitting Int32 milliseconds") } if ($authenticationMode -eq 'oauth') { - try { $null = ConvertTo-EppGuid $route['appId'] -AllowZero:$testConfiguration } + try { $null = ConvertTo-EppGuid $route['appId'] } catch { $issues.Add("deployment.routes.$channelId.$regionId.appId must identify the provider API application") } $scope = [string]$route['scope'] $resource = $scope -replace '/\.default$', '' $resourceUri = $null $resourceGuid = [Guid]::Empty - $validResource = ([Guid]::TryParse($resource, [ref] $resourceGuid) -and ($testConfiguration -or $resourceGuid -ne [Guid]::Empty)) -or + $validResource = ([Guid]::TryParse($resource, [ref] $resourceGuid) -and $resourceGuid -ne [Guid]::Empty) -or ([Uri]::TryCreate($resource, [UriKind]::Absolute, [ref] $resourceUri) -and $resourceUri.Scheme -in @('api', 'https') -and $resourceUri.Host -and -not $resourceUri.UserInfo -and -not $resourceUri.Query -and -not $resourceUri.Fragment) @@ -243,7 +244,6 @@ function ConvertTo-EppProviderSettings { EPP_PROVIDER_RETRY_INTERVAL_MS = [string]([long]$selectedRoute['retryIntervalSeconds'] * 1000) EPP_PROVIDER_AUTH_MODE = $authenticationMode EPP_PROVIDER_TENANT_ID = $providerTenantId - EPP_PROVIDER_TEST_CONFIGURATION = $testConfiguration.ToString().ToLowerInvariant() } if ($authenticationMode -eq 'oauth') { $settings.EPP_PROVIDER_SCOPE = [string]$selectedRoute['scope'] @@ -253,7 +253,6 @@ function ConvertTo-EppProviderSettings { Id = $Id DisplayName = $DisplayName Manifest = $Profile - IsTestConfiguration = $testConfiguration Channel = [string]$channelEntry['id'] EndpointRegion = [string]$regionEntry['id'] AuthenticationMode = $authenticationMode @@ -825,6 +824,24 @@ function Connect-EppContext { } } +function Get-EppResourceRows { + param([Collections.IDictionary] $Names) + + return @( + [pscustomobject]@{ Resource = 'Resource group'; Name = $Names.resourceGroup; Description = 'Contains all Azure resources created by this deployment.' } + [pscustomobject]@{ Resource = 'Function App'; Name = $Names.functionApp; Description = 'Hosts the External Phone Provider endpoint.' } + [pscustomobject]@{ Resource = 'Hosting plan'; Name = $Names.hostingPlan; Description = 'Linux Premium EP1 compute for the Function App.' } + [pscustomobject]@{ Resource = 'Storage account'; Name = $Names.storageAccount; Description = 'Provides Function host storage and private package storage.' } + [pscustomobject]@{ Resource = 'Blob container'; Name = 'packages'; Description = 'Stores the verified deployment package privately.' } + [pscustomobject]@{ Resource = 'Key Vault'; Name = $Names.keyVault; Description = 'Stores the encryption private key and provider credentials.' } + [pscustomobject]@{ Resource = 'Function App identity'; Name = 'System-assigned'; Description = 'Accesses package storage, host storage, Key Vault, and monitoring.' } + [pscustomobject]@{ Resource = 'Outbound identity'; Name = $Names.outboundIdentity; Description = 'Supports outbound provider authentication when required.' } + [pscustomobject]@{ Resource = 'Log Analytics'; Name = $Names.logAnalytics; Description = 'Stores platform and application diagnostic logs.' } + [pscustomobject]@{ Resource = 'Application Insights'; Name = $Names.applicationInsights; Description = 'Collects Function App telemetry.' } + [pscustomobject]@{ Resource = 'Diagnostic settings'; Name = 'Configured'; Description = 'Routes supported resource logs and metrics to Log Analytics.' } + ) +} + function Show-EppPlan { param([hashtable] $Inputs, [Collections.IDictionary] $Names, $ProviderConfiguration, $Context, [string] $SourceBaseUri) @@ -833,10 +850,11 @@ function Show-EppPlan { Write-Host "Subscription: $($Inputs.SubscriptionId)" Write-Host "Application: $($Inputs.ApplicationId)" Write-Host "Location: $($Inputs.Location)" - Write-Host "Language: $($Inputs.Language) ($($Inputs.BuildStrategy))" + Write-Host "Platform: $($Inputs.Platform)" Write-Host "Provider: $($ProviderConfiguration.DisplayName)" Write-Host "Channel: $($ProviderConfiguration.Channel)" - Write-Host "Endpoint: $($ProviderConfiguration.EndpointRegion)" + $tenantScope = if ($ProviderConfiguration.EndpointRegion -eq 'eu') { 'EU' } else { 'Global' } + Write-Host "Tenant scope: $tenantScope" Write-Host "Provider auth: $($ProviderConfiguration.AuthenticationMode)" Write-Host "Provider tenant: $($ProviderConfiguration.Settings.EPP_PROVIDER_TENANT_ID)" Write-Host "API endpoint: $($ProviderConfiguration.Settings.EPP_PROVIDER_ENDPOINT)" @@ -852,44 +870,52 @@ function Show-EppPlan { Write-Host "Package: $($Inputs.PackageUrl)" Write-Host "Source hash: $($Inputs.SourcePackageSha256) (verified automatically)" Write-Host "Source: $SourceBaseUri" - $Names.GetEnumerator() | ForEach-Object { [pscustomobject]@{ Resource = $_.Key; Name = $_.Value } } | - Format-Table -AutoSize | Out-String -Width 200 | Write-Host - Write-Host 'Required Azure resource providers (subscription-wide; register only those missing after approval):' + + Write-Host "`nAzure resources and configuration" -ForegroundColor Cyan + Get-EppResourceRows -Names $Names | + Format-Table Resource, Name, Description -AutoSize | Out-String -Width 240 | Write-Host + Write-Host ' - Required Azure resource providers are checked before deployment; existing registrations are reused.' $Context.ResourceProviders | Select-Object Namespace, RegistrationState | Format-Table -AutoSize | Out-String -Width 200 | Write-Host - Write-Host 'Registration and regional readiness are checked before certificate/resource creation. Existing or in-progress registrations are reused.' - Write-Host 'Includes the private packages blob container, Function system identity, Easy Auth, and diagnostic settings.' - Write-Host 'System identity: Storage Blob Data Owner, Queue/Table Data Contributor, Key Vault Secrets User, Monitoring Metrics Publisher.' - Write-Host "Azure operator $($Context.OperatorId) (ARM token oid): Key Vault Secrets Officer and Storage Blob Data Contributor." - Write-Host "Graph operator $($Context.GraphAccount) [$($Context.GraphOperatorId)]: configure the dedicated endpoint app and tenant service principals." + + Write-Host "`nAccess and permissions" -ForegroundColor Cyan + Write-Host ' - Function App managed identity:' + Write-Host ' Storage Blob Data Owner - access the private deployment package.' + Write-Host ' Storage Queue Data Contributor and Storage Table Data Contributor - use Azure Functions host storage.' + Write-Host ' Key Vault Secrets User - read provider credentials and the encryption private key.' + Write-Host ' Monitoring Metrics Publisher - publish platform metrics.' if ($Context.Application.SignInAudience -ne 'AzureADMultipleOrgs') { - Write-Host 'Change the dedicated endpoint application from single-tenant to organizational multi-tenant.' - } - if (-not $Context.ProviderTenantRestricted) { - Write-Host "Restrict the multi-tenant app to its home tenant plus provider tenant $($Inputs.ProviderTenantId) using the Entra allowed-tenants preview." + Write-Host ' - Change the endpoint application from single-tenant to organizational multi-tenant.' } if (-not $Context.InvokeRoleExists) { - Write-Host "Add application permission '$script:EppInvokeAppRoleValue' to the endpoint application." + Write-Host " - Add the '$script:EppInvokeAppRoleValue' application permission to the endpoint application." + } + Write-Host ' - Create or reuse the Microsoft phone-provider enterprise application:' + Write-Host " $script:EppInvokeAppRoleValue - allows Microsoft to obtain an access token and call the Azure Function endpoint." + Write-Host ' Microsoft Graph Application.Read.All - allows Microsoft to read the public encryption key from the endpoint application and encrypt request payloads.' + Write-Host ' WARNING: Application.Read.All permits app-only reading of every application and enterprise application in this tenant.' -ForegroundColor Yellow + + Write-Host "`nEndpoint security" -ForegroundColor Cyan + if (-not $Context.ProviderTenantRestricted) { + Write-Host ' - Restrict the multi-tenant endpoint application to the customer tenant and selected provider tenant.' } - Write-Host 'Create/reuse the endpoint service principal and require app-role assignment.' - Write-Host "Create/reuse Microsoft phone-provider service principal $script:MicrosoftPhoneProviderAppId." - Write-Host "Assign '$script:EppInvokeAppRoleValue' so the Microsoft phone-provider app can request an app-only endpoint token." - Write-Host 'Grant the Microsoft phone-provider service principal Microsoft Graph Application.Read.All.' - Write-Host 'WARNING: Application.Read.All permits app-only reading of every application and service principal in this tenant.' -ForegroundColor Yellow + Write-Host ' - Configure Easy Auth to require HTTPS authentication and allow only the Microsoft phone-provider enterprise application.' + Write-Host ' - Create an encryption certificate and store its private key in the new Key Vault.' if ($Inputs.ProviderAuthentication -eq 'oauth') { - Write-Host 'Soprano OAuth: add an outbound managed-identity federated credential to the EXISTING application.' + Write-Host ' - Soprano OAuth: add a federated credential so the outbound managed identity can authenticate without a client secret.' } - else { Write-Host 'Telesign API key: no outbound federated application credential is created.' } - Write-Host "Create/reuse an RSA certificate in CurrentUser\My; store its private key as phone-provider-decryption-key in the new vault." - Write-Host 'Deploy the verified package, synchronize triggers, and enable HTTPS ingress guarded by Easy Auth.' - Write-Host 'Premium EP1, storage, and telemetry incur charges. Reruns can restart the Function. No automatic rollback or deletion.' -ForegroundColor Yellow - Write-Host 'This does NOT register an application, grant provider API roles, or activate/change EPP policy.' -ForegroundColor Yellow - if ($Inputs.BuildStrategy -eq 'remote-build') { - Write-Host 'Python: enable the Entra-protected SCM endpoint, run Azure remote build, then save only the built output to private package storage.' + else { + Write-Host ' - Telesign API key: store the required provider credentials in Key Vault; no federated credential is created.' } - if ($ProviderConfiguration.IsTestConfiguration) { - Write-Host 'TEST CONFIGURATION: dummy provider values WILL be written to the actual Function App environment settings.' -ForegroundColor Yellow - Write-Host 'Zero GUIDs and example.invalid URLs are placeholders, not working provider credentials or endpoints.' -ForegroundColor Yellow + + Write-Host "`nDeployment notes" -ForegroundColor Cyan + Write-Host ' - Deploy the verified package, synchronize Function triggers, and enable HTTPS ingress after Easy Auth is verified.' + if ($Inputs.BuildStrategy -eq 'remote-build') { + Write-Host ' - Python: use the Entra-protected SCM endpoint for remote build, then save only the built output in private package storage.' } + Write-Host ' - Premium EP1, storage, and telemetry incur Azure charges.' -ForegroundColor Yellow + Write-Host ' - Rerunning setup can restart the Function App.' -ForegroundColor Yellow + Write-Host ' - Failed deployments are not automatically rolled back, and resources are not automatically deleted.' -ForegroundColor Yellow + } function Confirm-EppDeployment { @@ -905,6 +931,41 @@ function Confirm-EppDeployment { } } +function Show-EppDeploymentResult { + param( + [hashtable] $Inputs, [Collections.IDictionary] $Names, $ProviderConfiguration, + [string] $EndpointUrl, [string] $ResultPath + ) + + Write-Host "`nDeployment completed" -ForegroundColor Green + Write-Host "`nAzure resources created or updated" -ForegroundColor Cyan + Get-EppResourceRows -Names $Names | + Format-Table Resource, Name -AutoSize | Out-String -Width 160 | Write-Host + Write-Host "Function endpoint: $EndpointUrl" -ForegroundColor Green + Write-Host "Deployment details: $ResultPath" + + if ($ProviderConfiguration.Id -eq 'telesign') { + $authentication = $ProviderConfiguration.Manifest.deployment.authentication + Write-Host "`nPending operation" -ForegroundColor Yellow + Write-Host "Add the Telesign credentials to Key Vault '$($Names.keyVault)':" + Write-Host " - $($authentication.identityKeyVaultSecretName) - your Telesign customer ID." + Write-Host " - $($authentication.keyVaultSecretName) - your Telesign API key." + Write-Host ' Store the values as Key Vault secrets; do not enter them into this setup script or shared logs.' + } + + Write-Host "`nNext steps" -ForegroundColor Cyan + $authenticationMethod = if ($ProviderConfiguration.Channel -eq 'voice') { 'Voice' } else { 'Sms' } + $graphPolicyEndpoint = "https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy/authenticationMethodConfigurations/$authenticationMethod" + Write-Host 'After validating the endpoint, update the External Phone Provider policy in Microsoft Graph:' + Write-Host ' Microsoft Graph endpoint: ' -NoNewline + Write-Host $graphPolicyEndpoint -ForegroundColor Yellow + Write-Host ' url: ' -NoNewline + Write-Host $EndpointUrl -ForegroundColor Yellow + Write-Host ' appId: ' -NoNewline + Write-Host $Inputs.ApplicationId -ForegroundColor Yellow + Write-Host ' The setup script did not change the External Phone Provider policy.' +} + function Get-EppServicePrincipal { param([string] $AppId, [string] $Description) @@ -1328,7 +1389,6 @@ function Invoke-EppDeployment { if ($existingKeys.Count -gt 1) { throw 'Multiple encryption credentials match this certificate. Resolve the duplicate credentials manually.' } $keyId = if ($existingKeys.Count) { [string]$existingKeys[0].KeyId } else { [Guid]::NewGuid().ToString() } $settings = @{} + $ProviderConfiguration.Settings - $settings.EPP_PROVIDER_ACCOUNT_NAME = $Inputs.ProviderAccountName $settings.EPP_ENCRYPTION_KEY_ID = $keyId $settings.EPP_PROVIDER_AUTH_MODE = $Inputs.ProviderAuthentication $parameters = @{ @@ -1416,7 +1476,7 @@ function Invoke-EppDeployment { endpointRegion = $ProviderConfiguration.EndpointRegion; providerAuthentication = $Inputs.ProviderAuthentication providerTenantId = $Inputs.ProviderTenantId resourcePrefix = $Inputs.ResourcePrefix; resources = $Names - language = $Inputs.Language; testConfiguration = $ProviderConfiguration.IsTestConfiguration + language = $Inputs.Language endpointUrl = $outputs.endpointUrl.value; identifierUri = $outputs.identifierUri.value encryptionKeyId = $keyId; certificateThumbprint = $certificate.Thumbprint endpointServicePrincipalId = $graphAccess.EndpointPrincipalId @@ -1429,12 +1489,9 @@ function Invoke-EppDeployment { } $resultPath = Join-Path $OutputDirectory "deployment-$([DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss'))-$([Guid]::NewGuid().ToString('N').Substring(0, 8)).json" $result | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $resultPath -Encoding utf8NoBOM - Write-Host "Endpoint deployed: $($outputs.endpointUrl.value)" -ForegroundColor Green - Write-Host "Saved identifiers: $resultPath" - Write-Host 'EPP policy was not changed. Validate the endpoint, then complete manual Step 3.' -ForegroundColor Yellow - if ($ProviderConfiguration.IsTestConfiguration) { - Write-Warning 'The code and real app settings were deployed with DUMMY provider values. Replace them and provision the adapter-named Key Vault credentials before live SMS/voice delivery.' - } + + Show-EppDeploymentResult -Inputs $Inputs -Names $Names -ProviderConfiguration $ProviderConfiguration ` + -EndpointUrl $outputs.endpointUrl.value -ResultPath $resultPath return [pscustomobject]$result } @@ -1443,34 +1500,45 @@ function Invoke-EppSetup { param( [string] $TenantId, [string] $SubscriptionId, [string] $ApplicationId, [string] $Location, [string] $Provider, [string] $Channel, [string] $EndpointRegion, - [string] $ProviderAccountName, [string] $ResourcePrefix, + [string] $ResourcePrefix, [string] $Language, [string] $OutputDirectory, [string] $AssetDirectory, [string] $SourceBaseUri, [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$')] [string] $SourceRepository = 'Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample', + [string] $PackageReleaseTag, [switch] $NonInteractive, [switch] $InstallPrerequisites, [switch] $ForceAuthentication, [switch] $ApproveDeployment ) - Write-Host 'Step 2: deploy the External Phone Provider endpoint. Steps 1 and 3 are manual.' -ForegroundColor Cyan Write-Host "`nEnter missing customer settings. Supplied values will not be requested again." $inputs = @{} foreach ($name in @('TenantId', 'SubscriptionId', 'ApplicationId')) { $inputs[$name] = Read-EppInput -Name $name -Value (Get-Variable -Name $name -ValueOnly) -Kind Guid ` - -Hint 'Use the customer tenant/subscription or existing application CLIENT ID' -NonInteractive:$NonInteractive + -NonInteractive:$NonInteractive } $inputs.Location = Read-EppInput Location $Location -Kind Location -Hint 'Azure region, for example westus2' -NonInteractive:$NonInteractive - $inputs.ProviderAccountName = Read-EppInput ProviderAccountName $ProviderAccountName -Hint 'Your provider account/sender name, not a credential' -NonInteractive:$NonInteractive - $selection = Get-EppLanguage -AssetDirectory $AssetDirectory -Language $Language -SourceRepository $SourceRepository -NonInteractive:$NonInteractive - $inputs.Language = $selection.Id - $inputs.PackageUrl = $selection.Url - $inputs.BuildStrategy = $selection.BuildStrategy + $channelSelection = Select-EppOption -Entries @( + @{ id = 'sms'; displayName = 'SMS' } + @{ id = 'voice'; displayName = 'Voice' } + ) -Name Channel -Value $Channel -NonInteractive:$NonInteractive + $tenantScopeSelection = Select-EppOption -Entries @( + @{ id = 'global'; displayName = 'Global' } + @{ id = 'eu'; displayName = 'EU' } + ) -Name EndpointRegion -PromptName 'Tenant scope' -Value $EndpointRegion -NonInteractive:$NonInteractive $providerConfiguration = Get-EppProvider -AssetDirectory $AssetDirectory -SourceBaseUri $SourceBaseUri -Provider $Provider ` - -Channel $Channel -EndpointRegion $EndpointRegion -NonInteractive:$NonInteractive -SourceRepository $SourceRepository + -Channel $channelSelection['id'] -EndpointRegion $tenantScopeSelection['id'] ` + -NonInteractive:$NonInteractive -SourceRepository $SourceRepository $inputs.ProviderAuthentication = $providerConfiguration.AuthenticationMode $inputs.ProviderTenantId = $providerConfiguration.Settings.EPP_PROVIDER_TENANT_ID - $inputs.ResourcePrefix = Read-EppInput ResourcePrefix $ResourcePrefix -Kind Prefix -Hint '2-8 lowercase letters/digits; resource names add epp after this prefix' -NonInteractive:$NonInteractive + $selection = Get-EppLanguage -AssetDirectory $AssetDirectory -Language $Language -SourceRepository $SourceRepository ` + -PackageReleaseTag $PackageReleaseTag -NonInteractive:$NonInteractive + $inputs.Language = $selection.Id + $inputs.Platform = $selection.DisplayName + $inputs.PackageUrl = $selection.Url + $inputs.BuildStrategy = $selection.BuildStrategy + $inputs.ResourcePrefix = Read-EppInput ResourcePrefix $ResourcePrefix -Kind Prefix ` + -Hint 'All resources created by this script will start with this prefix' -NonInteractive:$NonInteractive $names = Get-EppResourceNames -SubscriptionId $inputs.SubscriptionId -ApplicationId $inputs.ApplicationId -ResourcePrefix $inputs.ResourcePrefix Write-Host "`nChecking prerequisites and the selected Azure context (no resource changes)..." -ForegroundColor Cyan @@ -1487,7 +1555,7 @@ function Invoke-EppSetup { return } try { - Invoke-EppDeployment -Inputs $inputs -Names $names -ProviderConfiguration $providerConfiguration -Context $context ` + $null = Invoke-EppDeployment -Inputs $inputs -Names $names -ProviderConfiguration $providerConfiguration -Context $context ` -AssetDirectory $AssetDirectory -Package $package -OutputDirectory $OutputDirectory -SourceBaseUri $SourceBaseUri } catch {