Setting the file. One moment.
Invoke Portal Search Nav · Azure Search Nav · microsoft/azure-skills · Skills Docs
ContentsBack to the top of the page Raw file
references/ Invoke-PortalSearchNav.ps1
PowerShell · 262 lines · 10 KB
15 . PARAMETER ResourceUrl
16 A portal URL (or bare ARM resource id) pointing at a supported Azure resource.
17
18 . PARAMETER Query
19 The natural-language search query, e.g. "image cleaner".
20
21 . PARAMETER ArmProvider
22 Override for the armProvider filter value. Auto-derived from ResourceUrl when omitted.
23
24 . PARAMETER AppTenantId
25 Value for the App-Tenant-Id header. Auto-derived from the URL or the signed-in token.
26
27 . PARAMETER Locale
28 Value for the locale filter. Default "en.en-us".
29
30 . PARAMETER UseDeviceAuthentication
31 Use device-code authentication instead of opening an interactive browser window.
32
33 . PARAMETER Raw
34 When set, only prints the raw JSON API response (skip link construction).
35
36 . EXAMPLE
37 ./Invoke-PortalSearchNav.ps1 -ResourceUrl $aksPortalLink -Query 'image cleaner'
38 #>
39 [ CmdletBinding ()]
40 param (
41 [ Parameter ( Mandatory = $true )]
42 [ string ] $ResourceUrl ,
43
44 [ Parameter ( Mandatory = $true )]
45 [ string ] $Query ,
46
47 [ string ] $ArmProvider ,
48
49 [ string ] $AppTenantId ,
50
51 [ string ] $Locale = 'en.en-us' ,
52
53 [ switch ] $UseDeviceAuthentication ,
54
55 [ switch ] $Raw
56 )
57
58 $ErrorActionPreference = 'Stop'
59
60 # Pinned production endpoint and token audience. Not exposed as parameters.
61 $ApiUrl = 'https://PCNX-AI-etc5bra3fscchrew.b02.azurefd.net/aks-search-direct-mid'
62 $TokenResourceUrl = 'https://management.azure.com/'
63
64 # ---------------------------------------------------------------------------
65 # 1. Parse the resource link.
66 # ---------------------------------------------------------------------------
67 $resourcePattern = '(?i)^(?<prefix>.*?)/resource/(?<resourceId>subscriptions/(?<sub>[^/]+)/resourceGroups/(?<rg>[^/]+)/providers/(?<provider>[^/]+)/(?<type>[^/]+)/(?<name>[^/?#]+))(?<trailing>/[^?#]*)?'
68 $match = [ regex ]::Match($ResourceUrl , $resourcePattern)
69
70 if ( -not $match.Success) {
71 $resourcePattern = '(?i)^/?(?<resourceId>subscriptions/(?<sub>[^/]+)/resourceGroups/(?<rg>[^/]+)/providers/(?<provider>[^/]+)/(?<type>[^/]+)/(?<name>[^/?#]+))/?$'
72 $match = [ regex ]::Match($ResourceUrl , $resourcePattern)
73 }
74
75 if ( -not $match.Success) {
76 throw "Could not parse a resource id out of -ResourceUrl. Expected a portal resource URL or '/subscriptions/.../resourceGroups/.../providers/<provider>/<type>/<name>'."
77 }
78
79 $resourceId = '/' + $match.Groups[ 'resourceId' ].Value
80 $subscriptionId = $match.Groups[ 'sub' ].Value
81 $providerNs = $match.Groups[ 'provider' ].Value
82 $resourceType = $match.Groups[ 'type' ].Value
83
84 if ( -not $ArmProvider) {
85 $ArmProvider = " $providerNs / $resourceType "
86 }
87
88 # Validate and normalise armProvider casing from resource-types.json.
89 $resourceTypesPath = Join-Path $PSScriptRoot 'resource-types.json'
90 $resourceTypes = Get-Content $resourceTypesPath - Raw | ConvertFrom-Json
91 $armKey = $ArmProvider.ToLowerInvariant()
92 $entry = $resourceTypes.PSObject.Properties[$armKey]
93 if ($entry) {
94 $ArmProvider = $entry.Value.armProvider
95 }
96 else {
97 throw "Resource type ' $ArmProvider ' is not enabled for portal navigation search."
98 }
99
100 if ( -not $AppTenantId) {
101 $tenantMatch = [ regex ]::Match($ResourceUrl , '#@(?<tenant>[^/]+)' )
102 if ($tenantMatch.Success) {
103 $AppTenantId = $tenantMatch.Groups[ 'tenant' ].Value
104 }
105 }
106
107 # Output links are always built against the production portal host.
108 $prefix = if ($AppTenantId) { "https://portal.azure.com/#@ $AppTenantId " } else { 'https://portal.azure.com' }
109
110 # ---------------------------------------------------------------------------
111 # 2. Sign in to Azure and acquire the API token.
112 # ---------------------------------------------------------------------------
113 if ( -not ( Get-Module - ListAvailable - Name Az.Accounts)) {
114 throw "The Az.Accounts PowerShell module is required. Install it with 'Install-Module Az.Accounts -Scope CurrentUser'."
115 }
116
117 Import-Module Az.Accounts
118
119 $connectParameters = @ { SkipContextPopulation = $true }
120 if ($AppTenantId) { $connectParameters[ 'Tenant' ] = $AppTenantId }
121 if ($subscriptionId) { $connectParameters[ 'Subscription' ] = $subscriptionId }
122 if ($UseDeviceAuthentication) { $connectParameters[ 'UseDeviceAuthentication' ] = $true }
123
124 Write-Host 'Sign in with the Microsoft account that has access to this Azure resource.' - ForegroundColor Cyan
125 Connect-AzAccount @connectParameters | Out-Null
126
127 $tokenParameters = @ { ResourceUrl = $TokenResourceUrl }
128 $azTenant = ( Get-AzContext ).Tenant.Id
129 if ($azTenant) { $tokenParameters[ 'TenantId' ] = $azTenant }
130 $accessToken = Get-AzAccessToken @tokenParameters
131
132 if ($accessToken.Token -is [ System.Security.SecureString ]) {
133 $tokenCredential = [ System.Net.NetworkCredential ]::new( '' , $accessToken.Token)
134 $BearerToken = $tokenCredential.Password
135 }
136 else {
137 $BearerToken = [ string ] $accessToken.Token
138 }
139
140 if ([ string ]::IsNullOrWhiteSpace($BearerToken)) {
141 throw "Azure sign-in succeeded, but no access token was returned for ' $TokenResourceUrl '."
142 }
143
144 # The API's APIM issuer check requires App-Tenant-Id to be the GUID that
145 # issued the token, not the domain from the URL.
146 try {
147 $jwtParts = $BearerToken.Split( '.' )
148 $payloadSeg = $jwtParts[ 1 ].Replace( '-' , '+' ).Replace( '_' , '/' )
149 switch ($payloadSeg.Length % 4 ) { 2 { $payloadSeg += '==' } 3 { $payloadSeg += '=' } }
150 $claims = [ System.Text.Encoding ]::UTF8.GetString([ Convert ]::FromBase64String($payloadSeg)) | ConvertFrom-Json
151 if ($claims.tid) { $AppTenantId = $claims.tid }
152 } catch {
153 Write-Verbose "Could not decode tid from JWT: $_ "
154 }
155
156 # ---------------------------------------------------------------------------
157 # 3. Build and send the request.
158 # ---------------------------------------------------------------------------
159 $payload = [ ordered ] @ {
160 search = $Query
161 count = 'true'
162 filter = "armProvider eq ' $ArmProvider ' and locale eq ' $Locale '"
163 queryType = 'semantic'
164 semanticConfiguration = 'semantic'
165 }
166
167 $headers = @ {
168 'Accept' = '*/*'
169 'User-Data-Boundary' = 'Global'
170 }
171 $headers[ 'Authorization' ] = "Bearer $BearerToken "
172 if ($AppTenantId) {
173 $headers[ 'App-Tenant-Id' ] = $AppTenantId
174 }
175
176 Write-Verbose "POST $ApiUrl "
177 Write-Verbose ($payload | ConvertTo-Json - Depth 10 )
178
179 $response = Invoke-RestMethod - Uri $ApiUrl - Method Post - Headers $headers - ContentType 'application/json' - Body ($payload | ConvertTo-Json - Depth 10 )
180
181 if ($Raw) {
182 $response | ConvertTo-Json - Depth 20
183 return
184 }
185
186 # ---------------------------------------------------------------------------
187 # 3. Find menu id(s) in the response (schema-tolerant recursive search).
188 # ---------------------------------------------------------------------------
189 function Find-MenuIdCandidates {
190 param (
191 [ Parameter ( Mandatory )] $Object ,
192 [ string []] $Names = @ ( 'menuId' , 'menuID' , 'menu_id' , 'bladeName' , 'blade' )
193 )
194
195 $found = New-Object System.Collections.Generic.List[ object ]
196
197 function Recurse ($obj) {
198 if ( $null -eq $obj) { return }
199
200 if ($obj -is [ string ]) { return }
201
202 if ($obj -is [ System.Collections.IEnumerable ]) {
203 foreach ($item in $obj) { Recurse $item }
204 return
205 }
206
207 if ($obj -is [ System.Management.Automation.PSCustomObject ]) {
208 $menuValue = $null
209 foreach ($name in $Names) {
210 $prop = $obj.PSObject.Properties[$name]
211 if ($prop -and -not [ string ]::IsNullOrWhiteSpace([ string ]$prop.Value)) {
212 $menuValue = $prop.Value
213 break
214 }
215 }
216 if ($menuValue) {
217 $title = $null
218 foreach ($titleName in @ ( 'title' , 'name' , 'displayName' , 'label' )) {
219 $titleProp = $obj.PSObject.Properties[$titleName]
220 if ($titleProp) { $title = $titleProp.Value; break }
221 }
222 $score = $null
223 $scoreProp = $obj.PSObject.Properties[ 'score' ]
224 if ($scoreProp) { $score = $scoreProp.Value }
225
226 $found.Add([ PSCustomObject ] @ { MenuId = $menuValue; Title = $title; Score = $score })
227 }
228 foreach ($prop in $obj.PSObject.Properties) {
229 Recurse $prop.Value
230 }
231 }
232 }
233
234 Recurse $Object
235 return $found
236 }
237
238 $candidates = Find-MenuIdCandidates - Object $response
239
240 if ( -not $candidates -or $candidates.Count -eq 0 ) {
241 Write-Warning 'No menu id found in the API response with the known property names (menuId, menuID, menu_id, bladeName, blade). Dumping raw response for inspection:'
242 $response | ConvertTo-Json - Depth 20
243 return
244 }
245
246 # ---------------------------------------------------------------------------
247 # 4. Rebuild the portal link for every candidate menu id.
248 # ---------------------------------------------------------------------------
249 $results = foreach ($candidate in $candidates) {
250 [ PSCustomObject ] @ {
251 Title = $candidate.Title
252 Score = $candidate.Score
253 MenuId = $candidate.MenuId
254 PortalLink = " $prefix /resource $resourceId / $( $candidate.MenuId ) "
255 }
256 }
257
258 $results | Format-Table - AutoSize - Wrap
259
260 Write-Host ''
261 Write-Host 'Best match portal link:' - ForegroundColor Green
262 Write-Host $results[ 0 ].PortalLink