garm/internal/templates/userdata/github_windows_userdata.tmpl
Gabriel 23f92bc335
Add runner install template management (#525)
* Add template api endpoints

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>

* Added template bypass

Pools and scale sets will automatically migrate to the new template
system for runner install scripts. If a pool or a scale set cannot be
migrate, it is left alone. It is expected that users set a runner install
template manually for scenarios we don't yet have a template for (windows
on gitea for example).

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>

* Integrate templates with pool create/update

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>

* Add webapp integration with templates

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>

* Add unit tests

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>

* Populate all relevant context fields

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>

* Update dependencies

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>

* Fix lint

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>

* Validate uint

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>

* Add CLI template management

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>

* Some editor improvements and bugfixes

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>

* Fix scale set return values post create

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>

* Fix template websocket events filter

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>

---------

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
2025-09-23 13:46:27 +03:00

509 lines
No EOL
17 KiB
Cheetah

#ps1_sysnative
Param(
[Parameter(Mandatory=$false)]
[string]$Token="{{.CallbackToken}}"
)
$ErrorActionPreference="Stop"
function Start-ExecuteWithRetry {
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)]
[ScriptBlock]$ScriptBlock,
[int]$MaxRetryCount=10,
[int]$RetryInterval=3,
[string]$RetryMessage,
[array]$ArgumentList=@()
)
PROCESS {
$currentErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = "Continue"
$retryCount = 0
while ($true) {
try {
$res = Invoke-Command -ScriptBlock $ScriptBlock -ArgumentList $ArgumentList
$ErrorActionPreference = $currentErrorActionPreference
return $res
} catch [System.Exception] {
$retryCount++
if ($_.Exception -is [System.Net.WebException]) {
$webResponse = $_.Exception.Response
# Skip retry on Error: 4XX (e.g. 401 Unauthorized, 404 Not Found etc.)
if ($webResponse -and $webResponse.StatusCode -ge 400 -and $webResponse.StatusCode -lt 500) {
# Skip retry on 4xx errors
Write-Output "Encountered non-retryable error (4xx): $($_.Exception.Message)"
$ErrorActionPreference = $currentErrorActionPreference
throw
}
}
if ($retryCount -gt $MaxRetryCount) {
$ErrorActionPreference = $currentErrorActionPreference
throw
} else {
if ($RetryMessage) {
Write-Output $RetryMessage
} elseif ($_) {
Write-Output $_
}
Start-Sleep -Seconds $RetryInterval
}
}
}
}
}
function Get-RandomString {
[CmdletBinding()]
Param(
[int]$Length=13
)
PROCESS {
if($Length -lt 6) {
$Length = 6
}
$special = @(44, 45, 46, 64)
$numeric = 48..57
$upper = 65..90
$lower = 97..122
$passwd = [System.Collections.Generic.List[object]](New-object "System.Collections.Generic.List[object]")
for($i=0; $i -lt $Length-4; $i++){
$c = get-random -input ($special + $numeric + $upper + $lower)
$passwd.Add([char]$c)
}
$passwd.Add([char](get-random -input $numeric))
$passwd.Add([char](get-random -input $special))
$passwd.Add([char](get-random -input $upper))
$passwd.Add([char](get-random -input $lower))
$Random = New-Object Random
return [string]::join("",($passwd|Sort-Object {$Random.Next()}))
}
}
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
using System.Text;
public class GrantSysPrivileges
{
[StructLayout(LayoutKind.Sequential)]
public struct LSA_UNICODE_STRING
{
public ushort Length;
public ushort MaximumLength;
public IntPtr Buffer;
}
[StructLayout(LayoutKind.Sequential)]
public struct LSA_OBJECT_ATTRIBUTES
{
public int Length;
public IntPtr RootDirectory;
public IntPtr ObjectName;
public uint Attributes;
public IntPtr SecurityDescriptor;
public IntPtr SecurityQualityOfService;
}
[DllImport("advapi32.dll", SetLastError=true)]
public static extern uint LsaOpenPolicy(
ref LSA_UNICODE_STRING SystemName,
ref LSA_OBJECT_ATTRIBUTES ObjectAttributes,
uint DesiredAccess,
out IntPtr PolicyHandle
);
[DllImport("advapi32.dll", SetLastError=true)]
public static extern uint LsaAddAccountRights(
IntPtr PolicyHandle,
IntPtr AccountSid,
LSA_UNICODE_STRING[] UserRights,
uint CountOfRights
);
[DllImport("advapi32.dll")]
public static extern uint LsaClose(IntPtr PolicyHandle);
[DllImport("advapi32.dll")]
public static extern uint LsaNtStatusToWinError(uint status);
public const uint POLICY_ALL_ACCESS = 0x00F0FFF;
public static uint GrantPrivilege(byte[] sid, string[] rights)
{
LSA_OBJECT_ATTRIBUTES loa = new LSA_OBJECT_ATTRIBUTES();
LSA_UNICODE_STRING systemName = new LSA_UNICODE_STRING();
IntPtr policyHandle;
uint result = LsaOpenPolicy(ref systemName, ref loa, POLICY_ALL_ACCESS, out policyHandle);
if (result != 0)
{
return LsaNtStatusToWinError(result);
}
LSA_UNICODE_STRING[] userRights = new LSA_UNICODE_STRING[rights.Length];
for (int i = 0; i < rights.Length; i++)
{
byte[] bytes = Encoding.Unicode.GetBytes(rights[i]);
IntPtr ptr = Marshal.AllocHGlobal(bytes.Length);
Marshal.Copy(bytes, 0, ptr, bytes.Length);
userRights[i].Buffer = ptr;
userRights[i].Length = (ushort)bytes.Length;
userRights[i].MaximumLength = (ushort)(bytes.Length);
}
IntPtr sidPtr = Marshal.AllocHGlobal(sid.Length);
Marshal.Copy(sid, 0, sidPtr, sid.Length);
result = LsaAddAccountRights(policyHandle, sidPtr, userRights, (uint)rights.Length);
LsaClose(policyHandle);
foreach (var right in userRights)
{
Marshal.FreeHGlobal(right.Buffer);
}
Marshal.FreeHGlobal(sidPtr);
return LsaNtStatusToWinError(result);
}
}
"@ -Language CSharp
function Invoke-FastWebRequest {
[CmdletBinding()]
Param(
[Parameter(Mandatory=$True,ValueFromPipeline=$true,Position=0)]
[System.Uri]$Uri,
[Parameter(Position=1)]
[string]$OutFile,
[Hashtable]$Headers=@{},
[switch]$SkipIntegrityCheck=$false
)
PROCESS
{
if(!([System.Management.Automation.PSTypeName]'System.Net.Http.HttpClient').Type)
{
$assembly = [System.Reflection.Assembly]::LoadWithPartialName("System.Net.Http")
}
if(!$OutFile) {
$OutFile = $Uri.PathAndQuery.Substring($Uri.PathAndQuery.LastIndexOf("/") + 1)
if(!$OutFile) {
throw "The ""OutFile"" parameter needs to be specified"
}
}
$fragment = $Uri.Fragment.Trim('#')
if ($fragment) {
$details = $fragment.Split("=")
$algorithm = $details[0]
$hash = $details[1]
}
if (!$SkipIntegrityCheck -and $fragment -and (Test-Path $OutFile)) {
try {
return (Test-FileIntegrity -File $OutFile -Algorithm $algorithm -ExpectedHash $hash)
} catch {
Remove-Item $OutFile
}
}
$client = new-object System.Net.Http.HttpClient
foreach ($k in $Headers.Keys){
$client.DefaultRequestHeaders.Add($k, $Headers[$k])
}
$task = $client.GetStreamAsync($Uri)
$response = $task.Result
if($task.IsFaulted) {
$msg = "Request for URL '{0}' is faulted. Task status: {1}." -f @($Uri, $task.Status)
if($task.Exception) {
$msg += "Exception details: {0}" -f @($task.Exception)
}
Throw $msg
}
$outStream = New-Object IO.FileStream $OutFile, Create, Write, None
try {
$totRead = 0
$buffer = New-Object Byte[] 1MB
while (($read = $response.Read($buffer, 0, $buffer.Length)) -gt 0) {
$totRead += $read
$outStream.Write($buffer, 0, $read);
}
}
finally {
$outStream.Close()
}
if(!$SkipIntegrityCheck -and $fragment) {
Test-FileIntegrity -File $OutFile -Algorithm $algorithm -ExpectedHash $hash
}
}
}
function Import-Certificate() {
[CmdletBinding()]
param (
[parameter(Mandatory=$true)]
$CertificateData,
[parameter(Mandatory=$false)]
[System.Security.Cryptography.X509Certificates.StoreLocation]$StoreLocation="LocalMachine",
[parameter(Mandatory=$false)]
[System.Security.Cryptography.X509Certificates.StoreName]$StoreName="TrustedPublisher"
)
PROCESS
{
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store(
$StoreName, $StoreLocation)
$store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite)
$cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($CertificateData)
$store.Add($cert)
}
}
function Invoke-APICall() {
[CmdletBinding()]
param (
[parameter(Mandatory=$true)]
[object]$Payload,
[parameter(Mandatory=$true)]
[string]$CallbackURL
)
PROCESS{
Invoke-WebRequest -UseBasicParsing -Method Post -Headers @{"Accept"="application/json"; "Authorization"="Bearer $Token"} -Uri $CallbackURL -Body (ConvertTo-Json $Payload) | Out-Null
}
}
function Update-GarmStatus() {
[CmdletBinding()]
param (
[parameter(Mandatory=$true)]
[string]$Message,
[parameter(Mandatory=$false)]
[int64]$AgentID=0,
[parameter(Mandatory=$false)]
[string]$Status="installing",
[parameter(Mandatory=$true)]
[string]$CallbackURL
)
PROCESS{
$body = @{
"status"=$Status
"message"=$Message
}
if ($AgentID -ne 0) {
$body["agent_id"] = $AgentID
}
Invoke-APICall -Payload $body -CallbackURL $CallbackURL | Out-Null
}
}
function Invoke-GarmSuccess() {
[CmdletBinding()]
param (
[parameter(Mandatory=$true)]
[string]$Message,
[parameter(Mandatory=$true)]
[int64]$AgentID,
[parameter(Mandatory=$true)]
[string]$CallbackURL
)
PROCESS{
Update-GarmStatus -Message $Message -AgentID $AgentID -CallbackURL $CallbackURL -Status "idle" | Out-Null
}
}
function Invoke-GarmFailure() {
[CmdletBinding()]
param (
[parameter(Mandatory=$true)]
[string]$Message,
[parameter(Mandatory=$true)]
[string]$CallbackURL
)
PROCESS{
Update-GarmStatus -Message $Message -CallbackURL $CallbackURL -Status "failed" | Out-Null
Throw $Message
}
}
function Set-SystemInfo {
[CmdletBinding()]
param (
[parameter(Mandatory=$true)]
[string]$CallbackURL,
[parameter(Mandatory=$true)]
[string]$RunnerDir,
[parameter(Mandatory=$true)]
[string]$BearerToken
)
# Construct the path to the .runner file
$agentInfoFile = Join-Path $RunnerDir ".runner"
# Read and parse the JSON content from the .runner file
$agentInfo = ConvertFrom-Json (Get-Content -Raw -Path $agentInfoFile)
$AgentId = $agentInfo.agent_id
# Retrieve OS information
$osInfo = Get-WmiObject -Class Win32_OperatingSystem
$osName = $osInfo.Caption
$osVersion = $osInfo.Version
# Strip status from the callback URL
if ($CallbackUrl -match '^(.*)/status(/)?$') {
$CallbackUrl = $matches[1]
}
$SysInfoUrl = "$CallbackUrl/system-info/"
$Payload = @{
os_name = $OSName
os_version = $OSVersion
agent_id = $AgentId
} | ConvertTo-Json
# Send the POST request
try {
Invoke-RestMethod -Uri $SysInfoUrl -Method Post -Body $Payload -ContentType 'application/json' -Headers @{ 'Authorization' = "Bearer $BearerToken" } -ErrorAction Stop
} catch {
Write-Output "Failed to send the system information."
}
}
$GHRunnerGroup = "{{.GitHubRunnerGroup}}"
function Install-Runner() {
$CallbackURL="{{.CallbackURL}}"
if (!($CallbackURL -match "^(.*)/status(/)?$")) {
$CallbackURL = "$CallbackURL/status"
}
if ($Token.Length -eq 0) {
Throw "missing callback authentication token"
}
try {
$MetadataURL="{{.MetadataURL}}"
$DownloadURL="{{.DownloadURL}}"
if($MetadataURL -eq ""){
Throw "missing metadata URL"
}
# Create user with administrator rights to run service as
$userPasswd = Get-RandomString -Length 10
$secPasswd = ConvertTo-SecureString "$userPasswd" -AsPlainText -Force
$userName = "runner"
$user = Get-LocalUser -Name $userName -ErrorAction SilentlyContinue
if (-not $user) {
New-LocalUser -Name $userName -Password $secPasswd -PasswordNeverExpires -UserMayNotChangePassword
} else {
Set-LocalUser -PasswordNeverExpires $true -Name $userName -Password $secPasswd
}
$pscreds = New-Object System.Management.Automation.PSCredential (".\$userName", $secPasswd)
$hasUser = Get-LocalGroupMember -SID S-1-5-32-544 -Member $userName -ErrorAction SilentlyContinue
if (-not $hasUser){
Add-LocalGroupMember -SID S-1-5-32-544 -Member $userName
}
$ntAcct = New-Object System.Security.Principal.NTAccount($userName)
$sid = $ntAcct.Translate([System.Security.Principal.SecurityIdentifier])
$sidBytes = New-Object byte[] ($sid.BinaryLength)
$sid.GetBinaryForm($sidBytes, 0)
$result = [GrantSysPrivileges]::GrantPrivilege($sidBytes, ("SeBatchLogonRight", "SeServiceLogonRight"))
if ($result -ne 0) {
Throw "Failed to grant privileges"
}
$bundle = wget -UseBasicParsing -Headers @{"Accept"="application/json"; "Authorization"="Bearer $Token"} -Uri $MetadataURL/system/cert-bundle
$converted = ConvertFrom-Json $bundle
foreach ($i in $converted.root_certificates.psobject.Properties){
$data = [System.Convert]::FromBase64String($i.Value)
Import-Certificate -CertificateData $data -StoreName Root -StoreLocation LocalMachine
}
$runnerDir = "C:\actions-runner"
# Check if a cached runner is available
if (-not (Test-Path $runnerDir)) {
# No cached runner found, proceed to download and extract
Update-GarmStatus -CallbackURL $CallbackURL -Message "downloading tools from {{ .DownloadURL }}"
$downloadToken="{{.TempDownloadToken}}"
$DownloadTokenHeaders=@{}
if ($downloadToken.Length -gt 0) {
$DownloadTokenHeaders=@{
"Authorization"="Bearer $downloadToken"
}
}
$downloadPath = Join-Path $env:TMP "{{ .FileName }}"
Start-ExecuteWithRetry -ScriptBlock {
Invoke-FastWebRequest -Uri "{{ .DownloadURL }}" -OutFile $downloadPath -Headers $DownloadTokenHeaders
} -MaxRetryCount 5 -RetryInterval 5 -RetryMessage "Retrying download of runner..."
mkdir $runnerDir
Update-GarmStatus -CallbackURL $CallbackURL -Message "extracting runner"
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::ExtractToDirectory($downloadPath, "$runnerDir")
} else {
Update-GarmStatus -CallbackURL $CallbackURL -Message "using cached runner found at $runnerDir"
}
# Ensure runner has full access to actions-runner folder
$runnerACL = Get-Acl $runnerDir
$runnerACL.SetAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule(
$userName, "FullControl", "ContainerInherit,ObjectInherit", "None", "Allow"
)))
Set-Acl -Path $runnerDir -AclObject $runnerAcl
Update-GarmStatus -CallbackURL $CallbackURL -Message "configuring and starting runner"
cd $runnerDir
{{- if .UseJITConfig }}
Update-GarmStatus -CallbackURL $CallbackURL -Message "downloading JIT credentials"
wget -UseBasicParsing -Headers @{"Accept"="application/json"; "Authorization"="Bearer $Token"} -Uri $MetadataURL/credentials/runner -OutFile (Join-Path $runnerDir ".runner")
wget -UseBasicParsing -Headers @{"Accept"="application/json"; "Authorization"="Bearer $Token"} -Uri $MetadataURL/credentials/credentials -OutFile (Join-Path $runnerDir ".credentials")
Add-Type -AssemblyName System.Security
$rsaData = (wget -UseBasicParsing -Headers @{"Accept"="application/json"; "Authorization"="Bearer $Token"} -Uri $MetadataURL/credentials/credentials_rsaparams)
$encodedBytes = [System.Text.Encoding]::UTF8.GetBytes($rsaData)
$protectedBytes = [Security.Cryptography.ProtectedData]::Protect( $encodedBytes, $null, [Security.Cryptography.DataProtectionScope]::LocalMachine )
[System.IO.File]::WriteAllBytes((Join-Path $runnerDir ".credentials_rsaparams"), $protectedBytes)
$serviceNameFile = (Join-Path $runnerDir ".service")
wget -UseBasicParsing -Headers @{"Accept"="application/json"; "Authorization"="Bearer $Token"} -Uri $MetadataURL/system/service-name -OutFile $serviceNameFile
Update-GarmStatus -CallbackURL $CallbackURL -Message "Creating system service"
$SVC_NAME=(gc -raw $serviceNameFile)
New-Service -Name "$SVC_NAME" -BinaryPathName "C:\actions-runner\bin\RunnerService.exe" -DisplayName "$SVC_NAME" -Description "GitHub Actions Runner ($SVC_NAME)" -StartupType Automatic -Credential $pscreds
Start-Service "$SVC_NAME"
Set-SystemInfo -CallbackURL $CallbackURL -RunnerDir $runnerDir -BearerToken $Token
Update-GarmStatus -Message "runner successfully installed" -CallbackURL $CallbackURL -Status "idle" | Out-Null
{{- else }}
$GithubRegistrationToken = Start-ExecuteWithRetry -ScriptBlock {
Invoke-WebRequest -UseBasicParsing -Headers @{"Accept"="application/json"; "Authorization"="Bearer $Token"} -Uri $MetadataURL/runner-registration-token/
} -MaxRetryCount 5 -RetryInterval 5 -RetryMessage "Retrying download of GitHub registration token..."
{{- if .GitHubRunnerGroup }}
./config.cmd --unattended --url "{{ .RepoURL }}" --token $GithubRegistrationToken --runnergroup {{.GitHubRunnerGroup}} --name "{{ .RunnerName }}" --labels "{{ .RunnerLabels }}" --no-default-labels --ephemeral --runasservice --windowslogonaccount "$userName" --windowslogonpassword "$userPasswd"
{{- else}}
./config.cmd --unattended --url "{{ .RepoURL }}" --token $GithubRegistrationToken --name "{{ .RunnerName }}" --labels "{{ .RunnerLabels }}" --no-default-labels --ephemeral --runasservice --windowslogonaccount "$userName" --windowslogonpassword "$userPasswd"
{{- end}}
if ($LASTEXITCODE) {
Throw "Failed to configure runner. Err code $LASTEXITCODE"
}
$agentInfoFile = Join-Path $runnerDir ".runner"
$agentInfo = ConvertFrom-Json (gc -raw $agentInfoFile)
Set-SystemInfo -CallbackURL $CallbackURL -RunnerDir $runnerDir -BearerToken $Token
Invoke-GarmSuccess -CallbackURL $CallbackURL -Message "runner successfully installed" -AgentID $agentInfo.agentId
{{- end }}
} catch {
Invoke-GarmFailure -CallbackURL $CallbackURL -Message $_
}
}
Install-Runner