From 0a16b7252343335d536c5b8fe54418e45f15c59a Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Fri, 29 Nov 2024 00:02:57 +0100 Subject: [PATCH 1/9] self-hosted-runner: update to newer ARM template schema Signed-off-by: Johannes Schindelin --- azure-self-hosted-runners/azure-arm-template.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-self-hosted-runners/azure-arm-template.json b/azure-self-hosted-runners/azure-arm-template.json index 1bf6b140..036e440a 100644 --- a/azure-self-hosted-runners/azure-arm-template.json +++ b/azure-self-hosted-runners/azure-arm-template.json @@ -1,5 +1,5 @@ { - "$schema": "http://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", "contentVersion": "1.0.0.0", "parameters": { "githubActionsRunnerRegistrationUrl": { From 2ba75ae095fe292fca04f26172bfb676260f22c5 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Mon, 22 Jan 2024 16:06:19 +0100 Subject: [PATCH 2/9] self-hosted-runner: document two more required secrets To obtain the token that is necessary to register the runner, we need some sort of credentials, and the way we do that is by using the GitForWindowsHelper GitHub App's credentials. These have to be generated from the App ID and the private key, therefore we require them as secrets, which has previously not been documented. Signed-off-by: Johannes Schindelin --- .github/workflows/create-azure-self-hosted-runners.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/create-azure-self-hosted-runners.yml b/.github/workflows/create-azure-self-hosted-runners.yml index 62bb5539..831a637e 100644 --- a/.github/workflows/create-azure-self-hosted-runners.yml +++ b/.github/workflows/create-azure-self-hosted-runners.yml @@ -50,6 +50,8 @@ env: # AZURE_RESOURCE_GROUP - Resource group to create the runner(s) in # AZURE_VM_USERNAME - Username of the VM so you can RDP into it # AZURE_VM_PASSWORD - Password of the VM so you can RDP into it +# GH_APP_ID - The ID of the GitHub App whose credentials are to be used to obtain the runner token +# GH_APP_PRIVATE_KEY - The private key of the GitHub App whose credentials are to be used to obtain the runner token jobs: create-runner: runs-on: ubuntu-latest From f887d39a91042754ea4edf94f0b37f7fac693b1a Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Mon, 22 Jan 2024 18:15:37 +0100 Subject: [PATCH 3/9] self-hosted-runner: emulate a Boolean type for `deallocate_immediately` Signed-off-by: Johannes Schindelin --- .github/workflows/create-azure-self-hosted-runners.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/create-azure-self-hosted-runners.yml b/.github/workflows/create-azure-self-hosted-runners.yml index 831a637e..6e8a2635 100644 --- a/.github/workflows/create-azure-self-hosted-runners.yml +++ b/.github/workflows/create-azure-self-hosted-runners.yml @@ -20,7 +20,10 @@ on: required: false description: Repo to deploy the runner to. Only needed if runner_scope is set to "repo-level" (defaults to current repository) deallocate_immediately: - type: string + type: choice + options: + - false + - true required: true description: Deallocate the runner immediately after creating it (useful for spinning up runners preemptively) default: "false" From 11e20e037fcc686a3a0fb0424bd7409feeba8762 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Mon, 22 Jan 2024 15:41:40 +0100 Subject: [PATCH 4/9] self-hosted-runner: optionally make 'em non-ephemeral In private repositories, we can often get away with keeping a runner around, at least as a deallocated VM for most of the time. Signed-off-by: Johannes Schindelin --- .../create-azure-self-hosted-runners.yml | 10 ++++++++++ .../azure-arm-template.json | 8 +++++++- .../post-deployment-script.ps1 | 17 +++++++++++++---- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/.github/workflows/create-azure-self-hosted-runners.yml b/.github/workflows/create-azure-self-hosted-runners.yml index 6e8a2635..9f6715af 100644 --- a/.github/workflows/create-azure-self-hosted-runners.yml +++ b/.github/workflows/create-azure-self-hosted-runners.yml @@ -27,12 +27,21 @@ on: required: true description: Deallocate the runner immediately after creating it (useful for spinning up runners preemptively) default: "false" + ephemeral: + type: choice + options: + - false + - true + required: true + description: Start the runner in ephemeral mode (i.e. unregister after running one job) + default: "true" env: ACTIONS_RUNNER_SCOPE: ${{ github.event.inputs.runner_scope }} ACTIONS_RUNNER_ORG: "${{ github.event.inputs.runner_org || github.repository_owner }}" ACTIONS_RUNNER_REPO: "${{ github.event.inputs.runner_repo || github.event.repository.name }}" DEALLOCATE_IMMEDIATELY: ${{ github.event.inputs.deallocate_immediately }} + EPHEMERAL_RUNNER: ${{ github.event.inputs.ephemeral }} # This has to be a public URL that the VM can access after creation POST_DEPLOYMENT_SCRIPT_URL: https://raw.githubusercontent.com/${{ github.repository }}/${{ github.ref }}/azure-self-hosted-runners/post-deployment-script.ps1 # Note that you'll need "p" (arm64 processor) and ideally "d" (local temp disk). The number 4 stands for 4 CPU-cores. @@ -142,6 +151,7 @@ jobs: publicIpAddressName1="${{ steps.generate-vm-name.outputs.vm_name }}-ip" adminUsername="${{ secrets.AZURE_VM_USERNAME }}" adminPassword="${{ secrets.AZURE_VM_PASSWORD }}" + ephemeral="$EPHEMERAL_RUNNER" stopService="$DEALLOCATE_IMMEDIATELY" githubActionsRunnerPath="$ACTIONS_RUNNER_PATH" location="$AZURE_VM_REGION" diff --git a/azure-self-hosted-runners/azure-arm-template.json b/azure-self-hosted-runners/azure-arm-template.json index 036e440a..e9f4b6e9 100644 --- a/azure-self-hosted-runners/azure-arm-template.json +++ b/azure-self-hosted-runners/azure-arm-template.json @@ -36,6 +36,12 @@ "description": "Windows Computer Name. Can be maximum 15 characters." } }, + "ephemeral": { + "type": "string", + "metadata": { + "description": "(optional) Whether to spin up an ephemeral runner or not." + } + }, "stopService": { "type": "string", "metadata": { @@ -116,7 +122,7 @@ "firstFileNameString": "[variables('UriFileNamePieces')[sub(length(variables('UriFileNamePieces')), 1)]]", "firstFileNameBreakString": "[split(variables('firstFileNameString'), '?')]", "firstFileName": "[variables('firstFileNameBreakString')[0]]", - "postDeploymentScriptArguments": "[concat('-GitHubActionsRunnerToken ', parameters('githubActionsRunnerToken'), ' -GithubActionsRunnerRegistrationUrl ', parameters('githubActionsRunnerRegistrationUrl'), ' -GithubActionsRunnerName ', parameters('virtualMachineName'), ' -StopService ', parameters('stopService'), ' -GitHubActionsRunnerPath ', parameters('githubActionsRunnerPath'))]" + "postDeploymentScriptArguments": "[concat('-GitHubActionsRunnerToken ', parameters('githubActionsRunnerToken'), ' -GithubActionsRunnerRegistrationUrl ', parameters('githubActionsRunnerRegistrationUrl'), ' -GithubActionsRunnerName ', parameters('virtualMachineName'), ' -Ephemeral ', parameters('ephemeral'), ' -StopService ', parameters('stopService'), ' -GitHubActionsRunnerPath ', parameters('githubActionsRunnerPath'))]" }, "resources": [ { diff --git a/azure-self-hosted-runners/post-deployment-script.ps1 b/azure-self-hosted-runners/post-deployment-script.ps1 index ac727c47..b663274c 100644 --- a/azure-self-hosted-runners/post-deployment-script.ps1 +++ b/azure-self-hosted-runners/post-deployment-script.ps1 @@ -15,6 +15,10 @@ param ( [ValidateNotNullOrEmpty()] [string]$GithubActionsRunnerName, + [Parameter(Mandatory = $false, HelpMessage = "Start an ephemeral runner (this is the default)")] + [ValidateSet('true', 'false')] + [string]$Ephemeral = 'true', + [Parameter(Mandatory = $false, HelpMessage = "Stop Service immediately (useful for spinning up runners preemptively)")] [ValidateSet('true', 'false')] [string]$StopService = 'true', @@ -261,12 +265,17 @@ Write-Output "Installing GitHub Actions runner $($GitHubAction.Tag) as a Windows Add-Type -AssemblyName System.IO.Compression.FileSystem ; [System.IO.Compression.ZipFile]::ExtractToDirectory($GitHubAction.OutFile, $GitHubActionsRunnerPath) -Write-Output "Configuring the runner to shut down automatically after running" -Set-Content -Path "${GitHubActionsRunnerPath}\shut-down.ps1" -Value "shutdown -s -t 60 -d p:4:0 -c `"workflow job is done`"" -[System.Environment]::SetEnvironmentVariable("ACTIONS_RUNNER_HOOK_JOB_COMPLETED", "${GitHubActionsRunnerPath}\shut-down.ps1", [System.EnvironmentVariableTarget]::Machine) +If ($Ephemeral -ne 'true') { + $EphemeralOption = "" +} Else { + $EphemeralOption = "--ephemeral" + Write-Output "Configuring the runner to shut down automatically after running" + Set-Content -Path "${GitHubActionsRunnerPath}\shut-down.ps1" -Value "shutdown -s -t 60 -d p:4:0 -c `"workflow job is done`"" + [System.Environment]::SetEnvironmentVariable("ACTIONS_RUNNER_HOOK_JOB_COMPLETED", "${GitHubActionsRunnerPath}\shut-down.ps1", [System.EnvironmentVariableTarget]::Machine) +} Write-Output "Configuring the runner" -cmd.exe /c "${GitHubActionsRunnerPath}\config.cmd" --unattended --ephemeral --name ${GithubActionsRunnerName} --runasservice --labels $($GitHubAction.RunnerLabels) --url ${GithubActionsRunnerRegistrationUrl} --token ${GitHubActionsRunnerToken} +cmd.exe /c "${GitHubActionsRunnerPath}\config.cmd" --unattended $EphemeralOption --name ${GithubActionsRunnerName} --runasservice --labels $($GitHubAction.RunnerLabels) --url ${GithubActionsRunnerRegistrationUrl} --token ${GitHubActionsRunnerToken} # Ensure that the service was created. If not, exit with error code. if ($null -eq (Get-Service -Name "actions.runner.*")) { From d6c5749444329ff79979aa2ba6ccb34e0f73072d Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 28 Nov 2024 21:14:17 +0100 Subject: [PATCH 5/9] self-hosted-runner: switch to federated credentials Instead of storing stealable credentials in repository secrets, let's create a managed identity instead and use federated credentials via GitHub Actions' support for OpenID Connect. This binds the authorization to GitHub workflows running in a specific repository, and stealing the information won't enable an attacker to get authorized to use the Azure subscription. Note that this change _does_ require Client ID, Tenant ID and Subscription ID to be stored separately as repository secrets (although they do not _technically_ need to be kept secret, security is a game of layers, so why give away this information?). Also note that for some strange reason, the `contents: read` permission seems to be lost when introducing a `permissions:` section. Therefore we have to add it back explicitly, otherwise `actions/checkout` will fail in a private repository. Signed-off-by: Johannes Schindelin --- .github/workflows/azure-login/action.yml | 46 +++++++------------ .../workflows/cleanup-self-hosted-runners.yml | 30 +++++++++--- .../create-azure-self-hosted-runners.yml | 32 ++++++++++--- .../workflows/delete-self-hosted-runner.yml | 36 ++++++++++++--- 4 files changed, 94 insertions(+), 50 deletions(-) diff --git a/.github/workflows/azure-login/action.yml b/.github/workflows/azure-login/action.yml index 01408fd9..0e53ca4e 100644 --- a/.github/workflows/azure-login/action.yml +++ b/.github/workflows/azure-login/action.yml @@ -1,43 +1,31 @@ name: Azure Login description: Logs into Azure using a service principal inputs: - credentials: - description: Your credentials in JSON format + client-id: + description: The Client ID of the Azure Managed Identity + required: true + tenant-id: + description: The Tenant ID of the Azure Managed Identity (i.e. the Azure Active Directory in which the Identity lives) required: true runs: using: "composite" steps: - - name: Process Azure credentials + - name: Obtain OpenID Connect token uses: actions/github-script@v7 - env: - AZURE_CREDENTIALS: ${{ inputs.credentials }} - with: - script: | - if (!process.env.AZURE_CREDENTIALS) { - core.setFailed('The AZURE_CREDENTIALS secret is required.') - process.exit(1) - } - - const azureCredentials = JSON.parse(process.env.AZURE_CREDENTIALS) - const {clientId, clientSecret, tenantId, subscriptionId} = azureCredentials - - core.setSecret(clientId) - core.exportVariable('AZURE_CLIENT_ID', clientId) - - core.setSecret(clientSecret) - core.exportVariable('AZURE_CLIENT_SECRET', clientSecret) - - core.setSecret(tenantId) - core.exportVariable('AZURE_TENANT_ID', tenantId) + id: token + with: + script: | + const token = await core.getIDToken('api://AzureADTokenExchange') + core.setSecret(token) + return token - core.setSecret(subscriptionId) - core.exportVariable('AZURE_SUBSCRIPTION_ID', subscriptionId) - - name: Azure Login shell: bash run: | echo "Logging into Azure..." - az login --service-principal -u ${{ env.AZURE_CLIENT_ID }} -p ${{ env.AZURE_CLIENT_SECRET }} --tenant ${{ env.AZURE_TENANT_ID }} - echo "Setting subscription..." - az account set --subscription ${{ env.AZURE_SUBSCRIPTION_ID }} --output none + az login --service-principal \ + --username ${{ inputs.client-id }} \ + --tenant ${{ inputs.tenant-id }} \ + --federated-token ${{ steps.token.outputs.result }} \ + --output none diff --git a/.github/workflows/cleanup-self-hosted-runners.yml b/.github/workflows/cleanup-self-hosted-runners.yml index aa8aff4b..70c60470 100644 --- a/.github/workflows/cleanup-self-hosted-runners.yml +++ b/.github/workflows/cleanup-self-hosted-runners.yml @@ -7,12 +7,28 @@ on: - cron: "0 */6 * * *" workflow_dispatch: +permissions: + id-token: write # required for Azure login via OIDC + # The following secrets are required for this workflow to run: -# AZURE_CREDENTIALS - Credentials for the Azure CLI. It's recommended to set up a resource -# group specifically for self-hosted Actions Runners. -# az ad sp create-for-rbac --name "{YOUR_DESCRIPTIVE_NAME_HERE}" --role contributor \ -# --scopes /subscriptions/{SUBSCRIPTION_ID_HERE}/resourceGroups/{RESOURCE_GROUP_HERE} \ -# --sdk-auth +# AZURE_CLIENT_ID - The Client ID of an Azure Managed Identity. It is recommended to set up a resource +# group specifically for self-hosted Actions Runners, and to add a federated identity +# to authenticate as the currently-running GitHub workflow. +# az identity create --name -g +# az identity federated-credential create \ +# --identity-name \ +# --resource-group \ +# --name github-workflow \ +# --issuer https://token.actions.githubusercontent.com \ +# --subject repo:git-for-windows/git-for-windows-automation:ref:refs/heads/main \ +# --audiences api://AzureADTokenExchange +# MSYS_NO_PATHCONV=1 \ +# az role assignment create \ +# --assignee \ +# --scope '/subscriptions//resourceGroups/' \ +# --role 'Contributor' +# AZURE_TENANT_ID - The Tenant ID of the Azure Managed Identity (i.e. the Azure Active Directory in which +# the Identity lives) # AZURE_RESOURCE_GROUP - Resource group to find the runner(s) in. It's recommended to set up a resource # group specifically for self-hosted Actions Runners. jobs: @@ -24,8 +40,8 @@ jobs: - name: Azure Login uses: ./.github/workflows/azure-login with: - credentials: ${{ secrets.AZURE_CREDENTIALS }} - + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} - name: Discover VMs to delete env: GH_APP_ID: ${{ secrets.GH_APP_ID }} diff --git a/.github/workflows/create-azure-self-hosted-runners.yml b/.github/workflows/create-azure-self-hosted-runners.yml index 9f6715af..e4bf0477 100644 --- a/.github/workflows/create-azure-self-hosted-runners.yml +++ b/.github/workflows/create-azure-self-hosted-runners.yml @@ -53,12 +53,29 @@ env: AZURE_VM_REGION: westus2 AZURE_VM_IMAGE: win11-24h2-ent +permissions: + id-token: write # required for Azure login via OIDC + contents: read + # The following secrets are required for this workflow to run: -# AZURE_CREDENTIALS - Credentials for the Azure CLI. It's recommended to set up a resource -# group specifically for self-hosted Actions Runners. -# az ad sp create-for-rbac --name "{YOUR_DESCRIPTIVE_NAME_HERE}" --role contributor \ -# --scopes /subscriptions/{SUBSCRIPTION_ID_HERE}/resourceGroups/{RESOURCE_GROUP_HERE} \ -# --sdk-auth +# AZURE_CLIENT_ID - The Client ID of an Azure Managed Identity. It is recommended to set up a resource +# group specifically for self-hosted Actions Runners, and to add a federated identity +# to authenticate as the currently-running GitHub workflow. +# az identity create --name -g +# az identity federated-credential create \ +# --identity-name \ +# --resource-group \ +# --name github-workflow \ +# --issuer https://token.actions.githubusercontent.com \ +# --subject repo:git-for-windows/git-for-windows-automation:ref:refs/heads/main \ +# --audiences api://AzureADTokenExchange +# MSYS_NO_PATHCONV=1 \ +# az role assignment create \ +# --assignee \ +# --scope '/subscriptions//resourceGroups/' \ +# --role 'Contributor' +# AZURE_TENANT_ID - The Tenant ID of the Azure Managed Identity (i.e. the Azure Active Directory in which +# the Identity lives) # AZURE_RESOURCE_GROUP - Resource group to create the runner(s) in # AZURE_VM_USERNAME - Username of the VM so you can RDP into it # AZURE_VM_PASSWORD - Password of the VM so you can RDP into it @@ -163,8 +180,9 @@ jobs: - name: Azure Login uses: ./.github/workflows/azure-login with: - credentials: ${{ secrets.AZURE_CREDENTIALS }} - + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + - uses: azure/arm-deploy@v2 id: deploy-arm-template with: diff --git a/.github/workflows/delete-self-hosted-runner.yml b/.github/workflows/delete-self-hosted-runner.yml index e1878a84..bce1441a 100644 --- a/.github/workflows/delete-self-hosted-runner.yml +++ b/.github/workflows/delete-self-hosted-runner.yml @@ -12,13 +12,33 @@ on: env: ACTIONS_RUNNER_NAME: ${{ github.event.inputs.runner_name }} +permissions: + id-token: write # required for Azure login via OIDC + # The following secrets are required for this workflow to run: -# AZURE_CREDENTIALS - Credentials for the Azure CLI. It's recommended to set up a resource -# group specifically for self-hosted Actions Runners. -# az ad sp create-for-rbac --name "{YOUR_DESCRIPTIVE_NAME_HERE}" --role contributor \ -# --scopes /subscriptions/{SUBSCRIPTION_ID_HERE}/resourceGroups/{RESOURCE_GROUP_HERE} \ -# --sdk-auth -# AZURE_RESOURCE_GROUP - Resource group to create the runner(s) in +# AZURE_CLIENT_ID - The Client ID of an Azure Managed Identity. It is recommended to set up a resource +# group specifically for self-hosted Actions Runners, and to add a federated identity +# to authenticate as the currently-running GitHub workflow. +# az identity create --name -g +# az identity federated-credential create \ +# --identity-name \ +# --resource-group \ +# --name github-workflow \ +# --issuer https://token.actions.githubusercontent.com \ +# --subject repo:git-for-windows/git-for-windows-automation:ref:refs/heads/main \ +# --audiences api://AzureADTokenExchange +# MSYS_NO_PATHCONV=1 \ +# az role assignment create \ +# --assignee \ +# --scope '/subscriptions//resourceGroups/' \ +# --role 'Contributor' +# AZURE_TENANT_ID - The Tenant ID of the Azure Managed Identity (i.e. the Azure Active Directory in which +# the Identity lives) +# AZURE_SUBSCRIPTION_ID - The Subscription ID with which the Azure Managed Identity is associated +# (technically, this is not necessary for `az login --service-principal` with a +# managed identity, but `Azure/login` requires it anyway) +# AZURE_RESOURCE_GROUP - Resource group to find the runner in. It's recommended to set up a resource +# group specifically for self-hosted Actions Runners. jobs: delete-runner: runs-on: ubuntu-latest @@ -26,7 +46,9 @@ jobs: - name: Azure Login uses: azure/login@v2 with: - creds: ${{ secrets.AZURE_CREDENTIALS }} + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - name: Delete VM '${{ env.ACTIONS_RUNNER_NAME }}' uses: azure/CLI@v2 with: From 7a87c8fe031157a61653547f22d6271ea996a31b Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 28 Nov 2024 21:18:20 +0100 Subject: [PATCH 6/9] self-hosted-runner: switch back to using `Azure/login` We do not need the custom Action: `Azure/login` logs in using the Azure CLI, and subsequent `az` calls work just fine. So let's drop the complexity of the custom Action and go back to using `Azure/login` instead. The only downside is that we now need to specify the subscription ID even though `az login` would work without it. But that's a small price to pay, as the `delete-self-hosted-runner` workflow _still_ uses the `Azure/login` Action and has to have that information as a repository secret anyway. Signed-off-by: Johannes Schindelin --- .github/workflows/azure-login/action.yml | 31 ------------------- .../workflows/cleanup-self-hosted-runners.yml | 6 +++- .../create-azure-self-hosted-runners.yml | 6 +++- 3 files changed, 10 insertions(+), 33 deletions(-) delete mode 100644 .github/workflows/azure-login/action.yml diff --git a/.github/workflows/azure-login/action.yml b/.github/workflows/azure-login/action.yml deleted file mode 100644 index 0e53ca4e..00000000 --- a/.github/workflows/azure-login/action.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Azure Login -description: Logs into Azure using a service principal -inputs: - client-id: - description: The Client ID of the Azure Managed Identity - required: true - tenant-id: - description: The Tenant ID of the Azure Managed Identity (i.e. the Azure Active Directory in which the Identity lives) - required: true - -runs: - using: "composite" - steps: - - name: Obtain OpenID Connect token - uses: actions/github-script@v7 - id: token - with: - script: | - const token = await core.getIDToken('api://AzureADTokenExchange') - core.setSecret(token) - return token - - - name: Azure Login - shell: bash - run: | - echo "Logging into Azure..." - az login --service-principal \ - --username ${{ inputs.client-id }} \ - --tenant ${{ inputs.tenant-id }} \ - --federated-token ${{ steps.token.outputs.result }} \ - --output none diff --git a/.github/workflows/cleanup-self-hosted-runners.yml b/.github/workflows/cleanup-self-hosted-runners.yml index 70c60470..f5ab5482 100644 --- a/.github/workflows/cleanup-self-hosted-runners.yml +++ b/.github/workflows/cleanup-self-hosted-runners.yml @@ -29,6 +29,9 @@ permissions: # --role 'Contributor' # AZURE_TENANT_ID - The Tenant ID of the Azure Managed Identity (i.e. the Azure Active Directory in which # the Identity lives) +# AZURE_SUBSCRIPTION_ID - The Subscription ID with which the Azure Managed Identity is associated +# (technically, this is not necessary for `az login --service-principal` with a +# managed identity, but `Azure/login` requires it anyway) # AZURE_RESOURCE_GROUP - Resource group to find the runner(s) in. It's recommended to set up a resource # group specifically for self-hosted Actions Runners. jobs: @@ -38,10 +41,11 @@ jobs: steps: - uses: actions/checkout@v4 - name: Azure Login - uses: ./.github/workflows/azure-login + uses: azure/login@v2 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - name: Discover VMs to delete env: GH_APP_ID: ${{ secrets.GH_APP_ID }} diff --git a/.github/workflows/create-azure-self-hosted-runners.yml b/.github/workflows/create-azure-self-hosted-runners.yml index e4bf0477..d3967954 100644 --- a/.github/workflows/create-azure-self-hosted-runners.yml +++ b/.github/workflows/create-azure-self-hosted-runners.yml @@ -76,6 +76,9 @@ permissions: # --role 'Contributor' # AZURE_TENANT_ID - The Tenant ID of the Azure Managed Identity (i.e. the Azure Active Directory in which # the Identity lives) +# AZURE_SUBSCRIPTION_ID - The Subscription ID with which the Azure Managed Identity is associated +# (technically, this is not necessary for `az login --service-principal` with a +# managed identity, but `Azure/login` requires it anyway) # AZURE_RESOURCE_GROUP - Resource group to create the runner(s) in # AZURE_VM_USERNAME - Username of the VM so you can RDP into it # AZURE_VM_PASSWORD - Password of the VM so you can RDP into it @@ -178,10 +181,11 @@ jobs: echo "AZURE_ARM_PARAMETERS=$AZURE_ARM_PARAMETERS" >> $GITHUB_ENV - name: Azure Login - uses: ./.github/workflows/azure-login + uses: azure/login@v2 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - uses: azure/arm-deploy@v2 id: deploy-arm-template From 15ecd92f0bc54ac4927bb18cb1288db4a4890e1e Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 28 Nov 2024 22:27:39 +0100 Subject: [PATCH 7/9] self-hosted-runner: pass the post-deployment script without a URL When running this workflow in a private repository, providing a public URL to the post-deployment script simply would not work. It is not even possible to use the `GITHUB_TOKEN` to construct an `Invoke-WebRequest` call: The `GITHUB_TOKEN` lacks the permission to access the resource. So let's just pass this post-deployment script as a parameter. Since it is somewhat large-ish, weighing 14kB, let's compress it. And since the compressed file is binary and cannot easily be passed around, let's Base64-encode it. The result is still somewhat large (5.6kB) but at least this works and still leaves some room for additional stuff to be put into the post-deployment script. Signed-off-by: Johannes Schindelin --- .../create-azure-self-hosted-runners.yml | 17 ++++++++++++---- .../azure-arm-template.json | 20 +++++++++---------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/.github/workflows/create-azure-self-hosted-runners.yml b/.github/workflows/create-azure-self-hosted-runners.yml index d3967954..bf41e1f5 100644 --- a/.github/workflows/create-azure-self-hosted-runners.yml +++ b/.github/workflows/create-azure-self-hosted-runners.yml @@ -42,8 +42,6 @@ env: ACTIONS_RUNNER_REPO: "${{ github.event.inputs.runner_repo || github.event.repository.name }}" DEALLOCATE_IMMEDIATELY: ${{ github.event.inputs.deallocate_immediately }} EPHEMERAL_RUNNER: ${{ github.event.inputs.ephemeral }} - # This has to be a public URL that the VM can access after creation - POST_DEPLOYMENT_SCRIPT_URL: https://raw.githubusercontent.com/${{ github.repository }}/${{ github.ref }}/azure-self-hosted-runners/post-deployment-script.ps1 # Note that you'll need "p" (arm64 processor) and ideally "d" (local temp disk). The number 4 stands for 4 CPU-cores. # For a convenient overview of all arm64 VM types, see e.g. https://azureprice.net/?_cpuArchitecture=Arm64 AZURE_VM_TYPE: Standard_D4plds_v5 @@ -161,10 +159,21 @@ jobs: ACTIONS_RUNNER_PATH="D:\a" fi + # Zip up and Base64-encode the post-deployment script; We used to provide a public URL + # for that script instead, but that does not work in private repositories (and we could + # not even use the `GITHUB_TOKEN` to access the file because it lacks the necessary + # scope to read repository contents). + POST_DEPLOYMENT_SCRIPT_ZIP_BASE64="$( + cd azure-self-hosted-runners && + zip -9 tmp.zip post-deployment-script.ps1 >&2 && + base64 -w 0 tmp.zip + )" + AZURE_ARM_PARAMETERS=$(tr '\n' ' ' <<-END githubActionsRunnerRegistrationUrl="$ACTIONS_RUNNER_REGISTRATION_URL" githubActionsRunnerToken="$ACTIONS_RUNNER_TOKEN" - postDeploymentPsScriptUrl="$POST_DEPLOYMENT_SCRIPT_URL" + postDeploymentScriptZipBase64="$POST_DEPLOYMENT_SCRIPT_ZIP_BASE64" + postDeploymentScriptFileName="post-deployment-script.ps1" virtualMachineImage="$AZURE_VM_IMAGE" virtualMachineName="${{ steps.generate-vm-name.outputs.vm_name }}" virtualMachineSize="$AZURE_VM_TYPE" @@ -216,7 +225,7 @@ jobs: if: always() env: CUSTOM_SCRIPT_OUTPUT: ${{ steps.deploy-arm-template.outputs.customScriptInstanceView }} - run: echo "$CUSTOM_SCRIPT_OUTPUT" | jq -r '.substatuses[0].message' + run: echo "$CUSTOM_SCRIPT_OUTPUT" | jq -r '.substatuses[0].message' - name: Deallocate the VM for later use if: env.DEALLOCATE_IMMEDIATELY == 'true' diff --git a/azure-self-hosted-runners/azure-arm-template.json b/azure-self-hosted-runners/azure-arm-template.json index e9f4b6e9..ee39d391 100644 --- a/azure-self-hosted-runners/azure-arm-template.json +++ b/azure-self-hosted-runners/azure-arm-template.json @@ -22,11 +22,18 @@ "description": "Path to the Actions Runner. Keep this path short to prevent Long Path issues, e.g. D:\\a" } }, - "postDeploymentPsScriptUrl": { + "postDeploymentScriptZipBase64": { "type": "string", "minLength": 6, "metadata": { - "description": "URL to the post-deployment PowerShell script. E.g. https://raw.githubusercontent.com/git-for-windows/git-for-windows-automation/main/azure-self-hosted-runners/post-deployment-script.ps1" + "description": "Base64-encoded .zip file containing the post-deployment script" + } + }, + "postDeploymentScriptFileName": { + "type": "string", + "minLength": 6, + "metadata": { + "description": "File name of the post-deployment script" } }, "computerName": { @@ -118,10 +125,6 @@ "vnetName": "[concat(parameters('virtualMachineName'), '-vnet')]", "vnetId": "[resourceId(resourceGroup().name,'Microsoft.Network/virtualNetworks', concat(parameters('virtualMachineName'), '-vnet'))]", "subnetRef": "[concat(variables('vnetId'), '/subnets/', parameters('subnetName'))]", - "UriFileNamePieces": "[split(parameters('postDeploymentPsScriptUrl'), '/')]", - "firstFileNameString": "[variables('UriFileNamePieces')[sub(length(variables('UriFileNamePieces')), 1)]]", - "firstFileNameBreakString": "[split(variables('firstFileNameString'), '?')]", - "firstFileName": "[variables('firstFileNameBreakString')[0]]", "postDeploymentScriptArguments": "[concat('-GitHubActionsRunnerToken ', parameters('githubActionsRunnerToken'), ' -GithubActionsRunnerRegistrationUrl ', parameters('githubActionsRunnerRegistrationUrl'), ' -GithubActionsRunnerName ', parameters('virtualMachineName'), ' -Ephemeral ', parameters('ephemeral'), ' -StopService ', parameters('stopService'), ' -GitHubActionsRunnerPath ', parameters('githubActionsRunnerPath'))]" }, "resources": [ @@ -269,11 +272,8 @@ "type": "CustomScriptExtension", "typeHandlerVersion": "1.9", "autoUpgradeMinorVersion": true, - "settings": { - "fileUris": "[split(parameters('postDeploymentPsScriptUrl'), ' ')]" - }, "protectedSettings": { - "commandToExecute": "[concat('powershell -ExecutionPolicy Unrestricted -File ', variables('firstFileName'), ' ', variables('postDeploymentScriptArguments'))]" + "commandToExecute": "[concat('powershell -ExecutionPolicy Unrestricted -Command \"[System.IO.File]::WriteAllBytes(\\\"tmp.zip\\\", [System.Convert]::FromBase64String(\\\"', parameters('postDeploymentScriptZipBase64'), '\\\")); Add-Type -AssemblyName System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::ExtractToDirectory(\\\"tmp.zip\\\", \\\".\\\"); & .\\', parameters('postDeploymentScriptFileName'), ' ', variables('postDeploymentScriptArguments'), '\"')]" } } } From 62691497cce0ed16d87f31fef5491e52a8f00fec Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Mon, 22 Jan 2024 15:46:30 +0100 Subject: [PATCH 8/9] self-hosted-runner: allow running in private repositories GitHub's documentation provides a stern warning against registering self-hosted runners on public repositories: https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners#self-hosted-runner-security To counter that, we specifically spin up ephemeral self-hosted runners in `git-for-windows/git-for-windows-automation` and have automation to prevent unauthorized people from trying to play games with our runners. However, for testing in separate repositories, this strategy is utterly inconvenient. And unnecessary, when running in a private repository anyway. Except that we need to have a public URL for the post-deployment script. So let's work around that by hard-coding the CI token into that URL. This should be good enough, especially when we scrub the token from the logs (manually, if necessary). Signed-off-by: Johannes Schindelin --- .../create-azure-self-hosted-runners.yml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/create-azure-self-hosted-runners.yml b/.github/workflows/create-azure-self-hosted-runners.yml index bf41e1f5..8e8ae8d3 100644 --- a/.github/workflows/create-azure-self-hosted-runners.yml +++ b/.github/workflows/create-azure-self-hosted-runners.yml @@ -126,14 +126,21 @@ jobs: # https://github.com/actions/runner/issues/475 - name: Generate Actions Runner token and registration URL run: | + # We need to URL-encode the user name because it usually is a GitHub App, which means that + # it has the suffix `[bot]`. If un-encoded, this would cause a cURL error "bad range in URL" + # because it would mistake this for an IPv6 address or something like that. + user_pwd="$(jq -n \ + --arg user '${{ github.actor }}' \ + --arg pwd '${{ secrets.GITHUB_TOKEN }}' \ + '$user | @uri + ":" + $pwd')" case "$ACTIONS_RUNNER_SCOPE" in "org-level") - ACTIONS_API_URL="https://api.github.com/repos/$ACTIONS_RUNNER_ORG/actions/runners/registration-token" - ACTIONS_RUNNER_REGISTRATION_URL="https://github.com/$ACTIONS_RUNNER_ORG" + ACTIONS_API_URL="https://$user_pwd@api.github.com/repos/$ACTIONS_RUNNER_ORG/actions/runners/registration-token" + ACTIONS_RUNNER_REGISTRATION_URL="https://$user_pwd@github.com/$ACTIONS_RUNNER_ORG" ;; "repo-level") - ACTIONS_API_URL="https://api.github.com/repos/$ACTIONS_RUNNER_ORG/$ACTIONS_RUNNER_REPO/actions/runners/registration-token" - ACTIONS_RUNNER_REGISTRATION_URL="https://github.com/$ACTIONS_RUNNER_ORG/$ACTIONS_RUNNER_REPO" + ACTIONS_API_URL="https://$user_pwd@api.github.com/repos/$ACTIONS_RUNNER_ORG/$ACTIONS_RUNNER_REPO/actions/runners/registration-token" + ACTIONS_RUNNER_REGISTRATION_URL="https://$user_pwd@github.com/$ACTIONS_RUNNER_ORG/$ACTIONS_RUNNER_REPO" ;; *) echo "Unsupported runner scope: $ACTIONS_RUNNER_SCOPE" @@ -225,7 +232,7 @@ jobs: if: always() env: CUSTOM_SCRIPT_OUTPUT: ${{ steps.deploy-arm-template.outputs.customScriptInstanceView }} - run: echo "$CUSTOM_SCRIPT_OUTPUT" | jq -r '.substatuses[0].message' + run: echo "$CUSTOM_SCRIPT_OUTPUT" | jq -r '.substatuses[0].message' | sed 's/${{ secrets.GITHUB_TOKEN }}/***/g' - name: Deallocate the VM for later use if: env.DEALLOCATE_IMMEDIATELY == 'true' From ebc7f080c6e1a4d74b7a4585f6969eec3bb8871a Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Fri, 29 Nov 2024 00:57:19 +0100 Subject: [PATCH 9/9] self-hosted-runner: don't use a public IP in a private repository Security is a game of layers, the less attack surface the better. Signed-off-by: Johannes Schindelin --- .../create-azure-self-hosted-runners.yml | 4 +++- .../azure-arm-template.json | 21 +++++++++++-------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/.github/workflows/create-azure-self-hosted-runners.yml b/.github/workflows/create-azure-self-hosted-runners.yml index 8e8ae8d3..f9c0e56d 100644 --- a/.github/workflows/create-azure-self-hosted-runners.yml +++ b/.github/workflows/create-azure-self-hosted-runners.yml @@ -176,6 +176,8 @@ jobs: base64 -w 0 tmp.zip )" + PUBLIC_IP_ADDRESS_NAME1="${{ github.repository_visibility != 'private' && format('{0}-ip', steps.generate-vm-name.outputs.vm_name) || '' }}" + AZURE_ARM_PARAMETERS=$(tr '\n' ' ' <<-END githubActionsRunnerRegistrationUrl="$ACTIONS_RUNNER_REGISTRATION_URL" githubActionsRunnerToken="$ACTIONS_RUNNER_TOKEN" @@ -184,7 +186,7 @@ jobs: virtualMachineImage="$AZURE_VM_IMAGE" virtualMachineName="${{ steps.generate-vm-name.outputs.vm_name }}" virtualMachineSize="$AZURE_VM_TYPE" - publicIpAddressName1="${{ steps.generate-vm-name.outputs.vm_name }}-ip" + publicIpAddressName1="$PUBLIC_IP_ADDRESS_NAME1" adminUsername="${{ secrets.AZURE_VM_USERNAME }}" adminPassword="${{ secrets.AZURE_VM_PASSWORD }}" ephemeral="$EPHEMERAL_RUNNER" diff --git a/azure-self-hosted-runners/azure-arm-template.json b/azure-self-hosted-runners/azure-arm-template.json index ee39d391..4fa0727c 100644 --- a/azure-self-hosted-runners/azure-arm-template.json +++ b/azure-self-hosted-runners/azure-arm-template.json @@ -125,7 +125,14 @@ "vnetName": "[concat(parameters('virtualMachineName'), '-vnet')]", "vnetId": "[resourceId(resourceGroup().name,'Microsoft.Network/virtualNetworks', concat(parameters('virtualMachineName'), '-vnet'))]", "subnetRef": "[concat(variables('vnetId'), '/subnets/', parameters('subnetName'))]", - "postDeploymentScriptArguments": "[concat('-GitHubActionsRunnerToken ', parameters('githubActionsRunnerToken'), ' -GithubActionsRunnerRegistrationUrl ', parameters('githubActionsRunnerRegistrationUrl'), ' -GithubActionsRunnerName ', parameters('virtualMachineName'), ' -Ephemeral ', parameters('ephemeral'), ' -StopService ', parameters('stopService'), ' -GitHubActionsRunnerPath ', parameters('githubActionsRunnerPath'))]" + "postDeploymentScriptArguments": "[concat('-GitHubActionsRunnerToken ', parameters('githubActionsRunnerToken'), ' -GithubActionsRunnerRegistrationUrl ', parameters('githubActionsRunnerRegistrationUrl'), ' -GithubActionsRunnerName ', parameters('virtualMachineName'), ' -Ephemeral ', parameters('ephemeral'), ' -StopService ', parameters('stopService'), ' -GitHubActionsRunnerPath ', parameters('githubActionsRunnerPath'))]", + "publicIpAddressName1": "[if(equals(parameters('publicIpAddressName1'), ''), 'dummy', parameters('publicIpAddressName1'))]", + "publicIpAddressId": { + "id": "[resourceId(resourceGroup().name, 'Microsoft.Network/publicIpAddresses', parameters('publicIpAddressName1'))]", + "properties": { + "deleteOption": "[parameters('pipDeleteOption')]" + } + } }, "resources": [ { @@ -136,7 +143,7 @@ "dependsOn": [ "[concat('Microsoft.Network/networkSecurityGroups/', variables('nsgName'))]", "[concat('Microsoft.Network/virtualNetworks/', variables('vnetName'))]", - "[concat('Microsoft.Network/publicIpAddresses/', parameters('publicIpAddressName1'))]" + "[concat('Microsoft.Network/publicIpAddresses/', variables('publicIpAddressName1'))]" ], "properties": { "ipConfigurations": [ @@ -147,12 +154,7 @@ "id": "[variables('subnetRef')]" }, "privateIPAllocationMethod": "Dynamic", - "publicIpAddress": { - "id": "[resourceId(resourceGroup().name, 'Microsoft.Network/publicIpAddresses', parameters('publicIpAddressName1'))]", - "properties": { - "deleteOption": "[parameters('pipDeleteOption')]" - } - } + "publicIpAddress": "[if(not(equals(parameters('publicIpAddressName1'), '')), variables('publicIpAddressId'), null())]" } } ], @@ -184,7 +186,8 @@ } }, { - "name": "[parameters('publicIpAddressName1')]", + "condition": "[not(equals(parameters('publicIpAddressName1'), ''))]", + "name": "[variables('publicIpAddressName1')]", "type": "Microsoft.Network/publicIpAddresses", "apiVersion": "2020-08-01", "location": "[parameters('location')]",