mirror of
https://github.com/actions/runner.git
synced 2025-12-25 02:47:19 +08:00
GitHub Actions Runner
This commit is contained in:
650
src/Misc/dotnet-install.ps1
vendored
Normal file
650
src/Misc/dotnet-install.ps1
vendored
Normal file
@@ -0,0 +1,650 @@
|
||||
#
|
||||
# Copyright (c) .NET Foundation and contributors. All rights reserved.
|
||||
# Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
||||
#
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Installs dotnet cli
|
||||
.DESCRIPTION
|
||||
Installs dotnet cli. If dotnet installation already exists in the given directory
|
||||
it will update it only if the requested version differs from the one already installed.
|
||||
.PARAMETER Channel
|
||||
Default: LTS
|
||||
Download from the Channel specified. Possible values:
|
||||
- Current - most current release
|
||||
- LTS - most current supported release
|
||||
- 2-part version in a format A.B - represents a specific release
|
||||
examples: 2.0, 1.0
|
||||
- Branch name
|
||||
examples: release/2.0.0, Master
|
||||
Note: The version parameter overrides the channel parameter.
|
||||
.PARAMETER Version
|
||||
Default: latest
|
||||
Represents a build version on specific channel. Possible values:
|
||||
- latest - most latest build on specific channel
|
||||
- coherent - most latest coherent build on specific channel
|
||||
coherent applies only to SDK downloads
|
||||
- 3-part version in a format A.B.C - represents specific version of build
|
||||
examples: 2.0.0-preview2-006120, 1.1.0
|
||||
.PARAMETER InstallDir
|
||||
Default: %LocalAppData%\Microsoft\dotnet
|
||||
Path to where to install dotnet. Note that binaries will be placed directly in a given directory.
|
||||
.PARAMETER Architecture
|
||||
Default: <auto> - this value represents currently running OS architecture
|
||||
Architecture of dotnet binaries to be installed.
|
||||
Possible values are: <auto>, amd64, x64, x86, arm64, arm
|
||||
.PARAMETER SharedRuntime
|
||||
This parameter is obsolete and may be removed in a future version of this script.
|
||||
The recommended alternative is '-Runtime dotnet'.
|
||||
|
||||
Default: false
|
||||
Installs just the shared runtime bits, not the entire SDK.
|
||||
This is equivalent to specifying `-Runtime dotnet`.
|
||||
.PARAMETER Runtime
|
||||
Installs just a shared runtime, not the entire SDK.
|
||||
Possible values:
|
||||
- dotnet - the Microsoft.NETCore.App shared runtime
|
||||
- aspnetcore - the Microsoft.AspNetCore.App shared runtime
|
||||
- windowsdesktop - the Microsoft.WindowsDesktop.App shared runtime
|
||||
.PARAMETER DryRun
|
||||
If set it will not perform installation but instead display what command line to use to consistently install
|
||||
currently requested version of dotnet cli. In example if you specify version 'latest' it will display a link
|
||||
with specific version so that this command can be used deterministicly in a build script.
|
||||
It also displays binaries location if you prefer to install or download it yourself.
|
||||
.PARAMETER NoPath
|
||||
By default this script will set environment variable PATH for the current process to the binaries folder inside installation folder.
|
||||
If set it will display binaries location but not set any environment variable.
|
||||
.PARAMETER Verbose
|
||||
Displays diagnostics information.
|
||||
.PARAMETER AzureFeed
|
||||
Default: https://dotnetcli.azureedge.net/dotnet
|
||||
This parameter typically is not changed by the user.
|
||||
It allows changing the URL for the Azure feed used by this installer.
|
||||
.PARAMETER UncachedFeed
|
||||
This parameter typically is not changed by the user.
|
||||
It allows changing the URL for the Uncached feed used by this installer.
|
||||
.PARAMETER FeedCredential
|
||||
Used as a query string to append to the Azure feed.
|
||||
It allows changing the URL to use non-public blob storage accounts.
|
||||
.PARAMETER ProxyAddress
|
||||
If set, the installer will use the proxy when making web requests
|
||||
.PARAMETER ProxyUseDefaultCredentials
|
||||
Default: false
|
||||
Use default credentials, when using proxy address.
|
||||
.PARAMETER SkipNonVersionedFiles
|
||||
Default: false
|
||||
Skips installing non-versioned files if they already exist, such as dotnet.exe.
|
||||
.PARAMETER NoCdn
|
||||
Disable downloading from the Azure CDN, and use the uncached feed directly.
|
||||
#>
|
||||
[cmdletbinding()]
|
||||
param(
|
||||
[string]$Channel="LTS",
|
||||
[string]$Version="Latest",
|
||||
[string]$InstallDir="<auto>",
|
||||
[string]$Architecture="<auto>",
|
||||
[ValidateSet("dotnet", "aspnetcore", "windowsdesktop", IgnoreCase = $false)]
|
||||
[string]$Runtime,
|
||||
[Obsolete("This parameter may be removed in a future version of this script. The recommended alternative is '-Runtime dotnet'.")]
|
||||
[switch]$SharedRuntime,
|
||||
[switch]$DryRun,
|
||||
[switch]$NoPath,
|
||||
[string]$AzureFeed="https://dotnetcli.azureedge.net/dotnet",
|
||||
[string]$UncachedFeed="https://dotnetcli.blob.core.windows.net/dotnet",
|
||||
[string]$FeedCredential,
|
||||
[string]$ProxyAddress,
|
||||
[switch]$ProxyUseDefaultCredentials,
|
||||
[switch]$SkipNonVersionedFiles,
|
||||
[switch]$NoCdn
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference="Stop"
|
||||
$ProgressPreference="SilentlyContinue"
|
||||
|
||||
if ($NoCdn) {
|
||||
$AzureFeed = $UncachedFeed
|
||||
}
|
||||
|
||||
$BinFolderRelativePath=""
|
||||
|
||||
if ($SharedRuntime -and (-not $Runtime)) {
|
||||
$Runtime = "dotnet"
|
||||
}
|
||||
|
||||
# example path with regex: shared/1.0.0-beta-12345/somepath
|
||||
$VersionRegEx="/\d+\.\d+[^/]+/"
|
||||
$OverrideNonVersionedFiles = !$SkipNonVersionedFiles
|
||||
|
||||
function Say($str) {
|
||||
Write-Host "dotnet-install: $str"
|
||||
}
|
||||
|
||||
function Say-Verbose($str) {
|
||||
Write-Verbose "dotnet-install: $str"
|
||||
}
|
||||
|
||||
function Say-Invocation($Invocation) {
|
||||
$command = $Invocation.MyCommand;
|
||||
$args = (($Invocation.BoundParameters.Keys | foreach { "-$_ `"$($Invocation.BoundParameters[$_])`"" }) -join " ")
|
||||
Say-Verbose "$command $args"
|
||||
}
|
||||
|
||||
function Invoke-With-Retry([ScriptBlock]$ScriptBlock, [int]$MaxAttempts = 3, [int]$SecondsBetweenAttempts = 1) {
|
||||
$Attempts = 0
|
||||
|
||||
while ($true) {
|
||||
try {
|
||||
return $ScriptBlock.Invoke()
|
||||
}
|
||||
catch {
|
||||
$Attempts++
|
||||
if ($Attempts -lt $MaxAttempts) {
|
||||
Start-Sleep $SecondsBetweenAttempts
|
||||
}
|
||||
else {
|
||||
throw
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-Machine-Architecture() {
|
||||
Say-Invocation $MyInvocation
|
||||
|
||||
# possible values: amd64, x64, x86, arm64, arm
|
||||
return $ENV:PROCESSOR_ARCHITECTURE
|
||||
}
|
||||
|
||||
function Get-CLIArchitecture-From-Architecture([string]$Architecture) {
|
||||
Say-Invocation $MyInvocation
|
||||
|
||||
switch ($Architecture.ToLower()) {
|
||||
{ $_ -eq "<auto>" } { return Get-CLIArchitecture-From-Architecture $(Get-Machine-Architecture) }
|
||||
{ ($_ -eq "amd64") -or ($_ -eq "x64") } { return "x64" }
|
||||
{ $_ -eq "x86" } { return "x86" }
|
||||
{ $_ -eq "arm" } { return "arm" }
|
||||
{ $_ -eq "arm64" } { return "arm64" }
|
||||
default { throw "Architecture not supported. If you think this is a bug, report it at https://github.com/dotnet/cli/issues" }
|
||||
}
|
||||
}
|
||||
|
||||
# The version text returned from the feeds is a 1-line or 2-line string:
|
||||
# For the SDK and the dotnet runtime (2 lines):
|
||||
# Line 1: # commit_hash
|
||||
# Line 2: # 4-part version
|
||||
# For the aspnetcore runtime (1 line):
|
||||
# Line 1: # 4-part version
|
||||
function Get-Version-Info-From-Version-Text([string]$VersionText) {
|
||||
Say-Invocation $MyInvocation
|
||||
|
||||
$Data = -split $VersionText
|
||||
|
||||
$VersionInfo = @{
|
||||
CommitHash = $(if ($Data.Count -gt 1) { $Data[0] })
|
||||
Version = $Data[-1] # last line is always the version number.
|
||||
}
|
||||
return $VersionInfo
|
||||
}
|
||||
|
||||
function Load-Assembly([string] $Assembly) {
|
||||
try {
|
||||
Add-Type -Assembly $Assembly | Out-Null
|
||||
}
|
||||
catch {
|
||||
# On Nano Server, Powershell Core Edition is used. Add-Type is unable to resolve base class assemblies because they are not GAC'd.
|
||||
# Loading the base class assemblies is not unnecessary as the types will automatically get resolved.
|
||||
}
|
||||
}
|
||||
|
||||
function GetHTTPResponse([Uri] $Uri)
|
||||
{
|
||||
Invoke-With-Retry(
|
||||
{
|
||||
|
||||
$HttpClient = $null
|
||||
|
||||
try {
|
||||
# HttpClient is used vs Invoke-WebRequest in order to support Nano Server which doesn't support the Invoke-WebRequest cmdlet.
|
||||
Load-Assembly -Assembly System.Net.Http
|
||||
|
||||
if(-not $ProxyAddress) {
|
||||
try {
|
||||
# Despite no proxy being explicitly specified, we may still be behind a default proxy
|
||||
$DefaultProxy = [System.Net.WebRequest]::DefaultWebProxy;
|
||||
if($DefaultProxy -and (-not $DefaultProxy.IsBypassed($Uri))) {
|
||||
$ProxyAddress = $DefaultProxy.GetProxy($Uri).OriginalString
|
||||
$ProxyUseDefaultCredentials = $true
|
||||
}
|
||||
} catch {
|
||||
# Eat the exception and move forward as the above code is an attempt
|
||||
# at resolving the DefaultProxy that may not have been a problem.
|
||||
$ProxyAddress = $null
|
||||
Say-Verbose("Exception ignored: $_.Exception.Message - moving forward...")
|
||||
}
|
||||
}
|
||||
|
||||
if($ProxyAddress) {
|
||||
$HttpClientHandler = New-Object System.Net.Http.HttpClientHandler
|
||||
$HttpClientHandler.Proxy = New-Object System.Net.WebProxy -Property @{Address=$ProxyAddress;UseDefaultCredentials=$ProxyUseDefaultCredentials}
|
||||
$HttpClient = New-Object System.Net.Http.HttpClient -ArgumentList $HttpClientHandler
|
||||
}
|
||||
else {
|
||||
|
||||
$HttpClient = New-Object System.Net.Http.HttpClient
|
||||
}
|
||||
# Default timeout for HttpClient is 100s. For a 50 MB download this assumes 500 KB/s average, any less will time out
|
||||
# 20 minutes allows it to work over much slower connections.
|
||||
$HttpClient.Timeout = New-TimeSpan -Minutes 20
|
||||
$Response = $HttpClient.GetAsync("${Uri}${FeedCredential}").Result
|
||||
if (($Response -eq $null) -or (-not ($Response.IsSuccessStatusCode))) {
|
||||
# The feed credential is potentially sensitive info. Do not log FeedCredential to console output.
|
||||
$ErrorMsg = "Failed to download $Uri."
|
||||
if ($Response -ne $null) {
|
||||
$ErrorMsg += " $Response"
|
||||
}
|
||||
|
||||
throw $ErrorMsg
|
||||
}
|
||||
|
||||
return $Response
|
||||
}
|
||||
finally {
|
||||
if ($HttpClient -ne $null) {
|
||||
$HttpClient.Dispose()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function Get-Latest-Version-Info([string]$AzureFeed, [string]$Channel, [bool]$Coherent) {
|
||||
Say-Invocation $MyInvocation
|
||||
|
||||
$VersionFileUrl = $null
|
||||
if ($Runtime -eq "dotnet") {
|
||||
$VersionFileUrl = "$UncachedFeed/Runtime/$Channel/latest.version"
|
||||
}
|
||||
elseif ($Runtime -eq "aspnetcore") {
|
||||
$VersionFileUrl = "$UncachedFeed/aspnetcore/Runtime/$Channel/latest.version"
|
||||
}
|
||||
# Currently, the WindowsDesktop runtime is manufactured with the .Net core runtime
|
||||
elseif ($Runtime -eq "windowsdesktop") {
|
||||
$VersionFileUrl = "$UncachedFeed/Runtime/$Channel/latest.version"
|
||||
}
|
||||
elseif (-not $Runtime) {
|
||||
if ($Coherent) {
|
||||
$VersionFileUrl = "$UncachedFeed/Sdk/$Channel/latest.coherent.version"
|
||||
}
|
||||
else {
|
||||
$VersionFileUrl = "$UncachedFeed/Sdk/$Channel/latest.version"
|
||||
}
|
||||
}
|
||||
else {
|
||||
throw "Invalid value for `$Runtime"
|
||||
}
|
||||
try {
|
||||
$Response = GetHTTPResponse -Uri $VersionFileUrl
|
||||
}
|
||||
catch {
|
||||
throw "Could not resolve version information."
|
||||
}
|
||||
$StringContent = $Response.Content.ReadAsStringAsync().Result
|
||||
|
||||
switch ($Response.Content.Headers.ContentType) {
|
||||
{ ($_ -eq "application/octet-stream") } { $VersionText = $StringContent }
|
||||
{ ($_ -eq "text/plain") } { $VersionText = $StringContent }
|
||||
{ ($_ -eq "text/plain; charset=UTF-8") } { $VersionText = $StringContent }
|
||||
default { throw "``$Response.Content.Headers.ContentType`` is an unknown .version file content type." }
|
||||
}
|
||||
|
||||
$VersionInfo = Get-Version-Info-From-Version-Text $VersionText
|
||||
|
||||
return $VersionInfo
|
||||
}
|
||||
|
||||
|
||||
function Get-Specific-Version-From-Version([string]$AzureFeed, [string]$Channel, [string]$Version) {
|
||||
Say-Invocation $MyInvocation
|
||||
|
||||
switch ($Version.ToLower()) {
|
||||
{ $_ -eq "latest" } {
|
||||
$LatestVersionInfo = Get-Latest-Version-Info -AzureFeed $AzureFeed -Channel $Channel -Coherent $False
|
||||
return $LatestVersionInfo.Version
|
||||
}
|
||||
{ $_ -eq "coherent" } {
|
||||
$LatestVersionInfo = Get-Latest-Version-Info -AzureFeed $AzureFeed -Channel $Channel -Coherent $True
|
||||
return $LatestVersionInfo.Version
|
||||
}
|
||||
default { return $Version }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-Download-Link([string]$AzureFeed, [string]$SpecificVersion, [string]$CLIArchitecture) {
|
||||
Say-Invocation $MyInvocation
|
||||
|
||||
if ($Runtime -eq "dotnet") {
|
||||
$PayloadURL = "$AzureFeed/Runtime/$SpecificVersion/dotnet-runtime-$SpecificVersion-win-$CLIArchitecture.zip"
|
||||
}
|
||||
elseif ($Runtime -eq "aspnetcore") {
|
||||
$PayloadURL = "$AzureFeed/aspnetcore/Runtime/$SpecificVersion/aspnetcore-runtime-$SpecificVersion-win-$CLIArchitecture.zip"
|
||||
}
|
||||
elseif ($Runtime -eq "windowsdesktop") {
|
||||
$PayloadURL = "$AzureFeed/Runtime/$SpecificVersion/windowsdesktop-runtime-$SpecificVersion-win-$CLIArchitecture.zip"
|
||||
}
|
||||
elseif (-not $Runtime) {
|
||||
$PayloadURL = "$AzureFeed/Sdk/$SpecificVersion/dotnet-sdk-$SpecificVersion-win-$CLIArchitecture.zip"
|
||||
}
|
||||
else {
|
||||
throw "Invalid value for `$Runtime"
|
||||
}
|
||||
|
||||
Say-Verbose "Constructed primary named payload URL: $PayloadURL"
|
||||
|
||||
return $PayloadURL
|
||||
}
|
||||
|
||||
function Get-LegacyDownload-Link([string]$AzureFeed, [string]$SpecificVersion, [string]$CLIArchitecture) {
|
||||
Say-Invocation $MyInvocation
|
||||
|
||||
if (-not $Runtime) {
|
||||
$PayloadURL = "$AzureFeed/Sdk/$SpecificVersion/dotnet-dev-win-$CLIArchitecture.$SpecificVersion.zip"
|
||||
}
|
||||
elseif ($Runtime -eq "dotnet") {
|
||||
$PayloadURL = "$AzureFeed/Runtime/$SpecificVersion/dotnet-win-$CLIArchitecture.$SpecificVersion.zip"
|
||||
}
|
||||
else {
|
||||
return $null
|
||||
}
|
||||
|
||||
Say-Verbose "Constructed legacy named payload URL: $PayloadURL"
|
||||
|
||||
return $PayloadURL
|
||||
}
|
||||
|
||||
function Get-User-Share-Path() {
|
||||
Say-Invocation $MyInvocation
|
||||
|
||||
$InstallRoot = $env:DOTNET_INSTALL_DIR
|
||||
if (!$InstallRoot) {
|
||||
$InstallRoot = "$env:LocalAppData\Microsoft\dotnet"
|
||||
}
|
||||
return $InstallRoot
|
||||
}
|
||||
|
||||
function Resolve-Installation-Path([string]$InstallDir) {
|
||||
Say-Invocation $MyInvocation
|
||||
|
||||
if ($InstallDir -eq "<auto>") {
|
||||
return Get-User-Share-Path
|
||||
}
|
||||
return $InstallDir
|
||||
}
|
||||
|
||||
function Get-Version-Info-From-Version-File([string]$InstallRoot, [string]$RelativePathToVersionFile) {
|
||||
Say-Invocation $MyInvocation
|
||||
|
||||
$VersionFile = Join-Path -Path $InstallRoot -ChildPath $RelativePathToVersionFile
|
||||
Say-Verbose "Local version file: $VersionFile"
|
||||
|
||||
if (Test-Path $VersionFile) {
|
||||
$VersionText = cat $VersionFile
|
||||
Say-Verbose "Local version file text: $VersionText"
|
||||
return Get-Version-Info-From-Version-Text $VersionText
|
||||
}
|
||||
|
||||
Say-Verbose "Local version file not found."
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Is-Dotnet-Package-Installed([string]$InstallRoot, [string]$RelativePathToPackage, [string]$SpecificVersion) {
|
||||
Say-Invocation $MyInvocation
|
||||
|
||||
$DotnetPackagePath = Join-Path -Path $InstallRoot -ChildPath $RelativePathToPackage | Join-Path -ChildPath $SpecificVersion
|
||||
Say-Verbose "Is-Dotnet-Package-Installed: Path to a package: $DotnetPackagePath"
|
||||
return Test-Path $DotnetPackagePath -PathType Container
|
||||
}
|
||||
|
||||
function Get-Absolute-Path([string]$RelativeOrAbsolutePath) {
|
||||
# Too much spam
|
||||
# Say-Invocation $MyInvocation
|
||||
|
||||
return $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($RelativeOrAbsolutePath)
|
||||
}
|
||||
|
||||
function Get-Path-Prefix-With-Version($path) {
|
||||
$match = [regex]::match($path, $VersionRegEx)
|
||||
if ($match.Success) {
|
||||
return $entry.FullName.Substring(0, $match.Index + $match.Length)
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-List-Of-Directories-And-Versions-To-Unpack-From-Dotnet-Package([System.IO.Compression.ZipArchive]$Zip, [string]$OutPath) {
|
||||
Say-Invocation $MyInvocation
|
||||
|
||||
$ret = @()
|
||||
foreach ($entry in $Zip.Entries) {
|
||||
$dir = Get-Path-Prefix-With-Version $entry.FullName
|
||||
if ($dir -ne $null) {
|
||||
$path = Get-Absolute-Path $(Join-Path -Path $OutPath -ChildPath $dir)
|
||||
if (-Not (Test-Path $path -PathType Container)) {
|
||||
$ret += $dir
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$ret = $ret | Sort-Object | Get-Unique
|
||||
|
||||
$values = ($ret | foreach { "$_" }) -join ";"
|
||||
Say-Verbose "Directories to unpack: $values"
|
||||
|
||||
return $ret
|
||||
}
|
||||
|
||||
# Example zip content and extraction algorithm:
|
||||
# Rule: files if extracted are always being extracted to the same relative path locally
|
||||
# .\
|
||||
# a.exe # file does not exist locally, extract
|
||||
# b.dll # file exists locally, override only if $OverrideFiles set
|
||||
# aaa\ # same rules as for files
|
||||
# ...
|
||||
# abc\1.0.0\ # directory contains version and exists locally
|
||||
# ... # do not extract content under versioned part
|
||||
# abc\asd\ # same rules as for files
|
||||
# ...
|
||||
# def\ghi\1.0.1\ # directory contains version and does not exist locally
|
||||
# ... # extract content
|
||||
function Extract-Dotnet-Package([string]$ZipPath, [string]$OutPath) {
|
||||
Say-Invocation $MyInvocation
|
||||
|
||||
Load-Assembly -Assembly System.IO.Compression.FileSystem
|
||||
Set-Variable -Name Zip
|
||||
try {
|
||||
$Zip = [System.IO.Compression.ZipFile]::OpenRead($ZipPath)
|
||||
|
||||
$DirectoriesToUnpack = Get-List-Of-Directories-And-Versions-To-Unpack-From-Dotnet-Package -Zip $Zip -OutPath $OutPath
|
||||
|
||||
foreach ($entry in $Zip.Entries) {
|
||||
$PathWithVersion = Get-Path-Prefix-With-Version $entry.FullName
|
||||
if (($PathWithVersion -eq $null) -Or ($DirectoriesToUnpack -contains $PathWithVersion)) {
|
||||
$DestinationPath = Get-Absolute-Path $(Join-Path -Path $OutPath -ChildPath $entry.FullName)
|
||||
$DestinationDir = Split-Path -Parent $DestinationPath
|
||||
$OverrideFiles=$OverrideNonVersionedFiles -Or (-Not (Test-Path $DestinationPath))
|
||||
if ((-Not $DestinationPath.EndsWith("\")) -And $OverrideFiles) {
|
||||
New-Item -ItemType Directory -Force -Path $DestinationDir | Out-Null
|
||||
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $DestinationPath, $OverrideNonVersionedFiles)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($Zip -ne $null) {
|
||||
$Zip.Dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function DownloadFile($Source, [string]$OutPath) {
|
||||
if ($Source -notlike "http*") {
|
||||
# Using System.IO.Path.GetFullPath to get the current directory
|
||||
# does not work in this context - $pwd gives the current directory
|
||||
if (![System.IO.Path]::IsPathRooted($Source)) {
|
||||
$Source = $(Join-Path -Path $pwd -ChildPath $Source)
|
||||
}
|
||||
$Source = Get-Absolute-Path $Source
|
||||
Say "Copying file from $Source to $OutPath"
|
||||
Copy-Item $Source $OutPath
|
||||
return
|
||||
}
|
||||
|
||||
$Stream = $null
|
||||
|
||||
try {
|
||||
$Response = GetHTTPResponse -Uri $Source
|
||||
$Stream = $Response.Content.ReadAsStreamAsync().Result
|
||||
$File = [System.IO.File]::Create($OutPath)
|
||||
$Stream.CopyTo($File)
|
||||
$File.Close()
|
||||
}
|
||||
finally {
|
||||
if ($Stream -ne $null) {
|
||||
$Stream.Dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Prepend-Sdk-InstallRoot-To-Path([string]$InstallRoot, [string]$BinFolderRelativePath) {
|
||||
$BinPath = Get-Absolute-Path $(Join-Path -Path $InstallRoot -ChildPath $BinFolderRelativePath)
|
||||
if (-Not $NoPath) {
|
||||
$SuffixedBinPath = "$BinPath;"
|
||||
if (-Not $env:path.Contains($SuffixedBinPath)) {
|
||||
Say "Adding to current process PATH: `"$BinPath`". Note: This change will not be visible if PowerShell was run as a child process."
|
||||
$env:path = $SuffixedBinPath + $env:path
|
||||
} else {
|
||||
Say-Verbose "Current process PATH already contains `"$BinPath`""
|
||||
}
|
||||
}
|
||||
else {
|
||||
Say "Binaries of dotnet can be found in $BinPath"
|
||||
}
|
||||
}
|
||||
|
||||
$CLIArchitecture = Get-CLIArchitecture-From-Architecture $Architecture
|
||||
$SpecificVersion = Get-Specific-Version-From-Version -AzureFeed $AzureFeed -Channel $Channel -Version $Version
|
||||
$DownloadLink = Get-Download-Link -AzureFeed $AzureFeed -SpecificVersion $SpecificVersion -CLIArchitecture $CLIArchitecture
|
||||
$LegacyDownloadLink = Get-LegacyDownload-Link -AzureFeed $AzureFeed -SpecificVersion $SpecificVersion -CLIArchitecture $CLIArchitecture
|
||||
|
||||
$InstallRoot = Resolve-Installation-Path $InstallDir
|
||||
Say-Verbose "InstallRoot: $InstallRoot"
|
||||
$ScriptName = $MyInvocation.MyCommand.Name
|
||||
|
||||
if ($DryRun) {
|
||||
Say "Payload URLs:"
|
||||
Say "Primary named payload URL: $DownloadLink"
|
||||
if ($LegacyDownloadLink) {
|
||||
Say "Legacy named payload URL: $LegacyDownloadLink"
|
||||
}
|
||||
$RepeatableCommand = ".\$ScriptName -Version `"$SpecificVersion`" -InstallDir `"$InstallRoot`" -Architecture `"$CLIArchitecture`""
|
||||
if ($Runtime -eq "dotnet") {
|
||||
$RepeatableCommand+=" -Runtime `"dotnet`""
|
||||
}
|
||||
elseif ($Runtime -eq "aspnetcore") {
|
||||
$RepeatableCommand+=" -Runtime `"aspnetcore`""
|
||||
}
|
||||
foreach ($key in $MyInvocation.BoundParameters.Keys) {
|
||||
if (-not (@("Architecture","Channel","DryRun","InstallDir","Runtime","SharedRuntime","Version") -contains $key)) {
|
||||
$RepeatableCommand+=" -$key `"$($MyInvocation.BoundParameters[$key])`""
|
||||
}
|
||||
}
|
||||
Say "Repeatable invocation: $RepeatableCommand"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($Runtime -eq "dotnet") {
|
||||
$assetName = ".NET Core Runtime"
|
||||
$dotnetPackageRelativePath = "shared\Microsoft.NETCore.App"
|
||||
}
|
||||
elseif ($Runtime -eq "aspnetcore") {
|
||||
$assetName = "ASP.NET Core Runtime"
|
||||
$dotnetPackageRelativePath = "shared\Microsoft.AspNetCore.App"
|
||||
}
|
||||
elseif ($Runtime -eq "windowsdesktop") {
|
||||
$assetName = ".NET Core Windows Desktop Runtime"
|
||||
$dotnetPackageRelativePath = "shared\Microsoft.WindowsDesktop.App"
|
||||
}
|
||||
elseif (-not $Runtime) {
|
||||
$assetName = ".NET Core SDK"
|
||||
$dotnetPackageRelativePath = "sdk"
|
||||
}
|
||||
else {
|
||||
throw "Invalid value for `$Runtime"
|
||||
}
|
||||
|
||||
# Check if the SDK version is already installed.
|
||||
$isAssetInstalled = Is-Dotnet-Package-Installed -InstallRoot $InstallRoot -RelativePathToPackage $dotnetPackageRelativePath -SpecificVersion $SpecificVersion
|
||||
if ($isAssetInstalled) {
|
||||
Say "$assetName version $SpecificVersion is already installed."
|
||||
Prepend-Sdk-InstallRoot-To-Path -InstallRoot $InstallRoot -BinFolderRelativePath $BinFolderRelativePath
|
||||
exit 0
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null
|
||||
|
||||
$installDrive = $((Get-Item $InstallRoot).PSDrive.Name);
|
||||
$diskInfo = Get-PSDrive -Name $installDrive
|
||||
if ($diskInfo.Free / 1MB -le 100) {
|
||||
Say "There is not enough disk space on drive ${installDrive}:"
|
||||
exit 0
|
||||
}
|
||||
|
||||
$ZipPath = [System.IO.Path]::combine([System.IO.Path]::GetTempPath(), [System.IO.Path]::GetRandomFileName())
|
||||
Say-Verbose "Zip path: $ZipPath"
|
||||
|
||||
$DownloadFailed = $false
|
||||
Say "Downloading link: $DownloadLink"
|
||||
try {
|
||||
DownloadFile -Source $DownloadLink -OutPath $ZipPath
|
||||
}
|
||||
catch {
|
||||
Say "Cannot download: $DownloadLink"
|
||||
if ($LegacyDownloadLink) {
|
||||
$DownloadLink = $LegacyDownloadLink
|
||||
$ZipPath = [System.IO.Path]::combine([System.IO.Path]::GetTempPath(), [System.IO.Path]::GetRandomFileName())
|
||||
Say-Verbose "Legacy zip path: $ZipPath"
|
||||
Say "Downloading legacy link: $DownloadLink"
|
||||
try {
|
||||
DownloadFile -Source $DownloadLink -OutPath $ZipPath
|
||||
}
|
||||
catch {
|
||||
Say "Cannot download: $DownloadLink"
|
||||
$DownloadFailed = $true
|
||||
}
|
||||
}
|
||||
else {
|
||||
$DownloadFailed = $true
|
||||
}
|
||||
}
|
||||
|
||||
if ($DownloadFailed) {
|
||||
throw "Could not find/download: `"$assetName`" with version = $SpecificVersion`nRefer to: https://aka.ms/dotnet-os-lifecycle for information on .NET Core support"
|
||||
}
|
||||
|
||||
Say "Extracting zip from $DownloadLink"
|
||||
Extract-Dotnet-Package -ZipPath $ZipPath -OutPath $InstallRoot
|
||||
|
||||
# Check if the SDK version is now installed; if not, fail the installation.
|
||||
$isAssetInstalled = Is-Dotnet-Package-Installed -InstallRoot $InstallRoot -RelativePathToPackage $dotnetPackageRelativePath -SpecificVersion $SpecificVersion
|
||||
if (!$isAssetInstalled) {
|
||||
throw "`"$assetName`" with version = $SpecificVersion failed to install with an unknown error."
|
||||
}
|
||||
|
||||
Remove-Item $ZipPath
|
||||
|
||||
Prepend-Sdk-InstallRoot-To-Path -InstallRoot $InstallRoot -BinFolderRelativePath $BinFolderRelativePath
|
||||
|
||||
Say "Installation finished"
|
||||
exit 0
|
||||
1025
src/Misc/dotnet-install.sh
vendored
Executable file
1025
src/Misc/dotnet-install.sh
vendored
Executable file
File diff suppressed because it is too large
Load Diff
148
src/Misc/externals.sh
Executable file
148
src/Misc/externals.sh
Executable file
@@ -0,0 +1,148 @@
|
||||
#!/bin/bash
|
||||
PACKAGERUNTIME=$1
|
||||
PRECACHE=$2
|
||||
|
||||
NODE_URL=https://nodejs.org/dist
|
||||
NODE12_VERSION="12.4.0"
|
||||
|
||||
get_abs_path() {
|
||||
# exploits the fact that pwd will print abs path when no args
|
||||
echo "$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
|
||||
}
|
||||
|
||||
LAYOUT_DIR=$(get_abs_path "$(dirname $0)/../../_layout")
|
||||
DOWNLOAD_DIR="$(get_abs_path "$(dirname $0)/../../_downloads")/netcore2x"
|
||||
|
||||
function failed() {
|
||||
local error=${1:-Undefined error}
|
||||
echo "Failed: $error" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
function checkRC() {
|
||||
local rc=$?
|
||||
if [ $rc -ne 0 ]; then
|
||||
failed "${1} failed with return code $rc"
|
||||
fi
|
||||
}
|
||||
|
||||
function acquireExternalTool() {
|
||||
local download_source=$1 # E.g. https://github.com/microsoft/vswhere/releases/download/2.6.7/vswhere.exe
|
||||
local target_dir="$LAYOUT_DIR/externals/$2" # E.g. $LAYOUT_DIR/externals/vswhere
|
||||
local fix_nested_dir=$3 # Flag that indicates whether to move nested contents up one directory.
|
||||
|
||||
# Extract the portion of the URL after the protocol. E.g. github.com/microsoft/vswhere/releases/download/2.6.7/vswhere.exe
|
||||
local relative_url="${download_source#*://}"
|
||||
|
||||
# Check if the download already exists.
|
||||
local download_target="$DOWNLOAD_DIR/$relative_url"
|
||||
local download_basename="$(basename "$download_target")"
|
||||
local download_dir="$(dirname "$download_target")"
|
||||
|
||||
if [[ "$PRECACHE" != "" ]]; then
|
||||
if [ -f "$download_target" ]; then
|
||||
echo "Download exists: $download_basename"
|
||||
else
|
||||
# Delete any previous partial file.
|
||||
local partial_target="$DOWNLOAD_DIR/partial/$download_basename"
|
||||
mkdir -p "$(dirname "$partial_target")" || checkRC 'mkdir'
|
||||
if [ -f "$partial_target" ]; then
|
||||
rm "$partial_target" || checkRC 'rm'
|
||||
fi
|
||||
|
||||
# Download from source to the partial file.
|
||||
echo "Downloading $download_source"
|
||||
mkdir -p "$(dirname "$download_target")" || checkRC 'mkdir'
|
||||
# curl -f Fail silently (no output at all) on HTTP errors (H)
|
||||
# -k Allow connections to SSL sites without certs (H)
|
||||
# -S Show error. With -s, make curl show errors when they occur
|
||||
# -L Follow redirects (H)
|
||||
# -o FILE Write to FILE instead of stdout
|
||||
curl -fkSL -o "$partial_target" "$download_source" 2>"${download_target}_download.log" || checkRC 'curl'
|
||||
|
||||
# Move the partial file to the download target.
|
||||
mv "$partial_target" "$download_target" || checkRC 'mv'
|
||||
|
||||
# Extract to current directory
|
||||
# Ensure we can extract those files
|
||||
# We might use them during dev.sh
|
||||
if [[ "$download_basename" == *.zip ]]; then
|
||||
# Extract the zip.
|
||||
echo "Testing zip"
|
||||
unzip "$download_target" -d "$download_dir" > /dev/null
|
||||
local rc=$?
|
||||
if [[ $rc -ne 0 && $rc -ne 1 ]]; then
|
||||
failed "unzip failed with return code $rc"
|
||||
fi
|
||||
elif [[ "$download_basename" == *.tar.gz ]]; then
|
||||
# Extract the tar gz.
|
||||
echo "Testing tar gz"
|
||||
tar xzf "$download_target" -C "$download_dir" > /dev/null || checkRC 'tar'
|
||||
fi
|
||||
fi
|
||||
else
|
||||
# Extract to layout.
|
||||
mkdir -p "$target_dir" || checkRC 'mkdir'
|
||||
local nested_dir=""
|
||||
if [[ "$download_basename" == *.zip ]]; then
|
||||
# Extract the zip.
|
||||
echo "Extracting zip to layout"
|
||||
unzip "$download_target" -d "$target_dir" > /dev/null
|
||||
local rc=$?
|
||||
if [[ $rc -ne 0 && $rc -ne 1 ]]; then
|
||||
failed "unzip failed with return code $rc"
|
||||
fi
|
||||
|
||||
# Capture the nested directory path if the fix_nested_dir flag is set.
|
||||
if [[ "$fix_nested_dir" == "fix_nested_dir" ]]; then
|
||||
nested_dir="${download_basename%.zip}" # Remove the trailing ".zip".
|
||||
fi
|
||||
elif [[ "$download_basename" == *.tar.gz ]]; then
|
||||
# Extract the tar gz.
|
||||
echo "Extracting tar gz to layout"
|
||||
tar xzf "$download_target" -C "$target_dir" > /dev/null || checkRC 'tar'
|
||||
|
||||
# Capture the nested directory path if the fix_nested_dir flag is set.
|
||||
if [[ "$fix_nested_dir" == "fix_nested_dir" ]]; then
|
||||
nested_dir="${download_basename%.tar.gz}" # Remove the trailing ".tar.gz".
|
||||
fi
|
||||
else
|
||||
# Copy the file.
|
||||
echo "Copying to layout"
|
||||
cp "$download_target" "$target_dir/" || checkRC 'cp'
|
||||
fi
|
||||
|
||||
# Fixup the nested directory.
|
||||
if [[ "$nested_dir" != "" ]]; then
|
||||
if [ -d "$target_dir/$nested_dir" ]; then
|
||||
mv "$target_dir/$nested_dir"/* "$target_dir/" || checkRC 'mv'
|
||||
rmdir "$target_dir/$nested_dir" || checkRC 'rmdir'
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Download the external tools only for Windows.
|
||||
if [[ "$PACKAGERUNTIME" == "win-x64" ]]; then
|
||||
acquireExternalTool "$NODE_URL/v${NODE12_VERSION}/win-x64/node.exe" node12/bin
|
||||
acquireExternalTool "$NODE_URL/v${NODE12_VERSION}/win-x64/node.lib" node12/bin
|
||||
if [[ "$PRECACHE" != "" ]]; then
|
||||
acquireExternalTool "https://github.com/microsoft/vswhere/releases/download/2.6.7/vswhere.exe" vswhere
|
||||
fi
|
||||
fi
|
||||
|
||||
# Download the external tools only for OSX.
|
||||
if [[ "$PACKAGERUNTIME" == "osx-x64" ]]; then
|
||||
acquireExternalTool "$NODE_URL/v${NODE12_VERSION}/node-v${NODE12_VERSION}-darwin-x64.tar.gz" node12 fix_nested_dir
|
||||
fi
|
||||
|
||||
# Download the external tools common across Linux PACKAGERUNTIMEs (excluding OSX).
|
||||
if [[ "$PACKAGERUNTIME" == "linux-x64" || "$PACKAGERUNTIME" == "rhel.6-x64" ]]; then
|
||||
acquireExternalTool "$NODE_URL/v${NODE12_VERSION}/node-v${NODE12_VERSION}-linux-x64.tar.gz" node12 fix_nested_dir
|
||||
# TODO: Repath this blob to use a consistent version format (_ vs .)
|
||||
acquireExternalTool "https://vstsagenttools.blob.core.windows.net/tools/nodejs/12_4_0/alpine/node-v${NODE12_VERSION}-alpine.tar.gz" node12_alpine
|
||||
fi
|
||||
|
||||
if [[ "$PACKAGERUNTIME" == "linux-arm" ]]; then
|
||||
acquireExternalTool "$NODE_URL/v${NODE12_VERSION}/node-v${NODE12_VERSION}-linux-armv7l.tar.gz" node12 fix_nested_dir
|
||||
fi
|
||||
91
src/Misc/layoutbin/RunnerService.js
Normal file
91
src/Misc/layoutbin/RunnerService.js
Normal file
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env node
|
||||
// Copyright (c) GitHub. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
||||
|
||||
var childProcess = require("child_process");
|
||||
var path = require("path")
|
||||
|
||||
var supported = ['linux', 'darwin']
|
||||
|
||||
if (supported.indexOf(process.platform) == -1) {
|
||||
console.log('Unsupported platform: ' + process.platform);
|
||||
console.log('Supported platforms are: ' + supported.toString());
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
var stopping = false;
|
||||
var listener = null;
|
||||
|
||||
var runService = function() {
|
||||
var listenerExePath = path.join(__dirname, '../bin/Runner.Listener');
|
||||
var interactive = process.argv[2] === "interactive";
|
||||
|
||||
if(!stopping) {
|
||||
try {
|
||||
if (interactive) {
|
||||
console.log('Starting Runner listener interactively');
|
||||
listener = childProcess.spawn(listenerExePath, ['run'], { env: process.env });
|
||||
} else {
|
||||
console.log('Starting Runner listener with startup type: service');
|
||||
listener = childProcess.spawn(listenerExePath, ['run', '--startuptype', 'service'], { env: process.env });
|
||||
}
|
||||
|
||||
console.log('Started listener process');
|
||||
|
||||
listener.stdout.on('data', (data) => {
|
||||
process.stdout.write(data.toString('utf8'));
|
||||
});
|
||||
|
||||
listener.stderr.on('data', (data) => {
|
||||
process.stdout.write(data.toString('utf8'));
|
||||
});
|
||||
|
||||
listener.on('close', (code) => {
|
||||
console.log(`Runner listener exited with error code ${code}`);
|
||||
|
||||
if (code === 0) {
|
||||
console.log('Runner listener exit with 0 return code, stop the service, no retry needed.');
|
||||
stopping = true;
|
||||
} else if (code === 1) {
|
||||
console.log('Runner listener exit with terminated error, stop the service, no retry needed.');
|
||||
stopping = true;
|
||||
} else if (code === 2) {
|
||||
console.log('Runner listener exit with retryable error, re-launch runner in 5 seconds.');
|
||||
} else if (code === 3) {
|
||||
console.log('Runner listener exit because of updating, re-launch runner in 5 seconds.');
|
||||
} else {
|
||||
console.log('Runner listener exit with undefined return code, re-launch runner in 5 seconds.');
|
||||
}
|
||||
|
||||
if(!stopping) {
|
||||
setTimeout(runService, 5000);
|
||||
}
|
||||
});
|
||||
|
||||
} catch(ex) {
|
||||
console.log(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runService();
|
||||
console.log('Started running service');
|
||||
|
||||
var gracefulShutdown = function(code) {
|
||||
console.log('Shutting down runner listener');
|
||||
stopping = true;
|
||||
if (listener) {
|
||||
console.log('Sending SIGINT to runner listener to stop');
|
||||
listener.kill('SIGINT');
|
||||
|
||||
// TODO wait for 30 seconds and send a SIGKILL
|
||||
}
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
gracefulShutdown(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
gracefulShutdown(0);
|
||||
});
|
||||
27
src/Misc/layoutbin/actions.runner.plist.template
Normal file
27
src/Misc/layoutbin/actions.runner.plist.template
Normal file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>{{SvcName}}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>{{RunnerRoot}}/runsvc.sh</string>
|
||||
</array>
|
||||
<key>UserName</key>
|
||||
<string>{{User}}</string>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>{{RunnerRoot}}</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>{{UserHome}}/Library/Logs/{{SvcName}}/stdout.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>{{UserHome}}/Library/Logs/{{SvcName}}/stderr.log</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>ACTIONS_RUNNER_SVC</key>
|
||||
<string>1</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
14
src/Misc/layoutbin/actions.runner.service.template
Normal file
14
src/Misc/layoutbin/actions.runner.service.template
Normal file
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description={{Description}}
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
ExecStart={{RunnerRoot}}/runsvc.sh
|
||||
User={{User}}
|
||||
WorkingDirectory={{RunnerRoot}}
|
||||
KillMode=process
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=5min
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
135
src/Misc/layoutbin/darwin.svc.sh.template
Normal file
135
src/Misc/layoutbin/darwin.svc.sh.template
Normal file
@@ -0,0 +1,135 @@
|
||||
#!/bin/bash
|
||||
|
||||
SVC_NAME="{{SvcNameVar}}"
|
||||
SVC_DESCRIPTION="{{SvcDescription}}"
|
||||
|
||||
user_id=`id -u`
|
||||
|
||||
# launchctl should not run as sudo for launch runners
|
||||
if [ $user_id -eq 0 ]; then
|
||||
echo "Must not run with sudo"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SVC_CMD=$1
|
||||
RUNNER_ROOT=`pwd`
|
||||
|
||||
LAUNCH_PATH="${HOME}/Library/LaunchAgents"
|
||||
PLIST_PATH="${LAUNCH_PATH}/${SVC_NAME}.plist"
|
||||
TEMPLATE_PATH=./bin/actions.runner.plist.template
|
||||
TEMP_PATH=./bin/actions.runner.plist.temp
|
||||
CONFIG_PATH=.service
|
||||
|
||||
function failed()
|
||||
{
|
||||
local error=${1:-Undefined error}
|
||||
echo "Failed: $error" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [ ! -f "${TEMPLATE_PATH}" ]; then
|
||||
failed "Must run from runner root or install is corrupt"
|
||||
fi
|
||||
|
||||
function install()
|
||||
{
|
||||
echo "Creating launch runner in ${PLIST_PATH}"
|
||||
|
||||
if [ ! -d "${LAUNCH_PATH}" ]; then
|
||||
mkdir ${LAUNCH_PATH}
|
||||
fi
|
||||
|
||||
if [ -f "${PLIST_PATH}" ]; then
|
||||
failed "error: exists ${PLIST_PATH}"
|
||||
fi
|
||||
|
||||
if [ -f "${TEMP_PATH}" ]; then
|
||||
rm "${TEMP_PATH}" || failed "failed to delete ${TEMP_PATH}"
|
||||
fi
|
||||
|
||||
log_path="${HOME}/Library/Logs/${SVC_NAME}"
|
||||
echo "Creating ${log_path}"
|
||||
mkdir -p "${log_path}" || failed "failed to create ${log_path}"
|
||||
|
||||
echo Creating ${PLIST_PATH}
|
||||
sed "s/{{User}}/${SUDO_USER:-$USER}/g; s/{{SvcName}}/$SVC_NAME/g; s@{{RunnerRoot}}@${RUNNER_ROOT}@g; s@{{UserHome}}@$HOME@g;" "${TEMPLATE_PATH}" > "${TEMP_PATH}" || failed "failed to create replacement temp file"
|
||||
mv "${TEMP_PATH}" "${PLIST_PATH}" || failed "failed to copy plist"
|
||||
|
||||
# Since we started with sudo, runsvc.sh will be owned by root. Change this to current login user.
|
||||
echo Creating runsvc.sh
|
||||
cp ./bin/runsvc.sh ./runsvc.sh || failed "failed to copy runsvc.sh"
|
||||
chmod u+x ./runsvc.sh || failed "failed to set permission for runsvc.sh"
|
||||
|
||||
echo Creating ${CONFIG_PATH}
|
||||
echo "${PLIST_PATH}" > ${CONFIG_PATH} || failed "failed to create .Service file"
|
||||
|
||||
echo "svc install complete"
|
||||
}
|
||||
|
||||
function start()
|
||||
{
|
||||
echo "starting ${SVC_NAME}"
|
||||
launchctl load -w "${PLIST_PATH}" || failed "failed to load ${PLIST_PATH}"
|
||||
status
|
||||
}
|
||||
|
||||
function stop()
|
||||
{
|
||||
echo "stopping ${SVC_NAME}"
|
||||
launchctl unload "${PLIST_PATH}" || failed "failed to unload ${PLIST_PATH}"
|
||||
status
|
||||
}
|
||||
|
||||
function uninstall()
|
||||
{
|
||||
echo "uninstalling ${SVC_NAME}"
|
||||
stop
|
||||
rm "${PLIST_PATH}" || failed "failed to delete ${PLIST_PATH}"
|
||||
if [ -f "${CONFIG_PATH}" ]; then
|
||||
rm "${CONFIG_PATH}" || failed "failed to delete ${CONFIG_PATH}"
|
||||
fi
|
||||
}
|
||||
|
||||
function status()
|
||||
{
|
||||
echo "status ${SVC_NAME}:"
|
||||
if [ -f "${PLIST_PATH}" ]; then
|
||||
echo
|
||||
echo "${PLIST_PATH}"
|
||||
else
|
||||
echo
|
||||
echo "not installed"
|
||||
echo
|
||||
return
|
||||
fi
|
||||
|
||||
echo
|
||||
status_out=`launchctl list | grep "${SVC_NAME}"`
|
||||
if [ ! -z "$status_out" ]; then
|
||||
echo Started:
|
||||
echo $status_out
|
||||
echo
|
||||
else
|
||||
echo Stopped
|
||||
echo
|
||||
fi
|
||||
}
|
||||
|
||||
function usage()
|
||||
{
|
||||
echo
|
||||
echo Usage:
|
||||
echo "./svc.sh [install, start, stop, status, uninstall]"
|
||||
echo
|
||||
}
|
||||
|
||||
case $SVC_CMD in
|
||||
"install") install;;
|
||||
"status") status;;
|
||||
"uninstall") uninstall;;
|
||||
"start") start;;
|
||||
"stop") stop;;
|
||||
*) usage;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
298
src/Misc/layoutbin/installdependencies.sh
Executable file
298
src/Misc/layoutbin/installdependencies.sh
Executable file
@@ -0,0 +1,298 @@
|
||||
#!/bin/bash
|
||||
|
||||
user_id=`id -u`
|
||||
|
||||
if [ $user_id -ne 0 ]; then
|
||||
echo "Need to run with sudo privilege"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Determine OS type
|
||||
# Debian based OS (Debian, Ubuntu, Linux Mint) has /etc/debian_version
|
||||
# Fedora based OS (Fedora, Redhat, Centos, Oracle Linux 7) has /etc/redhat-release
|
||||
# SUSE based OS (OpenSUSE, SUSE Enterprise) has ID_LIKE=suse in /etc/os-release
|
||||
|
||||
function print_errormessage()
|
||||
{
|
||||
echo "Can't install dotnet core dependencies."
|
||||
echo "You can manually install all required dependencies based on following documentation"
|
||||
echo "https://docs.microsoft.com/en-us/dotnet/core/linux-prerequisites?tabs=netcore2x"
|
||||
}
|
||||
|
||||
function print_rhel6message()
|
||||
{
|
||||
echo "We did our best effort to install dotnet core dependencies"
|
||||
echo "However, there are some dependencies which require manual installation"
|
||||
echo "You can install all remaining required dependencies based on the following documentation"
|
||||
echo "https://github.com/dotnet/core/blob/master/Documentation/build-and-install-rhel6-prerequisites.md"
|
||||
}
|
||||
|
||||
function print_rhel6errormessage()
|
||||
{
|
||||
echo "We couldn't install dotnet core dependencies"
|
||||
echo "You can manually install all required dependencies based on following documentation"
|
||||
echo "https://docs.microsoft.com/en-us/dotnet/core/linux-prerequisites?tabs=netcore2x"
|
||||
echo "In addition, there are some dependencies which require manual installation. Please follow this documentation"
|
||||
echo "https://github.com/dotnet/core/blob/master/Documentation/build-and-install-rhel6-prerequisites.md"
|
||||
}
|
||||
|
||||
if [ -e /etc/os-release ]
|
||||
then
|
||||
echo "--------OS Information--------"
|
||||
cat /etc/os-release
|
||||
echo "------------------------------"
|
||||
|
||||
if [ -e /etc/debian_version ]
|
||||
then
|
||||
echo "The current OS is Debian based"
|
||||
echo "--------Debian Version--------"
|
||||
cat /etc/debian_version
|
||||
echo "------------------------------"
|
||||
|
||||
# prefer apt over apt-get
|
||||
command -v apt
|
||||
if [ $? -eq 0 ]
|
||||
then
|
||||
apt update && apt install -y liblttng-ust0 libkrb5-3 zlib1g
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
echo "'apt' failed with exit code '$?'"
|
||||
print_errormessage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ubuntu 18 uses libcurl4
|
||||
# ubuntu 14, 16 and other linux use libcurl3
|
||||
apt install -y libcurl3 || apt install -y libcurl4
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
echo "'apt' failed with exit code '$?'"
|
||||
print_errormessage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# debian 9 use libssl1.0.2
|
||||
# other debian linux use libssl1.0.0
|
||||
apt install -y libssl1.0.0 || apt install -y libssl1.0.2
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
echo "'apt' failed with exit code '$?'"
|
||||
print_errormessage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# libicu version prefer: libicu52 -> libicu55 -> libicu57 -> libicu60
|
||||
apt install -y libicu52 || apt install -y libicu55 || apt install -y libicu57 || apt install -y libicu60
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
echo "'apt' failed with exit code '$?'"
|
||||
print_errormessage
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
command -v apt-get
|
||||
if [ $? -eq 0 ]
|
||||
then
|
||||
apt-get update && apt-get install -y liblttng-ust0 libkrb5-3 zlib1g
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
echo "'apt-get' failed with exit code '$?'"
|
||||
print_errormessage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ubuntu 18 uses libcurl4
|
||||
# ubuntu 14, 16 and other linux use libcurl3
|
||||
apt-get install -y libcurl3 || apt-get install -y libcurl4
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
echo "'apt-get' failed with exit code '$?'"
|
||||
print_errormessage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# debian 9 use libssl1.0.2
|
||||
# other debian linux use libssl1.0.0
|
||||
apt-get install -y libssl1.0.0 || apt install -y libssl1.0.2
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
echo "'apt-get' failed with exit code '$?'"
|
||||
print_errormessage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# libicu version prefer: libicu52 -> libicu55 -> libicu57 -> libicu60
|
||||
apt-get install -y libicu52 || apt install -y libicu55 || apt install -y libicu57 || apt install -y libicu60
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
echo "'apt-get' failed with exit code '$?'"
|
||||
print_errormessage
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Can not find 'apt' or 'apt-get'"
|
||||
print_errormessage
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
elif [ -e /etc/redhat-release ]
|
||||
then
|
||||
echo "The current OS is Fedora based"
|
||||
echo "--------Redhat Version--------"
|
||||
cat /etc/redhat-release
|
||||
echo "------------------------------"
|
||||
|
||||
# use dnf on fedora
|
||||
# use yum on centos and redhat
|
||||
if [ -e /etc/fedora-release ]
|
||||
then
|
||||
command -v dnf
|
||||
if [ $? -eq 0 ]
|
||||
then
|
||||
useCompatSsl=0
|
||||
grep -i 'fedora release 28' /etc/fedora-release
|
||||
if [ $? -eq 0 ]
|
||||
then
|
||||
useCompatSsl=1
|
||||
else
|
||||
grep -i 'fedora release 27' /etc/fedora-release
|
||||
if [ $? -eq 0 ]
|
||||
then
|
||||
useCompatSsl=1
|
||||
else
|
||||
grep -i 'fedora release 26' /etc/fedora-release
|
||||
if [ $? -eq 0 ]
|
||||
then
|
||||
useCompatSsl=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ $useCompatSsl -eq 1 ]
|
||||
then
|
||||
echo "Use compat-openssl10-devel instead of openssl-devel for Fedora 27/28 (dotnet core requires openssl 1.0.x)"
|
||||
dnf install -y compat-openssl10
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
echo "'dnf' failed with exit code '$?'"
|
||||
print_errormessage
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
dnf install -y openssl-libs
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
echo "'dnf' failed with exit code '$?'"
|
||||
print_errormessage
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
dnf install -y lttng-ust libcurl krb5-libs zlib libicu
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
echo "'dnf' failed with exit code '$?'"
|
||||
print_errormessage
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Can not find 'dnf'"
|
||||
print_errormessage
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
command -v yum
|
||||
if [ $? -eq 0 ]
|
||||
then
|
||||
yum install -y openssl-libs libcurl krb5-libs zlib libicu
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
echo "'yum' failed with exit code '$?'"
|
||||
print_errormessage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# install lttng-ust separately since it's not part of offical package repository
|
||||
yum install -y wget && wget -P /etc/yum.repos.d/ https://packages.efficios.com/repo.files/EfficiOS-RHEL7-x86-64.repo && rpmkeys --import https://packages.efficios.com/rhel/repo.key && yum updateinfo && yum install -y lttng-ust
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
echo "'lttng-ust' installation failed with exit code '$?'"
|
||||
print_errormessage
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Can not find 'yum'"
|
||||
print_errormessage
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
# we might on OpenSUSE
|
||||
OSTYPE=$(grep ID_LIKE /etc/os-release | cut -f2 -d=)
|
||||
echo $OSTYPE
|
||||
if [ $OSTYPE == '"suse"' ]
|
||||
then
|
||||
echo "The current OS is SUSE based"
|
||||
command -v zypper
|
||||
if [ $? -eq 0 ]
|
||||
then
|
||||
zypper -n install lttng-ust libopenssl1_0_0 libcurl4 krb5 zlib libicu52_1
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
echo "'zypper' failed with exit code '$?'"
|
||||
print_errormessage
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Can not find 'zypper'"
|
||||
print_errormessage
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Can't detect current OS type based on /etc/os-release."
|
||||
print_errormessage
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
elif [ -e /etc/redhat-release ]
|
||||
# RHEL6 doesn't have an os-release file defined, read redhat-release instead
|
||||
then
|
||||
redhatRelease=$(</etc/redhat-release)
|
||||
if [[ $redhatRelease == "CentOS release 6."* || $redhatRelease == "Red Hat Enterprise Linux Server release 6."* ]]
|
||||
then
|
||||
echo "The current OS is Red Hat Enterprise Linux 6 or Centos 6"
|
||||
|
||||
# Install known dependencies, as a best effort.
|
||||
# The remaining dependencies are covered by the GitHub doc that will be shown by `print_rhel6message`
|
||||
command -v yum
|
||||
if [ $? -eq 0 ]
|
||||
then
|
||||
yum install -y openssl krb5-libs zlib
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
echo "'yum' failed with exit code '$?'"
|
||||
print_rhel6errormessage
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Can not find 'yum'"
|
||||
print_rhel6errormessage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_rhel6message
|
||||
exit 1
|
||||
else
|
||||
echo "Unknown RHEL OS version"
|
||||
print_errormessage
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Unknown OS version"
|
||||
print_errormessage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "-----------------------------"
|
||||
echo " Finish Install Dependencies"
|
||||
echo "-----------------------------"
|
||||
20
src/Misc/layoutbin/runsvc.sh
Executable file
20
src/Misc/layoutbin/runsvc.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
|
||||
# convert SIGTERM signal to SIGINT
|
||||
# for more info on how to propagate SIGTERM to a child process see: http://veithen.github.io/2014/11/16/sigterm-propagation.html
|
||||
trap 'kill -INT $PID' TERM INT
|
||||
|
||||
if [ -f ".path" ]; then
|
||||
# configure
|
||||
export PATH=`cat .path`
|
||||
echo ".path=${PATH}"
|
||||
fi
|
||||
|
||||
# insert anything to setup env when running as a service
|
||||
|
||||
# run the host process which keep the listener alive
|
||||
./externals/node12/bin/node ./bin/RunnerService.js &
|
||||
PID=$!
|
||||
wait $PID
|
||||
trap - TERM INT
|
||||
wait $PID
|
||||
143
src/Misc/layoutbin/systemd.svc.sh.template
Normal file
143
src/Misc/layoutbin/systemd.svc.sh.template
Normal file
@@ -0,0 +1,143 @@
|
||||
#!/bin/bash
|
||||
|
||||
SVC_NAME="{{SvcNameVar}}"
|
||||
SVC_DESCRIPTION="{{SvcDescription}}"
|
||||
|
||||
SVC_CMD=$1
|
||||
arg_2=${2}
|
||||
|
||||
RUNNER_ROOT=`pwd`
|
||||
|
||||
UNIT_PATH=/etc/systemd/system/${SVC_NAME}
|
||||
TEMPLATE_PATH=./bin/actions.runner.service.template
|
||||
TEMP_PATH=./bin/actions.runner.service.temp
|
||||
CONFIG_PATH=.service
|
||||
|
||||
user_id=`id -u`
|
||||
|
||||
# systemctl must run as sudo
|
||||
# this script is a convenience wrapper around systemctl
|
||||
if [ $user_id -ne 0 ]; then
|
||||
echo "Must run as sudo"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
function failed()
|
||||
{
|
||||
local error=${1:-Undefined error}
|
||||
echo "Failed: $error" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [ ! -f "${TEMPLATE_PATH}" ]; then
|
||||
failed "Must run from runner root or install is corrupt"
|
||||
fi
|
||||
|
||||
#check if we run as root
|
||||
if [[ $(id -u) != "0" ]]; then
|
||||
echo "Failed: This script requires to run with sudo." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
function install()
|
||||
{
|
||||
echo "Creating launch runner in ${UNIT_PATH}"
|
||||
if [ -f "${UNIT_PATH}" ]; then
|
||||
failed "error: exists ${UNIT_PATH}"
|
||||
fi
|
||||
|
||||
if [ -f "${TEMP_PATH}" ]; then
|
||||
rm "${TEMP_PATH}" || failed "failed to delete ${TEMP_PATH}"
|
||||
fi
|
||||
|
||||
# can optionally use username supplied
|
||||
run_as_user=${arg_2:-$SUDO_USER}
|
||||
echo "Run as user: ${run_as_user}"
|
||||
|
||||
run_as_uid=$(id -u ${run_as_user}) || failed "User does not exist"
|
||||
echo "Run as uid: ${run_as_uid}"
|
||||
|
||||
run_as_gid=$(id -g ${run_as_user}) || failed "Group not available"
|
||||
echo "gid: ${run_as_gid}"
|
||||
|
||||
sed "s/{{User}}/${run_as_user}/g; s/{{Description}}/$(echo ${SVC_DESCRIPTION} | sed -e 's/[\/&]/\\&/g')/g; s/{{RunnerRoot}}/$(echo ${RUNNER_ROOT} | sed -e 's/[\/&]/\\&/g')/g;" "${TEMPLATE_PATH}" > "${TEMP_PATH}" || failed "failed to create replacement temp file"
|
||||
mv "${TEMP_PATH}" "${UNIT_PATH}" || failed "failed to copy unit file"
|
||||
|
||||
# unit file should not be executable and world writable
|
||||
chmod 664 ${UNIT_PATH} || failed "failed to set permissions on ${UNIT_PATH}"
|
||||
systemctl daemon-reload || failed "failed to reload daemons"
|
||||
|
||||
# Since we started with sudo, runsvc.sh will be owned by root. Change this to current login user.
|
||||
cp ./bin/runsvc.sh ./runsvc.sh || failed "failed to copy runsvc.sh"
|
||||
chown ${run_as_uid}:${run_as_gid} ./runsvc.sh || failed "failed to set owner for runsvc.sh"
|
||||
chmod 755 ./runsvc.sh || failed "failed to set permission for runsvc.sh"
|
||||
|
||||
systemctl enable ${SVC_NAME} || failed "failed to enable ${SVC_NAME}"
|
||||
|
||||
echo "${SVC_NAME}" > ${CONFIG_PATH} || failed "failed to create .service file"
|
||||
chown ${run_as_uid}:${run_as_gid} ${CONFIG_PATH} || failed "failed to set permission for ${CONFIG_PATH}"
|
||||
}
|
||||
|
||||
function start()
|
||||
{
|
||||
systemctl start ${SVC_NAME} || failed "failed to start ${SVC_NAME}"
|
||||
status
|
||||
}
|
||||
|
||||
function stop()
|
||||
{
|
||||
systemctl stop ${SVC_NAME} || failed "failed to stop ${SVC_NAME}"
|
||||
status
|
||||
}
|
||||
|
||||
function uninstall()
|
||||
{
|
||||
stop
|
||||
systemctl disable ${SVC_NAME} || failed "failed to disable ${SVC_NAME}"
|
||||
rm "${UNIT_PATH}" || failed "failed to delete ${UNIT_PATH}"
|
||||
if [ -f "${CONFIG_PATH}" ]; then
|
||||
rm "${CONFIG_PATH}" || failed "failed to delete ${CONFIG_PATH}"
|
||||
fi
|
||||
systemctl daemon-reload || failed "failed to reload daemons"
|
||||
}
|
||||
|
||||
function status()
|
||||
{
|
||||
if [ -f "${UNIT_PATH}" ]; then
|
||||
echo
|
||||
echo "${UNIT_PATH}"
|
||||
else
|
||||
echo
|
||||
echo "not installed"
|
||||
echo
|
||||
return
|
||||
fi
|
||||
|
||||
systemctl --no-pager status ${SVC_NAME}
|
||||
}
|
||||
|
||||
function usage()
|
||||
{
|
||||
echo
|
||||
echo Usage:
|
||||
echo "./svc.sh [install, start, stop, status, uninstall]"
|
||||
echo "Commands:"
|
||||
echo " install [user]: Install runner service as Root or specified user."
|
||||
echo " start: Manually start the runner service."
|
||||
echo " stop: Manually stop the runner service."
|
||||
echo " status: Display status of runner service."
|
||||
echo " uninstall: Uninstall runner service."
|
||||
echo
|
||||
}
|
||||
|
||||
case $SVC_CMD in
|
||||
"install") install;;
|
||||
"status") status;;
|
||||
"uninstall") uninstall;;
|
||||
"start") start;;
|
||||
"stop") stop;;
|
||||
"status") status;;
|
||||
*) usage;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
143
src/Misc/layoutbin/update.cmd.template
Normal file
143
src/Misc/layoutbin/update.cmd.template
Normal file
@@ -0,0 +1,143 @@
|
||||
@echo off
|
||||
|
||||
rem runner will replace key words in the template and generate a batch script to run.
|
||||
rem Keywords:
|
||||
rem PROCESSID = pid
|
||||
rem RUNNERPROCESSNAME = Runner.Listener[.exe]
|
||||
rem ROOTFOLDER = ./
|
||||
rem EXISTRUNNERVERSION = 2.100.0
|
||||
rem DOWNLOADRUNNERVERSION = 2.101.0
|
||||
rem UPDATELOG = _diag/SelfUpdate-UTC.log
|
||||
rem RESTARTINTERACTIVERUNNER = 0/1
|
||||
|
||||
setlocal
|
||||
set runnerpid=_PROCESS_ID_
|
||||
set runnerprocessname=_RUNNER_PROCESS_NAME_
|
||||
set rootfolder=_ROOT_FOLDER_
|
||||
set existrunnerversion=_EXIST_RUNNER_VERSION_
|
||||
set downloadrunnerversion=_DOWNLOAD_RUNNER_VERSION_
|
||||
set logfile=_UPDATE_LOG_
|
||||
set restartinteractiverunner=_RESTART_INTERACTIVE_RUNNER_
|
||||
|
||||
rem log user who run the script
|
||||
echo [%date% %time%] --------whoami-------- >> "%logfile%" 2>&1
|
||||
whoami >> "%logfile%" 2>&1
|
||||
echo [%date% %time%] --------whoami-------- >> "%logfile%" 2>&1
|
||||
|
||||
rem wait for runner process to exit.
|
||||
echo [%date% %time%] Waiting for %runnerprocessname% (%runnerpid%) to complete >> "%logfile%" 2>&1
|
||||
:loop
|
||||
tasklist /fi "pid eq %runnerpid%" | find /I "%runnerprocessname%" >> "%logfile%" 2>&1
|
||||
if ERRORLEVEL 1 (
|
||||
goto copy
|
||||
)
|
||||
|
||||
echo [%date% %time%] Process %runnerpid% still running, check again after 1 second. >> "%logfile%" 2>&1
|
||||
ping -n 2 127.0.0.1 >nul
|
||||
goto loop
|
||||
|
||||
rem start re-organize folders
|
||||
:copy
|
||||
echo [%date% %time%] Process %runnerpid% finished running >> "%logfile%" 2>&1
|
||||
echo [%date% %time%] Sleep 1 more second to make sure process exited >> "%logfile%" 2>&1
|
||||
ping -n 2 127.0.0.1 >nul
|
||||
echo [%date% %time%] Re-organize folders >> "%logfile%" 2>&1
|
||||
|
||||
rem the folder structure under runner root will be
|
||||
rem ./bin -> bin.2.100.0 (junction folder)
|
||||
rem ./externals -> externals.2.100.0 (junction folder)
|
||||
rem ./bin.2.100.0
|
||||
rem ./externals.2.100.0
|
||||
rem ./bin.2.99.0
|
||||
rem ./externals.2.99.0
|
||||
rem by using the juction folder we can avoid file in use problem.
|
||||
|
||||
rem if the bin/externals junction point already exist, we just need to delete the juction point then re-create to point to new bin/externals folder.
|
||||
rem if the bin/externals still are real folders, we need to rename the existing folder to bin.version format then create junction point to new bin/externals folder.
|
||||
|
||||
rem check bin folder
|
||||
rem we do findstr /C:" bin" since in migration mode, we create a junction folder from runner to bin.
|
||||
rem as result, dir /AL | findstr "bin" will return the runner folder. output looks like (07/27/2016 05:21 PM <JUNCTION> runner [E:\bin])
|
||||
dir "%rootfolder%" /AL 2>&1 | findstr /C:" bin" >> "%logfile%" 2>&1
|
||||
if ERRORLEVEL 1 (
|
||||
rem return code 1 means it can't find a bin folder that is a junction folder
|
||||
rem so we need to move the current bin folder to bin.2.99.0 folder.
|
||||
echo [%date% %time%] move "%rootfolder%\bin" "%rootfolder%\bin.%existrunnerversion%" >> "%logfile%" 2>&1
|
||||
move "%rootfolder%\bin" "%rootfolder%\bin.%existrunnerversion%" >> "%logfile%" 2>&1
|
||||
if ERRORLEVEL 1 (
|
||||
echo [%date% %time%] Can't move "%rootfolder%\bin" to "%rootfolder%\bin.%existrunnerversion%" >> "%logfile%" 2>&1
|
||||
goto fail
|
||||
)
|
||||
|
||||
) else (
|
||||
rem otherwise it find a bin folder that is a junction folder
|
||||
rem we just need to delete the junction point.
|
||||
echo [%date% %time%] Delete existing junction bin folder >> "%logfile%" 2>&1
|
||||
rmdir "%rootfolder%\bin" >> "%logfile%" 2>&1
|
||||
if ERRORLEVEL 1 (
|
||||
echo [%date% %time%] Can't delete existing junction bin folder >> "%logfile%" 2>&1
|
||||
goto fail
|
||||
)
|
||||
)
|
||||
|
||||
rem check externals folder
|
||||
dir "%rootfolder%" /AL 2>&1 | findstr "externals" >> "%logfile%" 2>&1
|
||||
if ERRORLEVEL 1 (
|
||||
rem return code 1 means it can't find a externals folder that is a junction folder
|
||||
rem so we need to move the current externals folder to externals.2.99.0 folder.
|
||||
echo [%date% %time%] move "%rootfolder%\externals" "%rootfolder%\externals.%existrunnerversion%" >> "%logfile%" 2>&1
|
||||
move "%rootfolder%\externals" "%rootfolder%\externals.%existrunnerversion%" >> "%logfile%" 2>&1
|
||||
if ERRORLEVEL 1 (
|
||||
echo [%date% %time%] Can't move "%rootfolder%\externals" to "%rootfolder%\externals.%existrunnerversion%" >> "%logfile%" 2>&1
|
||||
goto fail
|
||||
)
|
||||
) else (
|
||||
rem otherwise it find a externals folder that is a junction folder
|
||||
rem we just need to delete the junction point.
|
||||
echo [%date% %time%] Delete existing junction externals folder >> "%logfile%" 2>&1
|
||||
rmdir "%rootfolder%\externals" >> "%logfile%" 2>&1
|
||||
if ERRORLEVEL 1 (
|
||||
echo [%date% %time%] Can't delete existing junction externals folder >> "%logfile%" 2>&1
|
||||
goto fail
|
||||
)
|
||||
)
|
||||
|
||||
rem create junction bin folder
|
||||
echo [%date% %time%] Create junction bin folder >> "%logfile%" 2>&1
|
||||
mklink /J "%rootfolder%\bin" "%rootfolder%\bin.%downloadrunnerversion%" >> "%logfile%" 2>&1
|
||||
if ERRORLEVEL 1 (
|
||||
echo [%date% %time%] Can't create junction bin folder >> "%logfile%" 2>&1
|
||||
goto fail
|
||||
)
|
||||
|
||||
rem create junction externals folder
|
||||
echo [%date% %time%] Create junction externals folder >> "%logfile%" 2>&1
|
||||
mklink /J "%rootfolder%\externals" "%rootfolder%\externals.%downloadrunnerversion%" >> "%logfile%" 2>&1
|
||||
if ERRORLEVEL 1 (
|
||||
echo [%date% %time%] Can't create junction externals folder >> "%logfile%" 2>&1
|
||||
goto fail
|
||||
)
|
||||
|
||||
echo [%date% %time%] Update succeed >> "%logfile%" 2>&1
|
||||
|
||||
rem rename the update log file with %logfile%.succeed/.failed/succeedneedrestart
|
||||
rem runner service host can base on the log file name determin the result of the runner update
|
||||
echo [%date% %time%] Rename "%logfile%" to be "%logfile%.succeed" >> "%logfile%" 2>&1
|
||||
move "%logfile%" "%logfile%.succeed" >nul
|
||||
|
||||
rem restart interactive runner if needed
|
||||
if %restartinteractiverunner% equ 1 (
|
||||
echo [%date% %time%] Restart interactive runner >> "%logfile%.succeed" 2>&1
|
||||
endlocal
|
||||
start "Actions Runner" cmd.exe /k "_ROOT_FOLDER_\run.cmd"
|
||||
) else (
|
||||
endlocal
|
||||
)
|
||||
|
||||
goto :eof
|
||||
|
||||
:fail
|
||||
echo [%date% %time%] Rename "%logfile%" to be "%logfile%.failed" >> "%logfile%" 2>&1
|
||||
move "%logfile%" "%logfile%.failed" >nul
|
||||
goto :eof
|
||||
|
||||
133
src/Misc/layoutbin/update.sh.template
Normal file
133
src/Misc/layoutbin/update.sh.template
Normal file
@@ -0,0 +1,133 @@
|
||||
#!/bin/bash
|
||||
|
||||
# runner will replace key words in the template and generate a batch script to run.
|
||||
# Keywords:
|
||||
# PROCESSID = pid
|
||||
# RUNNERPROCESSNAME = Runner.Listener[.exe]
|
||||
# ROOTFOLDER = ./
|
||||
# EXISTRUNNERVERSION = 2.100.0
|
||||
# DOWNLOADRUNNERVERSION = 2.101.0
|
||||
# UPDATELOG = _diag/SelfUpdate-UTC.log
|
||||
# RESTARTINTERACTIVERUNNER = 0/1
|
||||
|
||||
runnerpid=_PROCESS_ID_
|
||||
runnerprocessname=_RUNNER_PROCESS_NAME_
|
||||
rootfolder="_ROOT_FOLDER_"
|
||||
existrunnerversion=_EXIST_RUNNER_VERSION_
|
||||
downloadrunnerversion=_DOWNLOAD_RUNNER_VERSION_
|
||||
logfile="_UPDATE_LOG_"
|
||||
restartinteractiverunner=_RESTART_INTERACTIVE_RUNNER_
|
||||
|
||||
# log user who run the script
|
||||
date "+[%F %T-%4N] --------whoami--------" >> "$logfile" 2>&1
|
||||
whoami >> "$logfile" 2>&1
|
||||
date "+[%F %T-%4N] --------whoami--------" >> "$logfile" 2>&1
|
||||
|
||||
# wait for runner process to exit.
|
||||
date "+[%F %T-%4N] Waiting for $runnerprocessname ($runnerpid) to complete" >> "$logfile" 2>&1
|
||||
while [ -e /proc/$runnerpid ]
|
||||
do
|
||||
date "+[%F %T-%4N] Process $runnerpid still running" >> "$logfile" 2>&1
|
||||
ping -c 2 127.0.0.1 >nul
|
||||
done
|
||||
date "+[%F %T-%4N] Process $runnerpid finished running" >> "$logfile" 2>&1
|
||||
|
||||
# start re-organize folders
|
||||
date "+[%F %T-%4N] Sleep 1 more second to make sure process exited" >> "$logfile" 2>&1
|
||||
ping -c 2 127.0.0.1 >nul
|
||||
|
||||
# the folder structure under runner root will be
|
||||
# ./bin -> bin.2.100.0 (junction folder)
|
||||
# ./externals -> externals.2.100.0 (junction folder)
|
||||
# ./bin.2.100.0
|
||||
# ./externals.2.100.0
|
||||
# ./bin.2.99.0
|
||||
# ./externals.2.99.0
|
||||
# by using the juction folder we can avoid file in use problem.
|
||||
|
||||
# if the bin/externals junction point already exist, we just need to delete the juction point then re-create to point to new bin/externals folder.
|
||||
# if the bin/externals still are real folders, we need to rename the existing folder to bin.version format then create junction point to new bin/externals folder.
|
||||
|
||||
# check bin folder
|
||||
if [[ -L "$rootfolder/bin" && -d "$rootfolder/bin" ]]
|
||||
then
|
||||
# return code 0 means it find a bin folder that is a junction folder
|
||||
# we just need to delete the junction point.
|
||||
date "+[%F %T-%4N] Delete existing junction bin folder" >> "$logfile"
|
||||
rm "$rootfolder/bin" >> "$logfile"
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
date "+[%F %T-%4N] Can't delete existing junction bin folder" >> "$logfile"
|
||||
mv -fv "$logfile" "$logfile.failed"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# otherwise, we need to move the current bin folder to bin.2.99.0 folder.
|
||||
date "+[%F %T-%4N] move $rootfolder/bin $rootfolder/bin.$existrunnerversion" >> "$logfile" 2>&1
|
||||
mv -fv "$rootfolder/bin" "$rootfolder/bin.$existrunnerversion" >> "$logfile" 2>&1
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
date "+[%F %T-%4N] Can't move $rootfolder/bin to $rootfolder/bin.$existrunnerversion" >> "$logfile" 2>&1
|
||||
mv -fv "$logfile" "$logfile.failed"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# check externals folder
|
||||
if [[ -L "$rootfolder/externals" && -d "$rootfolder/externals" ]]
|
||||
then
|
||||
# the externals folder is already a junction folder
|
||||
# we just need to delete the junction point.
|
||||
date "+[%F %T-%4N] Delete existing junction externals folder" >> "$logfile"
|
||||
rm "$rootfolder/externals" >> "$logfile"
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
date "+[%F %T-%4N] Can't delete existing junction externals folder" >> "$logfile"
|
||||
mv -fv "$logfile" "$logfile.failed"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# otherwise, we need to move the current externals folder to externals.2.99.0 folder.
|
||||
date "+[%F %T-%4N] move $rootfolder/externals $rootfolder/externals.$existrunnerversion" >> "$logfile" 2>&1
|
||||
mv -fv "$rootfolder/externals" "$rootfolder/externals.$existrunnerversion" >> "$logfile" 2>&1
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
date "+[%F %T-%4N] Can't move $rootfolder/externals to $rootfolder/externals.$existrunnerversion" >> "$logfile" 2>&1
|
||||
mv -fv "$logfile" "$logfile.failed"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# create junction bin folder
|
||||
date "+[%F %T-%4N] Create junction bin folder" >> "$logfile" 2>&1
|
||||
ln -s "$rootfolder/bin.$downloadrunnerversion" "$rootfolder/bin" >> "$logfile" 2>&1
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
date "+[%F %T-%4N] Can't create junction bin folder" >> "$logfile" 2>&1
|
||||
mv -fv "$logfile" "$logfile.failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# create junction externals folder
|
||||
date "+[%F %T-%4N] Create junction externals folder" >> "$logfile" 2>&1
|
||||
ln -s "$rootfolder/externals.$downloadrunnerversion" "$rootfolder/externals" >> "$logfile" 2>&1
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
date "+[%F %T-%4N] Can't create junction externals folder" >> "$logfile" 2>&1
|
||||
mv -fv "$logfile" "$logfile.failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
date "+[%F %T-%4N] Update succeed" >> "$logfile"
|
||||
|
||||
# rename the update log file with %logfile%.succeed/.failed/succeedneedrestart
|
||||
# runner service host can base on the log file name determin the result of the runner update
|
||||
date "+[%F %T-%4N] Rename $logfile to be $logfile.succeed" >> "$logfile" 2>&1
|
||||
mv -fv "$logfile" "$logfile.succeed" >> "$logfile" 2>&1
|
||||
|
||||
# restart interactive runner if needed
|
||||
if [ $restartinteractiverunner -ne 0 ]
|
||||
then
|
||||
date "+[%F %T-%4N] Restarting interactive runner" >> "$logfile.succeed" 2>&1
|
||||
"$rootfolder/run.sh" &
|
||||
fi
|
||||
26
src/Misc/layoutroot/config.cmd
Normal file
26
src/Misc/layoutroot/config.cmd
Normal file
@@ -0,0 +1,26 @@
|
||||
@echo off
|
||||
|
||||
rem ********************************************************************************
|
||||
rem Unblock specific files.
|
||||
rem ********************************************************************************
|
||||
setlocal
|
||||
if defined VERBOSE_ARG (
|
||||
set VERBOSE_ARG='Continue'
|
||||
) else (
|
||||
set VERBOSE_ARG='SilentlyContinue'
|
||||
)
|
||||
|
||||
rem Unblock files in the root of the layout folder. E.g. .cmd files.
|
||||
powershell.exe -NoLogo -Sta -NoProfile -NonInteractive -ExecutionPolicy Unrestricted -Command "$VerbosePreference = %VERBOSE_ARG% ; Get-ChildItem -LiteralPath '%~dp0' | ForEach-Object { Write-Verbose ('Unblock: {0}' -f $_.FullName) ; $_ } | Unblock-File | Out-Null"
|
||||
|
||||
if /i "%~1" equ "remove" (
|
||||
rem ********************************************************************************
|
||||
rem Unconfigure the runner.
|
||||
rem ********************************************************************************
|
||||
"%~dp0bin\Runner.Listener.exe" %*
|
||||
) else (
|
||||
rem ********************************************************************************
|
||||
rem Configure the runner.
|
||||
rem ********************************************************************************
|
||||
"%~dp0bin\Runner.Listener.exe" configure %*
|
||||
)
|
||||
86
src/Misc/layoutroot/config.sh
Executable file
86
src/Misc/layoutroot/config.sh
Executable file
@@ -0,0 +1,86 @@
|
||||
#!/bin/bash
|
||||
|
||||
user_id=`id -u`
|
||||
|
||||
# we want to snapshot the environment of the config user
|
||||
if [ $user_id -eq 0 -a -z "$AGENT_ALLOW_RUNASROOT" ]; then
|
||||
echo "Must not run with sudo"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check dotnet core 2.1 dependencies for Linux
|
||||
if [[ (`uname` == "Linux") ]]
|
||||
then
|
||||
command -v ldd > /dev/null
|
||||
if [ $? -ne 0 ]
|
||||
then
|
||||
echo "Can not find 'ldd'. Please install 'ldd' and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ldd ./bin/libcoreclr.so | grep 'not found'
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Dependencies is missing for Dotnet Core 2.1"
|
||||
echo "Execute ./bin/installdependencies.sh to install any missing Dotnet Core 2.1 dependencies."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ldd ./bin/System.Security.Cryptography.Native.OpenSsl.so | grep 'not found'
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Dependencies is missing for Dotnet Core 2.1"
|
||||
echo "Execute ./bin/installdependencies.sh to install any missing Dotnet Core 2.1 dependencies."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ldd ./bin/System.IO.Compression.Native.so | grep 'not found'
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Dependencies is missing for Dotnet Core 2.1"
|
||||
echo "Execute ./bin/installdependencies.sh to install any missing Dotnet Core 2.1 dependencies."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ldd ./bin/System.Net.Http.Native.so | grep 'not found'
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Dependencies is missing for Dotnet Core 2.1"
|
||||
echo "Execute ./bin/installdependencies.sh to install any missing Dotnet Core 2.1 dependencies."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! [ -x "$(command -v ldconfig)" ]; then
|
||||
LDCONFIG_COMMAND="/sbin/ldconfig"
|
||||
if ! [ -x "$LDCONFIG_COMMAND" ]; then
|
||||
echo "Can not find 'ldconfig' in PATH and '/sbin/ldconfig' doesn't exists either. Please install 'ldconfig' and try again."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
LDCONFIG_COMMAND="ldconfig"
|
||||
fi
|
||||
|
||||
libpath=${LD_LIBRARY_PATH:-}
|
||||
$LDCONFIG_COMMAND -NXv ${libpath//:/} 2>&1 | grep libicu >/dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Libicu's dependencies is missing for Dotnet Core 2.1"
|
||||
echo "Execute ./bin/installdependencies.sh to install any missing Dotnet Core 2.1 dependencies."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Change directory to the script root directory
|
||||
# https://stackoverflow.com/questions/59895/getting-the-source-directory-of-a-bash-script-from-within
|
||||
SOURCE="${BASH_SOURCE[0]}"
|
||||
while [ -h "$SOURCE" ]; do # resolve $SOURCE until the file is no longer a symlink
|
||||
DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )"
|
||||
SOURCE="$(readlink "$SOURCE")"
|
||||
[[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE" # if $SOURCE was a relative symlink, we need to resolve it relative to the path where the symlink file was located
|
||||
done
|
||||
DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )"
|
||||
cd $DIR
|
||||
|
||||
source ./env.sh
|
||||
|
||||
shopt -s nocasematch
|
||||
if [[ "$1" == "remove" ]]; then
|
||||
./bin/Runner.Listener "$@"
|
||||
else
|
||||
./bin/Runner.Listener configure "$@"
|
||||
fi
|
||||
44
src/Misc/layoutroot/env.sh
Executable file
44
src/Misc/layoutroot/env.sh
Executable file
@@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
|
||||
varCheckList=(
|
||||
'LANG'
|
||||
'JAVA_HOME'
|
||||
'ANT_HOME'
|
||||
'M2_HOME'
|
||||
'ANDROID_HOME'
|
||||
'GRADLE_HOME'
|
||||
'NVM_BIN'
|
||||
'NVM_PATH'
|
||||
'VSTS_HTTP_PROXY'
|
||||
'VSTS_HTTP_PROXY_USERNAME'
|
||||
'VSTS_HTTP_PROXY_PASSWORD'
|
||||
'LD_LIBRARY_PATH'
|
||||
'PERL5LIB'
|
||||
)
|
||||
|
||||
envContents=""
|
||||
|
||||
if [ -f ".env" ]; then
|
||||
envContents=`cat .env`
|
||||
else
|
||||
touch .env
|
||||
fi
|
||||
|
||||
function writeVar()
|
||||
{
|
||||
checkVar="$1"
|
||||
checkDelim="${1}="
|
||||
if test "${envContents#*$checkDelim}" = "$envContents"
|
||||
then
|
||||
if [ ! -z "${!checkVar}" ]; then
|
||||
echo "${checkVar}=${!checkVar}">>.env
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
echo $PATH>.path
|
||||
|
||||
for var_name in ${varCheckList[@]}
|
||||
do
|
||||
writeVar "${var_name}"
|
||||
done
|
||||
33
src/Misc/layoutroot/run.cmd
Normal file
33
src/Misc/layoutroot/run.cmd
Normal file
@@ -0,0 +1,33 @@
|
||||
@echo off
|
||||
|
||||
rem ********************************************************************************
|
||||
rem Unblock specific files.
|
||||
rem ********************************************************************************
|
||||
setlocal
|
||||
if defined VERBOSE_ARG (
|
||||
set VERBOSE_ARG='Continue'
|
||||
) else (
|
||||
set VERBOSE_ARG='SilentlyContinue'
|
||||
)
|
||||
|
||||
rem Unblock files in the root of the layout folder. E.g. .cmd files.
|
||||
powershell.exe -NoLogo -Sta -NoProfile -NonInteractive -ExecutionPolicy Unrestricted -Command "$VerbosePreference = %VERBOSE_ARG% ; Get-ChildItem -LiteralPath '%~dp0' | ForEach-Object { Write-Verbose ('Unblock: {0}' -f $_.FullName) ; $_ } | Unblock-File | Out-Null"
|
||||
|
||||
if /i "%~1" equ "localRun" (
|
||||
rem ********************************************************************************
|
||||
rem Local run.
|
||||
rem ********************************************************************************
|
||||
"%~dp0bin\Runner.Listener.exe" %*
|
||||
) else (
|
||||
rem ********************************************************************************
|
||||
rem Run.
|
||||
rem ********************************************************************************
|
||||
"%~dp0bin\Runner.Listener.exe" run %*
|
||||
|
||||
rem Return code 4 means the run once runner received an update message.
|
||||
rem Sleep 5 seconds to wait for the update process finish and run the runner again.
|
||||
if ERRORLEVEL 4 (
|
||||
timeout /t 5 /nobreak > NUL
|
||||
"%~dp0bin\Runner.Listener.exe" run %*
|
||||
)
|
||||
)
|
||||
51
src/Misc/layoutroot/run.sh
Executable file
51
src/Misc/layoutroot/run.sh
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Validate not sudo
|
||||
user_id=`id -u`
|
||||
if [ $user_id -eq 0 -a -z "$AGENT_ALLOW_RUNASROOT" ]; then
|
||||
echo "Must not run interactively with sudo"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Change directory to the script root directory
|
||||
# https://stackoverflow.com/questions/59895/getting-the-source-directory-of-a-bash-script-from-within
|
||||
SOURCE="${BASH_SOURCE[0]}"
|
||||
while [ -h "$SOURCE" ]; do # resolve $SOURCE until the file is no longer a symlink
|
||||
DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )"
|
||||
SOURCE="$(readlink "$SOURCE")"
|
||||
[[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE" # if $SOURCE was a relative symlink, we need to resolve it relative to the path where the symlink file was located
|
||||
done
|
||||
DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )"
|
||||
|
||||
# Do not "cd $DIR". For localRun, the current directory is expected to be the repo location on disk.
|
||||
|
||||
# Run
|
||||
shopt -s nocasematch
|
||||
if [[ "$1" == "localRun" ]]; then
|
||||
"$DIR"/bin/Runner.Listener $*
|
||||
else
|
||||
"$DIR"/bin/Runner.Listener run $*
|
||||
|
||||
# Return code 4 means the run once agent received an update message.
|
||||
# Sleep 5 seconds to wait for the update process finish and run the agent again.
|
||||
returnCode=$?
|
||||
if [[ $returnCode == 4 ]]; then
|
||||
if [ ! -x "$(command -v sleep)" ]; then
|
||||
if [ ! -x "$(command -v ping)" ]; then
|
||||
COUNT="0"
|
||||
while [[ $COUNT != 5000 ]]; do
|
||||
echo "SLEEP" >nul
|
||||
COUNT=$[$COUNT+1]
|
||||
done
|
||||
else
|
||||
ping -n 5 127.0.0.1 >nul
|
||||
fi
|
||||
else
|
||||
sleep 5 >nul
|
||||
fi
|
||||
|
||||
"$DIR"/bin/Runner.Listener run $*
|
||||
else
|
||||
exit $returnCode
|
||||
fi
|
||||
fi
|
||||
10
src/NuGet.Config
Normal file
10
src/NuGet.Config
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<!--To inherit the global NuGet package sources remove the <clear/> line below -->
|
||||
<clear />
|
||||
<add key="dotnet-core" value="https://www.myget.org/F/dotnet-core/api/v3/index.json" />
|
||||
<add key="dotnet-buildtools" value="https://www.myget.org/F/dotnet-buildtools/api/v3/index.json" />
|
||||
<add key="api.nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||
</packageSources>
|
||||
</configuration>
|
||||
253
src/Runner.Common/ActionCommand.cs
Normal file
253
src/Runner.Common/ActionCommand.cs
Normal file
@@ -0,0 +1,253 @@
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
public sealed class ActionCommand
|
||||
{
|
||||
private static readonly EscapeMapping[] _escapeMappings = new[]
|
||||
{
|
||||
new EscapeMapping(token: "%", replacement: "%25"),
|
||||
new EscapeMapping(token: ";", replacement: "%3B"),
|
||||
new EscapeMapping(token: "\r", replacement: "%0D"),
|
||||
new EscapeMapping(token: "\n", replacement: "%0A"),
|
||||
new EscapeMapping(token: "]", replacement: "%5D"),
|
||||
};
|
||||
|
||||
private static readonly EscapeMapping[] _escapeDataMappings = new[]
|
||||
{
|
||||
new EscapeMapping(token: "\r", replacement: "%0D"),
|
||||
new EscapeMapping(token: "\n", replacement: "%0A"),
|
||||
};
|
||||
|
||||
private static readonly EscapeMapping[] _escapePropertyMappings = new[]
|
||||
{
|
||||
new EscapeMapping(token: "%", replacement: "%25"),
|
||||
new EscapeMapping(token: "\r", replacement: "%0D"),
|
||||
new EscapeMapping(token: "\n", replacement: "%0A"),
|
||||
new EscapeMapping(token: ":", replacement: "%3A"),
|
||||
new EscapeMapping(token: ",", replacement: "%2C"),
|
||||
};
|
||||
|
||||
private readonly Dictionary<string, string> _properties = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
public const string Prefix = "##[";
|
||||
public const string _commandKey = "::";
|
||||
|
||||
public ActionCommand(string command)
|
||||
{
|
||||
ArgUtil.NotNullOrEmpty(command, nameof(command));
|
||||
Command = command;
|
||||
}
|
||||
|
||||
public string Command { get; }
|
||||
|
||||
|
||||
public Dictionary<string, string> Properties => _properties;
|
||||
|
||||
public string Data { get; set; }
|
||||
|
||||
public static bool TryParseV2(string message, HashSet<string> registeredCommands, out ActionCommand command)
|
||||
{
|
||||
command = null;
|
||||
if (string.IsNullOrEmpty(message))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// the message needs to start with the keyword after trim leading space.
|
||||
message = message.TrimStart();
|
||||
if (!message.StartsWith(_commandKey))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the index of the separator between the command info and the data.
|
||||
int endIndex = message.IndexOf(_commandKey, _commandKey.Length);
|
||||
if (endIndex < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the command info (command and properties).
|
||||
int cmdIndex = _commandKey.Length;
|
||||
string cmdInfo = message.Substring(cmdIndex, endIndex - cmdIndex);
|
||||
|
||||
// Get the command name
|
||||
int spaceIndex = cmdInfo.IndexOf(' ');
|
||||
string commandName =
|
||||
spaceIndex < 0
|
||||
? cmdInfo
|
||||
: cmdInfo.Substring(0, spaceIndex);
|
||||
|
||||
if (registeredCommands.Contains(commandName))
|
||||
{
|
||||
// Initialize the command.
|
||||
command = new ActionCommand(commandName);
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Set the properties.
|
||||
if (spaceIndex > 0)
|
||||
{
|
||||
string propertiesStr = cmdInfo.Substring(spaceIndex + 1).Trim();
|
||||
string[] splitProperties = propertiesStr.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (string propertyStr in splitProperties)
|
||||
{
|
||||
string[] pair = propertyStr.Split(new[] { '=' }, count: 2, options: StringSplitOptions.RemoveEmptyEntries);
|
||||
if (pair.Length == 2)
|
||||
{
|
||||
command.Properties[pair[0]] = UnescapeProperty(pair[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
command.Data = UnescapeData(message.Substring(endIndex + _commandKey.Length));
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
command = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryParse(string message, HashSet<string> registeredCommands, out ActionCommand command)
|
||||
{
|
||||
command = null;
|
||||
if (string.IsNullOrEmpty(message))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Get the index of the prefix.
|
||||
int prefixIndex = message.IndexOf(Prefix);
|
||||
if (prefixIndex < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the index of the separator between the command info and the data.
|
||||
int rbIndex = message.IndexOf(']', prefixIndex);
|
||||
if (rbIndex < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the command info (command and properties).
|
||||
int cmdIndex = prefixIndex + Prefix.Length;
|
||||
string cmdInfo = message.Substring(cmdIndex, rbIndex - cmdIndex);
|
||||
|
||||
// Get the command name
|
||||
int spaceIndex = cmdInfo.IndexOf(' ');
|
||||
string commandName =
|
||||
spaceIndex < 0
|
||||
? cmdInfo
|
||||
: cmdInfo.Substring(0, spaceIndex);
|
||||
|
||||
if (registeredCommands.Contains(commandName))
|
||||
{
|
||||
// Initialize the command.
|
||||
command = new ActionCommand(commandName);
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Set the properties.
|
||||
if (spaceIndex > 0)
|
||||
{
|
||||
string propertiesStr = cmdInfo.Substring(spaceIndex + 1);
|
||||
string[] splitProperties = propertiesStr.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (string propertyStr in splitProperties)
|
||||
{
|
||||
string[] pair = propertyStr.Split(new[] { '=' }, count: 2, options: StringSplitOptions.RemoveEmptyEntries);
|
||||
if (pair.Length == 2)
|
||||
{
|
||||
command.Properties[pair[0]] = Unescape(pair[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
command.Data = Unescape(message.Substring(rbIndex + 1));
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
command = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Unescape(string escaped)
|
||||
{
|
||||
if (string.IsNullOrEmpty(escaped))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
string unescaped = escaped;
|
||||
foreach (EscapeMapping mapping in _escapeMappings)
|
||||
{
|
||||
unescaped = unescaped.Replace(mapping.Replacement, mapping.Token);
|
||||
}
|
||||
|
||||
return unescaped;
|
||||
}
|
||||
|
||||
private static string UnescapeProperty(string escaped)
|
||||
{
|
||||
if (string.IsNullOrEmpty(escaped))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
string unescaped = escaped;
|
||||
foreach (EscapeMapping mapping in _escapePropertyMappings)
|
||||
{
|
||||
unescaped = unescaped.Replace(mapping.Replacement, mapping.Token);
|
||||
}
|
||||
|
||||
return unescaped;
|
||||
}
|
||||
|
||||
private static string UnescapeData(string escaped)
|
||||
{
|
||||
if (string.IsNullOrEmpty(escaped))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
string unescaped = escaped;
|
||||
foreach (EscapeMapping mapping in _escapeDataMappings)
|
||||
{
|
||||
unescaped = unescaped.Replace(mapping.Replacement, mapping.Token);
|
||||
}
|
||||
|
||||
return unescaped;
|
||||
}
|
||||
|
||||
private sealed class EscapeMapping
|
||||
{
|
||||
public string Replacement { get; }
|
||||
public string Token { get; }
|
||||
|
||||
public EscapeMapping(string token, string replacement)
|
||||
{
|
||||
ArgUtil.NotNullOrEmpty(token, nameof(token));
|
||||
ArgUtil.NotNullOrEmpty(replacement, nameof(replacement));
|
||||
Token = token;
|
||||
Replacement = replacement;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
src/Runner.Common/ActionResult.cs
Normal file
15
src/Runner.Common/ActionResult.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
public enum ActionResult
|
||||
{
|
||||
Success = 0,
|
||||
|
||||
Failure = 1,
|
||||
|
||||
Cancelled = 2,
|
||||
|
||||
Skipped = 3
|
||||
}
|
||||
}
|
||||
33
src/Runner.Common/AsyncManualResetEvent.cs
Normal file
33
src/Runner.Common/AsyncManualResetEvent.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
//Stephen Toub: http://blogs.msdn.com/b/pfxteam/archive/2012/02/11/10266920.aspx
|
||||
|
||||
public class AsyncManualResetEvent
|
||||
{
|
||||
private volatile TaskCompletionSource<bool> m_tcs = new TaskCompletionSource<bool>();
|
||||
|
||||
public Task WaitAsync() { return m_tcs.Task; }
|
||||
|
||||
public void Set()
|
||||
{
|
||||
var tcs = m_tcs;
|
||||
Task.Factory.StartNew(s => ((TaskCompletionSource<bool>)s).TrySetResult(true),
|
||||
tcs, CancellationToken.None, TaskCreationOptions.PreferFairness, TaskScheduler.Default);
|
||||
tcs.Task.Wait();
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var tcs = m_tcs;
|
||||
if (!tcs.Task.IsCompleted ||
|
||||
Interlocked.CompareExchange(ref m_tcs, new TaskCompletionSource<bool>(), tcs) == tcs)
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
73
src/Runner.Common/Capabilities/CapabilitiesManager.cs
Normal file
73
src/Runner.Common/Capabilities/CapabilitiesManager.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Common.Capabilities
|
||||
{
|
||||
[ServiceLocator(Default = typeof(CapabilitiesManager))]
|
||||
public interface ICapabilitiesManager : IRunnerService
|
||||
{
|
||||
Task<Dictionary<string, string>> GetCapabilitiesAsync(RunnerSettings settings, CancellationToken token);
|
||||
}
|
||||
|
||||
public sealed class CapabilitiesManager : RunnerService, ICapabilitiesManager
|
||||
{
|
||||
public async Task<Dictionary<string, string>> GetCapabilitiesAsync(RunnerSettings settings, CancellationToken cancellationToken)
|
||||
{
|
||||
Trace.Entering();
|
||||
ArgUtil.NotNull(settings, nameof(settings));
|
||||
|
||||
// Initialize a dictionary of capabilities.
|
||||
var capabilities = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (settings.SkipCapabilitiesScan)
|
||||
{
|
||||
Trace.Info("Skip capabilities scan.");
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
// Get the providers.
|
||||
var extensionManager = HostContext.GetService<IExtensionManager>();
|
||||
IEnumerable<ICapabilitiesProvider> providers =
|
||||
extensionManager
|
||||
.GetExtensions<ICapabilitiesProvider>()
|
||||
?.OrderBy(x => x.Order);
|
||||
|
||||
// Add each capability returned from each provider.
|
||||
foreach (ICapabilitiesProvider provider in providers ?? new ICapabilitiesProvider[0])
|
||||
{
|
||||
foreach (Capability capability in await provider.GetCapabilitiesAsync(settings, cancellationToken) ?? new List<Capability>())
|
||||
{
|
||||
// Make sure we mask secrets in capabilities values.
|
||||
capabilities[capability.Name] = HostContext.SecretMasker.MaskSecrets(capability.Value);
|
||||
}
|
||||
}
|
||||
|
||||
return capabilities;
|
||||
}
|
||||
}
|
||||
|
||||
public interface ICapabilitiesProvider : IExtension
|
||||
{
|
||||
int Order { get; }
|
||||
|
||||
Task<List<Capability>> GetCapabilitiesAsync(RunnerSettings settings, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class Capability
|
||||
{
|
||||
public string Name { get; }
|
||||
public string Value { get; }
|
||||
|
||||
public Capability(string name, string value)
|
||||
{
|
||||
ArgUtil.NotNullOrEmpty(name, nameof(name));
|
||||
Name = name;
|
||||
Value = value ?? string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
86
src/Runner.Common/Capabilities/RunnerCapabilitiesProvider.cs
Normal file
86
src/Runner.Common/Capabilities/RunnerCapabilitiesProvider.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using Microsoft.Win32;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GitHub.Runner.Common.Capabilities
|
||||
{
|
||||
public sealed class RunnerCapabilitiesProvider : RunnerService, ICapabilitiesProvider
|
||||
{
|
||||
public Type ExtensionType => typeof(ICapabilitiesProvider);
|
||||
|
||||
public int Order => 99; // Process last to override prior.
|
||||
|
||||
public Task<List<Capability>> GetCapabilitiesAsync(RunnerSettings settings, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgUtil.NotNull(settings, nameof(settings));
|
||||
var capabilities = new List<Capability>();
|
||||
Add(capabilities, "Runner.Name", settings.AgentName ?? string.Empty);
|
||||
Add(capabilities, "Runner.OS", VarUtil.OS);
|
||||
Add(capabilities, "Runner.OSArchitecture", VarUtil.OSArchitecture);
|
||||
#if OS_WINDOWS
|
||||
Add(capabilities, "Runner.OSVersion", GetOSVersionString());
|
||||
#endif
|
||||
Add(capabilities, "InteractiveSession", (HostContext.StartupType != StartupType.Service).ToString());
|
||||
Add(capabilities, "Runner.Version", BuildConstants.RunnerPackage.Version);
|
||||
Add(capabilities, "Runner.ComputerName", Environment.MachineName ?? string.Empty);
|
||||
Add(capabilities, "Runner.HomeDirectory", HostContext.GetDirectory(WellKnownDirectory.Root));
|
||||
return Task.FromResult(capabilities);
|
||||
}
|
||||
|
||||
private void Add(List<Capability> capabilities, string name, string value)
|
||||
{
|
||||
Trace.Info($"Adding '{name}': '{value}'");
|
||||
capabilities.Add(new Capability(name, value));
|
||||
}
|
||||
|
||||
private object GetHklmValue(string keyName, string valueName)
|
||||
{
|
||||
keyName = $@"HKEY_LOCAL_MACHINE\{keyName}";
|
||||
object value = Registry.GetValue(keyName, valueName, defaultValue: null);
|
||||
if (object.ReferenceEquals(value, null))
|
||||
{
|
||||
Trace.Info($"Key name '{keyName}', value name '{valueName}' is null.");
|
||||
return null;
|
||||
}
|
||||
|
||||
Trace.Info($"Key name '{keyName}', value name '{valueName}': '{value}'");
|
||||
return value;
|
||||
}
|
||||
|
||||
private string GetOSVersionString()
|
||||
{
|
||||
// Do not use System.Environment.OSVersion.Version to resolve the OS version number.
|
||||
// It leverages the GetVersionEx function which may report an incorrect version
|
||||
// depending on the app's manifest. For details, see:
|
||||
// https://msdn.microsoft.com/library/windows/desktop/ms724451(v=vs.85).aspx
|
||||
|
||||
// Attempt to retrieve the major/minor version from the new registry values added in
|
||||
// in Windows 10.
|
||||
//
|
||||
// The registry value "CurrentVersion" is unreliable in Windows 10. It contains the
|
||||
// value "6.3" instead of "10.0".
|
||||
object major = GetHklmValue(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion", "CurrentMajorVersionNumber");
|
||||
object minor = GetHklmValue(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion", "CurrentMinorVersionNumber");
|
||||
string majorMinorString;
|
||||
if (major != null && minor != null)
|
||||
{
|
||||
majorMinorString = StringUtil.Format("{0}.{1}", major, minor);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback to the registry value "CurrentVersion".
|
||||
majorMinorString = GetHklmValue(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion", "CurrentVersion") as string;
|
||||
}
|
||||
|
||||
// Opted to use the registry value "CurrentBuildNumber" over "CurrentBuild". Based on brief
|
||||
// internet investigation, the only difference appears to be that on Windows XP "CurrentBuild"
|
||||
// was unreliable and "CurrentBuildNumber" was the correct choice.
|
||||
string build = GetHklmValue(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion", "CurrentBuildNumber") as string;
|
||||
return StringUtil.Format("{0}.{1}", majorMinorString, build);
|
||||
}
|
||||
}
|
||||
}
|
||||
128
src/Runner.Common/CommandLineParser.cs
Normal file
128
src/Runner.Common/CommandLineParser.cs
Normal file
@@ -0,0 +1,128 @@
|
||||
using GitHub.Runner.Common.Util;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using GitHub.DistributedTask.Logging;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
//
|
||||
// Pattern:
|
||||
// cmd1 cmd2 --arg1 arg1val --aflag --arg2 arg2val
|
||||
//
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
public sealed class CommandLineParser
|
||||
{
|
||||
private ISecretMasker _secretMasker;
|
||||
private Tracing _trace;
|
||||
|
||||
public List<string> Commands { get; }
|
||||
public HashSet<string> Flags { get; }
|
||||
public Dictionary<string, string> Args { get; }
|
||||
public HashSet<string> SecretArgNames { get; }
|
||||
private bool HasArgs { get; set; }
|
||||
|
||||
public CommandLineParser(IHostContext hostContext, string[] secretArgNames)
|
||||
{
|
||||
_secretMasker = hostContext.SecretMasker;
|
||||
_trace = hostContext.GetTrace(nameof(CommandLineParser));
|
||||
|
||||
Commands = new List<string>();
|
||||
Flags = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
Args = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
SecretArgNames = new HashSet<string>(secretArgNames ?? new string[0], StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public bool IsCommand(string name)
|
||||
{
|
||||
bool result = false;
|
||||
if (Commands.Count > 0)
|
||||
{
|
||||
result = String.Equals(name, Commands[0], StringComparison.CurrentCultureIgnoreCase);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public void Parse(string[] args)
|
||||
{
|
||||
_trace.Info(nameof(Parse));
|
||||
ArgUtil.NotNull(args, nameof(args));
|
||||
_trace.Info("Parsing {0} args", args.Length);
|
||||
|
||||
string argScope = null;
|
||||
foreach (string arg in args)
|
||||
{
|
||||
_trace.Info("parsing argument");
|
||||
|
||||
HasArgs = HasArgs || arg.StartsWith("--");
|
||||
_trace.Info("HasArgs: {0}", HasArgs);
|
||||
|
||||
if (string.Equals(arg, "/?", StringComparison.Ordinal))
|
||||
{
|
||||
Flags.Add("help");
|
||||
}
|
||||
else if (!HasArgs)
|
||||
{
|
||||
_trace.Info("Adding Command: {0}", arg);
|
||||
Commands.Add(arg.Trim());
|
||||
}
|
||||
else
|
||||
{
|
||||
// it's either an arg, an arg value or a flag
|
||||
if (arg.StartsWith("--") && arg.Length > 2)
|
||||
{
|
||||
string argVal = arg.Substring(2);
|
||||
_trace.Info("arg: {0}", argVal);
|
||||
|
||||
// this means two --args in a row which means previous was a flag
|
||||
if (argScope != null)
|
||||
{
|
||||
_trace.Info("Adding flag: {0}", argScope);
|
||||
Flags.Add(argScope.Trim());
|
||||
}
|
||||
|
||||
argScope = argVal;
|
||||
}
|
||||
else if (!arg.StartsWith("-"))
|
||||
{
|
||||
// we found a value - check if we're in scope of an arg
|
||||
if (argScope != null && !Args.ContainsKey(argScope = argScope.Trim()))
|
||||
{
|
||||
if (SecretArgNames.Contains(argScope))
|
||||
{
|
||||
_secretMasker.AddValue(arg);
|
||||
}
|
||||
|
||||
_trace.Info("Adding option '{0}': '{1}'", argScope, arg);
|
||||
// ignore duplicates - first wins - below will be val1
|
||||
// --arg1 val1 --arg1 val1
|
||||
Args.Add(argScope, arg);
|
||||
argScope = null;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
//
|
||||
// ignoring the second value for an arg (val2 below)
|
||||
// --arg val1 val2
|
||||
|
||||
// ignoring invalid things like empty - and --
|
||||
// --arg val1 -- --flag
|
||||
_trace.Info("Ignoring arg");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_trace.Verbose("done parsing arguments");
|
||||
|
||||
// handle last arg being a flag
|
||||
if (argScope != null)
|
||||
{
|
||||
Flags.Add(argScope);
|
||||
}
|
||||
|
||||
_trace.Verbose("Exiting parse");
|
||||
}
|
||||
}
|
||||
}
|
||||
252
src/Runner.Common/ConfigurationStore.cs
Normal file
252
src/Runner.Common/ConfigurationStore.cs
Normal file
@@ -0,0 +1,252 @@
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using System.IO;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
//
|
||||
// Settings are persisted in this structure
|
||||
//
|
||||
[DataContract]
|
||||
public sealed class RunnerSettings
|
||||
{
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public bool AcceptTeeEula { get; set; }
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public int AgentId { get; set; }
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public string AgentName { get; set; }
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public string NotificationPipeName { get; set; }
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public string NotificationSocketAddress { get; set; }
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public bool SkipCapabilitiesScan { get; set; }
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public bool SkipSessionRecover { get; set; }
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public int PoolId { get; set; }
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public string PoolName { get; set; }
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public string ServerUrl { get; set; }
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public string GitHubUrl { get; set; }
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public string WorkFolder { get; set; }
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public string MonitorSocketAddress { get; set; }
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public sealed class RunnerRuntimeOptions
|
||||
{
|
||||
#if OS_WINDOWS
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public bool GitUseSecureChannel { get; set; }
|
||||
#endif
|
||||
}
|
||||
|
||||
[ServiceLocator(Default = typeof(ConfigurationStore))]
|
||||
public interface IConfigurationStore : IRunnerService
|
||||
{
|
||||
bool IsConfigured();
|
||||
bool IsServiceConfigured();
|
||||
bool HasCredentials();
|
||||
CredentialData GetCredentials();
|
||||
RunnerSettings GetSettings();
|
||||
void SaveCredential(CredentialData credential);
|
||||
void SaveSettings(RunnerSettings settings);
|
||||
void DeleteCredential();
|
||||
void DeleteSettings();
|
||||
RunnerRuntimeOptions GetRunnerRuntimeOptions();
|
||||
void SaveRunnerRuntimeOptions(RunnerRuntimeOptions options);
|
||||
void DeleteRunnerRuntimeOptions();
|
||||
}
|
||||
|
||||
public sealed class ConfigurationStore : RunnerService, IConfigurationStore
|
||||
{
|
||||
private string _binPath;
|
||||
private string _configFilePath;
|
||||
private string _credFilePath;
|
||||
private string _serviceConfigFilePath;
|
||||
private string _runtimeOptionsFilePath;
|
||||
|
||||
private CredentialData _creds;
|
||||
private RunnerSettings _settings;
|
||||
private RunnerRuntimeOptions _runtimeOptions;
|
||||
|
||||
public override void Initialize(IHostContext hostContext)
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
|
||||
var currentAssemblyLocation = System.Reflection.Assembly.GetEntryAssembly().Location;
|
||||
Trace.Info("currentAssemblyLocation: {0}", currentAssemblyLocation);
|
||||
|
||||
_binPath = HostContext.GetDirectory(WellKnownDirectory.Bin);
|
||||
Trace.Info("binPath: {0}", _binPath);
|
||||
|
||||
RootFolder = HostContext.GetDirectory(WellKnownDirectory.Root);
|
||||
Trace.Info("RootFolder: {0}", RootFolder);
|
||||
|
||||
_configFilePath = hostContext.GetConfigFile(WellKnownConfigFile.Runner);
|
||||
Trace.Info("ConfigFilePath: {0}", _configFilePath);
|
||||
|
||||
_credFilePath = hostContext.GetConfigFile(WellKnownConfigFile.Credentials);
|
||||
Trace.Info("CredFilePath: {0}", _credFilePath);
|
||||
|
||||
_serviceConfigFilePath = hostContext.GetConfigFile(WellKnownConfigFile.Service);
|
||||
Trace.Info("ServiceConfigFilePath: {0}", _serviceConfigFilePath);
|
||||
|
||||
_runtimeOptionsFilePath = hostContext.GetConfigFile(WellKnownConfigFile.Options);
|
||||
Trace.Info("RuntimeOptionsFilePath: {0}", _runtimeOptionsFilePath);
|
||||
}
|
||||
|
||||
public string RootFolder { get; private set; }
|
||||
|
||||
public bool HasCredentials()
|
||||
{
|
||||
ArgUtil.Equal(RunMode.Normal, HostContext.RunMode, nameof(HostContext.RunMode));
|
||||
Trace.Info("HasCredentials()");
|
||||
bool credsStored = (new FileInfo(_credFilePath)).Exists;
|
||||
Trace.Info("stored {0}", credsStored);
|
||||
return credsStored;
|
||||
}
|
||||
|
||||
public bool IsConfigured()
|
||||
{
|
||||
Trace.Info("IsConfigured()");
|
||||
bool configured = HostContext.RunMode == RunMode.Local || (new FileInfo(_configFilePath)).Exists;
|
||||
Trace.Info("IsConfigured: {0}", configured);
|
||||
return configured;
|
||||
}
|
||||
|
||||
public bool IsServiceConfigured()
|
||||
{
|
||||
ArgUtil.Equal(RunMode.Normal, HostContext.RunMode, nameof(HostContext.RunMode));
|
||||
Trace.Info("IsServiceConfigured()");
|
||||
bool serviceConfigured = (new FileInfo(_serviceConfigFilePath)).Exists;
|
||||
Trace.Info($"IsServiceConfigured: {serviceConfigured}");
|
||||
return serviceConfigured;
|
||||
}
|
||||
|
||||
public CredentialData GetCredentials()
|
||||
{
|
||||
ArgUtil.Equal(RunMode.Normal, HostContext.RunMode, nameof(HostContext.RunMode));
|
||||
if (_creds == null)
|
||||
{
|
||||
_creds = IOUtil.LoadObject<CredentialData>(_credFilePath);
|
||||
}
|
||||
|
||||
return _creds;
|
||||
}
|
||||
|
||||
public RunnerSettings GetSettings()
|
||||
{
|
||||
if (_settings == null)
|
||||
{
|
||||
RunnerSettings configuredSettings = null;
|
||||
if (File.Exists(_configFilePath))
|
||||
{
|
||||
string json = File.ReadAllText(_configFilePath, Encoding.UTF8);
|
||||
Trace.Info($"Read setting file: {json.Length} chars");
|
||||
configuredSettings = StringUtil.ConvertFromJson<RunnerSettings>(json);
|
||||
}
|
||||
|
||||
ArgUtil.NotNull(configuredSettings, nameof(configuredSettings));
|
||||
_settings = configuredSettings;
|
||||
}
|
||||
|
||||
return _settings;
|
||||
}
|
||||
|
||||
public void SaveCredential(CredentialData credential)
|
||||
{
|
||||
ArgUtil.Equal(RunMode.Normal, HostContext.RunMode, nameof(HostContext.RunMode));
|
||||
Trace.Info("Saving {0} credential @ {1}", credential.Scheme, _credFilePath);
|
||||
if (File.Exists(_credFilePath))
|
||||
{
|
||||
// Delete existing credential file first, since the file is hidden and not able to overwrite.
|
||||
Trace.Info("Delete exist runner credential file.");
|
||||
IOUtil.DeleteFile(_credFilePath);
|
||||
}
|
||||
|
||||
IOUtil.SaveObject(credential, _credFilePath);
|
||||
Trace.Info("Credentials Saved.");
|
||||
File.SetAttributes(_credFilePath, File.GetAttributes(_credFilePath) | FileAttributes.Hidden);
|
||||
}
|
||||
|
||||
public void SaveSettings(RunnerSettings settings)
|
||||
{
|
||||
ArgUtil.Equal(RunMode.Normal, HostContext.RunMode, nameof(HostContext.RunMode));
|
||||
Trace.Info("Saving runner settings.");
|
||||
if (File.Exists(_configFilePath))
|
||||
{
|
||||
// Delete existing runner settings file first, since the file is hidden and not able to overwrite.
|
||||
Trace.Info("Delete exist runner settings file.");
|
||||
IOUtil.DeleteFile(_configFilePath);
|
||||
}
|
||||
|
||||
IOUtil.SaveObject(settings, _configFilePath);
|
||||
Trace.Info("Settings Saved.");
|
||||
File.SetAttributes(_configFilePath, File.GetAttributes(_configFilePath) | FileAttributes.Hidden);
|
||||
}
|
||||
|
||||
public void DeleteCredential()
|
||||
{
|
||||
ArgUtil.Equal(RunMode.Normal, HostContext.RunMode, nameof(HostContext.RunMode));
|
||||
IOUtil.Delete(_credFilePath, default(CancellationToken));
|
||||
}
|
||||
|
||||
public void DeleteSettings()
|
||||
{
|
||||
ArgUtil.Equal(RunMode.Normal, HostContext.RunMode, nameof(HostContext.RunMode));
|
||||
IOUtil.Delete(_configFilePath, default(CancellationToken));
|
||||
}
|
||||
|
||||
public RunnerRuntimeOptions GetRunnerRuntimeOptions()
|
||||
{
|
||||
if (_runtimeOptions == null && File.Exists(_runtimeOptionsFilePath))
|
||||
{
|
||||
_runtimeOptions = IOUtil.LoadObject<RunnerRuntimeOptions>(_runtimeOptionsFilePath);
|
||||
}
|
||||
|
||||
return _runtimeOptions;
|
||||
}
|
||||
|
||||
public void SaveRunnerRuntimeOptions(RunnerRuntimeOptions options)
|
||||
{
|
||||
Trace.Info("Saving runtime options.");
|
||||
if (File.Exists(_runtimeOptionsFilePath))
|
||||
{
|
||||
// Delete existing runtime options file first, since the file is hidden and not able to overwrite.
|
||||
Trace.Info("Delete exist runtime options file.");
|
||||
IOUtil.DeleteFile(_runtimeOptionsFilePath);
|
||||
}
|
||||
|
||||
IOUtil.SaveObject(options, _runtimeOptionsFilePath);
|
||||
Trace.Info("Options Saved.");
|
||||
File.SetAttributes(_runtimeOptionsFilePath, File.GetAttributes(_runtimeOptionsFilePath) | FileAttributes.Hidden);
|
||||
}
|
||||
|
||||
public void DeleteRunnerRuntimeOptions()
|
||||
{
|
||||
IOUtil.Delete(_runtimeOptionsFilePath, default(CancellationToken));
|
||||
}
|
||||
}
|
||||
}
|
||||
343
src/Runner.Common/Constants.cs
Normal file
343
src/Runner.Common/Constants.cs
Normal file
@@ -0,0 +1,343 @@
|
||||
using System;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
public enum RunMode
|
||||
{
|
||||
Normal, // Keep "Normal" first (default value).
|
||||
Local,
|
||||
}
|
||||
|
||||
public enum WellKnownDirectory
|
||||
{
|
||||
Bin,
|
||||
Diag,
|
||||
Externals,
|
||||
Root,
|
||||
Actions,
|
||||
Temp,
|
||||
Tools,
|
||||
Update,
|
||||
Work,
|
||||
}
|
||||
|
||||
public enum WellKnownConfigFile
|
||||
{
|
||||
Runner,
|
||||
Credentials,
|
||||
RSACredentials,
|
||||
Service,
|
||||
CredentialStore,
|
||||
Certificates,
|
||||
Proxy,
|
||||
ProxyCredentials,
|
||||
ProxyBypass,
|
||||
Options,
|
||||
}
|
||||
|
||||
public static class Constants
|
||||
{
|
||||
/// <summary>Path environment variable name.</summary>
|
||||
#if OS_WINDOWS
|
||||
public static readonly string PathVariable = "Path";
|
||||
#else
|
||||
public static readonly string PathVariable = "PATH";
|
||||
#endif
|
||||
|
||||
public static string ProcessTrackingId = "RUNNER_TRACKING_ID";
|
||||
public static string PluginTracePrefix = "##[plugin.trace]";
|
||||
public static readonly int RunnerDownloadRetryMaxAttempts = 3;
|
||||
|
||||
// This enum is embedded within the Constants class to make it easier to reference and avoid
|
||||
// ambiguous type reference with System.Runtime.InteropServices.OSPlatform and System.Runtime.InteropServices.Architecture
|
||||
public enum OSPlatform
|
||||
{
|
||||
OSX,
|
||||
Linux,
|
||||
Windows
|
||||
}
|
||||
|
||||
public enum Architecture
|
||||
{
|
||||
X86,
|
||||
X64,
|
||||
Arm,
|
||||
Arm64
|
||||
}
|
||||
|
||||
public static class Runner
|
||||
{
|
||||
#if OS_LINUX
|
||||
public static readonly OSPlatform Platform = OSPlatform.Linux;
|
||||
#elif OS_OSX
|
||||
public static readonly OSPlatform Platform = OSPlatform.OSX;
|
||||
#elif OS_WINDOWS
|
||||
public static readonly OSPlatform Platform = OSPlatform.Windows;
|
||||
#endif
|
||||
|
||||
#if X86
|
||||
public static readonly Architecture PlatformArchitecture = Architecture.X86;
|
||||
#elif X64
|
||||
public static readonly Architecture PlatformArchitecture = Architecture.X64;
|
||||
#elif ARM
|
||||
public static readonly Architecture PlatformArchitecture = Architecture.Arm;
|
||||
#elif ARM64
|
||||
public static readonly Architecture PlatformArchitecture = Architecture.Arm64;
|
||||
#endif
|
||||
|
||||
public static readonly TimeSpan ExitOnUnloadTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
public static class CommandLine
|
||||
{
|
||||
//if you are adding a new arg, please make sure you update the
|
||||
//validArgs array as well present in the CommandSettings.cs
|
||||
public static class Args
|
||||
{
|
||||
public static readonly string Agent = "agent";
|
||||
public static readonly string Auth = "auth";
|
||||
public static readonly string CollectionName = "collectionname";
|
||||
public static readonly string DeploymentGroupName = "deploymentgroupname";
|
||||
public static readonly string DeploymentPoolName = "deploymentpoolname";
|
||||
public static readonly string DeploymentGroupTags = "deploymentgrouptags";
|
||||
public static readonly string MachineGroupName = "machinegroupname";
|
||||
public static readonly string MachineGroupTags = "machinegrouptags";
|
||||
public static readonly string Matrix = "matrix";
|
||||
public static readonly string MonitorSocketAddress = "monitorsocketaddress";
|
||||
public static readonly string NotificationPipeName = "notificationpipename";
|
||||
public static readonly string NotificationSocketAddress = "notificationsocketaddress";
|
||||
public static readonly string Pool = "pool";
|
||||
public static readonly string ProjectName = "projectname";
|
||||
public static readonly string ProxyUrl = "proxyurl";
|
||||
public static readonly string ProxyUserName = "proxyusername";
|
||||
public static readonly string SslCACert = "sslcacert";
|
||||
public static readonly string SslClientCert = "sslclientcert";
|
||||
public static readonly string SslClientCertKey = "sslclientcertkey";
|
||||
public static readonly string SslClientCertArchive = "sslclientcertarchive";
|
||||
public static readonly string SslClientCertPassword = "sslclientcertpassword";
|
||||
public static readonly string StartupType = "startuptype";
|
||||
public static readonly string Url = "url";
|
||||
public static readonly string UserName = "username";
|
||||
public static readonly string WindowsLogonAccount = "windowslogonaccount";
|
||||
public static readonly string Work = "work";
|
||||
public static readonly string Yml = "yml";
|
||||
|
||||
// Secret args. Must be added to the "Secrets" getter as well.
|
||||
public static readonly string Password = "password";
|
||||
public static readonly string ProxyPassword = "proxypassword";
|
||||
public static readonly string Token = "token";
|
||||
public static readonly string WindowsLogonPassword = "windowslogonpassword";
|
||||
public static string[] Secrets => new[]
|
||||
{
|
||||
Password,
|
||||
ProxyPassword,
|
||||
SslClientCertPassword,
|
||||
Token,
|
||||
WindowsLogonPassword,
|
||||
};
|
||||
}
|
||||
|
||||
public static class Commands
|
||||
{
|
||||
public static readonly string Configure = "configure";
|
||||
public static readonly string LocalRun = "localRun";
|
||||
public static readonly string Remove = "remove";
|
||||
public static readonly string Run = "run";
|
||||
public static readonly string Warmup = "warmup";
|
||||
}
|
||||
|
||||
//if you are adding a new flag, please make sure you update the
|
||||
//validFlags array as well present in the CommandSettings.cs
|
||||
public static class Flags
|
||||
{
|
||||
public static readonly string AcceptTeeEula = "acceptteeeula";
|
||||
public static readonly string AddDeploymentGroupTags = "adddeploymentgrouptags";
|
||||
public static readonly string AddMachineGroupTags = "addmachinegrouptags";
|
||||
public static readonly string Commit = "commit";
|
||||
public static readonly string DeploymentGroup = "deploymentgroup";
|
||||
public static readonly string DeploymentPool = "deploymentpool";
|
||||
public static readonly string OverwriteAutoLogon = "overwriteautologon";
|
||||
public static readonly string GitUseSChannel = "gituseschannel";
|
||||
public static readonly string Help = "help";
|
||||
public static readonly string MachineGroup = "machinegroup";
|
||||
public static readonly string Replace = "replace";
|
||||
public static readonly string NoRestart = "norestart";
|
||||
public static readonly string LaunchBrowser = "launchbrowser";
|
||||
public static readonly string Once = "once";
|
||||
public static readonly string RunAsAutoLogon = "runasautologon";
|
||||
public static readonly string RunAsService = "runasservice";
|
||||
public static readonly string SslSkipCertValidation = "sslskipcertvalidation";
|
||||
public static readonly string Unattended = "unattended";
|
||||
public static readonly string Version = "version";
|
||||
public static readonly string WhatIf = "whatif";
|
||||
}
|
||||
}
|
||||
|
||||
public static class ReturnCode
|
||||
{
|
||||
public const int Success = 0;
|
||||
public const int TerminatedError = 1;
|
||||
public const int RetryableError = 2;
|
||||
public const int RunnerUpdating = 3;
|
||||
public const int RunOnceRunnerUpdating = 4;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Pipeline
|
||||
{
|
||||
public static class Path
|
||||
{
|
||||
public static readonly string PipelineMappingDirectory = "_PipelineMapping";
|
||||
public static readonly string TrackingConfigFile = "PipelineFolder.json";
|
||||
}
|
||||
}
|
||||
|
||||
public static class Configuration
|
||||
{
|
||||
public static readonly string AAD = "AAD";
|
||||
public static readonly string OAuthAccessToken = "OAuthAccessToken";
|
||||
public static readonly string PAT = "PAT";
|
||||
public static readonly string OAuth = "OAuth";
|
||||
}
|
||||
|
||||
public static class Expressions
|
||||
{
|
||||
public static readonly string Always = "always";
|
||||
public static readonly string Canceled = "canceled";
|
||||
public static readonly string Cancelled = "cancelled";
|
||||
public static readonly string Failed = "failed";
|
||||
public static readonly string Failure = "failure";
|
||||
public static readonly string Success = "success";
|
||||
public static readonly string Succeeded = "succeeded";
|
||||
public static readonly string SucceededOrFailed = "succeededOrFailed";
|
||||
public static readonly string Variables = "variables";
|
||||
}
|
||||
|
||||
public static class Path
|
||||
{
|
||||
public static readonly string ActionsDirectory = "_actions";
|
||||
public static readonly string ActionManifestFile = "action.yml";
|
||||
public static readonly string BinDirectory = "bin";
|
||||
public static readonly string DiagDirectory = "_diag";
|
||||
public static readonly string ExternalsDirectory = "externals";
|
||||
public static readonly string RunnerDiagnosticLogPrefix = "Runner_";
|
||||
public static readonly string TempDirectory = "_temp";
|
||||
public static readonly string TeeDirectory = "tee";
|
||||
public static readonly string ToolDirectory = "_tool";
|
||||
public static readonly string TaskJsonFile = "task.json";
|
||||
public static readonly string UpdateDirectory = "_update";
|
||||
public static readonly string WorkDirectory = "_work";
|
||||
public static readonly string WorkerDiagnosticLogPrefix = "Worker_";
|
||||
}
|
||||
|
||||
// Related to definition variables.
|
||||
public static class Variables
|
||||
{
|
||||
public static readonly string MacroPrefix = "$(";
|
||||
public static readonly string MacroSuffix = ")";
|
||||
|
||||
public static class Actions
|
||||
{
|
||||
//
|
||||
// Keep alphabetical
|
||||
//
|
||||
public static readonly string RunnerDebug = "ACTIONS_RUNNER_DEBUG";
|
||||
public static readonly string StepDebug = "ACTIONS_STEP_DEBUG";
|
||||
}
|
||||
|
||||
public static class Agent
|
||||
{
|
||||
//
|
||||
// Keep alphabetical
|
||||
//
|
||||
public static readonly string AcceptTeeEula = "agent.acceptteeeula";
|
||||
public static readonly string AllowAllEndpoints = "agent.allowAllEndpoints"; // remove after sprint 120 or so.
|
||||
public static readonly string AllowAllSecureFiles = "agent.allowAllSecureFiles"; // remove after sprint 121 or so.
|
||||
public static readonly string BuildDirectory = "agent.builddirectory";
|
||||
public static readonly string ContainerId = "agent.containerid";
|
||||
public static readonly string ContainerNetwork = "agent.containernetwork";
|
||||
public static readonly string HomeDirectory = "agent.homedirectory";
|
||||
public static readonly string Id = "agent.id";
|
||||
public static readonly string GitUseSChannel = "agent.gituseschannel";
|
||||
public static readonly string JobName = "agent.jobname";
|
||||
public static readonly string MachineName = "agent.machinename";
|
||||
public static readonly string Name = "agent.name";
|
||||
public static readonly string OS = "agent.os";
|
||||
public static readonly string OSArchitecture = "agent.osarchitecture";
|
||||
public static readonly string OSVersion = "agent.osversion";
|
||||
public static readonly string ProxyUrl = "agent.proxyurl";
|
||||
public static readonly string ProxyUsername = "agent.proxyusername";
|
||||
public static readonly string ProxyPassword = "agent.proxypassword";
|
||||
public static readonly string ProxyBypassList = "agent.proxybypasslist";
|
||||
public static readonly string RetainDefaultEncoding = "agent.retainDefaultEncoding";
|
||||
public static readonly string RootDirectory = "agent.RootDirectory";
|
||||
public static readonly string RunMode = "agent.runmode";
|
||||
public static readonly string ServerOMDirectory = "agent.ServerOMDirectory";
|
||||
public static readonly string ServicePortPrefix = "agent.services";
|
||||
public static readonly string SslCAInfo = "agent.cainfo";
|
||||
public static readonly string SslClientCert = "agent.clientcert";
|
||||
public static readonly string SslClientCertKey = "agent.clientcertkey";
|
||||
public static readonly string SslClientCertArchive = "agent.clientcertarchive";
|
||||
public static readonly string SslClientCertPassword = "agent.clientcertpassword";
|
||||
public static readonly string SslSkipCertValidation = "agent.skipcertvalidation";
|
||||
public static readonly string TempDirectory = "agent.TempDirectory";
|
||||
public static readonly string ToolsDirectory = "agent.ToolsDirectory";
|
||||
public static readonly string Version = "agent.version";
|
||||
public static readonly string WorkFolder = "agent.workfolder";
|
||||
public static readonly string WorkingDirectory = "agent.WorkingDirectory";
|
||||
}
|
||||
|
||||
public static class Build
|
||||
{
|
||||
//
|
||||
// Keep alphabetical
|
||||
//
|
||||
public static readonly string ArtifactStagingDirectory = "build.artifactstagingdirectory";
|
||||
public static readonly string BinariesDirectory = "build.binariesdirectory";
|
||||
public static readonly string Number = "build.buildNumber";
|
||||
public static readonly string Clean = "build.clean";
|
||||
public static readonly string DefinitionName = "build.definitionname";
|
||||
public static readonly string GatedRunCI = "build.gated.runci";
|
||||
public static readonly string GatedShelvesetName = "build.gated.shelvesetname";
|
||||
public static readonly string RepoClean = "build.repository.clean";
|
||||
public static readonly string RepoGitSubmoduleCheckout = "build.repository.git.submodulecheckout";
|
||||
public static readonly string RepoId = "build.repository.id";
|
||||
public static readonly string RepoLocalPath = "build.repository.localpath";
|
||||
public static readonly string RepoName = "build.Repository.name";
|
||||
public static readonly string RepoProvider = "build.repository.provider";
|
||||
public static readonly string RepoTfvcWorkspace = "build.repository.tfvc.workspace";
|
||||
public static readonly string RepoUri = "build.repository.uri";
|
||||
public static readonly string SourceBranch = "build.sourcebranch";
|
||||
public static readonly string SourceTfvcShelveset = "build.sourcetfvcshelveset";
|
||||
public static readonly string SourceVersion = "build.sourceversion";
|
||||
public static readonly string SourcesDirectory = "build.sourcesdirectory";
|
||||
public static readonly string StagingDirectory = "build.stagingdirectory";
|
||||
public static readonly string SyncSources = "build.syncSources";
|
||||
}
|
||||
|
||||
|
||||
public static class System
|
||||
{
|
||||
//
|
||||
// Keep alphabetical
|
||||
//
|
||||
public static readonly string AccessToken = "system.accessToken";
|
||||
public static readonly string ArtifactsDirectory = "system.artifactsdirectory";
|
||||
public static readonly string CollectionId = "system.collectionid";
|
||||
public static readonly string Culture = "system.culture";
|
||||
public static readonly string DefaultWorkingDirectory = "system.defaultworkingdirectory";
|
||||
public static readonly string DefinitionId = "system.definitionid";
|
||||
public static readonly string EnableAccessToken = "system.enableAccessToken";
|
||||
public static readonly string HostType = "system.hosttype";
|
||||
public static readonly string PhaseDisplayName = "system.phaseDisplayName";
|
||||
public static readonly string PreferGitFromPath = "system.prefergitfrompath";
|
||||
public static readonly string PullRequestTargetBranchName = "system.pullrequest.targetbranch";
|
||||
public static readonly string SelfManageGitCreds = "system.selfmanagegitcreds";
|
||||
public static readonly string ServerType = "system.servertype";
|
||||
public static readonly string TFServerUrl = "system.TeamFoundationServerUri"; // back compat variable, do not document
|
||||
public static readonly string TeamProject = "system.teamproject";
|
||||
public static readonly string TeamProjectId = "system.teamProjectId";
|
||||
public static readonly string WorkFolder = "system.workfolder";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/Runner.Common/CredentialData.cs
Normal file
24
src/Runner.Common/CredentialData.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
public sealed class CredentialData
|
||||
{
|
||||
public string Scheme { get; set; }
|
||||
|
||||
public Dictionary<string, string> Data
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_data == null)
|
||||
{
|
||||
_data = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
return _data;
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, string> _data;
|
||||
}
|
||||
}
|
||||
19
src/Runner.Common/Exceptions.cs
Normal file
19
src/Runner.Common/Exceptions.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using System;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
public class NonRetryableException : Exception
|
||||
{
|
||||
public NonRetryableException()
|
||||
: base()
|
||||
{ }
|
||||
|
||||
public NonRetryableException(string message)
|
||||
: base(message)
|
||||
{ }
|
||||
|
||||
public NonRetryableException(string message, Exception inner)
|
||||
: base(message, inner)
|
||||
{ }
|
||||
}
|
||||
}
|
||||
80
src/Runner.Common/ExtensionManager.cs
Normal file
80
src/Runner.Common/ExtensionManager.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
[ServiceLocator(Default = typeof(ExtensionManager))]
|
||||
public interface IExtensionManager : IRunnerService
|
||||
{
|
||||
List<T> GetExtensions<T>() where T : class, IExtension;
|
||||
}
|
||||
|
||||
public sealed class ExtensionManager : RunnerService, IExtensionManager
|
||||
{
|
||||
private readonly ConcurrentDictionary<Type, List<IExtension>> _cache = new ConcurrentDictionary<Type, List<IExtension>>();
|
||||
|
||||
public List<T> GetExtensions<T>() where T : class, IExtension
|
||||
{
|
||||
Trace.Info("Getting extensions for interface: '{0}'", typeof(T).FullName);
|
||||
List<IExtension> extensions = _cache.GetOrAdd(
|
||||
key: typeof(T),
|
||||
valueFactory: (Type key) =>
|
||||
{
|
||||
return LoadExtensions<T>();
|
||||
});
|
||||
return extensions.Select(x => x as T).ToList();
|
||||
}
|
||||
|
||||
//
|
||||
// We will load extensions from assembly
|
||||
// once AssemblyLoadContext.Resolving event is able to
|
||||
// resolve dependency recursively
|
||||
//
|
||||
private List<IExtension> LoadExtensions<T>() where T : class, IExtension
|
||||
{
|
||||
var extensions = new List<IExtension>();
|
||||
switch (typeof(T).FullName)
|
||||
{
|
||||
// Listener capabilities providers.
|
||||
case "GitHub.Runner.Common.Capabilities.ICapabilitiesProvider":
|
||||
Add<T>(extensions, "GitHub.Runner.Common.Capabilities.RunnerCapabilitiesProvider, Runner.Common");
|
||||
break;
|
||||
// Action command extensions.
|
||||
case "GitHub.Runner.Worker.IActionCommandExtension":
|
||||
Add<T>(extensions, "GitHub.Runner.Worker.InternalPluginSetRepoPathCommandExtension, Runner.Worker");
|
||||
Add<T>(extensions, "GitHub.Runner.Worker.SetEnvCommandExtension, Runner.Worker");
|
||||
Add<T>(extensions, "GitHub.Runner.Worker.SetOutputCommandExtension, Runner.Worker");
|
||||
Add<T>(extensions, "GitHub.Runner.Worker.SaveStateCommandExtension, Runner.Worker");
|
||||
Add<T>(extensions, "GitHub.Runner.Worker.AddPathCommandExtension, Runner.Worker");
|
||||
Add<T>(extensions, "GitHub.Runner.Worker.AddMaskCommandExtension, Runner.Worker");
|
||||
Add<T>(extensions, "GitHub.Runner.Worker.AddMatcherCommandExtension, Runner.Worker");
|
||||
Add<T>(extensions, "GitHub.Runner.Worker.RemoveMatcherCommandExtension, Runner.Worker");
|
||||
Add<T>(extensions, "GitHub.Runner.Worker.WarningCommandExtension, Runner.Worker");
|
||||
Add<T>(extensions, "GitHub.Runner.Worker.ErrorCommandExtension, Runner.Worker");
|
||||
Add<T>(extensions, "GitHub.Runner.Worker.DebugCommandExtension, Runner.Worker");
|
||||
Add<T>(extensions, "GitHub.Runner.Worker.GroupCommandExtension, Runner.Worker");
|
||||
Add<T>(extensions, "GitHub.Runner.Worker.EndGroupCommandExtension, Runner.Worker");
|
||||
break;
|
||||
default:
|
||||
// This should never happen.
|
||||
throw new NotSupportedException($"Unexpected extension type: '{typeof(T).FullName}'");
|
||||
}
|
||||
|
||||
return extensions;
|
||||
}
|
||||
|
||||
private void Add<T>(List<IExtension> extensions, string assemblyQualifiedName) where T : class, IExtension
|
||||
{
|
||||
Trace.Info($"Creating instance: {assemblyQualifiedName}");
|
||||
Type type = Type.GetType(assemblyQualifiedName, throwOnError: true);
|
||||
var extension = Activator.CreateInstance(type) as T;
|
||||
ArgUtil.NotNull(extension, nameof(extension));
|
||||
extension.Initialize(HostContext);
|
||||
extensions.Add(extension);
|
||||
}
|
||||
}
|
||||
}
|
||||
30
src/Runner.Common/Extensions.cs
Normal file
30
src/Runner.Common/Extensions.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
//this code is documented on http://blogs.msdn.com/b/pfxteam/archive/2012/10/05/how-do-i-cancel-non-cancelable-async-operations.aspx
|
||||
public static class Extensions
|
||||
{
|
||||
public static async Task<T> WithCancellation<T>(this Task<T> task, CancellationToken cancellationToken)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
using (cancellationToken.Register(
|
||||
s => ((TaskCompletionSource<bool>)s).TrySetResult(true), tcs))
|
||||
if (task != await Task.WhenAny(task, tcs.Task))
|
||||
throw new OperationCanceledException(cancellationToken);
|
||||
return await task;
|
||||
}
|
||||
|
||||
public static async Task WithCancellation(this Task task, CancellationToken cancellationToken)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
using (cancellationToken.Register(
|
||||
s => ((TaskCompletionSource<bool>)s).TrySetResult(true), tcs))
|
||||
if (task != await Task.WhenAny(task, tcs.Task))
|
||||
throw new OperationCanceledException(cancellationToken);
|
||||
await task;
|
||||
}
|
||||
}
|
||||
}
|
||||
597
src/Runner.Common/HostContext.cs
Normal file
597
src/Runner.Common/HostContext.cs
Normal file
@@ -0,0 +1,597 @@
|
||||
using GitHub.Runner.Common.Util;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Diagnostics;
|
||||
using System.Net.Http;
|
||||
using System.Diagnostics.Tracing;
|
||||
using GitHub.DistributedTask.Logging;
|
||||
using System.Net.Http.Headers;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
public interface IHostContext : IDisposable
|
||||
{
|
||||
RunMode RunMode { get; set; }
|
||||
StartupType StartupType { get; set; }
|
||||
CancellationToken RunnerShutdownToken { get; }
|
||||
ShutdownReason RunnerShutdownReason { get; }
|
||||
ISecretMasker SecretMasker { get; }
|
||||
ProductInfoHeaderValue UserAgent { get; }
|
||||
string GetDirectory(WellKnownDirectory directory);
|
||||
string GetConfigFile(WellKnownConfigFile configFile);
|
||||
Tracing GetTrace(string name);
|
||||
Task Delay(TimeSpan delay, CancellationToken cancellationToken);
|
||||
T CreateService<T>() where T : class, IRunnerService;
|
||||
T GetService<T>() where T : class, IRunnerService;
|
||||
void SetDefaultCulture(string name);
|
||||
event EventHandler Unloading;
|
||||
void ShutdownRunner(ShutdownReason reason);
|
||||
void WritePerfCounter(string counter);
|
||||
}
|
||||
|
||||
public enum StartupType
|
||||
{
|
||||
Manual,
|
||||
Service,
|
||||
AutoStartup
|
||||
}
|
||||
|
||||
public sealed class HostContext : EventListener, IObserver<DiagnosticListener>, IObserver<KeyValuePair<string, object>>, IHostContext, IDisposable
|
||||
{
|
||||
private const int _defaultLogPageSize = 8; //MB
|
||||
private static int _defaultLogRetentionDays = 30;
|
||||
private static int[] _vssHttpMethodEventIds = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 24 };
|
||||
private static int[] _vssHttpCredentialEventIds = new int[] { 11, 13, 14, 15, 16, 17, 18, 20, 21, 22, 27, 29 };
|
||||
private readonly ConcurrentDictionary<Type, object> _serviceInstances = new ConcurrentDictionary<Type, object>();
|
||||
private readonly ConcurrentDictionary<Type, Type> _serviceTypes = new ConcurrentDictionary<Type, Type>();
|
||||
private readonly ISecretMasker _secretMasker = new SecretMasker();
|
||||
private readonly ProductInfoHeaderValue _userAgent = new ProductInfoHeaderValue($"GitHubActionsRunner-{BuildConstants.RunnerPackage.PackageName}", BuildConstants.RunnerPackage.Version);
|
||||
private CancellationTokenSource _runnerShutdownTokenSource = new CancellationTokenSource();
|
||||
private object _perfLock = new object();
|
||||
private RunMode _runMode = RunMode.Normal;
|
||||
private Tracing _trace;
|
||||
private Tracing _vssTrace;
|
||||
private Tracing _httpTrace;
|
||||
private ITraceManager _traceManager;
|
||||
private AssemblyLoadContext _loadContext;
|
||||
private IDisposable _httpTraceSubscription;
|
||||
private IDisposable _diagListenerSubscription;
|
||||
private StartupType _startupType;
|
||||
private string _perfFile;
|
||||
|
||||
public event EventHandler Unloading;
|
||||
public CancellationToken RunnerShutdownToken => _runnerShutdownTokenSource.Token;
|
||||
public ShutdownReason RunnerShutdownReason { get; private set; }
|
||||
public ISecretMasker SecretMasker => _secretMasker;
|
||||
public ProductInfoHeaderValue UserAgent => _userAgent;
|
||||
public HostContext(string hostType, string logFile = null)
|
||||
{
|
||||
// Validate args.
|
||||
ArgUtil.NotNullOrEmpty(hostType, nameof(hostType));
|
||||
|
||||
_loadContext = AssemblyLoadContext.GetLoadContext(typeof(HostContext).GetTypeInfo().Assembly);
|
||||
_loadContext.Unloading += LoadContext_Unloading;
|
||||
|
||||
this.SecretMasker.AddValueEncoder(ValueEncoders.Base64StringEscape);
|
||||
this.SecretMasker.AddValueEncoder(ValueEncoders.Base64StringEscapeShift1);
|
||||
this.SecretMasker.AddValueEncoder(ValueEncoders.Base64StringEscapeShift2);
|
||||
this.SecretMasker.AddValueEncoder(ValueEncoders.Base64StringEscapeShift3);
|
||||
this.SecretMasker.AddValueEncoder(ValueEncoders.Base64StringEscapeShift4);
|
||||
this.SecretMasker.AddValueEncoder(ValueEncoders.Base64StringEscapeShift5);
|
||||
this.SecretMasker.AddValueEncoder(ValueEncoders.ExpressionStringEscape);
|
||||
this.SecretMasker.AddValueEncoder(ValueEncoders.JsonStringEscape);
|
||||
this.SecretMasker.AddValueEncoder(ValueEncoders.UriDataEscape);
|
||||
this.SecretMasker.AddValueEncoder(ValueEncoders.XmlDataEscape);
|
||||
|
||||
// Create the trace manager.
|
||||
if (string.IsNullOrEmpty(logFile))
|
||||
{
|
||||
int logPageSize;
|
||||
string logSizeEnv = Environment.GetEnvironmentVariable($"{hostType.ToUpperInvariant()}_LOGSIZE");
|
||||
if (!string.IsNullOrEmpty(logSizeEnv) || !int.TryParse(logSizeEnv, out logPageSize))
|
||||
{
|
||||
logPageSize = _defaultLogPageSize;
|
||||
}
|
||||
|
||||
int logRetentionDays;
|
||||
string logRetentionDaysEnv = Environment.GetEnvironmentVariable($"{hostType.ToUpperInvariant()}_LOGRETENTION");
|
||||
if (!string.IsNullOrEmpty(logRetentionDaysEnv) || !int.TryParse(logRetentionDaysEnv, out logRetentionDays))
|
||||
{
|
||||
logRetentionDays = _defaultLogRetentionDays;
|
||||
}
|
||||
|
||||
// this should give us _diag folder under runner root directory
|
||||
string diagLogDirectory = Path.Combine(new DirectoryInfo(Path.GetDirectoryName(Assembly.GetEntryAssembly().Location)).Parent.FullName, Constants.Path.DiagDirectory);
|
||||
_traceManager = new TraceManager(new HostTraceListener(diagLogDirectory, hostType, logPageSize, logRetentionDays), this.SecretMasker);
|
||||
}
|
||||
else
|
||||
{
|
||||
_traceManager = new TraceManager(new HostTraceListener(logFile), this.SecretMasker);
|
||||
}
|
||||
|
||||
_trace = GetTrace(nameof(HostContext));
|
||||
_vssTrace = GetTrace("GitHubActionsRunner"); // VisualStudioService
|
||||
|
||||
// Enable Http trace
|
||||
bool enableHttpTrace;
|
||||
if (bool.TryParse(Environment.GetEnvironmentVariable("GITHUB_ACTIONS_RUNNER_HTTPTRACE"), out enableHttpTrace) && enableHttpTrace)
|
||||
{
|
||||
_trace.Warning("*****************************************************************************************");
|
||||
_trace.Warning("** **");
|
||||
_trace.Warning("** Http trace is enabled, all your http traffic will be dumped into runner diag log. **");
|
||||
_trace.Warning("** DO NOT share the log in public place! The trace may contains secrets in plain text. **");
|
||||
_trace.Warning("** **");
|
||||
_trace.Warning("*****************************************************************************************");
|
||||
|
||||
_httpTrace = GetTrace("HttpTrace");
|
||||
_diagListenerSubscription = DiagnosticListener.AllListeners.Subscribe(this);
|
||||
}
|
||||
|
||||
// Enable perf counter trace
|
||||
string perfCounterLocation = Environment.GetEnvironmentVariable("RUNNER_PERFLOG");
|
||||
if (!string.IsNullOrEmpty(perfCounterLocation))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(perfCounterLocation);
|
||||
_perfFile = Path.Combine(perfCounterLocation, $"{hostType}.perf");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_trace.Error(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public RunMode RunMode
|
||||
{
|
||||
get
|
||||
{
|
||||
return _runMode;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
_trace.Info($"Set run mode: {value}");
|
||||
_runMode = value;
|
||||
}
|
||||
}
|
||||
|
||||
public string GetDirectory(WellKnownDirectory directory)
|
||||
{
|
||||
string path;
|
||||
switch (directory)
|
||||
{
|
||||
case WellKnownDirectory.Bin:
|
||||
path = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location);
|
||||
break;
|
||||
|
||||
case WellKnownDirectory.Diag:
|
||||
path = Path.Combine(
|
||||
GetDirectory(WellKnownDirectory.Root),
|
||||
Constants.Path.DiagDirectory);
|
||||
break;
|
||||
|
||||
case WellKnownDirectory.Externals:
|
||||
path = Path.Combine(
|
||||
GetDirectory(WellKnownDirectory.Root),
|
||||
Constants.Path.ExternalsDirectory);
|
||||
break;
|
||||
|
||||
case WellKnownDirectory.Root:
|
||||
path = new DirectoryInfo(GetDirectory(WellKnownDirectory.Bin)).Parent.FullName;
|
||||
break;
|
||||
|
||||
case WellKnownDirectory.Temp:
|
||||
path = Path.Combine(
|
||||
GetDirectory(WellKnownDirectory.Work),
|
||||
Constants.Path.TempDirectory);
|
||||
break;
|
||||
|
||||
case WellKnownDirectory.Actions:
|
||||
path = Path.Combine(
|
||||
GetDirectory(WellKnownDirectory.Work),
|
||||
Constants.Path.ActionsDirectory);
|
||||
break;
|
||||
|
||||
case WellKnownDirectory.Tools:
|
||||
// TODO: Coallesce to just check RUNNER_TOOL_CACHE when images stabilize
|
||||
path = Environment.GetEnvironmentVariable("RUNNER_TOOL_CACHE") ?? Environment.GetEnvironmentVariable("RUNNER_TOOLSDIRECTORY") ?? Environment.GetEnvironmentVariable("AGENT_TOOLSDIRECTORY") ?? Environment.GetEnvironmentVariable(Constants.Variables.Agent.ToolsDirectory);
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
path = Path.Combine(
|
||||
GetDirectory(WellKnownDirectory.Work),
|
||||
Constants.Path.ToolDirectory);
|
||||
}
|
||||
break;
|
||||
|
||||
case WellKnownDirectory.Update:
|
||||
path = Path.Combine(
|
||||
GetDirectory(WellKnownDirectory.Work),
|
||||
Constants.Path.UpdateDirectory);
|
||||
break;
|
||||
|
||||
case WellKnownDirectory.Work:
|
||||
var configurationStore = GetService<IConfigurationStore>();
|
||||
RunnerSettings settings = configurationStore.GetSettings();
|
||||
ArgUtil.NotNull(settings, nameof(settings));
|
||||
ArgUtil.NotNullOrEmpty(settings.WorkFolder, nameof(settings.WorkFolder));
|
||||
path = Path.GetFullPath(Path.Combine(
|
||||
GetDirectory(WellKnownDirectory.Root),
|
||||
settings.WorkFolder));
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new NotSupportedException($"Unexpected well known directory: '{directory}'");
|
||||
}
|
||||
|
||||
_trace.Info($"Well known directory '{directory}': '{path}'");
|
||||
return path;
|
||||
}
|
||||
|
||||
public string GetConfigFile(WellKnownConfigFile configFile)
|
||||
{
|
||||
string path;
|
||||
switch (configFile)
|
||||
{
|
||||
case WellKnownConfigFile.Runner:
|
||||
path = Path.Combine(
|
||||
GetDirectory(WellKnownDirectory.Root),
|
||||
".runner");
|
||||
break;
|
||||
|
||||
case WellKnownConfigFile.Credentials:
|
||||
path = Path.Combine(
|
||||
GetDirectory(WellKnownDirectory.Root),
|
||||
".credentials");
|
||||
break;
|
||||
|
||||
case WellKnownConfigFile.RSACredentials:
|
||||
path = Path.Combine(
|
||||
GetDirectory(WellKnownDirectory.Root),
|
||||
".credentials_rsaparams");
|
||||
break;
|
||||
|
||||
case WellKnownConfigFile.Service:
|
||||
path = Path.Combine(
|
||||
GetDirectory(WellKnownDirectory.Root),
|
||||
".service");
|
||||
break;
|
||||
|
||||
case WellKnownConfigFile.CredentialStore:
|
||||
#if OS_OSX
|
||||
path = Path.Combine(
|
||||
GetDirectory(WellKnownDirectory.Root),
|
||||
".credential_store.keychain");
|
||||
#else
|
||||
path = Path.Combine(
|
||||
GetDirectory(WellKnownDirectory.Root),
|
||||
".credential_store");
|
||||
#endif
|
||||
break;
|
||||
|
||||
case WellKnownConfigFile.Certificates:
|
||||
path = Path.Combine(
|
||||
GetDirectory(WellKnownDirectory.Root),
|
||||
".certificates");
|
||||
break;
|
||||
|
||||
case WellKnownConfigFile.Proxy:
|
||||
path = Path.Combine(
|
||||
GetDirectory(WellKnownDirectory.Root),
|
||||
".proxy");
|
||||
break;
|
||||
|
||||
case WellKnownConfigFile.ProxyCredentials:
|
||||
path = Path.Combine(
|
||||
GetDirectory(WellKnownDirectory.Root),
|
||||
".proxycredentials");
|
||||
break;
|
||||
|
||||
case WellKnownConfigFile.ProxyBypass:
|
||||
path = Path.Combine(
|
||||
GetDirectory(WellKnownDirectory.Root),
|
||||
".proxybypass");
|
||||
break;
|
||||
|
||||
case WellKnownConfigFile.Options:
|
||||
path = Path.Combine(
|
||||
GetDirectory(WellKnownDirectory.Root),
|
||||
".options");
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"Unexpected well known config file: '{configFile}'");
|
||||
}
|
||||
|
||||
_trace.Info($"Well known config file '{configFile}': '{path}'");
|
||||
return path;
|
||||
}
|
||||
|
||||
public Tracing GetTrace(string name)
|
||||
{
|
||||
return _traceManager[name];
|
||||
}
|
||||
|
||||
public async Task Delay(TimeSpan delay, CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Delay(delay, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of T.
|
||||
/// </summary>
|
||||
public T CreateService<T>() where T : class, IRunnerService
|
||||
{
|
||||
Type target;
|
||||
if (!_serviceTypes.TryGetValue(typeof(T), out target))
|
||||
{
|
||||
// Infer the concrete type from the ServiceLocatorAttribute.
|
||||
CustomAttributeData attribute = typeof(T)
|
||||
.GetTypeInfo()
|
||||
.CustomAttributes
|
||||
.FirstOrDefault(x => x.AttributeType == typeof(ServiceLocatorAttribute));
|
||||
if (attribute != null)
|
||||
{
|
||||
foreach (CustomAttributeNamedArgument arg in attribute.NamedArguments)
|
||||
{
|
||||
if (string.Equals(arg.MemberName, ServiceLocatorAttribute.DefaultPropertyName, StringComparison.Ordinal))
|
||||
{
|
||||
target = arg.TypedValue.Value as Type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (target == null)
|
||||
{
|
||||
throw new KeyNotFoundException(string.Format(CultureInfo.InvariantCulture, "Service mapping not found for key '{0}'.", typeof(T).FullName));
|
||||
}
|
||||
|
||||
_serviceTypes.TryAdd(typeof(T), target);
|
||||
target = _serviceTypes[typeof(T)];
|
||||
}
|
||||
|
||||
// Create a new instance.
|
||||
T svc = Activator.CreateInstance(target) as T;
|
||||
svc.Initialize(this);
|
||||
return svc;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates an instance of T.
|
||||
/// </summary>
|
||||
public T GetService<T>() where T : class, IRunnerService
|
||||
{
|
||||
// Return the cached instance if one already exists.
|
||||
object instance;
|
||||
if (_serviceInstances.TryGetValue(typeof(T), out instance))
|
||||
{
|
||||
return instance as T;
|
||||
}
|
||||
|
||||
// Otherwise create a new instance and try to add it to the cache.
|
||||
_serviceInstances.TryAdd(typeof(T), CreateService<T>());
|
||||
|
||||
// Return the instance from the cache.
|
||||
return _serviceInstances[typeof(T)] as T;
|
||||
}
|
||||
|
||||
public void SetDefaultCulture(string name)
|
||||
{
|
||||
ArgUtil.NotNull(name, nameof(name));
|
||||
_trace.Verbose($"Setting default culture and UI culture to: '{name}'");
|
||||
CultureInfo.DefaultThreadCurrentCulture = new CultureInfo(name);
|
||||
CultureInfo.DefaultThreadCurrentUICulture = new CultureInfo(name);
|
||||
}
|
||||
|
||||
|
||||
public void ShutdownRunner(ShutdownReason reason)
|
||||
{
|
||||
ArgUtil.NotNull(reason, nameof(reason));
|
||||
_trace.Info($"Runner will be shutdown for {reason.ToString()}");
|
||||
RunnerShutdownReason = reason;
|
||||
_runnerShutdownTokenSource.Cancel();
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
public StartupType StartupType
|
||||
{
|
||||
get
|
||||
{
|
||||
return _startupType;
|
||||
}
|
||||
set
|
||||
{
|
||||
_startupType = value;
|
||||
}
|
||||
}
|
||||
|
||||
public void WritePerfCounter(string counter)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_perfFile))
|
||||
{
|
||||
string normalizedCounter = counter.Replace(':', '_');
|
||||
lock (_perfLock)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.AppendAllLines(_perfFile, new[] { $"{normalizedCounter}:{DateTime.UtcNow.ToString("O")}" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_trace.Error(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Dispose(bool disposing)
|
||||
{
|
||||
// TODO: Dispose the trace listener also.
|
||||
if (disposing)
|
||||
{
|
||||
if (_loadContext != null)
|
||||
{
|
||||
_loadContext.Unloading -= LoadContext_Unloading;
|
||||
_loadContext = null;
|
||||
}
|
||||
_httpTraceSubscription?.Dispose();
|
||||
_diagListenerSubscription?.Dispose();
|
||||
_traceManager?.Dispose();
|
||||
_traceManager = null;
|
||||
|
||||
_runnerShutdownTokenSource?.Dispose();
|
||||
_runnerShutdownTokenSource = null;
|
||||
|
||||
base.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadContext_Unloading(AssemblyLoadContext obj)
|
||||
{
|
||||
if (Unloading != null)
|
||||
{
|
||||
Unloading(this, null);
|
||||
}
|
||||
}
|
||||
|
||||
void IObserver<DiagnosticListener>.OnCompleted()
|
||||
{
|
||||
_httpTrace.Info("DiagListeners finished transmitting data.");
|
||||
}
|
||||
|
||||
void IObserver<DiagnosticListener>.OnError(Exception error)
|
||||
{
|
||||
_httpTrace.Error(error);
|
||||
}
|
||||
|
||||
void IObserver<DiagnosticListener>.OnNext(DiagnosticListener listener)
|
||||
{
|
||||
if (listener.Name == "HttpHandlerDiagnosticListener" && _httpTraceSubscription == null)
|
||||
{
|
||||
_httpTraceSubscription = listener.Subscribe(this);
|
||||
}
|
||||
}
|
||||
|
||||
void IObserver<KeyValuePair<string, object>>.OnCompleted()
|
||||
{
|
||||
_httpTrace.Info("HttpHandlerDiagnosticListener finished transmitting data.");
|
||||
}
|
||||
|
||||
void IObserver<KeyValuePair<string, object>>.OnError(Exception error)
|
||||
{
|
||||
_httpTrace.Error(error);
|
||||
}
|
||||
|
||||
void IObserver<KeyValuePair<string, object>>.OnNext(KeyValuePair<string, object> value)
|
||||
{
|
||||
_httpTrace.Info($"Trace {value.Key} event:{Environment.NewLine}{value.Value.ToString()}");
|
||||
}
|
||||
|
||||
protected override void OnEventSourceCreated(EventSource source)
|
||||
{
|
||||
if (source.Name.Equals("Microsoft-VSS-Http"))
|
||||
{
|
||||
EnableEvents(source, EventLevel.Verbose);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnEventWritten(EventWrittenEventArgs eventData)
|
||||
{
|
||||
if (eventData == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string message = eventData.Message;
|
||||
object[] payload = new object[0];
|
||||
if (eventData.Payload != null && eventData.Payload.Count > 0)
|
||||
{
|
||||
payload = eventData.Payload.ToArray();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_vssHttpMethodEventIds.Contains(eventData.EventId))
|
||||
{
|
||||
payload[0] = Enum.Parse(typeof(VssHttpMethod), ((int)payload[0]).ToString());
|
||||
}
|
||||
else if (_vssHttpCredentialEventIds.Contains(eventData.EventId))
|
||||
{
|
||||
payload[0] = Enum.Parse(typeof(GitHub.Services.Common.VssCredentialsType), ((int)payload[0]).ToString());
|
||||
}
|
||||
|
||||
if (payload.Length > 0)
|
||||
{
|
||||
message = String.Format(eventData.Message.Replace("%n", Environment.NewLine), payload);
|
||||
}
|
||||
|
||||
switch (eventData.Level)
|
||||
{
|
||||
case EventLevel.Critical:
|
||||
case EventLevel.Error:
|
||||
_vssTrace.Error(message);
|
||||
break;
|
||||
case EventLevel.Warning:
|
||||
_vssTrace.Warning(message);
|
||||
break;
|
||||
case EventLevel.Informational:
|
||||
_vssTrace.Info(message);
|
||||
break;
|
||||
default:
|
||||
_vssTrace.Verbose(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_vssTrace.Error(ex);
|
||||
_vssTrace.Info(eventData.Message);
|
||||
_vssTrace.Info(string.Join(", ", eventData.Payload?.ToArray() ?? new string[0]));
|
||||
}
|
||||
}
|
||||
|
||||
// Copied from pipelines server code base, used for EventData translation.
|
||||
internal enum VssHttpMethod
|
||||
{
|
||||
UNKNOWN,
|
||||
DELETE,
|
||||
HEAD,
|
||||
GET,
|
||||
OPTIONS,
|
||||
PATCH,
|
||||
POST,
|
||||
PUT,
|
||||
}
|
||||
}
|
||||
|
||||
public static class HostContextExtension
|
||||
{
|
||||
public static HttpClientHandler CreateHttpClientHandler(this IHostContext context)
|
||||
{
|
||||
HttpClientHandler clientHandler = new HttpClientHandler();
|
||||
var runnerWebProxy = context.GetService<IRunnerWebProxy>();
|
||||
clientHandler.Proxy = runnerWebProxy.WebProxy;
|
||||
return clientHandler;
|
||||
}
|
||||
}
|
||||
|
||||
public enum ShutdownReason
|
||||
{
|
||||
UserCancelled = 0,
|
||||
OperatingSystemShutdown = 1,
|
||||
}
|
||||
}
|
||||
202
src/Runner.Common/HostTraceListener.cs
Normal file
202
src/Runner.Common/HostTraceListener.cs
Normal file
@@ -0,0 +1,202 @@
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
public sealed class HostTraceListener : TextWriterTraceListener
|
||||
{
|
||||
private const string _logFileNamingPattern = "{0}_{1:yyyyMMdd-HHmmss}-utc.log";
|
||||
private string _logFileDirectory;
|
||||
private string _logFilePrefix;
|
||||
private bool _enablePageLog = false;
|
||||
private bool _enableLogRetention = false;
|
||||
private int _currentPageSize;
|
||||
private int _pageSizeLimit;
|
||||
private int _retentionDays;
|
||||
|
||||
public HostTraceListener(string logFileDirectory, string logFilePrefix, int pageSizeLimit, int retentionDays)
|
||||
: base()
|
||||
{
|
||||
ArgUtil.NotNullOrEmpty(logFileDirectory, nameof(logFileDirectory));
|
||||
ArgUtil.NotNullOrEmpty(logFilePrefix, nameof(logFilePrefix));
|
||||
_logFileDirectory = logFileDirectory;
|
||||
_logFilePrefix = logFilePrefix;
|
||||
|
||||
Directory.CreateDirectory(_logFileDirectory);
|
||||
|
||||
if (pageSizeLimit > 0)
|
||||
{
|
||||
_enablePageLog = true;
|
||||
_pageSizeLimit = pageSizeLimit * 1024 * 1024;
|
||||
_currentPageSize = 0;
|
||||
}
|
||||
|
||||
if (retentionDays > 0)
|
||||
{
|
||||
_enableLogRetention = true;
|
||||
_retentionDays = retentionDays;
|
||||
}
|
||||
|
||||
Writer = CreatePageLogWriter();
|
||||
}
|
||||
|
||||
public HostTraceListener(string logFile)
|
||||
: base()
|
||||
{
|
||||
ArgUtil.NotNullOrEmpty(logFile, nameof(logFile));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(logFile));
|
||||
Stream logStream = new FileStream(logFile, FileMode.Create, FileAccess.ReadWrite, FileShare.Read, bufferSize: 4096);
|
||||
Writer = new StreamWriter(logStream);
|
||||
}
|
||||
|
||||
// Copied and modified slightly from .Net Core source code. Modification was required to make it compile.
|
||||
// There must be some TraceFilter extension class that is missing in this source code.
|
||||
public override void TraceEvent(TraceEventCache eventCache, string source, TraceEventType eventType, int id, string message)
|
||||
{
|
||||
if (Filter != null && !Filter.ShouldTrace(eventCache, source, eventType, id, message, null, null, null))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
WriteHeader(source, eventType, id);
|
||||
WriteLine(message);
|
||||
WriteFooter(eventCache);
|
||||
}
|
||||
|
||||
public override void WriteLine(string message)
|
||||
{
|
||||
base.WriteLine(message);
|
||||
if (_enablePageLog)
|
||||
{
|
||||
int messageSize = UTF8Encoding.UTF8.GetByteCount(message);
|
||||
_currentPageSize += messageSize;
|
||||
if (_currentPageSize > _pageSizeLimit)
|
||||
{
|
||||
Flush();
|
||||
if (Writer != null)
|
||||
{
|
||||
Writer.Dispose();
|
||||
Writer = null;
|
||||
}
|
||||
|
||||
Writer = CreatePageLogWriter();
|
||||
_currentPageSize = 0;
|
||||
}
|
||||
}
|
||||
|
||||
Flush();
|
||||
}
|
||||
|
||||
public override void Write(string message)
|
||||
{
|
||||
base.Write(message);
|
||||
if (_enablePageLog)
|
||||
{
|
||||
int messageSize = UTF8Encoding.UTF8.GetByteCount(message);
|
||||
_currentPageSize += messageSize;
|
||||
}
|
||||
|
||||
Flush();
|
||||
}
|
||||
|
||||
internal bool IsEnabled(TraceOptions opts)
|
||||
{
|
||||
return (opts & TraceOutputOptions) != 0;
|
||||
}
|
||||
|
||||
// Altered from the original .Net Core implementation.
|
||||
private void WriteHeader(string source, TraceEventType eventType, int id)
|
||||
{
|
||||
string type = null;
|
||||
switch (eventType)
|
||||
{
|
||||
case TraceEventType.Critical:
|
||||
type = "CRIT";
|
||||
break;
|
||||
case TraceEventType.Error:
|
||||
type = "ERR ";
|
||||
break;
|
||||
case TraceEventType.Warning:
|
||||
type = "WARN";
|
||||
break;
|
||||
case TraceEventType.Information:
|
||||
type = "INFO";
|
||||
break;
|
||||
case TraceEventType.Verbose:
|
||||
type = "VERB";
|
||||
break;
|
||||
default:
|
||||
type = eventType.ToString();
|
||||
break;
|
||||
}
|
||||
|
||||
Write(StringUtil.Format("[{0:u} {1} {2}] ", DateTime.UtcNow, type, source));
|
||||
}
|
||||
|
||||
// Copied and modified slightly from .Net Core source code to make it compile. The original code
|
||||
// accesses a private indentLevel field. In this code it has been modified to use the getter/setter.
|
||||
private void WriteFooter(TraceEventCache eventCache)
|
||||
{
|
||||
if (eventCache == null)
|
||||
return;
|
||||
|
||||
IndentLevel++;
|
||||
if (IsEnabled(TraceOptions.ProcessId))
|
||||
WriteLine("ProcessId=" + eventCache.ProcessId);
|
||||
|
||||
if (IsEnabled(TraceOptions.ThreadId))
|
||||
WriteLine("ThreadId=" + eventCache.ThreadId);
|
||||
|
||||
if (IsEnabled(TraceOptions.DateTime))
|
||||
WriteLine("DateTime=" + eventCache.DateTime.ToString("o", CultureInfo.InvariantCulture));
|
||||
|
||||
if (IsEnabled(TraceOptions.Timestamp))
|
||||
WriteLine("Timestamp=" + eventCache.Timestamp);
|
||||
|
||||
IndentLevel--;
|
||||
}
|
||||
|
||||
private StreamWriter CreatePageLogWriter()
|
||||
{
|
||||
if (_enableLogRetention)
|
||||
{
|
||||
DirectoryInfo diags = new DirectoryInfo(_logFileDirectory);
|
||||
var logs = diags.GetFiles($"{_logFilePrefix}*.log");
|
||||
foreach (var log in logs)
|
||||
{
|
||||
if (log.LastWriteTimeUtc.AddDays(_retentionDays) < DateTime.UtcNow)
|
||||
{
|
||||
try
|
||||
{
|
||||
log.Delete();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// catch Exception and continue
|
||||
// we shouldn't block logging and fail the runner if the runner can't delete an older log file.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
string fileName = StringUtil.Format(_logFileNamingPattern, _logFilePrefix, DateTime.UtcNow);
|
||||
string logFile = Path.Combine(_logFileDirectory, fileName);
|
||||
Stream logStream;
|
||||
if (File.Exists(logFile))
|
||||
{
|
||||
logStream = new FileStream(logFile, FileMode.Append, FileAccess.Write, FileShare.Read, bufferSize: 4096);
|
||||
}
|
||||
else
|
||||
{
|
||||
logStream = new FileStream(logFile, FileMode.Create, FileAccess.ReadWrite, FileShare.Read, bufferSize: 4096);
|
||||
}
|
||||
|
||||
return new StreamWriter(logStream);
|
||||
}
|
||||
}
|
||||
}
|
||||
9
src/Runner.Common/IExtension.cs
Normal file
9
src/Runner.Common/IExtension.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using System;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
public interface IExtension : IRunnerService
|
||||
{
|
||||
Type ExtensionType { get; }
|
||||
}
|
||||
}
|
||||
296
src/Runner.Common/JobNotification.cs
Normal file
296
src/Runner.Common/JobNotification.cs
Normal file
@@ -0,0 +1,296 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
[ServiceLocator(Default = typeof(JobNotification))]
|
||||
public interface IJobNotification : IRunnerService, IDisposable
|
||||
{
|
||||
Task JobStarted(Guid jobId, string accessToken, Uri serverUrl);
|
||||
Task JobCompleted(Guid jobId);
|
||||
void StartClient(string pipeName, string monitorSocketAddress, CancellationToken cancellationToken);
|
||||
void StartClient(string socketAddress, string monitorSocketAddress);
|
||||
}
|
||||
|
||||
public sealed class JobNotification : RunnerService, IJobNotification
|
||||
{
|
||||
private NamedPipeClientStream _outClient;
|
||||
private StreamWriter _writeStream;
|
||||
private Socket _socket;
|
||||
private Socket _monitorSocket;
|
||||
private bool _configured = false;
|
||||
private bool _useSockets = false;
|
||||
private bool _isMonitorConfigured = false;
|
||||
|
||||
public async Task JobStarted(Guid jobId, string accessToken, Uri serverUrl)
|
||||
{
|
||||
Trace.Info("Entering JobStarted Notification");
|
||||
|
||||
StartMonitor(jobId, accessToken, serverUrl);
|
||||
|
||||
if (_configured)
|
||||
{
|
||||
String message = $"Starting job: {jobId.ToString()}";
|
||||
if (_useSockets)
|
||||
{
|
||||
try
|
||||
{
|
||||
Trace.Info("Writing JobStarted to socket");
|
||||
_socket.Send(Encoding.UTF8.GetBytes(message));
|
||||
Trace.Info("Finished JobStarted writing to socket");
|
||||
}
|
||||
catch (SocketException e)
|
||||
{
|
||||
Trace.Error($"Failed sending message \"{message}\" on socket!");
|
||||
Trace.Error(e);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info("Writing JobStarted to pipe");
|
||||
await _writeStream.WriteLineAsync(message);
|
||||
await _writeStream.FlushAsync();
|
||||
Trace.Info("Finished JobStarted writing to pipe");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task JobCompleted(Guid jobId)
|
||||
{
|
||||
Trace.Info("Entering JobCompleted Notification");
|
||||
|
||||
await EndMonitor();
|
||||
|
||||
if (_configured)
|
||||
{
|
||||
String message = $"Finished job: {jobId.ToString()}";
|
||||
if (_useSockets)
|
||||
{
|
||||
try
|
||||
{
|
||||
Trace.Info("Writing JobCompleted to socket");
|
||||
_socket.Send(Encoding.UTF8.GetBytes(message));
|
||||
Trace.Info("Finished JobCompleted writing to socket");
|
||||
}
|
||||
catch (SocketException e)
|
||||
{
|
||||
Trace.Error($"Failed sending message \"{message}\" on socket!");
|
||||
Trace.Error(e);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info("Writing JobCompleted to pipe");
|
||||
await _writeStream.WriteLineAsync(message);
|
||||
await _writeStream.FlushAsync();
|
||||
Trace.Info("Finished JobCompleted writing to pipe");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async void StartClient(string pipeName, string monitorSocketAddress, CancellationToken cancellationToken)
|
||||
{
|
||||
if (pipeName != null && !_configured)
|
||||
{
|
||||
Trace.Info("Connecting to named pipe {0}", pipeName);
|
||||
_outClient = new NamedPipeClientStream(".", pipeName, PipeDirection.Out, PipeOptions.Asynchronous);
|
||||
await _outClient.ConnectAsync(cancellationToken);
|
||||
_writeStream = new StreamWriter(_outClient, Encoding.UTF8);
|
||||
_configured = true;
|
||||
Trace.Info("Connection successful to named pipe {0}", pipeName);
|
||||
}
|
||||
|
||||
ConnectMonitor(monitorSocketAddress);
|
||||
}
|
||||
|
||||
public void StartClient(string socketAddress, string monitorSocketAddress)
|
||||
{
|
||||
if (!_configured)
|
||||
{
|
||||
try
|
||||
{
|
||||
string[] splitAddress = socketAddress.Split(':');
|
||||
if (splitAddress.Length != 2)
|
||||
{
|
||||
Trace.Error("Invalid socket address {0}. Job Notification will be disabled.", socketAddress);
|
||||
return;
|
||||
}
|
||||
|
||||
IPAddress address;
|
||||
try
|
||||
{
|
||||
address = IPAddress.Parse(splitAddress[0]);
|
||||
}
|
||||
catch (FormatException e)
|
||||
{
|
||||
Trace.Error("Invalid socket ip address {0}. Job Notification will be disabled",splitAddress[0]);
|
||||
Trace.Error(e);
|
||||
return;
|
||||
}
|
||||
|
||||
int port = -1;
|
||||
Int32.TryParse(splitAddress[1], out port);
|
||||
if (port < IPEndPoint.MinPort || port > IPEndPoint.MaxPort)
|
||||
{
|
||||
Trace.Error("Invalid tcp socket port {0}. Job Notification will be disabled.", splitAddress[1]);
|
||||
return;
|
||||
}
|
||||
|
||||
_socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
|
||||
_socket.Connect(address, port);
|
||||
Trace.Info("Connection successful to socket {0}", socketAddress);
|
||||
_useSockets = true;
|
||||
_configured = true;
|
||||
}
|
||||
catch (SocketException e)
|
||||
{
|
||||
Trace.Error("Connection to socket {0} failed!", socketAddress);
|
||||
Trace.Error(e);
|
||||
}
|
||||
}
|
||||
|
||||
ConnectMonitor(monitorSocketAddress);
|
||||
}
|
||||
|
||||
private void StartMonitor(Guid jobId, string accessToken, Uri serverUri)
|
||||
{
|
||||
if(String.IsNullOrEmpty(accessToken))
|
||||
{
|
||||
Trace.Info("No access token could be retrieved to start the monitor.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Trace.Info("Entering StartMonitor");
|
||||
if (_isMonitorConfigured)
|
||||
{
|
||||
String message = $"Start {jobId.ToString()} {accessToken} {serverUri.ToString()} {System.Diagnostics.Process.GetCurrentProcess().Id}";
|
||||
|
||||
Trace.Info("Writing StartMonitor to socket");
|
||||
_monitorSocket.Send(Encoding.UTF8.GetBytes(message));
|
||||
Trace.Info("Finished StartMonitor writing to socket");
|
||||
}
|
||||
}
|
||||
catch (SocketException e)
|
||||
{
|
||||
Trace.Error($"Failed sending StartMonitor message on socket!");
|
||||
Trace.Error(e);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Trace.Error($"Unexpected error occurred while sending StartMonitor message on socket!");
|
||||
Trace.Error(e);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EndMonitor()
|
||||
{
|
||||
try
|
||||
{
|
||||
Trace.Info("Entering EndMonitor");
|
||||
if (_isMonitorConfigured)
|
||||
{
|
||||
String message = $"End {System.Diagnostics.Process.GetCurrentProcess().Id}";
|
||||
Trace.Info("Writing EndMonitor to socket");
|
||||
_monitorSocket.Send(Encoding.UTF8.GetBytes(message));
|
||||
Trace.Info("Finished EndMonitor writing to socket");
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
}
|
||||
catch (SocketException e)
|
||||
{
|
||||
Trace.Error($"Failed sending end message on socket!");
|
||||
Trace.Error(e);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Trace.Error($"Unexpected error occurred while sending StartMonitor message on socket!");
|
||||
Trace.Error(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void ConnectMonitor(string monitorSocketAddress)
|
||||
{
|
||||
int port = -1;
|
||||
if (!_isMonitorConfigured && !String.IsNullOrEmpty(monitorSocketAddress))
|
||||
{
|
||||
try
|
||||
{
|
||||
string[] splitAddress = monitorSocketAddress.Split(':');
|
||||
if (splitAddress.Length != 2)
|
||||
{
|
||||
Trace.Error("Invalid socket address {0}. Unable to connect to monitor.", monitorSocketAddress);
|
||||
return;
|
||||
}
|
||||
|
||||
IPAddress address;
|
||||
try
|
||||
{
|
||||
address = IPAddress.Parse(splitAddress[0]);
|
||||
}
|
||||
catch (FormatException e)
|
||||
{
|
||||
Trace.Error("Invalid socket IP address {0}. Unable to connect to monitor.", splitAddress[0]);
|
||||
Trace.Error(e);
|
||||
return;
|
||||
}
|
||||
|
||||
Int32.TryParse(splitAddress[1], out port);
|
||||
if (port < IPEndPoint.MinPort || port > IPEndPoint.MaxPort)
|
||||
{
|
||||
Trace.Error("Invalid TCP socket port {0}. Unable to connect to monitor.", splitAddress[1]);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Trace.Verbose("Trying to connect to monitor at port {0}", port);
|
||||
_monitorSocket = new Socket(SocketType.Stream, ProtocolType.Tcp);
|
||||
_monitorSocket.Connect(address, port);
|
||||
Trace.Info("Connection successful to local port {0}", port);
|
||||
_isMonitorConfigured = true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Trace.Error("Connection to monitor port {0} failed!", port);
|
||||
Trace.Error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_outClient?.Dispose();
|
||||
|
||||
if (_socket != null)
|
||||
{
|
||||
_socket.Send(Encoding.UTF8.GetBytes("<EOF>"));
|
||||
_socket.Shutdown(SocketShutdown.Both);
|
||||
_socket = null;
|
||||
}
|
||||
|
||||
if (_monitorSocket != null)
|
||||
{
|
||||
_monitorSocket.Send(Encoding.UTF8.GetBytes("<EOF>"));
|
||||
_monitorSocket.Shutdown(SocketShutdown.Both);
|
||||
_monitorSocket = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
162
src/Runner.Common/JobServer.cs
Normal file
162
src/Runner.Common/JobServer.cs
Normal file
@@ -0,0 +1,162 @@
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Services.WebApi;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
[ServiceLocator(Default = typeof(JobServer))]
|
||||
public interface IJobServer : IRunnerService
|
||||
{
|
||||
Task ConnectAsync(VssConnection jobConnection);
|
||||
|
||||
// logging and console
|
||||
Task<TaskLog> AppendLogContentAsync(Guid scopeIdentifier, string hubName, Guid planId, int logId, Stream uploadStream, CancellationToken cancellationToken);
|
||||
Task AppendTimelineRecordFeedAsync(Guid scopeIdentifier, string hubName, Guid planId, Guid timelineId, Guid timelineRecordId, Guid stepId, IList<string> lines, CancellationToken cancellationToken);
|
||||
Task<TaskAttachment> CreateAttachmentAsync(Guid scopeIdentifier, string hubName, Guid planId, Guid timelineId, Guid timelineRecordId, String type, String name, Stream uploadStream, CancellationToken cancellationToken);
|
||||
Task<TaskLog> CreateLogAsync(Guid scopeIdentifier, string hubName, Guid planId, TaskLog log, CancellationToken cancellationToken);
|
||||
Task<Timeline> CreateTimelineAsync(Guid scopeIdentifier, string hubName, Guid planId, Guid timelineId, CancellationToken cancellationToken);
|
||||
Task<List<TimelineRecord>> UpdateTimelineRecordsAsync(Guid scopeIdentifier, string hubName, Guid planId, Guid timelineId, IEnumerable<TimelineRecord> records, CancellationToken cancellationToken);
|
||||
Task RaisePlanEventAsync<T>(Guid scopeIdentifier, string hubName, Guid planId, T eventData, CancellationToken cancellationToken) where T : JobEvent;
|
||||
Task<Timeline> GetTimelineAsync(Guid scopeIdentifier, string hubName, Guid planId, Guid timelineId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class JobServer : RunnerService, IJobServer
|
||||
{
|
||||
private bool _hasConnection;
|
||||
private VssConnection _connection;
|
||||
private TaskHttpClient _taskClient;
|
||||
|
||||
public async Task ConnectAsync(VssConnection jobConnection)
|
||||
{
|
||||
if (HostContext.RunMode == RunMode.Local)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_connection = jobConnection;
|
||||
int attemptCount = 5;
|
||||
while (!_connection.HasAuthenticated && attemptCount-- > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _connection.ConnectAsync();
|
||||
break;
|
||||
}
|
||||
catch (Exception ex) when (attemptCount > 0)
|
||||
{
|
||||
Trace.Info($"Catch exception during connect. {attemptCount} attemp left.");
|
||||
Trace.Error(ex);
|
||||
}
|
||||
|
||||
await Task.Delay(100);
|
||||
}
|
||||
|
||||
_taskClient = _connection.GetClient<TaskHttpClient>();
|
||||
_hasConnection = true;
|
||||
}
|
||||
|
||||
private void CheckConnection()
|
||||
{
|
||||
if (!_hasConnection)
|
||||
{
|
||||
throw new InvalidOperationException("SetConnection");
|
||||
}
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------
|
||||
// Feedback: WebConsole, TimelineRecords and Logs
|
||||
//-----------------------------------------------------------------
|
||||
|
||||
public Task<TaskLog> AppendLogContentAsync(Guid scopeIdentifier, string hubName, Guid planId, int logId, Stream uploadStream, CancellationToken cancellationToken)
|
||||
{
|
||||
if (HostContext.RunMode == RunMode.Local)
|
||||
{
|
||||
return Task.FromResult<TaskLog>(null);
|
||||
}
|
||||
|
||||
CheckConnection();
|
||||
return _taskClient.AppendLogContentAsync(scopeIdentifier, hubName, planId, logId, uploadStream, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public Task AppendTimelineRecordFeedAsync(Guid scopeIdentifier, string hubName, Guid planId, Guid timelineId, Guid timelineRecordId, Guid stepId, IList<string> lines, CancellationToken cancellationToken)
|
||||
{
|
||||
if (HostContext.RunMode == RunMode.Local)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
CheckConnection();
|
||||
return _taskClient.AppendTimelineRecordFeedAsync(scopeIdentifier, hubName, planId, timelineId, timelineRecordId, stepId, lines, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public Task<TaskAttachment> CreateAttachmentAsync(Guid scopeIdentifier, string hubName, Guid planId, Guid timelineId, Guid timelineRecordId, string type, string name, Stream uploadStream, CancellationToken cancellationToken)
|
||||
{
|
||||
if (HostContext.RunMode == RunMode.Local)
|
||||
{
|
||||
return Task.FromResult<TaskAttachment>(null);
|
||||
}
|
||||
|
||||
CheckConnection();
|
||||
return _taskClient.CreateAttachmentAsync(scopeIdentifier, hubName, planId, timelineId, timelineRecordId, type, name, uploadStream, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public Task<TaskLog> CreateLogAsync(Guid scopeIdentifier, string hubName, Guid planId, TaskLog log, CancellationToken cancellationToken)
|
||||
{
|
||||
if (HostContext.RunMode == RunMode.Local)
|
||||
{
|
||||
return Task.FromResult<TaskLog>(null);
|
||||
}
|
||||
|
||||
CheckConnection();
|
||||
return _taskClient.CreateLogAsync(scopeIdentifier, hubName, planId, log, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public Task<Timeline> CreateTimelineAsync(Guid scopeIdentifier, string hubName, Guid planId, Guid timelineId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (HostContext.RunMode == RunMode.Local)
|
||||
{
|
||||
return Task.FromResult<Timeline>(null);
|
||||
}
|
||||
|
||||
CheckConnection();
|
||||
return _taskClient.CreateTimelineAsync(scopeIdentifier, hubName, planId, new Timeline(timelineId), cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public Task<List<TimelineRecord>> UpdateTimelineRecordsAsync(Guid scopeIdentifier, string hubName, Guid planId, Guid timelineId, IEnumerable<TimelineRecord> records, CancellationToken cancellationToken)
|
||||
{
|
||||
if (HostContext.RunMode == RunMode.Local)
|
||||
{
|
||||
return Task.FromResult<List<TimelineRecord>>(null);
|
||||
}
|
||||
|
||||
CheckConnection();
|
||||
return _taskClient.UpdateTimelineRecordsAsync(scopeIdentifier, hubName, planId, timelineId, records, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public Task RaisePlanEventAsync<T>(Guid scopeIdentifier, string hubName, Guid planId, T eventData, CancellationToken cancellationToken) where T : JobEvent
|
||||
{
|
||||
if (HostContext.RunMode == RunMode.Local)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
CheckConnection();
|
||||
return _taskClient.RaisePlanEventAsync(scopeIdentifier, hubName, planId, eventData, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public Task<Timeline> GetTimelineAsync(Guid scopeIdentifier, string hubName, Guid planId, Guid timelineId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (HostContext.RunMode == RunMode.Local)
|
||||
{
|
||||
return Task.FromResult<Timeline>(null);
|
||||
}
|
||||
|
||||
CheckConnection();
|
||||
return _taskClient.GetTimelineAsync(scopeIdentifier, hubName, planId, timelineId, includeRecords: true, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
702
src/Runner.Common/JobServerQueue.cs
Normal file
702
src/Runner.Common/JobServerQueue.cs
Normal file
@@ -0,0 +1,702 @@
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
[ServiceLocator(Default = typeof(JobServerQueue))]
|
||||
public interface IJobServerQueue : IRunnerService, IThrottlingReporter
|
||||
{
|
||||
event EventHandler<ThrottlingEventArgs> JobServerQueueThrottling;
|
||||
Task ShutdownAsync();
|
||||
void Start(Pipelines.AgentJobRequestMessage jobRequest);
|
||||
void QueueWebConsoleLine(Guid stepRecordId, string line);
|
||||
void QueueFileUpload(Guid timelineId, Guid timelineRecordId, string type, string name, string path, bool deleteSource);
|
||||
void QueueTimelineRecordUpdate(Guid timelineId, TimelineRecord timelineRecord);
|
||||
}
|
||||
|
||||
public sealed class JobServerQueue : RunnerService, IJobServerQueue
|
||||
{
|
||||
// Default delay for Dequeue process
|
||||
private static readonly TimeSpan _aggressiveDelayForWebConsoleLineDequeue = TimeSpan.FromMilliseconds(250);
|
||||
private static readonly TimeSpan _delayForWebConsoleLineDequeue = TimeSpan.FromMilliseconds(500);
|
||||
private static readonly TimeSpan _delayForTimelineUpdateDequeue = TimeSpan.FromMilliseconds(500);
|
||||
private static readonly TimeSpan _delayForFileUploadDequeue = TimeSpan.FromMilliseconds(1000);
|
||||
|
||||
// Job message information
|
||||
private Guid _scopeIdentifier;
|
||||
private string _hubName;
|
||||
private Guid _planId;
|
||||
private Guid _jobTimelineId;
|
||||
private Guid _jobTimelineRecordId;
|
||||
|
||||
// queue for web console line
|
||||
private readonly ConcurrentQueue<ConsoleLineInfo> _webConsoleLineQueue = new ConcurrentQueue<ConsoleLineInfo>();
|
||||
|
||||
// queue for file upload (log file or attachment)
|
||||
private readonly ConcurrentQueue<UploadFileInfo> _fileUploadQueue = new ConcurrentQueue<UploadFileInfo>();
|
||||
|
||||
// queue for timeline or timeline record update (one queue per timeline)
|
||||
private readonly ConcurrentDictionary<Guid, ConcurrentQueue<TimelineRecord>> _timelineUpdateQueue = new ConcurrentDictionary<Guid, ConcurrentQueue<TimelineRecord>>();
|
||||
|
||||
// indicate how many timelines we have, we will process _timelineUpdateQueue base on the order of timeline in this list
|
||||
private readonly List<Guid> _allTimelines = new List<Guid>();
|
||||
|
||||
// bufferd timeline records that fail to update
|
||||
private readonly Dictionary<Guid, List<TimelineRecord>> _bufferedRetryRecords = new Dictionary<Guid, List<TimelineRecord>>();
|
||||
|
||||
// Task for each queue's dequeue process
|
||||
private Task _webConsoleLineDequeueTask;
|
||||
private Task _fileUploadDequeueTask;
|
||||
private Task _timelineUpdateDequeueTask;
|
||||
|
||||
// common
|
||||
private IJobServer _jobServer;
|
||||
private Task[] _allDequeueTasks;
|
||||
private readonly TaskCompletionSource<int> _jobCompletionSource = new TaskCompletionSource<int>();
|
||||
private bool _queueInProcess = false;
|
||||
private ITerminal _term;
|
||||
|
||||
public event EventHandler<ThrottlingEventArgs> JobServerQueueThrottling;
|
||||
|
||||
// Web console dequeue will start with process queue every 250ms for the first 60*4 times (~60 seconds).
|
||||
// Then the dequeue will happen every 500ms.
|
||||
// In this way, customer still can get instance live console output on job start,
|
||||
// at the same time we can cut the load to server after the build run for more than 60s
|
||||
private int _webConsoleLineAggressiveDequeueCount = 0;
|
||||
private const int _webConsoleLineAggressiveDequeueLimit = 4 * 60;
|
||||
private bool _webConsoleLineAggressiveDequeue = true;
|
||||
private bool _firstConsoleOutputs = true;
|
||||
|
||||
public override void Initialize(IHostContext hostContext)
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
_jobServer = hostContext.GetService<IJobServer>();
|
||||
}
|
||||
|
||||
public void Start(Pipelines.AgentJobRequestMessage jobRequest)
|
||||
{
|
||||
Trace.Entering();
|
||||
if (HostContext.RunMode == RunMode.Local)
|
||||
{
|
||||
_term = HostContext.GetService<ITerminal>();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_queueInProcess)
|
||||
{
|
||||
Trace.Info("No-opt, all queue process tasks are running.");
|
||||
return;
|
||||
}
|
||||
|
||||
ArgUtil.NotNull(jobRequest, nameof(jobRequest));
|
||||
ArgUtil.NotNull(jobRequest.Plan, nameof(jobRequest.Plan));
|
||||
ArgUtil.NotNull(jobRequest.Timeline, nameof(jobRequest.Timeline));
|
||||
|
||||
_scopeIdentifier = jobRequest.Plan.ScopeIdentifier;
|
||||
_hubName = jobRequest.Plan.PlanType;
|
||||
_planId = jobRequest.Plan.PlanId;
|
||||
_jobTimelineId = jobRequest.Timeline.Id;
|
||||
_jobTimelineRecordId = jobRequest.JobId;
|
||||
|
||||
// Server already create the job timeline
|
||||
_timelineUpdateQueue[_jobTimelineId] = new ConcurrentQueue<TimelineRecord>();
|
||||
_allTimelines.Add(_jobTimelineId);
|
||||
|
||||
// Start three dequeue task
|
||||
Trace.Info("Start process web console line queue.");
|
||||
_webConsoleLineDequeueTask = ProcessWebConsoleLinesQueueAsync();
|
||||
|
||||
Trace.Info("Start process file upload queue.");
|
||||
_fileUploadDequeueTask = ProcessFilesUploadQueueAsync();
|
||||
|
||||
Trace.Info("Start process timeline update queue.");
|
||||
_timelineUpdateDequeueTask = ProcessTimelinesUpdateQueueAsync();
|
||||
|
||||
_allDequeueTasks = new Task[] { _webConsoleLineDequeueTask, _fileUploadDequeueTask, _timelineUpdateDequeueTask };
|
||||
_queueInProcess = true;
|
||||
}
|
||||
|
||||
// WebConsoleLine queue and FileUpload queue are always best effort
|
||||
// TimelineUpdate queue error will become critical when timeline records contain output variabls.
|
||||
public async Task ShutdownAsync()
|
||||
{
|
||||
if (HostContext.RunMode == RunMode.Local)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_queueInProcess)
|
||||
{
|
||||
Trace.Info("No-op, all queue process tasks have been stopped.");
|
||||
}
|
||||
|
||||
Trace.Info("Fire signal to shutdown all queues.");
|
||||
_jobCompletionSource.TrySetResult(0);
|
||||
|
||||
await Task.WhenAll(_allDequeueTasks);
|
||||
_queueInProcess = false;
|
||||
Trace.Info("All queue process task stopped.");
|
||||
|
||||
// Drain the queue
|
||||
// ProcessWebConsoleLinesQueueAsync() will never throw exception, live console update is always best effort.
|
||||
Trace.Verbose("Draining web console line queue.");
|
||||
await ProcessWebConsoleLinesQueueAsync(runOnce: true);
|
||||
Trace.Info("Web console line queue drained.");
|
||||
|
||||
// ProcessFilesUploadQueueAsync() will never throw exception, log file upload is always best effort.
|
||||
Trace.Verbose("Draining file upload queue.");
|
||||
await ProcessFilesUploadQueueAsync(runOnce: true);
|
||||
Trace.Info("File upload queue drained.");
|
||||
|
||||
// ProcessTimelinesUpdateQueueAsync() will throw exception during shutdown
|
||||
// if there is any timeline records that failed to update contains output variabls.
|
||||
Trace.Verbose("Draining timeline update queue.");
|
||||
await ProcessTimelinesUpdateQueueAsync(runOnce: true);
|
||||
Trace.Info("Timeline update queue drained.");
|
||||
|
||||
Trace.Info("All queue process tasks have been stopped, and all queues are drained.");
|
||||
}
|
||||
|
||||
public void QueueWebConsoleLine(Guid stepRecordId, string line)
|
||||
{
|
||||
Trace.Verbose("Enqueue web console line queue: {0}", line);
|
||||
if (HostContext.RunMode == RunMode.Local)
|
||||
{
|
||||
if ((line ?? string.Empty).StartsWith("##[section]"))
|
||||
{
|
||||
Console.WriteLine("******************************************************************************");
|
||||
Console.WriteLine(line.Substring("##[section]".Length));
|
||||
Console.WriteLine("******************************************************************************");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(line);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_webConsoleLineQueue.Enqueue(new ConsoleLineInfo(stepRecordId, line));
|
||||
}
|
||||
|
||||
public void QueueFileUpload(Guid timelineId, Guid timelineRecordId, string type, string name, string path, bool deleteSource)
|
||||
{
|
||||
if (HostContext.RunMode == RunMode.Local)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ArgUtil.NotEmpty(timelineId, nameof(timelineId));
|
||||
ArgUtil.NotEmpty(timelineRecordId, nameof(timelineRecordId));
|
||||
|
||||
// all parameter not null, file path exist.
|
||||
var newFile = new UploadFileInfo()
|
||||
{
|
||||
TimelineId = timelineId,
|
||||
TimelineRecordId = timelineRecordId,
|
||||
Type = type,
|
||||
Name = name,
|
||||
Path = path,
|
||||
DeleteSource = deleteSource
|
||||
};
|
||||
|
||||
Trace.Verbose("Enqueue file upload queue: file '{0}' attach to record {1}", newFile.Path, timelineRecordId);
|
||||
_fileUploadQueue.Enqueue(newFile);
|
||||
}
|
||||
|
||||
public void QueueTimelineRecordUpdate(Guid timelineId, TimelineRecord timelineRecord)
|
||||
{
|
||||
if (HostContext.RunMode == RunMode.Local)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ArgUtil.NotEmpty(timelineId, nameof(timelineId));
|
||||
ArgUtil.NotNull(timelineRecord, nameof(timelineRecord));
|
||||
ArgUtil.NotEmpty(timelineRecord.Id, nameof(timelineRecord.Id));
|
||||
|
||||
_timelineUpdateQueue.TryAdd(timelineId, new ConcurrentQueue<TimelineRecord>());
|
||||
|
||||
Trace.Verbose("Enqueue timeline {0} update queue: {1}", timelineId, timelineRecord.Id);
|
||||
_timelineUpdateQueue[timelineId].Enqueue(timelineRecord.Clone());
|
||||
}
|
||||
|
||||
public void ReportThrottling(TimeSpan delay, DateTime expiration)
|
||||
{
|
||||
Trace.Info($"Receive server throttling report, expect delay {delay} milliseconds till {expiration}");
|
||||
var throttlingEvent = JobServerQueueThrottling;
|
||||
if (throttlingEvent != null)
|
||||
{
|
||||
throttlingEvent(this, new ThrottlingEventArgs(delay, expiration));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessWebConsoleLinesQueueAsync(bool runOnce = false)
|
||||
{
|
||||
while (!_jobCompletionSource.Task.IsCompleted || runOnce)
|
||||
{
|
||||
if (_webConsoleLineAggressiveDequeue && ++_webConsoleLineAggressiveDequeueCount > _webConsoleLineAggressiveDequeueLimit)
|
||||
{
|
||||
Trace.Info("Stop aggressive process web console line queue.");
|
||||
_webConsoleLineAggressiveDequeue = false;
|
||||
}
|
||||
|
||||
// Group consolelines by timeline record of each step
|
||||
Dictionary<Guid, List<string>> stepsConsoleLines = new Dictionary<Guid, List<string>>();
|
||||
List<Guid> stepRecordIds = new List<Guid>(); // We need to keep lines in order
|
||||
int linesCounter = 0;
|
||||
ConsoleLineInfo lineInfo;
|
||||
while (_webConsoleLineQueue.TryDequeue(out lineInfo))
|
||||
{
|
||||
if (!stepsConsoleLines.ContainsKey(lineInfo.StepRecordId))
|
||||
{
|
||||
stepsConsoleLines[lineInfo.StepRecordId] = new List<string>();
|
||||
stepRecordIds.Add(lineInfo.StepRecordId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(lineInfo.Line) && lineInfo.Line.Length > 1024)
|
||||
{
|
||||
Trace.Verbose("Web console line is more than 1024 chars, truncate to first 1024 chars");
|
||||
lineInfo.Line = $"{lineInfo.Line.Substring(0, 1024)}...";
|
||||
}
|
||||
|
||||
stepsConsoleLines[lineInfo.StepRecordId].Add(lineInfo.Line);
|
||||
linesCounter++;
|
||||
|
||||
// process at most about 500 lines of web console line during regular timer dequeue task.
|
||||
if (!runOnce && linesCounter > 500)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Batch post consolelines for each step timeline record
|
||||
foreach (var stepRecordId in stepRecordIds)
|
||||
{
|
||||
// Split consolelines into batch, each batch will container at most 100 lines.
|
||||
int batchCounter = 0;
|
||||
List<List<string>> batchedLines = new List<List<string>>();
|
||||
foreach (var line in stepsConsoleLines[stepRecordId])
|
||||
{
|
||||
var currentBatch = batchedLines.ElementAtOrDefault(batchCounter);
|
||||
if (currentBatch == null)
|
||||
{
|
||||
batchedLines.Add(new List<string>());
|
||||
currentBatch = batchedLines.ElementAt(batchCounter);
|
||||
}
|
||||
|
||||
currentBatch.Add(line);
|
||||
|
||||
if (currentBatch.Count >= 100)
|
||||
{
|
||||
batchCounter++;
|
||||
}
|
||||
}
|
||||
|
||||
if (batchedLines.Count > 0)
|
||||
{
|
||||
// When job finish, web console lines becomes less interesting to customer
|
||||
// We batch and produce 500 lines of web console output every 500ms
|
||||
// If customer's task produce massive of outputs, then the last queue drain run might take forever.
|
||||
// So we will only upload the last 200 lines of each step from all buffered web console lines.
|
||||
if (runOnce && batchedLines.Count > 2)
|
||||
{
|
||||
Trace.Info($"Skip {batchedLines.Count - 2} batches web console lines for last run");
|
||||
batchedLines = batchedLines.TakeLast(2).ToList();
|
||||
batchedLines[0].Insert(0, "...");
|
||||
}
|
||||
|
||||
int errorCount = 0;
|
||||
foreach (var batch in batchedLines)
|
||||
{
|
||||
try
|
||||
{
|
||||
// we will not requeue failed batch, since the web console lines are time sensitive.
|
||||
await _jobServer.AppendTimelineRecordFeedAsync(_scopeIdentifier, _hubName, _planId, _jobTimelineId, _jobTimelineRecordId, stepRecordId, batch, default(CancellationToken));
|
||||
if (_firstConsoleOutputs)
|
||||
{
|
||||
HostContext.WritePerfCounter($"WorkerJobServerQueueAppendFirstConsoleOutput_{_planId.ToString()}");
|
||||
_firstConsoleOutputs = false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Info("Catch exception during append web console line, keep going since the process is best effort.");
|
||||
Trace.Error(ex);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
Trace.Info("Try to append {0} batches web console lines for record '{2}', success rate: {1}/{0}.", batchedLines.Count, batchedLines.Count - errorCount, stepRecordId);
|
||||
}
|
||||
}
|
||||
|
||||
if (runOnce)
|
||||
{
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
await Task.Delay(_webConsoleLineAggressiveDequeue ? _aggressiveDelayForWebConsoleLineDequeue : _delayForWebConsoleLineDequeue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessFilesUploadQueueAsync(bool runOnce = false)
|
||||
{
|
||||
while (!_jobCompletionSource.Task.IsCompleted || runOnce)
|
||||
{
|
||||
List<UploadFileInfo> filesToUpload = new List<UploadFileInfo>();
|
||||
UploadFileInfo dequeueFile;
|
||||
while (_fileUploadQueue.TryDequeue(out dequeueFile))
|
||||
{
|
||||
filesToUpload.Add(dequeueFile);
|
||||
// process at most 10 file upload.
|
||||
if (!runOnce && filesToUpload.Count > 10)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (filesToUpload.Count > 0)
|
||||
{
|
||||
if (runOnce)
|
||||
{
|
||||
Trace.Info($"Uploading {filesToUpload.Count} files in one shot.");
|
||||
}
|
||||
|
||||
// TODO: upload all file in parallel
|
||||
int errorCount = 0;
|
||||
foreach (var file in filesToUpload)
|
||||
{
|
||||
try
|
||||
{
|
||||
await UploadFile(file);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Info("Catch exception during log or attachment file upload, keep going since the process is best effort.");
|
||||
Trace.Error(ex);
|
||||
errorCount++;
|
||||
|
||||
// put the failed upload file back to queue.
|
||||
// TODO: figure out how should we retry paging log upload.
|
||||
//lock (_fileUploadQueueLock)
|
||||
//{
|
||||
// _fileUploadQueue.Enqueue(file);
|
||||
//}
|
||||
}
|
||||
}
|
||||
|
||||
Trace.Info("Try to upload {0} log files or attachments, success rate: {1}/{0}.", filesToUpload.Count, filesToUpload.Count - errorCount);
|
||||
}
|
||||
|
||||
if (runOnce)
|
||||
{
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
await Task.Delay(_delayForFileUploadDequeue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessTimelinesUpdateQueueAsync(bool runOnce = false)
|
||||
{
|
||||
while (!_jobCompletionSource.Task.IsCompleted || runOnce)
|
||||
{
|
||||
List<PendingTimelineRecord> pendingUpdates = new List<PendingTimelineRecord>();
|
||||
foreach (var timeline in _allTimelines)
|
||||
{
|
||||
ConcurrentQueue<TimelineRecord> recordQueue;
|
||||
if (_timelineUpdateQueue.TryGetValue(timeline, out recordQueue))
|
||||
{
|
||||
List<TimelineRecord> records = new List<TimelineRecord>();
|
||||
TimelineRecord record;
|
||||
while (recordQueue.TryDequeue(out record))
|
||||
{
|
||||
records.Add(record);
|
||||
// process at most 25 timeline records update for each timeline.
|
||||
if (!runOnce && records.Count > 25)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (records.Count > 0)
|
||||
{
|
||||
pendingUpdates.Add(new PendingTimelineRecord() { TimelineId = timeline, PendingRecords = records.ToList() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// we need track whether we have new sub-timeline been created on the last run.
|
||||
// if so, we need continue update timeline record even we on the last run.
|
||||
bool pendingSubtimelineUpdate = false;
|
||||
List<Exception> mainTimelineRecordsUpdateErrors = new List<Exception>();
|
||||
if (pendingUpdates.Count > 0)
|
||||
{
|
||||
foreach (var update in pendingUpdates)
|
||||
{
|
||||
List<TimelineRecord> bufferedRecords;
|
||||
if (_bufferedRetryRecords.TryGetValue(update.TimelineId, out bufferedRecords))
|
||||
{
|
||||
update.PendingRecords.InsertRange(0, bufferedRecords);
|
||||
}
|
||||
|
||||
update.PendingRecords = MergeTimelineRecords(update.PendingRecords);
|
||||
|
||||
foreach (var detailTimeline in update.PendingRecords.Where(r => r.Details != null))
|
||||
{
|
||||
if (!_allTimelines.Contains(detailTimeline.Details.Id))
|
||||
{
|
||||
try
|
||||
{
|
||||
Timeline newTimeline = await _jobServer.CreateTimelineAsync(_scopeIdentifier, _hubName, _planId, detailTimeline.Details.Id, default(CancellationToken));
|
||||
_allTimelines.Add(newTimeline.Id);
|
||||
pendingSubtimelineUpdate = true;
|
||||
}
|
||||
catch (TimelineExistsException)
|
||||
{
|
||||
Trace.Info("Catch TimelineExistsException during timeline creation. Ignore the error since server already had this timeline.");
|
||||
_allTimelines.Add(detailTimeline.Details.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _jobServer.UpdateTimelineRecordsAsync(_scopeIdentifier, _hubName, _planId, update.TimelineId, update.PendingRecords, default(CancellationToken));
|
||||
if (_bufferedRetryRecords.Remove(update.TimelineId))
|
||||
{
|
||||
Trace.Verbose("Cleanup buffered timeline record for timeline: {0}.", update.TimelineId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Info("Catch exception during update timeline records, try to update these timeline records next time.");
|
||||
Trace.Error(ex);
|
||||
_bufferedRetryRecords[update.TimelineId] = update.PendingRecords.ToList();
|
||||
if (update.TimelineId == _jobTimelineId)
|
||||
{
|
||||
mainTimelineRecordsUpdateErrors.Add(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (runOnce)
|
||||
{
|
||||
// continue process timeline records update,
|
||||
// we might have more records need update,
|
||||
// since we just create a new sub-timeline
|
||||
if (pendingSubtimelineUpdate)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (mainTimelineRecordsUpdateErrors.Count > 0 &&
|
||||
_bufferedRetryRecords.ContainsKey(_jobTimelineId) &&
|
||||
_bufferedRetryRecords[_jobTimelineId] != null &&
|
||||
_bufferedRetryRecords[_jobTimelineId].Any(r => r.Variables.Count > 0))
|
||||
{
|
||||
Trace.Info("Fail to update timeline records with output variables. Throw exception to fail the job since output variables are critical to downstream jobs.");
|
||||
throw new AggregateException("Failed to publish output variables.", mainTimelineRecordsUpdateErrors);
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await Task.Delay(_delayForTimelineUpdateDequeue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<TimelineRecord> MergeTimelineRecords(List<TimelineRecord> timelineRecords)
|
||||
{
|
||||
if (timelineRecords == null || timelineRecords.Count <= 1)
|
||||
{
|
||||
return timelineRecords;
|
||||
}
|
||||
|
||||
Dictionary<Guid, TimelineRecord> dict = new Dictionary<Guid, TimelineRecord>();
|
||||
foreach (TimelineRecord rec in timelineRecords)
|
||||
{
|
||||
if (rec == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
TimelineRecord timelineRecord;
|
||||
if (dict.TryGetValue(rec.Id, out timelineRecord))
|
||||
{
|
||||
// Merge rec into timelineRecord
|
||||
timelineRecord.CurrentOperation = rec.CurrentOperation ?? timelineRecord.CurrentOperation;
|
||||
timelineRecord.Details = rec.Details ?? timelineRecord.Details;
|
||||
timelineRecord.FinishTime = rec.FinishTime ?? timelineRecord.FinishTime;
|
||||
timelineRecord.Log = rec.Log ?? timelineRecord.Log;
|
||||
timelineRecord.Name = rec.Name ?? timelineRecord.Name;
|
||||
timelineRecord.RefName = rec.RefName ?? timelineRecord.RefName;
|
||||
timelineRecord.PercentComplete = rec.PercentComplete ?? timelineRecord.PercentComplete;
|
||||
timelineRecord.RecordType = rec.RecordType ?? timelineRecord.RecordType;
|
||||
timelineRecord.Result = rec.Result ?? timelineRecord.Result;
|
||||
timelineRecord.ResultCode = rec.ResultCode ?? timelineRecord.ResultCode;
|
||||
timelineRecord.StartTime = rec.StartTime ?? timelineRecord.StartTime;
|
||||
timelineRecord.State = rec.State ?? timelineRecord.State;
|
||||
timelineRecord.WorkerName = rec.WorkerName ?? timelineRecord.WorkerName;
|
||||
|
||||
if (rec.ErrorCount != null && rec.ErrorCount > 0)
|
||||
{
|
||||
timelineRecord.ErrorCount = rec.ErrorCount;
|
||||
}
|
||||
|
||||
if (rec.WarningCount != null && rec.WarningCount > 0)
|
||||
{
|
||||
timelineRecord.WarningCount = rec.WarningCount;
|
||||
}
|
||||
|
||||
if (rec.Issues.Count > 0)
|
||||
{
|
||||
timelineRecord.Issues.Clear();
|
||||
timelineRecord.Issues.AddRange(rec.Issues.Select(i => i.Clone()));
|
||||
}
|
||||
|
||||
if (rec.Variables.Count > 0)
|
||||
{
|
||||
foreach (var variable in rec.Variables)
|
||||
{
|
||||
timelineRecord.Variables[variable.Key] = variable.Value.Clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
dict.Add(rec.Id, rec);
|
||||
}
|
||||
}
|
||||
|
||||
var mergedRecords = dict.Values.ToList();
|
||||
|
||||
Trace.Verbose("Merged Timeline records");
|
||||
foreach (var record in mergedRecords)
|
||||
{
|
||||
Trace.Verbose($" Record: t={record.RecordType}, n={record.Name}, s={record.State}, st={record.StartTime}, {record.PercentComplete}%, ft={record.FinishTime}, r={record.Result}: {record.CurrentOperation}");
|
||||
if (record.Issues != null && record.Issues.Count > 0)
|
||||
{
|
||||
foreach (var issue in record.Issues)
|
||||
{
|
||||
String source;
|
||||
issue.Data.TryGetValue("sourcepath", out source);
|
||||
Trace.Verbose($" Issue: c={issue.Category}, t={issue.Type}, s={source ?? string.Empty}, m={issue.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (record.Variables != null && record.Variables.Count > 0)
|
||||
{
|
||||
foreach (var variable in record.Variables)
|
||||
{
|
||||
Trace.Verbose($" Variable: n={variable.Key}, secret={variable.Value.IsSecret}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mergedRecords;
|
||||
}
|
||||
|
||||
private async Task UploadFile(UploadFileInfo file)
|
||||
{
|
||||
bool uploadSucceed = false;
|
||||
try
|
||||
{
|
||||
if (String.Equals(file.Type, CoreAttachmentType.Log, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Create the log
|
||||
var taskLog = await _jobServer.CreateLogAsync(_scopeIdentifier, _hubName, _planId, new TaskLog(String.Format(@"logs\{0:D}", file.TimelineRecordId)), default(CancellationToken));
|
||||
|
||||
// Upload the contents
|
||||
using (FileStream fs = File.Open(file.Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
{
|
||||
var logUploaded = await _jobServer.AppendLogContentAsync(_scopeIdentifier, _hubName, _planId, taskLog.Id, fs, default(CancellationToken));
|
||||
}
|
||||
|
||||
// Create a new record and only set the Log field
|
||||
var attachmentUpdataRecord = new TimelineRecord() { Id = file.TimelineRecordId, Log = taskLog };
|
||||
QueueTimelineRecordUpdate(file.TimelineId, attachmentUpdataRecord);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Create attachment
|
||||
using (FileStream fs = File.Open(file.Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
{
|
||||
var result = await _jobServer.CreateAttachmentAsync(_scopeIdentifier, _hubName, _planId, file.TimelineId, file.TimelineRecordId, file.Type, file.Name, fs, default(CancellationToken));
|
||||
}
|
||||
}
|
||||
|
||||
uploadSucceed = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (uploadSucceed && file.DeleteSource)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(file.Path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Info("Catch exception during delete success uploaded file.");
|
||||
Trace.Error(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class PendingTimelineRecord
|
||||
{
|
||||
public Guid TimelineId { get; set; }
|
||||
public List<TimelineRecord> PendingRecords { get; set; }
|
||||
}
|
||||
|
||||
internal class UploadFileInfo
|
||||
{
|
||||
public Guid TimelineId { get; set; }
|
||||
public Guid TimelineRecordId { get; set; }
|
||||
public string Type { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Path { get; set; }
|
||||
public bool DeleteSource { get; set; }
|
||||
}
|
||||
|
||||
|
||||
internal class ConsoleLineInfo
|
||||
{
|
||||
public ConsoleLineInfo(Guid recordId, string line)
|
||||
{
|
||||
this.StepRecordId = recordId;
|
||||
this.Line = line;
|
||||
}
|
||||
|
||||
public Guid StepRecordId { get; set; }
|
||||
public string Line { get; set; }
|
||||
}
|
||||
}
|
||||
61
src/Runner.Common/LocationServer.cs
Normal file
61
src/Runner.Common/LocationServer.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Services.WebApi;
|
||||
using GitHub.Services.Location.Client;
|
||||
using GitHub.Services.Location;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
[ServiceLocator(Default = typeof(LocationServer))]
|
||||
public interface ILocationServer : IRunnerService
|
||||
{
|
||||
Task ConnectAsync(VssConnection jobConnection);
|
||||
|
||||
Task<ConnectionData> GetConnectionDataAsync();
|
||||
}
|
||||
|
||||
public sealed class LocationServer : RunnerService, ILocationServer
|
||||
{
|
||||
private bool _hasConnection;
|
||||
private VssConnection _connection;
|
||||
private LocationHttpClient _locationClient;
|
||||
|
||||
public async Task ConnectAsync(VssConnection jobConnection)
|
||||
{
|
||||
_connection = jobConnection;
|
||||
int attemptCount = 5;
|
||||
while (!_connection.HasAuthenticated && attemptCount-- > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _connection.ConnectAsync();
|
||||
break;
|
||||
}
|
||||
catch (Exception ex) when (attemptCount > 0)
|
||||
{
|
||||
Trace.Info($"Catch exception during connect. {attemptCount} attempt left.");
|
||||
Trace.Error(ex);
|
||||
}
|
||||
|
||||
await Task.Delay(100);
|
||||
}
|
||||
|
||||
_locationClient = _connection.GetClient<LocationHttpClient>();
|
||||
_hasConnection = true;
|
||||
}
|
||||
|
||||
private void CheckConnection()
|
||||
{
|
||||
if (!_hasConnection)
|
||||
{
|
||||
throw new InvalidOperationException("SetConnection");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ConnectionData> GetConnectionDataAsync()
|
||||
{
|
||||
CheckConnection();
|
||||
return await _locationClient.GetConnectionDataAsync(ConnectOptions.None, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
124
src/Runner.Common/Logging.cs
Normal file
124
src/Runner.Common/Logging.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
using GitHub.Runner.Common.Util;
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
[ServiceLocator(Default = typeof(PagingLogger))]
|
||||
public interface IPagingLogger : IRunnerService
|
||||
{
|
||||
long TotalLines { get; }
|
||||
void Setup(Guid timelineId, Guid timelineRecordId);
|
||||
|
||||
void Write(string message);
|
||||
|
||||
void End();
|
||||
}
|
||||
|
||||
public class PagingLogger : RunnerService, IPagingLogger
|
||||
{
|
||||
public static string PagingFolder = "pages";
|
||||
|
||||
// 8 MB
|
||||
public const int PageSize = 8 * 1024 * 1024;
|
||||
|
||||
private Guid _timelineId;
|
||||
private Guid _timelineRecordId;
|
||||
private string _pageId;
|
||||
private FileStream _pageData;
|
||||
private StreamWriter _pageWriter;
|
||||
private int _byteCount;
|
||||
private int _pageCount;
|
||||
private long _totalLines;
|
||||
private string _dataFileName;
|
||||
private string _pagesFolder;
|
||||
private IJobServerQueue _jobServerQueue;
|
||||
|
||||
public long TotalLines => _totalLines;
|
||||
|
||||
public override void Initialize(IHostContext hostContext)
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
_totalLines = 0;
|
||||
_pageId = Guid.NewGuid().ToString();
|
||||
_pagesFolder = Path.Combine(hostContext.GetDirectory(WellKnownDirectory.Diag), PagingFolder);
|
||||
_jobServerQueue = HostContext.GetService<IJobServerQueue>();
|
||||
Directory.CreateDirectory(_pagesFolder);
|
||||
}
|
||||
|
||||
public void Setup(Guid timelineId, Guid timelineRecordId)
|
||||
{
|
||||
_timelineId = timelineId;
|
||||
_timelineRecordId = timelineRecordId;
|
||||
}
|
||||
|
||||
//
|
||||
// Write a metadata file with id etc, point to pages on disk.
|
||||
// Each page is a guid_#. As a page rolls over, it events it's done
|
||||
// and the consumer queues it for upload
|
||||
// Ensure this is lazy. Create a page on first write
|
||||
//
|
||||
public void Write(string message)
|
||||
{
|
||||
// lazy creation on write
|
||||
if (_pageWriter == null)
|
||||
{
|
||||
Create();
|
||||
}
|
||||
|
||||
string line = $"{DateTime.UtcNow.ToString("O")} {message}";
|
||||
_pageWriter.WriteLine(line);
|
||||
|
||||
_totalLines++;
|
||||
if (line.IndexOf('\n') != -1)
|
||||
{
|
||||
foreach (char c in line)
|
||||
{
|
||||
if (c == '\n')
|
||||
{
|
||||
_totalLines++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_byteCount += System.Text.Encoding.UTF8.GetByteCount(line);
|
||||
if (_byteCount >= PageSize)
|
||||
{
|
||||
NewPage();
|
||||
}
|
||||
}
|
||||
|
||||
public void End()
|
||||
{
|
||||
EndPage();
|
||||
}
|
||||
|
||||
private void Create()
|
||||
{
|
||||
NewPage();
|
||||
}
|
||||
|
||||
private void NewPage()
|
||||
{
|
||||
EndPage();
|
||||
_byteCount = 0;
|
||||
_dataFileName = Path.Combine(_pagesFolder, $"{_pageId}_{++_pageCount}.log");
|
||||
_pageData = new FileStream(_dataFileName, FileMode.CreateNew);
|
||||
_pageWriter = new StreamWriter(_pageData, System.Text.Encoding.UTF8);
|
||||
}
|
||||
|
||||
private void EndPage()
|
||||
{
|
||||
if (_pageWriter != null)
|
||||
{
|
||||
_pageWriter.Flush();
|
||||
_pageData.Flush();
|
||||
//The StreamWriter object calls Dispose() on the provided Stream object when StreamWriter.Dispose is called.
|
||||
_pageWriter.Dispose();
|
||||
_pageWriter = null;
|
||||
_pageData = null;
|
||||
_jobServerQueue.QueueFileUpload(_timelineId, _timelineRecordId, "DistributedTask.Core.Log", "CustomToolLog", _dataFileName, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
100
src/Runner.Common/ProcessChannel.cs
Normal file
100
src/Runner.Common/ProcessChannel.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
public delegate void StartProcessDelegate(string pipeHandleOut, string pipeHandleIn);
|
||||
|
||||
public enum MessageType
|
||||
{
|
||||
NotInitialized = -1,
|
||||
NewJobRequest = 1,
|
||||
CancelRequest = 2,
|
||||
RunnerShutdown = 3,
|
||||
OperatingSystemShutdown = 4
|
||||
}
|
||||
|
||||
public struct WorkerMessage
|
||||
{
|
||||
public MessageType MessageType;
|
||||
public string Body;
|
||||
public WorkerMessage(MessageType messageType, string body)
|
||||
{
|
||||
MessageType = messageType;
|
||||
Body = body;
|
||||
}
|
||||
}
|
||||
|
||||
[ServiceLocator(Default = typeof(ProcessChannel))]
|
||||
public interface IProcessChannel : IDisposable, IRunnerService
|
||||
{
|
||||
void StartServer(StartProcessDelegate startProcess);
|
||||
void StartClient(string pipeNameInput, string pipeNameOutput);
|
||||
|
||||
Task SendAsync(MessageType messageType, string body, CancellationToken cancellationToken);
|
||||
Task<WorkerMessage> ReceiveAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class ProcessChannel : RunnerService, IProcessChannel
|
||||
{
|
||||
private AnonymousPipeServerStream _inServer;
|
||||
private AnonymousPipeServerStream _outServer;
|
||||
private AnonymousPipeClientStream _inClient;
|
||||
private AnonymousPipeClientStream _outClient;
|
||||
private StreamString _writeStream;
|
||||
private StreamString _readStream;
|
||||
|
||||
public void StartServer(StartProcessDelegate startProcess)
|
||||
{
|
||||
_outServer = new AnonymousPipeServerStream(PipeDirection.Out, HandleInheritability.Inheritable);
|
||||
_inServer = new AnonymousPipeServerStream(PipeDirection.In, HandleInheritability.Inheritable);
|
||||
_readStream = new StreamString(_inServer);
|
||||
_writeStream = new StreamString(_outServer);
|
||||
startProcess(_outServer.GetClientHandleAsString(), _inServer.GetClientHandleAsString());
|
||||
_outServer.DisposeLocalCopyOfClientHandle();
|
||||
_inServer.DisposeLocalCopyOfClientHandle();
|
||||
}
|
||||
|
||||
public void StartClient(string pipeNameInput, string pipeNameOutput)
|
||||
{
|
||||
_inClient = new AnonymousPipeClientStream(PipeDirection.In, pipeNameInput);
|
||||
_outClient = new AnonymousPipeClientStream(PipeDirection.Out, pipeNameOutput);
|
||||
_readStream = new StreamString(_inClient);
|
||||
_writeStream = new StreamString(_outClient);
|
||||
}
|
||||
|
||||
public async Task SendAsync(MessageType messageType, string body, CancellationToken cancellationToken)
|
||||
{
|
||||
await _writeStream.WriteInt32Async((int)messageType, cancellationToken);
|
||||
await _writeStream.WriteStringAsync(body, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<WorkerMessage> ReceiveAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
WorkerMessage result = new WorkerMessage(MessageType.NotInitialized, string.Empty);
|
||||
result.MessageType = (MessageType)await _readStream.ReadInt32Async(cancellationToken);
|
||||
result.Body = await _readStream.ReadStringAsync(cancellationToken);
|
||||
return result;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_inServer?.Dispose();
|
||||
_outServer?.Dispose();
|
||||
_inClient?.Dispose();
|
||||
_outClient?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
396
src/Runner.Common/ProcessExtensions.cs
Normal file
396
src/Runner.Common/ProcessExtensions.cs
Normal file
@@ -0,0 +1,396 @@
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
#if OS_WINDOWS
|
||||
public static class WindowsProcessExtensions
|
||||
{
|
||||
// Reference: https://blogs.msdn.microsoft.com/matt_pietrek/2004/08/25/reading-another-processs-environment/
|
||||
// Reference: http://blog.gapotchenko.com/eazfuscator.net/reading-environment-variables
|
||||
public static string GetEnvironmentVariable(this Process process, IHostContext hostContext, string variable)
|
||||
{
|
||||
var trace = hostContext.GetTrace(nameof(WindowsProcessExtensions));
|
||||
Dictionary<string, string> environmentVariables = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
IntPtr processHandle = process.SafeHandle.DangerousGetHandle();
|
||||
|
||||
IntPtr environmentBlockAddress;
|
||||
if (Environment.Is64BitOperatingSystem)
|
||||
{
|
||||
PROCESS_BASIC_INFORMATION64 pbi = new PROCESS_BASIC_INFORMATION64();
|
||||
int returnLength = 0;
|
||||
int status = NtQueryInformationProcess64(processHandle, PROCESSINFOCLASS.ProcessBasicInformation, ref pbi, Marshal.SizeOf(pbi), ref returnLength);
|
||||
if (status != 0)
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
|
||||
bool wow64;
|
||||
if (!IsWow64Process(processHandle, out wow64))
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
|
||||
if (!wow64)
|
||||
{
|
||||
// 64 bits process on 64 bits OS
|
||||
IntPtr UserProcessParameterAddress = ReadIntPtr64(processHandle, new IntPtr(pbi.PebBaseAddress) + 0x20);
|
||||
environmentBlockAddress = ReadIntPtr64(processHandle, UserProcessParameterAddress + 0x80);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 32 bits process on 64 bits OS
|
||||
IntPtr UserProcessParameterAddress = ReadIntPtr32(processHandle, new IntPtr(pbi.PebBaseAddress) + 0x1010);
|
||||
environmentBlockAddress = ReadIntPtr32(processHandle, UserProcessParameterAddress + 0x48);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
PROCESS_BASIC_INFORMATION32 pbi = new PROCESS_BASIC_INFORMATION32();
|
||||
int returnLength = 0;
|
||||
int status = NtQueryInformationProcess32(processHandle, PROCESSINFOCLASS.ProcessBasicInformation, ref pbi, Marshal.SizeOf(pbi), ref returnLength);
|
||||
if (status != 0)
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
|
||||
// 32 bits process on 32 bits OS
|
||||
IntPtr UserProcessParameterAddress = ReadIntPtr32(processHandle, new IntPtr(pbi.PebBaseAddress) + 0x10);
|
||||
environmentBlockAddress = ReadIntPtr32(processHandle, UserProcessParameterAddress + 0x48);
|
||||
}
|
||||
|
||||
MEMORY_BASIC_INFORMATION memInfo = new MEMORY_BASIC_INFORMATION();
|
||||
if (VirtualQueryEx(processHandle, environmentBlockAddress, ref memInfo, Marshal.SizeOf(memInfo)) == 0)
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
|
||||
Int64 dataSize = memInfo.RegionSize.ToInt64() - (environmentBlockAddress.ToInt64() - memInfo.BaseAddress.ToInt64());
|
||||
|
||||
byte[] envData = new byte[dataSize];
|
||||
IntPtr res_len = IntPtr.Zero;
|
||||
if (!ReadProcessMemory(processHandle, environmentBlockAddress, envData, new IntPtr(dataSize), ref res_len))
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
|
||||
if (res_len.ToInt64() != dataSize)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(ReadProcessMemory));
|
||||
}
|
||||
|
||||
string environmentVariableString;
|
||||
Int64 environmentVariableBytesLength = 0;
|
||||
// check env encoding
|
||||
if (envData[0] != 0 && envData[1] == 0)
|
||||
{
|
||||
// Unicode
|
||||
for (Int64 index = 0; index < dataSize; index++)
|
||||
{
|
||||
// Unicode encoded environment variables block ends up with '\0\0\0\0'.
|
||||
if (environmentVariableBytesLength == 0 &&
|
||||
envData[index] == 0 &&
|
||||
index + 3 < dataSize &&
|
||||
envData[index + 1] == 0 &&
|
||||
envData[index + 2] == 0 &&
|
||||
envData[index + 3] == 0)
|
||||
{
|
||||
environmentVariableBytesLength = index + 3;
|
||||
}
|
||||
else if (environmentVariableBytesLength != 0)
|
||||
{
|
||||
// set it '\0' so we can easily trim it, most array method doesn't take int64
|
||||
envData[index] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (environmentVariableBytesLength == 0)
|
||||
{
|
||||
throw new ArgumentException(nameof(environmentVariableBytesLength));
|
||||
}
|
||||
|
||||
environmentVariableString = Encoding.Unicode.GetString(envData);
|
||||
}
|
||||
else if (envData[0] != 0 && envData[1] != 0)
|
||||
{
|
||||
// ANSI
|
||||
for (Int64 index = 0; index < dataSize; index++)
|
||||
{
|
||||
// Unicode encoded environment variables block ends up with '\0\0'.
|
||||
if (environmentVariableBytesLength == 0 &&
|
||||
envData[index] == 0 &&
|
||||
index + 1 < dataSize &&
|
||||
envData[index + 1] == 0)
|
||||
{
|
||||
environmentVariableBytesLength = index + 1;
|
||||
}
|
||||
else if (environmentVariableBytesLength != 0)
|
||||
{
|
||||
// set it '\0' so we can easily trim it, most array method doesn't take int64
|
||||
envData[index] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (environmentVariableBytesLength == 0)
|
||||
{
|
||||
throw new ArgumentException(nameof(environmentVariableBytesLength));
|
||||
}
|
||||
|
||||
environmentVariableString = Encoding.Default.GetString(envData);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new ArgumentException(nameof(envData));
|
||||
}
|
||||
|
||||
foreach (var envString in environmentVariableString.Split("\0", StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
string[] env = envString.Split("=", 2);
|
||||
if (!string.IsNullOrEmpty(env[0]))
|
||||
{
|
||||
environmentVariables[env[0]] = env[1];
|
||||
trace.Verbose($"PID:{process.Id} ({env[0]}={env[1]})");
|
||||
}
|
||||
}
|
||||
|
||||
if (environmentVariables.TryGetValue(variable, out string envVariable))
|
||||
{
|
||||
return envVariable;
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static IntPtr ReadIntPtr32(IntPtr hProcess, IntPtr ptr)
|
||||
{
|
||||
IntPtr readPtr = IntPtr.Zero;
|
||||
IntPtr data = Marshal.AllocHGlobal(sizeof(Int32));
|
||||
try
|
||||
{
|
||||
IntPtr res_len = IntPtr.Zero;
|
||||
if (!ReadProcessMemory(hProcess, ptr, data, new IntPtr(sizeof(Int32)), ref res_len))
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
|
||||
if (res_len.ToInt32() != sizeof(Int32))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(ReadProcessMemory));
|
||||
}
|
||||
|
||||
readPtr = new IntPtr(Marshal.ReadInt32(data));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(data);
|
||||
}
|
||||
|
||||
return readPtr;
|
||||
}
|
||||
|
||||
private static IntPtr ReadIntPtr64(IntPtr hProcess, IntPtr ptr)
|
||||
{
|
||||
IntPtr readPtr = IntPtr.Zero;
|
||||
IntPtr data = Marshal.AllocHGlobal(IntPtr.Size);
|
||||
try
|
||||
{
|
||||
IntPtr res_len = IntPtr.Zero;
|
||||
if (!ReadProcessMemory(hProcess, ptr, data, new IntPtr(sizeof(Int64)), ref res_len))
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
|
||||
if (res_len.ToInt32() != IntPtr.Size)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(ReadProcessMemory));
|
||||
}
|
||||
|
||||
readPtr = Marshal.ReadIntPtr(data);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(data);
|
||||
}
|
||||
|
||||
return readPtr;
|
||||
}
|
||||
|
||||
private enum PROCESSINFOCLASS : int
|
||||
{
|
||||
ProcessBasicInformation = 0
|
||||
};
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MEMORY_BASIC_INFORMATION
|
||||
{
|
||||
public IntPtr BaseAddress;
|
||||
public IntPtr AllocationBase;
|
||||
public int AllocationProtect;
|
||||
public IntPtr RegionSize;
|
||||
public int State;
|
||||
public int Protect;
|
||||
public int Type;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct PROCESS_BASIC_INFORMATION64
|
||||
{
|
||||
public long ExitStatus;
|
||||
public long PebBaseAddress;
|
||||
public long AffinityMask;
|
||||
public long BasePriority;
|
||||
public long UniqueProcessId;
|
||||
public long InheritedFromUniqueProcessId;
|
||||
};
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct PROCESS_BASIC_INFORMATION32
|
||||
{
|
||||
public int ExitStatus;
|
||||
public int PebBaseAddress;
|
||||
public int AffinityMask;
|
||||
public int BasePriority;
|
||||
public int UniqueProcessId;
|
||||
public int InheritedFromUniqueProcessId;
|
||||
};
|
||||
|
||||
[DllImport("ntdll.dll", SetLastError = true, EntryPoint = "NtQueryInformationProcess")]
|
||||
private static extern int NtQueryInformationProcess64(IntPtr processHandle, PROCESSINFOCLASS processInformationClass, ref PROCESS_BASIC_INFORMATION64 processInformation, int processInformationLength, ref int returnLength);
|
||||
|
||||
[DllImport("ntdll.dll", SetLastError = true, EntryPoint = "NtQueryInformationProcess")]
|
||||
private static extern int NtQueryInformationProcess32(IntPtr processHandle, PROCESSINFOCLASS processInformationClass, ref PROCESS_BASIC_INFORMATION32 processInformation, int processInformationLength, ref int returnLength);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool IsWow64Process(IntPtr processHandle, out bool wow64Process);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool ReadProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, IntPtr lpBuffer, IntPtr dwSize, ref IntPtr lpNumberOfBytesRead);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool ReadProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, [Out] byte[] lpBuffer, IntPtr dwSize, ref IntPtr lpNumberOfBytesRead);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern int VirtualQueryEx(IntPtr processHandle, IntPtr baseAddress, ref MEMORY_BASIC_INFORMATION memoryInformation, int memoryInformationLength);
|
||||
}
|
||||
#else
|
||||
public static class LinuxProcessExtensions
|
||||
{
|
||||
public static string GetEnvironmentVariable(this Process process, IHostContext hostContext, string variable)
|
||||
{
|
||||
var trace = hostContext.GetTrace(nameof(LinuxProcessExtensions));
|
||||
Dictionary<string, string> env = new Dictionary<string, string>();
|
||||
|
||||
if (Directory.Exists("/proc"))
|
||||
{
|
||||
string envFile = $"/proc/{process.Id}/environ";
|
||||
trace.Info($"Read env from {envFile}");
|
||||
string envContent = File.ReadAllText(envFile);
|
||||
if (!string.IsNullOrEmpty(envContent))
|
||||
{
|
||||
// on linux, environment variables are seprated by '\0'
|
||||
var envList = envContent.Split('\0', StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var envStr in envList)
|
||||
{
|
||||
// split on the first '='
|
||||
var keyValuePair = envStr.Split('=', 2);
|
||||
if (keyValuePair.Length == 2)
|
||||
{
|
||||
env[keyValuePair[0]] = keyValuePair[1];
|
||||
trace.Verbose($"PID:{process.Id} ({keyValuePair[0]}={keyValuePair[1]})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// On OSX, there is no /proc folder for us to read environment for given process,
|
||||
// So we have call `ps e -p <pid> -o command` to print out env to STDOUT,
|
||||
// However, the output env are not format in a parseable way, it's just a string that concatenate all envs with space,
|
||||
// It doesn't escape '=' or ' ', so we can't parse the output into a dictionary of all envs.
|
||||
// So we only look for the env you request, in the format of variable=value. (it won't work if you variable contains = or space)
|
||||
trace.Info($"Read env from output of `ps e -p {process.Id} -o command`");
|
||||
List<string> psOut = new List<string>();
|
||||
object outputLock = new object();
|
||||
using (var p = hostContext.CreateService<IProcessInvoker>())
|
||||
{
|
||||
p.OutputDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stdout)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(stdout.Data))
|
||||
{
|
||||
lock (outputLock)
|
||||
{
|
||||
psOut.Add(stdout.Data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
p.ErrorDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stderr)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(stderr.Data))
|
||||
{
|
||||
lock (outputLock)
|
||||
{
|
||||
trace.Error(stderr.Data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
int exitCode = p.ExecuteAsync(workingDirectory: hostContext.GetDirectory(WellKnownDirectory.Root),
|
||||
fileName: "ps",
|
||||
arguments: $"e -p {process.Id} -o command",
|
||||
environment: null,
|
||||
cancellationToken: CancellationToken.None).GetAwaiter().GetResult();
|
||||
if (exitCode == 0)
|
||||
{
|
||||
trace.Info($"Successfully dump environment variables for {process.Id}");
|
||||
if (psOut.Count > 0)
|
||||
{
|
||||
string psOutputString = string.Join(" ", psOut);
|
||||
trace.Verbose($"ps output: '{psOutputString}'");
|
||||
|
||||
int varStartIndex = psOutputString.IndexOf(variable, StringComparison.Ordinal);
|
||||
if (varStartIndex >= 0)
|
||||
{
|
||||
string rightPart = psOutputString.Substring(varStartIndex + variable.Length + 1);
|
||||
if (rightPart.IndexOf(' ') > 0)
|
||||
{
|
||||
string value = rightPart.Substring(0, rightPart.IndexOf(' '));
|
||||
env[variable] = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
env[variable] = rightPart;
|
||||
}
|
||||
|
||||
trace.Verbose($"PID:{process.Id} ({variable}={env[variable]})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (env.TryGetValue(variable, out string envVariable))
|
||||
{
|
||||
return envVariable;
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
329
src/Runner.Common/ProcessInvoker.cs
Normal file
329
src/Runner.Common/ProcessInvoker.cs
Normal file
@@ -0,0 +1,329 @@
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
[ServiceLocator(Default = typeof(ProcessInvokerWrapper))]
|
||||
public interface IProcessInvoker : IDisposable, IRunnerService
|
||||
{
|
||||
event EventHandler<ProcessDataReceivedEventArgs> OutputDataReceived;
|
||||
event EventHandler<ProcessDataReceivedEventArgs> ErrorDataReceived;
|
||||
|
||||
Task<int> ExecuteAsync(
|
||||
string workingDirectory,
|
||||
string fileName,
|
||||
string arguments,
|
||||
IDictionary<string, string> environment,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<int> ExecuteAsync(
|
||||
string workingDirectory,
|
||||
string fileName,
|
||||
string arguments,
|
||||
IDictionary<string, string> environment,
|
||||
bool requireExitCodeZero,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<int> ExecuteAsync(
|
||||
string workingDirectory,
|
||||
string fileName,
|
||||
string arguments,
|
||||
IDictionary<string, string> environment,
|
||||
bool requireExitCodeZero,
|
||||
Encoding outputEncoding,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<int> ExecuteAsync(
|
||||
string workingDirectory,
|
||||
string fileName,
|
||||
string arguments,
|
||||
IDictionary<string, string> environment,
|
||||
bool requireExitCodeZero,
|
||||
Encoding outputEncoding,
|
||||
bool killProcessOnCancel,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<int> ExecuteAsync(
|
||||
string workingDirectory,
|
||||
string fileName,
|
||||
string arguments,
|
||||
IDictionary<string, string> environment,
|
||||
bool requireExitCodeZero,
|
||||
Encoding outputEncoding,
|
||||
bool killProcessOnCancel,
|
||||
Channel<string> redirectStandardIn,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<int> ExecuteAsync(
|
||||
string workingDirectory,
|
||||
string fileName,
|
||||
string arguments,
|
||||
IDictionary<string, string> environment,
|
||||
bool requireExitCodeZero,
|
||||
Encoding outputEncoding,
|
||||
bool killProcessOnCancel,
|
||||
Channel<string> redirectStandardIn,
|
||||
bool inheritConsoleHandler,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<int> ExecuteAsync(
|
||||
string workingDirectory,
|
||||
string fileName,
|
||||
string arguments,
|
||||
IDictionary<string, string> environment,
|
||||
bool requireExitCodeZero,
|
||||
Encoding outputEncoding,
|
||||
bool killProcessOnCancel,
|
||||
Channel<string> redirectStandardIn,
|
||||
bool inheritConsoleHandler,
|
||||
bool keepStandardInOpen,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<int> ExecuteAsync(
|
||||
string workingDirectory,
|
||||
string fileName,
|
||||
string arguments,
|
||||
IDictionary<string, string> environment,
|
||||
bool requireExitCodeZero,
|
||||
Encoding outputEncoding,
|
||||
bool killProcessOnCancel,
|
||||
Channel<string> redirectStandardIn,
|
||||
bool inheritConsoleHandler,
|
||||
bool keepStandardInOpen,
|
||||
bool highPriorityProcess,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
// The implementation of the process invoker does not hook up DataReceivedEvent and ErrorReceivedEvent of Process,
|
||||
// instead, we read both STDOUT and STDERR stream manually on seperate thread.
|
||||
// The reason is we find a huge perf issue about process STDOUT/STDERR with those events.
|
||||
//
|
||||
// Missing functionalities:
|
||||
// 1. Cancel/Kill process tree
|
||||
// 2. Make sure STDOUT and STDERR not process out of order
|
||||
public sealed class ProcessInvokerWrapper : RunnerService, IProcessInvoker
|
||||
{
|
||||
private ProcessInvoker _invoker;
|
||||
|
||||
public override void Initialize(IHostContext hostContext)
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
_invoker = new ProcessInvoker(Trace);
|
||||
}
|
||||
|
||||
public event EventHandler<ProcessDataReceivedEventArgs> OutputDataReceived;
|
||||
public event EventHandler<ProcessDataReceivedEventArgs> ErrorDataReceived;
|
||||
|
||||
public Task<int> ExecuteAsync(
|
||||
string workingDirectory,
|
||||
string fileName,
|
||||
string arguments,
|
||||
IDictionary<string, string> environment,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return ExecuteAsync(
|
||||
workingDirectory: workingDirectory,
|
||||
fileName: fileName,
|
||||
arguments: arguments,
|
||||
environment: environment,
|
||||
requireExitCodeZero: false,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public Task<int> ExecuteAsync(
|
||||
string workingDirectory,
|
||||
string fileName,
|
||||
string arguments,
|
||||
IDictionary<string, string> environment,
|
||||
bool requireExitCodeZero,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return ExecuteAsync(
|
||||
workingDirectory: workingDirectory,
|
||||
fileName: fileName,
|
||||
arguments: arguments,
|
||||
environment: environment,
|
||||
requireExitCodeZero: requireExitCodeZero,
|
||||
outputEncoding: null,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public Task<int> ExecuteAsync(
|
||||
string workingDirectory,
|
||||
string fileName,
|
||||
string arguments,
|
||||
IDictionary<string, string> environment,
|
||||
bool requireExitCodeZero,
|
||||
Encoding outputEncoding,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return ExecuteAsync(
|
||||
workingDirectory: workingDirectory,
|
||||
fileName: fileName,
|
||||
arguments: arguments,
|
||||
environment: environment,
|
||||
requireExitCodeZero: requireExitCodeZero,
|
||||
outputEncoding: outputEncoding,
|
||||
killProcessOnCancel: false,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public Task<int> ExecuteAsync(
|
||||
string workingDirectory,
|
||||
string fileName,
|
||||
string arguments,
|
||||
IDictionary<string, string> environment,
|
||||
bool requireExitCodeZero,
|
||||
Encoding outputEncoding,
|
||||
bool killProcessOnCancel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return ExecuteAsync(
|
||||
workingDirectory: workingDirectory,
|
||||
fileName: fileName,
|
||||
arguments: arguments,
|
||||
environment: environment,
|
||||
requireExitCodeZero: requireExitCodeZero,
|
||||
outputEncoding: outputEncoding,
|
||||
killProcessOnCancel: killProcessOnCancel,
|
||||
redirectStandardIn: null,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public Task<int> ExecuteAsync(
|
||||
string workingDirectory,
|
||||
string fileName,
|
||||
string arguments,
|
||||
IDictionary<string, string> environment,
|
||||
bool requireExitCodeZero,
|
||||
Encoding outputEncoding,
|
||||
bool killProcessOnCancel,
|
||||
Channel<string> redirectStandardIn,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return ExecuteAsync(
|
||||
workingDirectory: workingDirectory,
|
||||
fileName: fileName,
|
||||
arguments: arguments,
|
||||
environment: environment,
|
||||
requireExitCodeZero: requireExitCodeZero,
|
||||
outputEncoding: outputEncoding,
|
||||
killProcessOnCancel: killProcessOnCancel,
|
||||
redirectStandardIn: redirectStandardIn,
|
||||
inheritConsoleHandler: false,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
public Task<int> ExecuteAsync(
|
||||
string workingDirectory,
|
||||
string fileName,
|
||||
string arguments,
|
||||
IDictionary<string, string> environment,
|
||||
bool requireExitCodeZero,
|
||||
Encoding outputEncoding,
|
||||
bool killProcessOnCancel,
|
||||
Channel<string> redirectStandardIn,
|
||||
bool inheritConsoleHandler,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return ExecuteAsync(
|
||||
workingDirectory: workingDirectory,
|
||||
fileName: fileName,
|
||||
arguments: arguments,
|
||||
environment: environment,
|
||||
requireExitCodeZero: requireExitCodeZero,
|
||||
outputEncoding: outputEncoding,
|
||||
killProcessOnCancel: killProcessOnCancel,
|
||||
redirectStandardIn: redirectStandardIn,
|
||||
inheritConsoleHandler: inheritConsoleHandler,
|
||||
keepStandardInOpen: false,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
public Task<int> ExecuteAsync(
|
||||
string workingDirectory,
|
||||
string fileName,
|
||||
string arguments,
|
||||
IDictionary<string, string> environment,
|
||||
bool requireExitCodeZero,
|
||||
Encoding outputEncoding,
|
||||
bool killProcessOnCancel,
|
||||
Channel<string> redirectStandardIn,
|
||||
bool inheritConsoleHandler,
|
||||
bool keepStandardInOpen,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return ExecuteAsync(
|
||||
workingDirectory: workingDirectory,
|
||||
fileName: fileName,
|
||||
arguments: arguments,
|
||||
environment: environment,
|
||||
requireExitCodeZero: requireExitCodeZero,
|
||||
outputEncoding: outputEncoding,
|
||||
killProcessOnCancel: killProcessOnCancel,
|
||||
redirectStandardIn: redirectStandardIn,
|
||||
inheritConsoleHandler: inheritConsoleHandler,
|
||||
keepStandardInOpen: keepStandardInOpen,
|
||||
highPriorityProcess: false,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<int> ExecuteAsync(
|
||||
string workingDirectory,
|
||||
string fileName,
|
||||
string arguments,
|
||||
IDictionary<string, string> environment,
|
||||
bool requireExitCodeZero,
|
||||
Encoding outputEncoding,
|
||||
bool killProcessOnCancel,
|
||||
Channel<string> redirectStandardIn,
|
||||
bool inheritConsoleHandler,
|
||||
bool keepStandardInOpen,
|
||||
bool highPriorityProcess,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_invoker.ErrorDataReceived += this.ErrorDataReceived;
|
||||
_invoker.OutputDataReceived += this.OutputDataReceived;
|
||||
return await _invoker.ExecuteAsync(
|
||||
workingDirectory,
|
||||
fileName,
|
||||
arguments,
|
||||
environment,
|
||||
requireExitCodeZero,
|
||||
outputEncoding,
|
||||
killProcessOnCancel,
|
||||
redirectStandardIn,
|
||||
inheritConsoleHandler,
|
||||
keepStandardInOpen,
|
||||
highPriorityProcess,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
if (_invoker != null)
|
||||
{
|
||||
_invoker.Dispose();
|
||||
_invoker = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
68
src/Runner.Common/Runner.Common.csproj
Normal file
68
src/Runner.Common/Runner.Common.csproj
Normal file
@@ -0,0 +1,68 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netcoreapp2.2</TargetFramework>
|
||||
<OutputType>Library</OutputType>
|
||||
<RuntimeIdentifiers>win-x64;win-x86;linux-x64;linux-arm;rhel.6-x64;osx-x64</RuntimeIdentifiers>
|
||||
<TargetLatestRuntimePatch>true</TargetLatestRuntimePatch>
|
||||
<AssetTargetFallback>portable-net45+win8</AssetTargetFallback>
|
||||
<NoWarn>NU1701;NU1603</NoWarn>
|
||||
<Version>$(Version)</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Sdk\Sdk.csproj" />
|
||||
<ProjectReference Include="..\Runner.Sdk\Runner.Sdk.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Win32.Registry" Version="4.4.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="4.4.0" />
|
||||
<PackageReference Include="System.Text.Encoding.CodePages" Version="4.4.0" />
|
||||
<PackageReference Include="System.Threading.Channels" Version="4.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
<DebugType>portable</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true' AND '$(PackageRuntime)' == 'win-x64'">
|
||||
<DefineConstants>OS_WINDOWS;X64;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true' AND '$(PackageRuntime)' == 'win-x86'">
|
||||
<DefineConstants>OS_WINDOWS;X86;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'win-x64'">
|
||||
<DefineConstants>OS_WINDOWS;X64;DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'win-x86'">
|
||||
<DefineConstants>OS_WINDOWS;X86;DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::OSX)))' == 'true'">
|
||||
<DefineConstants>OS_OSX;X64;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::OSX)))' == 'true' AND '$(Configuration)' == 'Debug'">
|
||||
<DefineConstants>OS_OSX;DEBUG;X64;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(PackageRuntime)' == 'linux-x64'">
|
||||
<DefineConstants>OS_LINUX;X64;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(PackageRuntime)' == 'rhel.6-x64'">
|
||||
<DefineConstants>OS_LINUX;OS_RHEL6;X64;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(PackageRuntime)' == 'linux-arm'">
|
||||
<DefineConstants>OS_LINUX;ARM;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'linux-x64'">
|
||||
<DefineConstants>OS_LINUX;X64;DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'rhel.6-x64'">
|
||||
<DefineConstants>OS_LINUX;OS_RHEL6;X64;DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'linux-arm'">
|
||||
<DefineConstants>OS_LINUX;ARM;DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
231
src/Runner.Common/RunnerCertificateManager.cs
Normal file
231
src/Runner.Common/RunnerCertificateManager.cs
Normal file
@@ -0,0 +1,231 @@
|
||||
using System;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using System.IO;
|
||||
using System.Runtime.Serialization;
|
||||
using GitHub.Services.Common;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Net.Http;
|
||||
using GitHub.Services.WebApi;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
[ServiceLocator(Default = typeof(RunnerCertificateManager))]
|
||||
public interface IRunnerCertificateManager : IRunnerService
|
||||
{
|
||||
bool SkipServerCertificateValidation { get; }
|
||||
string CACertificateFile { get; }
|
||||
string ClientCertificateFile { get; }
|
||||
string ClientCertificatePrivateKeyFile { get; }
|
||||
string ClientCertificateArchiveFile { get; }
|
||||
string ClientCertificatePassword { get; }
|
||||
IVssClientCertificateManager VssClientCertificateManager { get; }
|
||||
}
|
||||
|
||||
public class RunnerCertificateManager : RunnerService, IRunnerCertificateManager
|
||||
{
|
||||
private RunnerClientCertificateManager _runnerClientCertificateManager = new RunnerClientCertificateManager();
|
||||
|
||||
public bool SkipServerCertificateValidation { private set; get; }
|
||||
public string CACertificateFile { private set; get; }
|
||||
public string ClientCertificateFile { private set; get; }
|
||||
public string ClientCertificatePrivateKeyFile { private set; get; }
|
||||
public string ClientCertificateArchiveFile { private set; get; }
|
||||
public string ClientCertificatePassword { private set; get; }
|
||||
public IVssClientCertificateManager VssClientCertificateManager => _runnerClientCertificateManager;
|
||||
|
||||
public override void Initialize(IHostContext hostContext)
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
LoadCertificateSettings();
|
||||
}
|
||||
|
||||
// This should only be called from config
|
||||
public void SetupCertificate(bool skipCertValidation, string caCert, string clientCert, string clientCertPrivateKey, string clientCertArchive, string clientCertPassword)
|
||||
{
|
||||
Trace.Info("Setup runner certificate setting base on configuration inputs.");
|
||||
|
||||
if (skipCertValidation)
|
||||
{
|
||||
Trace.Info("Ignore SSL server certificate validation error");
|
||||
SkipServerCertificateValidation = true;
|
||||
VssClientHttpRequestSettings.Default.ServerCertificateValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(caCert))
|
||||
{
|
||||
ArgUtil.File(caCert, nameof(caCert));
|
||||
Trace.Info($"Self-Signed CA '{caCert}'");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(clientCert))
|
||||
{
|
||||
ArgUtil.File(clientCert, nameof(clientCert));
|
||||
ArgUtil.File(clientCertPrivateKey, nameof(clientCertPrivateKey));
|
||||
ArgUtil.File(clientCertArchive, nameof(clientCertArchive));
|
||||
|
||||
Trace.Info($"Client cert '{clientCert}'");
|
||||
Trace.Info($"Client cert private key '{clientCertPrivateKey}'");
|
||||
Trace.Info($"Client cert archive '{clientCertArchive}'");
|
||||
}
|
||||
|
||||
CACertificateFile = caCert;
|
||||
ClientCertificateFile = clientCert;
|
||||
ClientCertificatePrivateKeyFile = clientCertPrivateKey;
|
||||
ClientCertificateArchiveFile = clientCertArchive;
|
||||
ClientCertificatePassword = clientCertPassword;
|
||||
|
||||
_runnerClientCertificateManager.AddClientCertificate(ClientCertificateArchiveFile, ClientCertificatePassword);
|
||||
}
|
||||
|
||||
// This should only be called from config
|
||||
public void SaveCertificateSetting()
|
||||
{
|
||||
string certSettingFile = HostContext.GetConfigFile(WellKnownConfigFile.Certificates);
|
||||
IOUtil.DeleteFile(certSettingFile);
|
||||
|
||||
var setting = new RunnerCertificateSetting();
|
||||
if (SkipServerCertificateValidation)
|
||||
{
|
||||
Trace.Info($"Store Skip ServerCertificateValidation setting to '{certSettingFile}'");
|
||||
setting.SkipServerCertValidation = true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(CACertificateFile))
|
||||
{
|
||||
Trace.Info($"Store CA cert setting to '{certSettingFile}'");
|
||||
setting.CACert = CACertificateFile;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(ClientCertificateFile) &&
|
||||
!string.IsNullOrEmpty(ClientCertificatePrivateKeyFile) &&
|
||||
!string.IsNullOrEmpty(ClientCertificateArchiveFile))
|
||||
{
|
||||
Trace.Info($"Store client cert settings to '{certSettingFile}'");
|
||||
|
||||
setting.ClientCert = ClientCertificateFile;
|
||||
setting.ClientCertPrivatekey = ClientCertificatePrivateKeyFile;
|
||||
setting.ClientCertArchive = ClientCertificateArchiveFile;
|
||||
|
||||
if (!string.IsNullOrEmpty(ClientCertificatePassword))
|
||||
{
|
||||
string lookupKey = Guid.NewGuid().ToString("D").ToUpperInvariant();
|
||||
Trace.Info($"Store client cert private key password with lookup key {lookupKey}");
|
||||
|
||||
var credStore = HostContext.GetService<IRunnerCredentialStore>();
|
||||
credStore.Write($"GITHUB_ACTIONS_RUNNER_CLIENT_CERT_PASSWORD_{lookupKey}", "GitHub", ClientCertificatePassword);
|
||||
|
||||
setting.ClientCertPasswordLookupKey = lookupKey;
|
||||
}
|
||||
}
|
||||
|
||||
if (SkipServerCertificateValidation ||
|
||||
!string.IsNullOrEmpty(CACertificateFile) ||
|
||||
!string.IsNullOrEmpty(ClientCertificateFile))
|
||||
{
|
||||
IOUtil.SaveObject(setting, certSettingFile);
|
||||
File.SetAttributes(certSettingFile, File.GetAttributes(certSettingFile) | FileAttributes.Hidden);
|
||||
}
|
||||
}
|
||||
|
||||
// This should only be called from unconfig
|
||||
public void DeleteCertificateSetting()
|
||||
{
|
||||
string certSettingFile = HostContext.GetConfigFile(WellKnownConfigFile.Certificates);
|
||||
if (File.Exists(certSettingFile))
|
||||
{
|
||||
Trace.Info($"Load runner certificate setting from '{certSettingFile}'");
|
||||
var certSetting = IOUtil.LoadObject<RunnerCertificateSetting>(certSettingFile);
|
||||
|
||||
if (certSetting != null && !string.IsNullOrEmpty(certSetting.ClientCertPasswordLookupKey))
|
||||
{
|
||||
Trace.Info("Delete client cert private key password from credential store.");
|
||||
var credStore = HostContext.GetService<IRunnerCredentialStore>();
|
||||
credStore.Delete($"GITHUB_ACTIONS_RUNNER_CLIENT_CERT_PASSWORD_{certSetting.ClientCertPasswordLookupKey}");
|
||||
}
|
||||
|
||||
Trace.Info($"Delete cert setting file: {certSettingFile}");
|
||||
IOUtil.DeleteFile(certSettingFile);
|
||||
}
|
||||
}
|
||||
|
||||
public void LoadCertificateSettings()
|
||||
{
|
||||
string certSettingFile = HostContext.GetConfigFile(WellKnownConfigFile.Certificates);
|
||||
if (File.Exists(certSettingFile))
|
||||
{
|
||||
Trace.Info($"Load runner certificate setting from '{certSettingFile}'");
|
||||
var certSetting = IOUtil.LoadObject<RunnerCertificateSetting>(certSettingFile);
|
||||
ArgUtil.NotNull(certSetting, nameof(RunnerCertificateSetting));
|
||||
|
||||
if (certSetting.SkipServerCertValidation)
|
||||
{
|
||||
Trace.Info("Ignore SSL server certificate validation error");
|
||||
SkipServerCertificateValidation = true;
|
||||
VssClientHttpRequestSettings.Default.ServerCertificateValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(certSetting.CACert))
|
||||
{
|
||||
// make sure all settings file exist
|
||||
ArgUtil.File(certSetting.CACert, nameof(certSetting.CACert));
|
||||
Trace.Info($"CA '{certSetting.CACert}'");
|
||||
CACertificateFile = certSetting.CACert;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(certSetting.ClientCert))
|
||||
{
|
||||
// make sure all settings file exist
|
||||
ArgUtil.File(certSetting.ClientCert, nameof(certSetting.ClientCert));
|
||||
ArgUtil.File(certSetting.ClientCertPrivatekey, nameof(certSetting.ClientCertPrivatekey));
|
||||
ArgUtil.File(certSetting.ClientCertArchive, nameof(certSetting.ClientCertArchive));
|
||||
|
||||
Trace.Info($"Client cert '{certSetting.ClientCert}'");
|
||||
Trace.Info($"Client cert private key '{certSetting.ClientCertPrivatekey}'");
|
||||
Trace.Info($"Client cert archive '{certSetting.ClientCertArchive}'");
|
||||
|
||||
ClientCertificateFile = certSetting.ClientCert;
|
||||
ClientCertificatePrivateKeyFile = certSetting.ClientCertPrivatekey;
|
||||
ClientCertificateArchiveFile = certSetting.ClientCertArchive;
|
||||
|
||||
if (!string.IsNullOrEmpty(certSetting.ClientCertPasswordLookupKey))
|
||||
{
|
||||
var cerdStore = HostContext.GetService<IRunnerCredentialStore>();
|
||||
ClientCertificatePassword = cerdStore.Read($"GITHUB_ACTIONS_RUNNER_CLIENT_CERT_PASSWORD_{certSetting.ClientCertPasswordLookupKey}").Password;
|
||||
HostContext.SecretMasker.AddValue(ClientCertificatePassword);
|
||||
}
|
||||
|
||||
_runnerClientCertificateManager.AddClientCertificate(ClientCertificateArchiveFile, ClientCertificatePassword);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info("No certificate setting found.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
internal class RunnerCertificateSetting
|
||||
{
|
||||
[DataMember]
|
||||
public bool SkipServerCertValidation { get; set; }
|
||||
|
||||
[DataMember]
|
||||
public string CACert { get; set; }
|
||||
|
||||
[DataMember]
|
||||
public string ClientCert { get; set; }
|
||||
|
||||
[DataMember]
|
||||
public string ClientCertPrivatekey { get; set; }
|
||||
|
||||
[DataMember]
|
||||
public string ClientCertArchive { get; set; }
|
||||
|
||||
[DataMember]
|
||||
public string ClientCertPasswordLookupKey { get; set; }
|
||||
}
|
||||
}
|
||||
948
src/Runner.Common/RunnerCredentialStore.cs
Normal file
948
src/Runner.Common/RunnerCredentialStore.cs
Normal file
@@ -0,0 +1,948 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using Newtonsoft.Json;
|
||||
using System.IO;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Security.Cryptography;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
// The purpose of this class is to store user's credential during runner configuration and retrive the credential back at runtime.
|
||||
#if OS_WINDOWS
|
||||
[ServiceLocator(Default = typeof(WindowsRunnerCredentialStore))]
|
||||
#elif OS_OSX
|
||||
[ServiceLocator(Default = typeof(MacOSRunnerCredentialStore))]
|
||||
#else
|
||||
[ServiceLocator(Default = typeof(LinuxRunnerCredentialStore))]
|
||||
#endif
|
||||
public interface IRunnerCredentialStore : IRunnerService
|
||||
{
|
||||
NetworkCredential Write(string target, string username, string password);
|
||||
|
||||
// throw exception when target not found from cred store
|
||||
NetworkCredential Read(string target);
|
||||
|
||||
// throw exception when target not found from cred store
|
||||
void Delete(string target);
|
||||
}
|
||||
|
||||
#if OS_WINDOWS
|
||||
// Windows credential store is per user.
|
||||
// This is a limitation for user configure the runner run as windows service, when user's current login account is different with the service run as account.
|
||||
// Ex: I login the box as domain\admin, configure the runner as windows service and run as domian\buildserver
|
||||
// domain\buildserver won't read the stored credential from domain\admin's windows credential store.
|
||||
// To workaround this limitation.
|
||||
// Anytime we try to save a credential:
|
||||
// 1. store it into current user's windows credential store
|
||||
// 2. use DP-API do a machine level encrypt and store the encrypted content on disk.
|
||||
// At the first time we try to read the credential:
|
||||
// 1. read from current user's windows credential store, delete the DP-API encrypted backup content on disk if the windows credential store read succeed.
|
||||
// 2. if credential not found in current user's windows credential store, read from the DP-API encrypted backup content on disk,
|
||||
// write the credential back the current user's windows credential store and delete the backup on disk.
|
||||
public sealed class WindowsRunnerCredentialStore : RunnerService, IRunnerCredentialStore
|
||||
{
|
||||
private string _credStoreFile;
|
||||
private Dictionary<string, string> _credStore;
|
||||
|
||||
public override void Initialize(IHostContext hostContext)
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
|
||||
_credStoreFile = hostContext.GetConfigFile(WellKnownConfigFile.CredentialStore);
|
||||
if (File.Exists(_credStoreFile))
|
||||
{
|
||||
_credStore = IOUtil.LoadObject<Dictionary<string, string>>(_credStoreFile);
|
||||
}
|
||||
else
|
||||
{
|
||||
_credStore = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
public NetworkCredential Write(string target, string username, string password)
|
||||
{
|
||||
Trace.Entering();
|
||||
ArgUtil.NotNullOrEmpty(target, nameof(target));
|
||||
ArgUtil.NotNullOrEmpty(username, nameof(username));
|
||||
ArgUtil.NotNullOrEmpty(password, nameof(password));
|
||||
|
||||
// save to .credential_store file first, then Windows credential store
|
||||
string usernameBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(username));
|
||||
string passwordBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(password));
|
||||
|
||||
// Base64Username:Base64Password -> DP-API machine level encrypt -> Base64Encoding
|
||||
string encryptedUsernamePassword = Convert.ToBase64String(ProtectedData.Protect(Encoding.UTF8.GetBytes($"{usernameBase64}:{passwordBase64}"), null, DataProtectionScope.LocalMachine));
|
||||
Trace.Info($"Credentials for '{target}' written to credential store file.");
|
||||
_credStore[target] = encryptedUsernamePassword;
|
||||
|
||||
// save to .credential_store file
|
||||
SyncCredentialStoreFile();
|
||||
|
||||
// save to Windows Credential Store
|
||||
return WriteInternal(target, username, password);
|
||||
}
|
||||
|
||||
public NetworkCredential Read(string target)
|
||||
{
|
||||
Trace.Entering();
|
||||
ArgUtil.NotNullOrEmpty(target, nameof(target));
|
||||
IntPtr credPtr = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
if (CredRead(target, CredentialType.Generic, 0, out credPtr))
|
||||
{
|
||||
Credential credStruct = (Credential)Marshal.PtrToStructure(credPtr, typeof(Credential));
|
||||
int passwordLength = (int)credStruct.CredentialBlobSize;
|
||||
string password = passwordLength > 0 ? Marshal.PtrToStringUni(credStruct.CredentialBlob, passwordLength / sizeof(char)) : String.Empty;
|
||||
string username = Marshal.PtrToStringUni(credStruct.UserName);
|
||||
Trace.Info($"Credentials for '{target}' read from windows credential store.");
|
||||
|
||||
// delete from .credential_store file since we are able to read it from windows credential store
|
||||
if (_credStore.Remove(target))
|
||||
{
|
||||
Trace.Info($"Delete credentials for '{target}' from credential store file.");
|
||||
SyncCredentialStoreFile();
|
||||
}
|
||||
|
||||
return new NetworkCredential(username, password);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Can't read from Windows Credential Store, fail back to .credential_store file
|
||||
if (_credStore.ContainsKey(target) && !string.IsNullOrEmpty(_credStore[target]))
|
||||
{
|
||||
Trace.Info($"Credentials for '{target}' read from credential store file.");
|
||||
|
||||
// Base64Decode -> DP-API machine level decrypt -> Base64Username:Base64Password -> Base64Decode
|
||||
string decryptedUsernamePassword = Encoding.UTF8.GetString(ProtectedData.Unprotect(Convert.FromBase64String(_credStore[target]), null, DataProtectionScope.LocalMachine));
|
||||
|
||||
string[] credential = decryptedUsernamePassword.Split(':');
|
||||
if (credential.Length == 2 && !string.IsNullOrEmpty(credential[0]) && !string.IsNullOrEmpty(credential[1]))
|
||||
{
|
||||
string username = Encoding.UTF8.GetString(Convert.FromBase64String(credential[0]));
|
||||
string password = Encoding.UTF8.GetString(Convert.FromBase64String(credential[1]));
|
||||
|
||||
// store back to windows credential store for current user
|
||||
NetworkCredential creds = WriteInternal(target, username, password);
|
||||
|
||||
// delete from .credential_store file since we are able to write the credential to windows credential store for current user.
|
||||
if (_credStore.Remove(target))
|
||||
{
|
||||
Trace.Info($"Delete credentials for '{target}' from credential store file.");
|
||||
SyncCredentialStoreFile();
|
||||
}
|
||||
|
||||
return creds;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(decryptedUsernamePassword));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error(), $"CredRead throw an error for '{target}'");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (credPtr != IntPtr.Zero)
|
||||
{
|
||||
CredFree(credPtr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Delete(string target)
|
||||
{
|
||||
Trace.Entering();
|
||||
ArgUtil.NotNullOrEmpty(target, nameof(target));
|
||||
|
||||
// remove from .credential_store file
|
||||
if (_credStore.Remove(target))
|
||||
{
|
||||
Trace.Info($"Delete credentials for '{target}' from credential store file.");
|
||||
SyncCredentialStoreFile();
|
||||
}
|
||||
|
||||
// remove from windows credential store
|
||||
if (!CredDelete(target, CredentialType.Generic, 0))
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error(), $"Failed to delete credentials for {target}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info($"Credentials for '{target}' deleted from windows credential store.");
|
||||
}
|
||||
}
|
||||
|
||||
private NetworkCredential WriteInternal(string target, string username, string password)
|
||||
{
|
||||
// save to Windows Credential Store
|
||||
Credential credential = new Credential()
|
||||
{
|
||||
Type = CredentialType.Generic,
|
||||
Persist = (UInt32)CredentialPersist.LocalMachine,
|
||||
TargetName = Marshal.StringToCoTaskMemUni(target),
|
||||
UserName = Marshal.StringToCoTaskMemUni(username),
|
||||
CredentialBlob = Marshal.StringToCoTaskMemUni(password),
|
||||
CredentialBlobSize = (UInt32)Encoding.Unicode.GetByteCount(password),
|
||||
AttributeCount = 0,
|
||||
Comment = IntPtr.Zero,
|
||||
Attributes = IntPtr.Zero,
|
||||
TargetAlias = IntPtr.Zero
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
if (CredWrite(ref credential, 0))
|
||||
{
|
||||
Trace.Info($"Credentials for '{target}' written to windows credential store.");
|
||||
return new NetworkCredential(username, password);
|
||||
}
|
||||
else
|
||||
{
|
||||
int error = Marshal.GetLastWin32Error();
|
||||
throw new Win32Exception(error, "Failed to write credentials");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (credential.CredentialBlob != IntPtr.Zero)
|
||||
{
|
||||
Marshal.FreeCoTaskMem(credential.CredentialBlob);
|
||||
}
|
||||
if (credential.TargetName != IntPtr.Zero)
|
||||
{
|
||||
Marshal.FreeCoTaskMem(credential.TargetName);
|
||||
}
|
||||
if (credential.UserName != IntPtr.Zero)
|
||||
{
|
||||
Marshal.FreeCoTaskMem(credential.UserName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SyncCredentialStoreFile()
|
||||
{
|
||||
Trace.Info("Sync in-memory credential store with credential store file.");
|
||||
|
||||
// delete the cred store file first anyway, since it's a readonly file.
|
||||
IOUtil.DeleteFile(_credStoreFile);
|
||||
|
||||
// delete cred store file when all creds gone
|
||||
if (_credStore.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
IOUtil.SaveObject(_credStore, _credStoreFile);
|
||||
File.SetAttributes(_credStoreFile, File.GetAttributes(_credStoreFile) | FileAttributes.Hidden);
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("Advapi32.dll", EntryPoint = "CredDeleteW", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
internal static extern bool CredDelete(string target, CredentialType type, int reservedFlag);
|
||||
|
||||
[DllImport("Advapi32.dll", EntryPoint = "CredReadW", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
internal static extern bool CredRead(string target, CredentialType type, int reservedFlag, out IntPtr CredentialPtr);
|
||||
|
||||
[DllImport("Advapi32.dll", EntryPoint = "CredWriteW", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
internal static extern bool CredWrite([In] ref Credential userCredential, [In] UInt32 flags);
|
||||
|
||||
[DllImport("Advapi32.dll", EntryPoint = "CredFree", SetLastError = true)]
|
||||
internal static extern bool CredFree([In] IntPtr cred);
|
||||
|
||||
internal enum CredentialPersist : UInt32
|
||||
{
|
||||
Session = 0x01,
|
||||
LocalMachine = 0x02
|
||||
}
|
||||
|
||||
internal enum CredentialType : uint
|
||||
{
|
||||
Generic = 0x01,
|
||||
DomainPassword = 0x02,
|
||||
DomainCertificate = 0x03
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
internal struct Credential
|
||||
{
|
||||
public UInt32 Flags;
|
||||
public CredentialType Type;
|
||||
public IntPtr TargetName;
|
||||
public IntPtr Comment;
|
||||
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
|
||||
public UInt32 CredentialBlobSize;
|
||||
public IntPtr CredentialBlob;
|
||||
public UInt32 Persist;
|
||||
public UInt32 AttributeCount;
|
||||
public IntPtr Attributes;
|
||||
public IntPtr TargetAlias;
|
||||
public IntPtr UserName;
|
||||
}
|
||||
}
|
||||
#elif OS_OSX
|
||||
public sealed class MacOSRunnerCredentialStore : RunnerService, IRunnerCredentialStore
|
||||
{
|
||||
private const string _osxRunnerCredStoreKeyChainName = "_GITHUB_ACTIONS_RUNNER_CREDSTORE_INTERNAL_";
|
||||
|
||||
// Keychain requires a password, but this is not intended to add security
|
||||
private const string _osxRunnerCredStoreKeyChainPassword = "C46F23C36AF94B72B1EAEE32C68670A0";
|
||||
|
||||
private string _securityUtil;
|
||||
|
||||
private string _runnerCredStoreKeyChain;
|
||||
|
||||
public override void Initialize(IHostContext hostContext)
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
|
||||
_securityUtil = WhichUtil.Which("security", true, Trace);
|
||||
|
||||
_runnerCredStoreKeyChain = hostContext.GetConfigFile(WellKnownConfigFile.CredentialStore);
|
||||
|
||||
// Create osx key chain if it doesn't exists.
|
||||
if (!File.Exists(_runnerCredStoreKeyChain))
|
||||
{
|
||||
List<string> securityOut = new List<string>();
|
||||
List<string> securityError = new List<string>();
|
||||
object outputLock = new object();
|
||||
using (var p = HostContext.CreateService<IProcessInvoker>())
|
||||
{
|
||||
p.OutputDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stdout)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(stdout.Data))
|
||||
{
|
||||
lock (outputLock)
|
||||
{
|
||||
securityOut.Add(stdout.Data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
p.ErrorDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stderr)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(stderr.Data))
|
||||
{
|
||||
lock (outputLock)
|
||||
{
|
||||
securityError.Add(stderr.Data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// make sure the 'security' has access to the key so we won't get prompt at runtime.
|
||||
int exitCode = p.ExecuteAsync(workingDirectory: HostContext.GetDirectory(WellKnownDirectory.Root),
|
||||
fileName: _securityUtil,
|
||||
arguments: $"create-keychain -p {_osxRunnerCredStoreKeyChainPassword} \"{_runnerCredStoreKeyChain}\"",
|
||||
environment: null,
|
||||
cancellationToken: CancellationToken.None).GetAwaiter().GetResult();
|
||||
if (exitCode == 0)
|
||||
{
|
||||
Trace.Info($"Successfully create-keychain for {_runnerCredStoreKeyChain}");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (securityOut.Count > 0)
|
||||
{
|
||||
Trace.Error(string.Join(Environment.NewLine, securityOut));
|
||||
}
|
||||
if (securityError.Count > 0)
|
||||
{
|
||||
Trace.Error(string.Join(Environment.NewLine, securityError));
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"'security create-keychain' failed with exit code {exitCode}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Try unlock and lock the keychain, make sure it's still in good stage
|
||||
UnlockKeyChain();
|
||||
LockKeyChain();
|
||||
}
|
||||
}
|
||||
|
||||
public NetworkCredential Write(string target, string username, string password)
|
||||
{
|
||||
Trace.Entering();
|
||||
ArgUtil.NotNullOrEmpty(target, nameof(target));
|
||||
ArgUtil.NotNullOrEmpty(username, nameof(username));
|
||||
ArgUtil.NotNullOrEmpty(password, nameof(password));
|
||||
|
||||
try
|
||||
{
|
||||
UnlockKeyChain();
|
||||
|
||||
// base64encode username + ':' + base64encode password
|
||||
// OSX keychain requires you provide -s target and -a username to retrieve password
|
||||
// So, we will trade both username and password as 'secret' store into keychain
|
||||
string usernameBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(username));
|
||||
string passwordBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(password));
|
||||
string secretForKeyChain = $"{usernameBase64}:{passwordBase64}";
|
||||
|
||||
List<string> securityOut = new List<string>();
|
||||
List<string> securityError = new List<string>();
|
||||
object outputLock = new object();
|
||||
using (var p = HostContext.CreateService<IProcessInvoker>())
|
||||
{
|
||||
p.OutputDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stdout)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(stdout.Data))
|
||||
{
|
||||
lock (outputLock)
|
||||
{
|
||||
securityOut.Add(stdout.Data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
p.ErrorDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stderr)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(stderr.Data))
|
||||
{
|
||||
lock (outputLock)
|
||||
{
|
||||
securityError.Add(stderr.Data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// make sure the 'security' has access to the key so we won't get prompt at runtime.
|
||||
int exitCode = p.ExecuteAsync(workingDirectory: HostContext.GetDirectory(WellKnownDirectory.Root),
|
||||
fileName: _securityUtil,
|
||||
arguments: $"add-generic-password -s {target} -a GITHUBACTIONSRUNNER -w {secretForKeyChain} -T \"{_securityUtil}\" \"{_runnerCredStoreKeyChain}\"",
|
||||
environment: null,
|
||||
cancellationToken: CancellationToken.None).GetAwaiter().GetResult();
|
||||
if (exitCode == 0)
|
||||
{
|
||||
Trace.Info($"Successfully add-generic-password for {target} (GITHUBACTIONSRUNNER)");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (securityOut.Count > 0)
|
||||
{
|
||||
Trace.Error(string.Join(Environment.NewLine, securityOut));
|
||||
}
|
||||
if (securityError.Count > 0)
|
||||
{
|
||||
Trace.Error(string.Join(Environment.NewLine, securityError));
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"'security add-generic-password' failed with exit code {exitCode}.");
|
||||
}
|
||||
}
|
||||
|
||||
return new NetworkCredential(username, password);
|
||||
}
|
||||
finally
|
||||
{
|
||||
LockKeyChain();
|
||||
}
|
||||
}
|
||||
|
||||
public NetworkCredential Read(string target)
|
||||
{
|
||||
Trace.Entering();
|
||||
ArgUtil.NotNullOrEmpty(target, nameof(target));
|
||||
|
||||
try
|
||||
{
|
||||
UnlockKeyChain();
|
||||
|
||||
string username;
|
||||
string password;
|
||||
|
||||
List<string> securityOut = new List<string>();
|
||||
List<string> securityError = new List<string>();
|
||||
object outputLock = new object();
|
||||
using (var p = HostContext.CreateService<IProcessInvoker>())
|
||||
{
|
||||
p.OutputDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stdout)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(stdout.Data))
|
||||
{
|
||||
lock (outputLock)
|
||||
{
|
||||
securityOut.Add(stdout.Data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
p.ErrorDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stderr)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(stderr.Data))
|
||||
{
|
||||
lock (outputLock)
|
||||
{
|
||||
securityError.Add(stderr.Data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
int exitCode = p.ExecuteAsync(workingDirectory: HostContext.GetDirectory(WellKnownDirectory.Root),
|
||||
fileName: _securityUtil,
|
||||
arguments: $"find-generic-password -s {target} -a GITHUBACTIONSRUNNER -w -g \"{_runnerCredStoreKeyChain}\"",
|
||||
environment: null,
|
||||
cancellationToken: CancellationToken.None).GetAwaiter().GetResult();
|
||||
if (exitCode == 0)
|
||||
{
|
||||
string keyChainSecret = securityOut.First();
|
||||
string[] secrets = keyChainSecret.Split(':');
|
||||
if (secrets.Length == 2 && !string.IsNullOrEmpty(secrets[0]) && !string.IsNullOrEmpty(secrets[1]))
|
||||
{
|
||||
Trace.Info($"Successfully find-generic-password for {target} (GITHUBACTIONSRUNNER)");
|
||||
username = Encoding.UTF8.GetString(Convert.FromBase64String(secrets[0]));
|
||||
password = Encoding.UTF8.GetString(Convert.FromBase64String(secrets[1]));
|
||||
return new NetworkCredential(username, password);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(keyChainSecret));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (securityOut.Count > 0)
|
||||
{
|
||||
Trace.Error(string.Join(Environment.NewLine, securityOut));
|
||||
}
|
||||
if (securityError.Count > 0)
|
||||
{
|
||||
Trace.Error(string.Join(Environment.NewLine, securityError));
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"'security find-generic-password' failed with exit code {exitCode}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
LockKeyChain();
|
||||
}
|
||||
}
|
||||
|
||||
public void Delete(string target)
|
||||
{
|
||||
Trace.Entering();
|
||||
ArgUtil.NotNullOrEmpty(target, nameof(target));
|
||||
|
||||
try
|
||||
{
|
||||
UnlockKeyChain();
|
||||
|
||||
List<string> securityOut = new List<string>();
|
||||
List<string> securityError = new List<string>();
|
||||
object outputLock = new object();
|
||||
|
||||
using (var p = HostContext.CreateService<IProcessInvoker>())
|
||||
{
|
||||
p.OutputDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stdout)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(stdout.Data))
|
||||
{
|
||||
lock (outputLock)
|
||||
{
|
||||
securityOut.Add(stdout.Data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
p.ErrorDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stderr)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(stderr.Data))
|
||||
{
|
||||
lock (outputLock)
|
||||
{
|
||||
securityError.Add(stderr.Data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
int exitCode = p.ExecuteAsync(workingDirectory: HostContext.GetDirectory(WellKnownDirectory.Root),
|
||||
fileName: _securityUtil,
|
||||
arguments: $"delete-generic-password -s {target} -a GITHUBACTIONSRUNNER \"{_runnerCredStoreKeyChain}\"",
|
||||
environment: null,
|
||||
cancellationToken: CancellationToken.None).GetAwaiter().GetResult();
|
||||
if (exitCode == 0)
|
||||
{
|
||||
Trace.Info($"Successfully delete-generic-password for {target} (GITHUBACTIONSRUNNER)");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (securityOut.Count > 0)
|
||||
{
|
||||
Trace.Error(string.Join(Environment.NewLine, securityOut));
|
||||
}
|
||||
if (securityError.Count > 0)
|
||||
{
|
||||
Trace.Error(string.Join(Environment.NewLine, securityError));
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"'security delete-generic-password' failed with exit code {exitCode}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
LockKeyChain();
|
||||
}
|
||||
}
|
||||
|
||||
private void UnlockKeyChain()
|
||||
{
|
||||
Trace.Entering();
|
||||
ArgUtil.NotNullOrEmpty(_securityUtil, nameof(_securityUtil));
|
||||
ArgUtil.NotNullOrEmpty(_runnerCredStoreKeyChain, nameof(_runnerCredStoreKeyChain));
|
||||
|
||||
List<string> securityOut = new List<string>();
|
||||
List<string> securityError = new List<string>();
|
||||
object outputLock = new object();
|
||||
using (var p = HostContext.CreateService<IProcessInvoker>())
|
||||
{
|
||||
p.OutputDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stdout)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(stdout.Data))
|
||||
{
|
||||
lock (outputLock)
|
||||
{
|
||||
securityOut.Add(stdout.Data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
p.ErrorDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stderr)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(stderr.Data))
|
||||
{
|
||||
lock (outputLock)
|
||||
{
|
||||
securityError.Add(stderr.Data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// make sure the 'security' has access to the key so we won't get prompt at runtime.
|
||||
int exitCode = p.ExecuteAsync(workingDirectory: HostContext.GetDirectory(WellKnownDirectory.Root),
|
||||
fileName: _securityUtil,
|
||||
arguments: $"unlock-keychain -p {_osxRunnerCredStoreKeyChainPassword} \"{_runnerCredStoreKeyChain}\"",
|
||||
environment: null,
|
||||
cancellationToken: CancellationToken.None).GetAwaiter().GetResult();
|
||||
if (exitCode == 0)
|
||||
{
|
||||
Trace.Info($"Successfully unlock-keychain for {_runnerCredStoreKeyChain}");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (securityOut.Count > 0)
|
||||
{
|
||||
Trace.Error(string.Join(Environment.NewLine, securityOut));
|
||||
}
|
||||
if (securityError.Count > 0)
|
||||
{
|
||||
Trace.Error(string.Join(Environment.NewLine, securityError));
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"'security unlock-keychain' failed with exit code {exitCode}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void LockKeyChain()
|
||||
{
|
||||
Trace.Entering();
|
||||
ArgUtil.NotNullOrEmpty(_securityUtil, nameof(_securityUtil));
|
||||
ArgUtil.NotNullOrEmpty(_runnerCredStoreKeyChain, nameof(_runnerCredStoreKeyChain));
|
||||
|
||||
List<string> securityOut = new List<string>();
|
||||
List<string> securityError = new List<string>();
|
||||
object outputLock = new object();
|
||||
using (var p = HostContext.CreateService<IProcessInvoker>())
|
||||
{
|
||||
p.OutputDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stdout)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(stdout.Data))
|
||||
{
|
||||
lock (outputLock)
|
||||
{
|
||||
securityOut.Add(stdout.Data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
p.ErrorDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stderr)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(stderr.Data))
|
||||
{
|
||||
lock (outputLock)
|
||||
{
|
||||
securityError.Add(stderr.Data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// make sure the 'security' has access to the key so we won't get prompt at runtime.
|
||||
int exitCode = p.ExecuteAsync(workingDirectory: HostContext.GetDirectory(WellKnownDirectory.Root),
|
||||
fileName: _securityUtil,
|
||||
arguments: $"lock-keychain \"{_runnerCredStoreKeyChain}\"",
|
||||
environment: null,
|
||||
cancellationToken: CancellationToken.None).GetAwaiter().GetResult();
|
||||
if (exitCode == 0)
|
||||
{
|
||||
Trace.Info($"Successfully lock-keychain for {_runnerCredStoreKeyChain}");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (securityOut.Count > 0)
|
||||
{
|
||||
Trace.Error(string.Join(Environment.NewLine, securityOut));
|
||||
}
|
||||
if (securityError.Count > 0)
|
||||
{
|
||||
Trace.Error(string.Join(Environment.NewLine, securityError));
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"'security lock-keychain' failed with exit code {exitCode}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
public sealed class LinuxRunnerCredentialStore : RunnerService, IRunnerCredentialStore
|
||||
{
|
||||
// 'ghrunner' 128 bits iv
|
||||
private readonly byte[] iv = new byte[] { 0x67, 0x68, 0x72, 0x75, 0x6e, 0x6e, 0x65, 0x72, 0x67, 0x68, 0x72, 0x75, 0x6e, 0x6e, 0x65, 0x72 };
|
||||
|
||||
// 256 bits key
|
||||
private byte[] _symmetricKey;
|
||||
private string _credStoreFile;
|
||||
private Dictionary<string, Credential> _credStore;
|
||||
|
||||
public override void Initialize(IHostContext hostContext)
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
|
||||
_credStoreFile = hostContext.GetConfigFile(WellKnownConfigFile.CredentialStore);
|
||||
if (File.Exists(_credStoreFile))
|
||||
{
|
||||
_credStore = IOUtil.LoadObject<Dictionary<string, Credential>>(_credStoreFile);
|
||||
}
|
||||
else
|
||||
{
|
||||
_credStore = new Dictionary<string, Credential>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
string machineId;
|
||||
if (File.Exists("/etc/machine-id"))
|
||||
{
|
||||
// try use machine-id as encryption key
|
||||
// this helps avoid accidental information disclosure, but isn't intended for true security
|
||||
machineId = File.ReadAllLines("/etc/machine-id").FirstOrDefault();
|
||||
Trace.Info($"machine-id length {machineId?.Length ?? 0}.");
|
||||
|
||||
// machine-id doesn't exist or machine-id is not 256 bits
|
||||
if (string.IsNullOrEmpty(machineId) || machineId.Length != 32)
|
||||
{
|
||||
Trace.Warning("Can not get valid machine id from '/etc/machine-id'.");
|
||||
machineId = "43e7fe5da07740cf914b90f1dac51c2a";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// /etc/machine-id not exist
|
||||
Trace.Warning("/etc/machine-id doesn't exist.");
|
||||
machineId = "43e7fe5da07740cf914b90f1dac51c2a";
|
||||
}
|
||||
|
||||
List<byte> keyBuilder = new List<byte>();
|
||||
foreach (var c in machineId)
|
||||
{
|
||||
keyBuilder.Add(Convert.ToByte(c));
|
||||
}
|
||||
|
||||
_symmetricKey = keyBuilder.ToArray();
|
||||
}
|
||||
|
||||
public NetworkCredential Write(string target, string username, string password)
|
||||
{
|
||||
Trace.Entering();
|
||||
ArgUtil.NotNullOrEmpty(target, nameof(target));
|
||||
ArgUtil.NotNullOrEmpty(username, nameof(username));
|
||||
ArgUtil.NotNullOrEmpty(password, nameof(password));
|
||||
|
||||
Trace.Info($"Store credential for '{target}' to cred store.");
|
||||
Credential cred = new Credential(username, Encrypt(password));
|
||||
_credStore[target] = cred;
|
||||
SyncCredentialStoreFile();
|
||||
return new NetworkCredential(username, password);
|
||||
}
|
||||
|
||||
public NetworkCredential Read(string target)
|
||||
{
|
||||
Trace.Entering();
|
||||
ArgUtil.NotNullOrEmpty(target, nameof(target));
|
||||
Trace.Info($"Read credential for '{target}' from cred store.");
|
||||
if (_credStore.ContainsKey(target))
|
||||
{
|
||||
Credential cred = _credStore[target];
|
||||
if (!string.IsNullOrEmpty(cred.UserName) && !string.IsNullOrEmpty(cred.Password))
|
||||
{
|
||||
Trace.Info($"Return credential for '{target}' from cred store.");
|
||||
return new NetworkCredential(cred.UserName, Decrypt(cred.Password));
|
||||
}
|
||||
}
|
||||
|
||||
throw new KeyNotFoundException(target);
|
||||
}
|
||||
|
||||
public void Delete(string target)
|
||||
{
|
||||
Trace.Entering();
|
||||
ArgUtil.NotNullOrEmpty(target, nameof(target));
|
||||
|
||||
if (_credStore.ContainsKey(target))
|
||||
{
|
||||
Trace.Info($"Delete credential for '{target}' from cred store.");
|
||||
_credStore.Remove(target);
|
||||
SyncCredentialStoreFile();
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new KeyNotFoundException(target);
|
||||
}
|
||||
}
|
||||
|
||||
private void SyncCredentialStoreFile()
|
||||
{
|
||||
Trace.Entering();
|
||||
Trace.Info("Sync in-memory credential store with credential store file.");
|
||||
|
||||
// delete cred store file when all creds gone
|
||||
if (_credStore.Count == 0)
|
||||
{
|
||||
IOUtil.DeleteFile(_credStoreFile);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!File.Exists(_credStoreFile))
|
||||
{
|
||||
CreateCredentialStoreFile();
|
||||
}
|
||||
|
||||
IOUtil.SaveObject(_credStore, _credStoreFile);
|
||||
}
|
||||
|
||||
private string Encrypt(string secret)
|
||||
{
|
||||
using (Aes aes = Aes.Create())
|
||||
{
|
||||
aes.Key = _symmetricKey;
|
||||
aes.IV = iv;
|
||||
|
||||
// Create a decrytor to perform the stream transform.
|
||||
ICryptoTransform encryptor = aes.CreateEncryptor();
|
||||
|
||||
// Create the streams used for encryption.
|
||||
using (MemoryStream msEncrypt = new MemoryStream())
|
||||
{
|
||||
using (CryptoStream csEncrypt = new CryptoStream(msEncrypt, encryptor, CryptoStreamMode.Write))
|
||||
{
|
||||
using (StreamWriter swEncrypt = new StreamWriter(csEncrypt))
|
||||
{
|
||||
swEncrypt.Write(secret);
|
||||
}
|
||||
|
||||
return Convert.ToBase64String(msEncrypt.ToArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string Decrypt(string encryptedText)
|
||||
{
|
||||
using (Aes aes = Aes.Create())
|
||||
{
|
||||
aes.Key = _symmetricKey;
|
||||
aes.IV = iv;
|
||||
|
||||
// Create a decrytor to perform the stream transform.
|
||||
ICryptoTransform decryptor = aes.CreateDecryptor();
|
||||
|
||||
// Create the streams used for decryption.
|
||||
using (MemoryStream msDecrypt = new MemoryStream(Convert.FromBase64String(encryptedText)))
|
||||
{
|
||||
using (CryptoStream csDecrypt = new CryptoStream(msDecrypt, decryptor, CryptoStreamMode.Read))
|
||||
{
|
||||
using (StreamReader srDecrypt = new StreamReader(csDecrypt))
|
||||
{
|
||||
// Read the decrypted bytes from the decrypting stream and place them in a string.
|
||||
return srDecrypt.ReadToEnd();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CreateCredentialStoreFile()
|
||||
{
|
||||
File.WriteAllText(_credStoreFile, "");
|
||||
File.SetAttributes(_credStoreFile, File.GetAttributes(_credStoreFile) | FileAttributes.Hidden);
|
||||
|
||||
// Try to lock down the .credentials_store file to the owner/group
|
||||
var chmodPath = WhichUtil.Which("chmod", trace: Trace);
|
||||
if (!String.IsNullOrEmpty(chmodPath))
|
||||
{
|
||||
var arguments = $"600 {new FileInfo(_credStoreFile).FullName}";
|
||||
using (var invoker = HostContext.CreateService<IProcessInvoker>())
|
||||
{
|
||||
var exitCode = invoker.ExecuteAsync(HostContext.GetDirectory(WellKnownDirectory.Root), chmodPath, arguments, null, default(CancellationToken)).GetAwaiter().GetResult();
|
||||
if (exitCode == 0)
|
||||
{
|
||||
Trace.Info("Successfully set permissions for credentials store file {0}", _credStoreFile);
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Warning("Unable to successfully set permissions for credentials store file {0}. Received exit code {1} from {2}", _credStoreFile, exitCode, chmodPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Warning("Unable to locate chmod to set permissions for credentials store file {0}.", _credStoreFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
internal class Credential
|
||||
{
|
||||
public Credential()
|
||||
{ }
|
||||
|
||||
public Credential(string userName, string password)
|
||||
{
|
||||
UserName = userName;
|
||||
Password = password;
|
||||
}
|
||||
|
||||
[DataMember(IsRequired = true)]
|
||||
public string UserName { get; set; }
|
||||
|
||||
[DataMember(IsRequired = true)]
|
||||
public string Password { get; set; }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
355
src/Runner.Common/RunnerServer.cs
Normal file
355
src/Runner.Common/RunnerServer.cs
Normal file
@@ -0,0 +1,355 @@
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Services.WebApi;
|
||||
using GitHub.Services.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
public enum RunnerConnectionType
|
||||
{
|
||||
Generic,
|
||||
MessageQueue,
|
||||
JobRequest
|
||||
}
|
||||
|
||||
[ServiceLocator(Default = typeof(RunnerServer))]
|
||||
public interface IRunnerServer : IRunnerService
|
||||
{
|
||||
Task ConnectAsync(Uri serverUrl, VssCredentials credentials);
|
||||
|
||||
Task RefreshConnectionAsync(RunnerConnectionType connectionType, TimeSpan timeout);
|
||||
|
||||
void SetConnectionTimeout(RunnerConnectionType connectionType, TimeSpan timeout);
|
||||
|
||||
// Configuration
|
||||
Task<TaskAgent> AddAgentAsync(Int32 agentPoolId, TaskAgent agent);
|
||||
Task DeleteAgentAsync(int agentPoolId, int agentId);
|
||||
Task<List<TaskAgentPool>> GetAgentPoolsAsync(string agentPoolName = null, TaskAgentPoolType poolType = TaskAgentPoolType.Automation);
|
||||
Task<List<TaskAgent>> GetAgentsAsync(int agentPoolId, string agentName = null);
|
||||
Task<TaskAgent> UpdateAgentAsync(int agentPoolId, TaskAgent agent);
|
||||
|
||||
// messagequeue
|
||||
Task<TaskAgentSession> CreateAgentSessionAsync(Int32 poolId, TaskAgentSession session, CancellationToken cancellationToken);
|
||||
Task DeleteAgentMessageAsync(Int32 poolId, Int64 messageId, Guid sessionId, CancellationToken cancellationToken);
|
||||
Task DeleteAgentSessionAsync(Int32 poolId, Guid sessionId, CancellationToken cancellationToken);
|
||||
Task<TaskAgentMessage> GetAgentMessageAsync(Int32 poolId, Guid sessionId, Int64? lastMessageId, CancellationToken cancellationToken);
|
||||
|
||||
// job request
|
||||
Task<TaskAgentJobRequest> GetAgentRequestAsync(int poolId, long requestId, CancellationToken cancellationToken);
|
||||
Task<TaskAgentJobRequest> RenewAgentRequestAsync(int poolId, long requestId, Guid lockToken, CancellationToken cancellationToken);
|
||||
Task<TaskAgentJobRequest> FinishAgentRequestAsync(int poolId, long requestId, Guid lockToken, DateTime finishTime, TaskResult result, CancellationToken cancellationToken);
|
||||
|
||||
// agent package
|
||||
Task<List<PackageMetadata>> GetPackagesAsync(string packageType, string platform, int top, CancellationToken cancellationToken);
|
||||
Task<PackageMetadata> GetPackageAsync(string packageType, string platform, string version, CancellationToken cancellationToken);
|
||||
|
||||
// agent update
|
||||
Task<TaskAgent> UpdateAgentUpdateStateAsync(int agentPoolId, int agentId, string currentState);
|
||||
}
|
||||
|
||||
public sealed class RunnerServer : RunnerService, IRunnerServer
|
||||
{
|
||||
private bool _hasGenericConnection;
|
||||
private bool _hasMessageConnection;
|
||||
private bool _hasRequestConnection;
|
||||
private VssConnection _genericConnection;
|
||||
private VssConnection _messageConnection;
|
||||
private VssConnection _requestConnection;
|
||||
private TaskAgentHttpClient _genericTaskAgentClient;
|
||||
private TaskAgentHttpClient _messageTaskAgentClient;
|
||||
private TaskAgentHttpClient _requestTaskAgentClient;
|
||||
|
||||
public async Task ConnectAsync(Uri serverUrl, VssCredentials credentials)
|
||||
{
|
||||
if (HostContext.RunMode == RunMode.Local)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var createGenericConnection = EstablishVssConnection(serverUrl, credentials, TimeSpan.FromSeconds(100));
|
||||
var createMessageConnection = EstablishVssConnection(serverUrl, credentials, TimeSpan.FromSeconds(60));
|
||||
var createRequestConnection = EstablishVssConnection(serverUrl, credentials, TimeSpan.FromSeconds(60));
|
||||
|
||||
await Task.WhenAll(createGenericConnection, createMessageConnection, createRequestConnection);
|
||||
|
||||
_genericConnection = await createGenericConnection;
|
||||
_messageConnection = await createMessageConnection;
|
||||
_requestConnection = await createRequestConnection;
|
||||
|
||||
_genericTaskAgentClient = _genericConnection.GetClient<TaskAgentHttpClient>();
|
||||
_messageTaskAgentClient = _messageConnection.GetClient<TaskAgentHttpClient>();
|
||||
_requestTaskAgentClient = _requestConnection.GetClient<TaskAgentHttpClient>();
|
||||
|
||||
_hasGenericConnection = true;
|
||||
_hasMessageConnection = true;
|
||||
_hasRequestConnection = true;
|
||||
}
|
||||
|
||||
// Refresh connection is best effort. it should never throw exception
|
||||
public async Task RefreshConnectionAsync(RunnerConnectionType connectionType, TimeSpan timeout)
|
||||
{
|
||||
Trace.Info($"Refresh {connectionType} VssConnection to get on a different AFD node.");
|
||||
VssConnection newConnection = null;
|
||||
switch (connectionType)
|
||||
{
|
||||
case RunnerConnectionType.MessageQueue:
|
||||
try
|
||||
{
|
||||
_hasMessageConnection = false;
|
||||
newConnection = await EstablishVssConnection(_messageConnection.Uri, _messageConnection.Credentials, timeout);
|
||||
var client = newConnection.GetClient<TaskAgentHttpClient>();
|
||||
_messageConnection = newConnection;
|
||||
_messageTaskAgentClient = client;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Catch exception during reset {connectionType} connection.");
|
||||
Trace.Error(ex);
|
||||
newConnection?.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_hasMessageConnection = true;
|
||||
}
|
||||
break;
|
||||
case RunnerConnectionType.JobRequest:
|
||||
try
|
||||
{
|
||||
_hasRequestConnection = false;
|
||||
newConnection = await EstablishVssConnection(_requestConnection.Uri, _requestConnection.Credentials, timeout);
|
||||
var client = newConnection.GetClient<TaskAgentHttpClient>();
|
||||
_requestConnection = newConnection;
|
||||
_requestTaskAgentClient = client;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Catch exception during reset {connectionType} connection.");
|
||||
Trace.Error(ex);
|
||||
newConnection?.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_hasRequestConnection = true;
|
||||
}
|
||||
break;
|
||||
case RunnerConnectionType.Generic:
|
||||
try
|
||||
{
|
||||
_hasGenericConnection = false;
|
||||
newConnection = await EstablishVssConnection(_genericConnection.Uri, _genericConnection.Credentials, timeout);
|
||||
var client = newConnection.GetClient<TaskAgentHttpClient>();
|
||||
_genericConnection = newConnection;
|
||||
_genericTaskAgentClient = client;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Catch exception during reset {connectionType} connection.");
|
||||
Trace.Error(ex);
|
||||
newConnection?.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_hasGenericConnection = true;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
Trace.Error($"Unexpected connection type: {connectionType}.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetConnectionTimeout(RunnerConnectionType connectionType, TimeSpan timeout)
|
||||
{
|
||||
Trace.Info($"Set {connectionType} VssConnection's timeout to {timeout.TotalSeconds} seconds.");
|
||||
switch (connectionType)
|
||||
{
|
||||
case RunnerConnectionType.JobRequest:
|
||||
_requestConnection.Settings.SendTimeout = timeout;
|
||||
break;
|
||||
case RunnerConnectionType.MessageQueue:
|
||||
_messageConnection.Settings.SendTimeout = timeout;
|
||||
break;
|
||||
case RunnerConnectionType.Generic:
|
||||
_genericConnection.Settings.SendTimeout = timeout;
|
||||
break;
|
||||
default:
|
||||
Trace.Error($"Unexpected connection type: {connectionType}.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<VssConnection> EstablishVssConnection(Uri serverUrl, VssCredentials credentials, TimeSpan timeout)
|
||||
{
|
||||
Trace.Info($"Establish connection with {timeout.TotalSeconds} seconds timeout.");
|
||||
int attemptCount = 5;
|
||||
while (attemptCount-- > 0)
|
||||
{
|
||||
var connection = VssUtil.CreateConnection(serverUrl, credentials, timeout: timeout);
|
||||
try
|
||||
{
|
||||
await connection.ConnectAsync();
|
||||
return connection;
|
||||
}
|
||||
catch (Exception ex) when (attemptCount > 0)
|
||||
{
|
||||
Trace.Info($"Catch exception during connect. {attemptCount} attempt left.");
|
||||
Trace.Error(ex);
|
||||
|
||||
await HostContext.Delay(TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
// should never reach here.
|
||||
throw new InvalidOperationException(nameof(EstablishVssConnection));
|
||||
}
|
||||
|
||||
private void CheckConnection(RunnerConnectionType connectionType)
|
||||
{
|
||||
switch (connectionType)
|
||||
{
|
||||
case RunnerConnectionType.Generic:
|
||||
if (!_hasGenericConnection)
|
||||
{
|
||||
throw new InvalidOperationException($"SetConnection {RunnerConnectionType.Generic}");
|
||||
}
|
||||
break;
|
||||
case RunnerConnectionType.JobRequest:
|
||||
if (!_hasRequestConnection)
|
||||
{
|
||||
throw new InvalidOperationException($"SetConnection {RunnerConnectionType.JobRequest}");
|
||||
}
|
||||
break;
|
||||
case RunnerConnectionType.MessageQueue:
|
||||
if (!_hasMessageConnection)
|
||||
{
|
||||
throw new InvalidOperationException($"SetConnection {RunnerConnectionType.MessageQueue}");
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException(connectionType.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------
|
||||
// Configuration
|
||||
//-----------------------------------------------------------------
|
||||
|
||||
public Task<List<TaskAgentPool>> GetAgentPoolsAsync(string agentPoolName = null, TaskAgentPoolType poolType = TaskAgentPoolType.Automation)
|
||||
{
|
||||
CheckConnection(RunnerConnectionType.Generic);
|
||||
return _genericTaskAgentClient.GetAgentPoolsAsync(agentPoolName, poolType: poolType);
|
||||
}
|
||||
|
||||
public Task<TaskAgent> AddAgentAsync(Int32 agentPoolId, TaskAgent agent)
|
||||
{
|
||||
CheckConnection(RunnerConnectionType.Generic);
|
||||
return _genericTaskAgentClient.AddAgentAsync(agentPoolId, agent);
|
||||
}
|
||||
|
||||
public Task<List<TaskAgent>> GetAgentsAsync(int agentPoolId, string agentName = null)
|
||||
{
|
||||
CheckConnection(RunnerConnectionType.Generic);
|
||||
return _genericTaskAgentClient.GetAgentsAsync(agentPoolId, agentName, false);
|
||||
}
|
||||
|
||||
public Task<TaskAgent> UpdateAgentAsync(int agentPoolId, TaskAgent agent)
|
||||
{
|
||||
CheckConnection(RunnerConnectionType.Generic);
|
||||
return _genericTaskAgentClient.ReplaceAgentAsync(agentPoolId, agent);
|
||||
}
|
||||
|
||||
public Task DeleteAgentAsync(int agentPoolId, int agentId)
|
||||
{
|
||||
CheckConnection(RunnerConnectionType.Generic);
|
||||
return _genericTaskAgentClient.DeleteAgentAsync(agentPoolId, agentId);
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------
|
||||
// MessageQueue
|
||||
//-----------------------------------------------------------------
|
||||
|
||||
public Task<TaskAgentSession> CreateAgentSessionAsync(Int32 poolId, TaskAgentSession session, CancellationToken cancellationToken)
|
||||
{
|
||||
CheckConnection(RunnerConnectionType.MessageQueue);
|
||||
return _messageTaskAgentClient.CreateAgentSessionAsync(poolId, session, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public Task DeleteAgentMessageAsync(Int32 poolId, Int64 messageId, Guid sessionId, CancellationToken cancellationToken)
|
||||
{
|
||||
CheckConnection(RunnerConnectionType.MessageQueue);
|
||||
return _messageTaskAgentClient.DeleteMessageAsync(poolId, messageId, sessionId, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public Task DeleteAgentSessionAsync(Int32 poolId, Guid sessionId, CancellationToken cancellationToken)
|
||||
{
|
||||
CheckConnection(RunnerConnectionType.MessageQueue);
|
||||
return _messageTaskAgentClient.DeleteAgentSessionAsync(poolId, sessionId, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public Task<TaskAgentMessage> GetAgentMessageAsync(Int32 poolId, Guid sessionId, Int64? lastMessageId, CancellationToken cancellationToken)
|
||||
{
|
||||
CheckConnection(RunnerConnectionType.MessageQueue);
|
||||
return _messageTaskAgentClient.GetMessageAsync(poolId, sessionId, lastMessageId, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------
|
||||
// JobRequest
|
||||
//-----------------------------------------------------------------
|
||||
|
||||
public Task<TaskAgentJobRequest> RenewAgentRequestAsync(int poolId, long requestId, Guid lockToken, CancellationToken cancellationToken = default(CancellationToken))
|
||||
{
|
||||
if (HostContext.RunMode == RunMode.Local)
|
||||
{
|
||||
return Task.FromResult(JsonUtility.FromString<TaskAgentJobRequest>("{ lockedUntil: \"" + DateTime.Now.Add(TimeSpan.FromMinutes(5)).ToString("u") + "\" }"));
|
||||
}
|
||||
|
||||
CheckConnection(RunnerConnectionType.JobRequest);
|
||||
return _requestTaskAgentClient.RenewAgentRequestAsync(poolId, requestId, lockToken, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public Task<TaskAgentJobRequest> FinishAgentRequestAsync(int poolId, long requestId, Guid lockToken, DateTime finishTime, TaskResult result, CancellationToken cancellationToken = default(CancellationToken))
|
||||
{
|
||||
if (HostContext.RunMode == RunMode.Local)
|
||||
{
|
||||
return Task.FromResult<TaskAgentJobRequest>(null);
|
||||
}
|
||||
|
||||
CheckConnection(RunnerConnectionType.JobRequest);
|
||||
return _requestTaskAgentClient.FinishAgentRequestAsync(poolId, requestId, lockToken, finishTime, result, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public Task<TaskAgentJobRequest> GetAgentRequestAsync(int poolId, long requestId, CancellationToken cancellationToken = default(CancellationToken))
|
||||
{
|
||||
ArgUtil.Equal(RunMode.Normal, HostContext.RunMode, nameof(HostContext.RunMode));
|
||||
CheckConnection(RunnerConnectionType.JobRequest);
|
||||
return _requestTaskAgentClient.GetAgentRequestAsync(poolId, requestId, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------
|
||||
// Agent Package
|
||||
//-----------------------------------------------------------------
|
||||
public Task<List<PackageMetadata>> GetPackagesAsync(string packageType, string platform, int top, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgUtil.Equal(RunMode.Normal, HostContext.RunMode, nameof(HostContext.RunMode));
|
||||
CheckConnection(RunnerConnectionType.Generic);
|
||||
return _genericTaskAgentClient.GetPackagesAsync(packageType, platform, top, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public Task<PackageMetadata> GetPackageAsync(string packageType, string platform, string version, CancellationToken cancellationToken)
|
||||
{
|
||||
CheckConnection(RunnerConnectionType.Generic);
|
||||
return _genericTaskAgentClient.GetPackageAsync(packageType, platform, version, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public Task<TaskAgent> UpdateAgentUpdateStateAsync(int agentPoolId, int agentId, string currentState)
|
||||
{
|
||||
CheckConnection(RunnerConnectionType.Generic);
|
||||
return _genericTaskAgentClient.UpdateAgentUpdateStateAsync(agentPoolId, agentId, currentState);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
src/Runner.Common/RunnerService.cs
Normal file
39
src/Runner.Common/RunnerService.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
|
||||
[AttributeUsage(AttributeTargets.Interface, Inherited = false, AllowMultiple = false)]
|
||||
public sealed class ServiceLocatorAttribute : Attribute
|
||||
{
|
||||
public static readonly string DefaultPropertyName = "Default";
|
||||
|
||||
public Type Default { get; set; }
|
||||
}
|
||||
|
||||
public interface IRunnerService
|
||||
{
|
||||
void Initialize(IHostContext context);
|
||||
}
|
||||
|
||||
public abstract class RunnerService
|
||||
{
|
||||
protected IHostContext HostContext { get; private set; }
|
||||
protected Tracing Trace { get; private set; }
|
||||
|
||||
public string TraceName
|
||||
{
|
||||
get
|
||||
{
|
||||
return GetType().Name;
|
||||
}
|
||||
}
|
||||
|
||||
public virtual void Initialize(IHostContext hostContext)
|
||||
{
|
||||
HostContext = hostContext;
|
||||
Trace = HostContext.GetTrace(TraceName);
|
||||
Trace.Entering();
|
||||
}
|
||||
}
|
||||
}
|
||||
196
src/Runner.Common/RunnerWebProxy.cs
Normal file
196
src/Runner.Common/RunnerWebProxy.cs
Normal file
@@ -0,0 +1,196 @@
|
||||
using GitHub.Runner.Common.Util;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.IO;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
[ServiceLocator(Default = typeof(RunnerWebProxy))]
|
||||
public interface IRunnerWebProxy : IRunnerService
|
||||
{
|
||||
string ProxyAddress { get; }
|
||||
string ProxyUsername { get; }
|
||||
string ProxyPassword { get; }
|
||||
List<string> ProxyBypassList { get; }
|
||||
IWebProxy WebProxy { get; }
|
||||
}
|
||||
|
||||
public class RunnerWebProxy : RunnerService, IRunnerWebProxy
|
||||
{
|
||||
private readonly List<Regex> _regExBypassList = new List<Regex>();
|
||||
private readonly List<string> _bypassList = new List<string>();
|
||||
private RunnerWebProxyCore _runnerWebProxy = new RunnerWebProxyCore();
|
||||
|
||||
public string ProxyAddress { get; private set; }
|
||||
public string ProxyUsername { get; private set; }
|
||||
public string ProxyPassword { get; private set; }
|
||||
public List<string> ProxyBypassList => _bypassList;
|
||||
public IWebProxy WebProxy => _runnerWebProxy;
|
||||
|
||||
public override void Initialize(IHostContext context)
|
||||
{
|
||||
base.Initialize(context);
|
||||
LoadProxySetting();
|
||||
}
|
||||
|
||||
// This should only be called from config
|
||||
public void SetupProxy(string proxyAddress, string proxyUsername, string proxyPassword)
|
||||
{
|
||||
ArgUtil.NotNullOrEmpty(proxyAddress, nameof(proxyAddress));
|
||||
Trace.Info($"Update proxy setting from '{ProxyAddress ?? string.Empty}' to'{proxyAddress}'");
|
||||
ProxyAddress = proxyAddress;
|
||||
ProxyUsername = proxyUsername;
|
||||
ProxyPassword = proxyPassword;
|
||||
|
||||
if (string.IsNullOrEmpty(ProxyUsername) || string.IsNullOrEmpty(ProxyPassword))
|
||||
{
|
||||
Trace.Info($"Config proxy use DefaultNetworkCredentials.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info($"Config authentication proxy as: {ProxyUsername}.");
|
||||
}
|
||||
|
||||
_runnerWebProxy.Update(ProxyAddress, ProxyUsername, ProxyPassword, ProxyBypassList);
|
||||
}
|
||||
|
||||
// This should only be called from config
|
||||
public void SaveProxySetting()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(ProxyAddress))
|
||||
{
|
||||
string proxyConfigFile = HostContext.GetConfigFile(WellKnownConfigFile.Proxy);
|
||||
IOUtil.DeleteFile(proxyConfigFile);
|
||||
Trace.Info($"Store proxy configuration to '{proxyConfigFile}' for proxy '{ProxyAddress}'");
|
||||
File.WriteAllText(proxyConfigFile, ProxyAddress);
|
||||
File.SetAttributes(proxyConfigFile, File.GetAttributes(proxyConfigFile) | FileAttributes.Hidden);
|
||||
|
||||
string proxyCredFile = HostContext.GetConfigFile(WellKnownConfigFile.ProxyCredentials);
|
||||
IOUtil.DeleteFile(proxyCredFile);
|
||||
if (!string.IsNullOrEmpty(ProxyUsername) && !string.IsNullOrEmpty(ProxyPassword))
|
||||
{
|
||||
string lookupKey = Guid.NewGuid().ToString("D").ToUpperInvariant();
|
||||
Trace.Info($"Store proxy credential lookup key '{lookupKey}' to '{proxyCredFile}'");
|
||||
File.WriteAllText(proxyCredFile, lookupKey);
|
||||
File.SetAttributes(proxyCredFile, File.GetAttributes(proxyCredFile) | FileAttributes.Hidden);
|
||||
|
||||
var credStore = HostContext.GetService<IRunnerCredentialStore>();
|
||||
credStore.Write($"GITHUB_ACTIONS_RUNNER_PROXY_{lookupKey}", ProxyUsername, ProxyPassword);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info("No proxy configuration exist.");
|
||||
}
|
||||
}
|
||||
|
||||
// This should only be called from unconfig
|
||||
public void DeleteProxySetting()
|
||||
{
|
||||
string proxyCredFile = HostContext.GetConfigFile(WellKnownConfigFile.ProxyCredentials);
|
||||
if (File.Exists(proxyCredFile))
|
||||
{
|
||||
Trace.Info("Delete proxy credential from credential store.");
|
||||
string lookupKey = File.ReadAllLines(proxyCredFile).FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(lookupKey))
|
||||
{
|
||||
var credStore = HostContext.GetService<IRunnerCredentialStore>();
|
||||
credStore.Delete($"GITHUB_ACTIONS_RUNNER_PROXY_{lookupKey}");
|
||||
}
|
||||
|
||||
Trace.Info($"Delete .proxycredentials file: {proxyCredFile}");
|
||||
IOUtil.DeleteFile(proxyCredFile);
|
||||
}
|
||||
|
||||
string proxyBypassFile = HostContext.GetConfigFile(WellKnownConfigFile.ProxyBypass);
|
||||
if (File.Exists(proxyBypassFile))
|
||||
{
|
||||
Trace.Info($"Delete .proxybypass file: {proxyBypassFile}");
|
||||
IOUtil.DeleteFile(proxyBypassFile);
|
||||
}
|
||||
|
||||
string proxyConfigFile = HostContext.GetConfigFile(WellKnownConfigFile.Proxy);
|
||||
Trace.Info($"Delete .proxy file: {proxyConfigFile}");
|
||||
IOUtil.DeleteFile(proxyConfigFile);
|
||||
}
|
||||
|
||||
private void LoadProxySetting()
|
||||
{
|
||||
string proxyConfigFile = HostContext.GetConfigFile(WellKnownConfigFile.Proxy);
|
||||
if (File.Exists(proxyConfigFile))
|
||||
{
|
||||
// we expect the first line of the file is the proxy url
|
||||
Trace.Verbose($"Try read proxy setting from file: {proxyConfigFile}.");
|
||||
ProxyAddress = File.ReadLines(proxyConfigFile).FirstOrDefault() ?? string.Empty;
|
||||
ProxyAddress = ProxyAddress.Trim();
|
||||
Trace.Verbose($"{ProxyAddress}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(ProxyAddress) && !Uri.IsWellFormedUriString(ProxyAddress, UriKind.Absolute))
|
||||
{
|
||||
Trace.Info($"The proxy url is not a well formed absolute uri string: {ProxyAddress}.");
|
||||
ProxyAddress = string.Empty;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(ProxyAddress))
|
||||
{
|
||||
Trace.Info($"Config proxy at: {ProxyAddress}.");
|
||||
|
||||
string proxyCredFile = HostContext.GetConfigFile(WellKnownConfigFile.ProxyCredentials);
|
||||
if (File.Exists(proxyCredFile))
|
||||
{
|
||||
string lookupKey = File.ReadAllLines(proxyCredFile).FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(lookupKey))
|
||||
{
|
||||
var credStore = HostContext.GetService<IRunnerCredentialStore>();
|
||||
var proxyCred = credStore.Read($"GITHUB_ACTIONS_RUNNER_PROXY_{lookupKey}");
|
||||
ProxyUsername = proxyCred.UserName;
|
||||
ProxyPassword = proxyCred.Password;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(ProxyPassword))
|
||||
{
|
||||
HostContext.SecretMasker.AddValue(ProxyPassword);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(ProxyUsername) || string.IsNullOrEmpty(ProxyPassword))
|
||||
{
|
||||
Trace.Info($"Config proxy use DefaultNetworkCredentials.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info($"Config authentication proxy as: {ProxyUsername}.");
|
||||
}
|
||||
|
||||
string proxyBypassFile = HostContext.GetConfigFile(WellKnownConfigFile.ProxyBypass);
|
||||
if (File.Exists(proxyBypassFile))
|
||||
{
|
||||
Trace.Verbose($"Try read proxy bypass list from file: {proxyBypassFile}.");
|
||||
foreach (string bypass in File.ReadAllLines(proxyBypassFile))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bypass))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info($"Bypass proxy for: {bypass}.");
|
||||
ProxyBypassList.Add(bypass.Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_runnerWebProxy.Update(ProxyAddress, ProxyUsername, ProxyPassword, ProxyBypassList);
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info($"No proxy setting found.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
96
src/Runner.Common/StreamString.cs
Normal file
96
src/Runner.Common/StreamString.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
// Defines the data protocol for reading and writing strings on our stream
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
public class StreamString
|
||||
{
|
||||
private Stream _ioStream;
|
||||
private UnicodeEncoding streamEncoding;
|
||||
|
||||
public StreamString(Stream ioStream)
|
||||
{
|
||||
_ioStream = ioStream;
|
||||
streamEncoding = new UnicodeEncoding();
|
||||
}
|
||||
|
||||
public async Task<Int32> ReadInt32Async(CancellationToken cancellationToken)
|
||||
{
|
||||
byte[] readBytes = new byte[sizeof(Int32)];
|
||||
int dataread = 0;
|
||||
while (sizeof(Int32) - dataread > 0 && (!cancellationToken.IsCancellationRequested))
|
||||
{
|
||||
Task<int> op = _ioStream.ReadAsync(readBytes, dataread, sizeof(Int32) - dataread, cancellationToken);
|
||||
int newData = 0;
|
||||
newData = await op.WithCancellation(cancellationToken);
|
||||
dataread += newData;
|
||||
if (0 == newData)
|
||||
{
|
||||
await Task.Delay(100, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return BitConverter.ToInt32(readBytes, 0);
|
||||
}
|
||||
|
||||
public async Task WriteInt32Async(Int32 value, CancellationToken cancellationToken)
|
||||
{
|
||||
byte[] int32Bytes = BitConverter.GetBytes(value);
|
||||
Task op = _ioStream.WriteAsync(int32Bytes, 0, sizeof(Int32), cancellationToken);
|
||||
await op.WithCancellation(cancellationToken);
|
||||
}
|
||||
|
||||
const int MaxStringSize = 50 * 1000000;
|
||||
|
||||
public async Task<string> ReadStringAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Int32 len = await ReadInt32Async(cancellationToken);
|
||||
if (len == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
if (len < 0 || len > MaxStringSize)
|
||||
{
|
||||
throw new InvalidDataException();
|
||||
}
|
||||
|
||||
byte[] inBuffer = new byte[len];
|
||||
int dataread = 0;
|
||||
while (len - dataread > 0 && (!cancellationToken.IsCancellationRequested))
|
||||
{
|
||||
Task<int> op = _ioStream.ReadAsync(inBuffer, dataread, len - dataread, cancellationToken);
|
||||
int newData = 0;
|
||||
newData = await op.WithCancellation(cancellationToken);
|
||||
dataread += newData;
|
||||
if (0 == newData)
|
||||
{
|
||||
await Task.Delay(100, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
return streamEncoding.GetString(inBuffer);
|
||||
}
|
||||
|
||||
public async Task WriteStringAsync(string outString, CancellationToken cancellationToken)
|
||||
{
|
||||
byte[] outBuffer = streamEncoding.GetBytes(outString);
|
||||
Int32 len = outBuffer.Length;
|
||||
if (len > MaxStringSize)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
|
||||
await WriteInt32Async(len, cancellationToken);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
Task op = _ioStream.WriteAsync(outBuffer, 0, len, cancellationToken);
|
||||
await op.WithCancellation(cancellationToken);
|
||||
op = _ioStream.FlushAsync(cancellationToken);
|
||||
await op.WithCancellation(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
198
src/Runner.Common/Terminal.cs
Normal file
198
src/Runner.Common/Terminal.cs
Normal file
@@ -0,0 +1,198 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
//
|
||||
// Abstracts away interactions with the terminal which allows:
|
||||
// (1) Console writes also go to trace for better context in the trace
|
||||
// (2) Reroute in tests
|
||||
//
|
||||
[ServiceLocator(Default = typeof(Terminal))]
|
||||
public interface ITerminal : IRunnerService, IDisposable
|
||||
{
|
||||
event EventHandler CancelKeyPress;
|
||||
|
||||
bool Silent { get; set; }
|
||||
string ReadLine();
|
||||
string ReadSecret();
|
||||
void Write(string message, ConsoleColor? colorCode = null);
|
||||
void WriteLine();
|
||||
void WriteLine(string line, ConsoleColor? colorCode = null);
|
||||
void WriteError(Exception ex);
|
||||
void WriteError(string line);
|
||||
void WriteSection(string message);
|
||||
void WriteSuccessMessage(string message);
|
||||
}
|
||||
|
||||
public sealed class Terminal : RunnerService, ITerminal
|
||||
{
|
||||
public bool Silent { get; set; }
|
||||
|
||||
public event EventHandler CancelKeyPress;
|
||||
|
||||
public override void Initialize(IHostContext hostContext)
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
Console.CancelKeyPress += Console_CancelKeyPress;
|
||||
}
|
||||
|
||||
private void Console_CancelKeyPress(object sender, ConsoleCancelEventArgs e)
|
||||
{
|
||||
e.Cancel = true;
|
||||
CancelKeyPress?.Invoke(this, e);
|
||||
}
|
||||
|
||||
public string ReadLine()
|
||||
{
|
||||
// Read and trace the value.
|
||||
Trace.Info("READ LINE");
|
||||
string value = Console.ReadLine();
|
||||
Trace.Info($"Read value: '{value}'");
|
||||
return value;
|
||||
}
|
||||
|
||||
// TODO: Consider using SecureString.
|
||||
public string ReadSecret()
|
||||
{
|
||||
Trace.Info("READ SECRET");
|
||||
var chars = new List<char>();
|
||||
while (true)
|
||||
{
|
||||
ConsoleKeyInfo key = Console.ReadKey(intercept: true);
|
||||
if (key.Key == ConsoleKey.Enter)
|
||||
{
|
||||
Console.WriteLine();
|
||||
break;
|
||||
}
|
||||
else if (key.Key == ConsoleKey.Backspace)
|
||||
{
|
||||
if (chars.Count > 0)
|
||||
{
|
||||
chars.RemoveAt(chars.Count - 1);
|
||||
Console.Write("\b \b");
|
||||
}
|
||||
}
|
||||
else if (key.KeyChar > 0)
|
||||
{
|
||||
chars.Add(key.KeyChar);
|
||||
Console.Write("*");
|
||||
}
|
||||
}
|
||||
|
||||
// Trace whether a value was entered.
|
||||
string val = new String(chars.ToArray());
|
||||
if (!string.IsNullOrEmpty(val))
|
||||
{
|
||||
HostContext.SecretMasker.AddValue(val);
|
||||
}
|
||||
|
||||
Trace.Info($"Read value: '{val}'");
|
||||
return val;
|
||||
}
|
||||
|
||||
public void Write(string message, ConsoleColor? colorCode = null)
|
||||
{
|
||||
Trace.Info($"WRITE: {message}");
|
||||
if (!Silent)
|
||||
{
|
||||
if(colorCode != null)
|
||||
{
|
||||
Console.ForegroundColor = colorCode.Value;
|
||||
Console.Write(message);
|
||||
Console.ResetColor();
|
||||
}
|
||||
else {
|
||||
Console.Write(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void WriteLine()
|
||||
{
|
||||
WriteLine(string.Empty);
|
||||
}
|
||||
|
||||
// Do not add a format string overload. Terminal messages are user facing and therefore
|
||||
// should be localized. Use the Loc method in the StringUtil class.
|
||||
public void WriteLine(string line, ConsoleColor? colorCode = null)
|
||||
{
|
||||
Trace.Info($"WRITE LINE: {line}");
|
||||
if (!Silent)
|
||||
{
|
||||
if(colorCode != null)
|
||||
{
|
||||
Console.ForegroundColor = colorCode.Value;
|
||||
Console.WriteLine(line);
|
||||
Console.ResetColor();
|
||||
}
|
||||
else {
|
||||
Console.WriteLine(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void WriteError(Exception ex)
|
||||
{
|
||||
Trace.Error("WRITE ERROR (exception):");
|
||||
Trace.Error(ex);
|
||||
if (!Silent)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.Error.WriteLine(ex.Message);
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
|
||||
// Do not add a format string overload. Terminal messages are user facing and therefore
|
||||
// should be localized. Use the Loc method in the StringUtil class.
|
||||
public void WriteError(string line)
|
||||
{
|
||||
Trace.Error($"WRITE ERROR: {line}");
|
||||
if (!Silent)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.Error.WriteLine(line);
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
|
||||
public void WriteSection(string message)
|
||||
{
|
||||
if (!Silent)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.White;
|
||||
Console.WriteLine($"# {message}");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
|
||||
public void WriteSuccessMessage(string message)
|
||||
{
|
||||
if (!Silent)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.Write("√ ");
|
||||
Console.ForegroundColor = ConsoleColor.White;
|
||||
Console.WriteLine(message);
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
|
||||
private void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
Console.CancelKeyPress -= Console_CancelKeyPress;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
65
src/Runner.Common/ThrottlingReportHandler.cs
Normal file
65
src/Runner.Common/ThrottlingReportHandler.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Services.Common.Internal;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
public class ThrottlingEventArgs : EventArgs
|
||||
{
|
||||
public ThrottlingEventArgs(TimeSpan delay, DateTime expiration)
|
||||
{
|
||||
Delay = delay;
|
||||
Expiration = expiration;
|
||||
}
|
||||
|
||||
public TimeSpan Delay { get; private set; }
|
||||
public DateTime Expiration { get; private set; }
|
||||
}
|
||||
|
||||
public interface IThrottlingReporter
|
||||
{
|
||||
void ReportThrottling(TimeSpan delay, DateTime expiration);
|
||||
}
|
||||
|
||||
public class ThrottlingReportHandler : DelegatingHandler
|
||||
{
|
||||
private IThrottlingReporter _throttlingReporter;
|
||||
|
||||
public ThrottlingReportHandler(IThrottlingReporter throttlingReporter)
|
||||
: base()
|
||||
{
|
||||
ArgUtil.NotNull(throttlingReporter, nameof(throttlingReporter));
|
||||
_throttlingReporter = throttlingReporter;
|
||||
}
|
||||
|
||||
protected async override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Call the inner handler.
|
||||
var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Inspect whether response has throttling information
|
||||
IEnumerable<string> vssRequestDelayed = null;
|
||||
IEnumerable<string> vssRequestQuotaReset = null;
|
||||
|
||||
if (response.Headers.TryGetValues(HttpHeaders.VssRateLimitDelay, out vssRequestDelayed) &&
|
||||
response.Headers.TryGetValues(HttpHeaders.VssRateLimitReset, out vssRequestQuotaReset) &&
|
||||
!string.IsNullOrEmpty(vssRequestDelayed.FirstOrDefault()) &&
|
||||
!string.IsNullOrEmpty(vssRequestQuotaReset.FirstOrDefault()))
|
||||
{
|
||||
TimeSpan delay = TimeSpan.FromSeconds(double.Parse(vssRequestDelayed.First()));
|
||||
int expirationEpoch = int.Parse(vssRequestQuotaReset.First());
|
||||
DateTime expiration = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddSeconds(expirationEpoch);
|
||||
|
||||
_throttlingReporter.ReportThrottling(delay, expiration);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
88
src/Runner.Common/TraceManager.cs
Normal file
88
src/Runner.Common/TraceManager.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using GitHub.Runner.Common.Util;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using GitHub.DistributedTask.Logging;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
public interface ITraceManager : IDisposable
|
||||
{
|
||||
SourceSwitch Switch { get; }
|
||||
Tracing this[string name] { get; }
|
||||
}
|
||||
|
||||
public sealed class TraceManager : ITraceManager
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, Tracing> _sources = new ConcurrentDictionary<string, Tracing>(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HostTraceListener _hostTraceListener;
|
||||
private TraceSetting _traceSetting;
|
||||
private ISecretMasker _secretMasker;
|
||||
|
||||
public TraceManager(HostTraceListener traceListener, ISecretMasker secretMasker)
|
||||
: this(traceListener, new TraceSetting(), secretMasker)
|
||||
{
|
||||
}
|
||||
|
||||
public TraceManager(HostTraceListener traceListener, TraceSetting traceSetting, ISecretMasker secretMasker)
|
||||
{
|
||||
// Validate and store params.
|
||||
ArgUtil.NotNull(traceListener, nameof(traceListener));
|
||||
ArgUtil.NotNull(traceSetting, nameof(traceSetting));
|
||||
ArgUtil.NotNull(secretMasker, nameof(secretMasker));
|
||||
_hostTraceListener = traceListener;
|
||||
_traceSetting = traceSetting;
|
||||
_secretMasker = secretMasker;
|
||||
|
||||
Switch = new SourceSwitch("GitHubActionsRunnerSwitch")
|
||||
{
|
||||
Level = _traceSetting.DefaultTraceLevel.ToSourceLevels()
|
||||
};
|
||||
}
|
||||
|
||||
public SourceSwitch Switch { get; private set; }
|
||||
|
||||
public Tracing this[string name]
|
||||
{
|
||||
get
|
||||
{
|
||||
return _sources.GetOrAdd(name, key => CreateTraceSource(key));
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
foreach (Tracing traceSource in _sources.Values)
|
||||
{
|
||||
traceSource.Dispose();
|
||||
}
|
||||
|
||||
_sources.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private Tracing CreateTraceSource(string name)
|
||||
{
|
||||
SourceSwitch sourceSwitch = Switch;
|
||||
|
||||
TraceLevel sourceTraceLevel;
|
||||
if (_traceSetting.DetailTraceSetting.TryGetValue(name, out sourceTraceLevel))
|
||||
{
|
||||
sourceSwitch = new SourceSwitch("GitHubActionsRunnerSubSwitch")
|
||||
{
|
||||
Level = sourceTraceLevel.ToSourceLevels()
|
||||
};
|
||||
}
|
||||
return new Tracing(name, _secretMasker, sourceSwitch, _hostTraceListener);
|
||||
}
|
||||
}
|
||||
}
|
||||
92
src/Runner.Common/TraceSetting.cs
Normal file
92
src/Runner.Common/TraceSetting.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.Serialization;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
[DataContract]
|
||||
public class TraceSetting
|
||||
{
|
||||
public TraceSetting()
|
||||
{
|
||||
DefaultTraceLevel = TraceLevel.Info;
|
||||
#if DEBUG
|
||||
DefaultTraceLevel = TraceLevel.Verbose;
|
||||
#endif
|
||||
string actionsRunnerTrace = Environment.GetEnvironmentVariable("GITHUB_ACTIONS_RUNNER_TRACE");
|
||||
if (!string.IsNullOrEmpty(actionsRunnerTrace))
|
||||
{
|
||||
DefaultTraceLevel = TraceLevel.Verbose;
|
||||
}
|
||||
}
|
||||
|
||||
[DataMember(EmitDefaultValue = false)]
|
||||
public TraceLevel DefaultTraceLevel
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
public Dictionary<String, TraceLevel> DetailTraceSetting
|
||||
{
|
||||
get
|
||||
{
|
||||
if (m_detailTraceSetting == null)
|
||||
{
|
||||
m_detailTraceSetting = new Dictionary<String, TraceLevel>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
return m_detailTraceSetting;
|
||||
}
|
||||
}
|
||||
|
||||
[DataMember(EmitDefaultValue = false, Name = "DetailTraceSetting")]
|
||||
private Dictionary<String, TraceLevel> m_detailTraceSetting;
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public enum TraceLevel
|
||||
{
|
||||
[EnumMember]
|
||||
Off = 0,
|
||||
|
||||
[EnumMember]
|
||||
Critical = 1,
|
||||
|
||||
[EnumMember]
|
||||
Error = 2,
|
||||
|
||||
[EnumMember]
|
||||
Warning = 3,
|
||||
|
||||
[EnumMember]
|
||||
Info = 4,
|
||||
|
||||
[EnumMember]
|
||||
Verbose = 5,
|
||||
}
|
||||
|
||||
public static class TraceLevelExtensions
|
||||
{
|
||||
public static SourceLevels ToSourceLevels(this TraceLevel traceLevel)
|
||||
{
|
||||
switch (traceLevel)
|
||||
{
|
||||
case TraceLevel.Off:
|
||||
return SourceLevels.Off;
|
||||
case TraceLevel.Critical:
|
||||
return SourceLevels.Critical;
|
||||
case TraceLevel.Error:
|
||||
return SourceLevels.Error;
|
||||
case TraceLevel.Warning:
|
||||
return SourceLevels.Warning;
|
||||
case TraceLevel.Info:
|
||||
return SourceLevels.Information;
|
||||
case TraceLevel.Verbose:
|
||||
return SourceLevels.Verbose;
|
||||
default:
|
||||
return SourceLevels.Information;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
128
src/Runner.Common/Tracing.cs
Normal file
128
src/Runner.Common/Tracing.cs
Normal file
@@ -0,0 +1,128 @@
|
||||
|
||||
using GitHub.Runner.Common.Util;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using GitHub.DistributedTask.Logging;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Common
|
||||
{
|
||||
public sealed class Tracing : ITraceWriter, IDisposable
|
||||
{
|
||||
private ISecretMasker _secretMasker;
|
||||
private TraceSource _traceSource;
|
||||
|
||||
public Tracing(string name, ISecretMasker secretMasker, SourceSwitch sourceSwitch, HostTraceListener traceListener)
|
||||
{
|
||||
ArgUtil.NotNull(secretMasker, nameof(secretMasker));
|
||||
_secretMasker = secretMasker;
|
||||
_traceSource = new TraceSource(name);
|
||||
_traceSource.Switch = sourceSwitch;
|
||||
|
||||
// Remove the default trace listener.
|
||||
if (_traceSource.Listeners.Count > 0 &&
|
||||
_traceSource.Listeners[0] is DefaultTraceListener)
|
||||
{
|
||||
_traceSource.Listeners.RemoveAt(0);
|
||||
}
|
||||
|
||||
_traceSource.Listeners.Add(traceListener);
|
||||
}
|
||||
|
||||
public void Info(string message)
|
||||
{
|
||||
Trace(TraceEventType.Information, message);
|
||||
}
|
||||
|
||||
public void Info(string format, params object[] args)
|
||||
{
|
||||
Trace(TraceEventType.Information, StringUtil.Format(format, args));
|
||||
}
|
||||
|
||||
public void Info(object item)
|
||||
{
|
||||
string json = JsonConvert.SerializeObject(item, Formatting.Indented);
|
||||
Trace(TraceEventType.Information, json);
|
||||
}
|
||||
|
||||
public void Error(Exception exception)
|
||||
{
|
||||
Trace(TraceEventType.Error, exception.ToString());
|
||||
}
|
||||
|
||||
// Do not remove the non-format overload.
|
||||
public void Error(string message)
|
||||
{
|
||||
Trace(TraceEventType.Error, message);
|
||||
}
|
||||
|
||||
public void Error(string format, params object[] args)
|
||||
{
|
||||
Trace(TraceEventType.Error, StringUtil.Format(format, args));
|
||||
}
|
||||
|
||||
// Do not remove the non-format overload.
|
||||
public void Warning(string message)
|
||||
{
|
||||
Trace(TraceEventType.Warning, message);
|
||||
}
|
||||
|
||||
public void Warning(string format, params object[] args)
|
||||
{
|
||||
Trace(TraceEventType.Warning, StringUtil.Format(format, args));
|
||||
}
|
||||
|
||||
// Do not remove the non-format overload.
|
||||
public void Verbose(string message)
|
||||
{
|
||||
Trace(TraceEventType.Verbose, message);
|
||||
}
|
||||
|
||||
public void Verbose(string format, params object[] args)
|
||||
{
|
||||
Trace(TraceEventType.Verbose, StringUtil.Format(format, args));
|
||||
}
|
||||
|
||||
public void Verbose(object item)
|
||||
{
|
||||
string json = JsonConvert.SerializeObject(item, Formatting.Indented);
|
||||
Trace(TraceEventType.Verbose, json);
|
||||
}
|
||||
|
||||
public void Entering([CallerMemberName] string name = "")
|
||||
{
|
||||
Trace(TraceEventType.Verbose, $"Entering {name}");
|
||||
}
|
||||
|
||||
public void Leaving([CallerMemberName] string name = "")
|
||||
{
|
||||
Trace(TraceEventType.Verbose, $"Leaving {name}");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void Trace(TraceEventType eventType, string message)
|
||||
{
|
||||
ArgUtil.NotNull(_traceSource, nameof(_traceSource));
|
||||
_traceSource.TraceEvent(
|
||||
eventType: eventType,
|
||||
id: 0,
|
||||
message: _secretMasker.MaskSecrets(message));
|
||||
}
|
||||
|
||||
private void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_traceSource.Flush();
|
||||
_traceSource.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
src/Runner.Common/Util/EnumUtil.cs
Normal file
18
src/Runner.Common/Util/EnumUtil.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace GitHub.Runner.Common.Util
|
||||
{
|
||||
using System;
|
||||
|
||||
public static class EnumUtil
|
||||
{
|
||||
public static T? TryParse<T>(string value) where T: struct
|
||||
{
|
||||
T val;
|
||||
if (Enum.TryParse(value ?? string.Empty, ignoreCase: true, result: out val))
|
||||
{
|
||||
return val;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/Runner.Common/Util/PlanUtil.cs
Normal file
28
src/Runner.Common/Util/PlanUtil.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Common.Util
|
||||
{
|
||||
public static class PlanUtil
|
||||
{
|
||||
public static PlanFeatures GetFeatures(TaskOrchestrationPlanReference plan)
|
||||
{
|
||||
ArgUtil.NotNull(plan, nameof(plan));
|
||||
PlanFeatures features = PlanFeatures.None;
|
||||
if (plan.Version >= 8)
|
||||
{
|
||||
features |= PlanFeatures.JobCompletedPlanEvent;
|
||||
}
|
||||
|
||||
return features;
|
||||
}
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum PlanFeatures
|
||||
{
|
||||
None = 0,
|
||||
JobCompletedPlanEvent = 1,
|
||||
}
|
||||
}
|
||||
79
src/Runner.Common/Util/TaskResultUtil.cs
Normal file
79
src/Runner.Common/Util/TaskResultUtil.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using System;
|
||||
|
||||
namespace GitHub.Runner.Common.Util
|
||||
{
|
||||
public static class TaskResultUtil
|
||||
{
|
||||
private static readonly int _returnCodeOffset = 100;
|
||||
|
||||
public static bool IsValidReturnCode(int returnCode)
|
||||
{
|
||||
int resultInt = returnCode - _returnCodeOffset;
|
||||
return Enum.IsDefined(typeof(TaskResult), resultInt);
|
||||
}
|
||||
|
||||
public static int TranslateToReturnCode(TaskResult result)
|
||||
{
|
||||
return _returnCodeOffset + (int)result;
|
||||
}
|
||||
|
||||
public static TaskResult TranslateFromReturnCode(int returnCode)
|
||||
{
|
||||
int resultInt = returnCode - _returnCodeOffset;
|
||||
if (Enum.IsDefined(typeof(TaskResult), resultInt))
|
||||
{
|
||||
return (TaskResult)resultInt;
|
||||
}
|
||||
else
|
||||
{
|
||||
return TaskResult.Failed;
|
||||
}
|
||||
}
|
||||
|
||||
// Merge 2 TaskResults get the worst result.
|
||||
// Succeeded -> Failed/Canceled/Skipped/Abandoned
|
||||
// Failed -> Failed/Canceled
|
||||
// Canceled -> Canceled
|
||||
// Skipped -> Skipped
|
||||
// Abandoned -> Abandoned
|
||||
public static TaskResult MergeTaskResults(TaskResult? currentResult, TaskResult comingResult)
|
||||
{
|
||||
if (currentResult == null)
|
||||
{
|
||||
return comingResult;
|
||||
}
|
||||
|
||||
// current result is Canceled/Skip/Abandoned
|
||||
if (currentResult > TaskResult.Failed)
|
||||
{
|
||||
return currentResult.Value;
|
||||
}
|
||||
|
||||
// comming result is bad than current result
|
||||
if (comingResult >= currentResult)
|
||||
{
|
||||
return comingResult;
|
||||
}
|
||||
|
||||
return currentResult.Value;
|
||||
}
|
||||
|
||||
public static ActionResult ToActionResult(this TaskResult result)
|
||||
{
|
||||
switch (result)
|
||||
{
|
||||
case TaskResult.Succeeded:
|
||||
return ActionResult.Success;
|
||||
case TaskResult.Failed:
|
||||
return ActionResult.Failure;
|
||||
case TaskResult.Canceled:
|
||||
return ActionResult.Cancelled;
|
||||
case TaskResult.Skipped:
|
||||
return ActionResult.Skipped;
|
||||
default:
|
||||
throw new NotSupportedException(result.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
79
src/Runner.Common/Util/UnixUtil.cs
Normal file
79
src/Runner.Common/Util/UnixUtil.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Common.Util
|
||||
{
|
||||
[ServiceLocator(Default = typeof(UnixUtil))]
|
||||
public interface IUnixUtil : IRunnerService
|
||||
{
|
||||
Task ExecAsync(string workingDirectory, string toolName, string argLine);
|
||||
Task ChmodAsync(string mode, string file);
|
||||
Task ChownAsync(string owner, string group, string file);
|
||||
}
|
||||
|
||||
public sealed class UnixUtil : RunnerService, IUnixUtil
|
||||
{
|
||||
private ITerminal _term;
|
||||
|
||||
public override void Initialize(IHostContext hostContext)
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
_term = hostContext.GetService<ITerminal>();
|
||||
}
|
||||
|
||||
public async Task ChmodAsync(string mode, string file)
|
||||
{
|
||||
Trace.Entering();
|
||||
await ExecAsync(HostContext.GetDirectory(WellKnownDirectory.Root), "chmod", $"{mode} \"{file}\"");
|
||||
}
|
||||
|
||||
public async Task ChownAsync(string owner, string group, string file)
|
||||
{
|
||||
Trace.Entering();
|
||||
await ExecAsync(HostContext.GetDirectory(WellKnownDirectory.Root), "chown", $"{owner}:{group} \"{file}\"");
|
||||
}
|
||||
|
||||
public async Task ExecAsync(string workingDirectory, string toolName, string argLine)
|
||||
{
|
||||
Trace.Entering();
|
||||
|
||||
string toolPath = WhichUtil.Which(toolName, trace: Trace);
|
||||
Trace.Info($"Running {toolPath} {argLine}");
|
||||
|
||||
var processInvoker = HostContext.CreateService<IProcessInvoker>();
|
||||
processInvoker.OutputDataReceived += OnOutputDataReceived;
|
||||
processInvoker.ErrorDataReceived += OnErrorDataReceived;
|
||||
|
||||
try
|
||||
{
|
||||
using (var cs = new CancellationTokenSource(TimeSpan.FromSeconds(45)))
|
||||
{
|
||||
await processInvoker.ExecuteAsync(workingDirectory, toolPath, argLine, null, true, cs.Token);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
processInvoker.OutputDataReceived -= OnOutputDataReceived;
|
||||
processInvoker.ErrorDataReceived -= OnErrorDataReceived;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnOutputDataReceived(object sender, ProcessDataReceivedEventArgs e)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(e.Data))
|
||||
{
|
||||
_term.WriteLine(e.Data);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnErrorDataReceived(object sender, ProcessDataReceivedEventArgs e)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(e.Data))
|
||||
{
|
||||
_term.WriteLine(e.Data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
63
src/Runner.Common/Util/VarUtil.cs
Normal file
63
src/Runner.Common/Util/VarUtil.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Common.Util
|
||||
{
|
||||
public static class VarUtil
|
||||
{
|
||||
public static StringComparer EnvironmentVariableKeyComparer
|
||||
{
|
||||
get
|
||||
{
|
||||
switch (Constants.Runner.Platform)
|
||||
{
|
||||
case Constants.OSPlatform.Linux:
|
||||
case Constants.OSPlatform.OSX:
|
||||
return StringComparer.Ordinal;
|
||||
case Constants.OSPlatform.Windows:
|
||||
return StringComparer.OrdinalIgnoreCase;
|
||||
default:
|
||||
throw new NotSupportedException(); // Should never reach here.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static string OS
|
||||
{
|
||||
get
|
||||
{
|
||||
switch (Constants.Runner.Platform)
|
||||
{
|
||||
case Constants.OSPlatform.Linux:
|
||||
return "Linux";
|
||||
case Constants.OSPlatform.OSX:
|
||||
return "macOS";
|
||||
case Constants.OSPlatform.Windows:
|
||||
return "Windows";
|
||||
default:
|
||||
throw new NotSupportedException(); // Should never reach here.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static string OSArchitecture
|
||||
{
|
||||
get
|
||||
{
|
||||
switch (Constants.Runner.PlatformArchitecture)
|
||||
{
|
||||
case Constants.Architecture.X86:
|
||||
return "X86";
|
||||
case Constants.Architecture.X64:
|
||||
return "X64";
|
||||
case Constants.Architecture.Arm:
|
||||
return "ARM";
|
||||
default:
|
||||
throw new NotSupportedException(); // Should never reach here.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
493
src/Runner.Listener/Agent.cs
Normal file
493
src/Runner.Listener/Agent.cs
Normal file
@@ -0,0 +1,493 @@
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Listener.Configuration;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Services.WebApi;
|
||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Listener
|
||||
{
|
||||
[ServiceLocator(Default = typeof(Runner))]
|
||||
public interface IRunner : IRunnerService
|
||||
{
|
||||
Task<int> ExecuteCommand(CommandSettings command);
|
||||
}
|
||||
|
||||
public sealed class Runner : RunnerService, IRunner
|
||||
{
|
||||
private IMessageListener _listener;
|
||||
private ITerminal _term;
|
||||
private bool _inConfigStage;
|
||||
private ManualResetEvent _completedCommand = new ManualResetEvent(false);
|
||||
|
||||
public override void Initialize(IHostContext hostContext)
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
_term = HostContext.GetService<ITerminal>();
|
||||
}
|
||||
|
||||
public async Task<int> ExecuteCommand(CommandSettings command)
|
||||
{
|
||||
try
|
||||
{
|
||||
var runnerWebProxy = HostContext.GetService<IRunnerWebProxy>();
|
||||
var runnerCertManager = HostContext.GetService<IRunnerCertificateManager>();
|
||||
VssUtil.InitializeVssClientSettings(HostContext.UserAgent, runnerWebProxy.WebProxy, runnerCertManager.VssClientCertificateManager);
|
||||
|
||||
_inConfigStage = true;
|
||||
_completedCommand.Reset();
|
||||
_term.CancelKeyPress += CtrlCHandler;
|
||||
|
||||
//register a SIGTERM handler
|
||||
HostContext.Unloading += Runner_Unloading;
|
||||
|
||||
// TODO Unit test to cover this logic
|
||||
Trace.Info(nameof(ExecuteCommand));
|
||||
var configManager = HostContext.GetService<IConfigurationManager>();
|
||||
|
||||
// command is not required, if no command it just starts if configured
|
||||
|
||||
// TODO: Invalid config prints usage
|
||||
|
||||
if (command.Help)
|
||||
{
|
||||
PrintUsage(command);
|
||||
return Constants.Runner.ReturnCode.Success;
|
||||
}
|
||||
|
||||
if (command.Version)
|
||||
{
|
||||
_term.WriteLine(BuildConstants.RunnerPackage.Version);
|
||||
return Constants.Runner.ReturnCode.Success;
|
||||
}
|
||||
|
||||
if (command.Commit)
|
||||
{
|
||||
_term.WriteLine(BuildConstants.Source.CommitHash);
|
||||
return Constants.Runner.ReturnCode.Success;
|
||||
}
|
||||
|
||||
// Configure runner prompt for args if not supplied
|
||||
// Unattended configure mode will not prompt for args if not supplied and error on any missing or invalid value.
|
||||
if (command.Configure)
|
||||
{
|
||||
try
|
||||
{
|
||||
await configManager.ConfigureAsync(command);
|
||||
return Constants.Runner.ReturnCode.Success;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error(ex);
|
||||
_term.WriteError(ex.Message);
|
||||
return Constants.Runner.ReturnCode.TerminatedError;
|
||||
}
|
||||
}
|
||||
|
||||
// remove config files, remove service, and exit
|
||||
if (command.Remove)
|
||||
{
|
||||
try
|
||||
{
|
||||
await configManager.UnconfigureAsync(command);
|
||||
return Constants.Runner.ReturnCode.Success;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error(ex);
|
||||
_term.WriteError(ex.Message);
|
||||
return Constants.Runner.ReturnCode.TerminatedError;
|
||||
}
|
||||
}
|
||||
|
||||
_inConfigStage = false;
|
||||
|
||||
// warmup runner process (JIT/CLR)
|
||||
// In scenarios where the runner is single use (used and then thrown away), the system provisioning the runner can call `Runner.Listener --warmup` before the machine is made available to the pool for use.
|
||||
// this will optimizes the runner process startup time.
|
||||
if (command.Warmup)
|
||||
{
|
||||
var binDir = HostContext.GetDirectory(WellKnownDirectory.Bin);
|
||||
foreach (var assemblyFile in Directory.EnumerateFiles(binDir, "*.dll"))
|
||||
{
|
||||
try
|
||||
{
|
||||
Trace.Info($"Load assembly: {assemblyFile}.");
|
||||
var assembly = Assembly.LoadFrom(assemblyFile);
|
||||
var types = assembly.GetTypes();
|
||||
foreach (Type loadedType in types)
|
||||
{
|
||||
try
|
||||
{
|
||||
Trace.Info($"Load methods: {loadedType.FullName}.");
|
||||
var methods = loadedType.GetMethods(BindingFlags.DeclaredOnly | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static);
|
||||
foreach (var method in methods)
|
||||
{
|
||||
if (!method.IsAbstract && !method.ContainsGenericParameters)
|
||||
{
|
||||
Trace.Verbose($"Prepare method: {method.Name}.");
|
||||
RuntimeHelpers.PrepareMethod(method.MethodHandle);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error(ex);
|
||||
}
|
||||
}
|
||||
|
||||
return Constants.Runner.ReturnCode.Success;
|
||||
}
|
||||
|
||||
RunnerSettings settings = configManager.LoadSettings();
|
||||
|
||||
var store = HostContext.GetService<IConfigurationStore>();
|
||||
bool configuredAsService = store.IsServiceConfigured();
|
||||
|
||||
// Run runner
|
||||
if (command.Run) // this line is current break machine provisioner.
|
||||
{
|
||||
// Error if runner not configured.
|
||||
if (!configManager.IsConfigured())
|
||||
{
|
||||
_term.WriteError("Runner is not configured.");
|
||||
PrintUsage(command);
|
||||
return Constants.Runner.ReturnCode.TerminatedError;
|
||||
}
|
||||
|
||||
Trace.Verbose($"Configured as service: '{configuredAsService}'");
|
||||
|
||||
//Get the startup type of the runner i.e., autostartup, service, manual
|
||||
StartupType startType;
|
||||
var startupTypeAsString = command.GetStartupType();
|
||||
if (string.IsNullOrEmpty(startupTypeAsString) && configuredAsService)
|
||||
{
|
||||
// We need try our best to make the startup type accurate
|
||||
// The problem is coming from runner autoupgrade, which result an old version service host binary but a newer version runner binary
|
||||
// At that time the servicehost won't pass --startuptype to Runner.Listener while the runner is actually running as service.
|
||||
// We will guess the startup type only when the runner is configured as service and the guess will based on whether STDOUT/STDERR/STDIN been redirect or not
|
||||
Trace.Info($"Try determine runner startup type base on console redirects.");
|
||||
startType = (Console.IsErrorRedirected && Console.IsInputRedirected && Console.IsOutputRedirected) ? StartupType.Service : StartupType.Manual;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!Enum.TryParse(startupTypeAsString, true, out startType))
|
||||
{
|
||||
Trace.Info($"Could not parse the argument value '{startupTypeAsString}' for StartupType. Defaulting to {StartupType.Manual}");
|
||||
startType = StartupType.Manual;
|
||||
}
|
||||
}
|
||||
|
||||
#if !OS_WINDOWS
|
||||
// Fix the work folder setting on Linux
|
||||
if (settings.WorkFolder.Contains("vsts", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var workFolder = "/runner/work";
|
||||
var unix = HostContext.GetService<IUnixUtil>();
|
||||
|
||||
// create new work folder /runner/work
|
||||
await unix.ExecAsync(HostContext.GetDirectory(WellKnownDirectory.Root), "sh", $"-c \"sudo mkdir -p {workFolder}\"");
|
||||
|
||||
// fix permission
|
||||
await unix.ExecAsync(HostContext.GetDirectory(WellKnownDirectory.Root), "sh", $"-c \"sudo chown -R $USER {workFolder}\"");
|
||||
|
||||
// update settings
|
||||
settings.WorkFolder = workFolder;
|
||||
store.SaveSettings(settings);
|
||||
}
|
||||
#endif
|
||||
|
||||
Trace.Info($"Set runner startup type - {startType}");
|
||||
HostContext.StartupType = startType;
|
||||
|
||||
// Run the runner interactively or as service
|
||||
return await RunAsync(settings, command.RunOnce);
|
||||
}
|
||||
else
|
||||
{
|
||||
PrintUsage(command);
|
||||
return Constants.Runner.ReturnCode.Success;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_term.CancelKeyPress -= CtrlCHandler;
|
||||
HostContext.Unloading -= Runner_Unloading;
|
||||
_completedCommand.Set();
|
||||
}
|
||||
}
|
||||
|
||||
private void Runner_Unloading(object sender, EventArgs e)
|
||||
{
|
||||
if ((!_inConfigStage) && (!HostContext.RunnerShutdownToken.IsCancellationRequested))
|
||||
{
|
||||
HostContext.ShutdownRunner(ShutdownReason.UserCancelled);
|
||||
_completedCommand.WaitOne(Constants.Runner.ExitOnUnloadTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
private void CtrlCHandler(object sender, EventArgs e)
|
||||
{
|
||||
_term.WriteLine("Exiting...");
|
||||
if (_inConfigStage)
|
||||
{
|
||||
HostContext.Dispose();
|
||||
Environment.Exit(Constants.Runner.ReturnCode.TerminatedError);
|
||||
}
|
||||
else
|
||||
{
|
||||
ConsoleCancelEventArgs cancelEvent = e as ConsoleCancelEventArgs;
|
||||
if (cancelEvent != null && HostContext.GetService<IConfigurationStore>().IsServiceConfigured())
|
||||
{
|
||||
ShutdownReason reason;
|
||||
if (cancelEvent.SpecialKey == ConsoleSpecialKey.ControlBreak)
|
||||
{
|
||||
Trace.Info("Received Ctrl-Break signal from runner service host, this indicate the operating system is shutting down.");
|
||||
reason = ShutdownReason.OperatingSystemShutdown;
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info("Received Ctrl-C signal, stop Runner.Listener and Runner.Worker.");
|
||||
reason = ShutdownReason.UserCancelled;
|
||||
}
|
||||
|
||||
HostContext.ShutdownRunner(reason);
|
||||
}
|
||||
else
|
||||
{
|
||||
HostContext.ShutdownRunner(ShutdownReason.UserCancelled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//create worker manager, create message listener and start listening to the queue
|
||||
private async Task<int> RunAsync(RunnerSettings settings, bool runOnce = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
Trace.Info(nameof(RunAsync));
|
||||
_listener = HostContext.GetService<IMessageListener>();
|
||||
if (!await _listener.CreateSessionAsync(HostContext.RunnerShutdownToken))
|
||||
{
|
||||
return Constants.Runner.ReturnCode.TerminatedError;
|
||||
}
|
||||
|
||||
HostContext.WritePerfCounter("SessionCreated");
|
||||
_term.WriteLine($"{DateTime.UtcNow:u}: Listening for Jobs");
|
||||
|
||||
IJobDispatcher jobDispatcher = null;
|
||||
CancellationTokenSource messageQueueLoopTokenSource = CancellationTokenSource.CreateLinkedTokenSource(HostContext.RunnerShutdownToken);
|
||||
try
|
||||
{
|
||||
var notification = HostContext.GetService<IJobNotification>();
|
||||
if (!String.IsNullOrEmpty(settings.NotificationSocketAddress))
|
||||
{
|
||||
notification.StartClient(settings.NotificationSocketAddress, settings.MonitorSocketAddress);
|
||||
}
|
||||
else
|
||||
{
|
||||
notification.StartClient(settings.NotificationPipeName, settings.MonitorSocketAddress, HostContext.RunnerShutdownToken);
|
||||
}
|
||||
|
||||
bool autoUpdateInProgress = false;
|
||||
Task<bool> selfUpdateTask = null;
|
||||
bool runOnceJobReceived = false;
|
||||
jobDispatcher = HostContext.CreateService<IJobDispatcher>();
|
||||
|
||||
while (!HostContext.RunnerShutdownToken.IsCancellationRequested)
|
||||
{
|
||||
TaskAgentMessage message = null;
|
||||
bool skipMessageDeletion = false;
|
||||
try
|
||||
{
|
||||
Task<TaskAgentMessage> getNextMessage = _listener.GetNextMessageAsync(messageQueueLoopTokenSource.Token);
|
||||
if (autoUpdateInProgress)
|
||||
{
|
||||
Trace.Verbose("Auto update task running at backend, waiting for getNextMessage or selfUpdateTask to finish.");
|
||||
Task completeTask = await Task.WhenAny(getNextMessage, selfUpdateTask);
|
||||
if (completeTask == selfUpdateTask)
|
||||
{
|
||||
autoUpdateInProgress = false;
|
||||
if (await selfUpdateTask)
|
||||
{
|
||||
Trace.Info("Auto update task finished at backend, an runner update is ready to apply exit the current runner instance.");
|
||||
Trace.Info("Stop message queue looping.");
|
||||
messageQueueLoopTokenSource.Cancel();
|
||||
try
|
||||
{
|
||||
await getNextMessage;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Info($"Ignore any exception after cancel message loop. {ex}");
|
||||
}
|
||||
|
||||
if (runOnce)
|
||||
{
|
||||
return Constants.Runner.ReturnCode.RunOnceRunnerUpdating;
|
||||
}
|
||||
else
|
||||
{
|
||||
return Constants.Runner.ReturnCode.RunnerUpdating;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info("Auto update task finished at backend, there is no available runner update needs to apply, continue message queue looping.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (runOnceJobReceived)
|
||||
{
|
||||
Trace.Verbose("One time used runner has start running its job, waiting for getNextMessage or the job to finish.");
|
||||
Task completeTask = await Task.WhenAny(getNextMessage, jobDispatcher.RunOnceJobCompleted.Task);
|
||||
if (completeTask == jobDispatcher.RunOnceJobCompleted.Task)
|
||||
{
|
||||
Trace.Info("Job has finished at backend, the runner will exit since it is running under onetime use mode.");
|
||||
Trace.Info("Stop message queue looping.");
|
||||
messageQueueLoopTokenSource.Cancel();
|
||||
try
|
||||
{
|
||||
await getNextMessage;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Info($"Ignore any exception after cancel message loop. {ex}");
|
||||
}
|
||||
|
||||
return Constants.Runner.ReturnCode.Success;
|
||||
}
|
||||
}
|
||||
|
||||
message = await getNextMessage; //get next message
|
||||
HostContext.WritePerfCounter($"MessageReceived_{message.MessageType}");
|
||||
if (string.Equals(message.MessageType, AgentRefreshMessage.MessageType, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (autoUpdateInProgress == false)
|
||||
{
|
||||
autoUpdateInProgress = true;
|
||||
var runnerUpdateMessage = JsonUtility.FromString<AgentRefreshMessage>(message.Body);
|
||||
var selfUpdater = HostContext.GetService<ISelfUpdater>();
|
||||
selfUpdateTask = selfUpdater.SelfUpdate(runnerUpdateMessage, jobDispatcher, !runOnce && HostContext.StartupType != StartupType.Service, HostContext.RunnerShutdownToken);
|
||||
Trace.Info("Refresh message received, kick-off selfupdate background process.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info("Refresh message received, skip autoupdate since a previous autoupdate is already running.");
|
||||
}
|
||||
}
|
||||
else if (string.Equals(message.MessageType, JobRequestMessageTypes.PipelineAgentJobRequest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (autoUpdateInProgress || runOnceJobReceived)
|
||||
{
|
||||
skipMessageDeletion = true;
|
||||
Trace.Info($"Skip message deletion for job request message '{message.MessageId}'.");
|
||||
}
|
||||
else
|
||||
{
|
||||
var jobMessage = StringUtil.ConvertFromJson<Pipelines.AgentJobRequestMessage>(message.Body);
|
||||
jobDispatcher.Run(jobMessage, runOnce);
|
||||
if (runOnce)
|
||||
{
|
||||
Trace.Info("One time used runner received job message.");
|
||||
runOnceJobReceived = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (string.Equals(message.MessageType, JobCancelMessage.MessageType, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var cancelJobMessage = JsonUtility.FromString<JobCancelMessage>(message.Body);
|
||||
bool jobCancelled = jobDispatcher.Cancel(cancelJobMessage);
|
||||
skipMessageDeletion = (autoUpdateInProgress || runOnceJobReceived) && !jobCancelled;
|
||||
|
||||
if (skipMessageDeletion)
|
||||
{
|
||||
Trace.Info($"Skip message deletion for cancellation message '{message.MessageId}'.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Error($"Received message {message.MessageId} with unsupported message type {message.MessageType}.");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!skipMessageDeletion && message != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _listener.DeleteMessageAsync(message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Catch exception during delete message from message queue. message id: {message.MessageId}");
|
||||
Trace.Error(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
message = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (jobDispatcher != null)
|
||||
{
|
||||
await jobDispatcher.ShutdownAsync();
|
||||
}
|
||||
|
||||
//TODO: make sure we don't mask more important exception
|
||||
await _listener.DeleteSessionAsync();
|
||||
|
||||
messageQueueLoopTokenSource.Dispose();
|
||||
}
|
||||
}
|
||||
catch (TaskAgentAccessTokenExpiredException)
|
||||
{
|
||||
Trace.Info("Agent OAuth token has been revoked. Shutting down.");
|
||||
}
|
||||
|
||||
return Constants.Runner.ReturnCode.Success;
|
||||
}
|
||||
|
||||
private void PrintUsage(CommandSettings command)
|
||||
{
|
||||
string separator;
|
||||
string ext;
|
||||
#if OS_WINDOWS
|
||||
separator = "\\";
|
||||
ext = "cmd";
|
||||
#else
|
||||
separator = "/";
|
||||
ext = "sh";
|
||||
#endif
|
||||
_term.WriteLine($@"
|
||||
Commands:,
|
||||
.{separator}config.{ext} Configures the runner
|
||||
.{separator}config.{ext} remove Unconfigures the runner
|
||||
.{separator}run.{ext} Runs the runner interactively. Does not require any options.
|
||||
|
||||
Options:
|
||||
--version Prints the runner version
|
||||
--commit Prints the runner commit
|
||||
--help Prints the help for each command
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
467
src/Runner.Listener/CommandSettings.cs
Normal file
467
src/Runner.Listener/CommandSettings.cs
Normal file
@@ -0,0 +1,467 @@
|
||||
using GitHub.Runner.Listener.Configuration;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using GitHub.DistributedTask.Logging;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Listener
|
||||
{
|
||||
public sealed class CommandSettings
|
||||
{
|
||||
private readonly Dictionary<string, string> _envArgs = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly CommandLineParser _parser;
|
||||
private readonly IPromptManager _promptManager;
|
||||
private readonly Tracing _trace;
|
||||
|
||||
private readonly string[] validCommands =
|
||||
{
|
||||
Constants.Runner.CommandLine.Commands.Configure,
|
||||
Constants.Runner.CommandLine.Commands.Remove,
|
||||
Constants.Runner.CommandLine.Commands.Run,
|
||||
Constants.Runner.CommandLine.Commands.Warmup,
|
||||
};
|
||||
|
||||
private readonly string[] validFlags =
|
||||
{
|
||||
Constants.Runner.CommandLine.Flags.Commit,
|
||||
#if OS_WINDOWS
|
||||
Constants.Runner.CommandLine.Flags.GitUseSChannel,
|
||||
#endif
|
||||
Constants.Runner.CommandLine.Flags.Help,
|
||||
Constants.Runner.CommandLine.Flags.Replace,
|
||||
Constants.Runner.CommandLine.Flags.RunAsService,
|
||||
Constants.Runner.CommandLine.Flags.Once,
|
||||
Constants.Runner.CommandLine.Flags.SslSkipCertValidation,
|
||||
Constants.Runner.CommandLine.Flags.Unattended,
|
||||
Constants.Runner.CommandLine.Flags.Version
|
||||
};
|
||||
|
||||
private readonly string[] validArgs =
|
||||
{
|
||||
Constants.Runner.CommandLine.Args.Agent,
|
||||
Constants.Runner.CommandLine.Args.Auth,
|
||||
Constants.Runner.CommandLine.Args.MonitorSocketAddress,
|
||||
Constants.Runner.CommandLine.Args.NotificationPipeName,
|
||||
Constants.Runner.CommandLine.Args.Password,
|
||||
Constants.Runner.CommandLine.Args.Pool,
|
||||
Constants.Runner.CommandLine.Args.ProxyPassword,
|
||||
Constants.Runner.CommandLine.Args.ProxyUrl,
|
||||
Constants.Runner.CommandLine.Args.ProxyUserName,
|
||||
Constants.Runner.CommandLine.Args.SslCACert,
|
||||
Constants.Runner.CommandLine.Args.SslClientCert,
|
||||
Constants.Runner.CommandLine.Args.SslClientCertKey,
|
||||
Constants.Runner.CommandLine.Args.SslClientCertArchive,
|
||||
Constants.Runner.CommandLine.Args.SslClientCertPassword,
|
||||
Constants.Runner.CommandLine.Args.StartupType,
|
||||
Constants.Runner.CommandLine.Args.Token,
|
||||
Constants.Runner.CommandLine.Args.Url,
|
||||
Constants.Runner.CommandLine.Args.UserName,
|
||||
Constants.Runner.CommandLine.Args.WindowsLogonAccount,
|
||||
Constants.Runner.CommandLine.Args.WindowsLogonPassword,
|
||||
Constants.Runner.CommandLine.Args.Work
|
||||
};
|
||||
|
||||
// Commands.
|
||||
public bool Configure => TestCommand(Constants.Runner.CommandLine.Commands.Configure);
|
||||
public bool Remove => TestCommand(Constants.Runner.CommandLine.Commands.Remove);
|
||||
public bool Run => TestCommand(Constants.Runner.CommandLine.Commands.Run);
|
||||
public bool Warmup => TestCommand(Constants.Runner.CommandLine.Commands.Warmup);
|
||||
|
||||
// Flags.
|
||||
public bool Commit => TestFlag(Constants.Runner.CommandLine.Flags.Commit);
|
||||
public bool Help => TestFlag(Constants.Runner.CommandLine.Flags.Help);
|
||||
public bool Unattended => TestFlag(Constants.Runner.CommandLine.Flags.Unattended);
|
||||
public bool Version => TestFlag(Constants.Runner.CommandLine.Flags.Version);
|
||||
|
||||
#if OS_WINDOWS
|
||||
public bool GitUseSChannel => TestFlag(Constants.Runner.CommandLine.Flags.GitUseSChannel);
|
||||
#endif
|
||||
public bool RunOnce => TestFlag(Constants.Runner.CommandLine.Flags.Once);
|
||||
|
||||
// Constructor.
|
||||
public CommandSettings(IHostContext context, string[] args)
|
||||
{
|
||||
ArgUtil.NotNull(context, nameof(context));
|
||||
_promptManager = context.GetService<IPromptManager>();
|
||||
_trace = context.GetTrace(nameof(CommandSettings));
|
||||
|
||||
// Parse the command line args.
|
||||
_parser = new CommandLineParser(
|
||||
hostContext: context,
|
||||
secretArgNames: Constants.Runner.CommandLine.Args.Secrets);
|
||||
_parser.Parse(args);
|
||||
|
||||
// Store and remove any args passed via environment variables.
|
||||
IDictionary environment = Environment.GetEnvironmentVariables();
|
||||
string envPrefix = "ACTIONS_RUNNER_INPUT_";
|
||||
foreach (DictionaryEntry entry in environment)
|
||||
{
|
||||
// Test if starts with ACTIONS_RUNNER_INPUT_.
|
||||
string fullKey = entry.Key as string ?? string.Empty;
|
||||
if (fullKey.StartsWith(envPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string val = (entry.Value as string ?? string.Empty).Trim();
|
||||
if (!string.IsNullOrEmpty(val))
|
||||
{
|
||||
// Extract the name.
|
||||
string name = fullKey.Substring(envPrefix.Length);
|
||||
|
||||
// Mask secrets.
|
||||
bool secret = Constants.Runner.CommandLine.Args.Secrets.Any(x => string.Equals(x, name, StringComparison.OrdinalIgnoreCase));
|
||||
if (secret)
|
||||
{
|
||||
context.SecretMasker.AddValue(val);
|
||||
}
|
||||
|
||||
// Store the value.
|
||||
_envArgs[name] = val;
|
||||
}
|
||||
|
||||
// Remove from the environment block.
|
||||
_trace.Info($"Removing env var: '{fullKey}'");
|
||||
Environment.SetEnvironmentVariable(fullKey, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate commandline parser result
|
||||
public List<string> Validate()
|
||||
{
|
||||
List<string> unknowns = new List<string>();
|
||||
|
||||
// detect unknown commands
|
||||
unknowns.AddRange(_parser.Commands.Where(x => !validCommands.Contains(x, StringComparer.OrdinalIgnoreCase)));
|
||||
|
||||
// detect unknown flags
|
||||
unknowns.AddRange(_parser.Flags.Where(x => !validFlags.Contains(x, StringComparer.OrdinalIgnoreCase)));
|
||||
|
||||
// detect unknown args
|
||||
unknowns.AddRange(_parser.Args.Keys.Where(x => !validArgs.Contains(x, StringComparer.OrdinalIgnoreCase)));
|
||||
|
||||
return unknowns;
|
||||
}
|
||||
|
||||
//
|
||||
// Interactive flags.
|
||||
//
|
||||
public bool GetReplace()
|
||||
{
|
||||
return TestFlagOrPrompt(
|
||||
name: Constants.Runner.CommandLine.Flags.Replace,
|
||||
description: "Would you like to replace the existing runner? (Y/N)",
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
public bool GetRunAsService()
|
||||
{
|
||||
return TestFlagOrPrompt(
|
||||
name: Constants.Runner.CommandLine.Flags.RunAsService,
|
||||
description: "Would you like to run the runner as service? (Y/N)",
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
public bool GetAutoLaunchBrowser()
|
||||
{
|
||||
return TestFlagOrPrompt(
|
||||
name: Constants.Runner.CommandLine.Flags.LaunchBrowser,
|
||||
description: "Would you like to launch your browser for AAD Device Code Flow? (Y/N)",
|
||||
defaultValue: true);
|
||||
}
|
||||
//
|
||||
// Args.
|
||||
//
|
||||
public string GetAgentName()
|
||||
{
|
||||
return GetArgOrPrompt(
|
||||
name: Constants.Runner.CommandLine.Args.Agent,
|
||||
description: "Enter the name of runner:",
|
||||
defaultValue: Environment.MachineName ?? "myagent",
|
||||
validator: Validators.NonEmptyValidator);
|
||||
}
|
||||
|
||||
public string GetAuth(string defaultValue)
|
||||
{
|
||||
return GetArgOrPrompt(
|
||||
name: Constants.Runner.CommandLine.Args.Auth,
|
||||
description: "How would you like to authenticate?",
|
||||
defaultValue: defaultValue,
|
||||
validator: Validators.AuthSchemeValidator);
|
||||
}
|
||||
|
||||
public string GetPassword()
|
||||
{
|
||||
return GetArgOrPrompt(
|
||||
name: Constants.Runner.CommandLine.Args.Password,
|
||||
description: "What is your GitHub password?",
|
||||
defaultValue: string.Empty,
|
||||
validator: Validators.NonEmptyValidator);
|
||||
}
|
||||
|
||||
public string GetPool()
|
||||
{
|
||||
return GetArgOrPrompt(
|
||||
name: Constants.Runner.CommandLine.Args.Pool,
|
||||
description: "Enter the name of your runner pool:",
|
||||
defaultValue: "default",
|
||||
validator: Validators.NonEmptyValidator);
|
||||
}
|
||||
|
||||
public string GetToken()
|
||||
{
|
||||
return GetArgOrPrompt(
|
||||
name: Constants.Runner.CommandLine.Args.Token,
|
||||
description: "Enter your personal access token:",
|
||||
defaultValue: string.Empty,
|
||||
validator: Validators.NonEmptyValidator);
|
||||
}
|
||||
|
||||
public string GetRunnerRegisterToken()
|
||||
{
|
||||
return GetArgOrPrompt(
|
||||
name: Constants.Runner.CommandLine.Args.Token,
|
||||
description: "Enter runner register token:",
|
||||
defaultValue: string.Empty,
|
||||
validator: Validators.NonEmptyValidator);
|
||||
}
|
||||
|
||||
public string GetUrl(bool suppressPromptIfEmpty = false)
|
||||
{
|
||||
// Note, GetArg does not consume the arg (like GetArgOrPrompt does).
|
||||
if (suppressPromptIfEmpty &&
|
||||
string.IsNullOrEmpty(GetArg(Constants.Runner.CommandLine.Args.Url)))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return GetArgOrPrompt(
|
||||
name: Constants.Runner.CommandLine.Args.Url,
|
||||
description: "What is the URL of your repository?",
|
||||
defaultValue: string.Empty,
|
||||
validator: Validators.ServerUrlValidator);
|
||||
}
|
||||
|
||||
public string GetUserName()
|
||||
{
|
||||
return GetArgOrPrompt(
|
||||
name: Constants.Runner.CommandLine.Args.UserName,
|
||||
description: "What is your GitHub username?",
|
||||
defaultValue: string.Empty,
|
||||
validator: Validators.NonEmptyValidator);
|
||||
}
|
||||
|
||||
public string GetWindowsLogonAccount(string defaultValue, string descriptionMsg)
|
||||
{
|
||||
return GetArgOrPrompt(
|
||||
name: Constants.Runner.CommandLine.Args.WindowsLogonAccount,
|
||||
description: descriptionMsg,
|
||||
defaultValue: defaultValue,
|
||||
validator: Validators.NTAccountValidator);
|
||||
}
|
||||
|
||||
public string GetWindowsLogonPassword(string accountName)
|
||||
{
|
||||
return GetArgOrPrompt(
|
||||
name: Constants.Runner.CommandLine.Args.WindowsLogonPassword,
|
||||
description: $"Password for the account {accountName}",
|
||||
defaultValue: string.Empty,
|
||||
validator: Validators.NonEmptyValidator);
|
||||
}
|
||||
|
||||
public string GetWork()
|
||||
{
|
||||
return GetArgOrPrompt(
|
||||
name: Constants.Runner.CommandLine.Args.Work,
|
||||
description: "Enter name of work folder:",
|
||||
defaultValue: Constants.Path.WorkDirectory,
|
||||
validator: Validators.NonEmptyValidator);
|
||||
}
|
||||
|
||||
public string GetMonitorSocketAddress()
|
||||
{
|
||||
return GetArg(Constants.Runner.CommandLine.Args.MonitorSocketAddress);
|
||||
}
|
||||
|
||||
public string GetNotificationPipeName()
|
||||
{
|
||||
return GetArg(Constants.Runner.CommandLine.Args.NotificationPipeName);
|
||||
}
|
||||
|
||||
public string GetNotificationSocketAddress()
|
||||
{
|
||||
return GetArg(Constants.Runner.CommandLine.Args.NotificationSocketAddress);
|
||||
}
|
||||
|
||||
// This is used to find out the source from where the Runner.Listener.exe was launched at the time of run
|
||||
public string GetStartupType()
|
||||
{
|
||||
return GetArg(Constants.Runner.CommandLine.Args.StartupType);
|
||||
}
|
||||
|
||||
public string GetProxyUrl()
|
||||
{
|
||||
return GetArg(Constants.Runner.CommandLine.Args.ProxyUrl);
|
||||
}
|
||||
|
||||
public string GetProxyUserName()
|
||||
{
|
||||
return GetArg(Constants.Runner.CommandLine.Args.ProxyUserName);
|
||||
}
|
||||
|
||||
public string GetProxyPassword()
|
||||
{
|
||||
return GetArg(Constants.Runner.CommandLine.Args.ProxyPassword);
|
||||
}
|
||||
|
||||
public bool GetSkipCertificateValidation()
|
||||
{
|
||||
return TestFlag(Constants.Runner.CommandLine.Flags.SslSkipCertValidation);
|
||||
}
|
||||
|
||||
public string GetCACertificate()
|
||||
{
|
||||
return GetArg(Constants.Runner.CommandLine.Args.SslCACert);
|
||||
}
|
||||
|
||||
public string GetClientCertificate()
|
||||
{
|
||||
return GetArg(Constants.Runner.CommandLine.Args.SslClientCert);
|
||||
}
|
||||
|
||||
public string GetClientCertificatePrivateKey()
|
||||
{
|
||||
return GetArg(Constants.Runner.CommandLine.Args.SslClientCertKey);
|
||||
}
|
||||
|
||||
public string GetClientCertificateArchrive()
|
||||
{
|
||||
return GetArg(Constants.Runner.CommandLine.Args.SslClientCertArchive);
|
||||
}
|
||||
|
||||
public string GetClientCertificatePassword()
|
||||
{
|
||||
return GetArg(Constants.Runner.CommandLine.Args.SslClientCertPassword);
|
||||
}
|
||||
|
||||
//
|
||||
// Private helpers.
|
||||
//
|
||||
private string GetArg(string name)
|
||||
{
|
||||
string result;
|
||||
if (!_parser.Args.TryGetValue(name, out result))
|
||||
{
|
||||
result = GetEnvArg(name);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void RemoveArg(string name)
|
||||
{
|
||||
if (_parser.Args.ContainsKey(name))
|
||||
{
|
||||
_parser.Args.Remove(name);
|
||||
}
|
||||
|
||||
if (_envArgs.ContainsKey(name))
|
||||
{
|
||||
_envArgs.Remove(name);
|
||||
}
|
||||
}
|
||||
|
||||
private string GetArgOrPrompt(
|
||||
string name,
|
||||
string description,
|
||||
string defaultValue,
|
||||
Func<string, bool> validator)
|
||||
{
|
||||
// Check for the arg in the command line parser.
|
||||
ArgUtil.NotNull(validator, nameof(validator));
|
||||
string result = GetArg(name);
|
||||
|
||||
// Return the arg if it is not empty and is valid.
|
||||
_trace.Info($"Arg '{name}': '{result}'");
|
||||
if (!string.IsNullOrEmpty(result))
|
||||
{
|
||||
// After read the arg from input commandline args, remove it from Arg dictionary,
|
||||
// This will help if bad arg value passed through CommandLine arg, when ConfigurationManager ask CommandSetting the second time,
|
||||
// It will prompt for input instead of continue use the bad input.
|
||||
_trace.Info($"Remove {name} from Arg dictionary.");
|
||||
RemoveArg(name);
|
||||
|
||||
if (validator(result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
_trace.Info("Arg is invalid.");
|
||||
}
|
||||
|
||||
// Otherwise prompt for the arg.
|
||||
return _promptManager.ReadValue(
|
||||
argName: name,
|
||||
description: description,
|
||||
secret: Constants.Runner.CommandLine.Args.Secrets.Any(x => string.Equals(x, name, StringComparison.OrdinalIgnoreCase)),
|
||||
defaultValue: defaultValue,
|
||||
validator: validator,
|
||||
unattended: Unattended);
|
||||
}
|
||||
|
||||
private string GetEnvArg(string name)
|
||||
{
|
||||
string val;
|
||||
if (_envArgs.TryGetValue(name, out val) && !string.IsNullOrEmpty(val))
|
||||
{
|
||||
_trace.Info($"Env arg '{name}': '{val}'");
|
||||
return val;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool TestCommand(string name)
|
||||
{
|
||||
bool result = _parser.IsCommand(name);
|
||||
_trace.Info($"Command '{name}': '{result}'");
|
||||
return result;
|
||||
}
|
||||
|
||||
private bool TestFlag(string name)
|
||||
{
|
||||
bool result = _parser.Flags.Contains(name);
|
||||
if (!result)
|
||||
{
|
||||
string envStr = GetEnvArg(name);
|
||||
if (!bool.TryParse(envStr, out result))
|
||||
{
|
||||
result = false;
|
||||
}
|
||||
}
|
||||
|
||||
_trace.Info($"Flag '{name}': '{result}'");
|
||||
return result;
|
||||
}
|
||||
|
||||
private bool TestFlagOrPrompt(
|
||||
string name,
|
||||
string description,
|
||||
bool defaultValue)
|
||||
{
|
||||
bool result = TestFlag(name);
|
||||
if (!result)
|
||||
{
|
||||
result = _promptManager.ReadBool(
|
||||
argName: name,
|
||||
description: description,
|
||||
defaultValue: defaultValue,
|
||||
unattended: Unattended);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
667
src/Runner.Listener/Configuration/ConfigurationManager.cs
Normal file
667
src/Runner.Listener/Configuration/ConfigurationManager.cs
Normal file
@@ -0,0 +1,667 @@
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common.Capabilities;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Services.Common;
|
||||
using GitHub.Services.OAuth;
|
||||
using GitHub.Services.WebApi;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Principal;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Runtime.InteropServices;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
|
||||
namespace GitHub.Runner.Listener.Configuration
|
||||
{
|
||||
[ServiceLocator(Default = typeof(ConfigurationManager))]
|
||||
public interface IConfigurationManager : IRunnerService
|
||||
{
|
||||
bool IsConfigured();
|
||||
Task ConfigureAsync(CommandSettings command);
|
||||
Task UnconfigureAsync(CommandSettings command);
|
||||
RunnerSettings LoadSettings();
|
||||
}
|
||||
|
||||
public sealed class ConfigurationManager : RunnerService, IConfigurationManager
|
||||
{
|
||||
private IConfigurationStore _store;
|
||||
private IRunnerServer _runnerServer;
|
||||
private ITerminal _term;
|
||||
|
||||
public override void Initialize(IHostContext hostContext)
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
_runnerServer = HostContext.GetService<IRunnerServer>();
|
||||
Trace.Verbose("Creating _store");
|
||||
_store = hostContext.GetService<IConfigurationStore>();
|
||||
Trace.Verbose("store created");
|
||||
_term = hostContext.GetService<ITerminal>();
|
||||
}
|
||||
|
||||
public bool IsConfigured()
|
||||
{
|
||||
bool result = _store.IsConfigured();
|
||||
Trace.Info($"Is configured: {result}");
|
||||
return result;
|
||||
}
|
||||
|
||||
public RunnerSettings LoadSettings()
|
||||
{
|
||||
Trace.Info(nameof(LoadSettings));
|
||||
if (!IsConfigured())
|
||||
{
|
||||
throw new InvalidOperationException("Not configured");
|
||||
}
|
||||
|
||||
RunnerSettings settings = _store.GetSettings();
|
||||
Trace.Info("Settings Loaded");
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
public async Task ConfigureAsync(CommandSettings command)
|
||||
{
|
||||
_term.WriteLine();
|
||||
_term.WriteLine("--------------------------------------------------------------------------------", ConsoleColor.White);
|
||||
_term.WriteLine("| ____ _ _ _ _ _ _ _ _ |", ConsoleColor.White);
|
||||
_term.WriteLine("| / ___(_) |_| | | |_ _| |__ / \\ ___| |_(_) ___ _ __ ___ |", ConsoleColor.White);
|
||||
_term.WriteLine("| | | _| | __| |_| | | | | '_ \\ / _ \\ / __| __| |/ _ \\| '_ \\/ __| |", ConsoleColor.White);
|
||||
_term.WriteLine("| | |_| | | |_| _ | |_| | |_) | / ___ \\ (__| |_| | (_) | | | \\__ \\ |", ConsoleColor.White);
|
||||
_term.WriteLine("| \\____|_|\\__|_| |_|\\__,_|_.__/ /_/ \\_\\___|\\__|_|\\___/|_| |_|___/ |", ConsoleColor.White);
|
||||
_term.WriteLine("| |", ConsoleColor.White);
|
||||
_term.Write("| ", ConsoleColor.White);
|
||||
_term.Write("Self-hosted runner registration", ConsoleColor.Cyan);
|
||||
_term.WriteLine(" |", ConsoleColor.White);
|
||||
_term.WriteLine("| |", ConsoleColor.White);
|
||||
_term.WriteLine("--------------------------------------------------------------------------------", ConsoleColor.White);
|
||||
|
||||
ArgUtil.Equal(RunMode.Normal, HostContext.RunMode, nameof(HostContext.RunMode));
|
||||
Trace.Info(nameof(ConfigureAsync));
|
||||
if (IsConfigured())
|
||||
{
|
||||
throw new InvalidOperationException("Cannot configure the runner because it is already configured. To reconfigure the runner, run 'config.cmd remove' or './config.sh remove' first.");
|
||||
}
|
||||
|
||||
// Populate proxy setting from commandline args
|
||||
var runnerProxy = HostContext.GetService<IRunnerWebProxy>();
|
||||
bool saveProxySetting = false;
|
||||
string proxyUrl = command.GetProxyUrl();
|
||||
if (!string.IsNullOrEmpty(proxyUrl))
|
||||
{
|
||||
if (!Uri.IsWellFormedUriString(proxyUrl, UriKind.Absolute))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(proxyUrl));
|
||||
}
|
||||
|
||||
Trace.Info("Reset proxy base on commandline args.");
|
||||
string proxyUserName = command.GetProxyUserName();
|
||||
string proxyPassword = command.GetProxyPassword();
|
||||
(runnerProxy as RunnerWebProxy).SetupProxy(proxyUrl, proxyUserName, proxyPassword);
|
||||
saveProxySetting = true;
|
||||
}
|
||||
|
||||
// Populate cert setting from commandline args
|
||||
var runnerCertManager = HostContext.GetService<IRunnerCertificateManager>();
|
||||
bool saveCertSetting = false;
|
||||
bool skipCertValidation = command.GetSkipCertificateValidation();
|
||||
string caCert = command.GetCACertificate();
|
||||
string clientCert = command.GetClientCertificate();
|
||||
string clientCertKey = command.GetClientCertificatePrivateKey();
|
||||
string clientCertArchive = command.GetClientCertificateArchrive();
|
||||
string clientCertPassword = command.GetClientCertificatePassword();
|
||||
|
||||
// We require all Certificate files are under agent root.
|
||||
// So we can set ACL correctly when configure as service
|
||||
if (!string.IsNullOrEmpty(caCert))
|
||||
{
|
||||
caCert = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), caCert);
|
||||
ArgUtil.File(caCert, nameof(caCert));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(clientCert) &&
|
||||
!string.IsNullOrEmpty(clientCertKey) &&
|
||||
!string.IsNullOrEmpty(clientCertArchive))
|
||||
{
|
||||
// Ensure all client cert pieces are there.
|
||||
clientCert = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), clientCert);
|
||||
clientCertKey = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), clientCertKey);
|
||||
clientCertArchive = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), clientCertArchive);
|
||||
|
||||
ArgUtil.File(clientCert, nameof(clientCert));
|
||||
ArgUtil.File(clientCertKey, nameof(clientCertKey));
|
||||
ArgUtil.File(clientCertArchive, nameof(clientCertArchive));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(clientCert) ||
|
||||
!string.IsNullOrEmpty(clientCertKey) ||
|
||||
!string.IsNullOrEmpty(clientCertArchive))
|
||||
{
|
||||
// Print out which args are missing.
|
||||
ArgUtil.NotNullOrEmpty(Constants.Runner.CommandLine.Args.SslClientCert, Constants.Runner.CommandLine.Args.SslClientCert);
|
||||
ArgUtil.NotNullOrEmpty(Constants.Runner.CommandLine.Args.SslClientCertKey, Constants.Runner.CommandLine.Args.SslClientCertKey);
|
||||
ArgUtil.NotNullOrEmpty(Constants.Runner.CommandLine.Args.SslClientCertArchive, Constants.Runner.CommandLine.Args.SslClientCertArchive);
|
||||
}
|
||||
|
||||
if (skipCertValidation || !string.IsNullOrEmpty(caCert) || !string.IsNullOrEmpty(clientCert))
|
||||
{
|
||||
Trace.Info("Reset runner cert setting base on commandline args.");
|
||||
(runnerCertManager as RunnerCertificateManager).SetupCertificate(skipCertValidation, caCert, clientCert, clientCertKey, clientCertArchive, clientCertPassword);
|
||||
saveCertSetting = true;
|
||||
}
|
||||
|
||||
RunnerSettings runnerSettings = new RunnerSettings();
|
||||
|
||||
bool isHostedServer = false;
|
||||
// Loop getting url and creds until you can connect
|
||||
ICredentialProvider credProvider = null;
|
||||
VssCredentials creds = null;
|
||||
_term.WriteSection("Authentication");
|
||||
while (true)
|
||||
{
|
||||
// Get the URL
|
||||
var inputUrl = command.GetUrl();
|
||||
if (!inputUrl.Contains("github.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
runnerSettings.ServerUrl = inputUrl;
|
||||
// Get the credentials
|
||||
credProvider = GetCredentialProvider(command, runnerSettings.ServerUrl);
|
||||
creds = credProvider.GetVssCredentials(HostContext);
|
||||
Trace.Info("legacy vss cred retrieved");
|
||||
}
|
||||
else
|
||||
{
|
||||
runnerSettings.GitHubUrl = inputUrl;
|
||||
var githubToken = command.GetRunnerRegisterToken();
|
||||
GitHubAuthResult authResult = await GetTenantCredential(inputUrl, githubToken);
|
||||
runnerSettings.ServerUrl = authResult.TenantUrl;
|
||||
creds = authResult.ToVssCredentials();
|
||||
Trace.Info("cred retrieved via GitHub auth");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Determine the service deployment type based on connection data. (Hosted/OnPremises)
|
||||
isHostedServer = await IsHostedServer(runnerSettings.ServerUrl, creds);
|
||||
|
||||
// Validate can connect.
|
||||
await _runnerServer.ConnectAsync(new Uri(runnerSettings.ServerUrl), creds);
|
||||
|
||||
_term.WriteLine();
|
||||
_term.WriteSuccessMessage("Connected to GitHub");
|
||||
|
||||
Trace.Info("Test Connection complete.");
|
||||
break;
|
||||
}
|
||||
catch (Exception e) when (!command.Unattended)
|
||||
{
|
||||
_term.WriteError(e);
|
||||
_term.WriteError("Failed to connect. Try again or ctrl-c to quit");
|
||||
_term.WriteLine();
|
||||
}
|
||||
}
|
||||
|
||||
// We want to use the native CSP of the platform for storage, so we use the RSACSP directly
|
||||
RSAParameters publicKey;
|
||||
var keyManager = HostContext.GetService<IRSAKeyManager>();
|
||||
using (var rsa = keyManager.CreateKey())
|
||||
{
|
||||
publicKey = rsa.ExportParameters(false);
|
||||
}
|
||||
|
||||
_term.WriteSection("Runner Registration");
|
||||
|
||||
//Get all the agent pools, and select the first private pool
|
||||
List<TaskAgentPool> agentPools = await _runnerServer.GetAgentPoolsAsync();
|
||||
TaskAgentPool agentPool = agentPools?.Where(x => x.IsHosted == false).FirstOrDefault();
|
||||
|
||||
if (agentPool == null)
|
||||
{
|
||||
throw new TaskAgentPoolNotFoundException($"Could not find any private pool. Contact support.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info("Found a private pool with id {1} and name {2}", agentPool.Id, agentPool.Name);
|
||||
runnerSettings.PoolId = agentPool.Id;
|
||||
runnerSettings.PoolName = agentPool.Name;
|
||||
}
|
||||
|
||||
TaskAgent agent;
|
||||
while (true)
|
||||
{
|
||||
runnerSettings.AgentName = command.GetAgentName();
|
||||
|
||||
// Get the system capabilities.
|
||||
Dictionary<string, string> systemCapabilities = await HostContext.GetService<ICapabilitiesManager>().GetCapabilitiesAsync(runnerSettings, CancellationToken.None);
|
||||
|
||||
_term.WriteLine();
|
||||
|
||||
var agents = await _runnerServer.GetAgentsAsync(runnerSettings.PoolId, runnerSettings.AgentName);
|
||||
Trace.Verbose("Returns {0} agents", agents.Count);
|
||||
agent = agents.FirstOrDefault();
|
||||
if (agent != null)
|
||||
{
|
||||
_term.WriteLine("A runner exists with the same name", ConsoleColor.Yellow);
|
||||
if (command.GetReplace())
|
||||
{
|
||||
// Update existing agent with new PublicKey, agent version and SystemCapabilities.
|
||||
agent = UpdateExistingAgent(agent, publicKey, systemCapabilities);
|
||||
|
||||
try
|
||||
{
|
||||
agent = await _runnerServer.UpdateAgentAsync(runnerSettings.PoolId, agent);
|
||||
_term.WriteSuccessMessage("Successfully replaced the runner");
|
||||
break;
|
||||
}
|
||||
catch (Exception e) when (!command.Unattended)
|
||||
{
|
||||
_term.WriteError(e);
|
||||
_term.WriteError("Failed to replace the runner. Try again or ctrl-c to quit");
|
||||
}
|
||||
}
|
||||
else if (command.Unattended)
|
||||
{
|
||||
// if not replace and it is unattended config.
|
||||
throw new TaskAgentExistsException($"Pool {runnerSettings.PoolId} already contains a runner with name {runnerSettings.AgentName}.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Create a new agent.
|
||||
agent = CreateNewAgent(runnerSettings.AgentName, publicKey, systemCapabilities);
|
||||
|
||||
try
|
||||
{
|
||||
agent = await _runnerServer.AddAgentAsync(runnerSettings.PoolId, agent);
|
||||
_term.WriteSuccessMessage("Runner successfully added");
|
||||
break;
|
||||
}
|
||||
catch (Exception e) when (!command.Unattended)
|
||||
{
|
||||
_term.WriteError(e);
|
||||
_term.WriteError("Failed to add the runner. Try again or ctrl-c to quit");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Add Agent Id to settings
|
||||
runnerSettings.AgentId = agent.Id;
|
||||
|
||||
// respect the serverUrl resolve by server.
|
||||
// in case of agent configured using collection url instead of account url.
|
||||
string agentServerUrl;
|
||||
if (agent.Properties.TryGetValidatedValue<string>("ServerUrl", out agentServerUrl) &&
|
||||
!string.IsNullOrEmpty(agentServerUrl))
|
||||
{
|
||||
Trace.Info($"Agent server url resolve by server: '{agentServerUrl}'.");
|
||||
|
||||
// we need make sure the Schema/Host/Port component of the url remain the same.
|
||||
UriBuilder inputServerUrl = new UriBuilder(runnerSettings.ServerUrl);
|
||||
UriBuilder serverReturnedServerUrl = new UriBuilder(agentServerUrl);
|
||||
if (Uri.Compare(inputServerUrl.Uri, serverReturnedServerUrl.Uri, UriComponents.SchemeAndServer, UriFormat.Unescaped, StringComparison.OrdinalIgnoreCase) != 0)
|
||||
{
|
||||
inputServerUrl.Path = serverReturnedServerUrl.Path;
|
||||
Trace.Info($"Replace server returned url's scheme://host:port component with user input server url's scheme://host:port: '{inputServerUrl.Uri.AbsoluteUri}'.");
|
||||
runnerSettings.ServerUrl = inputServerUrl.Uri.AbsoluteUri;
|
||||
}
|
||||
else
|
||||
{
|
||||
runnerSettings.ServerUrl = agentServerUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// See if the server supports our OAuth key exchange for credentials
|
||||
if (agent.Authorization != null &&
|
||||
agent.Authorization.ClientId != Guid.Empty &&
|
||||
agent.Authorization.AuthorizationUrl != null)
|
||||
{
|
||||
UriBuilder configServerUrl = new UriBuilder(runnerSettings.ServerUrl);
|
||||
UriBuilder oauthEndpointUrlBuilder = new UriBuilder(agent.Authorization.AuthorizationUrl);
|
||||
if (!isHostedServer && Uri.Compare(configServerUrl.Uri, oauthEndpointUrlBuilder.Uri, UriComponents.SchemeAndServer, UriFormat.Unescaped, StringComparison.OrdinalIgnoreCase) != 0)
|
||||
{
|
||||
oauthEndpointUrlBuilder.Scheme = configServerUrl.Scheme;
|
||||
oauthEndpointUrlBuilder.Host = configServerUrl.Host;
|
||||
oauthEndpointUrlBuilder.Port = configServerUrl.Port;
|
||||
Trace.Info($"Set oauth endpoint url's scheme://host:port component to match runner configure url's scheme://host:port: '{oauthEndpointUrlBuilder.Uri.AbsoluteUri}'.");
|
||||
}
|
||||
|
||||
var credentialData = new CredentialData
|
||||
{
|
||||
Scheme = Constants.Configuration.OAuth,
|
||||
Data =
|
||||
{
|
||||
{ "clientId", agent.Authorization.ClientId.ToString("D") },
|
||||
{ "authorizationUrl", agent.Authorization.AuthorizationUrl.AbsoluteUri },
|
||||
{ "oauthEndpointUrl", oauthEndpointUrlBuilder.Uri.AbsoluteUri },
|
||||
},
|
||||
};
|
||||
|
||||
// Save the negotiated OAuth credential data
|
||||
_store.SaveCredential(credentialData);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
throw new NotSupportedException("Message queue listen OAuth token.");
|
||||
}
|
||||
|
||||
// Testing agent connection, detect any protential connection issue, like local clock skew that cause OAuth token expired.
|
||||
var credMgr = HostContext.GetService<ICredentialManager>();
|
||||
VssCredentials credential = credMgr.LoadCredentials();
|
||||
try
|
||||
{
|
||||
await _runnerServer.ConnectAsync(new Uri(runnerSettings.ServerUrl), credential);
|
||||
_term.WriteSuccessMessage("Runner connection is good");
|
||||
}
|
||||
catch (VssOAuthTokenRequestException ex) when (ex.Message.Contains("Current server time is"))
|
||||
{
|
||||
// there are two exception messages server send that indicate clock skew.
|
||||
// 1. The bearer token expired on {jwt.ValidTo}. Current server time is {DateTime.UtcNow}.
|
||||
// 2. The bearer token is not valid until {jwt.ValidFrom}. Current server time is {DateTime.UtcNow}.
|
||||
Trace.Error("Catch exception during test agent connection.");
|
||||
Trace.Error(ex);
|
||||
throw new Exception("The local machine's clock may be out of sync with the server time by more than five minutes. Please sync your clock with your domain or internet time and try again.");
|
||||
}
|
||||
|
||||
_term.WriteSection("Runner settings");
|
||||
|
||||
// We will Combine() what's stored with root. Defaults to string a relative path
|
||||
runnerSettings.WorkFolder = command.GetWork();
|
||||
|
||||
// notificationPipeName for Hosted agent provisioner.
|
||||
runnerSettings.NotificationPipeName = command.GetNotificationPipeName();
|
||||
|
||||
runnerSettings.MonitorSocketAddress = command.GetMonitorSocketAddress();
|
||||
|
||||
runnerSettings.NotificationSocketAddress = command.GetNotificationSocketAddress();
|
||||
|
||||
_store.SaveSettings(runnerSettings);
|
||||
|
||||
if (saveProxySetting)
|
||||
{
|
||||
Trace.Info("Save proxy setting to disk.");
|
||||
(runnerProxy as RunnerWebProxy).SaveProxySetting();
|
||||
}
|
||||
|
||||
if (saveCertSetting)
|
||||
{
|
||||
Trace.Info("Save agent cert setting to disk.");
|
||||
(runnerCertManager as RunnerCertificateManager).SaveCertificateSetting();
|
||||
}
|
||||
|
||||
_term.WriteLine();
|
||||
_term.WriteSuccessMessage("Settings Saved.");
|
||||
_term.WriteLine();
|
||||
|
||||
bool saveRuntimeOptions = false;
|
||||
var runtimeOptions = new RunnerRuntimeOptions();
|
||||
#if OS_WINDOWS
|
||||
if (command.GitUseSChannel)
|
||||
{
|
||||
saveRuntimeOptions = true;
|
||||
runtimeOptions.GitUseSecureChannel = true;
|
||||
}
|
||||
#endif
|
||||
if (saveRuntimeOptions)
|
||||
{
|
||||
Trace.Info("Save agent runtime options to disk.");
|
||||
_store.SaveRunnerRuntimeOptions(runtimeOptions);
|
||||
}
|
||||
|
||||
#if OS_WINDOWS
|
||||
// config windows service
|
||||
bool runAsService = command.GetRunAsService();
|
||||
if (runAsService)
|
||||
{
|
||||
Trace.Info("Configuring to run the agent as service");
|
||||
var serviceControlManager = HostContext.GetService<IWindowsServiceControlManager>();
|
||||
serviceControlManager.ConfigureService(runnerSettings, command);
|
||||
}
|
||||
|
||||
#elif OS_LINUX || OS_OSX
|
||||
// generate service config script for OSX and Linux, GenerateScripts() will no-opt on windows.
|
||||
var serviceControlManager = HostContext.GetService<ILinuxServiceControlManager>();
|
||||
serviceControlManager.GenerateScripts(runnerSettings);
|
||||
#endif
|
||||
}
|
||||
|
||||
public async Task UnconfigureAsync(CommandSettings command)
|
||||
{
|
||||
ArgUtil.Equal(RunMode.Normal, HostContext.RunMode, nameof(HostContext.RunMode));
|
||||
string currentAction = string.Empty;
|
||||
|
||||
_term.WriteSection("Runner removal");
|
||||
|
||||
try
|
||||
{
|
||||
//stop, uninstall service and remove service config file
|
||||
if (_store.IsServiceConfigured())
|
||||
{
|
||||
currentAction = "Removing service";
|
||||
_term.WriteLine(currentAction);
|
||||
#if OS_WINDOWS
|
||||
var serviceControlManager = HostContext.GetService<IWindowsServiceControlManager>();
|
||||
serviceControlManager.UnconfigureService();
|
||||
|
||||
_term.WriteLine();
|
||||
_term.WriteSuccessMessage("Runner service removed");
|
||||
#elif OS_LINUX
|
||||
// unconfig system D service first
|
||||
throw new Exception("Unconfigure service first");
|
||||
#elif OS_OSX
|
||||
// unconfig osx service first
|
||||
throw new Exception("Unconfigure service first");
|
||||
#endif
|
||||
}
|
||||
|
||||
//delete agent from the server
|
||||
currentAction = "Removing runner from the server";
|
||||
bool isConfigured = _store.IsConfigured();
|
||||
bool hasCredentials = _store.HasCredentials();
|
||||
if (isConfigured && hasCredentials)
|
||||
{
|
||||
RunnerSettings settings = _store.GetSettings();
|
||||
var credentialManager = HostContext.GetService<ICredentialManager>();
|
||||
|
||||
// Get the credentials
|
||||
VssCredentials creds = null;
|
||||
if (string.IsNullOrEmpty(settings.GitHubUrl))
|
||||
{
|
||||
var credProvider = GetCredentialProvider(command, settings.ServerUrl);
|
||||
creds = credProvider.GetVssCredentials(HostContext);
|
||||
Trace.Info("legacy vss cred retrieved");
|
||||
}
|
||||
else
|
||||
{
|
||||
var githubToken = command.GetToken();
|
||||
GitHubAuthResult authResult = await GetTenantCredential(settings.GitHubUrl, githubToken);
|
||||
creds = authResult.ToVssCredentials();
|
||||
Trace.Info("cred retrieved via GitHub auth");
|
||||
}
|
||||
|
||||
// Determine the service deployment type based on connection data. (Hosted/OnPremises)
|
||||
bool isHostedServer = await IsHostedServer(settings.ServerUrl, creds);
|
||||
await _runnerServer.ConnectAsync(new Uri(settings.ServerUrl), creds);
|
||||
|
||||
var agents = await _runnerServer.GetAgentsAsync(settings.PoolId, settings.AgentName);
|
||||
Trace.Verbose("Returns {0} agents", agents.Count);
|
||||
TaskAgent agent = agents.FirstOrDefault();
|
||||
if (agent == null)
|
||||
{
|
||||
_term.WriteLine("Does not exist. Skipping " + currentAction);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _runnerServer.DeleteAgentAsync(settings.PoolId, settings.AgentId);
|
||||
|
||||
_term.WriteLine();
|
||||
_term.WriteSuccessMessage("Runner removed successfully");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_term.WriteLine("Cannot connect to server, because config files are missing. Skipping removing runner from the server.");
|
||||
}
|
||||
|
||||
//delete credential config files
|
||||
currentAction = "Removing .credentials";
|
||||
if (hasCredentials)
|
||||
{
|
||||
_store.DeleteCredential();
|
||||
var keyManager = HostContext.GetService<IRSAKeyManager>();
|
||||
keyManager.DeleteKey();
|
||||
_term.WriteSuccessMessage("Removed .credentials");
|
||||
}
|
||||
else
|
||||
{
|
||||
_term.WriteLine("Does not exist. Skipping " + currentAction);
|
||||
}
|
||||
|
||||
//delete settings config file
|
||||
currentAction = "Removing .runner";
|
||||
if (isConfigured)
|
||||
{
|
||||
// delete proxy setting
|
||||
(HostContext.GetService<IRunnerWebProxy>() as RunnerWebProxy).DeleteProxySetting();
|
||||
|
||||
// delete agent cert setting
|
||||
(HostContext.GetService<IRunnerCertificateManager>() as RunnerCertificateManager).DeleteCertificateSetting();
|
||||
|
||||
// delete agent runtime option
|
||||
_store.DeleteRunnerRuntimeOptions();
|
||||
|
||||
_store.DeleteSettings();
|
||||
_term.WriteSuccessMessage("Removed .runner");
|
||||
}
|
||||
else
|
||||
{
|
||||
_term.WriteLine("Does not exist. Skipping " + currentAction);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_term.WriteError("Failed: " + currentAction);
|
||||
throw;
|
||||
}
|
||||
|
||||
_term.WriteLine();
|
||||
}
|
||||
|
||||
private ICredentialProvider GetCredentialProvider(CommandSettings command, string serverUrl)
|
||||
{
|
||||
Trace.Info(nameof(GetCredentialProvider));
|
||||
|
||||
var credentialManager = HostContext.GetService<ICredentialManager>();
|
||||
string authType = command.GetAuth(defaultValue: Constants.Configuration.AAD);
|
||||
|
||||
// Create the credential.
|
||||
Trace.Info("Creating credential for auth: {0}", authType);
|
||||
var provider = credentialManager.GetCredentialProvider(authType);
|
||||
if (provider.RequireInteractive && command.Unattended)
|
||||
{
|
||||
throw new NotSupportedException($"Authentication type '{authType}' is not supported for unattended configuration.");
|
||||
}
|
||||
|
||||
provider.EnsureCredential(HostContext, command, serverUrl);
|
||||
return provider;
|
||||
}
|
||||
|
||||
|
||||
private TaskAgent UpdateExistingAgent(TaskAgent agent, RSAParameters publicKey, Dictionary<string, string> systemCapabilities)
|
||||
{
|
||||
ArgUtil.NotNull(agent, nameof(agent));
|
||||
agent.Authorization = new TaskAgentAuthorization
|
||||
{
|
||||
PublicKey = new TaskAgentPublicKey(publicKey.Exponent, publicKey.Modulus),
|
||||
};
|
||||
|
||||
// update - update instead of delete so we don't lose user capabilities etc...
|
||||
agent.Version = BuildConstants.RunnerPackage.Version;
|
||||
agent.OSDescription = RuntimeInformation.OSDescription;
|
||||
|
||||
foreach (KeyValuePair<string, string> capability in systemCapabilities)
|
||||
{
|
||||
agent.SystemCapabilities[capability.Key] = capability.Value ?? string.Empty;
|
||||
}
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
private TaskAgent CreateNewAgent(string agentName, RSAParameters publicKey, Dictionary<string, string> systemCapabilities)
|
||||
{
|
||||
TaskAgent agent = new TaskAgent(agentName)
|
||||
{
|
||||
Authorization = new TaskAgentAuthorization
|
||||
{
|
||||
PublicKey = new TaskAgentPublicKey(publicKey.Exponent, publicKey.Modulus),
|
||||
},
|
||||
MaxParallelism = 1,
|
||||
Version = BuildConstants.RunnerPackage.Version,
|
||||
OSDescription = RuntimeInformation.OSDescription,
|
||||
};
|
||||
|
||||
foreach (KeyValuePair<string, string> capability in systemCapabilities)
|
||||
{
|
||||
agent.SystemCapabilities[capability.Key] = capability.Value ?? string.Empty;
|
||||
}
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
private async Task<bool> IsHostedServer(string serverUrl, VssCredentials credentials)
|
||||
{
|
||||
// Determine the service deployment type based on connection data. (Hosted/OnPremises)
|
||||
var locationServer = HostContext.GetService<ILocationServer>();
|
||||
VssConnection connection = VssUtil.CreateConnection(new Uri(serverUrl), credentials);
|
||||
await locationServer.ConnectAsync(connection);
|
||||
try
|
||||
{
|
||||
var connectionData = await locationServer.GetConnectionDataAsync();
|
||||
Trace.Info($"Server deployment type: {connectionData.DeploymentType}");
|
||||
return connectionData.DeploymentType.HasFlag(DeploymentFlags.Hosted);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Since the DeploymentType is Enum, deserialization exception means there is a new Enum member been added.
|
||||
// It's more likely to be Hosted since OnPremises is always behind and customer can update their agent if are on-prem
|
||||
Trace.Error(ex);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<GitHubAuthResult> GetTenantCredential(string githubUrl, string githubToken)
|
||||
{
|
||||
var gitHubUrl = new UriBuilder(githubUrl);
|
||||
var githubApiUrl = $"https://api.github.com/repos/{gitHubUrl.Path.Trim('/')}/actions-runners/registration";
|
||||
using (var httpClientHandler = HostContext.CreateHttpClientHandler())
|
||||
using (var httpClient = new HttpClient(httpClientHandler))
|
||||
{
|
||||
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("RemoteAuth", githubToken);
|
||||
httpClient.DefaultRequestHeaders.UserAgent.Add(HostContext.UserAgent);
|
||||
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github.shuri-preview+json"));
|
||||
var response = await httpClient.PostAsync(githubApiUrl, new StringContent("", null, "application/json"));
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
Trace.Info($"Http response code: {response.StatusCode} from 'POST {githubApiUrl}'");
|
||||
var jsonResponse = await response.Content.ReadAsStringAsync();
|
||||
return StringUtil.ConvertFromJson<GitHubAuthResult>(jsonResponse);
|
||||
}
|
||||
else
|
||||
{
|
||||
_term.WriteError($"Http response code: {response.StatusCode} from 'POST {githubApiUrl}'");
|
||||
var errorResponse = await response.Content.ReadAsStringAsync();
|
||||
_term.WriteError(errorResponse);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
91
src/Runner.Listener/Configuration/CredentialManager.cs
Normal file
91
src/Runner.Listener/Configuration/CredentialManager.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.Serialization;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Services.Common;
|
||||
using GitHub.Services.OAuth;
|
||||
|
||||
namespace GitHub.Runner.Listener.Configuration
|
||||
{
|
||||
// TODO: Refactor extension manager to enable using it from the agent process.
|
||||
[ServiceLocator(Default = typeof(CredentialManager))]
|
||||
public interface ICredentialManager : IRunnerService
|
||||
{
|
||||
ICredentialProvider GetCredentialProvider(string credType);
|
||||
VssCredentials LoadCredentials();
|
||||
}
|
||||
|
||||
public class CredentialManager : RunnerService, ICredentialManager
|
||||
{
|
||||
public static readonly Dictionary<string, Type> CredentialTypes = new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
{ Constants.Configuration.AAD, typeof(AadDeviceCodeAccessToken)},
|
||||
{ Constants.Configuration.PAT, typeof(PersonalAccessToken)},
|
||||
{ Constants.Configuration.OAuth, typeof(OAuthCredential)},
|
||||
{ Constants.Configuration.OAuthAccessToken, typeof(OAuthAccessTokenCredential)},
|
||||
};
|
||||
|
||||
public ICredentialProvider GetCredentialProvider(string credType)
|
||||
{
|
||||
Trace.Info(nameof(GetCredentialProvider));
|
||||
Trace.Info("Creating type {0}", credType);
|
||||
|
||||
if (!CredentialTypes.ContainsKey(credType))
|
||||
{
|
||||
throw new ArgumentException("Invalid Credential Type");
|
||||
}
|
||||
|
||||
Trace.Info("Creating credential type: {0}", credType);
|
||||
var creds = Activator.CreateInstance(CredentialTypes[credType]) as ICredentialProvider;
|
||||
Trace.Verbose("Created credential type");
|
||||
return creds;
|
||||
}
|
||||
|
||||
public VssCredentials LoadCredentials()
|
||||
{
|
||||
IConfigurationStore store = HostContext.GetService<IConfigurationStore>();
|
||||
|
||||
if (!store.HasCredentials())
|
||||
{
|
||||
throw new InvalidOperationException("Credentials not stored. Must reconfigure.");
|
||||
}
|
||||
|
||||
CredentialData credData = store.GetCredentials();
|
||||
ICredentialProvider credProv = GetCredentialProvider(credData.Scheme);
|
||||
credProv.CredentialData = credData;
|
||||
|
||||
VssCredentials creds = credProv.GetVssCredentials(HostContext);
|
||||
|
||||
return creds;
|
||||
}
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public sealed class GitHubAuthResult
|
||||
{
|
||||
[DataMember(Name = "url")]
|
||||
public string TenantUrl { get; set; }
|
||||
|
||||
[DataMember(Name = "token_schema")]
|
||||
public string TokenSchema { get; set; }
|
||||
|
||||
[DataMember(Name = "token")]
|
||||
public string Token { get; set; }
|
||||
|
||||
public VssCredentials ToVssCredentials()
|
||||
{
|
||||
ArgUtil.NotNullOrEmpty(TokenSchema, nameof(TokenSchema));
|
||||
ArgUtil.NotNullOrEmpty(Token, nameof(Token));
|
||||
|
||||
if (string.Equals(TokenSchema, "OAuthAccessToken", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new VssCredentials(null, new VssOAuthAccessTokenCredential(Token), CredentialPromptType.DoNotPrompt);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new NotSupportedException($"Not supported token schema: {TokenSchema}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
231
src/Runner.Listener/Configuration/CredentialProvider.cs
Normal file
231
src/Runner.Listener/Configuration/CredentialProvider.cs
Normal file
@@ -0,0 +1,231 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using Microsoft.IdentityModel.Clients.ActiveDirectory;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Services.Client;
|
||||
using GitHub.Services.Common;
|
||||
using GitHub.Services.WebApi;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Services.OAuth;
|
||||
|
||||
namespace GitHub.Runner.Listener.Configuration
|
||||
{
|
||||
public interface ICredentialProvider
|
||||
{
|
||||
Boolean RequireInteractive { get; }
|
||||
CredentialData CredentialData { get; set; }
|
||||
VssCredentials GetVssCredentials(IHostContext context);
|
||||
void EnsureCredential(IHostContext context, CommandSettings command, string serverUrl);
|
||||
}
|
||||
|
||||
public abstract class CredentialProvider : ICredentialProvider
|
||||
{
|
||||
public CredentialProvider(string scheme)
|
||||
{
|
||||
CredentialData = new CredentialData();
|
||||
CredentialData.Scheme = scheme;
|
||||
}
|
||||
|
||||
public virtual Boolean RequireInteractive => false;
|
||||
public CredentialData CredentialData { get; set; }
|
||||
|
||||
public abstract VssCredentials GetVssCredentials(IHostContext context);
|
||||
public abstract void EnsureCredential(IHostContext context, CommandSettings command, string serverUrl);
|
||||
}
|
||||
|
||||
public sealed class AadDeviceCodeAccessToken : CredentialProvider
|
||||
{
|
||||
private string _azureDevOpsClientId = "97877f11-0fc6-4aee-b1ff-febb0519dd00";
|
||||
|
||||
public override Boolean RequireInteractive => true;
|
||||
|
||||
public AadDeviceCodeAccessToken() : base(Constants.Configuration.AAD) { }
|
||||
|
||||
public override VssCredentials GetVssCredentials(IHostContext context)
|
||||
{
|
||||
ArgUtil.NotNull(context, nameof(context));
|
||||
Tracing trace = context.GetTrace(nameof(AadDeviceCodeAccessToken));
|
||||
trace.Info(nameof(GetVssCredentials));
|
||||
ArgUtil.NotNull(CredentialData, nameof(CredentialData));
|
||||
|
||||
CredentialData.Data.TryGetValue(Constants.Runner.CommandLine.Args.Url, out string serverUrl);
|
||||
ArgUtil.NotNullOrEmpty(serverUrl, nameof(serverUrl));
|
||||
|
||||
var tenantAuthorityUrl = GetTenantAuthorityUrl(context, serverUrl);
|
||||
if (tenantAuthorityUrl == null)
|
||||
{
|
||||
throw new NotSupportedException($"'{serverUrl}' is not backed by Azure Active Directory.");
|
||||
}
|
||||
|
||||
LoggerCallbackHandler.LogCallback = ((LogLevel level, string message, bool containsPii) =>
|
||||
{
|
||||
switch (level)
|
||||
{
|
||||
case LogLevel.Information:
|
||||
trace.Info(message);
|
||||
break;
|
||||
case LogLevel.Error:
|
||||
trace.Error(message);
|
||||
break;
|
||||
case LogLevel.Warning:
|
||||
trace.Warning(message);
|
||||
break;
|
||||
default:
|
||||
trace.Verbose(message);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
LoggerCallbackHandler.UseDefaultLogging = false;
|
||||
AuthenticationContext ctx = new AuthenticationContext(tenantAuthorityUrl.AbsoluteUri);
|
||||
var queryParameters = $"redirect_uri={Uri.EscapeDataString(new Uri(serverUrl).GetLeftPart(UriPartial.Authority))}";
|
||||
DeviceCodeResult codeResult = ctx.AcquireDeviceCodeAsync("https://management.core.windows.net/", _azureDevOpsClientId, queryParameters).GetAwaiter().GetResult();
|
||||
|
||||
var term = context.GetService<ITerminal>();
|
||||
term.WriteLine($"Please finish AAD device code flow in browser ({codeResult.VerificationUrl}), user code: {codeResult.UserCode}");
|
||||
if (string.Equals(CredentialData.Data[Constants.Runner.CommandLine.Flags.LaunchBrowser], bool.TrueString, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
#if OS_WINDOWS
|
||||
Process.Start(new ProcessStartInfo() { FileName = codeResult.VerificationUrl, UseShellExecute = true });
|
||||
#elif OS_LINUX
|
||||
Process.Start(new ProcessStartInfo() { FileName = "xdg-open", Arguments = codeResult.VerificationUrl });
|
||||
#else
|
||||
Process.Start(new ProcessStartInfo() { FileName = "open", Arguments = codeResult.VerificationUrl });
|
||||
#endif
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// not able to open browser, ex: xdg-open/open is not installed.
|
||||
trace.Error(ex);
|
||||
term.WriteLine($"Fail to open browser. {codeResult.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
AuthenticationResult authResult = ctx.AcquireTokenByDeviceCodeAsync(codeResult).GetAwaiter().GetResult();
|
||||
ArgUtil.NotNull(authResult, nameof(authResult));
|
||||
trace.Info($"receive AAD auth result with {authResult.AccessTokenType} token");
|
||||
|
||||
var aadCred = new VssAadCredential(new VssAadToken(authResult));
|
||||
VssCredentials creds = new VssCredentials(null, aadCred, CredentialPromptType.DoNotPrompt);
|
||||
trace.Info("cred created");
|
||||
|
||||
return creds;
|
||||
}
|
||||
|
||||
public override void EnsureCredential(IHostContext context, CommandSettings command, string serverUrl)
|
||||
{
|
||||
ArgUtil.NotNull(context, nameof(context));
|
||||
Tracing trace = context.GetTrace(nameof(AadDeviceCodeAccessToken));
|
||||
trace.Info(nameof(EnsureCredential));
|
||||
ArgUtil.NotNull(command, nameof(command));
|
||||
CredentialData.Data[Constants.Runner.CommandLine.Args.Url] = serverUrl;
|
||||
CredentialData.Data[Constants.Runner.CommandLine.Flags.LaunchBrowser] = command.GetAutoLaunchBrowser().ToString();
|
||||
}
|
||||
|
||||
private Uri GetTenantAuthorityUrl(IHostContext context, string serverUrl)
|
||||
{
|
||||
using (var client = new HttpClient(context.CreateHttpClientHandler()))
|
||||
{
|
||||
client.DefaultRequestHeaders.Accept.Clear();
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
client.DefaultRequestHeaders.Add("X-TFS-FedAuthRedirect", "Suppress");
|
||||
client.DefaultRequestHeaders.UserAgent.Clear();
|
||||
client.DefaultRequestHeaders.UserAgent.AddRange(VssClientHttpRequestSettings.Default.UserAgent);
|
||||
var requestMessage = new HttpRequestMessage(HttpMethod.Head, $"{serverUrl.Trim('/')}/_apis/connectiondata");
|
||||
var response = client.SendAsync(requestMessage).GetAwaiter().GetResult();
|
||||
|
||||
// Get the tenant from the Login URL, MSA backed accounts will not return `Bearer` www-authenticate header.
|
||||
var bearerResult = response.Headers.WwwAuthenticate.Where(p => p.Scheme.Equals("Bearer", StringComparison.OrdinalIgnoreCase)).FirstOrDefault();
|
||||
if (bearerResult != null && bearerResult.Parameter.StartsWith("authorization_uri=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var authorizationUri = bearerResult.Parameter.Substring("authorization_uri=".Length);
|
||||
if (Uri.TryCreate(authorizationUri, UriKind.Absolute, out Uri aadTenantUrl))
|
||||
{
|
||||
return aadTenantUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class OAuthAccessTokenCredential : CredentialProvider
|
||||
{
|
||||
public OAuthAccessTokenCredential() : base(Constants.Configuration.OAuthAccessToken) { }
|
||||
|
||||
public override VssCredentials GetVssCredentials(IHostContext context)
|
||||
{
|
||||
ArgUtil.NotNull(context, nameof(context));
|
||||
Tracing trace = context.GetTrace(nameof(OAuthAccessTokenCredential));
|
||||
trace.Info(nameof(GetVssCredentials));
|
||||
ArgUtil.NotNull(CredentialData, nameof(CredentialData));
|
||||
string token;
|
||||
if (!CredentialData.Data.TryGetValue(Constants.Runner.CommandLine.Args.Token, out token))
|
||||
{
|
||||
token = null;
|
||||
}
|
||||
|
||||
ArgUtil.NotNullOrEmpty(token, nameof(token));
|
||||
|
||||
trace.Info("token retrieved: {0} chars", token.Length);
|
||||
VssCredentials creds = new VssCredentials(null, new VssOAuthAccessTokenCredential(token), CredentialPromptType.DoNotPrompt);
|
||||
trace.Info("cred created");
|
||||
|
||||
return creds;
|
||||
}
|
||||
|
||||
public override void EnsureCredential(IHostContext context, CommandSettings command, string serverUrl)
|
||||
{
|
||||
ArgUtil.NotNull(context, nameof(context));
|
||||
Tracing trace = context.GetTrace(nameof(OAuthAccessTokenCredential));
|
||||
trace.Info(nameof(EnsureCredential));
|
||||
ArgUtil.NotNull(command, nameof(command));
|
||||
CredentialData.Data[Constants.Runner.CommandLine.Args.Token] = command.GetToken();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PersonalAccessToken : CredentialProvider
|
||||
{
|
||||
public PersonalAccessToken() : base(Constants.Configuration.PAT) { }
|
||||
|
||||
public override VssCredentials GetVssCredentials(IHostContext context)
|
||||
{
|
||||
ArgUtil.NotNull(context, nameof(context));
|
||||
Tracing trace = context.GetTrace(nameof(PersonalAccessToken));
|
||||
trace.Info(nameof(GetVssCredentials));
|
||||
ArgUtil.NotNull(CredentialData, nameof(CredentialData));
|
||||
string token;
|
||||
if (!CredentialData.Data.TryGetValue(Constants.Runner.CommandLine.Args.Token, out token))
|
||||
{
|
||||
token = null;
|
||||
}
|
||||
|
||||
ArgUtil.NotNullOrEmpty(token, nameof(token));
|
||||
|
||||
trace.Info("token retrieved: {0} chars", token.Length);
|
||||
|
||||
// PAT uses a basic credential
|
||||
VssBasicCredential basicCred = new VssBasicCredential("ActionsRunner", token);
|
||||
VssCredentials creds = new VssCredentials(null, basicCred, CredentialPromptType.DoNotPrompt);
|
||||
trace.Info("cred created");
|
||||
|
||||
return creds;
|
||||
}
|
||||
|
||||
public override void EnsureCredential(IHostContext context, CommandSettings command, string serverUrl)
|
||||
{
|
||||
ArgUtil.NotNull(context, nameof(context));
|
||||
Tracing trace = context.GetTrace(nameof(PersonalAccessToken));
|
||||
trace.Info(nameof(EnsureCredential));
|
||||
ArgUtil.NotNull(command, nameof(command));
|
||||
CredentialData.Data[Constants.Runner.CommandLine.Args.Token] = command.GetToken();
|
||||
}
|
||||
}
|
||||
}
|
||||
108
src/Runner.Listener/Configuration/IRSAKeyManager.cs
Normal file
108
src/Runner.Listener/Configuration/IRSAKeyManager.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using System;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Security.Cryptography;
|
||||
using GitHub.Runner.Common;
|
||||
|
||||
namespace GitHub.Runner.Listener.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages an RSA key for the agent using the most appropriate store for the target platform.
|
||||
/// </summary>
|
||||
#if OS_WINDOWS
|
||||
[ServiceLocator(Default = typeof(RSAEncryptedFileKeyManager))]
|
||||
#else
|
||||
[ServiceLocator(Default = typeof(RSAFileKeyManager))]
|
||||
#endif
|
||||
public interface IRSAKeyManager : IRunnerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new <c>RSACryptoServiceProvider</c> instance for the current agent. If a key file is found then the current
|
||||
/// key is returned to the caller.
|
||||
/// </summary>
|
||||
/// <returns>An <c>RSACryptoServiceProvider</c> instance representing the key for the agent</returns>
|
||||
RSACryptoServiceProvider CreateKey();
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the RSA key managed by the key manager.
|
||||
/// </summary>
|
||||
void DeleteKey();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <c>RSACryptoServiceProvider</c> instance currently stored by the key manager.
|
||||
/// </summary>
|
||||
/// <returns>An <c>RSACryptoServiceProvider</c> instance representing the key for the agent</returns>
|
||||
/// <exception cref="CryptographicException">No key exists in the store</exception>
|
||||
RSACryptoServiceProvider GetKey();
|
||||
}
|
||||
|
||||
// Newtonsoft 10 is not working properly with dotnet RSAParameters class
|
||||
// RSAParameters has fields marked as [NonSerialized] which cause we loss those fields after serialize to JSON
|
||||
// https://github.com/JamesNK/Newtonsoft.Json/issues/1517
|
||||
// https://github.com/dotnet/corefx/issues/23847
|
||||
// As workaround, we create our own RSAParameters class without any [NonSerialized] attributes.
|
||||
[Serializable]
|
||||
internal class RSAParametersSerializable : ISerializable
|
||||
{
|
||||
private RSAParameters _rsaParameters;
|
||||
|
||||
public RSAParameters RSAParameters
|
||||
{
|
||||
get
|
||||
{
|
||||
return _rsaParameters;
|
||||
}
|
||||
}
|
||||
|
||||
public RSAParametersSerializable(RSAParameters rsaParameters)
|
||||
{
|
||||
_rsaParameters = rsaParameters;
|
||||
}
|
||||
|
||||
private RSAParametersSerializable()
|
||||
{
|
||||
}
|
||||
|
||||
public byte[] D { get { return _rsaParameters.D; } set { _rsaParameters.D = value; } }
|
||||
|
||||
public byte[] DP { get { return _rsaParameters.DP; } set { _rsaParameters.DP = value; } }
|
||||
|
||||
public byte[] DQ { get { return _rsaParameters.DQ; } set { _rsaParameters.DQ = value; } }
|
||||
|
||||
public byte[] Exponent { get { return _rsaParameters.Exponent; } set { _rsaParameters.Exponent = value; } }
|
||||
|
||||
public byte[] InverseQ { get { return _rsaParameters.InverseQ; } set { _rsaParameters.InverseQ = value; } }
|
||||
|
||||
public byte[] Modulus { get { return _rsaParameters.Modulus; } set { _rsaParameters.Modulus = value; } }
|
||||
|
||||
public byte[] P { get { return _rsaParameters.P; } set { _rsaParameters.P = value; } }
|
||||
|
||||
public byte[] Q { get { return _rsaParameters.Q; } set { _rsaParameters.Q = value; } }
|
||||
|
||||
public RSAParametersSerializable(SerializationInfo information, StreamingContext context)
|
||||
{
|
||||
_rsaParameters = new RSAParameters()
|
||||
{
|
||||
D = (byte[])information.GetValue("d", typeof(byte[])),
|
||||
DP = (byte[])information.GetValue("dp", typeof(byte[])),
|
||||
DQ = (byte[])information.GetValue("dq", typeof(byte[])),
|
||||
Exponent = (byte[])information.GetValue("exponent", typeof(byte[])),
|
||||
InverseQ = (byte[])information.GetValue("inverseQ", typeof(byte[])),
|
||||
Modulus = (byte[])information.GetValue("modulus", typeof(byte[])),
|
||||
P = (byte[])information.GetValue("p", typeof(byte[])),
|
||||
Q = (byte[])information.GetValue("q", typeof(byte[]))
|
||||
};
|
||||
}
|
||||
|
||||
public void GetObjectData(SerializationInfo info, StreamingContext context)
|
||||
{
|
||||
info.AddValue("d", _rsaParameters.D);
|
||||
info.AddValue("dp", _rsaParameters.DP);
|
||||
info.AddValue("dq", _rsaParameters.DQ);
|
||||
info.AddValue("exponent", _rsaParameters.Exponent);
|
||||
info.AddValue("inverseQ", _rsaParameters.InverseQ);
|
||||
info.AddValue("modulus", _rsaParameters.Modulus);
|
||||
info.AddValue("p", _rsaParameters.P);
|
||||
info.AddValue("q", _rsaParameters.Q);
|
||||
}
|
||||
}
|
||||
}
|
||||
1319
src/Runner.Listener/Configuration/NativeWindowsServiceHelper.cs
Normal file
1319
src/Runner.Listener/Configuration/NativeWindowsServiceHelper.cs
Normal file
File diff suppressed because it is too large
Load Diff
49
src/Runner.Listener/Configuration/OAuthCredential.cs
Normal file
49
src/Runner.Listener/Configuration/OAuthCredential.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Services.Common;
|
||||
using GitHub.Services.OAuth;
|
||||
using GitHub.Services.WebApi;
|
||||
|
||||
namespace GitHub.Runner.Listener.Configuration
|
||||
{
|
||||
public class OAuthCredential : CredentialProvider
|
||||
{
|
||||
public OAuthCredential()
|
||||
: base(Constants.Configuration.OAuth)
|
||||
{
|
||||
}
|
||||
|
||||
public override void EnsureCredential(
|
||||
IHostContext context,
|
||||
CommandSettings command,
|
||||
String serverUrl)
|
||||
{
|
||||
// Nothing to verify here
|
||||
}
|
||||
|
||||
public override VssCredentials GetVssCredentials(IHostContext context)
|
||||
{
|
||||
var clientId = this.CredentialData.Data.GetValueOrDefault("clientId", null);
|
||||
var authorizationUrl = this.CredentialData.Data.GetValueOrDefault("authorizationUrl", null);
|
||||
|
||||
// For back compat with .credential file that doesn't has 'oauthEndpointUrl' section
|
||||
var oathEndpointUrl = this.CredentialData.Data.GetValueOrDefault("oauthEndpointUrl", authorizationUrl);
|
||||
|
||||
ArgUtil.NotNullOrEmpty(clientId, nameof(clientId));
|
||||
ArgUtil.NotNullOrEmpty(authorizationUrl, nameof(authorizationUrl));
|
||||
|
||||
// We expect the key to be in the machine store at this point. Configuration should have set all of
|
||||
// this up correctly so we can use the key to generate access tokens.
|
||||
var keyManager = context.GetService<IRSAKeyManager>();
|
||||
var signingCredentials = VssSigningCredentials.Create(() => keyManager.GetKey());
|
||||
var clientCredential = new VssOAuthJwtBearerClientCredential(clientId, authorizationUrl, signingCredentials);
|
||||
var agentCredential = new VssOAuthCredential(new Uri(oathEndpointUrl, UriKind.Absolute), VssOAuthGrant.ClientCredentials, clientCredential);
|
||||
|
||||
// Construct a credentials cache with a single OAuth credential for communication. The windows credential
|
||||
// is explicitly set to null to ensure we never do that negotiation.
|
||||
return new VssCredentials(null, agentCredential, CredentialPromptType.DoNotPrompt);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
#if OS_OSX
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Listener.Configuration
|
||||
{
|
||||
public class OsxServiceControlManager : ServiceControlManager, ILinuxServiceControlManager
|
||||
{
|
||||
// This is the name you would see when you do `systemctl list-units | grep runner`
|
||||
private const string _svcNamePattern = "actions.runner.{0}.{1}.{2}";
|
||||
private const string _svcDisplayPattern = "GitHub Actions Runner ({0}.{1}.{2})";
|
||||
private const string _shTemplate = "darwin.svc.sh.template";
|
||||
private const string _svcShName = "svc.sh";
|
||||
|
||||
public void GenerateScripts(RunnerSettings settings)
|
||||
{
|
||||
Trace.Entering();
|
||||
|
||||
string serviceName;
|
||||
string serviceDisplayName;
|
||||
CalculateServiceName(settings, _svcNamePattern, _svcDisplayPattern, out serviceName, out serviceDisplayName);
|
||||
|
||||
try
|
||||
{
|
||||
string svcShPath = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), _svcShName);
|
||||
|
||||
// TODO: encoding?
|
||||
// TODO: Loc strings formatted into MSG_xxx vars in shellscript
|
||||
string svcShContent = File.ReadAllText(Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Bin), _shTemplate));
|
||||
var tokensToReplace = new Dictionary<string, string>
|
||||
{
|
||||
{ "{{SvcDescription}}", serviceDisplayName },
|
||||
{ "{{SvcNameVar}}", serviceName }
|
||||
};
|
||||
|
||||
svcShContent = tokensToReplace.Aggregate(
|
||||
svcShContent,
|
||||
(current, item) => current.Replace(item.Key, item.Value));
|
||||
|
||||
//TODO: encoding?
|
||||
File.WriteAllText(svcShPath, svcShContent);
|
||||
|
||||
var unixUtil = HostContext.CreateService<IUnixUtil>();
|
||||
unixUtil.ChmodAsync("755", svcShPath).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Trace.Error(e);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
117
src/Runner.Listener/Configuration/PromptManager.cs
Normal file
117
src/Runner.Listener/Configuration/PromptManager.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using System;
|
||||
|
||||
namespace GitHub.Runner.Listener.Configuration
|
||||
{
|
||||
[ServiceLocator(Default = typeof(PromptManager))]
|
||||
public interface IPromptManager : IRunnerService
|
||||
{
|
||||
bool ReadBool(
|
||||
string argName,
|
||||
string description,
|
||||
bool defaultValue,
|
||||
bool unattended);
|
||||
|
||||
string ReadValue(
|
||||
string argName,
|
||||
string description,
|
||||
bool secret,
|
||||
string defaultValue,
|
||||
Func<String, bool> validator,
|
||||
bool unattended);
|
||||
}
|
||||
|
||||
public sealed class PromptManager : RunnerService, IPromptManager
|
||||
{
|
||||
private ITerminal _terminal;
|
||||
|
||||
public override void Initialize(IHostContext hostContext)
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
_terminal = HostContext.GetService<ITerminal>();
|
||||
}
|
||||
|
||||
public bool ReadBool(
|
||||
string argName,
|
||||
string description,
|
||||
bool defaultValue,
|
||||
bool unattended)
|
||||
{
|
||||
string answer = ReadValue(
|
||||
argName: argName,
|
||||
description: description,
|
||||
secret: false,
|
||||
defaultValue: defaultValue ? "Y" : "N",
|
||||
validator: Validators.BoolValidator,
|
||||
unattended: unattended);
|
||||
return String.Equals(answer, "true", StringComparison.OrdinalIgnoreCase) ||
|
||||
String.Equals(answer, "Y", StringComparison.CurrentCultureIgnoreCase);
|
||||
}
|
||||
|
||||
public string ReadValue(
|
||||
string argName,
|
||||
string description,
|
||||
bool secret,
|
||||
string defaultValue,
|
||||
Func<string, bool> validator,
|
||||
bool unattended)
|
||||
{
|
||||
Trace.Info(nameof(ReadValue));
|
||||
ArgUtil.NotNull(validator, nameof(validator));
|
||||
string value = string.Empty;
|
||||
|
||||
// Check if unattended.
|
||||
if (unattended)
|
||||
{
|
||||
// Return the default value if specified.
|
||||
if (!string.IsNullOrEmpty(defaultValue))
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
// Otherwise throw.
|
||||
throw new Exception($"Invalid configuration provided for {argName}. Terminating unattended configuration.");
|
||||
}
|
||||
|
||||
// Prompt until a valid value is read.
|
||||
while (true)
|
||||
{
|
||||
// Write the message prompt.
|
||||
_terminal.Write($"{description} ", ConsoleColor.White);
|
||||
|
||||
if(!string.IsNullOrEmpty(defaultValue))
|
||||
{
|
||||
_terminal.Write($"[press Enter for {defaultValue}] ");
|
||||
}
|
||||
|
||||
// Read and trim the value.
|
||||
value = secret ? _terminal.ReadSecret() : _terminal.ReadLine();
|
||||
value = value?.Trim() ?? string.Empty;
|
||||
|
||||
// Return the default if not specified.
|
||||
if (string.IsNullOrEmpty(value) && !string.IsNullOrEmpty(defaultValue))
|
||||
{
|
||||
Trace.Info($"Falling back to the default: '{defaultValue}'");
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
// Return the value if it is not empty and it is valid.
|
||||
// Otherwise try the loop again.
|
||||
if (!string.IsNullOrEmpty(value))
|
||||
{
|
||||
if (validator(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info("Invalid value.");
|
||||
_terminal.WriteLine("Entered value is invalid", ConsoleColor.Yellow);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
#if OS_WINDOWS
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Listener.Configuration
|
||||
{
|
||||
public class RSAEncryptedFileKeyManager : RunnerService, IRSAKeyManager
|
||||
{
|
||||
private string _keyFile;
|
||||
private IHostContext _context;
|
||||
|
||||
public RSACryptoServiceProvider CreateKey()
|
||||
{
|
||||
RSACryptoServiceProvider rsa = null;
|
||||
if (!File.Exists(_keyFile))
|
||||
{
|
||||
Trace.Info("Creating new RSA key using 2048-bit key length");
|
||||
|
||||
rsa = new RSACryptoServiceProvider(2048);
|
||||
|
||||
// Now write the parameters to disk
|
||||
SaveParameters(rsa.ExportParameters(true));
|
||||
Trace.Info("Successfully saved RSA key parameters to file {0}", _keyFile);
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info("Found existing RSA key parameters file {0}", _keyFile);
|
||||
|
||||
rsa = new RSACryptoServiceProvider();
|
||||
rsa.ImportParameters(LoadParameters());
|
||||
}
|
||||
|
||||
return rsa;
|
||||
}
|
||||
|
||||
public void DeleteKey()
|
||||
{
|
||||
if (File.Exists(_keyFile))
|
||||
{
|
||||
Trace.Info("Deleting RSA key parameters file {0}", _keyFile);
|
||||
File.Delete(_keyFile);
|
||||
}
|
||||
}
|
||||
|
||||
public RSACryptoServiceProvider GetKey()
|
||||
{
|
||||
if (!File.Exists(_keyFile))
|
||||
{
|
||||
throw new CryptographicException($"RSA key file {_keyFile} was not found");
|
||||
}
|
||||
|
||||
Trace.Info("Loading RSA key parameters from file {0}", _keyFile);
|
||||
|
||||
var rsa = new RSACryptoServiceProvider();
|
||||
rsa.ImportParameters(LoadParameters());
|
||||
return rsa;
|
||||
}
|
||||
|
||||
private RSAParameters LoadParameters()
|
||||
{
|
||||
var encryptedBytes = File.ReadAllBytes(_keyFile);
|
||||
var parametersString = Encoding.UTF8.GetString(ProtectedData.Unprotect(encryptedBytes, null, DataProtectionScope.LocalMachine));
|
||||
return StringUtil.ConvertFromJson<RSAParametersSerializable>(parametersString).RSAParameters;
|
||||
}
|
||||
|
||||
private void SaveParameters(RSAParameters parameters)
|
||||
{
|
||||
var parametersString = StringUtil.ConvertToJson(new RSAParametersSerializable(parameters));
|
||||
var encryptedBytes = ProtectedData.Protect(Encoding.UTF8.GetBytes(parametersString), null, DataProtectionScope.LocalMachine);
|
||||
File.WriteAllBytes(_keyFile, encryptedBytes);
|
||||
File.SetAttributes(_keyFile, File.GetAttributes(_keyFile) | FileAttributes.Hidden);
|
||||
}
|
||||
|
||||
void IRunnerService.Initialize(IHostContext context)
|
||||
{
|
||||
base.Initialize(context);
|
||||
|
||||
_context = context;
|
||||
_keyFile = context.GetConfigFile(WellKnownConfigFile.RSACredentials);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
97
src/Runner.Listener/Configuration/RSAFileKeyManager.cs
Normal file
97
src/Runner.Listener/Configuration/RSAFileKeyManager.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
#if OS_LINUX || OS_OSX
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Listener.Configuration
|
||||
{
|
||||
public class RSAFileKeyManager : RunnerService, IRSAKeyManager
|
||||
{
|
||||
private string _keyFile;
|
||||
private IHostContext _context;
|
||||
|
||||
public RSACryptoServiceProvider CreateKey()
|
||||
{
|
||||
RSACryptoServiceProvider rsa = null;
|
||||
if (!File.Exists(_keyFile))
|
||||
{
|
||||
Trace.Info("Creating new RSA key using 2048-bit key length");
|
||||
|
||||
rsa = new RSACryptoServiceProvider(2048);
|
||||
|
||||
// Now write the parameters to disk
|
||||
IOUtil.SaveObject(new RSAParametersSerializable(rsa.ExportParameters(true)), _keyFile);
|
||||
Trace.Info("Successfully saved RSA key parameters to file {0}", _keyFile);
|
||||
|
||||
// Try to lock down the credentials_key file to the owner/group
|
||||
var chmodPath = WhichUtil.Which("chmod", trace: Trace);
|
||||
if (!String.IsNullOrEmpty(chmodPath))
|
||||
{
|
||||
var arguments = $"600 {new FileInfo(_keyFile).FullName}";
|
||||
using (var invoker = _context.CreateService<IProcessInvoker>())
|
||||
{
|
||||
var exitCode = invoker.ExecuteAsync(HostContext.GetDirectory(WellKnownDirectory.Root), chmodPath, arguments, null, default(CancellationToken)).GetAwaiter().GetResult();
|
||||
if (exitCode == 0)
|
||||
{
|
||||
Trace.Info("Successfully set permissions for RSA key parameters file {0}", _keyFile);
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Warning("Unable to succesfully set permissions for RSA key parameters file {0}. Received exit code {1} from {2}", _keyFile, exitCode, chmodPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Warning("Unable to locate chmod to set permissions for RSA key parameters file {0}.", _keyFile);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info("Found existing RSA key parameters file {0}", _keyFile);
|
||||
|
||||
rsa = new RSACryptoServiceProvider();
|
||||
rsa.ImportParameters(IOUtil.LoadObject<RSAParametersSerializable>(_keyFile).RSAParameters);
|
||||
}
|
||||
|
||||
return rsa;
|
||||
}
|
||||
|
||||
public void DeleteKey()
|
||||
{
|
||||
if (File.Exists(_keyFile))
|
||||
{
|
||||
Trace.Info("Deleting RSA key parameters file {0}", _keyFile);
|
||||
File.Delete(_keyFile);
|
||||
}
|
||||
}
|
||||
|
||||
public RSACryptoServiceProvider GetKey()
|
||||
{
|
||||
if (!File.Exists(_keyFile))
|
||||
{
|
||||
throw new CryptographicException($"RSA key file {_keyFile} was not found");
|
||||
}
|
||||
|
||||
Trace.Info("Loading RSA key parameters from file {0}", _keyFile);
|
||||
|
||||
var parameters = IOUtil.LoadObject<RSAParametersSerializable>(_keyFile).RSAParameters;
|
||||
var rsa = new RSACryptoServiceProvider();
|
||||
rsa.ImportParameters(parameters);
|
||||
return rsa;
|
||||
}
|
||||
|
||||
void IRunnerService.Initialize(IHostContext context)
|
||||
{
|
||||
base.Initialize(context);
|
||||
|
||||
_context = context;
|
||||
_keyFile = context.GetConfigFile(WellKnownConfigFile.RSACredentials);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
63
src/Runner.Listener/Configuration/ServiceControlManager.cs
Normal file
63
src/Runner.Listener/Configuration/ServiceControlManager.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Listener.Configuration
|
||||
{
|
||||
#if OS_WINDOWS
|
||||
[ServiceLocator(Default = typeof(WindowsServiceControlManager))]
|
||||
public interface IWindowsServiceControlManager : IRunnerService
|
||||
{
|
||||
void ConfigureService(RunnerSettings settings, CommandSettings command);
|
||||
|
||||
void UnconfigureService();
|
||||
}
|
||||
#endif
|
||||
|
||||
#if !OS_WINDOWS
|
||||
|
||||
#if OS_LINUX
|
||||
[ServiceLocator(Default = typeof(SystemDControlManager))]
|
||||
#elif OS_OSX
|
||||
[ServiceLocator(Default = typeof(OsxServiceControlManager))]
|
||||
#endif
|
||||
public interface ILinuxServiceControlManager : IRunnerService
|
||||
{
|
||||
void GenerateScripts(RunnerSettings settings);
|
||||
}
|
||||
#endif
|
||||
|
||||
public class ServiceControlManager : RunnerService
|
||||
{
|
||||
public void CalculateServiceName(RunnerSettings settings, string serviceNamePattern, string serviceDisplayNamePattern, out string serviceName, out string serviceDisplayName)
|
||||
{
|
||||
Trace.Entering();
|
||||
serviceName = string.Empty;
|
||||
serviceDisplayName = string.Empty;
|
||||
|
||||
Uri accountUri = new Uri(settings.ServerUrl);
|
||||
string accountName = string.Empty;
|
||||
|
||||
if (accountUri.Host.EndsWith(".githubusercontent.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
accountName = accountUri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
|
||||
}
|
||||
else
|
||||
{
|
||||
accountName = accountUri.Host.Split('.').FirstOrDefault();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(accountName))
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot find GitHub organization name from server url: '{settings.ServerUrl}'");
|
||||
}
|
||||
|
||||
serviceName = StringUtil.Format(serviceNamePattern, accountName, settings.PoolName, settings.AgentName);
|
||||
serviceDisplayName = StringUtil.Format(serviceDisplayNamePattern, accountName, settings.PoolName, settings.AgentName);
|
||||
|
||||
Trace.Info($"Service name '{serviceName}' display name '{serviceDisplayName}' will be used for service configuration.");
|
||||
}
|
||||
}
|
||||
}
|
||||
55
src/Runner.Listener/Configuration/SystemdControlManager.cs
Normal file
55
src/Runner.Listener/Configuration/SystemdControlManager.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
#if OS_LINUX
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Listener.Configuration
|
||||
{
|
||||
public class SystemDControlManager : ServiceControlManager, ILinuxServiceControlManager
|
||||
{
|
||||
// This is the name you would see when you do `systemctl list-units | grep runner`
|
||||
private const string _svcNamePattern = "actions.runner.{0}.{1}.{2}.service";
|
||||
private const string _svcDisplayPattern = "GitHub Actions Runner ({0}.{1}.{2})";
|
||||
private const string _shTemplate = "systemd.svc.sh.template";
|
||||
private const string _shName = "svc.sh";
|
||||
|
||||
public void GenerateScripts(RunnerSettings settings)
|
||||
{
|
||||
try
|
||||
{
|
||||
string serviceName;
|
||||
string serviceDisplayName;
|
||||
CalculateServiceName(settings, _svcNamePattern, _svcDisplayPattern, out serviceName, out serviceDisplayName);
|
||||
|
||||
string svcShPath = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), _shName);
|
||||
|
||||
string svcShContent = File.ReadAllText(Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Bin), _shTemplate));
|
||||
var tokensToReplace = new Dictionary<string, string>
|
||||
{
|
||||
{ "{{SvcDescription}}", serviceDisplayName },
|
||||
{ "{{SvcNameVar}}", serviceName }
|
||||
};
|
||||
|
||||
svcShContent = tokensToReplace.Aggregate(
|
||||
svcShContent,
|
||||
(current, item) => current.Replace(item.Key, item.Value));
|
||||
|
||||
File.WriteAllText(svcShPath, svcShContent, new UTF8Encoding(false));
|
||||
|
||||
var unixUtil = HostContext.CreateService<IUnixUtil>();
|
||||
unixUtil.ChmodAsync("755", svcShPath).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error(ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
94
src/Runner.Listener/Configuration/Validators.cs
Normal file
94
src/Runner.Listener/Configuration/Validators.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Principal;
|
||||
|
||||
namespace GitHub.Runner.Listener.Configuration
|
||||
{
|
||||
public static class Validators
|
||||
{
|
||||
private static String UriHttpScheme = "http";
|
||||
private static String UriHttpsScheme = "https";
|
||||
|
||||
public static bool ServerUrlValidator(string value)
|
||||
{
|
||||
try
|
||||
{
|
||||
Uri uri;
|
||||
if (Uri.TryCreate(value, UriKind.Absolute, out uri))
|
||||
{
|
||||
if (uri.Scheme.Equals(UriHttpScheme, StringComparison.OrdinalIgnoreCase)
|
||||
|| uri.Scheme.Equals(UriHttpsScheme, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool AuthSchemeValidator(string value)
|
||||
{
|
||||
return CredentialManager.CredentialTypes.ContainsKey(value);
|
||||
}
|
||||
|
||||
public static bool FilePathValidator(string value)
|
||||
{
|
||||
var directoryInfo = new DirectoryInfo(value);
|
||||
|
||||
if (!directoryInfo.Exists)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(value);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool BoolValidator(string value)
|
||||
{
|
||||
return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(value, "false", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(value, "Y", StringComparison.CurrentCultureIgnoreCase) ||
|
||||
string.Equals(value, "N", StringComparison.CurrentCultureIgnoreCase);
|
||||
}
|
||||
|
||||
public static bool NonEmptyValidator(string value)
|
||||
{
|
||||
return !string.IsNullOrEmpty(value);
|
||||
}
|
||||
|
||||
public static bool NTAccountValidator(string arg)
|
||||
{
|
||||
if (string.IsNullOrEmpty(arg) || String.IsNullOrEmpty(arg.TrimStart('.', '\\')))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var logonAccount = arg.TrimStart('.');
|
||||
NTAccount ntaccount = new NTAccount(logonAccount);
|
||||
SecurityIdentifier sid = (SecurityIdentifier)ntaccount.Translate(typeof(SecurityIdentifier));
|
||||
}
|
||||
catch (IdentityNotMappedException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
#if OS_WINDOWS
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security;
|
||||
using System.Security.Principal;
|
||||
using System.Text;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Listener.Configuration
|
||||
{
|
||||
public class WindowsServiceControlManager : ServiceControlManager, IWindowsServiceControlManager
|
||||
{
|
||||
public const string WindowsServiceControllerName = "RunnerService.exe";
|
||||
|
||||
private const string ServiceNamePattern = "actionsrunner.{0}.{1}.{2}";
|
||||
private const string ServiceDisplayNamePattern = "GitHub Actions Runner ({0}.{1}.{2})";
|
||||
|
||||
private INativeWindowsServiceHelper _windowsServiceHelper;
|
||||
private ITerminal _term;
|
||||
|
||||
public override void Initialize(IHostContext hostContext)
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
_windowsServiceHelper = HostContext.GetService<INativeWindowsServiceHelper>();
|
||||
_term = HostContext.GetService<ITerminal>();
|
||||
}
|
||||
|
||||
public void ConfigureService(RunnerSettings settings, CommandSettings command)
|
||||
{
|
||||
Trace.Entering();
|
||||
|
||||
if (!_windowsServiceHelper.IsRunningInElevatedMode())
|
||||
{
|
||||
Trace.Error("Needs Administrator privileges for configure runner as windows service.");
|
||||
throw new SecurityException("Needs Administrator privileges for configuring runner as windows service.");
|
||||
}
|
||||
|
||||
// We use NetworkService as default account for actions runner
|
||||
NTAccount defaultServiceAccount = _windowsServiceHelper.GetDefaultServiceAccount();
|
||||
string logonAccount = command.GetWindowsLogonAccount(defaultValue: defaultServiceAccount.ToString(), descriptionMsg: "User account to use for the service");
|
||||
|
||||
string domainName;
|
||||
string userName;
|
||||
GetAccountSegments(logonAccount, out domainName, out userName);
|
||||
|
||||
if ((string.IsNullOrEmpty(domainName) || domainName.Equals(".", StringComparison.CurrentCultureIgnoreCase)) && !logonAccount.Contains('@'))
|
||||
{
|
||||
logonAccount = String.Format("{0}\\{1}", Environment.MachineName, userName);
|
||||
domainName = Environment.MachineName;
|
||||
}
|
||||
|
||||
Trace.Info("LogonAccount after transforming: {0}, user: {1}, domain: {2}", logonAccount, userName, domainName);
|
||||
|
||||
string logonPassword = string.Empty;
|
||||
if (!defaultServiceAccount.Equals(new NTAccount(logonAccount)) && !NativeWindowsServiceHelper.IsWellKnownIdentity(logonAccount))
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
logonPassword = command.GetWindowsLogonPassword(logonAccount);
|
||||
if (_windowsServiceHelper.IsValidCredential(domainName, userName, logonPassword))
|
||||
{
|
||||
Trace.Info("Credential validation succeed");
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!command.Unattended)
|
||||
{
|
||||
Trace.Info("Invalid credential entered");
|
||||
_term.WriteLine("Invalid windows credentials entered. Try again or ctrl-c to quit");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new SecurityException("Invalid windows credentials entered. Try again or ctrl-c to quit");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
string serviceName;
|
||||
string serviceDisplayName;
|
||||
CalculateServiceName(settings, ServiceNamePattern, ServiceDisplayNamePattern, out serviceName, out serviceDisplayName);
|
||||
if (_windowsServiceHelper.IsServiceExists(serviceName))
|
||||
{
|
||||
_term.WriteLine($"The service already exists: {serviceName}, it will be replaced");
|
||||
_windowsServiceHelper.UninstallService(serviceName);
|
||||
}
|
||||
|
||||
Trace.Info("Verifying if the account has LogonAsService permission");
|
||||
if (_windowsServiceHelper.IsUserHasLogonAsServicePrivilege(domainName, userName))
|
||||
{
|
||||
Trace.Info($"Account: {logonAccount} already has Logon As Service Privilege.");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!_windowsServiceHelper.GrantUserLogonAsServicePrivilege(domainName, userName))
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot grant LogonAsService permission to the user {logonAccount}");
|
||||
}
|
||||
}
|
||||
|
||||
// grant permission for runner root folder and work folder
|
||||
Trace.Info("Create local group and grant folder permission to service logon account.");
|
||||
string runnerRoot = HostContext.GetDirectory(WellKnownDirectory.Root);
|
||||
string workFolder = HostContext.GetDirectory(WellKnownDirectory.Work);
|
||||
Directory.CreateDirectory(workFolder);
|
||||
_windowsServiceHelper.GrantDirectoryPermissionForAccount(logonAccount, new[] { runnerRoot, workFolder });
|
||||
_term.WriteLine($"Granting file permissions to '{logonAccount}'.");
|
||||
|
||||
// install service.
|
||||
_windowsServiceHelper.InstallService(serviceName, serviceDisplayName, logonAccount, logonPassword);
|
||||
|
||||
// create .service file with service name.
|
||||
SaveServiceSettings(serviceName);
|
||||
|
||||
Trace.Info("Configuration was successful, trying to start the service");
|
||||
_windowsServiceHelper.StartService(serviceName);
|
||||
}
|
||||
|
||||
public void UnconfigureService()
|
||||
{
|
||||
if (!_windowsServiceHelper.IsRunningInElevatedMode())
|
||||
{
|
||||
Trace.Error("Needs Administrator privileges for unconfigure windows service runner.");
|
||||
throw new SecurityException("Needs Administrator privileges for unconfiguring runner that running as windows service.");
|
||||
}
|
||||
|
||||
string serviceConfigPath = HostContext.GetConfigFile(WellKnownConfigFile.Service);
|
||||
string serviceName = File.ReadAllText(serviceConfigPath);
|
||||
if (_windowsServiceHelper.IsServiceExists(serviceName))
|
||||
{
|
||||
_windowsServiceHelper.StopService(serviceName);
|
||||
_windowsServiceHelper.UninstallService(serviceName);
|
||||
|
||||
// Delete local group we created during configure.
|
||||
string runnerRoot = HostContext.GetDirectory(WellKnownDirectory.Root);
|
||||
string workFolder = HostContext.GetDirectory(WellKnownDirectory.Work);
|
||||
_windowsServiceHelper.RevokeDirectoryPermissionForAccount(new[] { runnerRoot, workFolder });
|
||||
}
|
||||
|
||||
IOUtil.DeleteFile(serviceConfigPath);
|
||||
}
|
||||
|
||||
private void SaveServiceSettings(string serviceName)
|
||||
{
|
||||
string serviceConfigPath = HostContext.GetConfigFile(WellKnownConfigFile.Service);
|
||||
if (File.Exists(serviceConfigPath))
|
||||
{
|
||||
IOUtil.DeleteFile(serviceConfigPath);
|
||||
}
|
||||
|
||||
File.WriteAllText(serviceConfigPath, serviceName, new UTF8Encoding(false));
|
||||
File.SetAttributes(serviceConfigPath, File.GetAttributes(serviceConfigPath) | FileAttributes.Hidden);
|
||||
}
|
||||
|
||||
private void GetAccountSegments(string account, out string domain, out string user)
|
||||
{
|
||||
string[] segments = account.Split('\\');
|
||||
domain = string.Empty;
|
||||
user = account;
|
||||
if (segments.Length == 2)
|
||||
{
|
||||
domain = segments[0];
|
||||
user = segments[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
909
src/Runner.Listener/JobDispatcher.cs
Normal file
909
src/Runner.Listener/JobDispatcher.cs
Normal file
@@ -0,0 +1,909 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Services.WebApi;
|
||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
using System.Linq;
|
||||
using GitHub.Services.Common;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Listener
|
||||
{
|
||||
[ServiceLocator(Default = typeof(JobDispatcher))]
|
||||
public interface IJobDispatcher : IRunnerService
|
||||
{
|
||||
TaskCompletionSource<bool> RunOnceJobCompleted { get; }
|
||||
void Run(Pipelines.AgentJobRequestMessage message, bool runOnce = false);
|
||||
bool Cancel(JobCancelMessage message);
|
||||
Task WaitAsync(CancellationToken token);
|
||||
TaskResult GetLocalRunJobResult(AgentJobRequestMessage message);
|
||||
Task ShutdownAsync();
|
||||
}
|
||||
|
||||
// This implementation of IDobDispatcher is not thread safe.
|
||||
// It is base on the fact that the current design of runner is dequeue
|
||||
// and process one message from message queue everytime.
|
||||
// In addition, it only execute one job every time,
|
||||
// and server will not send another job while this one is still running.
|
||||
public sealed class JobDispatcher : RunnerService, IJobDispatcher
|
||||
{
|
||||
private readonly Lazy<Dictionary<long, TaskResult>> _localRunJobResult = new Lazy<Dictionary<long, TaskResult>>();
|
||||
private int _poolId;
|
||||
RunnerSettings _runnerSetting;
|
||||
private static readonly string _workerProcessName = $"Runner.Worker{IOUtil.ExeExtension}";
|
||||
|
||||
// this is not thread-safe
|
||||
private readonly Queue<Guid> _jobDispatchedQueue = new Queue<Guid>();
|
||||
private readonly ConcurrentDictionary<Guid, WorkerDispatcher> _jobInfos = new ConcurrentDictionary<Guid, WorkerDispatcher>();
|
||||
|
||||
//allow up to 30sec for any data to be transmitted over the process channel
|
||||
//timeout limit can be overwrite by environment GITHUB_ACTIONS_RUNNER_CHANNEL_TIMEOUT
|
||||
private TimeSpan _channelTimeout;
|
||||
|
||||
private TaskCompletionSource<bool> _runOnceJobCompleted = new TaskCompletionSource<bool>();
|
||||
|
||||
public override void Initialize(IHostContext hostContext)
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
|
||||
// get pool id from config
|
||||
var configurationStore = hostContext.GetService<IConfigurationStore>();
|
||||
_runnerSetting = configurationStore.GetSettings();
|
||||
_poolId = _runnerSetting.PoolId;
|
||||
|
||||
int channelTimeoutSeconds;
|
||||
if (!int.TryParse(Environment.GetEnvironmentVariable("GITHUB_ACTIONS_RUNNER_CHANNEL_TIMEOUT") ?? string.Empty, out channelTimeoutSeconds))
|
||||
{
|
||||
channelTimeoutSeconds = 30;
|
||||
}
|
||||
|
||||
// _channelTimeout should in range [30, 300] seconds
|
||||
_channelTimeout = TimeSpan.FromSeconds(Math.Min(Math.Max(channelTimeoutSeconds, 30), 300));
|
||||
Trace.Info($"Set runner/worker IPC timeout to {_channelTimeout.TotalSeconds} seconds.");
|
||||
}
|
||||
|
||||
public TaskCompletionSource<bool> RunOnceJobCompleted => _runOnceJobCompleted;
|
||||
|
||||
public void Run(Pipelines.AgentJobRequestMessage jobRequestMessage, bool runOnce = false)
|
||||
{
|
||||
Trace.Info($"Job request {jobRequestMessage.RequestId} for plan {jobRequestMessage.Plan.PlanId} job {jobRequestMessage.JobId} received.");
|
||||
|
||||
WorkerDispatcher currentDispatch = null;
|
||||
if (_jobDispatchedQueue.Count > 0)
|
||||
{
|
||||
Guid dispatchedJobId = _jobDispatchedQueue.Dequeue();
|
||||
if (_jobInfos.TryGetValue(dispatchedJobId, out currentDispatch))
|
||||
{
|
||||
Trace.Verbose($"Retrive previous WorkerDispather for job {currentDispatch.JobId}.");
|
||||
}
|
||||
}
|
||||
|
||||
WorkerDispatcher newDispatch = new WorkerDispatcher(jobRequestMessage.JobId, jobRequestMessage.RequestId);
|
||||
if (runOnce)
|
||||
{
|
||||
Trace.Info("Start dispatcher for one time used runner.");
|
||||
newDispatch.WorkerDispatch = RunOnceAsync(jobRequestMessage, currentDispatch, newDispatch.WorkerCancellationTokenSource.Token, newDispatch.WorkerCancelTimeoutKillTokenSource.Token);
|
||||
}
|
||||
else
|
||||
{
|
||||
newDispatch.WorkerDispatch = RunAsync(jobRequestMessage, currentDispatch, newDispatch.WorkerCancellationTokenSource.Token, newDispatch.WorkerCancelTimeoutKillTokenSource.Token);
|
||||
}
|
||||
|
||||
_jobInfos.TryAdd(newDispatch.JobId, newDispatch);
|
||||
_jobDispatchedQueue.Enqueue(newDispatch.JobId);
|
||||
}
|
||||
|
||||
public bool Cancel(JobCancelMessage jobCancelMessage)
|
||||
{
|
||||
Trace.Info($"Job cancellation request {jobCancelMessage.JobId} received, cancellation timeout {jobCancelMessage.Timeout.TotalMinutes} minutes.");
|
||||
|
||||
WorkerDispatcher workerDispatcher;
|
||||
if (!_jobInfos.TryGetValue(jobCancelMessage.JobId, out workerDispatcher))
|
||||
{
|
||||
Trace.Verbose($"Job request {jobCancelMessage.JobId} is not a current running job, ignore cancllation request.");
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (workerDispatcher.Cancel(jobCancelMessage.Timeout))
|
||||
{
|
||||
Trace.Verbose($"Fired cancellation token for job request {workerDispatcher.JobId}.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task WaitAsync(CancellationToken token)
|
||||
{
|
||||
WorkerDispatcher currentDispatch = null;
|
||||
Guid dispatchedJobId;
|
||||
if (_jobDispatchedQueue.Count > 0)
|
||||
{
|
||||
dispatchedJobId = _jobDispatchedQueue.Dequeue();
|
||||
if (_jobInfos.TryGetValue(dispatchedJobId, out currentDispatch))
|
||||
{
|
||||
Trace.Verbose($"Retrive previous WorkerDispather for job {currentDispatch.JobId}.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Verbose($"There is no running WorkerDispather needs to await.");
|
||||
}
|
||||
|
||||
if (currentDispatch != null)
|
||||
{
|
||||
using (var registration = token.Register(() => { if (currentDispatch.Cancel(TimeSpan.FromSeconds(60))) { Trace.Verbose($"Fired cancellation token for job request {currentDispatch.JobId}."); } }))
|
||||
{
|
||||
try
|
||||
{
|
||||
Trace.Info($"Waiting WorkerDispather for job {currentDispatch.JobId} run to finish.");
|
||||
await currentDispatch.WorkerDispatch;
|
||||
Trace.Info($"Job request {currentDispatch.JobId} processed succeed.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Worker Dispatch failed with an exception for job request {currentDispatch.JobId}.");
|
||||
Trace.Error(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
WorkerDispatcher workerDispatcher;
|
||||
if (_jobInfos.TryRemove(currentDispatch.JobId, out workerDispatcher))
|
||||
{
|
||||
Trace.Verbose($"Remove WorkerDispather from {nameof(_jobInfos)} dictionary for job {currentDispatch.JobId}.");
|
||||
workerDispatcher.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public TaskResult GetLocalRunJobResult(AgentJobRequestMessage message)
|
||||
{
|
||||
return _localRunJobResult.Value[message.RequestId];
|
||||
}
|
||||
|
||||
public async Task ShutdownAsync()
|
||||
{
|
||||
Trace.Info($"Shutting down JobDispatcher. Make sure all WorkerDispatcher has finished.");
|
||||
WorkerDispatcher currentDispatch = null;
|
||||
if (_jobDispatchedQueue.Count > 0)
|
||||
{
|
||||
Guid dispatchedJobId = _jobDispatchedQueue.Dequeue();
|
||||
if (_jobInfos.TryGetValue(dispatchedJobId, out currentDispatch))
|
||||
{
|
||||
try
|
||||
{
|
||||
Trace.Info($"Ensure WorkerDispather for job {currentDispatch.JobId} run to finish, cancel any running job.");
|
||||
await EnsureDispatchFinished(currentDispatch, cancelRunningJob: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Catching worker dispatch exception for job request {currentDispatch.JobId} durning job dispatcher shut down.");
|
||||
Trace.Error(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
WorkerDispatcher workerDispatcher;
|
||||
if (_jobInfos.TryRemove(currentDispatch.JobId, out workerDispatcher))
|
||||
{
|
||||
Trace.Verbose($"Remove WorkerDispather from {nameof(_jobInfos)} dictionary for job {currentDispatch.JobId}.");
|
||||
workerDispatcher.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureDispatchFinished(WorkerDispatcher jobDispatch, bool cancelRunningJob = false)
|
||||
{
|
||||
if (!jobDispatch.WorkerDispatch.IsCompleted)
|
||||
{
|
||||
if (cancelRunningJob)
|
||||
{
|
||||
// cancel running job when shutting down the runner.
|
||||
// this will happen when runner get Ctrl+C or message queue loop crashed.
|
||||
jobDispatch.WorkerCancellationTokenSource.Cancel();
|
||||
// wait for worker process exit then return.
|
||||
await jobDispatch.WorkerDispatch;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// base on the current design, server will only send one job for a given runner everytime.
|
||||
// if the runner received a new job request while a previous job request is still running, this typically indicate two situations
|
||||
// 1. an runner bug cause server and runner mismatch on the state of the job request, ex. runner not renew jobrequest properly but think it still own the job reqest, however server already abandon the jobrequest.
|
||||
// 2. a server bug or design change that allow server send more than one job request to an given runner that haven't finish previous job request.
|
||||
var runnerServer = HostContext.GetService<IRunnerServer>();
|
||||
TaskAgentJobRequest request = null;
|
||||
try
|
||||
{
|
||||
request = await runnerServer.GetAgentRequestAsync(_poolId, jobDispatch.RequestId, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// we can't even query for the jobrequest from server, something totally busted, stop runner/worker.
|
||||
Trace.Error($"Catch exception while checking jobrequest {jobDispatch.JobId} status. Cancel running worker right away.");
|
||||
Trace.Error(ex);
|
||||
|
||||
jobDispatch.WorkerCancellationTokenSource.Cancel();
|
||||
// make sure worker process exit before we rethrow, otherwise we might leave orphan worker process behind.
|
||||
await jobDispatch.WorkerDispatch;
|
||||
|
||||
// rethrow original exception
|
||||
throw;
|
||||
}
|
||||
|
||||
if (request.Result != null)
|
||||
{
|
||||
// job request has been finished, the server already has result.
|
||||
// this means runner is busted since it still running that request.
|
||||
// cancel the zombie worker, run next job request.
|
||||
Trace.Error($"Received job request while previous job {jobDispatch.JobId} still running on worker. Cancel the previous job since the job request have been finished on server side with result: {request.Result.Value}.");
|
||||
jobDispatch.WorkerCancellationTokenSource.Cancel();
|
||||
|
||||
// wait 45 sec for worker to finish.
|
||||
Task completedTask = await Task.WhenAny(jobDispatch.WorkerDispatch, Task.Delay(TimeSpan.FromSeconds(45)));
|
||||
if (completedTask != jobDispatch.WorkerDispatch)
|
||||
{
|
||||
// at this point, the job exectuion might encounter some dead lock and even not able to be canclled.
|
||||
// no need to localize the exception string should never happen.
|
||||
throw new InvalidOperationException($"Job dispatch process for {jobDispatch.JobId} has encountered unexpected error, the dispatch task is not able to be canceled within 45 seconds.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// something seriously wrong on server side. stop runner from continue running.
|
||||
// no need to localize the exception string should never happen.
|
||||
throw new InvalidOperationException($"Server send a new job request while the previous job request {jobDispatch.JobId} haven't finished.");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await jobDispatch.WorkerDispatch;
|
||||
Trace.Info($"Job request {jobDispatch.JobId} processed succeed.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Worker Dispatch failed with an exception for job request {jobDispatch.JobId}.");
|
||||
Trace.Error(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
WorkerDispatcher workerDispatcher;
|
||||
if (_jobInfos.TryRemove(jobDispatch.JobId, out workerDispatcher))
|
||||
{
|
||||
Trace.Verbose($"Remove WorkerDispather from {nameof(_jobInfos)} dictionary for job {jobDispatch.JobId}.");
|
||||
workerDispatcher.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunOnceAsync(Pipelines.AgentJobRequestMessage message, WorkerDispatcher previousJobDispatch, CancellationToken jobRequestCancellationToken, CancellationToken workerCancelTimeoutKillToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RunAsync(message, previousJobDispatch, jobRequestCancellationToken, workerCancelTimeoutKillToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Trace.Info("Fire signal for one time used runner.");
|
||||
_runOnceJobCompleted.TrySetResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunAsync(Pipelines.AgentJobRequestMessage message, WorkerDispatcher previousJobDispatch, CancellationToken jobRequestCancellationToken, CancellationToken workerCancelTimeoutKillToken)
|
||||
{
|
||||
if (previousJobDispatch != null)
|
||||
{
|
||||
Trace.Verbose($"Make sure the previous job request {previousJobDispatch.JobId} has successfully finished on worker.");
|
||||
await EnsureDispatchFinished(previousJobDispatch);
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Verbose($"This is the first job request.");
|
||||
}
|
||||
|
||||
var term = HostContext.GetService<ITerminal>();
|
||||
term.WriteLine($"{DateTime.UtcNow:u}: Running job: {message.JobDisplayName}");
|
||||
|
||||
// first job request renew succeed.
|
||||
TaskCompletionSource<int> firstJobRequestRenewed = new TaskCompletionSource<int>();
|
||||
var notification = HostContext.GetService<IJobNotification>();
|
||||
|
||||
// lock renew cancellation token.
|
||||
using (var lockRenewalTokenSource = new CancellationTokenSource())
|
||||
using (var workerProcessCancelTokenSource = new CancellationTokenSource())
|
||||
{
|
||||
long requestId = message.RequestId;
|
||||
Guid lockToken = Guid.Empty; // lockToken has never been used, keep this here of compat
|
||||
|
||||
// start renew job request
|
||||
Trace.Info($"Start renew job request {requestId} for job {message.JobId}.");
|
||||
Task renewJobRequest = RenewJobRequestAsync(_poolId, requestId, lockToken, firstJobRequestRenewed, lockRenewalTokenSource.Token);
|
||||
|
||||
// wait till first renew succeed or job request is canceled
|
||||
// not even start worker if the first renew fail
|
||||
await Task.WhenAny(firstJobRequestRenewed.Task, renewJobRequest, Task.Delay(-1, jobRequestCancellationToken));
|
||||
|
||||
if (renewJobRequest.IsCompleted)
|
||||
{
|
||||
// renew job request task complete means we run out of retry for the first job request renew.
|
||||
Trace.Info($"Unable to renew job request for job {message.JobId} for the first time, stop dispatching job to worker.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (jobRequestCancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Trace.Info($"Stop renew job request for job {message.JobId}.");
|
||||
// stop renew lock
|
||||
lockRenewalTokenSource.Cancel();
|
||||
// renew job request should never blows up.
|
||||
await renewJobRequest;
|
||||
|
||||
// complete job request with result Cancelled
|
||||
await CompleteJobRequestAsync(_poolId, message, lockToken, TaskResult.Canceled);
|
||||
return;
|
||||
}
|
||||
|
||||
HostContext.WritePerfCounter($"JobRequestRenewed_{requestId.ToString()}");
|
||||
|
||||
Task<int> workerProcessTask = null;
|
||||
object _outputLock = new object();
|
||||
List<string> workerOutput = new List<string>();
|
||||
using (var processChannel = HostContext.CreateService<IProcessChannel>())
|
||||
using (var processInvoker = HostContext.CreateService<IProcessInvoker>())
|
||||
{
|
||||
// Start the process channel.
|
||||
// It's OK if StartServer bubbles an execption after the worker process has already started.
|
||||
// The worker will shutdown after 30 seconds if it hasn't received the job message.
|
||||
processChannel.StartServer(
|
||||
// Delegate to start the child process.
|
||||
startProcess: (string pipeHandleOut, string pipeHandleIn) =>
|
||||
{
|
||||
// Validate args.
|
||||
ArgUtil.NotNullOrEmpty(pipeHandleOut, nameof(pipeHandleOut));
|
||||
ArgUtil.NotNullOrEmpty(pipeHandleIn, nameof(pipeHandleIn));
|
||||
|
||||
if (HostContext.RunMode == RunMode.Normal)
|
||||
{
|
||||
// Save STDOUT from worker, worker will use STDOUT report unhandle exception.
|
||||
processInvoker.OutputDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stdout)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(stdout.Data))
|
||||
{
|
||||
lock (_outputLock)
|
||||
{
|
||||
workerOutput.Add(stdout.Data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Save STDERR from worker, worker will use STDERR on crash.
|
||||
processInvoker.ErrorDataReceived += delegate (object sender, ProcessDataReceivedEventArgs stderr)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(stderr.Data))
|
||||
{
|
||||
lock (_outputLock)
|
||||
{
|
||||
workerOutput.Add(stderr.Data);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
else if (HostContext.RunMode == RunMode.Local)
|
||||
{
|
||||
processInvoker.OutputDataReceived += (object sender, ProcessDataReceivedEventArgs e) => Console.WriteLine(e.Data);
|
||||
processInvoker.ErrorDataReceived += (object sender, ProcessDataReceivedEventArgs e) => Console.WriteLine(e.Data);
|
||||
}
|
||||
|
||||
// Start the child process.
|
||||
HostContext.WritePerfCounter("StartingWorkerProcess");
|
||||
var assemblyDirectory = HostContext.GetDirectory(WellKnownDirectory.Bin);
|
||||
string workerFileName = Path.Combine(assemblyDirectory, _workerProcessName);
|
||||
workerProcessTask = processInvoker.ExecuteAsync(
|
||||
workingDirectory: assemblyDirectory,
|
||||
fileName: workerFileName,
|
||||
arguments: "spawnclient " + pipeHandleOut + " " + pipeHandleIn,
|
||||
environment: null,
|
||||
requireExitCodeZero: false,
|
||||
outputEncoding: null,
|
||||
killProcessOnCancel: true,
|
||||
redirectStandardIn: null,
|
||||
inheritConsoleHandler: false,
|
||||
keepStandardInOpen: false,
|
||||
highPriorityProcess: true,
|
||||
cancellationToken: workerProcessCancelTokenSource.Token);
|
||||
});
|
||||
|
||||
// Send the job request message.
|
||||
// Kill the worker process if sending the job message times out. The worker
|
||||
// process may have successfully received the job message.
|
||||
try
|
||||
{
|
||||
Trace.Info($"Send job request message to worker for job {message.JobId}.");
|
||||
HostContext.WritePerfCounter($"RunnerSendingJobToWorker_{message.JobId}");
|
||||
using (var csSendJobRequest = new CancellationTokenSource(_channelTimeout))
|
||||
{
|
||||
await processChannel.SendAsync(
|
||||
messageType: MessageType.NewJobRequest,
|
||||
body: JsonUtility.ToString(message),
|
||||
cancellationToken: csSendJobRequest.Token);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// message send been cancelled.
|
||||
// timeout 30 sec. kill worker.
|
||||
Trace.Info($"Job request message sending for job {message.JobId} been cancelled, kill running worker.");
|
||||
workerProcessCancelTokenSource.Cancel();
|
||||
try
|
||||
{
|
||||
await workerProcessTask;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Trace.Info("worker process has been killed.");
|
||||
}
|
||||
|
||||
Trace.Info($"Stop renew job request for job {message.JobId}.");
|
||||
// stop renew lock
|
||||
lockRenewalTokenSource.Cancel();
|
||||
// renew job request should never blows up.
|
||||
await renewJobRequest;
|
||||
|
||||
// not finish the job request since the job haven't run on worker at all, we will not going to set a result to server.
|
||||
return;
|
||||
}
|
||||
|
||||
// we get first jobrequest renew succeed and start the worker process with the job message.
|
||||
// send notification to machine provisioner.
|
||||
var systemConnection = message.Resources.Endpoints.SingleOrDefault(x => string.Equals(x.Name, WellKnownServiceEndpointNames.SystemVssConnection, StringComparison.OrdinalIgnoreCase));
|
||||
var accessToken = systemConnection?.Authorization?.Parameters["AccessToken"];
|
||||
await notification.JobStarted(message.JobId, accessToken, systemConnection.Url);
|
||||
|
||||
HostContext.WritePerfCounter($"SentJobToWorker_{requestId.ToString()}");
|
||||
|
||||
try
|
||||
{
|
||||
TaskResult resultOnAbandonOrCancel = TaskResult.Succeeded;
|
||||
// wait for renewlock, worker process or cancellation token been fired.
|
||||
var completedTask = await Task.WhenAny(renewJobRequest, workerProcessTask, Task.Delay(-1, jobRequestCancellationToken));
|
||||
if (completedTask == workerProcessTask)
|
||||
{
|
||||
// worker finished successfully, complete job request with result, attach unhandled exception reported by worker, stop renew lock, job has finished.
|
||||
int returnCode = await workerProcessTask;
|
||||
Trace.Info($"Worker finished for job {message.JobId}. Code: " + returnCode);
|
||||
|
||||
string detailInfo = null;
|
||||
if (!TaskResultUtil.IsValidReturnCode(returnCode))
|
||||
{
|
||||
detailInfo = string.Join(Environment.NewLine, workerOutput);
|
||||
Trace.Info($"Return code {returnCode} indicate worker encounter an unhandled exception or app crash, attach worker stdout/stderr to JobRequest result.");
|
||||
await LogWorkerProcessUnhandledException(message, detailInfo);
|
||||
}
|
||||
|
||||
TaskResult result = TaskResultUtil.TranslateFromReturnCode(returnCode);
|
||||
Trace.Info($"finish job request for job {message.JobId} with result: {result}");
|
||||
term.WriteLine($"{DateTime.UtcNow:u}: Job {message.JobDisplayName} completed with result: {result}");
|
||||
|
||||
Trace.Info($"Stop renew job request for job {message.JobId}.");
|
||||
// stop renew lock
|
||||
lockRenewalTokenSource.Cancel();
|
||||
// renew job request should never blows up.
|
||||
await renewJobRequest;
|
||||
|
||||
// complete job request
|
||||
await CompleteJobRequestAsync(_poolId, message, lockToken, result, detailInfo);
|
||||
|
||||
// print out unhandled exception happened in worker after we complete job request.
|
||||
// when we run out of disk space, report back to server has higher priority.
|
||||
if (!string.IsNullOrEmpty(detailInfo))
|
||||
{
|
||||
Trace.Error("Unhandled exception happened in worker:");
|
||||
Trace.Error(detailInfo);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
else if (completedTask == renewJobRequest)
|
||||
{
|
||||
resultOnAbandonOrCancel = TaskResult.Abandoned;
|
||||
}
|
||||
else
|
||||
{
|
||||
resultOnAbandonOrCancel = TaskResult.Canceled;
|
||||
}
|
||||
|
||||
// renew job request completed or job request cancellation token been fired for RunAsync(jobrequestmessage)
|
||||
// cancel worker gracefully first, then kill it after worker cancel timeout
|
||||
try
|
||||
{
|
||||
Trace.Info($"Send job cancellation message to worker for job {message.JobId}.");
|
||||
using (var csSendCancel = new CancellationTokenSource(_channelTimeout))
|
||||
{
|
||||
var messageType = MessageType.CancelRequest;
|
||||
if (HostContext.RunnerShutdownToken.IsCancellationRequested)
|
||||
{
|
||||
switch (HostContext.RunnerShutdownReason)
|
||||
{
|
||||
case ShutdownReason.UserCancelled:
|
||||
messageType = MessageType.RunnerShutdown;
|
||||
break;
|
||||
case ShutdownReason.OperatingSystemShutdown:
|
||||
messageType = MessageType.OperatingSystemShutdown;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await processChannel.SendAsync(
|
||||
messageType: messageType,
|
||||
body: string.Empty,
|
||||
cancellationToken: csSendCancel.Token);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// message send been cancelled.
|
||||
Trace.Info($"Job cancel message sending for job {message.JobId} been cancelled, kill running worker.");
|
||||
workerProcessCancelTokenSource.Cancel();
|
||||
try
|
||||
{
|
||||
await workerProcessTask;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Trace.Info("worker process has been killed.");
|
||||
}
|
||||
}
|
||||
|
||||
// wait worker to exit
|
||||
// if worker doesn't exit within timeout, then kill worker.
|
||||
completedTask = await Task.WhenAny(workerProcessTask, Task.Delay(-1, workerCancelTimeoutKillToken));
|
||||
|
||||
// worker haven't exit within cancellation timeout.
|
||||
if (completedTask != workerProcessTask)
|
||||
{
|
||||
Trace.Info($"worker process for job {message.JobId} haven't exit within cancellation timout, kill running worker.");
|
||||
workerProcessCancelTokenSource.Cancel();
|
||||
try
|
||||
{
|
||||
await workerProcessTask;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Trace.Info("worker process has been killed.");
|
||||
}
|
||||
}
|
||||
|
||||
Trace.Info($"finish job request for job {message.JobId} with result: {resultOnAbandonOrCancel}");
|
||||
term.WriteLine($"{DateTime.UtcNow:u}: Job {message.JobDisplayName} completed with result: {resultOnAbandonOrCancel}");
|
||||
// complete job request with cancel result, stop renew lock, job has finished.
|
||||
|
||||
Trace.Info($"Stop renew job request for job {message.JobId}.");
|
||||
// stop renew lock
|
||||
lockRenewalTokenSource.Cancel();
|
||||
// renew job request should never blows up.
|
||||
await renewJobRequest;
|
||||
|
||||
// complete job request
|
||||
await CompleteJobRequestAsync(_poolId, message, lockToken, resultOnAbandonOrCancel);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// This should be the last thing to run so we don't notify external parties until actually finished
|
||||
await notification.JobCompleted(message.JobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RenewJobRequestAsync(int poolId, long requestId, Guid lockToken, TaskCompletionSource<int> firstJobRequestRenewed, CancellationToken token)
|
||||
{
|
||||
var runnerServer = HostContext.GetService<IRunnerServer>();
|
||||
TaskAgentJobRequest request = null;
|
||||
int firstRenewRetryLimit = 5;
|
||||
int encounteringError = 0;
|
||||
|
||||
// renew lock during job running.
|
||||
// stop renew only if cancellation token for lock renew task been signal or exception still happen after retry.
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
request = await runnerServer.RenewAgentRequestAsync(poolId, requestId, lockToken, token);
|
||||
|
||||
Trace.Info($"Successfully renew job request {requestId}, job is valid till {request.LockedUntil.Value}");
|
||||
|
||||
if (!firstJobRequestRenewed.Task.IsCompleted)
|
||||
{
|
||||
// fire first renew succeed event.
|
||||
firstJobRequestRenewed.TrySetResult(0);
|
||||
}
|
||||
|
||||
if (encounteringError > 0)
|
||||
{
|
||||
encounteringError = 0;
|
||||
runnerServer.SetConnectionTimeout(RunnerConnectionType.JobRequest, TimeSpan.FromSeconds(60));
|
||||
HostContext.WritePerfCounter("JobRenewRecovered");
|
||||
}
|
||||
|
||||
// renew again after 60 sec delay
|
||||
await HostContext.Delay(TimeSpan.FromSeconds(60), token);
|
||||
}
|
||||
catch (TaskAgentJobNotFoundException)
|
||||
{
|
||||
// no need for retry. the job is not valid anymore.
|
||||
Trace.Info($"TaskAgentJobNotFoundException received when renew job request {requestId}, job is no longer valid, stop renew job request.");
|
||||
return;
|
||||
}
|
||||
catch (TaskAgentJobTokenExpiredException)
|
||||
{
|
||||
// no need for retry. the job is not valid anymore.
|
||||
Trace.Info($"TaskAgentJobTokenExpiredException received renew job request {requestId}, job is no longer valid, stop renew job request.");
|
||||
return;
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
// OperationCanceledException may caused by http timeout or _lockRenewalTokenSource.Cance();
|
||||
// Stop renew only on cancellation token fired.
|
||||
Trace.Info($"job renew has been canceled, stop renew job request {requestId}.");
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Catch exception during renew runner jobrequest {requestId}.");
|
||||
Trace.Error(ex);
|
||||
encounteringError++;
|
||||
|
||||
// retry
|
||||
TimeSpan remainingTime = TimeSpan.Zero;
|
||||
if (!firstJobRequestRenewed.Task.IsCompleted)
|
||||
{
|
||||
// retry 5 times every 10 sec for the first renew
|
||||
if (firstRenewRetryLimit-- > 0)
|
||||
{
|
||||
remainingTime = TimeSpan.FromSeconds(10);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// retry till reach lockeduntil + 5 mins extra buffer.
|
||||
remainingTime = request.LockedUntil.Value + TimeSpan.FromMinutes(5) - DateTime.UtcNow;
|
||||
}
|
||||
|
||||
if (remainingTime > TimeSpan.Zero)
|
||||
{
|
||||
TimeSpan delayTime;
|
||||
if (!firstJobRequestRenewed.Task.IsCompleted)
|
||||
{
|
||||
Trace.Info($"Retrying lock renewal for jobrequest {requestId}. The first job renew request has failed.");
|
||||
delayTime = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10));
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info($"Retrying lock renewal for jobrequest {requestId}. Job is valid until {request.LockedUntil.Value}.");
|
||||
if (encounteringError > 5)
|
||||
{
|
||||
delayTime = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(30));
|
||||
}
|
||||
else
|
||||
{
|
||||
delayTime = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15));
|
||||
}
|
||||
}
|
||||
|
||||
// Re-establish connection to server in order to avoid affinity with server.
|
||||
// Reduce connection timeout to 30 seconds (from 60s)
|
||||
HostContext.WritePerfCounter("ResetJobRenewConnection");
|
||||
await runnerServer.RefreshConnectionAsync(RunnerConnectionType.JobRequest, TimeSpan.FromSeconds(30));
|
||||
|
||||
try
|
||||
{
|
||||
// back-off before next retry.
|
||||
await HostContext.Delay(delayTime, token);
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
Trace.Info($"job renew has been canceled, stop renew job request {requestId}.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info($"Lock renewal has run out of retry, stop renew lock for jobrequest {requestId}.");
|
||||
HostContext.WritePerfCounter("JobRenewReachLimit");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: We need send detailInfo back to DT in order to add an issue for the job
|
||||
private async Task CompleteJobRequestAsync(int poolId, Pipelines.AgentJobRequestMessage message, Guid lockToken, TaskResult result, string detailInfo = null)
|
||||
{
|
||||
Trace.Entering();
|
||||
if (HostContext.RunMode == RunMode.Local)
|
||||
{
|
||||
_localRunJobResult.Value[message.RequestId] = result;
|
||||
return;
|
||||
}
|
||||
|
||||
if (PlanUtil.GetFeatures(message.Plan).HasFlag(PlanFeatures.JobCompletedPlanEvent))
|
||||
{
|
||||
Trace.Verbose($"Skip FinishAgentRequest call from Listener because Plan version is {message.Plan.Version}");
|
||||
return;
|
||||
}
|
||||
|
||||
var runnerServer = HostContext.GetService<IRunnerServer>();
|
||||
int completeJobRequestRetryLimit = 5;
|
||||
List<Exception> exceptions = new List<Exception>();
|
||||
while (completeJobRequestRetryLimit-- > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
await runnerServer.FinishAgentRequestAsync(poolId, message.RequestId, lockToken, DateTime.UtcNow, result, CancellationToken.None);
|
||||
return;
|
||||
}
|
||||
catch (TaskAgentJobNotFoundException)
|
||||
{
|
||||
Trace.Info($"TaskAgentJobNotFoundException received, job {message.JobId} is no longer valid.");
|
||||
return;
|
||||
}
|
||||
catch (TaskAgentJobTokenExpiredException)
|
||||
{
|
||||
Trace.Info($"TaskAgentJobTokenExpiredException received, job {message.JobId} is no longer valid.");
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error($"Catch exception during complete runner jobrequest {message.RequestId}.");
|
||||
Trace.Error(ex);
|
||||
exceptions.Add(ex);
|
||||
}
|
||||
|
||||
// delay 5 seconds before next retry.
|
||||
await Task.Delay(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
// rethrow all catched exceptions during retry.
|
||||
throw new AggregateException(exceptions);
|
||||
}
|
||||
|
||||
// log an error issue to job level timeline record
|
||||
private async Task LogWorkerProcessUnhandledException(Pipelines.AgentJobRequestMessage message, string errorMessage)
|
||||
{
|
||||
try
|
||||
{
|
||||
var systemConnection = message.Resources.Endpoints.SingleOrDefault(x => string.Equals(x.Name, WellKnownServiceEndpointNames.SystemVssConnection));
|
||||
ArgUtil.NotNull(systemConnection, nameof(systemConnection));
|
||||
|
||||
var jobServer = HostContext.GetService<IJobServer>();
|
||||
VssCredentials jobServerCredential = VssUtil.GetVssCredential(systemConnection);
|
||||
Uri jobServerUrl = systemConnection.Url;
|
||||
|
||||
// Make sure SystemConnection Url match Config Url base for OnPremises server
|
||||
if (!message.Variables.ContainsKey(Constants.Variables.System.ServerType) ||
|
||||
string.Equals(message.Variables[Constants.Variables.System.ServerType]?.Value, "OnPremises", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
Uri result = null;
|
||||
Uri configUri = new Uri(_runnerSetting.ServerUrl);
|
||||
if (Uri.TryCreate(new Uri(configUri.GetComponents(UriComponents.SchemeAndServer, UriFormat.Unescaped)), jobServerUrl.PathAndQuery, out result))
|
||||
{
|
||||
//replace the schema and host portion of messageUri with the host from the
|
||||
//server URI (which was set at config time)
|
||||
jobServerUrl = result;
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
//cannot parse the Uri - not a fatal error
|
||||
Trace.Error(ex);
|
||||
}
|
||||
catch (UriFormatException ex)
|
||||
{
|
||||
//cannot parse the Uri - not a fatal error
|
||||
Trace.Error(ex);
|
||||
}
|
||||
}
|
||||
|
||||
VssConnection jobConnection = VssUtil.CreateConnection(jobServerUrl, jobServerCredential);
|
||||
await jobServer.ConnectAsync(jobConnection);
|
||||
var timeline = await jobServer.GetTimelineAsync(message.Plan.ScopeIdentifier, message.Plan.PlanType, message.Plan.PlanId, message.Timeline.Id, CancellationToken.None);
|
||||
ArgUtil.NotNull(timeline, nameof(timeline));
|
||||
TimelineRecord jobRecord = timeline.Records.FirstOrDefault(x => x.Id == message.JobId && x.RecordType == "Job");
|
||||
ArgUtil.NotNull(jobRecord, nameof(jobRecord));
|
||||
jobRecord.ErrorCount++;
|
||||
jobRecord.Issues.Add(new Issue() { Type = IssueType.Error, Message = errorMessage });
|
||||
await jobServer.UpdateTimelineRecordsAsync(message.Plan.ScopeIdentifier, message.Plan.PlanType, message.Plan.PlanId, message.Timeline.Id, new TimelineRecord[] { jobRecord }, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error("Fail to report unhandled exception from Runner.Worker process");
|
||||
Trace.Error(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private class WorkerDispatcher : IDisposable
|
||||
{
|
||||
public long RequestId { get; }
|
||||
public Guid JobId { get; }
|
||||
public Task WorkerDispatch { get; set; }
|
||||
public CancellationTokenSource WorkerCancellationTokenSource { get; private set; }
|
||||
public CancellationTokenSource WorkerCancelTimeoutKillTokenSource { get; private set; }
|
||||
private readonly object _lock = new object();
|
||||
|
||||
public WorkerDispatcher(Guid jobId, long requestId)
|
||||
{
|
||||
JobId = jobId;
|
||||
RequestId = requestId;
|
||||
WorkerCancelTimeoutKillTokenSource = new CancellationTokenSource();
|
||||
WorkerCancellationTokenSource = new CancellationTokenSource();
|
||||
}
|
||||
|
||||
public bool Cancel(TimeSpan timeout)
|
||||
{
|
||||
if (WorkerCancellationTokenSource != null && WorkerCancelTimeoutKillTokenSource != null)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (WorkerCancellationTokenSource != null && WorkerCancelTimeoutKillTokenSource != null)
|
||||
{
|
||||
WorkerCancellationTokenSource.Cancel();
|
||||
|
||||
// make sure we have at least 60 seconds for cancellation.
|
||||
if (timeout.TotalSeconds < 60)
|
||||
{
|
||||
timeout = TimeSpan.FromSeconds(60);
|
||||
}
|
||||
|
||||
WorkerCancelTimeoutKillTokenSource.CancelAfter(timeout.Subtract(TimeSpan.FromSeconds(15)));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
if (WorkerCancellationTokenSource != null || WorkerCancelTimeoutKillTokenSource != null)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (WorkerCancellationTokenSource != null)
|
||||
{
|
||||
WorkerCancellationTokenSource.Dispose();
|
||||
WorkerCancellationTokenSource = null;
|
||||
}
|
||||
|
||||
if (WorkerCancelTimeoutKillTokenSource != null)
|
||||
{
|
||||
WorkerCancelTimeoutKillTokenSource.Dispose();
|
||||
WorkerCancelTimeoutKillTokenSource = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
407
src/Runner.Listener/MessageListener.cs
Normal file
407
src/Runner.Listener/MessageListener.cs
Normal file
@@ -0,0 +1,407 @@
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common.Capabilities;
|
||||
using GitHub.Runner.Listener.Configuration;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Services.Common;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Security.Cryptography;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using GitHub.Services.WebApi;
|
||||
using GitHub.Services.OAuth;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Listener
|
||||
{
|
||||
[ServiceLocator(Default = typeof(MessageListener))]
|
||||
public interface IMessageListener : IRunnerService
|
||||
{
|
||||
Task<Boolean> CreateSessionAsync(CancellationToken token);
|
||||
Task DeleteSessionAsync();
|
||||
Task<TaskAgentMessage> GetNextMessageAsync(CancellationToken token);
|
||||
Task DeleteMessageAsync(TaskAgentMessage message);
|
||||
}
|
||||
|
||||
public sealed class MessageListener : RunnerService, IMessageListener
|
||||
{
|
||||
private long? _lastMessageId;
|
||||
private RunnerSettings _settings;
|
||||
private ITerminal _term;
|
||||
private IRunnerServer _runnerServer;
|
||||
private TaskAgentSession _session;
|
||||
private TimeSpan _getNextMessageRetryInterval;
|
||||
private readonly TimeSpan _sessionCreationRetryInterval = TimeSpan.FromSeconds(30);
|
||||
private readonly TimeSpan _sessionConflictRetryLimit = TimeSpan.FromMinutes(4);
|
||||
private readonly TimeSpan _clockSkewRetryLimit = TimeSpan.FromMinutes(30);
|
||||
private readonly Dictionary<string, int> _sessionCreationExceptionTracker = new Dictionary<string, int>();
|
||||
|
||||
public override void Initialize(IHostContext hostContext)
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
|
||||
_term = HostContext.GetService<ITerminal>();
|
||||
_runnerServer = HostContext.GetService<IRunnerServer>();
|
||||
}
|
||||
|
||||
public async Task<Boolean> CreateSessionAsync(CancellationToken token)
|
||||
{
|
||||
Trace.Entering();
|
||||
|
||||
// Settings
|
||||
var configManager = HostContext.GetService<IConfigurationManager>();
|
||||
_settings = configManager.LoadSettings();
|
||||
var serverUrl = _settings.ServerUrl;
|
||||
Trace.Info(_settings);
|
||||
|
||||
// Capabilities.
|
||||
Dictionary<string, string> systemCapabilities = await HostContext.GetService<ICapabilitiesManager>().GetCapabilitiesAsync(_settings, token);
|
||||
|
||||
// Create connection.
|
||||
Trace.Info("Loading Credentials");
|
||||
var credMgr = HostContext.GetService<ICredentialManager>();
|
||||
VssCredentials creds = credMgr.LoadCredentials();
|
||||
|
||||
var agent = new TaskAgentReference
|
||||
{
|
||||
Id = _settings.AgentId,
|
||||
Name = _settings.AgentName,
|
||||
Version = BuildConstants.RunnerPackage.Version,
|
||||
OSDescription = RuntimeInformation.OSDescription,
|
||||
};
|
||||
string sessionName = $"{Environment.MachineName ?? "RUNNER"}";
|
||||
var taskAgentSession = new TaskAgentSession(sessionName, agent, systemCapabilities);
|
||||
|
||||
string errorMessage = string.Empty;
|
||||
bool encounteringError = false;
|
||||
|
||||
while (true)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
Trace.Info($"Attempt to create session.");
|
||||
try
|
||||
{
|
||||
Trace.Info("Connecting to the Agent Server...");
|
||||
await _runnerServer.ConnectAsync(new Uri(serverUrl), creds);
|
||||
Trace.Info("VssConnection created");
|
||||
|
||||
_term.WriteLine();
|
||||
_term.WriteSuccessMessage("Connected to GitHub");
|
||||
_term.WriteLine();
|
||||
|
||||
_session = await _runnerServer.CreateAgentSessionAsync(
|
||||
_settings.PoolId,
|
||||
taskAgentSession,
|
||||
token);
|
||||
|
||||
Trace.Info($"Session created.");
|
||||
if (encounteringError)
|
||||
{
|
||||
_term.WriteLine($"{DateTime.UtcNow:u}: Runner reconnected.");
|
||||
_sessionCreationExceptionTracker.Clear();
|
||||
encounteringError = false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
Trace.Info("Session creation has been cancelled.");
|
||||
throw;
|
||||
}
|
||||
catch (TaskAgentAccessTokenExpiredException)
|
||||
{
|
||||
Trace.Info("Agent OAuth token has been revoked. Session creation failed.");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error("Catch exception during create session.");
|
||||
Trace.Error(ex);
|
||||
|
||||
if (!IsSessionCreationExceptionRetriable(ex))
|
||||
{
|
||||
_term.WriteError($"Failed to create session. {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!encounteringError) //print the message only on the first error
|
||||
{
|
||||
_term.WriteError($"{DateTime.UtcNow:u}: Runner connect error: {ex.Message}. Retrying until reconnected.");
|
||||
encounteringError = true;
|
||||
}
|
||||
|
||||
Trace.Info("Sleeping for {0} seconds before retrying.", _sessionCreationRetryInterval.TotalSeconds);
|
||||
await HostContext.Delay(_sessionCreationRetryInterval, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteSessionAsync()
|
||||
{
|
||||
if (_session != null && _session.SessionId != Guid.Empty)
|
||||
{
|
||||
using (var ts = new CancellationTokenSource(TimeSpan.FromSeconds(30)))
|
||||
{
|
||||
await _runnerServer.DeleteAgentSessionAsync(_settings.PoolId, _session.SessionId, ts.Token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TaskAgentMessage> GetNextMessageAsync(CancellationToken token)
|
||||
{
|
||||
Trace.Entering();
|
||||
ArgUtil.NotNull(_session, nameof(_session));
|
||||
ArgUtil.NotNull(_settings, nameof(_settings));
|
||||
bool encounteringError = false;
|
||||
int continuousError = 0;
|
||||
string errorMessage = string.Empty;
|
||||
Stopwatch heartbeat = new Stopwatch();
|
||||
heartbeat.Restart();
|
||||
while (true)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
TaskAgentMessage message = null;
|
||||
try
|
||||
{
|
||||
message = await _runnerServer.GetAgentMessageAsync(_settings.PoolId,
|
||||
_session.SessionId,
|
||||
_lastMessageId,
|
||||
token);
|
||||
|
||||
// Decrypt the message body if the session is using encryption
|
||||
message = DecryptMessage(message);
|
||||
|
||||
if (message != null)
|
||||
{
|
||||
_lastMessageId = message.MessageId;
|
||||
}
|
||||
|
||||
if (encounteringError) //print the message once only if there was an error
|
||||
{
|
||||
_term.WriteLine($"{DateTime.UtcNow:u}: Runner reconnected.");
|
||||
encounteringError = false;
|
||||
continuousError = 0;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
Trace.Info("Get next message has been cancelled.");
|
||||
throw;
|
||||
}
|
||||
catch (TaskAgentAccessTokenExpiredException)
|
||||
{
|
||||
Trace.Info("Agent OAuth token has been revoked. Unable to pull message.");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error("Catch exception during get next message.");
|
||||
Trace.Error(ex);
|
||||
|
||||
// don't retry if SkipSessionRecover = true, DT service will delete agent session to stop agent from taking more jobs.
|
||||
if (ex is TaskAgentSessionExpiredException && !_settings.SkipSessionRecover && await CreateSessionAsync(token))
|
||||
{
|
||||
Trace.Info($"{nameof(TaskAgentSessionExpiredException)} received, recovered by recreate session.");
|
||||
}
|
||||
else if (!IsGetNextMessageExceptionRetriable(ex))
|
||||
{
|
||||
throw;
|
||||
}
|
||||
else
|
||||
{
|
||||
continuousError++;
|
||||
//retry after a random backoff to avoid service throttling
|
||||
//in case of there is a service error happened and all agents get kicked off of the long poll and all agent try to reconnect back at the same time.
|
||||
if (continuousError <= 5)
|
||||
{
|
||||
// random backoff [15, 30]
|
||||
_getNextMessageRetryInterval = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(30), _getNextMessageRetryInterval);
|
||||
}
|
||||
else
|
||||
{
|
||||
// more aggressive backoff [30, 60]
|
||||
_getNextMessageRetryInterval = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(60), _getNextMessageRetryInterval);
|
||||
}
|
||||
|
||||
if (!encounteringError)
|
||||
{
|
||||
//print error only on the first consecutive error
|
||||
_term.WriteError($"{DateTime.UtcNow:u}: Runner connect error: {ex.Message}. Retrying until reconnected.");
|
||||
encounteringError = true;
|
||||
}
|
||||
|
||||
// re-create VssConnection before next retry
|
||||
await _runnerServer.RefreshConnectionAsync(RunnerConnectionType.MessageQueue, TimeSpan.FromSeconds(60));
|
||||
|
||||
Trace.Info("Sleeping for {0} seconds before retrying.", _getNextMessageRetryInterval.TotalSeconds);
|
||||
await HostContext.Delay(_getNextMessageRetryInterval, token);
|
||||
}
|
||||
}
|
||||
|
||||
if (message == null)
|
||||
{
|
||||
if (heartbeat.Elapsed > TimeSpan.FromMinutes(30))
|
||||
{
|
||||
Trace.Info($"No message retrieved from session '{_session.SessionId}' within last 30 minutes.");
|
||||
heartbeat.Restart();
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Verbose($"No message retrieved from session '{_session.SessionId}'.");
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
Trace.Info($"Message '{message.MessageId}' received from session '{_session.SessionId}'.");
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteMessageAsync(TaskAgentMessage message)
|
||||
{
|
||||
Trace.Entering();
|
||||
ArgUtil.NotNull(_session, nameof(_session));
|
||||
|
||||
if (message != null && _session.SessionId != Guid.Empty)
|
||||
{
|
||||
using (var cs = new CancellationTokenSource(TimeSpan.FromSeconds(30)))
|
||||
{
|
||||
await _runnerServer.DeleteAgentMessageAsync(_settings.PoolId, message.MessageId, _session.SessionId, cs.Token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private TaskAgentMessage DecryptMessage(TaskAgentMessage message)
|
||||
{
|
||||
if (_session.EncryptionKey == null ||
|
||||
_session.EncryptionKey.Value.Length == 0 ||
|
||||
message == null ||
|
||||
message.IV == null ||
|
||||
message.IV.Length == 0)
|
||||
{
|
||||
return message;
|
||||
}
|
||||
|
||||
using (var aes = Aes.Create())
|
||||
using (var decryptor = GetMessageDecryptor(aes, message))
|
||||
using (var body = new MemoryStream(Convert.FromBase64String(message.Body)))
|
||||
using (var cryptoStream = new CryptoStream(body, decryptor, CryptoStreamMode.Read))
|
||||
using (var bodyReader = new StreamReader(cryptoStream, Encoding.UTF8))
|
||||
{
|
||||
message.Body = bodyReader.ReadToEnd();
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
private ICryptoTransform GetMessageDecryptor(
|
||||
Aes aes,
|
||||
TaskAgentMessage message)
|
||||
{
|
||||
if (_session.EncryptionKey.Encrypted)
|
||||
{
|
||||
// The agent session encryption key uses the AES symmetric algorithm
|
||||
var keyManager = HostContext.GetService<IRSAKeyManager>();
|
||||
using (var rsa = keyManager.GetKey())
|
||||
{
|
||||
return aes.CreateDecryptor(rsa.Decrypt(_session.EncryptionKey.Value, RSAEncryptionPadding.OaepSHA1), message.IV);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return aes.CreateDecryptor(_session.EncryptionKey.Value, message.IV);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsGetNextMessageExceptionRetriable(Exception ex)
|
||||
{
|
||||
if (ex is TaskAgentNotFoundException ||
|
||||
ex is TaskAgentPoolNotFoundException ||
|
||||
ex is TaskAgentSessionExpiredException ||
|
||||
ex is AccessDeniedException ||
|
||||
ex is VssUnauthorizedException)
|
||||
{
|
||||
Trace.Info($"Non-retriable exception: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info($"Retriable exception: {ex.Message}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsSessionCreationExceptionRetriable(Exception ex)
|
||||
{
|
||||
if (ex is TaskAgentNotFoundException)
|
||||
{
|
||||
Trace.Info("The agent no longer exists on the server. Stopping the runner.");
|
||||
_term.WriteError("The runner no longer exists on the server. Please reconfigure the runner.");
|
||||
return false;
|
||||
}
|
||||
else if (ex is TaskAgentSessionConflictException)
|
||||
{
|
||||
Trace.Info("The session for this runner already exists.");
|
||||
_term.WriteError("A session for this runner already exists.");
|
||||
if (_sessionCreationExceptionTracker.ContainsKey(nameof(TaskAgentSessionConflictException)))
|
||||
{
|
||||
_sessionCreationExceptionTracker[nameof(TaskAgentSessionConflictException)]++;
|
||||
if (_sessionCreationExceptionTracker[nameof(TaskAgentSessionConflictException)] * _sessionCreationRetryInterval.TotalSeconds >= _sessionConflictRetryLimit.TotalSeconds)
|
||||
{
|
||||
Trace.Info("The session conflict exception have reached retry limit.");
|
||||
_term.WriteError($"Stop retry on SessionConflictException after retried for {_sessionConflictRetryLimit.TotalSeconds} seconds.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_sessionCreationExceptionTracker[nameof(TaskAgentSessionConflictException)] = 1;
|
||||
}
|
||||
|
||||
Trace.Info("The session conflict exception haven't reached retry limit.");
|
||||
return true;
|
||||
}
|
||||
else if (ex is VssOAuthTokenRequestException && ex.Message.Contains("Current server time is"))
|
||||
{
|
||||
Trace.Info("Local clock might skewed.");
|
||||
_term.WriteError("The local machine's clock may be out of sync with the server time by more than five minutes. Please sync your clock with your domain or internet time and try again.");
|
||||
if (_sessionCreationExceptionTracker.ContainsKey(nameof(VssOAuthTokenRequestException)))
|
||||
{
|
||||
_sessionCreationExceptionTracker[nameof(VssOAuthTokenRequestException)]++;
|
||||
if (_sessionCreationExceptionTracker[nameof(VssOAuthTokenRequestException)] * _sessionCreationRetryInterval.TotalSeconds >= _clockSkewRetryLimit.TotalSeconds)
|
||||
{
|
||||
Trace.Info("The OAuth token request exception have reached retry limit.");
|
||||
_term.WriteError($"Stopped retrying OAuth token request exception after {_clockSkewRetryLimit.TotalSeconds} seconds.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_sessionCreationExceptionTracker[nameof(VssOAuthTokenRequestException)] = 1;
|
||||
}
|
||||
|
||||
Trace.Info("The OAuth token request exception haven't reached retry limit.");
|
||||
return true;
|
||||
}
|
||||
else if (ex is TaskAgentPoolNotFoundException ||
|
||||
ex is AccessDeniedException ||
|
||||
ex is VssUnauthorizedException)
|
||||
{
|
||||
Trace.Info($"Non-retriable exception: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info($"Retriable exception: {ex.Message}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
140
src/Runner.Listener/Program.cs
Normal file
140
src/Runner.Listener/Program.cs
Normal file
@@ -0,0 +1,140 @@
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using GitHub.Runner.Sdk;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GitHub.Runner.Listener
|
||||
{
|
||||
public static class Program
|
||||
{
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
using (HostContext context = new HostContext("Runner"))
|
||||
{
|
||||
return MainAsync(context, args).GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
|
||||
// Return code definition: (this will be used by service host to determine whether it will re-launch Runner.Listener)
|
||||
// 0: Runner exit
|
||||
// 1: Terminate failure
|
||||
// 2: Retriable failure
|
||||
// 3: Exit for self update
|
||||
public async static Task<int> MainAsync(IHostContext context, string[] args)
|
||||
{
|
||||
Tracing trace = context.GetTrace(nameof(GitHub.Runner.Listener));
|
||||
trace.Info($"Runner is built for {Constants.Runner.Platform} ({Constants.Runner.PlatformArchitecture}) - {BuildConstants.RunnerPackage.PackageName}.");
|
||||
trace.Info($"RuntimeInformation: {RuntimeInformation.OSDescription}.");
|
||||
context.WritePerfCounter("RunnerProcessStarted");
|
||||
var terminal = context.GetService<ITerminal>();
|
||||
|
||||
// Validate the binaries intended for one OS are not running on a different OS.
|
||||
switch (Constants.Runner.Platform)
|
||||
{
|
||||
case Constants.OSPlatform.Linux:
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
terminal.WriteLine("This runner version is built for Linux. Please install a correct build for your OS.");
|
||||
return Constants.Runner.ReturnCode.TerminatedError;
|
||||
}
|
||||
break;
|
||||
case Constants.OSPlatform.OSX:
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
terminal.WriteLine("This runner version is built for OSX. Please install a correct build for your OS.");
|
||||
return Constants.Runner.ReturnCode.TerminatedError;
|
||||
}
|
||||
break;
|
||||
case Constants.OSPlatform.Windows:
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
terminal.WriteLine("This runner version is built for Windows. Please install a correct build for your OS.");
|
||||
return Constants.Runner.ReturnCode.TerminatedError;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
terminal.WriteLine($"Running the runner on this platform is not supported. The current platform is {RuntimeInformation.OSDescription} and it was built for {Constants.Runner.Platform.ToString()}.");
|
||||
return Constants.Runner.ReturnCode.TerminatedError;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
trace.Info($"Version: {BuildConstants.RunnerPackage.Version}");
|
||||
trace.Info($"Commit: {BuildConstants.Source.CommitHash}");
|
||||
trace.Info($"Culture: {CultureInfo.CurrentCulture.Name}");
|
||||
trace.Info($"UI Culture: {CultureInfo.CurrentUICulture.Name}");
|
||||
|
||||
// Validate directory permissions.
|
||||
string runnerDirectory = context.GetDirectory(WellKnownDirectory.Root);
|
||||
trace.Info($"Validating directory permissions for: '{runnerDirectory}'");
|
||||
try
|
||||
{
|
||||
IOUtil.ValidateExecutePermission(runnerDirectory);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
terminal.WriteError($"An error occurred: {e.Message}");
|
||||
trace.Error(e);
|
||||
return Constants.Runner.ReturnCode.TerminatedError;
|
||||
}
|
||||
|
||||
// Add environment variables from .env file
|
||||
string envFile = Path.Combine(context.GetDirectory(WellKnownDirectory.Root), ".env");
|
||||
if (File.Exists(envFile))
|
||||
{
|
||||
var envContents = File.ReadAllLines(envFile);
|
||||
foreach (var env in envContents)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(env) && env.IndexOf('=') > 0)
|
||||
{
|
||||
string envKey = env.Substring(0, env.IndexOf('='));
|
||||
string envValue = env.Substring(env.IndexOf('=') + 1);
|
||||
Environment.SetEnvironmentVariable(envKey, envValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the command line args.
|
||||
var command = new CommandSettings(context, args);
|
||||
trace.Info("Arguments parsed");
|
||||
|
||||
// Up front validation, warn for unrecognized commandline args.
|
||||
var unknownCommandlines = command.Validate();
|
||||
if (unknownCommandlines.Count > 0)
|
||||
{
|
||||
terminal.WriteError($"Unrecognized command-line input arguments: '{string.Join(", ", unknownCommandlines)}'. For usage refer to: .\\config.cmd --help or ./config.sh --help");
|
||||
}
|
||||
|
||||
// Defer to the Runner class to execute the command.
|
||||
IRunner runner = context.GetService<IRunner>();
|
||||
try
|
||||
{
|
||||
return await runner.ExecuteCommand(command);
|
||||
}
|
||||
catch (OperationCanceledException) when (context.RunnerShutdownToken.IsCancellationRequested)
|
||||
{
|
||||
trace.Info("Runner execution been cancelled.");
|
||||
return Constants.Runner.ReturnCode.Success;
|
||||
}
|
||||
catch (NonRetryableException e)
|
||||
{
|
||||
terminal.WriteError($"An error occurred: {e.Message}");
|
||||
trace.Error(e);
|
||||
return Constants.Runner.ReturnCode.TerminatedError;
|
||||
}
|
||||
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
terminal.WriteError($"An error occurred: {e.Message}");
|
||||
trace.Error(e);
|
||||
return Constants.Runner.ReturnCode.RetryableError;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
70
src/Runner.Listener/Runner.Listener.csproj
Normal file
70
src/Runner.Listener/Runner.Listener.csproj
Normal file
@@ -0,0 +1,70 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netcoreapp2.2</TargetFramework>
|
||||
<OutputType>Exe</OutputType>
|
||||
<RuntimeIdentifiers>win-x64;win-x86;linux-x64;linux-arm;rhel.6-x64;osx-x64</RuntimeIdentifiers>
|
||||
<TargetLatestRuntimePatch>true</TargetLatestRuntimePatch>
|
||||
<AssetTargetFallback>portable-net45+win8</AssetTargetFallback>
|
||||
<NoWarn>NU1701;NU1603</NoWarn>
|
||||
<Version>$(Version)</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Sdk\Sdk.csproj" />
|
||||
<ProjectReference Include="..\Runner.Sdk\Runner.Sdk.csproj" />
|
||||
<ProjectReference Include="..\Runner.Common\Runner.Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Win32.Registry" Version="4.4.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
|
||||
<PackageReference Include="System.IO.FileSystem.AccessControl" Version="4.4.0" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="4.4.0" />
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="4.4.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Clients.ActiveDirectory" Version="3.19.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
<DebugType>portable</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true' AND '$(PackageRuntime)' == 'win-x64'">
|
||||
<DefineConstants>OS_WINDOWS;X64;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true' AND '$(PackageRuntime)' == 'win-x86'">
|
||||
<DefineConstants>OS_WINDOWS;X86;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'win-x64'">
|
||||
<DefineConstants>OS_WINDOWS;X64;DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'win-x86'">
|
||||
<DefineConstants>OS_WINDOWS;X86;DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::OSX)))' == 'true'">
|
||||
<DefineConstants>OS_OSX;X64;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::OSX)))' == 'true' AND '$(Configuration)' == 'Debug'">
|
||||
<DefineConstants>OS_OSX;DEBUG;X64;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(PackageRuntime)' == 'linux-x64'">
|
||||
<DefineConstants>OS_LINUX;X64;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(PackageRuntime)' == 'rhel.6-x64'">
|
||||
<DefineConstants>OS_LINUX;OS_RHEL6;X64;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(PackageRuntime)' == 'linux-arm'">
|
||||
<DefineConstants>OS_LINUX;ARM;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'linux-x64'">
|
||||
<DefineConstants>OS_LINUX;X64;DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'rhel.6-x64'">
|
||||
<DefineConstants>OS_LINUX;OS_RHEL6;X64;DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'linux-arm'">
|
||||
<DefineConstants>OS_LINUX;ARM;DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
461
src/Runner.Listener/SelfUpdater.cs
Normal file
461
src/Runner.Listener/SelfUpdater.cs
Normal file
@@ -0,0 +1,461 @@
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Common.Util;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Services.WebApi;
|
||||
using GitHub.Runner.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Listener
|
||||
{
|
||||
[ServiceLocator(Default = typeof(SelfUpdater))]
|
||||
public interface ISelfUpdater : IRunnerService
|
||||
{
|
||||
Task<bool> SelfUpdate(AgentRefreshMessage updateMessage, IJobDispatcher jobDispatcher, bool restartInteractiveRunner, CancellationToken token);
|
||||
}
|
||||
|
||||
public class SelfUpdater : RunnerService, ISelfUpdater
|
||||
{
|
||||
private static string _packageType = "agent";
|
||||
private static string _platform = BuildConstants.RunnerPackage.PackageName;
|
||||
|
||||
private PackageMetadata _targetPackage;
|
||||
private ITerminal _terminal;
|
||||
private IRunnerServer _runnerServer;
|
||||
private int _poolId;
|
||||
private int _agentId;
|
||||
|
||||
public override void Initialize(IHostContext hostContext)
|
||||
{
|
||||
base.Initialize(hostContext);
|
||||
|
||||
_terminal = hostContext.GetService<ITerminal>();
|
||||
_runnerServer = HostContext.GetService<IRunnerServer>();
|
||||
var configStore = HostContext.GetService<IConfigurationStore>();
|
||||
var settings = configStore.GetSettings();
|
||||
_poolId = settings.PoolId;
|
||||
_agentId = settings.AgentId;
|
||||
}
|
||||
|
||||
public async Task<bool> SelfUpdate(AgentRefreshMessage updateMessage, IJobDispatcher jobDispatcher, bool restartInteractiveRunner, CancellationToken token)
|
||||
{
|
||||
if (!await UpdateNeeded(updateMessage.TargetVersion, token))
|
||||
{
|
||||
Trace.Info($"Can't find available update package.");
|
||||
return false;
|
||||
}
|
||||
|
||||
Trace.Info($"An update is available.");
|
||||
|
||||
// Print console line that warn user not shutdown runner.
|
||||
await UpdateRunnerUpdateStateAsync("Runner update in progress, do not shutdown runner.");
|
||||
await UpdateRunnerUpdateStateAsync($"Downloading {_targetPackage.Version} runner");
|
||||
|
||||
await DownloadLatestRunner(token);
|
||||
Trace.Info($"Download latest runner and unzip into runner root.");
|
||||
|
||||
// wait till all running job finish
|
||||
await UpdateRunnerUpdateStateAsync("Waiting for current job finish running.");
|
||||
|
||||
await jobDispatcher.WaitAsync(token);
|
||||
Trace.Info($"All running job has exited.");
|
||||
|
||||
// delete runner backup
|
||||
DeletePreviousVersionRunnerBackup(token);
|
||||
Trace.Info($"Delete old version runner backup.");
|
||||
|
||||
// generate update script from template
|
||||
await UpdateRunnerUpdateStateAsync("Generate and execute update script.");
|
||||
|
||||
string updateScript = GenerateUpdateScript(restartInteractiveRunner);
|
||||
Trace.Info($"Generate update script into: {updateScript}");
|
||||
|
||||
// kick off update script
|
||||
Process invokeScript = new Process();
|
||||
#if OS_WINDOWS
|
||||
invokeScript.StartInfo.FileName = WhichUtil.Which("cmd.exe", trace: Trace);
|
||||
invokeScript.StartInfo.Arguments = $"/c \"{updateScript}\"";
|
||||
#elif (OS_OSX || OS_LINUX)
|
||||
invokeScript.StartInfo.FileName = WhichUtil.Which("bash", trace: Trace);
|
||||
invokeScript.StartInfo.Arguments = $"\"{updateScript}\"";
|
||||
#endif
|
||||
invokeScript.Start();
|
||||
Trace.Info($"Update script start running");
|
||||
|
||||
await UpdateRunnerUpdateStateAsync("Runner will exit shortly for update, should back online within 10 seconds.");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> UpdateNeeded(string targetVersion, CancellationToken token)
|
||||
{
|
||||
// when talk to old version server, always prefer latest package.
|
||||
// old server won't send target version as part of update message.
|
||||
if (string.IsNullOrEmpty(targetVersion))
|
||||
{
|
||||
var packages = await _runnerServer.GetPackagesAsync(_packageType, _platform, 1, token);
|
||||
if (packages == null || packages.Count == 0)
|
||||
{
|
||||
Trace.Info($"There is no package for {_packageType} and {_platform}.");
|
||||
return false;
|
||||
}
|
||||
|
||||
_targetPackage = packages.FirstOrDefault();
|
||||
}
|
||||
else
|
||||
{
|
||||
_targetPackage = await _runnerServer.GetPackageAsync(_packageType, _platform, targetVersion, token);
|
||||
if (_targetPackage == null)
|
||||
{
|
||||
Trace.Info($"There is no package for {_packageType} and {_platform} with version {targetVersion}.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Trace.Info($"Version '{_targetPackage.Version}' of '{_targetPackage.Type}' package available in server.");
|
||||
PackageVersion serverVersion = new PackageVersion(_targetPackage.Version);
|
||||
Trace.Info($"Current running runner version is {BuildConstants.RunnerPackage.Version}");
|
||||
PackageVersion runnerVersion = new PackageVersion(BuildConstants.RunnerPackage.Version);
|
||||
|
||||
return serverVersion.CompareTo(runnerVersion) > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// _work
|
||||
/// \_update
|
||||
/// \bin
|
||||
/// \externals
|
||||
/// \run.sh
|
||||
/// \run.cmd
|
||||
/// \package.zip //temp download .zip/.tar.gz
|
||||
/// </summary>
|
||||
/// <param name="token"></param>
|
||||
/// <returns></returns>
|
||||
private async Task DownloadLatestRunner(CancellationToken token)
|
||||
{
|
||||
string latestRunnerDirectory = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Work), Constants.Path.UpdateDirectory);
|
||||
IOUtil.DeleteDirectory(latestRunnerDirectory, token);
|
||||
Directory.CreateDirectory(latestRunnerDirectory);
|
||||
|
||||
int runnerSuffix = 1;
|
||||
string archiveFile = null;
|
||||
bool downloadSucceeded = false;
|
||||
|
||||
try
|
||||
{
|
||||
// Download the runner, using multiple attempts in order to be resilient against any networking/CDN issues
|
||||
for (int attempt = 1; attempt <= Constants.RunnerDownloadRetryMaxAttempts; attempt++)
|
||||
{
|
||||
// Generate an available package name, and do our best effort to clean up stale local zip files
|
||||
while (true)
|
||||
{
|
||||
if (_targetPackage.Platform.StartsWith("win"))
|
||||
{
|
||||
archiveFile = Path.Combine(latestRunnerDirectory, $"runner{runnerSuffix}.zip");
|
||||
}
|
||||
else
|
||||
{
|
||||
archiveFile = Path.Combine(latestRunnerDirectory, $"runner{runnerSuffix}.tar.gz");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// delete .zip file
|
||||
if (!string.IsNullOrEmpty(archiveFile) && File.Exists(archiveFile))
|
||||
{
|
||||
Trace.Verbose("Deleting latest runner package zip '{0}'", archiveFile);
|
||||
IOUtil.DeleteFile(archiveFile);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// couldn't delete the file for whatever reason, so generate another name
|
||||
Trace.Warning("Failed to delete runner package zip '{0}'. Exception: {1}", archiveFile, ex);
|
||||
runnerSuffix++;
|
||||
}
|
||||
}
|
||||
|
||||
// Allow a 15-minute package download timeout, which is good enough to update the runner from a 1 Mbit/s ADSL connection.
|
||||
if (!int.TryParse(Environment.GetEnvironmentVariable("GITHUB_ACTIONS_RUNNER_DOWNLOAD_TIMEOUT") ?? string.Empty, out int timeoutSeconds))
|
||||
{
|
||||
timeoutSeconds = 15 * 60;
|
||||
}
|
||||
|
||||
Trace.Info($"Attempt {attempt}: save latest runner into {archiveFile}.");
|
||||
|
||||
using (var downloadTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)))
|
||||
using (var downloadCts = CancellationTokenSource.CreateLinkedTokenSource(downloadTimeout.Token, token))
|
||||
{
|
||||
try
|
||||
{
|
||||
Trace.Info($"Download runner: begin download");
|
||||
|
||||
//open zip stream in async mode
|
||||
using (HttpClient httpClient = new HttpClient(HostContext.CreateHttpClientHandler()))
|
||||
using (FileStream fs = new FileStream(archiveFile, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 4096, useAsync: true))
|
||||
using (Stream result = await httpClient.GetStreamAsync(_targetPackage.DownloadUrl))
|
||||
{
|
||||
//81920 is the default used by System.IO.Stream.CopyTo and is under the large object heap threshold (85k).
|
||||
await result.CopyToAsync(fs, 81920, downloadCts.Token);
|
||||
await fs.FlushAsync(downloadCts.Token);
|
||||
}
|
||||
|
||||
Trace.Info($"Download runner: finished download");
|
||||
downloadSucceeded = true;
|
||||
break;
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
Trace.Info($"Runner download has been canceled.");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (downloadCts.Token.IsCancellationRequested)
|
||||
{
|
||||
Trace.Warning($"Runner download has timed out after {timeoutSeconds} seconds");
|
||||
}
|
||||
|
||||
Trace.Warning($"Failed to get package '{archiveFile}' from '{_targetPackage.DownloadUrl}'. Exception {ex}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!downloadSucceeded)
|
||||
{
|
||||
throw new TaskCanceledException($"Runner package '{archiveFile}' failed after {Constants.RunnerDownloadRetryMaxAttempts} download attempts");
|
||||
}
|
||||
|
||||
// If we got this far, we know that we've successfully downloaded the runner package
|
||||
if (archiveFile.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ZipFile.ExtractToDirectory(archiveFile, latestRunnerDirectory);
|
||||
}
|
||||
else if (archiveFile.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string tar = WhichUtil.Which("tar", trace: Trace);
|
||||
|
||||
if (string.IsNullOrEmpty(tar))
|
||||
{
|
||||
throw new NotSupportedException($"tar -xzf");
|
||||
}
|
||||
|
||||
// tar -xzf
|
||||
using (var processInvoker = HostContext.CreateService<IProcessInvoker>())
|
||||
{
|
||||
processInvoker.OutputDataReceived += new EventHandler<ProcessDataReceivedEventArgs>((sender, args) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(args.Data))
|
||||
{
|
||||
Trace.Info(args.Data);
|
||||
}
|
||||
});
|
||||
|
||||
processInvoker.ErrorDataReceived += new EventHandler<ProcessDataReceivedEventArgs>((sender, args) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(args.Data))
|
||||
{
|
||||
Trace.Error(args.Data);
|
||||
}
|
||||
});
|
||||
|
||||
int exitCode = await processInvoker.ExecuteAsync(latestRunnerDirectory, tar, $"-xzf \"{archiveFile}\"", null, token);
|
||||
if (exitCode != 0)
|
||||
{
|
||||
throw new NotSupportedException($"Can't use 'tar -xzf' extract archive file: {archiveFile}. return code: {exitCode}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new NotSupportedException($"{archiveFile}");
|
||||
}
|
||||
|
||||
Trace.Info($"Finished getting latest runner package at: {latestRunnerDirectory}.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
// delete .zip file
|
||||
if (!string.IsNullOrEmpty(archiveFile) && File.Exists(archiveFile))
|
||||
{
|
||||
Trace.Verbose("Deleting latest runner package zip: {0}", archiveFile);
|
||||
IOUtil.DeleteFile(archiveFile);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
//it is not critical if we fail to delete the .zip file
|
||||
Trace.Warning("Failed to delete runner package zip '{0}'. Exception: {1}", archiveFile, ex);
|
||||
}
|
||||
}
|
||||
|
||||
// copy latest runner into runner root folder
|
||||
// copy bin from _work/_update -> bin.version under root
|
||||
string binVersionDir = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), $"{Constants.Path.BinDirectory}.{_targetPackage.Version}");
|
||||
Directory.CreateDirectory(binVersionDir);
|
||||
Trace.Info($"Copy {Path.Combine(latestRunnerDirectory, Constants.Path.BinDirectory)} to {binVersionDir}.");
|
||||
IOUtil.CopyDirectory(Path.Combine(latestRunnerDirectory, Constants.Path.BinDirectory), binVersionDir, token);
|
||||
|
||||
// copy externals from _work/_update -> externals.version under root
|
||||
string externalsVersionDir = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), $"{Constants.Path.ExternalsDirectory}.{_targetPackage.Version}");
|
||||
Directory.CreateDirectory(externalsVersionDir);
|
||||
Trace.Info($"Copy {Path.Combine(latestRunnerDirectory, Constants.Path.ExternalsDirectory)} to {externalsVersionDir}.");
|
||||
IOUtil.CopyDirectory(Path.Combine(latestRunnerDirectory, Constants.Path.ExternalsDirectory), externalsVersionDir, token);
|
||||
|
||||
// copy and replace all .sh/.cmd files
|
||||
Trace.Info($"Copy any remaining .sh/.cmd files into runner root.");
|
||||
foreach (FileInfo file in new DirectoryInfo(latestRunnerDirectory).GetFiles() ?? new FileInfo[0])
|
||||
{
|
||||
// Copy and replace the file.
|
||||
file.CopyTo(Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), file.Name), true);
|
||||
}
|
||||
}
|
||||
|
||||
private void DeletePreviousVersionRunnerBackup(CancellationToken token)
|
||||
{
|
||||
// delete previous backup runner (back compat, can be remove after serval sprints)
|
||||
// bin.bak.2.99.0
|
||||
// externals.bak.2.99.0
|
||||
foreach (string existBackUp in Directory.GetDirectories(HostContext.GetDirectory(WellKnownDirectory.Root), "*.bak.*"))
|
||||
{
|
||||
Trace.Info($"Delete existing runner backup at {existBackUp}.");
|
||||
try
|
||||
{
|
||||
IOUtil.DeleteDirectory(existBackUp, token);
|
||||
}
|
||||
catch (Exception ex) when (!(ex is OperationCanceledException))
|
||||
{
|
||||
Trace.Error(ex);
|
||||
Trace.Info($"Catch exception during delete backup folder {existBackUp}, ignore this error try delete the backup folder on next auto-update.");
|
||||
}
|
||||
}
|
||||
|
||||
// delete old bin.2.99.0 folder, only leave the current version and the latest download version
|
||||
var allBinDirs = Directory.GetDirectories(HostContext.GetDirectory(WellKnownDirectory.Root), "bin.*");
|
||||
if (allBinDirs.Length > 2)
|
||||
{
|
||||
// there are more than 2 bin.version folder.
|
||||
// delete older bin.version folders.
|
||||
foreach (var oldBinDir in allBinDirs)
|
||||
{
|
||||
if (string.Equals(oldBinDir, Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), $"bin"), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(oldBinDir, Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), $"bin.{BuildConstants.RunnerPackage.Version}"), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(oldBinDir, Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), $"bin.{_targetPackage.Version}"), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// skip for current runner version
|
||||
continue;
|
||||
}
|
||||
|
||||
Trace.Info($"Delete runner bin folder's backup at {oldBinDir}.");
|
||||
try
|
||||
{
|
||||
IOUtil.DeleteDirectory(oldBinDir, token);
|
||||
}
|
||||
catch (Exception ex) when (!(ex is OperationCanceledException))
|
||||
{
|
||||
Trace.Error(ex);
|
||||
Trace.Info($"Catch exception during delete backup folder {oldBinDir}, ignore this error try delete the backup folder on next auto-update.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// delete old externals.2.99.0 folder, only leave the current version and the latest download version
|
||||
var allExternalsDirs = Directory.GetDirectories(HostContext.GetDirectory(WellKnownDirectory.Root), "externals.*");
|
||||
if (allExternalsDirs.Length > 2)
|
||||
{
|
||||
// there are more than 2 externals.version folder.
|
||||
// delete older externals.version folders.
|
||||
foreach (var oldExternalDir in allExternalsDirs)
|
||||
{
|
||||
if (string.Equals(oldExternalDir, Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), $"externals"), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(oldExternalDir, Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), $"externals.{BuildConstants.RunnerPackage.Version}"), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(oldExternalDir, Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Root), $"externals.{_targetPackage.Version}"), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// skip for current runner version
|
||||
continue;
|
||||
}
|
||||
|
||||
Trace.Info($"Delete runner externals folder's backup at {oldExternalDir}.");
|
||||
try
|
||||
{
|
||||
IOUtil.DeleteDirectory(oldExternalDir, token);
|
||||
}
|
||||
catch (Exception ex) when (!(ex is OperationCanceledException))
|
||||
{
|
||||
Trace.Error(ex);
|
||||
Trace.Info($"Catch exception during delete backup folder {oldExternalDir}, ignore this error try delete the backup folder on next auto-update.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string GenerateUpdateScript(bool restartInteractiveRunner)
|
||||
{
|
||||
int processId = Process.GetCurrentProcess().Id;
|
||||
string updateLog = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Diag), $"SelfUpdate-{DateTime.UtcNow.ToString("yyyyMMdd-HHmmss")}.log");
|
||||
string runnerRoot = HostContext.GetDirectory(WellKnownDirectory.Root);
|
||||
|
||||
#if OS_WINDOWS
|
||||
string templateName = "update.cmd.template";
|
||||
#else
|
||||
string templateName = "update.sh.template";
|
||||
#endif
|
||||
|
||||
string templatePath = Path.Combine(runnerRoot, $"bin.{_targetPackage.Version}", templateName);
|
||||
string template = File.ReadAllText(templatePath);
|
||||
|
||||
template = template.Replace("_PROCESS_ID_", processId.ToString());
|
||||
template = template.Replace("_RUNNER_PROCESS_NAME_", $"Runner.Listener{IOUtil.ExeExtension}");
|
||||
template = template.Replace("_ROOT_FOLDER_", runnerRoot);
|
||||
template = template.Replace("_EXIST_RUNNER_VERSION_", BuildConstants.RunnerPackage.Version);
|
||||
template = template.Replace("_DOWNLOAD_RUNNER_VERSION_", _targetPackage.Version);
|
||||
template = template.Replace("_UPDATE_LOG_", updateLog);
|
||||
template = template.Replace("_RESTART_INTERACTIVE_RUNNER_", restartInteractiveRunner ? "1" : "0");
|
||||
|
||||
#if OS_WINDOWS
|
||||
string scriptName = "_update.cmd";
|
||||
#else
|
||||
string scriptName = "_update.sh";
|
||||
#endif
|
||||
|
||||
string updateScript = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Work), scriptName);
|
||||
if (File.Exists(updateScript))
|
||||
{
|
||||
IOUtil.DeleteFile(updateScript);
|
||||
}
|
||||
|
||||
File.WriteAllText(updateScript, template);
|
||||
return updateScript;
|
||||
}
|
||||
|
||||
private async Task UpdateRunnerUpdateStateAsync(string currentState)
|
||||
{
|
||||
_terminal.WriteLine(currentState);
|
||||
|
||||
try
|
||||
{
|
||||
await _runnerServer.UpdateAgentUpdateStateAsync(_poolId, _agentId, currentState);
|
||||
}
|
||||
catch (VssResourceNotFoundException)
|
||||
{
|
||||
// ignore VssResourceNotFoundException, this exception means the runner is configured against a old server that doesn't support report runner update detail.
|
||||
Trace.Info($"Catch VssResourceNotFoundException during report update state, ignore this error for backcompat.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Error(ex);
|
||||
Trace.Info($"Catch exception during report update state, ignore this error and continue auto-update.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
109
src/Runner.PluginHost/Program.cs
Normal file
109
src/Runner.PluginHost/Program.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
|
||||
namespace GitHub.Runner.PluginHost
|
||||
{
|
||||
public static class Program
|
||||
{
|
||||
private static CancellationTokenSource tokenSource = new CancellationTokenSource();
|
||||
private static string executingAssemblyLocation = string.Empty;
|
||||
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
Console.CancelKeyPress += Console_CancelKeyPress;
|
||||
|
||||
// Set encoding to UTF8, process invoker will use UTF8 write to STDIN
|
||||
Console.InputEncoding = Encoding.UTF8;
|
||||
Console.OutputEncoding = Encoding.UTF8;
|
||||
try
|
||||
{
|
||||
ArgUtil.NotNull(args, nameof(args));
|
||||
ArgUtil.Equal(2, args.Length, nameof(args.Length));
|
||||
|
||||
string pluginType = args[0];
|
||||
if (string.Equals("action", pluginType, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string assemblyQualifiedName = args[1];
|
||||
ArgUtil.NotNullOrEmpty(assemblyQualifiedName, nameof(assemblyQualifiedName));
|
||||
|
||||
string serializedContext = Console.ReadLine();
|
||||
ArgUtil.NotNullOrEmpty(serializedContext, nameof(serializedContext));
|
||||
|
||||
RunnerActionPluginExecutionContext executionContext = StringUtil.ConvertFromJson<RunnerActionPluginExecutionContext>(serializedContext);
|
||||
ArgUtil.NotNull(executionContext, nameof(executionContext));
|
||||
|
||||
VariableValue culture;
|
||||
ArgUtil.NotNull(executionContext.Variables, nameof(executionContext.Variables));
|
||||
if (executionContext.Variables.TryGetValue("system.culture", out culture) &&
|
||||
!string.IsNullOrEmpty(culture?.Value))
|
||||
{
|
||||
CultureInfo.DefaultThreadCurrentCulture = new CultureInfo(culture.Value);
|
||||
CultureInfo.DefaultThreadCurrentUICulture = new CultureInfo(culture.Value);
|
||||
}
|
||||
|
||||
AssemblyLoadContext.Default.Resolving += ResolveAssembly;
|
||||
try
|
||||
{
|
||||
Type type = Type.GetType(assemblyQualifiedName, throwOnError: true);
|
||||
var taskPlugin = Activator.CreateInstance(type) as IRunnerActionPlugin;
|
||||
ArgUtil.NotNull(taskPlugin, nameof(taskPlugin));
|
||||
taskPlugin.RunAsync(executionContext, tokenSource.Token).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// any exception throw from plugin will fail the task.
|
||||
executionContext.Error(ex.Message);
|
||||
executionContext.Debug(ex.StackTrace);
|
||||
return 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
AssemblyLoadContext.Default.Resolving -= ResolveAssembly;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(pluginType);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// infrastructure failure.
|
||||
Console.Error.WriteLine(ex.ToString());
|
||||
return 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.CancelKeyPress -= Console_CancelKeyPress;
|
||||
}
|
||||
}
|
||||
|
||||
private static Assembly ResolveAssembly(AssemblyLoadContext context, AssemblyName assembly)
|
||||
{
|
||||
string assemblyFilename = assembly.Name + ".dll";
|
||||
if (string.IsNullOrEmpty(executingAssemblyLocation))
|
||||
{
|
||||
executingAssemblyLocation = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
|
||||
}
|
||||
return context.LoadFromAssemblyPath(Path.Combine(executingAssemblyLocation, assemblyFilename));
|
||||
}
|
||||
|
||||
private static void Console_CancelKeyPress(object sender, ConsoleCancelEventArgs e)
|
||||
{
|
||||
e.Cancel = true;
|
||||
tokenSource.Cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
63
src/Runner.PluginHost/Runner.PluginHost.csproj
Normal file
63
src/Runner.PluginHost/Runner.PluginHost.csproj
Normal file
@@ -0,0 +1,63 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netcoreapp2.2</TargetFramework>
|
||||
<OutputType>Exe</OutputType>
|
||||
<RuntimeIdentifiers>win-x64;win-x86;linux-x64;linux-arm;rhel.6-x64;osx-x64</RuntimeIdentifiers>
|
||||
<TargetLatestRuntimePatch>true</TargetLatestRuntimePatch>
|
||||
<AssetTargetFallback>portable-net45+win8</AssetTargetFallback>
|
||||
<NoWarn>NU1701;NU1603</NoWarn>
|
||||
<Version>$(Version)</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Sdk\Sdk.csproj" />
|
||||
<ProjectReference Include="..\Runner.Sdk\Runner.Sdk.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Runtime.Loader" Version="4.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
<DebugType>portable</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true' AND '$(PackageRuntime)' == 'win-x64'">
|
||||
<DefineConstants>OS_WINDOWS;X64;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true' AND '$(PackageRuntime)' == 'win-x86'">
|
||||
<DefineConstants>OS_WINDOWS;X86;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'win-x64'">
|
||||
<DefineConstants>OS_WINDOWS;X64;DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'win-x86'">
|
||||
<DefineConstants>OS_WINDOWS;X86;DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::OSX)))' == 'true'">
|
||||
<DefineConstants>OS_OSX;X64;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::OSX)))' == 'true' AND '$(Configuration)' == 'Debug'">
|
||||
<DefineConstants>OS_OSX;DEBUG;X64;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(PackageRuntime)' == 'linux-x64'">
|
||||
<DefineConstants>OS_LINUX;X64;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(PackageRuntime)' == 'rhel.6-x64'">
|
||||
<DefineConstants>OS_LINUX;OS_RHEL6;X64;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(PackageRuntime)' == 'linux-arm'">
|
||||
<DefineConstants>OS_LINUX;ARM;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'linux-x64'">
|
||||
<DefineConstants>OS_LINUX;X64;DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'rhel.6-x64'">
|
||||
<DefineConstants>OS_LINUX;OS_RHEL6;X64;DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'linux-arm'">
|
||||
<DefineConstants>OS_LINUX;ARM;DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
58
src/Runner.Plugins/Artifact/BuildServer.cs
Normal file
58
src/Runner.Plugins/Artifact/BuildServer.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Services.WebApi;
|
||||
using GitHub.Build.WebApi;
|
||||
|
||||
namespace GitHub.Runner.Plugins.Artifact
|
||||
{
|
||||
// A client wrapper interacting with Build's Artifact API
|
||||
public class BuildServer
|
||||
{
|
||||
private readonly BuildHttpClient _buildHttpClient;
|
||||
|
||||
public BuildServer(VssConnection connection)
|
||||
{
|
||||
ArgUtil.NotNull(connection, nameof(connection));
|
||||
_buildHttpClient = connection.GetClient<BuildHttpClient>();
|
||||
}
|
||||
|
||||
// Associate the specified artifact with a build, along with custom data.
|
||||
public async Task<BuildArtifact> AssociateArtifact(
|
||||
Guid projectId,
|
||||
int pipelineId,
|
||||
string jobId,
|
||||
string name,
|
||||
string type,
|
||||
string data,
|
||||
Dictionary<string, string> propertiesDictionary,
|
||||
CancellationToken cancellationToken = default(CancellationToken))
|
||||
{
|
||||
BuildArtifact artifact = new BuildArtifact()
|
||||
{
|
||||
Name = name,
|
||||
Source = jobId,
|
||||
Resource = new ArtifactResource()
|
||||
{
|
||||
Data = data,
|
||||
Type = type,
|
||||
Properties = propertiesDictionary
|
||||
}
|
||||
};
|
||||
|
||||
return await _buildHttpClient.CreateArtifactAsync(artifact, projectId, pipelineId, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
// Get named artifact from a build
|
||||
public async Task<BuildArtifact> GetArtifact(
|
||||
Guid projectId,
|
||||
int pipelineId,
|
||||
string name,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await _buildHttpClient.GetArtifactAsync(projectId, pipelineId, name, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
79
src/Runner.Plugins/Artifact/DownloadArtifact.cs
Normal file
79
src/Runner.Plugins/Artifact/DownloadArtifact.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Build.WebApi;
|
||||
using GitHub.Services.Common;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Plugins.Artifact
|
||||
{
|
||||
public class DownloadArtifact : IRunnerActionPlugin
|
||||
{
|
||||
|
||||
private static class DownloadArtifactInputNames
|
||||
{
|
||||
public static readonly string Name = "name";
|
||||
public static readonly string ArtifactName = "artifact";
|
||||
public static readonly string Path = "path";
|
||||
}
|
||||
|
||||
public async Task RunAsync(
|
||||
RunnerActionPluginExecutionContext context,
|
||||
CancellationToken token)
|
||||
{
|
||||
ArgUtil.NotNull(context, nameof(context));
|
||||
string artifactName = context.GetInput(DownloadArtifactInputNames.ArtifactName, required: false); // Back compat since we rename input `artifact` to `name`
|
||||
if (string.IsNullOrEmpty(artifactName))
|
||||
{
|
||||
artifactName = context.GetInput(DownloadArtifactInputNames.Name, required: true);
|
||||
}
|
||||
|
||||
string targetPath = context.GetInput(DownloadArtifactInputNames.Path, required: false);
|
||||
string defaultWorkingDirectory = context.GetGitHubContext("workspace");
|
||||
|
||||
if (string.IsNullOrEmpty(targetPath))
|
||||
{
|
||||
targetPath = artifactName;
|
||||
}
|
||||
|
||||
targetPath = Path.IsPathFullyQualified(targetPath) ? targetPath : Path.GetFullPath(Path.Combine(defaultWorkingDirectory, targetPath));
|
||||
|
||||
// Project ID
|
||||
Guid projectId = new Guid(context.Variables.GetValueOrDefault(BuildVariables.TeamProjectId)?.Value ?? Guid.Empty.ToString());
|
||||
|
||||
// Build ID
|
||||
string buildIdStr = context.Variables.GetValueOrDefault(BuildVariables.BuildId)?.Value ?? string.Empty;
|
||||
if (!int.TryParse(buildIdStr, out int buildId))
|
||||
{
|
||||
throw new ArgumentException($"Run Id is not an Int32: {buildIdStr}");
|
||||
}
|
||||
|
||||
context.Output($"Download artifact '{artifactName}' to: '{targetPath}'");
|
||||
|
||||
BuildServer buildHelper = new BuildServer(context.VssConnection);
|
||||
BuildArtifact buildArtifact = await buildHelper.GetArtifact(projectId, buildId, artifactName, token);
|
||||
|
||||
if (string.Equals(buildArtifact.Resource.Type, "Container", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string containerUrl = buildArtifact.Resource.Data;
|
||||
string[] parts = containerUrl.Split(new[] { '/' }, 3);
|
||||
if (parts.Length < 3 || !long.TryParse(parts[1], out long containerId))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException($"Invalid container url '{containerUrl}' for artifact '{buildArtifact.Name}'");
|
||||
}
|
||||
|
||||
string containerPath = parts[2];
|
||||
FileContainerServer fileContainerServer = new FileContainerServer(context.VssConnection, projectId, containerId, containerPath);
|
||||
await fileContainerServer.DownloadFromContainerAsync(context, targetPath, token);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new NotSupportedException($"Invalid artifact type: {buildArtifact.Resource.Type}");
|
||||
}
|
||||
|
||||
context.Output("Artifact download finished.");
|
||||
}
|
||||
}
|
||||
}
|
||||
660
src/Runner.Plugins/Artifact/FileContainerServer.cs
Normal file
660
src/Runner.Plugins/Artifact/FileContainerServer.cs
Normal file
@@ -0,0 +1,660 @@
|
||||
using GitHub.Services.FileContainer.Client;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Diagnostics;
|
||||
using GitHub.Services.WebApi;
|
||||
using System.Net.Http;
|
||||
using System.Net;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Services.FileContainer;
|
||||
using GitHub.Services.Common;
|
||||
|
||||
namespace GitHub.Runner.Plugins.Artifact
|
||||
{
|
||||
public class FileContainerServer
|
||||
{
|
||||
private const int _defaultFileStreamBufferSize = 4096;
|
||||
|
||||
//81920 is the default used by System.IO.Stream.CopyTo and is under the large object heap threshold (85k).
|
||||
private const int _defaultCopyBufferSize = 81920;
|
||||
|
||||
private readonly ConcurrentQueue<string> _fileUploadQueue = new ConcurrentQueue<string>();
|
||||
private readonly ConcurrentQueue<DownloadInfo> _fileDownloadQueue = new ConcurrentQueue<DownloadInfo>();
|
||||
private readonly ConcurrentDictionary<string, ConcurrentQueue<string>> _fileUploadTraceLog = new ConcurrentDictionary<string, ConcurrentQueue<string>>();
|
||||
private readonly ConcurrentDictionary<string, ConcurrentQueue<string>> _fileUploadProgressLog = new ConcurrentDictionary<string, ConcurrentQueue<string>>();
|
||||
private readonly FileContainerHttpClient _fileContainerHttpClient;
|
||||
|
||||
private CancellationTokenSource _uploadCancellationTokenSource;
|
||||
private CancellationTokenSource _downloadCancellationTokenSource;
|
||||
private TaskCompletionSource<int> _uploadFinished;
|
||||
private TaskCompletionSource<int> _downloadFinished;
|
||||
private Guid _projectId;
|
||||
private long _containerId;
|
||||
private string _containerPath;
|
||||
private int _uploadFilesProcessed = 0;
|
||||
private int _downloadFilesProcessed = 0;
|
||||
private string _sourceParentDirectory;
|
||||
|
||||
public FileContainerServer(
|
||||
VssConnection connection,
|
||||
Guid projectId,
|
||||
long containerId,
|
||||
string containerPath)
|
||||
{
|
||||
_projectId = projectId;
|
||||
_containerId = containerId;
|
||||
_containerPath = containerPath;
|
||||
|
||||
// default file upload/download request timeout to 600 seconds
|
||||
var fileContainerClientConnectionSetting = connection.Settings.Clone();
|
||||
if (fileContainerClientConnectionSetting.SendTimeout < TimeSpan.FromSeconds(600))
|
||||
{
|
||||
fileContainerClientConnectionSetting.SendTimeout = TimeSpan.FromSeconds(600);
|
||||
}
|
||||
|
||||
var fileContainerClientConnection = new VssConnection(connection.Uri, connection.Credentials, fileContainerClientConnectionSetting);
|
||||
_fileContainerHttpClient = fileContainerClientConnection.GetClient<FileContainerHttpClient>();
|
||||
}
|
||||
|
||||
public async Task DownloadFromContainerAsync(
|
||||
RunnerActionPluginExecutionContext context,
|
||||
String destination,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Find out all container items need to be processed
|
||||
List<FileContainerItem> containerItems = new List<FileContainerItem>();
|
||||
int retryCount = 0;
|
||||
while (retryCount < 3)
|
||||
{
|
||||
try
|
||||
{
|
||||
containerItems = await _fileContainerHttpClient.QueryContainerItemsAsync(_containerId,
|
||||
_projectId,
|
||||
_containerPath,
|
||||
cancellationToken: cancellationToken);
|
||||
break;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
context.Debug($"Container query has been cancelled.");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (retryCount < 2)
|
||||
{
|
||||
retryCount++;
|
||||
context.Warning($"Fail to query container items under #/{_containerId}/{_containerPath}, Error: {ex.Message}");
|
||||
context.Debug(ex.ToString());
|
||||
}
|
||||
|
||||
var backOff = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15));
|
||||
context.Warning($"Back off {backOff.TotalSeconds} seconds before retry.");
|
||||
await Task.Delay(backOff);
|
||||
}
|
||||
|
||||
if (containerItems.Count == 0)
|
||||
{
|
||||
context.Output($"There is nothing under #/{_containerId}/{_containerPath}");
|
||||
return;
|
||||
}
|
||||
|
||||
// container items will include both folders, files and even file with zero size
|
||||
// Create all required empty folders and emptry files, gather a list of files that we need to download from server.
|
||||
int foldersCreated = 0;
|
||||
int emptryFilesCreated = 0;
|
||||
List<DownloadInfo> downloadFiles = new List<DownloadInfo>();
|
||||
foreach (var item in containerItems.OrderBy(x => x.Path))
|
||||
{
|
||||
if (!item.Path.StartsWith(_containerPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException($"Item {item.Path} is not under #/{_containerId}/{_containerPath}");
|
||||
}
|
||||
|
||||
var localRelativePath = item.Path.Substring(_containerPath.Length).TrimStart('/');
|
||||
var localPath = Path.Combine(destination, localRelativePath);
|
||||
|
||||
if (item.ItemType == ContainerItemType.Folder)
|
||||
{
|
||||
context.Debug($"Ensure folder exists: {localPath}");
|
||||
Directory.CreateDirectory(localPath);
|
||||
foldersCreated++;
|
||||
}
|
||||
else if (item.ItemType == ContainerItemType.File)
|
||||
{
|
||||
if (item.FileLength == 0)
|
||||
{
|
||||
context.Debug($"Create empty file at: {localPath}");
|
||||
var parentDirectory = Path.GetDirectoryName(localPath);
|
||||
Directory.CreateDirectory(parentDirectory);
|
||||
IOUtil.DeleteFile(localPath);
|
||||
using (new FileStream(localPath, FileMode.Create))
|
||||
{
|
||||
}
|
||||
emptryFilesCreated++;
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Debug($"Prepare download {item.Path} to {localPath}");
|
||||
downloadFiles.Add(new DownloadInfo(item.Path, localPath));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new NotSupportedException(item.ItemType.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
if (foldersCreated > 0)
|
||||
{
|
||||
context.Output($"{foldersCreated} folders created.");
|
||||
}
|
||||
|
||||
if (emptryFilesCreated > 0)
|
||||
{
|
||||
context.Output($"{emptryFilesCreated} empty files created.");
|
||||
}
|
||||
|
||||
if (downloadFiles.Count == 0)
|
||||
{
|
||||
context.Output($"There is nothing to download");
|
||||
return;
|
||||
}
|
||||
|
||||
// Start multi-task to download all files.
|
||||
using (_downloadCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
|
||||
{
|
||||
// try download all files for the first time.
|
||||
DownloadResult downloadResult = await ParallelDownloadAsync(context, downloadFiles.AsReadOnly(), Math.Min(downloadFiles.Count, Environment.ProcessorCount), _downloadCancellationTokenSource.Token);
|
||||
if (downloadResult.FailedFiles.Count == 0)
|
||||
{
|
||||
// all files have been download succeed.
|
||||
context.Output($"{downloadFiles.Count} files download succeed.");
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Output($"{downloadResult.FailedFiles.Count} files failed to download, retry these files after a minute.");
|
||||
}
|
||||
|
||||
// Delay 1 min then retry failed files.
|
||||
for (int timer = 60; timer > 0; timer -= 5)
|
||||
{
|
||||
context.Output($"Retry file download after {timer} seconds.");
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), _uploadCancellationTokenSource.Token);
|
||||
}
|
||||
|
||||
// Retry download all failed files.
|
||||
context.Output($"Start retry {downloadResult.FailedFiles.Count} failed files upload.");
|
||||
DownloadResult retryDownloadResult = await ParallelDownloadAsync(context, downloadResult.FailedFiles.AsReadOnly(), Math.Min(downloadResult.FailedFiles.Count, Environment.ProcessorCount), _downloadCancellationTokenSource.Token);
|
||||
if (retryDownloadResult.FailedFiles.Count == 0)
|
||||
{
|
||||
// all files have been download succeed after retry.
|
||||
context.Output($"{downloadResult.FailedFiles} files download succeed after retry.");
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception($"{retryDownloadResult.FailedFiles.Count} files failed to download even after retry.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<long> CopyToContainerAsync(
|
||||
RunnerActionPluginExecutionContext context,
|
||||
String source,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
//set maxConcurrentUploads up to 2 until figure out how to use WinHttpHandler.MaxConnectionsPerServer modify DefaultConnectionLimit
|
||||
int maxConcurrentUploads = Math.Min(Environment.ProcessorCount, 2);
|
||||
//context.Output($"Max Concurrent Uploads {maxConcurrentUploads}");
|
||||
|
||||
List<String> files;
|
||||
if (File.Exists(source))
|
||||
{
|
||||
files = new List<String>() { source };
|
||||
_sourceParentDirectory = Path.GetDirectoryName(source);
|
||||
}
|
||||
else
|
||||
{
|
||||
files = Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories).ToList();
|
||||
_sourceParentDirectory = source.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
}
|
||||
|
||||
context.Output($"Uploading {files.Count()} files");
|
||||
using (_uploadCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
|
||||
{
|
||||
// hook up reporting event from file container client.
|
||||
_fileContainerHttpClient.UploadFileReportTrace += UploadFileTraceReportReceived;
|
||||
_fileContainerHttpClient.UploadFileReportProgress += UploadFileProgressReportReceived;
|
||||
|
||||
try
|
||||
{
|
||||
// try upload all files for the first time.
|
||||
UploadResult uploadResult = await ParallelUploadAsync(context, files, maxConcurrentUploads, _uploadCancellationTokenSource.Token);
|
||||
|
||||
if (uploadResult.FailedFiles.Count == 0)
|
||||
{
|
||||
// all files have been upload succeed.
|
||||
context.Output("File upload succeed.");
|
||||
return uploadResult.TotalFileSizeUploaded;
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Output($"{uploadResult.FailedFiles.Count} files failed to upload, retry these files after a minute.");
|
||||
}
|
||||
|
||||
// Delay 1 min then retry failed files.
|
||||
for (int timer = 60; timer > 0; timer -= 5)
|
||||
{
|
||||
context.Output($"Retry file upload after {timer} seconds.");
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), _uploadCancellationTokenSource.Token);
|
||||
}
|
||||
|
||||
// Retry upload all failed files.
|
||||
context.Output($"Start retry {uploadResult.FailedFiles.Count} failed files upload.");
|
||||
UploadResult retryUploadResult = await ParallelUploadAsync(context, uploadResult.FailedFiles, maxConcurrentUploads, _uploadCancellationTokenSource.Token);
|
||||
|
||||
if (retryUploadResult.FailedFiles.Count == 0)
|
||||
{
|
||||
// all files have been upload succeed after retry.
|
||||
context.Output("File upload succeed after retry.");
|
||||
return uploadResult.TotalFileSizeUploaded + retryUploadResult.TotalFileSizeUploaded;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception("File upload failed even after retry.");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_fileContainerHttpClient.UploadFileReportTrace -= UploadFileTraceReportReceived;
|
||||
_fileContainerHttpClient.UploadFileReportProgress -= UploadFileProgressReportReceived;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<DownloadResult> ParallelDownloadAsync(RunnerActionPluginExecutionContext context, IReadOnlyList<DownloadInfo> files, int concurrentDownloads, CancellationToken token)
|
||||
{
|
||||
// return files that fail to download
|
||||
var downloadResult = new DownloadResult();
|
||||
|
||||
// nothing needs to download
|
||||
if (files.Count == 0)
|
||||
{
|
||||
return downloadResult;
|
||||
}
|
||||
|
||||
// ensure the file download queue is empty.
|
||||
if (!_fileDownloadQueue.IsEmpty)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(_fileDownloadQueue));
|
||||
}
|
||||
|
||||
// enqueue file into download queue.
|
||||
foreach (var file in files)
|
||||
{
|
||||
_fileDownloadQueue.Enqueue(file);
|
||||
}
|
||||
|
||||
// Start download monitor task.
|
||||
_downloadFilesProcessed = 0;
|
||||
_downloadFinished = new TaskCompletionSource<int>();
|
||||
Task downloadMonitor = DownloadReportingAsync(context, files.Count(), token);
|
||||
|
||||
// Start parallel download tasks.
|
||||
List<Task<DownloadResult>> parallelDownloadingTasks = new List<Task<DownloadResult>>();
|
||||
for (int downloader = 0; downloader < concurrentDownloads; downloader++)
|
||||
{
|
||||
parallelDownloadingTasks.Add(DownloadAsync(context, downloader, token));
|
||||
}
|
||||
|
||||
// Wait for parallel download finish.
|
||||
await Task.WhenAll(parallelDownloadingTasks);
|
||||
foreach (var downloadTask in parallelDownloadingTasks)
|
||||
{
|
||||
// record all failed files.
|
||||
downloadResult.AddDownloadResult(await downloadTask);
|
||||
}
|
||||
|
||||
// Stop monitor task;
|
||||
_downloadFinished.TrySetResult(0);
|
||||
await downloadMonitor;
|
||||
|
||||
return downloadResult;
|
||||
}
|
||||
|
||||
private async Task<UploadResult> ParallelUploadAsync(RunnerActionPluginExecutionContext context, IReadOnlyList<string> files, int concurrentUploads, CancellationToken token)
|
||||
{
|
||||
// return files that fail to upload and total artifact size
|
||||
var uploadResult = new UploadResult();
|
||||
|
||||
// nothing needs to upload
|
||||
if (files.Count == 0)
|
||||
{
|
||||
return uploadResult;
|
||||
}
|
||||
|
||||
// ensure the file upload queue is empty.
|
||||
if (!_fileUploadQueue.IsEmpty)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(_fileUploadQueue));
|
||||
}
|
||||
|
||||
// enqueue file into upload queue.
|
||||
foreach (var file in files)
|
||||
{
|
||||
_fileUploadQueue.Enqueue(file);
|
||||
}
|
||||
|
||||
// Start upload monitor task.
|
||||
_uploadFilesProcessed = 0;
|
||||
_uploadFinished = new TaskCompletionSource<int>();
|
||||
_fileUploadTraceLog.Clear();
|
||||
_fileUploadProgressLog.Clear();
|
||||
Task uploadMonitor = UploadReportingAsync(context, files.Count(), _uploadCancellationTokenSource.Token);
|
||||
|
||||
// Start parallel upload tasks.
|
||||
List<Task<UploadResult>> parallelUploadingTasks = new List<Task<UploadResult>>();
|
||||
for (int uploader = 0; uploader < concurrentUploads; uploader++)
|
||||
{
|
||||
parallelUploadingTasks.Add(UploadAsync(context, uploader, _uploadCancellationTokenSource.Token));
|
||||
}
|
||||
|
||||
// Wait for parallel upload finish.
|
||||
await Task.WhenAll(parallelUploadingTasks);
|
||||
foreach (var uploadTask in parallelUploadingTasks)
|
||||
{
|
||||
// record all failed files.
|
||||
uploadResult.AddUploadResult(await uploadTask);
|
||||
}
|
||||
|
||||
// Stop monitor task;
|
||||
_uploadFinished.TrySetResult(0);
|
||||
await uploadMonitor;
|
||||
|
||||
return uploadResult;
|
||||
}
|
||||
|
||||
private async Task<DownloadResult> DownloadAsync(RunnerActionPluginExecutionContext context, int downloaderId, CancellationToken token)
|
||||
{
|
||||
List<DownloadInfo> failedFiles = new List<DownloadInfo>();
|
||||
Stopwatch downloadTimer = new Stopwatch();
|
||||
while (_fileDownloadQueue.TryDequeue(out DownloadInfo fileToDownload))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
int retryCount = 0;
|
||||
bool downloadFailed = false;
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
context.Debug($"Start downloading file: '{fileToDownload.ItemPath}' (Downloader {downloaderId})");
|
||||
downloadTimer.Restart();
|
||||
using (FileStream fs = new FileStream(fileToDownload.LocalPath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: _defaultFileStreamBufferSize, useAsync: true))
|
||||
using (var downloadStream = await _fileContainerHttpClient.DownloadFileAsync(_containerId, fileToDownload.ItemPath, token, _projectId))
|
||||
{
|
||||
await downloadStream.CopyToAsync(fs, _defaultCopyBufferSize, token);
|
||||
await fs.FlushAsync(token);
|
||||
downloadTimer.Stop();
|
||||
context.Debug($"File: '{fileToDownload.LocalPath}' took {downloadTimer.ElapsedMilliseconds} milliseconds to finish download (Downloader {downloaderId})");
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
context.Debug($"Download has been cancelled while downloading {fileToDownload.ItemPath}. (Downloader {downloaderId})");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
retryCount++;
|
||||
context.Warning($"Fail to download '{fileToDownload.ItemPath}', error: {ex.Message} (Downloader {downloaderId})");
|
||||
context.Debug(ex.ToString());
|
||||
}
|
||||
|
||||
if (retryCount < 3)
|
||||
{
|
||||
var backOff = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30));
|
||||
context.Warning($"Back off {backOff.TotalSeconds} seconds before retry. (Downloader {downloaderId})");
|
||||
await Task.Delay(backOff);
|
||||
}
|
||||
else
|
||||
{
|
||||
// upload still failed after 3 tries.
|
||||
downloadFailed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (downloadFailed)
|
||||
{
|
||||
// tracking file that failed to download.
|
||||
failedFiles.Add(fileToDownload);
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _downloadFilesProcessed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// We should never
|
||||
context.Error($"Error '{ex.Message}' when downloading file '{fileToDownload}'. (Downloader {downloaderId})");
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
return new DownloadResult(failedFiles);
|
||||
}
|
||||
|
||||
private async Task<UploadResult> UploadAsync(RunnerActionPluginExecutionContext context, int uploaderId, CancellationToken token)
|
||||
{
|
||||
List<string> failedFiles = new List<string>();
|
||||
long uploadedSize = 0;
|
||||
string fileToUpload;
|
||||
Stopwatch uploadTimer = new Stopwatch();
|
||||
while (_fileUploadQueue.TryDequeue(out fileToUpload))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
using (FileStream fs = File.Open(fileToUpload, FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||
{
|
||||
string itemPath = (_containerPath.TrimEnd('/') + "/" + fileToUpload.Remove(0, _sourceParentDirectory.Length + 1)).Replace('\\', '/');
|
||||
uploadTimer.Restart();
|
||||
bool catchExceptionDuringUpload = false;
|
||||
HttpResponseMessage response = null;
|
||||
try
|
||||
{
|
||||
response = await _fileContainerHttpClient.UploadFileAsync(_containerId, itemPath, fs, _projectId, cancellationToken: token, chunkSize: 4 * 1024 * 1024);
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
context.Output($"File upload has been cancelled during upload file: '{fileToUpload}'.");
|
||||
if (response != null)
|
||||
{
|
||||
response.Dispose();
|
||||
response = null;
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
catchExceptionDuringUpload = true;
|
||||
context.Output($"Fail to upload '{fileToUpload}' due to '{ex.Message}'.");
|
||||
context.Output(ex.ToString());
|
||||
}
|
||||
|
||||
uploadTimer.Stop();
|
||||
if (catchExceptionDuringUpload || (response != null && response.StatusCode != HttpStatusCode.Created))
|
||||
{
|
||||
if (response != null)
|
||||
{
|
||||
context.Output($"Unable to copy file to server StatusCode={response.StatusCode}: {response.ReasonPhrase}. Source file path: {fileToUpload}. Target server path: {itemPath}");
|
||||
}
|
||||
|
||||
// output detail upload trace for the file.
|
||||
ConcurrentQueue<string> logQueue;
|
||||
if (_fileUploadTraceLog.TryGetValue(itemPath, out logQueue))
|
||||
{
|
||||
context.Output($"Detail upload trace for file that fail to upload: {itemPath}");
|
||||
string message;
|
||||
while (logQueue.TryDequeue(out message))
|
||||
{
|
||||
context.Output(message);
|
||||
}
|
||||
}
|
||||
|
||||
// tracking file that failed to upload.
|
||||
failedFiles.Add(fileToUpload);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Debug($"File: '{fileToUpload}' took {uploadTimer.ElapsedMilliseconds} milliseconds to finish upload");
|
||||
uploadedSize += fs.Length;
|
||||
// debug detail upload trace for the file.
|
||||
ConcurrentQueue<string> logQueue;
|
||||
if (_fileUploadTraceLog.TryGetValue(itemPath, out logQueue))
|
||||
{
|
||||
context.Debug($"Detail upload trace for file: {itemPath}");
|
||||
string message;
|
||||
while (logQueue.TryDequeue(out message))
|
||||
{
|
||||
context.Debug(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (response != null)
|
||||
{
|
||||
response.Dispose();
|
||||
response = null;
|
||||
}
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _uploadFilesProcessed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.Output($"File error '{ex.Message}' when uploading file '{fileToUpload}'.");
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
return new UploadResult(failedFiles, uploadedSize);
|
||||
}
|
||||
|
||||
private async Task UploadReportingAsync(RunnerActionPluginExecutionContext context, int totalFiles, CancellationToken token)
|
||||
{
|
||||
int traceInterval = 0;
|
||||
while (!_uploadFinished.Task.IsCompleted && !token.IsCancellationRequested)
|
||||
{
|
||||
bool hasDetailProgress = false;
|
||||
foreach (var file in _fileUploadProgressLog)
|
||||
{
|
||||
string message;
|
||||
while (file.Value.TryDequeue(out message))
|
||||
{
|
||||
hasDetailProgress = true;
|
||||
context.Output(message);
|
||||
}
|
||||
}
|
||||
|
||||
// trace total file progress every 25 seconds when there is no file level detail progress
|
||||
if (++traceInterval % 2 == 0 && !hasDetailProgress)
|
||||
{
|
||||
context.Output($"Total file: {totalFiles} ---- Processed file: {_uploadFilesProcessed} ({(_uploadFilesProcessed * 100) / totalFiles}%)");
|
||||
}
|
||||
|
||||
await Task.WhenAny(_uploadFinished.Task, Task.Delay(5000, token));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DownloadReportingAsync(RunnerActionPluginExecutionContext context, int totalFiles, CancellationToken token)
|
||||
{
|
||||
int traceInterval = 0;
|
||||
while (!_downloadFinished.Task.IsCompleted && !token.IsCancellationRequested)
|
||||
{
|
||||
// trace total file progress every 10 seconds when there is no file level detail progress
|
||||
if (++traceInterval % 2 == 0)
|
||||
{
|
||||
context.Output($"Total file: {totalFiles} ---- Downloaded file: {_downloadFilesProcessed} ({(_downloadFilesProcessed * 100) / totalFiles}%)");
|
||||
}
|
||||
|
||||
await Task.WhenAny(_downloadFinished.Task, Task.Delay(5000, token));
|
||||
}
|
||||
}
|
||||
|
||||
private void UploadFileTraceReportReceived(object sender, ReportTraceEventArgs e)
|
||||
{
|
||||
ConcurrentQueue<string> logQueue = _fileUploadTraceLog.GetOrAdd(e.File, new ConcurrentQueue<string>());
|
||||
logQueue.Enqueue(e.Message);
|
||||
}
|
||||
|
||||
private void UploadFileProgressReportReceived(object sender, ReportProgressEventArgs e)
|
||||
{
|
||||
ConcurrentQueue<string> progressQueue = _fileUploadProgressLog.GetOrAdd(e.File, new ConcurrentQueue<string>());
|
||||
progressQueue.Enqueue($"Uploading '{e.File}' ({(e.CurrentChunk * 100) / e.TotalChunks}%)");
|
||||
}
|
||||
}
|
||||
|
||||
public class UploadResult
|
||||
{
|
||||
public UploadResult()
|
||||
{
|
||||
FailedFiles = new List<string>();
|
||||
TotalFileSizeUploaded = 0;
|
||||
}
|
||||
|
||||
public UploadResult(List<string> failedFiles, long totalFileSizeUploaded)
|
||||
{
|
||||
FailedFiles = failedFiles;
|
||||
TotalFileSizeUploaded = totalFileSizeUploaded;
|
||||
}
|
||||
public List<string> FailedFiles { get; set; }
|
||||
|
||||
public long TotalFileSizeUploaded { get; set; }
|
||||
|
||||
public void AddUploadResult(UploadResult resultToAdd)
|
||||
{
|
||||
this.FailedFiles.AddRange(resultToAdd.FailedFiles);
|
||||
this.TotalFileSizeUploaded += resultToAdd.TotalFileSizeUploaded;
|
||||
}
|
||||
}
|
||||
|
||||
public class DownloadInfo
|
||||
{
|
||||
public DownloadInfo(string itemPath, string localPath)
|
||||
{
|
||||
this.ItemPath = itemPath;
|
||||
this.LocalPath = localPath;
|
||||
}
|
||||
|
||||
public string ItemPath { get; set; }
|
||||
public string LocalPath { get; set; }
|
||||
}
|
||||
|
||||
public class DownloadResult
|
||||
{
|
||||
public DownloadResult()
|
||||
{
|
||||
FailedFiles = new List<DownloadInfo>();
|
||||
}
|
||||
|
||||
public DownloadResult(List<DownloadInfo> failedFiles)
|
||||
{
|
||||
FailedFiles = failedFiles;
|
||||
}
|
||||
public List<DownloadInfo> FailedFiles { get; set; }
|
||||
|
||||
public void AddDownloadResult(DownloadResult resultToAdd)
|
||||
{
|
||||
this.FailedFiles.AddRange(resultToAdd.FailedFiles);
|
||||
}
|
||||
}
|
||||
}
|
||||
90
src/Runner.Plugins/Artifact/PublishArtifact.cs
Normal file
90
src/Runner.Plugins/Artifact/PublishArtifact.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Build.WebApi;
|
||||
using GitHub.Services.Common;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Plugins.Artifact
|
||||
{
|
||||
public class PublishArtifact : IRunnerActionPlugin
|
||||
{
|
||||
private static class PublishArtifactInputNames
|
||||
{
|
||||
public static readonly string ArtifactName = "artifactName";
|
||||
public static readonly string Name = "name";
|
||||
public static readonly string Path = "path";
|
||||
}
|
||||
|
||||
public async Task RunAsync(
|
||||
RunnerActionPluginExecutionContext context,
|
||||
CancellationToken token)
|
||||
{
|
||||
string artifactName = context.GetInput(PublishArtifactInputNames.ArtifactName, required: false); // Back compat since we rename input `artifactName` to `name`
|
||||
if (string.IsNullOrEmpty(artifactName))
|
||||
{
|
||||
artifactName = context.GetInput(PublishArtifactInputNames.Name, required: true);
|
||||
}
|
||||
|
||||
string targetPath = context.GetInput(PublishArtifactInputNames.Path, required: true);
|
||||
string defaultWorkingDirectory = context.GetGitHubContext("workspace");
|
||||
|
||||
targetPath = Path.IsPathFullyQualified(targetPath) ? targetPath : Path.GetFullPath(Path.Combine(defaultWorkingDirectory, targetPath));
|
||||
|
||||
if (String.IsNullOrWhiteSpace(artifactName))
|
||||
{
|
||||
throw new ArgumentException($"Artifact name can not be empty string");
|
||||
}
|
||||
|
||||
if (Path.GetInvalidFileNameChars().Any(x => artifactName.Contains(x)))
|
||||
{
|
||||
throw new ArgumentException($"Artifact name is not valid: {artifactName}. It cannot contain '\\', '/', \"', ':', '<', '>', '|', '*', and '?'");
|
||||
}
|
||||
|
||||
// Project ID
|
||||
Guid projectId = new Guid(context.Variables.GetValueOrDefault(BuildVariables.TeamProjectId)?.Value ?? Guid.Empty.ToString());
|
||||
|
||||
// Build ID
|
||||
string buildIdStr = context.Variables.GetValueOrDefault(BuildVariables.BuildId)?.Value ?? string.Empty;
|
||||
if (!int.TryParse(buildIdStr, out int buildId))
|
||||
{
|
||||
throw new ArgumentException($"Run Id is not an Int32: {buildIdStr}");
|
||||
}
|
||||
|
||||
string fullPath = Path.GetFullPath(targetPath);
|
||||
bool isFile = File.Exists(fullPath);
|
||||
bool isDir = Directory.Exists(fullPath);
|
||||
if (!isFile && !isDir)
|
||||
{
|
||||
// if local path is neither file nor folder
|
||||
throw new FileNotFoundException($"Path does not exist {targetPath}");
|
||||
}
|
||||
|
||||
// Container ID
|
||||
string containerIdStr = context.Variables.GetValueOrDefault(BuildVariables.ContainerId)?.Value ?? string.Empty;
|
||||
if (!long.TryParse(containerIdStr, out long containerId))
|
||||
{
|
||||
throw new ArgumentException($"Container Id is not a Int64: {containerIdStr}");
|
||||
}
|
||||
|
||||
context.Output($"Uploading artifact '{artifactName}' from '{fullPath}' for run #{buildId}");
|
||||
|
||||
FileContainerServer fileContainerHelper = new FileContainerServer(context.VssConnection, projectId, containerId, artifactName);
|
||||
long size = await fileContainerHelper.CopyToContainerAsync(context, fullPath, token);
|
||||
var propertiesDictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
propertiesDictionary.Add("artifactsize", size.ToString());
|
||||
|
||||
string fileContainerFullPath = StringUtil.Format($"#/{containerId}/{artifactName}");
|
||||
context.Output($"Uploaded '{fullPath}' to server");
|
||||
|
||||
BuildServer buildHelper = new BuildServer(context.VssConnection);
|
||||
string jobId = context.Variables.GetValueOrDefault(WellKnownDistributedTaskVariables.JobId).Value ?? string.Empty;
|
||||
var artifact = await buildHelper.AssociateArtifact(projectId, buildId, jobId, artifactName, ArtifactResourceTypes.Container, fileContainerFullPath, propertiesDictionary, token);
|
||||
context.Output($"Associated artifact {artifactName} ({artifact.Id}) with run #{buildId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
686
src/Runner.Plugins/Repository/GitCliManager.cs
Normal file
686
src/Runner.Plugins/Repository/GitCliManager.cs
Normal file
@@ -0,0 +1,686 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.IO;
|
||||
using GitHub.Runner.Sdk;
|
||||
using GitHub.Services.Common;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
|
||||
namespace GitHub.Runner.Plugins.Repository
|
||||
{
|
||||
public class GitCliManager
|
||||
{
|
||||
#if OS_WINDOWS
|
||||
private static readonly Encoding s_encoding = Encoding.UTF8;
|
||||
#else
|
||||
private static readonly Encoding s_encoding = null;
|
||||
#endif
|
||||
private readonly Dictionary<string, string> gitEnv = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
{ "GIT_TERMINAL_PROMPT", "0" },
|
||||
};
|
||||
|
||||
private string gitPath = null;
|
||||
private Version gitVersion = null;
|
||||
private string gitLfsPath = null;
|
||||
private Version gitLfsVersion = null;
|
||||
|
||||
public GitCliManager(Dictionary<string, string> envs = null)
|
||||
{
|
||||
if (envs != null)
|
||||
{
|
||||
foreach (var env in envs)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(env.Key))
|
||||
{
|
||||
gitEnv[env.Key] = env.Value ?? string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool EnsureGitVersion(Version requiredVersion, bool throwOnNotMatch)
|
||||
{
|
||||
ArgUtil.NotNull(gitPath, nameof(gitPath));
|
||||
ArgUtil.NotNull(gitVersion, nameof(gitVersion));
|
||||
|
||||
if (gitVersion < requiredVersion && throwOnNotMatch)
|
||||
{
|
||||
throw new NotSupportedException($"Min required git version is '{requiredVersion}', your git ('{gitPath}') version is '{gitVersion}'");
|
||||
}
|
||||
|
||||
return gitVersion >= requiredVersion;
|
||||
}
|
||||
|
||||
public bool EnsureGitLFSVersion(Version requiredVersion, bool throwOnNotMatch)
|
||||
{
|
||||
ArgUtil.NotNull(gitLfsPath, nameof(gitLfsPath));
|
||||
ArgUtil.NotNull(gitLfsVersion, nameof(gitLfsVersion));
|
||||
|
||||
if (gitLfsVersion < requiredVersion && throwOnNotMatch)
|
||||
{
|
||||
throw new NotSupportedException($"Min required git-lfs version is '{requiredVersion}', your git-lfs ('{gitLfsPath}') version is '{gitLfsVersion}'");
|
||||
}
|
||||
|
||||
return gitLfsVersion >= requiredVersion;
|
||||
}
|
||||
|
||||
public async Task LoadGitExecutionInfo(RunnerActionPluginExecutionContext context)
|
||||
{
|
||||
// Resolve the location of git.
|
||||
gitPath = WhichUtil.Which("git", require: true, trace: context);
|
||||
ArgUtil.File(gitPath, nameof(gitPath));
|
||||
|
||||
// Get the Git version.
|
||||
gitVersion = await GitVersion(context);
|
||||
ArgUtil.NotNull(gitVersion, nameof(gitVersion));
|
||||
context.Debug($"Detect git version: {gitVersion.ToString()}.");
|
||||
|
||||
// Resolve the location of git-lfs.
|
||||
// This should be best effort since checkout lfs objects is an option.
|
||||
// We will check and ensure git-lfs version later
|
||||
gitLfsPath = WhichUtil.Which("git-lfs", require: false, trace: context);
|
||||
|
||||
// Get the Git-LFS version if git-lfs exist in %PATH%.
|
||||
if (!string.IsNullOrEmpty(gitLfsPath))
|
||||
{
|
||||
gitLfsVersion = await GitLfsVersion(context);
|
||||
context.Debug($"Detect git-lfs version: '{gitLfsVersion?.ToString() ?? string.Empty}'.");
|
||||
}
|
||||
|
||||
// required 2.0, all git operation commandline args need min git version 2.0
|
||||
Version minRequiredGitVersion = new Version(2, 0);
|
||||
EnsureGitVersion(minRequiredGitVersion, throwOnNotMatch: true);
|
||||
|
||||
// suggest user upgrade to 2.9 for better git experience
|
||||
Version recommendGitVersion = new Version(2, 9);
|
||||
if (!EnsureGitVersion(recommendGitVersion, throwOnNotMatch: false))
|
||||
{
|
||||
context.Output($"To get a better Git experience, upgrade your Git to at least version '{recommendGitVersion}'. Your current Git version is '{gitVersion}'.");
|
||||
}
|
||||
|
||||
// Set the user agent.
|
||||
string gitHttpUserAgentEnv = $"git/{gitVersion.ToString()} (github-actions-runner-git/{BuildConstants.RunnerPackage.Version})";
|
||||
context.Debug($"Set git useragent to: {gitHttpUserAgentEnv}.");
|
||||
gitEnv["GIT_HTTP_USER_AGENT"] = gitHttpUserAgentEnv;
|
||||
}
|
||||
|
||||
// git init <LocalDir>
|
||||
public async Task<int> GitInit(RunnerActionPluginExecutionContext context, string repositoryPath)
|
||||
{
|
||||
context.Debug($"Init git repository at: {repositoryPath}.");
|
||||
string repoRootEscapeSpace = StringUtil.Format(@"""{0}""", repositoryPath.Replace(@"""", @"\"""));
|
||||
return await ExecuteGitCommandAsync(context, repositoryPath, "init", StringUtil.Format($"{repoRootEscapeSpace}"));
|
||||
}
|
||||
|
||||
// git fetch --tags --prune --progress --no-recurse-submodules [--depth=15] origin [+refs/pull/*:refs/remote/pull/*]
|
||||
public async Task<int> GitFetch(RunnerActionPluginExecutionContext context, string repositoryPath, string remoteName, int fetchDepth, List<string> refSpec, string additionalCommandLine, CancellationToken cancellationToken)
|
||||
{
|
||||
context.Debug($"Fetch git repository at: {repositoryPath} remote: {remoteName}.");
|
||||
if (refSpec != null && refSpec.Count > 0)
|
||||
{
|
||||
refSpec = refSpec.Where(r => !string.IsNullOrEmpty(r)).ToList();
|
||||
}
|
||||
|
||||
// default options for git fetch.
|
||||
string options = StringUtil.Format($"--tags --prune --progress --no-recurse-submodules {remoteName} {string.Join(" ", refSpec)}");
|
||||
|
||||
// If shallow fetch add --depth arg
|
||||
// If the local repository is shallowed but there is no fetch depth provide for this build,
|
||||
// add --unshallow to convert the shallow repository to a complete repository
|
||||
if (fetchDepth > 0)
|
||||
{
|
||||
options = StringUtil.Format($"--tags --prune --progress --no-recurse-submodules --depth={fetchDepth} {remoteName} {string.Join(" ", refSpec)}");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (File.Exists(Path.Combine(repositoryPath, ".git", "shallow")))
|
||||
{
|
||||
options = StringUtil.Format($"--tags --prune --progress --no-recurse-submodules --unshallow {remoteName} {string.Join(" ", refSpec)}");
|
||||
}
|
||||
}
|
||||
|
||||
int retryCount = 0;
|
||||
int fetchExitCode = 0;
|
||||
while (retryCount < 3)
|
||||
{
|
||||
fetchExitCode = await ExecuteGitCommandAsync(context, repositoryPath, "fetch", options, additionalCommandLine, cancellationToken);
|
||||
if (fetchExitCode == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (++retryCount < 3)
|
||||
{
|
||||
var backOff = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10));
|
||||
context.Warning($"Git fetch failed with exit code {fetchExitCode}, back off {backOff.TotalSeconds} seconds before retry.");
|
||||
await Task.Delay(backOff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fetchExitCode;
|
||||
}
|
||||
|
||||
// git fetch --no-tags --prune --progress --no-recurse-submodules [--depth=15] origin [+refs/pull/*:refs/remote/pull/*] [+refs/tags/1:refs/tags/1]
|
||||
public async Task<int> GitFetchNoTags(RunnerActionPluginExecutionContext context, string repositoryPath, string remoteName, int fetchDepth, List<string> refSpec, string additionalCommandLine, CancellationToken cancellationToken)
|
||||
{
|
||||
context.Debug($"Fetch git repository at: {repositoryPath} remote: {remoteName}.");
|
||||
if (refSpec != null && refSpec.Count > 0)
|
||||
{
|
||||
refSpec = refSpec.Where(r => !string.IsNullOrEmpty(r)).ToList();
|
||||
}
|
||||
|
||||
string options;
|
||||
|
||||
// If shallow fetch add --depth arg
|
||||
// If the local repository is shallowed but there is no fetch depth provide for this build,
|
||||
// add --unshallow to convert the shallow repository to a complete repository
|
||||
if (fetchDepth > 0)
|
||||
{
|
||||
options = StringUtil.Format($"--no-tags --prune --progress --no-recurse-submodules --depth={fetchDepth} {remoteName} {string.Join(" ", refSpec)}");
|
||||
}
|
||||
else if (File.Exists(Path.Combine(repositoryPath, ".git", "shallow")))
|
||||
{
|
||||
options = StringUtil.Format($"--no-tags --prune --progress --no-recurse-submodules --unshallow {remoteName} {string.Join(" ", refSpec)}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// default options for git fetch.
|
||||
options = StringUtil.Format($"--no-tags --prune --progress --no-recurse-submodules {remoteName} {string.Join(" ", refSpec)}");
|
||||
}
|
||||
|
||||
int retryCount = 0;
|
||||
int fetchExitCode = 0;
|
||||
while (retryCount < 3)
|
||||
{
|
||||
fetchExitCode = await ExecuteGitCommandAsync(context, repositoryPath, "fetch", options, additionalCommandLine, cancellationToken);
|
||||
if (fetchExitCode == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (++retryCount < 3)
|
||||
{
|
||||
var backOff = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10));
|
||||
context.Warning($"Git fetch failed with exit code {fetchExitCode}, back off {backOff.TotalSeconds} seconds before retry.");
|
||||
await Task.Delay(backOff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fetchExitCode;
|
||||
}
|
||||
|
||||
// git lfs fetch origin [ref]
|
||||
public async Task<int> GitLFSFetch(RunnerActionPluginExecutionContext context, string repositoryPath, string remoteName, string refSpec, string additionalCommandLine, CancellationToken cancellationToken)
|
||||
{
|
||||
context.Debug($"Fetch LFS objects for git repository at: {repositoryPath} remote: {remoteName}.");
|
||||
|
||||
// default options for git lfs fetch.
|
||||
string options = StringUtil.Format($"fetch origin {refSpec}");
|
||||
|
||||
int retryCount = 0;
|
||||
int fetchExitCode = 0;
|
||||
while (retryCount < 3)
|
||||
{
|
||||
fetchExitCode = await ExecuteGitCommandAsync(context, repositoryPath, "lfs", options, additionalCommandLine, cancellationToken);
|
||||
if (fetchExitCode == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (++retryCount < 3)
|
||||
{
|
||||
var backOff = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10));
|
||||
context.Warning($"Git lfs fetch failed with exit code {fetchExitCode}, back off {backOff.TotalSeconds} seconds before retry.");
|
||||
await Task.Delay(backOff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fetchExitCode;
|
||||
}
|
||||
|
||||
// git lfs pull
|
||||
public async Task<int> GitLFSPull(RunnerActionPluginExecutionContext context, string repositoryPath, string additionalCommandLine, CancellationToken cancellationToken)
|
||||
{
|
||||
context.Debug($"Download LFS objects for git repository at: {repositoryPath}.");
|
||||
|
||||
int retryCount = 0;
|
||||
int pullExitCode = 0;
|
||||
while (retryCount < 3)
|
||||
{
|
||||
pullExitCode = await ExecuteGitCommandAsync(context, repositoryPath, "lfs", "pull", additionalCommandLine, cancellationToken);
|
||||
if (pullExitCode == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (++retryCount < 3)
|
||||
{
|
||||
var backOff = BackoffTimerHelper.GetRandomBackoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10));
|
||||
context.Warning($"Git lfs pull failed with exit code {pullExitCode}, back off {backOff.TotalSeconds} seconds before retry.");
|
||||
await Task.Delay(backOff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pullExitCode;
|
||||
}
|
||||
|
||||
// git symbolic-ref -q <HEAD>
|
||||
public async Task<int> GitSymbolicRefHEAD(RunnerActionPluginExecutionContext context, string repositoryPath)
|
||||
{
|
||||
context.Debug($"Check whether HEAD is detached HEAD.");
|
||||
return await ExecuteGitCommandAsync(context, repositoryPath, "symbolic-ref", "-q HEAD");
|
||||
}
|
||||
|
||||
// git checkout -f --progress <commitId/branch>
|
||||
public async Task<int> GitCheckout(RunnerActionPluginExecutionContext context, string repositoryPath, string committishOrBranchSpec, CancellationToken cancellationToken)
|
||||
{
|
||||
context.Debug($"Checkout {committishOrBranchSpec}.");
|
||||
|
||||
// Git 2.7 support report checkout progress to stderr during stdout/err redirect.
|
||||
string options;
|
||||
if (gitVersion >= new Version(2, 7))
|
||||
{
|
||||
options = StringUtil.Format("--progress --force {0}", committishOrBranchSpec);
|
||||
}
|
||||
else
|
||||
{
|
||||
options = StringUtil.Format("--force {0}", committishOrBranchSpec);
|
||||
}
|
||||
|
||||
return await ExecuteGitCommandAsync(context, repositoryPath, "checkout", options, cancellationToken);
|
||||
}
|
||||
|
||||
// git checkout -B --progress branch remoteBranch
|
||||
public async Task<int> GitCheckoutB(RunnerActionPluginExecutionContext context, string repositoryPath, string newBranch, string startPoint, CancellationToken cancellationToken)
|
||||
{
|
||||
context.Debug($"Checkout -B {newBranch} {startPoint}.");
|
||||
|
||||
// Git 2.7 support report checkout progress to stderr during stdout/err redirect.
|
||||
string options;
|
||||
if (gitVersion >= new Version(2, 7))
|
||||
{
|
||||
options = $"--progress --force -B {newBranch} {startPoint}";
|
||||
}
|
||||
else
|
||||
{
|
||||
options = $"--force -B {newBranch} {startPoint}";
|
||||
}
|
||||
|
||||
return await ExecuteGitCommandAsync(context, repositoryPath, "checkout", options, cancellationToken);
|
||||
}
|
||||
|
||||
// git clean -ffdx
|
||||
public async Task<int> GitClean(RunnerActionPluginExecutionContext context, string repositoryPath)
|
||||
{
|
||||
context.Debug($"Delete untracked files/folders for repository at {repositoryPath}.");
|
||||
|
||||
// Git 2.4 support git clean -ffdx.
|
||||
string options;
|
||||
if (gitVersion >= new Version(2, 4))
|
||||
{
|
||||
options = "-ffdx";
|
||||
}
|
||||
else
|
||||
{
|
||||
options = "-fdx";
|
||||
}
|
||||
|
||||
return await ExecuteGitCommandAsync(context, repositoryPath, "clean", options);
|
||||
}
|
||||
|
||||
// git reset --hard <commit>
|
||||
public async Task<int> GitReset(RunnerActionPluginExecutionContext context, string repositoryPath, string commit = "HEAD")
|
||||
{
|
||||
context.Debug($"Undo any changes to tracked files in the working tree for repository at {repositoryPath}.");
|
||||
return await ExecuteGitCommandAsync(context, repositoryPath, "reset", $"--hard {commit}");
|
||||
}
|
||||
|
||||
// get remote set-url <origin> <url>
|
||||
public async Task<int> GitRemoteAdd(RunnerActionPluginExecutionContext context, string repositoryPath, string remoteName, string remoteUrl)
|
||||
{
|
||||
context.Debug($"Add git remote: {remoteName} to url: {remoteUrl} for repository under: {repositoryPath}.");
|
||||
return await ExecuteGitCommandAsync(context, repositoryPath, "remote", StringUtil.Format($"add {remoteName} {remoteUrl}"));
|
||||
}
|
||||
|
||||
// get remote set-url <origin> <url>
|
||||
public async Task<int> GitRemoteSetUrl(RunnerActionPluginExecutionContext context, string repositoryPath, string remoteName, string remoteUrl)
|
||||
{
|
||||
context.Debug($"Set git fetch url to: {remoteUrl} for remote: {remoteName}.");
|
||||
return await ExecuteGitCommandAsync(context, repositoryPath, "remote", StringUtil.Format($"set-url {remoteName} {remoteUrl}"));
|
||||
}
|
||||
|
||||
// get remote set-url --push <origin> <url>
|
||||
public async Task<int> GitRemoteSetPushUrl(RunnerActionPluginExecutionContext context, string repositoryPath, string remoteName, string remoteUrl)
|
||||
{
|
||||
context.Debug($"Set git push url to: {remoteUrl} for remote: {remoteName}.");
|
||||
return await ExecuteGitCommandAsync(context, repositoryPath, "remote", StringUtil.Format($"set-url --push {remoteName} {remoteUrl}"));
|
||||
}
|
||||
|
||||
// git submodule foreach git clean -ffdx
|
||||
public async Task<int> GitSubmoduleClean(RunnerActionPluginExecutionContext context, string repositoryPath)
|
||||
{
|
||||
context.Debug($"Delete untracked files/folders for submodules at {repositoryPath}.");
|
||||
|
||||
// Git 2.4 support git clean -ffdx.
|
||||
string options;
|
||||
if (gitVersion >= new Version(2, 4))
|
||||
{
|
||||
options = "-ffdx";
|
||||
}
|
||||
else
|
||||
{
|
||||
options = "-fdx";
|
||||
}
|
||||
|
||||
return await ExecuteGitCommandAsync(context, repositoryPath, "submodule", $"foreach git clean {options}");
|
||||
}
|
||||
|
||||
// git submodule foreach git reset --hard HEAD
|
||||
public async Task<int> GitSubmoduleReset(RunnerActionPluginExecutionContext context, string repositoryPath)
|
||||
{
|
||||
context.Debug($"Undo any changes to tracked files in the working tree for submodules at {repositoryPath}.");
|
||||
return await ExecuteGitCommandAsync(context, repositoryPath, "submodule", "foreach git reset --hard HEAD");
|
||||
}
|
||||
|
||||
// git submodule update --init --force [--depth=15] [--recursive]
|
||||
public async Task<int> GitSubmoduleUpdate(RunnerActionPluginExecutionContext context, string repositoryPath, int fetchDepth, string additionalCommandLine, bool recursive, CancellationToken cancellationToken)
|
||||
{
|
||||
context.Debug("Update the registered git submodules.");
|
||||
string options = "update --init --force";
|
||||
if (fetchDepth > 0)
|
||||
{
|
||||
options = options + $" --depth={fetchDepth}";
|
||||
}
|
||||
if (recursive)
|
||||
{
|
||||
options = options + " --recursive";
|
||||
}
|
||||
|
||||
return await ExecuteGitCommandAsync(context, repositoryPath, "submodule", options, additionalCommandLine, cancellationToken);
|
||||
}
|
||||
|
||||
// git submodule sync [--recursive]
|
||||
public async Task<int> GitSubmoduleSync(RunnerActionPluginExecutionContext context, string repositoryPath, bool recursive, CancellationToken cancellationToken)
|
||||
{
|
||||
context.Debug("Synchronizes submodules' remote URL configuration setting.");
|
||||
string options = "sync";
|
||||
if (recursive)
|
||||
{
|
||||
options = options + " --recursive";
|
||||
}
|
||||
|
||||
return await ExecuteGitCommandAsync(context, repositoryPath, "submodule", options, cancellationToken);
|
||||
}
|
||||
|
||||
// git config --get remote.origin.url
|
||||
public async Task<Uri> GitGetFetchUrl(RunnerActionPluginExecutionContext context, string repositoryPath)
|
||||
{
|
||||
context.Debug($"Inspect remote.origin.url for repository under {repositoryPath}");
|
||||
Uri fetchUrl = null;
|
||||
|
||||
List<string> outputStrings = new List<string>();
|
||||
int exitCode = await ExecuteGitCommandAsync(context, repositoryPath, "config", "--get remote.origin.url", outputStrings);
|
||||
|
||||
if (exitCode != 0)
|
||||
{
|
||||
context.Warning($"'git config --get remote.origin.url' failed with exit code: {exitCode}, output: '{string.Join(Environment.NewLine, outputStrings)}'");
|
||||
}
|
||||
else
|
||||
{
|
||||
// remove empty strings
|
||||
outputStrings = outputStrings.Where(o => !string.IsNullOrEmpty(o)).ToList();
|
||||
if (outputStrings.Count == 1 && !string.IsNullOrEmpty(outputStrings.First()))
|
||||
{
|
||||
string remoteFetchUrl = outputStrings.First();
|
||||
if (Uri.IsWellFormedUriString(remoteFetchUrl, UriKind.Absolute))
|
||||
{
|
||||
context.Debug($"Get remote origin fetch url from git config: {remoteFetchUrl}");
|
||||
fetchUrl = new Uri(remoteFetchUrl);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Debug($"The Origin fetch url from git config: {remoteFetchUrl} is not a absolute well formed url.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Debug($"Unable capture git remote fetch uri from 'git config --get remote.origin.url' command's output, the command's output is not expected: {string.Join(Environment.NewLine, outputStrings)}.");
|
||||
}
|
||||
}
|
||||
|
||||
return fetchUrl;
|
||||
}
|
||||
|
||||
// git config <key> <value>
|
||||
public async Task<int> GitConfig(RunnerActionPluginExecutionContext context, string repositoryPath, string configKey, string configValue)
|
||||
{
|
||||
context.Debug($"Set git config {configKey} {configValue}");
|
||||
return await ExecuteGitCommandAsync(context, repositoryPath, "config", StringUtil.Format($"{configKey} {configValue}"));
|
||||
}
|
||||
|
||||
// git config --get-all <key>
|
||||
public async Task<bool> GitConfigExist(RunnerActionPluginExecutionContext context, string repositoryPath, string configKey)
|
||||
{
|
||||
// git config --get-all {configKey} will return 0 and print the value if the config exist.
|
||||
context.Debug($"Checking git config {configKey} exist or not");
|
||||
|
||||
// ignore any outputs by redirect them into a string list, since the output might contains secrets.
|
||||
List<string> outputStrings = new List<string>();
|
||||
int exitcode = await ExecuteGitCommandAsync(context, repositoryPath, "config", StringUtil.Format($"--get-all {configKey}"), outputStrings);
|
||||
|
||||
return exitcode == 0;
|
||||
}
|
||||
|
||||
// git config --unset-all <key>
|
||||
public async Task<int> GitConfigUnset(RunnerActionPluginExecutionContext context, string repositoryPath, string configKey)
|
||||
{
|
||||
context.Debug($"Unset git config --unset-all {configKey}");
|
||||
return await ExecuteGitCommandAsync(context, repositoryPath, "config", StringUtil.Format($"--unset-all {configKey}"));
|
||||
}
|
||||
|
||||
// git config gc.auto 0
|
||||
public async Task<int> GitDisableAutoGC(RunnerActionPluginExecutionContext context, string repositoryPath)
|
||||
{
|
||||
context.Debug("Disable git auto garbage collection.");
|
||||
return await ExecuteGitCommandAsync(context, repositoryPath, "config", "gc.auto 0");
|
||||
}
|
||||
|
||||
// git repack -adfl
|
||||
public async Task<int> GitRepack(RunnerActionPluginExecutionContext context, string repositoryPath)
|
||||
{
|
||||
context.Debug("Compress .git directory.");
|
||||
return await ExecuteGitCommandAsync(context, repositoryPath, "repack", "-adfl");
|
||||
}
|
||||
|
||||
// git prune
|
||||
public async Task<int> GitPrune(RunnerActionPluginExecutionContext context, string repositoryPath)
|
||||
{
|
||||
context.Debug("Delete unreachable objects under .git directory.");
|
||||
return await ExecuteGitCommandAsync(context, repositoryPath, "prune", "-v");
|
||||
}
|
||||
|
||||
// git count-objects -v -H
|
||||
public async Task<int> GitCountObjects(RunnerActionPluginExecutionContext context, string repositoryPath)
|
||||
{
|
||||
context.Debug("Inspect .git directory.");
|
||||
return await ExecuteGitCommandAsync(context, repositoryPath, "count-objects", "-v -H");
|
||||
}
|
||||
|
||||
// git lfs install --local
|
||||
public async Task<int> GitLFSInstall(RunnerActionPluginExecutionContext context, string repositoryPath)
|
||||
{
|
||||
context.Debug("Ensure git-lfs installed.");
|
||||
return await ExecuteGitCommandAsync(context, repositoryPath, "lfs", "install --local");
|
||||
}
|
||||
|
||||
// git lfs logs last
|
||||
public async Task<int> GitLFSLogs(RunnerActionPluginExecutionContext context, string repositoryPath)
|
||||
{
|
||||
context.Debug("Get git-lfs logs.");
|
||||
return await ExecuteGitCommandAsync(context, repositoryPath, "lfs", "logs last");
|
||||
}
|
||||
|
||||
// git version
|
||||
public async Task<Version> GitVersion(RunnerActionPluginExecutionContext context)
|
||||
{
|
||||
context.Debug("Get git version.");
|
||||
string runnerWorkspace = context.GetRunnerContext("workspace");
|
||||
ArgUtil.Directory(runnerWorkspace, "runnerWorkspace");
|
||||
Version version = null;
|
||||
List<string> outputStrings = new List<string>();
|
||||
int exitCode = await ExecuteGitCommandAsync(context, runnerWorkspace, "version", null, outputStrings);
|
||||
context.Output($"{string.Join(Environment.NewLine, outputStrings)}");
|
||||
if (exitCode == 0)
|
||||
{
|
||||
// remove any empty line.
|
||||
outputStrings = outputStrings.Where(o => !string.IsNullOrEmpty(o)).ToList();
|
||||
if (outputStrings.Count == 1 && !string.IsNullOrEmpty(outputStrings.First()))
|
||||
{
|
||||
string verString = outputStrings.First();
|
||||
// we interested about major.minor.patch version
|
||||
Regex verRegex = new Regex("\\d+\\.\\d+(\\.\\d+)?", RegexOptions.IgnoreCase);
|
||||
var matchResult = verRegex.Match(verString);
|
||||
if (matchResult.Success && !string.IsNullOrEmpty(matchResult.Value))
|
||||
{
|
||||
if (!Version.TryParse(matchResult.Value, out version))
|
||||
{
|
||||
version = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
// git lfs version
|
||||
public async Task<Version> GitLfsVersion(RunnerActionPluginExecutionContext context)
|
||||
{
|
||||
context.Debug("Get git-lfs version.");
|
||||
string runnerWorkspace = context.GetRunnerContext("workspace");
|
||||
ArgUtil.Directory(runnerWorkspace, "runnerWorkspace");
|
||||
Version version = null;
|
||||
List<string> outputStrings = new List<string>();
|
||||
int exitCode = await ExecuteGitCommandAsync(context, runnerWorkspace, "lfs version", null, outputStrings);
|
||||
context.Output($"{string.Join(Environment.NewLine, outputStrings)}");
|
||||
if (exitCode == 0)
|
||||
{
|
||||
// remove any empty line.
|
||||
outputStrings = outputStrings.Where(o => !string.IsNullOrEmpty(o)).ToList();
|
||||
if (outputStrings.Count == 1 && !string.IsNullOrEmpty(outputStrings.First()))
|
||||
{
|
||||
string verString = outputStrings.First();
|
||||
// we interested about major.minor.patch version
|
||||
Regex verRegex = new Regex("\\d+\\.\\d+(\\.\\d+)?", RegexOptions.IgnoreCase);
|
||||
var matchResult = verRegex.Match(verString);
|
||||
if (matchResult.Success && !string.IsNullOrEmpty(matchResult.Value))
|
||||
{
|
||||
if (!Version.TryParse(matchResult.Value, out version))
|
||||
{
|
||||
version = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
private async Task<int> ExecuteGitCommandAsync(RunnerActionPluginExecutionContext context, string repoRoot, string command, string options, CancellationToken cancellationToken = default(CancellationToken))
|
||||
{
|
||||
string arg = StringUtil.Format($"{command} {options}").Trim();
|
||||
context.Command($"git {arg}");
|
||||
|
||||
var processInvoker = new ProcessInvoker(context);
|
||||
processInvoker.OutputDataReceived += delegate (object sender, ProcessDataReceivedEventArgs message)
|
||||
{
|
||||
context.Output(message.Data);
|
||||
};
|
||||
|
||||
processInvoker.ErrorDataReceived += delegate (object sender, ProcessDataReceivedEventArgs message)
|
||||
{
|
||||
context.Output(message.Data);
|
||||
};
|
||||
|
||||
return await processInvoker.ExecuteAsync(
|
||||
workingDirectory: repoRoot,
|
||||
fileName: gitPath,
|
||||
arguments: arg,
|
||||
environment: gitEnv,
|
||||
requireExitCodeZero: false,
|
||||
outputEncoding: s_encoding,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<int> ExecuteGitCommandAsync(RunnerActionPluginExecutionContext context, string repoRoot, string command, string options, IList<string> output)
|
||||
{
|
||||
string arg = StringUtil.Format($"{command} {options}").Trim();
|
||||
context.Command($"git {arg}");
|
||||
|
||||
if (output == null)
|
||||
{
|
||||
output = new List<string>();
|
||||
}
|
||||
|
||||
var processInvoker = new ProcessInvoker(context);
|
||||
processInvoker.OutputDataReceived += delegate (object sender, ProcessDataReceivedEventArgs message)
|
||||
{
|
||||
output.Add(message.Data);
|
||||
};
|
||||
|
||||
processInvoker.ErrorDataReceived += delegate (object sender, ProcessDataReceivedEventArgs message)
|
||||
{
|
||||
context.Output(message.Data);
|
||||
};
|
||||
|
||||
return await processInvoker.ExecuteAsync(
|
||||
workingDirectory: repoRoot,
|
||||
fileName: gitPath,
|
||||
arguments: arg,
|
||||
environment: gitEnv,
|
||||
requireExitCodeZero: false,
|
||||
outputEncoding: s_encoding,
|
||||
cancellationToken: default(CancellationToken));
|
||||
}
|
||||
|
||||
private async Task<int> ExecuteGitCommandAsync(RunnerActionPluginExecutionContext context, string repoRoot, string command, string options, string additionalCommandLine, CancellationToken cancellationToken)
|
||||
{
|
||||
string arg = StringUtil.Format($"{additionalCommandLine} {command} {options}").Trim();
|
||||
context.Command($"git {arg}");
|
||||
|
||||
var processInvoker = new ProcessInvoker(context);
|
||||
processInvoker.OutputDataReceived += delegate (object sender, ProcessDataReceivedEventArgs message)
|
||||
{
|
||||
context.Output(message.Data);
|
||||
};
|
||||
|
||||
processInvoker.ErrorDataReceived += delegate (object sender, ProcessDataReceivedEventArgs message)
|
||||
{
|
||||
context.Output(message.Data);
|
||||
};
|
||||
|
||||
return await processInvoker.ExecuteAsync(
|
||||
workingDirectory: repoRoot,
|
||||
fileName: gitPath,
|
||||
arguments: arg,
|
||||
environment: gitEnv,
|
||||
requireExitCodeZero: false,
|
||||
outputEncoding: s_encoding,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
703
src/Runner.Plugins/Repository/v1.0/GitSourceProvider.cs
Normal file
703
src/Runner.Plugins/Repository/v1.0/GitSourceProvider.cs
Normal file
@@ -0,0 +1,703 @@
|
||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Text;
|
||||
using System.Diagnostics;
|
||||
using GitHub.Runner.Sdk;
|
||||
using System.Linq;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Services.WebApi;
|
||||
|
||||
namespace GitHub.Runner.Plugins.Repository.v1_0
|
||||
{
|
||||
public sealed class GitHubSourceProvider
|
||||
{
|
||||
// refs prefix
|
||||
private const string _refsPrefix = "refs/heads/";
|
||||
private const string _remoteRefsPrefix = "refs/remotes/origin/";
|
||||
private const string _pullRefsPrefix = "refs/pull/";
|
||||
private const string _remotePullRefsPrefix = "refs/remotes/pull/";
|
||||
|
||||
// min git version that support add extra auth header.
|
||||
private Version _minGitVersionSupportAuthHeader = new Version(2, 9);
|
||||
|
||||
#if OS_WINDOWS
|
||||
// min git version that support override sslBackend setting.
|
||||
private Version _minGitVersionSupportSSLBackendOverride = new Version(2, 14, 2);
|
||||
#endif
|
||||
|
||||
// min git-lfs version that support add extra auth header.
|
||||
private Version _minGitLfsVersionSupportAuthHeader = new Version(2, 1);
|
||||
|
||||
private void RequirementCheck(RunnerActionPluginExecutionContext executionContext, GitCliManager gitCommandManager, bool checkGitLfs)
|
||||
{
|
||||
// v2.9 git exist use auth header.
|
||||
gitCommandManager.EnsureGitVersion(_minGitVersionSupportAuthHeader, throwOnNotMatch: true);
|
||||
|
||||
#if OS_WINDOWS
|
||||
// check git version for SChannel SSLBackend (Windows Only)
|
||||
bool schannelSslBackend = StringUtil.ConvertToBoolean(executionContext.GetRunnerContext("gituseschannel"));
|
||||
if (schannelSslBackend)
|
||||
{
|
||||
gitCommandManager.EnsureGitVersion(_minGitVersionSupportSSLBackendOverride, throwOnNotMatch: true);
|
||||
}
|
||||
#endif
|
||||
if (checkGitLfs)
|
||||
{
|
||||
// v2.1 git-lfs exist use auth header.
|
||||
gitCommandManager.EnsureGitLFSVersion(_minGitLfsVersionSupportAuthHeader, throwOnNotMatch: true);
|
||||
}
|
||||
}
|
||||
|
||||
private string GenerateBasicAuthHeader(RunnerActionPluginExecutionContext executionContext, string accessToken)
|
||||
{
|
||||
// use basic auth header with username:password in base64encoding.
|
||||
string authHeader = $"x-access-token:{accessToken}";
|
||||
string base64encodedAuthHeader = Convert.ToBase64String(Encoding.UTF8.GetBytes(authHeader));
|
||||
|
||||
// add base64 encoding auth header into secretMasker.
|
||||
executionContext.AddMask(base64encodedAuthHeader);
|
||||
return $"basic {base64encodedAuthHeader}";
|
||||
}
|
||||
|
||||
public async Task GetSourceAsync(
|
||||
RunnerActionPluginExecutionContext executionContext,
|
||||
string repositoryPath,
|
||||
string repoFullName,
|
||||
string sourceBranch,
|
||||
string sourceVersion,
|
||||
bool clean,
|
||||
string submoduleInput,
|
||||
int fetchDepth,
|
||||
bool gitLfsSupport,
|
||||
string accessToken,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Validate args.
|
||||
ArgUtil.NotNull(executionContext, nameof(executionContext));
|
||||
Uri proxyUrlWithCred = null;
|
||||
string proxyUrlWithCredString = null;
|
||||
bool useSelfSignedCACert = false;
|
||||
bool useClientCert = false;
|
||||
string clientCertPrivateKeyAskPassFile = null;
|
||||
bool acceptUntrustedCerts = false;
|
||||
|
||||
executionContext.Output($"Syncing repository: {repoFullName}");
|
||||
Uri repositoryUrl = new Uri($"https://github.com/{repoFullName}");
|
||||
if (!repositoryUrl.IsAbsoluteUri)
|
||||
{
|
||||
throw new InvalidOperationException("Repository url need to be an absolute uri.");
|
||||
}
|
||||
|
||||
string targetPath = repositoryPath;
|
||||
|
||||
// input Submodules can be ['', true, false, recursive]
|
||||
// '' or false indicate don't checkout submodules
|
||||
// true indicate checkout top level submodules
|
||||
// recursive indicate checkout submodules recursively
|
||||
bool checkoutSubmodules = false;
|
||||
bool checkoutNestedSubmodules = false;
|
||||
if (!string.IsNullOrEmpty(submoduleInput))
|
||||
{
|
||||
if (string.Equals(submoduleInput, Pipelines.PipelineConstants.CheckoutTaskInputs.SubmodulesOptions.Recursive, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
checkoutSubmodules = true;
|
||||
checkoutNestedSubmodules = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
checkoutSubmodules = StringUtil.ConvertToBoolean(submoduleInput);
|
||||
}
|
||||
}
|
||||
|
||||
var runnerCert = executionContext.GetCertConfiguration();
|
||||
acceptUntrustedCerts = runnerCert?.SkipServerCertificateValidation ?? false;
|
||||
|
||||
executionContext.Debug($"repository url={repositoryUrl}");
|
||||
executionContext.Debug($"targetPath={targetPath}");
|
||||
executionContext.Debug($"sourceBranch={sourceBranch}");
|
||||
executionContext.Debug($"sourceVersion={sourceVersion}");
|
||||
executionContext.Debug($"clean={clean}");
|
||||
executionContext.Debug($"checkoutSubmodules={checkoutSubmodules}");
|
||||
executionContext.Debug($"checkoutNestedSubmodules={checkoutNestedSubmodules}");
|
||||
executionContext.Debug($"fetchDepth={fetchDepth}");
|
||||
executionContext.Debug($"gitLfsSupport={gitLfsSupport}");
|
||||
executionContext.Debug($"acceptUntrustedCerts={acceptUntrustedCerts}");
|
||||
|
||||
#if OS_WINDOWS
|
||||
bool schannelSslBackend = StringUtil.ConvertToBoolean(executionContext.GetRunnerContext("gituseschannel"));
|
||||
executionContext.Debug($"schannelSslBackend={schannelSslBackend}");
|
||||
#endif
|
||||
|
||||
// Initialize git command manager with additional environment variables.
|
||||
Dictionary<string, string> gitEnv = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Disable prompting for git credential manager
|
||||
gitEnv["GCM_INTERACTIVE"] = "Never";
|
||||
|
||||
// Git-lfs will try to pull down asset if any of the local/user/system setting exist.
|
||||
// If customer didn't enable `LFS` in their pipeline definition, we will use ENV to disable LFS fetch/checkout.
|
||||
if (!gitLfsSupport)
|
||||
{
|
||||
gitEnv["GIT_LFS_SKIP_SMUDGE"] = "1";
|
||||
}
|
||||
|
||||
// Add the public variables.
|
||||
foreach (var variable in executionContext.Variables)
|
||||
{
|
||||
// Add the variable using the formatted name.
|
||||
string formattedKey = (variable.Key ?? string.Empty).Replace('.', '_').Replace(' ', '_').ToUpperInvariant();
|
||||
gitEnv[formattedKey] = variable.Value?.Value ?? string.Empty;
|
||||
}
|
||||
|
||||
GitCliManager gitCommandManager = new GitCliManager(gitEnv);
|
||||
await gitCommandManager.LoadGitExecutionInfo(executionContext);
|
||||
|
||||
// Make sure the build machine met all requirements for the git repository
|
||||
// For now, the requirement we have are:
|
||||
// 1. git version greater than 2.9 since we need to use auth header.
|
||||
// 2. git-lfs version greater than 2.1 since we need to use auth header.
|
||||
// 3. git version greater than 2.14.2 if use SChannel for SSL backend (Windows only)
|
||||
RequirementCheck(executionContext, gitCommandManager, gitLfsSupport);
|
||||
|
||||
// prepare credentail embedded urls
|
||||
var runnerProxy = executionContext.GetProxyConfiguration();
|
||||
if (runnerProxy != null && !string.IsNullOrEmpty(runnerProxy.ProxyAddress) && !runnerProxy.WebProxy.IsBypassed(repositoryUrl))
|
||||
{
|
||||
proxyUrlWithCred = UrlUtil.GetCredentialEmbeddedUrl(new Uri(runnerProxy.ProxyAddress), runnerProxy.ProxyUsername, runnerProxy.ProxyPassword);
|
||||
|
||||
// uri.absoluteuri will not contains port info if the scheme is http/https and the port is 80/443
|
||||
// however, git.exe always require you provide port info, if nothing passed in, it will use 1080 as default
|
||||
// as result, we need prefer the uri.originalstring when it's different than uri.absoluteuri.
|
||||
if (string.Equals(proxyUrlWithCred.AbsoluteUri, proxyUrlWithCred.OriginalString, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
proxyUrlWithCredString = proxyUrlWithCred.AbsoluteUri;
|
||||
}
|
||||
else
|
||||
{
|
||||
proxyUrlWithCredString = proxyUrlWithCred.OriginalString;
|
||||
}
|
||||
}
|
||||
|
||||
// prepare askpass for client cert private key, if the repository's endpoint url match the runner config url
|
||||
var systemConnection = executionContext.Endpoints.Single(x => string.Equals(x.Name, WellKnownServiceEndpointNames.SystemVssConnection, StringComparison.OrdinalIgnoreCase));
|
||||
if (runnerCert != null && Uri.Compare(repositoryUrl, systemConnection.Url, UriComponents.SchemeAndServer, UriFormat.Unescaped, StringComparison.OrdinalIgnoreCase) == 0)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(runnerCert.CACertificateFile))
|
||||
{
|
||||
useSelfSignedCACert = true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(runnerCert.ClientCertificateFile) &&
|
||||
!string.IsNullOrEmpty(runnerCert.ClientCertificatePrivateKeyFile))
|
||||
{
|
||||
useClientCert = true;
|
||||
|
||||
// prepare askpass for client cert password
|
||||
if (!string.IsNullOrEmpty(runnerCert.ClientCertificatePassword))
|
||||
{
|
||||
clientCertPrivateKeyAskPassFile = Path.Combine(executionContext.GetRunnerContext("temp"), $"{Guid.NewGuid()}.sh");
|
||||
List<string> askPass = new List<string>();
|
||||
askPass.Add("#!/bin/sh");
|
||||
askPass.Add($"echo \"{runnerCert.ClientCertificatePassword}\"");
|
||||
File.WriteAllLines(clientCertPrivateKeyAskPassFile, askPass);
|
||||
|
||||
#if !OS_WINDOWS
|
||||
string toolPath = WhichUtil.Which("chmod", true);
|
||||
string argLine = $"775 {clientCertPrivateKeyAskPassFile}";
|
||||
executionContext.Command($"chmod {argLine}");
|
||||
|
||||
var processInvoker = new ProcessInvoker(executionContext);
|
||||
processInvoker.OutputDataReceived += (object sender, ProcessDataReceivedEventArgs args) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(args.Data))
|
||||
{
|
||||
executionContext.Output(args.Data);
|
||||
}
|
||||
};
|
||||
processInvoker.ErrorDataReceived += (object sender, ProcessDataReceivedEventArgs args) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(args.Data))
|
||||
{
|
||||
executionContext.Output(args.Data);
|
||||
}
|
||||
};
|
||||
|
||||
string workingDirectory = executionContext.GetRunnerContext("workspace");
|
||||
await processInvoker.ExecuteAsync(workingDirectory, toolPath, argLine, null, true, CancellationToken.None);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check the current contents of the root folder to see if there is already a repo
|
||||
// If there is a repo, see if it matches the one we are expecting to be there based on the remote fetch url
|
||||
// if the repo is not what we expect, remove the folder
|
||||
if (!await IsRepositoryOriginUrlMatch(executionContext, gitCommandManager, targetPath, repositoryUrl))
|
||||
{
|
||||
// Delete source folder
|
||||
IOUtil.DeleteDirectory(targetPath, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// delete the index.lock file left by previous canceled build or any operation cause git.exe crash last time.
|
||||
string lockFile = Path.Combine(targetPath, ".git\\index.lock");
|
||||
if (File.Exists(lockFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(lockFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
executionContext.Debug($"Unable to delete the index.lock file: {lockFile}");
|
||||
executionContext.Debug(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
// delete the shallow.lock file left by previous canceled build or any operation cause git.exe crash last time.
|
||||
string shallowLockFile = Path.Combine(targetPath, ".git\\shallow.lock");
|
||||
if (File.Exists(shallowLockFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(shallowLockFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
executionContext.Debug($"Unable to delete the shallow.lock file: {shallowLockFile}");
|
||||
executionContext.Debug(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
// When repo.clean is selected for a git repo, execute git clean -ffdx and git reset --hard HEAD on the current repo.
|
||||
// This will help us save the time to reclone the entire repo.
|
||||
// If any git commands exit with non-zero return code or any exception happened during git.exe invoke, fall back to delete the repo folder.
|
||||
if (clean)
|
||||
{
|
||||
Boolean softCleanSucceed = true;
|
||||
|
||||
// git clean -ffdx
|
||||
int exitCode_clean = await gitCommandManager.GitClean(executionContext, targetPath);
|
||||
if (exitCode_clean != 0)
|
||||
{
|
||||
executionContext.Debug($"'git clean -ffdx' failed with exit code {exitCode_clean}, this normally caused by:\n 1) Path too long\n 2) Permission issue\n 3) File in use\nFor futher investigation, manually run 'git clean -ffdx' on repo root: {targetPath} after each build.");
|
||||
softCleanSucceed = false;
|
||||
}
|
||||
|
||||
// git reset --hard HEAD
|
||||
if (softCleanSucceed)
|
||||
{
|
||||
int exitCode_reset = await gitCommandManager.GitReset(executionContext, targetPath);
|
||||
if (exitCode_reset != 0)
|
||||
{
|
||||
executionContext.Debug($"'git reset --hard HEAD' failed with exit code {exitCode_reset}\nFor futher investigation, manually run 'git reset --hard HEAD' on repo root: {targetPath} after each build.");
|
||||
softCleanSucceed = false;
|
||||
}
|
||||
}
|
||||
|
||||
// git clean -ffdx and git reset --hard HEAD for each submodule
|
||||
if (checkoutSubmodules)
|
||||
{
|
||||
if (softCleanSucceed)
|
||||
{
|
||||
int exitCode_submoduleclean = await gitCommandManager.GitSubmoduleClean(executionContext, targetPath);
|
||||
if (exitCode_submoduleclean != 0)
|
||||
{
|
||||
executionContext.Debug($"'git submodule foreach git clean -ffdx' failed with exit code {exitCode_submoduleclean}\nFor futher investigation, manually run 'git submodule foreach git clean -ffdx' on repo root: {targetPath} after each build.");
|
||||
softCleanSucceed = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (softCleanSucceed)
|
||||
{
|
||||
int exitCode_submodulereset = await gitCommandManager.GitSubmoduleReset(executionContext, targetPath);
|
||||
if (exitCode_submodulereset != 0)
|
||||
{
|
||||
executionContext.Debug($"'git submodule foreach git reset --hard HEAD' failed with exit code {exitCode_submodulereset}\nFor futher investigation, manually run 'git submodule foreach git reset --hard HEAD' on repo root: {targetPath} after each build.");
|
||||
softCleanSucceed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!softCleanSucceed)
|
||||
{
|
||||
//fall back
|
||||
executionContext.Warning("Unable to run \"git clean -ffdx\" and \"git reset --hard HEAD\" successfully, delete source folder instead.");
|
||||
IOUtil.DeleteDirectory(targetPath, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if the folder is missing, create it
|
||||
if (!Directory.Exists(targetPath))
|
||||
{
|
||||
Directory.CreateDirectory(targetPath);
|
||||
}
|
||||
|
||||
// if the folder contains a .git folder, it means the folder contains a git repo that matches the remote url and in a clean state.
|
||||
// we will run git fetch to update the repo.
|
||||
if (!Directory.Exists(Path.Combine(targetPath, ".git")))
|
||||
{
|
||||
// init git repository
|
||||
int exitCode_init = await gitCommandManager.GitInit(executionContext, targetPath);
|
||||
if (exitCode_init != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Unable to use git.exe init repository under {targetPath}, 'git init' failed with exit code: {exitCode_init}");
|
||||
}
|
||||
|
||||
int exitCode_addremote = await gitCommandManager.GitRemoteAdd(executionContext, targetPath, "origin", repositoryUrl.AbsoluteUri);
|
||||
if (exitCode_addremote != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Unable to use git.exe add remote 'origin', 'git remote add' failed with exit code: {exitCode_addremote}");
|
||||
}
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// disable git auto gc
|
||||
int exitCode_disableGC = await gitCommandManager.GitDisableAutoGC(executionContext, targetPath);
|
||||
if (exitCode_disableGC != 0)
|
||||
{
|
||||
executionContext.Warning("Unable turn off git auto garbage collection, git fetch operation may trigger auto garbage collection which will affect the performance of fetching.");
|
||||
}
|
||||
|
||||
// always remove any possible left extraheader setting from git config.
|
||||
if (await gitCommandManager.GitConfigExist(executionContext, targetPath, $"http.{repositoryUrl.AbsoluteUri}.extraheader"))
|
||||
{
|
||||
executionContext.Debug("Remove any extraheader setting from git config.");
|
||||
await RemoveGitConfig(executionContext, gitCommandManager, targetPath, $"http.{repositoryUrl.AbsoluteUri}.extraheader", string.Empty);
|
||||
}
|
||||
|
||||
// always remove any possible left proxy setting from git config, the proxy setting may contains credential
|
||||
if (await gitCommandManager.GitConfigExist(executionContext, targetPath, $"http.proxy"))
|
||||
{
|
||||
executionContext.Debug("Remove any proxy setting from git config.");
|
||||
await RemoveGitConfig(executionContext, gitCommandManager, targetPath, $"http.proxy", string.Empty);
|
||||
}
|
||||
|
||||
List<string> additionalFetchArgs = new List<string>();
|
||||
List<string> additionalLfsFetchArgs = new List<string>();
|
||||
|
||||
// add accessToken as basic auth header to handle auth challenge.
|
||||
if (!string.IsNullOrEmpty(accessToken))
|
||||
{
|
||||
additionalFetchArgs.Add($"-c http.extraheader=\"AUTHORIZATION: {GenerateBasicAuthHeader(executionContext, accessToken)}\"");
|
||||
}
|
||||
|
||||
// Prepare proxy config for fetch.
|
||||
if (runnerProxy != null && !string.IsNullOrEmpty(runnerProxy.ProxyAddress) && !runnerProxy.WebProxy.IsBypassed(repositoryUrl))
|
||||
{
|
||||
executionContext.Debug($"Config proxy server '{runnerProxy.ProxyAddress}' for git fetch.");
|
||||
ArgUtil.NotNullOrEmpty(proxyUrlWithCredString, nameof(proxyUrlWithCredString));
|
||||
additionalFetchArgs.Add($"-c http.proxy=\"{proxyUrlWithCredString}\"");
|
||||
additionalLfsFetchArgs.Add($"-c http.proxy=\"{proxyUrlWithCredString}\"");
|
||||
}
|
||||
|
||||
// Prepare ignore ssl cert error config for fetch.
|
||||
if (acceptUntrustedCerts)
|
||||
{
|
||||
additionalFetchArgs.Add($"-c http.sslVerify=false");
|
||||
additionalLfsFetchArgs.Add($"-c http.sslVerify=false");
|
||||
}
|
||||
|
||||
// Prepare self-signed CA cert config for fetch from server.
|
||||
if (useSelfSignedCACert)
|
||||
{
|
||||
executionContext.Debug($"Use self-signed certificate '{runnerCert.CACertificateFile}' for git fetch.");
|
||||
additionalFetchArgs.Add($"-c http.sslcainfo=\"{runnerCert.CACertificateFile}\"");
|
||||
additionalLfsFetchArgs.Add($"-c http.sslcainfo=\"{runnerCert.CACertificateFile}\"");
|
||||
}
|
||||
|
||||
// Prepare client cert config for fetch from server.
|
||||
if (useClientCert)
|
||||
{
|
||||
executionContext.Debug($"Use client certificate '{runnerCert.ClientCertificateFile}' for git fetch.");
|
||||
|
||||
if (!string.IsNullOrEmpty(clientCertPrivateKeyAskPassFile))
|
||||
{
|
||||
additionalFetchArgs.Add($"-c http.sslcert=\"{runnerCert.ClientCertificateFile}\" -c http.sslkey=\"{runnerCert.ClientCertificatePrivateKeyFile}\" -c http.sslCertPasswordProtected=true -c core.askpass=\"{clientCertPrivateKeyAskPassFile}\"");
|
||||
additionalLfsFetchArgs.Add($"-c http.sslcert=\"{runnerCert.ClientCertificateFile}\" -c http.sslkey=\"{runnerCert.ClientCertificatePrivateKeyFile}\" -c http.sslCertPasswordProtected=true -c core.askpass=\"{clientCertPrivateKeyAskPassFile}\"");
|
||||
}
|
||||
else
|
||||
{
|
||||
additionalFetchArgs.Add($"-c http.sslcert=\"{runnerCert.ClientCertificateFile}\" -c http.sslkey=\"{runnerCert.ClientCertificatePrivateKeyFile}\"");
|
||||
additionalLfsFetchArgs.Add($"-c http.sslcert=\"{runnerCert.ClientCertificateFile}\" -c http.sslkey=\"{runnerCert.ClientCertificatePrivateKeyFile}\"");
|
||||
}
|
||||
}
|
||||
|
||||
#if OS_WINDOWS
|
||||
if (schannelSslBackend)
|
||||
{
|
||||
executionContext.Debug("Use SChannel SslBackend for git fetch.");
|
||||
additionalFetchArgs.Add("-c http.sslbackend=\"schannel\"");
|
||||
additionalLfsFetchArgs.Add("-c http.sslbackend=\"schannel\"");
|
||||
}
|
||||
#endif
|
||||
// Prepare gitlfs url for fetch and checkout
|
||||
if (gitLfsSupport)
|
||||
{
|
||||
// Initialize git lfs by execute 'git lfs install'
|
||||
executionContext.Debug("Setup the local Git hooks for Git LFS.");
|
||||
int exitCode_lfsInstall = await gitCommandManager.GitLFSInstall(executionContext, targetPath);
|
||||
if (exitCode_lfsInstall != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Git-lfs installation failed with exit code: {exitCode_lfsInstall}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(accessToken))
|
||||
{
|
||||
string authorityUrl = repositoryUrl.AbsoluteUri.Replace(repositoryUrl.PathAndQuery, string.Empty);
|
||||
additionalLfsFetchArgs.Add($"-c http.{authorityUrl}.extraheader=\"AUTHORIZATION: {GenerateBasicAuthHeader(executionContext, accessToken)}\"");
|
||||
}
|
||||
}
|
||||
|
||||
List<string> additionalFetchSpecs = new List<string>();
|
||||
additionalFetchSpecs.Add("+refs/heads/*:refs/remotes/origin/*");
|
||||
|
||||
if (IsPullRequest(sourceBranch))
|
||||
{
|
||||
additionalFetchSpecs.Add($"+{sourceBranch}:{GetRemoteRefName(sourceBranch)}");
|
||||
}
|
||||
|
||||
int exitCode_fetch = await gitCommandManager.GitFetch(executionContext, targetPath, "origin", fetchDepth, additionalFetchSpecs, string.Join(" ", additionalFetchArgs), cancellationToken);
|
||||
if (exitCode_fetch != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Git fetch failed with exit code: {exitCode_fetch}");
|
||||
}
|
||||
|
||||
// Checkout
|
||||
// sourceToBuild is used for checkout
|
||||
// if sourceBranch is a PR branch or sourceVersion is null, make sure branch name is a remote branch. we need checkout to detached head.
|
||||
// (change refs/heads to refs/remotes/origin, refs/pull to refs/remotes/pull, or leave it as it when the branch name doesn't contain refs/...)
|
||||
// if sourceVersion provide, just use that for checkout, since when you checkout a commit, it will end up in detached head.
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string sourcesToBuild;
|
||||
if (IsPullRequest(sourceBranch) || string.IsNullOrEmpty(sourceVersion))
|
||||
{
|
||||
sourcesToBuild = GetRemoteRefName(sourceBranch);
|
||||
}
|
||||
else
|
||||
{
|
||||
sourcesToBuild = sourceVersion;
|
||||
}
|
||||
|
||||
// fetch lfs object upfront, this will avoid fetch lfs object during checkout which cause checkout taking forever
|
||||
// since checkout will fetch lfs object 1 at a time, while git lfs fetch will fetch lfs object in parallel.
|
||||
if (gitLfsSupport)
|
||||
{
|
||||
int exitCode_lfsFetch = await gitCommandManager.GitLFSFetch(executionContext, targetPath, "origin", sourcesToBuild, string.Join(" ", additionalLfsFetchArgs), cancellationToken);
|
||||
if (exitCode_lfsFetch != 0)
|
||||
{
|
||||
// local repository is shallow repository, lfs fetch may fail due to lack of commits history.
|
||||
// this will happen when the checkout commit is older than tip -> fetchDepth
|
||||
if (fetchDepth > 0)
|
||||
{
|
||||
executionContext.Warning($"Git lfs fetch failed on shallow repository, this might because of git fetch with depth '{fetchDepth}' doesn't include the lfs fetch commit '{sourcesToBuild}'.");
|
||||
}
|
||||
|
||||
// git lfs fetch failed, get lfs log, the log is critical for debug.
|
||||
int exitCode_lfsLogs = await gitCommandManager.GitLFSLogs(executionContext, targetPath);
|
||||
throw new InvalidOperationException($"Git lfs fetch failed with exit code: {exitCode_lfsFetch}. Git lfs logs returned with exit code: {exitCode_lfsLogs}.");
|
||||
}
|
||||
}
|
||||
|
||||
// Finally, checkout the sourcesToBuild (if we didn't find a valid git object this will throw)
|
||||
int exitCode_checkout = await gitCommandManager.GitCheckout(executionContext, targetPath, sourcesToBuild, cancellationToken);
|
||||
if (exitCode_checkout != 0)
|
||||
{
|
||||
// local repository is shallow repository, checkout may fail due to lack of commits history.
|
||||
// this will happen when the checkout commit is older than tip -> fetchDepth
|
||||
if (fetchDepth > 0)
|
||||
{
|
||||
executionContext.Warning($"Git checkout failed on shallow repository, this might because of git fetch with depth '{fetchDepth}' doesn't include the checkout commit '{sourcesToBuild}'.");
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Git checkout failed with exit code: {exitCode_checkout}");
|
||||
}
|
||||
|
||||
// Submodule update
|
||||
if (checkoutSubmodules)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
int exitCode_submoduleSync = await gitCommandManager.GitSubmoduleSync(executionContext, targetPath, checkoutNestedSubmodules, cancellationToken);
|
||||
if (exitCode_submoduleSync != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Git submodule sync failed with exit code: {exitCode_submoduleSync}");
|
||||
}
|
||||
|
||||
List<string> additionalSubmoduleUpdateArgs = new List<string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(accessToken))
|
||||
{
|
||||
string authorityUrl = repositoryUrl.AbsoluteUri.Replace(repositoryUrl.PathAndQuery, string.Empty);
|
||||
additionalSubmoduleUpdateArgs.Add($"-c http.{authorityUrl}.extraheader=\"AUTHORIZATION: {GenerateBasicAuthHeader(executionContext, accessToken)}\"");
|
||||
}
|
||||
|
||||
// Prepare proxy config for submodule update.
|
||||
if (runnerProxy != null && !string.IsNullOrEmpty(runnerProxy.ProxyAddress) && !runnerProxy.WebProxy.IsBypassed(repositoryUrl))
|
||||
{
|
||||
executionContext.Debug($"Config proxy server '{runnerProxy.ProxyAddress}' for git submodule update.");
|
||||
ArgUtil.NotNullOrEmpty(proxyUrlWithCredString, nameof(proxyUrlWithCredString));
|
||||
additionalSubmoduleUpdateArgs.Add($"-c http.proxy=\"{proxyUrlWithCredString}\"");
|
||||
}
|
||||
|
||||
// Prepare ignore ssl cert error config for fetch.
|
||||
if (acceptUntrustedCerts)
|
||||
{
|
||||
additionalSubmoduleUpdateArgs.Add($"-c http.sslVerify=false");
|
||||
}
|
||||
|
||||
// Prepare self-signed CA cert config for submodule update.
|
||||
if (useSelfSignedCACert)
|
||||
{
|
||||
executionContext.Debug($"Use self-signed CA certificate '{runnerCert.CACertificateFile}' for git submodule update.");
|
||||
string authorityUrl = repositoryUrl.AbsoluteUri.Replace(repositoryUrl.PathAndQuery, string.Empty);
|
||||
additionalSubmoduleUpdateArgs.Add($"-c http.{authorityUrl}.sslcainfo=\"{runnerCert.CACertificateFile}\"");
|
||||
}
|
||||
|
||||
// Prepare client cert config for submodule update.
|
||||
if (useClientCert)
|
||||
{
|
||||
executionContext.Debug($"Use client certificate '{runnerCert.ClientCertificateFile}' for git submodule update.");
|
||||
string authorityUrl = repositoryUrl.AbsoluteUri.Replace(repositoryUrl.PathAndQuery, string.Empty);
|
||||
|
||||
if (!string.IsNullOrEmpty(clientCertPrivateKeyAskPassFile))
|
||||
{
|
||||
additionalSubmoduleUpdateArgs.Add($"-c http.{authorityUrl}.sslcert=\"{runnerCert.ClientCertificateFile}\" -c http.{authorityUrl}.sslkey=\"{runnerCert.ClientCertificatePrivateKeyFile}\" -c http.{authorityUrl}.sslCertPasswordProtected=true -c core.askpass=\"{clientCertPrivateKeyAskPassFile}\"");
|
||||
}
|
||||
else
|
||||
{
|
||||
additionalSubmoduleUpdateArgs.Add($"-c http.{authorityUrl}.sslcert=\"{runnerCert.ClientCertificateFile}\" -c http.{authorityUrl}.sslkey=\"{runnerCert.ClientCertificatePrivateKeyFile}\"");
|
||||
}
|
||||
}
|
||||
#if OS_WINDOWS
|
||||
if (schannelSslBackend)
|
||||
{
|
||||
executionContext.Debug("Use SChannel SslBackend for git submodule update.");
|
||||
additionalSubmoduleUpdateArgs.Add("-c http.sslbackend=\"schannel\"");
|
||||
}
|
||||
#endif
|
||||
|
||||
int exitCode_submoduleUpdate = await gitCommandManager.GitSubmoduleUpdate(executionContext, targetPath, fetchDepth, string.Join(" ", additionalSubmoduleUpdateArgs), checkoutNestedSubmodules, cancellationToken);
|
||||
if (exitCode_submoduleUpdate != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Git submodule update failed with exit code: {exitCode_submoduleUpdate}");
|
||||
}
|
||||
}
|
||||
|
||||
if (useClientCert && !string.IsNullOrEmpty(clientCertPrivateKeyAskPassFile))
|
||||
{
|
||||
executionContext.Debug("Remove git.sslkey askpass file.");
|
||||
IOUtil.DeleteFile(clientCertPrivateKeyAskPassFile);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> IsRepositoryOriginUrlMatch(RunnerActionPluginExecutionContext context, GitCliManager gitCommandManager, string repositoryPath, Uri expectedRepositoryOriginUrl)
|
||||
{
|
||||
context.Debug($"Checking if the repo on {repositoryPath} matches the expected repository origin URL. expected Url: {expectedRepositoryOriginUrl.AbsoluteUri}");
|
||||
if (!Directory.Exists(Path.Combine(repositoryPath, ".git")))
|
||||
{
|
||||
// There is no repo directory
|
||||
context.Debug($"Repository is not found since '.git' directory does not exist under. {repositoryPath}");
|
||||
return false;
|
||||
}
|
||||
|
||||
Uri remoteUrl;
|
||||
remoteUrl = await gitCommandManager.GitGetFetchUrl(context, repositoryPath);
|
||||
|
||||
if (remoteUrl == null)
|
||||
{
|
||||
// origin fetch url not found.
|
||||
context.Debug("Repository remote origin fetch url is empty.");
|
||||
return false;
|
||||
}
|
||||
|
||||
context.Debug($"Repository remote origin fetch url is {remoteUrl}");
|
||||
// compare the url passed in with the remote url found
|
||||
if (expectedRepositoryOriginUrl.Equals(remoteUrl))
|
||||
{
|
||||
context.Debug("URLs match.");
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Debug($"The remote.origin.url of the repository under root folder '{repositoryPath}' doesn't matches source repository url.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveGitConfig(RunnerActionPluginExecutionContext executionContext, GitCliManager gitCommandManager, string targetPath, string configKey, string configValue)
|
||||
{
|
||||
int exitCode_configUnset = await gitCommandManager.GitConfigUnset(executionContext, targetPath, configKey);
|
||||
if (exitCode_configUnset != 0)
|
||||
{
|
||||
// if unable to use git.exe unset http.extraheader, http.proxy or core.askpass, modify git config file on disk. make sure we don't left credential.
|
||||
if (!string.IsNullOrEmpty(configValue))
|
||||
{
|
||||
executionContext.Warning("An unsuccessful attempt was made using git command line to remove \"http.extraheader\" from the git config. Attempting to modify the git config file directly to remove the credential.");
|
||||
string gitConfig = Path.Combine(targetPath, ".git/config");
|
||||
if (File.Exists(gitConfig))
|
||||
{
|
||||
string gitConfigContent = File.ReadAllText(Path.Combine(targetPath, ".git", "config"));
|
||||
if (gitConfigContent.Contains(configKey))
|
||||
{
|
||||
string setting = $"extraheader = {configValue}";
|
||||
gitConfigContent = Regex.Replace(gitConfigContent, setting, string.Empty, RegexOptions.IgnoreCase);
|
||||
|
||||
setting = $"proxy = {configValue}";
|
||||
gitConfigContent = Regex.Replace(gitConfigContent, setting, string.Empty, RegexOptions.IgnoreCase);
|
||||
|
||||
setting = $"askpass = {configValue}";
|
||||
gitConfigContent = Regex.Replace(gitConfigContent, setting, string.Empty, RegexOptions.IgnoreCase);
|
||||
|
||||
File.WriteAllText(gitConfig, gitConfigContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
executionContext.Warning($"Unable to remove \"{configKey}\" from the git config. To remove the credential, execute \"git config --unset - all {configKey}\" from the repository root \"{targetPath}\".");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsPullRequest(string sourceBranch)
|
||||
{
|
||||
return !string.IsNullOrEmpty(sourceBranch) &&
|
||||
(sourceBranch.StartsWith(_pullRefsPrefix, StringComparison.OrdinalIgnoreCase) ||
|
||||
sourceBranch.StartsWith(_remotePullRefsPrefix, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private string GetRemoteRefName(string refName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(refName))
|
||||
{
|
||||
// If the refName is empty return the remote name for master
|
||||
refName = _remoteRefsPrefix + "master";
|
||||
}
|
||||
else if (refName.Equals("master", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// If the refName is master return the remote name for master
|
||||
refName = _remoteRefsPrefix + refName;
|
||||
}
|
||||
else if (refName.StartsWith(_refsPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// If the refName is refs/heads change it to the remote version of the name
|
||||
refName = _remoteRefsPrefix + refName.Substring(_refsPrefix.Length);
|
||||
}
|
||||
else if (refName.StartsWith(_pullRefsPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// If the refName is refs/pull change it to the remote version of the name
|
||||
refName = refName.Replace(_pullRefsPrefix, _remotePullRefsPrefix);
|
||||
}
|
||||
|
||||
return refName;
|
||||
}
|
||||
}
|
||||
}
|
||||
175
src/Runner.Plugins/Repository/v1.0/RepositoryPlugin.cs
Normal file
175
src/Runner.Plugins/Repository/v1.0/RepositoryPlugin.cs
Normal file
@@ -0,0 +1,175 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Runner.Sdk;
|
||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
using System.IO;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using System.Text.RegularExpressions;
|
||||
using GitHub.DistributedTask.Pipelines.Expressions;
|
||||
using System.Text;
|
||||
|
||||
namespace GitHub.Runner.Plugins.Repository.v1_0
|
||||
{
|
||||
public class CheckoutTask : IRunnerActionPlugin
|
||||
{
|
||||
private readonly Regex _validSha1 = new Regex(@"\b[0-9a-f]{40}\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled, TimeSpan.FromSeconds(2));
|
||||
|
||||
public async Task RunAsync(RunnerActionPluginExecutionContext executionContext, CancellationToken token)
|
||||
{
|
||||
string runnerWorkspace = executionContext.GetRunnerContext("workspace");
|
||||
ArgUtil.Directory(runnerWorkspace, nameof(runnerWorkspace));
|
||||
string tempDirectory = executionContext.GetRunnerContext("temp");
|
||||
ArgUtil.Directory(tempDirectory, nameof(tempDirectory));
|
||||
|
||||
var repoFullName = executionContext.GetInput(Pipelines.PipelineConstants.CheckoutTaskInputs.Repository);
|
||||
if (string.IsNullOrEmpty(repoFullName))
|
||||
{
|
||||
repoFullName = executionContext.GetGitHubContext("repository");
|
||||
}
|
||||
|
||||
var repoFullNameSplit = repoFullName.Split("/", StringSplitOptions.RemoveEmptyEntries);
|
||||
if (repoFullNameSplit.Length != 2)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(repoFullName);
|
||||
}
|
||||
|
||||
string expectRepoPath;
|
||||
var path = executionContext.GetInput(Pipelines.PipelineConstants.CheckoutTaskInputs.Path);
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
expectRepoPath = IOUtil.ResolvePath(runnerWorkspace, path);
|
||||
if (!expectRepoPath.StartsWith(runnerWorkspace.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar))
|
||||
{
|
||||
throw new ArgumentException($"Input path '{path}' should resolve to a directory under '{runnerWorkspace}', current resolved path '{expectRepoPath}'.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// When repository doesn't has path set, default to sources directory 1/repoName
|
||||
expectRepoPath = Path.Combine(runnerWorkspace, repoFullNameSplit[1]);
|
||||
}
|
||||
|
||||
var workspaceRepo = executionContext.GetGitHubContext("repository");
|
||||
// for self repository, we need to let the worker knows where it is after checkout.
|
||||
if (string.Equals(workspaceRepo, repoFullName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var workspaceRepoPath = executionContext.GetGitHubContext("workspace");
|
||||
|
||||
executionContext.Debug($"Repository requires to be placed at '{expectRepoPath}', current location is '{workspaceRepoPath}'");
|
||||
if (!string.Equals(workspaceRepoPath.Trim(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), expectRepoPath.Trim(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), IOUtil.FilePathStringComparison))
|
||||
{
|
||||
executionContext.Output($"Repository is current at '{workspaceRepoPath}', move to '{expectRepoPath}'.");
|
||||
var count = 1;
|
||||
var staging = Path.Combine(tempDirectory, $"_{count}");
|
||||
while (Directory.Exists(staging))
|
||||
{
|
||||
count++;
|
||||
staging = Path.Combine(tempDirectory, $"_{count}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
executionContext.Debug($"Move existing repository '{workspaceRepoPath}' to '{expectRepoPath}' via staging directory '{staging}'.");
|
||||
IOUtil.MoveDirectory(workspaceRepoPath, expectRepoPath, staging, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
executionContext.Debug("Catch exception during repository move.");
|
||||
executionContext.Debug(ex.ToString());
|
||||
executionContext.Warning("Unable move and reuse existing repository to required location.");
|
||||
IOUtil.DeleteDirectory(expectRepoPath, CancellationToken.None);
|
||||
}
|
||||
|
||||
executionContext.Output($"Repository will locate at '{expectRepoPath}'.");
|
||||
}
|
||||
|
||||
executionContext.Debug($"Update workspace repository location.");
|
||||
executionContext.SetRepositoryPath(repoFullName, expectRepoPath, true);
|
||||
}
|
||||
|
||||
string sourceBranch;
|
||||
string sourceVersion;
|
||||
string refInput = executionContext.GetInput(Pipelines.PipelineConstants.CheckoutTaskInputs.Ref);
|
||||
if (string.IsNullOrEmpty(refInput))
|
||||
{
|
||||
sourceBranch = executionContext.GetGitHubContext("ref");
|
||||
sourceVersion = executionContext.GetGitHubContext("sha");
|
||||
}
|
||||
else
|
||||
{
|
||||
sourceBranch = refInput;
|
||||
sourceVersion = executionContext.GetInput(Pipelines.PipelineConstants.CheckoutTaskInputs.Version); // version get removed when checkout move to repo in the graph
|
||||
if (string.IsNullOrEmpty(sourceVersion) && RegexUtility.IsMatch(sourceBranch, WellKnownRegularExpressions.SHA1))
|
||||
{
|
||||
sourceVersion = sourceBranch;
|
||||
|
||||
// If Ref is a SHA and the repo is self, we need to use github.ref as source branch since it might be refs/pull/*
|
||||
if (string.Equals(workspaceRepo, repoFullName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
sourceBranch = executionContext.GetGitHubContext("ref");
|
||||
}
|
||||
else
|
||||
{
|
||||
sourceBranch = "refs/heads/master";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool clean = StringUtil.ConvertToBoolean(executionContext.GetInput(Pipelines.PipelineConstants.CheckoutTaskInputs.Clean), true);
|
||||
string submoduleInput = executionContext.GetInput(Pipelines.PipelineConstants.CheckoutTaskInputs.Submodules);
|
||||
|
||||
int fetchDepth = 0;
|
||||
if (!int.TryParse(executionContext.GetInput("fetch-depth"), out fetchDepth) || fetchDepth < 0)
|
||||
{
|
||||
fetchDepth = 0;
|
||||
}
|
||||
|
||||
bool gitLfsSupport = StringUtil.ConvertToBoolean(executionContext.GetInput(Pipelines.PipelineConstants.CheckoutTaskInputs.Lfs));
|
||||
string accessToken = executionContext.GetInput(Pipelines.PipelineConstants.CheckoutTaskInputs.Token);
|
||||
if (string.IsNullOrEmpty(accessToken))
|
||||
{
|
||||
accessToken = executionContext.GetGitHubContext("token");
|
||||
}
|
||||
|
||||
// register problem matcher
|
||||
string problemMatcher = @"
|
||||
{
|
||||
""problemMatcher"": [
|
||||
{
|
||||
""owner"": ""checkout-git"",
|
||||
""pattern"": [
|
||||
{
|
||||
""regexp"": ""^fatal: (.*)$"",
|
||||
""message"": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}";
|
||||
string matcherFile = Path.Combine(tempDirectory, $"git_{Guid.NewGuid()}.json");
|
||||
File.WriteAllText(matcherFile, problemMatcher, new UTF8Encoding(false));
|
||||
executionContext.Output($"##[add-matcher]{matcherFile}");
|
||||
try
|
||||
{
|
||||
await new GitHubSourceProvider().GetSourceAsync(executionContext,
|
||||
expectRepoPath,
|
||||
repoFullName,
|
||||
sourceBranch,
|
||||
sourceVersion,
|
||||
clean,
|
||||
submoduleInput,
|
||||
fetchDepth,
|
||||
gitLfsSupport,
|
||||
accessToken,
|
||||
token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
executionContext.Output("##[remove-matcher owner=checkout-git]");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
740
src/Runner.Plugins/Repository/v1.1/GitSourceProvider.cs
Normal file
740
src/Runner.Plugins/Repository/v1.1/GitSourceProvider.cs
Normal file
@@ -0,0 +1,740 @@
|
||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Text;
|
||||
using System.Diagnostics;
|
||||
using GitHub.Runner.Sdk;
|
||||
using System.Linq;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Services.WebApi;
|
||||
|
||||
namespace GitHub.Runner.Plugins.Repository.v1_1
|
||||
{
|
||||
public sealed class GitHubSourceProvider
|
||||
{
|
||||
// refs prefix
|
||||
private const string _refsPrefix = "refs/heads/";
|
||||
private const string _remoteRefsPrefix = "refs/remotes/origin/";
|
||||
private const string _pullRefsPrefix = "refs/pull/";
|
||||
private const string _remotePullRefsPrefix = "refs/remotes/pull/";
|
||||
private const string _tagRefsPrefix = "refs/tags/";
|
||||
|
||||
// min git version that support add extra auth header.
|
||||
private Version _minGitVersionSupportAuthHeader = new Version(2, 9);
|
||||
|
||||
#if OS_WINDOWS
|
||||
// min git version that support override sslBackend setting.
|
||||
private Version _minGitVersionSupportSSLBackendOverride = new Version(2, 14, 2);
|
||||
#endif
|
||||
|
||||
// min git-lfs version that support add extra auth header.
|
||||
private Version _minGitLfsVersionSupportAuthHeader = new Version(2, 1);
|
||||
|
||||
public static string ProblemMatcher => @"
|
||||
{
|
||||
""problemMatcher"": [
|
||||
{
|
||||
""owner"": ""checkout-git"",
|
||||
""pattern"": [
|
||||
{
|
||||
""regexp"": ""^(fatal|error): (.*)$"",
|
||||
""message"": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}";
|
||||
|
||||
public async Task GetSourceAsync(
|
||||
RunnerActionPluginExecutionContext executionContext,
|
||||
string repositoryPath,
|
||||
string repoFullName,
|
||||
string sourceBranch,
|
||||
string sourceVersion,
|
||||
bool clean,
|
||||
string submoduleInput,
|
||||
int fetchDepth,
|
||||
bool gitLfsSupport,
|
||||
string accessToken,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Validate args.
|
||||
ArgUtil.NotNull(executionContext, nameof(executionContext));
|
||||
Dictionary<string, string> configModifications = new Dictionary<string, string>();
|
||||
Uri proxyUrlWithCred = null;
|
||||
string proxyUrlWithCredString = null;
|
||||
bool useSelfSignedCACert = false;
|
||||
bool useClientCert = false;
|
||||
string clientCertPrivateKeyAskPassFile = null;
|
||||
bool acceptUntrustedCerts = false;
|
||||
|
||||
executionContext.Output($"Syncing repository: {repoFullName}");
|
||||
Uri repositoryUrl = new Uri($"https://github.com/{repoFullName}");
|
||||
if (!repositoryUrl.IsAbsoluteUri)
|
||||
{
|
||||
throw new InvalidOperationException("Repository url need to be an absolute uri.");
|
||||
}
|
||||
|
||||
string targetPath = repositoryPath;
|
||||
|
||||
// input Submodules can be ['', true, false, recursive]
|
||||
// '' or false indicate don't checkout submodules
|
||||
// true indicate checkout top level submodules
|
||||
// recursive indicate checkout submodules recursively
|
||||
bool checkoutSubmodules = false;
|
||||
bool checkoutNestedSubmodules = false;
|
||||
if (!string.IsNullOrEmpty(submoduleInput))
|
||||
{
|
||||
if (string.Equals(submoduleInput, Pipelines.PipelineConstants.CheckoutTaskInputs.SubmodulesOptions.Recursive, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
checkoutSubmodules = true;
|
||||
checkoutNestedSubmodules = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
checkoutSubmodules = StringUtil.ConvertToBoolean(submoduleInput);
|
||||
}
|
||||
}
|
||||
|
||||
var runnerCert = executionContext.GetCertConfiguration();
|
||||
acceptUntrustedCerts = runnerCert?.SkipServerCertificateValidation ?? false;
|
||||
|
||||
executionContext.Debug($"repository url={repositoryUrl}");
|
||||
executionContext.Debug($"targetPath={targetPath}");
|
||||
executionContext.Debug($"sourceBranch={sourceBranch}");
|
||||
executionContext.Debug($"sourceVersion={sourceVersion}");
|
||||
executionContext.Debug($"clean={clean}");
|
||||
executionContext.Debug($"checkoutSubmodules={checkoutSubmodules}");
|
||||
executionContext.Debug($"checkoutNestedSubmodules={checkoutNestedSubmodules}");
|
||||
executionContext.Debug($"fetchDepth={fetchDepth}");
|
||||
executionContext.Debug($"gitLfsSupport={gitLfsSupport}");
|
||||
executionContext.Debug($"acceptUntrustedCerts={acceptUntrustedCerts}");
|
||||
|
||||
#if OS_WINDOWS
|
||||
bool schannelSslBackend = StringUtil.ConvertToBoolean(executionContext.GetRunnerContext("gituseschannel"));
|
||||
executionContext.Debug($"schannelSslBackend={schannelSslBackend}");
|
||||
#endif
|
||||
|
||||
// Initialize git command manager with additional environment variables.
|
||||
Dictionary<string, string> gitEnv = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Disable git prompt
|
||||
gitEnv["GIT_TERMINAL_PROMPT"] = "0";
|
||||
|
||||
// Disable prompting for git credential manager
|
||||
gitEnv["GCM_INTERACTIVE"] = "Never";
|
||||
|
||||
// Git-lfs will try to pull down asset if any of the local/user/system setting exist.
|
||||
// If customer didn't enable `LFS` in their pipeline definition, we will use ENV to disable LFS fetch/checkout.
|
||||
if (!gitLfsSupport)
|
||||
{
|
||||
gitEnv["GIT_LFS_SKIP_SMUDGE"] = "1";
|
||||
}
|
||||
|
||||
// Add the public variables.
|
||||
foreach (var variable in executionContext.Variables)
|
||||
{
|
||||
// Add the variable using the formatted name.
|
||||
string formattedKey = (variable.Key ?? string.Empty).Replace('.', '_').Replace(' ', '_').ToUpperInvariant();
|
||||
gitEnv[formattedKey] = variable.Value?.Value ?? string.Empty;
|
||||
}
|
||||
|
||||
GitCliManager gitCommandManager = new GitCliManager(gitEnv);
|
||||
await gitCommandManager.LoadGitExecutionInfo(executionContext);
|
||||
|
||||
// Make sure the build machine met all requirements for the git repository
|
||||
// For now, the requirement we have are:
|
||||
// 1. git version greater than 2.9 since we need to use auth header.
|
||||
// 2. git-lfs version greater than 2.1 since we need to use auth header.
|
||||
// 3. git version greater than 2.14.2 if use SChannel for SSL backend (Windows only)
|
||||
RequirementCheck(executionContext, gitCommandManager, gitLfsSupport);
|
||||
|
||||
// prepare credentail embedded urls
|
||||
var runnerProxy = executionContext.GetProxyConfiguration();
|
||||
if (runnerProxy != null && !string.IsNullOrEmpty(runnerProxy.ProxyAddress) && !runnerProxy.WebProxy.IsBypassed(repositoryUrl))
|
||||
{
|
||||
proxyUrlWithCred = UrlUtil.GetCredentialEmbeddedUrl(new Uri(runnerProxy.ProxyAddress), runnerProxy.ProxyUsername, runnerProxy.ProxyPassword);
|
||||
|
||||
// uri.absoluteuri will not contains port info if the scheme is http/https and the port is 80/443
|
||||
// however, git.exe always require you provide port info, if nothing passed in, it will use 1080 as default
|
||||
// as result, we need prefer the uri.originalstring over uri.absoluteuri.
|
||||
proxyUrlWithCredString = proxyUrlWithCred.OriginalString;
|
||||
}
|
||||
|
||||
// prepare askpass for client cert private key, if the repository's endpoint url match the runner config url
|
||||
var systemConnection = executionContext.Endpoints.Single(x => string.Equals(x.Name, WellKnownServiceEndpointNames.SystemVssConnection, StringComparison.OrdinalIgnoreCase));
|
||||
if (runnerCert != null && Uri.Compare(repositoryUrl, systemConnection.Url, UriComponents.SchemeAndServer, UriFormat.Unescaped, StringComparison.OrdinalIgnoreCase) == 0)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(runnerCert.CACertificateFile))
|
||||
{
|
||||
useSelfSignedCACert = true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(runnerCert.ClientCertificateFile) &&
|
||||
!string.IsNullOrEmpty(runnerCert.ClientCertificatePrivateKeyFile))
|
||||
{
|
||||
useClientCert = true;
|
||||
|
||||
// prepare askpass for client cert password
|
||||
if (!string.IsNullOrEmpty(runnerCert.ClientCertificatePassword))
|
||||
{
|
||||
clientCertPrivateKeyAskPassFile = Path.Combine(executionContext.GetRunnerContext("temp"), $"{Guid.NewGuid()}.sh");
|
||||
List<string> askPass = new List<string>();
|
||||
askPass.Add("#!/bin/sh");
|
||||
askPass.Add($"echo \"{runnerCert.ClientCertificatePassword}\"");
|
||||
File.WriteAllLines(clientCertPrivateKeyAskPassFile, askPass);
|
||||
|
||||
#if !OS_WINDOWS
|
||||
string toolPath = WhichUtil.Which("chmod", true);
|
||||
string argLine = $"775 {clientCertPrivateKeyAskPassFile}";
|
||||
executionContext.Command($"chmod {argLine}");
|
||||
|
||||
var processInvoker = new ProcessInvoker(executionContext);
|
||||
processInvoker.OutputDataReceived += (object sender, ProcessDataReceivedEventArgs args) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(args.Data))
|
||||
{
|
||||
executionContext.Output(args.Data);
|
||||
}
|
||||
};
|
||||
processInvoker.ErrorDataReceived += (object sender, ProcessDataReceivedEventArgs args) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(args.Data))
|
||||
{
|
||||
executionContext.Output(args.Data);
|
||||
}
|
||||
};
|
||||
|
||||
string workingDirectory = executionContext.GetRunnerContext("workspace");
|
||||
await processInvoker.ExecuteAsync(workingDirectory, toolPath, argLine, null, true, CancellationToken.None);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check the current contents of the root folder to see if there is already a repo
|
||||
// If there is a repo, see if it matches the one we are expecting to be there based on the remote fetch url
|
||||
// if the repo is not what we expect, remove the folder
|
||||
if (!await IsRepositoryOriginUrlMatch(executionContext, gitCommandManager, targetPath, repositoryUrl))
|
||||
{
|
||||
// Delete source folder
|
||||
IOUtil.DeleteDirectory(targetPath, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// delete the index.lock file left by previous canceled build or any operation cause git.exe crash last time.
|
||||
string lockFile = Path.Combine(targetPath, ".git\\index.lock");
|
||||
if (File.Exists(lockFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(lockFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
executionContext.Debug($"Unable to delete the index.lock file: {lockFile}");
|
||||
executionContext.Debug(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
// delete the shallow.lock file left by previous canceled build or any operation cause git.exe crash last time.
|
||||
string shallowLockFile = Path.Combine(targetPath, ".git\\shallow.lock");
|
||||
if (File.Exists(shallowLockFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(shallowLockFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
executionContext.Debug($"Unable to delete the shallow.lock file: {shallowLockFile}");
|
||||
executionContext.Debug(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
// When repo.clean is selected for a git repo, execute git clean -ffdx and git reset --hard HEAD on the current repo.
|
||||
// This will help us save the time to reclone the entire repo.
|
||||
// If any git commands exit with non-zero return code or any exception happened during git.exe invoke, fall back to delete the repo folder.
|
||||
if (clean)
|
||||
{
|
||||
Boolean softCleanSucceed = true;
|
||||
|
||||
// git clean -ffdx
|
||||
int exitCode_clean = await gitCommandManager.GitClean(executionContext, targetPath);
|
||||
if (exitCode_clean != 0)
|
||||
{
|
||||
executionContext.Debug($"'git clean -ffdx' failed with exit code {exitCode_clean}, this normally caused by:\n 1) Path too long\n 2) Permission issue\n 3) File in use\nFor futher investigation, manually run 'git clean -ffdx' on repo root: {targetPath} after each build.");
|
||||
softCleanSucceed = false;
|
||||
}
|
||||
|
||||
// git reset --hard HEAD
|
||||
if (softCleanSucceed)
|
||||
{
|
||||
int exitCode_reset = await gitCommandManager.GitReset(executionContext, targetPath);
|
||||
if (exitCode_reset != 0)
|
||||
{
|
||||
executionContext.Debug($"'git reset --hard HEAD' failed with exit code {exitCode_reset}\nFor futher investigation, manually run 'git reset --hard HEAD' on repo root: {targetPath} after each build.");
|
||||
softCleanSucceed = false;
|
||||
}
|
||||
}
|
||||
|
||||
// git clean -ffdx and git reset --hard HEAD for each submodule
|
||||
if (checkoutSubmodules)
|
||||
{
|
||||
if (softCleanSucceed)
|
||||
{
|
||||
int exitCode_submoduleclean = await gitCommandManager.GitSubmoduleClean(executionContext, targetPath);
|
||||
if (exitCode_submoduleclean != 0)
|
||||
{
|
||||
executionContext.Debug($"'git submodule foreach git clean -ffdx' failed with exit code {exitCode_submoduleclean}\nFor futher investigation, manually run 'git submodule foreach git clean -ffdx' on repo root: {targetPath} after each build.");
|
||||
softCleanSucceed = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (softCleanSucceed)
|
||||
{
|
||||
int exitCode_submodulereset = await gitCommandManager.GitSubmoduleReset(executionContext, targetPath);
|
||||
if (exitCode_submodulereset != 0)
|
||||
{
|
||||
executionContext.Debug($"'git submodule foreach git reset --hard HEAD' failed with exit code {exitCode_submodulereset}\nFor futher investigation, manually run 'git submodule foreach git reset --hard HEAD' on repo root: {targetPath} after each build.");
|
||||
softCleanSucceed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!softCleanSucceed)
|
||||
{
|
||||
//fall back
|
||||
executionContext.Warning("Unable to run \"git clean -ffdx\" and \"git reset --hard HEAD\" successfully, delete source folder instead.");
|
||||
IOUtil.DeleteDirectory(targetPath, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if the folder is missing, create it
|
||||
if (!Directory.Exists(targetPath))
|
||||
{
|
||||
Directory.CreateDirectory(targetPath);
|
||||
}
|
||||
|
||||
// if the folder contains a .git folder, it means the folder contains a git repo that matches the remote url and in a clean state.
|
||||
// we will run git fetch to update the repo.
|
||||
if (!Directory.Exists(Path.Combine(targetPath, ".git")))
|
||||
{
|
||||
// init git repository
|
||||
int exitCode_init = await gitCommandManager.GitInit(executionContext, targetPath);
|
||||
if (exitCode_init != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Unable to use git.exe init repository under {targetPath}, 'git init' failed with exit code: {exitCode_init}");
|
||||
}
|
||||
|
||||
int exitCode_addremote = await gitCommandManager.GitRemoteAdd(executionContext, targetPath, "origin", repositoryUrl.AbsoluteUri);
|
||||
if (exitCode_addremote != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Unable to use git.exe add remote 'origin', 'git remote add' failed with exit code: {exitCode_addremote}");
|
||||
}
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// disable git auto gc
|
||||
int exitCode_disableGC = await gitCommandManager.GitDisableAutoGC(executionContext, targetPath);
|
||||
if (exitCode_disableGC != 0)
|
||||
{
|
||||
executionContext.Warning("Unable turn off git auto garbage collection, git fetch operation may trigger auto garbage collection which will affect the performance of fetching.");
|
||||
}
|
||||
|
||||
// always remove any possible left extraheader setting from git config.
|
||||
if (await gitCommandManager.GitConfigExist(executionContext, targetPath, $"http.{repositoryUrl.AbsoluteUri}.extraheader"))
|
||||
{
|
||||
executionContext.Debug("Remove any extraheader setting from git config.");
|
||||
await RemoveGitConfig(executionContext, gitCommandManager, targetPath, $"http.{repositoryUrl.AbsoluteUri}.extraheader", string.Empty);
|
||||
}
|
||||
|
||||
// always remove any possible left proxy setting from git config, the proxy setting may contains credential
|
||||
if (await gitCommandManager.GitConfigExist(executionContext, targetPath, $"http.proxy"))
|
||||
{
|
||||
executionContext.Debug("Remove any proxy setting from git config.");
|
||||
await RemoveGitConfig(executionContext, gitCommandManager, targetPath, $"http.proxy", string.Empty);
|
||||
}
|
||||
|
||||
List<string> additionalFetchArgs = new List<string>();
|
||||
List<string> additionalLfsFetchArgs = new List<string>();
|
||||
|
||||
// Add http.https://github.com.extraheader=... to gitconfig
|
||||
// accessToken as basic auth header to handle any auth challenge from github.com
|
||||
string configKey = $"http.https://github.com/.extraheader";
|
||||
string configValue = $"\"AUTHORIZATION: {GenerateBasicAuthHeader(executionContext, accessToken)}\"";
|
||||
configModifications[configKey] = configValue.Trim('\"');
|
||||
int exitCode_config = await gitCommandManager.GitConfig(executionContext, targetPath, configKey, configValue);
|
||||
if (exitCode_config != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Git config failed with exit code: {exitCode_config}");
|
||||
}
|
||||
|
||||
// Prepare proxy config for fetch.
|
||||
if (runnerProxy != null && !string.IsNullOrEmpty(runnerProxy.ProxyAddress) && !runnerProxy.WebProxy.IsBypassed(repositoryUrl))
|
||||
{
|
||||
executionContext.Debug($"Config proxy server '{runnerProxy.ProxyAddress}' for git fetch.");
|
||||
ArgUtil.NotNullOrEmpty(proxyUrlWithCredString, nameof(proxyUrlWithCredString));
|
||||
additionalFetchArgs.Add($"-c http.proxy=\"{proxyUrlWithCredString}\"");
|
||||
additionalLfsFetchArgs.Add($"-c http.proxy=\"{proxyUrlWithCredString}\"");
|
||||
}
|
||||
|
||||
// Prepare ignore ssl cert error config for fetch.
|
||||
if (acceptUntrustedCerts)
|
||||
{
|
||||
additionalFetchArgs.Add($"-c http.sslVerify=false");
|
||||
additionalLfsFetchArgs.Add($"-c http.sslVerify=false");
|
||||
}
|
||||
|
||||
// Prepare self-signed CA cert config for fetch from server.
|
||||
if (useSelfSignedCACert)
|
||||
{
|
||||
executionContext.Debug($"Use self-signed certificate '{runnerCert.CACertificateFile}' for git fetch.");
|
||||
additionalFetchArgs.Add($"-c http.sslcainfo=\"{runnerCert.CACertificateFile}\"");
|
||||
additionalLfsFetchArgs.Add($"-c http.sslcainfo=\"{runnerCert.CACertificateFile}\"");
|
||||
}
|
||||
|
||||
// Prepare client cert config for fetch from server.
|
||||
if (useClientCert)
|
||||
{
|
||||
executionContext.Debug($"Use client certificate '{runnerCert.ClientCertificateFile}' for git fetch.");
|
||||
|
||||
if (!string.IsNullOrEmpty(clientCertPrivateKeyAskPassFile))
|
||||
{
|
||||
additionalFetchArgs.Add($"-c http.sslcert=\"{runnerCert.ClientCertificateFile}\" -c http.sslkey=\"{runnerCert.ClientCertificatePrivateKeyFile}\" -c http.sslCertPasswordProtected=true -c core.askpass=\"{clientCertPrivateKeyAskPassFile}\"");
|
||||
additionalLfsFetchArgs.Add($"-c http.sslcert=\"{runnerCert.ClientCertificateFile}\" -c http.sslkey=\"{runnerCert.ClientCertificatePrivateKeyFile}\" -c http.sslCertPasswordProtected=true -c core.askpass=\"{clientCertPrivateKeyAskPassFile}\"");
|
||||
}
|
||||
else
|
||||
{
|
||||
additionalFetchArgs.Add($"-c http.sslcert=\"{runnerCert.ClientCertificateFile}\" -c http.sslkey=\"{runnerCert.ClientCertificatePrivateKeyFile}\"");
|
||||
additionalLfsFetchArgs.Add($"-c http.sslcert=\"{runnerCert.ClientCertificateFile}\" -c http.sslkey=\"{runnerCert.ClientCertificatePrivateKeyFile}\"");
|
||||
}
|
||||
}
|
||||
|
||||
#if OS_WINDOWS
|
||||
if (schannelSslBackend)
|
||||
{
|
||||
executionContext.Debug("Use SChannel SslBackend for git fetch.");
|
||||
additionalFetchArgs.Add("-c http.sslbackend=\"schannel\"");
|
||||
additionalLfsFetchArgs.Add("-c http.sslbackend=\"schannel\"");
|
||||
}
|
||||
#endif
|
||||
// Prepare gitlfs url for fetch and checkout
|
||||
if (gitLfsSupport)
|
||||
{
|
||||
// Initialize git lfs by execute 'git lfs install'
|
||||
executionContext.Debug("Setup the local Git hooks for Git LFS.");
|
||||
int exitCode_lfsInstall = await gitCommandManager.GitLFSInstall(executionContext, targetPath);
|
||||
if (exitCode_lfsInstall != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Git-lfs installation failed with exit code: {exitCode_lfsInstall}");
|
||||
}
|
||||
}
|
||||
|
||||
List<string> additionalFetchSpecs = new List<string>();
|
||||
additionalFetchSpecs.Add("+refs/heads/*:refs/remotes/origin/*");
|
||||
|
||||
if (IsPullRequest(sourceBranch))
|
||||
{
|
||||
additionalFetchSpecs.Add($"+{sourceBranch}:{GetRemoteRefName(sourceBranch)}");
|
||||
}
|
||||
|
||||
int exitCode_fetch = await gitCommandManager.GitFetch(executionContext, targetPath, "origin", fetchDepth, additionalFetchSpecs, string.Join(" ", additionalFetchArgs), cancellationToken);
|
||||
if (exitCode_fetch != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Git fetch failed with exit code: {exitCode_fetch}");
|
||||
}
|
||||
|
||||
// Checkout
|
||||
// sourceToBuild is used for checkout
|
||||
// if sourceBranch is a PR branch or sourceVersion is null, make sure branch name is a remote branch. we need checkout to detached head.
|
||||
// (change refs/heads to refs/remotes/origin, refs/pull to refs/remotes/pull, or leave it as it when the branch name doesn't contain refs/...)
|
||||
// if sourceVersion provide, just use that for checkout, since when you checkout a commit, it will end up in detached head.
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string sourcesToBuild;
|
||||
if (IsPullRequest(sourceBranch) || string.IsNullOrEmpty(sourceVersion))
|
||||
{
|
||||
sourcesToBuild = GetRemoteRefName(sourceBranch);
|
||||
}
|
||||
else
|
||||
{
|
||||
sourcesToBuild = sourceVersion;
|
||||
}
|
||||
|
||||
// fetch lfs object upfront, this will avoid fetch lfs object during checkout which cause checkout taking forever
|
||||
// since checkout will fetch lfs object 1 at a time, while git lfs fetch will fetch lfs object in parallel.
|
||||
if (gitLfsSupport)
|
||||
{
|
||||
int exitCode_lfsFetch = await gitCommandManager.GitLFSFetch(executionContext, targetPath, "origin", sourcesToBuild, string.Join(" ", additionalLfsFetchArgs), cancellationToken);
|
||||
if (exitCode_lfsFetch != 0)
|
||||
{
|
||||
// local repository is shallow repository, lfs fetch may fail due to lack of commits history.
|
||||
// this will happen when the checkout commit is older than tip -> fetchDepth
|
||||
if (fetchDepth > 0)
|
||||
{
|
||||
executionContext.Warning($"Git lfs fetch failed on shallow repository, this might because of git fetch with depth '{fetchDepth}' doesn't include the lfs fetch commit '{sourcesToBuild}'.");
|
||||
}
|
||||
|
||||
// git lfs fetch failed, get lfs log, the log is critical for debug.
|
||||
int exitCode_lfsLogs = await gitCommandManager.GitLFSLogs(executionContext, targetPath);
|
||||
throw new InvalidOperationException($"Git lfs fetch failed with exit code: {exitCode_lfsFetch}. Git lfs logs returned with exit code: {exitCode_lfsLogs}.");
|
||||
}
|
||||
}
|
||||
|
||||
// Finally, checkout the sourcesToBuild (if we didn't find a valid git object this will throw)
|
||||
int exitCode_checkout = await gitCommandManager.GitCheckout(executionContext, targetPath, sourcesToBuild, cancellationToken);
|
||||
if (exitCode_checkout != 0)
|
||||
{
|
||||
// local repository is shallow repository, checkout may fail due to lack of commits history.
|
||||
// this will happen when the checkout commit is older than tip -> fetchDepth
|
||||
if (fetchDepth > 0)
|
||||
{
|
||||
executionContext.Warning($"Git checkout failed on shallow repository, this might because of git fetch with depth '{fetchDepth}' doesn't include the checkout commit '{sourcesToBuild}'.");
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Git checkout failed with exit code: {exitCode_checkout}");
|
||||
}
|
||||
|
||||
// Submodule update
|
||||
if (checkoutSubmodules)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
int exitCode_submoduleSync = await gitCommandManager.GitSubmoduleSync(executionContext, targetPath, checkoutNestedSubmodules, cancellationToken);
|
||||
if (exitCode_submoduleSync != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Git submodule sync failed with exit code: {exitCode_submoduleSync}");
|
||||
}
|
||||
|
||||
List<string> additionalSubmoduleUpdateArgs = new List<string>();
|
||||
|
||||
// Prepare proxy config for submodule update.
|
||||
if (runnerProxy != null && !string.IsNullOrEmpty(runnerProxy.ProxyAddress) && !runnerProxy.WebProxy.IsBypassed(repositoryUrl))
|
||||
{
|
||||
executionContext.Debug($"Config proxy server '{runnerProxy.ProxyAddress}' for git submodule update.");
|
||||
ArgUtil.NotNullOrEmpty(proxyUrlWithCredString, nameof(proxyUrlWithCredString));
|
||||
additionalSubmoduleUpdateArgs.Add($"-c http.proxy=\"{proxyUrlWithCredString}\"");
|
||||
}
|
||||
|
||||
// Prepare ignore ssl cert error config for fetch.
|
||||
if (acceptUntrustedCerts)
|
||||
{
|
||||
additionalSubmoduleUpdateArgs.Add($"-c http.sslVerify=false");
|
||||
}
|
||||
|
||||
// Prepare self-signed CA cert config for submodule update.
|
||||
if (useSelfSignedCACert)
|
||||
{
|
||||
executionContext.Debug($"Use self-signed CA certificate '{runnerCert.CACertificateFile}' for git submodule update.");
|
||||
string authorityUrl = repositoryUrl.AbsoluteUri.Replace(repositoryUrl.PathAndQuery, string.Empty);
|
||||
additionalSubmoduleUpdateArgs.Add($"-c http.{authorityUrl}.sslcainfo=\"{runnerCert.CACertificateFile}\"");
|
||||
}
|
||||
|
||||
// Prepare client cert config for submodule update.
|
||||
if (useClientCert)
|
||||
{
|
||||
executionContext.Debug($"Use client certificate '{runnerCert.ClientCertificateFile}' for git submodule update.");
|
||||
string authorityUrl = repositoryUrl.AbsoluteUri.Replace(repositoryUrl.PathAndQuery, string.Empty);
|
||||
|
||||
if (!string.IsNullOrEmpty(clientCertPrivateKeyAskPassFile))
|
||||
{
|
||||
additionalSubmoduleUpdateArgs.Add($"-c http.{authorityUrl}.sslcert=\"{runnerCert.ClientCertificateFile}\" -c http.{authorityUrl}.sslkey=\"{runnerCert.ClientCertificatePrivateKeyFile}\" -c http.{authorityUrl}.sslCertPasswordProtected=true -c core.askpass=\"{clientCertPrivateKeyAskPassFile}\"");
|
||||
}
|
||||
else
|
||||
{
|
||||
additionalSubmoduleUpdateArgs.Add($"-c http.{authorityUrl}.sslcert=\"{runnerCert.ClientCertificateFile}\" -c http.{authorityUrl}.sslkey=\"{runnerCert.ClientCertificatePrivateKeyFile}\"");
|
||||
}
|
||||
}
|
||||
#if OS_WINDOWS
|
||||
if (schannelSslBackend)
|
||||
{
|
||||
executionContext.Debug("Use SChannel SslBackend for git submodule update.");
|
||||
additionalSubmoduleUpdateArgs.Add("-c http.sslbackend=\"schannel\"");
|
||||
}
|
||||
#endif
|
||||
|
||||
int exitCode_submoduleUpdate = await gitCommandManager.GitSubmoduleUpdate(executionContext, targetPath, fetchDepth, string.Join(" ", additionalSubmoduleUpdateArgs), checkoutNestedSubmodules, cancellationToken);
|
||||
if (exitCode_submoduleUpdate != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Git submodule update failed with exit code: {exitCode_submoduleUpdate}");
|
||||
}
|
||||
}
|
||||
|
||||
if (useClientCert && !string.IsNullOrEmpty(clientCertPrivateKeyAskPassFile))
|
||||
{
|
||||
executionContext.Debug("Remove git.sslkey askpass file.");
|
||||
IOUtil.DeleteFile(clientCertPrivateKeyAskPassFile);
|
||||
}
|
||||
|
||||
// Set intra-task variable for post job cleanup
|
||||
executionContext.SetIntraActionState("repositoryPath", targetPath);
|
||||
executionContext.SetIntraActionState("modifiedgitconfig", JsonUtility.ToString(configModifications.Keys));
|
||||
foreach (var config in configModifications)
|
||||
{
|
||||
executionContext.SetIntraActionState(config.Key, config.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CleanupAsync(RunnerActionPluginExecutionContext executionContext)
|
||||
{
|
||||
ArgUtil.NotNull(executionContext, nameof(executionContext));
|
||||
var repositoryPath = Environment.GetEnvironmentVariable("STATE_repositoryPath");
|
||||
ArgUtil.NotNullOrEmpty(repositoryPath, nameof(repositoryPath));
|
||||
executionContext.Output($"Cleanup cached git credential from {repositoryPath}.");
|
||||
|
||||
// Initialize git command manager
|
||||
GitCliManager gitCommandManager = new GitCliManager();
|
||||
await gitCommandManager.LoadGitExecutionInfo(executionContext);
|
||||
|
||||
executionContext.Debug("Remove any extraheader and proxy setting from git config.");
|
||||
var configKeys = JsonUtility.FromString<List<string>>(Environment.GetEnvironmentVariable("STATE_modifiedgitconfig"));
|
||||
if (configKeys?.Count > 0)
|
||||
{
|
||||
foreach (var config in configKeys)
|
||||
{
|
||||
var configValue = Environment.GetEnvironmentVariable($"STATE_{config}");
|
||||
if (!string.IsNullOrEmpty(configValue))
|
||||
{
|
||||
await RemoveGitConfig(executionContext, gitCommandManager, repositoryPath, config, configValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RequirementCheck(RunnerActionPluginExecutionContext executionContext, GitCliManager gitCommandManager, bool checkGitLfs)
|
||||
{
|
||||
// v2.9 git exist use auth header.
|
||||
gitCommandManager.EnsureGitVersion(_minGitVersionSupportAuthHeader, throwOnNotMatch: true);
|
||||
|
||||
#if OS_WINDOWS
|
||||
// check git version for SChannel SSLBackend (Windows Only)
|
||||
bool schannelSslBackend = StringUtil.ConvertToBoolean(executionContext.GetRunnerContext("gituseschannel"));
|
||||
if (schannelSslBackend)
|
||||
{
|
||||
gitCommandManager.EnsureGitVersion(_minGitVersionSupportSSLBackendOverride, throwOnNotMatch: true);
|
||||
}
|
||||
#endif
|
||||
if (checkGitLfs)
|
||||
{
|
||||
// v2.1 git-lfs exist use auth header.
|
||||
gitCommandManager.EnsureGitLFSVersion(_minGitLfsVersionSupportAuthHeader, throwOnNotMatch: true);
|
||||
}
|
||||
}
|
||||
|
||||
private string GenerateBasicAuthHeader(RunnerActionPluginExecutionContext executionContext, string accessToken)
|
||||
{
|
||||
// use basic auth header with username:password in base64encoding.
|
||||
string authHeader = $"x-access-token:{accessToken}";
|
||||
string base64encodedAuthHeader = Convert.ToBase64String(Encoding.UTF8.GetBytes(authHeader));
|
||||
|
||||
// add base64 encoding auth header into secretMasker.
|
||||
executionContext.AddMask(base64encodedAuthHeader);
|
||||
return $"basic {base64encodedAuthHeader}";
|
||||
}
|
||||
|
||||
private async Task<bool> IsRepositoryOriginUrlMatch(RunnerActionPluginExecutionContext context, GitCliManager gitCommandManager, string repositoryPath, Uri expectedRepositoryOriginUrl)
|
||||
{
|
||||
context.Debug($"Checking if the repo on {repositoryPath} matches the expected repository origin URL. expected Url: {expectedRepositoryOriginUrl.AbsoluteUri}");
|
||||
if (!Directory.Exists(Path.Combine(repositoryPath, ".git")))
|
||||
{
|
||||
// There is no repo directory
|
||||
context.Debug($"Repository is not found since '.git' directory does not exist under. {repositoryPath}");
|
||||
return false;
|
||||
}
|
||||
|
||||
Uri remoteUrl;
|
||||
remoteUrl = await gitCommandManager.GitGetFetchUrl(context, repositoryPath);
|
||||
|
||||
if (remoteUrl == null)
|
||||
{
|
||||
// origin fetch url not found.
|
||||
context.Debug("Repository remote origin fetch url is empty.");
|
||||
return false;
|
||||
}
|
||||
|
||||
context.Debug($"Repository remote origin fetch url is {remoteUrl}");
|
||||
// compare the url passed in with the remote url found
|
||||
if (expectedRepositoryOriginUrl.Equals(remoteUrl))
|
||||
{
|
||||
context.Debug("URLs match.");
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Debug($"The remote.origin.url of the repository under root folder '{repositoryPath}' doesn't matches source repository url.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveGitConfig(RunnerActionPluginExecutionContext executionContext, GitCliManager gitCommandManager, string targetPath, string configKey, string configValue)
|
||||
{
|
||||
int exitCode_configUnset = await gitCommandManager.GitConfigUnset(executionContext, targetPath, configKey);
|
||||
if (exitCode_configUnset != 0)
|
||||
{
|
||||
// if unable to use git.exe unset http.extraheader, http.proxy or core.askpass, modify git config file on disk. make sure we don't left credential.
|
||||
if (!string.IsNullOrEmpty(configValue))
|
||||
{
|
||||
executionContext.Warning("An unsuccessful attempt was made using git command line to remove \"http.extraheader\" from the git config. Attempting to modify the git config file directly to remove the credential.");
|
||||
string gitConfig = Path.Combine(targetPath, ".git/config");
|
||||
if (File.Exists(gitConfig))
|
||||
{
|
||||
List<string> safeGitConfig = new List<string>();
|
||||
var gitConfigContents = File.ReadAllLines(gitConfig);
|
||||
foreach (var line in gitConfigContents)
|
||||
{
|
||||
if (!line.Contains(configValue))
|
||||
{
|
||||
safeGitConfig.Add(line);
|
||||
}
|
||||
}
|
||||
|
||||
File.WriteAllLines(gitConfig, safeGitConfig);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
executionContext.Warning($"Unable to remove \"{configKey}\" from the git config. To remove the credential, execute \"git config --unset - all {configKey}\" from the repository root \"{targetPath}\".");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsPullRequest(string sourceBranch)
|
||||
{
|
||||
return !string.IsNullOrEmpty(sourceBranch) &&
|
||||
(sourceBranch.StartsWith(_pullRefsPrefix, StringComparison.OrdinalIgnoreCase) ||
|
||||
sourceBranch.StartsWith(_remotePullRefsPrefix, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private string GetRemoteRefName(string refName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(refName))
|
||||
{
|
||||
// If the refName is empty return the remote name for master
|
||||
refName = _remoteRefsPrefix + "master";
|
||||
}
|
||||
else if (refName.Equals("master", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// If the refName is master return the remote name for master
|
||||
refName = _remoteRefsPrefix + refName;
|
||||
}
|
||||
else if (refName.StartsWith(_refsPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// If the refName is refs/heads change it to the remote version of the name
|
||||
refName = _remoteRefsPrefix + refName.Substring(_refsPrefix.Length);
|
||||
}
|
||||
else if (refName.StartsWith(_pullRefsPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// If the refName is refs/pull change it to the remote version of the name
|
||||
refName = refName.Replace(_pullRefsPrefix, _remotePullRefsPrefix);
|
||||
}
|
||||
|
||||
return refName;
|
||||
}
|
||||
}
|
||||
}
|
||||
180
src/Runner.Plugins/Repository/v1.1/RepositoryPlugin.cs
Normal file
180
src/Runner.Plugins/Repository/v1.1/RepositoryPlugin.cs
Normal file
@@ -0,0 +1,180 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Runner.Sdk;
|
||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
using System.IO;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using System.Text.RegularExpressions;
|
||||
using GitHub.DistributedTask.Pipelines.Expressions;
|
||||
using System.Text;
|
||||
|
||||
namespace GitHub.Runner.Plugins.Repository.v1_1
|
||||
{
|
||||
public class CheckoutTask : IRunnerActionPlugin
|
||||
{
|
||||
public async Task RunAsync(RunnerActionPluginExecutionContext executionContext, CancellationToken token)
|
||||
{
|
||||
string runnerWorkspace = executionContext.GetRunnerContext("workspace");
|
||||
ArgUtil.Directory(runnerWorkspace, nameof(runnerWorkspace));
|
||||
string tempDirectory = executionContext.GetRunnerContext("temp");
|
||||
ArgUtil.Directory(tempDirectory, nameof(tempDirectory));
|
||||
|
||||
var repoFullName = executionContext.GetInput(Pipelines.PipelineConstants.CheckoutTaskInputs.Repository);
|
||||
if (string.IsNullOrEmpty(repoFullName))
|
||||
{
|
||||
repoFullName = executionContext.GetGitHubContext("repository");
|
||||
}
|
||||
|
||||
var repoFullNameSplit = repoFullName.Split("/", StringSplitOptions.RemoveEmptyEntries);
|
||||
if (repoFullNameSplit.Length != 2)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(repoFullName);
|
||||
}
|
||||
|
||||
string expectRepoPath;
|
||||
var path = executionContext.GetInput(Pipelines.PipelineConstants.CheckoutTaskInputs.Path);
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
expectRepoPath = IOUtil.ResolvePath(runnerWorkspace, path);
|
||||
if (!expectRepoPath.StartsWith(runnerWorkspace.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar))
|
||||
{
|
||||
throw new ArgumentException($"Input path '{path}' should resolve to a directory under '{runnerWorkspace}', current resolved path '{expectRepoPath}'.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// When repository doesn't has path set, default to sources directory 1/repoName
|
||||
expectRepoPath = Path.Combine(runnerWorkspace, repoFullNameSplit[1]);
|
||||
}
|
||||
|
||||
var workspaceRepo = executionContext.GetGitHubContext("repository");
|
||||
// for self repository, we need to let the worker knows where it is after checkout.
|
||||
if (string.Equals(workspaceRepo, repoFullName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var workspaceRepoPath = executionContext.GetGitHubContext("workspace");
|
||||
|
||||
executionContext.Debug($"Repository requires to be placed at '{expectRepoPath}', current location is '{workspaceRepoPath}'");
|
||||
if (!string.Equals(workspaceRepoPath.Trim(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), expectRepoPath.Trim(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), IOUtil.FilePathStringComparison))
|
||||
{
|
||||
executionContext.Output($"Repository is current at '{workspaceRepoPath}', move to '{expectRepoPath}'.");
|
||||
var count = 1;
|
||||
var staging = Path.Combine(tempDirectory, $"_{count}");
|
||||
while (Directory.Exists(staging))
|
||||
{
|
||||
count++;
|
||||
staging = Path.Combine(tempDirectory, $"_{count}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
executionContext.Debug($"Move existing repository '{workspaceRepoPath}' to '{expectRepoPath}' via staging directory '{staging}'.");
|
||||
IOUtil.MoveDirectory(workspaceRepoPath, expectRepoPath, staging, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
executionContext.Debug("Catch exception during repository move.");
|
||||
executionContext.Debug(ex.ToString());
|
||||
executionContext.Warning("Unable move and reuse existing repository to required location.");
|
||||
IOUtil.DeleteDirectory(expectRepoPath, CancellationToken.None);
|
||||
}
|
||||
|
||||
executionContext.Output($"Repository will locate at '{expectRepoPath}'.");
|
||||
}
|
||||
|
||||
executionContext.Debug($"Update workspace repository location.");
|
||||
executionContext.SetRepositoryPath(repoFullName, expectRepoPath, true);
|
||||
}
|
||||
|
||||
string sourceBranch;
|
||||
string sourceVersion;
|
||||
string refInput = executionContext.GetInput(Pipelines.PipelineConstants.CheckoutTaskInputs.Ref);
|
||||
if (string.IsNullOrEmpty(refInput))
|
||||
{
|
||||
sourceBranch = executionContext.GetGitHubContext("ref");
|
||||
sourceVersion = executionContext.GetGitHubContext("sha");
|
||||
}
|
||||
else
|
||||
{
|
||||
sourceBranch = refInput;
|
||||
sourceVersion = executionContext.GetInput(Pipelines.PipelineConstants.CheckoutTaskInputs.Version); // version get removed when checkout move to repo in the graph
|
||||
if (string.IsNullOrEmpty(sourceVersion) && RegexUtility.IsMatch(sourceBranch, WellKnownRegularExpressions.SHA1))
|
||||
{
|
||||
sourceVersion = sourceBranch;
|
||||
// If Ref is a SHA and the repo is self, we need to use github.ref as source branch since it might be refs/pull/*
|
||||
if (string.Equals(workspaceRepo, repoFullName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
sourceBranch = executionContext.GetGitHubContext("ref");
|
||||
}
|
||||
else
|
||||
{
|
||||
sourceBranch = "refs/heads/master";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool clean = StringUtil.ConvertToBoolean(executionContext.GetInput(Pipelines.PipelineConstants.CheckoutTaskInputs.Clean), true);
|
||||
string submoduleInput = executionContext.GetInput(Pipelines.PipelineConstants.CheckoutTaskInputs.Submodules);
|
||||
|
||||
int fetchDepth = 0;
|
||||
if (!int.TryParse(executionContext.GetInput("fetch-depth"), out fetchDepth) || fetchDepth < 0)
|
||||
{
|
||||
fetchDepth = 0;
|
||||
}
|
||||
|
||||
bool gitLfsSupport = StringUtil.ConvertToBoolean(executionContext.GetInput(Pipelines.PipelineConstants.CheckoutTaskInputs.Lfs));
|
||||
string accessToken = executionContext.GetInput(Pipelines.PipelineConstants.CheckoutTaskInputs.Token);
|
||||
if (string.IsNullOrEmpty(accessToken))
|
||||
{
|
||||
accessToken = executionContext.GetGitHubContext("token");
|
||||
}
|
||||
|
||||
// register problem matcher
|
||||
string matcherFile = Path.Combine(tempDirectory, $"git_{Guid.NewGuid()}.json");
|
||||
File.WriteAllText(matcherFile, GitHubSourceProvider.ProblemMatcher, new UTF8Encoding(false));
|
||||
executionContext.Output($"##[add-matcher]{matcherFile}");
|
||||
try
|
||||
{
|
||||
await new GitHubSourceProvider().GetSourceAsync(executionContext,
|
||||
expectRepoPath,
|
||||
repoFullName,
|
||||
sourceBranch,
|
||||
sourceVersion,
|
||||
clean,
|
||||
submoduleInput,
|
||||
fetchDepth,
|
||||
gitLfsSupport,
|
||||
accessToken,
|
||||
token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
executionContext.Output("##[remove-matcher owner=checkout-git]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class CleanupTask : IRunnerActionPlugin
|
||||
{
|
||||
public async Task RunAsync(RunnerActionPluginExecutionContext executionContext, CancellationToken token)
|
||||
{
|
||||
string tempDirectory = executionContext.GetRunnerContext("temp");
|
||||
ArgUtil.Directory(tempDirectory, nameof(tempDirectory));
|
||||
|
||||
// register problem matcher
|
||||
string matcherFile = Path.Combine(tempDirectory, $"git_{Guid.NewGuid()}.json");
|
||||
File.WriteAllText(matcherFile, GitHubSourceProvider.ProblemMatcher, new UTF8Encoding(false));
|
||||
executionContext.Output($"##[add-matcher]{matcherFile}");
|
||||
try
|
||||
{
|
||||
await new GitHubSourceProvider().CleanupAsync(executionContext);
|
||||
}
|
||||
finally
|
||||
{
|
||||
executionContext.Output("##[remove-matcher owner=checkout-git]");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
60
src/Runner.Plugins/Runner.Plugins.csproj
Normal file
60
src/Runner.Plugins/Runner.Plugins.csproj
Normal file
@@ -0,0 +1,60 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netcoreapp2.2</TargetFramework>
|
||||
<OutputType>Library</OutputType>
|
||||
<RuntimeIdentifiers>win-x64;win-x86;linux-x64;linux-arm;rhel.6-x64;osx-x64</RuntimeIdentifiers>
|
||||
<TargetLatestRuntimePatch>true</TargetLatestRuntimePatch>
|
||||
<AssetTargetFallback>portable-net45+win8</AssetTargetFallback>
|
||||
<NoWarn>NU1701;NU1603</NoWarn>
|
||||
<Version>$(Version)</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Sdk\Sdk.csproj" />
|
||||
<ProjectReference Include="..\Runner.Sdk\Runner.Sdk.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
<DebugType>portable</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true' AND '$(PackageRuntime)' == 'win-x64'">
|
||||
<DefineConstants>OS_WINDOWS;X64;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true' AND '$(PackageRuntime)' == 'win-x86'">
|
||||
<DefineConstants>OS_WINDOWS;X86;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'win-x64'">
|
||||
<DefineConstants>OS_WINDOWS;X64;DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'win-x86'">
|
||||
<DefineConstants>OS_WINDOWS;X86;DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::OSX)))' == 'true'">
|
||||
<DefineConstants>OS_OSX;X64;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::OSX)))' == 'true' AND '$(Configuration)' == 'Debug'">
|
||||
<DefineConstants>OS_OSX;DEBUG;X64;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(PackageRuntime)' == 'linux-x64'">
|
||||
<DefineConstants>OS_LINUX;X64;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(PackageRuntime)' == 'rhel.6-x64'">
|
||||
<DefineConstants>OS_LINUX;OS_RHEL6;X64;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(PackageRuntime)' == 'linux-arm'">
|
||||
<DefineConstants>OS_LINUX;ARM;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'linux-x64'">
|
||||
<DefineConstants>OS_LINUX;X64;DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'rhel.6-x64'">
|
||||
<DefineConstants>OS_LINUX;OS_RHEL6;X64;DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'linux-arm'">
|
||||
<DefineConstants>OS_LINUX;ARM;DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
314
src/Runner.Sdk/ActionPlugin.cs
Normal file
314
src/Runner.Sdk/ActionPlugin.cs
Normal file
@@ -0,0 +1,314 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.DistributedTask.Pipelines.ContextData;
|
||||
using GitHub.DistributedTask.WebApi;
|
||||
using GitHub.Services.Common;
|
||||
using GitHub.Services.WebApi;
|
||||
using Newtonsoft.Json;
|
||||
using Pipelines = GitHub.DistributedTask.Pipelines;
|
||||
|
||||
namespace GitHub.Runner.Sdk
|
||||
{
|
||||
public interface IRunnerActionPlugin
|
||||
{
|
||||
Task RunAsync(RunnerActionPluginExecutionContext executionContext, CancellationToken token);
|
||||
}
|
||||
|
||||
public class RunnerActionPluginExecutionContext : ITraceWriter
|
||||
{
|
||||
private readonly string DebugEnvironmentalVariable = "ACTIONS_STEP_DEBUG";
|
||||
private VssConnection _connection;
|
||||
private readonly object _stdoutLock = new object();
|
||||
private readonly ITraceWriter _trace; // for unit tests
|
||||
|
||||
public RunnerActionPluginExecutionContext()
|
||||
: this(null)
|
||||
{ }
|
||||
|
||||
public RunnerActionPluginExecutionContext(ITraceWriter trace)
|
||||
{
|
||||
_trace = trace;
|
||||
this.Endpoints = new List<ServiceEndpoint>();
|
||||
this.Inputs = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
this.Variables = new Dictionary<string, VariableValue>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public List<ServiceEndpoint> Endpoints { get; set; }
|
||||
public Dictionary<string, VariableValue> Variables { get; set; }
|
||||
public Dictionary<string, string> Inputs { get; set; }
|
||||
public DictionaryContextData Context { get; set; } = new DictionaryContextData();
|
||||
|
||||
[JsonIgnore]
|
||||
public VssConnection VssConnection
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_connection == null)
|
||||
{
|
||||
_connection = InitializeVssConnection();
|
||||
}
|
||||
return _connection;
|
||||
}
|
||||
}
|
||||
|
||||
public VssConnection InitializeVssConnection()
|
||||
{
|
||||
var headerValues = new List<ProductInfoHeaderValue>();
|
||||
headerValues.Add(new ProductInfoHeaderValue($"GitHubActionsRunner-Plugin", BuildConstants.RunnerPackage.Version));
|
||||
headerValues.Add(new ProductInfoHeaderValue($"({RuntimeInformation.OSDescription.Trim()})"));
|
||||
|
||||
if (VssClientHttpRequestSettings.Default.UserAgent != null && VssClientHttpRequestSettings.Default.UserAgent.Count > 0)
|
||||
{
|
||||
headerValues.AddRange(VssClientHttpRequestSettings.Default.UserAgent);
|
||||
}
|
||||
|
||||
VssClientHttpRequestSettings.Default.UserAgent = headerValues;
|
||||
|
||||
#if OS_LINUX || OS_OSX
|
||||
// The .NET Core 2.1 runtime switched its HTTP default from HTTP 1.1 to HTTP 2.
|
||||
// This causes problems with some versions of the Curl handler.
|
||||
// See GitHub issue https://github.com/dotnet/corefx/issues/32376
|
||||
VssClientHttpRequestSettings.Default.UseHttp11 = true;
|
||||
#endif
|
||||
|
||||
var certSetting = GetCertConfiguration();
|
||||
if (certSetting != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(certSetting.ClientCertificateArchiveFile))
|
||||
{
|
||||
VssClientHttpRequestSettings.Default.ClientCertificateManager = new RunnerClientCertificateManager(certSetting.ClientCertificateArchiveFile, certSetting.ClientCertificatePassword);
|
||||
}
|
||||
|
||||
if (certSetting.SkipServerCertificateValidation)
|
||||
{
|
||||
VssClientHttpRequestSettings.Default.ServerCertificateValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
|
||||
}
|
||||
}
|
||||
|
||||
var proxySetting = GetProxyConfiguration();
|
||||
if (proxySetting != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(proxySetting.ProxyAddress))
|
||||
{
|
||||
VssHttpMessageHandler.DefaultWebProxy = new RunnerWebProxyCore(proxySetting.ProxyAddress, proxySetting.ProxyUsername, proxySetting.ProxyPassword, proxySetting.ProxyBypassList);
|
||||
}
|
||||
}
|
||||
|
||||
ServiceEndpoint systemConnection = this.Endpoints.FirstOrDefault(e => string.Equals(e.Name, WellKnownServiceEndpointNames.SystemVssConnection, StringComparison.OrdinalIgnoreCase));
|
||||
ArgUtil.NotNull(systemConnection, nameof(systemConnection));
|
||||
ArgUtil.NotNull(systemConnection.Url, nameof(systemConnection.Url));
|
||||
|
||||
VssCredentials credentials = VssUtil.GetVssCredential(systemConnection);
|
||||
ArgUtil.NotNull(credentials, nameof(credentials));
|
||||
return VssUtil.CreateConnection(systemConnection.Url, credentials);
|
||||
}
|
||||
|
||||
public string GetInput(string name, bool required = false)
|
||||
{
|
||||
string value = null;
|
||||
if (this.Inputs.ContainsKey(name))
|
||||
{
|
||||
value = this.Inputs[name];
|
||||
}
|
||||
|
||||
Debug($"Input '{name}': '{value ?? string.Empty}'");
|
||||
|
||||
if (string.IsNullOrEmpty(value) && required)
|
||||
{
|
||||
throw new ArgumentNullException(name);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
public void Info(string message)
|
||||
{
|
||||
Debug(message);
|
||||
}
|
||||
|
||||
public void Verbose(string message)
|
||||
{
|
||||
Debug(message);
|
||||
}
|
||||
|
||||
public void Error(string message)
|
||||
{
|
||||
Output($"##[error]{Escape(message)}");
|
||||
}
|
||||
|
||||
public void Debug(string message)
|
||||
{
|
||||
var debugString = Variables.GetValueOrDefault(DebugEnvironmentalVariable)?.Value;
|
||||
if (StringUtil.ConvertToBoolean(debugString))
|
||||
{
|
||||
var multilines = message?.Replace("\r\n", "\n")?.Split("\n");
|
||||
if (multilines != null)
|
||||
{
|
||||
foreach (var line in multilines)
|
||||
{
|
||||
Output($"##[debug]{Escape(line)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Warning(string message)
|
||||
{
|
||||
Output($"##[warning]{Escape(message)}");
|
||||
}
|
||||
|
||||
public void Output(string message)
|
||||
{
|
||||
lock (_stdoutLock)
|
||||
{
|
||||
if (_trace == null)
|
||||
{
|
||||
Console.WriteLine(message);
|
||||
}
|
||||
else
|
||||
{
|
||||
_trace.Info(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void AddMask(string secret)
|
||||
{
|
||||
Output($"##[add-mask]{Escape(secret)}");
|
||||
}
|
||||
|
||||
public void Command(string command)
|
||||
{
|
||||
Output($"##[command]{Escape(command)}");
|
||||
}
|
||||
|
||||
public void SetRepositoryPath(string repoName, string path, bool workspaceRepo)
|
||||
{
|
||||
Output($"##[internal-set-repo-path repoFullName={repoName};workspaceRepo={workspaceRepo.ToString()}]{path}");
|
||||
}
|
||||
|
||||
public void SetIntraActionState(string name, string value)
|
||||
{
|
||||
Output($"##[save-state name={Escape(name)}]{Escape(value)}");
|
||||
}
|
||||
|
||||
public String GetRunnerContext(string contextName)
|
||||
{
|
||||
this.Context.TryGetValue("runner", out var context);
|
||||
var runnerContext = context as DictionaryContextData;
|
||||
ArgUtil.NotNull(runnerContext, nameof(runnerContext));
|
||||
if (runnerContext.TryGetValue(contextName, out var data))
|
||||
{
|
||||
return data as StringContextData;
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public String GetGitHubContext(string contextName)
|
||||
{
|
||||
this.Context.TryGetValue("github", out var context);
|
||||
var githubContext = context as DictionaryContextData;
|
||||
ArgUtil.NotNull(githubContext, nameof(githubContext));
|
||||
if (githubContext.TryGetValue(contextName, out var data))
|
||||
{
|
||||
return data as StringContextData;
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public RunnerCertificateSettings GetCertConfiguration()
|
||||
{
|
||||
bool skipCertValidation = StringUtil.ConvertToBoolean(GetRunnerContext("SkipCertValidation"));
|
||||
string caFile = GetRunnerContext("CAInfo");
|
||||
string clientCertFile = GetRunnerContext("ClientCert");
|
||||
|
||||
if (!string.IsNullOrEmpty(caFile) || !string.IsNullOrEmpty(clientCertFile) || skipCertValidation)
|
||||
{
|
||||
var certConfig = new RunnerCertificateSettings();
|
||||
certConfig.SkipServerCertificateValidation = skipCertValidation;
|
||||
certConfig.CACertificateFile = caFile;
|
||||
|
||||
if (!string.IsNullOrEmpty(clientCertFile))
|
||||
{
|
||||
certConfig.ClientCertificateFile = clientCertFile;
|
||||
string clientCertKey = GetRunnerContext("ClientCertKey");
|
||||
string clientCertArchive = GetRunnerContext("ClientCertArchive");
|
||||
string clientCertPassword = GetRunnerContext("ClientCertPassword");
|
||||
|
||||
certConfig.ClientCertificatePrivateKeyFile = clientCertKey;
|
||||
certConfig.ClientCertificateArchiveFile = clientCertArchive;
|
||||
certConfig.ClientCertificatePassword = clientCertPassword;
|
||||
|
||||
certConfig.VssClientCertificateManager = new RunnerClientCertificateManager(clientCertArchive, clientCertPassword);
|
||||
}
|
||||
|
||||
return certConfig;
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public RunnerWebProxySettings GetProxyConfiguration()
|
||||
{
|
||||
string proxyUrl = GetRunnerContext("ProxyUrl");
|
||||
if (!string.IsNullOrEmpty(proxyUrl))
|
||||
{
|
||||
string proxyUsername = GetRunnerContext("ProxyUsername");
|
||||
string proxyPassword = GetRunnerContext("ProxyPassword");
|
||||
List<string> proxyBypassHosts = StringUtil.ConvertFromJson<List<string>>(GetRunnerContext("ProxyBypassList") ?? "[]");
|
||||
return new RunnerWebProxySettings()
|
||||
{
|
||||
ProxyAddress = proxyUrl,
|
||||
ProxyUsername = proxyUsername,
|
||||
ProxyPassword = proxyPassword,
|
||||
ProxyBypassList = proxyBypassHosts,
|
||||
WebProxy = new RunnerWebProxyCore(proxyUrl, proxyUsername, proxyPassword, proxyBypassHosts)
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private string Escape(string input)
|
||||
{
|
||||
foreach (var mapping in _commandEscapeMappings)
|
||||
{
|
||||
input = input.Replace(mapping.Key, mapping.Value);
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
private Dictionary<string, string> _commandEscapeMappings = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
{
|
||||
";", "%3B"
|
||||
},
|
||||
{
|
||||
"\r", "%0D"
|
||||
},
|
||||
{
|
||||
"\n", "%0A"
|
||||
},
|
||||
{
|
||||
"]", "%5D"
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
8
src/Runner.Sdk/ITraceWriter.cs
Normal file
8
src/Runner.Sdk/ITraceWriter.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace GitHub.Runner.Sdk
|
||||
{
|
||||
public interface ITraceWriter
|
||||
{
|
||||
void Info(string message);
|
||||
void Verbose(string message);
|
||||
}
|
||||
}
|
||||
892
src/Runner.Sdk/ProcessInvoker.cs
Normal file
892
src/Runner.Sdk/ProcessInvoker.cs
Normal file
@@ -0,0 +1,892 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using GitHub.Runner.Sdk;
|
||||
|
||||
namespace GitHub.Runner.Sdk
|
||||
{
|
||||
|
||||
// The implementation of the process invoker does not hook up DataReceivedEvent and ErrorReceivedEvent of Process,
|
||||
// instead, we read both STDOUT and STDERR stream manually on separate thread.
|
||||
// The reason is we find a huge perf issue about process STDOUT/STDERR with those events.
|
||||
public sealed class ProcessInvoker : IDisposable
|
||||
{
|
||||
private Process _proc;
|
||||
private Stopwatch _stopWatch;
|
||||
private int _asyncStreamReaderCount = 0;
|
||||
private bool _waitingOnStreams = false;
|
||||
private readonly AsyncManualResetEvent _outputProcessEvent = new AsyncManualResetEvent();
|
||||
private readonly TaskCompletionSource<bool> _processExitedCompletionSource = new TaskCompletionSource<bool>();
|
||||
private readonly CancellationTokenSource _processStandardInWriteCancellationTokenSource = new CancellationTokenSource();
|
||||
private readonly ConcurrentQueue<string> _errorData = new ConcurrentQueue<string>();
|
||||
private readonly ConcurrentQueue<string> _outputData = new ConcurrentQueue<string>();
|
||||
private readonly TimeSpan _sigintTimeout = TimeSpan.FromMilliseconds(7500);
|
||||
private readonly TimeSpan _sigtermTimeout = TimeSpan.FromMilliseconds(2500);
|
||||
private ITraceWriter Trace { get; set; }
|
||||
|
||||
private class AsyncManualResetEvent
|
||||
{
|
||||
private volatile TaskCompletionSource<bool> m_tcs = new TaskCompletionSource<bool>();
|
||||
|
||||
public Task WaitAsync() { return m_tcs.Task; }
|
||||
|
||||
public void Set()
|
||||
{
|
||||
var tcs = m_tcs;
|
||||
Task.Factory.StartNew(s => ((TaskCompletionSource<bool>)s).TrySetResult(true),
|
||||
tcs, CancellationToken.None, TaskCreationOptions.PreferFairness, TaskScheduler.Default);
|
||||
tcs.Task.Wait();
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var tcs = m_tcs;
|
||||
if (!tcs.Task.IsCompleted ||
|
||||
Interlocked.CompareExchange(ref m_tcs, new TaskCompletionSource<bool>(), tcs) == tcs)
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public event EventHandler<ProcessDataReceivedEventArgs> OutputDataReceived;
|
||||
public event EventHandler<ProcessDataReceivedEventArgs> ErrorDataReceived;
|
||||
|
||||
public ProcessInvoker(ITraceWriter trace)
|
||||
{
|
||||
this.Trace = trace;
|
||||
}
|
||||
|
||||
public Task<int> ExecuteAsync(
|
||||
string workingDirectory,
|
||||
string fileName,
|
||||
string arguments,
|
||||
IDictionary<string, string> environment,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return ExecuteAsync(
|
||||
workingDirectory: workingDirectory,
|
||||
fileName: fileName,
|
||||
arguments: arguments,
|
||||
environment: environment,
|
||||
requireExitCodeZero: false,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public Task<int> ExecuteAsync(
|
||||
string workingDirectory,
|
||||
string fileName,
|
||||
string arguments,
|
||||
IDictionary<string, string> environment,
|
||||
bool requireExitCodeZero,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return ExecuteAsync(
|
||||
workingDirectory: workingDirectory,
|
||||
fileName: fileName,
|
||||
arguments: arguments,
|
||||
environment: environment,
|
||||
requireExitCodeZero: requireExitCodeZero,
|
||||
outputEncoding: null,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public Task<int> ExecuteAsync(
|
||||
string workingDirectory,
|
||||
string fileName,
|
||||
string arguments,
|
||||
IDictionary<string, string> environment,
|
||||
bool requireExitCodeZero,
|
||||
Encoding outputEncoding,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return ExecuteAsync(
|
||||
workingDirectory: workingDirectory,
|
||||
fileName: fileName,
|
||||
arguments: arguments,
|
||||
environment: environment,
|
||||
requireExitCodeZero: requireExitCodeZero,
|
||||
outputEncoding: outputEncoding,
|
||||
killProcessOnCancel: false,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public Task<int> ExecuteAsync(
|
||||
string workingDirectory,
|
||||
string fileName,
|
||||
string arguments,
|
||||
IDictionary<string, string> environment,
|
||||
bool requireExitCodeZero,
|
||||
Encoding outputEncoding,
|
||||
bool killProcessOnCancel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return ExecuteAsync(
|
||||
workingDirectory: workingDirectory,
|
||||
fileName: fileName,
|
||||
arguments: arguments,
|
||||
environment: environment,
|
||||
requireExitCodeZero: requireExitCodeZero,
|
||||
outputEncoding: outputEncoding,
|
||||
killProcessOnCancel: killProcessOnCancel,
|
||||
redirectStandardIn: null,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public Task<int> ExecuteAsync(
|
||||
string workingDirectory,
|
||||
string fileName,
|
||||
string arguments,
|
||||
IDictionary<string, string> environment,
|
||||
bool requireExitCodeZero,
|
||||
Encoding outputEncoding,
|
||||
bool killProcessOnCancel,
|
||||
Channel<string> redirectStandardIn,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return ExecuteAsync(
|
||||
workingDirectory: workingDirectory,
|
||||
fileName: fileName,
|
||||
arguments: arguments,
|
||||
environment: environment,
|
||||
requireExitCodeZero: requireExitCodeZero,
|
||||
outputEncoding: outputEncoding,
|
||||
killProcessOnCancel: killProcessOnCancel,
|
||||
redirectStandardIn: redirectStandardIn,
|
||||
inheritConsoleHandler: false,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public Task<int> ExecuteAsync(
|
||||
string workingDirectory,
|
||||
string fileName,
|
||||
string arguments,
|
||||
IDictionary<string, string> environment,
|
||||
bool requireExitCodeZero,
|
||||
Encoding outputEncoding,
|
||||
bool killProcessOnCancel,
|
||||
Channel<string> redirectStandardIn,
|
||||
bool inheritConsoleHandler,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return ExecuteAsync(
|
||||
workingDirectory: workingDirectory,
|
||||
fileName: fileName,
|
||||
arguments: arguments,
|
||||
environment: environment,
|
||||
requireExitCodeZero: requireExitCodeZero,
|
||||
outputEncoding: outputEncoding,
|
||||
killProcessOnCancel: killProcessOnCancel,
|
||||
redirectStandardIn: redirectStandardIn,
|
||||
inheritConsoleHandler: inheritConsoleHandler,
|
||||
keepStandardInOpen: false,
|
||||
highPriorityProcess: false,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<int> ExecuteAsync(
|
||||
string workingDirectory,
|
||||
string fileName,
|
||||
string arguments,
|
||||
IDictionary<string, string> environment,
|
||||
bool requireExitCodeZero,
|
||||
Encoding outputEncoding,
|
||||
bool killProcessOnCancel,
|
||||
Channel<string> redirectStandardIn,
|
||||
bool inheritConsoleHandler,
|
||||
bool keepStandardInOpen,
|
||||
bool highPriorityProcess,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgUtil.Null(_proc, nameof(_proc));
|
||||
ArgUtil.NotNullOrEmpty(fileName, nameof(fileName));
|
||||
|
||||
Trace.Info("Starting process:");
|
||||
Trace.Info($" File name: '{fileName}'");
|
||||
Trace.Info($" Arguments: '{arguments}'");
|
||||
Trace.Info($" Working directory: '{workingDirectory}'");
|
||||
Trace.Info($" Require exit code zero: '{requireExitCodeZero}'");
|
||||
Trace.Info($" Encoding web name: {outputEncoding?.WebName} ; code page: '{outputEncoding?.CodePage}'");
|
||||
Trace.Info($" Force kill process on cancellation: '{killProcessOnCancel}'");
|
||||
Trace.Info($" Redirected STDIN: '{redirectStandardIn != null}'");
|
||||
Trace.Info($" Persist current code page: '{inheritConsoleHandler}'");
|
||||
Trace.Info($" Keep redirected STDIN open: '{keepStandardInOpen}'");
|
||||
Trace.Info($" High priority process: '{highPriorityProcess}'");
|
||||
|
||||
_proc = new Process();
|
||||
_proc.StartInfo.FileName = fileName;
|
||||
_proc.StartInfo.Arguments = arguments;
|
||||
_proc.StartInfo.WorkingDirectory = workingDirectory;
|
||||
_proc.StartInfo.UseShellExecute = false;
|
||||
_proc.StartInfo.CreateNoWindow = !inheritConsoleHandler;
|
||||
_proc.StartInfo.RedirectStandardInput = true;
|
||||
_proc.StartInfo.RedirectStandardError = true;
|
||||
_proc.StartInfo.RedirectStandardOutput = true;
|
||||
|
||||
// Ensure we process STDERR even the process exit event happen before we start read STDERR stream.
|
||||
if (_proc.StartInfo.RedirectStandardError)
|
||||
{
|
||||
Interlocked.Increment(ref _asyncStreamReaderCount);
|
||||
}
|
||||
|
||||
// Ensure we process STDOUT even the process exit event happen before we start read STDOUT stream.
|
||||
if (_proc.StartInfo.RedirectStandardOutput)
|
||||
{
|
||||
Interlocked.Increment(ref _asyncStreamReaderCount);
|
||||
}
|
||||
|
||||
#if OS_WINDOWS
|
||||
// If StandardErrorEncoding or StandardOutputEncoding is not specified the on the
|
||||
// ProcessStartInfo object, then .NET PInvokes to resolve the default console output
|
||||
// code page:
|
||||
// [DllImport("api-ms-win-core-console-l1-1-0.dll", SetLastError = true)]
|
||||
// public extern static uint GetConsoleOutputCP();
|
||||
StringUtil.EnsureRegisterEncodings();
|
||||
#endif
|
||||
if (outputEncoding != null)
|
||||
{
|
||||
_proc.StartInfo.StandardErrorEncoding = outputEncoding;
|
||||
_proc.StartInfo.StandardOutputEncoding = outputEncoding;
|
||||
}
|
||||
|
||||
// Copy the environment variables.
|
||||
if (environment != null && environment.Count > 0)
|
||||
{
|
||||
foreach (KeyValuePair<string, string> kvp in environment)
|
||||
{
|
||||
_proc.StartInfo.Environment[kvp.Key] = kvp.Value;
|
||||
}
|
||||
}
|
||||
|
||||
// Indicate GitHub Actions process.
|
||||
_proc.StartInfo.Environment["GITHUB_ACTIONS"] = "true";
|
||||
|
||||
// Hook up the events.
|
||||
_proc.EnableRaisingEvents = true;
|
||||
_proc.Exited += ProcessExitedHandler;
|
||||
|
||||
// Start the process.
|
||||
_stopWatch = Stopwatch.StartNew();
|
||||
_proc.Start();
|
||||
|
||||
// Decrease invoked process priority, in platform specifc way, relative to parent
|
||||
if (!highPriorityProcess)
|
||||
{
|
||||
DecreaseProcessPriority(_proc);
|
||||
}
|
||||
|
||||
// Start the standard error notifications, if appropriate.
|
||||
if (_proc.StartInfo.RedirectStandardError)
|
||||
{
|
||||
StartReadStream(_proc.StandardError, _errorData);
|
||||
}
|
||||
|
||||
// Start the standard output notifications, if appropriate.
|
||||
if (_proc.StartInfo.RedirectStandardOutput)
|
||||
{
|
||||
StartReadStream(_proc.StandardOutput, _outputData);
|
||||
}
|
||||
|
||||
if (_proc.StartInfo.RedirectStandardInput)
|
||||
{
|
||||
if (redirectStandardIn != null)
|
||||
{
|
||||
StartWriteStream(redirectStandardIn, _proc.StandardInput, keepStandardInOpen);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Close the input stream. This is done to prevent commands from blocking the build waiting for input from the user.
|
||||
_proc.StandardInput.Close();
|
||||
}
|
||||
}
|
||||
|
||||
using (var registration = cancellationToken.Register(async () => await CancelAndKillProcessTree(killProcessOnCancel)))
|
||||
{
|
||||
Trace.Info($"Process started with process id {_proc.Id}, waiting for process exit.");
|
||||
while (true)
|
||||
{
|
||||
Task outputSignal = _outputProcessEvent.WaitAsync();
|
||||
var signaled = await Task.WhenAny(outputSignal, _processExitedCompletionSource.Task);
|
||||
|
||||
if (signaled == outputSignal)
|
||||
{
|
||||
ProcessOutput();
|
||||
}
|
||||
else
|
||||
{
|
||||
_stopWatch.Stop();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Just in case there was some pending output when the process shut down go ahead and check the
|
||||
// data buffers one last time before returning
|
||||
ProcessOutput();
|
||||
|
||||
Trace.Info($"Finished process {_proc.Id} with exit code {_proc.ExitCode}, and elapsed time {_stopWatch.Elapsed}.");
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Wait for process to finish.
|
||||
if (_proc.ExitCode != 0 && requireExitCodeZero)
|
||||
{
|
||||
throw new ProcessExitCodeException(exitCode: _proc.ExitCode, fileName: fileName, arguments: arguments);
|
||||
}
|
||||
|
||||
return _proc.ExitCode;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
if (_proc != null)
|
||||
{
|
||||
_proc.Dispose();
|
||||
_proc = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessOutput()
|
||||
{
|
||||
List<string> errorData = new List<string>();
|
||||
List<string> outputData = new List<string>();
|
||||
|
||||
string errorLine;
|
||||
while (_errorData.TryDequeue(out errorLine))
|
||||
{
|
||||
errorData.Add(errorLine);
|
||||
}
|
||||
|
||||
string outputLine;
|
||||
while (_outputData.TryDequeue(out outputLine))
|
||||
{
|
||||
outputData.Add(outputLine);
|
||||
}
|
||||
|
||||
_outputProcessEvent.Reset();
|
||||
|
||||
// Write the error lines.
|
||||
if (errorData != null && this.ErrorDataReceived != null)
|
||||
{
|
||||
foreach (string line in errorData)
|
||||
{
|
||||
if (line != null)
|
||||
{
|
||||
this.ErrorDataReceived(this, new ProcessDataReceivedEventArgs(line));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process the output lines.
|
||||
if (outputData != null && this.OutputDataReceived != null)
|
||||
{
|
||||
foreach (string line in outputData)
|
||||
{
|
||||
if (line != null)
|
||||
{
|
||||
// The line is output from the process that was invoked.
|
||||
this.OutputDataReceived(this, new ProcessDataReceivedEventArgs(line));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CancelAndKillProcessTree(bool killProcessOnCancel)
|
||||
{
|
||||
ArgUtil.NotNull(_proc, nameof(_proc));
|
||||
if (!killProcessOnCancel)
|
||||
{
|
||||
bool sigint_succeed = await SendSIGINT(_sigintTimeout);
|
||||
if (sigint_succeed)
|
||||
{
|
||||
Trace.Info("Process cancelled successfully through Ctrl+C/SIGINT.");
|
||||
return;
|
||||
}
|
||||
|
||||
bool sigterm_succeed = await SendSIGTERM(_sigtermTimeout);
|
||||
if (sigterm_succeed)
|
||||
{
|
||||
Trace.Info("Process terminate successfully through Ctrl+Break/SIGTERM.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Trace.Info("Kill entire process tree since both cancel and terminate signal has been ignored by the target process.");
|
||||
KillProcessTree();
|
||||
}
|
||||
|
||||
private async Task<bool> SendSIGINT(TimeSpan timeout)
|
||||
{
|
||||
#if OS_WINDOWS
|
||||
return await SendCtrlSignal(ConsoleCtrlEvent.CTRL_C, timeout);
|
||||
#else
|
||||
return await SendSignal(Signals.SIGINT, timeout);
|
||||
#endif
|
||||
}
|
||||
|
||||
private async Task<bool> SendSIGTERM(TimeSpan timeout)
|
||||
{
|
||||
#if OS_WINDOWS
|
||||
return await SendCtrlSignal(ConsoleCtrlEvent.CTRL_BREAK, timeout);
|
||||
#else
|
||||
return await SendSignal(Signals.SIGTERM, timeout);
|
||||
#endif
|
||||
}
|
||||
|
||||
private void ProcessExitedHandler(object sender, EventArgs e)
|
||||
{
|
||||
if ((_proc.StartInfo.RedirectStandardError || _proc.StartInfo.RedirectStandardOutput) && _asyncStreamReaderCount != 0)
|
||||
{
|
||||
_waitingOnStreams = true;
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
// Wait 5 seconds and then Cancel/Kill process tree
|
||||
await Task.Delay(TimeSpan.FromSeconds(5));
|
||||
KillProcessTree();
|
||||
_processExitedCompletionSource.TrySetResult(true);
|
||||
_processStandardInWriteCancellationTokenSource.Cancel();
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
_processExitedCompletionSource.TrySetResult(true);
|
||||
_processStandardInWriteCancellationTokenSource.Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
private void StartReadStream(StreamReader reader, ConcurrentQueue<string> dataBuffer)
|
||||
{
|
||||
Task.Run(() =>
|
||||
{
|
||||
while (!reader.EndOfStream)
|
||||
{
|
||||
string line = reader.ReadLine();
|
||||
if (line != null)
|
||||
{
|
||||
dataBuffer.Enqueue(line);
|
||||
_outputProcessEvent.Set();
|
||||
}
|
||||
}
|
||||
|
||||
Trace.Info("STDOUT/STDERR stream read finished.");
|
||||
|
||||
if (Interlocked.Decrement(ref _asyncStreamReaderCount) == 0 && _waitingOnStreams)
|
||||
{
|
||||
_processExitedCompletionSource.TrySetResult(true);
|
||||
_processStandardInWriteCancellationTokenSource.Cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void StartWriteStream(Channel<string> redirectStandardIn, StreamWriter standardIn, bool keepStandardInOpen)
|
||||
{
|
||||
Task.Run(async () =>
|
||||
{
|
||||
// Write the contents as UTF8 to handle all characters.
|
||||
var utf8Writer = new StreamWriter(standardIn.BaseStream, new UTF8Encoding(false));
|
||||
|
||||
while (!_processExitedCompletionSource.Task.IsCompleted)
|
||||
{
|
||||
ValueTask<string> dequeueTask = redirectStandardIn.Reader.ReadAsync(_processStandardInWriteCancellationTokenSource.Token);
|
||||
string input = await dequeueTask;
|
||||
if (input != null)
|
||||
{
|
||||
utf8Writer.WriteLine(input);
|
||||
utf8Writer.Flush();
|
||||
|
||||
if (!keepStandardInOpen)
|
||||
{
|
||||
Trace.Info("Close STDIN after the first redirect finished.");
|
||||
standardIn.Close();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Trace.Info("STDIN stream write finished.");
|
||||
});
|
||||
}
|
||||
|
||||
private void KillProcessTree()
|
||||
{
|
||||
#if OS_WINDOWS
|
||||
WindowsKillProcessTree();
|
||||
#else
|
||||
NixKillProcessTree();
|
||||
#endif
|
||||
}
|
||||
|
||||
private void DecreaseProcessPriority(Process process)
|
||||
{
|
||||
#if OS_LINUX
|
||||
int oomScoreAdj = 500;
|
||||
string userOomScoreAdj;
|
||||
if (process.StartInfo.Environment.TryGetValue("PIPELINE_JOB_OOMSCOREADJ", out userOomScoreAdj))
|
||||
{
|
||||
int userOomScoreAdjParsed;
|
||||
if (int.TryParse(userOomScoreAdj, out userOomScoreAdjParsed) && userOomScoreAdjParsed >= -1000 && userOomScoreAdjParsed <= 1000)
|
||||
{
|
||||
oomScoreAdj = userOomScoreAdjParsed;
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info($"Invalid PIPELINE_JOB_OOMSCOREADJ ({userOomScoreAdj}). Valid range is -1000:1000. Using default 500.");
|
||||
}
|
||||
}
|
||||
// Values (up to 1000) make the process more likely to be killed under OOM scenario,
|
||||
// protecting the agent by extension. Default of 500 is likely to get killed, but can
|
||||
// be adjusted up or down as appropriate.
|
||||
WriteProcessOomScoreAdj(process.Id, oomScoreAdj);
|
||||
#endif
|
||||
}
|
||||
|
||||
#if OS_WINDOWS
|
||||
private async Task<bool> SendCtrlSignal(ConsoleCtrlEvent signal, TimeSpan timeout)
|
||||
{
|
||||
Trace.Info($"Sending {signal} to process {_proc.Id}.");
|
||||
ConsoleCtrlDelegate ctrlEventHandler = new ConsoleCtrlDelegate(ConsoleCtrlHandler);
|
||||
try
|
||||
{
|
||||
if (!FreeConsole())
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
|
||||
if (!AttachConsole(_proc.Id))
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
|
||||
if (!SetConsoleCtrlHandler(ctrlEventHandler, true))
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
|
||||
if (!GenerateConsoleCtrlEvent(signal, 0))
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
|
||||
Trace.Info($"Successfully send {signal} to process {_proc.Id}.");
|
||||
Trace.Info($"Waiting for process exit or {timeout.TotalSeconds} seconds after {signal} signal fired.");
|
||||
var completedTask = await Task.WhenAny(Task.Delay(timeout), _processExitedCompletionSource.Task);
|
||||
if (completedTask == _processExitedCompletionSource.Task)
|
||||
{
|
||||
Trace.Info("Process exit successfully.");
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info($"Process did not honor {signal} signal within {timeout.TotalSeconds} seconds.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Info($"{signal} signal doesn't fire successfully.");
|
||||
Trace.Verbose($"Catch exception during send {signal} event to process {_proc.Id}");
|
||||
Trace.Verbose(ex.ToString());
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
FreeConsole();
|
||||
SetConsoleCtrlHandler(ctrlEventHandler, false);
|
||||
}
|
||||
}
|
||||
|
||||
private bool ConsoleCtrlHandler(ConsoleCtrlEvent ctrlType)
|
||||
{
|
||||
switch (ctrlType)
|
||||
{
|
||||
case ConsoleCtrlEvent.CTRL_C:
|
||||
Trace.Info($"Ignore Ctrl+C to current process.");
|
||||
// We return True, so the default Ctrl handler will not take action.
|
||||
return true;
|
||||
case ConsoleCtrlEvent.CTRL_BREAK:
|
||||
Trace.Info($"Ignore Ctrl+Break to current process.");
|
||||
// We return True, so the default Ctrl handler will not take action.
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the function handles the control signal, it should return TRUE.
|
||||
// If it returns FALSE, the next handler function in the list of handlers for this process is used.
|
||||
return false;
|
||||
}
|
||||
|
||||
private void WindowsKillProcessTree()
|
||||
{
|
||||
var pid = _proc?.Id;
|
||||
if (pid == null)
|
||||
{
|
||||
// process already exit, stop here.
|
||||
return;
|
||||
}
|
||||
|
||||
Dictionary<int, int> processRelationship = new Dictionary<int, int>();
|
||||
Trace.Info($"Scan all processes to find relationship between all processes.");
|
||||
foreach (Process proc in Process.GetProcesses())
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!proc.SafeHandle.IsInvalid)
|
||||
{
|
||||
PROCESS_BASIC_INFORMATION pbi = new PROCESS_BASIC_INFORMATION();
|
||||
int returnLength = 0;
|
||||
int queryResult = NtQueryInformationProcess(proc.SafeHandle.DangerousGetHandle(), PROCESSINFOCLASS.ProcessBasicInformation, ref pbi, Marshal.SizeOf(pbi), ref returnLength);
|
||||
if (queryResult == 0) // == 0 is OK
|
||||
{
|
||||
Trace.Verbose($"Process: {proc.Id} is child process of {pbi.InheritedFromUniqueProcessId}.");
|
||||
processRelationship[proc.Id] = (int)pbi.InheritedFromUniqueProcessId;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Ignore all exceptions, since KillProcessTree is best effort.
|
||||
Trace.Verbose("Ignore any catched exception during detecting process relationship.");
|
||||
Trace.Verbose(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
Trace.Verbose($"Start killing process tree of process '{pid.Value}'.");
|
||||
Stack<ProcessTerminationInfo> processesNeedtoKill = new Stack<ProcessTerminationInfo>();
|
||||
processesNeedtoKill.Push(new ProcessTerminationInfo(pid.Value, false));
|
||||
while (processesNeedtoKill.Count() > 0)
|
||||
{
|
||||
ProcessTerminationInfo procInfo = processesNeedtoKill.Pop();
|
||||
List<int> childProcessesIds = new List<int>();
|
||||
if (!procInfo.ChildPidExpanded)
|
||||
{
|
||||
Trace.Info($"Find all child processes of process '{procInfo.Pid}'.");
|
||||
childProcessesIds = processRelationship.Where(p => p.Value == procInfo.Pid).Select(k => k.Key).ToList();
|
||||
}
|
||||
|
||||
if (childProcessesIds.Count > 0)
|
||||
{
|
||||
Trace.Info($"Need kill all child processes trees before kill process '{procInfo.Pid}'.");
|
||||
processesNeedtoKill.Push(new ProcessTerminationInfo(procInfo.Pid, true));
|
||||
foreach (var childPid in childProcessesIds)
|
||||
{
|
||||
Trace.Info($"Child process '{childPid}' needs be killed first.");
|
||||
processesNeedtoKill.Push(new ProcessTerminationInfo(childPid, false));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info($"Kill process '{procInfo.Pid}'.");
|
||||
try
|
||||
{
|
||||
Process leafProcess = Process.GetProcessById(procInfo.Pid);
|
||||
try
|
||||
{
|
||||
leafProcess.Kill();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
// The process has already exited
|
||||
Trace.Verbose("Ignore InvalidOperationException during Process.Kill().");
|
||||
Trace.Verbose(ex.ToString());
|
||||
}
|
||||
catch (Win32Exception ex) when (ex.NativeErrorCode == 5)
|
||||
{
|
||||
// The associated process could not be terminated
|
||||
// The process is terminating
|
||||
// NativeErrorCode 5 means Access Denied
|
||||
Trace.Verbose("Ignore Win32Exception with NativeErrorCode 5 during Process.Kill().");
|
||||
Trace.Verbose(ex.ToString());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Ignore any additional exception
|
||||
Trace.Verbose("Ignore additional exceptions during Process.Kill().");
|
||||
Trace.Verbose(ex.ToString());
|
||||
}
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
// process already gone, nothing needs killed.
|
||||
Trace.Verbose("Ignore ArgumentException during Process.GetProcessById().");
|
||||
Trace.Verbose(ex.ToString());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Ignore any additional exception
|
||||
Trace.Verbose("Ignore additional exceptions during Process.GetProcessById().");
|
||||
Trace.Verbose(ex.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ProcessTerminationInfo
|
||||
{
|
||||
public ProcessTerminationInfo(int pid, bool expanded)
|
||||
{
|
||||
Pid = pid;
|
||||
ChildPidExpanded = expanded;
|
||||
}
|
||||
|
||||
public int Pid { get; }
|
||||
public bool ChildPidExpanded { get; }
|
||||
}
|
||||
|
||||
private enum ConsoleCtrlEvent
|
||||
{
|
||||
CTRL_C = 0,
|
||||
CTRL_BREAK = 1
|
||||
}
|
||||
|
||||
private enum PROCESSINFOCLASS : int
|
||||
{
|
||||
ProcessBasicInformation = 0
|
||||
};
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct PROCESS_BASIC_INFORMATION
|
||||
{
|
||||
public long ExitStatus;
|
||||
public long PebBaseAddress;
|
||||
public long AffinityMask;
|
||||
public long BasePriority;
|
||||
public long UniqueProcessId;
|
||||
public long InheritedFromUniqueProcessId;
|
||||
};
|
||||
|
||||
|
||||
[DllImport("ntdll.dll", SetLastError = true)]
|
||||
private static extern int NtQueryInformationProcess(IntPtr processHandle, PROCESSINFOCLASS processInformationClass, ref PROCESS_BASIC_INFORMATION processInformation, int processInformationLength, ref int returnLength);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool GenerateConsoleCtrlEvent(ConsoleCtrlEvent sigevent, int dwProcessGroupId);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool FreeConsole();
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool AttachConsole(int dwProcessId);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool SetConsoleCtrlHandler(ConsoleCtrlDelegate HandlerRoutine, bool Add);
|
||||
|
||||
// Delegate type to be used as the Handler Routine for SetConsoleCtrlHandler
|
||||
private delegate Boolean ConsoleCtrlDelegate(ConsoleCtrlEvent CtrlType);
|
||||
#else
|
||||
private async Task<bool> SendSignal(Signals signal, TimeSpan timeout)
|
||||
{
|
||||
Trace.Info($"Sending {signal} to process {_proc.Id}.");
|
||||
int errorCode = kill(_proc.Id, (int)signal);
|
||||
if (errorCode != 0)
|
||||
{
|
||||
Trace.Info($"{signal} signal doesn't fire successfully.");
|
||||
Trace.Info($"Error code: {errorCode}.");
|
||||
return false;
|
||||
}
|
||||
|
||||
Trace.Info($"Successfully send {signal} to process {_proc.Id}.");
|
||||
Trace.Info($"Waiting for process exit or {timeout.TotalSeconds} seconds after {signal} signal fired.");
|
||||
var completedTask = await Task.WhenAny(Task.Delay(timeout), _processExitedCompletionSource.Task);
|
||||
if (completedTask == _processExitedCompletionSource.Task)
|
||||
{
|
||||
Trace.Info("Process exit successfully.");
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace.Info($"Process did not honor {signal} signal within {timeout.TotalSeconds} seconds.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void NixKillProcessTree()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_proc?.HasExited == false)
|
||||
{
|
||||
_proc?.Kill();
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
Trace.Info("Ignore InvalidOperationException during Process.Kill().");
|
||||
Trace.Info(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
#if OS_LINUX
|
||||
private void WriteProcessOomScoreAdj(int processId, int oomScoreAdj)
|
||||
{
|
||||
try
|
||||
{
|
||||
string procFilePath = $"/proc/{processId}/oom_score_adj";
|
||||
if (File.Exists(procFilePath))
|
||||
{
|
||||
File.WriteAllText(procFilePath, oomScoreAdj.ToString());
|
||||
Trace.Info($"Updated oom_score_adj to {oomScoreAdj} for PID: {processId}.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.Info($"Failed to update oom_score_adj for PID: {processId}.");
|
||||
Trace.Info(ex.ToString());
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private enum Signals : int
|
||||
{
|
||||
SIGINT = 2,
|
||||
SIGTERM = 15
|
||||
}
|
||||
|
||||
[DllImport("libc", SetLastError = true)]
|
||||
private static extern int kill(int pid, int sig);
|
||||
#endif
|
||||
}
|
||||
|
||||
public sealed class ProcessExitCodeException : Exception
|
||||
{
|
||||
public int ExitCode { get; private set; }
|
||||
|
||||
public ProcessExitCodeException(int exitCode, string fileName, string arguments)
|
||||
: base($"Exit code {exitCode} returned from process: file name '{fileName}', arguments '{arguments}'.")
|
||||
{
|
||||
ExitCode = exitCode;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ProcessDataReceivedEventArgs : EventArgs
|
||||
{
|
||||
public ProcessDataReceivedEventArgs(string data)
|
||||
{
|
||||
Data = data;
|
||||
}
|
||||
|
||||
public string Data { get; set; }
|
||||
}
|
||||
}
|
||||
65
src/Runner.Sdk/Runner.Sdk.csproj
Normal file
65
src/Runner.Sdk/Runner.Sdk.csproj
Normal file
@@ -0,0 +1,65 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netcoreapp2.2</TargetFramework>
|
||||
<OutputType>Library</OutputType>
|
||||
<RuntimeIdentifiers>win-x64;win-x86;linux-x64;linux-arm;rhel.6-x64;osx-x64</RuntimeIdentifiers>
|
||||
<TargetLatestRuntimePatch>true</TargetLatestRuntimePatch>
|
||||
<AssetTargetFallback>portable-net45+win8</AssetTargetFallback>
|
||||
<NoWarn>NU1701;NU1603</NoWarn>
|
||||
<Version>$(Version)</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Sdk\Sdk.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Text.Encoding.CodePages" Version="4.4.0" />
|
||||
<PackageReference Include="Microsoft.Win32.Registry" Version="4.4.0" />
|
||||
<PackageReference Include="System.Threading.Channels" Version="4.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
<DebugType>portable</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true' AND '$(PackageRuntime)' == 'win-x64'">
|
||||
<DefineConstants>OS_WINDOWS;X64;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true' AND '$(PackageRuntime)' == 'win-x86'">
|
||||
<DefineConstants>OS_WINDOWS;X86;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'win-x64'">
|
||||
<DefineConstants>OS_WINDOWS;X64;DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'win-x86'">
|
||||
<DefineConstants>OS_WINDOWS;X86;DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::OSX)))' == 'true'">
|
||||
<DefineConstants>OS_OSX;X64;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::OSX)))' == 'true' AND '$(Configuration)' == 'Debug'">
|
||||
<DefineConstants>OS_OSX;DEBUG;X64;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(PackageRuntime)' == 'linux-x64'">
|
||||
<DefineConstants>OS_LINUX;X64;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(PackageRuntime)' == 'rhel.6-x64'">
|
||||
<DefineConstants>OS_LINUX;OS_RHEL6;X64;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(PackageRuntime)' == 'linux-arm'">
|
||||
<DefineConstants>OS_LINUX;ARM;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'linux-x64'">
|
||||
<DefineConstants>OS_LINUX;X64;DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'rhel.6-x64'">
|
||||
<DefineConstants>OS_LINUX;OS_RHEL6;X64;DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' == 'true' AND '$(Configuration)' == 'Debug' AND '$(PackageRuntime)' == 'linux-arm'">
|
||||
<DefineConstants>OS_LINUX;ARM;DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
40
src/Runner.Sdk/RunnerClientCertificateManager.cs
Normal file
40
src/Runner.Sdk/RunnerClientCertificateManager.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using GitHub.Services.Common;
|
||||
|
||||
namespace GitHub.Runner.Sdk
|
||||
{
|
||||
public class RunnerCertificateSettings
|
||||
{
|
||||
public bool SkipServerCertificateValidation { get; set; }
|
||||
public string CACertificateFile { get; set; }
|
||||
public string ClientCertificateFile { get; set; }
|
||||
public string ClientCertificatePrivateKeyFile { get; set; }
|
||||
public string ClientCertificateArchiveFile { get; set; }
|
||||
public string ClientCertificatePassword { get; set; }
|
||||
public IVssClientCertificateManager VssClientCertificateManager { get; set; }
|
||||
}
|
||||
|
||||
public class RunnerClientCertificateManager : IVssClientCertificateManager
|
||||
{
|
||||
private readonly X509Certificate2Collection _clientCertificates = new X509Certificate2Collection();
|
||||
public X509Certificate2Collection ClientCertificates => _clientCertificates;
|
||||
|
||||
public RunnerClientCertificateManager()
|
||||
{
|
||||
}
|
||||
|
||||
public RunnerClientCertificateManager(string clientCertificateArchiveFile, string clientCertificatePassword)
|
||||
{
|
||||
AddClientCertificate(clientCertificateArchiveFile, clientCertificatePassword);
|
||||
}
|
||||
|
||||
public void AddClientCertificate(string clientCertificateArchiveFile, string clientCertificatePassword)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(clientCertificateArchiveFile))
|
||||
{
|
||||
_clientCertificates.Add(new X509Certificate2(clientCertificateArchiveFile, clientCertificatePassword));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
104
src/Runner.Sdk/RunnerWebProxyCore.cs
Normal file
104
src/Runner.Sdk/RunnerWebProxyCore.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GitHub.Runner.Sdk
|
||||
{
|
||||
public class RunnerWebProxySettings
|
||||
{
|
||||
public string ProxyAddress { get; set; }
|
||||
public string ProxyUsername { get; set; }
|
||||
public string ProxyPassword { get; set; }
|
||||
public List<string> ProxyBypassList { get; set; }
|
||||
public IWebProxy WebProxy { get; set; }
|
||||
}
|
||||
|
||||
public class RunnerWebProxyCore : IWebProxy
|
||||
{
|
||||
private string _proxyAddress;
|
||||
private readonly List<Regex> _regExBypassList = new List<Regex>();
|
||||
|
||||
public ICredentials Credentials { get; set; }
|
||||
|
||||
public RunnerWebProxyCore()
|
||||
{
|
||||
}
|
||||
|
||||
public RunnerWebProxyCore(string proxyAddress, string proxyUsername, string proxyPassword, List<string> proxyBypassList)
|
||||
{
|
||||
Update(proxyAddress, proxyUsername, proxyPassword, proxyBypassList);
|
||||
}
|
||||
|
||||
public void Update(string proxyAddress, string proxyUsername, string proxyPassword, List<string> proxyBypassList)
|
||||
{
|
||||
_proxyAddress = proxyAddress?.Trim();
|
||||
|
||||
if (string.IsNullOrEmpty(proxyUsername) || string.IsNullOrEmpty(proxyPassword))
|
||||
{
|
||||
Credentials = CredentialCache.DefaultNetworkCredentials;
|
||||
}
|
||||
else
|
||||
{
|
||||
Credentials = new NetworkCredential(proxyUsername, proxyPassword);
|
||||
}
|
||||
|
||||
if (proxyBypassList != null)
|
||||
{
|
||||
foreach (string bypass in proxyBypassList)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bypass))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
Regex bypassRegex = new Regex(bypass.Trim(), RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.ECMAScript);
|
||||
_regExBypassList.Add(bypassRegex);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// eat all exceptions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Uri GetProxy(Uri destination)
|
||||
{
|
||||
if (IsBypassed(destination))
|
||||
{
|
||||
return destination;
|
||||
}
|
||||
else
|
||||
{
|
||||
return new Uri(_proxyAddress);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsBypassed(Uri uri)
|
||||
{
|
||||
return string.IsNullOrEmpty(_proxyAddress) || uri.IsLoopback || IsMatchInBypassList(uri);
|
||||
}
|
||||
|
||||
private bool IsMatchInBypassList(Uri input)
|
||||
{
|
||||
string matchUriString = input.IsDefaultPort ?
|
||||
input.Scheme + "://" + input.Host :
|
||||
input.Scheme + "://" + input.Host + ":" + input.Port.ToString();
|
||||
|
||||
foreach (Regex r in _regExBypassList)
|
||||
{
|
||||
if (r.IsMatch(matchUriString))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
78
src/Runner.Sdk/Util/ArgUtil.cs
Normal file
78
src/Runner.Sdk/Util/ArgUtil.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace GitHub.Runner.Sdk
|
||||
{
|
||||
public static class ArgUtil
|
||||
{
|
||||
public static void Directory(string directory, string name)
|
||||
{
|
||||
ArgUtil.NotNullOrEmpty(directory, name);
|
||||
if (!System.IO.Directory.Exists(directory))
|
||||
{
|
||||
throw new DirectoryNotFoundException(
|
||||
message: $"Directory not found: '{directory}'");
|
||||
}
|
||||
}
|
||||
|
||||
public static void Equal<T>(T expected, T actual, string name)
|
||||
{
|
||||
if (object.ReferenceEquals(expected, actual))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (object.ReferenceEquals(expected, null) ||
|
||||
!expected.Equals(actual))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
paramName: name,
|
||||
actualValue: actual,
|
||||
message: $"{name} does not equal expected value. Expected '{expected}'. Actual '{actual}'.");
|
||||
}
|
||||
}
|
||||
|
||||
public static void File(string fileName, string name)
|
||||
{
|
||||
ArgUtil.NotNullOrEmpty(fileName, name);
|
||||
if (!System.IO.File.Exists(fileName))
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
message: $"File not found: '{fileName}'",
|
||||
fileName: fileName);
|
||||
}
|
||||
}
|
||||
|
||||
public static void NotNull(object value, string name)
|
||||
{
|
||||
if (object.ReferenceEquals(value, null))
|
||||
{
|
||||
throw new ArgumentNullException(name);
|
||||
}
|
||||
}
|
||||
|
||||
public static void NotNullOrEmpty(string value, string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
throw new ArgumentNullException(name);
|
||||
}
|
||||
}
|
||||
|
||||
public static void NotEmpty(Guid value, string name)
|
||||
{
|
||||
if (value == Guid.Empty)
|
||||
{
|
||||
throw new ArgumentNullException(name);
|
||||
}
|
||||
}
|
||||
|
||||
public static void Null(object value, string name)
|
||||
{
|
||||
if (!object.ReferenceEquals(value, null))
|
||||
{
|
||||
throw new ArgumentException(message: $"{name} should be null.", paramName: name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
467
src/Runner.Sdk/Util/IOUtil.cs
Normal file
467
src/Runner.Sdk/Util/IOUtil.cs
Normal file
@@ -0,0 +1,467 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
|
||||
namespace GitHub.Runner.Sdk
|
||||
{
|
||||
public static class IOUtil
|
||||
{
|
||||
public static string ExeExtension
|
||||
{
|
||||
get
|
||||
{
|
||||
#if OS_WINDOWS
|
||||
return ".exe";
|
||||
#else
|
||||
return string.Empty;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
public static StringComparison FilePathStringComparison
|
||||
{
|
||||
get
|
||||
{
|
||||
#if OS_LINUX
|
||||
return StringComparison.Ordinal;
|
||||
#else
|
||||
return StringComparison.OrdinalIgnoreCase;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
public static void SaveObject(object obj, string path)
|
||||
{
|
||||
File.WriteAllText(path, StringUtil.ConvertToJson(obj), Encoding.UTF8);
|
||||
}
|
||||
|
||||
public static T LoadObject<T>(string path)
|
||||
{
|
||||
string json = File.ReadAllText(path, Encoding.UTF8);
|
||||
return StringUtil.ConvertFromJson<T>(json);
|
||||
}
|
||||
|
||||
public static string GetPathHash(string path)
|
||||
{
|
||||
string hashString = path.ToLowerInvariant();
|
||||
using (SHA256 sha256hash = SHA256.Create())
|
||||
{
|
||||
byte[] data = sha256hash.ComputeHash(Encoding.UTF8.GetBytes(hashString));
|
||||
StringBuilder sBuilder = new StringBuilder();
|
||||
for (int i = 0; i < data.Length; i++)
|
||||
{
|
||||
sBuilder.Append(data[i].ToString("x2"));
|
||||
}
|
||||
|
||||
string hash = sBuilder.ToString();
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Delete(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
DeleteDirectory(path, cancellationToken);
|
||||
DeleteFile(path);
|
||||
}
|
||||
|
||||
public static void DeleteDirectory(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
DeleteDirectory(path, contentsOnly: false, continueOnContentDeleteError: false, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public static void DeleteDirectory(string path, bool contentsOnly, bool continueOnContentDeleteError, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgUtil.NotNullOrEmpty(path, nameof(path));
|
||||
DirectoryInfo directory = new DirectoryInfo(path);
|
||||
if (!directory.Exists)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!contentsOnly)
|
||||
{
|
||||
// Remove the readonly flag.
|
||||
RemoveReadOnly(directory);
|
||||
|
||||
// Check if the directory is a reparse point.
|
||||
if (directory.Attributes.HasFlag(FileAttributes.ReparsePoint))
|
||||
{
|
||||
// Delete the reparse point directory and short-circuit.
|
||||
directory.Delete();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize a concurrent stack to store the directories. The directories
|
||||
// cannot be deleted until the files are deleted.
|
||||
var directories = new ConcurrentStack<DirectoryInfo>();
|
||||
|
||||
if (!contentsOnly)
|
||||
{
|
||||
directories.Push(directory);
|
||||
}
|
||||
|
||||
// Create a new token source for the parallel query. The parallel query should be
|
||||
// canceled after the first error is encountered. Otherwise the number of exceptions
|
||||
// could get out of control for a large directory with access denied on every file.
|
||||
using (var tokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
// Recursively delete all files and store all subdirectories.
|
||||
Enumerate(directory, tokenSource)
|
||||
.AsParallel()
|
||||
.WithCancellation(tokenSource.Token)
|
||||
.ForAll((FileSystemInfo item) =>
|
||||
{
|
||||
bool success = false;
|
||||
try
|
||||
{
|
||||
// Remove the readonly attribute.
|
||||
RemoveReadOnly(item);
|
||||
|
||||
// Check if the item is a file.
|
||||
if (item is FileInfo)
|
||||
{
|
||||
// Delete the file.
|
||||
item.Delete();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check if the item is a directory reparse point.
|
||||
var subdirectory = item as DirectoryInfo;
|
||||
ArgUtil.NotNull(subdirectory, nameof(subdirectory));
|
||||
if (subdirectory.Attributes.HasFlag(FileAttributes.ReparsePoint))
|
||||
{
|
||||
try
|
||||
{
|
||||
// Delete the reparse point.
|
||||
subdirectory.Delete();
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
// The target of the reparse point directory has been deleted.
|
||||
// Therefore the item is no longer a directory and is now a file.
|
||||
//
|
||||
// Deletion of reparse point directories happens in parallel. This case can occur
|
||||
// when reparse point directory FOO points to some other reparse point directory BAR,
|
||||
// and BAR is deleted after the DirectoryInfo for FOO has already been initialized.
|
||||
File.Delete(subdirectory.FullName);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Store the directory.
|
||||
directories.Push(subdirectory);
|
||||
}
|
||||
}
|
||||
|
||||
success = true;
|
||||
}
|
||||
catch (Exception) when (continueOnContentDeleteError)
|
||||
{
|
||||
// ignore any exception when continueOnContentDeleteError is true.
|
||||
success = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!success)
|
||||
{
|
||||
tokenSource.Cancel(); // Cancel is thread-safe.
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
tokenSource.Cancel();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the directories.
|
||||
foreach (DirectoryInfo dir in directories.OrderByDescending(x => x.FullName.Length))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
dir.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
public static void DeleteFile(string path)
|
||||
{
|
||||
ArgUtil.NotNullOrEmpty(path, nameof(path));
|
||||
var file = new FileInfo(path);
|
||||
if (file.Exists)
|
||||
{
|
||||
RemoveReadOnly(file);
|
||||
file.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
public static void MoveDirectory(string sourceDir, string targetDir, string stagingDir, CancellationToken token)
|
||||
{
|
||||
ArgUtil.Directory(sourceDir, nameof(sourceDir));
|
||||
ArgUtil.NotNullOrEmpty(targetDir, nameof(targetDir));
|
||||
ArgUtil.NotNullOrEmpty(stagingDir, nameof(stagingDir));
|
||||
|
||||
// delete existing stagingDir
|
||||
DeleteDirectory(stagingDir, token);
|
||||
|
||||
// make sure parent dir of stagingDir exist
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(stagingDir));
|
||||
|
||||
// move source to staging
|
||||
Directory.Move(sourceDir, stagingDir);
|
||||
|
||||
// delete existing targetDir
|
||||
DeleteDirectory(targetDir, token);
|
||||
|
||||
// make sure parent dir of targetDir exist
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetDir));
|
||||
|
||||
// move staging to target
|
||||
Directory.Move(stagingDir, targetDir);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Given a path and directory, return the path relative to the directory. If the path is not
|
||||
/// under the directory the path is returned un modified. Examples:
|
||||
/// MakeRelative(@"d:\src\project\foo.cpp", @"d:\src") -> @"project\foo.cpp"
|
||||
/// MakeRelative(@"d:\src\project\foo.cpp", @"d:\specs") -> @"d:\src\project\foo.cpp"
|
||||
/// MakeRelative(@"d:\src\project\foo.cpp", @"d:\src\proj") -> @"d:\src\project\foo.cpp"
|
||||
/// </summary>
|
||||
/// <remarks>Safe for remote paths. Does not access the local disk.</remarks>
|
||||
/// <param name="path">Path to make relative.</param>
|
||||
/// <param name="folder">Folder to make it relative to.</param>
|
||||
/// <returns>Relative path.</returns>
|
||||
public static string MakeRelative(string path, string folder)
|
||||
{
|
||||
ArgUtil.NotNullOrEmpty(path, nameof(path));
|
||||
ArgUtil.NotNull(folder, nameof(folder));
|
||||
|
||||
// Replace all Path.AltDirectorySeparatorChar with Path.DirectorySeparatorChar from both inputs
|
||||
path = path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
|
||||
folder = folder.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
|
||||
|
||||
// Check if the dir is a prefix of the path (if not, it isn't relative at all).
|
||||
if (!path.StartsWith(folder, IOUtil.FilePathStringComparison))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
// Dir is a prefix of the path, if they are the same length then the relative path is empty.
|
||||
if (path.Length == folder.Length)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// If the dir ended in a '\\' (like d:\) or '/' (like user/bin/) then we have a relative path.
|
||||
if (folder.Length > 0 && folder[folder.Length - 1] == Path.DirectorySeparatorChar)
|
||||
{
|
||||
return path.Substring(folder.Length);
|
||||
}
|
||||
// The next character needs to be a '\\' or they aren't really relative.
|
||||
else if (path[folder.Length] == Path.DirectorySeparatorChar)
|
||||
{
|
||||
return path.Substring(folder.Length + 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
public static string ResolvePath(String rootPath, String relativePath)
|
||||
{
|
||||
ArgUtil.NotNullOrEmpty(rootPath, nameof(rootPath));
|
||||
ArgUtil.NotNullOrEmpty(relativePath, nameof(relativePath));
|
||||
|
||||
if (!Path.IsPathRooted(rootPath))
|
||||
{
|
||||
throw new ArgumentException($"{rootPath} should be a rooted path.");
|
||||
}
|
||||
|
||||
if (relativePath.IndexOfAny(Path.GetInvalidPathChars()) > -1)
|
||||
{
|
||||
throw new InvalidOperationException($"{relativePath} contains invalid path characters.");
|
||||
}
|
||||
else if (Path.GetFileName(relativePath).IndexOfAny(Path.GetInvalidFileNameChars()) > -1)
|
||||
{
|
||||
throw new InvalidOperationException($"{relativePath} contains invalid folder name characters.");
|
||||
}
|
||||
else if (Path.IsPathRooted(relativePath))
|
||||
{
|
||||
throw new InvalidOperationException($"{relativePath} can not be a rooted path.");
|
||||
}
|
||||
else
|
||||
{
|
||||
rootPath = rootPath.Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
relativePath = relativePath.Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
|
||||
// Root the path
|
||||
relativePath = String.Concat(rootPath, Path.AltDirectorySeparatorChar, relativePath);
|
||||
|
||||
// Collapse ".." directories with their parent, and skip "." directories.
|
||||
String[] split = relativePath.Split(new[] { Path.AltDirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries);
|
||||
var segments = new Stack<String>(split.Length);
|
||||
Int32 skip = 0;
|
||||
for (Int32 i = split.Length - 1; i >= 0; i--)
|
||||
{
|
||||
String segment = split[i];
|
||||
if (String.Equals(segment, ".", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
else if (String.Equals(segment, "..", StringComparison.Ordinal))
|
||||
{
|
||||
skip++;
|
||||
}
|
||||
else if (skip > 0)
|
||||
{
|
||||
skip--;
|
||||
}
|
||||
else
|
||||
{
|
||||
segments.Push(segment);
|
||||
}
|
||||
}
|
||||
|
||||
if (skip > 0)
|
||||
{
|
||||
throw new InvalidOperationException($"The file path {relativePath} is invalid");
|
||||
}
|
||||
|
||||
#if OS_WINDOWS
|
||||
if (segments.Count > 1)
|
||||
{
|
||||
return String.Join(Path.DirectorySeparatorChar, segments);
|
||||
}
|
||||
else
|
||||
{
|
||||
return segments.Pop() + Path.DirectorySeparatorChar;
|
||||
}
|
||||
#else
|
||||
return Path.DirectorySeparatorChar + String.Join(Path.DirectorySeparatorChar, segments);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
public static void CopyDirectory(string source, string target, CancellationToken cancellationToken)
|
||||
{
|
||||
// Validate args.
|
||||
ArgUtil.Directory(source, nameof(source));
|
||||
ArgUtil.NotNullOrEmpty(target, nameof(target));
|
||||
ArgUtil.NotNull(cancellationToken, nameof(cancellationToken));
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Create the target directory.
|
||||
Directory.CreateDirectory(target);
|
||||
|
||||
// Get the file contents of the directory to copy.
|
||||
DirectoryInfo sourceDir = new DirectoryInfo(source);
|
||||
foreach (FileInfo sourceFile in sourceDir.GetFiles() ?? new FileInfo[0])
|
||||
{
|
||||
// Check if the file already exists.
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
FileInfo targetFile = new FileInfo(Path.Combine(target, sourceFile.Name));
|
||||
if (!targetFile.Exists ||
|
||||
sourceFile.Length != targetFile.Length ||
|
||||
sourceFile.LastWriteTime != targetFile.LastWriteTime)
|
||||
{
|
||||
// Copy the file.
|
||||
sourceFile.CopyTo(targetFile.FullName, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Copy the subdirectories.
|
||||
foreach (DirectoryInfo subDir in sourceDir.GetDirectories() ?? new DirectoryInfo[0])
|
||||
{
|
||||
CopyDirectory(
|
||||
source: subDir.FullName,
|
||||
target: Path.Combine(target, subDir.Name),
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public static void ValidateExecutePermission(string directory)
|
||||
{
|
||||
ArgUtil.Directory(directory, nameof(directory));
|
||||
string dir = directory;
|
||||
string failsafeString = Environment.GetEnvironmentVariable("AGENT_TEST_VALIDATE_EXECUTE_PERMISSIONS_FAILSAFE");
|
||||
int failsafe;
|
||||
if (string.IsNullOrEmpty(failsafeString) || !int.TryParse(failsafeString, out failsafe))
|
||||
{
|
||||
failsafe = 100;
|
||||
}
|
||||
|
||||
for (int i = 0; i < failsafe; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.EnumerateFileSystemEntries(dir).FirstOrDefault();
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
// Permission to read the directory contents is required for '{0}' and each directory up the hierarchy. {1}
|
||||
string message = $"Permission to read the directory contents is required for '{directory}' and each directory up the hierarchy. {ex.Message}";
|
||||
throw new UnauthorizedAccessException(message, ex);
|
||||
}
|
||||
|
||||
dir = Path.GetDirectoryName(dir);
|
||||
if (string.IsNullOrEmpty(dir))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// This should never happen.
|
||||
throw new NotSupportedException($"Unable to validate execute permissions for directory '{directory}'. Exceeded maximum iterations.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively enumerates a directory without following directory reparse points.
|
||||
/// </summary>
|
||||
private static IEnumerable<FileSystemInfo> Enumerate(DirectoryInfo directory, CancellationTokenSource tokenSource)
|
||||
{
|
||||
ArgUtil.NotNull(directory, nameof(directory));
|
||||
ArgUtil.Equal(false, directory.Attributes.HasFlag(FileAttributes.ReparsePoint), nameof(directory.Attributes.HasFlag));
|
||||
|
||||
// Push the directory onto the processing stack.
|
||||
var directories = new Stack<DirectoryInfo>(new[] { directory });
|
||||
while (directories.Count > 0)
|
||||
{
|
||||
// Pop the next directory.
|
||||
directory = directories.Pop();
|
||||
foreach (FileSystemInfo item in directory.GetFileSystemInfos())
|
||||
{
|
||||
// Push non-reparse-point directories onto the processing stack.
|
||||
directory = item as DirectoryInfo;
|
||||
if (directory != null &&
|
||||
!item.Attributes.HasFlag(FileAttributes.ReparsePoint))
|
||||
{
|
||||
directories.Push(directory);
|
||||
}
|
||||
|
||||
// Then yield the directory. Otherwise there is a race condition when this method attempts to initialize
|
||||
// the Attributes and the caller is deleting the reparse point in parallel (FileNotFoundException).
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void RemoveReadOnly(FileSystemInfo item)
|
||||
{
|
||||
ArgUtil.NotNull(item, nameof(item));
|
||||
if (item.Attributes.HasFlag(FileAttributes.ReadOnly))
|
||||
{
|
||||
item.Attributes = item.Attributes & ~FileAttributes.ReadOnly;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/Runner.Sdk/Util/PathUtil.cs
Normal file
36
src/Runner.Sdk/Util/PathUtil.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace GitHub.Runner.Sdk
|
||||
{
|
||||
public static class PathUtil
|
||||
{
|
||||
#if OS_WINDOWS
|
||||
public static readonly string PathVariable = "Path";
|
||||
#else
|
||||
public static readonly string PathVariable = "PATH";
|
||||
#endif
|
||||
|
||||
public static string PrependPath(string path, string currentPath)
|
||||
{
|
||||
ArgUtil.NotNullOrEmpty(path, nameof(path));
|
||||
if (string.IsNullOrEmpty(currentPath))
|
||||
{
|
||||
// Careful not to add a trailing separator if the PATH is empty.
|
||||
// On OSX/Linux, a trailing separator indicates that "current directory"
|
||||
// is added to the PATH, which is considered a security risk.
|
||||
return path;
|
||||
}
|
||||
|
||||
// Not prepend path if it is already the first path in %PATH%
|
||||
if (currentPath.StartsWith(path + Path.PathSeparator, IOUtil.FilePathStringComparison))
|
||||
{
|
||||
return currentPath;
|
||||
}
|
||||
else
|
||||
{
|
||||
return path + Path.PathSeparator + currentPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user