2023-06-02 21:47:59 +02:00
using System ;
2019-10-10 00:52:42 -04:00
using System.Collections.Generic ;
using System.IO ;
2022-03-28 14:31:23 -04:00
using System.Linq ;
2021-09-30 13:40:34 -04:00
using System.Net.Http ;
2022-03-28 14:31:23 -04:00
using System.Net.Http.Headers ;
2024-08-06 18:20:54 -04:00
using System.Net.Security ;
2022-03-17 21:35:20 -04:00
using System.Net.WebSockets ;
using System.Text ;
2019-10-10 00:52:42 -04:00
using System.Threading ;
using System.Threading.Tasks ;
2022-05-20 21:31:21 -04:00
using GitHub.DistributedTask.WebApi ;
2021-09-30 13:40:34 -04:00
using GitHub.Runner.Sdk ;
using GitHub.Services.Common ;
2023-05-08 18:54:34 -04:00
using GitHub.Services.OAuth ;
using GitHub.Services.Results.Client ;
2019-10-10 00:52:42 -04:00
using GitHub.Services.WebApi ;
2022-03-28 14:31:23 -04:00
using GitHub.Services.WebApi.Utilities.Internal ;
2019-10-10 00:52:42 -04:00
namespace GitHub.Runner.Common
{
[ServiceLocator(Default = typeof(JobServer))]
2022-03-17 21:35:20 -04:00
public interface IJobServer : IRunnerService , IAsyncDisposable
2019-10-10 00:52:42 -04:00
{
2021-09-15 13:21:50 -04:00
Task ConnectAsync ( VssConnection jobConnection );
2019-10-10 00:52:42 -04:00
2022-03-17 21:35:20 -04:00
void InitializeWebsocketClient ( ServiceEndpoint serviceEndpoint );
2019-10-10 00:52:42 -04:00
// logging and console
Task < TaskLog > AppendLogContentAsync ( Guid scopeIdentifier , string hubName , Guid planId , int logId , Stream uploadStream , CancellationToken cancellationToken );
2022-03-17 21:35:20 -04:00
Task AppendTimelineRecordFeedAsync ( Guid scopeIdentifier , string hubName , Guid planId , Guid timelineId , Guid timelineRecordId , Guid stepId , IList < string > lines , long? startLine , CancellationToken cancellationToken );
2019-10-10 00:52:42 -04:00
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 );
2022-02-10 01:12:13 +05:30
Task < ActionDownloadInfoCollection > ResolveActionDownloadInfoAsync ( Guid scopeIdentifier , string hubName , Guid planId , Guid jobId , ActionReferenceList actions , CancellationToken cancellationToken );
2019-10-10 00:52:42 -04:00
}
public sealed class JobServer : RunnerService , IJobServer
{
private bool _hasConnection ;
private VssConnection _connection ;
private TaskHttpClient _taskClient ;
2022-03-17 21:35:20 -04:00
private ClientWebSocket _websocketClient ;
private ServiceEndpoint _serviceEndpoint ;
private int totalBatchedLinesAttemptedByWebsocket = 0 ;
private int failedAttemptsToPostBatchedLinesByWebsocket = 0 ;
private static readonly TimeSpan _minDelayForWebsocketReconnect = TimeSpan . FromMilliseconds ( 100 );
private static readonly TimeSpan _maxDelayForWebsocketReconnect = TimeSpan . FromMilliseconds ( 500 );
private static readonly int _minWebsocketFailurePercentageAllowed = 50 ;
private static readonly int _minWebsocketBatchedLinesCountToConsider = 5 ;
private Task _websocketConnectTask ;
2019-10-10 00:52:42 -04:00
2021-09-15 13:21:50 -04:00
public async Task ConnectAsync ( VssConnection jobConnection )
2019-10-10 00:52:42 -04:00
{
2021-09-15 13:21:50 -04:00
_connection = jobConnection ;
2021-10-29 16:04:34 -04:00
int totalAttempts = 5 ;
int attemptCount = totalAttempts ;
2021-09-30 13:40:34 -04:00
var configurationStore = HostContext . GetService < IConfigurationStore >();
var runnerSettings = configurationStore . GetSettings ();
2022-02-10 01:12:13 +05:30
2021-09-15 13:21:50 -04:00
while (! _connection . HasAuthenticated && attemptCount -- > 0 )
2019-10-10 00:52:42 -04:00
{
try
{
2021-09-15 13:21:50 -04:00
await _connection . ConnectAsync ();
2019-10-10 00:52:42 -04:00
break ;
}
catch ( Exception ex ) when ( attemptCount > 0 )
{
2021-09-30 13:40:34 -04:00
Trace . Info ( $"Catch exception during connect. {attemptCount} attempts left." );
2019-10-10 00:52:42 -04:00
Trace . Error ( ex );
2021-09-30 13:40:34 -04:00
if ( runnerSettings . IsHostedServer )
{
2021-10-29 16:04:34 -04:00
await CheckNetworkEndpointsAsync ( attemptCount );
2021-09-30 13:40:34 -04:00
}
2019-10-10 00:52:42 -04:00
}
2021-10-29 16:04:34 -04:00
int attempt = totalAttempts - attemptCount ;
TimeSpan backoff = BackoffTimerHelper . GetExponentialBackoff ( attempt , TimeSpan . FromMilliseconds ( 100 ), TimeSpan . FromSeconds ( 3.2 ), TimeSpan . FromMilliseconds ( 100 ));
await Task . Delay ( backoff );
2019-10-10 00:52:42 -04:00
}
_taskClient = _connection . GetClient < TaskHttpClient >();
_hasConnection = true ;
}
2021-10-29 16:04:34 -04:00
private async Task CheckNetworkEndpointsAsync ( int attemptsLeft )
2021-09-30 13:40:34 -04:00
{
try
{
Trace . Info ( "Requesting Actions Service health endpoint status" );
using ( var httpClientHandler = HostContext . CreateHttpClientHandler ())
using ( var actionsClient = new HttpClient ( httpClientHandler ))
{
var baseUri = new Uri ( _connection . Uri . GetLeftPart ( UriPartial . Authority ));
actionsClient . DefaultRequestHeaders . UserAgent . AddRange ( HostContext . UserAgents );
2021-10-29 16:04:34 -04:00
// Call the _apis/health endpoint, and include how many attempts are left as a URL query for easy tracking
var response = await actionsClient . GetAsync ( new Uri ( baseUri , $"_apis/health?_internalRunnerAttemptsLeft={attemptsLeft}" ));
2021-09-30 13:40:34 -04:00
Trace . Info ( $"Actions health status code: {response.StatusCode}" );
}
}
catch ( Exception ex )
{
// Log error, but continue as this call is best-effort
Trace . Info ( $"Actions Service health endpoint failed due to {ex.GetType().Name}" );
Trace . Error ( ex );
}
try
{
Trace . Info ( "Requesting Github API endpoint status" );
// This is a dotcom public API... just call it directly
using ( var httpClientHandler = HostContext . CreateHttpClientHandler ())
using ( var gitHubClient = new HttpClient ( httpClientHandler ))
{
gitHubClient . DefaultRequestHeaders . UserAgent . AddRange ( HostContext . UserAgents );
2021-10-29 16:04:34 -04:00
// Call the api.github.com endpoint, and include how many attempts are left as a URL query for easy tracking
var response = await gitHubClient . GetAsync ( $"https://api.github.com?_internalRunnerAttemptsLeft={attemptsLeft}" );
2021-09-30 13:40:34 -04:00
Trace . Info ( $"api.github.com status code: {response.StatusCode}" );
}
}
catch ( Exception ex )
{
// Log error, but continue as this call is best-effort
Trace . Info ( $"Github API endpoint failed due to {ex.GetType().Name}" );
Trace . Error ( ex );
}
}
2022-03-17 21:35:20 -04:00
public void InitializeWebsocketClient ( ServiceEndpoint serviceEndpoint )
{
2022-05-20 21:31:21 -04:00
this . _serviceEndpoint = serviceEndpoint ;
InitializeWebsocketClient ( TimeSpan . Zero );
2022-03-17 21:35:20 -04:00
}
public ValueTask DisposeAsync ()
{
2022-03-28 14:41:21 -04:00
CloseWebSocket ( WebSocketCloseStatus . NormalClosure , CancellationToken . None );
2022-03-17 21:35:20 -04:00
GC . SuppressFinalize ( this );
2022-03-28 14:41:21 -04:00
2022-03-17 21:35:20 -04:00
return ValueTask . CompletedTask ;
}
2019-10-10 00:52:42 -04:00
private void CheckConnection ()
{
if (! _hasConnection )
{
throw new InvalidOperationException ( "SetConnection" );
}
}
2022-03-17 21:35:20 -04:00
private void InitializeWebsocketClient ( TimeSpan delay )
{
2022-05-20 21:31:21 -04:00
if ( _serviceEndpoint . Authorization != null &&
_serviceEndpoint . Authorization . Parameters . TryGetValue ( EndpointAuthorizationParameters . AccessToken , out var accessToken ) &&
! string . IsNullOrEmpty ( accessToken ))
2022-03-17 21:35:20 -04:00
{
if ( _serviceEndpoint . Data . TryGetValue ( "FeedStreamUrl" , out var feedStreamUrl ) && ! string . IsNullOrEmpty ( feedStreamUrl ))
{
// let's ensure we use the right scheme
feedStreamUrl = feedStreamUrl . Replace ( "https://" , "wss://" ). Replace ( "http://" , "ws://" );
Trace . Info ( $"Creating websocket client ..." + feedStreamUrl );
this . _websocketClient = new ClientWebSocket ();
this . _websocketClient . Options . SetRequestHeader ( "Authorization" , $"Bearer {accessToken}" );
2022-03-28 14:31:23 -04:00
var userAgentValues = new List < ProductInfoHeaderValue >();
userAgentValues . AddRange ( UserAgentUtility . GetDefaultRestUserAgent ());
userAgentValues . AddRange ( HostContext . UserAgents );
2022-05-20 21:31:21 -04:00
this . _websocketClient . Options . SetRequestHeader ( "User-Agent" , string . Join ( " " , userAgentValues . Select ( x => x . ToString ())));
2024-08-06 18:20:54 -04:00
if ( StringUtil . ConvertToBoolean ( Environment . GetEnvironmentVariable ( "GITHUB_ACTIONS_RUNNER_TLS_NO_VERIFY" )))
{
this . _websocketClient . Options . RemoteCertificateValidationCallback = ( _ , _ , _ , _ ) => true ;
}
2022-03-28 14:31:23 -04:00
2022-03-17 21:35:20 -04:00
this . _websocketConnectTask = ConnectWebSocketClient ( feedStreamUrl , delay );
}
else
{
Trace . Info ( $"No FeedStreamUrl found, so we will use Rest API calls for sending feed data" );
}
}
else
{
Trace . Info ( $"No access token from the service endpoint" );
}
}
private async Task ConnectWebSocketClient ( string feedStreamUrl , TimeSpan delay )
{
try
{
Trace . Info ( $"Attempting to start websocket client with delay {delay}." );
await Task . Delay ( delay );
2023-04-19 10:20:00 -04:00
using var connectTimeoutTokenSource = new CancellationTokenSource ( TimeSpan . FromSeconds ( 30 ));
await this . _websocketClient . ConnectAsync ( new Uri ( feedStreamUrl ), connectTimeoutTokenSource . Token );
2022-03-17 21:35:20 -04:00
Trace . Info ( $"Successfully started websocket client." );
}
2022-05-20 21:31:21 -04:00
catch ( Exception ex )
2022-03-17 21:35:20 -04:00
{
Trace . Info ( "Exception caught during websocket client connect, fallback of HTTP would be used now instead of websocket." );
Trace . Error ( ex );
2023-04-19 10:20:00 -04:00
this . _websocketClient = null ;
2022-03-17 21:35:20 -04:00
}
}
2019-10-10 00:52:42 -04:00
//-----------------------------------------------------------------
// Feedback: WebConsole, TimelineRecords and Logs
//-----------------------------------------------------------------
public Task < TaskLog > AppendLogContentAsync ( Guid scopeIdentifier , string hubName , Guid planId , int logId , Stream uploadStream , CancellationToken cancellationToken )
{
CheckConnection ();
return _taskClient . AppendLogContentAsync ( scopeIdentifier , hubName , planId , logId , uploadStream , cancellationToken : cancellationToken );
}
2022-03-17 21:35:20 -04:00
public async Task AppendTimelineRecordFeedAsync ( Guid scopeIdentifier , string hubName , Guid planId , Guid timelineId , Guid timelineRecordId , Guid stepId , IList < string > lines , long? startLine , CancellationToken cancellationToken )
2019-10-10 00:52:42 -04:00
{
CheckConnection ();
2022-03-17 21:35:20 -04:00
var pushedLinesViaWebsocket = false ;
if ( _websocketConnectTask != null )
{
await _websocketConnectTask ;
}
2019-10-10 00:52:42 -04:00
2022-03-17 21:35:20 -04:00
// "_websocketClient != null" implies either: We have a successful connection OR we have to attempt sending again and then reconnect
// ...in other words, if websocket client is null, we will skip sending to websocket and just use rest api calls to send data
if ( _websocketClient != null )
{
2022-05-20 21:31:21 -04:00
var linesWrapper = startLine . HasValue ? new TimelineRecordFeedLinesWrapper ( stepId , lines , startLine . Value ) : new TimelineRecordFeedLinesWrapper ( stepId , lines );
2022-03-17 21:35:20 -04:00
var jsonData = StringUtil . ConvertToJson ( linesWrapper );
try
{
totalBatchedLinesAttemptedByWebsocket ++;
var jsonDataBytes = Encoding . UTF8 . GetBytes ( jsonData );
// break the message into chunks of 1024 bytes
for ( var i = 0 ; i < jsonDataBytes . Length ; i += 1 * 1024 )
{
var lastChunk = i + ( 1 * 1024 ) >= jsonDataBytes . Length ;
var chunk = new ArraySegment < byte >( jsonDataBytes , i , Math . Min ( 1 * 1024 , jsonDataBytes . Length - i ));
2022-05-20 21:31:21 -04:00
await _websocketClient . SendAsync ( chunk , WebSocketMessageType . Text , endOfMessage : lastChunk , cancellationToken );
2022-03-17 21:35:20 -04:00
}
pushedLinesViaWebsocket = true ;
}
catch ( Exception ex )
{
failedAttemptsToPostBatchedLinesByWebsocket ++;
Trace . Info ( $"Caught exception during append web console line to websocket, let's fallback to sending via non-websocket call (total calls: {totalBatchedLinesAttemptedByWebsocket}, failed calls: {failedAttemptsToPostBatchedLinesByWebsocket}, websocket state: {this._websocketClient?.State})." );
2023-05-08 18:54:34 -04:00
Trace . Verbose ( ex . ToString ());
2022-03-17 21:35:20 -04:00
if ( totalBatchedLinesAttemptedByWebsocket > _minWebsocketBatchedLinesCountToConsider )
{
// let's consider failure percentage
if ( failedAttemptsToPostBatchedLinesByWebsocket * 100 / totalBatchedLinesAttemptedByWebsocket > _minWebsocketFailurePercentageAllowed )
{
Trace . Info ( $"Exhausted websocket allowed retries, we will not attempt websocket connection for this job to post lines again." );
2022-03-28 14:41:21 -04:00
CloseWebSocket ( WebSocketCloseStatus . InternalServerError , cancellationToken );
2022-03-17 21:35:20 -04:00
// By setting it to null, we will ensure that we never try websocket path again for this job
_websocketClient = null ;
}
}
if ( _websocketClient != null )
{
var delay = BackoffTimerHelper . GetRandomBackoff ( _minDelayForWebsocketReconnect , _maxDelayForWebsocketReconnect );
Trace . Info ( $"Websocket is not open, let's attempt to connect back again with random backoff {delay} ms (total calls: {totalBatchedLinesAttemptedByWebsocket}, failed calls: {failedAttemptsToPostBatchedLinesByWebsocket})." );
InitializeWebsocketClient ( delay );
}
}
}
2022-05-20 21:31:21 -04:00
if (! pushedLinesViaWebsocket && ! cancellationToken . IsCancellationRequested )
2022-03-17 21:35:20 -04:00
{
if ( startLine . HasValue )
{
await _taskClient . AppendTimelineRecordFeedAsync ( scopeIdentifier , hubName , planId , timelineId , timelineRecordId , stepId , lines , startLine . Value , cancellationToken : cancellationToken );
}
else
{
await _taskClient . AppendTimelineRecordFeedAsync ( scopeIdentifier , hubName , planId , timelineId , timelineRecordId , stepId , lines , cancellationToken : cancellationToken );
}
}
2020-08-25 12:02:29 -04:00
}
2022-03-28 14:41:21 -04:00
private void CloseWebSocket ( WebSocketCloseStatus closeStatus , CancellationToken cancellationToken )
{
try
{
_websocketClient ?. CloseOutputAsync ( closeStatus , "Closing websocket" , cancellationToken );
}
catch ( Exception websocketEx )
{
// In some cases this might be okay since the websocket might be open yet, so just close and don't trace exceptions
Trace . Info ( $"Failed to close websocket gracefully {websocketEx.GetType().Name}" );
}
}
2019-10-10 00:52:42 -04:00
public Task < TaskAttachment > CreateAttachmentAsync ( Guid scopeIdentifier , string hubName , Guid planId , Guid timelineId , Guid timelineRecordId , string type , string name , Stream uploadStream , CancellationToken cancellationToken )
{
CheckConnection ();
return _taskClient . CreateAttachmentAsync ( scopeIdentifier , hubName , planId , timelineId , timelineRecordId , type , name , uploadStream , cancellationToken : cancellationToken );
}
2022-12-14 00:28:33 -08:00
2019-10-10 00:52:42 -04:00
public Task < TaskLog > CreateLogAsync ( Guid scopeIdentifier , string hubName , Guid planId , TaskLog log , CancellationToken cancellationToken )
{
CheckConnection ();
return _taskClient . CreateLogAsync ( scopeIdentifier , hubName , planId , log , cancellationToken : cancellationToken );
}
public Task < Timeline > CreateTimelineAsync ( Guid scopeIdentifier , string hubName , Guid planId , Guid timelineId , CancellationToken cancellationToken )
{
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 )
{
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
{
CheckConnection ();
return _taskClient . RaisePlanEventAsync ( scopeIdentifier , hubName , planId , eventData , cancellationToken : cancellationToken );
}
public Task < Timeline > GetTimelineAsync ( Guid scopeIdentifier , string hubName , Guid planId , Guid timelineId , CancellationToken cancellationToken )
{
CheckConnection ();
return _taskClient . GetTimelineAsync ( scopeIdentifier , hubName , planId , timelineId , includeRecords : true , cancellationToken : cancellationToken );
}
2020-06-09 08:53:28 -04:00
//-----------------------------------------------------------------
// Action download info
//-----------------------------------------------------------------
2022-02-10 01:12:13 +05:30
public Task < ActionDownloadInfoCollection > ResolveActionDownloadInfoAsync ( Guid scopeIdentifier , string hubName , Guid planId , Guid jobId , ActionReferenceList actions , CancellationToken cancellationToken )
2020-06-09 08:53:28 -04:00
{
CheckConnection ();
2022-02-10 01:12:13 +05:30
return _taskClient . ResolveActionDownloadInfoAsync ( scopeIdentifier , hubName , planId , jobId , actions , cancellationToken : cancellationToken );
2020-06-09 08:53:28 -04:00
}
2019-10-10 00:52:42 -04:00
}
}