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 ;
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 ( ) ) ) ) ;
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
}
}