diff --git a/src/code/FindHelper.cs b/src/code/FindHelper.cs index abb8c773f..c43469c6b 100644 --- a/src/code/FindHelper.cs +++ b/src/code/FindHelper.cs @@ -924,7 +924,12 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R responses = response.GetAwaiter().GetResult(); + bool requestFailed = !errorMsgs.IsEmpty; Utils.WriteOutConcurrentQueue(_cmdletPassedIn, errorMsgs, warningMsgs, debugMsgs, verboseMsgs); + if (requestFailed) + { + continue; + } } else { diff --git a/src/code/PublishHelper.cs b/src/code/PublishHelper.cs index 5a1dea45e..803daa9a3 100644 --- a/src/code/PublishHelper.cs +++ b/src/code/PublishHelper.cs @@ -715,7 +715,7 @@ private bool PushNupkg(string outputNupkgDir, string repoName, string repoUri, N // look in PS repo for how httpRequestExceptions are handled // Unfortunately there is no response message are no status codes provided with the exception and no - var ex = new ArgumentException(String.Format("Repository '{0}': {1}", repoName, e.Message)); + var ex = new ArgumentException(String.Format("Repository '{0}': {1}", repoName, e.Message) + Utils.GetPSGalleryStatusMessage(publishLocation, e.Message)); if (e.Message.Contains("400")) { if (e.Message.Contains("Api")) @@ -850,7 +850,7 @@ private bool PushNupkg(string outputNupkgDir, string repoName, string repoUri, N else { error = new ErrorRecord( - new ArgumentException($"Repository '{repoName}': {e.InnerException.Message}"), + new ArgumentException($"Repository '{repoName}': {e.InnerException.Message}" + Utils.GetPSGalleryStatusMessage(publishLocation, e.InnerException.Message)), "ProtocolFailError", ErrorCategory.ProtocolError, this); diff --git a/src/code/Utils.cs b/src/code/Utils.cs index 3e05f1089..e772258eb 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -223,6 +223,61 @@ public static string FormatRequestsExceptions(Exception exception, HttpRequestMe return exMsg; } + public const string PSGalleryStatusUri = "https://aka.ms/psgallery"; + + /// + /// Formats an HTTP request exception and, when the request targeted the PowerShell Gallery and failed + /// with a 5xx status code, appends a pointer to the PowerShell Gallery status page. + /// + public static string FormatRequestsExceptions(Exception exception, HttpRequestMessage request, HttpStatusCode statusCode) + { + return FormatRequestsExceptions(exception, request) + GetPSGalleryStatusMessage(request?.RequestUri, statusCode); + } + + /// + /// Returns a sentence pointing to the PowerShell Gallery status page when the request targeted the + /// PowerShell Gallery and failed with a 5xx status code; otherwise returns an empty string. + /// + public static string GetPSGalleryStatusMessage(Uri requestUri, HttpStatusCode statusCode) + { + return IsPSGalleryServiceError(requestUri, statusCode) + ? $" The PowerShell Gallery may be experiencing an issue. Check {PSGalleryStatusUri} for the current service status." + : string.Empty; + } + + /// + /// Same as GetPSGalleryStatusMessage, for callers (such as NuGet's PushRunner) that only expose the + /// status code inside an exception message like "Response status code does not indicate success: 503 (...)". + /// + public static string GetPSGalleryStatusMessage(string requestUri, string exceptionMessage) + { + if (string.IsNullOrEmpty(exceptionMessage) || !Uri.TryCreate(requestUri, UriKind.Absolute, out Uri uri)) + { + return string.Empty; + } + + Match match = Regex.Match(exceptionMessage, @"status code does not indicate success: (\d{3})", RegexOptions.IgnoreCase); + return match.Success ? GetPSGalleryStatusMessage(uri, (HttpStatusCode)int.Parse(match.Groups[1].Value)) : string.Empty; + } + + /// + /// True when the request targeted the PowerShell Gallery and failed with a server-side (5xx) error. + /// 404 is deliberately excluded because it can legitimately mean the requested resource doesn't exist. + /// + public static bool IsPSGalleryServiceError(Uri requestUri, HttpStatusCode statusCode) + { + if (requestUri == null || !requestUri.IsAbsoluteUri) + { + return false; + } + + string host = requestUri.Host; + bool isPSGallery = host.Equals("powershellgallery.com", StringComparison.OrdinalIgnoreCase) || + host.EndsWith(".powershellgallery.com", StringComparison.OrdinalIgnoreCase); + + return isPSGallery && (int)statusCode >= 500 && (int)statusCode < 600; + } + public static string FormatCredentialRequestExceptions(Exception exception) { string exMsg = $"'{exception.Message}' Re-run the command with -Credential."; diff --git a/src/code/V2ServerAPICalls.cs b/src/code/V2ServerAPICalls.cs index 6ea3361d7..ec1287aa9 100644 --- a/src/code/V2ServerAPICalls.cs +++ b/src/code/V2ServerAPICalls.cs @@ -434,7 +434,7 @@ public override async Task FindNameAsync(string packageName, bool i } var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?{queryBuilder.BuildQueryString()}"; - string response = string.Empty; + string response = null; try { response = await HttpRequestCallAsync(requestUrlV2, errorMsgs, warningMsgs, debugMsgs, verboseMsgs); @@ -476,6 +476,12 @@ public override async Task FindNameAsync(string packageName, bool i } } + // The request failure has already been reported; don't also report a parse/not-found error. + if (response == null) + { + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); + } + int count = GetCountFromResponse(response, out string errStr); if (errStr != null) { @@ -832,7 +838,7 @@ public override async Task FindVersionAsync(string packageName, str } var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?{queryBuilder.BuildQueryString()}"; - string response = string.Empty; + string response = null; try { response = await HttpRequestCallAsync(requestUrlV2, errorMsgs, warningMsgs, debugMsgs, verboseMsgs); @@ -874,6 +880,12 @@ public override async Task FindVersionAsync(string packageName, str } } + // The request failure has already been reported; don't also report a parse/not-found error. + if (response == null) + { + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); + } + int count = GetCountFromResponse(response, out string errStr); if (errStr != null) { @@ -1071,11 +1083,13 @@ private string HttpRequestCall(string requestUrlV2, out ErrorRecord errRecord) /// /// Helper method that makes the HTTP request for the V2 server protocol url passed in for find APIs. + /// Returns null if the request failed (the failure has already been added to errorMsgs), so callers + /// must not report an additional parse/not-found error for it. /// private async Task HttpRequestCallAsync(string requestUrlV2, ConcurrentQueue errorMsgs, ConcurrentQueue warningMsgs, ConcurrentQueue debugMsgs, ConcurrentQueue verboseMsgs) { debugMsgs.Enqueue("In V2ServerAPICalls::HttpRequestCallAsync()"); - string response = string.Empty; + string response = null; try { @@ -1087,7 +1101,7 @@ private async Task HttpRequestCallAsync(string requestUrlV2, ConcurrentQ catch (ResourceNotFoundException e) { errorMsgs.Enqueue(new ErrorRecord( - new ResourceNotFoundException($"Failure when attempting to make request for '{requestUrlV2}'", e), + e, "ResourceNotFound", ErrorCategory.InvalidResult, this)); @@ -1095,7 +1109,7 @@ private async Task HttpRequestCallAsync(string requestUrlV2, ConcurrentQ catch (UnauthorizedException e) { errorMsgs.Enqueue(new ErrorRecord( - new UnauthorizedException($"Failure when attempting to make request for '{requestUrlV2}'", e), + new UnauthorizedException($"Failure when attempting to make request for '{requestUrlV2}'. {e.Message}", e), "UnauthorizedRequest", ErrorCategory.InvalidResult, this)); @@ -1103,7 +1117,7 @@ private async Task HttpRequestCallAsync(string requestUrlV2, ConcurrentQ catch (HttpRequestException e) { errorMsgs.Enqueue(new ErrorRecord( - new HttpRequestException($"Failure when attempting to make request for '{requestUrlV2}'", e), + e, "HttpRequestCallFailure", ErrorCategory.ConnectionError, this)); @@ -1132,6 +1146,7 @@ private async Task HttpRequestCallForContentAsync(string requestUrl { debugMsgs.Enqueue("In V2ServerAPICalls::HttpRequestCallForContentAsync()"); HttpContent content = null; + bool requestFailed = true; try { @@ -1139,11 +1154,20 @@ private async Task HttpRequestCallForContentAsync(string requestUrl HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, requestUrlV2); content = await SendV2RequestForContentAsync(request, _sessionClient); + requestFailed = false; + } + catch (ResourceNotFoundException e) + { + errorMsgs.Enqueue(new ErrorRecord( + e, + "ResourceNotFound", + ErrorCategory.ObjectNotFound, + this)); } catch (HttpRequestException e) { errorMsgs.Enqueue(new ErrorRecord( - new HttpRequestException($"Failure when attempting to make request for content with '{requestUrlV2}'", e), + e, "HttpRequestFailure", ErrorCategory.ConnectionError, this)); @@ -1165,7 +1189,8 @@ private async Task HttpRequestCallForContentAsync(string requestUrl this)); } - if (content == null || string.IsNullOrEmpty(content.ToString())) + // Only report an empty response if the request itself didn't already report a failure. + if (!requestFailed && (content == null || string.IsNullOrEmpty(content.ToString()))) { errorMsgs.Enqueue(new ErrorRecord( new ArgumentException($"Response returned is null or empty. Request for content made: '{requestUrlV2}'"), @@ -1190,6 +1215,14 @@ private HttpContent HttpRequestCallForContent(string requestUrlV2, out ErrorReco content = SendV2RequestForContentAsync(request, _sessionClient).GetAwaiter().GetResult(); } + catch (ResourceNotFoundException e) + { + errRecord = new ErrorRecord( + exception: e, + "ResourceNotFound", + ErrorCategory.ObjectNotFound, + this); + } catch (HttpRequestException e) { errRecord = new ErrorRecord( @@ -1715,6 +1748,12 @@ public override async Task FindVersionGlobbingAsync(string packageN var initialResponse = await FindVersionGlobbingAsync(packageName, versionRange, includePrerelease, type, skip, getOnlyLatest, errorMsgs, warningMsgs, debugMsgs, verboseMsgs); + // The request failure has already been reported; don't also report a parse/not-found error. + if (initialResponse == null) + { + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); + } + int initialCount = GetCountFromResponse(initialResponse, out string errStr); if (errStr != null) { @@ -1743,7 +1782,7 @@ public override async Task FindVersionGlobbingAsync(string packageN // skip 100 skip += 100; var tmpResponse = await FindVersionGlobbingAsync(packageName, versionRange, includePrerelease, type, skip, getOnlyLatest, errorMsgs, warningMsgs, debugMsgs, verboseMsgs); - responses.Add(tmpResponse); + responses.Add(tmpResponse ?? string.Empty); count--; } } @@ -1924,14 +1963,9 @@ private async Task InstallVersionAsync(string packageName, string versio var response = await HttpRequestCallForContentAsync(requestUrlV2, errorMsgs, warningMsgs, debugMsgs, verboseMsgs); + // HttpRequestCallForContentAsync has already reported why no content was returned. if (response is null) { - errorMsgs.Enqueue(new ErrorRecord( - new Exception($"No content was returned by repository '{Repository.Name}'"), - "InstallFailureContentNullv2Async", - ErrorCategory.InvalidResult, - this)); - return new MemoryStream(); } @@ -2027,7 +2061,7 @@ public static async Task SendV2RequestAsync(HttpRequestMessage message, { if (responseStatusCode.Equals(HttpStatusCode.NotFound)) { - throw new ResourceNotFoundException(Utils.FormatRequestsExceptions(e, message)); + throw new ResourceNotFoundException(Utils.FormatRequestsExceptions(e, message, responseStatusCode)); } // ADO feed will return a 401 if a package does not exist on the feed, with the following message: // 401 (Unauthorized - No local versions of package 'NonExistentModule'; please provide authentication to access @@ -2036,13 +2070,13 @@ public static async Task SendV2RequestAsync(HttpRequestMessage message, { if (e.Message.Contains("access versions from upstream that have not yet been saved to your feed")) { - throw new ResourceNotFoundException(Utils.FormatRequestsExceptions(e, message)); + throw new ResourceNotFoundException(Utils.FormatRequestsExceptions(e, message, responseStatusCode)); } throw new UnauthorizedException(Utils.FormatCredentialRequestExceptions(e)); } - throw new HttpRequestException(Utils.FormatRequestsExceptions(e, message)); + throw new HttpRequestException(Utils.FormatRequestsExceptions(e, message, responseStatusCode)); } catch (ArgumentNullException e) { @@ -2072,14 +2106,14 @@ public static async Task SendV2RequestForContentAsync(HttpRequestMe { if (responseStatusCode.Equals(HttpStatusCode.NotFound)) { - throw new ResourceNotFoundException(Utils.FormatRequestsExceptions(e, message)); + throw new ResourceNotFoundException(Utils.FormatRequestsExceptions(e, message, responseStatusCode)); } if (responseStatusCode.Equals(HttpStatusCode.Unauthorized)) { throw new UnauthorizedException(Utils.FormatCredentialRequestExceptions(e)); } - throw new HttpRequestException(Utils.FormatRequestsExceptions(e, message)); + throw new HttpRequestException(Utils.FormatRequestsExceptions(e, message, responseStatusCode)); } catch (ArgumentNullException e) { diff --git a/test/PublishPSResourceTests/PublishPSResource.Tests.ps1 b/test/PublishPSResourceTests/PublishPSResource.Tests.ps1 index c8965ed24..dce3f74b2 100644 --- a/test/PublishPSResourceTests/PublishPSResource.Tests.ps1 +++ b/test/PublishPSResourceTests/PublishPSResource.Tests.ps1 @@ -42,6 +42,23 @@ function script:CreateTestModule '@ | Out-File -FilePath $moduleSrc } +Describe 'PowerShell Gallery status hint' -tags 'CI' { + BeforeAll { + $utilsType = [Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource].Assembly.GetType('Microsoft.PowerShell.PSResourceGet.UtilClasses.Utils') + $statusMessageMethod = $utilsType.GetMethod('GetPSGalleryStatusMessage', [type[]]@([uri], [System.Net.HttpStatusCode])) + } + + It 'includes the hint for a 503 from the PowerShell Gallery' { + $hint = $statusMessageMethod.Invoke($null, @([uri]'https://www.powershellgallery.com/api/v2', [System.Net.HttpStatusCode]::ServiceUnavailable)) + $hint | Should -Match 'https://aka.ms/psgallery' + } + + It 'does not include the hint for a 503 from a non-Gallery host' { + $hint = $statusMessageMethod.Invoke($null, @([uri]'https://example.org/api/v2', [System.Net.HttpStatusCode]::ServiceUnavailable)) + $hint | Should -BeNullOrEmpty + } +} + Describe "Test Publish-PSResource" -tags 'CI' { BeforeAll { Get-NewPSResourceRepositoryFile diff --git a/test/Test-PSGalleryOutage.ps1 b/test/Test-PSGalleryOutage.ps1 new file mode 100644 index 000000000..0bb36ec04 --- /dev/null +++ b/test/Test-PSGalleryOutage.ps1 @@ -0,0 +1,160 @@ +<# +.SYNOPSIS + Simulates a PowerShell Gallery outage locally to verify the aka.ms/psgallery hint in PSResourceGet errors. +.DESCRIPTION + 1. Maps a fake gallery host (test.powershellgallery.com) to 127.0.0.1 in the hosts file (requires elevation). + 2. Starts a tiny local HTTP server on that host that returns -StatusCode: + -Mode All every request fails (search, download, and publish). + -Mode DownloadOnly search requests are forwarded to the real PowerShell Gallery (with download links + rewritten to the fake host) and only package downloads and publish fail. + 3. Registers a temporary V2 repository pointing at the fake host and runs Find, Install, Save, Update, + and Publish against it, printing every error each command reports. + 4. Cleans everything up. + + For a 5xx -StatusCode, every error from the fake gallery should end with: + The PowerShell Gallery may be experiencing an issue. Check https://aka.ms/psgallery for the current service status. + For any other status code (for example 404, which can legitimately mean "not found"), the errors should NOT include it. + + This is a manual test (not run by CI). Build the module first, then run from an elevated PowerShell 7 session: + ./build.ps1 -Clean -Build -BuildConfiguration Debug -BuildFramework net472 + ./test/Test-PSGalleryOutage.ps1 -StatusCode 503 + ./test/Test-PSGalleryOutage.ps1 -StatusCode 500 -Mode DownloadOnly -UpdateName + ./test/Test-PSGalleryOutage.ps1 -StatusCode 404 +.PARAMETER UpdateName + Name of a module installed for the current user that has a newer version on the PowerShell Gallery. Update-PSResource + only reaches the download step for such a module. If omitted, the Update case is skipped. Downloads always fail, so the + installed module is never changed. +#> +param( + [int] $StatusCode = 503, + [ValidateSet('All', 'DownloadOnly')] + [string] $Mode = 'All', + [string] $UpdateName, + [int] $Port = 8080, + [string] $FakeHost = 'test.powershellgallery.com', + [string] $ModulePath = (Join-Path $PSScriptRoot '..\out\Microsoft.PowerShell.PSResourceGet\Microsoft.PowerShell.PSResourceGet.psd1'), + [switch] $SkipHostsFile +) + +# Run in a fresh pwsh process so the module under test is loaded from the current build and released afterward. +# Importing it into the caller's session would lock the DLLs in .\out (breaking ./build.ps1 -Clean) and, because +# .NET can't unload assemblies, later runs in the same session would keep using the old build. +if (-not $env:PSRESOURCEGET_OUTAGE_TEST_CHILD) { + $childArgs = @('-NoProfile', '-File', $PSCommandPath) + foreach ($param in $PSBoundParameters.GetEnumerator()) { + if ($param.Value -is [switch]) { + if ($param.Value.IsPresent) { $childArgs += "-$($param.Key)" } + } + else { + $childArgs += "-$($param.Key)", "$($param.Value)" + } + } + + $env:PSRESOURCEGET_OUTAGE_TEST_CHILD = '1' + try { & (Get-Process -Id $PID).Path @childArgs } + finally { Remove-Item Env:\PSRESOURCEGET_OUTAGE_TEST_CHILD -ErrorAction SilentlyContinue } + return +} + +$ErrorActionPreference = 'Stop' +$hostsFile = "$env:SystemRoot\System32\drivers\etc\hosts" +$hostsLine = "127.0.0.1`t$FakeHost # PSResourceGet outage test" +$repoName = 'FakePSGalleryOutage' +$workDir = Join-Path ([System.IO.Path]::GetTempPath()) 'PSGalleryOutageTest' + +# Bind the port here (not inside the job) so a port that's already in use fails fast instead of +# silently sending requests to whatever else is listening on it. +$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $Port) +try { $listener.Start() } +catch { throw "Port $Port is already in use. Stop the process using it or pass a different -Port. $_" } + +$server = Start-ThreadJob -ArgumentList $listener, $StatusCode, $Mode, "http://${FakeHost}:$Port/api/v2" -ScriptBlock { + param($listener, $StatusCode, $Mode, $fakeRepoUri) + $http = [System.Net.Http.HttpClient]::new() + # Poll instead of blocking on AcceptTcpClient so Stop-Job can end the thread. + while ($true) { + if (-not $listener.Pending()) { Start-Sleep -Milliseconds 50; continue } + $client = $listener.AcceptTcpClient() + try { + $stream = $client.GetStream() + $reader = [System.IO.StreamReader]::new($stream) + $requestLine = $reader.ReadLine() + while (($line = $reader.ReadLine()) -ne '' -and $null -ne $line) { } + $method, $path = ($requestLine -split ' ')[0, 1] + + if ($Mode -eq 'DownloadOnly' -and $method -eq 'GET' -and $path -notmatch '/package/') { + # Forward search/metadata queries to the real gallery, pointing download links back at this server. + $body = $http.GetStringAsync("https://www.powershellgallery.com$path").GetAwaiter().GetResult() + $bytes = [System.Text.Encoding]::UTF8.GetBytes($body.Replace('https://www.powershellgallery.com/api/v2', $fakeRepoUri)) + $header = "HTTP/1.1 200 OK`r`nContent-Type: application/atom+xml;charset=utf-8`r`nContent-Length: $($bytes.Length)`r`nConnection: close`r`n`r`n" + } + else { + $bytes = [System.Text.Encoding]::ASCII.GetBytes("Simulated $StatusCode") + $header = "HTTP/1.1 $StatusCode Simulated`r`nContent-Type: text/plain`r`nContent-Length: $($bytes.Length)`r`nConnection: close`r`n`r`n" + } + + $headerBytes = [System.Text.Encoding]::ASCII.GetBytes($header) + $stream.Write($headerBytes, 0, $headerBytes.Length) + $stream.Write($bytes, 0, $bytes.Length) + } + finally { $client.Close() } + } +} + +function Invoke-OutageCase([string] $Name, [scriptblock] $Command) { + Write-Host "`n--- $Name ---" -ForegroundColor Cyan + $errors = @() + try { $errors = @(& $Command 2>&1 | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] }) } + catch { $errors += $_ } + + if (-not $errors) { Write-Host '(no error)' -ForegroundColor Green } + foreach ($err in $errors) { + $color = if ($err.Exception.Message -like '*aka.ms/psgallery*') { 'Yellow' } else { 'DarkGray' } + Write-Host $err.Exception.Message -ForegroundColor $color + } +} + +try { + if (-not $SkipHostsFile) { + Add-Content -Path $hostsFile -Value $hostsLine + Clear-DnsClientCache + } + + Remove-Item $workDir -Recurse -Force -ErrorAction SilentlyContinue + $publishDir = New-Item -ItemType Directory -Path (Join-Path $workDir 'OutageTestModule') -Force + New-ModuleManifest -Path (Join-Path $publishDir 'OutageTestModule.psd1') -ModuleVersion 1.0.0 -Author 'test' -Description 'Outage test module' + + Import-Module $ModulePath -Force + Register-PSResourceRepository -Name $repoName -Uri "http://${FakeHost}:$Port/api/v2" -ApiVersion V2 -Trusted -Force + + Write-Host "Fake gallery: http://${FakeHost}:$Port/api/v2 Mode: $Mode Status: $StatusCode" -ForegroundColor Cyan + Write-Host 'Errors that include the aka.ms/psgallery hint are shown in yellow; other errors in gray.' -ForegroundColor Cyan + $expectHint = $StatusCode -ge 500 -and $StatusCode -lt 600 + Write-Host ("Expected for HTTP {0}: {1}" -f $StatusCode, $(if ($expectHint) { 'gallery errors include the hint (yellow).' } else { 'no error includes the hint (all gray).' })) -ForegroundColor Cyan + + $ErrorActionPreference = 'Continue' + Invoke-OutageCase 'Find-PSResource -Name' { Find-PSResource -Name PowerShellGet -Repository $repoName } + Invoke-OutageCase 'Find-PSResource -Name -Version' { Find-PSResource -Name PowerShellGet -Version 2.2.5 -Repository $repoName } + Invoke-OutageCase 'Find-PSResource -Name -Version (range)' { Find-PSResource -Name PowerShellGet -Version '[2.0.0, 3.0.0]' -Repository $repoName } + # -Reinstall forces the download step even if the module is already installed; the download always fails. + Invoke-OutageCase 'Install-PSResource' { Install-PSResource -Name PowerShellGet -Version 2.2.5 -Repository $repoName -Scope CurrentUser -Reinstall } + Invoke-OutageCase 'Save-PSResource' { Save-PSResource -Name PowerShellGet -Version 2.2.5 -Repository $repoName -Path $workDir } + if ($UpdateName) { + Invoke-OutageCase "Update-PSResource -Name $UpdateName" { Update-PSResource -Name $UpdateName -Repository $repoName -Scope CurrentUser } + } + else { + Write-Host "`n--- Update-PSResource --- skipped (pass -UpdateName to test it)" -ForegroundColor DarkGray + } + Invoke-OutageCase 'Publish-PSResource' { Publish-PSResource -Path $publishDir -Repository $repoName -ApiKey 'fake' -SkipDependenciesCheck -AllowInsecureConnection $true } +} +finally { + $ErrorActionPreference = 'Continue' + Unregister-PSResourceRepository -Name $repoName -ErrorAction SilentlyContinue + if (-not $SkipHostsFile) { + (Get-Content $hostsFile) | Where-Object { $_ -ne $hostsLine } | Set-Content $hostsFile + Clear-DnsClientCache + } + Stop-Job $server; Remove-Job $server -Force + $listener.Stop() + Remove-Item $workDir -Recurse -Force -ErrorAction SilentlyContinue +}