-
Notifications
You must be signed in to change notification settings - Fork 104
Initial e2e testing for external auth for ARO HCP. #2376
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
99 commits
Select commit
Hold shift + click to select a range
7479c77
Initial e2e testing for external auth for ARO HCP.
c3eb0cf
Fix formatting and label usage for external auth API test
f4dda64
Add license header to external_auth_cluster.go
96bdc7b
updating bicep to param files.
ede1ac5
adding fix update-bicep-json.sh .
aa18e0b
Some breadcrumbs
mociarain c330c9b
adding external auth demo scripts.
efdf928
Fix: clean external auth config and update bicep artifacts
09901c7
Resolve merge conflict in per_invocation_framework.go
a2f74fc
add missing depends on steps to housekeeping steps (#2375)
geoberle e38511b
Fix create mock identities
janboll e7498fc
add image registry disablement test
deads2k 5906b0e
Additional permissions for ev2
janboll 00c662b
Add endpoints
mociarain 145ed17
Add the basic models
mociarain 6d4891b
Tidy up internal tests
mociarain 489258a
Fix enum
mociarain 2b0d2e8
Add more internals
mociarain 4555534
Add VersionedHCPOpenShiftClusterExternalAuth
mociarain 9095524
Comment out some pieces
mociarain 95e8b1b
Linting fixes
mociarain 36c5985
Fix typo
mociarain 0e18d46
Fix typos
mociarain fdbe16a
Drop unneeded TODO
mociarain 163212c
WIP
mociarain 4922ad7
Add ocm clients and tests
mociarain 1cf9c28
WIP
mociarain ffbc67a
Tidy up CSClient
mociarain dc692eb
Add ValidateStatic method to ExternalAuth
mociarain c9310f1
Wire up create and update
mociarain 1d764ed
Explicitly check for nil
mociarain 0955192
Remove duplicate import
mociarain 7e296d8
Add testing internals
mociarain 15536a7
Add createOrUpdate test
mociarain 839a428
Remove old comment
mociarain 0651f30
Regenerate mocks
mociarain 61d8c59
Update a comment
mociarain 569a6d3
Tidy up JSON validation
mociarain f47b6bf
Update comments
mociarain afdb84c
Use better name
mociarain ebb5262
Turn back the clock
mociarain 29510fc
Move the routes around
mociarain 06790ab
Add the list endpoint
mociarain 1613644
Remove unnecessary tags
mociarain 1165301
Fix typo
mociarain c2f5efd
Fix typo
mociarain 3d213b6
Tidy up the JSON validation logic
mociarain fb7c37b
Fix validation logic
mociarain 8a584e4
Handle Groups as a pointer
mociarain 638137b
Fix InternalId pattern
mociarain a454ad4
Add Type to DB ResourceDocument Types
mociarain 4df34eb
Support GET and DELETE
mociarain 8af31b3
Add cURL commands
mociarain d9b345e
Fix test
mociarain dc2a6e5
Update ArmDeploymentPreflight
mociarain 3907884
Always set ExternalAuthDocs to Succeeded
mociarain 0f01b8f
Set the correct label
mociarain 413b2c9
fix: remove admin env context from adminCertName
bennerv 36c1490
use the shared_dir for backup cleanup from e2e
deads2k a62b17e
denyassignment env variable for CS
geoberle a52b557
Use correct ev2 role
janboll 32f66c5
bump cluster services image to 089791c74a681b9173025d0c72aad621e97e5f…
ziccardi cbb0ffd
Make kv role configurable
janboll c516108
Remove old access block
janboll bf123ae
e2e setup: improve fallback and env var handling for cluster creation
patriksuba 27454c0
Refactor E2E test artifact handling and Bicep build integration
patriksuba 8b86c0e
Create directory generated-test-artifacts if not already present.
patriksuba 49e7f49
Mention Fallback to bicep setup in README
patriksuba 71668da
Fix error message typos for teardown validation test case
patriksuba d6d019c
Fix extraction of managed identities from infra-only bicep deployment
patriksuba c99f0e6
Create a new helper function to return bytes from bicep deployment.
patriksuba d31b578
Update fallback bicep creation with resource group helper from framew…
patriksuba 1ab7ed8
Update location of ARM templates. Build ARM templates into container.
patriksuba 593c0dd
fix up compile due to two passing PRs
deads2k bddd8a7
config: Bump RP images
8e12657
add config parameters for postgres backup settings
jfchevrette 2458113
Set the default of enabled
mociarain b17ad12
Fix the OCM layer
mociarain e48a831
Add James and David to test dir owners
jharrington22 91ae4df
bump HO in MSFT envs
geoberle 81c3ed9
Force centraluseuap to be non-zonal
tsatam 7bc2266
Update eastus2euap to use zones 1,3,4
tsatam 4d131fd
Update dev-infrastructure/modules/common.bicep
tsatam 75a1493
update demo cluster creation bicep
geoberle a64b405
also handle nodepools and non bicep
geoberle 8cc6f94
update e2e
geoberle 7e74034
tooling: bump ARO-Tools to latest revision
stevekuznetsov c97ca86
*: adapt to new ARO-Tools
stevekuznetsov 0487834
update monitoring documentation
tony-schndr 4362728
A Monumental One-Letter Namespace Correction That Will Echo Through t…
swiencki 489d9d1
tooling: bump ARO-Tools to get execution constraints
stevekuznetsov 016b6b8
pipelines: constrain global resource groups to uksouth
stevekuznetsov abfa7e0
bump cs image to efd4998. Fixes tls certs and CPO image override hard…
bennerv 0c23f5e
Revert "pipelines: constrain global resource groups to uksouth"
geoberle 3df837a
Revert "tooling: bump ARO-Tools to latest revision"
geoberle 0a9a3d7
Initial e2e testing for external auth for ARO HCP.
6a74cda
Resolve merge conflict in per_invocation_framework.go
a96e04c
ExternalAuth E2E: add complete cluster test and supporting demo paylo…
8adead9
Merge branch 'main' into ARO-20092-ARO-externalauth-tests
Nanyte25 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| param name string | ||
| param location string = resourceGroup().location | ||
| param currentTime string = utcNow() | ||
| param consoleCallbackUrl string // e.g., https://console-openshift-console.apps.example.com/auth/callback | ||
|
|
||
| resource script 'Microsoft.Resources/deploymentScripts@2019-10-01-preview' = { | ||
| name: name | ||
| location: location | ||
| kind: 'AzurePowerShell' | ||
| identity: { | ||
| type: 'UserAssigned' | ||
| userAssignedIdentities: { | ||
| '${resourceId("app-reg-automation", "Microsoft.ManagedIdentity/userAssignedIdentities", "AppRegCreator")}': {} | ||
| } | ||
| } | ||
| properties: { | ||
| azPowerShellVersion: '5.0' | ||
| arguments: '-resourceName "${name}" -consoleCallback "${consoleCallbackUrl}"' | ||
| scriptContent: ''' | ||
| param([string] $resourceName, [string] $consoleCallback) | ||
|
|
||
| $token = (Get-AzAccessToken -ResourceUrl https://graph.microsoft.com).Token | ||
| $headers = @{'Content-Type' = 'application/json'; 'Authorization' = 'Bearer ' + $token} | ||
|
|
||
| $template = @{ | ||
| displayName = $resourceName | ||
| signInAudience = "AzureADMyOrg" | ||
| web = @{ | ||
| redirectUris = @($consoleCallback) | ||
| } | ||
| requiredResourceAccess = @( | ||
| @{ | ||
| resourceAppId = "00000003-0000-0000-c000-000000000000" | ||
| resourceAccess = @( | ||
| @{ | ||
| id = "e1fe6dd8-ba31-4d61-89e7-88639da4683d" | ||
| type = "Scope" | ||
| } | ||
| ) | ||
| } | ||
| ) | ||
| } | ||
|
|
||
| # Upsert App registration | ||
| $app = (Invoke-RestMethod -Method Get -Headers $headers -Uri "https://graph.microsoft.com/beta/applications?filter=displayName eq '$($resourceName)'").value | ||
| $principal = @{} | ||
| if ($app) { | ||
| $ignore = Invoke-RestMethod -Method Patch -Headers $headers -Uri "https://graph.microsoft.com/beta/applications/$($app.id)" -Body ($template | ConvertTo-Json -Depth 10) | ||
| $principal = (Invoke-RestMethod -Method Get -Headers $headers -Uri "https://graph.microsoft.com/beta/servicePrincipals?filter=appId eq '$($app.appId)'").value | ||
| } else { | ||
| $app = (Invoke-RestMethod -Method Post -Headers $headers -Uri "https://graph.microsoft.com/beta/applications" -Body ($template | ConvertTo-Json -Depth 10)) | ||
| $principal = Invoke-RestMethod -Method POST -Headers $headers -Uri "https://graph.microsoft.com/beta/servicePrincipals" -Body (@{ "appId" = $app.appId } | ConvertTo-Json) | ||
| } | ||
|
|
||
| # Regenerate client secret | ||
| $app = (Invoke-RestMethod -Method Get -Headers $headers -Uri "https://graph.microsoft.com/beta/applications/$($app.id)") | ||
| foreach ($password in $app.passwordCredentials) { | ||
| $body = @{ "keyId" = $password.keyId } | ||
| $ignore = Invoke-RestMethod -Method POST -Headers $headers -Uri "https://graph.microsoft.com/beta/applications/$($app.id)/removePassword" -Body ($body | ConvertTo-Json) | ||
| } | ||
|
|
||
| $body = @{ "passwordCredential" = @{ "displayName"= "Client Secret" } } | ||
| $secret = (Invoke-RestMethod -Method POST -Headers $headers -Uri "https://graph.microsoft.com/beta/applications/$($app.id)/addPassword" -Body ($body | ConvertTo-Json)).secretText | ||
|
|
||
| $DeploymentScriptOutputs = @{ | ||
| objectId = $app.id | ||
| clientId = $app.appId | ||
| clientSecret = $secret | ||
| principalId = $principal.id | ||
| redirectUri = $consoleCallback | ||
| } | ||
| ''' | ||
| cleanupPreference: 'OnSuccess' | ||
| retentionInterval: 'P1D' | ||
| forceUpdateTag: currentTime | ||
| } | ||
| } | ||
|
|
||
| output objectId string = script.properties.outputs.objectId | ||
| output clientId string = script.properties.outputs.clientId | ||
| output clientSecret string = script.properties.outputs.clientSecret | ||
| output principalId string = script.properties.outputs.principalId | ||
| output redirectUri string = script.properties.outputs.redirectUri |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| { | ||
| "clientId": "00000000-0000-0000-0000-000000000000", | ||
| "clientSecret": "your-secret-value", | ||
| "issuer": "https://login.microsoftonline.com/your-tenant-id/v2.0", | ||
| "callbackUrl": "https://console-openshift.example.com/oauth2callback" | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,261 @@ | ||
|
|
||
| show_info_box() { | ||
| gum style --border double --margin "1" --padding "1 2" --border-foreground 99 \ | ||
| "📘 External Entra Auth Prerequisites:" "" \ | ||
| "• A fully created ARO-HCP cluster" \ | ||
| "• A Hosted Control Plane (HCP) running" \ | ||
| "• A NodePool created and provisioned" \ | ||
| "• RP is port-forwarded and reachable on localhost:8443" \ | ||
| "• Azure CLI is authenticated and target subscription is selected" | ||
| echo "" | ||
| } | ||
|
|
||
| #!/usr/bin/env bash | ||
| set -euo pipefail | ||
|
|
||
| # Icons | ||
| K8S="☸️" | ||
| AZ="🔷" | ||
| GIT="🌳" | ||
| CHECK="✅" | ||
| PENDING="⏳" | ||
|
|
||
| # Dependencies check | ||
| install_if_missing() { | ||
| cmd=$1 | ||
| pkg=$2 | ||
| icon=$3 | ||
| if ! command -v "$cmd" &>/dev/null; then | ||
| echo "$icon Installing $cmd..." | ||
| sudo dnf install -y "$pkg" | ||
| fi | ||
| } | ||
|
|
||
| check_deps() { | ||
| install_if_missing az azure-cli "$AZ" | ||
| install_if_missing kubectl kubectl "$K8S" | ||
| install_if_missing jq jq "🧩" | ||
| install_if_missing gum gum "💎" | ||
| install_if_missing git git "$GIT" | ||
| } | ||
|
|
||
| # Verify az login | ||
| verify_az_login() { | ||
| echo "$AZ Verifying Azure login..." | ||
| az account show &>/dev/null || az login | ||
| } | ||
|
|
||
| # Print step with checkboxes | ||
| declare -A STATUS | ||
| STATUS=( | ||
| [entra]="⏳" | ||
| [group]="⏳" | ||
| [callback]="⏳" | ||
| [update_uri]="⏳" | ||
| [apply_rp]="⏳" | ||
| [test]="⏳" | ||
| ) | ||
|
|
||
| print_status() { | ||
| clear | ||
| echo "🔐 External Auth Entra Setup Progress:" | ||
| echo "${STATUS[entra]} Create Entra App" | ||
| echo "${STATUS[group]} Create AD Group & Add User" | ||
| echo "${STATUS[callback]} Get Cluster Callback URL" | ||
| echo "${STATUS[update_uri]} Update Entra Redirect URI" | ||
| echo "${STATUS[apply_rp]} Apply Config via RP" | ||
| echo "${STATUS[test]} Test Redirect" | ||
| echo "" | ||
| } | ||
|
|
||
| create_entra_app() { | ||
| print_status | ||
| echo "$AZ Creating Entra App..." | ||
| app_name="ARO-HCP-Auth-$(date +%s)" | ||
| app_info=$(az ad app create --display-name "$app_name" --query '{appId: appId, id: id}' -o json) | ||
| client_id=$(echo "$app_info" | jq -r '.appId') | ||
| app_obj_id=$(echo "$app_info" | jq -r '.id') | ||
| secret_info=$(az ad app credential reset --id "$client_id" --append --display-name "AROSecret" -o json) | ||
| client_secret=$(echo "$secret_info" | jq -r '.password') | ||
| echo "client_id=$client_id" > demo_env.sh | ||
| echo "client_secret=$client_secret" >> demo_env.sh | ||
| echo "app_obj_id=$app_obj_id" >> demo_env.sh | ||
| echo "app_name=$app_name" >> demo_env.sh | ||
| STATUS[entra]="✅" | ||
| } | ||
|
|
||
| create_ad_group() { | ||
| print_status | ||
| echo "👥 Creating AD Group..." | ||
| source demo_env.sh | ||
| group_name="ARO-HCP-Admins" | ||
| az ad group create --display-name "$group_name" --mail-nickname "$group_name" &>/dev/null || true | ||
| read -p "Enter user email to add: " user_email | ||
| user_id=$(az ad user show --id "$user_email" --query id -o tsv) | ||
| az ad group member add --group "$group_name" --member-id "$user_id" | ||
| STATUS[group]="✅" | ||
| } | ||
|
|
||
| get_callback_url() { | ||
| print_status | ||
| echo "$K8S Fetching callback URL..." | ||
| echo "" | ||
| echo "🔐 Ensure you're authenticated to the correct cluster (management or hosted control plane)." | ||
| echo "If you encounter x509 certificate errors:" | ||
| echo " - Run ./request-admin-credential.sh to create break-glass credentials" | ||
| echo " - export KUBECONFIG=./kubeconfig" | ||
| echo " - Use --insecure-skip-tls-verify for kubectl commands" | ||
| echo "" | ||
|
|
||
| hcp_ns=$(gum input --placeholder "Enter Hypershift namespace:") | ||
| hcp_name=$(gum input --placeholder "Enter Hypershift cluster name:") | ||
|
|
||
| echo "" | ||
| echo "🔍 Attempting to retrieve callback URL from HostedCluster..." | ||
| callback_url=$(kubectl get hostedcluster "$hcp_name" -n "$hcp_ns" -o jsonpath="{.status.oauthCallbackURL}" 2>/dev/null || echo "") | ||
|
|
||
| if [[ -z "$callback_url" ]]; then | ||
| echo "HostedCluster callback URL not available. Attempting to get OpenShift console route..." | ||
| callback_url=$(kubectl get route console -n openshift-console --insecure-skip-tls-verify -o jsonpath="{.spec.host}" 2>/dev/null || echo "") | ||
|
|
||
| if [[ -z "$callback_url" ]]; then | ||
| echo "⚠️ Failed to retrieve callback URL from both HostedCluster and OpenShift console route." | ||
| echo "↩️ Returning to menu without setting callback URL." | ||
| return | ||
| else | ||
| echo "✅ Found fallback callback URL from OpenShift console route: https://$callback_url" | ||
| callback_url="https://$callback_url" | ||
| fi | ||
| else | ||
| echo "✅ Callback URL from HostedCluster: $callback_url" | ||
| fi | ||
|
|
||
| echo "callback_url=$callback_url" >> demo_env.sh | ||
| STATUS[callback]="✅" | ||
| } | ||
|
|
||
|
|
||
| update_app_redirect_uri() { | ||
| print_status | ||
| echo "$AZ Updating redirect URI..." | ||
| source demo_env.sh | ||
|
|
||
| # Ensure the callback URL is present | ||
| if [[ -z "${callback_url:-}" ]]; then | ||
| echo "❌ callback_url is not set. Please run 'Get Callback URL' step first." | ||
| return | ||
| fi | ||
|
|
||
| # Ensure it ends with /oauth/callback | ||
| redirect_uri="${callback_url%/}/oauth/callback" | ||
| echo "🔗 Setting redirect URI to: $redirect_uri" | ||
|
|
||
| az ad app update --id "$client_id" --web-redirect-uris "$redirect_uri" | ||
|
|
||
| STATUS[update_uri]="✅" | ||
| } | ||
|
|
||
| apply_idp_config_via_rp() { | ||
| print_status | ||
| echo "📡 Preparing to send external auth config to RP frontend..." | ||
|
|
||
| echo "" | ||
| echo "💡 Ensure RP is forwarded:" | ||
| echo " kubectl port-forward svc/aro-hcp-frontend -n aro-hcp 8443:8443" | ||
| echo "" | ||
|
|
||
| source demo_env.sh | ||
|
|
||
| # Check if IDP is already configured | ||
| echo "🔍 Checking existing IDPs in the HostedCluster..." | ||
| if kubectl get authentication cluster --insecure-skip-tls-verify -o json | jq -e '.spec.identityProviders | length > 0' >/dev/null; then | ||
| echo "⚠️ Identity Provider already configured in the cluster." | ||
| gum confirm "Return to menu?" && return | ||
| else | ||
| echo "✅ No existing IDP found." | ||
| fi | ||
|
|
||
| # Get Entra app name or ID | ||
| read -p "Enter the external auth ID (e.g., entra): " external_auth_id | ||
|
|
||
| # Get access token | ||
| ACCESS_TOKEN=$(az account get-access-token --query accessToken -o tsv 2>/dev/null || true) | ||
| if [[ -z "$ACCESS_TOKEN" ]]; then | ||
| read -p "Could not auto-acquire access token. Please enter it manually: " ACCESS_TOKEN | ||
| else | ||
| echo "🔑 Azure access token acquired." | ||
| fi | ||
|
|
||
| # Get subscription/RG/cluster name | ||
| default_sub=$(az account show --query id -o tsv 2>/dev/null || echo "") | ||
| read -p "Enter your subscription ID [default: $default_sub]: " subscription_id | ||
| subscription_id=${subscription_id:-$default_sub} | ||
| read -p "Enter your resource group name: " resource_group | ||
| read -p "Enter your cluster name: " cluster_name | ||
|
|
||
| # Build RP URL | ||
| rp_url="http://localhost:8443/subscriptions/$subscription_id/resourceGroups/$resource_group/providers/Microsoft.RedHatOpenShift/hcpOpenShiftClusters/$cluster_name/externalAuths/$external_auth_id?api-version=2024-06-10-preview" | ||
| created_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) | ||
|
|
||
| echo "" | ||
| echo "🔗 RP Endpoint: $rp_url" | ||
| echo "🚀 Sending PUT request with payload..." | ||
|
|
||
| # Execute request | ||
| curl -s -w "%{http_code}" --fail-with-body -o rp_response.log -X PUT "$rp_url" \ | ||
| -H "Authorization: Bearer $ACCESS_TOKEN" \ | ||
| -H "Content-Type: application/json" \ | ||
| -H "X-Ms-Identity-Url: https://dummy.identity.azure.net" \ | ||
| -H "X-Ms-Arm-Resource-System-Data: {\"createdBy\": \"dev-user\", \"createdByType\": \"User\", \"createdAt\": \"$created_at\"}" \ | ||
| --data-binary @external-auth-payload.json || echo "error" >> rp_response.log | ||
|
|
||
| echo "" | ||
| echo "Logging RP pod logs (top 30 lines)..." | ||
|
|
||
| echo "Switching to Service Cluster for logs..." | ||
| export KUBECONFIG=$(make infra.svc.aks.kubeconfigfile 2>/dev/null) | ||
|
|
||
| if [[ -z "$KUBECONFIG" ]]; then | ||
| echo "Could not switch to service cluster. Skipping pod log capture." >> rp_response.log | ||
| else | ||
| echo "Capturing logs from RP frontend pod..." | ||
| { | ||
| echo "" | ||
| echo "================ RP FRONTEND POD LOGS (top 30) ================" | ||
| kubectl logs deployment/aro-hcp-frontend -c aro-hcp-frontend -n aro-hcp --tail=30 2>&1 || echo "Failed to get logs" | ||
| } >> rp_response.log | ||
| fi | ||
|
|
||
| echo "" | ||
| if grep -q '"status": *"Succeeded"' rp_response.log; then | ||
| echo "✅ Successfully applied external auth config to RP." | ||
| STATUS[apply_rp]="✅" | ||
| else | ||
| echo "❌ Failed to apply config or confirm success." | ||
| echo "📄 See full logs in rp_response.log" | ||
| gum confirm "Retry apply to RP?" && apply_idp_config_via_rp || echo "↩️ Returning to main menu." | ||
| fi | ||
| } | ||
|
|
||
|
|
||
| test_login_redirect() { | ||
| print_status | ||
| source demo_env.sh | ||
| gum confirm "Open callback URL in browser?" && xdg-open "$callback_url" | ||
| STATUS[test]="✅" | ||
| } | ||
|
|
||
| run_flow() { | ||
| check_deps | ||
| verify_az_login | ||
| create_entra_app | ||
| create_ad_group | ||
| get_callback_url | ||
| update_app_redirect_uri | ||
| apply_idp_config_via_rp | ||
| test_login_redirect | ||
| print_status | ||
| echo "🎉 All tasks completed." | ||
| } | ||
|
|
||
| run_flow | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| { | ||
| "error": { | ||
| "code": "InternalServerError", | ||
| "message": "Internal server error." | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.