diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 67018945e05..0956c1d4149 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,7 +4,7 @@ "features": { "ghcr.io/devcontainers/features/docker-in-docker:1": {}, "ghcr.io/devcontainers/features/dotnet": { - "version": "8.0.407" + "version": "8.0.408" }, "ghcr.io/devcontainers/features/node:1": { "version": "20" diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000000..834c1663261 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,25 @@ +## Making changes + +### Tests + +Whenever possible, changes should be accompanied by non-trivial tests that meaningfully exercise the core functionality of the new code being introduced. + +All tests are in the `Test/` directory at the repo root. Fast unit tests are in the `Test/L0` directory and by convention have the suffix `L0.cs`. For example: unit tests for a hypothetical `src/Runner.Worker/Foo.cs` would go in `src/Test/L0/Worker/FooL0.cs`. + +Run tests using this command: + +```sh +cd src && ./dev.sh test +``` + +### Formatting + +After editing .cs files, always format the code using this command: + +```sh +cd src && ./dev.sh format +``` + +### Feature Flags + +Wherever possible, all changes should be safeguarded by a feature flag; `Features` are declared in [Constants.cs](src/Runner.Common/Constants.cs). diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2661416c8df..90e76971166 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,6 @@ jobs: with: github-token: ${{secrets.GITHUB_TOKEN}} script: | - const core = require('@actions/core') const fs = require('fs'); const runnerVersion = fs.readFileSync('${{ github.workspace }}/src/runnerversion', 'utf8').replace(/\n$/g, '') const releaseVersion = fs.readFileSync('${{ github.workspace }}/releaseVersion', 'utf8').replace(/\n$/g, '') @@ -30,7 +29,7 @@ jobs: return } try { - const release = await github.repos.getReleaseByTag({ + const release = await github.rest.repos.getReleaseByTag({ owner: '${{ github.event.repository.owner.name }}', repo: '${{ github.event.repository.name }}', tag: 'v' + runnerVersion @@ -176,7 +175,6 @@ jobs: with: github-token: ${{secrets.GITHUB_TOKEN}} script: | - const core = require('@actions/core') const fs = require('fs'); const runnerVersion = fs.readFileSync('${{ github.workspace }}/src/runnerversion', 'utf8').replace(/\n$/g, '') var releaseNote = fs.readFileSync('${{ github.workspace }}/releaseNote.md', 'utf8').replace(//g, runnerVersion) @@ -216,7 +214,7 @@ jobs: # Upload release assets (full runner packages) - name: Upload Release Asset (win-x64) - uses: actions/upload-release-asset@v1.0.1 + uses: actions/upload-release-asset@v1.0.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -226,7 +224,7 @@ jobs: asset_content_type: application/octet-stream - name: Upload Release Asset (win-arm64) - uses: actions/upload-release-asset@v1.0.1 + uses: actions/upload-release-asset@v1.0.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -236,7 +234,7 @@ jobs: asset_content_type: application/octet-stream - name: Upload Release Asset (linux-x64) - uses: actions/upload-release-asset@v1.0.1 + uses: actions/upload-release-asset@v1.0.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -246,7 +244,7 @@ jobs: asset_content_type: application/octet-stream - name: Upload Release Asset (osx-x64) - uses: actions/upload-release-asset@v1.0.1 + uses: actions/upload-release-asset@v1.0.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -256,7 +254,7 @@ jobs: asset_content_type: application/octet-stream - name: Upload Release Asset (osx-arm64) - uses: actions/upload-release-asset@v1.0.1 + uses: actions/upload-release-asset@v1.0.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -266,7 +264,7 @@ jobs: asset_content_type: application/octet-stream - name: Upload Release Asset (linux-arm) - uses: actions/upload-release-asset@v1.0.1 + uses: actions/upload-release-asset@v1.0.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -276,7 +274,7 @@ jobs: asset_content_type: application/octet-stream - name: Upload Release Asset (linux-arm64) - uses: actions/upload-release-asset@v1.0.1 + uses: actions/upload-release-asset@v1.0.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/docs/adrs/0276-problem-matchers.md b/docs/adrs/0276-problem-matchers.md index bed1f75ff1e..5d7a034d179 100644 --- a/docs/adrs/0276-problem-matchers.md +++ b/docs/adrs/0276-problem-matchers.md @@ -250,6 +250,42 @@ Two problem matchers can be used: } ``` +#### Default from path + +The problem matcher can specify a `fromPath` property at the top level, which applies when a specific pattern doesn't provide a value for `fromPath`. This is useful for tools that don't include project file information in their output. + +For example, given the following compiler output that doesn't include project file information: + +``` +ClassLibrary.cs(16,24): warning CS0612: 'ClassLibrary.Helpers.MyHelper.Name' is obsolete +``` + +A problem matcher with a default from path can be used: + +```json +{ + "problemMatcher": [ + { + "owner": "csc-minimal", + "fromPath": "ClassLibrary/ClassLibrary.csproj", + "pattern": [ + { + "regexp": "^(.+)\\((\\d+),(\\d+)\\): (error|warning) (.+): (.*)$", + "file": 1, + "line": 2, + "column": 3, + "severity": 4, + "code": 5, + "message": 6 + } + ] + } + ] +} +``` + +This ensures that the file is rooted to the correct path when there's not enough information in the error messages to extract a `fromPath`. + #### Mitigate regular expression denial of service (ReDos) If a matcher exceeds a 1 second timeout when processing a line, retry up to two three times total. diff --git a/images/Dockerfile b/images/Dockerfile index 532905bf11f..38c76814ce3 100644 --- a/images/Dockerfile +++ b/images/Dockerfile @@ -4,9 +4,9 @@ FROM mcr.microsoft.com/dotnet/runtime-deps:8.0-jammy AS build ARG TARGETOS ARG TARGETARCH ARG RUNNER_VERSION -ARG RUNNER_CONTAINER_HOOKS_VERSION=0.6.1 -ARG DOCKER_VERSION=28.0.1 -ARG BUILDX_VERSION=0.21.2 +ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0 +ARG DOCKER_VERSION=28.1.1 +ARG BUILDX_VERSION=0.23.0 RUN apt update -y && apt install curl unzip -y diff --git a/releaseNote.md b/releaseNote.md index 82481c6e66f..4ebc703f006 100644 --- a/releaseNote.md +++ b/releaseNote.md @@ -1,36 +1,37 @@ ## What's Changed -* Bump docker/login-action from 2 to 3 by @dependabot in https://github.com/actions/runner/pull/3673 -* Bump actions/stale from 8 to 9 by @dependabot in https://github.com/actions/runner/pull/3554 -* Bump docker/build-push-action from 3 to 6 by @dependabot in https://github.com/actions/runner/pull/3674 -* update node version from 20.18.0 -> 20.18.2 by @aiqiaoy in https://github.com/actions/runner/pull/3682 -* Pass BillingOwnerId through Acquire/Complete calls by @luketomlinson in https://github.com/actions/runner/pull/3689 -* Do not retry CompleteJobAsync for known non-retryable errors by @ericsciple in https://github.com/actions/runner/pull/3696 -* Update dotnet sdk to latest version @8.0.406 by @github-actions in https://github.com/actions/runner/pull/3712 -* Update Dockerfile with new docker and buildx versions by @thboop in https://github.com/actions/runner/pull/3680 -* chore: remove redundant words by @finaltrip in https://github.com/actions/runner/pull/3705 -* fix: actions feedback link is incorrect by @Yaminyam in https://github.com/actions/runner/pull/3165 -* Bump actions/github-script from 0.3.0 to 7.0.1 by @dependabot in https://github.com/actions/runner/pull/3557 -* Docker container provenance by @paveliak in https://github.com/actions/runner/pull/3736 -* Add request-id to http eventsource trace. by @TingluoHuang in https://github.com/actions/runner/pull/3740 -* Update Bocker and Buildx version to mitigate images scanners alerts by @Blizter in https://github.com/actions/runner/pull/3750 -* Fix typo, add invariant culture to timestamp for workflow log reporting by @GhadimiR in https://github.com/actions/runner/pull/3749 -* Create vssconnection to actions service when URL provided. by @TingluoHuang in https://github.com/actions/runner/pull/3751 -* Housekeeping: Update npm packages and node version by @thboop in https://github.com/actions/runner/pull/3752 -* Improve the out-of-date warning message. by @tecimovic in https://github.com/actions/runner/pull/3595 -* Update dotnet sdk to latest version @8.0.407 by @github-actions in https://github.com/actions/runner/pull/3753 -* Exit hosted runner cleanly during deprovisioning. by @TingluoHuang in https://github.com/actions/runner/pull/3755 -* Send annotation title to run-service. by @TingluoHuang in https://github.com/actions/runner/pull/3757 -* Allow server enforce runner settings. by @TingluoHuang in https://github.com/actions/runner/pull/3758 -* Support refresh runner configs with pipelines service. by @TingluoHuang in https://github.com/actions/runner/pull/3706 +* Increase error body max length before truncation by @ericsciple in https://github.com/actions/runner/pull/3762 +* Fix release.yml break by upgrading actions/github-script by @TingluoHuang in https://github.com/actions/runner/pull/3772 +* Small runner code cleanup. by @TingluoHuang in https://github.com/actions/runner/pull/3773 +* Enable hostcontext to track auth migration. by @TingluoHuang in https://github.com/actions/runner/pull/3776 +* Add option in OAuthCred to load authUrlV2. by @TingluoHuang in https://github.com/actions/runner/pull/3777 +* Remove create session with broker in MessageListener. by @TingluoHuang in https://github.com/actions/runner/pull/3782 +* Enable auth migration based on config refresh. by @TingluoHuang in https://github.com/actions/runner/pull/3786 +* Set JWT.alg to PS256 with PssPadding. by @TingluoHuang in https://github.com/actions/runner/pull/3789 +* Enable FIPS by default. by @TingluoHuang in https://github.com/actions/runner/pull/3793 +* Support auth migration using authUrlV2 in Runner/MessageListener. by @TingluoHuang in https://github.com/actions/runner/pull/3787 +* Cleanup feature flag actions_skip_retry_complete_job_upon_known_errors by @ericsciple in https://github.com/actions/runner/pull/3806 +* Update dotnet sdk to latest version @8.0.408 by @github-actions in https://github.com/actions/runner/pull/3808 +* Bump hook to 0.7.0 by @nikola-jokic in https://github.com/actions/runner/pull/3813 +* Allow enable auth migration by default. by @TingluoHuang in https://github.com/actions/runner/pull/3804 +* Do not retry /renewjob on 404 by @ericsciple in https://github.com/actions/runner/pull/3828 +* Bump Microsoft.NET.Test.Sdk from 17.12.0 to 17.13.0 in /src by @dependabot in https://github.com/actions/runner/pull/3719 +* Add copilot-instructions.md by @pje in https://github.com/actions/runner/pull/3810 +* Bump actions/upload-release-asset from 1.0.1 to 1.0.2 by @dependabot in https://github.com/actions/runner/pull/3553 +* Ignore exception during auth migration. by @TingluoHuang in https://github.com/actions/runner/pull/3835 +* feat: default fromPath for problem matchers by @dsanders11 in https://github.com/actions/runner/pull/3802 +* Bump Azure.Storage.Blobs from 12.23.0 to 12.24.0 in /src by @dependabot in https://github.com/actions/runner/pull/3837 +* Bump nodejs version. by @TingluoHuang in https://github.com/actions/runner/pull/3840 +* Feature-flagged support for `JobContext.CheckRunID` by @pje in https://github.com/actions/runner/pull/3811 +* Bump System.ServiceProcess.ServiceController from 8.0.0 to 8.0.1 in /src by @dependabot in https://github.com/actions/runner/pull/3844 +* Bump xunit.runner.visualstudio from 2.5.8 to 2.8.2 in /src by @dependabot in https://github.com/actions/runner/pull/3845 +* Make sure the token's claims are match as expected. by @TingluoHuang in https://github.com/actions/runner/pull/3846 +* Prefer _migrated config on startup by @lokesh755 in https://github.com/actions/runner/pull/3853 +* Update docker and buildx by @TingluoHuang in https://github.com/actions/runner/pull/3854 ## New Contributors -* @finaltrip made their first contribution in https://github.com/actions/runner/pull/3705 -* @Yaminyam made their first contribution in https://github.com/actions/runner/pull/3165 -* @Blizter made their first contribution in https://github.com/actions/runner/pull/3750 -* @GhadimiR made their first contribution in https://github.com/actions/runner/pull/3749 -* @tecimovic made their first contribution in https://github.com/actions/runner/pull/3595 +* @dsanders11 made their first contribution in https://github.com/actions/runner/pull/3802 -**Full Changelog**: https://github.com/actions/runner/compare/v2.322.0...v2.323.0 +**Full Changelog**: https://github.com/actions/runner/compare/v2.323.0...v2.324.0 _Note: Actions Runner follows a progressive release policy, so the latest release might not be available to your enterprise, organization, or repository yet. To confirm which version of the Actions Runner you should expect, please view the download instructions for your enterprise, organization, or repository. diff --git a/src/Misc/externals.sh b/src/Misc/externals.sh index 9523a7962b4..b2216ba2ddf 100755 --- a/src/Misc/externals.sh +++ b/src/Misc/externals.sh @@ -6,7 +6,7 @@ NODE_URL=https://nodejs.org/dist NODE_ALPINE_URL=https://github.com/actions/alpine_nodejs/releases/download # When you update Node versions you must also create a new release of alpine_nodejs at that updated version. # Follow the instructions here: https://github.com/actions/alpine_nodejs?tab=readme-ov-file#getting-started -NODE20_VERSION="20.19.0" +NODE20_VERSION="20.19.1" get_abs_path() { # exploits the fact that pwd will print abs path when no args diff --git a/src/Runner.Common/AuthMigration.cs b/src/Runner.Common/AuthMigration.cs new file mode 100644 index 00000000000..a951215f001 --- /dev/null +++ b/src/Runner.Common/AuthMigration.cs @@ -0,0 +1,13 @@ +using System; + +namespace GitHub.Runner.Common +{ + public class AuthMigrationEventArgs : EventArgs + { + public AuthMigrationEventArgs(string trace) + { + Trace = trace; + } + public string Trace { get; private set; } + } +} diff --git a/src/Runner.Common/BrokerServer.cs b/src/Runner.Common/BrokerServer.cs index b0774178f02..1f6c01685bd 100644 --- a/src/Runner.Common/BrokerServer.cs +++ b/src/Runner.Common/BrokerServer.cs @@ -37,6 +37,7 @@ public sealed class BrokerServer : RunnerService, IBrokerServer public async Task ConnectAsync(Uri serverUri, VssCredentials credentials) { + Trace.Entering(); _brokerUri = serverUri; _connection = VssUtil.CreateRawConnection(serverUri, credentials); @@ -88,7 +89,12 @@ public Task UpdateConnectionIfNeeded(Uri serverUri, VssCredentials credentials) public Task ForceRefreshConnection(VssCredentials credentials) { - return ConnectAsync(_brokerUri, credentials); + if (!string.IsNullOrEmpty(_brokerUri?.AbsoluteUri)) + { + return ConnectAsync(_brokerUri, credentials); + } + + return Task.CompletedTask; } public bool ShouldRetryException(Exception ex) diff --git a/src/Runner.Common/ConfigurationStore.cs b/src/Runner.Common/ConfigurationStore.cs index 576360c6f4d..8d47f96c09d 100644 --- a/src/Runner.Common/ConfigurationStore.cs +++ b/src/Runner.Common/ConfigurationStore.cs @@ -116,6 +116,7 @@ public interface IConfigurationStore : IRunnerService bool IsConfigured(); bool IsServiceConfigured(); bool HasCredentials(); + bool IsMigratedConfigured(); CredentialData GetCredentials(); CredentialData GetMigratedCredentials(); RunnerSettings GetSettings(); @@ -198,6 +199,14 @@ public bool IsServiceConfigured() return serviceConfigured; } + public bool IsMigratedConfigured() + { + Trace.Info("IsMigratedConfigured()"); + bool configured = new FileInfo(_migratedConfigFilePath).Exists; + Trace.Info("IsMigratedConfigured: {0}", configured); + return configured; + } + public CredentialData GetCredentials() { if (_creds == null) diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs index 041b5be9a37..51503142400 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -155,15 +155,19 @@ public static class ReturnCode public const int RunnerUpdating = 3; public const int RunOnceRunnerUpdating = 4; public const int SessionConflict = 5; + // Temporary error code to indicate that the runner configuration has been refreshed + // and the runner should be restarted. This is a temporary code and will be removed in the future after + // the runner is migrated to runner admin. + public const int RunnerConfigurationRefreshed = 6; } public static class Features { public static readonly string DiskSpaceWarning = "runner.diskspace.warning"; public static readonly string LogTemplateErrorsAsDebugMessages = "DistributedTask.LogTemplateErrorsAsDebugMessages"; - public static readonly string SkipRetryCompleteJobUponKnownErrors = "actions_skip_retry_complete_job_upon_known_errors"; public static readonly string UseContainerPathForTemplate = "DistributedTask.UseContainerPathForTemplate"; public static readonly string AllowRunnerContainerHooks = "DistributedTask.AllowRunnerContainerHooks"; + public static readonly string AddCheckRunIdToJobContext = "actions_add_check_run_id_to_job_context"; } public static readonly string InternalTelemetryIssueDataKey = "_internal_telemetry"; diff --git a/src/Runner.Common/HostContext.cs b/src/Runner.Common/HostContext.cs index 8475cd43b91..73ca108ae9d 100644 --- a/src/Runner.Common/HostContext.cs +++ b/src/Runner.Common/HostContext.cs @@ -37,6 +37,11 @@ public interface IHostContext : IDisposable void ShutdownRunner(ShutdownReason reason); void WritePerfCounter(string counter); void LoadDefaultUserAgents(); + + bool AllowAuthMigration { get; } + void EnableAuthMigration(string trace); + void DeferAuthMigration(TimeSpan deferred, string trace); + event EventHandler AuthMigrationChanged; } public enum StartupType @@ -70,12 +75,21 @@ public sealed class HostContext : EventListener, IObserver, private RunnerWebProxy _webProxy = new(); private string _hostType = string.Empty; + // disable auth migration by default + private readonly ManualResetEventSlim _allowAuthMigration = new ManualResetEventSlim(false); + private DateTime _deferredAuthMigrationTime = DateTime.MaxValue; + private readonly object _authMigrationLock = new object(); + private CancellationTokenSource _authMigrationAutoReenableTaskCancellationTokenSource = new(); + private Task _authMigrationAutoReenableTask; + public event EventHandler Unloading; + public event EventHandler AuthMigrationChanged; public CancellationToken RunnerShutdownToken => _runnerShutdownTokenSource.Token; public ShutdownReason RunnerShutdownReason { get; private set; } public ISecretMasker SecretMasker => _secretMasker; public List UserAgents => _userAgents; public RunnerWebProxy WebProxy => _webProxy; + public bool AllowAuthMigration => _allowAuthMigration.IsSet; public HostContext(string hostType, string logFile = null) { // Validate args. @@ -207,6 +221,71 @@ public HostContext(string hostType, string logFile = null) LoadDefaultUserAgents(); } + // marked as internal for testing + internal async Task AuthMigrationAuthReenableAsync(TimeSpan refreshInterval, CancellationToken token) + { + try + { + while (!token.IsCancellationRequested) + { + _trace.Verbose($"Auth migration defer timer is set to expire at {_deferredAuthMigrationTime.ToString("O")}. AllowAuthMigration: {_allowAuthMigration.IsSet}."); + await Task.Delay(refreshInterval, token); + if (!_allowAuthMigration.IsSet && DateTime.UtcNow > _deferredAuthMigrationTime) + { + _trace.Info($"Auth migration defer timer expired. Allowing auth migration."); + EnableAuthMigration("Auth migration defer timer expired."); + } + } + } + catch (TaskCanceledException) + { + // Task was cancelled, exit the loop. + } + catch (Exception ex) + { + _trace.Info("Error in auth migration reenable task."); + _trace.Error(ex); + } + } + + public void EnableAuthMigration(string trace) + { + _allowAuthMigration.Set(); + + lock (_authMigrationLock) + { + if (_authMigrationAutoReenableTask == null) + { + var refreshIntervalInMS = 60 * 1000; +#if DEBUG + // For L0, we will refresh faster + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("_GITHUB_ACTION_AUTH_MIGRATION_REFRESH_INTERVAL"))) + { + refreshIntervalInMS = int.Parse(Environment.GetEnvironmentVariable("_GITHUB_ACTION_AUTH_MIGRATION_REFRESH_INTERVAL")); + } +#endif + _authMigrationAutoReenableTask = AuthMigrationAuthReenableAsync(TimeSpan.FromMilliseconds(refreshIntervalInMS), _authMigrationAutoReenableTaskCancellationTokenSource.Token); + } + } + + _trace.Info($"Enable auth migration at {DateTime.UtcNow.ToString("O")}."); + AuthMigrationChanged?.Invoke(this, new AuthMigrationEventArgs(trace)); + } + + public void DeferAuthMigration(TimeSpan deferred, string trace) + { + _allowAuthMigration.Reset(); + + // defer migration for a while + lock (_authMigrationLock) + { + _deferredAuthMigrationTime = DateTime.UtcNow.Add(deferred); + } + + _trace.Info($"Disabled auth migration until {_deferredAuthMigrationTime.ToString("O")}."); + AuthMigrationChanged?.Invoke(this, new AuthMigrationEventArgs(trace)); + } + public void LoadDefaultUserAgents() { if (string.IsNullOrEmpty(WebProxy.HttpProxyAddress) && string.IsNullOrEmpty(WebProxy.HttpsProxyAddress)) @@ -549,6 +628,18 @@ private void Dispose(bool disposing) _loadContext.Unloading -= LoadContext_Unloading; _loadContext = null; } + + if (_authMigrationAutoReenableTask != null) + { + _authMigrationAutoReenableTaskCancellationTokenSource?.Cancel(); + } + + if (_authMigrationAutoReenableTaskCancellationTokenSource != null) + { + _authMigrationAutoReenableTaskCancellationTokenSource?.Dispose(); + _authMigrationAutoReenableTaskCancellationTokenSource = null; + } + _httpTraceSubscription?.Dispose(); _diagListenerSubscription?.Dispose(); _traceManager?.Dispose(); diff --git a/src/Runner.Common/RunServer.cs b/src/Runner.Common/RunServer.cs index 9c3b1bdfbf2..b57d2754b68 100644 --- a/src/Runner.Common/RunServer.cs +++ b/src/Runner.Common/RunServer.cs @@ -32,18 +32,6 @@ Task CompleteJobAsync( string billingOwnerId, CancellationToken token); - Task CompleteJob2Async( - Guid planId, - Guid jobId, - TaskResult result, - Dictionary outputs, - IList stepResults, - IList jobAnnotations, - string environmentUrl, - IList telemetry, - string billingOwnerId, - CancellationToken token); - Task RenewJobAsync(Guid planId, Guid jobId, CancellationToken token); } @@ -82,7 +70,6 @@ ex is not TaskOrchestrationJobAlreadyAcquiredException && // HTTP status 409 ex is not TaskOrchestrationJobUnprocessableException); // HTTP status 422 } - // Legacy will be deleted when SkipRetryCompleteJobUponKnownErrors is cleaned up public Task CompleteJobAsync( Guid planId, Guid jobId, @@ -94,23 +81,6 @@ public Task CompleteJobAsync( IList telemetry, string billingOwnerId, CancellationToken cancellationToken) - { - CheckConnection(); - return RetryRequest( - async () => await _runServiceHttpClient.CompleteJobAsync(requestUri, planId, jobId, result, outputs, stepResults, jobAnnotations, environmentUrl, telemetry, billingOwnerId, cancellationToken), cancellationToken); - } - - public Task CompleteJob2Async( - Guid planId, - Guid jobId, - TaskResult result, - Dictionary outputs, - IList stepResults, - IList jobAnnotations, - string environmentUrl, - IList telemetry, - string billingOwnerId, - CancellationToken cancellationToken) { CheckConnection(); return RetryRequest( @@ -124,7 +94,9 @@ public Task RenewJobAsync(Guid planId, Guid jobId, Cancellatio { CheckConnection(); return RetryRequest( - async () => await _runServiceHttpClient.RenewJobAsync(requestUri, planId, jobId, cancellationToken), cancellationToken); + async () => await _runServiceHttpClient.RenewJobAsync(requestUri, planId, jobId, cancellationToken), cancellationToken, + shouldRetry: ex => + ex is not TaskOrchestrationJobNotFoundException); // HTTP status 404 } } } diff --git a/src/Runner.Listener/BrokerMessageListener.cs b/src/Runner.Listener/BrokerMessageListener.cs index 2a994631e3b..a25670c9852 100644 --- a/src/Runner.Listener/BrokerMessageListener.cs +++ b/src/Runner.Listener/BrokerMessageListener.cs @@ -26,15 +26,31 @@ public sealed class BrokerMessageListener : RunnerService, IMessageListener private TaskAgentStatus runnerStatus = TaskAgentStatus.Online; private CancellationTokenSource _getMessagesTokenSource; private VssCredentials _creds; + private VssCredentials _credsV2; private TaskAgentSession _session; private IRunnerServer _runnerServer; private IBrokerServer _brokerServer; + private ICredentialManager _credMgr; private readonly Dictionary _sessionCreationExceptionTracker = new(); private bool _accessTokenRevoked = false; private readonly TimeSpan _sessionCreationRetryInterval = TimeSpan.FromSeconds(30); private readonly TimeSpan _sessionConflictRetryLimit = TimeSpan.FromMinutes(4); private readonly TimeSpan _clockSkewRetryLimit = TimeSpan.FromMinutes(30); + private bool _needRefreshCredsV2 = false; + private bool _handlerInitialized = false; + private bool _isMigratedSettings = false; + private const int _maxMigratedSettingsRetries = 3; + private int _migratedSettingsRetryCount = 0; + public BrokerMessageListener() + { + } + + public BrokerMessageListener(RunnerSettings settings, bool isMigratedSettings = false) + { + _settings = settings; + _isMigratedSettings = isMigratedSettings; + } public override void Initialize(IHostContext hostContext) { @@ -43,15 +59,29 @@ public override void Initialize(IHostContext hostContext) _term = HostContext.GetService(); _runnerServer = HostContext.GetService(); _brokerServer = HostContext.GetService(); + _credMgr = HostContext.GetService(); } public async Task CreateSessionAsync(CancellationToken token) { Trace.Entering(); - // Settings - var configManager = HostContext.GetService(); - _settings = configManager.LoadSettings(); + // Load settings if not provided through constructor + if (_settings == null) + { + var configManager = HostContext.GetService(); + _settings = configManager.LoadSettings(); + Trace.Info("Settings loaded from config manager"); + } + else + { + Trace.Info("Using provided settings"); + if (_isMigratedSettings) + { + Trace.Info("Using migrated settings from .runner_migrated"); + } + } + var serverUrlV2 = _settings.ServerUrlV2; var serverUrl = _settings.ServerUrl; Trace.Info(_settings); @@ -63,8 +93,7 @@ public async Task CreateSessionAsync(CancellationToken toke // Create connection. Trace.Info("Loading Credentials"); - var credMgr = HostContext.GetService(); - _creds = credMgr.LoadCredentials(); + _creds = _credMgr.LoadCredentials(allowAuthUrlV2: false); var agent = new TaskAgentReference { @@ -87,7 +116,8 @@ public async Task CreateSessionAsync(CancellationToken toke try { Trace.Info("Connecting to the Broker Server..."); - await _brokerServer.ConnectAsync(new Uri(serverUrlV2), _creds); + _credsV2 = _credMgr.LoadCredentials(allowAuthUrlV2: true); + await _brokerServer.ConnectAsync(new Uri(serverUrlV2), _credsV2); Trace.Info("VssConnection created"); if (!string.IsNullOrEmpty(serverUrl) && @@ -112,6 +142,13 @@ public async Task CreateSessionAsync(CancellationToken toke encounteringError = false; } + if (!_handlerInitialized) + { + // Register event handler for auth migration state change + HostContext.AuthMigrationChanged += HandleAuthMigrationChanged; + _handlerInitialized = true; + } + return CreateSessionResult.Success; } catch (OperationCanceledException) when (token.IsCancellationRequested) @@ -130,7 +167,22 @@ public async Task CreateSessionAsync(CancellationToken toke Trace.Error("Catch exception during create session."); Trace.Error(ex); - if (ex is VssOAuthTokenRequestException vssOAuthEx && _creds.Federated is VssOAuthCredential vssOAuthCred) + // If using migrated settings, limit the number of retries before returning failure + if (_isMigratedSettings) + { + _migratedSettingsRetryCount++; + Trace.Warning($"Migrated settings retry {_migratedSettingsRetryCount} of {_maxMigratedSettingsRetries}"); + + if (_migratedSettingsRetryCount >= _maxMigratedSettingsRetries) + { + Trace.Warning("Reached maximum retry attempts for migrated settings. Returning failure to try default settings."); + return CreateSessionResult.Failure; + } + } + + if (!HostContext.AllowAuthMigration && + ex is VssOAuthTokenRequestException vssOAuthEx && + _credsV2.Federated is VssOAuthCredential vssOAuthCred) { // "invalid_client" means the runner registration has been deleted from the server. if (string.Equals(vssOAuthEx.Error, "invalid_client", StringComparison.OrdinalIgnoreCase)) @@ -151,7 +203,8 @@ public async Task CreateSessionAsync(CancellationToken toke } } - if (!IsSessionCreationExceptionRetriable(ex)) + if (!HostContext.AllowAuthMigration && + !IsSessionCreationExceptionRetriable(ex)) { _term.WriteError($"Failed to create session. {ex.Message}"); if (ex is TaskAgentSessionConflictException) @@ -161,6 +214,12 @@ public async Task CreateSessionAsync(CancellationToken toke return CreateSessionResult.Failure; } + if (HostContext.AllowAuthMigration) + { + Trace.Info("Disable migration mode for 60 minutes."); + HostContext.DeferAuthMigration(TimeSpan.FromMinutes(60), $"Session creation failed with exception: {ex}"); + } + if (!encounteringError) //print the message only on the first error { _term.WriteError($"{DateTime.UtcNow:u}: Runner connect error: {ex.Message}. Retrying until reconnected."); @@ -177,6 +236,11 @@ public async Task DeleteSessionAsync() { if (_session != null && _session.SessionId != Guid.Empty) { + if (_handlerInitialized) + { + HostContext.AuthMigrationChanged -= HandleAuthMigrationChanged; + } + if (!_accessTokenRevoked) { using (var ts = new CancellationTokenSource(TimeSpan.FromSeconds(30))) @@ -219,6 +283,13 @@ public async Task GetNextMessageAsync(CancellationToken token) _getMessagesTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); try { + if (_needRefreshCredsV2) + { + Trace.Info("Refreshing broker connection."); + await RefreshBrokerConnectionAsync(); + _needRefreshCredsV2 = false; + } + message = await _brokerServer.GetRunnerMessageAsync(_session.SessionId, runnerStatus, BuildConstants.RunnerPackage.Version, @@ -254,11 +325,11 @@ public async Task GetNextMessageAsync(CancellationToken token) Trace.Info("Hosted runner has been deprovisioned."); throw; } - catch (AccessDeniedException e) when (e.ErrorCode == 1) + catch (AccessDeniedException e) when (e.ErrorCode == 1 && !HostContext.AllowAuthMigration) { throw; } - catch (RunnerNotFoundException) + catch (RunnerNotFoundException) when (!HostContext.AllowAuthMigration) { throw; } @@ -267,7 +338,8 @@ public async Task GetNextMessageAsync(CancellationToken token) Trace.Error("Catch exception during get next message."); Trace.Error(ex); - if (!IsGetNextMessageExceptionRetriable(ex)) + if (!HostContext.AllowAuthMigration && + !IsGetNextMessageExceptionRetriable(ex)) { throw new NonRetryableException("Get next message failed with non-retryable error.", ex); } @@ -298,6 +370,12 @@ public async Task GetNextMessageAsync(CancellationToken token) encounteringError = true; } + if (HostContext.AllowAuthMigration) + { + Trace.Info("Disable migration mode for 60 minutes."); + HostContext.DeferAuthMigration(TimeSpan.FromMinutes(60), $"Get next message failed with exception: {ex}"); + } + // re-create VssConnection before next retry await RefreshBrokerConnectionAsync(); @@ -329,7 +407,7 @@ public async Task GetNextMessageAsync(CancellationToken token) } } - public async Task RefreshListenerTokenAsync(CancellationToken cancellationToken) + public async Task RefreshListenerTokenAsync() { await RefreshBrokerConnectionAsync(); } @@ -432,17 +510,16 @@ ex is AccessDeniedException || private async Task RefreshBrokerConnectionAsync() { - var configManager = HostContext.GetService(); - _settings = configManager.LoadSettings(); - - if (string.IsNullOrEmpty(_settings.ServerUrlV2)) - { - throw new InvalidOperationException("ServerUrlV2 is not set"); - } + Trace.Info("Reload credentials."); + _credsV2 = _credMgr.LoadCredentials(allowAuthUrlV2: true); + await _brokerServer.ConnectAsync(new Uri(_settings.ServerUrlV2), _credsV2); + Trace.Info("Connection to Broker Server recreated."); + } - var credMgr = HostContext.GetService(); - VssCredentials creds = credMgr.LoadCredentials(); - await _brokerServer.ConnectAsync(new Uri(_settings.ServerUrlV2), creds); + private void HandleAuthMigrationChanged(object sender, EventArgs e) + { + Trace.Info($"Auth migration changed. Current allow auth migration state: {HostContext.AllowAuthMigration}"); + _needRefreshCredsV2 = true; } } } diff --git a/src/Runner.Listener/Configuration/ConfigurationManager.cs b/src/Runner.Listener/Configuration/ConfigurationManager.cs index e83eab1e199..15a57631704 100644 --- a/src/Runner.Listener/Configuration/ConfigurationManager.cs +++ b/src/Runner.Listener/Configuration/ConfigurationManager.cs @@ -25,6 +25,7 @@ public interface IConfigurationManager : IRunnerService Task UnconfigureAsync(CommandSettings command); void DeleteLocalRunnerConfig(); RunnerSettings LoadSettings(); + RunnerSettings LoadMigratedSettings(); } public sealed class ConfigurationManager : RunnerService, IConfigurationManager @@ -66,6 +67,22 @@ public RunnerSettings LoadSettings() return settings; } + public RunnerSettings LoadMigratedSettings() + { + Trace.Info(nameof(LoadMigratedSettings)); + + // Check if migrated settings file exists + if (!_store.IsMigratedConfigured()) + { + throw new NonRetryableException("No migrated configuration found."); + } + + RunnerSettings settings = _store.GetMigratedSettings(); + Trace.Info("Migrated Settings Loaded"); + + return settings; + } + public async Task ConfigureAsync(CommandSettings command) { _term.WriteLine(); @@ -127,7 +144,7 @@ public async Task ConfigureAsync(CommandSettings command) runnerSettings.ServerUrl = inputUrl; // Get the credentials credProvider = GetCredentialProvider(command, runnerSettings.ServerUrl); - creds = credProvider.GetVssCredentials(HostContext); + creds = credProvider.GetVssCredentials(HostContext, allowAuthUrlV2: false); Trace.Info("legacy vss cred retrieved"); } else @@ -366,7 +383,7 @@ public async Task ConfigureAsync(CommandSettings command) { { "clientId", agent.Authorization.ClientId.ToString("D") }, { "authorizationUrl", agent.Authorization.AuthorizationUrl.AbsoluteUri }, - { "requireFipsCryptography", agent.Properties.GetValue("RequireFipsCryptography", false).ToString() } + { "requireFipsCryptography", agent.Properties.GetValue("RequireFipsCryptography", true).ToString() } }, }; @@ -384,7 +401,7 @@ public async Task ConfigureAsync(CommandSettings command) if (!runnerSettings.UseV2Flow) { var credMgr = HostContext.GetService(); - VssCredentials credential = credMgr.LoadCredentials(); + VssCredentials credential = credMgr.LoadCredentials(allowAuthUrlV2: false); try { await _runnerServer.ConnectAsync(new Uri(runnerSettings.ServerUrl), credential); @@ -519,7 +536,7 @@ public async Task UnconfigureAsync(CommandSettings command) if (string.IsNullOrEmpty(settings.GitHubUrl)) { var credProvider = GetCredentialProvider(command, settings.ServerUrl); - creds = credProvider.GetVssCredentials(HostContext); + creds = credProvider.GetVssCredentials(HostContext, allowAuthUrlV2: false); Trace.Info("legacy vss cred retrieved"); } else diff --git a/src/Runner.Listener/Configuration/CredentialManager.cs b/src/Runner.Listener/Configuration/CredentialManager.cs index f13fb120778..89e76a22da4 100644 --- a/src/Runner.Listener/Configuration/CredentialManager.cs +++ b/src/Runner.Listener/Configuration/CredentialManager.cs @@ -13,7 +13,7 @@ namespace GitHub.Runner.Listener.Configuration public interface ICredentialManager : IRunnerService { ICredentialProvider GetCredentialProvider(string credType); - VssCredentials LoadCredentials(); + VssCredentials LoadCredentials(bool allowAuthUrlV2); } public class CredentialManager : RunnerService, ICredentialManager @@ -40,7 +40,7 @@ public ICredentialProvider GetCredentialProvider(string credType) return creds; } - public VssCredentials LoadCredentials() + public VssCredentials LoadCredentials(bool allowAuthUrlV2) { IConfigurationStore store = HostContext.GetService(); @@ -51,21 +51,16 @@ public VssCredentials LoadCredentials() CredentialData credData = store.GetCredentials(); var migratedCred = store.GetMigratedCredentials(); - if (migratedCred != null) + if (migratedCred != null && + migratedCred.Scheme == Constants.Configuration.OAuth) { credData = migratedCred; - - // Re-write .credentials with Token URL - store.SaveCredential(credData); - - // Delete .credentials_migrated - store.DeleteMigratedCredential(); } ICredentialProvider credProv = GetCredentialProvider(credData.Scheme); credProv.CredentialData = credData; - VssCredentials creds = credProv.GetVssCredentials(HostContext); + VssCredentials creds = credProv.GetVssCredentials(HostContext, allowAuthUrlV2); return creds; } diff --git a/src/Runner.Listener/Configuration/CredentialProvider.cs b/src/Runner.Listener/Configuration/CredentialProvider.cs index def579a0daf..c6bac758df9 100644 --- a/src/Runner.Listener/Configuration/CredentialProvider.cs +++ b/src/Runner.Listener/Configuration/CredentialProvider.cs @@ -1,7 +1,7 @@ using System; -using GitHub.Services.Common; using GitHub.Runner.Common; using GitHub.Runner.Sdk; +using GitHub.Services.Common; using GitHub.Services.OAuth; namespace GitHub.Runner.Listener.Configuration @@ -10,7 +10,7 @@ public interface ICredentialProvider { Boolean RequireInteractive { get; } CredentialData CredentialData { get; set; } - VssCredentials GetVssCredentials(IHostContext context); + VssCredentials GetVssCredentials(IHostContext context, bool allowAuthUrlV2); void EnsureCredential(IHostContext context, CommandSettings command, string serverUrl); } @@ -25,7 +25,7 @@ public CredentialProvider(string scheme) public virtual Boolean RequireInteractive => false; public CredentialData CredentialData { get; set; } - public abstract VssCredentials GetVssCredentials(IHostContext context); + public abstract VssCredentials GetVssCredentials(IHostContext context, bool allowAuthUrlV2); public abstract void EnsureCredential(IHostContext context, CommandSettings command, string serverUrl); } @@ -33,7 +33,7 @@ public sealed class OAuthAccessTokenCredential : CredentialProvider { public OAuthAccessTokenCredential() : base(Constants.Configuration.OAuthAccessToken) { } - public override VssCredentials GetVssCredentials(IHostContext context) + public override VssCredentials GetVssCredentials(IHostContext context, bool allowAuthUrlV2) { ArgUtil.NotNull(context, nameof(context)); Tracing trace = context.GetTrace(nameof(OAuthAccessTokenCredential)); diff --git a/src/Runner.Listener/Configuration/OAuthCredential.cs b/src/Runner.Listener/Configuration/OAuthCredential.cs index a0d2042b95f..b09d6775498 100644 --- a/src/Runner.Listener/Configuration/OAuthCredential.cs +++ b/src/Runner.Listener/Configuration/OAuthCredential.cs @@ -22,10 +22,18 @@ public override void EnsureCredential( // Nothing to verify here } - public override VssCredentials GetVssCredentials(IHostContext context) + public override VssCredentials GetVssCredentials(IHostContext context, bool allowAuthUrlV2) { var clientId = this.CredentialData.Data.GetValueOrDefault("clientId", null); var authorizationUrl = this.CredentialData.Data.GetValueOrDefault("authorizationUrl", null); + var authorizationUrlV2 = this.CredentialData.Data.GetValueOrDefault("authorizationUrlV2", null); + + if (allowAuthUrlV2 && + !string.IsNullOrEmpty(authorizationUrlV2) && + context.AllowAuthMigration) + { + authorizationUrl = authorizationUrlV2; + } // For back compat with .credential file that doesn't has 'oauthEndpointUrl' section var oauthEndpointUrl = this.CredentialData.Data.GetValueOrDefault("oauthEndpointUrl", authorizationUrl); diff --git a/src/Runner.Listener/MessageListener.cs b/src/Runner.Listener/MessageListener.cs index fcff3a50799..d30ed2bf9da 100644 --- a/src/Runner.Listener/MessageListener.cs +++ b/src/Runner.Listener/MessageListener.cs @@ -33,7 +33,7 @@ public interface IMessageListener : IRunnerService Task GetNextMessageAsync(CancellationToken token); Task DeleteMessageAsync(TaskAgentMessage message); - Task RefreshListenerTokenAsync(CancellationToken token); + Task RefreshListenerTokenAsync(); void OnJobStatus(object sender, JobStatusEventArgs e); } @@ -44,6 +44,7 @@ public sealed class MessageListener : RunnerService, IMessageListener private ITerminal _term; private IRunnerServer _runnerServer; private IBrokerServer _brokerServer; + private ICredentialManager _credMgr; private TaskAgentSession _session; private TimeSpan _getNextMessageRetryInterval; private bool _accessTokenRevoked = false; @@ -54,8 +55,9 @@ public sealed class MessageListener : RunnerService, IMessageListener private TaskAgentStatus runnerStatus = TaskAgentStatus.Online; private CancellationTokenSource _getMessagesTokenSource; private VssCredentials _creds; - - private bool _isBrokerSession = false; + private VssCredentials _credsV2; + private bool _needRefreshCredsV2 = false; + private bool _handlerInitialized = false; public override void Initialize(IHostContext hostContext) { @@ -64,6 +66,7 @@ public override void Initialize(IHostContext hostContext) _term = HostContext.GetService(); _runnerServer = HostContext.GetService(); _brokerServer = hostContext.GetService(); + _credMgr = hostContext.GetService(); } public async Task CreateSessionAsync(CancellationToken token) @@ -78,8 +81,7 @@ public async Task CreateSessionAsync(CancellationToken toke // Create connection. Trace.Info("Loading Credentials"); - var credMgr = HostContext.GetService(); - _creds = credMgr.LoadCredentials(); + _creds = _credMgr.LoadCredentials(allowAuthUrlV2: false); var agent = new TaskAgentReference { @@ -113,16 +115,6 @@ public async Task CreateSessionAsync(CancellationToken toke _settings.PoolId, taskAgentSession, token); - - if (_session.BrokerMigrationMessage != null) - { - Trace.Info("Runner session is in migration mode: Creating Broker session with BrokerBaseUrl: {0}", _session.BrokerMigrationMessage.BrokerBaseUrl); - - await _brokerServer.UpdateConnectionIfNeeded(_session.BrokerMigrationMessage.BrokerBaseUrl, _creds); - _session = await _brokerServer.CreateSessionAsync(taskAgentSession, token); - _isBrokerSession = true; - } - Trace.Info($"Session created."); if (encounteringError) { @@ -131,6 +123,13 @@ public async Task CreateSessionAsync(CancellationToken toke encounteringError = false; } + if (!_handlerInitialized) + { + Trace.Info("Registering AuthMigrationChanged event handler."); + HostContext.AuthMigrationChanged += HandleAuthMigrationChanged; + _handlerInitialized = true; + } + return CreateSessionResult.Success; } catch (OperationCanceledException) when (token.IsCancellationRequested) @@ -196,16 +195,16 @@ public async Task DeleteSessionAsync() { if (_session != null && _session.SessionId != Guid.Empty) { + if (_handlerInitialized) + { + HostContext.AuthMigrationChanged -= HandleAuthMigrationChanged; + } + if (!_accessTokenRevoked) { using (var ts = new CancellationTokenSource(TimeSpan.FromSeconds(30))) { await _runnerServer.DeleteAgentSessionAsync(_settings.PoolId, _session.SessionId, ts.Token); - - if (_isBrokerSession) - { - await _brokerServer.DeleteSessionAsync(ts.Token); - } } } else @@ -261,12 +260,19 @@ public async Task GetNextMessageAsync(CancellationToken token) // Decrypt the message body if the session is using encryption message = DecryptMessage(message); - if (message != null && message.MessageType == BrokerMigrationMessage.MessageType) { var migrationMessage = JsonUtility.FromString(message.Body); - await _brokerServer.UpdateConnectionIfNeeded(migrationMessage.BrokerBaseUrl, _creds); + _credsV2 = _credMgr.LoadCredentials(allowAuthUrlV2: true); + await _brokerServer.UpdateConnectionIfNeeded(migrationMessage.BrokerBaseUrl, _credsV2); + if (_needRefreshCredsV2) + { + Trace.Info("Refreshing credentials for V2."); + await _brokerServer.ForceRefreshConnection(_credsV2); + _needRefreshCredsV2 = false; + } + message = await _brokerServer.GetRunnerMessageAsync(_session.SessionId, runnerStatus, BuildConstants.RunnerPackage.Version, @@ -309,11 +315,11 @@ public async Task GetNextMessageAsync(CancellationToken token) Trace.Info("Hosted runner has been deprovisioned."); throw; } - catch (AccessDeniedException e) when (e.ErrorCode == 1) + catch (AccessDeniedException e) when (e.ErrorCode == 1 && !HostContext.AllowAuthMigration) { throw; } - catch (RunnerNotFoundException) + catch (RunnerNotFoundException) when (!HostContext.AllowAuthMigration) { throw; } @@ -322,12 +328,19 @@ public async Task GetNextMessageAsync(CancellationToken token) Trace.Error("Catch exception during get next message."); Trace.Error(ex); + // clear out potential message for broker migration, + // in case the exception is thrown from get message from broker-listener. + message = null; + // 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) == CreateSessionResult.Success)) + if (!HostContext.AllowAuthMigration && + ex is TaskAgentSessionExpiredException && + !_settings.SkipSessionRecover && (await CreateSessionAsync(token) == CreateSessionResult.Success)) { Trace.Info($"{nameof(TaskAgentSessionExpiredException)} received, recovered by recreate session."); } - else if (!IsGetNextMessageExceptionRetriable(ex)) + else if (!HostContext.AllowAuthMigration && + !IsGetNextMessageExceptionRetriable(ex)) { throw; } @@ -354,6 +367,12 @@ public async Task GetNextMessageAsync(CancellationToken token) encounteringError = true; } + if (HostContext.AllowAuthMigration) + { + Trace.Info("Disable migration mode for 60 minutes."); + HostContext.DeferAuthMigration(TimeSpan.FromMinutes(60), $"Get next message failed with exception: {ex}"); + } + // re-create VssConnection before next retry await _runnerServer.RefreshConnectionAsync(RunnerConnectionType.MessageQueue, TimeSpan.FromSeconds(60)); @@ -411,10 +430,11 @@ public async Task DeleteMessageAsync(TaskAgentMessage message) } } - public async Task RefreshListenerTokenAsync(CancellationToken cancellationToken) + public async Task RefreshListenerTokenAsync() { await _runnerServer.RefreshConnectionAsync(RunnerConnectionType.MessageQueue, TimeSpan.FromSeconds(60)); - await _brokerServer.ForceRefreshConnection(_creds); + _credsV2 = _credMgr.LoadCredentials(allowAuthUrlV2: true); + await _brokerServer.ForceRefreshConnection(_credsV2); } private TaskAgentMessage DecryptMessage(TaskAgentMessage message) @@ -545,5 +565,11 @@ ex is VssUnauthorizedException || return true; } } + + private void HandleAuthMigrationChanged(object sender, EventArgs e) + { + Trace.Info($"Auth migration changed. Current allow auth migration state: {HostContext.AllowAuthMigration}"); + _needRefreshCredsV2 = true; + } } } diff --git a/src/Runner.Listener/Runner.Listener.csproj b/src/Runner.Listener/Runner.Listener.csproj index afd528128a5..68df8fbbc67 100644 --- a/src/Runner.Listener/Runner.Listener.csproj +++ b/src/Runner.Listener/Runner.Listener.csproj @@ -23,7 +23,7 @@ - + diff --git a/src/Runner.Listener/Runner.cs b/src/Runner.Listener/Runner.cs index 28b65d8778c..569d5505cad 100644 --- a/src/Runner.Listener/Runner.cs +++ b/src/Runner.Listener/Runner.cs @@ -1,10 +1,12 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Security.Cryptography; +using System.Security.Claims; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -14,7 +16,9 @@ using GitHub.Runner.Listener.Check; using GitHub.Runner.Listener.Configuration; using GitHub.Runner.Sdk; +using GitHub.Services.OAuth; using GitHub.Services.WebApi; +using GitHub.Services.WebApi.Jwt; using Pipelines = GitHub.DistributedTask.Pipelines; namespace GitHub.Runner.Listener @@ -31,6 +35,14 @@ public sealed class Runner : RunnerService, IRunner private ITerminal _term; private bool _inConfigStage; private ManualResetEvent _completedCommand = new(false); + private readonly ConcurrentQueue _authMigrationTelemetries = new(); + private Task _authMigrationTelemetryTask; + private readonly object _authMigrationTelemetryLock = new(); + private Task _authMigrationClaimsCheckTask; + private readonly object _authMigrationClaimsCheckLock = new(); + private IRunnerServer _runnerServer; + private CancellationTokenSource _authMigrationTelemetryTokenSource = new(); + private CancellationTokenSource _authMigrationClaimsCheckTokenSource = new(); // // Helps avoid excessive calls to Run Service when encountering non-retriable errors from /acquirejob. @@ -51,6 +63,7 @@ public override void Initialize(IHostContext hostContext) base.Initialize(hostContext); _term = HostContext.GetService(); _acquireJobThrottler = HostContext.CreateService(); + _runnerServer = HostContext.GetService(); } public async Task ExecuteCommand(CommandSettings command) @@ -66,6 +79,8 @@ public async Task ExecuteCommand(CommandSettings command) //register a SIGTERM handler HostContext.Unloading += Runner_Unloading; + HostContext.AuthMigrationChanged += HandleAuthMigrationChanged; + // TODO Unit test to cover this logic Trace.Info(nameof(ExecuteCommand)); var configManager = HostContext.GetService(); @@ -300,8 +315,17 @@ public async Task ExecuteCommand(CommandSettings command) _term.WriteLine("https://docs.github.com/en/actions/hosting-your-own-runners/autoscaling-with-self-hosted-runners#using-ephemeral-runners-for-autoscaling", ConsoleColor.Yellow); } + var cred = store.GetCredentials(); + if (cred != null && + cred.Scheme == Constants.Configuration.OAuth && + cred.Data.ContainsKey("EnableAuthMigrationByDefault")) + { + Trace.Info("Enable auth migration by default."); + HostContext.EnableAuthMigration("EnableAuthMigrationByDefault"); + } + // Run the runner interactively or as service - return await RunAsync(settings, command.RunOnce || settings.Ephemeral); + return await ExecuteRunnerAsync(settings, command.RunOnce || settings.Ephemeral); } else { @@ -311,6 +335,9 @@ public async Task ExecuteCommand(CommandSettings command) } finally { + _authMigrationClaimsCheckTokenSource?.Cancel(); + _authMigrationTelemetryTokenSource?.Cancel(); + HostContext.AuthMigrationChanged -= HandleAuthMigrationChanged; _term.CancelKeyPress -= CtrlCHandler; HostContext.Unloading -= Runner_Unloading; _completedCommand.Set(); @@ -360,12 +387,12 @@ private void CtrlCHandler(object sender, EventArgs e) } } - private IMessageListener GetMessageListener(RunnerSettings settings) + private IMessageListener GetMessageListener(RunnerSettings settings, bool isMigratedSettings = false) { if (settings.UseV2Flow) { Trace.Info($"Using BrokerMessageListener"); - var brokerListener = new BrokerMessageListener(); + var brokerListener = new BrokerMessageListener(settings, isMigratedSettings); brokerListener.Initialize(HostContext); return brokerListener; } @@ -379,15 +406,65 @@ private async Task RunAsync(RunnerSettings settings, bool runOnce = false) try { Trace.Info(nameof(RunAsync)); - _listener = GetMessageListener(settings); - CreateSessionResult createSessionResult = await _listener.CreateSessionAsync(HostContext.RunnerShutdownToken); - if (createSessionResult == CreateSessionResult.SessionConflict) + + // First try using migrated settings if available + var configManager = HostContext.GetService(); + RunnerSettings migratedSettings = null; + + try + { + migratedSettings = configManager.LoadMigratedSettings(); + Trace.Info("Loaded migrated settings from .runner_migrated file"); + Trace.Info(migratedSettings); + } + catch (Exception ex) + { + // If migrated settings file doesn't exist or can't be loaded, we'll use the provided settings + Trace.Info($"Failed to load migrated settings: {ex.Message}"); + } + + bool usedMigratedSettings = false; + + if (migratedSettings != null) { - return Constants.Runner.ReturnCode.SessionConflict; + // Try to create session with migrated settings first + Trace.Info("Attempting to create session using migrated settings"); + _listener = GetMessageListener(migratedSettings, isMigratedSettings: true); + + try + { + CreateSessionResult createSessionResult = await _listener.CreateSessionAsync(HostContext.RunnerShutdownToken); + if (createSessionResult == CreateSessionResult.Success) + { + Trace.Info("Successfully created session with migrated settings"); + settings = migratedSettings; // Use migrated settings for the rest of the process + usedMigratedSettings = true; + } + else + { + Trace.Warning($"Failed to create session with migrated settings: {createSessionResult}"); + } + } + catch (Exception ex) + { + Trace.Error($"Exception when creating session with migrated settings: {ex}"); + } } - else if (createSessionResult == CreateSessionResult.Failure) + + // If migrated settings weren't used or session creation failed, use original settings + if (!usedMigratedSettings) { - return Constants.Runner.ReturnCode.TerminatedError; + Trace.Info("Falling back to original .runner settings"); + _listener = GetMessageListener(settings); + CreateSessionResult createSessionResult = await _listener.CreateSessionAsync(HostContext.RunnerShutdownToken); + if (createSessionResult == CreateSessionResult.SessionConflict) + { + return Constants.Runner.ReturnCode.SessionConflict; + } + else if (createSessionResult == CreateSessionResult.Failure) + { + return Constants.Runner.ReturnCode.TerminatedError; + } } HostContext.WritePerfCounter("SessionCreated"); @@ -401,6 +478,8 @@ private async Task RunAsync(RunnerSettings settings, bool runOnce = false) // Should we try to cleanup ephemeral runners bool runOnceJobCompleted = false; bool skipSessionDeletion = false; + bool restartSession = false; // Flag to indicate session restart + bool restartSessionPending = false; try { var notification = HostContext.GetService(); @@ -416,6 +495,15 @@ private async Task RunAsync(RunnerSettings settings, bool runOnce = false) while (!HostContext.RunnerShutdownToken.IsCancellationRequested) { + // Check if we need to restart the session and can do so (job dispatcher not busy) + if (restartSessionPending && !jobDispatcher.Busy) + { + Trace.Info("Pending session restart detected and job dispatcher is not busy. Restarting session now."); + messageQueueLoopTokenSource.Cancel(); + restartSession = true; + break; + } + TaskAgentMessage message = null; bool skipMessageDeletion = false; try @@ -570,18 +658,18 @@ private async Task RunAsync(RunnerSettings settings, bool runOnce = false) // Create connection var credMgr = HostContext.GetService(); - var creds = credMgr.LoadCredentials(); - if (string.IsNullOrEmpty(messageRef.RunServiceUrl)) { + var creds = credMgr.LoadCredentials(allowAuthUrlV2: false); var actionsRunServer = HostContext.CreateService(); await actionsRunServer.ConnectAsync(new Uri(settings.ServerUrl), creds); jobRequestMessage = await actionsRunServer.GetJobMessageAsync(messageRef.RunnerRequestId, messageQueueLoopTokenSource.Token); } else { + var credsV2 = credMgr.LoadCredentials(allowAuthUrlV2: true); var runServer = HostContext.CreateService(); - await runServer.ConnectAsync(new Uri(messageRef.RunServiceUrl), creds); + await runServer.ConnectAsync(new Uri(messageRef.RunServiceUrl), credsV2); try { jobRequestMessage = await runServer.GetJobMessageAsync(messageRef.RunnerRequestId, messageRef.BillingOwnerId, messageQueueLoopTokenSource.Token); @@ -599,6 +687,13 @@ ex is TaskOrchestrationJobAlreadyAcquiredException || // HTTP status 409 catch (Exception ex) { Trace.Error($"Caught exception from acquiring job message: {ex}"); + + if (HostContext.AllowAuthMigration) + { + Trace.Info("Disable migration mode for 60 minutes."); + HostContext.DeferAuthMigration(TimeSpan.FromMinutes(60), $"Acquire job failed with exception: {ex}"); + } + continue; } } @@ -633,7 +728,7 @@ ex is TaskOrchestrationJobAlreadyAcquiredException || // HTTP status 409 else if (string.Equals(message.MessageType, TaskAgentMessageTypes.ForceTokenRefresh)) { Trace.Info("Received ForceTokenRefreshMessage"); - await _listener.RefreshListenerTokenAsync(messageQueueLoopTokenSource.Token); + await _listener.RefreshListenerTokenAsync(); } else if (string.Equals(message.MessageType, RunnerRefreshConfigMessage.MessageType)) { @@ -645,6 +740,17 @@ await configUpdater.UpdateRunnerConfigAsync( configType: runnerRefreshConfigMessage.ConfigType, serviceType: runnerRefreshConfigMessage.ServiceType, configRefreshUrl: runnerRefreshConfigMessage.ConfigRefreshUrl); + + // Set flag to schedule session restart if ConfigType is "runner" + if (string.Equals(runnerRefreshConfigMessage.ConfigType, "runner", StringComparison.OrdinalIgnoreCase)) + { + Trace.Info("Runner configuration was updated. Session restart has been scheduled"); + restartSessionPending = true; + } + else + { + Trace.Info($"No session restart needed for config type: {runnerRefreshConfigMessage.ConfigType}"); + } } else { @@ -699,10 +805,16 @@ await configUpdater.UpdateRunnerConfigAsync( if (settings.Ephemeral && runOnceJobCompleted) { - var configManager = HostContext.GetService(); configManager.DeleteLocalRunnerConfig(); } } + + // After cleanup, check if we need to restart the session + if (restartSession) + { + Trace.Info("Restarting runner session after config update..."); + return Constants.Runner.ReturnCode.RunnerConfigurationRefreshed; + } } catch (TaskAgentAccessTokenExpiredException) { @@ -716,6 +828,220 @@ await configUpdater.UpdateRunnerConfigAsync( return Constants.Runner.ReturnCode.Success; } + private async Task ExecuteRunnerAsync(RunnerSettings settings, bool runOnce) + { + int returnCode = Constants.Runner.ReturnCode.Success; + bool restart = false; + do + { + restart = false; + returnCode = await RunAsync(settings, runOnce); + + if (returnCode == Constants.Runner.ReturnCode.RunnerConfigurationRefreshed) + { + Trace.Info("Runner configuration was refreshed, restarting session..."); + // Reload settings in case they changed + var configManager = HostContext.GetService(); + settings = configManager.LoadSettings(); + restart = true; + } + } while (restart); + + return returnCode; + } + + private void HandleAuthMigrationChanged(object sender, AuthMigrationEventArgs e) + { + Trace.Verbose("Handle AuthMigrationChanged in Runner"); + _authMigrationTelemetries.Enqueue($"{DateTime.UtcNow.ToString("O")}: {e.Trace}"); + + // only start the telemetry reporting task once auth migration is changed (enabled or disabled) + lock (_authMigrationTelemetryLock) + { + if (_authMigrationTelemetryTask == null) + { + _authMigrationTelemetryTask = ReportAuthMigrationTelemetryAsync(_authMigrationTelemetryTokenSource.Token); + } + } + + // only start the claims check task once auth migration is changed (enabled or disabled) + lock (_authMigrationClaimsCheckLock) + { + if (_authMigrationClaimsCheckTask == null) + { + _authMigrationClaimsCheckTask = CheckOAuthTokenClaimsAsync(_authMigrationClaimsCheckTokenSource.Token); + } + } + } + + private async Task CheckOAuthTokenClaimsAsync(CancellationToken token) + { + string[] expectedClaims = + [ + "owner_id", + "runner_id", + "runner_group_id", + "scale_set_id", + "is_ephemeral", + "labels" + ]; + + try + { + var credMgr = HostContext.GetService(); + while (!token.IsCancellationRequested) + { + try + { + await HostContext.Delay(TimeSpan.FromMinutes(100), token); + } + catch (TaskCanceledException) + { + // Ignore cancellation + } + + if (token.IsCancellationRequested) + { + break; + } + + if (!HostContext.AllowAuthMigration) + { + Trace.Info("Skip checking oauth token claims since auth migration is disabled."); + continue; + } + + var baselineCred = credMgr.LoadCredentials(allowAuthUrlV2: false); + var authV2Cred = credMgr.LoadCredentials(allowAuthUrlV2: true); + + if (!(baselineCred.Federated is VssOAuthCredential baselineVssOAuthCred) || + !(authV2Cred.Federated is VssOAuthCredential vssOAuthCredV2) || + baselineVssOAuthCred == null || + vssOAuthCredV2 == null) + { + Trace.Info("Skip checking oauth token claims for non-oauth credentials"); + continue; + } + + if (string.Equals(baselineVssOAuthCred.AuthorizationUrl.AbsoluteUri, vssOAuthCredV2.AuthorizationUrl.AbsoluteUri, StringComparison.OrdinalIgnoreCase)) + { + Trace.Info("Skip checking oauth token claims for same authorization url"); + continue; + } + + var baselineProvider = baselineVssOAuthCred.GetTokenProvider(baselineVssOAuthCred.AuthorizationUrl); + var v2Provider = vssOAuthCredV2.GetTokenProvider(vssOAuthCredV2.AuthorizationUrl); + try + { + using (var timeoutTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(30))) + using (var requestTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token, timeoutTokenSource.Token)) + { + var baselineToken = await baselineProvider.GetTokenAsync(null, requestTokenSource.Token); + var v2Token = await v2Provider.GetTokenAsync(null, requestTokenSource.Token); + if (baselineToken is VssOAuthAccessToken baselineAccessToken && + v2Token is VssOAuthAccessToken v2AccessToken && + !string.IsNullOrEmpty(baselineAccessToken.Value) && + !string.IsNullOrEmpty(v2AccessToken.Value)) + { + var baselineJwt = JsonWebToken.Create(baselineAccessToken.Value); + var baselineClaims = baselineJwt.ExtractClaims(); + var v2Jwt = JsonWebToken.Create(v2AccessToken.Value); + var v2Claims = v2Jwt.ExtractClaims(); + + // Log extracted claims for debugging + Trace.Verbose($"Baseline token expected claims: {string.Join(", ", baselineClaims + .Where(c => expectedClaims.Contains(c.Type.ToLowerInvariant())) + .Select(c => $"{c.Type}:{c.Value}"))}"); + Trace.Verbose($"V2 token expected claims: {string.Join(", ", v2Claims + .Where(c => expectedClaims.Contains(c.Type.ToLowerInvariant())) + .Select(c => $"{c.Type}:{c.Value}"))}"); + + foreach (var claim in expectedClaims) + { + // if baseline has the claim, v2 should have it too with exactly same value. + if (baselineClaims.FirstOrDefault(c => c.Type.ToLowerInvariant() == claim) is Claim baselineClaim && + !string.IsNullOrEmpty(baselineClaim?.Value)) + { + var v2Claim = v2Claims.FirstOrDefault(c => c.Type.ToLowerInvariant() == claim); + if (v2Claim?.Value != baselineClaim.Value) + { + Trace.Info($"Token Claim mismatch between two issuers. Expected: {baselineClaim.Type}:{baselineClaim.Value}. Actual: {v2Claim?.Type ?? "Empty"}:{v2Claim?.Value ?? "Empty"}"); + HostContext.DeferAuthMigration(TimeSpan.FromMinutes(60), $"Expected claim {baselineClaim.Type}:{baselineClaim.Value} does not match {v2Claim?.Type ?? "Empty"}:{v2Claim?.Value ?? "Empty"}"); + break; + } + } + } + + Trace.Info("OAuth token claims check passed."); + } + } + } + catch (Exception ex) + { + Trace.Error("Failed to fetch and check OAuth token claims."); + Trace.Error(ex); + } + } + } + catch (Exception ex) + { + Trace.Error("Failed to check OAuth token claims in background."); + Trace.Error(ex); + } + } + + private async Task ReportAuthMigrationTelemetryAsync(CancellationToken token) + { + var configManager = HostContext.GetService(); + var runnerSettings = configManager.LoadSettings(); + + while (!token.IsCancellationRequested) + { + try + { + await HostContext.Delay(TimeSpan.FromSeconds(60), token); + } + catch (TaskCanceledException) + { + // Ignore cancellation + } + + Trace.Verbose("Checking for auth migration telemetry to report"); + while (_authMigrationTelemetries.TryDequeue(out var telemetry)) + { + Trace.Verbose($"Reporting auth migration telemetry: {telemetry}"); + if (runnerSettings != null) + { + try + { + using (var tokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(30))) + { + await _runnerServer.UpdateAgentUpdateStateAsync(runnerSettings.PoolId, runnerSettings.AgentId, "RefreshConfig", telemetry, tokenSource.Token); + } + } + catch (Exception ex) + { + Trace.Error("Failed to report auth migration telemetry."); + Trace.Error(ex); + _authMigrationTelemetries.Enqueue(telemetry); + } + } + + if (!token.IsCancellationRequested) + { + try + { + await HostContext.Delay(TimeSpan.FromSeconds(10), token); + } + catch (TaskCanceledException) + { + // Ignore cancellation + } + } + } + } + } + private void PrintUsage(CommandSettings command) { string separator; diff --git a/src/Runner.Listener/RunnerConfigUpdater.cs b/src/Runner.Listener/RunnerConfigUpdater.cs index 34c4fea44d4..c188ad7318a 100644 --- a/src/Runner.Listener/RunnerConfigUpdater.cs +++ b/src/Runner.Listener/RunnerConfigUpdater.cs @@ -197,11 +197,31 @@ private async Task UpdateRunnerCredentialsAsync(string serviceType, string confi await ReportTelemetryAsync($"Credential clientId in refreshed config '{refreshedClientId ?? "Empty"}' does not match the current credential clientId '{clientId}'."); return; } + + // make sure the credential authorizationUrl in the refreshed config match the current credential authorizationUrl for OAuth auth scheme + var authorizationUrl = _credData.Data.GetValueOrDefault("authorizationUrl", null); + var refreshedAuthorizationUrl = refreshedCredConfig.Data.GetValueOrDefault("authorizationUrl", null); + if (authorizationUrl != refreshedAuthorizationUrl) + { + Trace.Error($"Credential authorizationUrl in refreshed config '{refreshedAuthorizationUrl ?? "Empty"}' does not match the current credential authorizationUrl '{authorizationUrl}'."); + await ReportTelemetryAsync($"Credential authorizationUrl in refreshed config '{refreshedAuthorizationUrl ?? "Empty"}' does not match the current credential authorizationUrl '{authorizationUrl}'."); + return; + } } // save the refreshed runner credentials as a separate file _store.SaveMigratedCredential(refreshedCredConfig); - await ReportTelemetryAsync("Runner credentials updated successfully."); + + if (refreshedCredConfig.Data.ContainsKey("authorizationUrlV2")) + { + HostContext.EnableAuthMigration("Credential file updated"); + await ReportTelemetryAsync("Runner credentials updated successfully. Auth migration is enabled."); + } + else + { + HostContext.DeferAuthMigration(TimeSpan.FromDays(365), "Credential file does not contain authorizationUrlV2"); + await ReportTelemetryAsync("Runner credentials updated successfully. Auth migration is disabled."); + } } private async Task VerifyRunnerQualifiedId(string runnerQualifiedId) diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index dfe92523089..e64c6e24ae9 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -862,7 +862,21 @@ public void InitializeJob(Pipelines.AgentJobRequestMessage message, Cancellation ExpressionValues["secrets"] = Global.Variables.ToSecretsContext(); ExpressionValues["runner"] = new RunnerContext(); - ExpressionValues["job"] = new JobContext(); + + Trace.Info("Initializing Job context"); + var jobContext = new JobContext(); + if (Global.Variables.GetBoolean(Constants.Runner.Features.AddCheckRunIdToJobContext) ?? false) + { + ExpressionValues.TryGetValue("job", out var jobDictionary); + if (jobDictionary != null) + { + foreach (var pair in jobDictionary.AssertDictionary("job")) + { + jobContext[pair.Key] = pair.Value; + } + } + } + ExpressionValues["job"] = jobContext; Trace.Info("Initialize GitHub context"); var githubAccessToken = new StringContextData(Global.Variables.Get("system.github.token")); diff --git a/src/Runner.Worker/IssueMatcher.cs b/src/Runner.Worker/IssueMatcher.cs index 35c1f881ccf..4089d93da2d 100644 --- a/src/Runner.Worker/IssueMatcher.cs +++ b/src/Runner.Worker/IssueMatcher.cs @@ -21,6 +21,7 @@ public MatcherChangedEventArgs(IssueMatcherConfig config) public sealed class IssueMatcher { private string _defaultSeverity; + private string _defaultFromPath; private string _owner; private IssuePattern[] _patterns; private IssueMatch[] _state; @@ -29,6 +30,7 @@ public IssueMatcher(IssueMatcherConfig config, TimeSpan timeout) { _owner = config.Owner; _defaultSeverity = config.Severity; + _defaultFromPath = config.FromPath; _patterns = config.Patterns.Select(x => new IssuePattern(x, timeout)).ToArray(); Reset(); } @@ -59,6 +61,19 @@ public string DefaultSeverity } } + public string DefaultFromPath + { + get + { + if (_defaultFromPath == null) + { + _defaultFromPath = string.Empty; + } + + return _defaultFromPath; + } + } + public IssueMatch Match(string line) { // Single pattern @@ -69,7 +84,7 @@ public IssueMatch Match(string line) if (regexMatch.Success) { - return new IssueMatch(null, pattern, regexMatch.Groups, DefaultSeverity); + return new IssueMatch(null, pattern, regexMatch.Groups, DefaultSeverity, DefaultFromPath); } return null; @@ -110,7 +125,7 @@ public IssueMatch Match(string line) } // Return - return new IssueMatch(runningMatch, pattern, regexMatch.Groups, DefaultSeverity); + return new IssueMatch(runningMatch, pattern, regexMatch.Groups, DefaultSeverity, DefaultFromPath); } // Not the last pattern else @@ -184,7 +199,7 @@ public IssuePattern(IssuePatternConfig config, TimeSpan timeout) public sealed class IssueMatch { - public IssueMatch(IssueMatch runningMatch, IssuePattern pattern, GroupCollection groups, string defaultSeverity = null) + public IssueMatch(IssueMatch runningMatch, IssuePattern pattern, GroupCollection groups, string defaultSeverity = null, string defaultFromPath = null) { File = runningMatch?.File ?? GetValue(groups, pattern.File); Line = runningMatch?.Line ?? GetValue(groups, pattern.Line); @@ -198,6 +213,11 @@ public IssueMatch(IssueMatch runningMatch, IssuePattern pattern, GroupCollection { Severity = defaultSeverity; } + + if (string.IsNullOrEmpty(FromPath) && !string.IsNullOrEmpty(defaultFromPath)) + { + FromPath = defaultFromPath; + } } public string File { get; } @@ -282,6 +302,9 @@ public sealed class IssueMatcherConfig [DataMember(Name = "pattern")] private IssuePatternConfig[] _patterns; + [DataMember(Name = "fromPath")] + private string _fromPath; + public string Owner { get @@ -318,6 +341,24 @@ public string Severity } } + public string FromPath + { + get + { + if (_fromPath == null) + { + _fromPath = string.Empty; + } + + return _fromPath; + } + + set + { + _fromPath = value; + } + } + public IssuePatternConfig[] Patterns { get diff --git a/src/Runner.Worker/JobContext.cs b/src/Runner.Worker/JobContext.cs index e3760560fa0..09f3296de9c 100644 --- a/src/Runner.Worker/JobContext.cs +++ b/src/Runner.Worker/JobContext.cs @@ -1,4 +1,4 @@ -using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.DistributedTask.Pipelines.ContextData; using GitHub.Runner.Common.Util; using GitHub.Runner.Common; @@ -56,5 +56,31 @@ public DictionaryContextData Container } } } + + public double? CheckRunId + { + get + { + if (this.TryGetValue("check_run_id", out var value) && value is NumberContextData number) + { + return number.Value; + } + else + { + return null; + } + } + set + { + if (value.HasValue) + { + this["check_run_id"] = new NumberContextData(value.Value); + } + else + { + this["check_run_id"] = null; + } + } + } } } diff --git a/src/Runner.Worker/JobRunner.cs b/src/Runner.Worker/JobRunner.cs index 54710b86e32..32447470645 100644 --- a/src/Runner.Worker/JobRunner.cs +++ b/src/Runner.Worker/JobRunner.cs @@ -318,24 +318,17 @@ private async Task CompleteJobAsync(IRunServer runServer, IExecution { try { - if (jobContext.Global.Variables.GetBoolean(Constants.Runner.Features.SkipRetryCompleteJobUponKnownErrors) ?? false) - { - await runServer.CompleteJob2Async(message.Plan.PlanId, message.JobId, result, jobContext.JobOutputs, jobContext.Global.StepsResult, jobContext.Global.JobAnnotations, environmentUrl, telemetry, billingOwnerId: message.BillingOwnerId, default); - } - else - { - await runServer.CompleteJobAsync(message.Plan.PlanId, message.JobId, result, jobContext.JobOutputs, jobContext.Global.StepsResult, jobContext.Global.JobAnnotations, environmentUrl, telemetry, billingOwnerId: message.BillingOwnerId, default); - } + await runServer.CompleteJobAsync(message.Plan.PlanId, message.JobId, result, jobContext.JobOutputs, jobContext.Global.StepsResult, jobContext.Global.JobAnnotations, environmentUrl, telemetry, billingOwnerId: message.BillingOwnerId, default); return result; } - catch (VssUnauthorizedException ex) when (jobContext.Global.Variables.GetBoolean(Constants.Runner.Features.SkipRetryCompleteJobUponKnownErrors) ?? false) + catch (VssUnauthorizedException ex) { Trace.Error($"Catch exception while attempting to complete job {message.JobId}, job request {message.RequestId}."); Trace.Error(ex); exceptions.Add(ex); break; } - catch (TaskOrchestrationJobNotFoundException ex) when (jobContext.Global.Variables.GetBoolean(Constants.Runner.Features.SkipRetryCompleteJobUponKnownErrors) ?? false) + catch (TaskOrchestrationJobNotFoundException ex) { Trace.Error($"Catch exception while attempting to complete job {message.JobId}, job request {message.RequestId}."); Trace.Error(ex); diff --git a/src/Runner.Worker/Runner.Worker.csproj b/src/Runner.Worker/Runner.Worker.csproj index 53c1610df3e..4470920e10c 100644 --- a/src/Runner.Worker/Runner.Worker.csproj +++ b/src/Runner.Worker/Runner.Worker.csproj @@ -20,7 +20,7 @@ - + diff --git a/src/Sdk/Sdk.csproj b/src/Sdk/Sdk.csproj index 41f63ce3e86..633e8c222e9 100644 --- a/src/Sdk/Sdk.csproj +++ b/src/Sdk/Sdk.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/Sdk/WebApi/WebApi/Jwt/JsonWebToken.cs b/src/Sdk/WebApi/WebApi/Jwt/JsonWebToken.cs index 15216cb14cd..10c25904b3d 100644 --- a/src/Sdk/WebApi/WebApi/Jwt/JsonWebToken.cs +++ b/src/Sdk/WebApi/WebApi/Jwt/JsonWebToken.cs @@ -25,7 +25,10 @@ public enum JWTAlgorithm HS256, [EnumMember] - RS256 + RS256, + + [EnumMember] + PS256, } //JsonWebToken is marked as DataContract so @@ -286,6 +289,7 @@ private static byte[] GetSignature(JWTHeader header, JWTPayload payload, JWTAlgo { case JWTAlgorithm.HS256: case JWTAlgorithm.RS256: + case JWTAlgorithm.PS256: return signingCredentials.SignData(bytes); default: diff --git a/src/Sdk/WebApi/WebApi/VssSigningCredentials.cs b/src/Sdk/WebApi/WebApi/VssSigningCredentials.cs index 6b7e0c34846..68a99cf700d 100644 --- a/src/Sdk/WebApi/WebApi/VssSigningCredentials.cs +++ b/src/Sdk/WebApi/WebApi/VssSigningCredentials.cs @@ -166,6 +166,21 @@ public override Int32 KeySize } } + public override JWTAlgorithm SignatureAlgorithm + { + get + { + if (m_signaturePadding == RSASignaturePadding.Pss) + { + return JWTAlgorithm.PS256; + } + else + { + return base.SignatureAlgorithm; + } + } + } + protected override Byte[] GetSignature(Byte[] input) { using (var rsa = m_factory()) diff --git a/src/Test/L0/HostContextL0.cs b/src/Test/L0/HostContextL0.cs index 017a7dc294e..2b6a0b59015 100644 --- a/src/Test/L0/HostContextL0.cs +++ b/src/Test/L0/HostContextL0.cs @@ -1,10 +1,10 @@ -using GitHub.Runner.Common.Util; -using System; +using System; using System.IO; using System.Reflection; using System.Runtime.CompilerServices; using System.Text; using System.Threading; +using System.Threading.Tasks; using Xunit; namespace GitHub.Runner.Common.Tests @@ -172,6 +172,133 @@ public void SecretMaskerForProxy() } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void AuthMigrationDisabledByDefault() + { + try + { + Environment.SetEnvironmentVariable("_GITHUB_ACTION_AUTH_MIGRATION_REFRESH_INTERVAL", "100"); + + // Arrange. + Setup(); + + // Assert. + Assert.False(_hc.AllowAuthMigration); + + // Change migration state is error free. + _hc.EnableAuthMigration("L0Test"); + _hc.DeferAuthMigration(TimeSpan.FromHours(1), "L0Test"); + } + finally + { + Environment.SetEnvironmentVariable("_GITHUB_ACTION_AUTH_MIGRATION_REFRESH_INTERVAL", null); + // Cleanup. + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public async Task AuthMigrationReenableTaskNotRunningByDefault() + { + try + { + Environment.SetEnvironmentVariable("_GITHUB_ACTION_AUTH_MIGRATION_REFRESH_INTERVAL", "50"); + + // Arrange. + Setup(); + + // Assert. + Assert.False(_hc.AllowAuthMigration); + await Task.Delay(TimeSpan.FromMilliseconds(200)); + } + finally + { + Environment.SetEnvironmentVariable("_GITHUB_ACTION_AUTH_MIGRATION_REFRESH_INTERVAL", null); + // Cleanup. + Teardown(); + } + + var logFile = Path.Combine(Path.GetDirectoryName(Assembly.GetEntryAssembly().Location), $"trace_{nameof(HostContextL0)}_{nameof(AuthMigrationReenableTaskNotRunningByDefault)}.log"); + var logContent = await File.ReadAllTextAsync(logFile); + Assert.Contains("HostContext", logContent); + Assert.DoesNotContain("Auth migration defer timer", logContent); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void AuthMigrationEnableDisable() + { + try + { + // Arrange. + Setup(); + + var eventFiredCount = 0; + _hc.AuthMigrationChanged += (sender, e) => + { + eventFiredCount++; + Assert.Equal("L0Test", e.Trace); + }; + + // Assert. + _hc.EnableAuthMigration("L0Test"); + Assert.True(_hc.AllowAuthMigration); + + _hc.DeferAuthMigration(TimeSpan.FromHours(1), "L0Test"); + Assert.False(_hc.AllowAuthMigration); + Assert.Equal(2, eventFiredCount); + } + finally + { + // Cleanup. + Teardown(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public async Task AuthMigrationAutoReset() + { + try + { + Environment.SetEnvironmentVariable("_GITHUB_ACTION_AUTH_MIGRATION_REFRESH_INTERVAL", "100"); + + // Arrange. + Setup(); + + var eventFiredCount = 0; + _hc.AuthMigrationChanged += (sender, e) => + { + eventFiredCount++; + Assert.NotEmpty(e.Trace); + }; + + // Assert. + _hc.EnableAuthMigration("L0Test"); + Assert.True(_hc.AllowAuthMigration); + + _hc.DeferAuthMigration(TimeSpan.FromMilliseconds(500), "L0Test"); + Assert.False(_hc.AllowAuthMigration); + + await Task.Delay(TimeSpan.FromSeconds(1)); + Assert.True(_hc.AllowAuthMigration); + Assert.Equal(3, eventFiredCount); + } + finally + { + Environment.SetEnvironmentVariable("_GITHUB_ACTION_AUTH_MIGRATION_REFRESH_INTERVAL", null); + + // Cleanup. + Teardown(); + } + } + private void Setup([CallerMemberName] string testName = "") { _tokenSource = new CancellationTokenSource(); diff --git a/src/Test/L0/Listener/BrokerMessageListenerL0.cs b/src/Test/L0/Listener/BrokerMessageListenerL0.cs index 245438d15e5..c42d134dd41 100644 --- a/src/Test/L0/Listener/BrokerMessageListenerL0.cs +++ b/src/Test/L0/Listener/BrokerMessageListenerL0.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -18,8 +19,6 @@ public sealed class BrokerMessageListenerL0 private readonly Mock _brokerServer; private readonly Mock _runnerServer; private readonly Mock _credMgr; - private Mock _store; - public BrokerMessageListenerL0() { @@ -27,7 +26,6 @@ public BrokerMessageListenerL0() _config = new Mock(); _config.Setup(x => x.LoadSettings()).Returns(_settings); _credMgr = new Mock(); - _store = new Mock(); _brokerServer = new Mock(); _runnerServer = new Mock(); } @@ -35,14 +33,275 @@ public BrokerMessageListenerL0() [Fact] [Trait("Level", "L0")] [Trait("Category", "Runner")] - public async void CreatesSession() + public async Task CreatesSession() + { + using (TestHostContext tc = CreateTestContext()) + using (var tokenSource = new CancellationTokenSource()) + { + Tracing trace = tc.GetTrace(); + + // Arrange. + var expectedSession = new TaskAgentSession(); + _brokerServer + .Setup(x => x.CreateSessionAsync( + It.Is(y => y != null), + tokenSource.Token)) + .Returns(Task.FromResult(expectedSession)); + + _credMgr.Setup(x => x.LoadCredentials(true)).Returns(new VssCredentials()); + + // Act. + BrokerMessageListener listener = new(); + listener.Initialize(tc); + + CreateSessionResult result = await listener.CreateSessionAsync(tokenSource.Token); + trace.Info("result: {0}", result); + + // Assert. + Assert.Equal(CreateSessionResult.Success, result); + _brokerServer + .Verify(x => x.CreateSessionAsync( + It.Is(y => y != null), + tokenSource.Token), Times.Once()); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Runner")] + public async Task HandleAuthMigrationChanged() + { + using (TestHostContext tc = CreateTestContext()) + using (var tokenSource = new CancellationTokenSource()) + { + Tracing trace = tc.GetTrace(); + + // Arrange. + var expectedSession = new TaskAgentSession(); + _brokerServer + .Setup(x => x.CreateSessionAsync( + It.Is(y => y != null), + tokenSource.Token)) + .Returns(Task.FromResult(expectedSession)); + + _credMgr.Setup(x => x.LoadCredentials(true)).Returns(new VssCredentials()); + + // Act. + BrokerMessageListener listener = new(); + listener.Initialize(tc); + + CreateSessionResult result = await listener.CreateSessionAsync(tokenSource.Token); + trace.Info("result: {0}", result); + + // Assert. + Assert.Equal(CreateSessionResult.Success, result); + _brokerServer + .Verify(x => x.CreateSessionAsync( + It.Is(y => y != null), + tokenSource.Token), Times.Once()); + + tc.EnableAuthMigration("L0Test"); + + var traceFile = Path.GetTempFileName(); + File.Copy(tc.TraceFileName, traceFile, true); + Assert.Contains("Auth migration changed", File.ReadAllText(traceFile)); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Runner")] + public async Task CreatesSession_DeferAuthMigration() + { + using (TestHostContext tc = CreateTestContext()) + using (var tokenSource = new CancellationTokenSource()) + { + Tracing trace = tc.GetTrace(); + + // Arrange. + var throwException = true; + var expectedSession = new TaskAgentSession(); + _brokerServer + .Setup(x => x.CreateSessionAsync( + It.Is(y => y != null), + tokenSource.Token)) + .Returns(async (TaskAgentSession session, CancellationToken token) => + { + await Task.Yield(); + if (throwException) + { + throwException = false; + throw new NotSupportedException("Error during create session"); + } + + return expectedSession; + }); + + _credMgr.Setup(x => x.LoadCredentials(true)).Returns(new VssCredentials()); + + // Act. + BrokerMessageListener listener = new(); + listener.Initialize(tc); + + tc.EnableAuthMigration("L0Test"); + Assert.True(tc.AllowAuthMigration); + + CreateSessionResult result = await listener.CreateSessionAsync(tokenSource.Token); + trace.Info("result: {0}", result); + + // Assert. + Assert.Equal(CreateSessionResult.Success, result); + _brokerServer + .Verify(x => x.CreateSessionAsync( + It.Is(y => y != null), + tokenSource.Token), Times.Exactly(2)); + _credMgr.Verify(x => x.LoadCredentials(true), Times.Exactly(2)); + + Assert.False(tc.AllowAuthMigration); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Runner")] + public async Task GetNextMessage() + { + using (TestHostContext tc = CreateTestContext()) + using (var tokenSource = new CancellationTokenSource()) + { + Tracing trace = tc.GetTrace(); + + // Arrange. + _credMgr.Setup(x => x.LoadCredentials(true)).Returns(new VssCredentials()); + + var expectedSession = new TaskAgentSession(); + _brokerServer + .Setup(x => x.CreateSessionAsync( + It.Is(y => y != null), + tokenSource.Token)) + .Returns(Task.FromResult(expectedSession)); + + var expectedMessage = new TaskAgentMessage(); + _brokerServer + .Setup(x => x.GetRunnerMessageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.FromResult(expectedMessage)); + + // Act. + BrokerMessageListener listener = new(); + listener.Initialize(tc); + + CreateSessionResult result = await listener.CreateSessionAsync(tokenSource.Token); + trace.Info("result: {0}", result); + Assert.Equal(CreateSessionResult.Success, result); + + TaskAgentMessage message = await listener.GetNextMessageAsync(tokenSource.Token); + trace.Info("message: {0}", message); + + // Assert. + Assert.Equal(expectedMessage, message); + _brokerServer + .Verify(x => x.GetRunnerMessageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Once()); + + _brokerServer.Verify(x => x.ConnectAsync(It.IsAny(), It.IsAny()), Times.Once()); + + _credMgr.Verify(x => x.LoadCredentials(true), Times.Once()); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Runner")] + public async Task GetNextMessage_EnableAuthMigration() + { + using (TestHostContext tc = CreateTestContext()) + using (var tokenSource = new CancellationTokenSource()) + { + Tracing trace = tc.GetTrace(); + + // Arrange. + _credMgr.Setup(x => x.LoadCredentials(true)).Returns(new VssCredentials()); + + var expectedSession = new TaskAgentSession(); + _brokerServer + .Setup(x => x.CreateSessionAsync( + It.Is(y => y != null), + tokenSource.Token)) + .Returns(Task.FromResult(expectedSession)); + + var expectedMessage = new TaskAgentMessage(); + _brokerServer + .Setup(x => x.GetRunnerMessageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.FromResult(expectedMessage)); + + // Act. + BrokerMessageListener listener = new(); + listener.Initialize(tc); + + CreateSessionResult result = await listener.CreateSessionAsync(tokenSource.Token); + trace.Info("result: {0}", result); + Assert.Equal(CreateSessionResult.Success, result); + + tc.EnableAuthMigration("L0Test"); + + TaskAgentMessage message = await listener.GetNextMessageAsync(tokenSource.Token); + trace.Info("message: {0}", message); + + // Assert. + Assert.Equal(expectedMessage, message); + _brokerServer + .Verify(x => x.GetRunnerMessageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Once()); + + _brokerServer.Verify(x => x.ConnectAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + + _credMgr.Verify(x => x.LoadCredentials(true), Times.Exactly(2)); + + Assert.True(tc.AllowAuthMigration); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Runner")] + public async Task GetNextMessage_AuthMigrationFallback() { using (TestHostContext tc = CreateTestContext()) using (var tokenSource = new CancellationTokenSource()) { Tracing trace = tc.GetTrace(); + tc.EnableAuthMigration("L0Test"); + // Arrange. + _credMgr.Setup(x => x.LoadCredentials(true)).Returns(new VssCredentials()); + var expectedSession = new TaskAgentSession(); _brokerServer .Setup(x => x.CreateSessionAsync( @@ -50,14 +309,88 @@ public async void CreatesSession() tokenSource.Token)) .Returns(Task.FromResult(expectedSession)); - _credMgr.Setup(x => x.LoadCredentials()).Returns(new VssCredentials()); - _store.Setup(x => x.GetCredentials()).Returns(new CredentialData() { Scheme = Constants.Configuration.OAuthAccessToken }); - _store.Setup(x => x.GetMigratedCredentials()).Returns(default(CredentialData)); + var expectedMessage = new TaskAgentMessage(); + _brokerServer + .Setup(x => x.GetRunnerMessageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(async (Guid? sessionId, TaskAgentStatus status, string version, string os, string architecture, bool disableUpdate, CancellationToken token) => + { + await Task.Yield(); + if (tc.AllowAuthMigration) + { + throw new NotSupportedException("Error during get message"); + } + + return expectedMessage; + }); // Act. BrokerMessageListener listener = new(); listener.Initialize(tc); + CreateSessionResult result = await listener.CreateSessionAsync(tokenSource.Token); + trace.Info("result: {0}", result); + Assert.Equal(CreateSessionResult.Success, result); + + Assert.True(tc.AllowAuthMigration); + + TaskAgentMessage message = await listener.GetNextMessageAsync(tokenSource.Token); + trace.Info("message: {0}", message); + + // Assert. + Assert.Equal(expectedMessage, message); + _brokerServer + .Verify(x => x.GetRunnerMessageAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Exactly(2)); + + _brokerServer.Verify(x => x.ConnectAsync(It.IsAny(), It.IsAny()), Times.Exactly(3)); + + _credMgr.Verify(x => x.LoadCredentials(true), Times.Exactly(3)); + + Assert.False(tc.AllowAuthMigration); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Runner")] + public async Task CreatesSessionWithProvidedSettings() + { + using (TestHostContext tc = CreateTestContext()) + using (var tokenSource = new CancellationTokenSource()) + { + Tracing trace = tc.GetTrace(); + + // Arrange. + var expectedSession = new TaskAgentSession(); + _brokerServer + .Setup(x => x.CreateSessionAsync( + It.Is(y => y != null), + tokenSource.Token)) + .Returns(Task.FromResult(expectedSession)); + + _credMgr.Setup(x => x.LoadCredentials(true)).Returns(new VssCredentials()); + + // Make sure the config is never called when settings are provided + _config.Setup(x => x.LoadSettings()).Throws(new InvalidOperationException("Should not be called")); + + // Act. + // Use the constructor that accepts settings + BrokerMessageListener listener = new(_settings); + listener.Initialize(tc); + CreateSessionResult result = await listener.CreateSessionAsync(tokenSource.Token); trace.Info("result: {0}", result); @@ -67,6 +400,9 @@ public async void CreatesSession() .Verify(x => x.CreateSessionAsync( It.Is(y => y != null), tokenSource.Token), Times.Once()); + + // Verify LoadSettings was never called + _config.Verify(x => x.LoadSettings(), Times.Never()); } } @@ -75,7 +411,6 @@ private TestHostContext CreateTestContext([CallerMemberName] String testName = " TestHostContext tc = new(this, testName); tc.SetSingleton(_config.Object); tc.SetSingleton(_credMgr.Object); - tc.SetSingleton(_store.Object); tc.SetSingleton(_brokerServer.Object); tc.SetSingleton(_runnerServer.Object); return tc; diff --git a/src/Test/L0/Listener/Configuration/RunnerCredentialL0.cs b/src/Test/L0/Listener/Configuration/RunnerCredentialL0.cs index 609a7129498..a2c5d0c20ba 100644 --- a/src/Test/L0/Listener/Configuration/RunnerCredentialL0.cs +++ b/src/Test/L0/Listener/Configuration/RunnerCredentialL0.cs @@ -1,14 +1,18 @@ -using GitHub.Runner.Listener; +using System.Collections.Generic; +using System.Security.Cryptography; +using GitHub.Runner.Listener; using GitHub.Runner.Listener.Configuration; using GitHub.Services.Common; using GitHub.Services.OAuth; +using Moq; +using Xunit; namespace GitHub.Runner.Common.Tests.Listener.Configuration { public class TestRunnerCredential : CredentialProvider { public TestRunnerCredential() : base("TEST") { } - public override VssCredentials GetVssCredentials(IHostContext context) + public override VssCredentials GetVssCredentials(IHostContext context, bool allowAuthUrlV2) { Tracing trace = context.GetTrace("OuthAccessToken"); trace.Info("GetVssCredentials()"); @@ -23,4 +27,85 @@ public override void EnsureCredential(IHostContext context, CommandSettings comm { } } -} + + public class OAuthCredentialTestsL0 + { + private Mock _rsaKeyManager = new Mock(); + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "OAuthCredential")] + public void NotUseAuthV2Url() + { + using (TestHostContext hc = new(this)) + { + // Arrange. + var oauth = new OAuthCredential(); + oauth.CredentialData = new CredentialData() + { + Scheme = Constants.Configuration.OAuth + }; + oauth.CredentialData.Data.Add("clientId", "someClientId"); + oauth.CredentialData.Data.Add("authorizationUrl", "http://myserver/"); + oauth.CredentialData.Data.Add("authorizationUrlV2", "http://myserverv2/"); + + _rsaKeyManager.Setup(x => x.GetKey()).Returns(RSA.Create(2048)); + hc.SetSingleton(_rsaKeyManager.Object); + + // Act. + var cred = oauth.GetVssCredentials(hc, false); // not allow auth v2 + + var cred2 = oauth.GetVssCredentials(hc, true); // use auth v2 but hostcontext doesn't + + hc.EnableAuthMigration("L0Test"); + var cred3 = oauth.GetVssCredentials(hc, false); // not use auth v2 but hostcontext does + + oauth.CredentialData.Data.Remove("authorizationUrlV2"); + var cred4 = oauth.GetVssCredentials(hc, true); // v2 url is not there + + // Assert. + Assert.Equal("http://myserver/", (cred.Federated as VssOAuthCredential).AuthorizationUrl.AbsoluteUri); + Assert.Equal("someClientId", (cred.Federated as VssOAuthCredential).ClientCredential.ClientId); + + Assert.Equal("http://myserver/", (cred2.Federated as VssOAuthCredential).AuthorizationUrl.AbsoluteUri); + Assert.Equal("someClientId", (cred2.Federated as VssOAuthCredential).ClientCredential.ClientId); + + Assert.Equal("http://myserver/", (cred3.Federated as VssOAuthCredential).AuthorizationUrl.AbsoluteUri); + Assert.Equal("someClientId", (cred3.Federated as VssOAuthCredential).ClientCredential.ClientId); + + Assert.Equal("http://myserver/", (cred4.Federated as VssOAuthCredential).AuthorizationUrl.AbsoluteUri); + Assert.Equal("someClientId", (cred4.Federated as VssOAuthCredential).ClientCredential.ClientId); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "OAuthCredential")] + public void UseAuthV2Url() + { + using (TestHostContext hc = new(this)) + { + // Arrange. + var oauth = new OAuthCredential(); + oauth.CredentialData = new CredentialData() + { + Scheme = Constants.Configuration.OAuth + }; + oauth.CredentialData.Data.Add("clientId", "someClientId"); + oauth.CredentialData.Data.Add("authorizationUrl", "http://myserver/"); + oauth.CredentialData.Data.Add("authorizationUrlV2", "http://myserverv2/"); + + _rsaKeyManager.Setup(x => x.GetKey()).Returns(RSA.Create(2048)); + hc.SetSingleton(_rsaKeyManager.Object); + + // Act. + hc.EnableAuthMigration("L0Test"); + var cred = oauth.GetVssCredentials(hc, true); + + // Assert. + Assert.Equal("http://myserverv2/", (cred.Federated as VssOAuthCredential).AuthorizationUrl.AbsoluteUri); + Assert.Equal("someClientId", (cred.Federated as VssOAuthCredential).ClientCredential.ClientId); + } + } + } +} \ No newline at end of file diff --git a/src/Test/L0/Listener/MessageListenerL0.cs b/src/Test/L0/Listener/MessageListenerL0.cs index f44d4988928..80792539be9 100644 --- a/src/Test/L0/Listener/MessageListenerL0.cs +++ b/src/Test/L0/Listener/MessageListenerL0.cs @@ -51,7 +51,7 @@ private TestHostContext CreateTestContext([CallerMemberName] String testName = " [Fact] [Trait("Level", "L0")] [Trait("Category", "Runner")] - public async void CreatesSession() + public async Task CreatesSession() { using (TestHostContext tc = CreateTestContext()) using (var tokenSource = new CancellationTokenSource()) @@ -67,7 +67,7 @@ public async void CreatesSession() tokenSource.Token)) .Returns(Task.FromResult(expectedSession)); - _credMgr.Setup(x => x.LoadCredentials()).Returns(new VssCredentials()); + _credMgr.Setup(x => x.LoadCredentials(It.IsAny())).Returns(new VssCredentials()); _store.Setup(x => x.GetCredentials()).Returns(new CredentialData() { Scheme = Constants.Configuration.OAuthAccessToken }); _store.Setup(x => x.GetMigratedCredentials()).Returns(default(CredentialData)); @@ -95,69 +95,7 @@ public async void CreatesSession() [Fact] [Trait("Level", "L0")] [Trait("Category", "Runner")] - public async void CreatesSessionWithBrokerMigration() - { - using (TestHostContext tc = CreateTestContext()) - using (var tokenSource = new CancellationTokenSource()) - { - Tracing trace = tc.GetTrace(); - - // Arrange. - var expectedSession = new TaskAgentSession() - { - OwnerName = "legacy", - BrokerMigrationMessage = new BrokerMigrationMessage(new Uri("https://broker.actions.github.com")) - }; - - var expectedBrokerSession = new TaskAgentSession() - { - OwnerName = "broker" - }; - - _runnerServer - .Setup(x => x.CreateAgentSessionAsync( - _settings.PoolId, - It.Is(y => y != null), - tokenSource.Token)) - .Returns(Task.FromResult(expectedSession)); - - _brokerServer - .Setup(x => x.CreateSessionAsync( - It.Is(y => y != null), - tokenSource.Token)) - .Returns(Task.FromResult(expectedBrokerSession)); - - _credMgr.Setup(x => x.LoadCredentials()).Returns(new VssCredentials()); - _store.Setup(x => x.GetCredentials()).Returns(new CredentialData() { Scheme = Constants.Configuration.OAuthAccessToken }); - _store.Setup(x => x.GetMigratedCredentials()).Returns(default(CredentialData)); - - // Act. - MessageListener listener = new(); - listener.Initialize(tc); - - CreateSessionResult result = await listener.CreateSessionAsync(tokenSource.Token); - trace.Info("result: {0}", result); - - // Assert. - Assert.Equal(CreateSessionResult.Success, result); - - _runnerServer - .Verify(x => x.CreateAgentSessionAsync( - _settings.PoolId, - It.Is(y => y != null), - tokenSource.Token), Times.Once()); - - _brokerServer - .Verify(x => x.CreateSessionAsync( - It.Is(y => y != null), - tokenSource.Token), Times.Once()); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Runner")] - public async void DeleteSession() + public async Task DeleteSession() { using (TestHostContext tc = CreateTestContext()) using (var tokenSource = new CancellationTokenSource()) @@ -177,7 +115,7 @@ public async void DeleteSession() tokenSource.Token)) .Returns(Task.FromResult(expectedSession)); - _credMgr.Setup(x => x.LoadCredentials()).Returns(new VssCredentials()); + _credMgr.Setup(x => x.LoadCredentials(It.IsAny())).Returns(new VssCredentials()); _store.Setup(x => x.GetCredentials()).Returns(new CredentialData() { Scheme = Constants.Configuration.OAuthAccessToken }); _store.Setup(x => x.GetMigratedCredentials()).Returns(default(CredentialData)); @@ -204,84 +142,7 @@ public async void DeleteSession() [Fact] [Trait("Level", "L0")] [Trait("Category", "Runner")] - public async void DeleteSessionWithBrokerMigration() - { - using (TestHostContext tc = CreateTestContext()) - using (var tokenSource = new CancellationTokenSource()) - { - Tracing trace = tc.GetTrace(); - - // Arrange. - var expectedSession = new TaskAgentSession() - { - OwnerName = "legacy", - BrokerMigrationMessage = new BrokerMigrationMessage(new Uri("https://broker.actions.github.com")) - }; - - var expectedBrokerSession = new TaskAgentSession() - { - SessionId = Guid.NewGuid(), - OwnerName = "broker" - }; - - _runnerServer - .Setup(x => x.CreateAgentSessionAsync( - _settings.PoolId, - It.Is(y => y != null), - tokenSource.Token)) - .Returns(Task.FromResult(expectedSession)); - - _brokerServer - .Setup(x => x.CreateSessionAsync( - It.Is(y => y != null), - tokenSource.Token)) - .Returns(Task.FromResult(expectedBrokerSession)); - - _credMgr.Setup(x => x.LoadCredentials()).Returns(new VssCredentials()); - _store.Setup(x => x.GetCredentials()).Returns(new CredentialData() { Scheme = Constants.Configuration.OAuthAccessToken }); - _store.Setup(x => x.GetMigratedCredentials()).Returns(default(CredentialData)); - - // Act. - MessageListener listener = new(); - listener.Initialize(tc); - - CreateSessionResult result = await listener.CreateSessionAsync(tokenSource.Token); - trace.Info("result: {0}", result); - - Assert.Equal(CreateSessionResult.Success, result); - - _runnerServer - .Verify(x => x.CreateAgentSessionAsync( - _settings.PoolId, - It.Is(y => y != null), - tokenSource.Token), Times.Once()); - - _brokerServer - .Verify(x => x.CreateSessionAsync( - It.Is(y => y != null), - tokenSource.Token), Times.Once()); - - _brokerServer - .Setup(x => x.DeleteSessionAsync(It.IsAny())) - .Returns(Task.CompletedTask); - - // Act. - await listener.DeleteSessionAsync(); - - - //Assert - _runnerServer - .Verify(x => x.DeleteAgentSessionAsync( - _settings.PoolId, expectedBrokerSession.SessionId, It.IsAny()), Times.Once()); - _brokerServer - .Verify(x => x.DeleteSessionAsync(It.IsAny()), Times.Once()); - } - } - - [Fact] - [Trait("Level", "L0")] - [Trait("Category", "Runner")] - public async void GetNextMessage() + public async Task GetNextMessage() { using (TestHostContext tc = CreateTestContext()) using (var tokenSource = new CancellationTokenSource()) @@ -301,7 +162,7 @@ public async void GetNextMessage() tokenSource.Token)) .Returns(Task.FromResult(expectedSession)); - _credMgr.Setup(x => x.LoadCredentials()).Returns(new VssCredentials()); + _credMgr.Setup(x => x.LoadCredentials(It.IsAny())).Returns(new VssCredentials()); _store.Setup(x => x.GetCredentials()).Returns(new CredentialData() { Scheme = Constants.Configuration.OAuthAccessToken }); _store.Setup(x => x.GetMigratedCredentials()).Returns(default(CredentialData)); @@ -362,7 +223,7 @@ public async void GetNextMessage() [Fact] [Trait("Level", "L0")] [Trait("Category", "Runner")] - public async void GetNextMessageWithBrokerMigration() + public async Task GetNextMessageWithBrokerMigration() { using (TestHostContext tc = CreateTestContext()) using (var tokenSource = new CancellationTokenSource()) @@ -382,7 +243,7 @@ public async void GetNextMessageWithBrokerMigration() tokenSource.Token)) .Returns(Task.FromResult(expectedSession)); - _credMgr.Setup(x => x.LoadCredentials()).Returns(new VssCredentials()); + _credMgr.Setup(x => x.LoadCredentials(It.IsAny())).Returns(new VssCredentials()); _store.Setup(x => x.GetCredentials()).Returns(new CredentialData() { Scheme = Constants.Configuration.OAuthAccessToken }); _store.Setup(x => x.GetMigratedCredentials()).Returns(default(CredentialData)); @@ -462,13 +323,22 @@ public async void GetNextMessageWithBrokerMigration() _brokerServer .Verify(x => x.GetRunnerMessageAsync( expectedSession.SessionId, TaskAgentStatus.Online, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(brokerMessages.Length)); + + _credMgr + .Verify(x => x.LoadCredentials(true), Times.Exactly(brokerMessages.Length)); + + _brokerServer + .Verify(x => x.UpdateConnectionIfNeeded(brokerMigrationMesage.BrokerBaseUrl, It.IsAny()), Times.Exactly(brokerMessages.Length)); + + _brokerServer + .Verify(x => x.ForceRefreshConnection(It.IsAny()), Times.Never); } } [Fact] [Trait("Level", "L0")] [Trait("Category", "Runner")] - public async void CreateSessionWithOriginalCredential() + public async Task CreateSessionWithOriginalCredential() { using (TestHostContext tc = CreateTestContext()) using (var tokenSource = new CancellationTokenSource()) @@ -484,7 +354,7 @@ public async void CreateSessionWithOriginalCredential() tokenSource.Token)) .Returns(Task.FromResult(expectedSession)); - _credMgr.Setup(x => x.LoadCredentials()).Returns(new VssCredentials()); + _credMgr.Setup(x => x.LoadCredentials(It.IsAny())).Returns(new VssCredentials()); var originalCred = new CredentialData() { Scheme = Constants.Configuration.OAuth }; originalCred.Data["authorizationUrl"] = "https://s.server"; @@ -513,7 +383,7 @@ public async void CreateSessionWithOriginalCredential() [Fact] [Trait("Level", "L0")] [Trait("Category", "Runner")] - public async void SkipDeleteSession_WhenGetNextMessageGetTaskAgentAccessTokenExpiredException() + public async Task SkipDeleteSession_WhenGetNextMessageGetTaskAgentAccessTokenExpiredException() { using (TestHostContext tc = CreateTestContext()) using (var tokenSource = new CancellationTokenSource()) @@ -533,7 +403,7 @@ public async void SkipDeleteSession_WhenGetNextMessageGetTaskAgentAccessTokenExp tokenSource.Token)) .Returns(Task.FromResult(expectedSession)); - _credMgr.Setup(x => x.LoadCredentials()).Returns(new VssCredentials()); + _credMgr.Setup(x => x.LoadCredentials(It.IsAny())).Returns(new VssCredentials()); _store.Setup(x => x.GetCredentials()).Returns(new CredentialData() { Scheme = Constants.Configuration.OAuthAccessToken }); _store.Setup(x => x.GetMigratedCredentials()).Returns(default(CredentialData)); @@ -571,5 +441,301 @@ public async void SkipDeleteSession_WhenGetNextMessageGetTaskAgentAccessTokenExp _settings.PoolId, expectedSession.SessionId, It.IsAny()), Times.Never); } } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Runner")] + public async Task HandleAuthMigrationChanged() + { + using (TestHostContext tc = CreateTestContext()) + using (var tokenSource = new CancellationTokenSource()) + { + Tracing trace = tc.GetTrace(); + + // Arrange. + var expectedSession = new TaskAgentSession(); + _runnerServer + .Setup(x => x.CreateAgentSessionAsync( + _settings.PoolId, + It.Is(y => y != null), + tokenSource.Token)) + .Returns(Task.FromResult(expectedSession)); + + _credMgr.Setup(x => x.LoadCredentials(It.IsAny())).Returns(new VssCredentials()); + + // Act. + MessageListener listener = new(); + listener.Initialize(tc); + + CreateSessionResult result = await listener.CreateSessionAsync(tokenSource.Token); + trace.Info("result: {0}", result); + + // Assert. + Assert.Equal(CreateSessionResult.Success, result); + _runnerServer + .Verify(x => x.CreateAgentSessionAsync( + _settings.PoolId, + It.Is(y => y != null), + tokenSource.Token), Times.Once()); + _brokerServer + .Verify(x => x.CreateSessionAsync( + It.Is(y => y != null), + tokenSource.Token), Times.Never()); + + tc.EnableAuthMigration("L0Test"); + + var traceFile = Path.GetTempFileName(); + File.Copy(tc.TraceFileName, traceFile, true); + Assert.Contains("Auth migration changed", File.ReadAllText(traceFile)); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Runner")] + public async Task GetNextMessageWithBrokerMigration_AuthMigrationFallback() + { + using (TestHostContext tc = CreateTestContext()) + using (var tokenSource = new CancellationTokenSource()) + { + Tracing trace = tc.GetTrace(); + + // Arrange. + var expectedSession = new TaskAgentSession(); + PropertyInfo sessionIdProperty = expectedSession.GetType().GetProperty("SessionId", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + Assert.NotNull(sessionIdProperty); + sessionIdProperty.SetValue(expectedSession, Guid.NewGuid()); + + _runnerServer + .Setup(x => x.CreateAgentSessionAsync( + _settings.PoolId, + It.Is(y => y != null), + tokenSource.Token)) + .Returns(Task.FromResult(expectedSession)); + + _credMgr.Setup(x => x.LoadCredentials(It.IsAny())).Returns(new VssCredentials()); + _store.Setup(x => x.GetCredentials()).Returns(new CredentialData() { Scheme = Constants.Configuration.OAuthAccessToken }); + _store.Setup(x => x.GetMigratedCredentials()).Returns(default(CredentialData)); + + // Act. + MessageListener listener = new(); + listener.Initialize(tc); + + tc.EnableAuthMigration("L0Test"); + + CreateSessionResult result = await listener.CreateSessionAsync(tokenSource.Token); + Assert.Equal(CreateSessionResult.Success, result); + + var brokerMigrationMesage = new BrokerMigrationMessage(new Uri("https://actions.broker.com")); + + var arMessages = new TaskAgentMessage[] + { + new TaskAgentMessage + { + Body = JsonUtility.ToString(brokerMigrationMesage), + MessageType = BrokerMigrationMessage.MessageType + }, + }; + + var brokerMessages = new TaskAgentMessage[] + { + new TaskAgentMessage + { + Body = "somebody1", + MessageId = 4234, + MessageType = JobRequestMessageTypes.PipelineAgentJobRequest + }, + new TaskAgentMessage + { + Body = "somebody2", + MessageId = 4235, + MessageType = JobCancelMessage.MessageType + }, + null, //should be skipped by GetNextMessageAsync implementation + null, + new TaskAgentMessage + { + Body = "somebody3", + MessageId = 4236, + MessageType = JobRequestMessageTypes.PipelineAgentJobRequest + } + }; + var brokerMessageQueue = new Queue(brokerMessages); + + _runnerServer + .Setup(x => x.GetAgentMessageAsync( + _settings.PoolId, expectedSession.SessionId, It.IsAny(), TaskAgentStatus.Online, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(async (Int32 poolId, Guid sessionId, Int64? lastMessageId, TaskAgentStatus status, string runnerVersion, string os, string architecture, bool disableUpdate, CancellationToken cancellationToken) => + { + await Task.Yield(); + return arMessages[0]; // always send migration message + }); + + var counter = 0; + _brokerServer + .Setup(x => x.GetRunnerMessageAsync( + expectedSession.SessionId, TaskAgentStatus.Online, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(async (Guid sessionId, TaskAgentStatus status, string runnerVersion, string os, string architecture, bool disableUpdate, CancellationToken cancellationToken) => + { + counter++; + await Task.Yield(); + if (counter == 2) + { + throw new NotSupportedException("Something wrong."); + } + + return brokerMessageQueue.Dequeue(); + }); + + TaskAgentMessage message1 = await listener.GetNextMessageAsync(tokenSource.Token); + TaskAgentMessage message2 = await listener.GetNextMessageAsync(tokenSource.Token); + TaskAgentMessage message3 = await listener.GetNextMessageAsync(tokenSource.Token); + Assert.Equal(brokerMessages[0], message1); + Assert.Equal(brokerMessages[1], message2); + Assert.Equal(brokerMessages[4], message3); + + //Assert + _runnerServer + .Verify(x => x.GetAgentMessageAsync( + _settings.PoolId, expectedSession.SessionId, It.IsAny(), TaskAgentStatus.Online, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(brokerMessages.Length + 1)); + + _brokerServer + .Verify(x => x.GetRunnerMessageAsync( + expectedSession.SessionId, TaskAgentStatus.Online, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(brokerMessages.Length + 1)); + + _credMgr + .Verify(x => x.LoadCredentials(true), Times.Exactly(brokerMessages.Length + 1)); + + _brokerServer + .Verify(x => x.UpdateConnectionIfNeeded(brokerMigrationMesage.BrokerBaseUrl, It.IsAny()), Times.Exactly(brokerMessages.Length + 1)); + + _brokerServer + .Verify(x => x.ForceRefreshConnection(It.IsAny()), Times.Once()); + + Assert.False(tc.AllowAuthMigration); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Runner")] + public async Task GetNextMessageWithBrokerMigration_EnableAuthMigration() + { + using (TestHostContext tc = CreateTestContext()) + using (var tokenSource = new CancellationTokenSource()) + { + Tracing trace = tc.GetTrace(); + + // Arrange. + var expectedSession = new TaskAgentSession(); + PropertyInfo sessionIdProperty = expectedSession.GetType().GetProperty("SessionId", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + Assert.NotNull(sessionIdProperty); + sessionIdProperty.SetValue(expectedSession, Guid.NewGuid()); + + _runnerServer + .Setup(x => x.CreateAgentSessionAsync( + _settings.PoolId, + It.Is(y => y != null), + tokenSource.Token)) + .Returns(Task.FromResult(expectedSession)); + + _credMgr.Setup(x => x.LoadCredentials(It.IsAny())).Returns(new VssCredentials()); + _store.Setup(x => x.GetCredentials()).Returns(new CredentialData() { Scheme = Constants.Configuration.OAuthAccessToken }); + _store.Setup(x => x.GetMigratedCredentials()).Returns(default(CredentialData)); + + // Act. + MessageListener listener = new(); + listener.Initialize(tc); + + CreateSessionResult result = await listener.CreateSessionAsync(tokenSource.Token); + Assert.Equal(CreateSessionResult.Success, result); + + var brokerMigrationMesage = new BrokerMigrationMessage(new Uri("https://actions.broker.com")); + + var arMessages = new TaskAgentMessage[] + { + new TaskAgentMessage + { + Body = JsonUtility.ToString(brokerMigrationMesage), + MessageType = BrokerMigrationMessage.MessageType + }, + }; + + var brokerMessages = new TaskAgentMessage[] + { + new TaskAgentMessage + { + Body = "somebody1", + MessageId = 4234, + MessageType = JobRequestMessageTypes.PipelineAgentJobRequest + }, + new TaskAgentMessage + { + Body = "somebody2", + MessageId = 4235, + MessageType = JobCancelMessage.MessageType + }, + null, //should be skipped by GetNextMessageAsync implementation + null, + new TaskAgentMessage + { + Body = "somebody3", + MessageId = 4236, + MessageType = JobRequestMessageTypes.PipelineAgentJobRequest + } + }; + var brokerMessageQueue = new Queue(brokerMessages); + + _runnerServer + .Setup(x => x.GetAgentMessageAsync( + _settings.PoolId, expectedSession.SessionId, It.IsAny(), TaskAgentStatus.Online, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(async (Int32 poolId, Guid sessionId, Int64? lastMessageId, TaskAgentStatus status, string runnerVersion, string os, string architecture, bool disableUpdate, CancellationToken cancellationToken) => + { + await Task.Yield(); + return arMessages[0]; // always send migration message + }); + + _brokerServer + .Setup(x => x.GetRunnerMessageAsync( + expectedSession.SessionId, TaskAgentStatus.Online, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(async (Guid sessionId, TaskAgentStatus status, string runnerVersion, string os, string architecture, bool disableUpdate, CancellationToken cancellationToken) => + { + await Task.Yield(); + if (!tc.AllowAuthMigration) + { + tc.EnableAuthMigration("L0Test"); + } + + return brokerMessageQueue.Dequeue(); + }); + + TaskAgentMessage message1 = await listener.GetNextMessageAsync(tokenSource.Token); + TaskAgentMessage message2 = await listener.GetNextMessageAsync(tokenSource.Token); + TaskAgentMessage message3 = await listener.GetNextMessageAsync(tokenSource.Token); + Assert.Equal(brokerMessages[0], message1); + Assert.Equal(brokerMessages[1], message2); + Assert.Equal(brokerMessages[4], message3); + + //Assert + _runnerServer + .Verify(x => x.GetAgentMessageAsync( + _settings.PoolId, expectedSession.SessionId, It.IsAny(), TaskAgentStatus.Online, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(brokerMessages.Length)); + + _brokerServer + .Verify(x => x.GetRunnerMessageAsync( + expectedSession.SessionId, TaskAgentStatus.Online, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(brokerMessages.Length)); + + _credMgr + .Verify(x => x.LoadCredentials(true), Times.Exactly(brokerMessages.Length)); + + _brokerServer + .Verify(x => x.UpdateConnectionIfNeeded(brokerMigrationMesage.BrokerBaseUrl, It.IsAny()), Times.Exactly(brokerMessages.Length)); + + _brokerServer + .Verify(x => x.ForceRefreshConnection(It.IsAny()), Times.Once()); + + Assert.True(tc.AllowAuthMigration); + } + } } } diff --git a/src/Test/L0/Listener/RunnerConfigUpdaterTests.cs b/src/Test/L0/Listener/RunnerConfigUpdaterTests.cs index 70ee07cc3fb..63deafe5b85 100644 --- a/src/Test/L0/Listener/RunnerConfigUpdaterTests.cs +++ b/src/Test/L0/Listener/RunnerConfigUpdaterTests.cs @@ -1,13 +1,13 @@ using System; +using System.Text; +using System.Threading; using System.Threading.Tasks; -using GitHub.Runner.Listener; using GitHub.Runner.Common; +using GitHub.Runner.Common.Tests; +using GitHub.Runner.Listener; using GitHub.Runner.Sdk; using Moq; using Xunit; -using System.Threading; -using GitHub.Runner.Common.Tests; -using System.Text; namespace GitHub.Runner.Tests.Listener { @@ -210,9 +210,9 @@ public async Task UpdateRunnerConfigAsync_UpdateRunnerCredentials_ShouldSucceed( var encodedConfig = Convert.ToBase64String(Encoding.UTF8.GetBytes(StringUtil.ConvertToJson(credData))); _runnerServer.Setup(x => x.RefreshRunnerConfigAsync(It.IsAny(), It.Is(s => s == "credentials"), It.IsAny(), It.IsAny())).ReturnsAsync(encodedConfig); - var _runnerConfigUpdater = new RunnerConfigUpdater(); _runnerConfigUpdater.Initialize(hc); + hc.EnableAuthMigration("L0Test"); var validRunnerQualifiedId = "valid/runner/qualifiedid/1"; var configType = "credentials"; @@ -226,6 +226,7 @@ public async Task UpdateRunnerConfigAsync_UpdateRunnerCredentials_ShouldSucceed( _runnerServer.Verify(x => x.RefreshRunnerConfigAsync(1, "credentials", It.IsAny(), It.IsAny()), Times.Once); _runnerServer.Verify(x => x.UpdateAgentUpdateStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.Is(s => s.Contains("Runner credentials updated successfully")), It.IsAny()), Times.Once); _configurationStore.Verify(x => x.SaveMigratedCredential(It.IsAny()), Times.Once); + Assert.False(hc.AllowAuthMigration); } } @@ -306,7 +307,7 @@ public async Task UpdateRunnerConfigAsync_RefreshRunnerSettingsFailure_ShouldRep [Fact] [Trait("Level", "L0")] [Trait("Category", "Runner")] - public async Task UpdateRunnerConfigAsync_RefreshRunnerCredetialsFailure_ShouldReportTelemetry() + public async Task UpdateRunnerConfigAsync_RefreshRunnerCredentialsFailure_ShouldReportTelemetry() { using (var hc = new TestHostContext(this)) { @@ -510,6 +511,56 @@ public async Task UpdateRunnerConfigAsync_RefreshOAuthCredentialsWithDifferentCl } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Runner")] + public async Task UpdateRunnerConfigAsync_RefreshOAuthCredentialsWithDifferentAuthUrl_ShouldReportTelemetry() + { + using (var hc = new TestHostContext(this)) + { + hc.SetSingleton(_configurationStore.Object); + hc.SetSingleton(_runnerServer.Object); + + // Arrange + var setting = new RunnerSettings { AgentId = 1, AgentName = "agent1" }; + _configurationStore.Setup(x => x.GetSettings()).Returns(setting); + var credData = new CredentialData + { + Scheme = "OAuth" + }; + credData.Data.Add("clientId", "12345"); + credData.Data.Add("authorizationUrl", "http://example.com/"); + _configurationStore.Setup(x => x.GetCredentials()).Returns(credData); + + IOUtil.SaveObject(setting, hc.GetConfigFile(WellKnownConfigFile.Runner)); + IOUtil.SaveObject(credData, hc.GetConfigFile(WellKnownConfigFile.Credentials)); + + var differentCredData = new CredentialData + { + Scheme = "OAuth" + }; + differentCredData.Data.Add("clientId", "12345"); + differentCredData.Data.Add("authorizationUrl", "http://example2.com/"); + var encodedConfig = Convert.ToBase64String(Encoding.UTF8.GetBytes(StringUtil.ConvertToJson(differentCredData))); + _runnerServer.Setup(x => x.RefreshRunnerConfigAsync(It.IsAny(), It.Is(s => s == "credentials"), It.IsAny(), It.IsAny())).ReturnsAsync(encodedConfig); + + var _runnerConfigUpdater = new RunnerConfigUpdater(); + _runnerConfigUpdater.Initialize(hc); + + var validRunnerQualifiedId = "valid/runner/qualifiedid/1"; + var configType = "credentials"; + var serviceType = "pipelines"; + var configRefreshUrl = "http://example.com"; + + // Act + await _runnerConfigUpdater.UpdateRunnerConfigAsync(validRunnerQualifiedId, configType, serviceType, configRefreshUrl); + + // Assert + _runnerServer.Verify(x => x.UpdateAgentUpdateStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.Is(s => s.Contains("Credential authorizationUrl in refreshed config")), It.IsAny()), Times.Once); + _configurationStore.Verify(x => x.SaveMigratedCredential(It.IsAny()), Times.Never); + } + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Runner")] @@ -575,5 +626,53 @@ public async Task UpdateRunnerConfigAsync_RunnerAdminService_ShouldThrowNotSuppo _configurationStore.Verify(x => x.SaveMigratedSettings(It.IsAny()), Times.Never); } } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Runner")] + public async Task UpdateRunnerConfigAsync_UpdateRunnerCredentials_EnableDisableAuthMigration() + { + using (var hc = new TestHostContext(this)) + { + hc.SetSingleton(_configurationStore.Object); + hc.SetSingleton(_runnerServer.Object); + + // Arrange + var setting = new RunnerSettings { AgentId = 1, AgentName = "agent1" }; + _configurationStore.Setup(x => x.GetSettings()).Returns(setting); + var credData = new CredentialData + { + Scheme = "OAuth" + }; + credData.Data.Add("ClientId", "12345"); + credData.Data.Add("AuthorizationUrl", "https://example.com"); + credData.Data.Add("AuthorizationUrlV2", "https://example2.com"); + _configurationStore.Setup(x => x.GetCredentials()).Returns(credData); + + IOUtil.SaveObject(setting, hc.GetConfigFile(WellKnownConfigFile.Runner)); + IOUtil.SaveObject(credData, hc.GetConfigFile(WellKnownConfigFile.Credentials)); + + var encodedConfig = Convert.ToBase64String(Encoding.UTF8.GetBytes(StringUtil.ConvertToJson(credData))); + _runnerServer.Setup(x => x.RefreshRunnerConfigAsync(It.IsAny(), It.Is(s => s == "credentials"), It.IsAny(), It.IsAny())).ReturnsAsync(encodedConfig); + + var _runnerConfigUpdater = new RunnerConfigUpdater(); + _runnerConfigUpdater.Initialize(hc); + Assert.False(hc.AllowAuthMigration); + + var validRunnerQualifiedId = "valid/runner/qualifiedid/1"; + var configType = "credentials"; + var serviceType = "pipelines"; + var configRefreshUrl = "http://example.com"; + + // Act + await _runnerConfigUpdater.UpdateRunnerConfigAsync(validRunnerQualifiedId, configType, serviceType, configRefreshUrl); + + // Assert + _runnerServer.Verify(x => x.RefreshRunnerConfigAsync(1, "credentials", It.IsAny(), It.IsAny()), Times.Once); + _runnerServer.Verify(x => x.UpdateAgentUpdateStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.Is(s => s.Contains("Runner credentials updated successfully")), It.IsAny()), Times.Once); + _configurationStore.Verify(x => x.SaveMigratedCredential(It.IsAny()), Times.Once); + Assert.True(hc.AllowAuthMigration); + } + } } } diff --git a/src/Test/L0/Listener/RunnerL0.cs b/src/Test/L0/Listener/RunnerL0.cs index b29f8835c2b..6a4dce37217 100644 --- a/src/Test/L0/Listener/RunnerL0.cs +++ b/src/Test/L0/Listener/RunnerL0.cs @@ -1,13 +1,15 @@ -using GitHub.DistributedTask.WebApi; -using GitHub.Runner.Listener; -using GitHub.Runner.Listener.Configuration; -using Moq; -using System; +using System; using System.Collections.Generic; +using System.IO; using System.Threading; using System.Threading.Tasks; -using Xunit; +using GitHub.DistributedTask.WebApi; +using GitHub.Runner.Listener; +using GitHub.Runner.Listener.Configuration; +using GitHub.Services.Common; using GitHub.Services.WebApi; +using Moq; +using Xunit; using Pipelines = GitHub.DistributedTask.Pipelines; namespace GitHub.Runner.Common.Tests.Listener @@ -24,6 +26,9 @@ public sealed class RunnerL0 private Mock _configStore; private Mock _updater; private Mock _acquireJobThrottler; + private Mock _credentialManager; + private Mock _actionsRunServer; + private Mock _runServer; public RunnerL0() { @@ -37,6 +42,9 @@ public RunnerL0() _configStore = new Mock(); _updater = new Mock(); _acquireJobThrottler = new Mock(); + _credentialManager = new Mock(); + _actionsRunServer = new Mock(); + _runServer = new Mock(); } private Pipelines.AgentJobRequestMessage CreateJobRequestMessage(string jobName) @@ -57,7 +65,7 @@ private JobCancelMessage CreateJobCancelMessage() [Trait("Level", "L0")] [Trait("Category", "Runner")] //process 2 new job messages, and one cancel message - public async void TestRunAsync() + public async Task TestRunAsync() { using (var hc = new TestHostContext(this)) { @@ -169,7 +177,7 @@ public async void TestRunAsync() [MemberData(nameof(RunAsServiceTestData))] [Trait("Level", "L0")] [Trait("Category", "Runner")] - public async void TestExecuteCommandForRunAsService(string[] args, bool configureAsService, Times expectedTimes) + public async Task TestExecuteCommandForRunAsService(string[] args, bool configureAsService, Times expectedTimes) { using (var hc = new TestHostContext(this)) { @@ -177,6 +185,7 @@ public async void TestExecuteCommandForRunAsService(string[] args, bool configur hc.SetSingleton(_promptManager.Object); hc.SetSingleton(_messageListener.Object); hc.SetSingleton(_configStore.Object); + hc.SetSingleton(_runnerServer.Object); hc.EnqueueInstance(_acquireJobThrottler.Object); var command = new CommandSettings(hc, args); @@ -201,7 +210,7 @@ public async void TestExecuteCommandForRunAsService(string[] args, bool configur [Fact] [Trait("Level", "L0")] [Trait("Category", "Runner")] - public async void TestMachineProvisionerCLI() + public async Task TestMachineProvisionerCLI() { using (var hc = new TestHostContext(this)) { @@ -209,6 +218,7 @@ public async void TestMachineProvisionerCLI() hc.SetSingleton(_promptManager.Object); hc.SetSingleton(_messageListener.Object); hc.SetSingleton(_configStore.Object); + hc.SetSingleton(_runnerServer.Object); hc.EnqueueInstance(_acquireJobThrottler.Object); var command = new CommandSettings(hc, new[] { "run" }); @@ -235,7 +245,7 @@ public async void TestMachineProvisionerCLI() [Fact] [Trait("Level", "L0")] [Trait("Category", "Runner")] - public async void TestRunOnce() + public async Task TestRunOnce() { using (var hc = new TestHostContext(this)) { @@ -332,7 +342,7 @@ public async void TestRunOnce() [Fact] [Trait("Level", "L0")] [Trait("Category", "Runner")] - public async void TestRunOnceOnlyTakeOneJobMessage() + public async Task TestRunOnceOnlyTakeOneJobMessage() { using (var hc = new TestHostContext(this)) { @@ -433,7 +443,7 @@ public async void TestRunOnceOnlyTakeOneJobMessage() [Fact] [Trait("Level", "L0")] [Trait("Category", "Runner")] - public async void TestRunOnceHandleUpdateMessage() + public async Task TestRunOnceHandleUpdateMessage() { using (var hc = new TestHostContext(this)) { @@ -523,13 +533,14 @@ public async void TestRunOnceHandleUpdateMessage() [Fact] [Trait("Level", "L0")] [Trait("Category", "Runner")] - public async void TestRemoveLocalRunnerConfig() + public async Task TestRemoveLocalRunnerConfig() { using (var hc = new TestHostContext(this)) { hc.SetSingleton(_configurationManager.Object); hc.SetSingleton(_configStore.Object); hc.SetSingleton(_promptManager.Object); + hc.SetSingleton(_runnerServer.Object); hc.EnqueueInstance(_acquireJobThrottler.Object); var command = new CommandSettings(hc, new[] { "remove", "--local" }); @@ -549,5 +560,521 @@ public async void TestRemoveLocalRunnerConfig() _configurationManager.Verify(x => x.DeleteLocalRunnerConfig(), Times.Once()); } } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Runner")] + public async Task TestReportAuthMigrationTelemetry() + { + using (var hc = new TestHostContext(this)) + { + //Arrange + var runner = new Runner.Listener.Runner(); + hc.SetSingleton(_configurationManager.Object); + hc.SetSingleton(_jobNotification.Object); + hc.SetSingleton(_messageListener.Object); + hc.SetSingleton(_promptManager.Object); + hc.SetSingleton(_runnerServer.Object); + hc.SetSingleton(_configStore.Object); + hc.SetSingleton(_credentialManager.Object); + hc.EnqueueInstance(_acquireJobThrottler.Object); + hc.EnqueueInstance(_jobDispatcher.Object); + + runner.Initialize(hc); + var settings = new RunnerSettings + { + PoolId = 43242, + AgentId = 5678, + Ephemeral = true + }; + + var message1 = new TaskAgentMessage() + { + MessageId = 4234, + MessageType = "unknown" + }; + + var messages = new Queue(); + messages.Enqueue(message1); + _updater.Setup(x => x.SelfUpdate(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(true)); + _configurationManager.Setup(x => x.LoadSettings()) + .Returns(settings); + _configurationManager.Setup(x => x.IsConfigured()) + .Returns(true); + _messageListener.Setup(x => x.CreateSessionAsync(It.IsAny())) + .Returns(Task.FromResult(CreateSessionResult.Success)); + _messageListener.Setup(x => x.GetNextMessageAsync(It.IsAny())) + .Returns(async (CancellationToken token) => + { + hc.GetTrace().Info("Waiting for message"); + Assert.False(hc.AllowAuthMigration); + await Task.Delay(100, token); + + var traceFile = Path.GetTempFileName(); + File.Copy(hc.TraceFileName, traceFile, true); + Assert.DoesNotContain("Checking for auth migration telemetry to report", File.ReadAllText(traceFile)); + + hc.EnableAuthMigration("L0Test"); + hc.DeferAuthMigration(TimeSpan.FromSeconds(1), "L0Test"); + hc.EnableAuthMigration("L0Test"); + hc.DeferAuthMigration(TimeSpan.FromSeconds(1), "L0Test"); + + await Task.Delay(1000, token); + + hc.ShutdownRunner(ShutdownReason.UserCancelled); + + File.Copy(hc.TraceFileName, traceFile, true); + Assert.Contains("Checking for auth migration telemetry to report", File.ReadAllText(traceFile)); + + return messages.Dequeue(); + }); + _messageListener.Setup(x => x.DeleteSessionAsync()) + .Returns(Task.CompletedTask); + _messageListener.Setup(x => x.DeleteMessageAsync(It.IsAny())) + .Returns(Task.CompletedTask); + _jobNotification.Setup(x => x.StartClient(It.IsAny())) + .Callback(() => + { + + }); + + _configStore.Setup(x => x.IsServiceConfigured()).Returns(false); + + _runnerServer.Setup(x => x.UpdateAgentUpdateStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new TaskAgent())); + + //Act + var command = new CommandSettings(hc, new string[] { "run" }); + var returnCode = await runner.ExecuteCommand(command); + + //Assert + Assert.Equal(Constants.Runner.ReturnCode.Success, returnCode); + + _messageListener.Verify(x => x.GetNextMessageAsync(It.IsAny()), Times.AtLeastOnce()); + _messageListener.Verify(x => x.CreateSessionAsync(It.IsAny()), Times.Once()); + _messageListener.Verify(x => x.DeleteSessionAsync(), Times.Once()); + _messageListener.Verify(x => x.DeleteMessageAsync(It.IsAny()), Times.Once()); + + _runnerServer.Verify(x => x.UpdateAgentUpdateStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.Is(s => s.Contains("L0Test")), It.IsAny()), Times.Exactly(4)); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Runner")] + public async Task TestRunnerJobRequestMessageFromPipeline() + { + using (var hc = new TestHostContext(this)) + { + //Arrange + var runner = new Runner.Listener.Runner(); + hc.SetSingleton(_configurationManager.Object); + hc.SetSingleton(_jobNotification.Object); + hc.SetSingleton(_messageListener.Object); + hc.SetSingleton(_promptManager.Object); + hc.SetSingleton(_runnerServer.Object); + hc.SetSingleton(_configStore.Object); + hc.SetSingleton(_updater.Object); + hc.SetSingleton(_credentialManager.Object); + hc.EnqueueInstance(_acquireJobThrottler.Object); + hc.EnqueueInstance(_actionsRunServer.Object); + hc.EnqueueInstance(_jobDispatcher.Object); + + runner.Initialize(hc); + var settings = new RunnerSettings + { + PoolId = 43242, + AgentId = 5678, + Ephemeral = true, + ServerUrl = "https://github.com", + }; + + var message1 = new TaskAgentMessage() + { + Body = JsonUtility.ToString(new RunnerJobRequestRef() { BillingOwnerId = "github", RunnerRequestId = "999" }), + MessageId = 4234, + MessageType = JobRequestMessageTypes.RunnerJobRequest + }; + + var messages = new Queue(); + messages.Enqueue(message1); + _updater.Setup(x => x.SelfUpdate(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(true)); + _configurationManager.Setup(x => x.LoadSettings()) + .Returns(settings); + _configurationManager.Setup(x => x.IsConfigured()) + .Returns(true); + _messageListener.Setup(x => x.CreateSessionAsync(It.IsAny())) + .Returns(Task.FromResult(CreateSessionResult.Success)); + _messageListener.Setup(x => x.GetNextMessageAsync(It.IsAny())) + .Returns(async (CancellationToken token) => + { + if (0 == messages.Count) + { + await Task.Delay(2000, token); + } + + return messages.Dequeue(); + }); + _messageListener.Setup(x => x.DeleteSessionAsync()) + .Returns(Task.CompletedTask); + _messageListener.Setup(x => x.DeleteMessageAsync(It.IsAny())) + .Returns(Task.CompletedTask); + _jobNotification.Setup(x => x.StartClient(It.IsAny())) + .Callback(() => + { + + }); + _actionsRunServer.Setup(x => x.GetJobMessageAsync("999", It.IsAny())) + .Returns(Task.FromResult(CreateJobRequestMessage("test"))); + + _credentialManager.Setup(x => x.LoadCredentials(false)).Returns(new VssCredentials()); + + _configStore.Setup(x => x.IsServiceConfigured()).Returns(false); + + var completedTask = new TaskCompletionSource(); + completedTask.SetResult(true); + _jobDispatcher.Setup(x => x.RunOnceJobCompleted).Returns(completedTask); + + //Act + var command = new CommandSettings(hc, new string[] { "run" }); + Task runnerTask = runner.ExecuteCommand(command); + + //Assert + //wait for the runner to exit with right return code + await Task.WhenAny(runnerTask, Task.Delay(30000)); + + Assert.True(runnerTask.IsCompleted, $"{nameof(runner.ExecuteCommand)} timed out."); + Assert.True(!runnerTask.IsFaulted, runnerTask.Exception?.ToString()); + if (runnerTask.IsCompleted) + { + Assert.Equal(Constants.Runner.ReturnCode.Success, await runnerTask); + } + + _jobDispatcher.Verify(x => x.Run(It.IsAny(), true), Times.Once()); + _messageListener.Verify(x => x.GetNextMessageAsync(It.IsAny()), Times.AtLeastOnce()); + _messageListener.Verify(x => x.CreateSessionAsync(It.IsAny()), Times.Once()); + _messageListener.Verify(x => x.DeleteSessionAsync(), Times.Once()); + _messageListener.Verify(x => x.DeleteMessageAsync(It.IsAny()), Times.Once()); + _credentialManager.Verify(x => x.LoadCredentials(false), Times.Once()); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Runner")] + public async Task TestRunnerJobRequestMessageFromRunService() + { + using (var hc = new TestHostContext(this)) + { + //Arrange + var runner = new Runner.Listener.Runner(); + hc.SetSingleton(_configurationManager.Object); + hc.SetSingleton(_jobNotification.Object); + hc.SetSingleton(_messageListener.Object); + hc.SetSingleton(_promptManager.Object); + hc.SetSingleton(_runnerServer.Object); + hc.SetSingleton(_configStore.Object); + hc.SetSingleton(_updater.Object); + hc.SetSingleton(_credentialManager.Object); + hc.EnqueueInstance(_acquireJobThrottler.Object); + hc.EnqueueInstance(_runServer.Object); + hc.EnqueueInstance(_jobDispatcher.Object); + + runner.Initialize(hc); + var settings = new RunnerSettings + { + PoolId = 43242, + AgentId = 5678, + Ephemeral = true, + ServerUrl = "https://github.com", + }; + + var message1 = new TaskAgentMessage() + { + Body = JsonUtility.ToString(new RunnerJobRequestRef() { BillingOwnerId = "github", RunnerRequestId = "999", RunServiceUrl = "https://run-service.com" }), + MessageId = 4234, + MessageType = JobRequestMessageTypes.RunnerJobRequest + }; + + var messages = new Queue(); + messages.Enqueue(message1); + _updater.Setup(x => x.SelfUpdate(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(true)); + _configurationManager.Setup(x => x.LoadSettings()) + .Returns(settings); + _configurationManager.Setup(x => x.IsConfigured()) + .Returns(true); + _messageListener.Setup(x => x.CreateSessionAsync(It.IsAny())) + .Returns(Task.FromResult(CreateSessionResult.Success)); + _messageListener.Setup(x => x.GetNextMessageAsync(It.IsAny())) + .Returns(async (CancellationToken token) => + { + if (0 == messages.Count) + { + await Task.Delay(2000, token); + } + + return messages.Dequeue(); + }); + _messageListener.Setup(x => x.DeleteSessionAsync()) + .Returns(Task.CompletedTask); + _messageListener.Setup(x => x.DeleteMessageAsync(It.IsAny())) + .Returns(Task.CompletedTask); + _jobNotification.Setup(x => x.StartClient(It.IsAny())) + .Callback(() => + { + + }); + _runServer.Setup(x => x.GetJobMessageAsync("999", "github", It.IsAny())) + .Returns(Task.FromResult(CreateJobRequestMessage("test"))); + + _credentialManager.Setup(x => x.LoadCredentials(true)).Returns(new VssCredentials()); + + _configStore.Setup(x => x.IsServiceConfigured()).Returns(false); + + var completedTask = new TaskCompletionSource(); + completedTask.SetResult(true); + _jobDispatcher.Setup(x => x.RunOnceJobCompleted).Returns(completedTask); + + //Act + var command = new CommandSettings(hc, new string[] { "run" }); + Task runnerTask = runner.ExecuteCommand(command); + + //Assert + //wait for the runner to exit with right return code + await Task.WhenAny(runnerTask, Task.Delay(30000)); + + Assert.True(runnerTask.IsCompleted, $"{nameof(runner.ExecuteCommand)} timed out."); + Assert.True(!runnerTask.IsFaulted, runnerTask.Exception?.ToString()); + if (runnerTask.IsCompleted) + { + Assert.Equal(Constants.Runner.ReturnCode.Success, await runnerTask); + } + + _jobDispatcher.Verify(x => x.Run(It.IsAny(), true), Times.Once()); + _messageListener.Verify(x => x.GetNextMessageAsync(It.IsAny()), Times.AtLeastOnce()); + _messageListener.Verify(x => x.CreateSessionAsync(It.IsAny()), Times.Once()); + _messageListener.Verify(x => x.DeleteSessionAsync(), Times.Once()); + _messageListener.Verify(x => x.DeleteMessageAsync(It.IsAny()), Times.Once()); + _credentialManager.Verify(x => x.LoadCredentials(true), Times.Once()); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Runner")] + public async Task TestRunnerJobRequestMessageFromRunService_AuthMigrationFallback() + { + using (var hc = new TestHostContext(this)) + { + //Arrange + var runner = new Runner.Listener.Runner(); + hc.SetSingleton(_configurationManager.Object); + hc.SetSingleton(_jobNotification.Object); + hc.SetSingleton(_messageListener.Object); + hc.SetSingleton(_promptManager.Object); + hc.SetSingleton(_runnerServer.Object); + hc.SetSingleton(_configStore.Object); + hc.SetSingleton(_updater.Object); + hc.SetSingleton(_credentialManager.Object); + hc.EnqueueInstance(_acquireJobThrottler.Object); + hc.EnqueueInstance(_jobDispatcher.Object); + hc.EnqueueInstance(_runServer.Object); + hc.EnqueueInstance(_runServer.Object); + + runner.Initialize(hc); + var settings = new RunnerSettings + { + PoolId = 43242, + AgentId = 5678, + Ephemeral = true, + ServerUrl = "https://github.com", + }; + + var message1 = new TaskAgentMessage() + { + Body = JsonUtility.ToString(new RunnerJobRequestRef() { BillingOwnerId = "github", RunnerRequestId = "999", RunServiceUrl = "https://run-service.com" }), + MessageId = 4234, + MessageType = JobRequestMessageTypes.RunnerJobRequest + }; + + var messages = new Queue(); + messages.Enqueue(message1); + messages.Enqueue(message1); + _updater.Setup(x => x.SelfUpdate(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(true)); + _configurationManager.Setup(x => x.LoadSettings()) + .Returns(settings); + _configurationManager.Setup(x => x.IsConfigured()) + .Returns(true); + _messageListener.Setup(x => x.CreateSessionAsync(It.IsAny())) + .Returns(Task.FromResult(CreateSessionResult.Success)); + _messageListener.Setup(x => x.GetNextMessageAsync(It.IsAny())) + .Returns(async (CancellationToken token) => + { + if (2 == messages.Count) + { + hc.EnableAuthMigration("L0Test"); + } + + if (0 == messages.Count) + { + await Task.Delay(2000, token); + } + + return messages.Dequeue(); + }); + _messageListener.Setup(x => x.DeleteSessionAsync()) + .Returns(Task.CompletedTask); + _messageListener.Setup(x => x.DeleteMessageAsync(It.IsAny())) + .Returns(Task.CompletedTask); + _jobNotification.Setup(x => x.StartClient(It.IsAny())) + .Callback(() => + { + + }); + + var throwError = true; + _runServer.Setup(x => x.GetJobMessageAsync("999", "github", It.IsAny())) + .Returns(() => + { + if (throwError) + { + Assert.True(hc.AllowAuthMigration); + throwError = false; + throw new NotSupportedException("some error"); + } + + return Task.FromResult(CreateJobRequestMessage("test")); + }); + + _credentialManager.Setup(x => x.LoadCredentials(true)).Returns(new VssCredentials()); + + _configStore.Setup(x => x.IsServiceConfigured()).Returns(false); + + var completedTask = new TaskCompletionSource(); + completedTask.SetResult(true); + _jobDispatcher.Setup(x => x.RunOnceJobCompleted).Returns(completedTask); + + //Act + var command = new CommandSettings(hc, new string[] { "run" }); + Task runnerTask = runner.ExecuteCommand(command); + + //Assert + //wait for the runner to exit with right return code + await Task.WhenAny(runnerTask, Task.Delay(30000)); + + Assert.True(runnerTask.IsCompleted, $"{nameof(runner.ExecuteCommand)} timed out."); + Assert.True(!runnerTask.IsFaulted, runnerTask.Exception?.ToString()); + if (runnerTask.IsCompleted) + { + Assert.Equal(Constants.Runner.ReturnCode.Success, await runnerTask); + } + + _jobDispatcher.Verify(x => x.Run(It.IsAny(), true), Times.Once()); + _messageListener.Verify(x => x.CreateSessionAsync(It.IsAny()), Times.Once()); + _messageListener.Verify(x => x.GetNextMessageAsync(It.IsAny()), Times.AtLeast(2)); + _messageListener.Verify(x => x.DeleteMessageAsync(It.IsAny()), Times.AtLeast(2)); + _messageListener.Verify(x => x.DeleteSessionAsync(), Times.Once()); + _credentialManager.Verify(x => x.LoadCredentials(true), Times.Exactly(2)); + + Assert.False(hc.AllowAuthMigration); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Runner")] + public async Task TestRunnerEnableAuthMigrationByDefault() + { + using (var hc = new TestHostContext(this)) + { + //Arrange + var runner = new Runner.Listener.Runner(); + hc.SetSingleton(_configurationManager.Object); + hc.SetSingleton(_jobNotification.Object); + hc.SetSingleton(_messageListener.Object); + hc.SetSingleton(_promptManager.Object); + hc.SetSingleton(_configStore.Object); + hc.SetSingleton(_credentialManager.Object); + hc.SetSingleton(_runnerServer.Object); + hc.EnqueueInstance(_acquireJobThrottler.Object); + + runner.Initialize(hc); + var settings = new RunnerSettings + { + PoolId = 43242, + AgentId = 5678, + Ephemeral = true, + ServerUrl = "https://github.com", + }; + + var message1 = new TaskAgentMessage() + { + Body = JsonUtility.ToString(new RunnerJobRequestRef() { BillingOwnerId = "github", RunnerRequestId = "999", RunServiceUrl = "https://run-service.com" }), + MessageId = 4234, + MessageType = JobRequestMessageTypes.RunnerJobRequest + }; + + var messages = new Queue(); + messages.Enqueue(message1); + messages.Enqueue(message1); + _updater.Setup(x => x.SelfUpdate(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(true)); + _configurationManager.Setup(x => x.LoadSettings()) + .Returns(settings); + _configurationManager.Setup(x => x.IsConfigured()) + .Returns(true); + _messageListener.Setup(x => x.CreateSessionAsync(It.IsAny())) + .Returns(Task.FromResult(CreateSessionResult.Failure)); + _jobNotification.Setup(x => x.StartClient(It.IsAny())) + .Callback(() => + { + + }); + + var throwError = true; + _runServer.Setup(x => x.GetJobMessageAsync("999", "github", It.IsAny())) + .Returns(() => + { + if (throwError) + { + Assert.True(hc.AllowAuthMigration); + throwError = false; + throw new NotSupportedException("some error"); + } + + return Task.FromResult(CreateJobRequestMessage("test")); + }); + + _credentialManager.Setup(x => x.LoadCredentials(true)).Returns(new VssCredentials()); + + _configStore.Setup(x => x.IsServiceConfigured()).Returns(false); + + var credData = new CredentialData() + { + Scheme = Constants.Configuration.OAuth, + }; + credData.Data["ClientId"] = "testClientId"; + credData.Data["AuthUrl"] = "https://github.com"; + credData.Data["EnableAuthMigrationByDefault"] = "true"; + _configStore.Setup(x => x.GetCredentials()).Returns(credData); + + Assert.False(hc.AllowAuthMigration); + + //Act + var command = new CommandSettings(hc, new string[] { "run" }); + var returnCode = await runner.ExecuteCommand(command); + + //Assert + Assert.Equal(Constants.Runner.ReturnCode.TerminatedError, returnCode); + + _messageListener.Verify(x => x.CreateSessionAsync(It.IsAny()), Times.Once()); + + Assert.True(hc.AllowAuthMigration); + } + } } } diff --git a/src/Test/L0/TestHostContext.cs b/src/Test/L0/TestHostContext.cs index 2818215e3ff..c1cf692204f 100644 --- a/src/Test/L0/TestHostContext.cs +++ b/src/Test/L0/TestHostContext.cs @@ -1,16 +1,15 @@ -using GitHub.Runner.Common.Util; -using System; +using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Net.Http.Headers; +using System.Reflection; using System.Runtime.CompilerServices; +using System.Runtime.Loader; using System.Threading; using System.Threading.Tasks; -using System.Runtime.Loader; -using System.Reflection; -using System.Collections.Generic; using GitHub.DistributedTask.Logging; -using System.Net.Http.Headers; using GitHub.Runner.Sdk; namespace GitHub.Runner.Common.Tests @@ -31,6 +30,7 @@ public sealed class TestHostContext : IHostContext, IDisposable private StartupType _startupType; public event EventHandler Unloading; public event EventHandler Delaying; + public event EventHandler AuthMigrationChanged; public CancellationToken RunnerShutdownToken => _runnerShutdownTokenSource.Token; public ShutdownReason RunnerShutdownReason { get; private set; } public ISecretMasker SecretMasker => _secretMasker; @@ -92,6 +92,8 @@ public StartupType StartupType public RunnerWebProxy WebProxy => new(); + public bool AllowAuthMigration { get; set; } + public async Task Delay(TimeSpan delay, CancellationToken token) { // Event callback @@ -101,8 +103,8 @@ public async Task Delay(TimeSpan delay, CancellationToken token) handler(this, new DelayEventArgs(delay, token)); } - // Delay zero - await Task.Delay(TimeSpan.Zero); + // Delay 10ms + await Task.Delay(TimeSpan.FromMilliseconds(10)); } public T CreateService() where T : class, IRunnerService @@ -387,6 +389,18 @@ public void LoadDefaultUserAgents() { return; } + + public void EnableAuthMigration(string trace) + { + AllowAuthMigration = true; + AuthMigrationChanged?.Invoke(this, new AuthMigrationEventArgs(trace)); + } + + public void DeferAuthMigration(TimeSpan deferred, string trace) + { + AllowAuthMigration = false; + AuthMigrationChanged?.Invoke(this, new AuthMigrationEventArgs(trace)); + } } public class DelayEventArgs : EventArgs diff --git a/src/Test/L0/Worker/ExecutionContextL0.cs b/src/Test/L0/Worker/ExecutionContextL0.cs index 7357212d172..2f28f797fb5 100644 --- a/src/Test/L0/Worker/ExecutionContextL0.cs +++ b/src/Test/L0/Worker/ExecutionContextL0.cs @@ -1168,6 +1168,77 @@ public void ActionVariables_SecretsPrecedenceForDebugUsingVars() } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void InitializeJob_HydratesJobContextWithCheckRunId() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange: Create a job request message and make sure the feature flag is enabled + var variables = new Dictionary() + { + [Constants.Runner.Features.AddCheckRunIdToJobContext] = new VariableValue("true"), + }; + var jobRequest = new Pipelines.AgentJobRequestMessage(new TaskOrchestrationPlanReference(), new TimelineReference(), Guid.NewGuid(), "some job name", "some job name", null, null, null, variables, new List(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List(), null, null, null, null, null); + var pagingLogger = new Moq.Mock(); + var jobServerQueue = new Moq.Mock(); + hc.EnqueueInstance(pagingLogger.Object); + hc.SetSingleton(jobServerQueue.Object); + var ec = new Runner.Worker.ExecutionContext(); + ec.Initialize(hc); + + // Arrange: Add check_run_id to the job context + var jobContext = new Pipelines.ContextData.DictionaryContextData(); + jobContext["check_run_id"] = new NumberContextData(123456); + jobRequest.ContextData["job"] = jobContext; + jobRequest.ContextData["github"] = new Pipelines.ContextData.DictionaryContextData(); + + // Act + ec.InitializeJob(jobRequest, CancellationToken.None); + + // Assert + Assert.NotNull(ec.JobContext); + Assert.Equal(123456, ec.JobContext.CheckRunId); + } + } + + // TODO: this test can be deleted when `AddCheckRunIdToJobContext` is fully rolled out + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void InitializeJob_HydratesJobContextWithCheckRunId_FeatureFlagDisabled() + { + using (TestHostContext hc = CreateTestContext()) + { + // Arrange: Create a job request message and make sure the feature flag is disabled + var variables = new Dictionary() + { + [Constants.Runner.Features.AddCheckRunIdToJobContext] = new VariableValue("false"), + }; + var jobRequest = new Pipelines.AgentJobRequestMessage(new TaskOrchestrationPlanReference(), new TimelineReference(), Guid.NewGuid(), "some job name", "some job name", null, null, null, variables, new List(), new Pipelines.JobResources(), new Pipelines.ContextData.DictionaryContextData(), new Pipelines.WorkspaceOptions(), new List(), null, null, null, null, null); + var pagingLogger = new Moq.Mock(); + var jobServerQueue = new Moq.Mock(); + hc.EnqueueInstance(pagingLogger.Object); + hc.SetSingleton(jobServerQueue.Object); + var ec = new Runner.Worker.ExecutionContext(); + ec.Initialize(hc); + + // Arrange: Add check_run_id to the job context + var jobContext = new Pipelines.ContextData.DictionaryContextData(); + jobContext["check_run_id"] = new NumberContextData(123456); + jobRequest.ContextData["job"] = jobContext; + jobRequest.ContextData["github"] = new Pipelines.ContextData.DictionaryContextData(); + + // Act + ec.InitializeJob(jobRequest, CancellationToken.None); + + // Assert + Assert.NotNull(ec.JobContext); + Assert.Null(ec.JobContext.CheckRunId); // with the feature flag disabled we should not have added a CheckRunId to the JobContext + } + } + private bool ExpressionValuesAssertEqual(DictionaryContextData expect, DictionaryContextData actual) { foreach (var key in expect.Keys.ToList()) diff --git a/src/Test/L0/Worker/IssueMatcherL0.cs b/src/Test/L0/Worker/IssueMatcherL0.cs index 777772a8484..177dd6de20c 100644 --- a/src/Test/L0/Worker/IssueMatcherL0.cs +++ b/src/Test/L0/Worker/IssueMatcherL0.cs @@ -896,5 +896,173 @@ public void Matcher_SinglePattern_ExtractsProperties() Assert.Equal("not-working", match.Message); Assert.Equal("my-project.proj", match.FromPath); } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Matcher_SinglePattern_DefaultFromPath() + { + var config = JsonUtility.FromString(@" +{ + ""problemMatcher"": [ + { + ""owner"": ""myMatcher"", + ""fromPath"": ""subdir/default-project.csproj"", + ""pattern"": [ + { + ""regexp"": ""^file:(.+) line:(.+) column:(.+) severity:(.+) code:(.+) message:(.+)$"", + ""file"": 1, + ""line"": 2, + ""column"": 3, + ""severity"": 4, + ""code"": 5, + ""message"": 6 + } + ] + } + ] +} +"); + config.Validate(); + var matcher = new IssueMatcher(config.Matchers[0], TimeSpan.FromSeconds(1)); + + var match = matcher.Match("file:my-file.cs line:123 column:45 severity:real-bad code:uh-oh message:not-working"); + Assert.Equal("my-file.cs", match.File); + Assert.Equal("123", match.Line); + Assert.Equal("45", match.Column); + Assert.Equal("real-bad", match.Severity); + Assert.Equal("uh-oh", match.Code); + Assert.Equal("not-working", match.Message); + Assert.Equal("subdir/default-project.csproj", match.FromPath); + + // Test that a pattern-specific fromPath overrides the default + config = JsonUtility.FromString(@" +{ + ""problemMatcher"": [ + { + ""owner"": ""myMatcher"", + ""fromPath"": ""subdir/default-project.csproj"", + ""pattern"": [ + { + ""regexp"": ""^file:(.+) line:(.+) column:(.+) severity:(.+) code:(.+) message:(.+) fromPath:(.+)$"", + ""file"": 1, + ""line"": 2, + ""column"": 3, + ""severity"": 4, + ""code"": 5, + ""message"": 6, + ""fromPath"": 7 + } + ] + } + ] +} +"); + config.Validate(); + matcher = new IssueMatcher(config.Matchers[0], TimeSpan.FromSeconds(1)); + + match = matcher.Match("file:my-file.cs line:123 column:45 severity:real-bad code:uh-oh message:not-working fromPath:my-project.proj"); + Assert.Equal("my-file.cs", match.File); + Assert.Equal("123", match.Line); + Assert.Equal("45", match.Column); + Assert.Equal("real-bad", match.Severity); + Assert.Equal("uh-oh", match.Code); + Assert.Equal("not-working", match.Message); + Assert.Equal("my-project.proj", match.FromPath); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Matcher_MultiplePatterns_DefaultFromPath() + { + var config = JsonUtility.FromString(@" +{ + ""problemMatcher"": [ + { + ""owner"": ""myMatcher"", + ""fromPath"": ""subdir/default-project.csproj"", + ""pattern"": [ + { + ""regexp"": ""^file:(.+)$"", + ""file"": 1, + }, + { + ""regexp"": ""^severity:(.+)$"", + ""severity"": 1 + }, + { + ""regexp"": ""^line:(.+) column:(.+) code:(.+) message:(.+)$"", + ""line"": 1, + ""column"": 2, + ""code"": 3, + ""message"": 4 + } + ] + } + ] +} +"); + config.Validate(); + var matcher = new IssueMatcher(config.Matchers[0], TimeSpan.FromSeconds(1)); + + var match = matcher.Match("file:my-file.cs"); + Assert.Null(match); + match = matcher.Match("severity:real-bad"); + Assert.Null(match); + match = matcher.Match("line:123 column:45 code:uh-oh message:not-working"); + Assert.Equal("my-file.cs", match.File); + Assert.Equal("123", match.Line); + Assert.Equal("45", match.Column); + Assert.Equal("real-bad", match.Severity); + Assert.Equal("uh-oh", match.Code); + Assert.Equal("not-working", match.Message); + Assert.Equal("subdir/default-project.csproj", match.FromPath); + + // Test that pattern-specific fromPath overrides the default + config = JsonUtility.FromString(@" +{ + ""problemMatcher"": [ + { + ""owner"": ""myMatcher"", + ""fromPath"": ""subdir/default-project.csproj"", + ""pattern"": [ + { + ""regexp"": ""^file:(.+) fromPath:(.+)$"", + ""file"": 1, + ""fromPath"": 2 + }, + { + ""regexp"": ""^severity:(.+)$"", + ""severity"": 1 + }, + { + ""regexp"": ""^line:(.+) column:(.+) code:(.+) message:(.+)$"", + ""line"": 1, + ""column"": 2, + ""code"": 3, + ""message"": 4 + } + ] + } + ] +} +"); + config.Validate(); + matcher = new IssueMatcher(config.Matchers[0], TimeSpan.FromSeconds(1)); + + match = matcher.Match("file:my-file.cs fromPath:my-project.proj"); + Assert.Null(match); + match = matcher.Match("severity:real-bad"); + Assert.Null(match); + match = matcher.Match("line:123 column:45 code:uh-oh message:not-working"); + Assert.Equal("my-file.cs", match.File); + Assert.Equal("123", match.Line); + Assert.Equal("45", match.Column); + Assert.Equal("real-bad", match.Severity); + Assert.Equal("uh-oh", match.Code); + Assert.Equal("not-working", match.Message); + Assert.Equal("my-project.proj", match.FromPath); + } } } diff --git a/src/Test/L0/Worker/JobContextL0.cs b/src/Test/L0/Worker/JobContextL0.cs new file mode 100644 index 00000000000..87e33437914 --- /dev/null +++ b/src/Test/L0/Worker/JobContextL0.cs @@ -0,0 +1,38 @@ +using System; +using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.Runner.Worker; +using Xunit; + +namespace GitHub.Runner.Common.Tests.Worker +{ + public class JobContextL0 + { + [Fact] + public void CheckRunId_SetAndGet_WorksCorrectly() + { + var ctx = new JobContext(); + ctx.CheckRunId = 12345; + Assert.Equal(12345, ctx.CheckRunId); + Assert.True(ctx.TryGetValue("check_run_id", out var value)); + Assert.IsType(value); + Assert.Equal(12345, ((NumberContextData)value).Value); + } + + [Fact] + public void CheckRunId_NotSet_ReturnsNull() + { + var ctx = new JobContext(); + Assert.Null(ctx.CheckRunId); + Assert.False(ctx.TryGetValue("check_run_id", out var value)); + } + + [Fact] + public void CheckRunId_SetNull_RemovesKey() + { + var ctx = new JobContext(); + ctx.CheckRunId = 12345; + ctx.CheckRunId = null; + Assert.Null(ctx.CheckRunId); + } + } +} diff --git a/src/Test/L0/Worker/OutputManagerL0.cs b/src/Test/L0/Worker/OutputManagerL0.cs index 9d7f5d3f2eb..7005547b56e 100644 --- a/src/Test/L0/Worker/OutputManagerL0.cs +++ b/src/Test/L0/Worker/OutputManagerL0.cs @@ -937,6 +937,62 @@ public async void MatcherFromPath() } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async void MatcherDefaultFromPath() + { + var matchers = new IssueMatchersConfig + { + Matchers = + { + new IssueMatcherConfig + { + Owner = "my-matcher-1", + FromPath = "workflow-repo/some-project/some-project.proj", + Patterns = new[] + { + new IssuePatternConfig + { + Pattern = @"(.+): (.+)", + File = 1, + Message = 2, + }, + }, + }, + }, + }; + using (var hostContext = Setup(matchers: matchers)) + using (_outputManager) + { + // Setup github.workspace, github.repository + var workDirectory = hostContext.GetDirectory(WellKnownDirectory.Work); + ArgUtil.NotNullOrEmpty(workDirectory, nameof(workDirectory)); + Directory.CreateDirectory(workDirectory); + var workspaceDirectory = Path.Combine(workDirectory, "workspace"); + Directory.CreateDirectory(workspaceDirectory); + _executionContext.Setup(x => x.GetGitHubContext("workspace")).Returns(workspaceDirectory); + _executionContext.Setup(x => x.GetGitHubContext("repository")).Returns("my-org/workflow-repo"); + + // Setup a git repository + var repositoryPath = Path.Combine(workspaceDirectory, "workflow-repo"); + await CreateRepository(hostContext, repositoryPath, "https://github.com/my-org/workflow-repo"); + + // Create a test file + var filePath = Path.Combine(repositoryPath, "some-project", "some-directory", "some-file.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(filePath)); + File.WriteAllText(filePath, ""); + + // Process + Process("some-directory/some-file.txt: some error"); + Assert.Equal(1, _issues.Count); + Assert.Equal("some error", _issues[0].Item1.Message); + Assert.Equal("some-project/some-directory/some-file.txt", _issues[0].Item1.Data["file"]); + Assert.Equal(0, _commands.Count); + Assert.Equal(0, _messages.Count); + } + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")] diff --git a/src/Test/Test.csproj b/src/Test/Test.csproj index 105400524ff..3d604432087 100644 --- a/src/Test/Test.csproj +++ b/src/Test/Test.csproj @@ -15,9 +15,9 @@ - + - + diff --git a/src/dev.sh b/src/dev.sh index 34b428f034c..2a008cbd128 100755 --- a/src/dev.sh +++ b/src/dev.sh @@ -17,7 +17,7 @@ LAYOUT_DIR="$SCRIPT_DIR/../_layout" DOWNLOAD_DIR="$SCRIPT_DIR/../_downloads/netcore2x" PACKAGE_DIR="$SCRIPT_DIR/../_package" DOTNETSDK_ROOT="$SCRIPT_DIR/../_dotnetsdk" -DOTNETSDK_VERSION="8.0.407" +DOTNETSDK_VERSION="8.0.408" DOTNETSDK_INSTALLDIR="$DOTNETSDK_ROOT/$DOTNETSDK_VERSION" RUNNER_VERSION=$(cat runnerversion) diff --git a/src/global.json b/src/global.json index 6537190e060..fc88f757ace 100644 --- a/src/global.json +++ b/src/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "8.0.407" + "version": "8.0.408" } } diff --git a/src/runnerversion b/src/runnerversion index 4981c204f93..c0fbc63596d 100644 --- a/src/runnerversion +++ b/src/runnerversion @@ -1 +1 @@ -2.323.0 +2.324.0